"""客户评级等级配置的请求/响应模型。 分值(min_score/max_score)入参出参一律用**字符串**承载:前端 JS 的 number 无法精确表示全部十进制小数,字符串是分值红线的入口闸门,Decimal 转换统一 收敛在 service 层(与 schemas/product.py 同一口径)。 注意:schema 只拦**格式**(可解析、有限数);0≤min≤max≤100 与区间不重叠 是横跨两字段/多行的业务值域规则,在 service 层审计上下文内统一判定 —— 值域校验失败的 400 也要留痕(2026-10-13 定稿口径)。 """ from datetime import datetime from decimal import Decimal, InvalidOperation from pydantic import BaseModel, ConfigDict, Field, field_validator def _parse_score(value: str, *, label: str) -> str: """分值字符串格式校验:必须可被 Decimal 解析且为有限数。 ``NaN`` 参与比较抛 InvalidOperation、``Infinity`` 落库即溢出,均须在 入口用 ``is_finite()`` 拦下(与 schemas/product.py 的 _parse_non_negative 同口径);非负与 ≤100 属业务值域,留给 service 层在审计上下文内判定。 """ try: score = Decimal(value.strip()) except InvalidOperation as exc: raise ValueError(f"{label}必须是有效数字:{value}") from exc if not score.is_finite(): raise ValueError(f"{label}必须为有限数字:{value}") return value class RatingGradeOut(BaseModel): """评级等级响应(列表直出)。分值按红线以字符串出参。""" id: int grade_name: str min_score: str max_score: str sort_order: int remark: str | None = None create_time: datetime update_time: datetime class RatingGradeCreateIn(BaseModel): """新增评级等级。 min_score/max_score 必填:区间是判定表的判定依据,缺省无从判定。 sort_order 缺省 0(列默认值同源),判定按其升序取首个命中等级。 """ model_config = ConfigDict(str_strip_whitespace=True) grade_name: str = Field(min_length=1, max_length=32, description="等级名称(如 A/优秀)") min_score: str = Field(description="总分下限(含),0-100 数字字符串") max_score: str = Field(description="总分上限(展示用),0-100 数字字符串") sort_order: int = Field(default=0, ge=0, description="判定顺序,升序") remark: str | None = Field(default=None, max_length=255, description="说明") @field_validator("min_score") @classmethod def _check_min_score(cls, value: str) -> str: """总分下限须为有限数字(值域 0-100 由 service 层判定)。""" return _parse_score(value, label="总分下限") @field_validator("max_score") @classmethod def _check_max_score(cls, value: str) -> str: """总分上限须为有限数字(值域 0-100 由 service 层判定)。""" return _parse_score(value, label="总分上限") class RatingGradeUpdateIn(RatingGradeCreateIn): """编辑评级等级。 前端表单全量提交,故为全量替换语义;等级表无启停状态列,字段与新增一致。 """ class CustomerRatingOut(BaseModel): """客户评级响应(每客户一行;总分与分值按红线以字符串出参)。 grade_name 是保存时点按等级映射**快照固化**的值(无外键):等级表后续 增删改不影响已保存的评级结论;为 NULL 表示保存时未命中任何等级 (总分低于全部下限或等级表被清空)。 """ customer_id: int score_ship_amount: str score_receipt_rate: str score_profit_rate: str score_contract_cooperation: str score_site_construction: str total_score: str grade_name: str | None = None remark: str | None = None scorer_name: str create_time: datetime update_time: datetime class CustomerRatingSaveIn(BaseModel): """保存客户评级(每客户一行,整体替换语义)。 五项分值为 0-20 的数字字符串(schema 只拦格式;0≤分≤20 与总分等级 判定在 service 层审计上下文内完成 —— 失败的 400 同样留痕)。总分由 服务端按五项重算,**不采信前端回传的总分**。 """ model_config = ConfigDict(str_strip_whitespace=True) score_ship_amount: str = Field(description="发货额评分(0-20 数字字符串)") score_receipt_rate: str = Field(description="回款率评分(0-20 数字字符串)") score_profit_rate: str = Field(description="毛利率评分(0-20 数字字符串)") score_contract_cooperation: str = Field(description="合同履约配合能力评分(0-20 数字字符串)") score_site_construction: str = Field(description="现场规范施工能力评分(0-20 数字字符串)") remark: str | None = Field(default=None, max_length=255, description="备注") @field_validator( "score_ship_amount", "score_receipt_rate", "score_profit_rate", "score_contract_cooperation", "score_site_construction", ) @classmethod def _check_score(cls, value: str) -> str: """单项分值须为有限数字(值域 0-20 由 service 层判定)。""" return _parse_score(value, label="评分")