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

191 lines
7.1 KiB
Python

"""完整工资按人汇总:基本工资只计一次,试用期缓发及年度释放分列。"""
from decimal import ROUND_HALF_UP, Decimal
from sqlalchemy import select
from app.models import BusinessRole, Personnel, ProbationPayment, SettlementItem
from app.services import formula, probation_service
from app.services.manager_share import month_end
ZERO = Decimal("0")
def build_items(session, rows, month):
from app.services import personnel_salary
from app.services.settlement_service import _salary_mode_of
end = month_end(month)
# 人员锁同时串行化不同工资月份对同一年度缓发余额的结清。
people = {
p.id: p for p in session.scalars(select(Personnel).order_by(Personnel.id).with_for_update())
}
roles = {r.id: r.role_code for r in session.scalars(select(BusinessRole))}
profiles = {pid: personnel_salary.resolve(person, end) for pid, person in people.items()}
buckets = {}
def bucket(person):
return buckets.setdefault(
person.id,
{
"person": person,
"performance": ZERO,
"normal": ZERO,
"deferred": ZERO,
"ledger_ids": [],
"annual": [],
"details": [],
},
)
for person in people.values():
if (
personnel_salary.employed(person, profiles[person.id], end)
and profiles[person.id].salary_standard is not None
):
bucket(person)
for row in rows:
person = people.get(row.personnel_id)
if person is None:
continue
item = bucket(person)
item["performance"] += row.performance_amount or ZERO
key = "deferred" if row.is_probation == 1 else "normal"
item[key] += row.payable_amount or ZERO
item["ledger_ids"].append(row.id)
item["details"].append(
{
"ledger_id": row.id,
"calc_type": row.calc_type,
"performance": str(row.performance_amount or ZERO),
"payable": str(row.payable_amount or ZERO),
}
)
probation_service.record(session, row)
session.flush()
preview = probation_service.preview(session, month, lock=True)
for annual in preview["rows"]:
person = people.get(annual["personnel_id"])
if person is None or not Decimal(annual["delta"]):
continue
later = session.scalars(
select(ProbationPayment).where(
ProbationPayment.personnel_id == person.id,
ProbationPayment.year == annual["year"],
ProbationPayment.month > month,
)
)
if any(probation_service.payment_state(session, payment, True) != "ignored" for payment in later):
from app.core.exceptions import BadRequestError
raise BadRequestError("该年度已有后续月份结清记录,请按月份顺序处理差额")
bucket(person)["annual"].append(annual)
result = []
for pid, values in sorted(buckets.items()):
person = values["person"]
profile = profiles[pid]
role = roles.get(profile.business_role_id, "salesperson")
mode = _salary_mode_of(session, role, month)
annual = sum((Decimal(r["delta"]) for r in values["annual"]), ZERO)
normal = values["normal"]
basic = profile.salary_standard if personnel_salary.employed(person, profile, end) else ZERO
trial = probation_service.on_date(profile, end)
net = (
((basic or ZERO) + normal + annual)
if trial
else formula.monthly_salary(normal + annual, basic, mode)
)
result.append(
SettlementItem(
personnel_id=pid,
personnel_name=person.personnel_name,
role_code=role,
calc_type=2 if role == "sales_manager" else 1,
performance_sum=values["performance"].quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
),
payable_sum=(normal + annual).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
normal_payable_sum=normal,
deferred_sum=values["deferred"],
probation_payment_sum=annual,
basic_amount=basic,
salary_mode="INCLUDE" if trial else mode,
net_amount=net.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
if net is not None
else None,
is_probation=int(trial),
probation_note="试用期绩效进入缓发台账;本月仅发基本工资及符合条件的年度补发"
if trial
else None,
snapshot_json={
"ledger_ids": values["ledger_ids"],
"calculation_details": values["details"],
"probation_settlements": values["annual"],
},
)
)
return result, [row.id for row in rows]
def frozen_rows(session, month, personnel_ids=None):
from app.models import MonthlySettlement
statement = (
select(SettlementItem)
.join(MonthlySettlement)
.where(MonthlySettlement.settlement_month == month)
)
if personnel_ids is not None:
statement = statement.where(SettlementItem.personnel_id.in_(personnel_ids))
return [
{
"personnel_id": item.personnel_id,
"personnel_name": item.personnel_name,
"basic_amount": str(item.basic_amount) if item.basic_amount is not None else None,
"normal_payable_sum": str(item.normal_payable_sum),
"deferred_sum": str(item.deferred_sum),
"probation_payment_sum": str(item.probation_payment_sum),
"payable_sum": str(item.payable_sum),
"net_amount": str(item.net_amount) if item.net_amount is not None else None,
}
for item in session.scalars(statement)
]
def export_settlement(session, month):
from io import BytesIO
from openpyxl import Workbook
book = Workbook()
sheet = book.active
sheet.title = "工资分项"
sheet.append(
["人员", "基本工资", "正常绩效", "试用期缓发", "年终及续付差额", "本月实付绩效", "净工资"]
)
for row in frozen_rows(session, month):
sheet.append(
[
row["personnel_name"],
*[
Decimal(row[key]) if row[key] is not None else None
for key in (
"basic_amount",
"normal_payable_sum",
"deferred_sum",
"probation_payment_sum",
"payable_sum",
"net_amount",
)
],
]
)
if sheet.cell(sheet.max_row, 1).data_type == "f":
sheet.cell(sheet.max_row, 1).data_type = "s"
sheet.freeze_panes = "A2"
for col in sheet.columns:
sheet.column_dimensions[col[0].column_letter].width = 24
output = BytesIO()
book.save(output)
return output.getvalue()