336 lines
11 KiB
Python
336 lines
11 KiB
Python
|
|
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
|