打通 T+ 快照、业务、发货、回款和核算准备链路,保留来源差异并提供待补状态。 新增业务自动接入任务、跨平台读取服务托管、权限复核、来源关联和前端核对页面。 补充迁移、隔离集成测试、浏览器模拟验收及角色授权边界修复。
839 lines
53 KiB
Python
839 lines
53 KiB
Python
"""业务来源、待补资料和来源业务核算准备;不重新录入源单据。"""
|
||
|
||
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,
|
||
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)
|
||
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 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), "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"]])
|
||
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
|