aps-agent/server/integrations/mes_stub.py

142 lines
5.2 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.

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