# ============================================================ # SAP 同步领域服务(moduleId: domain-sap-sync, 可重生 ✅) # MD-05:入站拉单/库存 → 柔性订单;出站回写开完工(经 Mock SAP 桩) # ============================================================ from __future__ import annotations import copy import hashlib import json import time from typing import Any from server.integrations.sap_stub import get_sap_client from server.timeutil import fmt_date, today0 World = dict[str, Any] def sap_connection_status() -> dict: return get_sap_client().status() def preview_inbound(world: World) -> dict: """预览 SAP→APS 入站:新生产订单 / 库存差异(不改世界)。""" from server.state.seed import ensure_flex_seed ensure_flex_seed(world) client = get_sap_client() remote_orders = client.pull_orders() remote_stocks = client.pull_stocks() existing = {o.get("externalAufnr") or o.get("orderNo") for o in world.get("flexOrders", [])} # 也认 FO- 映射表 links = {l.get("aufnr") for l in world.get("sapLinks", []) if l.get("kind") == "order"} new_orders, skip_orders = [], [] for ro in remote_orders: aufnr = ro["aufnr"] if aufnr in existing or aufnr in links: skip_orders.append({"aufnr": aufnr, "matnr": ro["matnr"], "reason": "已同步"}) else: new_orders.append({ "aufnr": aufnr, "productCode": ro["matnr"], "productName": ro.get("maktx"), "quantity": ro["gamng"], "dueDate": ro["gstrp"], "priority": ro.get("priority", 5), "kitOk": ro.get("kitOk", True), "status": ro.get("status"), }) stock_patches = [] mats = {m["code"]: m for m in world.get("flexMaterials", [])} for rs in remote_stocks: local = mats.get(rs["matnr"]) if not local: stock_patches.append({"matnr": rs["matnr"], "action": "skip", "reason": "本地无此物料"}) continue if int(local.get("stock") or 0) != int(rs["labst"]) or \ int(local.get("inTransit") or 0) != int(rs.get("inTransit") or 0): stock_patches.append({ "matnr": rs["matnr"], "action": "update", "fromStock": local.get("stock"), "toStock": rs["labst"], "fromTransit": local.get("inTransit"), "toTransit": rs.get("inTransit", 0), }) return { "direction": "inbound", "connection": client.status(), "newOrders": new_orders, "skipOrders": skip_orders, "stockPatches": stock_patches, "summary": (f"新订单 {len(new_orders)} / 跳过 {len(skip_orders)} / " f"库存更新 {sum(1 for s in stock_patches if s['action'] == 'update')}"), } def apply_inbound(store, actor: str = "web") -> dict: """执行入站:写入 flexOrders + 更新库存 + sapLinks。""" from server.agent_core.audit import write_audit from server.state.seed import ensure_flex_seed ensure_flex_seed(store.data) world = store.data preview = preview_inbound(world) created = [] for row in preview["newOrders"]: mid = max((o.get("id", 0) for o in world.get("flexOrders", [])), default=0) + 1 order_no = f"FO-SAP-{row['aufnr'][-4:]}" order = { "id": mid, "orderNo": order_no, "productCode": row["productCode"], "productName": row.get("productName") or row["productCode"], "quantity": row["quantity"], "dueDate": row["dueDate"], "priority": row.get("priority", 5), "status": "RELEASED", "wbs": None, "source": "SAP", "externalAufnr": row["aufnr"], "kitOk": row.get("kitOk", True), } world.setdefault("flexOrders", []).append(order) world.setdefault("sapLinks", []).append({ "kind": "order", "aufnr": row["aufnr"], "orderNo": order_no, "flexOrderId": mid, "syncedAt": fmt_date(today0()), "actor": actor, }) created.append(order) stock_updated = [] mats = {m["code"]: m for m in world.get("flexMaterials", [])} for patch in preview["stockPatches"]: if patch["action"] != "update": continue m = mats.get(patch["matnr"]) if not m: continue m["stock"] = patch["toStock"] m["inTransit"] = patch.get("toTransit", 0) stock_updated.append(patch["matnr"]) journal = { "id": store.next_id("sapJournal"), "direction": "inbound", "at": fmt_date(today0()), "actor": actor, "createdOrders": len(created), "stockUpdated": stock_updated, "summary": preview["summary"], } world.setdefault("sapSyncJournal", []).append(journal) write_audit(world, store.next_id, actor=actor, category="INTEGRATION", action="sap.sync.inbound", target={"type": "SAP", "id": "inbound"}, power="P2", rationale={"created": len(created), "stocks": stock_updated}) store.save() return {"journal": journal, "created": created, "stockUpdated": stock_updated, "message": (f"SAP 入站完成 ✅ 新建柔性订单 {len(created)} 条," f"库存更新 {len(stock_updated)} 项。")} def _canonical_json(value: Any) -> str: return json.dumps( value, ensure_ascii=False, allow_nan=False, sort_keys=True, separators=(",", ":"), ) def _canonical_digest(value: Any) -> str: return hashlib.sha256(_canonical_json(value).encode("utf-8")).hexdigest() def build_outbound_projection(world: World, *, limit: int = 50) -> dict: """Build the deterministic APS -> SAP payload without touching any client.""" safe_limit = max(1, int(limit)) versions = list(world.get("flexScheduleVersions") or world.get("scheduleVersions") or []) if not versions: empty_digest = _canonical_digest([]) return { "direction": "outbound", "versionId": None, "versionNo": None, "versionStatus": None, "items": [], "idemKeys": [], "payloadDigest": _canonical_digest({"version": None, "items": []}), "worldFingerprint": _canonical_digest( {"version": None, "fullCandidateDigest": empty_digest, "totalCount": 0, "limit": safe_limit} ), "fullCandidateDigest": empty_digest, "totalCount": 0, "selectedCount": 0, "truncatedCount": 0, "limit": safe_limit, "evidenceRefs": [], } latest = copy.deepcopy(versions[-1]) version_id = latest.get("id") version_no = latest.get("versionNo") version_status = latest.get("status") orders = { str(order.get("orderNo")): order for order in world.get("flexOrders", []) if order.get("orderNo") is not None } candidates: list[dict[str, Any]] = [] for work_order in world.get("flexWorkOrders", []): if work_order.get("versionId") != version_id: continue raw_order_no = work_order.get("orderNo") flex_order_no = work_order.get("flexOrderNo") resolved_order_no = flex_order_no or raw_order_no order = orders.get(str(resolved_order_no or ""), {}) candidates.append( { "woId": work_order.get("id"), "versionId": work_order.get("versionId"), "orderNo": raw_order_no, "flexOrderNo": flex_order_no, "resolvedOrderNo": resolved_order_no, "operation": work_order.get("operationCode"), "equipment": work_order.get("equipmentCode"), "start": work_order.get("plannedStartTime"), "end": work_order.get("plannedEndTime"), "frozen": bool(work_order.get("frozen")), "qty": order.get("quantity"), "aufnr": order.get("externalAufnr"), } ) candidates.sort(key=_canonical_json) full_candidate_digest = _canonical_digest(candidates) selected = [candidate for candidate in candidates if not candidate["frozen"]][:safe_limit] items: list[dict[str, Any]] = [] for candidate in selected: idem_key = f"{version_id}:{candidate['woId']}:finish" items.append( { "event": "FINISH", "aufnr": candidate.get("aufnr"), "orderNo": candidate.get("resolvedOrderNo"), "operation": candidate.get("operation"), "equipment": candidate.get("equipment"), "start": candidate.get("start"), "end": candidate.get("end"), "qty": candidate.get("qty"), "woId": candidate.get("woId"), "idemKey": idem_key, } ) payload_body = { "versionId": version_id, "versionNo": version_no, "items": items, } payload_digest = _canonical_digest(payload_body) world_fingerprint = _canonical_digest( { "version": { "id": version_id, "versionNo": version_no, "status": version_status, }, "fullCandidateDigest": full_candidate_digest, "totalCount": len(candidates), "limit": safe_limit, } ) evidence_refs = [ f"sap-outbound-version:{version_id}", f"sap-outbound-payload:{payload_digest}", f"sap-outbound-world:{world_fingerprint}", ] return { "direction": "outbound", "versionId": version_id, "versionNo": version_no, "versionStatus": version_status, "items": items, "idemKeys": [item["idemKey"] for item in items], "payloadDigest": payload_digest, "worldFingerprint": world_fingerprint, "fullCandidateDigest": full_candidate_digest, "totalCount": len(candidates), "selectedCount": len(items), "truncatedCount": max(0, len(candidates) - len(items)), "limit": safe_limit, "evidenceRefs": evidence_refs, } def preview_outbound(world: World) -> dict: """Preview APS -> SAP outbound items without mutating the shared world.""" projection = build_outbound_projection(world, limit=50) connection = get_sap_client().status() if projection.get("versionId") is None: return { **projection, "connection": connection, "summary": "无柔性排产版本可回写", } return { **projection, "connection": connection, "summary": ( f"版本 {projection.get('versionNo')} 可回写 {projection.get('selectedCount', 0)} 条工序完工" ), } def apply_outbound( store, *, actor: str, confirm_id: str, execution_grant: str, approval_params: dict, requester: dict | None, approvals: list[dict], before_snapshot: str, checkpoint_store, ) -> dict: """Execute a P3 SAP outbound attempt from a frozen approval projection.""" from server.agent_core import harness from server.agent_core.audit import write_audit world = store.data approved = copy.deepcopy(approval_params) evidence_refs = list(approved.get("evidenceRefs") or []) if isinstance(approved, dict) else [] grant_digest = ( hashlib.sha256(str(execution_grant).encode()).hexdigest() if execution_grant else None ) def audit_denied(reason: str) -> None: write_audit( world, store.next_id, actor=actor, category="GATE", action="sap.sync.outbound.evidence.denied", target={"type": "SAP", "id": "outbound"}, power="P3", rationale={ "confirmId": confirm_id, "reason": reason, "requester": copy.deepcopy(requester), "approvers": copy.deepcopy(approvals), "payloadDigest": (approved.get("payloadDigest") if isinstance(approved, dict) else None), "worldFingerprint": (approved.get("worldFingerprint") if isinstance(approved, dict) else None), "idempotencyKeys": (copy.deepcopy(approved.get("idemKeys") or []) if isinstance(approved, dict) else []), "grantDigest": grant_digest, "grantConsumed": False, }, result="DENIED", before_snapshot=before_snapshot or None, evidence_refs=evidence_refs, ) store.save() def audit_failed(reason: str, *, phase: str) -> None: write_audit( world, store.next_id, actor=actor, category="GATE", action="sap.sync.outbound.evidence.failed", target={"type": "SAP", "id": "outbound"}, power="P3", rationale={ "confirmId": confirm_id, "phase": phase, "reason": reason, "requester": copy.deepcopy(requester), "approvers": copy.deepcopy(approvals), "payloadDigest": (approved.get("payloadDigest") if isinstance(approved, dict) else None), "worldFingerprint": (approved.get("worldFingerprint") if isinstance(approved, dict) else None), "idempotencyKeys": (copy.deepcopy(approved.get("idemKeys") or []) if isinstance(approved, dict) else []), "grantDigest": grant_digest, "grantConsumed": False, }, result="FAILED", before_snapshot=before_snapshot or None, evidence_refs=evidence_refs, ) store.save() try: if not confirm_id or not execution_grant or not before_snapshot: raise PermissionError("SAP P3 出站缺少确认、执行凭据或审批时快照") if checkpoint_store is None or not isinstance(approved, dict): raise PermissionError("SAP P3 出站缺少确认参数或检查点存储") required = { "direction", "beforeSnapshot", "versionId", "versionNo", "versionStatus", "items", "idemKeys", "payloadDigest", "worldFingerprint", "fullCandidateDigest", "totalCount", "selectedCount", "truncatedCount", "limit", "evidenceRefs", } if required - set(approved): raise PermissionError("SAP P3 出站确认参数不完整") if approved.get("direction") != "outbound" or approved.get("beforeSnapshot") != before_snapshot: raise PermissionError("SAP P3 出站确认动作或快照不一致") if not isinstance(requester, dict) or requester.get("userId") is None: raise PermissionError("SAP P3 出站缺少可信发起人记录") if not isinstance(approvals, list) or len(approvals) != 2: raise PermissionError("SAP P3 出站缺少完整双人审批记录") approver_ids = [ str(item.get("userId")) for item in approvals if isinstance(item, dict) and item.get("userId") is not None ] if len(approver_ids) != 2 or len(set(approver_ids)) != 2: raise PermissionError("SAP P3 出站双人审批身份不满足职责分离") if not isinstance(approved.get("items"), list) or not isinstance(approved.get("idemKeys"), list): raise PermissionError("SAP P3 出站冻结载荷非法") if not all(isinstance(item, dict) for item in approved["items"]): raise PermissionError("SAP P3 出站冻结载荷条目非法") idem_keys = [item.get("idemKey") for item in approved["items"]] if ( idem_keys != approved["idemKeys"] or any(not isinstance(key, str) or not key for key in idem_keys) or len(set(idem_keys)) != len(idem_keys) ): raise PermissionError("SAP P3 出站幂等键与冻结载荷不一致") safe_limit = int(approved["limit"]) total_count = int(approved["totalCount"]) selected_count = int(approved["selectedCount"]) truncated_count = int(approved["truncatedCount"]) if ( safe_limit < 1 or total_count < 0 or selected_count != len(approved["items"]) or selected_count > safe_limit or truncated_count != total_count - selected_count or truncated_count < 0 ): raise PermissionError("SAP P3 出站候选计数或发送上限非法") approved_payload_digest = _canonical_digest( { "versionId": approved["versionId"], "versionNo": approved["versionNo"], "items": approved["items"], } ) if approved_payload_digest != approved["payloadDigest"]: raise PermissionError("SAP P3 出站批准载荷摘要非法") expected_refs = [ f"sap-outbound-version:{approved.get('versionId')}", f"sap-outbound-payload:{approved.get('payloadDigest')}", f"sap-outbound-world:{approved.get('worldFingerprint')}", ] if evidence_refs != expected_refs: raise PermissionError("SAP P3 出站证据引用与批准摘要不一致") compare_keys = ( "versionId", "versionNo", "versionStatus", "items", "idemKeys", "payloadDigest", "worldFingerprint", "fullCandidateDigest", "totalCount", "selectedCount", "truncatedCount", "limit", "evidenceRefs", ) current = build_outbound_projection(world, limit=safe_limit) if any(current.get(key) != approved.get(key) for key in compare_keys): raise PermissionError("SAP P3 出站世界状态或回写载荷已漂移") checkpoint = checkpoint_store.get(before_snapshot) if checkpoint is None or not isinstance(checkpoint.get("world"), dict): raise PermissionError("SAP P3 出站审批时快照不存在") checkpoint_projection = build_outbound_projection( checkpoint["world"], limit=safe_limit ) if any(checkpoint_projection.get(key) != approved.get(key) for key in compare_keys): raise PermissionError("SAP P3 出站审批快照与批准载荷不一致") except PermissionError as exc: audit_denied(str(exc)) raise except (KeyError, TypeError, ValueError) as exc: denied = PermissionError("SAP P3 出站证据信封字段类型或结构非法") audit_denied(str(denied)) raise denied from exc except Exception as exc: audit_failed(f"{type(exc).__name__}: {exc}", phase="PREFLIGHT") raise payload_digest = str(approved["payloadDigest"]) execution_id = hashlib.sha256( f"{confirm_id}:{payload_digest}".encode() ).hexdigest()[:24] executions = world.setdefault("sapOutboundExecutions", []) if any(item.get("id") == execution_id for item in executions): audit_denied("SAP P3 出站执行记录已存在,拒绝重放") raise PermissionError("SAP P3 出站执行记录已存在,拒绝重放") previous = next( ( item for item in reversed(executions) if item.get("payloadDigest") == payload_digest ), None, ) frozen_items = copy.deepcopy(approved["items"]) execution = { "id": execution_id, "confirmId": confirm_id, "payloadDigest": payload_digest, "worldFingerprint": approved["worldFingerprint"], "beforeSnapshot": before_snapshot, "requester": copy.deepcopy(requester), "approvals": copy.deepcopy(approvals), "previousExecutionId": previous.get("id") if previous else None, "status": "READY", "at": fmt_date(today0()), "reservationAtEpoch": time.time(), "validatedWorldFingerprint": current["worldFingerprint"], "grantDigest": grant_digest, "grantConsumed": False, "grantConsumedAtEpoch": None, "items": [ {"idemKey": item["idemKey"], "woId": item.get("woId"), "status": "PENDING"} for item in frozen_items ], } executions.append(execution) store.save() post_reservation = build_outbound_projection(world, limit=safe_limit) if any(post_reservation.get(key) != approved.get(key) for key in compare_keys): execution["status"] = "STALE_BEFORE_GRANT" execution["error"] = "SAP P3 出站 READY 后世界状态已漂移" execution["failedAtEpoch"] = time.time() audit_denied(execution["error"]) raise PermissionError(execution["error"]) sap_links = world.setdefault("sapLinks", []) linked_keys = { str(link.get("idemKey")) for link in sap_links if link.get("kind") == "receipt" and link.get("idemKey") is not None } if not harness.consume_execution_grant( execution_grant, confirm_id=confirm_id, action="sap.sync.outbound", params=approved, ): execution["status"] = "DENIED" audit_denied("SAP P3 出站缺少有效的最终批准凭据") raise PermissionError("SAP P3 出站缺少有效的最终批准凭据") execution["status"] = "GRANT_CONSUMED" execution["grantConsumed"] = True execution["grantConsumedAtEpoch"] = time.time() store.save() try: client = get_sap_client() except Exception as exc: execution["status"] = "FAILED" execution["error"] = f"{type(exc).__name__}: {exc}" execution["failedAtEpoch"] = time.time() write_audit( world, store.next_id, actor=actor, category="INTEGRATION", action="sap.sync.outbound", target={"type": "SAP", "id": "outbound", "executionId": execution_id}, power="P3", rationale={ "confirmId": confirm_id, "requester": requester, "approvers": approvals, "payloadDigest": payload_digest, "worldFingerprint": approved["worldFingerprint"], "idempotencyKeys": list(approved["idemKeys"]), "grantDigest": grant_digest, "grantConsumed": True, "grantConsumedAtEpoch": execution["grantConsumedAtEpoch"], "phase": "CLIENT_INIT", "reason": execution["error"], }, result="FAILED", before_snapshot=before_snapshot, evidence_refs=evidence_refs, ) store.save() raise pushed: list[str] = [] pushed_keys: list[str] = [] duped: list[str] = [] for index, item in enumerate(frozen_items): state = execution["items"][index] payload = {key: copy.deepcopy(value) for key, value in item.items() if key != "idemKey"} try: response = client.push_receipt(payload, item["idemKey"]) except Exception as exc: state["status"] = "FAILED" state["error"] = f"{type(exc).__name__}: {exc}" execution["status"] = "PARTIAL_FAILED" if pushed_keys or duped else "FAILED" execution["failedAtEpoch"] = time.time() write_audit( world, store.next_id, actor=actor, category="INTEGRATION", action="sap.sync.outbound", target={"type": "SAP", "id": "outbound", "executionId": execution_id}, power="P3", rationale={ "confirmId": confirm_id, "requester": requester, "approvers": approvals, "payloadDigest": payload_digest, "worldFingerprint": approved["worldFingerprint"], "pushedReceiptIds": list(pushed), "pushedIdemKeys": list(pushed_keys), "duplicateIdemKeys": list(duped), "idempotencyKeys": list(approved["idemKeys"]), "failedIdemKey": item["idemKey"], "pendingIdemKeys": [ pending["idemKey"] for pending in execution["items"] if pending.get("status") == "PENDING" ], "grantDigest": grant_digest, "grantConsumed": True, "grantConsumedAtEpoch": execution["grantConsumedAtEpoch"], "phase": "REMOTE_PUSH", }, result="FAILED", before_snapshot=before_snapshot, evidence_refs=evidence_refs, ) store.save() raise if not isinstance(response, dict): state["status"] = "UNKNOWN_RECONCILE_REQUIRED" state["error"] = "SAP 回写响应格式非法" execution["status"] = "RECONCILE_REQUIRED" execution["failedAtEpoch"] = time.time() response_error = TypeError(state["error"]) else: receipt = response.get("receipt") or {} if not isinstance(receipt, dict): receipt_id = None state["status"] = "UNKNOWN_RECONCILE_REQUIRED" state["error"] = "SAP 回写 receipt 结构非法" execution["status"] = "RECONCILE_REQUIRED" execution["failedAtEpoch"] = time.time() response_error = TypeError(state["error"]) else: receipt_id = receipt.get("id") if not response.get("duplicate") and receipt_id is None: state["status"] = "UNKNOWN_RECONCILE_REQUIRED" state["error"] = "SAP 回写响应缺少 receipt id" execution["status"] = "RECONCILE_REQUIRED" execution["failedAtEpoch"] = time.time() response_error = RuntimeError(state["error"]) else: response_error = None if response_error is not None: write_audit( world, store.next_id, actor=actor, category="INTEGRATION", action="sap.sync.outbound", target={"type": "SAP", "id": "outbound", "executionId": execution_id}, power="P3", rationale={ "confirmId": confirm_id, "requester": requester, "approvers": approvals, "payloadDigest": payload_digest, "worldFingerprint": approved["worldFingerprint"], "idempotencyKeys": list(approved["idemKeys"]), "unknownIdemKey": item["idemKey"], "grantDigest": grant_digest, "grantConsumed": True, "grantConsumedAtEpoch": execution["grantConsumedAtEpoch"], "phase": "REMOTE_RESPONSE", "reason": state["error"], }, result="FAILED", before_snapshot=before_snapshot, evidence_refs=evidence_refs, ) store.save() raise response_error if response.get("duplicate"): duped.append(item["idemKey"]) state["status"] = "DUPLICATE" state["remoteStatus"] = "DUPLICATE" else: pushed.append(str(receipt_id)) pushed_keys.append(item["idemKey"]) state["status"] = "PUSHED" state["remoteStatus"] = "PUSHED" if item["idemKey"] not in linked_keys: sap_links.append( { "kind": "receipt", "receiptId": receipt_id, "woId": item.get("woId"), "idemKey": item["idemKey"], "syncedAt": fmt_date(today0()), "actor": actor, "executionId": execution_id, } ) linked_keys.add(item["idemKey"]) state["receiptId"] = receipt_id try: store.save() except Exception as exc: state["status"] = "REMOTE_ACCEPTED_LOCAL_COMMIT_FAILED" state["error"] = f"{type(exc).__name__}: {exc}" execution["status"] = "RECONCILE_REQUIRED" execution["failedAtEpoch"] = time.time() write_audit( world, store.next_id, actor=actor, category="INTEGRATION", action="sap.sync.outbound", target={"type": "SAP", "id": "outbound", "executionId": execution_id}, power="P3", rationale={ "confirmId": confirm_id, "requester": requester, "approvers": approvals, "payloadDigest": payload_digest, "worldFingerprint": approved["worldFingerprint"], "pushedReceiptIds": list(pushed), "pushedIdemKeys": list(pushed_keys), "duplicateIdemKeys": list(duped), "idempotencyKeys": list(approved["idemKeys"]), "remoteAcceptedIdemKey": item["idemKey"], "grantDigest": grant_digest, "grantConsumed": True, "grantConsumedAtEpoch": execution["grantConsumedAtEpoch"], "phase": "LOCAL_COMMIT", "reason": state["error"], }, result="FAILED", before_snapshot=before_snapshot, evidence_refs=evidence_refs, ) store.save() raise RuntimeError( "SAP 已接受回写,但本地提交失败,需要按幂等键对账恢复" ) from exc execution["status"] = "SUCCEEDED" execution["finishedAt"] = fmt_date(today0()) execution["finishedAtEpoch"] = time.time() journal = { "id": store.next_id("sapJournal"), "direction": "outbound", "at": fmt_date(today0()), "actor": actor, "pushed": len(pushed), "duplicates": len(duped), "versionNo": approved.get("versionNo"), "executionId": execution_id, "confirmId": confirm_id, "payloadDigest": payload_digest, "worldFingerprint": approved["worldFingerprint"], "pushedIdemKeys": list(pushed_keys), "duplicateIdemKeys": list(duped), "grantDigest": grant_digest, "grantConsumed": True, "grantConsumedAtEpoch": execution["grantConsumedAtEpoch"], "summary": f"回写 {len(pushed)} 新 / {len(duped)} 幂等跳过", } world.setdefault("sapSyncJournal", []).append(journal) write_audit( world, store.next_id, actor=actor, category="INTEGRATION", action="sap.sync.outbound", target={"type": "SAP", "id": "outbound", "executionId": execution_id}, power="P3", rationale={ "confirmId": confirm_id, "requester": requester, "approvers": approvals, "payloadDigest": payload_digest, "worldFingerprint": approved["worldFingerprint"], "pushedReceiptIds": list(pushed), "pushedIdemKeys": list(pushed_keys), "duplicateIdemKeys": list(duped), "idempotencyKeys": list(approved["idemKeys"]), "grantDigest": grant_digest, "grantConsumed": True, "grantConsumedAtEpoch": execution["grantConsumedAtEpoch"], }, before_snapshot=before_snapshot, evidence_refs=evidence_refs, ) store.save() return { "journal": journal, "pushed": pushed, "duplicates": duped, "pushedIdemKeys": pushed_keys, "execution": execution, "message": ( f"SAP 出站完成 ✅ 新回写 {len(pushed)}," f"幂等跳过 {len(duped)}(Mock 已收)。" ), } def confirmation_for_sap_sync(world: World, direction: str) -> tuple[str, list[str]]: if direction == "inbound": p = preview_inbound(world) return "SAP 入站同步确认", [ p["summary"], f"系统 {p['connection'].get('system')} / 工厂 {p['connection'].get('plant')}", "将写入柔性订单并更新库存(可回滚检查点)", ] p = preview_outbound(world) return "SAP 出站回写确认", [ p["summary"], f"系统 {p['connection'].get('system')}(Mock 桩,无真实 RFC)", "幂等键保证重复回写不产生重复外部单", ] def stage_sap_sync(store, direction: str, *, session_id: str, actor: str = "web") -> dict: """Stage inbound as P2 or freeze a P3 outbound evidence envelope.""" from server.agent_core import harness from server.agent_core.audit import write_audit normalized = str(direction or "").lower() if normalized not in {"inbound", "outbound"}: raise ValueError("SAP sync direction must be inbound or outbound") action = f"sap.sync.{normalized}" if normalized == "inbound": title, lines = confirmation_for_sap_sync(store.data, normalized) params = {"direction": normalized} block = harness.stage_confirmation( session_id, action, params, title=title, summary_lines=lines, ) write_audit( store.data, store.next_id, actor=actor, category="GATE", action=f"{action}.stage", target={"type": "SAP", "id": normalized}, power="P2", rationale={"confirmId": block.props["confirmId"]}, ) store.save() return { "staged": True, "message": f"{title} 属于 P2,需要你确认后执行。", "block": block, } from server.state.checkpoints import get_checkpoints harness.require_can_initiate(action) projection = build_outbound_projection(store.data, limit=50) if projection.get("versionId") is None: return {"staged": False, "message": "无柔性排产版本可回写", "block": None} if not projection.get("items"): return {"staged": False, "message": "当前版本没有可回写工序", "block": None} connection = get_sap_client().status() checkpoint_store = get_checkpoints() protected_pair_ids = { str(record.get("beforeSnapshot")) for record in harness.list_pending() if record.get("beforeSnapshot") } checkpoint = checkpoint_store.create( store.data, label=f"SAP 出站审批基线 {projection.get('versionNo') or projection.get('versionId')}", reason="stage:sap.sync.outbound", conversation_note="发起 SAP 出站 P3 审批", protected_pair_ids=protected_pair_ids, ) before_snapshot = str(checkpoint["pairId"]) approval_params = copy.deepcopy(projection) approval_params["beforeSnapshot"] = before_snapshot title = "SAP 出站回写确认" lines = [ f"版本 {projection.get('versionNo')} 可回写 {projection.get('selectedCount')} 条,候选总数 {projection.get('totalCount')}", f"系统 {connection.get('system')}(Mock 桩,无真实 RFC)", f"载荷摘要 {projection.get('payloadDigest')}", "该外部副作用属于 P3,必须由两名不同审批人确认。", ] try: block = harness.stage_confirmation( session_id, action, approval_params, title=title, summary_lines=lines, evidence_refs=list(projection["evidenceRefs"]), before_snapshot=before_snapshot, ) except Exception: checkpoint_store.delete(before_snapshot) raise write_audit( store.data, store.next_id, actor=actor, category="GATE", action=f"{action}.stage", target={"type": "SAP", "id": normalized}, power="P3", rationale={ "confirmId": block.props["confirmId"], "payloadDigest": projection["payloadDigest"], "worldFingerprint": projection["worldFingerprint"], "totalCount": projection["totalCount"], "selectedCount": projection["selectedCount"], }, before_snapshot=before_snapshot, evidence_refs=projection["evidenceRefs"], ) store.save() return { "staged": True, "message": f"{title} 属于 P3,需要两名不同用户批准后执行。", "block": block, "validation": { "versionId": projection["versionId"], "payloadDigest": projection["payloadDigest"], "worldFingerprint": projection["worldFingerprint"], "beforeSnapshot": before_snapshot, }, }