PayrollManagementSystem/backend/app/services/business_source.py

995 lines
60 KiB
Python
Raw Normal View History

"""业务来源、待补资料和来源业务核算准备;不重新录入源单据。"""
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