935 lines
37 KiB
Python
935 lines
37 KiB
Python
# ============================================================
|
||
# 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,
|
||
},
|
||
}
|