打通 T+ 快照、业务、发货、回款和核算准备链路,保留来源差异并提供待补状态。 新增业务自动接入任务、跨平台读取服务托管、权限复核、来源关联和前端核对页面。 补充迁移、隔离集成测试、浏览器模拟验收及角色授权边界修复。
308 lines
12 KiB
Python
308 lines
12 KiB
Python
"""基础导入与试算入口:补充事实必须给出依据,金额只接受字符串。"""
|
||
|
||
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)
|