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

63 lines
2.7 KiB
Python

"""月份行锁同时保护工资生成与分配事实修改。"""
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.dialects.mysql import insert
from app.core.exceptions import BadRequestError, ForbiddenError
from app.core.month import validate_month
from app.models import MonthlySettlement, PayrollMonth
from app.services.payroll_access import ensure_payroll_writer
def current_month() -> str:
return datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m")
def month_state(session, month):
"""查询生成锁及制作人,兼容升级前已存在的月结记录。"""
validate_month(month)
control = session.scalar(select(PayrollMonth).where(PayrollMonth.month == month))
existing = session.scalar(
select(MonthlySettlement.id).where(MonthlySettlement.settlement_month == month)
)
return {
"month": month,
"allocation_locked": bool(existing or (control and control.generated_at)),
"maker_name": control.maker_name if control else None,
}
def lock_month(session, month, operator, *, require_maker=True):
"""INSERT 的唯一键串行化首次建行,避免两个请求同时认为月份不存在。"""
if require_maker:
ensure_payroll_writer(operator)
validate_month(month)
values = {"month": month, "maker_id": operator.id, "maker_name": operator.username}
if session.bind.dialect.name == "mysql":
stmt = insert(PayrollMonth).values(**values)
session.execute(stmt.on_duplicate_key_update(month=stmt.inserted.month))
elif session.scalar(select(PayrollMonth.id).where(PayrollMonth.month == month)) is None:
session.add(PayrollMonth(**values))
session.flush()
control = session.scalar(
select(PayrollMonth).where(PayrollMonth.month == month).with_for_update()
)
if control.generated_at is not None and control.maker_id != operator.id:
raise ForbiddenError("该月工资由其他制作人生成,不可代为修改或重新生成")
if control.generated_at is None:
control.maker_id, control.maker_name = operator.id, operator.username
return control
def ensure_allocation_editable(session, month, operator, *, require_maker=True):
control = lock_month(session, month, operator, require_maker=require_maker)
# 兼容升级前已有月结;没有控制行不代表历史工资可以修改。
old = session.scalar(
select(MonthlySettlement.id).where(MonthlySettlement.settlement_month == month)
)
if control.generated_at is not None or old is not None:
raise BadRequestError("该月工资已生成,二次分配已锁定;差额请走当月调整单")
return control