from __future__ import annotations from datetime import date, datetime from decimal import Decimal from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator SavingsValueType = Literal["cash", "labor"] SavingsOpportunityStatus = Literal[ "identified", "accepted", "in_progress", "realized", "verified", "reversed", "rejected", "expired", ] SavingsOpportunityAction = Literal["accept", "start", "reject", "expire"] SavingsOpportunityAvailableAction = Literal[ "accept", "start", "reject", "expire", "record_realization", ] SavingsRealizationAction = Literal["confirm", "reject", "reverse"] class ProfileBaselineSnapshotRead(BaseModel): model_config = ConfigDict(from_attributes=True) id: str tenant_id: str baseline_key: str baseline_type: Literal["historical_cohort", "policy_counterfactual", "manual"] dimension_type: str dimension_id: str metric_key: str unit: str original_currency: str | None = None baseline_value: Decimal window_start: datetime | None = None window_end: datetime | None = None sample_count: int method: str query_fingerprint: str data_quality_status: Literal["complete", "partial", "insufficient", "invalid"] data_quality_score: Decimal quality_issues_json: list[dict[str, Any]] = Field(default_factory=list) algorithm_version: str policy_version: str | None = None policy_effective_from: date | None = None policy_effective_to: date | None = None target_resource_type: str | None = None target_resource_id: str | None = None frozen_at: datetime frozen_by: str valid_until: datetime | None = None version: int created_at: datetime class SavingsEvidenceLinkRead(BaseModel): model_config = ConfigDict(from_attributes=True) id: str tenant_id: str evidence_key: str entity_type: Literal["baseline", "opportunity", "realization"] entity_id: str evidence_role: str resource_type: str resource_id: str source_system: str external_event_id: str | None = None content_hash: str occurred_at: datetime collected_at: datetime verification_status: Literal["unverified", "verified", "rejected", "unavailable"] verified_by: str | None = None verified_at: datetime | None = None metadata_json: dict[str, Any] = Field(default_factory=dict) created_at: datetime class SavingsEventRead(BaseModel): model_config = ConfigDict(from_attributes=True) id: str tenant_id: str aggregate_type: Literal["baseline", "opportunity", "realization"] aggregate_id: str action: str actor_id: str actor_name: str actor_type: Literal["user", "system", "agent", "service"] request_id: str expected_version: int result_version: int payload_fingerprint: str payload_json: dict[str, Any] = Field(default_factory=dict) before_json: dict[str, Any] = Field(default_factory=dict) after_json: dict[str, Any] = Field(default_factory=dict) response_json: dict[str, Any] = Field(default_factory=dict) correlation_id: str | None = None causation_id: str | None = None occurred_at: datetime class SavingsRealizationRead(BaseModel): model_config = ConfigDict(from_attributes=True) id: str tenant_id: str realization_key: str opportunity_id: str expense_case_id: str claim_id: str claim_item_id: str | None = None business_event_id: str | None = None realization_type: Literal["actual", "reversal"] reversal_of_realization_id: str | None = None realized_at: datetime recorded_by_id: str recorded_by_name: str actual_gross: Decimal incremental_cost: Decimal actual_net: Decimal original_currency: str reporting_amount: Decimal reporting_currency: str fx_rate: Decimal fx_source: str fx_date: date fx_version: str attribution_method: str attribution_ratio: Decimal benefit_key: str dedupe_status: Literal["pending_review", "canonical", "duplicate", "excluded"] canonical_realization_id: str | None = None status: Literal["pending_confirmation", "finance_confirmed", "rejected", "reversed"] finance_confirmer_id: str | None = None finance_confirmer_name: str | None = None confirmed_at: datetime | None = None confirmation_note: str | None = None rejected_by_id: str | None = None rejected_by_name: str | None = None rejected_at: datetime | None = None rejection_reason: str | None = None reversed_by_id: str | None = None reversed_by_name: str | None = None reversed_at: datetime | None = None reversal_reason: str | None = None baseline_snapshot_json: dict[str, Any] = Field(default_factory=dict) final_snapshot_json: dict[str, Any] = Field(default_factory=dict) evidence_json: list[dict[str, Any]] = Field(default_factory=list) version: int created_at: datetime updated_at: datetime available_actions: list[SavingsRealizationAction] = Field(default_factory=list) read_only_reason: str = "" class SavingsOpportunityRead(BaseModel): model_config = ConfigDict(from_attributes=True) id: str tenant_id: str opportunity_key: str expense_case_id: str claim_id: str claim_item_id: str | None = None discovery_business_event_id: str | None = None source_type: str source_id: str category: str claim_no_snapshot: str title: str description: str value_kind: SavingsValueType exposure_amount: Decimal baseline_snapshot_id: str baseline_amount: Decimal target_amount: Decimal estimated_gross: Decimal estimated_cost: Decimal estimated_net: Decimal estimated_low: Decimal estimated_high: Decimal confidence: Decimal currency: str reporting_currency: str attribution_method: str ai_decision_id: str | None = None owner_id: str owner_name: str owner_role: str due_at: datetime | None = None status: SavingsOpportunityStatus version: int benefit_key: str suggested_action: str dimension_json: dict[str, Any] = Field(default_factory=dict) baseline_snapshot_json: dict[str, Any] = Field(default_factory=dict) evidence_json: list[dict[str, Any]] = Field(default_factory=list) accepted_at: datetime | None = None started_at: datetime | None = None realized_at: datetime | None = None verified_at: datetime | None = None closed_at: datetime | None = None created_at: datetime updated_at: datetime available_actions: list[SavingsOpportunityAvailableAction] = Field(default_factory=list) read_only_reason: str = "" baseline: ProfileBaselineSnapshotRead | None = None realizations: list[SavingsRealizationRead] = Field(default_factory=list) evidence: list[SavingsEvidenceLinkRead] = Field(default_factory=list) events: list[SavingsEventRead] = Field(default_factory=list) class SavingsOpportunityListRead(BaseModel): items: list[SavingsOpportunityRead] = Field(default_factory=list) total: int = 0 page: int = 1 page_size: int = 20 total_pages: int = 0 generated_at: datetime class SavingsActionBase(BaseModel): request_id: str = Field(min_length=8, max_length=120) expected_version: int = Field(ge=1) comment: str = Field(min_length=2, max_length=1000) @field_validator("request_id", "comment", mode="before") @classmethod def normalize_text(cls, value: Any) -> str: return str(value or "").strip() class SavingsOpportunityActionCreate(SavingsActionBase): action: SavingsOpportunityAction class SavingsEvidenceCreate(BaseModel): evidence_key: str = Field(min_length=1, max_length=160) evidence_role: str = Field(min_length=1, max_length=50) resource_type: str = Field(min_length=1, max_length=50) resource_id: str = Field(min_length=1, max_length=160) source_system: str = Field(min_length=1, max_length=60) external_event_id: str | None = Field(default=None, max_length=160) content_hash: str = Field(min_length=16, max_length=80) occurred_at: datetime verification_status: Literal["unverified", "unavailable"] = "unverified" metadata_json: dict[str, Any] = Field(default_factory=dict) @field_validator( "evidence_key", "evidence_role", "resource_type", "resource_id", "source_system", "external_event_id", "content_hash", mode="before", ) @classmethod def normalize_optional_text(cls, value: Any) -> Any: if value is None: return None normalized = str(value).strip() return normalized or None class SavingsRealizationCreate(SavingsActionBase): actual_gross: Decimal = Field(gt=0, max_digits=16, decimal_places=2) incremental_cost: Decimal = Field( default=Decimal("0.00"), ge=0, max_digits=16, decimal_places=2, ) currency: str = Field(default="CNY", min_length=3, max_length=10) realized_at: datetime attribution_method: str = Field(min_length=2, max_length=80) attribution_ratio: Decimal = Field(default=Decimal("1.0000"), gt=0, le=1) evidence_level: Literal["business_state", "external_document", "external_verified"] evidence: list[SavingsEvidenceCreate] = Field(default_factory=list, max_length=30) @field_validator("currency", mode="before") @classmethod def normalize_currency(cls, value: Any) -> str: return str(value or "CNY").strip().upper() @field_validator("attribution_method", mode="before") @classmethod def normalize_method(cls, value: Any) -> str: return str(value or "").strip() @model_validator(mode="after") def require_result_evidence(self) -> SavingsRealizationCreate: if not self.evidence: raise ValueError("实际节省结果必须至少提供一条可追溯证据。") if self.incremental_cost > self.actual_gross: raise ValueError("执行成本不能高于实际毛节省。") return self class SavingsRealizationActionCreate(SavingsActionBase): action: SavingsRealizationAction reversal_amount: Decimal | None = Field(default=None, gt=0, max_digits=16, decimal_places=2) evidence: list[SavingsEvidenceCreate] = Field(default_factory=list, max_length=30) @model_validator(mode="after") def validate_action_payload(self) -> SavingsRealizationActionCreate: if self.action == "reverse" and self.reversal_amount is None: raise ValueError("冲回动作必须提供冲回金额。") if self.action != "reverse" and self.reversal_amount is not None: raise ValueError("只有冲回动作可以提供冲回金额。") return self class SavingsOpportunityMutationRead(BaseModel): opportunity: SavingsOpportunityRead event: SavingsEventRead replayed: bool = False class SavingsRealizationMutationRead(BaseModel): realization: SavingsRealizationRead opportunity: SavingsOpportunityRead event: SavingsEventRead replayed: bool = False