PayrollManagementSystem/backend/app/services/personnel_salary.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

85 lines
2.7 KiB
Python

"""人员工资与任职资料按生效日期留存,生成历史工资不读后来变更的标准。"""
from datetime import date
from types import SimpleNamespace
from sqlalchemy import select
from app.core.exceptions import BadRequestError
from app.models import MonthlySettlement
FIELDS = (
"salary_standard",
"business_role_id",
"hire_date",
"resignation_date",
"probation_start_date",
"regularization_date",
"is_probation",
)
def capture(person, effective):
return {
"status": str(person.status),
"effective_date": str(effective),
**{
key: str(getattr(person, key)) if getattr(person, key) is not None else None
for key in FIELDS
},
}
def begin_update(session, person, effective):
if session.scalar(
select(MonthlySettlement.id).where(
MonthlySettlement.settlement_month >= effective.strftime("%Y-%m")
)
):
raise BadRequestError(
"生效月份或后续月份已生成工资,请使用未来生效日期;历史差额走当月调整"
)
history = list(person.salary_history_json or [])
if not history:
baseline = person.hire_date or person.create_time.date()
history.append(capture(person, baseline))
return history
def save_version(person, effective, history=None):
entries = list(history or person.salary_history_json or [])
entries.append(capture(person, effective))
person.salary_history_json = entries
def resolve(person, day):
entries = [
entry for entry in (person.salary_history_json or []) if entry["effective_date"] <= str(day)
]
values = {key: getattr(person, key) for key in FIELDS}
values["status"] = person.status
if person.salary_history_json and not entries:
values["salary_standard"] = None
elif entries:
selected = max(enumerate(entries), key=lambda pair: (pair[1]["effective_date"], pair[0]))[1]
values["status"] = int(selected.get("status", "1"))
for key in FIELDS:
raw = selected.get(key)
if key.endswith("date"):
values[key] = date.fromisoformat(raw) if raw else None
elif key in ("business_role_id", "is_probation"):
values[key] = int(raw) if raw else 0
elif key == "salary_standard":
from decimal import Decimal
values[key] = Decimal(raw) if raw is not None else None
return SimpleNamespace(**values)
def employed(person, profile, day):
if profile.hire_date and profile.hire_date > day:
return False
if profile.resignation_date:
return day < profile.resignation_date
return profile.status == 1