PayrollManagementSystem/backend/app/services/payroll_calculation.py

470 lines
18 KiB
Python
Raw Normal View History

"""正式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.personnel_roles import input_role
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-11-04.1"
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):
from app.services.receipt_confirmation import ensure_confirmed
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("正式回款与核算资料不一致,请重新核对资料版本")
ensure_confirmed(session, [
values["maps"]["receipts"].get(str(row["source_id"]))
for row in cash if not row.get("exclusion") and row["date"] <= until.isoformat()
])
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.get("gross_tax_amount", row["amount"]) if values.get("input_schema") == "TPLUS_SYNC_V1" else 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=input_role(record.data_json, owner, event["effective_date"]) or 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):
business = session.get(Business, business_id)
if business is not None and business.integration_json:
from app.services.business_source import source_calculation_lock
with source_calculation_lock(session):
return _calculate_locked(session, business_id, month, operator=operator, meta=meta, reason=reason)
return _calculate_locked(session, business_id, month, operator=operator, meta=meta, reason=reason)
def _calculate_locked(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 record.data_json.get("input_schema") == "TPLUS_SYNC_V1":
from app.services.business_source import LABELS, readiness
state = readiness(session, business_id)
blocking = {r["code"] for r in state["reasons"]} & {
"SOURCE_CONFLICT", "SOURCE_SNAPSHOT_PENDING", "INPUT_REFRESH_REQUIRED", "CUSTOMER_CATEGORY_PENDING",
"PRODUCT_CATEGORY_PENDING", "NO_ACTUAL_SHIPMENT", "SALES_RELATION_PENDING",
}
if blocking:
raise BadRequestError("核算资料待补:" + ";".join(LABELS[code] for code in sorted(blocking)))
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,
)