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