PayrollManagementSystem/backend/app/schemas/tplus_workbench.py
liuwenxin 108bf87225 feat(核算)!: 添加 T+ 正式核算与采集托管
补齐源快照、资料预演、正式导入及工资核算的依赖链路。
新增版本化输入、逐段 FIFO、生成锁和结构化当月调整,
并提供默认关闭的周期采集、有限重试及 Windows 托管。

原始客户资料、私有配置及首页改动不纳入本次提交。

BREAKING CHANGE: 升级前需应用新增迁移,并通过
PAYROLL_MAKER_USER_ID 绑定实际工资制作人,否则业务只读。
旧单批次核算默认关闭;历史兼容需显式启用
PAYROLL_ALLOW_LEGACY_CALCULATION。生产启用和真实金额待验收。
2026-11-02 09:42:53 +08:00

254 lines
9.9 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 typing import Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.core.response import PageQuery
class FactData(BaseModel):
"""不同类型通过事实入口分别校验,保留未确定值而不猜默认口径。"""
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
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]
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(min_length=1, 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)
@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
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)