# ============================================================ # MES 下发与报工(moduleId: domain-mes, 可重生 ✅) # EX-05 下发 / EX-09 报工回流:经 Mock MES 桩,幂等键防重复。 # ============================================================ from __future__ import annotations from typing import Any from server.integrations.mes_stub import get_mes_client from server.timeutil import fmt_date, today0 World = dict[str, Any] def mes_connection_status() -> dict: return get_mes_client().status() def _latest_flex(world: World) -> dict | None: vers = world.get("flexScheduleVersions") or [] return vers[-1] if vers else None def _latest_fixed(world: World, *, published_only: bool = True) -> dict | None: vers = world.get("scheduleVersions") or [] if published_only: pubs = [v for v in vers if v.get("status") == "PUBLISHED"] return pubs[-1] if pubs else None return vers[-1] if vers else None def _linked_keys(world: World) -> set[str]: return {l.get("idemKey") for l in world.get("mesLinks", []) if l.get("kind") == "dispatch"} def preview_dispatch(world: World, track: str = "flex") -> dict: """预览可下发工单(不改世界)。track ∈ flex|fixed。""" from server.state.seed import ensure_flex_seed client = get_mes_client() tr = (track or "flex").lower() if tr == "flex": ensure_flex_seed(world) ver = _latest_flex(world) if not ver: return {"track": "flex", "connection": client.status(), "items": [], "summary": "无柔性排产版本可下发"} vid = ver["id"] wos = [w for w in world.get("flexWorkOrders", []) if w.get("versionId") == vid and not w.get("frozen")] items = [] linked = _linked_keys(world) for w in wos[:80]: idem = f"flex:{vid}:{w['id']}" items.append({ "woId": w["id"], "orderNo": w.get("flexOrderNo"), "operation": w.get("operationCode"), "equipment": w.get("equipmentCode"), "start": w.get("plannedStartTime"), "end": w.get("plannedEndTime"), "idemKey": idem, "already": idem in linked, "mesExternalId": w.get("mesExternalId"), }) new_n = sum(1 for i in items if not i["already"]) return { "track": "flex", "connection": client.status(), "versionId": vid, "versionNo": ver.get("versionNo"), "versionStatus": ver.get("status"), "items": items, "newCount": new_n, "summary": (f"柔性版本 {ver.get('versionNo')} 可新下发 {new_n} / " f"共 {len(items)} 条工序"), } ver = _latest_fixed(world, published_only=True) if not ver: return {"track": "fixed", "connection": client.status(), "items": [], "summary": "无已发布固定排产版本(请先「发布版本」)"} vid = ver["id"] wos = [w for w in world.get("workOrders", []) if w.get("versionId") == vid] linked = _linked_keys(world) items = [] for w in wos[:80]: idem = f"fixed:{vid}:{w['id']}" items.append({ "woId": w["id"], "orderNo": w.get("productionOrderNo") or w.get("orderNo"), "operation": w.get("operationCode") or w.get("operationName"), "equipment": w.get("workstationName") or w.get("lineName"), "start": w.get("plannedStartTime"), "end": w.get("plannedEndTime"), "idemKey": idem, "already": idem in linked, "mesExternalId": w.get("mesExternalId"), }) new_n = sum(1 for i in items if not i["already"]) return { "track": "fixed", "connection": client.status(), "versionId": vid, "versionNo": ver.get("versionNo"), "versionStatus": ver.get("status"), "items": items, "newCount": new_n, "summary": (f"固定版本 {ver.get('versionNo')} 可新下发 {new_n} / " f"共 {len(items)} 条工序"), } def apply_dispatch(store, track: str = "flex", actor: str = "web") -> dict: """执行下发:幂等推送 Mock MES,回写 mesExternalId。""" from server.agent_core.audit import write_audit from server.state.seed import ensure_flex_seed tr = (track or "flex").lower() if tr == "flex": ensure_flex_seed(store.data) world = store.data preview = preview_dispatch(world, tr) client = get_mes_client() created, duped = [], [] wo_by_id = {} if tr == "flex": wo_by_id = {w["id"]: w for w in world.get("flexWorkOrders", [])} else: wo_by_id = {w["id"]: w for w in world.get("workOrders", [])} for it in preview.get("items") or []: if it.get("already"): duped.append(it["idemKey"]) continue payload = { "track": tr, "apsWoId": it["woId"], "orderNo": it.get("orderNo"), "operation": it.get("operation"), "equipment": it.get("equipment"), "start": it.get("start"), "end": it.get("end"), "versionId": preview.get("versionId"), "versionNo": preview.get("versionNo"), } r = client.create_work_order(payload, it["idemKey"]) ext = r["externalWo"] if r["duplicate"]: duped.append(it["idemKey"]) else: created.append(ext["id"]) wo = wo_by_id.get(it["woId"]) if wo: wo["mesExternalId"] = ext["id"] wo["mesIdemKey"] = it["idemKey"] if wo.get("status") in (None, "PENDING", "DRAFT"): wo["status"] = "RELEASED" wo.setdefault("progressPct", 0) wo.setdefault("qtyDone", 0) if not r["duplicate"]: world.setdefault("mesLinks", []).append({ "kind": "dispatch", "idemKey": it["idemKey"], "externalWoId": ext["id"], "woId": it["woId"], "track": tr, "versionId": preview.get("versionId"), "syncedAt": fmt_date(today0()), "actor": actor, }) # 版本标记已下发 vid = preview.get("versionId") if tr == "flex" and vid: for v in world.get("flexScheduleVersions", []): if v["id"] == vid: v["status"] = "DISPATCHED" v["dispatchedAt"] = fmt_date(today0()) break elif tr == "fixed" and vid: for v in world.get("scheduleVersions", []): if v["id"] == vid: v["dispatchedAt"] = fmt_date(today0()) v["mesDispatched"] = True break journal = { "id": store.next_id("mesJournal"), "direction": "dispatch", "track": tr, "at": fmt_date(today0()), "actor": actor, "created": len(created), "duplicates": len(duped), "versionNo": preview.get("versionNo"), "summary": f"下发 {len(created)} 新 / {len(duped)} 跳过", } world.setdefault("mesSyncJournal", []).append(journal) write_audit(world, store.next_id, actor=actor, category="INTEGRATION", action="mes.dispatch", target={"type": "MES", "id": tr}, power="P3", rationale={"created": len(created), "duped": len(duped)}) store.save() return { "journal": journal, "created": created, "duplicates": duped, "message": (f"MES 下发完成 ✅ 新外部工单 {len(created)}," f"幂等跳过 {len(duped)}({preview.get('versionNo')})。"), } def list_execution(world: World, track: str = "flex") -> dict: """已下发工单进度清单(EX-09)。""" from server.state.seed import ensure_flex_seed tr = (track or "flex").lower() if tr == "flex": ensure_flex_seed(world) ver = _latest_flex(world) vid = ver["id"] if ver else None wos = [w for w in world.get("flexWorkOrders", []) if (not vid or w.get("versionId") == vid) and w.get("mesExternalId")] else: ver = _latest_fixed(world, published_only=False) vid = ver["id"] if ver else None wos = [w for w in world.get("workOrders", []) if (not vid or w.get("versionId") == vid) and w.get("mesExternalId")] rows = [{ "woId": w["id"], "orderNo": w.get("flexOrderNo") or w.get("productionOrderNo"), "operation": w.get("operationCode") or w.get("operationName"), "equipment": w.get("equipmentCode") or w.get("workstationName"), "status": w.get("status"), "progressPct": w.get("progressPct", 0), "qtyDone": w.get("qtyDone", 0), "mesExternalId": w.get("mesExternalId"), "start": w.get("plannedStartTime"), "end": w.get("plannedEndTime"), } for w in wos] done = sum(1 for r in rows if r["status"] == "COMPLETED" or (r["progressPct"] or 0) >= 100) return { "track": tr, "versionNo": (ver or {}).get("versionNo"), "total": len(rows), "completed": done, "rows": rows, "connection": get_mes_client().status(), } def apply_report(store, wo_id: int, *, track: str = "flex", progress_pct: int | None = None, finish: bool = False, actor: str = "web") -> dict: """报工回流:更新 APS 工单进度并写 Mock MES。""" from server.agent_core import harness from server.agent_core.audit import write_audit from server.state.seed import ensure_flex_seed def _run(): tr = (track or "flex").lower() if tr == "flex": ensure_flex_seed(store.data) wos = store.data.get("flexWorkOrders", []) else: wos = store.data.get("workOrders", []) wo = next((w for w in wos if w["id"] == wo_id), None) if not wo: raise ValueError(f"工单 #{wo_id} 不存在") ext_id = wo.get("mesExternalId") if not ext_id: raise ValueError(f"工单 #{wo_id} 尚未下发 MES,无法报工") pct = 100 if finish else int(progress_pct if progress_pct is not None else 50) pct = max(0, min(100, pct)) status = "COMPLETED" if pct >= 100 else "RUNNING" qty = wo.get("qtyDone") or 0 # 柔性订单数量作参考 if tr == "flex": fo = next((o for o in store.data.get("flexOrders", []) if o.get("orderNo") == wo.get("flexOrderNo")), None) target_qty = int((fo or {}).get("quantity") or 1) else: target_qty = 1 qty_done = target_qty if pct >= 100 else max(qty, int(target_qty * pct / 100)) client = get_mes_client() client.post_report(ext_id, { "progressPct": pct, "qtyDone": qty_done, "status": status, "actor": actor, }) wo["progressPct"] = pct wo["qtyDone"] = qty_done wo["status"] = status if status == "COMPLETED": wo["actualEndTime"] = wo.get("plannedEndTime") # 同订单工序全完工 → 柔性订单完成 order_done = False if tr == "flex" and status == "COMPLETED": order_no = wo.get("flexOrderNo") vid = wo.get("versionId") sibs = [w for w in store.data.get("flexWorkOrders", []) if w.get("flexOrderNo") == order_no and w.get("versionId") == vid and not w.get("frozen")] if sibs and all((w.get("status") == "COMPLETED" or (w.get("progressPct") or 0) >= 100) for w in sibs): fo = next((o for o in store.data.get("flexOrders", []) if o.get("orderNo") == order_no), None) if fo: fo["status"] = "COMPLETED" order_done = True store.data.setdefault("mesLinks", []).append({ "kind": "report", "woId": wo_id, "externalWoId": ext_id, "track": tr, "progressPct": pct, "status": status, "syncedAt": fmt_date(today0()), "actor": actor, }) write_audit(store.data, store.next_id, actor=actor, category="INTEGRATION", action="mes.report", target={"type": "WORK_ORDER", "id": wo_id}, power="P1", rationale={"pct": pct, "status": status, "orderDone": order_done}) store.save() msg = f"报工已回写 ✅ WO#{wo_id} → {pct}%({status})" if order_done: msg += f";订单 {wo.get('flexOrderNo')} 已完工" return {"woId": wo_id, "progressPct": pct, "status": status, "orderDone": order_done, "message": msg} return harness.guard("mes.report", {"woId": wo_id}, _run) def confirmation_for_dispatch(world: World, track: str = "flex") -> tuple[str, list[str]]: p = preview_dispatch(world, track) tr_cn = "柔性" if (track or "flex").lower() == "flex" else "固定" return f"MES 下发确认({tr_cn})", [ p["summary"], f"系统 {p['connection'].get('system')} / 工厂 {p['connection'].get('plant')}(Mock)", "外部副作用:写入 Mock MES;幂等键防重复工单(P3)", ] def stage_dispatch(store, track: str = "flex", *, session_id: str, actor: str = "web") -> dict: from server.agent_core import harness from server.agent_core.audit import write_audit tr = (track or "flex").lower() title, lines = confirmation_for_dispatch(store.data, tr) preview = preview_dispatch(store.data, tr) if not preview.get("items"): return {"staged": False, "message": preview.get("summary") or "无可下发工单", "block": None} if preview.get("newCount", 0) == 0: return {"staged": False, "message": "全部工序已下发(幂等),无需重复。", "block": None} block = harness.stage_confirmation( session_id, "mes.dispatch", {"track": tr}, title=title, summary_lines=lines) write_audit(store.data, store.next_id, actor=actor, category="GATE", action="mes.dispatch.stage", target={"type": "MES", "id": tr}, power="P3", rationale={"confirmId": block.props["confirmId"]}) store.save() return {"staged": True, "message": f"{title} 属于 P3,需要你确认后执行。", "block": block}