- 覆盖:401/引用校验/删除保护/子表全量替换/类别与组合唯一/归属变更留痕/审计双记录 - 缺陷修复:personnel/product/customer 三 service 的前置校验移入审计上下文, 校验失败(400)同样留痕(成功与失败都记的既定原则) - conftest 兜底清理扩展到 M2 五表(留痕无外键,按 operator_name + 前缀双轨清理) Co-Authored-By: Claude Code <noreply@anthropic.com>
107 lines
4.0 KiB
Python
107 lines
4.0 KiB
Python
"""产品管理的请求/响应模型。
|
||
|
||
数量与单价(stock_quantity/sale_price)入参出参一律用**字符串**承载:
|
||
前端 JS 的 number 无法精确表示全部十进制小数,字符串是金额红线的入口闸门,
|
||
Decimal 转换统一收敛在 service 层。字段命名与 user/role/param 一致采用
|
||
蛇形直出(前端 TS 接口同构)。
|
||
"""
|
||
|
||
from datetime import datetime
|
||
from decimal import Decimal, InvalidOperation
|
||
|
||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||
|
||
from app.core.response import PageQuery
|
||
|
||
|
||
def _parse_non_negative(value: str, *, label: str) -> str:
|
||
"""非负数字字符串校验:必须可被 Decimal 解析、为有限数且 ≥ 0。
|
||
|
||
``NaN`` 参与比较会抛 InvalidOperation、``Infinity`` 会在后续运算溢出,
|
||
均须在入口用 ``is_finite()`` 拦下(与 schemas/param.py 的 _parse_amount
|
||
同一口径)。
|
||
"""
|
||
try:
|
||
amount = Decimal(value.strip())
|
||
except InvalidOperation as exc:
|
||
raise ValueError(f"{label}必须是有效数字:{value}") from exc
|
||
if not amount.is_finite():
|
||
raise ValueError(f"{label}必须为有限数字:{value}")
|
||
if amount < 0:
|
||
raise ValueError(f"{label}不得为负数:{value}")
|
||
return value
|
||
|
||
|
||
class ProductOut(BaseModel):
|
||
"""产品响应(列表与详情共用)。数量/单价按红线以字符串出参。"""
|
||
|
||
id: int
|
||
product_name: str
|
||
spec_model: str
|
||
unit: str | None = None
|
||
stock_quantity: str | None = None
|
||
sale_price: str | None = None
|
||
product_category: str
|
||
status: int = 1
|
||
remark: str | None = None
|
||
create_time: datetime
|
||
update_time: datetime
|
||
|
||
|
||
class ProductCreateIn(BaseModel):
|
||
"""新增产品。
|
||
|
||
spec_model 允许缺省/空:service 层归一为空串(组合唯一键
|
||
uk_products_name_spec 对 NULL 不去重,归 NULL 会放行重名同规格数据)。
|
||
"""
|
||
|
||
model_config = ConfigDict(str_strip_whitespace=True)
|
||
|
||
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="计量单位")
|
||
stock_quantity: str | None = Field(default=None, description="数量,非负数字字符串")
|
||
sale_price: str | None = Field(default=None, description="销售单价(元),非负数字字符串")
|
||
product_category: str = Field(min_length=1, max_length=32, description="产品类别")
|
||
remark: str | None = Field(default=None, max_length=255)
|
||
|
||
@field_validator("stock_quantity")
|
||
@classmethod
|
||
def _check_stock_quantity(cls, value: str | None) -> str | None:
|
||
"""空串与 None 同义(可空列),归一为 None;非空则须为非负数字。"""
|
||
if not value:
|
||
return None
|
||
return _parse_non_negative(value, label="数量")
|
||
|
||
@field_validator("sale_price")
|
||
@classmethod
|
||
def _check_sale_price(cls, value: str | None) -> str | None:
|
||
"""空串与 None 同义(可空列),归一为 None;非空则须为非负数字。"""
|
||
if not value:
|
||
return None
|
||
return _parse_non_negative(value, label="销售单价")
|
||
|
||
|
||
class ProductUpdateIn(ProductCreateIn):
|
||
"""编辑产品。
|
||
|
||
前端表单全量提交,故为全量替换语义;**不含 status** —— 启用/停用是
|
||
独立操作(走 PUT /{id}/status),避免编辑表单误触停用。
|
||
"""
|
||
|
||
|
||
class ProductStatusIn(BaseModel):
|
||
"""启用/停用产品。"""
|
||
|
||
status: int = Field(ge=0, le=1, description="1启用 0停用")
|
||
|
||
|
||
class ProductQuery(PageQuery):
|
||
"""产品列表查询条件。"""
|
||
|
||
keyword: str | None = Field(default=None, max_length=64, description="名称或规格,模糊匹配")
|
||
product_category: str | None = Field(default=None, max_length=32, description="按类别筛选")
|
||
status: int | None = Field(default=None, ge=0, le=1, description="状态筛选")
|