feat(核算)!: 添加 T+ 正式核算与采集托管

补齐源快照、资料预演、正式导入及工资核算的依赖链路。
新增版本化输入、逐段 FIFO、生成锁和结构化当月调整,
并提供默认关闭的周期采集、有限重试及 Windows 托管。

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

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

No files matched your search

+2
View File
@@ -56,6 +56,8 @@ class UserBrief(BaseModel):
username: str
real_name: str | None = None
roles: list[str] = Field(default_factory=list, description="角色编码列表")
can_write_payroll: bool = False
payroll_approval_enabled: bool = False
class LoginOut(BaseModel):
+2
View File
@@ -250,6 +250,8 @@ class BusinessCreateIn(BaseModel):
class BusinessUpdateIn(BaseModel):
"""编辑业务(全量替换明细与二次分配方案;归属不在此端点调整,走 /ownership)。"""
model_config = ConfigDict(str_strip_whitespace=True)
customer_id: int = Field(ge=1, description="客户ID")
+59
View File
@@ -0,0 +1,59 @@
"""正式工资核算的最小输入契约;金额使用字符串,资料版本显式校验。"""
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.month import validate_month
from app.schemas.tplus_workbench import HandoverFact, _decimal
class PayrollMonthIn(BaseModel):
month: str
reason: str | None = Field(default=None, min_length=1, max_length=255)
@field_validator("month")
@classmethod
def check_month(cls, value):
return validate_month(value)
class ShipmentOwnershipIn(BaseModel):
model_config = ConfigDict(extra="forbid")
shipment_item_id: int = Field(ge=1)
personnel_id: int = Field(ge=1)
manager_id: int | None = Field(default=None, ge=1)
unit_cost: str
@field_validator("unit_cost")
@classmethod
def check_cost(cls, value):
_decimal(value, 4)
return value
class PayrollSetupIn(PayrollMonthIn):
model_config = ConfigDict(extra="forbid")
expected_version: int = Field(default=0, ge=0)
evidence: str = Field(min_length=1, max_length=500)
opening_balance: str
history_confirmed: Literal[True]
guidance_finalized: Literal[True]
shipment_ownerships: list[ShipmentOwnershipIn] = Field(min_length=1, max_length=10000)
handovers: list[HandoverFact] = Field(default_factory=list, max_length=1000)
@field_validator("opening_balance")
@classmethod
def check_opening(cls, value):
_decimal(value, 2)
return value
class AllocationAdjustmentIn(BaseModel):
"""分配差额仅允许归生成当月;关联已结算原台账留痕。"""
ledger_id: int = Field(ge=1)
personnel_id: int = Field(ge=1)
direction: Literal[1, 2]
amount: str = Field(pattern=r"^\d{1,12}(\.\d{1,2})?$")
reason: str = Field(min_length=1, max_length=255)
+2
View File
@@ -62,6 +62,7 @@ class SettlementOut(BaseModel):
total_payable: Decimal | None = None
person_count: int
approval_order_no: str | None = None
approval_required: bool = False
submitter_name: str
locked_at: datetime | datetime | None = None
locked_by: str | None = None
@@ -89,6 +90,7 @@ class AdjustmentCreateIn(BaseModel):
description="金额(元,两位小数,恒正)",
)
reason: str = Field(min_length=1, max_length=255, description="扣补说明")
source_ledger_id: int | None = Field(default=None, ge=1, description="分配差额所对应的已生成工资台账")
@field_validator("settlement_month")
@classmethod
+22
View File
@@ -77,6 +77,8 @@ class TPlusBatchOut(BaseModel):
finished_at: datetime | None
can_retry: bool
error_summary: str | None = None
trigger_type: str = "MANUAL"
automatic_attempt: int = 0
class TPlusBatchDetailOut(TPlusBatchOut):
@@ -97,6 +99,25 @@ class TPlusErrorOut(BaseModel):
create_time: datetime
class TPlusAutomationAlert(BaseModel):
code: str
message: str
batch_no: str | None = None
class TPlusAutomationOverview(BaseModel):
enabled: bool = False
weekday: int = 0
time: str = "02:00"
timezone: str = "Asia/Shanghai"
worker_phase: str = "UNKNOWN"
heartbeat_at: datetime | None = None
last_success_at: datetime | None = None
next_retry_at: datetime | None = None
stop_requested: bool = False
alerts: list[TPlusAutomationAlert] = Field(default_factory=list)
class TPlusOverviewOut(BaseModel):
"""配置就绪不等同于源库连通或兼容验收通过。"""
@@ -107,3 +128,4 @@ class TPlusOverviewOut(BaseModel):
compatibility_status: str = "PENDING"
business_import_enabled: bool = False
latest_audit: dict | None = None
automation: TPlusAutomationOverview = Field(default_factory=TPlusAutomationOverview)
+253
View File
@@ -0,0 +1,253 @@
"""基础导入与试算入口:补充事实必须给出依据,金额只接受字符串。"""
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)