Add sales contract repair routes
This commit is contained in:
@@ -1606,6 +1606,33 @@ def _apply_stage(
|
||||
)
|
||||
|
||||
|
||||
def _apply_stage_if_needed(
|
||||
session,
|
||||
deal: SalesDealRow,
|
||||
stage_code: str,
|
||||
*,
|
||||
actor_type: str,
|
||||
actor_id: str | None,
|
||||
reason: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
_, current_stage = _deal_pipeline_stage(session, deal)
|
||||
if current_stage is not None and current_stage.code == stage_code:
|
||||
deal.updated_at = utc_now_iso()
|
||||
return
|
||||
_apply_stage(
|
||||
session,
|
||||
deal,
|
||||
stage_code,
|
||||
actor_type=actor_type,
|
||||
actor_id=actor_id,
|
||||
reason=reason,
|
||||
metadata=metadata,
|
||||
force=force,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_stage_change_target_code(session, deal: SalesDealRow, payload: SalesDealStageChangeIn) -> str:
|
||||
target_stage_code = str(payload.target_stage_code or payload.stage_code or "").strip() or None
|
||||
target_stage_id = str(payload.target_stage_id or payload.stage_id or "").strip() or None
|
||||
@@ -5324,6 +5351,31 @@ def list_calls(
|
||||
session.close()
|
||||
|
||||
|
||||
@app.get("/api/v1/calls/{call_id}/transcript", response_model=SalesTranscriptOut)
|
||||
def get_call_transcript(
|
||||
call_id: str,
|
||||
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
|
||||
) -> SalesTranscriptOut:
|
||||
session = get_session()
|
||||
try:
|
||||
tenant_id = _tenant_id(actor)
|
||||
call = session.execute(
|
||||
select(SalesCallRow).where(SalesCallRow.call_id == call_id, SalesCallRow.tenant_id == tenant_id)
|
||||
).scalar_one_or_none()
|
||||
if call is None:
|
||||
raise HTTPException(status_code=404, detail="Call not found")
|
||||
row = session.execute(
|
||||
select(SalesTranscriptRow)
|
||||
.where(SalesTranscriptRow.call_id == call.call_id, SalesTranscriptRow.tenant_id == tenant_id)
|
||||
.order_by(SalesTranscriptRow.updated_at.desc(), SalesTranscriptRow.id.desc())
|
||||
).scalars().first()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Transcript not found")
|
||||
return _transcript_to_out(row)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@app.post("/api/v1/calls/{call_id}/transcript", response_model=SalesTranscriptOut)
|
||||
def attach_transcript(
|
||||
call_id: str,
|
||||
@@ -5421,6 +5473,25 @@ def create_offer(
|
||||
session.close()
|
||||
|
||||
|
||||
@app.get("/api/v1/deals/{deal_id}/offers", response_model=list[SalesOfferOut])
|
||||
def list_offers(
|
||||
deal_id: str,
|
||||
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
|
||||
) -> list[SalesOfferOut]:
|
||||
session = get_session()
|
||||
try:
|
||||
tenant_id = _tenant_id(actor)
|
||||
_get_deal(session, deal_id, tenant_id)
|
||||
rows = session.execute(
|
||||
select(SalesOfferRow)
|
||||
.where(SalesOfferRow.deal_id == deal_id, SalesOfferRow.tenant_id == tenant_id)
|
||||
.order_by(SalesOfferRow.updated_at.desc(), SalesOfferRow.id.desc())
|
||||
).scalars().all()
|
||||
return [_offer_to_out(row) for row in rows]
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@app.get("/api/v1/offers/{offer_id}", response_model=SalesOfferOut)
|
||||
def get_offer(
|
||||
offer_id: str,
|
||||
@@ -5552,7 +5623,28 @@ def reject_offer(
|
||||
session.close()
|
||||
|
||||
|
||||
@app.get("/api/v1/deals/{deal_id}/conditions", response_model=SalesConditionOut | None)
|
||||
def get_deal_conditions(
|
||||
deal_id: str,
|
||||
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
|
||||
) -> SalesConditionOut | None:
|
||||
session = get_session()
|
||||
try:
|
||||
tenant_id = _tenant_id(actor)
|
||||
_get_deal(session, deal_id, tenant_id)
|
||||
row = session.execute(
|
||||
select(SalesConditionRow)
|
||||
.where(SalesConditionRow.deal_id == deal_id, SalesConditionRow.tenant_id == tenant_id)
|
||||
.order_by(SalesConditionRow.updated_at.desc(), SalesConditionRow.id.desc())
|
||||
).scalars().first()
|
||||
return _condition_to_out(row) if row else None
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@app.post("/api/v1/deals/{deal_id}/conditions", response_model=SalesConditionOut)
|
||||
@app.patch("/api/v1/deals/{deal_id}/conditions", response_model=SalesConditionOut)
|
||||
@app.put("/api/v1/deals/{deal_id}/conditions", response_model=SalesConditionOut)
|
||||
def upsert_conditions(
|
||||
deal_id: str,
|
||||
payload: SalesConditionUpsertIn,
|
||||
@@ -5605,7 +5697,51 @@ def upsert_conditions(
|
||||
row.updated_at = now
|
||||
if payload.agreed_price is not None:
|
||||
deal.final_amount = payload.agreed_price
|
||||
_apply_stage(session, deal, "conditions_negotiation", actor_type="human", actor_id=actor["user"], reason="deal.conditions_confirmed")
|
||||
_apply_stage_if_needed(session, deal, "conditions_negotiation", actor_type="human", actor_id=actor["user"], reason="deal.conditions_confirmed")
|
||||
_publish_sales_event(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
event_type=sales_event_types.DEAL_CONDITIONS_CONFIRMED,
|
||||
aggregate_type="deal",
|
||||
aggregate_id=deal.deal_id,
|
||||
actor_type="human",
|
||||
actor_id=actor["user"],
|
||||
payload={
|
||||
"deal_id": deal.deal_id,
|
||||
"condition_id": row.condition_id,
|
||||
"agreed_price": row.agreed_price,
|
||||
"currency": row.currency,
|
||||
"payment_terms": row.payment_terms,
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
return _condition_to_out(row)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@app.post("/api/v1/deals/{deal_id}/conditions/confirm", response_model=SalesConditionOut)
|
||||
def confirm_deal_conditions(
|
||||
deal_id: str,
|
||||
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
|
||||
) -> SalesConditionOut:
|
||||
session = get_session()
|
||||
try:
|
||||
tenant_id = _tenant_id(actor)
|
||||
deal = _get_deal(session, deal_id, tenant_id)
|
||||
row = session.execute(
|
||||
select(SalesConditionRow)
|
||||
.where(SalesConditionRow.deal_id == deal.deal_id, SalesConditionRow.tenant_id == tenant_id)
|
||||
.order_by(SalesConditionRow.updated_at.desc(), SalesConditionRow.id.desc())
|
||||
).scalars().first()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Conditions not found")
|
||||
now = utc_now_iso()
|
||||
row.confirmed_at = row.confirmed_at or now
|
||||
row.updated_at = now
|
||||
if row.agreed_price is not None:
|
||||
deal.final_amount = row.agreed_price
|
||||
_apply_stage_if_needed(session, deal, "conditions_negotiation", actor_type="human", actor_id=actor["user"], reason="deal.conditions_confirmed")
|
||||
_publish_sales_event(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
@@ -5692,7 +5828,7 @@ def patch_counterparty(
|
||||
deal=deal,
|
||||
counterparty=row,
|
||||
)
|
||||
_apply_stage(
|
||||
_apply_stage_if_needed(
|
||||
session,
|
||||
deal,
|
||||
"counterparty_data_received" if status == "completed" else "counterparty_data_requested",
|
||||
@@ -5741,6 +5877,25 @@ def get_counterparty(
|
||||
session.close()
|
||||
|
||||
|
||||
@app.get("/api/v1/deals/{deal_id}/documents", response_model=list[SalesDocumentOut])
|
||||
def list_documents(
|
||||
deal_id: str,
|
||||
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
|
||||
) -> list[SalesDocumentOut]:
|
||||
session = get_session()
|
||||
try:
|
||||
tenant_id = _tenant_id(actor)
|
||||
_get_deal(session, deal_id, tenant_id)
|
||||
rows = session.execute(
|
||||
select(SalesDocumentRow)
|
||||
.where(SalesDocumentRow.deal_id == deal_id, SalesDocumentRow.tenant_id == tenant_id)
|
||||
.order_by(SalesDocumentRow.updated_at.desc(), SalesDocumentRow.id.desc())
|
||||
).scalars().all()
|
||||
return [_document_to_out(row) for row in rows]
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@app.post("/api/v1/deals/{deal_id}/documents", response_model=SalesDocumentOut)
|
||||
def create_document(
|
||||
deal_id: str,
|
||||
@@ -5938,6 +6093,25 @@ def sign_document(
|
||||
session.close()
|
||||
|
||||
|
||||
@app.get("/api/v1/deals/{deal_id}/invoices", response_model=list[SalesInvoiceOut])
|
||||
def list_invoices(
|
||||
deal_id: str,
|
||||
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
|
||||
) -> list[SalesInvoiceOut]:
|
||||
session = get_session()
|
||||
try:
|
||||
tenant_id = _tenant_id(actor)
|
||||
_get_deal(session, deal_id, tenant_id)
|
||||
rows = session.execute(
|
||||
select(SalesInvoiceRow)
|
||||
.where(SalesInvoiceRow.deal_id == deal_id, SalesInvoiceRow.tenant_id == tenant_id)
|
||||
.order_by(SalesInvoiceRow.updated_at.desc(), SalesInvoiceRow.id.desc())
|
||||
).scalars().all()
|
||||
return [_invoice_to_out(row) for row in rows]
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@app.post("/api/v1/deals/{deal_id}/invoices", response_model=SalesInvoiceOut)
|
||||
def create_invoice(
|
||||
deal_id: str,
|
||||
@@ -6300,6 +6474,83 @@ async def payment_webhook(
|
||||
session.close()
|
||||
|
||||
|
||||
@app.get("/api/v1/deals/{deal_id}/automation-tasks", response_model=list[SalesAutomationTaskOut])
|
||||
def list_automation_tasks(
|
||||
deal_id: str,
|
||||
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)),
|
||||
) -> list[SalesAutomationTaskOut]:
|
||||
session = get_session()
|
||||
try:
|
||||
tenant_id = _tenant_id(actor)
|
||||
_get_deal(session, deal_id, tenant_id)
|
||||
rows = session.execute(
|
||||
select(SalesAutomationTaskRow)
|
||||
.where(SalesAutomationTaskRow.deal_id == deal_id, SalesAutomationTaskRow.tenant_id == tenant_id)
|
||||
.order_by(SalesAutomationTaskRow.run_at.desc(), SalesAutomationTaskRow.id.desc())
|
||||
).scalars().all()
|
||||
return [_task_to_out(row) for row in rows]
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@app.post("/api/v1/automation-tasks/{task_id}/cancel", response_model=SalesAutomationTaskOut)
|
||||
def cancel_automation_task(
|
||||
task_id: str,
|
||||
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
|
||||
) -> SalesAutomationTaskOut:
|
||||
session = get_session()
|
||||
try:
|
||||
tenant_id = _tenant_id(actor)
|
||||
row = session.execute(
|
||||
select(SalesAutomationTaskRow).where(SalesAutomationTaskRow.task_id == task_id, SalesAutomationTaskRow.tenant_id == tenant_id)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Automation task not found")
|
||||
if row.status != "pending":
|
||||
raise HTTPException(status_code=400, detail={"code": "automation_task_not_pending", "message": "Only pending automation tasks can be canceled"})
|
||||
payload = _json_dict(row.payload_json)
|
||||
payload["canceled_by"] = actor["user"]
|
||||
payload["canceled_reason"] = "manual"
|
||||
row.payload_json = json.dumps(payload, ensure_ascii=False)
|
||||
row.status = "canceled"
|
||||
row.locked_at = None
|
||||
row.locked_by = None
|
||||
row.updated_at = utc_now_iso()
|
||||
session.commit()
|
||||
return _task_to_out(row)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@app.post("/api/v1/automation-tasks/{task_id}/run-now", response_model=SalesAutomationTaskOut)
|
||||
def run_automation_task_now(
|
||||
task_id: str,
|
||||
actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
|
||||
) -> SalesAutomationTaskOut:
|
||||
session = get_session()
|
||||
try:
|
||||
tenant_id = _tenant_id(actor)
|
||||
row = session.execute(
|
||||
select(SalesAutomationTaskRow).where(SalesAutomationTaskRow.task_id == task_id, SalesAutomationTaskRow.tenant_id == tenant_id)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Automation task not found")
|
||||
if row.status not in {"pending", "failed", "canceled"}:
|
||||
raise HTTPException(status_code=400, detail={"code": "automation_task_not_pending", "message": "Automation task cannot be queued"})
|
||||
row.status = "pending"
|
||||
row.run_at = utc_now_iso()
|
||||
row.locked_at = None
|
||||
row.locked_by = None
|
||||
row.failed_at = None
|
||||
row.completed_at = None
|
||||
row.last_error = None
|
||||
row.updated_at = row.run_at
|
||||
session.commit()
|
||||
return _task_to_out(row)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@app.get("/api/v1/deals/{deal_id}/payments", response_model=list[SalesPaymentOut])
|
||||
def list_payments(
|
||||
deal_id: str,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import services.sales_service.app as sales_module
|
||||
|
||||
|
||||
def _methods(path: str) -> set[str]:
|
||||
result: set[str] = set()
|
||||
for route in sales_module.app.routes:
|
||||
if getattr(route, "path", None) == path:
|
||||
result.update(getattr(route, "methods", set()))
|
||||
return result
|
||||
|
||||
|
||||
def test_sales_frontend_contract_routes_are_registered():
|
||||
expected = {
|
||||
"/api/v1/deals/{deal_id}/change-stage": {"POST"},
|
||||
"/api/v1/deals/{deal_id}/notes/{note_id}": {"PATCH"},
|
||||
"/api/v1/deals/{deal_id}/switch-channel": {"POST"},
|
||||
"/api/v1/communications/{communication_id}/switch-channel": {"POST"},
|
||||
"/api/v1/calls/{call_id}/transcript": {"GET", "POST"},
|
||||
"/api/v1/deals/{deal_id}/offers": {"GET", "POST"},
|
||||
"/api/v1/deals/{deal_id}/documents": {"GET", "POST"},
|
||||
"/api/v1/deals/{deal_id}/invoices": {"GET", "POST"},
|
||||
"/api/v1/deals/{deal_id}/conditions": {"GET", "POST", "PATCH", "PUT"},
|
||||
"/api/v1/deals/{deal_id}/conditions/confirm": {"POST"},
|
||||
"/api/v1/deals/{deal_id}/automation-tasks": {"GET"},
|
||||
"/api/v1/automation-tasks/{task_id}/cancel": {"POST"},
|
||||
"/api/v1/automation-tasks/{task_id}/run-now": {"POST"},
|
||||
}
|
||||
|
||||
for path, methods in expected.items():
|
||||
assert methods.issubset(_methods(path))
|
||||
Reference in New Issue
Block a user