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>
535 lines
14 KiB
Python
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
|