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

352 lines
15 KiB
Python

"""核算预演纯函数:只分配已明确的平账组,不写正式台账。"""
from datetime import date
from decimal import ROUND_HALF_UP, Decimal
from fractions import Fraction
from app.services import formula
from app.services.rate_rules import decide
CENT = Decimal("0.01")
ZERO = Decimal("0")
START = date(2026, 3, 1)
AUGUST = date(2026, 8, 1)
def money(value) -> Decimal:
"""拒绝非有限金额,不用浮点数参与计算。"""
if isinstance(value, float):
raise ValueError("金额必须为十进制字符串")
result = Decimal(str(value))
if not result.is_finite():
raise ValueError("金额必须为有限数字")
return result
def amount_text(value: Decimal) -> str:
"""在展示边界按分四舍五入。"""
return str(value.quantize(CENT, rounding=ROUND_HALF_UP))
def _day(value) -> date:
return date.fromisoformat(str(value)[:10])
def _key(row):
return (_day(row["date"]), int(row["source_id"]), int(row.get("line_id", 0)))
def _beneficiary(shipment, receipt, facts, as_of):
owner = str(shipment.get("personnel_id") or "")
applicable = sorted(
(f for f in facts if f.get("business_key") == shipment.get("business_key")),
key=lambda f: (_day(f["effective_date"]), f.get("version", 0)),
)
for fact in applicable:
# 在职交接仍按每次发货的归属,不把整笔绩效按固定比例拆分。
if (
fact["mode"] == "RESIGNATION"
and _day(fact["effective_date"]) <= _day(as_of)
and owner == str(fact["from_personnel_id"])
):
owner = str(fact["to_personnel_id"])
return owner
def _calculate(shipment, receipt, matched, previously_matched, facts, params):
reasons = list(shipment.get("pending", [])) + list(receipt.get("pending", []))
effective = max(_day(shipment["date"]), _day(receipt["date"]))
is_prepayment = _day(receipt["date"]) < _day(shipment["date"])
if is_prepayment and not params.get("prepayment_parameter_basis"):
reasons.append("PREPAYMENT_PARAMETERS_PENDING")
if effective >= AUGUST and not params.get("august_confirmed", False):
reasons.append("AUGUST_RULE_PENDING")
owner = _beneficiary(shipment, receipt, facts.get("handovers", []), facts["as_of"])
person = facts.get("personnel", {}).get(owner)
original_person = facts.get("personnel", {}).get(str(shipment.get("personnel_id")))
if not person:
reasons.append("PERSONNEL_PENDING")
elif person.get("status") == 0:
known_later_departure = any(
record.get("mode") == "RESIGNATION"
and record.get("business_key") == shipment.get("business_key")
and str(record.get("from_personnel_id")) == owner
and _day(record["effective_date"]) > _day(facts["as_of"])
for record in facts.get("handovers", [])
)
if not known_later_departure:
reasons.append("HANDOVER_PENDING")
cost = shipment.get("cost_total")
if shipment.get("cost_month_basis") == "RECEIPT":
cost = shipment.get("cost_by_month", {}).get(str(receipt["date"])[:7])
if cost is None:
reasons.append("GUIDANCE_PENDING")
category = shipment.get("category")
if category not in ("municipal_water", "rail_transit"):
reasons.append("PRODUCT_CATEGORY_PENDING")
customer_type = shipment.get("customer_type")
if customer_type not in ("personal", "manager_personal", "enterprise"):
reasons.append("CUSTOMER_CATEGORY_PENDING")
parameter_date = (
_day(receipt["date"])
if is_prepayment and params.get("prepayment_parameter_basis") == "RECEIPT"
else effective
)
rate_params = params.get(parameter_date.isoformat())
if rate_params is None:
reasons.append("PARAMETERS_PENDING")
if reasons:
return {"status": "PENDING", "reasons": sorted(set(reasons)), "performance": None}
total = money(shipment["amount"])
cost_total = money(cost)
# 累计舍入差分使同一发货拆成多笔回款时,成本分摊合计仍等于总成本。
before = (cost_total * previously_matched / total).quantize(CENT, rounding=ROUND_HALF_UP)
after = (cost_total * (previously_matched + matched) / total).quantize(
CENT, rounding=ROUND_HALF_UP
)
allocated_cost = after - before
threshold = Fraction(str(rate_params["thresholds"][category]))
converted = formula.converted_profit(
matched, formula.profit_rate(matched - allocated_cost, matched), threshold
)
days = formula.payment_days(_day(shipment["date"]), _day(receipt["date"]))
manager_own = bool(original_person and original_person["role_code"] == "sales_manager")
rate, basis, rule = decide(
rate_params, category=category, customer_type=customer_type,
role_code="sales_manager" if manager_own else person["role_code"],
days=days, prepayment=is_prepayment,
)
calculation_base = matched if basis == "sales" else converted
return {
"status": "READY",
"reasons": [],
"personnel_id": owner,
"manager_id": shipment.get("manager_id"),
"manager_pending": not bool(shipment.get("manager_id")),
"effective_date": effective.isoformat(),
"days": days,
"allocated_cost": amount_text(allocated_cost),
"converted_profit": amount_text(converted),
"profit_threshold": str(rate_params["thresholds"][category]),
"rate": str(rate),
"rate_percent": format(
(Decimal(rate.numerator) * 100 / Decimal(rate.denominator)).quantize(Decimal("0.0001")),
"f",
)
.rstrip("0")
.rstrip(".")
+ "%",
"parameters": rate_params,
"parameter_date": parameter_date.isoformat(),
"performance": amount_text(formula.performance_amount(calculation_base, rate)),
"calculation_base": amount_text(calculation_base),
"basis": basis,
"rule": rule,
"excluded_manager_own": manager_own or person["role_code"] == "sales_manager",
}
def preview_group(group, shipments, receipts, facts, params, until: date) -> dict:
"""按明确分组分配资金,缺条件时保留金额和原因,不猜测绩效。"""
if not group.get("scope_confirmed"):
return {"status": "PENDING", "reasons": ["GROUP_SCOPE_PENDING"], "lines": []}
if not group.get("opening_confirmed") or group.get("opening_balance") is None:
return {"status": "PENDING", "reasons": ["OPENING_BALANCE_PENDING"], "lines": []}
if not group.get("historical_settlement_confirmed"):
return {"status": "PENDING", "reasons": ["HISTORICAL_SETTLEMENT_PENDING"], "lines": []}
shipments = [row for row in shipments if START <= _day(row["date"]) <= until]
receipts = [row for row in receipts if START <= _day(row["date"]) <= until]
excluded_shipments = [row for row in shipments if row.get("performance_excluded")]
shipments = [row for row in shipments if not row.get("performance_excluded")]
excluded_receipts = sorted((row for row in receipts if row.get("exclusion")), key=_key)
receipts = [row for row in receipts if not row.get("exclusion")]
amount_pending = {
reason
for row in shipments
for reason in row.get("pending", [])
if reason in ("FREIGHT_PENDING", "TAX_AMOUNT_PENDING")
}
if amount_pending:
# 净销售额未知会改变后续所有分配,不能先猜零运费再计算同组其他业务。
return {"status": "PENDING", "reasons": sorted(amount_pending), "lines": []}
opening = money(group["opening_balance"])
if opening < 0:
raise ValueError("期初应收不能为负,预收余额应单独登记")
invalid = [r for r in (*shipments, *receipts) if money(r["amount"]) <= 0]
if invalid:
return {"status": "PENDING", "reasons": ["NEGATIVE_OR_ZERO_DOCUMENT"], "lines": []}
if any("NEGATIVE_EVENT_REVIEW_REQUIRED" in r.get("pending", []) for r in shipments):
return {"status": "PENDING", "reasons": ["NEGATIVE_EVENT_REVIEW_REQUIRED"], "lines": []}
if any(
money(r["amount"]) != money(r["amount"]).quantize(CENT) for r in (*shipments, *receipts)
):
return {"status": "PENDING", "reasons": ["AMOUNT_PRECISION_PENDING"], "lines": []}
groups = {str(r.get("group_key", "")) for r in (*shipments, *receipts)}
if groups - {str(group["key"])}:
raise ValueError("禁止跨平账组分配")
ship_rows = sorted(shipments, key=_key)
cash_rows = sorted(receipts, key=_key)
ship_keys = [(str(r["source_id"]), str(r.get("line_id", 0))) for r in ship_rows]
receipt_keys = [str(r["source_id"]) for r in cash_rows]
if len(set(ship_keys)) != len(ship_keys) or len(set(receipt_keys)) != len(receipt_keys):
raise ValueError("预演输入包含重复单据或明细")
# 凭证状态未证实的收款不能消耗后续发货,否则会改变其他款的 FIFO 顺序。
if any(not r.get("voucher_confirmed") for r in cash_rows):
reasons = {"VOUCHER_STATE_PENDING"}
for receipt in cash_rows:
reasons.update(receipt.get("pending", []))
return {"status": "PENDING", "reasons": sorted(reasons), "lines": []}
consumed = [ZERO for _ in ship_rows]
lines = [
{
"kind": "EXCLUDED",
"shipment_id": str(row["source_id"]),
"shipment_no": row.get("source_no"),
"shipment_date": row["date"],
"line_id": str(row.get("line_id", 0)),
"amount": amount_text(money(row["amount"])),
"status": "EXCLUDED",
"performance": None,
"reasons": [row.get("exclusion_reason") or "CORRECTION_EXCLUDED"],
"correction_pair_key": row.get("correction_pair_key"),
}
for row in excluded_shipments
] + [
{
"kind": "EXCLUDED",
"receipt_id": str(row["source_id"]),
"receipt_no": row.get("source_no"),
"receipt_date": row["date"],
"amount": amount_text(money(row["amount"])),
"status": "EXCLUDED",
"performance": None,
"reasons": [row["exclusion"]],
"voucher_evidence": row.get("voucher_evidence"),
"accounting_voucher_code": row.get("accounting_voucher_code"),
}
for row in excluded_receipts
]
remaining_opening = opening
cash_total = ZERO
for receipt in cash_rows:
available = money(receipt["amount"])
cash_total += available
if remaining_opening:
matched = min(available, remaining_opening)
if matched:
lines.append(
{
"kind": "OPENING",
"receipt_id": str(receipt["source_id"]),
"receipt_no": receipt.get("source_no"),
"receipt_date": receipt["date"],
"voucher_evidence": receipt.get("voucher_evidence"),
"accounting_voucher_code": receipt.get("accounting_voucher_code"),
"amount": amount_text(matched),
"status": "LEGACY",
"performance": None,
"reasons": ["LEGACY_SYSTEM_CALCULATES"],
}
)
remaining_opening -= matched
available -= matched
for i, shipment in enumerate(ship_rows):
if available == 0:
break
remaining = money(shipment["amount"]) - consumed[i]
matched = min(available, remaining)
if matched <= 0:
continue
result = _calculate(
shipment,
receipt,
matched,
consumed[i],
{**facts, "as_of": until.isoformat()},
params,
)
lines.append(
{
"kind": "SHIPMENT",
"receipt_id": str(receipt["source_id"]),
"receipt_no": receipt.get("source_no"),
"receipt_date": receipt["date"],
"voucher_evidence": receipt.get("voucher_evidence"),
"accounting_voucher_code": receipt.get("accounting_voucher_code"),
"shipment_id": str(shipment["source_id"]),
"shipment_no": shipment.get("source_no"),
"shipment_date": shipment["date"],
"line_id": str(shipment.get("line_id", 0)),
"product_id": shipment.get("product_id"),
"quantity": shipment.get("quantity"),
"cost_total": shipment.get("cost_total"),
"gross_tax_amount": shipment.get("gross_tax_amount"),
"freight_amount": shipment.get("freight_amount"),
"cost_month_basis": shipment.get("cost_month_basis"),
"cost_by_month": shipment.get("cost_by_month"),
"shipment_remaining_after": amount_text(remaining - matched),
"amount": amount_text(matched),
**result,
}
)
consumed[i] += matched
available -= matched
if available:
lines.append(
{
"kind": "PREPAYMENT",
"receipt_id": str(receipt["source_id"]),
"receipt_no": receipt.get("source_no"),
"receipt_date": receipt["date"],
"voucher_evidence": receipt.get("voucher_evidence"),
"accounting_voucher_code": receipt.get("accounting_voucher_code"),
"amount": amount_text(available),
"status": "WAITING_SHIPMENT",
"performance": None,
"reasons": ["NO_ACTUAL_SHIPMENT"],
}
)
allocated = sum((money(r["amount"]) for r in lines if r["kind"] != "EXCLUDED"), ZERO)
if allocated != cash_total:
raise ValueError("回款分配金额不守恒")
return {
"status": "PARTIAL" if any(r["status"] == "PENDING" for r in lines) else "SUCCEEDED",
"reasons": [],
"lines": lines,
"receipt_total": amount_text(cash_total),
"excluded_receipt_total": amount_text(
sum((money(r["amount"]) for r in excluded_receipts), ZERO)
),
"excluded_shipment_total": amount_text(
sum((money(r["amount"]) for r in excluded_shipments), ZERO)
),
"allocated_total": amount_text(allocated),
"opening_remaining": amount_text(remaining_opening),
"shipment_remaining": amount_text(
sum(
(
money(row["amount"]) - used
for row, used in zip(ship_rows, consumed, strict=True)
),
ZERO,
)
),
"prepayment_remaining": amount_text(
sum((money(r["amount"]) for r in lines if r["kind"] == "PREPAYMENT"), ZERO)
),
"performance_total": amount_text(
sum((money(r["performance"]) for r in lines if r.get("performance") is not None), ZERO)
),
"month_performance_total": amount_text(
sum(
(
money(r["performance"])
for r in lines
if r.get("performance") is not None
and r.get("effective_date", "")[:7] == until.isoformat()[:7]
),
ZERO,
)
),
}