- 覆盖:401/引用校验/删除保护/子表全量替换/类别与组合唯一/归属变更留痕/审计双记录 - 缺陷修复:personnel/product/customer 三 service 的前置校验移入审计上下文, 校验失败(400)同样留痕(成功与失败都记的既定原则) - conftest 兜底清理扩展到 M2 五表(留痕无外键,按 operator_name + 前缀双轨清理) Co-Authored-By: Claude Code <noreply@anthropic.com>
150 lines
5.1 KiB
Python
150 lines
5.1 KiB
Python
"""营销人员的请求/响应模型(需求 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="岗位筛选")
|