238 lines
10 KiB
Python
238 lines
10 KiB
Python
# ============================================================
|
||
# 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}
|