"""Phase 6.2 — Enterprise Sales Process Engine models.""" from __future__ import annotations import uuid from datetime import date, datetime from decimal import Decimal from sqlalchemy import ( Boolean, Date, DateTime, Index, Integer, Numeric, String, Text, UniqueConstraint, ) from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.types import JSON from app.core.database import Base from app.models.base import ( ActorAuditMixin, SoftDeleteMixin, TenantMixin, TimestampMixin, UUIDPrimaryKeyMixin, ) from app.models.types import ( ContactRoleType, ForecastKind, ForecastPeriod, GoalMetric, GoalScope, GUID, PlaybookAssignmentStatus, PlaybookStepKind, TargetPeriod, WinLossOutcome, ) class SalesProcess(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin): """Named sales process definition optionally bound to a pipeline.""" __tablename__ = "sales_processes" name: Mapped[str] = mapped_column(String(255), nullable=False) code: Mapped[str] = mapped_column(String(50), nullable=False) description: Mapped[str | None] = mapped_column(Text, nullable=True) pipeline_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) __table_args__ = ( UniqueConstraint("tenant_id", "code", name="uq_sales_processes_tenant_code"), ) class SalesStageHistory(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin): __tablename__ = "sales_stage_history" opportunity_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) previous_stage_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) next_stage_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) changed_by: Mapped[str | None] = mapped_column(String(100), nullable=True) changed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) duration_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True) reason: Mapped[str | None] = mapped_column(Text, nullable=True) __table_args__ = ( Index("ix_sales_stage_history_opp", "tenant_id", "opportunity_id"), ) class OpportunityProduct(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin): """Product line on opportunity — references Product Service only (no inventory).""" __tablename__ = "opportunity_products" opportunity_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) product_ref_id: Mapped[str] = mapped_column(String(100), nullable=False) product_name: Mapped[str | None] = mapped_column(String(255), nullable=True) quantity: Mapped[Decimal] = mapped_column( Numeric(18, 4), default=Decimal("1"), nullable=False ) unit_price: Mapped[Decimal] = mapped_column( Numeric(18, 4), default=Decimal("0"), nullable=False ) discount_percent: Mapped[Decimal] = mapped_column( Numeric(8, 4), default=Decimal("0"), nullable=False ) expected_revenue: Mapped[Decimal] = mapped_column( Numeric(18, 4), default=Decimal("0"), nullable=False ) __table_args__ = ( Index("ix_opportunity_products_opp", "tenant_id", "opportunity_id"), ) class OpportunityContact(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin): __tablename__ = "opportunity_contacts" opportunity_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) contact_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) role: Mapped[ContactRoleType] = mapped_column( default=ContactRoleType.INFLUENCER, nullable=False ) custom_role_label: Mapped[str | None] = mapped_column(String(100), nullable=True) is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) __table_args__ = ( UniqueConstraint( "tenant_id", "opportunity_id", "contact_id", "role", name="uq_opportunity_contacts_role", ), Index("ix_opportunity_contacts_opp", "tenant_id", "opportunity_id"), ) class OpportunityCompetitor(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin): __tablename__ = "opportunity_competitors" opportunity_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) competitor_name: Mapped[str] = mapped_column(String(255), nullable=False) strength: Mapped[str | None] = mapped_column(Text, nullable=True) weakness: Mapped[str | None] = mapped_column(Text, nullable=True) notes: Mapped[str | None] = mapped_column(Text, nullable=True) win_probability: Mapped[int] = mapped_column(Integer, default=0, nullable=False) __table_args__ = ( Index("ix_opportunity_competitors_opp", "tenant_id", "opportunity_id"), ) class SalesPlaybook( Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin, SoftDeleteMixin, ActorAuditMixin, ): __tablename__ = "sales_playbooks" name: Mapped[str] = mapped_column(String(255), nullable=False) code: Mapped[str] = mapped_column(String(50), nullable=False) description: Mapped[str | None] = mapped_column(Text, nullable=True) version: Mapped[int] = mapped_column(Integer, default=1, nullable=False) pipeline_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) __table_args__ = ( UniqueConstraint( "tenant_id", "code", "version", name="uq_sales_playbooks_code_version" ), ) class SalesPlaybookStep(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin): __tablename__ = "sales_playbook_steps" playbook_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) title: Mapped[str] = mapped_column(String(255), nullable=False) step_kind: Mapped[PlaybookStepKind] = mapped_column(nullable=False) position: Mapped[int] = mapped_column(Integer, default=0, nullable=False) is_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) checklist_items: Mapped[list | None] = mapped_column(JSON, nullable=True) template_body: Mapped[str | None] = mapped_column(Text, nullable=True) __table_args__ = ( Index("ix_sales_playbook_steps_playbook", "tenant_id", "playbook_id"), ) class SalesPlaybookAssignment(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin): __tablename__ = "sales_playbook_assignments" playbook_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) opportunity_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) pipeline_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) status: Mapped[PlaybookAssignmentStatus] = mapped_column( default=PlaybookAssignmentStatus.ASSIGNED, nullable=False ) assigned_by: Mapped[str | None] = mapped_column(String(100), nullable=True) completed_steps: Mapped[list | None] = mapped_column(JSON, nullable=True) completion_percent: Mapped[int] = mapped_column(Integer, default=0, nullable=False) completed_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) __table_args__ = ( Index("ix_sales_playbook_assignments_opp", "tenant_id", "opportunity_id"), Index("ix_sales_playbook_assignments_pb", "tenant_id", "playbook_id"), ) class WinLossCategory(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin): __tablename__ = "win_loss_categories" code: Mapped[str] = mapped_column(String(50), nullable=False) name: Mapped[str] = mapped_column(String(150), nullable=False) parent_category_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) __table_args__ = ( UniqueConstraint( "tenant_id", "code", name="uq_win_loss_categories_tenant_code" ), ) class WinLossReason(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin): __tablename__ = "win_loss_reasons" code: Mapped[str] = mapped_column(String(50), nullable=False) name: Mapped[str] = mapped_column(String(150), nullable=False) outcome: Mapped[WinLossOutcome] = mapped_column(nullable=False) category_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) __table_args__ = ( UniqueConstraint("tenant_id", "code", name="uq_win_loss_reasons_tenant_code"), ) class OpportunityWinLoss(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin): """Immutable win/loss analysis snapshot after opportunity close.""" __tablename__ = "opportunity_win_loss" opportunity_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) outcome: Mapped[WinLossOutcome] = mapped_column(nullable=False) reason_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) category_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) sub_category: Mapped[str | None] = mapped_column(String(150), nullable=True) root_cause: Mapped[str | None] = mapped_column(Text, nullable=True) competitor_name: Mapped[str | None] = mapped_column(String(255), nullable=True) price_factor: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) feature_gap: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) timing_factor: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) relationship_factor: Mapped[bool] = mapped_column( Boolean, default=False, nullable=False ) approval_factor: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) custom_reason: Mapped[str | None] = mapped_column(Text, nullable=True) recorded_by: Mapped[str | None] = mapped_column(String(100), nullable=True) recorded_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False ) is_locked: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) __table_args__ = ( UniqueConstraint( "tenant_id", "opportunity_id", name="uq_opportunity_win_loss_opp" ), ) class SalesForecast(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin): __tablename__ = "sales_forecasts" name: Mapped[str] = mapped_column(String(255), nullable=False) kind: Mapped[ForecastKind] = mapped_column(nullable=False) period: Mapped[ForecastPeriod] = mapped_column(nullable=False) period_start: Mapped[date] = mapped_column(Date, nullable=False) period_end: Mapped[date] = mapped_column(Date, nullable=False) pipeline_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) owner_user_id: Mapped[str | None] = mapped_column(String(100), nullable=True) pipeline_amount: Mapped[Decimal] = mapped_column( Numeric(18, 4), default=Decimal("0"), nullable=False ) weighted_amount: Mapped[Decimal] = mapped_column( Numeric(18, 4), default=Decimal("0"), nullable=False ) committed_amount: Mapped[Decimal] = mapped_column( Numeric(18, 4), default=Decimal("0"), nullable=False ) currency_code: Mapped[str] = mapped_column(String(3), default="IRR", nullable=False) opportunity_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) __table_args__ = ( Index("ix_sales_forecasts_period", "tenant_id", "period_start", "period_end"), ) class SalesGoal(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin): __tablename__ = "sales_goals" name: Mapped[str] = mapped_column(String(255), nullable=False) scope: Mapped[GoalScope] = mapped_column(nullable=False) metric: Mapped[GoalMetric] = mapped_column(nullable=False) owner_user_id: Mapped[str | None] = mapped_column(String(100), nullable=True) team_key: Mapped[str | None] = mapped_column(String(100), nullable=True) department_key: Mapped[str | None] = mapped_column(String(100), nullable=True) target_value: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False) period_start: Mapped[date] = mapped_column(Date, nullable=False) period_end: Mapped[date] = mapped_column(Date, nullable=False) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) __table_args__ = ( Index("ix_sales_goals_owner", "tenant_id", "owner_user_id"), ) class SalesTarget(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin): __tablename__ = "sales_targets" name: Mapped[str] = mapped_column(String(255), nullable=False) period: Mapped[TargetPeriod] = mapped_column(nullable=False) period_start: Mapped[date] = mapped_column(Date, nullable=False) period_end: Mapped[date] = mapped_column(Date, nullable=False) goal_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) owner_user_id: Mapped[str | None] = mapped_column(String(100), nullable=True) target_value: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False) actual_value: Mapped[Decimal] = mapped_column( Numeric(18, 4), default=Decimal("0"), nullable=False ) achievement_percent: Mapped[Decimal] = mapped_column( Numeric(8, 4), default=Decimal("0"), nullable=False ) __table_args__ = ( Index("ix_sales_targets_period", "tenant_id", "period_start", "period_end"), ) class SalesAudit(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin): """Sales-engine audit trail (distinct from generic crm_audit).""" __tablename__ = "sales_audit" entity_type: Mapped[str] = mapped_column(String(50), nullable=False) entity_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) action: Mapped[str] = mapped_column(String(50), nullable=False) actor_user_id: Mapped[str | None] = mapped_column(String(100), nullable=True) changes: Mapped[dict | None] = mapped_column(JSON, nullable=True) message: Mapped[str | None] = mapped_column(Text, nullable=True) __table_args__ = ( Index("ix_sales_audit_entity", "tenant_id", "entity_type", "entity_id"), )