PayrollManagementSystem/backend/app/schemas/tplus_workbench.py

308 lines
12 KiB
Python
Raw Normal View History

"""基础导入与试算入口:补充事实必须给出依据,金额只接受字符串。"""
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)