aps-agent/server/aps_domain/adjust.py

430 lines
20 KiB
Python
Raw Normal View History

# ============================================================
# 工单拖拽调程(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}