2026-11-01 20:42:53 -05:00
|
|
|
|
"""人工业务核算准备:复用已有单据,显式确认期初、成本与发货归属。"""
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-11-01 20:42:55 -05:00
|
|
|
|
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,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-11-01 20:42:53 -05:00
|
|
|
|
def setup(session, business_id, payload, operator, meta=None):
|
2026-11-05 05:47:13 -05:00
|
|
|
|
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)
|
2026-11-01 20:42:53 -05:00
|
|
|
|
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-05 05:47:13 -05:00
|
|
|
|
# 用户 2026-11-03:抵顶按普通业务计算,特殊应得绩效线下核实,
|
|
|
|
|
|
# 不自动乘80%,也不因抵顶标记阻断整个客户的正常平账。
|
2026-11-01 20:42:53 -05:00
|
|
|
|
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"])
|
2026-11-05 05:47:13 -05:00
|
|
|
|
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]
|
2026-11-01 20:42:53 -05:00
|
|
|
|
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
|