Implement sales tenant pipeline events
This commit is contained in:
@@ -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(
|
||||
|
||||
+1808
-272
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user