aps-agent/server/aps_domain/masterdata_consumption.py

309 lines
16 KiB
Python
Raw Normal View History

"""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