Implement sales CRM workflow foundation

This commit is contained in:
Admin
2026-05-10 20:54:01 +05:00
parent dad4ab115a
commit 8577d97356
22 changed files with 5625 additions and 242 deletions
+101 -3
View File
@@ -38,9 +38,10 @@ SalesDocumentStatus = Literal["draft", "sent", "under_review", "confirmed", "sig
SalesInvoiceStatus = Literal["draft", "issued", "sent", "partially_paid", "paid", "overdue", "canceled"]
SalesPaymentStatus = Literal["pending", "success", "failed", "canceled", "partial"]
SalesEscalationSeverity = Literal["low", "medium", "high", "critical"]
SalesEscalationStatus = Literal["open", "in_progress", "resolved"]
SalesEscalationStatus = Literal["open", "assigned", "in_progress", "resolved", "canceled"]
SalesAutomationStatus = Literal["pending", "running", "completed", "failed", "canceled"]
SalesPipelineStageCategory = Literal["entry", "communication", "commercial", "paperwork", "finance", "closing"]
SalesSwitchChannel = Literal["text", "voice"]
class SalesStageRefOut(BaseModel):
@@ -222,7 +223,11 @@ class SalesDealUpdate(BaseModel):
class SalesDealStageChangeIn(BaseModel):
stage_id: str | None = Field(default=None, min_length=2)
stage_code: str | None = Field(default=None, min_length=2)
target_stage_id: str | None = Field(default=None, min_length=2)
target_stage_code: str | None = Field(default=None, min_length=2)
reason: str | None = None
metadata: dict = Field(default_factory=dict)
force: bool = False
class SalesDealScenarioIn(BaseModel):
@@ -295,8 +300,16 @@ class SalesCommunicationSummaryIn(BaseModel):
class SalesCommunicationSwitchChannelIn(BaseModel):
to_channel: SalesPreferredChannel
reason_for_channel_switch: str = Field(min_length=3)
to_channel: SalesSwitchChannel
reason_code: str = "human_decision"
reason_text: str | None = None
reason_for_channel_switch: str | None = None
create_session: bool = False
scheduled_at: str | None = None
metadata: dict = Field(default_factory=dict)
source_type: str | None = None
source_id: str | None = None
recommended_by_task_id: str | None = None
class SalesCommunicationBindExternalIn(BaseModel):
@@ -315,6 +328,7 @@ class SalesCommunicationOut(BaseModel):
lead_id: str | None = None
customer_id: str | None = None
channel_type: SalesChannelType
channel_provider: str | None = None
direction: SalesDirection
agent_type: SalesAgentType
started_at: str
@@ -580,7 +594,11 @@ class SalesPaymentWebhookIn(BaseModel):
deal_id: str
invoice_id: str | None = None
payment_provider: str = "manual"
provider_account_id: str | None = None
external_event_id: str | None = None
external_payment_id: str | None = None
event_type: str | None = None
provider_status: str | None = None
amount: float = Field(ge=0)
currency: str = "KZT"
status: SalesPaymentStatus = "success"
@@ -618,6 +636,10 @@ class SalesEscalationCreateIn(BaseModel):
reason: str = Field(min_length=3)
severity: SalesEscalationSeverity = "medium"
assigned_to_user_id: str | None = None
source_channel: str | None = None
source_communication_session_id: str | None = None
source_automation_task_id: str | None = None
sla_due_at: str | None = None
class SalesEscalationOut(BaseModel):
@@ -628,8 +650,35 @@ class SalesEscalationOut(BaseModel):
severity: SalesEscalationSeverity
status: SalesEscalationStatus
assigned_to_user_id: str | None = None
assigned_at: str | None = None
started_at: str | None = None
created_at: str
resolved_at: str | None = None
canceled_at: str | None = None
resolution_code: str | None = None
resolution_summary: str | None = None
sla_due_at: str | None = None
source_channel: str | None = None
source_communication_session_id: str | None = None
source_automation_task_id: str | None = None
updated_at: str | None = None
class SalesEscalationAssignIn(BaseModel):
assigned_to_user_id: str = Field(min_length=1)
class SalesEscalationResolveIn(BaseModel):
resolution_code: str = Field(min_length=2)
resolution_summary: str | None = None
target_stage_code: str | None = None
metadata: dict = Field(default_factory=dict)
class SalesEscalationCancelIn(BaseModel):
resolution_code: str = "canceled"
resolution_summary: str | None = None
metadata: dict = Field(default_factory=dict)
class SalesAutomationTaskOut(BaseModel):
@@ -640,6 +689,11 @@ class SalesAutomationTaskOut(BaseModel):
run_at: str
status: SalesAutomationStatus
retry_count: int = 0
max_retries: int = 3
locked_at: str | None = None
locked_by: str | None = None
completed_at: str | None = None
failed_at: str | None = None
last_error: str | None = None
created_at: str
updated_at: str
@@ -662,10 +716,50 @@ class SalesChannelSwitchOut(BaseModel):
switch_id: str
deal_id: str
communication_id: str | None = None
communication_session_id: str | None = None
previous_communication_session_id: str | None = None
new_communication_session_id: str | None = None
from_channel: str
to_channel: str
reason_code: str
reason_text: str | None = None
reason_for_channel_switch: str
initiated_by_type: str
initiated_by_id: str | None = None
source_type: str | None = None
source_id: str | None = None
recommended_by_task_id: str | None = None
switched_at: str
created_at: str
new_communication: SalesCommunicationOut | None = None
class SalesNoteCreateIn(BaseModel):
note_type: str = "general"
content: str = Field(min_length=1)
source_type: str | None = None
source_id: str | None = None
class SalesNoteUpdateIn(BaseModel):
note_type: str | None = None
content: str | None = Field(default=None, min_length=1)
source_type: str | None = None
source_id: str | None = None
class SalesNoteOut(BaseModel):
note_id: str
deal_id: str
author_type: str
author_id: str | None = None
note_type: str
content: str
source_type: str | None = None
source_id: str | None = None
created_at: str
updated_at: str | None = None
archived_at: str | None = None
class SalesTimelineEventOut(BaseModel):
@@ -684,6 +778,8 @@ class SalesWorkspaceOut(BaseModel):
communications: list[SalesCommunicationOut] = Field(default_factory=list)
messages: list[SalesMessageOut] = Field(default_factory=list)
calls: list[SalesCallOut] = Field(default_factory=list)
transcripts: list[SalesTranscriptOut] = Field(default_factory=list)
notes: list[SalesNoteOut] = Field(default_factory=list)
offers: list[SalesOfferOut] = Field(default_factory=list)
conditions: list[SalesConditionOut] = Field(default_factory=list)
counterparty: SalesCounterpartyOut | None = None
@@ -691,9 +787,11 @@ class SalesWorkspaceOut(BaseModel):
invoices: list[SalesInvoiceOut] = Field(default_factory=list)
payments: list[SalesPaymentOut] = Field(default_factory=list)
escalations: list[SalesEscalationOut] = Field(default_factory=list)
active_escalation: SalesEscalationOut | None = None
tasks: list[SalesAutomationTaskOut] = Field(default_factory=list)
stage_history: list[SalesStageHistoryOut] = Field(default_factory=list)
channel_switches: list[SalesChannelSwitchOut] = Field(default_factory=list)
recommended_channel_switch: dict | None = None
timeline: list[SalesTimelineEventOut] = Field(default_factory=list)
+76
View File
@@ -178,6 +178,7 @@ class SalesCommunicationSessionRow(Base):
lead_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
channel_type: Mapped[str] = mapped_column(String(16), index=True)
channel_provider: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
direction: Mapped[str] = mapped_column(String(16), index=True)
agent_type: Mapped[str] = mapped_column(String(32), index=True)
started_at: Mapped[str] = mapped_column(String(64), index=True)
@@ -275,7 +276,11 @@ class SalesNoteRow(Base):
author_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
note_type: Mapped[str] = mapped_column(String(64), index=True)
content: Mapped[str] = mapped_column(Text)
source_type: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
source_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
created_at: Mapped[str] = mapped_column(String(64), index=True)
updated_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
archived_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
class SalesOfferRow(Base):
@@ -403,6 +408,15 @@ class SalesPaymentRow(Base):
__tablename__ = "sales_payments"
__table_args__ = (
Index("idx_sales_payments_deal_status", "deal_id", "status"),
Index(
"idx_sales_payments_tenant_external_payment_unique",
"tenant_id",
"payment_provider",
"external_payment_id",
unique=True,
sqlite_where=text("external_payment_id IS NOT NULL"),
postgresql_where=text("external_payment_id IS NOT NULL"),
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
@@ -423,6 +437,41 @@ class SalesPaymentRow(Base):
updated_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesPaymentWebhookEventRow(Base):
__tablename__ = "sales_payment_webhook_events"
__table_args__ = (
Index("idx_sales_payment_webhook_events_received", "tenant_id", "received_at"),
Index("idx_sales_payment_webhook_events_payment", "tenant_id", "payment_provider", "external_payment_id"),
Index(
"idx_sales_payment_webhook_events_external_event_unique",
"tenant_id",
"payment_provider",
"external_event_id",
unique=True,
sqlite_where=text("external_event_id IS NOT NULL"),
postgresql_where=text("external_event_id IS NOT NULL"),
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
payment_provider: Mapped[str] = mapped_column(String(64), index=True)
provider_account_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
external_event_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
external_payment_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
event_type: Mapped[str] = mapped_column(String(128), index=True)
event_status: Mapped[str] = mapped_column(String(32), index=True, default="received")
signature_status: Mapped[str] = mapped_column(String(32), index=True, default="unchecked")
raw_payload_hash: Mapped[str] = mapped_column(String(64), index=True)
normalized_payload_json: Mapped[str] = mapped_column(Text, default="{}")
headers_json: Mapped[str] = mapped_column(Text, default="{}")
received_at: Mapped[str] = mapped_column(String(64), index=True)
processed_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[str] = mapped_column(String(64), index=True)
updated_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesStageHistoryRow(Base):
__tablename__ = "sales_stage_history"
__table_args__ = (
@@ -456,14 +505,25 @@ class SalesEscalationRow(Base):
severity: Mapped[str] = mapped_column(String(16), index=True)
status: Mapped[str] = mapped_column(String(32), index=True, default="open")
assigned_to_user_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
assigned_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
started_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
created_at: Mapped[str] = mapped_column(String(64), index=True)
resolved_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
canceled_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
resolution_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
resolution_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
sla_due_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
source_channel: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
source_communication_session_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
source_automation_task_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
updated_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
class SalesAutomationTaskRow(Base):
__tablename__ = "sales_automation_tasks"
__table_args__ = (
Index("idx_sales_tasks_run_status", "run_at", "status"),
Index("idx_sales_tasks_lock", "status", "locked_at", "locked_by"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
@@ -475,6 +535,11 @@ class SalesAutomationTaskRow(Base):
run_at: Mapped[str] = mapped_column(String(64), index=True)
status: Mapped[str] = mapped_column(String(32), index=True, default="pending")
retry_count: Mapped[int] = mapped_column(Integer, default=0)
max_retries: Mapped[int] = mapped_column(Integer, default=3)
locked_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
locked_by: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
completed_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
failed_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[str] = mapped_column(String(64), index=True)
updated_at: Mapped[str] = mapped_column(String(64), index=True)
@@ -491,10 +556,21 @@ class SalesChannelSwitchRow(Base):
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
deal_id: Mapped[str] = mapped_column(String(64), index=True)
communication_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
communication_session_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
previous_communication_session_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
new_communication_session_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
from_channel: Mapped[str] = mapped_column(String(32), index=True)
to_channel: Mapped[str] = mapped_column(String(32), index=True)
reason_code: Mapped[str] = mapped_column(String(64), default="human_decision", index=True)
reason_text: Mapped[str | None] = mapped_column(Text, nullable=True)
reason_for_channel_switch: Mapped[str] = mapped_column(Text)
initiated_by_type: Mapped[str] = mapped_column(String(32), default="human", index=True)
initiated_by_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
source_type: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
source_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
recommended_by_task_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
switched_at: Mapped[str] = mapped_column(String(64), index=True)
created_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesExternalLinkRow(Base):
+139
View File
@@ -601,6 +601,145 @@ def _apply_runtime_schema_compatibility() -> None:
)
)
if "sales_automation_tasks" in table_names:
columns = _table_columns(inspector, "sales_automation_tasks")
_add_column_if_missing(conn, columns, "sales_automation_tasks", "max_retries", "INTEGER DEFAULT 3")
_add_column_if_missing(conn, columns, "sales_automation_tasks", "locked_at", "VARCHAR(64)")
_add_column_if_missing(conn, columns, "sales_automation_tasks", "locked_by", "VARCHAR(128)")
_add_column_if_missing(conn, columns, "sales_automation_tasks", "completed_at", "VARCHAR(64)")
_add_column_if_missing(conn, columns, "sales_automation_tasks", "failed_at", "VARCHAR(64)")
conn.execute(
text(
"""
UPDATE sales_automation_tasks
SET max_retries = 3
WHERE max_retries IS NULL
"""
)
)
indexes = _table_indexes(inspector, "sales_automation_tasks")
if "idx_sales_tasks_lock" not in indexes:
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_sales_tasks_lock "
"ON sales_automation_tasks(status, locked_at, locked_by)"
)
)
if "sales_communication_sessions" in table_names:
columns = _table_columns(inspector, "sales_communication_sessions")
_add_column_if_missing(conn, columns, "sales_communication_sessions", "channel_provider", "VARCHAR(32)")
indexes = _table_indexes(inspector, "sales_communication_sessions")
if "idx_sales_comm_channel_provider" not in indexes:
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_sales_comm_channel_provider "
"ON sales_communication_sessions(channel_provider)"
)
)
if "sales_notes" in table_names:
columns = _table_columns(inspector, "sales_notes")
_add_column_if_missing(conn, columns, "sales_notes", "source_type", "VARCHAR(64)")
_add_column_if_missing(conn, columns, "sales_notes", "source_id", "VARCHAR(128)")
_add_column_if_missing(conn, columns, "sales_notes", "updated_at", "VARCHAR(64)")
_add_column_if_missing(conn, columns, "sales_notes", "archived_at", "VARCHAR(64)")
conn.execute(
text(
"""
UPDATE sales_notes
SET updated_at = created_at
WHERE updated_at IS NULL OR TRIM(updated_at) = ''
"""
)
)
if "sales_escalations" in table_names:
columns = _table_columns(inspector, "sales_escalations")
_add_column_if_missing(conn, columns, "sales_escalations", "assigned_at", "VARCHAR(64)")
_add_column_if_missing(conn, columns, "sales_escalations", "started_at", "VARCHAR(64)")
_add_column_if_missing(conn, columns, "sales_escalations", "canceled_at", "VARCHAR(64)")
_add_column_if_missing(conn, columns, "sales_escalations", "resolution_code", "VARCHAR(64)")
_add_column_if_missing(conn, columns, "sales_escalations", "resolution_summary", "TEXT")
_add_column_if_missing(conn, columns, "sales_escalations", "sla_due_at", "VARCHAR(64)")
_add_column_if_missing(conn, columns, "sales_escalations", "source_channel", "VARCHAR(32)")
_add_column_if_missing(conn, columns, "sales_escalations", "source_communication_session_id", "VARCHAR(64)")
_add_column_if_missing(conn, columns, "sales_escalations", "source_automation_task_id", "VARCHAR(64)")
_add_column_if_missing(conn, columns, "sales_escalations", "updated_at", "VARCHAR(64)")
conn.execute(
text(
"""
UPDATE sales_escalations
SET updated_at = COALESCE(updated_at, resolved_at, created_at)
WHERE updated_at IS NULL OR TRIM(updated_at) = ''
"""
)
)
if "sales_channel_switches" in table_names:
columns = _table_columns(inspector, "sales_channel_switches")
_add_column_if_missing(conn, columns, "sales_channel_switches", "communication_session_id", "VARCHAR(64)")
_add_column_if_missing(conn, columns, "sales_channel_switches", "previous_communication_session_id", "VARCHAR(64)")
_add_column_if_missing(conn, columns, "sales_channel_switches", "new_communication_session_id", "VARCHAR(64)")
_add_column_if_missing(conn, columns, "sales_channel_switches", "reason_code", "VARCHAR(64) DEFAULT 'human_decision'")
_add_column_if_missing(conn, columns, "sales_channel_switches", "reason_text", "TEXT")
_add_column_if_missing(conn, columns, "sales_channel_switches", "initiated_by_type", "VARCHAR(32) DEFAULT 'human'")
_add_column_if_missing(conn, columns, "sales_channel_switches", "initiated_by_id", "VARCHAR(128)")
_add_column_if_missing(conn, columns, "sales_channel_switches", "source_type", "VARCHAR(64)")
_add_column_if_missing(conn, columns, "sales_channel_switches", "source_id", "VARCHAR(128)")
_add_column_if_missing(conn, columns, "sales_channel_switches", "recommended_by_task_id", "VARCHAR(64)")
_add_column_if_missing(conn, columns, "sales_channel_switches", "created_at", "VARCHAR(64)")
conn.execute(
text(
"""
UPDATE sales_channel_switches
SET communication_session_id = COALESCE(communication_session_id, communication_id),
previous_communication_session_id = COALESCE(previous_communication_session_id, communication_id),
reason_code = COALESCE(reason_code, 'human_decision'),
reason_text = COALESCE(reason_text, reason_for_channel_switch),
initiated_by_type = COALESCE(initiated_by_type, 'human'),
created_at = COALESCE(created_at, switched_at)
WHERE created_at IS NULL OR TRIM(created_at) = ''
"""
)
)
if "sales_payment_webhook_events" in table_names:
indexes = _table_indexes(inspector, "sales_payment_webhook_events")
if "idx_sales_payment_webhook_events_received" not in indexes:
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_sales_payment_webhook_events_received "
"ON sales_payment_webhook_events(tenant_id, received_at)"
)
)
if "idx_sales_payment_webhook_events_payment" not in indexes:
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_sales_payment_webhook_events_payment "
"ON sales_payment_webhook_events(tenant_id, payment_provider, external_payment_id)"
)
)
if "idx_sales_payment_webhook_events_external_event_unique" not in indexes:
conn.execute(
text(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_sales_payment_webhook_events_external_event_unique "
"ON sales_payment_webhook_events(tenant_id, payment_provider, external_event_id) "
"WHERE external_event_id IS NOT NULL"
)
)
if "sales_payments" in table_names:
indexes = _table_indexes(inspector, "sales_payments")
if "idx_sales_payments_tenant_external_payment_unique" not in indexes:
conn.execute(
text(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_sales_payments_tenant_external_payment_unique "
"ON sales_payments(tenant_id, payment_provider, external_payment_id) "
"WHERE external_payment_id IS NOT NULL"
)
)
def init_sql_schema() -> None:
global _BASE_SCHEMA_INITIALIZED