PayrollManagementSystem/backend/app/services/payroll_months.py
liuwenxin 108bf87225 feat(核算)!: 添加 T+ 正式核算与采集托管
补齐源快照、资料预演、正式导入及工资核算的依赖链路。
新增版本化输入、逐段 FIFO、生成锁和结构化当月调整,
并提供默认关闭的周期采集、有限重试及 Windows 托管。

原始客户资料、私有配置及首页改动不纳入本次提交。

BREAKING CHANGE: 升级前需应用新增迁移,并通过
PAYROLL_MAKER_USER_ID 绑定实际工资制作人,否则业务只读。
旧单批次核算默认关闭;历史兼容需显式启用
PAYROLL_ALLOW_LEGACY_CALCULATION。生产启用和真实金额待验收。
2026-11-02 09:42:53 +08:00

48 lines
2.0 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 lock_month(session, month, operator):
"""INSERT 的唯一键串行化首次建行,避免两个请求同时认为月份不存在。"""
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):
control = lock_month(session, month, operator)
# 兼容升级前已有月结;没有控制行不代表历史工资可以修改。
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