aps-agent/server/aps_domain/sap_sync.py

238 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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