2026-07-23 13:38:43 +08:00
|
|
|
|
# ============================================================
|
|
|
|
|
|
# SAP 同步领域服务(moduleId: domain-sap-sync, 可重生 ✅)
|
|
|
|
|
|
# MD-05:入站拉单/库存 → 柔性订单;出站回写开完工(经 Mock SAP 桩)
|
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-08-11 00:54:05 +08:00
|
|
|
|
import copy
|
|
|
|
|
|
import hashlib
|
|
|
|
|
|
import json
|
|
|
|
|
|
import time
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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)} 项。")}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 00:54:05 +08:00
|
|
|
|
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 [])
|
2026-07-23 13:38:43 +08:00
|
|
|
|
if not versions:
|
2026-08-11 00:54:05 +08:00
|
|
|
|
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}",
|
|
|
|
|
|
]
|
2026-07-23 13:38:43 +08:00
|
|
|
|
return {
|
|
|
|
|
|
"direction": "outbound",
|
2026-08-11 00:54:05 +08:00
|
|
|
|
"versionId": version_id,
|
|
|
|
|
|
"versionNo": version_no,
|
|
|
|
|
|
"versionStatus": version_status,
|
2026-07-23 13:38:43 +08:00
|
|
|
|
"items": items,
|
2026-08-11 00:54:05 +08:00
|
|
|
|
"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)} 条工序完工"
|
|
|
|
|
|
),
|
2026-07-23 13:38:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 00:54:05 +08:00
|
|
|
|
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
|
2026-07-23 13:38:43 +08:00
|
|
|
|
from server.agent_core.audit import write_audit
|
2026-08-11 00:54:05 +08:00
|
|
|
|
|
2026-07-23 13:38:43 +08:00
|
|
|
|
world = store.data
|
2026-08-11 00:54:05 +08:00
|
|
|
|
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",
|
2026-07-23 13:38:43 +08:00
|
|
|
|
}
|
2026-08-11 00:54:05 +08:00
|
|
|
|
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"])
|
2026-07-23 13:38:43 +08:00
|
|
|
|
else:
|
2026-08-11 00:54:05 +08:00
|
|
|
|
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()
|
2026-07-23 13:38:43 +08:00
|
|
|
|
journal = {
|
|
|
|
|
|
"id": store.next_id("sapJournal"),
|
2026-08-11 00:54:05 +08:00
|
|
|
|
"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"],
|
2026-07-23 13:38:43 +08:00
|
|
|
|
"summary": f"回写 {len(pushed)} 新 / {len(duped)} 幂等跳过",
|
|
|
|
|
|
}
|
|
|
|
|
|
world.setdefault("sapSyncJournal", []).append(journal)
|
2026-08-11 00:54:05 +08:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
2026-07-23 13:38:43 +08:00
|
|
|
|
store.save()
|
2026-08-11 00:54:05 +08:00
|
|
|
|
return {
|
|
|
|
|
|
"journal": journal,
|
|
|
|
|
|
"pushed": pushed,
|
|
|
|
|
|
"duplicates": duped,
|
|
|
|
|
|
"pushedIdemKeys": pushed_keys,
|
|
|
|
|
|
"execution": execution,
|
|
|
|
|
|
"message": (
|
|
|
|
|
|
f"SAP 出站完成 ✅ 新回写 {len(pushed)},"
|
|
|
|
|
|
f"幂等跳过 {len(duped)}(Mock 已收)。"
|
|
|
|
|
|
),
|
|
|
|
|
|
}
|
2026-07-23 13:38:43 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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:
|
2026-08-11 00:54:05 +08:00
|
|
|
|
"""Stage inbound as P2 or freeze a P3 outbound evidence envelope."""
|
2026-07-23 13:38:43 +08:00
|
|
|
|
from server.agent_core import harness
|
|
|
|
|
|
from server.agent_core.audit import write_audit
|
2026-08-11 00:54:05 +08:00
|
|
|
|
|
|
|
|
|
|
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"],
|
|
|
|
|
|
)
|
2026-07-23 13:38:43 +08:00
|
|
|
|
store.save()
|
2026-08-11 00:54:05 +08:00
|
|
|
|
return {
|
|
|
|
|
|
"staged": True,
|
|
|
|
|
|
"message": f"{title} 属于 P3,需要两名不同用户批准后执行。",
|
|
|
|
|
|
"block": block,
|
|
|
|
|
|
"validation": {
|
|
|
|
|
|
"versionId": projection["versionId"],
|
|
|
|
|
|
"payloadDigest": projection["payloadDigest"],
|
|
|
|
|
|
"worldFingerprint": projection["worldFingerprint"],
|
|
|
|
|
|
"beforeSnapshot": before_snapshot,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|