Implement sales tenant pipeline events

This commit is contained in:
Magzhan Zhumabayev
2026-05-10 18:24:06 +05:00
parent 24ccdb3e73
commit 866d96e560
30 changed files with 24412 additions and 281 deletions
+8
View File
@@ -566,6 +566,13 @@ def oidc_callback(request: Request, state: str = "", code: str = "", error: str
).strip()
full_name = str(claims.get("name") or "").strip() or None
email = str(claims.get("email") or "").strip() or None
tenant_id = str(
claims.get("tenant_id")
or claims.get("organization_id")
or claims.get("org_id")
or claims.get("tid")
or ""
).strip() or None
token = issue_app_token(
subject=str(claims.get("sub") or ""),
@@ -575,6 +582,7 @@ def oidc_callback(request: Request, state: str = "", code: str = "", error: str
provider=_oidc_provider(),
full_name=full_name,
email=email,
tenant_id=tenant_id,
)
_consume_oidc_state(state)
return _html_bridge(
File diff suppressed because it is too large Load Diff
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
from typing import Any
from sqlalchemy.orm import Session
from services.shared.event_bus import append_outbox_event
from .sales_events import SALES_EVENT_VERSION
class SalesEventPublisher:
producer_service = "sales-service"
@classmethod
def publish_sales_event(
cls,
session: Session,
*,
tenant_id: str,
event_type: str,
aggregate_type: str,
aggregate_id: str,
payload: dict[str, Any],
actor_type: str | None = None,
actor_id: str | None = None,
correlation_id: str | None = None,
causation_id: str | None = None,
):
event_payload: dict[str, Any] = {
"tenant_id": tenant_id,
"aggregate_type": aggregate_type,
"aggregate_id": aggregate_id,
**payload,
}
if actor_type:
event_payload["actor_type"] = actor_type
if actor_id:
event_payload["actor_id"] = actor_id
if causation_id:
event_payload["causation_id"] = causation_id
row = append_outbox_event(
session,
event_type=event_type,
producer_service=cls.producer_service,
entity_type=aggregate_type,
entity_id=aggregate_id,
payload=event_payload,
correlation_id=correlation_id,
)
row.event_version = SALES_EVENT_VERSION
return row
+69
View File
@@ -0,0 +1,69 @@
SALES_EVENT_VERSION = 1
LEAD_ENTERED_CRM = "lead.entered_crm"
LEAD_ENRICHMENT_COMPLETED = "lead.enrichment_completed"
DEAL_STAGE_CHANGED = "deal.stage_changed"
DEAL_SCENARIO_SELECTED = "deal.scenario_selected"
DEAL_NEXT_ACTION_SCHEDULED = "deal.next_action_scheduled"
DEAL_CLOSED = "deal.closed"
DEAL_LOST = "deal.lost"
DEAL_WON = "deal.won"
COMMUNICATION_STARTED = "communication.started"
COMMUNICATION_SUMMARY_CREATED = "communication.summary_created"
MESSAGE_RECEIVED = "message.received"
MESSAGE_SENT = "message.sent"
CALL_RECEIVED = "call.received"
CALL_COMPLETED = "call.completed"
OFFER_CREATED = "offer.created"
OFFER_SENT = "offer.sent"
OFFER_ACCEPTED = "offer.accepted"
OFFER_REJECTED = "offer.rejected"
DEAL_CONDITIONS_CONFIRMED = "deal.conditions_confirmed"
COUNTERPARTY_COMPLETED = "counterparty.completed"
DOCUMENT_CREATED = "document.created"
DOCUMENT_SENT = "document.sent"
DOCUMENT_CONFIRMED = "document.confirmed"
DOCUMENT_SIGNED = "document.signed"
INVOICE_CREATED = "invoice.created"
INVOICE_SENT = "invoice.sent"
INVOICE_OVERDUE = "invoice.overdue"
PAYMENT_RECEIVED = "payment.received"
INVOICE_PAID = "invoice.paid"
SALES_EVENT_TYPES = {
LEAD_ENTERED_CRM,
LEAD_ENRICHMENT_COMPLETED,
DEAL_STAGE_CHANGED,
DEAL_SCENARIO_SELECTED,
DEAL_NEXT_ACTION_SCHEDULED,
DEAL_CLOSED,
DEAL_LOST,
DEAL_WON,
COMMUNICATION_STARTED,
COMMUNICATION_SUMMARY_CREATED,
MESSAGE_RECEIVED,
MESSAGE_SENT,
CALL_RECEIVED,
CALL_COMPLETED,
OFFER_CREATED,
OFFER_SENT,
OFFER_ACCEPTED,
OFFER_REJECTED,
DEAL_CONDITIONS_CONFIRMED,
COUNTERPARTY_COMPLETED,
DOCUMENT_CREATED,
DOCUMENT_SENT,
DOCUMENT_CONFIRMED,
DOCUMENT_SIGNED,
INVOICE_CREATED,
INVOICE_SENT,
INVOICE_OVERDUE,
PAYMENT_RECEIVED,
INVOICE_PAID,
}
+80 -3
View File
@@ -40,6 +40,74 @@ SalesPaymentStatus = Literal["pending", "success", "failed", "canceled", "partia
SalesEscalationSeverity = Literal["low", "medium", "high", "critical"]
SalesEscalationStatus = Literal["open", "in_progress", "resolved"]
SalesAutomationStatus = Literal["pending", "running", "completed", "failed", "canceled"]
SalesPipelineStageCategory = Literal["entry", "communication", "commercial", "paperwork", "finance", "closing"]
class SalesStageRefOut(BaseModel):
id: str
code: str
name: str
category: SalesPipelineStageCategory
sort_order: int
class SalesPipelineOut(BaseModel):
pipeline_id: str
tenant_id: str
code: str
name: str
description: str | None = None
is_default: bool
is_active: bool
created_at: str
updated_at: str
class SalesPipelineCreate(BaseModel):
code: str = Field(default="default_sales", min_length=2)
name: str = Field(min_length=2)
description: str | None = None
is_default: bool = False
is_active: bool = True
class SalesPipelineUpdate(BaseModel):
name: str | None = Field(default=None, min_length=2)
description: str | None = None
is_default: bool | None = None
is_active: bool | None = None
class SalesPipelineStageOut(BaseModel):
stage_id: str
tenant_id: str
pipeline_id: str
code: str
name: str
category: SalesPipelineStageCategory
sort_order: int
is_terminal: bool
is_system: bool
is_active: bool
created_at: str
updated_at: str
class SalesPipelineStageCreate(BaseModel):
code: str = Field(min_length=2)
name: str = Field(min_length=2)
category: SalesPipelineStageCategory
sort_order: int = 0
is_terminal: bool = False
is_system: bool = False
is_active: bool = True
class SalesPipelineStageUpdate(BaseModel):
name: str | None = Field(default=None, min_length=2)
category: SalesPipelineStageCategory | None = None
sort_order: int | None = None
is_active: bool | None = None
class SalesLeadCreate(BaseModel):
@@ -117,7 +185,8 @@ class SalesLeadOut(BaseModel):
class SalesDealCreate(BaseModel):
lead_id: str | None = None
customer_id: str | None = None
stage_id: str = "new_qualified_lead"
stage_id: str | None = Field(default=None, min_length=2)
stage_code: str | None = Field(default=None, min_length=2)
scenario_type: SalesScenarioType = "quick_sale"
priority: int = Field(default=3, ge=1, le=5)
title: str = Field(min_length=3)
@@ -151,7 +220,8 @@ class SalesDealUpdate(BaseModel):
class SalesDealStageChangeIn(BaseModel):
stage_id: str = Field(min_length=2)
stage_id: str | None = Field(default=None, min_length=2)
stage_code: str | None = Field(default=None, min_length=2)
reason: str | None = None
@@ -175,8 +245,10 @@ class SalesDealOut(BaseModel):
tenant_id: str
lead_id: str | None = None
customer_id: str | None = None
pipeline_id: str = "sales_default"
pipeline_id: str
stage_id: str
pipeline: SalesPipelineOut | None = None
stage: SalesStageRefOut | None = None
scenario_type: SalesScenarioType
priority: int
title: str
@@ -578,6 +650,8 @@ class SalesStageHistoryOut(BaseModel):
deal_id: str
from_stage_id: str | None = None
to_stage_id: str
from_stage: SalesStageRefOut | None = None
to_stage: SalesStageRefOut | None = None
changed_by_type: str
changed_by_id: str | None = None
reason: str | None = None
@@ -605,6 +679,8 @@ class SalesTimelineEventOut(BaseModel):
class SalesWorkspaceOut(BaseModel):
lead: SalesLeadOut | None = None
deal: SalesDealOut
pipeline: SalesPipelineOut | None = None
stage: SalesStageRefOut | None = None
communications: list[SalesCommunicationOut] = Field(default_factory=list)
messages: list[SalesMessageOut] = Field(default_factory=list)
calls: list[SalesCallOut] = Field(default_factory=list)
@@ -624,6 +700,7 @@ class SalesWorkspaceOut(BaseModel):
class SalesDashboardStageOut(BaseModel):
stage_id: str
label: str
stage: SalesStageRefOut | None = None
count: int = 0
amount: float = 0.0
+87 -2
View File
@@ -1,11 +1,96 @@
from __future__ import annotations
from sqlalchemy import Boolean, Float, Index, Integer, String, Text
from sqlalchemy import Boolean, Float, Index, Integer, String, Text, UniqueConstraint, text
from sqlalchemy.orm import Mapped, mapped_column
from services.shared.sql_models import Base
class TenantSalesSettingsRow(Base):
__tablename__ = "tenant_sales_settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tenant_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
default_pipeline_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
default_language: Mapped[str] = mapped_column(String(16), default="ru", index=True)
default_currency: Mapped[str] = mapped_column(String(16), default="KZT", index=True)
enabled_text_channels_json: Mapped[str] = mapped_column(Text, default="[]")
enabled_voice_channels_json: Mapped[str] = mapped_column(Text, default="[]")
payment_provider_settings_json: Mapped[str] = mapped_column(Text, default="{}")
document_settings_json: Mapped[str] = mapped_column(Text, default="{}")
created_at: Mapped[str] = mapped_column(String(64), index=True)
updated_at: Mapped[str] = mapped_column(String(64), index=True)
class TenantIntegrationRow(Base):
__tablename__ = "tenant_integrations"
__table_args__ = (
Index("idx_tenant_integrations_provider_account", "provider_type", "provider_name", "provider_account_id"),
Index("idx_tenant_integrations_external_identifier", "provider_type", "provider_name", "external_identifier"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
integration_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
provider_type: Mapped[str] = mapped_column(String(64), index=True)
provider_name: Mapped[str] = mapped_column(String(64), index=True)
provider_account_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
external_identifier: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
settings_json: Mapped[str] = mapped_column(Text, default="{}")
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
created_at: Mapped[str] = mapped_column(String(64), index=True)
updated_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesPipelineRow(Base):
__tablename__ = "sales_pipelines"
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_sales_pipelines_tenant_code"),
Index("idx_sales_pipelines_tenant_default", "tenant_id", "is_default", "is_active"),
Index(
"uq_sales_pipelines_one_default_per_tenant",
"tenant_id",
unique=True,
sqlite_where=text("is_default = 1"),
postgresql_where=text("is_default IS TRUE"),
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
pipeline_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
code: Mapped[str] = mapped_column(String(64), index=True)
name: Mapped[str] = mapped_column(String(256))
description: Mapped[str | None] = mapped_column(Text, nullable=True)
is_default: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
created_at: Mapped[str] = mapped_column(String(64), index=True)
updated_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesPipelineStageRow(Base):
__tablename__ = "sales_pipeline_stages"
__table_args__ = (
UniqueConstraint("tenant_id", "pipeline_id", "code", name="uq_sales_pipeline_stages_tenant_pipeline_code"),
Index("idx_sales_pipeline_stages_pipeline_order", "tenant_id", "pipeline_id", "sort_order"),
Index("idx_sales_pipeline_stages_tenant_active", "tenant_id", "is_active"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
stage_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
pipeline_id: Mapped[str] = mapped_column(String(64), index=True)
code: Mapped[str] = mapped_column(String(64), index=True)
name: Mapped[str] = mapped_column(String(256))
category: Mapped[str] = mapped_column(String(64), index=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0, index=True)
is_terminal: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
is_system: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
created_at: Mapped[str] = mapped_column(String(64), index=True)
updated_at: Mapped[str] = mapped_column(String(64), index=True)
class SalesLeadRow(Base):
__tablename__ = "sales_leads"
__table_args__ = (
@@ -50,7 +135,7 @@ class SalesDealRow(Base):
tenant_id: Mapped[str] = mapped_column(String(64), index=True)
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)
pipeline_id: Mapped[str] = mapped_column(String(64), default="sales_default", index=True)
pipeline_id: Mapped[str] = mapped_column(String(64), default="", index=True)
stage_id: Mapped[str] = mapped_column(String(64), index=True)
scenario_type: Mapped[str] = mapped_column(String(64), index=True)
priority: Mapped[int] = mapped_column(Integer, default=3, index=True)
+20 -2
View File
@@ -64,6 +64,7 @@ def issue_app_token(
provider: str | None = None,
full_name: str | None = None,
email: str | None = None,
tenant_id: str | None = None,
ttl_seconds: int | None = None,
) -> str:
now = datetime.now(timezone.utc)
@@ -83,6 +84,8 @@ def issue_app_token(
payload["full_name"] = full_name
if email:
payload["email"] = email
if tenant_id:
payload["tenant_id"] = tenant_id
encoded_header = _b64url_encode(json.dumps(header, separators=(",", ":")).encode("utf-8"))
encoded_payload = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
@@ -119,12 +122,18 @@ def get_actor(
authorization: str | None = Header(default=None, alias="Authorization"),
x_user: str | None = Header(default=None, alias="X-User"),
x_role: str | None = Header(default=None, alias="X-Role"),
x_tenant_id: str | None = Header(default=None, alias="X-Tenant-ID"),
) -> dict:
if authorization:
scheme, _, value = authorization.partition(" ")
if scheme.lower() != "bearer" or not value.strip():
raise HTTPException(status_code=401, detail="Invalid authorization header")
payload = decode_app_token(value.strip())
tenant_id = str(payload.get("tenant_id") or "").strip()
tenant_source = "token" if tenant_id else None
if not tenant_id and legacy_header_auth_allowed():
tenant_id = str(x_tenant_id or "").strip()
tenant_source = "header" if tenant_id else None
return {
"sub": payload.get("sub", ""),
"user": payload.get("username", "anonymous"),
@@ -133,13 +142,22 @@ def get_actor(
"provider": payload.get("provider"),
"full_name": payload.get("full_name"),
"email": payload.get("email"),
"tenant_id": tenant_id or None,
"tenant_source": tenant_source,
}
if legacy_header_auth_allowed():
role = (x_role or "").strip().lower()
return {"user": (x_user or "anonymous").strip(), "role": role or "anonymous", "auth_source": "legacy"}
tenant_id = str(x_tenant_id or "").strip()
return {
"user": (x_user or "anonymous").strip(),
"role": role or "anonymous",
"auth_source": "legacy",
"tenant_id": tenant_id or None,
"tenant_source": "header" if tenant_id else None,
}
return {"user": "anonymous", "role": "anonymous", "auth_source": "none"}
return {"user": "anonymous", "role": "anonymous", "auth_source": "none", "tenant_id": None, "tenant_source": None}
def require_roles(*allowed: Role) -> Callable:
+19
View File
@@ -50,6 +50,25 @@ class AuthOIDCState(Base):
consumed_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
class Tenant(Base):
__tablename__ = "tenants"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tenant_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
name: Mapped[str] = mapped_column(String(256), index=True)
business_type: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
industry: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
country: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
city: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
timezone: Mapped[str] = mapped_column(String(64), default="Asia/Almaty")
language_preferences_json: Mapped[str] = mapped_column(Text, default='["ru"]')
default_currency: Mapped[str] = mapped_column(String(16), default="KZT", index=True)
subscription_plan: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
created_at: Mapped[str] = mapped_column(String(64), index=True)
updated_at: Mapped[str] = mapped_column(String(64), index=True)
class Customer(Base):
__tablename__ = "customers"