PayrollManagementSystem/backend/app/schemas/personnel.py
liuwenxin 1461a8d113 feat(后端): 添加授权体系、系统监控与试用期计提
按 2026-11-02 需求补齐计划交付后端能力:
- 角色授权:权限目录化,接口统一准入校验,未登记默认拒绝;
  管理员判断改为按数据库角色重新判定,防止权限码持有者绕过
- 系统监控:单进程采集资源、请求与异常指标,分钟级聚合,
  阈值配置化;敏感配置增加 repr 保护,Redis 密码 URL 转义
- 试用期计提:按回款日判定并写入快照,年度结清与调整单联动;
  人员模型补试用期、离职日期与工资版本历史字段
- 计提模板:公式骨架受控,页面维护名称与参数绑定
- T+ 采集:自动化配置表与进度查询

内置角色禁停用保护对齐模块注释;核算规则版本提为常量并
补充批量核算失败堆栈日志。

影响范围:后端全部模块、数据库迁移 V22-V28
验证:uv run ruff check 通过;922 个单元测试全部通过;
集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行
2026-11-03 11:20:55 +08:00

166 lines
5.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""营销人员的请求/响应模型(需求 2.1.1)。"""
from datetime import date
from decimal import Decimal, InvalidOperation
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_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
probation_start_date: date | None = None
regularization_date: date | None = None
resignation_date: date | None = None
is_probation: int = 0
salary_standard: str | None = None
status: int = 1
remark: str | None = None
class PersonnelDetailOut(PersonnelOut):
salary_history_json: list[dict] | None = None
"""人员详情:附区域与拓展计划,供编辑抽屉一次回显。"""
regions: list[PersonnelRegionOut] = Field(default_factory=list)
plans: list[PersonnelPlanOut] = Field(default_factory=list)
class PersonnelCreateIn(BaseModel):
salary_effective_date: date | None = None
"""新增人员。"""
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 格式")
probation_start_date: date | None = None
regularization_date: date | None = None
resignation_date: date | None = None
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
@model_validator(mode="after")
def _validate_probation_dates(self):
if self.probation_start_date and self.regularization_date and self.regularization_date <= self.probation_start_date:
raise ValueError("转正生效日必须晚于试用期开始日")
return self
class PersonnelUpdateIn(PersonnelCreateIn):
"""编辑人员,全量提交、全量替换(不含状态,状态走独立接口)。"""
class PersonnelStatusIn(BaseModel):
"""在职/离职切换。"""
status: int = Field(ge=0, le=1, description="1在职 0离职")
effective_date: date | None = None
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="岗位筛选")