aps-agent/server/aps_domain/adjust.py

430 lines
20 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.

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