"""营销人员的请求/响应模型(需求 2.1.1)。""" from datetime import date from decimal import Decimal, InvalidOperation from pydantic import BaseModel, ConfigDict, Field, field_validator from app.core.response import PageQuery # salary_standard 列为 Numeric(12,2):整数部分最多 10 位。 # 工资系统金额红线——入参出参一律 str,服务层转 Decimal,全程不经 float SALARY_MAX = Decimal("9999999999.99") class PersonnelRegionOut(BaseModel): """辐射区域条目响应。""" model_config = ConfigDict(from_attributes=True) id: int region_name: str remark: str | None = None class PersonnelPlanOut(BaseModel): """拓展计划条目响应。""" model_config = ConfigDict(from_attributes=True) id: int plan_period: str plan_content: str target_desc: str | None = None class PersonnelOut(BaseModel): """人员响应(列表与写操作回包共用)。""" model_config = ConfigDict(from_attributes=True) id: int personnel_name: str business_role_id: int role_name: str | None = None manager_id: int | None = None manager_name: str | None = None phone: str | None = None hire_date: date | None = None is_probation: int = 0 salary_standard: str | None = None status: int = 1 remark: str | None = None class PersonnelDetailOut(PersonnelOut): """人员详情:附区域与拓展计划,供编辑抽屉一次回显。""" regions: list[PersonnelRegionOut] = Field(default_factory=list) plans: list[PersonnelPlanOut] = Field(default_factory=list) class PersonnelCreateIn(BaseModel): """新增人员。""" model_config = ConfigDict(str_strip_whitespace=True) personnel_name: str = Field(min_length=1, max_length=64, description="姓名") business_role_id: int = Field(ge=1, description="岗位角色ID") manager_id: int | None = Field(default=None, ge=1, description="直属营销经理ID,空=无") phone: str | None = Field(default=None, max_length=20, description="联系电话") hire_date: date | None = Field(default=None, description="入职日期,ISO 格式") is_probation: int = Field(default=0, ge=0, le=1, description="是否试用期:1是 0否") salary_standard: str | None = Field( default=None, description="月工资标准(元),字符串形式的数字" ) remark: str | None = Field(default=None, max_length=255) @field_validator("salary_standard") @classmethod def _check_salary_standard(cls, value: str | None) -> str | None: """按 Numeric(12,2) 的容量校验字符串金额。 金额以字符串进出避免 float 精度损失(项目金额红线); 小数位超过两位直接拒绝而非静默舍入——舍入即算错钱。 """ if value is None: return None try: amount = Decimal(value) except InvalidOperation: raise ValueError("工资标准须为有效数字") from None if not amount.is_finite(): raise ValueError("工资标准须为有效数字") if amount < 0: raise ValueError("工资标准不能为负数") if amount > SALARY_MAX: raise ValueError("工资标准超出可录入上限") if -amount.as_tuple().exponent > 2: raise ValueError("工资标准最多两位小数") return value class PersonnelUpdateIn(PersonnelCreateIn): """编辑人员,全量提交、全量替换(不含状态,状态走独立接口)。""" class PersonnelStatusIn(BaseModel): """在职/离职切换。""" status: int = Field(ge=0, le=1, description="1在职 0离职") class RegionItemIn(BaseModel): """辐射区域条目。""" model_config = ConfigDict(str_strip_whitespace=True) region_name: str = Field(min_length=1, max_length=128, description="区域名称") remark: str | None = Field(default=None, max_length=255) class RegionReplaceIn(BaseModel): """辐射区域全量替换。空列表即清空该人员的全部区域。""" items: list[RegionItemIn] = Field(default_factory=list, description="全量条目") class PlanItemIn(BaseModel): """拓展计划条目。""" model_config = ConfigDict(str_strip_whitespace=True) plan_period: str = Field(min_length=1, max_length=32, description="计划期间,如 2026Q1") plan_content: str = Field(min_length=1, max_length=500, description="计划内容") target_desc: str | None = Field(default=None, max_length=255, description="指标说明") class PlanReplaceIn(BaseModel): """拓展计划全量替换。空列表即清空该人员的全部计划。""" items: list[PlanItemIn] = Field(default_factory=list, description="全量条目") class PersonnelQuery(PageQuery): """人员列表查询条件。""" keyword: str | None = Field(default=None, max_length=64, description="姓名,模糊匹配") status: int | None = Field(default=None, ge=0, le=1, description="状态筛选:1在职 0离职") business_role_id: int | None = Field(default=None, ge=1, description="岗位筛选")