feat(platform): close AI expense value loop
Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
This commit is contained in:
335
server/src/app/schemas/savings.py
Normal file
335
server/src/app/schemas/savings.py
Normal file
@@ -0,0 +1,335 @@
|
||||
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
|
||||
Reference in New Issue
Block a user