aps-agent/server/aps_domain/sap_sync.py

935 lines
37 KiB
Python
Raw Normal View History

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