"""业务管理的请求/响应模型(需求 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) @model_validator(mode="after") def _derive_guided_cost(self): from app.services.cost_rules import guided_total total = guided_total(self.quantity, self.guided_price, self.cost_amount) self.cost_amount = str(total) if total is not None else None return self 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 | None = None 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 source_type: str = "MANUAL" source_no: str | None = None source_personnel: list[str] = Field(default_factory=list) integration_status: str | None = None readiness_status: str | None = None readiness_reasons: list[dict[str, str]] = Field(default_factory=list) class SecondaryAllocationIn(BaseModel): """二次分配参与人入参(2026-10-15 补充需求「二次分配方案」)。 比例**不要求合计 100%**(需求原文):余额归属待业务澄清(待澄清 #2), 服务层仅以 hint 展示合计/余额,不做强制校验。ratio 沿金额红线口径: 字符串入参、两位小数、超限拒绝不静默舍入。 """ model_config = ConfigDict(str_strip_whitespace=True) personnel_id: int = Field(ge=1, description="参与业务员ID(personnel.id)") ratio: str = Field(description="分摊比例(0-100,两位小数)") @field_validator("ratio") @classmethod def _check_ratio(cls, value: str) -> str: return _check_amount(value, "分摊比例", Decimal("100"), allow_positive_only=True) or value class SecondaryAllocationOut(BaseModel): """二次分配参与人响应(personnel_name 冗余固化,离职后回显有名)。""" model_config = ConfigDict(from_attributes=True) personnel_id: int personnel_name: str ratio: str class AllocationVersionOut(SecondaryAllocationOut): """方案版本行(含历史;业务详情抽屉回溯用)。""" version_no: int is_active: int = Field(description="1当前生效 0历史版本") create_time: datetime def _ensure_allocations_unique(items: list["SecondaryAllocationIn"]) -> None: """同一业务内参与人去重:uk(business_id, personnel_id) 的前置友好提示 (让 400 文案说「重复参与人」而不是裸唯一键冲突)。""" seen: set[int] = set() for item in items: if item.personnel_id in seen: raise ValueError(f"二次分配参与人重复:人员ID {item.personnel_id}") seen.add(item.personnel_id) class BusinessDetailOut(BusinessOut): """业务详情:附产品明细行、当前方案与方案版本历史。""" items: list[BusinessItemOut] = Field(default_factory=list) secondary_allocations: list[SecondaryAllocationOut] = Field(default_factory=list) allocation_hint: str | None = Field( default=None, description="方案合计与余额提示(余额归属待澄清#2,仅展示)" ) allocation_history: list[AllocationVersionOut] = Field( default_factory=list, description="方案全部版本(含历史;被驳回方案亦保留)" ) 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 行") secondary_allocations: list[SecondaryAllocationIn] = Field( default_factory=list, description="二次分配方案(只出现在合同刚建立时;可空)" ) 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 @model_validator(mode="after") def _check_allocations(self) -> "BusinessCreateIn": _ensure_allocations_unique(self.secondary_allocations) 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 行(全量替换)" ) secondary_allocations: list[SecondaryAllocationIn] = Field( default_factory=list, description="二次分配方案(全量替换;空数组=清空方案)" ) 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 @model_validator(mode="after") def _check_allocations(self) -> "BusinessUpdateIn": _ensure_allocations_unique(self.secondary_allocations) 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="业务日期止(含)") source_type: str | None = Field(default=None, pattern=r"^(MANUAL|TPLUS)$") source_personnel: str | None = Field(default=None, max_length=64) pending_only: bool = False readiness_status: str | None = Field(default=None, pattern=r"^(READY|PENDING)$") readiness_reason: str | None = Field(default=None, max_length=64, pattern=r"^[A-Z_]+$") class GrossProfitRowOut(BaseModel): """毛利润视图行:行级源数据 + 派生值(实时计算,不落库)。""" item_id: int product_category: str | None = None 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 | None = None 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: "轨道交通", }