# ============================================================ # 工单拖拽调程(moduleId: domain-adjust, 可重生 ✅) # EX-04:预览(沙盒/只读校验)+ 提交(新草稿版本,P2) # 本切片优先柔性轨;固定轨后续对称扩展。 # ============================================================ from __future__ import annotations import copy from datetime import datetime, timedelta from typing import Any from server.timeutil import add_minutes, fmt_date, fmt_dt, parse_dt World = dict[str, Any] SNAP_MIN = 15 def snap_dt(dt, snap: int = SNAP_MIN): """吸附到 snap 分钟网格。""" total = dt.hour * 60 + dt.minute snapped = round(total / snap) * snap base = dt.replace(hour=0, minute=0, second=0, microsecond=0) return base + timedelta(minutes=snapped) def preview_flex_adjust(world: World, wo_id: int, new_start: str) -> dict: """预览柔性工单平移(P0):不改主干;校验设备占槽与工序先后。""" from server.state.seed import ensure_flex_seed ensure_flex_seed(world) versions = world.get("flexScheduleVersions", []) if not versions: raise ValueError("尚无柔性排产版本") latest_id = versions[-1]["id"] wos = [w for w in world.get("flexWorkOrders", []) if w.get("versionId") == latest_id] wo = next((w for w in wos if w["id"] == wo_id), None) if wo is None: raise ValueError(f"工单 id={wo_id} 不在最新版本") if wo.get("frozen"): return {"ok": False, "reasons": ["冻结工单不可拖拽"], "moves": [], "woId": wo_id, "newStart": None} old_start = parse_dt(wo["plannedStartTime"]) old_end = parse_dt(wo["plannedEndTime"]) dur = (old_end - old_start).total_seconds() / 60 target = snap_dt(parse_dt(new_start)) delta = (target - old_start).total_seconds() / 60 if abs(delta) < 1: return {"ok": True, "reasons": ["位置未变"], "moves": [], "woId": wo_id, "newStart": fmt_dt(old_start), "deltaMin": 0} # 同虚拟产线:本工序及后续工序整体平移,保持工艺先后 same_vl = sorted( [w for w in wos if w.get("vlId") == wo.get("vlId")], key=lambda w: w.get("seq") or 0) to_move = [w for w in same_vl if (w.get("seq") or 0) >= (wo.get("seq") or 0)] proposed = [] for w in to_move: ns = add_minutes(parse_dt(w["plannedStartTime"]), delta) ne = add_minutes(parse_dt(w["plannedEndTime"]), delta) proposed.append({ "woId": w["id"], "orderNo": w.get("flexOrderNo"), "seq": w.get("seq"), "equipmentId": w["equipmentId"], "equipmentCode": w.get("equipmentCode"), "oldStart": w["plannedStartTime"], "oldEnd": w["plannedEndTime"], "newStart": fmt_dt(ns), "newEnd": fmt_dt(ne), }) reasons: list[str] = [] # 设备占槽:与未移动工单、以及本批内其他设备冲突 moved_ids = {p["woId"] for p in proposed} for p in proposed: ns, ne = parse_dt(p["newStart"]), parse_dt(p["newEnd"]) for other in wos: if other["id"] in moved_ids: # 同批内不同设备可并行;同设备则互查 op = next((x for x in proposed if x["woId"] == other["id"]), None) if op and op["equipmentId"] == p["equipmentId"] and op["woId"] != p["woId"]: os, oe = parse_dt(op["newStart"]), parse_dt(op["newEnd"]) if ns < oe and ne > os: reasons.append( f"同设备冲突:{p['orderNo']}#{p['seq']} 与 " f"{op['orderNo']}#{op['seq']} @ {p['equipmentCode']}") continue if other["equipmentId"] != p["equipmentId"]: continue os, oe = parse_dt(other["plannedStartTime"]), parse_dt(other["plannedEndTime"]) if ns < oe and ne > os: reasons.append( f"设备 {p['equipmentCode']} 时段被 {other.get('flexOrderNo')} 占用" f"({other['plannedStartTime']}~{other['plannedEndTime']})") # 与前序工序:移动后仍须 ≥ 前序结束(前序若也在 to_move 则用新时间) by_id = {p["woId"]: p for p in proposed} for p in proposed: prev = next((w for w in same_vl if (w.get("seq") or 0) == (p["seq"] or 0) - 1), None) if not prev: continue if prev["id"] in by_id: prev_end = parse_dt(by_id[prev["id"]]["newEnd"]) else: prev_end = parse_dt(prev["plannedEndTime"]) if parse_dt(p["newStart"]) < prev_end: reasons.append(f"{p['orderNo']} 工序#{p['seq']} 早于前序完工 {fmt_dt(prev_end)}") # 去重 uniq = list(dict.fromkeys(reasons)) return { "ok": len(uniq) == 0, "reasons": uniq, "moves": proposed, "woId": wo_id, "newStart": fmt_dt(target), "deltaMin": delta, "durationMin": dur, "versionId": latest_id, "versionNo": versions[-1].get("versionNo"), } def stage_flex_adjust(store, wo_id: int, new_start: str, session_id: str, actor: str) -> dict: """暂存拖拽提交为 P2 确认卡。""" from server.agent_core import harness from server.agent_core.audit import write_audit preview = preview_flex_adjust(store.data, wo_id, new_start) if not preview["ok"]: raise ValueError(";".join(preview["reasons"]) or "调程不可行") if not preview["moves"]: return {"message": "位置未变,无需提交。", "preview": preview} lines = [ f"工单 #{wo_id} 平移 {preview['deltaMin']:.0f} 分钟 → {preview['newStart']}", f"联动同产线后续工序共 {len(preview['moves'])} 条", "将生成新草稿版本(不改历史版本)", ] for m in preview["moves"][:4]: lines.append(f" · {m['orderNo']}#{m['seq']} {m['oldStart']} → {m['newStart']}") block = harness.stage_confirmation( session_id, "flex.adjust.commit", {"woId": wo_id, "newStart": preview["newStart"], "moves": preview["moves"]}, title="甘特调程确认", summary_lines=lines) write_audit(store.data, store.next_id, actor=actor, category="GATE", action="flex.adjust.stage", target={"type": "FLEX_WO", "id": wo_id}, power="P2", rationale={"confirmId": block.props["confirmId"], "deltaMin": preview["deltaMin"]}) store.save() return {"message": "调程已进入 P2 确认队列。", "block": block.model_dump(), "preview": preview} def commit_flex_adjust(store, wo_id: int, new_start: str, actor: str = "web") -> dict: """执行调程:复制最新版本为新草稿并写入平移后时间。""" from server.agent_core.audit import write_audit from server.state.seed import ensure_flex_seed ensure_flex_seed(store.data) preview = preview_flex_adjust(store.data, wo_id, new_start) if not preview["ok"]: raise ValueError(";".join(preview["reasons"]) or "调程不可行") if not preview["moves"]: return {"message": "位置未变", "versionId": preview["versionId"]} world = store.data versions = world["flexScheduleVersions"] latest = versions[-1] old_vid = latest["id"] new_vid = store.next_id("flexScheduleVersion") now = datetime.now() ver_no = "FV" + fmt_date(now).replace("-", "") + f"-{len(versions) + 1:03d}" new_ver = { **{k: v for k, v in latest.items() if k not in ("id", "versionNo", "versionName", "createdAt", "createdBy", "parentVersionId", "adjustOf")}, "id": new_vid, "versionNo": ver_no, "versionName": f"拖拽调程 #{wo_id}", "status": "DRAFT", "parentVersionId": old_vid, "adjustOf": wo_id, "createdBy": actor, "createdAt": fmt_dt(now), "conflictCount": 0, } # 复制 VL vl_map: dict[int, int] = {} for vl in world.get("flexVirtualLines", []): if vl.get("versionId") != old_vid: continue nid = store.next_id("flexVirtualLine") vl_map[vl["id"]] = nid world["flexVirtualLines"].append({**copy.deepcopy(vl), "id": nid, "versionId": new_vid}) move_by_id = {m["woId"]: m for m in preview["moves"]} for wo in world.get("flexWorkOrders", []): if wo.get("versionId") != old_vid: continue nw = copy.deepcopy(wo) nw["id"] = store.next_id("flexWorkOrder") nw["versionId"] = new_vid nw["vlId"] = vl_map.get(wo["vlId"], wo["vlId"]) mv = move_by_id.get(wo["id"]) if mv: nw["plannedStartTime"] = mv["newStart"] nw["plannedEndTime"] = mv["newEnd"] nw["adjustedFrom"] = mv["oldStart"] world["flexWorkOrders"].append(nw) # 刷新 VL 起止 for old_vl_id, new_vl_id in vl_map.items(): vl_wos = [w for w in world["flexWorkOrders"] if w.get("vlId") == new_vl_id] if not vl_wos: continue vl = next(v for v in world["flexVirtualLines"] if v["id"] == new_vl_id) vl["plannedStart"] = min(w["plannedStartTime"] for w in vl_wos) vl["plannedEnd"] = max(w["plannedEndTime"] for w in vl_wos) new_ver["vlCount"] = len(vl_map) new_ver["woCount"] = sum(1 for w in world["flexWorkOrders"] if w["versionId"] == new_vid) new_ver["conflictCount"] = 0 world["flexScheduleVersions"].append(new_ver) write_audit(world, store.next_id, actor=actor, category="WORLD_WRITE", action="flex.adjust.commit", target={"type": "FLEX_VERSION", "id": new_vid}, power="P2", rationale={"woId": wo_id, "newStart": new_start, "moved": len(preview["moves"])}) store.save() return {"message": f"调程完成 ✅ 新版本 {ver_no}(联动 {len(preview['moves'])} 条工单)。", "versionId": new_vid, "versionNo": ver_no, "preview": preview} # ---------------- 固定轨(产线/工位甘特) ---------------- def _latest_fixed_wos(world: World) -> tuple[dict | None, list[dict], list[dict]]: versions = world.get("scheduleVersions", []) if not versions: return None, [], [] latest = versions[-1] pos = [p for p in world.get("productionOrders", []) if p.get("schedulingVersionId") == latest["id"]] po_ids = {p["id"] for p in pos} wos = [w for w in world.get("workOrders", []) if w.get("productionOrderId") in po_ids] return latest, pos, wos def preview_fixed_adjust(world: World, wo_id: int, new_start: str) -> dict: """预览固定轨工单平移(P0):工位占槽 + 同 PO 工序先后 + 维保窗口。""" latest, _pos, wos = _latest_fixed_wos(world) if latest is None: raise ValueError("尚无固定轨排产版本") wo = next((w for w in wos if w["id"] == wo_id), None) if wo is None: raise ValueError(f"工单 id={wo_id} 不在最新版本") if wo.get("isFrozen"): return {"ok": False, "reasons": ["冻结工单不可拖拽"], "moves": [], "woId": wo_id, "newStart": None} old_start = parse_dt(wo["plannedStartTime"]) target = snap_dt(parse_dt(new_start)) delta = (target - old_start).total_seconds() / 60 if abs(delta) < 1: return {"ok": True, "reasons": ["位置未变"], "moves": [], "woId": wo_id, "newStart": fmt_dt(old_start), "deltaMin": 0} same_po = sorted( [w for w in wos if w.get("productionOrderId") == wo.get("productionOrderId")], key=lambda w: w.get("sequenceNo") or 0) to_move = [w for w in same_po if (w.get("sequenceNo") or 0) >= (wo.get("sequenceNo") or 0)] proposed = [] for w in to_move: ns = add_minutes(parse_dt(w["plannedStartTime"]), delta) ne = add_minutes(parse_dt(w["plannedEndTime"]), delta) proposed.append({ "woId": w["id"], "orderNo": w.get("orderNo"), "seq": w.get("sequenceNo"), "workstationId": w["workstationId"], "workstationName": w.get("workstationName"), "oldStart": w["plannedStartTime"], "oldEnd": w["plannedEndTime"], "newStart": fmt_dt(ns), "newEnd": fmt_dt(ne), }) reasons: list[str] = [] moved_ids = {p["woId"] for p in proposed} for p in proposed: ns, ne = parse_dt(p["newStart"]), parse_dt(p["newEnd"]) for other in wos: if other["id"] in moved_ids: op = next((x for x in proposed if x["woId"] == other["id"]), None) if op and op["workstationId"] == p["workstationId"] and op["woId"] != p["woId"]: os, oe = parse_dt(op["newStart"]), parse_dt(op["newEnd"]) if ns < oe and ne > os: reasons.append( f"同工位冲突:{p['orderNo']} 与 {op['orderNo']} @ {p['workstationName']}") continue if other["workstationId"] != p["workstationId"]: continue os, oe = parse_dt(other["plannedStartTime"]), parse_dt(other["plannedEndTime"]) if ns < oe and ne > os: reasons.append( f"工位 {p['workstationName']} 被 {other.get('orderNo')} 占用" f"({other['plannedStartTime']}~{other['plannedEndTime']})") by_id = {p["woId"]: p for p in proposed} for p in proposed: prev = next((w for w in same_po if (w.get("sequenceNo") or 0) == (p["seq"] or 0) - 1), None) if not prev: continue if prev["id"] in by_id: prev_end = parse_dt(by_id[prev["id"]]["newEnd"]) else: prev_end = parse_dt(prev["plannedEndTime"]) if parse_dt(p["newStart"]) < prev_end: reasons.append(f"{p['orderNo']} 早于前序完工 {fmt_dt(prev_end)}") # 维保窗口:设备.workstationId ↔ 工单工位 eq_by_ws = {e["workstationId"]: e for e in world.get("equipment", []) if e.get("workstationId")} for p in proposed: eq = eq_by_ws.get(p["workstationId"]) if not eq: continue ns, ne = parse_dt(p["newStart"]), parse_dt(p["newEnd"]) for m in world.get("maintenance", []): if m.get("status", "PLANNED") not in ("PLANNED", "IN_PROGRESS"): continue if m.get("equipmentId") != eq["id"]: continue ms, me = parse_dt(m["plannedStart"]), parse_dt(m["plannedEnd"]) if ns < me and ne > ms: reasons.append(f"{p['orderNo']} 落入维保窗口({eq.get('name') or eq['id']})") uniq = list(dict.fromkeys(reasons)) return { "ok": len(uniq) == 0, "reasons": uniq, "moves": proposed, "woId": wo_id, "newStart": fmt_dt(target), "deltaMin": delta, "versionId": latest["id"], "versionNo": latest.get("versionNo"), "track": "fixed", } def stage_fixed_adjust(store, wo_id: int, new_start: str, session_id: str, actor: str) -> dict: from server.agent_core import harness from server.agent_core.audit import write_audit preview = preview_fixed_adjust(store.data, wo_id, new_start) if not preview["ok"]: raise ValueError(";".join(preview["reasons"]) or "调程不可行") if not preview["moves"]: return {"message": "位置未变,无需提交。", "preview": preview} lines = [ f"工单 #{wo_id} 平移 {preview['deltaMin']:.0f} 分钟 → {preview['newStart']}", f"联动同 PO 后续工序共 {len(preview['moves'])} 条", "将生成新草稿版本(不改历史版本)", ] for m in preview["moves"][:4]: lines.append(f" · {m['orderNo']} {m['oldStart']} → {m['newStart']}") block = harness.stage_confirmation( session_id, "schedule.adjust.commit", {"woId": wo_id, "newStart": preview["newStart"], "moves": preview["moves"]}, title="固定甘特调程确认", summary_lines=lines) write_audit(store.data, store.next_id, actor=actor, category="GATE", action="schedule.adjust.stage", target={"type": "WORK_ORDER", "id": wo_id}, power="P2", rationale={"confirmId": block.props["confirmId"], "deltaMin": preview["deltaMin"]}) store.save() return {"message": "调程已进入 P2 确认队列。", "block": block.model_dump(), "preview": preview} def commit_fixed_adjust(store, wo_id: int, new_start: str, actor: str = "web") -> dict: """执行固定轨调程:复制最新版本 PO/WO 为新草稿。""" from server.agent_core.audit import write_audit preview = preview_fixed_adjust(store.data, wo_id, new_start) if not preview["ok"]: raise ValueError(";".join(preview["reasons"]) or "调程不可行") if not preview["moves"]: return {"message": "位置未变", "versionId": preview["versionId"]} world = store.data versions = world["scheduleVersions"] latest = versions[-1] old_vid = latest["id"] new_vid = store.next_id("scheduleVersion") now = datetime.now() ver_no = "V" + fmt_date(now).replace("-", "") + f"-{len(versions) + 1:03d}" new_ver = { **{k: v for k, v in latest.items() if k not in ("id", "versionNo", "versionName", "createdAt", "createdBy", "publishedAt", "parentVersionId", "adjustOf")}, "id": new_vid, "versionNo": ver_no, "versionName": f"拖拽调程 #{wo_id}", "status": "DRAFT", "parentVersionId": old_vid, "adjustOf": wo_id, "createdBy": actor, "createdAt": fmt_dt(now), "publishedAt": None, "conflictCount": 0, "resolvedCount": 0, } old_pos = [p for p in world.get("productionOrders", []) if p.get("schedulingVersionId") == old_vid] po_map: dict[int, int] = {} for po in old_pos: nid = store.next_id("productionOrder") po_map[po["id"]] = nid np = copy.deepcopy(po) np["id"] = nid np["schedulingVersionId"] = new_vid world["productionOrders"].append(np) move_by_id = {m["woId"]: m for m in preview["moves"]} wo_count = 0 for wo in world.get("workOrders", []): if wo.get("productionOrderId") not in po_map: continue nw = copy.deepcopy(wo) nw["id"] = store.next_id("workOrder") nw["productionOrderId"] = po_map[wo["productionOrderId"]] mv = move_by_id.get(wo["id"]) if mv: nw["plannedStartTime"] = mv["newStart"] nw["plannedEndTime"] = mv["newEnd"] nw["adjustedFrom"] = mv["oldStart"] world["workOrders"].append(nw) wo_count += 1 # 回填 PO 起止 for old_po_id, new_po_id in po_map.items(): po_wos = [w for w in world["workOrders"] if w["productionOrderId"] == new_po_id] if not po_wos: continue po = next(p for p in world["productionOrders"] if p["id"] == new_po_id) po["plannedStartDate"] = min(w["plannedStartTime"] for w in po_wos) po["plannedEndDate"] = max(w["plannedEndTime"] for w in po_wos) new_ver["poCount"] = len(po_map) new_ver["woCount"] = wo_count world["scheduleVersions"].append(new_ver) write_audit(world, store.next_id, actor=actor, category="WORLD_WRITE", action="schedule.adjust.commit", target={"type": "SCHEDULE_VERSION", "id": new_vid}, power="P2", rationale={"woId": wo_id, "newStart": new_start, "moved": len(preview["moves"])}) store.save() return {"message": f"调程完成 ✅ 新版本 {ver_no}(联动 {len(preview['moves'])} 条工单)。", "versionId": new_vid, "versionNo": ver_no, "preview": preview}