From 64862dd49a38fb3822b6a33590c173f1375dc255 Mon Sep 17 00:00:00 2001 From: Magzhan Zhumabayev Date: Mon, 11 May 2026 16:57:30 +0500 Subject: [PATCH] Add sales contract repair routes --- services/sales_service/app.py | 255 +++++++++++++++++++++++++++- tests/test_sales_contract_routes.py | 30 ++++ 2 files changed, 283 insertions(+), 2 deletions(-) create mode 100644 tests/test_sales_contract_routes.py diff --git a/services/sales_service/app.py b/services/sales_service/app.py index a84f4df..09ef3e7 100644 --- a/services/sales_service/app.py +++ b/services/sales_service/app.py @@ -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, diff --git a/tests/test_sales_contract_routes.py b/tests/test_sales_contract_routes.py new file mode 100644 index 0000000..81dbf3e --- /dev/null +++ b/tests/test_sales_contract_routes.py @@ -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))