"""来源订单与发货人工关系;仅追加结论,不修改来源或封存数据。""" from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError from app.core.exceptions import BadRequestError from app.core.tplus_lock import TPlusBusyError, sync_lock from app.models import ( Business, BusinessItem, Shipment, ShipmentItem, TPlusBusinessDocument, TPlusFact, ) from app.services.audit_service import AuditRecord, audit from app.services.tplus.workbench import digest KIND = "SALES_RELATION" def item_revision(item): return digest([item.id, item.business_id, item.product_id, item.product_name, item.spec_model, item.unit, str(item.quantity), str(item.sale_price), str(item.sale_amount)]) def document_revision(doc): return [doc.id, doc.entity_type, doc.source_id, doc.record_id, doc.applied_record_id, doc.fingerprint, doc.status, doc.business_id, doc.target_id] def shipment_revision(doc, head, shipment, line): return digest([document_revision(doc), document_revision(head), shipment.id, shipment.business_id, shipment.ship_date.isoformat(), line.id, line.product_id, str(line.quantity), str(line.sale_price), str(line.amount)]) def order_revision(doc, head, item): return digest([document_revision(doc), document_revision(head), item_revision(item)]) def _head(session, doc, entity, target_id=None, *, lock=True): parent = doc.data_json.get("source_parent_id") query = select(TPlusBusinessDocument).where( TPlusBusinessDocument.source_database == doc.source_database, TPlusBusinessDocument.business_id == doc.business_id, TPlusBusinessDocument.entity_type == entity, TPlusBusinessDocument.status == "APPLIED", TPlusBusinessDocument.source_id == str(parent), ) if target_id is not None: query = query.where(TPlusBusinessDocument.target_id == target_id) head = session.scalar(query.with_for_update() if lock else query) if head is None or head.record_id != head.applied_record_id: raise BadRequestError("来源单据已变化,请刷新后核对") return head def _applied_document(session, document_id, business_id, entity, source, *, lock=True): doc = session.get(TPlusBusinessDocument, document_id, with_for_update=lock) if (doc is None or doc.business_id != business_id or doc.source_database != source or doc.entity_type != entity or doc.status != "APPLIED" or doc.record_id != doc.applied_record_id): raise BadRequestError("来源明细已变化,请刷新后核对") return doc def _latest_business_facts(session, source, business_id): ids = select(func.max(TPlusFact.id)).where( TPlusFact.source_database == source, TPlusFact.kind == KIND, TPlusFact.fact_key.like(f"sales:{business_id}:%"), ).group_by(TPlusFact.fact_key) return list(session.scalars(select(TPlusFact).where(TPlusFact.id.in_(ids)))) def _fact_is_current(session, business, item, fact): """任何来源身份、版本或业务明细变化均使结论失效。""" data = fact.data_json try: if (data["business_id"] != business.id or data["item_id"] != item.id or data["item_revision"] != item_revision(item)): return False source = business.integration_json["source_database"] doc = _applied_document(session, data["shipment_document_id"], business.id, "shipment_items", source, lock=False) line = session.get(ShipmentItem, doc.target_id) shipment = session.get(Shipment, line.shipment_id) if line else None if (line is None or shipment is None or shipment.business_id != business.id or line.product_id != item.product_id or line.quantity != item.quantity or line.amount != item.sale_amount): return False head = _head(session, doc, "shipments", shipment.id, lock=False) if shipment_revision(doc, head, shipment, line) != data["shipment_revision"]: return False if data["disposition"] == "INDEPENDENT": return data.get("order_document_id") is None and data.get("order_revision") is None if data["disposition"] != "ORDER": return False order = _applied_document(session, data["order_document_id"], business.id, "business_items", source, lock=False) order_item = session.get(BusinessItem, order.target_id) if order_item is None or order_item.business_id != business.id or order_item.product_id != item.product_id: return False order_head = _head(session, order, "businesses", lock=False) return order_revision(order, order_head, order_item) == data["order_revision"] except (BadRequestError, KeyError, TypeError, ValueError): return False def resolve_items(session, business, items, order_ids): """返回待核对状态和可计入销售额的明细,不猜测来源关系。""" orders = set(order_ids) if not orders: return False, items extra = [item for item in items if item.id not in orders] if not extra: return False, items facts = {fact.fact_key: fact for fact in _latest_business_facts( session, business.integration_json["source_database"], business.id)} included = [item for item in items if item.id in orders] used = set() for item in extra: fact = facts.get(f"sales:{business.id}:{item.id}") if fact is None or not _fact_is_current(session, business, item, fact): return True, [] doc_id = fact.data_json["shipment_document_id"] if doc_id in used: return True, [] used.add(doc_id) if fact.data_json["disposition"] == "INDEPENDENT": included.append(item) return False, included def confirm(session, business_id, payload, operator, meta=None): """绑定明确选择及版本;调用者须具备业务更新和核算准备权限。""" record = AuditRecord(module="业务管理", action="核对订单发货关系", summary=f"业务 #{business_id} 来源关系", request_data=payload.model_dump(mode="json")) try: with audit(session, operator=operator, meta=meta, record=record), sync_lock(session): business = session.get(Business, business_id, with_for_update=True) if business is None or not business.integration_json: raise BadRequestError("仅可核对来源业务") source = business.integration_json["source_database"] item = session.get(BusinessItem, payload.item_id, with_for_update=True) if item is None or item.business_id != business_id or item_revision(item) != payload.item_revision: raise BadRequestError("业务明细已变化,请刷新后核对") existing_order = session.scalar(select(TPlusBusinessDocument.id).where( TPlusBusinessDocument.business_id == business_id, TPlusBusinessDocument.entity_type == "business_items", TPlusBusinessDocument.target_id == item.id, ).limit(1)) if existing_order is not None: raise BadRequestError("订单明细不能作为待核对发货明细") doc = _applied_document(session, payload.shipment_document_id, business_id, "shipment_items", source) line = session.get(ShipmentItem, doc.target_id, with_for_update=True) shipment = session.get(Shipment, line.shipment_id, with_for_update=True) if line else None if (line is None or shipment is None or shipment.business_id != business_id or line.product_id != item.product_id or line.quantity != item.quantity or line.amount != item.sale_amount): raise BadRequestError("发货明细与业务明细不一致,请重新核对") head = _head(session, doc, "shipments", shipment.id) if shipment_revision(doc, head, shipment, line) != payload.shipment_revision: raise BadRequestError("来源发货已变化,请刷新后核对") order = order_head = order_item = None if payload.disposition == "ORDER": order = _applied_document(session, payload.order_document_id, business_id, "business_items", source) order_item = session.get(BusinessItem, order.target_id, with_for_update=True) if order_item is None or order_item.business_id != business_id or order_item.product_id != item.product_id: raise BadRequestError("订单明细不属于本业务或产品不一致") order_head = _head(session, order, "businesses") if order_revision(order, order_head, order_item) != payload.order_revision: raise BadRequestError("来源订单已变化,请刷新后核对") facts = _latest_business_facts(session, source, business_id) key = f"sales:{business_id}:{item.id}" current = next((fact for fact in facts if fact.fact_key == key), None) if (current.version if current else 0) != payload.expected_version: raise BadRequestError("核对结论已变化,请刷新后重试") if any(fact.fact_key != key and fact.data_json.get("disposition") != "RELEASED" and fact.data_json.get("shipment_document_id") == doc.id for fact in facts): raise BadRequestError("该发货明细已被其他业务明细确认") fact = TPlusFact(source_database=source, kind=KIND, fact_key=key, version=payload.expected_version + 1, evidence=payload.evidence, requested_by=operator.id, data_json={"business_id": business_id, "item_id": item.id, "shipment_document_id": doc.id, "order_document_id": order.id if order else None, "disposition": payload.disposition, "item_revision": payload.item_revision, "shipment_revision": payload.shipment_revision, "order_revision": payload.order_revision}) session.add(fact) session.flush() from app.services.tplus.business_sync import _refresh_business_totals _refresh_business_totals(session, business) session.commit() return {"item_id": item.id, "version": fact.version} except (TPlusBusyError, IntegrityError): session.rollback() raise BadRequestError("来源数据或核对结论正在更新,请稍后重试") from None def release(session, business_id, payload, operator, meta=None): """追加撤销记录,释放发货占用,同时保留既往核对依据。""" record = AuditRecord(module="业务管理", action="撤销订单发货关系", summary=f"业务 #{business_id} 来源关系", request_data=payload.model_dump(mode="json")) try: with audit(session, operator=operator, meta=meta, record=record), sync_lock(session): business = session.get(Business, business_id, with_for_update=True) if business is None or not business.integration_json: raise BadRequestError("仅可核对来源业务") source = business.integration_json["source_database"] key = f"sales:{business_id}:{payload.item_id}" current = next((fact for fact in _latest_business_facts(session, source, business_id) if fact.fact_key == key), None) if current is None or current.version != payload.expected_version: raise BadRequestError("核对结论已变化,请刷新后重试") if current.data_json.get("disposition") == "RELEASED": raise BadRequestError("核对结论已撤销") fact = TPlusFact(source_database=source, kind=KIND, fact_key=key, version=current.version + 1, evidence=payload.evidence, requested_by=operator.id, data_json={"business_id": business_id, "item_id": payload.item_id, "disposition": "RELEASED"}) session.add(fact) session.flush() from app.services.tplus.business_sync import _refresh_business_totals _refresh_business_totals(session, business) session.commit() return {"item_id": payload.item_id, "version": fact.version} except (TPlusBusyError, IntegrityError): session.rollback() raise BadRequestError("来源数据或核对结论正在更新,请稍后重试") from None