aps-agent/server/aps_domain/sap_sync.py

935 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 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,
},
}