309 lines
16 KiB
Python
309 lines
16 KiB
Python
"""Confirmed master data consumed by a capability-pool trial.
|
|
|
|
No source mutation: helpers return an immutable-input snapshot and explicit
|
|
admission issues. Work already in progress reserves capacity even if its order
|
|
cannot yet be admitted for new scheduling.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import math
|
|
from datetime import datetime, time, timedelta
|
|
from typing import Any
|
|
|
|
|
|
def as_datetime(value: Any) -> datetime | None:
|
|
if not value:
|
|
return None
|
|
try:
|
|
return datetime.fromisoformat(str(value)).replace(tzinfo=None)
|
|
except (ValueError, TypeError):
|
|
return None
|
|
|
|
|
|
def uses_masterdata_constraints(world: dict) -> bool:
|
|
return bool(world.get("planningContext") or world.get("flexPersonnel")
|
|
or world.get("flexWip") or world.get("flexMaintenance")
|
|
or world.get("flexCalendarOverrides"))
|
|
|
|
|
|
def planning_bounds(world: dict, start_date: str | None) -> tuple[datetime, datetime, datetime]:
|
|
context = world.get("planningContext") or {}
|
|
parameters = world.get("scheduleParams") or {}
|
|
start = as_datetime(context.get("planStart"))
|
|
if not start_date and start is None:
|
|
start_date = context.get("dataDate") or world.get("businessDate")
|
|
if start_date:
|
|
try:
|
|
date = datetime.fromisoformat(start_date).date()
|
|
if "T" in start_date or " " in start_date:
|
|
start = as_datetime(start_date)
|
|
elif start is not None:
|
|
start = datetime.combine(date, start.time())
|
|
else:
|
|
clocks = [time.fromisoformat(str(shift["startTime"]))
|
|
for shift in world.get("flexCalendar") or []
|
|
if shift.get("enabled", True) and shift.get("startTime")]
|
|
if not clocks:
|
|
raise ValueError("缺少启用班次的开始时间")
|
|
start = datetime.combine(date, min(clocks))
|
|
except (ValueError, TypeError) as exc:
|
|
raise ValueError(f"计划开始时间不明确:{exc}") from exc
|
|
if start is None:
|
|
raise ValueError("请明确本次计划开始日期和时间,不使用系统日期代替")
|
|
values = {}
|
|
for key, fallback_key, label, allow_zero in (("horizonDays", "planningHorizonDays", "计划周期(天)", False),
|
|
("freezeHours", "freezeWindowHours", "冻结窗口(小时)", True)):
|
|
raw = context.get(key, parameters.get(fallback_key))
|
|
if raw is None or isinstance(raw, bool):
|
|
raise ValueError(f"请提供{label}")
|
|
number = float(raw)
|
|
if not math.isfinite(number) or number < 0 or (not allow_zero and number == 0):
|
|
raise ValueError(f"{label}无效")
|
|
values[key] = number
|
|
horizon = start + timedelta(days=values["horizonDays"])
|
|
freeze = start + timedelta(hours=values["freezeHours"])
|
|
return start, horizon, freeze
|
|
|
|
|
|
def qualified_people(world: dict, operation: str, required_level: str | None = None) -> list[dict]:
|
|
levels = (world.get("planningContext") or {}).get("skillLevelOrder") or []
|
|
rank = {level: index for index, level in enumerate(levels)}
|
|
if required_level and required_level not in rank:
|
|
return []
|
|
minimum = rank.get(required_level, 0)
|
|
return [person for person in world.get("flexPersonnel") or []
|
|
if operation in (person.get("skills") or [])
|
|
and person.get("status", "ACTIVE") not in ("INACTIVE", "DISABLED", "LEAVE")
|
|
and (not required_level or rank.get(person.get("skillLevel", ""), -1) >= minimum)]
|
|
|
|
|
|
# 试排可假设项:这些是「尚未登记/尚未确认」的资料事实。试排草稿可以带着
|
|
# 显式假设继续排,正式排产仍按 error 硬阻断,二者永不互相冒充。
|
|
TRIAL_ASSUMABLE_ISSUES = frozenset({
|
|
"NO_PERSONNEL_SKILL", "WIP_REMAINING_UNCONFIRMED", "WIP_QUANTITY_UNCONFIRMED",
|
|
"WIP_END_UNKNOWN", "WIP_PREDECESSORS_UNCONFIRMED", "WIP_RESOURCE_UNKNOWN",
|
|
"WIP_MATERIAL_HOLD",
|
|
})
|
|
|
|
|
|
def order_execution_issues(world: dict, order: dict, trial: bool = False) -> list[dict]:
|
|
"""Validate execution facts without fabricating predecessor completion.
|
|
|
|
``trial=True`` never invents values. Unconfirmed *registration* facts
|
|
(人员技能未登记、在制进度/前序/设备/缺料未确认) come back with
|
|
``severity="assumption"`` so a draft plan can still be produced, and the
|
|
caller must surface every one of them. Other facts stay ``severity="error"``
|
|
and keep blocking both draft and formal runs.
|
|
"""
|
|
if not uses_masterdata_constraints(world):
|
|
return []
|
|
issues = []
|
|
routes = sorted([r for r in world.get("flexRoutings", [])
|
|
if r.get("productCode") == order.get("productCode")], key=lambda r: r.get("seq", 0))
|
|
records = [r for r in world.get("flexWip", []) if r.get("orderNo") == order.get("orderNo")]
|
|
equipment = {e.get("code") for e in world.get("flexEquipment", [])}
|
|
qty = float(order.get("quantity") or 0)
|
|
done_ops = {r.get("operationCode") for r in records
|
|
if r.get("status") == "DONE" and float(r.get("completedQuantity") or 0) >= qty}
|
|
def issue(code: str, detail: str, **extra):
|
|
severity = "assumption" if trial and code in TRIAL_ASSUMABLE_ISSUES else "error"
|
|
issues.append({"type": code, "severity": severity, "detail": detail,
|
|
"orderNo": order.get("orderNo"), **extra})
|
|
by_operation: dict[str, list[dict]] = {}
|
|
for row in records:
|
|
by_operation.setdefault(row.get("operationCode"), []).append(row)
|
|
for operation, batches in by_operation.items():
|
|
if len(batches) > 1:
|
|
issue("WIP_SPLIT_UNSUPPORTED",
|
|
f"{order.get('orderNo')} 的 {operation} 有多条在制批次,需核对各批数量及后续衔接;当前不会合并或重复全量排产",
|
|
operationCode=operation, taskNos=[r.get("taskNo") for r in batches])
|
|
for row in records:
|
|
op = row.get("operationCode")
|
|
step = next((s for s in routes if s.get("operationCode") == op), None)
|
|
label = row.get("taskNo") or op or "在制任务"
|
|
if not step:
|
|
issue("WIP_ROUTING_UNKNOWN", f"{label} 加工步骤无法关联到该订单工艺", sourceRef=row.get("sourceRef"))
|
|
continue
|
|
if row.get("equipmentCode") not in equipment:
|
|
issue("WIP_RESOURCE_UNKNOWN", f"{label} 设备 {row.get('equipmentCode')} 不在设备档案,请核对", sourceRef=row.get("sourceRef"))
|
|
completed = float(row.get("completedQuantity") or 0)
|
|
if completed < 0 or completed > qty or (row.get("status") == "DONE" and completed < qty):
|
|
issue("WIP_QUANTITY_UNCONFIRMED", f"{label} 已完成量与订单数量不一致,请确认剩余量")
|
|
if row.get("status") == "RUNNING":
|
|
remaining = row.get("remainingQuantity")
|
|
if remaining is None:
|
|
issue("WIP_REMAINING_UNCONFIRMED", f"{label} 正在加工但未确认整道工序剩余量,请核对后再安排下道工序")
|
|
elif not isinstance(remaining, (int, float)) or remaining < 0 or not math.isclose(completed + remaining, qty):
|
|
issue("WIP_QUANTITY_UNCONFIRMED", f"{label} 已完成量加剩余量不等于订单数量,不能将预计完成视为整单完成")
|
|
if row.get("status") == "RUNNING" and not as_datetime(row.get("expectedEnd") or row.get("completionTime")):
|
|
issue("WIP_END_UNKNOWN", f"{label} 正在加工但缺预计结束时间,该设备暂不安排其他任务")
|
|
if row.get("status") == "MATERIAL_BLOCKED":
|
|
issue("WIP_MATERIAL_HOLD", f"{label} 仍标记缺料,请确认物料到齐后解除等待")
|
|
if row.get("status") in ("RUNNING", "WAITING", "DONE") and not row.get("predecessorsConfirmed"):
|
|
missing = [s.get("operationCode") for s in routes
|
|
if s.get("seq", 0) < step.get("seq", 0) and s.get("operationCode") not in done_ops]
|
|
if missing:
|
|
issue("WIP_PREDECESSORS_UNCONFIRMED", f"{label} 前序 {', '.join(missing)} 的完成量未确认,请核对后续排范围")
|
|
if "flexPersonnel" in world:
|
|
for step in routes:
|
|
op = step.get("operationCode")
|
|
if op in done_ops:
|
|
continue
|
|
if not qualified_people(world, op, step.get("requiredSkillLevel")):
|
|
issue("NO_PERSONNEL_SKILL", f"{order.get('orderNo')} 的 {step.get('operationName') or op} 没有已登记合格人员,请补充人员技能", operationCode=op)
|
|
return issues
|
|
|
|
|
|
def bom_requirement(bom: dict, quantity: float) -> float:
|
|
return float(bom.get("quantity") or 0) * quantity * (1 + max(0, float(bom.get("lossRate") or 0)))
|
|
|
|
|
|
def calendar_intervals(world: dict, equipment_code: str, day: datetime,
|
|
shift_code: str | None = None) -> list[tuple[datetime, datetime]]:
|
|
"""Materialize a date, respecting disabled shifts, per-shift weekdays and overrides."""
|
|
date = day.date().isoformat()
|
|
override = next((r for r in world.get("flexCalendarOverrides", [])
|
|
if r.get("equipmentCode") == equipment_code and r.get("date") == date), None)
|
|
shifts = override.get("shifts", []) if override is not None else world.get("flexCalendar", [])
|
|
out = []
|
|
for shift in shifts:
|
|
if shift.get("enabled", True) is False or shift.get("status") in ("DISABLED", "INACTIVE", "CLOSED"):
|
|
continue
|
|
if shift_code and (shift.get("code") or shift.get("shiftCode")) not in (None, shift_code):
|
|
continue
|
|
if override is None and day.isoweekday() not in shift.get("workdays", []):
|
|
continue
|
|
start_text, end_text = shift.get("startTime") or shift.get("start"), shift.get("endTime") or shift.get("end")
|
|
if not start_text or not end_text:
|
|
continue
|
|
start = as_datetime(date + " " + start_text)
|
|
end = as_datetime(date + " " + end_text)
|
|
if start is None or end is None:
|
|
continue
|
|
overnight = end <= start
|
|
if overnight:
|
|
end += timedelta(days=1)
|
|
segments = [(start, end)]
|
|
for pause in shift.get("breaks", []):
|
|
ps = as_datetime(date + " " + pause["start"])
|
|
pe = as_datetime(date + " " + pause["end"])
|
|
if ps is None or pe is None:
|
|
continue
|
|
if overnight and ps < start:
|
|
ps += timedelta(days=1)
|
|
if overnight and pe < start:
|
|
pe += timedelta(days=1)
|
|
if pe <= ps:
|
|
pe += timedelta(days=1)
|
|
next_segments = []
|
|
for a, b in segments:
|
|
if ps >= b or pe <= a:
|
|
next_segments.append((a, b))
|
|
else:
|
|
if a < ps:
|
|
next_segments.append((a, ps))
|
|
if pe < b:
|
|
next_segments.append((pe, b))
|
|
segments = next_segments
|
|
out.extend(segments)
|
|
return sorted(out)
|
|
|
|
|
|
def execution_reservations(world: dict, start: datetime, horizon: datetime) -> dict[int, list[tuple[datetime, datetime]]]:
|
|
equipment = {e.get("code"): e.get("id") for e in world.get("flexEquipment", [])}
|
|
busy: dict[int, list[tuple[datetime, datetime]]] = {}
|
|
for row in world.get("flexMaintenance", []):
|
|
if row.get("status") in ("CANCELLED", "COMPLETED"):
|
|
continue
|
|
eid = equipment.get(row.get("equipmentCode"))
|
|
a, b = as_datetime(row.get("start")), as_datetime(row.get("end"))
|
|
if eid is not None and a and b and b > a:
|
|
busy.setdefault(eid, []).append((a, b))
|
|
for row in world.get("flexWip", []):
|
|
if row.get("status") != "RUNNING":
|
|
continue
|
|
eid = equipment.get(row.get("equipmentCode"))
|
|
if eid is not None:
|
|
a = as_datetime(row.get("actualStart")) or start
|
|
b = as_datetime(row.get("expectedEnd") or row.get("completionTime")) or horizon
|
|
if b > a:
|
|
busy.setdefault(eid, []).append((a, b))
|
|
return busy
|
|
|
|
|
|
def execution_people_reservations(world: dict, start: datetime, horizon: datetime) -> dict:
|
|
"""Unknown WIP operator reserves its eligible pool conservatively, without assigning an identity."""
|
|
busy: dict[str, list[tuple[datetime, datetime]]] = {}
|
|
for row in world.get("flexWip", []):
|
|
if row.get("status") != "RUNNING":
|
|
continue
|
|
end = as_datetime(row.get("expectedEnd") or row.get("completionTime")) or horizon
|
|
if end <= start:
|
|
continue
|
|
codes = [row["personCode"]] if row.get("personCode") else [
|
|
p["code"] for p in qualified_people(world, row.get("operationCode"))]
|
|
for code in codes:
|
|
busy.setdefault(code, []).append((start, end))
|
|
return busy
|
|
|
|
|
|
def frozen_assignments(world: dict, start: datetime, freeze: datetime) -> list[dict]:
|
|
versions = [v for v in world.get("flexScheduleVersions", [])
|
|
if v.get("status") in ("APPROVED", "PUBLISHED", "RELEASED")]
|
|
if not versions or freeze <= start:
|
|
return []
|
|
vid = versions[-1]["id"]
|
|
return [copy.deepcopy(w) for w in world.get("flexWorkOrders", [])
|
|
if w.get("versionId") == vid and (as_datetime(w.get("plannedStartTime")) or freeze) < freeze
|
|
and (as_datetime(w.get("plannedEndTime")) or start) > start]
|
|
|
|
|
|
def place_masterdata_slot(world: dict, equipment: dict, operation: str,
|
|
cursor: datetime, duration: float, horizon: datetime,
|
|
eq_busy: dict, people_busy: dict,
|
|
required_level: str | None = None,
|
|
allow_unassigned_person: bool = False) -> dict | None:
|
|
"""Reserve working segments with a single eligible person; no hard-placement fallback.
|
|
|
|
Equipment/person reservations may span breaks. Work segments never include
|
|
breaks, closed shifts or maintenance. A job is restarted after a capacity
|
|
collision rather than silently overlapping an existing execution.
|
|
|
|
``allow_unassigned_person`` is the trial-only escape hatch for operations
|
|
without any registered qualified person: the slot is placed on the
|
|
equipment calendar alone and no person is reserved, so the draft never
|
|
fabricates an operator identity.
|
|
"""
|
|
people = qualified_people(world, operation, required_level) if "flexPersonnel" in world else [None]
|
|
if not people and allow_unassigned_person:
|
|
people = [None]
|
|
best = None
|
|
for person in people:
|
|
person_code = person.get("code") if person else None
|
|
reservations = list(eq_busy.get(equipment["id"], [])) + list(people_busy.get(person_code, []))
|
|
t, remaining, segments = cursor.replace(second=0, microsecond=0), max(1, math.ceil(duration)), []
|
|
while t < horizon and remaining > 1e-7:
|
|
intervals = calendar_intervals(world, equipment["code"], t, person.get("shiftCode") if person else None)
|
|
intervals += calendar_intervals(world, equipment["code"], t - timedelta(days=1), person.get("shiftCode") if person else None)
|
|
active = next(((a, b) for a, b in intervals if a <= t < b), None)
|
|
if active is None:
|
|
future = [a for a, _ in intervals if a > t]
|
|
t = min(future) if future else (t + timedelta(days=1)).replace(hour=0, minute=0)
|
|
continue
|
|
end = min(active[1], horizon, t + timedelta(minutes=remaining))
|
|
collision = [(a, b) for a, b in reservations if t < b and end > a]
|
|
if collision:
|
|
t = max(b for _, b in collision)
|
|
remaining, segments = max(1, math.ceil(duration)), []
|
|
continue
|
|
segments.append((t, end))
|
|
remaining -= (end - t).total_seconds() / 60
|
|
t = end
|
|
if remaining <= 1e-7 and segments:
|
|
result = {"start": segments[0][0], "end": segments[-1][1],
|
|
"segments": segments, "personCode": person_code}
|
|
if best is None or result["end"] < best["end"]:
|
|
best = result
|
|
return best
|