aps-agent/server/integrations/mes_stub.py

142 lines
5.2 KiB
Python
Raw Permalink Normal View History

# ============================================================
# MES 集成桩(moduleId: integ-mes-stub, 可重生 ✅)
# EX-05/09 最小切片:进程内 Mock MES,无真实车间接口。
# 下发:创建外部工单(幂等);报工:进度/完工回写镜像。
# ============================================================
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" / "mes_mirror.json"
def default_mirror() -> dict[str, Any]:
return {
"system": "MES-MOCK",
"plant": "CNWH",
"updatedAt": datetime.now().strftime("%Y-%m-%d %H:%M"),
"workOrders": [], # 外部工单
"reports": [], # 报工流水
"idempotency": {}, # key → externalWoId
}
class MockMesClient:
"""进程内 Mock MES:读写本地镜像,模拟下发与报工。"""
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()
wos = m.get("workOrders") or []
return {
"connected": True,
"mode": "stub",
"system": m.get("system"),
"plant": m.get("plant"),
"updatedAt": m.get("updatedAt"),
"woCount": len(wos),
"openCount": sum(1 for w in wos if w.get("status") != "COMPLETED"),
"reportCount": len(m.get("reports") or []),
}
def create_work_order(self, payload: dict, idem_key: str) -> dict:
"""幂等下发:相同 idem_key 返回原外部工单。"""
m = self._load()
existing = (m.get("idempotency") or {}).get(idem_key)
if existing:
wo = next((w for w in m.get("workOrders", []) if w["id"] == existing), None)
return {"duplicate": True, "externalWo": wo or {"id": existing, "idemKey": idem_key}}
eid = f"MES-WO-{len(m.get('workOrders') or []) + 1:04d}"
external = {
"id": eid, "idemKey": idem_key, "plant": m.get("plant"),
"at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"status": "RELEASED", "qtyDone": 0, "progressPct": 0,
**payload,
}
m.setdefault("workOrders", []).append(external)
m.setdefault("idempotency", {})[idem_key] = eid
self._save(m)
return {"duplicate": False, "externalWo": external}
def post_report(self, external_wo_id: str, payload: dict) -> dict:
"""写入报工流水并更新外部工单进度。"""
m = self._load()
wo = next((w for w in m.get("workOrders", []) if w["id"] == external_wo_id), None)
if not wo:
raise ValueError(f"MES 无外部工单 {external_wo_id}")
rid = f"MES-RPT-{len(m.get('reports') or []) + 1:04d}"
report = {
"id": rid, "externalWoId": external_wo_id,
"at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
**payload,
}
m.setdefault("reports", []).append(report)
if "qtyDone" in payload:
wo["qtyDone"] = payload["qtyDone"]
if "progressPct" in payload:
wo["progressPct"] = payload["progressPct"]
if payload.get("status"):
wo["status"] = payload["status"]
self._save(m)
return {"report": report, "externalWo": wo}
def cancel_work_order(self, external_wo_id: str, *, reason: str = "saga-compensation") -> dict:
"""幂等撤销外部工单(Saga 补偿:cancel dispatch)。
已 CANCELLED → duplicate(不重复撤销);不存在 → ValueError。
"""
m = self._load()
wo = next((w for w in m.get("workOrders", []) if w["id"] == external_wo_id), None)
if wo is None:
raise ValueError(f"MES 无外部工单 {external_wo_id}")
if wo.get("status") == "CANCELLED":
return {"duplicate": True, "externalWo": wo}
wo["status"] = "CANCELLED"
wo["cancelReason"] = reason
wo["cancelledAt"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
self._save(m)
return {"duplicate": False, "externalWo": wo}
def reset(self) -> dict:
data = default_mirror()
self._save(data)
return self.status()
_client: MockMesClient | None = None
def get_mes_client() -> MockMesClient:
global _client
if _client is None:
_client = MockMesClient()
return _client
def reset_mes_client(path: Path | None = None) -> MockMesClient:
global _client
_client = MockMesClient(path)
_client.reset()
return _client