PayrollManagementSystem/backend/app/schemas/business.py
liuwenxin 4c32a556de feat(approval): 审批单内容快照与二次分配方案版本化
补齐 code-review 遗留 2 项:审批单此前只固化 ref_id/refer_no 指针,复核者看到
的是业务当前内容;二次分配方案在编辑替换与驳回清空时一律物理删除,方案事实
无处回溯。

- 审批单加 version_no 与 snapshot_json(信封 schema_version/order_type/
  captured_at/data);二次分配、月结、调整单三类提交均固化送审内容,开关关闭
  的自动通过路径同样写快照;详情按 snapshot/live/missing 三态回显,历史单
  只读回退当前内容并显式告警,不回填历史快照
- 二次分配方案行改 append-only 版本化(version_no/is_active,唯一键扩为
  business+version+personnel):编辑替换与驳回清空只置历史,核算与详情默认
  只取生效行,业务详情新增 allocation_history
- 送审内容未变不重提、方案未变不新增版本:判据统一为送审内容,两个
  version_no 语义解耦(方案版本=内容第几版,审批单版本=第几次送审)
- 审批单列表、工作台待办、审批超时预警三处 defer 快照列,避免整列拉取
- 月结提交取行加 for_update,防并发提交算出重复的审批单版本号
- 修复审批类型筛选上界 le=2(调整单无法筛选)、快照金额把空值写成 0.00
- V11 迁移三件套同步(Alembic / db/migration / init-dev.sql)

测试:单元 +16(方案内容比较 10、审批入参 6)、集成 +12;全量 891 passed。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 18:30:51 +08:00

408 lines
16 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.

