- 客户删除保护:被业务引用 → 400(T4 占位注释在业务表就位后漏补,FK 500 暴露) - 人员删除保护:业务归属(负责/协助)引用纳入检查(同样漏检 500) - schema 单价类列(DECIMAL 14,4)小数位放行至 4 位:回显 '5000.0000' 原样提交被两位校验永久拒收(编辑任何带精度单价的历史行都失败) - 补 2 个集成用例锁定;文案同步「客户归属或业务归属引用」 Co-Authored-By: Claude Code <noreply@anthropic.com>
338 lines
13 KiB
Python
338 lines
13 KiB
Python
"""业务管理的请求/响应模型(需求 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 BusinessDetailOut(BusinessOut):
|
||
"""业务详情:附产品明细行。"""
|
||
|
||
items: list[BusinessItemOut] = Field(default_factory=list)
|
||
|
||
|
||
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 行")
|
||
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
|
||
|
||
|
||
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 行(全量替换)"
|
||
)
|
||
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
|
||
|
||
|
||
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: "轨道交通",
|
||
}
|