PayrollManagementSystem/backend/app/schemas/tplus_workbench.py
liuwenxin 1e53792ad6 feat(T+): 完成数据贯通与自动接入闭环
打通 T+ 快照、业务、发货、回款和核算准备链路,保留来源差异并提供待补状态。
新增业务自动接入任务、跨平台读取服务托管、权限复核、来源关联和前端核对页面。
补充迁移、隔离集成测试、浏览器模拟验收及角色授权边界修复。
2026-11-05 18:47:13 +08:00

308 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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