aps-agent/server/integrations/wms_stub.py

205 lines
8.7 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.

# ============================================================
# Mock WMS 缺料/库存事件源(moduleId: integ-wms-stub, 可重生 ✅)
# round-38 方向 N:缺料 → 重排端到端闭环 Mock
# 与 mes_stub 同构:读/写本地镜像(库存台账 + 缺料事件流);
# emit_shortage 幂等生成缺料事件(eventId + 乱序容忍 offset/seq);
# replay 按事件流重放;库存版本号单调递增(monotonic)。
# ============================================================
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from typing import Any
_MIRROR_PATH = Path(__file__).resolve().parents[1] / "data" / "wms_mirror.json"
def default_mirror() -> dict[str, Any]:
return {
"system": "WMS-MOCK",
"plant": "CNWH",
"updatedAt": datetime.now().strftime("%Y-%m-%d %H:%M"),
"inventory": [], # 库存台账 [{materialCode,name,unit,stock,inTransit,safetyStock,version}]
"shortageEvents": [], # 缺料/库存事件流 [{eventId,seq,offset,type,materialCode,...}]
"idempotency": {}, # dedupKey -> eventId
"inventoryVersion": 0, # 单调库存版本号
}
def _now_ts() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
class MockWmsClient:
"""进程内 Mock WMS:读写本地镜像,模拟缺料事件上报与重放。"""
def __init__(self, path: Path | None = None):
self.path = path or _MIRROR_PATH
self.path.parent.mkdir(parents=True, exist_ok=True)
if not self.path.exists():
self._save(default_mirror())
def _load(self) -> dict:
try:
return json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
data = default_mirror()
self._save(data)
return data
def _save(self, data: dict) -> None:
data["updatedAt"] = datetime.now().strftime("%Y-%m-%d %H:%M")
self.path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
# ---------------- 只读:状态 / 台账 / 版本 ----------------
def status(self) -> dict:
m = self._load()
return {
"connected": True,
"mode": "stub",
"system": m.get("system"),
"plant": m.get("plant"),
"updatedAt": m.get("updatedAt"),
"inventoryVersion": int(m.get("inventoryVersion") or 0),
"inventoryCount": len(m.get("inventory") or []),
"eventCount": len(m.get("shortageEvents") or []),
"shortageCount": sum(1 for e in m.get("shortageEvents") or [] if e.get("type") == "SHORTAGE"),
}
def inventory_version(self) -> int:
"""库存版本号(monotonic,随每次事件 +1)。"""
return int(self._load().get("inventoryVersion") or 0)
def ledger(self) -> list[dict]:
"""库存台账快照(P0 只读)。"""
return list(self._load().get("inventory") or [])
# ---------------- 写:库存台账(初始同步/测试种子) ----------------
def upsert_inventory(self, rows: list[dict[str, Any]]) -> list[dict]:
"""写入/更新库存台账(幂等按 materialCode 合并)。返回当前台账。"""
m = self._load()
existing = {r.get("materialCode"): r for r in m.get("inventory", [])}
for row in rows:
code = row.get("materialCode")
if not code:
continue
if code in existing:
existing[code].update({k: v for k, v in row.items() if v is not None})
else:
m.setdefault("inventory", []).append(dict(row))
self._save(m)
return self.ledger()
# ---------------- 写:缺料事件(幂等 eventId + 乱序容忍 offset/seq) ----------------
def emit_shortage(self, material_code: str, *, new_stock: float,
shortage_qty: float | None = None, occurred_at: str | None = None,
idem_key: str | None = None,
name: str | None = None, unit: str | None = None) -> dict:
"""生成一条缺料事件并写镜像事件流。
幂等:相同 idem_key(缺省 = materialCode + occurredAt)只生成一次,
重复调用返回同一事件(duplicate=True)。
乱序容忍:事件携带单调 seq(逻辑顺序)与 offset(到达顺序),
消费端按 seq 应用、按 eventId 去重,不依赖到达顺序。
"""
m = self._load()
key = idem_key or f"shortage:{material_code}:{occurred_at or ''}"
seen = (m.get("idempotency") or {}).get(key)
if seen:
ev = next((e for e in m.get("shortageEvents", []) if e.get("eventId") == seen), None)
if ev is not None:
return {**ev, "duplicate": True}
row = next((r for r in m.get("inventory", []) if r.get("materialCode") == material_code), None)
seq = len(m.get("shortageEvents") or []) + 1
event_id = f"WMS-EVT-{seq:04d}"
new_version = int(m.get("inventoryVersion") or 0) + 1
event = {
"eventId": event_id,
"seq": seq,
"offset": len(m.get("shortageEvents") or []),
"type": "SHORTAGE",
"materialCode": material_code,
"name": name or (row or {}).get("name"),
"unit": unit or (row or {}).get("unit"),
"oldStock": (float(row["stock"]) if row is not None and row.get("stock") is not None else None),
"newStock": float(new_stock),
"shortageQty": float(shortage_qty) if shortage_qty is not None else None,
"occurredAt": occurred_at or _now_ts(),
"dedupKey": key,
"source": "WMS-MOCK",
"wmsVersion": new_version,
}
m.setdefault("shortageEvents", []).append(event)
m.setdefault("idempotency", {})[key] = event_id
if row is None:
m.setdefault("inventory", []).append({
"materialCode": material_code, "name": name or material_code, "unit": unit or "",
"stock": float(new_stock), "inTransit": 0, "safetyStock": 0, "version": new_version,
})
else:
row["stock"] = float(new_stock)
row["version"] = new_version
m["inventoryVersion"] = new_version
self._save(m)
return {**event, "duplicate": False}
def record_external(self, event: dict[str, Any]) -> dict:
"""把外部上报事件追加进镜像流(供 replay 覆盖),按 eventId 幂等。"""
m = self._load()
eid = str(event.get("eventId") or "").strip()
if not eid:
raise ValueError("外部 WMS 事件缺少 eventId")
for e in m.get("shortageEvents", []):
if e.get("eventId") == eid:
return e
seq = len(m.get("shortageEvents") or []) + 1
new_version = int(m.get("inventoryVersion") or 0) + 1
rec = {
"eventId": eid, "seq": seq, "offset": len(m.get("shortageEvents") or []),
"type": str(event.get("type") or "SHORTAGE").upper(),
"materialCode": str(event.get("materialCode") or ""),
"name": event.get("name"), "unit": event.get("unit"),
"oldStock": event.get("oldStock"), "newStock": event.get("newStock"),
"shortageQty": event.get("shortageQty"), "occurredAt": event.get("occurredAt") or _now_ts(),
"dedupKey": f"external:{eid}", "source": str(event.get("source") or "WMS-EXTERNAL"),
"wmsVersion": new_version,
}
m.setdefault("shortageEvents", []).append(rec)
m["inventoryVersion"] = new_version
self._save(m)
return rec
# ---------------- 读:事件流重放 ----------------
def replay(self, limit: int | None = None, order: str = "seq") -> list[dict]:
"""按事件流重放(P0)。order: seq=逻辑顺序(乱序容忍基准)/ arrival=到达顺序。"""
events = list(self._load().get("shortageEvents") or [])
if (order or "seq").lower() == "arrival":
events.sort(key=lambda e: int(e.get("offset") or 0))
else:
events.sort(key=lambda e: int(e.get("seq") or 0))
if limit is not None:
events = events[: max(0, int(limit))]
return events
def reset(self) -> dict:
data = default_mirror()
self._save(data)
return self.status()
_client: MockWmsClient | None = None
def get_wms_client() -> MockWmsClient:
global _client
if _client is None:
_client = MockWmsClient()
return _client
def reset_wms_client(path: Path | None = None) -> MockWmsClient:
global _client
_client = MockWmsClient(path)
_client.reset()
return _client