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

443 lines
17 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.

"""正式FIFO核算:按月份固化逐段计提和累计可付差额,不覆盖历史工资。"""
import copy
from collections import defaultdict
from datetime import date
from decimal import Decimal
from fractions import Fraction
from sqlalchemy import func, select
from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageResult
from app.models import (
Business,
BusinessRole,
PayrollAllocation,
PayrollInput,
PerformanceLedger,
Personnel,
Receipt,
Shipment,
ShipmentItem,
)
from app.services import audit_service, formula, payroll_months
from app.services.audit_service import AuditRecord
from app.services.param_service import resolve_snapshot
from app.services.tplus.calculation_preview import _parameters
from app.services.tplus.fifo import START, _beneficiary, amount_text, money, preview_group
from app.services.tplus.workbench import digest
# 核算规则版本:改公式或口径时同步更新,写入台账快照用于追溯。
RULE_VERSION = "2026-10-30.2"
def latest_input(session, business_id, *, for_update=False):
statement = (
select(PayrollInput)
.where(PayrollInput.business_id == business_id)
.order_by(PayrollInput.version.desc())
.limit(1)
)
if for_update:
statement = statement.with_for_update().execution_options(populate_existing=True)
return session.scalar(statement)
def _event_key(line):
return digest([line.get("receipt_id"), line.get("shipment_id"), line.get("line_id")])
def _previous(session, business_id, month):
rows = list(
session.scalars(
select(PerformanceLedger)
.where(
PerformanceLedger.business_id == business_id,
PerformanceLedger.calc_type == 1,
PerformanceLedger.is_active == 1,
)
.order_by(PerformanceLedger.id)
.with_for_update()
.execution_options(populate_existing=True)
)
)
if any(r.settlement_month >= month for r in rows):
raise BadRequestError("该业务本月或后续月份已有核算;重算需先处理未生成工资的后续月份")
events = {}
paid = defaultdict(lambda: Decimal(0))
for row in rows:
snapshot = row.snapshot_json or {}
if snapshot.get("engine") != "FIFO":
raise BadRequestError("该业务存在旧口径台账,请先核对历史承接,不能混算")
key = snapshot["event_key"]
if snapshot.get("first_accrual"):
events[key] = snapshot["event"]
paid[key] += row.payable_amount or Decimal(0)
return events, paid
def _inputs(session, record, until):
values = copy.deepcopy(record.data_json)
ships, cash = values["shipments"], values["receipts"]
for row in cash:
if row.get("exclusion"):
continue
current = session.get(Receipt, values["maps"]["receipts"].get(str(row["source_id"])))
if (
current is None
or current.amount != money(row["amount"])
or current.receipt_date.isoformat() != row["date"]
or current.business_id != record.business_id
):
raise BadRequestError("正式回款与核算资料不一致,请重新核对资料版本")
for row in ships:
item_id = values["maps"].get("shipment_items", {}).get(str(row["line_id"]))
if item_id is not None:
item = session.get(ShipmentItem, item_id)
head = session.get(Shipment, item.shipment_id) if item else None
if (
item is None
or head is None
or item.amount != money(row["amount"])
or head.ship_date.isoformat() != row["date"]
or head.business_id != record.business_id
):
raise BadRequestError("正式发货与核算资料不一致,请重新核对资料版本")
days = {row["date"] for row in [*ships, *cash] if row["date"] <= until.isoformat()}
params = _parameters(session, days)
params["prepayment_parameter_basis"], params["august_confirmed"] = "RECEIPT", True
result = preview_group(values["group"], ships, cash, values["facts"], params, until)
if result["status"] != "SUCCEEDED":
reasons = set(result.get("reasons", []))
reasons.update(
reason
for line in result["lines"]
for reason in line.get("reasons", [])
if line["status"] == "PENDING"
)
raise BadRequestError("核算资料待补:" + ",".join(sorted(reasons)))
return values, result
def _post(
session,
business,
record,
event,
key,
month,
owner,
manager,
performance,
payable,
weight_r,
first,
version,
reason=None,
):
from app.services.probation_service import record as record_probation
person = session.get(Personnel, owner)
if person is None:
raise BadRequestError("正式归属人员不存在")
role = session.get(BusinessRole, person.business_role_id)
if role is None:
raise BadRequestError("正式归属人员岗位缺失")
receipt_id = record.data_json["maps"]["receipts"].get(str(event["receipt_id"]))
if receipt_id is None:
raise BadRequestError("回款尚未正式导入")
row = PerformanceLedger(
receipt_id=receipt_id,
calc_version=version,
allocation_key=key,
is_active=1,
calc_type=1,
business_id=business.id,
business_no=business.business_no,
personnel_id=owner,
personnel_name=person.personnel_name,
role_code=role.role_code,
customer_type=event["customer_type"],
product_category=event["category"],
ship_date=date.fromisoformat(event["shipment_date"]),
receipt_date=date.fromisoformat(event["receipt_date"]),
payment_days=event["days"],
rate_text=event["rate"],
converted_profit=money(event["converted_profit"]) if first else Decimal(0),
performance_amount=performance,
payable_amount=payable,
barter_applied=0,
weight_r=weight_r,
payment_weight=formula.to_decimal(formula.payment_weight(weight_r)),
settlement_month=month,
is_probation=1 if event.get("is_probation", False) else 0,
missing_cost=0,
calc_reason=reason,
snapshot_json={
"engine": "FIFO",
"input_id": record.id,
"event_key": key,
"event": event,
"first_accrual": first,
"manager_id": manager,
"manager_own_excluded": event.get("excluded_manager_own", False),
"payable_delta": amount_text(payable),
"rule_version": RULE_VERSION,
"recalc_reason": reason,
},
)
session.add(row)
session.flush()
record_probation(session, row)
session.add(
PayrollAllocation(
ledger_id=row.id,
business_id=business.id,
line_no=1,
input_id=record.id,
amount=money(event["amount"]) if first else Decimal(0),
data_json={
**event,
"event_key": key,
"first_accrual": first,
"performance_delta": amount_text(performance),
"payable_delta": amount_text(payable),
"beneficiary_id": owner,
"manager_id": manager,
"recalc_reason": reason,
},
)
)
return row
def calculate(session, business_id, month, *, operator, meta=None, reason=None):
"""重算从指定月起级联重建尚未生成工资的月份,同一事务保存全部版本。"""
from app.services.manager_share import month_end
month_end(month)
if reason is not None and not reason.strip():
raise BadRequestError("重算原因不能为空")
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module="绩效核算",
action="正式FIFO重算" if reason else "正式FIFO核算",
summary=f"业务 #{business_id} 核算 {month}",
request_data={"business_id": business_id, "month": month, "reason": reason},
),
meta=meta,
):
months = {month}
if reason:
months.update(
session.scalars(
select(PerformanceLedger.settlement_month).where(
PerformanceLedger.business_id == business_id,
PerformanceLedger.calc_type == 1,
PerformanceLedger.settlement_month >= month,
)
)
)
for current_month in sorted(months):
payroll_months.ensure_allocation_editable(session, current_month, operator)
business = session.get(Business, business_id, with_for_update=True)
if business is None:
raise NotFoundError("业务不存在")
record = latest_input(session, business_id, for_update=True)
if record is None:
raise BadRequestError("请先补录该业务的正式核算资料")
if reason:
old_rows = list(
session.scalars(
select(PerformanceLedger)
.where(
PerformanceLedger.business_id == business_id,
PerformanceLedger.is_active == 1,
PerformanceLedger.calc_type == 1,
PerformanceLedger.settlement_month >= month,
)
.with_for_update()
)
)
if not any(r.settlement_month == month for r in old_rows):
raise BadRequestError("该月尚无可重算台账")
if {r.settlement_month for r in old_rows} - months:
raise BadRequestError("后续月份在核算期间变化,请刷新后重试")
for old in old_rows:
if old.settlement_id is not None:
raise BadRequestError("后续月份已有工资生成,原工资保留,请走当月调整单")
old.is_active = 0
session.flush()
outputs = [
_calculate_month(
session, business, record, current_month, reason.strip() if reason else None
)
for current_month in sorted(months)
]
out = outputs[0]
out["recalculated_months"] = sorted(months) if reason else []
session.commit()
return out
def _calculate_month(session, business, record, month, reason=None):
from app.services.manager_share import month_end
from app.services.probation_service import on_date
business_id = business.id
until = month_end(month)
previous, paid = _previous(session, business_id, month)
values, result = _inputs(session, record, until)
active_lines = [line for line in result["lines"] if line["kind"] == "SHIPMENT"]
current = {}
for line in active_lines:
key = _event_key(line)
if key in previous:
if any(
str(line.get(field)) != str(previous[key].get(field))
for field in ("amount", "shipment_date", "receipt_date")
):
raise BadRequestError("历史分配金额或日期已变化,请通过更正或调整处理")
current[key] = previous[key]
elif line["effective_date"][:7] < month:
raise BadRequestError(f"请先完成 {line['effective_date'][:7]} 的业务核算")
else:
shipments_by_line = {
str(s["line_id"]): s for s in values["shipments"]
}
ship = shipments_by_line.get(str(line["line_id"]))
if ship is None:
raise BadRequestError(f"发货明细缺失,行号 {line['line_id']} 未找到,请重新核对资料")
current[key] = {
**line,
"category": ship["category"],
"customer_type": ship["customer_type"],
"source_owner": ship["personnel_id"],
"business_key": ship["business_key"],
}
if set(previous) - current.keys():
raise BadRequestError("历史已核算事件发生变化,请核对更正或调整,不可直接覆盖")
# 启用前的发货由期初应收承接,不再进入新业务的起付比例分母。
sales = sum(
(
money(s["amount"])
for s in values["shipments"]
if START.isoformat() <= s["date"] <= until.isoformat()
and not s.get("performance_excluded")
),
Decimal(0),
)
matched = sum((money(line["amount"]) for line in active_lines), Decimal(0))
snapshot = resolve_snapshot(session, until)
threshold = Fraction(snapshot.get("payment.start.ratio"))
weight_r = formula.clamp_weight_r(matched, sales)
start_ok = formula.start_payment_ok(matched, sales, threshold)
weight = formula.payment_weight(weight_r) if start_ok else Fraction(0)
rows = []
receipt_versions = {}
for key, event in current.items():
first = key not in previous
performance = money(event["performance"])
desired = formula.to_decimal(Fraction(performance) * weight).quantize(
Decimal("0.01"), rounding="ROUND_HALF_UP"
)
delta = desired - paid[key]
if not first and delta == 0:
continue
target = _beneficiary(
{"personnel_id": event["personnel_id"], "business_key": event["business_key"]},
{},
values["facts"].get("handovers", []),
until.isoformat(),
)
owner = values["maps"]["personnel"].get(str(target))
if owner is None:
raise BadRequestError("接手人员尚未正式导入")
person = session.get(Personnel, owner)
# 试用判定以回款日为准:_post 会把结果写进快照,历史台账不再按当前状态重分类。
receipt_day = date.fromisoformat(event["receipt_date"])
event = {
**event,
"is_probation": on_date(person, receipt_day),
"probation_year": event["receipt_date"][:4],
}
source_manager = event.get("manager_id")
manager = values["maps"]["personnel"].get(str(source_manager)) if source_manager else None
if not event.get("excluded_manager_own") and not manager:
raise BadRequestError("请补录该业务在发货时的经理分管关系")
rid = values["maps"]["receipts"].get(str(event["receipt_id"]))
if rid not in receipt_versions:
receipt_versions[rid] = (
int(
session.scalar(
select(func.max(PerformanceLedger.calc_version)).where(
PerformanceLedger.receipt_id == rid
)
)
or 0
)
+ 1
)
rows.append(
_post(
session,
business,
record,
event,
key,
month,
owner,
manager,
performance if first else Decimal(0),
delta,
weight_r,
first,
receipt_versions[rid],
reason=reason,
)
)
if not rows and not reason:
raise BadRequestError("本月没有新增计提或应付差额")
for row in rows:
row.calc_reason = reason
if reason:
row.snapshot_json = {**row.snapshot_json, "recalc_reason": reason}
for rid in receipt_versions:
session.get(Receipt, rid).settlement_status = 1
session.flush()
out = {
"month": month,
"business_id": business_id,
"ledger_ids": [r.id for r in rows],
"performance": amount_text(sum((r.performance_amount for r in rows), Decimal(0))),
"payable": amount_text(sum((r.payable_amount for r in rows), Decimal(0))),
"start_payment_ok": start_ok,
}
return out
def allocation_page(session, ledger_id, query):
if session.get(PerformanceLedger, ledger_id) is None:
raise NotFoundError("台账不存在")
condition = PayrollAllocation.ledger_id == ledger_id
rows = session.scalars(
select(PayrollAllocation)
.where(condition)
.order_by(PayrollAllocation.line_no)
.offset(query.offset)
.limit(query.page_size)
)
return PageResult(
records=[
{"id": r.id, "input_id": r.input_id, "amount": str(r.amount), **r.data_json}
for r in rows
],
total=session.scalar(select(func.count()).select_from(PayrollAllocation).where(condition))
or 0,
page_num=query.page_num,
page_size=query.page_size,
)