"""基础导入与试算入口:补充事实必须给出依据,金额只接受字符串。""" from datetime import date from decimal import Decimal, InvalidOperation from itertools import pairwise from typing import Literal from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from app.core.month import validate_month from app.core.response import PageQuery class FactData(BaseModel): """不同类型通过事实入口分别校验,保留未确定值而不猜默认口径。""" model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) class RolePeriod(FactData): """客户只确认到月份时保留月份精度,区间含首尾月份。""" start_month: str end_month: str role_code: Literal["salesperson", "sales_manager"] @field_validator("start_month", "end_month") @classmethod def check_month(cls, value): return validate_month(value) @model_validator(mode="after") def check_range(self): if self.end_month < self.start_month: raise ValueError("岗位结束月份不能早于开始月份") return self class PersonFact(FactData): code: str = Field(min_length=1, max_length=64) name: str = Field(min_length=1, max_length=64) role_code: Literal["salesperson", "sales_manager"] status: Literal[0, 1] role_history: list[RolePeriod] = Field(default_factory=list, max_length=120) @model_validator(mode="after") def check_role_history(self): periods = sorted(self.role_history, key=lambda period: period.start_month) if any(left.end_month >= right.start_month for left, right in pairwise(periods)): raise ValueError("历史岗位月份不能重叠") self.role_history = periods return self class ProductFact(FactData): category: Literal["municipal_water", "rail_transit"] unit: str = Field(min_length=1, max_length=16) class CustomerFact(FactData): customer_type: Literal["personal", "manager_personal", "enterprise"] owner_name: str = Field(default="", max_length=64) class GroupFact(FactData): customer_id: str = Field(pattern=r"^[0-9]+$", max_length=64) shipment_ids: list[str] = Field(default_factory=list, max_length=10000) receipt_ids: list[str] = Field(default_factory=list, max_length=10000) scope_confirmed: bool = False opening_confirmed: bool = False opening_balance: str | None = None historical_settlement_confirmed: bool = False excluded_receipt_ids: list[str] = Field(default_factory=list, max_length=10000) guidance_month_basis: Literal["SHIPMENT", "RECEIPT"] | None = "SHIPMENT" prepayment_parameter_basis: Literal["SHIPMENT", "RECEIPT"] | None = None august_confirmed: bool = False @model_validator(mode="after") def check_group(self): """只允许显式数字源ID,不能通过组成员重叠重复消耗资金。""" for values in (self.shipment_ids, self.receipt_ids, self.excluded_receipt_ids): if len(set(values)) != len(values) or any( not v.isascii() or not v.isdigit() for v in values ): raise ValueError("平账组成员必须是无重复的源数字ID") if set(self.excluded_receipt_ids) - set(self.receipt_ids): raise ValueError("历史已结算款必须属于本组所选回款") if self.opening_balance is not None: _decimal(self.opening_balance, 2) if self.opening_confirmed and self.opening_balance is None: raise ValueError("确认期初应收时必须填写余额,已核实无应收请填0") return self def _decimal(value: str, scale: int): try: amount = Decimal(value) except InvalidOperation: raise ValueError("金额格式不正确") from None if not amount.is_finite() or amount < 0 or amount > Decimal("999999999999"): raise ValueError("金额必须是有效的非负数") if amount.as_tuple().exponent < -scale: raise ValueError(f"最多保留{scale}位小数") return amount class GuidanceFact(FactData): product_id: str = Field(pattern=r"^[0-9]+$", max_length=64) month: str = Field(pattern=r"^20[0-9]{2}-(0[1-9]|1[0-2])$") basis: Literal["UNIT", "LINE_TOTAL"] amount: str unit: str = Field(min_length=1, max_length=16) shipment_line_id: str | None = None is_finalized: bool = False @model_validator(mode="after") def check_amount(self): _decimal(self.amount, 4 if self.basis == "UNIT" else 2) if self.basis == "LINE_TOTAL" and not self.shipment_line_id: raise ValueError("明细总成本必须指定发货明细ID") return self class ReceiptFact(FactData): voucher_confirmed: bool voucher_reference: str = Field(min_length=1, max_length=128) class ShipmentFact(FactData): """运费是否已剔除须逐明细确认,不将未知运费当作0。""" sales_amount_excludes_freight: bool freight_amount: str | None = None business_key: str = Field(min_length=1, max_length=64) personnel_id: str = Field(min_length=1, max_length=64) date_override: date | None = None @model_validator(mode="after") def check_date_rule(self): if self.freight_amount is not None: freight = _decimal(self.freight_amount, 2) if self.sales_amount_excludes_freight and freight != 0: raise ValueError("已剔除运费的销售额不能再次扣除运费") if self.date_override is not None: raise ValueError("日期更正规则尚未确认,本轮只采用源销货单日期") return self class HandoverFact(FactData): business_key: str = Field(min_length=1, max_length=64) mode: Literal["RESIGNATION", "ACTIVE"] from_personnel_id: str = Field(min_length=1, max_length=64) to_personnel_id: str = Field(min_length=1, max_length=64) effective_date: date shipping_ownership_confirmed: bool = False class ManagerFact(FactData): business_key: str = Field(min_length=1, max_length=64) manager_id: str = Field(min_length=1, max_length=64) effective_date: date class CorrectionFact(FactData): """已核对的 T+ 更正单处理事实。 更正单仍保留在源快照中;绩效核算只消费人工明确排除的发货, 跨客户回款则通过 redirect_business_key 归入原业务,避免在新客户下重复计提。 """ entity: Literal["SHIPMENT", "RECEIPT"] pair_key: str = Field(min_length=1, max_length=128) exclude_performance: bool = False redirect_business_key: str | None = Field(default=None, max_length=128) paired_source_id: str | None = Field(default=None, pattern=r"^[0-9]+$", max_length=64) source_hash: str | None = Field(default=None, min_length=64, max_length=64) paired_hash: str | None = Field(default=None, min_length=64, max_length=64) details_hash: str | None = Field(default=None, min_length=64, max_length=64) @model_validator(mode="after") def check_correction(self): if self.entity == "SHIPMENT" and not self.exclude_performance: raise ValueError("更正发货必须明确排除绩效核算") if self.entity == "RECEIPT" and not self.redirect_business_key: raise ValueError("跨客户回款必须指定归入的原业务标识") if self.entity == "SHIPMENT" and self.redirect_business_key: raise ValueError("更正发货不能通过回款归属字段转移业务") return self class CorrectionPairIn(FactData): """一次登记完整更正对及实际回款,不能单边排除。""" negative_shipment_id: str = Field(pattern=r"^[0-9]+$", max_length=64) positive_shipment_id: str = Field(pattern=r"^[0-9]+$", max_length=64) receipt_ids: list[str] = Field(default_factory=list, max_length=100) target_group_key: str = Field(min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_.:-]+$") evidence: str = Field(min_length=1, max_length=500) @model_validator(mode="after") def check_members(self): if self.negative_shipment_id == self.positive_shipment_id: raise ValueError("正负更正单不能为同一张单据") if len(set(self.receipt_ids)) != len(self.receipt_ids) or any( not v.isascii() or not v.isdigit() for v in self.receipt_ids ): raise ValueError("实际回款必须使用不重复的源数字标识") return self FACT_TYPES = { "PERSONNEL": PersonFact, "PRODUCT": ProductFact, "CUSTOMER": CustomerFact, "GROUP": GroupFact, "GUIDANCE": GuidanceFact, "RECEIPT": ReceiptFact, "SHIPMENT": ShipmentFact, "HANDOVER": HandoverFact, "MANAGER": ManagerFact, "CORRECTION": CorrectionFact, } class FactIn(BaseModel): """追加资料版本;expected_version=0 表示首次录入。""" model_config = ConfigDict(extra="forbid") kind: Literal[ "PERSONNEL", "PRODUCT", "CUSTOMER", "GROUP", "GUIDANCE", "RECEIPT", "SHIPMENT", "HANDOVER", "MANAGER", "CORRECTION", ] fact_key: str = Field(min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_.:-]+$") expected_version: int = Field(default=0, ge=0) evidence: str = Field(min_length=1, max_length=500) data: dict @model_validator(mode="after") def validate_data(self): self.evidence = self.evidence.strip() if not self.evidence: raise ValueError("确认依据不能为空") self.data = FACT_TYPES[self.kind].model_validate(self.data).model_dump(mode="json") return self class WorkCreateIn(BaseModel): """创建异步工作;所有参数固定后再排队,重试须使用新的请求键。""" model_config = ConfigDict(extra="forbid") kind: Literal["MASTER_PREVIEW", "MASTER_APPLY", "CALC_PREVIEW", "BUSINESS_PREVIEW", "BUSINESS_APPLY"] request_key: UUID entity_types: list[Literal["personnel", "products", "customers"]] = Field(default_factory=list) source_ids: list[str] = Field(default_factory=list, max_length=10000) preview_no: str | None = Field(default=None, max_length=32, pattern=r"^[A-Za-z0-9_-]+$") replay_no: str | None = Field(default=None, max_length=32, pattern=r"^[A-Za-z0-9_-]+$") group_keys: list[str] = Field(default_factory=list, max_length=100) month: str | None = Field(default=None, pattern=r"^20[0-9]{2}-(0[1-9]|1[0-2])$") @model_validator(mode="after") def check_intent(self): if self.kind == "MASTER_PREVIEW" and not self.entity_types: raise ValueError("请选择基础数据类型") if self.kind == "MASTER_APPLY" and not self.preview_no: raise ValueError("执行导入前必须完成预览") if self.kind == "BUSINESS_APPLY" and not self.preview_no: raise ValueError("正式业务导入必须先预览") if self.kind == "BUSINESS_PREVIEW" and (not self.month or not self.group_keys): raise ValueError("请选择正式导入月份及平账组") if self.replay_no and (self.kind != "CALC_PREVIEW" or self.month or self.group_keys): raise ValueError("冻结重跑仅适用于核算预演,不能同时指定新月份或平账组") if ( self.kind == "CALC_PREVIEW" and not self.replay_no and (not self.month or not self.group_keys) ): raise ValueError("请选择月份及已登记的平账组") if len(set(self.entity_types)) != len(self.entity_types) or len( set(self.group_keys) ) != len(self.group_keys): raise ValueError("请求范围不能重复") if any(not v.isascii() or not v.isdigit() for v in self.source_ids): raise ValueError("源ID格式不正确") return self class WorkQuery(PageQuery): """运行、资料和明细共用的有界分页参数。""" kind: str | None = Field(default=None, max_length=32) action: str | None = Field(default=None, max_length=32) class SourceChoiceQuery(PageQuery): """将筛选与分页一起声明,保证查询参数使用同一扁平格式。""" entity: Literal["personnel", "products", "customers", "shipments", "shipment_items", "receipts"] search: str = Field(default="", max_length=64)