Files
call-center/services/sales_service/deal_state_machine.py
T

649 lines
21 KiB
Python

from __future__ import annotations
from typing import Any
from sqlalchemy import select
from services.sales_service import sales_events as sales_event_types
from services.sales_service.event_publisher import SalesEventPublisher
from services.shared.core import new_id, utc_now_iso
from services.shared.sales_sql_models import (
SalesConditionRow,
SalesCounterpartyRow,
SalesDealRow,
SalesDocumentRow,
SalesInvoiceRow,
SalesOfferRow,
SalesPaymentRow,
SalesPipelineStageRow,
SalesStageHistoryRow,
)
ALLOWED_TRANSITIONS: dict[str, list[str]] = {
"new_qualified_lead": [
"warm_lead",
"hot_lead",
"enrichment_required",
"active_text_communication",
"active_voice_communication",
"lost",
],
"warm_lead": [
"active_text_communication",
"active_voice_communication",
"enrichment_required",
"lost",
],
"hot_lead": [
"active_text_communication",
"active_voice_communication",
"offer_selection",
"lost",
],
"enrichment_required": [
"active_text_communication",
"active_voice_communication",
"need_clarification",
"lost",
],
"active_text_communication": [
"waiting_customer_reply",
"need_clarification",
"need_confirmed",
"active_voice_communication",
"follow_up_scheduled",
"transferred_to_support",
"lost",
],
"active_voice_communication": [
"waiting_customer_reply",
"need_clarification",
"need_confirmed",
"active_text_communication",
"follow_up_scheduled",
"transferred_to_support",
"lost",
],
"waiting_customer_reply": [
"active_text_communication",
"active_voice_communication",
"follow_up_scheduled",
"lost",
],
"need_clarification": [
"active_text_communication",
"active_voice_communication",
"need_confirmed",
"transferred_to_support",
"lost",
],
"need_confirmed": [
"offer_selection",
"offer_preparing",
"transferred_to_support",
"lost",
],
"offer_selection": [
"offer_preparing",
"conditions_negotiation",
"lost",
],
"offer_preparing": [
"offer_sent",
"conditions_negotiation",
"lost",
],
"offer_sent": [
"conditions_negotiation",
"counterparty_data_requested",
"follow_up_scheduled",
"lost",
],
"conditions_negotiation": [
"counterparty_data_requested",
"counterparty_data_received",
"document_preparing",
"invoice_preparing",
"transferred_to_support",
"lost",
],
"counterparty_data_requested": [
"counterparty_data_received",
"follow_up_scheduled",
"lost",
],
"counterparty_data_received": [
"document_preparing",
"invoice_preparing",
"lost",
],
"document_preparing": [
"document_sent",
"lost",
],
"document_sent": [
"document_under_review",
"document_confirmed",
"follow_up_scheduled",
"lost",
],
"document_under_review": [
"document_confirmed",
"transferred_to_support",
"lost",
],
"document_confirmed": [
"invoice_preparing",
"invoice_sent",
"lost",
],
"invoice_preparing": [
"invoice_sent",
"lost",
],
"invoice_sent": [
"payment_expected",
"partially_paid",
"paid",
"payment_overdue",
"lost",
],
"payment_expected": [
"partially_paid",
"paid",
"payment_overdue",
"lost",
],
"partially_paid": [
"paid",
"payment_overdue",
"lost",
],
"payment_overdue": [
"payment_expected",
"partially_paid",
"paid",
"lost",
],
"paid": [
"won",
"transferred_to_execution",
"transferred_to_support",
],
"follow_up_scheduled": [
"active_text_communication",
"active_voice_communication",
"waiting_customer_reply",
"lost",
],
"postponed": [
"follow_up_scheduled",
"active_text_communication",
"active_voice_communication",
"lost",
],
"transferred_to_support": [
"active_text_communication",
"active_voice_communication",
"conditions_negotiation",
"lost",
"won",
],
"won": [],
"lost": [],
"transferred_to_execution": [],
}
LEGACY_STAGE_ALIASES = {
"new": "new_qualified_lead",
"active_text": "active_text_communication",
"invoice": "invoice_sent",
"paid": "paid",
"won": "won",
}
TERMINAL_STAGE_CODES = {"won", "lost", "transferred_to_execution"}
SCENARIOS_REQUIRING_OFFER = {"quotation_based_sale", "booking_based_sale", "subscription_sale"}
class DealStateMachineError(Exception):
def __init__(
self,
*,
status_code: int,
error: str,
message: str,
details: dict[str, Any] | None = None,
) -> None:
super().__init__(message)
self.status_code = status_code
self.error = error
self.message = message
self.details = details or {}
def response(self) -> dict[str, Any]:
return {"error": self.error, "message": self.message, "details": self.details}
def _raise(status_code: int, error: str, message: str, details: dict[str, Any] | None = None) -> None:
raise DealStateMachineError(status_code=status_code, error=error, message=message, details=details)
def _normalize_stage_code(value: str | None) -> str:
code = str(value or "").strip()
return LEGACY_STAGE_ALIASES.get(code, code)
def _get_deal(session, *, tenant_id: str, deal_id: str) -> SalesDealRow:
row = session.execute(
select(SalesDealRow).where(
SalesDealRow.tenant_id == tenant_id,
SalesDealRow.deal_id == deal_id,
)
).scalar_one_or_none()
if row is None:
_raise(404, "deal_not_found", "Deal not found", {"deal_id": deal_id})
return row
def _get_current_stage(session, deal: SalesDealRow) -> SalesPipelineStageRow:
row = session.execute(
select(SalesPipelineStageRow).where(
SalesPipelineStageRow.tenant_id == deal.tenant_id,
SalesPipelineStageRow.stage_id == deal.stage_id,
)
).scalar_one_or_none()
if row is None:
_raise(
404,
"stage_not_found",
"Current deal stage not found",
{"deal_id": deal.deal_id, "stage_id": deal.stage_id},
)
if row.pipeline_id != deal.pipeline_id:
_raise(
400,
"invalid_stage_pipeline",
"Current deal stage does not belong to deal pipeline",
{
"deal_id": deal.deal_id,
"stage_id": row.stage_id,
"stage_pipeline_id": row.pipeline_id,
"deal_pipeline_id": deal.pipeline_id,
},
)
return row
def _get_target_stage(session, deal: SalesDealRow, target_stage_code: str) -> SalesPipelineStageRow:
code = _normalize_stage_code(target_stage_code)
row = session.execute(
select(SalesPipelineStageRow).where(
SalesPipelineStageRow.tenant_id == deal.tenant_id,
SalesPipelineStageRow.pipeline_id == deal.pipeline_id,
SalesPipelineStageRow.code == code,
)
).scalar_one_or_none()
if row is None:
_raise(
404,
"stage_not_found",
"Target stage not found",
{"deal_id": deal.deal_id, "target_stage_code": code},
)
if not row.is_active:
_raise(
400,
"stage_inactive",
"Target stage is inactive",
{"stage_id": row.stage_id, "stage_code": row.code},
)
return row
def _first_offer(session, deal: SalesDealRow, statuses: set[str] | None = None) -> SalesOfferRow | None:
stmt = select(SalesOfferRow).where(
SalesOfferRow.tenant_id == deal.tenant_id,
SalesOfferRow.deal_id == deal.deal_id,
)
if statuses is not None:
stmt = stmt.where(SalesOfferRow.status.in_(statuses))
return session.execute(stmt.order_by(SalesOfferRow.updated_at.desc(), SalesOfferRow.id.desc())).scalars().first()
def _counterparty(session, deal: SalesDealRow) -> SalesCounterpartyRow | None:
return session.execute(
select(SalesCounterpartyRow).where(
SalesCounterpartyRow.tenant_id == deal.tenant_id,
SalesCounterpartyRow.deal_id == deal.deal_id,
)
).scalar_one_or_none()
def _first_document(session, deal: SalesDealRow, statuses: set[str] | None = None) -> SalesDocumentRow | None:
stmt = select(SalesDocumentRow).where(
SalesDocumentRow.tenant_id == deal.tenant_id,
SalesDocumentRow.deal_id == deal.deal_id,
)
if statuses is not None:
stmt = stmt.where(SalesDocumentRow.status.in_(statuses))
return session.execute(stmt.order_by(SalesDocumentRow.updated_at.desc(), SalesDocumentRow.id.desc())).scalars().first()
def _first_invoice(session, deal: SalesDealRow, statuses: set[str] | None = None) -> SalesInvoiceRow | None:
stmt = select(SalesInvoiceRow).where(
SalesInvoiceRow.tenant_id == deal.tenant_id,
SalesInvoiceRow.deal_id == deal.deal_id,
)
if statuses is not None:
stmt = stmt.where(SalesInvoiceRow.status.in_(statuses))
return session.execute(stmt.order_by(SalesInvoiceRow.updated_at.desc(), SalesInvoiceRow.id.desc())).scalars().first()
def _successful_payment(session, deal: SalesDealRow) -> SalesPaymentRow | None:
return session.execute(
select(SalesPaymentRow)
.where(
SalesPaymentRow.tenant_id == deal.tenant_id,
SalesPaymentRow.deal_id == deal.deal_id,
SalesPaymentRow.status == "success",
)
.order_by(SalesPaymentRow.updated_at.desc(), SalesPaymentRow.id.desc())
).scalars().first()
def _confirmed_conditions(session, deal: SalesDealRow) -> SalesConditionRow | None:
return session.execute(
select(SalesConditionRow)
.where(
SalesConditionRow.tenant_id == deal.tenant_id,
SalesConditionRow.deal_id == deal.deal_id,
SalesConditionRow.confirmed_at.is_not(None),
)
.order_by(SalesConditionRow.updated_at.desc(), SalesConditionRow.id.desc())
).scalars().first()
def _has_paid_invoice_or_payment(session, deal: SalesDealRow) -> bool:
return _first_invoice(session, deal, {"paid"}) is not None or _successful_payment(session, deal) is not None
def _scenario_allows_transition(deal: SalesDealRow, from_code: str, to_code: str) -> bool:
if deal.scenario_type == "quick_sale" and from_code == "need_confirmed":
return to_code in {"invoice_preparing", "invoice_sent"}
if deal.scenario_type == "custom_human_escalation" and to_code == "transferred_to_support":
return True
return False
def _validate_allowed_transition(
deal: SalesDealRow,
from_stage: SalesPipelineStageRow,
to_stage: SalesPipelineStageRow,
) -> None:
if from_stage.stage_id == to_stage.stage_id:
return
allowed = ALLOWED_TRANSITIONS.get(from_stage.code, [])
if to_stage.code in allowed or _scenario_allows_transition(deal, from_stage.code, to_stage.code):
return
_raise(
400,
"invalid_stage_transition",
f"Cannot transition deal from {from_stage.code} to {to_stage.code}",
{
"deal_id": deal.deal_id,
"from_stage_code": from_stage.code,
"to_stage_code": to_stage.code,
},
)
def _validate_preconditions(
session,
*,
deal: SalesDealRow,
to_stage: SalesPipelineStageRow,
reason: str | None,
metadata: dict[str, Any],
) -> None:
target = to_stage.code
if target == "need_confirmed" and not str(deal.need_summary or "").strip():
_raise(
400,
"missing_required_need_summary",
"need_summary is required before moving deal to need_confirmed",
{"deal_id": deal.deal_id, "to_stage_code": target},
)
if target == "invoice_preparing" and deal.scenario_type in SCENARIOS_REQUIRING_OFFER:
if _first_offer(session, deal) is None:
_raise(
400,
"offer_required_before_invoice",
"Offer is required before invoice preparation for this scenario",
{"deal_id": deal.deal_id, "scenario_type": deal.scenario_type},
)
if target == "offer_sent" and _first_offer(session, deal, {"draft", "ready", "sent"}) is None:
_raise(
400,
"offer_required_before_offer_sent",
"Offer is required before moving deal to offer_sent",
{"deal_id": deal.deal_id, "to_stage_code": target},
)
if target == "counterparty_data_received":
counterparty = _counterparty(session, deal)
status = str(counterparty.completeness_status if counterparty else "").strip().lower()
if status not in {"complete", "completed", "partial_acceptable"}:
_raise(
400,
"counterparty_required_before_document",
"Complete or acceptable counterparty data is required",
{"deal_id": deal.deal_id, "to_stage_code": target},
)
if target == "document_sent" and _first_document(session, deal, {"rendered", "sent"}) is None:
_raise(
400,
"document_required_before_document_sent",
"Rendered or sent document is required before moving deal to document_sent",
{"deal_id": deal.deal_id, "to_stage_code": target},
)
if target == "invoice_sent":
if _first_invoice(session, deal, {"issued", "sent"}) is None:
_raise(
400,
"invoice_required_before_invoice_sent",
"Issued or sent invoice is required before moving deal to invoice_sent",
{"deal_id": deal.deal_id, "to_stage_code": target},
)
if deal.document_required and _first_document(session, deal, {"confirmed", "signed"}) is None:
_raise(
400,
"document_confirmed_required_before_invoice_sent",
"Confirmed document is required before sending invoice",
{"deal_id": deal.deal_id, "to_stage_code": target},
)
if target == "paid" and not _has_paid_invoice_or_payment(session, deal):
_raise(
400,
"payment_required_before_paid",
"Successful payment or paid invoice is required before moving deal to paid",
{"deal_id": deal.deal_id, "to_stage_code": target},
)
if target == "won":
if deal.payment_required and not _has_paid_invoice_or_payment(session, deal):
_raise(
400,
"payment_required_before_won",
"Successful payment or paid invoice is required before moving deal to won",
{"deal_id": deal.deal_id, "to_stage_code": target},
)
if not deal.payment_required and _confirmed_conditions(session, deal) is None:
_raise(
400,
"conditions_required_before_won",
"Confirmed conditions are required before moving deal to won without payment",
{"deal_id": deal.deal_id, "to_stage_code": target},
)
if target == "lost":
close_reason = (
str(metadata.get("lost_reason") or "").strip()
or str(metadata.get("close_reason") or "").strip()
or str(reason or "").strip()
or str(deal.lost_reason or "").strip()
or str(deal.close_reason or "").strip()
)
if not close_reason:
_raise(
400,
"lost_reason_required",
"lost_reason or close_reason is required before moving deal to lost",
{"deal_id": deal.deal_id, "to_stage_code": target},
)
def _record_stage_change(
session,
*,
deal: SalesDealRow,
from_stage_id: str | None,
to_stage_id: str,
actor_type: str,
actor_id: str | None,
reason: str | None,
changed_at: str,
) -> None:
session.add(
SalesStageHistoryRow(
history_id=new_id("sth"),
tenant_id=deal.tenant_id,
deal_id=deal.deal_id,
from_stage_id=from_stage_id,
to_stage_id=to_stage_id,
changed_by_type=actor_type,
changed_by_id=actor_id,
reason=reason,
changed_at=changed_at,
)
)
def _apply_terminal_state(deal: SalesDealRow, *, to_stage: SalesPipelineStageRow, reason: str | None, metadata: dict[str, Any], now: str) -> None:
if to_stage.code == "won":
deal.status = "won"
deal.closed_at = deal.closed_at or now
deal.won_reason = str(metadata.get("won_reason") or reason or deal.won_reason or "deal.won").strip()
return
if to_stage.code == "lost":
close_reason = (
str(metadata.get("lost_reason") or "").strip()
or str(metadata.get("close_reason") or "").strip()
or str(reason or "").strip()
or str(deal.lost_reason or "").strip()
or str(deal.close_reason or "").strip()
)
deal.status = "lost"
deal.closed_at = deal.closed_at or now
deal.lost_reason = close_reason
deal.close_reason = deal.close_reason or close_reason
return
if to_stage.code == "transferred_to_execution":
deal.status = "closed"
deal.closed_at = deal.closed_at or now
deal.close_reason = str(metadata.get("close_reason") or reason or deal.close_reason or "transferred_to_execution").strip()
return
if deal.status in {"won", "lost", "closed"}:
deal.status = "active"
deal.closed_at = None
def transition_deal_stage(
session,
*,
tenant_id: str,
deal_id: str,
target_stage_code: str,
actor_type: str,
actor_id: str | None = None,
reason: str | None = None,
metadata: dict | None = None,
force: bool = False,
) -> SalesPipelineStageRow:
reason_text = str(reason or "").strip() or None
event_metadata = dict(metadata or {})
if force:
if actor_type not in {"system", "admin"}:
_raise(
403,
"force_transition_forbidden",
"force transition is allowed only for system or admin actors",
{"actor_type": actor_type},
)
if not reason_text:
_raise(
400,
"force_transition_reason_required",
"reason is required for force transition",
{"deal_id": deal_id, "target_stage_code": target_stage_code},
)
event_metadata["force"] = True
deal = _get_deal(session, tenant_id=tenant_id, deal_id=deal_id)
current_stage = _get_current_stage(session, deal)
target_stage = _get_target_stage(session, deal, target_stage_code)
if not force:
_validate_allowed_transition(deal, current_stage, target_stage)
_validate_preconditions(session, deal=deal, to_stage=target_stage, reason=reason_text, metadata=event_metadata)
now = utc_now_iso()
if current_stage.stage_id == target_stage.stage_id:
deal.updated_at = now
_apply_terminal_state(deal, to_stage=target_stage, reason=reason_text, metadata=event_metadata, now=now)
return target_stage
previous_stage_id = deal.stage_id
deal.stage_id = target_stage.stage_id
_apply_terminal_state(deal, to_stage=target_stage, reason=reason_text, metadata=event_metadata, now=now)
deal.updated_at = now
_record_stage_change(
session,
deal=deal,
from_stage_id=previous_stage_id,
to_stage_id=target_stage.stage_id,
actor_type=actor_type,
actor_id=actor_id,
reason=reason_text,
changed_at=now,
)
SalesEventPublisher.publish_sales_event(
session,
tenant_id=deal.tenant_id,
event_type=sales_event_types.DEAL_STAGE_CHANGED,
aggregate_type="deal",
aggregate_id=deal.deal_id,
actor_type=actor_type,
actor_id=actor_id,
payload={
"deal_id": deal.deal_id,
"pipeline_id": deal.pipeline_id,
"from_stage_id": previous_stage_id,
"from_stage_code": current_stage.code,
"to_stage_id": target_stage.stage_id,
"to_stage_code": target_stage.code,
"reason": reason_text,
"metadata": event_metadata,
},
)
return target_stage