2026-07-23 13:38:43 +08:00
|
|
|
|
# ============================================================
|
|
|
|
|
|
# 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}
|
|
|
|
|
|
|
2026-08-11 00:54:05 +08:00
|
|
|
|
|
|
|
|
|
|
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}
|
|
|
|
|
|
|
2026-07-23 13:38:43 +08:00
|
|
|
|
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
|