PayrollManagementSystem/backend/app/services/tplus/sales_relations.py
liuwenxin 1e53792ad6 feat(T+): 完成数据贯通与自动接入闭环
打通 T+ 快照、业务、发货、回款和核算准备链路,保留来源差异并提供待补状态。
新增业务自动接入任务、跨平台读取服务托管、权限复核、来源关联和前端核对页面。
补充迁移、隔离集成测试、浏览器模拟验收及角色授权边界修复。
2026-11-05 18:47:13 +08:00

227 lines
12 KiB
Python

"""来源订单与发货人工关系;仅追加结论,不修改来源或封存数据。"""
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