打通 T+ 快照、业务、发货、回款和核算准备链路,保留来源差异并提供待补状态。 新增业务自动接入任务、跨平台读取服务托管、权限复核、来源关联和前端核对页面。 补充迁移、隔离集成测试、浏览器模拟验收及角色授权边界修复。
203 lines
8.6 KiB
Python
203 lines
8.6 KiB
Python
"""人工业务核算准备:复用已有单据,显式确认期初、成本与发货归属。"""
|
||
|
||
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 detail(session, business_id):
|
||
"""返回最新核算资料;未准备资料的业务保持空结果。"""
|
||
record = latest_input(session, business_id)
|
||
if record is None:
|
||
return None
|
||
return {
|
||
"id": record.id,
|
||
"version": record.version,
|
||
"source": record.source_database,
|
||
"fingerprint": record.fingerprint,
|
||
"data": record.data_json,
|
||
}
|
||
|
||
|
||
def setup(session, business_id, payload, operator, meta=None):
|
||
source_business = session.get(Business, business_id)
|
||
if source_business and source_business.integration_json:
|
||
from app.services.business_source import prepare
|
||
|
||
return prepare(session, business_id, payload, operator, meta)
|
||
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)))
|
||
# 用户 2026-11-03:抵顶按普通业务计算,特殊应得绩效线下核实,
|
||
# 不自动乘80%,也不因抵顶标记阻断整个客户的正常平账。
|
||
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:
|
||
old_unit_cost = old.get("unit_cost")
|
||
if (old["cost_total"] != ship["cost_total"] or old["date"] != ship["date"]
|
||
or old_unit_cost is None or Decimal(str(old_unit_cost)) != Decimal(ship["unit_cost"])):
|
||
raise BadRequestError("原发货月份指导成本已封存,不得随后续回款更改")
|
||
if current and "handovers" not in payload.model_fields_set:
|
||
handovers = current.data_json.get("facts", {}).get("handovers", [])
|
||
else:
|
||
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
|