TorbatYar/backend/services/crm/app/schemas/sales_engine.py
Mortezakoohjani 064d67f099 Ship CRM Core Platform (phases 6.0–6.3) with docs and prod deploy.
Add the Sales CRM service through collaboration, sync architecture/registry docs, expose crm.torbatyar.ir, and include a production deploy script.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 20:32:00 +03:30

535 lines
14 KiB
Python

"""Phase 6.2 sales engine DTOs."""
from __future__ import annotations
from datetime import date, datetime
from decimal import Decimal
from uuid import UUID
from pydantic import BaseModel, Field
from app.models.types import (
ContactRoleType,
ForecastKind,
ForecastPeriod,
GoalMetric,
GoalScope,
OpportunityStatus,
PlaybookAssignmentStatus,
PlaybookStepKind,
TargetPeriod,
WinLossOutcome,
)
from app.schemas.common import ORMBase
# ── Pipeline / Stage ─────────────────────────────────────────────────────────
class PipelineCreate(BaseModel):
name: str = Field(max_length=255)
code: str = Field(max_length=50)
description: str | None = None
sort_order: int = 0
is_default: bool = False
is_active: bool = True
status: str = "active"
class PipelineUpdate(BaseModel):
name: str | None = Field(default=None, max_length=255)
description: str | None = None
sort_order: int | None = None
is_default: bool | None = None
is_active: bool | None = None
status: str | None = None
class PipelineRead(ORMBase):
id: UUID
tenant_id: UUID
name: str
code: str
description: str | None
sort_order: int
is_default: bool
is_active: bool
status: str
created_at: datetime
updated_at: datetime
class PipelineStageCreate(BaseModel):
pipeline_id: UUID
name: str = Field(max_length=255)
code: str = Field(max_length=50)
position: int = 0
win_probability: int = Field(default=0, ge=0, le=100)
color: str | None = Field(default=None, max_length=20)
is_start_stage: bool = False
is_won_stage: bool = False
is_lost_stage: bool = False
is_closed_stage: bool = False
required_fields: list[str] | None = None
exit_validation: dict | None = None
is_active: bool = True
class PipelineStageUpdate(BaseModel):
name: str | None = Field(default=None, max_length=255)
position: int | None = None
win_probability: int | None = Field(default=None, ge=0, le=100)
color: str | None = Field(default=None, max_length=20)
is_start_stage: bool | None = None
is_won_stage: bool | None = None
is_lost_stage: bool | None = None
is_closed_stage: bool | None = None
required_fields: list[str] | None = None
exit_validation: dict | None = None
is_active: bool | None = None
class PipelineStageRead(ORMBase):
id: UUID
tenant_id: UUID
pipeline_id: UUID
name: str
code: str
position: int
win_probability: int
color: str | None
is_start_stage: bool
is_won_stage: bool
is_lost_stage: bool
is_closed_stage: bool
required_fields: list | None
exit_validation: dict | None
is_active: bool
created_at: datetime
updated_at: datetime
# ── Opportunity ──────────────────────────────────────────────────────────────
class OpportunityCreate(BaseModel):
name: str | None = Field(default=None, max_length=255)
title: str | None = Field(default=None, max_length=255)
opportunity_number: str | None = Field(default=None, max_length=50)
pipeline_id: UUID
stage_id: UUID
amount: Decimal = Decimal("0")
expected_revenue: Decimal | None = None
currency_code: str = Field(default="IRR", max_length=3)
probability: int = Field(default=0, ge=0, le=100)
priority: str = "medium"
expected_close_date: date | None = None
organization_id: UUID | None = None
contact_id: UUID | None = None
lead_id: UUID | None = None
owner_user_id: str | None = Field(default=None, max_length=100)
description: str | None = None
class OpportunityUpdate(BaseModel):
name: str | None = Field(default=None, max_length=255)
title: str | None = Field(default=None, max_length=255)
stage_id: UUID | None = None
amount: Decimal | None = None
expected_revenue: Decimal | None = None
currency_code: str | None = Field(default=None, max_length=3)
probability: int | None = Field(default=None, ge=0, le=100)
priority: str | None = None
expected_close_date: date | None = None
organization_id: UUID | None = None
contact_id: UUID | None = None
owner_user_id: str | None = Field(default=None, max_length=100)
description: str | None = None
class OpportunityRead(ORMBase):
id: UUID
tenant_id: UUID
opportunity_number: str
name: str
title: str
pipeline_id: UUID
stage_id: UUID
status: OpportunityStatus
amount: Decimal
expected_revenue: Decimal
currency_code: str
probability: int
priority: str
expected_close_date: date | None
organization_id: UUID | None
contact_id: UUID | None
lead_id: UUID | None
owner_user_id: str | None
description: str | None
won_at: datetime | None
lost_at: datetime | None
is_deleted: bool
created_by: str | None
updated_by: str | None
created_at: datetime
updated_at: datetime
class OpportunityStageChangeRequest(BaseModel):
stage_id: UUID
reason: str | None = None
class OpportunityWinRequest(BaseModel):
reason_id: UUID | None = None
category_id: UUID | None = None
sub_category: str | None = None
root_cause: str | None = None
competitor_name: str | None = None
price_factor: bool = False
feature_gap: bool = False
timing_factor: bool = False
relationship_factor: bool = False
approval_factor: bool = False
custom_reason: str | None = None
class OpportunityLoseRequest(BaseModel):
reason_id: UUID | None = None
category_id: UUID | None = None
sub_category: str | None = None
root_cause: str | None = None
competitor_name: str | None = None
price_factor: bool = False
feature_gap: bool = False
timing_factor: bool = False
relationship_factor: bool = False
approval_factor: bool = False
custom_reason: str | None = None
class StageHistoryRead(ORMBase):
id: UUID
tenant_id: UUID
opportunity_id: UUID
previous_stage_id: UUID | None
next_stage_id: UUID
changed_by: str | None
changed_at: datetime
duration_seconds: int | None
reason: str | None
created_at: datetime
# ── Products / Contacts / Competitors ────────────────────────────────────────
class OpportunityProductCreate(BaseModel):
opportunity_id: UUID
product_ref_id: str = Field(max_length=100)
product_name: str | None = Field(default=None, max_length=255)
quantity: Decimal = Decimal("1")
unit_price: Decimal = Decimal("0")
discount_percent: Decimal = Decimal("0")
class OpportunityProductRead(ORMBase):
id: UUID
tenant_id: UUID
opportunity_id: UUID
product_ref_id: str
product_name: str | None
quantity: Decimal
unit_price: Decimal
discount_percent: Decimal
expected_revenue: Decimal
created_at: datetime
class OpportunityContactCreate(BaseModel):
opportunity_id: UUID
contact_id: UUID
role: ContactRoleType = ContactRoleType.INFLUENCER
custom_role_label: str | None = Field(default=None, max_length=100)
is_primary: bool = False
class OpportunityContactRead(ORMBase):
id: UUID
tenant_id: UUID
opportunity_id: UUID
contact_id: UUID
role: ContactRoleType
custom_role_label: str | None
is_primary: bool
created_at: datetime
class OpportunityCompetitorCreate(BaseModel):
opportunity_id: UUID
competitor_name: str = Field(max_length=255)
strength: str | None = None
weakness: str | None = None
notes: str | None = None
win_probability: int = Field(default=0, ge=0, le=100)
class OpportunityCompetitorRead(ORMBase):
id: UUID
tenant_id: UUID
opportunity_id: UUID
competitor_name: str
strength: str | None
weakness: str | None
notes: str | None
win_probability: int
created_at: datetime
# ── Playbooks ────────────────────────────────────────────────────────────────
class PlaybookCreate(BaseModel):
name: str = Field(max_length=255)
code: str = Field(max_length=50)
description: str | None = None
version: int = 1
pipeline_id: UUID | None = None
is_active: bool = True
class PlaybookRead(ORMBase):
id: UUID
tenant_id: UUID
name: str
code: str
description: str | None
version: int
pipeline_id: UUID | None
is_active: bool
is_deleted: bool
created_at: datetime
class PlaybookStepCreate(BaseModel):
playbook_id: UUID
title: str = Field(max_length=255)
step_kind: PlaybookStepKind
position: int = 0
is_required: bool = False
checklist_items: list[str] | None = None
template_body: str | None = None
class PlaybookStepRead(ORMBase):
id: UUID
tenant_id: UUID
playbook_id: UUID
title: str
step_kind: PlaybookStepKind
position: int
is_required: bool
checklist_items: list | None
template_body: str | None
created_at: datetime
class PlaybookAssignRequest(BaseModel):
playbook_id: UUID
opportunity_id: UUID | None = None
pipeline_id: UUID | None = None
class PlaybookAssignmentRead(ORMBase):
id: UUID
tenant_id: UUID
playbook_id: UUID
opportunity_id: UUID | None
pipeline_id: UUID | None
status: PlaybookAssignmentStatus
assigned_by: str | None
completed_steps: list | None
completion_percent: int
completed_at: datetime | None
created_at: datetime
class PlaybookCompleteStepRequest(BaseModel):
step_id: UUID
# ── Win/Loss / Forecast / Goals / Targets ────────────────────────────────────
class WinLossCategoryCreate(BaseModel):
code: str = Field(max_length=50)
name: str = Field(max_length=150)
parent_category_id: UUID | None = None
is_active: bool = True
class WinLossCategoryRead(ORMBase):
id: UUID
tenant_id: UUID
code: str
name: str
parent_category_id: UUID | None
is_active: bool
created_at: datetime
class WinLossReasonCreate(BaseModel):
code: str = Field(max_length=50)
name: str = Field(max_length=150)
outcome: WinLossOutcome
category_id: UUID | None = None
is_active: bool = True
class WinLossReasonRead(ORMBase):
id: UUID
tenant_id: UUID
code: str
name: str
outcome: WinLossOutcome
category_id: UUID | None
is_active: bool
created_at: datetime
class OpportunityWinLossRead(ORMBase):
id: UUID
tenant_id: UUID
opportunity_id: UUID
outcome: WinLossOutcome
reason_id: UUID | None
category_id: UUID | None
sub_category: str | None
root_cause: str | None
competitor_name: str | None
price_factor: bool
feature_gap: bool
timing_factor: bool
relationship_factor: bool
approval_factor: bool
custom_reason: str | None
recorded_by: str | None
recorded_at: datetime
is_locked: bool
created_at: datetime
class ForecastComputeRequest(BaseModel):
name: str = Field(max_length=255)
kind: ForecastKind = ForecastKind.WEIGHTED
period: ForecastPeriod
period_start: date
period_end: date
pipeline_id: UUID | None = None
owner_user_id: str | None = None
currency_code: str = "IRR"
class ForecastRead(ORMBase):
id: UUID
tenant_id: UUID
name: str
kind: ForecastKind
period: ForecastPeriod
period_start: date
period_end: date
pipeline_id: UUID | None
owner_user_id: str | None
pipeline_amount: Decimal
weighted_amount: Decimal
committed_amount: Decimal
currency_code: str
opportunity_count: int
created_at: datetime
class GoalCreate(BaseModel):
name: str = Field(max_length=255)
scope: GoalScope
metric: GoalMetric
owner_user_id: str | None = None
team_key: str | None = None
department_key: str | None = None
target_value: Decimal
period_start: date
period_end: date
is_active: bool = True
class GoalRead(ORMBase):
id: UUID
tenant_id: UUID
name: str
scope: GoalScope
metric: GoalMetric
owner_user_id: str | None
team_key: str | None
department_key: str | None
target_value: Decimal
period_start: date
period_end: date
is_active: bool
created_at: datetime
class TargetCreate(BaseModel):
name: str = Field(max_length=255)
period: TargetPeriod
period_start: date
period_end: date
goal_id: UUID | None = None
owner_user_id: str | None = None
target_value: Decimal
actual_value: Decimal = Decimal("0")
class TargetRead(ORMBase):
id: UUID
tenant_id: UUID
name: str
period: TargetPeriod
period_start: date
period_end: date
goal_id: UUID | None
owner_user_id: str | None
target_value: Decimal
actual_value: Decimal
achievement_percent: Decimal
created_at: datetime
class SalesAuditRead(ORMBase):
id: UUID
tenant_id: UUID
entity_type: str
entity_id: UUID
action: str
actor_user_id: str | None
changes: dict | None
message: str | None
created_at: datetime
class SalesProcessCreate(BaseModel):
name: str = Field(max_length=255)
code: str = Field(max_length=50)
description: str | None = None
pipeline_id: UUID | None = None
class SalesProcessRead(ORMBase):
id: UUID
tenant_id: UUID
name: str
code: str
description: str | None
pipeline_id: UUID | None
is_active: bool
created_at: datetime
updated_at: datetime