aps-agent/server/aps_domain/sap_sync.py

238 lines
10 KiB
Python
Raw Normal View History

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