PayrollManagementSystem/backend/app/schemas/product.py
liuwenxin ca88ee7a0b test(base): 基础数据管理模块集成用例(48 例)与审计盲区修复
- 覆盖:401/引用校验/删除保护/子表全量替换/类别与组合唯一/归属变更留痕/审计双记录
- 缺陷修复:personnel/product/customer 三 service 的前置校验移入审计上下文,
  校验失败(400)同样留痕(成功与失败都记的既定原则)
- conftest 兜底清理扩展到 M2 五表(留痕无外键,按 operator_name + 前缀双轨清理)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 11:22:34 +08:00

107 lines
4.0 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.

"""产品管理的请求/响应模型。
数量与单价(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="状态筛选")