649 lines
21 KiB
Python
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
|