PayrollManagementSystem/backend/app/services/business_source.py
liuwenxin b8920c0d28 fix(T+): 补齐指导成本与读取服务保护
补充业务详情指导成本入口、来源版本和历史封存保护,接入月份锁并完善权限复核。
同步接入读取服务运行状态到页面,补充前端控制、回归测试和服务端只读连接授权校验。

定向隔离测试:89 项通过,1 条既有 Starlette 弃用警告。
2026-11-05 19:32:20 +08:00

995 lines
60 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 collections import defaultdict
from contextlib import contextmanager
from datetime import date
from decimal import ROUND_HALF_UP, Decimal
from sqlalchemy import Numeric, String, and_, cast, func, literal, or_, select, union
from sqlalchemy.orm import aliased
from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageResult
from app.models import (
Business,
BusinessItem,
BusinessRole,
Customer,
PayrollInput,
Personnel,
Product,
Receipt,
Shipment,
ShipmentItem,
TPlusBusinessDocument,
TPlusBusinessSyncConfig,
TPlusFact,
TPlusSourceHead,
TPlusSyncRecord,
TPlusWorkRun,
)
from app.services import payroll_months
from app.services.personnel_roles import role_on
from app.services.tplus.calculation_preview import _voucher_evidence
from app.services.tplus.workbench import digest, latest_facts
LABELS = {
"CUSTOMER_CATEGORY_PENDING": "请确认客户核算类型",
"PRODUCT_CATEGORY_PENDING": "请补齐产品核算分类",
"PERSONNEL_PENDING": "请逐条确认发货归属人员及历史岗位",
"OPENING_BALANCE_PENDING": "请核实2026年2月末期初应收与历史承接",
"GUIDANCE_PENDING": "请录入发货月份指导成本并封存",
"FREIGHT_PENDING": "请核实源销售金额中的运费",
"NO_ACTUAL_SHIPMENT": "尚未发货,可继续关联订单和预收款",
"SOURCE_CONFLICT": "来源有差异或异常记录,请先核对",
"SOURCE_SNAPSHOT_PENDING": "最新完整来源快照尚未完成业务接入,请等待或重试接入",
"INPUT_REFRESH_REQUIRED": "新增单据或资料已变化,请刷新核算资料版本",
"SOURCE_DOCUMENT_INACTIVE": "存在尚未生效的来源单据",
"SALES_RELATION_PENDING": "订单与发货明细的关联不完整,请核对销售额,避免重复统计",
}
def business(session, business_id):
row = session.get(Business, business_id)
if row is None:
raise NotFoundError("业务不存在")
return row
def ensure_source_editable(session, entity, target_id):
"""来源单据不得通过手工全量替换接口绕过差异核对。"""
linked = session.scalar(select(TPlusBusinessDocument.id).where(
TPlusBusinessDocument.entity_type == entity,
TPlusBusinessDocument.target_id == target_id,
TPlusBusinessDocument.applied_record_id.is_not(None),
).limit(1))
if linked is not None:
raise BadRequestError("T+ 来源单据请通过差异核对处理,不能直接编辑或删除")
def _latest(session, business_id):
return session.scalar(select(PayrollInput).where(PayrollInput.business_id == business_id).order_by(PayrollInput.version.desc()).limit(1))
def sales_relations_pending(session, business_ids):
"""批量核对当前明细,兼容尚未由新版 worker 更新标记的旧业务。"""
if not business_ids:
return set()
order_match = select(TPlusBusinessDocument.id).where(
TPlusBusinessDocument.business_id == BusinessItem.business_id,
TPlusBusinessDocument.entity_type == "business_items",
TPlusBusinessDocument.status == "APPLIED",
TPlusBusinessDocument.target_id == BusinessItem.id,
).exists()
rows = session.execute(select(BusinessItem.business_id, order_match).where(
BusinessItem.business_id.in_(business_ids)).distinct())
matches, unmatched = set(), set()
for business_id, matched in rows:
(matches if matched else unmatched).add(business_id)
mismatch = set(session.scalars(select(TPlusBusinessDocument.business_id).where(
TPlusBusinessDocument.business_id.in_(business_ids),
TPlusBusinessDocument.status == "CONFLICT",
TPlusBusinessDocument.reason == "ORDER_SHIPMENT_MISMATCH",
).distinct()))
pending = (matches & unmatched) | mismatch
if not pending:
return pending
from app.models import TPlusFact
from app.services.tplus.sales_relations import KIND, resolve_items
# 仅曾确认过的业务需要进一步检查事实;大多数待核对业务无需逐条加载。
confirmed = set(session.scalars(select(Business.id).join(TPlusFact, (
TPlusFact.source_database == Business.integration_json["source_database"].as_string())
& (TPlusFact.kind == KIND) & (TPlusFact.fact_key.like(func.concat("sales:", Business.id, ":%")))
).where(Business.id.in_(pending)).distinct())) if session.bind.dialect.name == "mysql" else {
business_id for business_id in pending if session.scalar(select(TPlusFact.id).where(
TPlusFact.kind == KIND, TPlusFact.fact_key.like(f"sales:{business_id}:%")).limit(1))
}
for business_id in confirmed:
business = session.get(Business, business_id)
if not business or not business.integration_json:
continue
items = list(session.scalars(select(BusinessItem).where(BusinessItem.business_id == business_id)))
order_ids = list(session.scalars(select(TPlusBusinessDocument.target_id).where(
TPlusBusinessDocument.business_id == business_id,
TPlusBusinessDocument.entity_type == "business_items", TPlusBusinessDocument.status == "APPLIED")))
if business_id not in mismatch and not resolve_items(session, business, items, order_ids)[0]:
pending.discard(business_id)
return pending
def sales_relation_pending(session, business_id):
return business_id in sales_relations_pending(session, [business_id])
def readiness_summaries(session, rows, *, snapshot_cache=None):
"""批量计算列表准备状态,与详情使用同一组判断,不按历史输入是否存在猜测。"""
sources = [row for row in rows if row.integration_json]
if not sources:
return {}
ids = [row.id for row in sources]
customers = {c.id: c for c in session.scalars(select(Customer).where(
Customer.id.in_({row.customer_id for row in sources})))}
missing_products = set(session.scalars(select(BusinessItem.business_id).join(
Product, Product.id == BusinessItem.product_id).where(
BusinessItem.business_id.in_(ids), Product.product_category.is_(None)).distinct()))
shipped = set(session.scalars(select(Shipment.business_id).where(
Shipment.business_id.in_(ids)).distinct()))
latest = select(PayrollInput.business_id, func.max(PayrollInput.version).label("version")).where(
PayrollInput.business_id.in_(ids)).group_by(PayrollInput.business_id).subquery()
inputs = {item.business_id: item for item in session.scalars(select(PayrollInput).join(
latest, (PayrollInput.business_id == latest.c.business_id) & (PayrollInput.version == latest.c.version)))}
lines = defaultdict(dict)
receipts = defaultdict(set)
if inputs:
for business_id, item_id, category in session.execute(select(
Shipment.business_id, ShipmentItem.id, Product.product_category).join(
Shipment, Shipment.id == ShipmentItem.shipment_id).outerjoin(
Product, Product.id == ShipmentItem.product_id).where(Shipment.business_id.in_(inputs))):
lines[business_id][item_id] = category
for business_id, receipt_id in session.execute(select(Receipt.business_id, Receipt.id).where(
Receipt.business_id.in_(inputs))):
receipts[business_id].add(receipt_id)
conflicts = set(session.scalars(select(TPlusBusinessDocument.business_id).where(
TPlusBusinessDocument.business_id.in_(ids),
TPlusBusinessDocument.status.in_(("CONFLICT", "PENDING"))).distinct()))
relation_pending = sales_relations_pending(session, ids)
snapshots = snapshot_cache if snapshot_cache is not None else {}
for source in {row.integration_json["source_database"] for row in sources}:
if source not in snapshots:
snapshots[source] = source_snapshot_ready(session, source)
result = {}
for row in sources:
reasons = set()
customer = customers[row.customer_id]
record = inputs.get(row.id)
if not snapshots[row.integration_json["source_database"]]:
reasons.add("SOURCE_SNAPSHOT_PENDING")
if row.id in relation_pending:
reasons.add("SALES_RELATION_PENDING")
if customer.customer_type is None:
reasons.add("CUSTOMER_CATEGORY_PENDING")
if row.id in missing_products:
reasons.add("PRODUCT_CATEGORY_PENDING")
if row.id not in shipped:
reasons.add("NO_ACTUAL_SHIPMENT")
if record is None:
reasons.update(("OPENING_BALANCE_PENDING", "GUIDANCE_PENDING", "PERSONNEL_PENDING"))
if row.id in shipped:
reasons.add("FREIGHT_PENDING")
else:
maps = record.data_json.get("maps", {})
if (set(lines[row.id]) != set(maps.get("shipment_items", {}).values()) or
receipts[row.id] != set(maps.get("receipts", {}).values())):
reasons.add("INPUT_REFRESH_REQUIRED")
for item in record.data_json.get("shipments", []):
item_id = maps.get("shipment_items", {}).get(str(item.get("line_id")))
if (item_id not in lines[row.id] or item.get("category") != lines[row.id][item_id]
or item.get("customer_type") != customer.customer_type):
reasons.add("INPUT_REFRESH_REQUIRED")
if row.id in conflicts:
reasons.add("SOURCE_CONFLICT")
result[row.id] = {"status": "PENDING" if reasons else "READY",
"reasons": [{"code": code, "message": LABELS[code]} for code in sorted(reasons)]}
return result
def sales_relation_candidates(session, business_id, query, shipment_page=1, order_page=1):
"""只读列出候选,金额与产品相同不代表已确认关联。"""
from app.services.tplus.sales_relations import (
_fact_is_current,
_latest_business_facts,
item_revision,
order_revision,
shipment_revision,
)
row = business(session, business_id)
if not row.integration_json:
raise BadRequestError("仅来源业务需要核对订单与发货关联")
if query.page_size > 20:
raise BadRequestError("每页最多查询 20 条待核对明细")
source = row.integration_json["source_database"]
order_match = select(TPlusBusinessDocument.id).where(
TPlusBusinessDocument.business_id == business_id,
TPlusBusinessDocument.entity_type == "business_items",
TPlusBusinessDocument.status == "APPLIED",
TPlusBusinessDocument.target_id == BusinessItem.id,
).exists()
conditions = (BusinessItem.business_id == business_id, ~order_match)
total = session.scalar(select(func.count()).select_from(BusinessItem).where(*conditions)) or 0
items = list(session.scalars(select(BusinessItem).where(*conditions).order_by(BusinessItem.id)
.offset(query.offset).limit(query.page_size)))
facts = {fact.fact_key: fact for fact in _latest_business_facts(session, source, business_id)}
def confirmed_source(document_id, entity, parent_entity):
doc = session.get(TPlusBusinessDocument, document_id) if document_id else None
if not doc or doc.business_id != business_id or doc.source_database != source or doc.entity_type != entity:
return None
parent = session.scalar(select(TPlusBusinessDocument).where(
TPlusBusinessDocument.business_id == business_id,
TPlusBusinessDocument.source_database == source,
TPlusBusinessDocument.entity_type == parent_entity,
TPlusBusinessDocument.source_id == str(doc.data_json.get("source_parent_id"))).limit(1))
return {"source_id": doc.source_id, "source_no": parent.source_no if parent else None}
results = []
for item in items:
head_doc = aliased(TPlusBusinessDocument)
candidates = list(session.execute(select(TPlusBusinessDocument, head_doc, Shipment, ShipmentItem)
.join(ShipmentItem, ShipmentItem.id == TPlusBusinessDocument.target_id)
.join(Shipment, Shipment.id == ShipmentItem.shipment_id)
.join(head_doc, (head_doc.business_id == business_id) & (head_doc.entity_type == "shipments")
& (head_doc.source_database == source)
& (head_doc.target_id == Shipment.id)
& (head_doc.source_id == TPlusBusinessDocument.data_json["source_parent_id"].as_string()))
.where(TPlusBusinessDocument.business_id == business_id,
TPlusBusinessDocument.source_database == source,
TPlusBusinessDocument.entity_type == "shipment_items",
TPlusBusinessDocument.status == "APPLIED",
TPlusBusinessDocument.record_id == TPlusBusinessDocument.applied_record_id,
head_doc.status == "APPLIED", head_doc.record_id == head_doc.applied_record_id,
Shipment.business_id == business_id, ShipmentItem.product_id == item.product_id,
ShipmentItem.quantity == item.quantity, ShipmentItem.amount == item.sale_amount)
.order_by(TPlusBusinessDocument.id).offset((shipment_page - 1) * 20).limit(21)))
order_head = aliased(TPlusBusinessDocument)
orders = list(session.execute(select(TPlusBusinessDocument, order_head, BusinessItem)
.join(BusinessItem, BusinessItem.id == TPlusBusinessDocument.target_id)
.join(order_head, (order_head.business_id == business_id) & (order_head.entity_type == "businesses")
& (order_head.source_database == source)
& (order_head.source_id == TPlusBusinessDocument.data_json["source_parent_id"].as_string()))
.where(TPlusBusinessDocument.business_id == business_id,
TPlusBusinessDocument.source_database == source,
TPlusBusinessDocument.entity_type == "business_items",
TPlusBusinessDocument.status == "APPLIED", BusinessItem.product_id == item.product_id,
TPlusBusinessDocument.record_id == TPlusBusinessDocument.applied_record_id,
order_head.status == "APPLIED", order_head.record_id == order_head.applied_record_id)
.order_by(TPlusBusinessDocument.id).offset((order_page - 1) * 20).limit(21)))
fact = facts.get(f"sales:{business_id}:{item.id}")
results.append({"item_id": item.id, "product_name": item.product_name, "spec": item.spec_model,
"quantity": str(item.quantity), "amount": str(item.sale_amount), "item_revision": item_revision(item),
"version": fact.version if fact else 0,
"confirmation": ({"disposition": fact.data_json.get("disposition"),
"shipment_document_id": fact.data_json.get("shipment_document_id"),
"order_document_id": fact.data_json.get("order_document_id"),
"shipment": confirmed_source(fact.data_json.get("shipment_document_id"), "shipment_items", "shipments"),
"order": confirmed_source(fact.data_json.get("order_document_id"), "business_items", "businesses"),
"evidence": fact.evidence, "current": _fact_is_current(session, row, item, fact)}
if fact else None),
"shipments": [{"document_id": doc.id, "source_id": doc.source_id,
"source_no": shipment.source_no, "date": shipment.ship_date.isoformat(),
"shipment_item_id": line.id, "revision": shipment_revision(doc, head, shipment, line)}
for doc, head, shipment, line in candidates[:20]],
"shipments_truncated": len(candidates) > 20,
"orders": [{"document_id": doc.id, "source_id": doc.source_id,
"item_id": order.id, "quantity": str(order.quantity), "amount": str(order.sale_amount),
"revision": order_revision(doc, head, order)}
for doc, head, order in orders[:20]], "orders_truncated": len(orders) > 20})
return PageResult(records=results, total=total, page_num=query.page_num, page_size=query.page_size)
def readiness(session, business_id):
row = business(session, business_id)
if not row.integration_json:
return {"source_type": "MANUAL", "status": "MANUAL", "reasons": [], "products": [], "customer_type": session.get(Customer, row.customer_id).customer_type}
customer = session.get(Customer, row.customer_id)
product_ids = select(BusinessItem.product_id).where(BusinessItem.business_id == business_id, BusinessItem.product_id.is_not(None))
products = list(session.scalars(select(Product).where(Product.id.in_(product_ids)).order_by(Product.id)))
record = _latest(session, business_id)
summary = readiness_summaries(session, [row])[business_id]
reasons = {reason["code"] for reason in summary["reasons"]}
eligible_receipts = None
if (record is not None and record.data_json.get("input_schema") == "TPLUS_SYNC_V1"
and not reasons.intersection({"SOURCE_CONFLICT", "SOURCE_SNAPSHOT_PENDING", "INPUT_REFRESH_REQUIRED"})):
entries = record.data_json.get("receipts", [])
if all(r.get("exclusion") or r.get("voucher_confirmed") for r in entries):
eligible_receipts = str(sum(
(Decimal(str(r["amount"])) for r in entries if not r.get("exclusion")), Decimal("0.00")
))
return {"source_type": "TPLUS", **summary,
"source_database": row.integration_json["source_database"], "customer_type": customer.customer_type,
"version": record.version if record else 0,
"source_personnel": row.integration_json.get("personnel_names", []),
"eligible_receipts_snapshot": eligible_receipts,
"products": [{"id": p.id, "name": p.product_name, "spec": p.spec_model, "category": p.product_category} for p in products[:200]],
"products_total": len(products)}
def source_snapshot_ready(session, source):
"""仅认可覆盖当前完整快照且已处理结束的业务接入任务。"""
from app.services.tplus.business_sync import START
from app.services.tplus.snapshots import source_manifest
from app.services.tplus.workbench import WorkbenchError
active = session.scalar(select(TPlusWorkRun.id).where(
TPlusWorkRun.source_database == source, TPlusWorkRun.kind == "BUSINESS_SYNC",
TPlusWorkRun.status.in_(("QUEUED", "RUNNING"))).limit(1))
if active is not None:
return False
try:
manifest = source_manifest(session, source)
except WorkbenchError:
return False
run = session.scalar(select(TPlusWorkRun).where(
TPlusWorkRun.source_database == source, TPlusWorkRun.kind == "BUSINESS_SYNC"
).order_by(TPlusWorkRun.id.desc()).limit(1))
if not run or run.status not in ("SUCCEEDED", "PARTIAL") or (run.summary_json or {}).get("source_manifest") != manifest:
return False
config = session.scalar(select(TPlusBusinessSyncConfig).where(TPlusBusinessSyncConfig.source_database == source))
try:
run_start = date.fromisoformat((run.input_json or {}).get("start_date", START.isoformat()))
except (TypeError, ValueError):
return False
return run_start <= (config.start_date if config else START)
@contextmanager
def source_calculation_lock(session):
"""在新事务中持锁检查快照并提交,避免沿用鉴权查询建立的旧读视图。"""
if session.get_bind().dialect.name != "mysql":
yield
return
if session.new or session.deleted or any(session.is_modified(obj) for obj in session.dirty):
raise BadRequestError("核算前存在未提交的修改,请先完成当前操作")
session.rollback()
from app.core.tplus_lock import TPlusBusyError, sync_lock
try:
with sync_lock(session):
yield
except TPlusBusyError:
raise BadRequestError("T+ 数据正在采集或接入,请完成后再核算") from None
def _source_values(record):
raw = record.payload_json
def first(*names):
return next((raw[name] for name in names if raw.get(name) is not None), None)
return {"source_no": record.source_no, "date": first("voucherdate", "voucherDate"),
"customer_id": first("idcustomer", "idpartner"), "personnel_id": first("idclerk", "idperson"),
"product_id": first("idinventory"), "order_id": first("saleOrderId"),
"order_detail_id": first("saleOrderDetailId"),
"quantity": first("quantity"), "price": first("taxPrice", "price"),
"price_type": ("含税" if raw.get("taxPrice") is not None else "未税" if raw.get("price") is not None else None),
"amount": first("taxAmount", "amount"), "state": first("voucherState", "voucherstate")}
def _source_changes(previous, current):
labels = {"source_no": "来源单号", "date": "日期", "customer_id": "客户标识", "personnel_id": "经办人标识",
"product_id": "产品标识", "order_id": "订单标识", "order_detail_id": "订单明细标识",
"quantity": "数量", "price": "单价", "price_type": "单价税别", "amount": "金额", "state": "单据状态"}
old, new = _source_values(previous), _source_values(current)
def text(value):
return str(value) if value is not None else None
return [{"field": key, "label": label, "before": text(old[key]), "after": text(new[key])}
for key, label in labels.items() if text(old[key]) != text(new[key])]
def source_quantities(session, business_id, query):
"""按产品和单位分别统计已接入的来源订单及发货数量。"""
business(session, business_id)
zero = literal(0, type_=Numeric(14, 2))
orders = select(
BusinessItem.product_id.label("product_id"), BusinessItem.product_name.label("product_name"),
BusinessItem.spec_model.label("spec_model"), BusinessItem.unit.label("unit"),
BusinessItem.quantity.label("order_quantity"), zero.label("shipment_quantity"),
).join(TPlusBusinessDocument, and_(TPlusBusinessDocument.target_id == BusinessItem.id,
TPlusBusinessDocument.entity_type == "business_items")).where(
TPlusBusinessDocument.business_id == business_id,
TPlusBusinessDocument.applied_record_id.is_not(None), BusinessItem.business_id == business_id)
shipments = select(
ShipmentItem.product_id.label("product_id"), ShipmentItem.product_name.label("product_name"),
ShipmentItem.spec_model.label("spec_model"), ShipmentItem.unit.label("unit"),
zero.label("order_quantity"), ShipmentItem.quantity.label("shipment_quantity"),
).join(TPlusBusinessDocument, and_(TPlusBusinessDocument.target_id == ShipmentItem.id,
TPlusBusinessDocument.entity_type == "shipment_items")).join(
Shipment, Shipment.id == ShipmentItem.shipment_id).where(
TPlusBusinessDocument.business_id == business_id,
TPlusBusinessDocument.applied_record_id.is_not(None), Shipment.business_id == business_id)
combined = orders.union_all(shipments).subquery()
grouped = select(combined.c.product_id, combined.c.product_name, combined.c.spec_model, combined.c.unit,
func.sum(combined.c.order_quantity).label("order_quantity"),
func.sum(combined.c.shipment_quantity).label("shipment_quantity")).group_by(
combined.c.product_id, combined.c.product_name, combined.c.spec_model, combined.c.unit).subquery()
total = session.scalar(select(func.count()).select_from(grouped)) or 0
rows = session.execute(select(grouped).order_by(grouped.c.product_name, grouped.c.spec_model,
grouped.c.unit, grouped.c.product_id).offset(query.offset).limit(query.page_size)).mappings()
result = [{**row, "order_quantity": str(row["order_quantity"]),
"shipment_quantity": str(row["shipment_quantity"])} for row in rows]
return PageResult(records=result, total=total, page_num=query.page_num, page_size=query.page_size)
def _price_repair_revision(doc, parent, target, record):
return digest(["source-price-v1", doc.id, doc.applied_record_id, doc.record_id, doc.target_id,
parent.id, parent.record_id, parent.applied_record_id, parent.fingerprint,
parent.status, parent.reason, target.id, str(target.sale_price), record.id, record.row_hash])
def documents(session, business_id, query, entity=None):
from app.services.tplus.snapshots import source_records
from app.services.tplus.workbench import WorkbenchError
business(session, business_id)
conditions = [TPlusBusinessDocument.business_id == business_id]
if entity:
conditions.append(TPlusBusinessDocument.entity_type == entity)
total = session.scalar(select(func.count()).select_from(TPlusBusinessDocument).where(*conditions)) or 0
rows = list(session.execute(select(TPlusBusinessDocument, TPlusSyncRecord).join(TPlusSyncRecord, TPlusSyncRecord.id == TPlusBusinessDocument.record_id)
.where(*conditions).order_by(TPlusBusinessDocument.id).offset(query.offset).limit(query.page_size)))
previous_ids = {doc.applied_record_id for doc, _ in rows if doc.applied_record_id and doc.applied_record_id != doc.record_id}
previous_records = {record.id: record for record in session.scalars(select(TPlusSyncRecord).where(TPlusSyncRecord.id.in_(previous_ids)))} if previous_ids else {}
details = {}
for source_database, entity_type in {(doc.source_database, doc.entity_type) for doc, _ in rows
if doc.entity_type in ("business_items", "shipment_items")}:
ids = [doc.source_id for doc, _ in rows if doc.source_database == source_database and doc.entity_type == entity_type]
try:
details[(source_database, entity_type)] = {
record.source_id: record for record in source_records(session, source_database, entity_type, ids=ids)
}
except WorkbenchError:
details[(source_database, entity_type)] = None
detail_docs = [doc for doc, _ in rows if doc.entity_type in ("business_items", "shipment_items")]
parent_ids = {str(doc.data_json["source_parent_id"]) for doc in detail_docs if doc.data_json.get("source_parent_id") is not None}
parents = {(doc.source_database, doc.entity_type, doc.source_id): doc for doc in session.scalars(
select(TPlusBusinessDocument).where(TPlusBusinessDocument.business_id == business_id,
TPlusBusinessDocument.entity_type.in_(("businesses", "shipments")),
TPlusBusinessDocument.source_id.in_(parent_ids)))} if parent_ids else {}
order_ids = {doc.target_id for doc in detail_docs if doc.entity_type == "business_items" and doc.target_id}
shipment_ids = {doc.target_id for doc in detail_docs if doc.entity_type == "shipment_items" and doc.target_id}
targets = {("business_items", item.id): item for item in session.scalars(select(BusinessItem).where(BusinessItem.id.in_(order_ids)))} if order_ids else {}
if shipment_ids:
targets.update({("shipment_items", item.id): item for item in session.scalars(select(ShipmentItem).where(ShipmentItem.id.in_(shipment_ids)))})
has_input = bool(detail_docs and session.scalar(select(PayrollInput.id).where(PayrollInput.business_id == business_id).limit(1)))
result = []
for doc, source in rows:
current = source
snapshot_state = None
if (doc.source_database, doc.entity_type) in details:
records = details[(doc.source_database, doc.entity_type)]
current = records.get(doc.source_id) if records is not None else None
snapshot_state = ("UNAVAILABLE" if records is None else "MISSING" if current is None
else "CHANGED" if current.id != doc.applied_record_id else "CURRENT")
raw = (current or source).payload_json
previous = previous_records.get(doc.applied_record_id, source if doc.applied_record_id == source.id else None)
person_id = doc.data_json.get("source_personnel_id")
if person_id is None:
person_id = raw.get("idclerk") if raw.get("idclerk") is not None else raw.get("idperson")
changes = _source_changes(previous, current) if previous and current and previous.id != current.id else []
if doc.reason == "PENDING_SOURCE_CUSTOMER_CHANGED" and current:
customer_id = _source_values(current)["customer_id"]
changes = [{"field": "customer_id", "label": "客户标识",
"before": str(doc.data_json["source_customer_id"]),
"after": str(customer_id) if customer_id is not None else None}]
elif doc.reason == "PENDING_SOURCE_OUT_OF_RANGE" and current:
source_date = _source_values(current)["date"]
changes = [{"field": "date", "label": "日期", "before": doc.data_json.get("source_date"),
"after": str(source_date)[:10] if source_date is not None else None}]
source_price = raw.get("taxPrice") if raw.get("taxPrice") is not None else raw.get("price")
repair_revision = None
parent_type = {"business_items": "businesses", "shipment_items": "shipments"}.get(doc.entity_type)
parent = parents.get((doc.source_database, parent_type, str(doc.data_json.get("source_parent_id"))))
target = targets.get((doc.entity_type, doc.target_id))
if (not has_input and parent and parent.reason == "SOURCE_PRICE_REVIEW_REQUIRED" and target
and target.sale_price is None and current and current.id == doc.applied_record_id
and raw.get("taxPrice") is None and raw.get("price") is not None):
from app.services.tplus.business_sync import decimal_value
try:
decimal_value(raw["price"], 4)
except WorkbenchError:
valid_price = False
else:
valid_price = True
if valid_price:
repair_revision = _price_repair_revision(doc, parent, target, current)
result.append({"id": doc.id, "entity": doc.entity_type, "source_id": doc.source_id, "source_no": doc.source_no,
"current_source_no": current.source_no if current else None,
"snapshot_state": snapshot_state,
"changes": changes,
"target_id": doc.target_id, "status": doc.status, "reason": doc.reason,
"date": doc.data_json.get("source_date"), "personnel_name": doc.data_json.get("source_personnel_name"),
"personnel_source_id": str(person_id) if person_id is not None else None,
"quantity": str(raw["quantity"]) if raw.get("quantity") is not None else None,
"amount": str(raw.get("taxAmount", raw.get("amount"))) if raw.get("taxAmount", raw.get("amount")) is not None else None,
"price": str(source_price) if source_price is not None else None,
"price_type": ("TAX_INCLUDED" if raw.get("taxPrice") is not None
else "TAX_EXCLUDED" if raw.get("price") is not None else None),
"price_repair_revision": repair_revision})
return PageResult(records=result, total=total, page_num=query.page_num, page_size=query.page_size)
def document_details(session, business_id, document_id, query):
"""对照已接入明细与当前完整快照,新增明细也按来源父单分页展示。"""
from app.services.tplus.snapshots import source_statement
from app.services.tplus.workbench import WorkbenchError
business(session, business_id)
head = session.get(TPlusBusinessDocument, document_id)
if head is None or head.business_id != business_id or head.entity_type not in ("businesses", "shipments"):
raise NotFoundError("来源订单或发货单不存在")
detail_type = "business_items" if head.entity_type == "businesses" else "shipment_items"
old = select(TPlusBusinessDocument.source_id).where(
TPlusBusinessDocument.source_database == head.source_database,
TPlusBusinessDocument.business_id == business_id,
TPlusBusinessDocument.entity_type == detail_type,
TPlusBusinessDocument.applied_record_id.is_not(None),
TPlusBusinessDocument.data_json["source_parent_id"].as_string() == head.source_id,
)
try:
current = source_statement(session, head.source_database, detail_type).where(or_(
TPlusSourceHead.source_parent_id == head.source_id,
and_(TPlusSourceHead.source_parent_id.is_(None),
cast(TPlusSyncRecord.payload_json["source_parent_id"].as_string(), String) == head.source_id),
))
except WorkbenchError:
raise BadRequestError("来源明细的完整快照不可用,请等待采集完成") from None
ids = union(old, current.with_only_columns(TPlusSyncRecord.source_id)).subquery()
total = session.scalar(select(func.count()).select_from(ids)) or 0
page_ids = list(session.scalars(select(ids.c.source_id).order_by(ids.c.source_id)
.offset(query.offset).limit(query.page_size)))
previous = dict(session.execute(select(TPlusBusinessDocument.source_id, TPlusSyncRecord)
.join(TPlusSyncRecord, TPlusSyncRecord.id == TPlusBusinessDocument.applied_record_id)
.where(TPlusBusinessDocument.source_database == head.source_database,
TPlusBusinessDocument.business_id == business_id,
TPlusBusinessDocument.entity_type == detail_type,
TPlusBusinessDocument.data_json["source_parent_id"].as_string() == head.source_id,
TPlusBusinessDocument.source_id.in_(page_ids))).all()) if page_ids else {}
latest = {record.source_id: record for record in session.scalars(
current.where(TPlusSyncRecord.source_id.in_(page_ids)))} if page_ids else {}
def values(record):
return {key: str(value) if value is not None else None
for key, value in _source_values(record).items()} if record else None
rows = []
for source_id in page_ids:
before, after = previous.get(source_id), latest.get(source_id)
changes = _source_changes(before, after) if before is not None and after is not None else []
state = ("ADDED" if before is None else "REMOVED" if after is None
else "CHANGED" if before.id != after.id else "UNCHANGED")
rows.append({"source_id": source_id, "state": state, "changes": changes,
"before": values(before), "after": values(after)})
return PageResult(records=rows, total=total, page_num=query.page_num, page_size=query.page_size)
def confirm_missing_source_price(session, business_id, document_id, payload, operator, meta=None):
"""仅经人工确认补全未封存的空单价;来源变化或非空人工值均不得覆盖。"""
from app.services.audit_service import AuditRecord, audit
from app.services.tplus.business_sync import (
START,
Context,
_apply_document,
decimal_value,
details,
statement,
)
from app.services.tplus.workbench import WorkbenchError
with source_calculation_lock(session):
audit_record = AuditRecord(module="业务管理", action="确认来源缺失单价", summary=f"业务 #{business_id} 来源明细 #{document_id}",
request_data=payload.model_dump(mode="json"))
with audit(session, operator=operator, meta=meta, record=audit_record):
row = session.get(Business, business_id, with_for_update=True)
if row is None or not row.integration_json:
raise BadRequestError("仅可核对来源业务")
source = row.integration_json["source_database"]
if not source_snapshot_ready(session, source):
raise BadRequestError("来源快照或业务接入未完成,请稍后重试")
if session.scalar(select(PayrollInput.id).where(PayrollInput.business_id == business_id).limit(1)):
raise BadRequestError("业务已有封存核算资料,请通过差异核对处理")
doc = session.get(TPlusBusinessDocument, document_id, with_for_update=True)
if (doc is None or doc.business_id != business_id or doc.source_database != source
or doc.entity_type not in ("business_items", "shipment_items") or doc.applied_record_id is None):
raise BadRequestError("来源明细不存在或尚未接入")
parent_type = "businesses" if doc.entity_type == "business_items" else "shipments"
parent_id = doc.data_json.get("source_parent_id")
parent = session.scalar(select(TPlusBusinessDocument).where(
TPlusBusinessDocument.business_id == business_id, TPlusBusinessDocument.source_database == source,
TPlusBusinessDocument.entity_type == parent_type, TPlusBusinessDocument.source_id == str(parent_id),
).with_for_update())
if parent is None or parent.reason != "SOURCE_PRICE_REVIEW_REQUIRED" or parent.status != "CONFLICT":
raise BadRequestError("来源单价状态已变化,请刷新后核对")
model = BusinessItem if doc.entity_type == "business_items" else ShipmentItem
target = session.get(model, doc.target_id, with_for_update=True) if doc.target_id else None
if target is None or target.sale_price is not None:
raise BadRequestError("正式单价已存在或明细不存在,不能覆盖")
if doc.entity_type == "business_items":
belongs = target.business_id == business_id
else:
shipment = session.get(Shipment, target.shipment_id, with_for_update=True)
belongs = bool(shipment and shipment.business_id == business_id)
if not belongs:
raise BadRequestError("正式明细不属于该业务")
try:
current = session.scalar(statement(session, source, doc.entity_type).where(TPlusSyncRecord.source_id == doc.source_id))
head = session.scalar(statement(session, source, parent_type).where(TPlusSyncRecord.source_id == parent.source_id))
if current is None or head is None or current.id != doc.applied_record_id or head.id != parent.record_id:
raise BadRequestError("来源明细已变化,请刷新后核对")
if _price_repair_revision(doc, parent, target, current) != payload.revision:
raise BadRequestError("来源单价或正式明细已变化,请刷新后核对")
raw = current.payload_json
if raw.get("taxPrice") is not None or raw.get("price") is None:
raise BadRequestError("来源单价状态已变化,请刷新后核对")
target.sale_price = decimal_value(raw["price"], 4)
session.flush()
ctx = Context(session, source, operator)
action, checked = _apply_document(ctx, parent_type, head, details(ctx, parent_type, parent.source_id), "MANUAL", START)
if action not in ("UNCHANGED", "CONFLICT") or (action == "CONFLICT" and checked.reason != "SOURCE_PRICE_REVIEW_REQUIRED"):
raise BadRequestError("来源单据已变化,请刷新后核对")
except WorkbenchError:
raise BadRequestError("来源单价无效或超出精度,请核对原单") from None
result = {"document_id": doc.id, "parent_status": checked.status}
session.commit()
return result
def confirm_guidance(session, business_id, payload, operator, meta=None):
"""登记并封存一个来源产品发货月份的单位指导成本。"""
from app.services.audit_service import AuditRecord, audit
from app.services.tplus.business_sync import statement
from app.services.tplus.guidance import (
check_unit_guidance_conflicts,
frozen_guidance_shipments,
)
with source_calculation_lock(session):
audit_record = AuditRecord(
module="业务管理",
action="登记月度指导成本",
summary=f"业务 #{business_id} 发货明细 #{payload.shipment_item_id}",
request_data=payload.model_dump(mode="json"),
)
with audit(session, operator=operator, meta=meta, record=audit_record):
row = session.get(Business, business_id, with_for_update=True)
if row is None or not row.integration_json:
raise BadRequestError("仅可为自动接入业务登记指导成本")
source = row.integration_json.get("source_database")
if not source or not source_snapshot_ready(session, source):
raise BadRequestError("来源快照或业务接入未完成,请稍后重试")
if _latest(session, business_id) is not None:
raise BadRequestError("业务已有封存核算资料,请通过差异核对处理")
item = session.scalar(
select(ShipmentItem)
.join(Shipment, Shipment.id == ShipmentItem.shipment_id)
.where(ShipmentItem.id == payload.shipment_item_id, Shipment.business_id == business_id)
.with_for_update()
)
if item is None or not item.unit:
raise BadRequestError("发货明细不存在或缺少计量单位")
shipment = session.get(Shipment, item.shipment_id, with_for_update=True)
if shipment is None or shipment.business_id != business_id:
raise BadRequestError("发货单不存在或不属于该业务")
documents = list(session.scalars(
select(TPlusBusinessDocument)
.where(
TPlusBusinessDocument.business_id == business_id,
TPlusBusinessDocument.source_database == source,
TPlusBusinessDocument.entity_type == "shipment_items",
TPlusBusinessDocument.target_id == item.id,
TPlusBusinessDocument.status == "APPLIED",
TPlusBusinessDocument.applied_record_id.is_not(None),
)
))
if not documents:
raise BadRequestError("发货明细尚未建立来源关联")
if len(documents) != 1:
raise BadRequestError("发货明细来源关联不唯一,请先核对")
document = documents[0]
parent_id = document.data_json.get("source_parent_id")
parents = list(session.scalars(
select(TPlusBusinessDocument).where(
TPlusBusinessDocument.business_id == business_id,
TPlusBusinessDocument.source_database == source,
TPlusBusinessDocument.entity_type == "shipments",
TPlusBusinessDocument.source_id == str(parent_id),
TPlusBusinessDocument.status == "APPLIED",
TPlusBusinessDocument.applied_record_id.is_not(None),
)
))
if not parents:
raise BadRequestError("发货单尚未建立来源关联")
if len(parents) != 1:
raise BadRequestError("发货单来源关联不唯一,请先核对")
parent = parents[0]
current_line = session.scalar(
statement(session, source, "shipment_items").where(
TPlusSyncRecord.source_id == document.source_id
)
)
current_parent = session.scalar(
statement(session, source, "shipments").where(
TPlusSyncRecord.source_id == parent.source_id
)
)
if (
current_line is None
or current_parent is None
or current_line.id != document.applied_record_id
or current_parent.id != parent.applied_record_id
):
raise BadRequestError("来源单据已变化,请刷新后核对")
source_product_id = current_line.payload_json.get("idinventory")
if source_product_id is None:
raise BadRequestError("来源明细缺少产品身份,请先核对")
source_product_id = str(source_product_id).strip()
if not source_product_id.isascii() or not source_product_id.isdigit() or len(source_product_id) > 64:
raise BadRequestError("来源产品身份格式异常,请先核对")
source_date = current_parent.payload_json.get("voucherdate") or current_parent.payload_json.get("voucherDate")
if source_date is None or str(source_date)[:10] != shipment.ship_date.isoformat():
raise BadRequestError("来源发货日期已变化,请刷新后核对")
month = shipment.ship_date.strftime("%Y-%m")
amount = format(Decimal(payload.amount), "f")
facts = latest_facts(session, source)
fact_key = f"GUIDANCE:{source_product_id}:{month}:UNIT"
existing = facts.get(("GUIDANCE", fact_key))
if existing is not None:
existing_data = existing.data_json
if (
existing_data.get("is_finalized")
and existing_data.get("unit") == item.unit
and Decimal(str(existing_data.get("amount"))) == Decimal(amount)
):
return {
"status": "REUSED",
"fact_id": existing.id,
"product_id": str(source_product_id),
"month": month,
"unit": item.unit,
"amount": existing_data["amount"],
}
raise BadRequestError("同一产品同月已有不同指导成本,请先核对")
frozen = frozen_guidance_shipments(session, source, str(source_product_id), month)
check_unit_guidance_conflicts(
facts, str(source_product_id), month, item.unit, amount, frozen
)
fact = TPlusFact(
source_database=source,
kind="GUIDANCE",
fact_key=fact_key,
version=1,
data_json={
"product_id": str(source_product_id),
"month": month,
"basis": "UNIT",
"amount": amount,
"unit": item.unit,
"is_finalized": True,
},
evidence=payload.evidence,
requested_by=operator.id,
)
session.add(fact)
session.flush()
result = {
"status": "CREATED",
"fact_id": fact.id,
"product_id": str(source_product_id),
"month": month,
"unit": item.unit,
"amount": amount,
}
session.commit()
return result
def save_materials(session, business_id, payload, actor, meta=None):
"""同一业务引用的档案才可批量维护;不接受名称、金额或来源字段。"""
from app.services.audit_service import AuditRecord, audit
from app.services.authorization_service import require_permission
from app.services.category_batch import fill_missing_product_items
if payload.customer_type:
require_permission(session, actor, "base:customers:update")
if payload.products:
require_permission(session, actor, "base:products:update")
with audit(session, operator=actor, meta=meta, record=AuditRecord(module="业务管理", action="补充来源分类", summary=f"业务 #{business_id} 核算分类", request_data=payload.model_dump(mode="json"))):
row = session.get(Business, business_id, with_for_update=True)
if row is None or not row.integration_json:
raise BadRequestError("仅可维护自动接入业务的分类资料")
record = _latest(session, business_id)
if record is not None:
raise BadRequestError("已有封存核算资料,请通过差异核对处理分类变更")
if payload.customer_type:
customer = session.get(Customer, row.customer_id, with_for_update=True)
if customer.customer_type and customer.customer_type != payload.customer_type:
raise BadRequestError("客户已有核算类型,请刷新后核对")
customer.customer_type = payload.customer_type
ids = {item.product_id for item in payload.products}
actual = set(session.scalars(select(BusinessItem.product_id).where(BusinessItem.business_id == business_id, BusinessItem.product_id.in_(ids))))
if ids != actual:
raise BadRequestError("产品不属于本业务")
for value in payload.products:
product = session.get(Product, value.product_id, with_for_update=True)
if product.product_category and product.product_category != value.category:
raise BadRequestError("已有核算分类,请在产品档案核对后调整")
product.product_category = value.category
fill_missing_product_items(session, [product.id], value.category)
session.commit()
return readiness(session, business_id)
def prepare(session, business_id, payload, operator, meta=None):
with source_calculation_lock(session):
return _prepare_locked(session, business_id, payload, operator, meta)
def _prepare_locked(session, business_id, payload, operator, meta=None):
"""将正式源单据与人工成本、归属合成一个不可变核算输入版本。"""
from app.services.audit_service import AuditRecord, audit
with audit(session, operator=operator, meta=meta, record=AuditRecord(module="绩效核算", action="来源业务核算准备", summary=f"业务 #{business_id} 核算资料", request_data=payload.model_dump(mode="json"))):
payroll_months.ensure_allocation_editable(session, payload.month, operator)
row = session.get(Business, business_id, with_for_update=True)
if row is None or not row.integration_json:
raise BadRequestError("该业务不是自动接入的来源业务")
current = _latest(session, business_id)
if (current.version if current else 0) != payload.expected_version:
raise BadRequestError("核算资料版本已变化,请刷新")
state = readiness(session, business_id)
blocked = {r["code"] for r in state["reasons"]} & {"SOURCE_CONFLICT", "SOURCE_SNAPSHOT_PENDING", "CUSTOMER_CATEGORY_PENDING", "PRODUCT_CATEGORY_PENDING", "NO_ACTUAL_SHIPMENT", "SALES_RELATION_PENDING"}
if blocked:
raise BadRequestError("核算资料待补:" + ";".join(LABELS[c] for c in sorted(blocked)))
source = row.integration_json["source_database"]
facts = latest_facts(session, source)
source_docs = list(session.scalars(select(TPlusBusinessDocument).where(TPlusBusinessDocument.business_id == business_id, TPlusBusinessDocument.status == "APPLIED")))
refs = {doc.record_id: doc for doc in source_docs}
raw = {r.id: r for r in session.scalars(select(TPlusSyncRecord).where(TPlusSyncRecord.id.in_(refs)))}
by_target = {(d.entity_type, d.target_id): d for d in source_docs}
targets = list(session.scalars(select(ShipmentItem).join(Shipment).where(Shipment.business_id == business_id)))
assignments = {o.shipment_item_id: o for o in payload.shipment_ownerships}
if set(assignments) != {i.id for i in targets} or len(assignments) != len(payload.shipment_ownerships):
raise BadRequestError("请逐条核实全部发货明细,不可遗漏或重复")
people, people_facts, ships = {}, {}, []
key = f"customer:{row.integration_json['customer_source_id']}"
if current and "handovers" not in payload.model_fields_set:
handovers = current.data_json.get("facts", {}).get("handovers", [])
else:
handovers = [handover.model_dump(mode="json") for handover in payload.handovers]
if any(handover["business_key"] != key for handover in handovers):
raise BadRequestError("交接记录不属于本业务")
if any(handover["mode"] == "ACTIVE" and not handover.get("shipping_ownership_confirmed") for handover in handovers):
raise BadRequestError("在职交接须先逐次确认发货归属")
if any(handover["from_personnel_id"] == handover["to_personnel_id"] for handover in handovers):
raise BadRequestError("移交人与接手人不能相同")
def include_person(pid):
if pid in people:
return
try:
person = session.get(Personnel, pid)
except OverflowError:
raise BadRequestError("交接人员必须是有效的正式人员") from None
role = session.get(BusinessRole, person.business_role_id) if person else None
if not person or not role:
raise BadRequestError("人员或岗位未确认")
fact = facts.get(("PERSONNEL", person.source_no))
people[pid] = person
people_facts[str(pid)] = {**(fact.data_json if fact else {}), "role_code": role.role_code, "status": person.status}
for handover in handovers:
for field in ("from_personnel_id", "to_personnel_id"):
value = handover[field]
if (not value.isascii() or not value.isdigit() or str(int(value)) != value
or not 1 <= int(value) <= 2**64 - 1):
raise BadRequestError("交接人员必须是有效的正式人员")
include_person(int(value))
if role_on(people_facts[handover["to_personnel_id"]], date.fromisoformat(handover["effective_date"])) not in ("salesperson", "sales_manager"):
raise BadRequestError("接手人员在交接月份的岗位未确认")
for item in targets:
entry = assignments[item.id]
head = session.get(Shipment, item.shipment_id)
for pid in (entry.personnel_id, entry.manager_id):
if pid is None or pid in people:
continue
include_person(pid)
if role_on(people_facts[str(entry.personnel_id)], head.ship_date) not in ("salesperson", "sales_manager"):
raise BadRequestError("发货月份的人员岗位未确认")
if entry.manager_id is not None and role_on(people_facts[str(entry.manager_id)], head.ship_date) != "sales_manager":
raise BadRequestError("分管人员在发货月份须为经理")
if entry.freight_amount is None:
raise BadRequestError("请逐条核实运费金额;已核实无运费时填写0")
freight = Decimal(entry.freight_amount)
if freight >= item.amount:
raise BadRequestError("运费须小于该明细销售额")
product = session.get(Product, item.product_id)
cost_total = (Decimal(entry.unit_cost) * item.quantity).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
line_doc = by_target.get(("shipment_items", item.id))
source_line = raw.get(line_doc.record_id) if line_doc else None
if line_doc and (source_line is None or source_line.payload_json.get("idinventory") is None):
raise BadRequestError("发货来源产品标识缺失,请先核对")
product_source_id = source_line.payload_json.get("idinventory") if source_line else None
if product_source_id is not None:
from app.services.tplus.guidance import resolve_guidance
status, guidance = resolve_guidance(facts, str(product_source_id), line_doc.source_id,
head.ship_date.strftime("%Y-%m"), item.unit)
errors = {"AMBIGUOUS": "指导成本存在多条匹配记录,请先核对",
"UNIT_MISMATCH": "指导成本单位与发货明细不一致,请先核对",
"UNFINALIZED": "指导成本尚未封存,请先核对"}
if status in errors:
raise BadRequestError(errors[status])
if guidance:
submitted_cost = Decimal(entry.unit_cost) if guidance["basis"] == "UNIT" else cost_total
if submitted_cost != Decimal(guidance["amount"]):
raise BadRequestError("指导成本与已封存依据不一致,请先核对")
ship = {
"source_id": str(head.id),
"line_id": str(item.id),
"product_id": str(product_source_id) if product_source_id is not None else None,
"source_no": head.source_no,
"date": head.ship_date.isoformat(), "group_key": key, "business_key": key,
"personnel_id": str(entry.personnel_id), "manager_id": str(entry.manager_id) if entry.manager_id else None,
"amount": str(item.amount - freight), "gross_tax_amount": str(item.amount), "freight_amount": str(freight),
"quantity": str(item.quantity), "unit": item.unit, "category": product.product_category,
"customer_type": session.get(Customer, row.customer_id).customer_type,
"unit_cost": entry.unit_cost, "cost_total": str(cost_total), "pending": []}
ships.append(ship)
if current:
old = {s["line_id"]: s for s in current.data_json["shipments"]}
for ship in ships:
previous = old.get(ship["line_id"])
if previous:
changed = any(previous.get(k) != ship.get(k) for k in ("cost_total", "date", "category", "customer_type", "personnel_id", "manager_id", "freight_amount"))
old_unit_cost = previous.get("unit_cost")
if changed or old_unit_cost is None or Decimal(str(old_unit_cost)) != Decimal(ship["unit_cost"]):
raise BadRequestError("既有发货成本或归属已封存,后续新增单据不得改写历史依据")
cash = list(session.scalars(select(Receipt).where(Receipt.business_id == business_id)))
receipts = []
for receipt in cash:
doc = by_target.get(("receipts", receipt.id))
if doc is None:
raise BadRequestError("回款缺少来源关联")
record = raw[doc.record_id]
if Decimal(str(record.payload_json["amount"])) != receipt.amount or str(record.payload_json["voucherDate"])[:10] != receipt.receipt_date.isoformat():
raise BadRequestError("正式回款与来源不一致,请先核对差异")
fact = facts.get(("RECEIPT", record.source_id))
try:
confirmed, evidence, pending = _voucher_evidence(record.payload_json, fact)
except (TypeError, ValueError):
raise BadRequestError("收款凭证数据异常,请先核对来源") from None
if pending:
raise BadRequestError("收款凭证依据冲突,请先核实")
exclusion = "NOT_GENERATED_VOUCHER" if evidence.get("count") == 0 else None
if not confirmed and not exclusion:
raise BadRequestError("收款凭证状态待核实,不能封存核算资料")
receipts.append({"source_id": str(receipt.id), "source_no": receipt.source_no,
"date": receipt.receipt_date.isoformat(), "group_key": key,
"amount": str(receipt.amount), "voucher_confirmed": confirmed,
"exclusion": exclusion, "pending": [], "voucher_evidence": evidence})
from app.services.receipt_confirmation import ensure_confirmed
ensure_confirmed(session, [receipt.id for receipt, snapshot in zip(cash, receipts, strict=True)
if not snapshot["exclusion"] and receipt.receipt_date.strftime("%Y-%m") <= payload.month])
values = {"input_schema": "TPLUS_SYNC_V1", "group": {"key": key, "scope_confirmed": True, "opening_confirmed": True,
"opening_balance": payload.opening_balance, "historical_settlement_confirmed": True},
"shipments": ships, "receipts": receipts, "facts": {"personnel": people_facts, "handovers": handovers},
"maps": {"personnel": {str(p): p for p in people}, "shipments": {s["source_id"]: int(s["source_id"]) for s in ships},
"shipment_items": {s["line_id"]: int(s["line_id"]) for s in ships}, "receipts": {str(r.id): r.id for r in cash}},
"evidence": payload.evidence, "guidance_finalized": True, "as_of_month": payload.month}
fingerprint = digest(values)
record = PayrollInput(business_id=business_id, version=payload.expected_version + 1, source_database=source,
group_key=key, fingerprint=fingerprint, data_json=values, requested_by=operator.id)
session.add(record)
session.flush()
result = {"input_id": record.id, "version": record.version, "fingerprint": fingerprint}
session.commit()
return result