# ============================================================ # SAP 同步领域服务(moduleId: domain-sap-sync, 可重生 ✅) # MD-05:入站拉单/库存 → 柔性订单;出站回写开完工(经 Mock SAP 桩) # ============================================================ from __future__ import annotations 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 preview_outbound(world: World) -> dict: """预览 APS→SAP 出站:最新柔性版本工单开完工回写清单。""" from server.state.seed import ensure_flex_seed ensure_flex_seed(world) client = get_sap_client() versions = world.get("flexScheduleVersions", []) if not versions: return {"direction": "outbound", "connection": client.status(), "items": [], "summary": "无柔性排产版本可回写"} latest = versions[-1] vid = latest["id"] wos = [w for w in world.get("flexWorkOrders", []) if w.get("versionId") == vid and not w.get("frozen")] orders = {o["orderNo"]: o for o in world.get("flexOrders", [])} items = [] for w in wos[:50]: # 演示上限 fo = orders.get(w.get("flexOrderNo") or "") aufnr = (fo or {}).get("externalAufnr") idem = f"{vid}:{w['id']}:finish" items.append({ "woId": w["id"], "orderNo": w.get("flexOrderNo"), "aufnr": aufnr, "operation": w.get("operationCode"), "equipment": w.get("equipmentCode"), "start": w.get("plannedStartTime"), "end": w.get("plannedEndTime"), "qty": (fo or {}).get("quantity"), "idemKey": idem, "event": "FINISH", }) return { "direction": "outbound", "connection": client.status(), "versionNo": latest.get("versionNo"), "versionId": vid, "items": items, "summary": f"版本 {latest.get('versionNo')} 可回写 {len(items)} 条工序完工", } def apply_outbound(store, actor: str = "web") -> dict: """执行出站:幂等推送到 Mock SAP。""" 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_outbound(world) client = get_sap_client() pushed, duped = [], [] for it in preview.get("items") or []: payload = { "event": it["event"], "aufnr": it.get("aufnr"), "orderNo": it.get("orderNo"), "operation": it.get("operation"), "equipment": it.get("equipment"), "start": it.get("start"), "end": it.get("end"), "qty": it.get("qty"), "woId": it["woId"], } r = client.push_receipt(payload, it["idemKey"]) if r["duplicate"]: duped.append(it["idemKey"]) else: pushed.append(r["receipt"]["id"]) world.setdefault("sapLinks", []).append({ "kind": "receipt", "receiptId": r["receipt"]["id"], "woId": it["woId"], "idemKey": it["idemKey"], "syncedAt": fmt_date(today0()), "actor": actor, }) journal = { "id": store.next_id("sapJournal"), "direction": "outbound", "at": fmt_date(today0()), "actor": actor, "pushed": len(pushed), "duplicates": len(duped), "versionNo": preview.get("versionNo"), "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"}, power="P2", rationale={"pushed": len(pushed), "duped": len(duped)}) store.save() return {"journal": journal, "pushed": pushed, "duplicates": duped, "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: """出 P2 确认卡(不改世界业务数据)。direction ∈ inbound|outbound。""" from server.agent_core import harness from server.agent_core.audit import write_audit action = f"sap.sync.{direction}" title, lines = confirmation_for_sap_sync(store.data, direction) block = harness.stage_confirmation( session_id, action, {"direction": direction}, title=title, summary_lines=lines) write_audit(store.data, store.next_id, actor=actor, category="GATE", action=f"{action}.stage", target={"type": "SAP", "id": direction}, power="P2", rationale={"confirmId": block.props["confirmId"]}) store.save() return {"staged": True, "message": f"{title} 属于 P2,需要你确认后执行。", "block": block}