"""发货管理的请求/响应模型(需求 2.2.2)。 金额/数量入参出参一律 str(服务层 Decimal 运算,全程禁 float),容量与 小数位校验复用 business.py 的 ``_check_amount``(同一套口径,防两处漂移); 字段契约全线蛇形直出(2026-10-13 定稿:无驼峰 alias)。 amount(行金额)与 total_amount(发货总金额)不在入参内 —— 由服务层按 数量×单价 计算固化,不信任前端传值;运费仅记录展示,不计入毛利润口径。 """ from datetime import date from pydantic import BaseModel, ConfigDict, Field, field_validator from app.core.response import PageQuery from app.schemas.business import AMOUNT_MAX, PRICE_MAX, _check_amount class ShipmentItemIn(BaseModel): """发货产品明细行(product_id 空=自由录入;价格口径两可,可空人工补)。""" 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="计量单位") quantity: str = Field(description="数量,>0") sale_price: str | None = Field( default=None, description="发货单价(元),可空=待人工补价,补价前行金额记 0" ) @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") @classmethod def _check_price(cls, value: str | None) -> str | None: """发货单价列 DECIMAL(14,4):按 4 位小数口径(回显可原样提交)。""" return _check_amount(value, "单价", PRICE_MAX, scale=4) class ShipmentItemOut(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 quantity: str sale_price: str | None = None amount: str class ShipmentOut(BaseModel): """发货单响应(列表与写操作回包共用);business_no 为 join 附带列。""" model_config = ConfigDict(from_attributes=True) id: int shipment_no: str business_id: int business_no: str | None = None ship_date: date freight_amount: str total_amount: str remark: str | None = None class ShipmentDetailOut(ShipmentOut): """发货单详情:附产品明细行。""" items: list[ShipmentItemOut] = Field(default_factory=list) class ShipmentCreateIn(BaseModel): """创建发货单:业务维度 + 明细行数组(明细 ≥1 行)。 字段名全线蛇形直出(同 business.py 契约,仅分页信封 list/pageNum 驼峰)。 """ model_config = ConfigDict(str_strip_whitespace=True) business_id: int = Field(ge=1, description="业务ID") ship_date: date = Field(description="发货日期(回款天数基准日,发货日首日=第 1 天)") freight_amount: str | None = Field( default=None, description="运费金额(元),空记 0;仅记录展示,不计入毛利润口径" ) items: list[ShipmentItemIn] = Field(min_length=1, description="发货明细行,至少 1 行") remark: str | None = Field(default=None, max_length=255) @field_validator("freight_amount") @classmethod def _check_freight(cls, value: str | None) -> str | None: """运费同样是金额源数据:复用 _check_amount 拒负数/多小数位/超容量。""" return _check_amount(value, "运费金额", AMOUNT_MAX) class ShipmentUpdateIn(BaseModel): """编辑发货单(明细全量替换)。 business_id 不在编辑入参内 —— 发货单挂靠的业务创建时定死,改挂会让 已指定本发货单的回款语义断裂。 """ model_config = ConfigDict(str_strip_whitespace=True) ship_date: date = Field(description="发货日期(回款天数基准日,发货日首日=第 1 天)") freight_amount: str | None = Field(default=None, description="运费金额(元),空记 0") items: list[ShipmentItemIn] = Field( min_length=1, description="发货明细行,至少 1 行(全量替换)" ) remark: str | None = Field(default=None, max_length=255) @field_validator("freight_amount") @classmethod def _check_freight(cls, value: str | None) -> str | None: """运费同样是金额源数据:复用 _check_amount 拒负数/多小数位/超容量。""" return _check_amount(value, "运费金额", AMOUNT_MAX) class ShipmentQuery(PageQuery): """发货单列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。 business_id 可空=全量:发货单以业务维度查看为主(一期),但单号可 独立检索,不强制挂业务条件。 """ business_id: int | None = Field(default=None, ge=1, description="业务筛选,空=全部") ship_date_from: date | None = Field(default=None, description="发货日期起(含)") ship_date_to: date | None = Field(default=None, description="发货日期止(含)")