"""业务管理的请求/响应模型(需求 2.2.1/2.2.4)。
金额/数量入参出参一律 str(服务层 Decimal/Fraction,全程禁 float);
派生值(毛利润额/毛利润率/折算毛利润额)不在写接口入参内 —— 落库列只有
源数据,派生值由毛利润视图实时计算。
"""
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.core.constants import (
PRODUCT_CATEGORIES,
PRODUCT_CATEGORY_MUNICIPAL_WATER,
PRODUCT_CATEGORY_RAIL_TRANSIT,
)
from app.core.response import PageQuery
# DECIMAL(14,2) 容量上限:整数部分 12 位(同 personnel.py 的金额校验口径)
AMOUNT_MAX = Decimal("999999999999.99")
# DECIMAL(14,4) 容量上限:整数部分 10 位
PRICE_MAX = Decimal("9999999999.9999")
def _check_amount(
value: str | None,
label: str,
cap: Decimal,
allow_positive_only: bool = False,
scale: int = 2,
) -> str | None:
"""字符串金额的通用校验:数字/有限/非负/容量/小数位上限。
小数位超限直接拒绝而非静默舍入 —— 舍入即算错钱(工资系统红线)。
``scale`` 按列口径区分:金额/数量列 DECIMAL(14,2) 为 2;单价类列
DECIMAL(14,4) 为 4(回显 "5000.0000" 必须能原样提交,否则任何带
精度的历史单价都被编辑端点永远拒收)。
"""
if value is None:
return None
try:
amount = Decimal(value)
except InvalidOperation:
raise ValueError(f"{label}须为有效数字") from None
if not amount.is_finite():
raise ValueError(f"{label}须为有效数字")
if allow_positive_only:
if amount <= 0:
raise ValueError(f"{label}必须大于 0")
elif amount < 0:
raise ValueError(f"{label}不能为负数")
if amount > cap:
raise ValueError(f"{label}超出可录入上限")
if -amount.as_tuple().exponent > scale:
raise ValueError(f"{label}最多{'两' if scale == 2 else '四'}位小数")
return value
class BusinessItemIn(BaseModel):
"""业务产品明细行(需求 2.2.1「支持添加多种产品,录入指导价」)。
product_category 保留蛇形:该值与 M1 参数维度值域同源(枚举码本就是
蛇形);其余标识类字段驼峰兼容(snake/驼峰双入,同 customer.py 契约)。
"""
model_config = ConfigDict(str_strip_whitespace=True)
product_id: int | None = Field(default=None, ge=1, description="产品ID;空=自由录入")
product_name: str = Field(min_length=1, max_length=128, description="产品名称")
spec_model: str | None = Field(
default=None, max_length=128, description="规格型号,空=空串归一"
)
unit: str | None = Field(default=None, max_length=16, description="计量单位")
product_category: str = Field(
description="产品类别:municipal_water/rail_transit(行级折算锚点)"
)
quantity: str = Field(description="数量,>0")
sale_price: str | None = Field(default=None, description="销售单价(元)")
cost_amount: str | None = Field(
default=None, description="成本额(元);毛利润额 = 销售额 − 成本额"
)
guided_price: str | None = Field(default=None, description="指导价(元)")
remark: str | None = Field(default=None, max_length=255)
@field_validator("product_category")
@classmethod
def _check_category(cls, value: str) -> str:
if value not in PRODUCT_CATEGORIES:
raise ValueError("产品类别不合法")
return value
@field_validator("quantity")
@classmethod
def _check_quantity(cls, value: str) -> str:
return _check_amount(value, "数量", AMOUNT_MAX, allow_positive_only=True) or value
@field_validator("sale_price", "guided_price")
@classmethod
def _check_unit_prices(cls, value: str | None) -> str | None:
"""单价类字段按 DECIMAL(14,4) 口径校验(4 位小数)。"""
return _check_amount(value, "单价", PRICE_MAX, scale=4)
@field_validator("cost_amount")
@classmethod
def _check_cost(cls, value: str | None) -> str | None:
return _check_amount(value, "金额", AMOUNT_MAX)
class BusinessItemOut(BaseModel):
"""业务产品明细行响应。金额/数量一律 str。"""
model_config = ConfigDict(from_attributes=True)
id: int
product_id: int | None = None
product_name: str
spec_model: str | None = None
unit: str | None = None
product_category: str
quantity: str
sale_price: str | None = None
sale_amount: str
cost_amount: str | None = None
guided_price: str | None = None
remark: str | None = None
class BusinessOut(BaseModel):
"""业务响应(列表与写操作回包共用)。"""
model_config = ConfigDict(from_attributes=True)
id: int
business_no: str
customer_id: int
customer_name: str | None = None
business_date: date | None = None
status: int = 1
owner_personnel_id: int | None = None
owner_personnel_name: str | None = None
assistant_personnel_id: int | None = None
assistant_personnel_name: str | None = None
is_special: int = 0
special_note: str | None = None
total_sale_amount: str | None = None
total_cost_amount: str | None = None
total_ship_amount: str | None = None
total_received_amount: str | None = None
total_freight_amount: str | None = None
remark: str | None = None
class SecondaryAllocationIn(BaseModel):
"""二次分配参与人入参(2026-10-15 补充需求「二次分配方案」)。
比例**不要求合计 100%**(需求原文):余额归属待业务澄清(待澄清 #2),
服务层仅以 hint 展示合计/余额,不做强制校验。ratio 沿金额红线口径:
字符串入参、两位小数、超限拒绝不静默舍入。
"""
model_config = ConfigDict(str_strip_whitespace=True)
personnel_id: int = Field(ge=1, description="参与业务员ID(personnel.id)")
ratio: str = Field(description="分摊比例(0-100,两位小数)")
@field_validator("ratio")
@classmethod
def _check_ratio(cls, value: str) -> str:
return _check_amount(value, "分摊比例", Decimal("100"), allow_positive_only=True) or value
class SecondaryAllocationOut(BaseModel):
"""二次分配参与人响应(personnel_name 冗余固化,离职后回显有名)。"""
model_config = ConfigDict(from_attributes=True)
personnel_id: int
personnel_name: str
ratio: str
class AllocationVersionOut(SecondaryAllocationOut):
"""方案版本行(含历史;业务详情抽屉回溯用)。"""
version_no: int
is_active: int = Field(description="1当前生效 0历史版本")
create_time: datetime
def _ensure_allocations_unique(items: list["SecondaryAllocationIn"]) -> None:
"""同一业务内参与人去重:uk(business_id, personnel_id) 的前置友好提示
(让 400 文案说「重复参与人」而不是裸唯一键冲突)。"""
seen: set[int] = set()
for item in items:
if item.personnel_id in seen:
raise ValueError(f"二次分配参与人重复:人员ID {item.personnel_id}")
seen.add(item.personnel_id)
class BusinessDetailOut(BusinessOut):
"""业务详情:附产品明细行、当前方案与方案版本历史。"""
items: list[BusinessItemOut] = Field(default_factory=list)
secondary_allocations: list[SecondaryAllocationOut] = Field(default_factory=list)
allocation_hint: str | None = Field(
default=None, description="方案合计与余额提示(余额归属待澄清#2,仅展示)"
)
allocation_history: list[AllocationVersionOut] = Field(
default_factory=list, description="方案全部版本(含历史;被驳回方案亦保留)"
)
class BusinessCreateIn(BaseModel):
"""创建业务:主表信息 + 明细行数组(明细 ≥1 行)。
字段名全线蛇形直出(M1 既有契约:前端 api 封装与 schemas 同名对应,
仅分页信封 list/pageNum 驼峰)。
"""
model_config = ConfigDict(str_strip_whitespace=True)
customer_id: int = Field(ge=1, description="客户ID")
business_date: date | None = Field(default=None, description="业务日期,ISO 格式")
owner_personnel_id: int | None = Field(
default=None, ge=1, description="负责营销人员ID,可空=待指定"
)
assistant_personnel_id: int | None = Field(default=None, ge=1, description="协助营销人员ID")
is_special: int = Field(default=0, ge=0, le=1, description="特殊业务标记:1是 0否")
special_note: str | None = Field(default=None, max_length=255, description="特殊业务说明")
items: list[BusinessItemIn] = Field(min_length=1, description="产品明细行,至少 1 行")
secondary_allocations: list[SecondaryAllocationIn] = Field(
default_factory=list, description="二次分配方案(只出现在合同刚建立时;可空)"
)
remark: str | None = Field(default=None, max_length=255)
@model_validator(mode="after")
def _check_special_note(self) -> "BusinessCreateIn":
"""特殊业务须有说明:标记的意义就在于可追溯「特殊在哪」。"""
if self.is_special == 1 and not self.special_note:
raise ValueError("特殊业务必须填写说明")
return self
@model_validator(mode="after")
def _check_allocations(self) -> "BusinessCreateIn":
_ensure_allocations_unique(self.secondary_allocations)
return self
class BusinessUpdateIn(BaseModel):
"""编辑业务(全量替换明细与二次分配方案;归属不在此端点调整,走 /ownership)。"""
model_config = ConfigDict(str_strip_whitespace=True)
customer_id: int = Field(ge=1, description="客户ID")
business_date: date | None = Field(default=None, description="业务日期,ISO 格式")
owner_personnel_id: int | None = Field(
default=None,
ge=1,
description="负责营销人员ID(编辑端点忽略,仅 /ownership 生效)",
)
assistant_personnel_id: int | None = Field(
default=None,
ge=1,
description="协助营销人员ID(编辑端点忽略)",
)
is_special: int = Field(default=0, ge=0, le=1, description="特殊业务标记:1是 0否")
special_note: str | None = Field(default=None, max_length=255, description="特殊业务说明")
items: list[BusinessItemIn] = Field(
min_length=1, description="产品明细行,至少 1 行(全量替换)"
)
secondary_allocations: list[SecondaryAllocationIn] = Field(
default_factory=list, description="二次分配方案(全量替换;空数组=清空方案)"
)
remark: str | None = Field(default=None, max_length=255)
@model_validator(mode="after")
def _check_special_note(self) -> "BusinessUpdateIn":
if self.is_special == 1 and not self.special_note:
raise ValueError("特殊业务必须填写说明")
return self
@model_validator(mode="after")
def _check_allocations(self) -> "BusinessUpdateIn":
_ensure_allocations_unique(self.secondary_allocations)
return self
class BusinessStatusIn(BaseModel):
"""状态流转:1执行中 → 2已完成 / 3已终止(终态不可再变更)。"""
model_config = ConfigDict(str_strip_whitespace=True)
status: int = Field(ge=1, le=3, description="目标状态:2已完成 3已终止")
reason: str | None = Field(default=None, max_length=255, description="变更原因(终止时必填)")
@model_validator(mode="after")
def _check_reason(self) -> "BusinessStatusIn":
if self.status == 3 and not self.reason:
raise ValueError("终止业务必须填写原因")
return self
class BusinessOwnershipIn(BaseModel):
"""业务归属变更(负责/协助;前后值写入留痕)。"""
model_config = ConfigDict(str_strip_whitespace=True)
new_owner_personnel_id: int | None = Field(default=None, ge=1)
new_assistant_personnel_id: int | None = Field(default=None, ge=1)
reason: str | None = Field(default=None, max_length=255)
class BusinessQuery(PageQuery):
"""业务列表查询条件(筛选参数驼峰 alias,customer.py 同款契约)。"""
keyword: str | None = Field(default=None, max_length=32, description="业务编号/公司名称,模糊")
status: int | None = Field(default=None, ge=1, le=3, description="状态筛选")
customer_id: int | None = Field(default=None, ge=1, description="客户筛选")
owner_personnel_id: int | None = Field(default=None, ge=1, description="负责营销人员筛选")
date_from: date | None = Field(default=None, description="业务日期起(含)")
date_to: date | None = Field(default=None, description="业务日期止(含)")
class GrossProfitRowOut(BaseModel):
"""毛利润视图行:行级源数据 + 派生值(实时计算,不落库)。"""
item_id: int
product_category: str
product_name: str
spec_model: str | None = None
quantity: str
sale_amount: str
cost_amount: str | None = None
"""成本缺失(未录入)时为 None,派生列同为 None 并置 missing_reason"""
profit_amount: str | None = None
profit_rate: str | None = None
threshold: str
converted_profit: str | None = None
missing_reason: str | None = None
class GrossProfitCategoryOut(BaseModel):
"""按产品类别的毛利润汇总(可计算行聚合;成本缺失行不计入)。"""
product_category: str
sale_amount: str
cost_amount: str
profit_amount: str
profit_rate: str
threshold: str
converted_profit: str
class GrossProfitOut(BaseModel):
"""毛利润数据视图(需求 2.2.4):行级 + 类别汇总。
口径注释(前端须展示):毛利润率 = 毛利润额 / 销售额,**不含运费**
(对照表 #2 作废需规含运费公式);阈值按 asOf 取 M1 参数快照;M3 核算
实际按回款到账日取参,本视图默认今日仅供录入期核对。
"""
business_id: int
as_of: date
rows: list[GrossProfitRowOut] = Field(default_factory=list)
categories: list[GrossProfitCategoryOut] = Field(default_factory=list)
missing_count: int = 0
note: str = "毛利润率与折算额不含运费;M3 核算按回款到账日取参数版本"
class OwnershipChangeLogOut(BaseModel):
"""业务归属变更留痕(人员删除后姓名为 None,固化 ID 兜底可追溯)。"""
id: int
business_id: int
old_owner_personnel_id: int | None = None
new_owner_personnel_id: int | None = None
old_assistant_personnel_id: int | None = None
new_assistant_personnel_id: int | None = None
old_owner_name: str | None = None
new_owner_name: str | None = None
change_reason: str | None = None
operator_id: int | None = None
operator_name: str
create_time: datetime
class BusinessStatusLogOut(BaseModel):
"""业务状态流转记录(详情页时间线;创建首条 old=new=1)。"""
model_config = ConfigDict(from_attributes=True)
id: int
business_id: int
old_status: int
new_status: int
change_reason: str | None = None
operator_id: int | None = None
operator_name: str
create_time: datetime
# 供 schema 外部引用的两类别常量(类别汇总展示用)
CATEGORY_LABELS = {
PRODUCT_CATEGORY_MUNICIPAL_WATER: "市政水利",
PRODUCT_CATEGORY_RAIL_TRANSIT: "轨道交通",
}