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

321 lines
12 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.

"""试用期计提、缓发和年度结清;支付受原业务起付及平方权重约束。"""
from datetime import date
from decimal import Decimal
from fractions import Fraction
from io import BytesIO
from openpyxl import Workbook
from sqlalchemy import select
from app.models import AdjustmentOrder, PerformanceLedger, ProbationDeferral, ProbationPayment
from app.services import param_service
ZERO = Decimal("0")
def payment_state(session, payment, lock=False):
if payment.adjustment_order_id:
order = session.get(AdjustmentOrder, payment.adjustment_order_id, with_for_update=lock, populate_existing=lock)
return "ignored" if order is None or order.status not in (1, 2) else "reserved" if order.status == 1 else "paid"
if payment.settlement_id:
from app.models import MonthlySettlement
settlement = session.get(MonthlySettlement, payment.settlement_id, with_for_update=lock, populate_existing=lock)
return "ignored" if settlement is None or settlement.status == 4 else "reserved" if settlement.status != 3 else "paid"
return "ignored"
def on_date(person, day):
if hasattr(person, "salary_history_json"):
from app.services.personnel_salary import resolve
person = resolve(person, day)
start = person.probation_start_date
end = person.regularization_date
if start or end:
return (start is None or day >= start) and (end is None or day < end)
return person.is_probation == 1
def record(session, ledger):
if ledger.is_probation != 1:
return
if session.scalar(select(ProbationDeferral.id).where(ProbationDeferral.ledger_id == ledger.id)):
return
event = (ledger.snapshot_json or {}).get("event", {})
session.add(
ProbationDeferral(
ledger_id=ledger.id,
personnel_id=ledger.personnel_id,
year=str(event.get("probation_year") or ledger.receipt_date.year),
month=ledger.settlement_month,
accrued=ledger.performance_amount or ZERO,
eligible=ledger.payable_amount or ZERO,
)
)
def preview(session, month, personnel_id=None, *, lock=False):
"""查询不写表;旧试用期台账同样进入缓发查询,历史标记不重分类。"""
from app.core.month import validate_month
validate_month(month)
statement = (
select(PerformanceLedger)
.where(
PerformanceLedger.is_active == 1,
PerformanceLedger.is_probation == 1,
PerformanceLedger.settlement_month <= month,
)
.order_by(PerformanceLedger.personnel_id, PerformanceLedger.id)
)
if personnel_id:
statement = statement.where(PerformanceLedger.personnel_id == personnel_id)
if lock:
statement = statement.with_for_update().execution_options(populate_existing=True)
grouped = {}
for ledger in session.scalars(statement):
event = (ledger.snapshot_json or {}).get("event", {})
year = str(event.get("probation_year") or ledger.receipt_date.year)
key = ledger.personnel_id, year
row = grouped.setdefault(
key,
{
"personnel_id": ledger.personnel_id,
"personnel_name": ledger.personnel_name,
"year": year,
"accrued": ZERO,
"eligible": ZERO,
"paid": ZERO,
"reserved": ZERO,
"details": [],
},
)
row["accrued"] += ledger.performance_amount or ZERO
row["eligible"] += ledger.payable_amount or ZERO
row["details"].append(
{
"ledger_id": ledger.id,
"business_no": ledger.business_no,
"month": ledger.settlement_month,
"receipt_date": str(ledger.receipt_date),
"accrued": str(ledger.performance_amount or ZERO),
"eligible": str(ledger.payable_amount or ZERO),
}
)
payments = select(ProbationPayment).where(ProbationPayment.month <= month)
if lock:
payments = payments.with_for_update().execution_options(populate_existing=True)
for payment in session.scalars(payments):
state = payment_state(session, payment, lock)
if state == "ignored":
continue
key = payment.personnel_id, payment.year
if key in grouped:
grouped[key][state] += payment.amount
result = []
for (_, year), row in sorted(grouped.items()):
snap = param_service.resolve_snapshot(session, date(int(year), 12, 31))
threshold = param_service.to_decimal(Fraction(snap.get("probation.annual.threshold")))
entitlement = max(row["accrued"] - threshold, ZERO)
available = min(entitlement, max(row["eligible"], ZERO))
enabled = month >= year + "-12"
delta = available - row["paid"] - row["reserved"] if enabled else ZERO
result.append(
{
**{
k: v
for k, v in row.items()
if k not in ("accrued", "eligible", "paid", "reserved")
},
"accrued": str(row["accrued"]),
"eligible": str(row["eligible"]),
"paid": str(row["paid"]),
"threshold": str(threshold),
"entitlement": str(entitlement),
"available": str(available),
"reserved": str(row["reserved"]),
"remaining": str(max(entitlement - row["paid"], ZERO)),
"delta": str(delta),
"payment_month": month,
"can_settle": enabled,
}
)
from app.services.tplus.workbench import digest
result[-1]["fingerprint"] = digest(result[-1])
return {
"month": month,
"rows": result,
"rule": "仅累计试用期绩效;超年度门槛部分仍受业务起付和R×R限制;12月及以后月份结清差额。",
}
def persist_payments(session, settlement, items):
for item in items:
for row in (item.snapshot_json or {}).get("probation_settlements", []):
amount = Decimal(row["delta"])
if amount:
session.add(
ProbationPayment(
settlement_id=settlement.id,
personnel_id=item.personnel_id,
year=row["year"],
month=settlement.settlement_month,
amount=amount,
snapshot_json=row,
)
)
def export(session, month):
data = preview(session, month)
book = Workbook()
sheet = book.active
sheet.title = "缓发及年度结清"
sheet.append(
[
"人员",
"原年度",
"试用期计提",
"符合支付条件",
"年度门槛",
"超额权益",
"累计可补发",
"历史已补发",
"待审核占用",
"本月差额",
"剩余权益",
]
)
keys = (
"personnel_name",
"year",
"accrued",
"eligible",
"threshold",
"entitlement",
"available",
"paid",
"reserved",
"delta",
"remaining",
)
detail = book.create_sheet("原台账明细")
detail.append(["人员", "年度", "台账ID", "业务编号", "月份", "到账日", "计提额", "应付变化"])
for row in data["rows"]:
values = [row[k] for k in keys]
sheet.append(values)
for cell in sheet[sheet.max_row]:
if cell.data_type == "f":
cell.data_type = "s"
for entry in row["details"]:
detail.append(
[
row["personnel_name"],
row["year"],
*[
entry[k]
for k in (
"ledger_id",
"business_no",
"month",
"receipt_date",
"accrued",
"eligible",
)
],
]
)
for cell in detail[detail.max_row]:
if cell.data_type == "f":
cell.data_type = "s"
for page in (sheet, detail):
page.freeze_panes = "A2"
for col in page.columns:
page.column_dimensions[col[0].column_letter].width = 22
output = BytesIO()
book.save(output)
return output.getvalue()
def adjustment(session, payload, actor, meta=None):
"""月结已生成后只创建当月差额调整;年度余额与调整单原子写入。"""
from app.core.config import get_settings
from app.core.exceptions import BadRequestError
from app.models import MonthlySettlement, Personnel
from app.services import approval_service, audit_service, payroll_months
from app.services.audit_service import AuditRecord
from app.services.no_generator import next_no
from app.services.settlement_service import _wire_adjustment_approval
month = payroll_months.current_month()
from app.services.payroll_access import ensure_payroll_writer
ensure_payroll_writer(actor)
payroll_months.lock_month(session, month, actor)
if (
session.scalar(
select(MonthlySettlement.id).where(MonthlySettlement.settlement_month == month)
)
is None
):
raise BadRequestError("本月工资尚未生成,请随月结结清,无需单独调整")
person = session.get(Personnel, payload.personnel_id, with_for_update=True)
if person is None:
raise BadRequestError("人员不存在")
with audit_service.audit(
session,
operator=actor,
record=AuditRecord(
module="月度工资",
action="试用期结清调整",
summary=f"人员#{person.id}年度{payload.year}结清差额",
request_data=payload.model_dump(),
),
meta=meta,
):
row = next(
(r for r in preview(session, month, person.id, lock=True)["rows"] if r["year"] == payload.year),
None,
)
if row is None or row["fingerprint"] != payload.fingerprint:
raise BadRequestError("年度余额已变化,请重新查询预览")
amount = Decimal(row["delta"])
if not amount:
raise BadRequestError("没有待补发或扣回差额")
order = AdjustmentOrder(
order_no=next_no(session, model=AdjustmentOrder, column_name="order_no", prefix="TZ"),
settlement_month=month,
adjustment_type="PROBATION",
personnel_id=person.id,
personnel_name=person.personnel_name,
direction=1 if amount > 0 else 2,
amount=abs(amount),
reason=payload.reason,
submitter_id=actor.id,
submitter_name=actor.username,
attribution_json={"probation_year": payload.year, "snapshot": row},
)
session.add(order)
session.flush()
if get_settings().payroll_approval_enabled and approval_service.is_enabled(session, 3):
_wire_adjustment_approval(session, order, actor)
else:
order.status = 2
session.add(
ProbationPayment(
adjustment_order_id=order.id,
personnel_id=person.id,
year=payload.year,
month=month,
amount=amount,
snapshot_json=row,
)
)
session.flush()
out = {"order_no": order.order_no, "amount": str(amount), "month": month}
session.commit()
return out