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