- 客户删除保护:被业务引用 → 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: "轨道交通",
|
|
}
|