Merge branch 'codex/p0-sales-contract-repair' into 'main'

Add sales contract repair routes

See merge request crm/call-center!3
This commit is contained in:
Administrator
2026-05-11 12:01:27 +00:00
2 changed files with 283 additions and 2 deletions
+253 -2
View File
@@ -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,
+30
View File
@@ -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))