PayrollManagementSystem/backend/app/services/payroll_inputs.py
liuwenxin 108bf87225 feat(核算)!: 添加 T+ 正式核算与采集托管
补齐源快照、资料预演、正式导入及工资核算的依赖链路。
新增版本化输入、逐段 FIFO、生成锁和结构化当月调整,
并提供默认关闭的周期采集、有限重试及 Windows 托管。

原始客户资料、私有配置及首页改动不纳入本次提交。

BREAKING CHANGE: 升级前需应用新增迁移,并通过
PAYROLL_MAKER_USER_ID 绑定实际工资制作人,否则业务只读。
旧单批次核算默认关闭;历史兼容需显式启用
PAYROLL_ALLOW_LEGACY_CALCULATION。生产启用和真实金额待验收。
2026-11-02 09:42:53 +08:00

178 lines
7.6 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 decimal import Decimal
from sqlalchemy import select
from app.core.exceptions import BadRequestError, NotFoundError
from app.models import (
Business,
BusinessItem,
BusinessRole,
Customer,
PayrollInput,
Personnel,
Receipt,
Shipment,
ShipmentItem,
)
from app.services import audit_service, payroll_months
from app.services.audit_service import AuditRecord
from app.services.payroll_calculation import latest_input
from app.services.tplus.workbench import digest
def setup(session, business_id, payload, operator, meta=None):
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module="绩效核算",
action="核算资料版本",
summary=f"业务 #{business_id} 核算资料",
request_data=payload.model_dump(mode="json"),
),
meta=meta,
):
payroll_months.ensure_allocation_editable(session, payload.month, operator)
business = session.get(Business, business_id, with_for_update=True)
if business is None:
raise NotFoundError("业务不存在")
current = latest_input(session, business_id, for_update=True)
if current and current.source_database != "MANUAL":
raise BadRequestError("源导入业务请通过来源差异核对更新,不能用人工资料替换")
if (current.version if current else 0) != payload.expected_version:
raise BadRequestError("核算资料版本已变化,请刷新")
customer = session.get(Customer, business.customer_id)
heads = list(session.scalars(select(Shipment).where(Shipment.business_id == business_id)))
by_head = {h.id: h for h in heads}
items = list(
session.scalars(select(ShipmentItem).where(ShipmentItem.shipment_id.in_(by_head)))
)
assignments = {i.shipment_item_id: i for i in payload.shipment_ownerships}
if len(assignments) != len(payload.shipment_ownerships) or set(assignments) != {
i.id for i in items
}:
raise BadRequestError("请逐条核实全部发货明细归属,不可遗漏、重复或跨业务")
business_items = list(
session.scalars(select(BusinessItem).where(BusinessItem.business_id == business_id))
)
roles = {r.id: r.role_code for r in session.scalars(select(BusinessRole))}
people = {p.id: p for p in session.scalars(select(Personnel))}
key = business.business_no
ships = []
for item in items:
owner = assignments[item.id]
if owner.personnel_id not in people or owner.manager_id not in people:
raise BadRequestError("发货归属人员或分管经理不存在")
if roles.get(people[owner.manager_id].business_role_id) != "sales_manager":
raise BadRequestError("分管人员必须为营销经理")
candidates = [
b
for b in business_items
if (
b.product_id == item.product_id
if item.product_id
else b.product_name == item.product_name and b.spec_model == item.spec_model
)
]
if len(candidates) != 1:
raise BadRequestError("发货产品无法唯一确定核算分类,请先核对业务明细")
head = by_head[item.shipment_id]
if item.amount <= 0:
raise BadRequestError("请先补齐发货销售金额")
ships.append(
{
"source_id": str(head.id),
"line_id": str(item.id),
"source_no": head.shipment_no,
"date": head.ship_date.isoformat(),
"group_key": key,
"business_key": key,
"personnel_id": str(owner.personnel_id),
"manager_id": str(owner.manager_id),
"amount": str(item.amount),
"quantity": str(item.quantity),
"unit": item.unit,
"category": candidates[0].product_category,
"customer_type": customer.customer_type,
"cost_total": str(
(Decimal(owner.unit_cost) * item.quantity).quantize(
Decimal("0.01"), rounding="ROUND_HALF_UP"
)
),
"unit_cost": owner.unit_cost,
"cost_month_basis": "SHIPMENT",
"pending": [],
}
)
receipts = list(session.scalars(select(Receipt).where(Receipt.business_id == business_id)))
if any(r.is_barter for r in receipts):
raise BadRequestError("抵顶特殊绩效继续线下处理,不进入普通FIFO核算")
if current:
previous_costs = {s["line_id"]: s for s in current.data_json["shipments"]}
for ship in ships:
old = previous_costs.get(ship["line_id"])
if old and (old["cost_total"] != ship["cost_total"] or old["date"] != ship["date"]):
raise BadRequestError("原发货月份指导成本已封存,不得随后续回款更改")
handovers = [h.model_dump(mode="json") for h in payload.handovers]
if any(h["business_key"] != key for h in handovers):
raise BadRequestError("交接记录不属于本业务")
values = {
"group": {
"key": key,
"scope_confirmed": True,
"opening_confirmed": True,
"opening_balance": payload.opening_balance,
"historical_settlement_confirmed": True,
},
"shipments": ships,
"receipts": [
{
"source_id": str(r.id),
"source_no": r.receipt_batch_no,
"date": r.receipt_date.isoformat(),
"group_key": key,
"amount": str(r.amount),
"voucher_confirmed": True,
"pending": [],
}
for r in receipts
],
"facts": {
"personnel": {
str(p.id): {"role_code": roles.get(p.business_role_id), "status": p.status}
for p in people.values()
},
"handovers": handovers,
},
"maps": {
"receipts": {str(r.id): r.id for r in receipts},
"personnel": {str(p.id): p.id for p in people.values()},
"shipments": {str(h.id): h.id for h in heads},
"shipment_items": {str(i.id): i.id for i in items},
},
"evidence": payload.evidence,
"guidance_finalized": True,
"as_of_month": payload.month,
}
# 旧资料包含的单据金额和归属保持稳定,新增版本用于新增发货与接手事实。
record = PayrollInput(
business_id=business_id,
version=payload.expected_version + 1,
source_database="MANUAL",
group_key=key,
fingerprint=digest(values),
data_json=values,
requested_by=operator.id,
)
session.add(record)
session.flush()
result = {
"input_id": record.id,
"version": record.version,
"fingerprint": record.fingerprint,
}
session.commit()
return result