1905 lines
68 KiB
Python
1905 lines
68 KiB
Python
# ============================================================
|
|
# Plan-layer MPS / S&OP (moduleId: domain-mps-planning, R71.4)
|
|
# Long-term master plan draft + finite rough-cut capacity check
|
|
# + plan -> schedule -> publish -> execution feedback trace.
|
|
# Read-only projections and lightweight trace storage only;
|
|
# existing PlanStore governance is reused, never mutated.
|
|
# ============================================================
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import tempfile
|
|
import threading
|
|
import uuid
|
|
from datetime import UTC, datetime
|
|
from decimal import Decimal
|
|
from typing import Any
|
|
|
|
from server.aps_domain.planning import WARN_RATIO, build_bucket_skeleton, daily_capacity
|
|
from server.timeutil import add_minutes, fmt_date, parse_dt, today0
|
|
|
|
World = dict[str, Any]
|
|
|
|
MPS_CAPACITY_MODES = {"FINITE", "INFINITE"}
|
|
MPS_LONG_CYCLE_THRESHOLD_DAYS = 30.0
|
|
_DRAFT_HASH_V1 = "mps-draft-hash.v1"
|
|
_DRAFT_HASH_V1_NUMERIC = "mps-draft-hash.v1-json-numbers"
|
|
_DRAFT_HASH_V2 = "mps-draft-hash.v2"
|
|
_DRAFT_BINDING_KEYS = frozenset(
|
|
{"draftHash", "draftHashVersion", "scope", "worldFingerprint"}
|
|
)
|
|
_MPS_DERIVED_WORLD_KEYS = frozenset(
|
|
{
|
|
"scheduleVersions",
|
|
"productionOrders",
|
|
"workOrders",
|
|
"conflicts",
|
|
"flexScheduleVersions",
|
|
"flexVirtualLines",
|
|
"flexWorkOrders",
|
|
"flexConflicts",
|
|
}
|
|
)
|
|
_SCHEDULE_LIFECYCLE_KEYS = frozenset(
|
|
{
|
|
"status",
|
|
"publishedAt",
|
|
"publishReady",
|
|
"dispatchReady",
|
|
"completedQuantity",
|
|
"progressPercent",
|
|
"actualStartTime",
|
|
"actualEndTime",
|
|
"reportedAt",
|
|
}
|
|
)
|
|
|
|
|
|
def _now_iso() -> str:
|
|
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
def _canonical_hash(value: Any) -> str:
|
|
payload = json.dumps(
|
|
value,
|
|
ensure_ascii=False,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
allow_nan=False,
|
|
default=str,
|
|
).encode("utf-8")
|
|
return hashlib.sha256(payload).hexdigest()
|
|
|
|
|
|
def _scope_payload(
|
|
*,
|
|
tenant_uuid: str,
|
|
project_id: str,
|
|
world_key: str,
|
|
) -> dict[str, str]:
|
|
values = {
|
|
"tenantUuid": str(tenant_uuid or "").strip(),
|
|
"projectId": str(project_id or "").strip(),
|
|
"worldKey": str(world_key or "").strip(),
|
|
}
|
|
if not all(values.values()):
|
|
raise ValueError("MPS scope requires tenantUuid, projectId, and worldKey")
|
|
return values
|
|
|
|
|
|
def _draft_inputs(draft: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"mode": str(draft.get("mode") or "WEEK").upper(),
|
|
"startDate": str(draft.get("startDate") or "")[:10],
|
|
"horizonDays": int(draft.get("horizonDays") or 0),
|
|
"includeForecast": bool(draft.get("includeForecast", True)),
|
|
"includeFirm": bool(draft.get("includeFirm", True)),
|
|
"longCycleOnly": bool(draft.get("longCycleOnly", False)),
|
|
"capacityMode": str(draft.get("capacityMode") or "FINITE").upper(),
|
|
}
|
|
|
|
|
|
def _draft_hash(draft: dict[str, Any]) -> str:
|
|
version = str(draft.get("draftHashVersion") or _DRAFT_HASH_V1)
|
|
return _draft_hash_for_version(draft, version)
|
|
|
|
|
|
def _draft_hash_for_version(draft: dict[str, Any], version: str) -> str:
|
|
payload = {
|
|
key: value
|
|
for key, value in copy.deepcopy(draft).items()
|
|
if key not in _DRAFT_BINDING_KEYS and key != "planId"
|
|
}
|
|
if version == _DRAFT_HASH_V1:
|
|
return _canonical_hash(payload)
|
|
if version == _DRAFT_HASH_V1_NUMERIC:
|
|
return _canonical_hash(_normalize_json_numbers_v1(payload))
|
|
if version == _DRAFT_HASH_V2:
|
|
return _canonical_hash(_typed_json_tree(payload))
|
|
raise ValueError(f"unsupported MPS draft hash version: {version}")
|
|
|
|
|
|
def _resolve_draft_hash_version(draft: dict[str, Any]) -> str:
|
|
"""Resolve explicit v2 or either unversioned historical hash algorithm."""
|
|
supplied_hash = str(draft.get("draftHash") or "")
|
|
explicit = str(draft.get("draftHashVersion") or "")
|
|
if explicit:
|
|
if explicit not in {_DRAFT_HASH_V1, _DRAFT_HASH_V1_NUMERIC, _DRAFT_HASH_V2}:
|
|
raise ValueError("MPS draft hash version is not supported")
|
|
if supplied_hash != _draft_hash_for_version(draft, explicit):
|
|
raise ValueError("MPS draft content hash mismatch")
|
|
return explicit
|
|
for legacy in (_DRAFT_HASH_V1, _DRAFT_HASH_V1_NUMERIC):
|
|
if supplied_hash == _draft_hash_for_version(draft, legacy):
|
|
return legacy
|
|
raise ValueError("MPS draft content hash mismatch")
|
|
|
|
|
|
def _json_number_text(value: int | float | Decimal) -> str:
|
|
"""Return one decimal representation for the same JSON numeric value."""
|
|
number = Decimal(str(value))
|
|
if not number.is_finite():
|
|
raise ValueError("MPS draft contains a non-finite number")
|
|
text = format(number.normalize(), "f")
|
|
if "." in text:
|
|
text = text.rstrip("0").rstrip(".")
|
|
return "0" if text in {"", "-0"} else text
|
|
|
|
|
|
def _normalize_json_numbers_v1(value: Any) -> Any:
|
|
"""Exact interim unversioned algorithm kept only for trace migration."""
|
|
if value is None or isinstance(value, (str, bool)):
|
|
return value
|
|
if isinstance(value, (int, float, Decimal)):
|
|
return {"__apsJsonNumber__": _json_number_text(value)}
|
|
if isinstance(value, dict):
|
|
return {str(key): _normalize_json_numbers_v1(item) for key, item in value.items()}
|
|
if isinstance(value, (list, tuple)):
|
|
return [_normalize_json_numbers_v1(item) for item in value]
|
|
return str(value)
|
|
|
|
|
|
def _typed_json_tree(value: Any) -> Any:
|
|
"""Encode every JSON type so internal hash nodes cannot collide with business data."""
|
|
if value is None:
|
|
return ["null"]
|
|
if isinstance(value, bool):
|
|
return ["bool", value]
|
|
if isinstance(value, (int, float, Decimal)):
|
|
return ["number", _json_number_text(value)]
|
|
if isinstance(value, str):
|
|
return ["string", value]
|
|
if isinstance(value, dict):
|
|
items: dict[str, Any] = {}
|
|
for key, item in value.items():
|
|
string_key = str(key)
|
|
if string_key in items:
|
|
raise ValueError(f"MPS draft has duplicate JSON object key: {string_key}")
|
|
items[string_key] = _typed_json_tree(item)
|
|
return ["object", [[key, items[key]] for key in sorted(items)]]
|
|
if isinstance(value, (list, tuple)):
|
|
return ["array", [_typed_json_tree(item) for item in value]]
|
|
return ["stringified", type(value).__qualname__, str(value)]
|
|
|
|
|
|
def _world_fingerprint(world: World) -> str:
|
|
from server.agent_core.harness import world_fingerprint
|
|
|
|
planning_inputs = {
|
|
key: value
|
|
for key, value in (world or {}).items()
|
|
if key not in _MPS_DERIVED_WORLD_KEYS
|
|
}
|
|
return world_fingerprint(planning_inputs)
|
|
|
|
|
|
def bind_mps_draft(
|
|
world: World,
|
|
draft: dict[str, Any],
|
|
*,
|
|
tenant_uuid: str,
|
|
project_id: str,
|
|
world_key: str,
|
|
) -> dict[str, Any]:
|
|
"""Bind a server-generated draft to one tenant/project world snapshot."""
|
|
bound = copy.deepcopy(draft)
|
|
bound["scope"] = _scope_payload(
|
|
tenant_uuid=tenant_uuid,
|
|
project_id=project_id,
|
|
world_key=world_key,
|
|
)
|
|
bound["worldFingerprint"] = _world_fingerprint(world)
|
|
bound["draftHashVersion"] = _DRAFT_HASH_V2
|
|
bound["draftHash"] = _draft_hash(bound)
|
|
return bound
|
|
|
|
|
|
def validate_mps_draft_binding(
|
|
world: World,
|
|
draft: dict[str, Any],
|
|
*,
|
|
tenant_uuid: str,
|
|
project_id: str,
|
|
world_key: str,
|
|
allow_legacy: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""Fail closed unless a draft is reproducible in the current scoped world."""
|
|
if not isinstance(draft, dict) or draft.get("kind") != "MPS_DRAFT":
|
|
raise ValueError("MPS draft payload is invalid")
|
|
expected_scope = _scope_payload(
|
|
tenant_uuid=tenant_uuid,
|
|
project_id=project_id,
|
|
world_key=world_key,
|
|
)
|
|
if draft.get("scope") != expected_scope:
|
|
raise ValueError("MPS draft scope does not match the authenticated project")
|
|
current_world_fingerprint = _world_fingerprint(world)
|
|
if draft.get("worldFingerprint") != current_world_fingerprint:
|
|
raise ValueError("MPS draft world fingerprint is stale")
|
|
supplied_hash = str(draft.get("draftHash") or "")
|
|
hash_version = _resolve_draft_hash_version(draft)
|
|
if hash_version != _DRAFT_HASH_V2 and not allow_legacy:
|
|
raise ValueError("MPS draft persist/gap requires draftHashVersion v2")
|
|
|
|
inputs = _draft_inputs(draft)
|
|
rebuilt = build_mps_draft(
|
|
world,
|
|
mode=inputs["mode"],
|
|
start_date=inputs["startDate"],
|
|
horizon_days=inputs["horizonDays"],
|
|
include_forecast=inputs["includeForecast"],
|
|
include_firm=inputs["includeFirm"],
|
|
long_cycle_only=inputs["longCycleOnly"],
|
|
capacity_mode=inputs["capacityMode"],
|
|
)
|
|
rebuilt["draftId"] = draft.get("draftId")
|
|
if draft.get("draftHashVersion"):
|
|
rebuilt["draftHashVersion"] = hash_version
|
|
if _draft_hash_for_version(rebuilt, hash_version) != supplied_hash:
|
|
raise ValueError("MPS draft cannot be reproduced from the current world")
|
|
return copy.deepcopy(draft)
|
|
|
|
|
|
def _input_summary(
|
|
draft: dict[str, Any],
|
|
*,
|
|
tenant_uuid: str,
|
|
project_id: str,
|
|
world_key: str,
|
|
) -> dict[str, Any]:
|
|
return {
|
|
**_scope_payload(
|
|
tenant_uuid=tenant_uuid,
|
|
project_id=project_id,
|
|
world_key=world_key,
|
|
),
|
|
"draftId": draft.get("draftId"),
|
|
"draftHash": draft.get("draftHash"),
|
|
"draftHashVersion": _resolve_draft_hash_version(draft),
|
|
"worldFingerprint": draft.get("worldFingerprint"),
|
|
"parameters": _draft_inputs(draft),
|
|
}
|
|
|
|
|
|
def _as_date(value: str) -> Any:
|
|
return parse_dt(str(value)[:10] + " 00:00")
|
|
|
|
|
|
def _shift_minutes(
|
|
start_time: str, end_time: str, breaks: list[dict[str, Any]]
|
|
) -> float:
|
|
try:
|
|
start = parse_dt(f"2000-01-01 {start_time}")
|
|
end = parse_dt(f"2000-01-01 {end_time}")
|
|
minutes = (end - start).total_seconds() / 60.0
|
|
for item in breaks or []:
|
|
b0 = parse_dt(f"2000-01-01 {item.get('start')}")
|
|
b1 = parse_dt(f"2000-01-01 {item.get('end')}")
|
|
minutes -= (b1 - b0).total_seconds() / 60.0
|
|
return max(float(minutes), 60.0)
|
|
except (TypeError, ValueError):
|
|
return 480.0
|
|
|
|
|
|
def _calendar_context(world: World) -> dict[str, Any]:
|
|
flex = world.get("flexCalendar") or []
|
|
if flex:
|
|
cal = flex[0]
|
|
return {
|
|
"source": "flexCalendar",
|
|
"workdays": list(cal.get("workdays") or [1, 2, 3, 4, 5]),
|
|
"shiftMinutes": _shift_minutes(
|
|
str(cal.get("startTime") or "08:00"),
|
|
str(cal.get("endTime") or "17:00"),
|
|
list(cal.get("breaks") or []),
|
|
),
|
|
}
|
|
shift_rows = world.get("shiftCalendar") or []
|
|
if shift_rows:
|
|
working_dates = sorted(
|
|
{
|
|
str(row["date"])
|
|
for row in shift_rows
|
|
if row.get("isWorking") is not False
|
|
}
|
|
)
|
|
total = 0.0
|
|
for shift in world.get("shifts") or []:
|
|
if str(shift.get("status") or "ACTIVE").upper() not in ("ACTIVE",):
|
|
continue
|
|
total += _shift_minutes(
|
|
str(shift.get("startTime") or "08:00"),
|
|
str(shift.get("endTime") or "17:00"),
|
|
list(shift.get("breakPeriods") or []),
|
|
)
|
|
return {
|
|
"source": "shiftCalendar",
|
|
"workingDates": working_dates,
|
|
"shiftMinutes": max(total, 60.0),
|
|
}
|
|
return {
|
|
"source": "default",
|
|
"workdays": [1, 2, 3, 4, 5],
|
|
"shiftMinutes": 480.0,
|
|
}
|
|
|
|
|
|
def _is_workday(cal: dict[str, Any], date_s: str) -> bool:
|
|
if cal.get("source") == "shiftCalendar":
|
|
return date_s in set(cal.get("workingDates") or [])
|
|
try:
|
|
return parse_dt(date_s + " 00:00").isoweekday() in set(
|
|
cal.get("workdays") or [1, 2, 3, 4, 5]
|
|
)
|
|
except (TypeError, ValueError):
|
|
return True
|
|
|
|
|
|
def _bucket_workdays(cal: dict[str, Any], bucket: dict[str, Any]) -> int:
|
|
start = _as_date(bucket["start"])
|
|
end = _as_date(bucket["end"])
|
|
count = 0
|
|
current = start
|
|
while current <= end:
|
|
if _is_workday(cal, fmt_date(current)):
|
|
count += 1
|
|
current = add_minutes(current, 24 * 60)
|
|
return count
|
|
|
|
|
|
def _order_lead_time_days(order: dict[str, Any]) -> float:
|
|
raw = order.get("leadTimeDays")
|
|
if raw is not None:
|
|
try:
|
|
return float(raw)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
values = [item.get("leadTimeDays") for item in order.get("items") or []]
|
|
parsed = [float(v) for v in values if v is not None]
|
|
return max(parsed) if parsed else 0.0
|
|
|
|
|
|
def _collect_mps_demands(
|
|
world: World,
|
|
*,
|
|
include_forecast: bool,
|
|
include_firm: bool,
|
|
long_cycle_only: bool,
|
|
) -> list[dict[str, Any]]:
|
|
from server.aps_domain.forecast import ensure_forecast_table
|
|
from server.aps_domain.orders import SCHEDULABLE_STATUSES
|
|
|
|
points: list[dict[str, Any]] = []
|
|
if include_firm:
|
|
for so in world.get("salesOrders") or []:
|
|
if so.get("status") not in SCHEDULABLE_STATUSES:
|
|
continue
|
|
due = str(so.get("deliveryDate") or "")[:10]
|
|
if not due:
|
|
continue
|
|
is_long = (
|
|
bool(so.get("isLongCycle"))
|
|
or _order_lead_time_days(so) >= MPS_LONG_CYCLE_THRESHOLD_DAYS
|
|
)
|
|
if long_cycle_only and not is_long:
|
|
continue
|
|
for item in so.get("items") or []:
|
|
if item.get("status") in ("CANCELLED", "COMPLETED"):
|
|
continue
|
|
qty = int(item.get("quantity") or 0)
|
|
if qty <= 0:
|
|
continue
|
|
points.append(
|
|
{
|
|
"source": "LONG_CYCLE" if is_long else "FIRM",
|
|
"ref": so.get("orderNo"),
|
|
"productId": item.get("productId"),
|
|
"productCode": item.get("productCode"),
|
|
"productName": item.get("productName"),
|
|
"quantity": qty,
|
|
"weightedQty": float(qty),
|
|
"dueDate": due,
|
|
"leadTimeDays": _order_lead_time_days(so),
|
|
}
|
|
)
|
|
if include_forecast:
|
|
ensure_forecast_table(world)
|
|
for fc in world.get("forecastOrders") or []:
|
|
if fc.get("status") not in ("ACTIVE",):
|
|
continue
|
|
due = str(fc.get("dueDate") or fc.get("periodEnd") or "")[:10]
|
|
if not due:
|
|
continue
|
|
qty = int(fc.get("quantity") or 0)
|
|
if qty <= 0:
|
|
continue
|
|
confidence = float(
|
|
fc.get("confidence") if fc.get("confidence") is not None else 1.0
|
|
)
|
|
confidence = max(0.0, min(1.0, confidence))
|
|
|
|
points.append(
|
|
{
|
|
"source": "FORECAST",
|
|
"ref": fc.get("forecastNo"),
|
|
"productId": fc.get("productId"),
|
|
"productCode": fc.get("productCode"),
|
|
"productName": fc.get("productName"),
|
|
"quantity": qty,
|
|
"weightedQty": round(qty * confidence, 1),
|
|
"dueDate": due,
|
|
"confidence": confidence,
|
|
"leadTimeDays": 0,
|
|
}
|
|
)
|
|
return points
|
|
|
|
|
|
def _bucket_for_date(
|
|
buckets: list[dict[str, Any]], date_s: str
|
|
) -> dict[str, Any] | None:
|
|
if not buckets:
|
|
return None
|
|
if date_s < buckets[0]["start"]:
|
|
return buckets[0]
|
|
for bucket in buckets:
|
|
if bucket["start"] <= date_s <= bucket["end"]:
|
|
return bucket
|
|
return None
|
|
|
|
|
|
def _status_for(ratio: float) -> str:
|
|
if ratio >= 1.0:
|
|
return "OVER"
|
|
if ratio >= WARN_RATIO:
|
|
return "WARN"
|
|
return "OK"
|
|
|
|
|
|
def _commitment(status: str) -> str:
|
|
if status == "OVER":
|
|
return "INFEASIBLE"
|
|
if status == "WARN":
|
|
return "AT_RISK"
|
|
return "FEASIBLE"
|
|
|
|
|
|
def assess_mps_capacity(world: World, draft: dict[str, Any]) -> dict[str, Any]:
|
|
"""Rough-cut finite capacity assessment for an MPS draft."""
|
|
buckets = draft.get("buckets") or []
|
|
total_weighted = sum(float(b.get("weightedDemand") or 0) for b in buckets)
|
|
feasible_weighted = sum(
|
|
float(b.get("weightedDemand") or 0)
|
|
for b in buckets
|
|
if b.get("status") != "OVER"
|
|
)
|
|
over = sum(1 for b in buckets if b.get("status") == "OVER")
|
|
warn = sum(1 for b in buckets if b.get("status") == "WARN")
|
|
bottleneck_buckets = sorted(
|
|
[b for b in buckets if b.get("status") == "OVER"],
|
|
key=lambda b: float(b.get("loadRatio") or 0),
|
|
reverse=True,
|
|
)
|
|
bottlenecks: list[dict[str, Any]] = []
|
|
for bucket in bottleneck_buckets[:5]:
|
|
drivers = [
|
|
{
|
|
"productCode": item.get("productCode"),
|
|
"productName": item.get("productName"),
|
|
"weightedQty": item.get("weightedQty"),
|
|
}
|
|
for item in (bucket.get("byProduct") or [])[:3]
|
|
]
|
|
workdays = int(bucket.get("workdays") or 1)
|
|
bottlenecks.append(
|
|
{
|
|
"key": bucket.get("key"),
|
|
"label": bucket.get("label"),
|
|
"loadRatio": bucket.get("loadRatio"),
|
|
"gap": bucket.get("gap"),
|
|
"requiredDaily": round(
|
|
float(bucket.get("weightedDemand") or 0) / workdays, 1
|
|
),
|
|
"drivers": drivers,
|
|
}
|
|
)
|
|
peak_load = max((float(b.get("loadRatio") or 0) for b in buckets), default=0.0)
|
|
feasible_rate = (
|
|
round(feasible_weighted / total_weighted, 3) if total_weighted else 1.0
|
|
)
|
|
feasible_bucket_rate = (
|
|
round((len(buckets) - over) / len(buckets), 3) if buckets else 1.0
|
|
)
|
|
if over:
|
|
first = bottlenecks[0]
|
|
hint = (
|
|
f"Finite capacity OVER in {over} bucket(s); earliest bottleneck {first.get('label')} "
|
|
f"at load {first.get('loadRatio')}. Review leveling/outsourcing before trial scheduling."
|
|
)
|
|
elif warn:
|
|
hint = (
|
|
f"No overloaded bucket, but {warn} bucket(s) at or above {WARN_RATIO:.0%} load. "
|
|
"Monitor the warning window before commit."
|
|
)
|
|
else:
|
|
hint = "Finite rough-cut capacity is feasible across the MPS horizon."
|
|
return {
|
|
"capacityMode": draft.get("capacityMode") or "FINITE",
|
|
"dailyCapacity": draft.get("dailyCapacity") or daily_capacity(world),
|
|
"feasibleRate": feasible_rate,
|
|
"feasibleBucketRate": feasible_bucket_rate,
|
|
"overCount": over,
|
|
"warnCount": warn,
|
|
"okCount": len(buckets) - over - warn,
|
|
"peakLoadRatio": round(peak_load, 3),
|
|
"bottleneckBucketCount": len(bottleneck_buckets),
|
|
"bottlenecks": bottlenecks,
|
|
"hint": hint,
|
|
}
|
|
|
|
|
|
def build_mps_draft(
|
|
world: World,
|
|
*,
|
|
mode: str = "WEEK",
|
|
start_date: str | None = None,
|
|
horizon_days: int = 90,
|
|
include_forecast: bool = True,
|
|
include_firm: bool = True,
|
|
long_cycle_only: bool = False,
|
|
capacity_mode: str = "FINITE",
|
|
) -> dict[str, Any]:
|
|
"""Build a bucketed long-term MPS draft from forecasts and firm/long-cycle orders."""
|
|
cap_mode = (capacity_mode or "FINITE").upper()
|
|
if cap_mode not in MPS_CAPACITY_MODES:
|
|
raise ValueError("capacityMode must be FINITE or INFINITE")
|
|
skeleton = build_bucket_skeleton(
|
|
mode=mode, start_date=start_date, horizon_days=horizon_days
|
|
)
|
|
calendar = _calendar_context(world)
|
|
shift_minutes = float(calendar.get("shiftMinutes") or 480.0)
|
|
day_cap = daily_capacity(world)
|
|
points = _collect_mps_demands(
|
|
world,
|
|
include_forecast=include_forecast,
|
|
include_firm=include_firm,
|
|
long_cycle_only=long_cycle_only,
|
|
)
|
|
|
|
buckets: list[dict[str, Any]] = []
|
|
for raw in skeleton:
|
|
workdays = _bucket_workdays(calendar, raw)
|
|
capacity = round(day_cap * workdays, 1) if cap_mode == "FINITE" else None
|
|
buckets.append(
|
|
{
|
|
**raw,
|
|
"workdays": workdays,
|
|
"workHours": round(workdays * shift_minutes / 60.0, 1),
|
|
"capacityMode": cap_mode,
|
|
"capacity": capacity,
|
|
"firmQty": 0,
|
|
"forecastQty": 0,
|
|
"demandQty": 0,
|
|
"weightedDemand": 0.0,
|
|
"loadRatio": 0.0,
|
|
"gap": 0.0,
|
|
"status": "OK",
|
|
"byProduct": {},
|
|
"mpsLines": [],
|
|
}
|
|
)
|
|
|
|
unbucketed: list[dict[str, Any]] = []
|
|
line_no = 0
|
|
for point in points:
|
|
bucket = _bucket_for_date(buckets, point["dueDate"])
|
|
if bucket is None:
|
|
unbucketed.append(
|
|
{
|
|
**point,
|
|
"planLineId": f"ML-UNBUCKETED-{len(unbucketed) + 1:03d}",
|
|
"reason": "OUT_OF_HORIZON",
|
|
}
|
|
)
|
|
continue
|
|
line_no += 1
|
|
line = {
|
|
"planLineId": f"ML-{bucket['key']}-{line_no:03d}",
|
|
"source": point["source"],
|
|
"ref": point["ref"],
|
|
"productId": point["productId"],
|
|
"productCode": point["productCode"],
|
|
"productName": point["productName"],
|
|
"quantity": point["quantity"],
|
|
"weightedQty": point["weightedQty"],
|
|
"dueDate": point["dueDate"],
|
|
"bucketKey": bucket["key"],
|
|
"recommendedStartDate": bucket["start"],
|
|
"plannedOrderQty": point["quantity"],
|
|
"leadTimeDays": round(float(point.get("leadTimeDays") or 0)),
|
|
}
|
|
bucket["mpsLines"].append(line)
|
|
bucket["demandQty"] += point["quantity"]
|
|
bucket["weightedDemand"] += point["weightedQty"]
|
|
if point["source"] == "FORECAST":
|
|
bucket["forecastQty"] += point["quantity"]
|
|
else:
|
|
bucket["firmQty"] += point["quantity"]
|
|
product = bucket["byProduct"].setdefault(
|
|
point["productCode"],
|
|
{
|
|
"productCode": point["productCode"],
|
|
"productName": point["productName"],
|
|
"firmQty": 0,
|
|
"forecastQty": 0,
|
|
"weightedQty": 0.0,
|
|
"lineCount": 0,
|
|
},
|
|
)
|
|
product["weightedQty"] = round(product["weightedQty"] + point["weightedQty"], 1)
|
|
product["lineCount"] += 1
|
|
if point["source"] == "FORECAST":
|
|
product["forecastQty"] += point["quantity"]
|
|
else:
|
|
product["firmQty"] += point["quantity"]
|
|
|
|
for bucket in buckets:
|
|
if cap_mode == "FINITE" and bucket["capacity"] and bucket["capacity"] > 0:
|
|
ratio = round(
|
|
float(bucket["weightedDemand"]) / float(bucket["capacity"]), 3
|
|
)
|
|
gap = round(float(bucket["weightedDemand"]) - float(bucket["capacity"]), 1)
|
|
else:
|
|
ratio, gap = 0.0, 0.0
|
|
status = _status_for(ratio) if cap_mode == "FINITE" else "OK"
|
|
bucket["loadRatio"] = ratio
|
|
bucket["gap"] = gap
|
|
bucket["status"] = status
|
|
bucket["byProduct"] = sorted(
|
|
bucket["byProduct"].values(),
|
|
key=lambda item: -(float(item["firmQty"]) + float(item["forecastQty"])),
|
|
)
|
|
for line in bucket["mpsLines"]:
|
|
line["commitment"] = _commitment(status)
|
|
line["commitmentReason"] = f"bucket status {status}"
|
|
|
|
total_firm = sum(int(b["firmQty"]) for b in buckets)
|
|
total_forecast = sum(int(b["forecastQty"]) for b in buckets)
|
|
total_weighted = sum(float(b["weightedDemand"]) for b in buckets)
|
|
total_capacity = sum(float(b["capacity"] or 0) for b in buckets)
|
|
over_count = sum(1 for b in buckets if b["status"] == "OVER")
|
|
warn_count = sum(1 for b in buckets if b["status"] == "WARN")
|
|
line_count = sum(len(b["mpsLines"]) for b in buckets)
|
|
feasible_lines = sum(
|
|
1
|
|
for b in buckets
|
|
for line in b["mpsLines"]
|
|
if line["commitment"] != "INFEASIBLE"
|
|
)
|
|
draft = {
|
|
"kind": "MPS_DRAFT",
|
|
"draftId": f"MPS-{fmt_date(today0()).replace('-', '')}-{uuid.uuid4().hex[:8].upper()}",
|
|
"planId": None,
|
|
"mode": (mode or "WEEK").upper(),
|
|
"capacityMode": cap_mode,
|
|
"startDate": skeleton[0]["start"] if skeleton else fmt_date(today0()),
|
|
"endDate": skeleton[-1]["end"] if skeleton else fmt_date(today0()),
|
|
"horizonDays": int(horizon_days),
|
|
"includeForecast": include_forecast,
|
|
"includeFirm": include_firm,
|
|
"longCycleOnly": long_cycle_only,
|
|
"dailyCapacity": day_cap,
|
|
"warnThreshold": WARN_RATIO,
|
|
"calendar": {
|
|
"source": calendar["source"],
|
|
"shiftMinutes": round(shift_minutes),
|
|
"workdays": calendar.get("workdays")
|
|
or len(calendar.get("workingDates") or []),
|
|
},
|
|
"summary": {
|
|
"bucketCount": len(buckets),
|
|
"overCount": over_count,
|
|
"warnCount": warn_count,
|
|
"okCount": len(buckets) - over_count - warn_count,
|
|
"firmQty": total_firm,
|
|
"forecastQty": total_forecast,
|
|
"demandQty": total_firm + total_forecast,
|
|
"weightedDemand": round(total_weighted, 1),
|
|
"capacity": round(total_capacity, 1) if cap_mode == "FINITE" else None,
|
|
"overallLoad": round(total_weighted / total_capacity, 3)
|
|
if total_capacity
|
|
else 0.0,
|
|
"lineCount": line_count,
|
|
"feasibleLineCount": feasible_lines,
|
|
"infeasibleLineCount": line_count - feasible_lines,
|
|
"unbucketedCount": len(unbucketed),
|
|
},
|
|
"buckets": buckets,
|
|
"unbucketed": unbucketed,
|
|
}
|
|
draft["capacity"] = assess_mps_capacity(world, draft)
|
|
if cap_mode == "INFINITE":
|
|
draft["hint"] = (
|
|
"INFINITE rough-cut capacity: demand is not capped by line capacity; "
|
|
"finite reference is available in assess_mps_capacity."
|
|
)
|
|
else:
|
|
draft["hint"] = (
|
|
"MPS draft uses the shift calendar for workdays and ACTIVE line daily capacity; "
|
|
"OVER buckets need leveling/outsourcing before trial scheduling."
|
|
)
|
|
return draft
|
|
|
|
|
|
class MpsTraceStore:
|
|
"""Append-only lightweight envelope journal for MPS -> schedule -> publish -> feedback."""
|
|
|
|
schema_version = "2.0"
|
|
|
|
def __init__(
|
|
self,
|
|
path: str,
|
|
*,
|
|
tenant_uuid: str,
|
|
project_id: str,
|
|
world_key: str,
|
|
) -> None:
|
|
self.path = path
|
|
self.scope = _scope_payload(
|
|
tenant_uuid=tenant_uuid,
|
|
project_id=project_id,
|
|
world_key=world_key,
|
|
)
|
|
self._lock = threading.RLock()
|
|
self._traces = self._load()
|
|
|
|
def _load(self) -> dict[str, dict[str, Any]]:
|
|
try:
|
|
with open(self.path, "r", encoding="utf-8") as handle:
|
|
document = json.load(handle)
|
|
except FileNotFoundError:
|
|
return {}
|
|
except (json.JSONDecodeError, OSError) as exc:
|
|
raise ValueError(f"cannot read MPS trace store: {exc}") from exc
|
|
if not isinstance(document, dict):
|
|
raise TypeError("MPS trace store document must be a JSON object")
|
|
traces = document.get("traces")
|
|
if traces is None:
|
|
return {}
|
|
if not isinstance(traces, dict):
|
|
raise TypeError("MPS trace store traces must be a JSON object")
|
|
return copy.deepcopy(traces)
|
|
|
|
def _write(self) -> None:
|
|
directory = os.path.dirname(self.path) or "."
|
|
os.makedirs(directory, exist_ok=True)
|
|
fd, temporary = tempfile.mkstemp(dir=directory, suffix=".tmp")
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
json.dump(
|
|
{"schemaVersion": self.schema_version, "traces": self._traces},
|
|
handle,
|
|
ensure_ascii=False,
|
|
)
|
|
os.replace(temporary, self.path)
|
|
except BaseException:
|
|
if os.path.exists(temporary):
|
|
os.unlink(temporary)
|
|
raise
|
|
|
|
def create(
|
|
self,
|
|
*,
|
|
plan_id: str,
|
|
plan_version: int | None = None,
|
|
draft_id: str | None = None,
|
|
draft_snapshot: dict[str, Any],
|
|
input_summary: dict[str, Any],
|
|
world_fingerprint: str,
|
|
trace_id: str | None = None,
|
|
created_by: str = "SYSTEM",
|
|
) -> dict[str, Any]:
|
|
with self._lock:
|
|
if any(
|
|
input_summary.get(key) != value for key, value in self.scope.items()
|
|
):
|
|
raise ValueError(
|
|
"MPS trace input summary scope does not match its store"
|
|
)
|
|
if draft_snapshot.get("scope") != self.scope:
|
|
raise ValueError("MPS trace draft scope does not match its store")
|
|
if draft_snapshot.get("draftHash") != input_summary.get("draftHash"):
|
|
raise ValueError(
|
|
"MPS trace draft hash does not match its input summary"
|
|
)
|
|
draft_hash_version = _resolve_draft_hash_version(draft_snapshot)
|
|
summary_hash_version = str(
|
|
input_summary.get("draftHashVersion") or draft_hash_version
|
|
)
|
|
if draft_hash_version != summary_hash_version:
|
|
raise ValueError(
|
|
"MPS trace draft hash version does not match its input summary"
|
|
)
|
|
if world_fingerprint != input_summary.get("worldFingerprint"):
|
|
raise ValueError(
|
|
"MPS trace world fingerprint does not match its input summary"
|
|
)
|
|
resolved_id = trace_id or f"TR-{uuid.uuid4().hex[:12].upper()}"
|
|
if resolved_id in self._traces:
|
|
raise ValueError(f"MPS trace {resolved_id!r} already exists")
|
|
now = _now_iso()
|
|
envelope: dict[str, Any] = {
|
|
"traceId": resolved_id,
|
|
"planId": plan_id,
|
|
"planVersion": plan_version,
|
|
"draftId": draft_id,
|
|
**self.scope,
|
|
"draftHash": input_summary.get("draftHash"),
|
|
"draftHashVersion": summary_hash_version,
|
|
"worldFingerprint": world_fingerprint,
|
|
"inputSummary": copy.deepcopy(input_summary),
|
|
"draftSnapshot": copy.deepcopy(draft_snapshot),
|
|
"scheduleId": None,
|
|
"scheduleVersionNo": None,
|
|
"scheduleTrack": None,
|
|
"scheduleFingerprint": None,
|
|
"scheduleStatusAtLink": None,
|
|
"scheduleLinkedAt": None,
|
|
"scheduleLinkedBy": None,
|
|
"adoptId": None,
|
|
"adoptedAt": None,
|
|
"adoptedBy": None,
|
|
"publishId": None,
|
|
"publishedAt": None,
|
|
"publishedBy": None,
|
|
"executionFeedback": [],
|
|
"events": [{"at": now, "event": "OPEN", "by": created_by}],
|
|
"createdAt": now,
|
|
"updatedAt": now,
|
|
}
|
|
self._traces[resolved_id] = copy.deepcopy(envelope)
|
|
try:
|
|
self._write()
|
|
except BaseException:
|
|
self._traces.pop(resolved_id, None)
|
|
raise
|
|
return copy.deepcopy(envelope)
|
|
|
|
def discard_uncommitted(self, trace_id: str) -> None:
|
|
"""Compensate a freshly opened trace before its Plan node commits."""
|
|
with self._lock:
|
|
envelope = self._require(trace_id)
|
|
events = envelope.get("events") or []
|
|
if (
|
|
len(events) != 1
|
|
or events[0].get("event") != "OPEN"
|
|
or envelope.get("scheduleId") is not None
|
|
or envelope.get("publishId") is not None
|
|
or envelope.get("executionFeedback")
|
|
):
|
|
raise ValueError("only an uncommitted OPEN MPS trace can be discarded")
|
|
previous = copy.deepcopy(envelope)
|
|
del self._traces[trace_id]
|
|
try:
|
|
self._write()
|
|
except BaseException:
|
|
self._traces[trace_id] = previous
|
|
raise
|
|
|
|
def _require(self, trace_id: str) -> dict[str, Any]:
|
|
envelope = self._traces.get(trace_id)
|
|
if envelope is None or any(
|
|
envelope.get(key) != value for key, value in self.scope.items()
|
|
):
|
|
raise ValueError(f"MPS trace {trace_id!r} does not exist")
|
|
return envelope
|
|
|
|
def get(self, trace_id: str) -> dict[str, Any]:
|
|
with self._lock:
|
|
return copy.deepcopy(self._require(trace_id))
|
|
|
|
def list(self) -> list[dict[str, Any]]:
|
|
with self._lock:
|
|
rows = [
|
|
copy.deepcopy(value)
|
|
for value in self._traces.values()
|
|
if all(
|
|
value.get(key) == expected for key, expected in self.scope.items()
|
|
)
|
|
]
|
|
rows.sort(key=lambda row: row.get("createdAt") or "")
|
|
return rows
|
|
|
|
def replay(self, trace_id: str, *, world: World) -> dict[str, Any]:
|
|
envelope = self.get(trace_id)
|
|
draft = validate_mps_draft_binding(
|
|
world,
|
|
envelope.get("draftSnapshot") or {},
|
|
tenant_uuid=self.scope["tenantUuid"],
|
|
project_id=self.scope["projectId"],
|
|
world_key=self.scope["worldKey"],
|
|
allow_legacy=True,
|
|
)
|
|
summary = envelope.get("inputSummary") or {}
|
|
if summary.get("draftHash") != draft.get("draftHash"):
|
|
raise ValueError(
|
|
"MPS trace input summary does not match its draft snapshot"
|
|
)
|
|
return {
|
|
"traceId": envelope["traceId"],
|
|
"planId": envelope["planId"],
|
|
"planVersion": envelope.get("planVersion"),
|
|
"inputSummary": copy.deepcopy(summary),
|
|
"draft": draft,
|
|
}
|
|
|
|
def link_schedule(
|
|
self,
|
|
trace_id: str,
|
|
schedule_id: Any,
|
|
*,
|
|
schedule_version_no: str | None = None,
|
|
track: str = "fixed",
|
|
schedule_fingerprint: str | None = None,
|
|
schedule_status: str | None = None,
|
|
world_fingerprint: str | None = None,
|
|
actor: str = "SYSTEM",
|
|
_persist: bool = True,
|
|
) -> dict[str, Any]:
|
|
with self._lock:
|
|
envelope = self._require(trace_id)
|
|
normalized_track = str(track or "").lower()
|
|
if normalized_track not in {"fixed", "flex"}:
|
|
raise ValueError("MPS schedule track must be fixed or flex")
|
|
if schedule_id is None:
|
|
raise ValueError("MPS schedule id is required")
|
|
if (
|
|
world_fingerprint is not None
|
|
and envelope.get("worldFingerprint") != world_fingerprint
|
|
):
|
|
raise ValueError("MPS schedule world fingerprint does not match its trace")
|
|
|
|
existing_id = envelope.get("scheduleId")
|
|
if existing_id is not None:
|
|
previous = copy.deepcopy(envelope)
|
|
expected = {
|
|
"scheduleId": schedule_id,
|
|
"scheduleVersionNo": schedule_version_no,
|
|
"scheduleTrack": normalized_track,
|
|
"scheduleFingerprint": schedule_fingerprint,
|
|
}
|
|
mismatched = [
|
|
key
|
|
for key, value in expected.items()
|
|
if value is not None
|
|
and envelope.get(key) is not None
|
|
and (
|
|
not _same_id(envelope.get(key), value)
|
|
if key == "scheduleId"
|
|
else envelope.get(key) != value
|
|
)
|
|
]
|
|
if mismatched:
|
|
raise ValueError(
|
|
"MPS trace already links a different schedule version"
|
|
)
|
|
backfilled = False
|
|
for key, value in expected.items():
|
|
if envelope.get(key) is None and value is not None:
|
|
envelope[key] = value
|
|
backfilled = True
|
|
if schedule_status and envelope.get("scheduleStatusAtLink") is None:
|
|
envelope["scheduleStatusAtLink"] = schedule_status
|
|
backfilled = True
|
|
if not backfilled:
|
|
return copy.deepcopy(envelope)
|
|
now = _now_iso()
|
|
envelope["updatedAt"] = now
|
|
try:
|
|
if _persist:
|
|
self._write()
|
|
except BaseException:
|
|
self._traces[trace_id] = previous
|
|
raise
|
|
return copy.deepcopy(envelope)
|
|
|
|
previous = copy.deepcopy(envelope)
|
|
now = _now_iso()
|
|
envelope["scheduleId"] = schedule_id
|
|
envelope["scheduleVersionNo"] = schedule_version_no
|
|
envelope["scheduleTrack"] = normalized_track
|
|
envelope["scheduleFingerprint"] = schedule_fingerprint
|
|
envelope["scheduleStatusAtLink"] = schedule_status
|
|
envelope["scheduleLinkedAt"] = now
|
|
envelope["scheduleLinkedBy"] = actor
|
|
envelope["updatedAt"] = now
|
|
envelope["events"].append(
|
|
{
|
|
"at": now,
|
|
"event": "SCHEDULE",
|
|
"by": actor,
|
|
"detail": {
|
|
"scheduleId": schedule_id,
|
|
"scheduleVersionNo": schedule_version_no,
|
|
"track": envelope["scheduleTrack"],
|
|
"scheduleFingerprint": schedule_fingerprint,
|
|
},
|
|
}
|
|
)
|
|
try:
|
|
if _persist:
|
|
self._write()
|
|
except BaseException:
|
|
self._traces[trace_id] = previous
|
|
raise
|
|
return copy.deepcopy(envelope)
|
|
|
|
def link_publish(
|
|
self,
|
|
trace_id: str,
|
|
publish_id: Any,
|
|
*,
|
|
published_at: str | None = None,
|
|
fact_type: str = "PUBLISH",
|
|
schedule_id: Any = None,
|
|
schedule_version_no: str | None = None,
|
|
track: str | None = None,
|
|
schedule_fingerprint: str | None = None,
|
|
actor: str = "SYSTEM",
|
|
_persist: bool = True,
|
|
) -> dict[str, Any]:
|
|
with self._lock:
|
|
envelope = self._require(trace_id)
|
|
normalized_fact = str(fact_type or "PUBLISH").upper()
|
|
if normalized_fact not in {"ADOPT", "PUBLISH"}:
|
|
raise ValueError("MPS local fact must be ADOPT or PUBLISH")
|
|
if publish_id in (None, ""):
|
|
raise ValueError("MPS local fact id is required")
|
|
expected_track = str(track).lower() if track is not None else None
|
|
expected = {
|
|
"scheduleId": schedule_id,
|
|
"scheduleVersionNo": schedule_version_no,
|
|
"scheduleTrack": expected_track,
|
|
"scheduleFingerprint": schedule_fingerprint,
|
|
}
|
|
for key, value in expected.items():
|
|
if value is None:
|
|
continue
|
|
current = envelope.get(key)
|
|
matches = (
|
|
_same_id(current, value) if key == "scheduleId" else current == value
|
|
)
|
|
if current is None or not matches:
|
|
raise ValueError(
|
|
"MPS local fact does not match the linked schedule version"
|
|
)
|
|
|
|
id_key = "adoptId" if normalized_fact == "ADOPT" else "publishId"
|
|
at_key = "adoptedAt" if normalized_fact == "ADOPT" else "publishedAt"
|
|
by_key = "adoptedBy" if normalized_fact == "ADOPT" else "publishedBy"
|
|
existing_fact_id = envelope.get(id_key)
|
|
if existing_fact_id is not None:
|
|
if str(existing_fact_id) != str(publish_id):
|
|
raise ValueError(
|
|
f"MPS trace already records a different {normalized_fact} fact"
|
|
)
|
|
return copy.deepcopy(envelope)
|
|
|
|
previous = copy.deepcopy(envelope)
|
|
now = _now_iso()
|
|
envelope[id_key] = publish_id
|
|
envelope[at_key] = published_at or now
|
|
envelope[by_key] = actor
|
|
envelope["updatedAt"] = now
|
|
envelope["events"].append(
|
|
{
|
|
"at": now,
|
|
"event": normalized_fact,
|
|
"by": actor,
|
|
"detail": {
|
|
"factId": publish_id,
|
|
"factType": normalized_fact,
|
|
"scheduleId": envelope.get("scheduleId"),
|
|
"scheduleVersionNo": envelope.get("scheduleVersionNo"),
|
|
"track": envelope.get("scheduleTrack"),
|
|
"scheduleFingerprint": envelope.get("scheduleFingerprint"),
|
|
},
|
|
}
|
|
)
|
|
try:
|
|
if _persist:
|
|
self._write()
|
|
except BaseException:
|
|
self._traces[trace_id] = previous
|
|
raise
|
|
return copy.deepcopy(envelope)
|
|
|
|
def link_local_fact(
|
|
self,
|
|
trace_id: str,
|
|
*,
|
|
schedule_id: Any,
|
|
schedule_version_no: str,
|
|
track: str,
|
|
schedule_fingerprint: str,
|
|
schedule_status: str,
|
|
world_fingerprint: str,
|
|
fact_type: str,
|
|
fact_id: str,
|
|
fact_at: str | None = None,
|
|
actor: str = "SYSTEM",
|
|
) -> dict[str, Any]:
|
|
"""Atomically bind one local version and its adoption/publication fact."""
|
|
with self._lock:
|
|
previous = copy.deepcopy(self._require(trace_id))
|
|
try:
|
|
self.link_schedule(
|
|
trace_id,
|
|
schedule_id,
|
|
schedule_version_no=schedule_version_no,
|
|
track=track,
|
|
schedule_fingerprint=schedule_fingerprint,
|
|
schedule_status=schedule_status,
|
|
world_fingerprint=world_fingerprint,
|
|
actor=actor,
|
|
_persist=False,
|
|
)
|
|
self.link_publish(
|
|
trace_id,
|
|
fact_id,
|
|
published_at=fact_at,
|
|
fact_type=fact_type,
|
|
schedule_id=schedule_id,
|
|
schedule_version_no=schedule_version_no,
|
|
track=track,
|
|
schedule_fingerprint=schedule_fingerprint,
|
|
actor=actor,
|
|
_persist=False,
|
|
)
|
|
envelope = self._require(trace_id)
|
|
idempotent = envelope == previous
|
|
if not idempotent:
|
|
self._write()
|
|
return {
|
|
"trace": copy.deepcopy(envelope),
|
|
"idempotent": idempotent,
|
|
}
|
|
except BaseException:
|
|
self._traces[trace_id] = previous
|
|
raise
|
|
|
|
def link_feedback(
|
|
self,
|
|
trace_id: str,
|
|
*,
|
|
feedback_id: str,
|
|
work_order_id: Any,
|
|
progress_pct: int,
|
|
status: str,
|
|
qty_done: Any = None,
|
|
reported_at: str | None = None,
|
|
actor: str = "SYSTEM",
|
|
) -> dict[str, Any]:
|
|
with self._lock:
|
|
envelope = self._require(trace_id)
|
|
now = _now_iso()
|
|
feedback = {
|
|
"feedbackId": feedback_id,
|
|
"workOrderId": work_order_id,
|
|
"progressPct": max(0, min(100, int(progress_pct))),
|
|
"status": status,
|
|
"qtyDone": qty_done,
|
|
"reportedAt": reported_at or now,
|
|
"by": actor,
|
|
}
|
|
envelope["executionFeedback"].append(feedback)
|
|
envelope["updatedAt"] = now
|
|
envelope["events"].append(
|
|
{
|
|
"at": now,
|
|
"event": "FEEDBACK",
|
|
"by": actor,
|
|
"detail": {"feedbackId": feedback_id, "workOrderId": work_order_id},
|
|
}
|
|
)
|
|
self._write()
|
|
return copy.deepcopy(envelope)
|
|
|
|
|
|
def create_mps_plan_node(
|
|
draft: dict[str, Any],
|
|
*,
|
|
plan_store: Any,
|
|
input_summary: dict[str, Any],
|
|
plan_id: str | None = None,
|
|
created_by: str = "SYSTEM",
|
|
) -> Any:
|
|
"""Persist an MPS draft as an immutable L2 Plan node under a reusable L0/L1 chain."""
|
|
from server.agent_core.plan_runtime import PlanNotFoundError
|
|
|
|
l0_id = "intent-mps"
|
|
l1_id = "strategy-mps"
|
|
try:
|
|
plan_store.latest(l0_id)
|
|
except PlanNotFoundError:
|
|
plan_store.create(
|
|
plan_id=l0_id,
|
|
layer="L0",
|
|
parent_id=None,
|
|
inputs={"intent": "mps"},
|
|
payload={"kind": "intent", "action": "mps.plan"},
|
|
status="APPROVED",
|
|
created_by="SYSTEM",
|
|
)
|
|
try:
|
|
plan_store.latest(l1_id)
|
|
except PlanNotFoundError:
|
|
plan_store.create(
|
|
plan_id=l1_id,
|
|
layer="L1",
|
|
parent_id=l0_id,
|
|
inputs={"intent": l0_id},
|
|
payload={"kind": "strategy", "action": "mps.plan"},
|
|
status="APPROVED",
|
|
created_by="SYSTEM",
|
|
)
|
|
summary = draft.get("summary") or {}
|
|
capacity = draft.get("capacity") or {}
|
|
node = plan_store.create(
|
|
plan_id=plan_id or f"mps-{uuid.uuid4().hex[:12]}",
|
|
layer="L2",
|
|
parent_id=l1_id,
|
|
inputs=copy.deepcopy(input_summary),
|
|
payload={
|
|
"kind": "mps-draft",
|
|
"draftId": draft.get("draftId"),
|
|
"draftHash": draft.get("draftHash"),
|
|
"worldFingerprint": draft.get("worldFingerprint"),
|
|
"inputSummary": copy.deepcopy(input_summary),
|
|
"draftSnapshot": copy.deepcopy(draft),
|
|
"summary": {
|
|
"bucketCount": summary.get("bucketCount"),
|
|
"weightedDemand": summary.get("weightedDemand"),
|
|
"overCount": summary.get("overCount"),
|
|
"warnCount": summary.get("warnCount"),
|
|
"overallLoad": summary.get("overallLoad"),
|
|
"feasibleLineCount": summary.get("feasibleLineCount"),
|
|
},
|
|
"capacity": {
|
|
"feasibleRate": capacity.get("feasibleRate"),
|
|
"peakLoadRatio": capacity.get("peakLoadRatio"),
|
|
"bottleneckBucketCount": capacity.get("bottleneckBucketCount"),
|
|
},
|
|
"bucketSummary": [
|
|
{
|
|
"key": b.get("key"),
|
|
"label": b.get("label"),
|
|
"status": b.get("status"),
|
|
"loadRatio": b.get("loadRatio"),
|
|
"gap": b.get("gap"),
|
|
}
|
|
for b in (draft.get("buckets") or [])
|
|
],
|
|
},
|
|
status="DRAFT",
|
|
evidence_refs=[f"mps-draft:{draft.get('draftId')}"],
|
|
created_by=created_by,
|
|
)
|
|
return node
|
|
|
|
|
|
def create_mps_trace(
|
|
plan_node: Any,
|
|
*,
|
|
trace_store: MpsTraceStore,
|
|
draft: dict[str, Any],
|
|
input_summary: dict[str, Any],
|
|
trace_id: str | None = None,
|
|
created_by: str = "SYSTEM",
|
|
) -> dict[str, Any]:
|
|
return trace_store.create(
|
|
plan_id=plan_node.planId,
|
|
plan_version=plan_node.version,
|
|
draft_id=draft.get("draftId"),
|
|
draft_snapshot=draft,
|
|
input_summary=input_summary,
|
|
world_fingerprint=str(draft.get("worldFingerprint") or ""),
|
|
trace_id=trace_id,
|
|
created_by=created_by,
|
|
)
|
|
|
|
|
|
def persist_mps_draft(
|
|
draft: dict[str, Any],
|
|
*,
|
|
world: World,
|
|
plan_store: Any,
|
|
trace_store: MpsTraceStore,
|
|
tenant_uuid: str,
|
|
project_id: str,
|
|
world_key: str,
|
|
plan_id: str | None = None,
|
|
created_by: str = "SYSTEM",
|
|
actor: str = "SYSTEM",
|
|
) -> dict[str, Any]:
|
|
expected_scope = _scope_payload(
|
|
tenant_uuid=tenant_uuid,
|
|
project_id=project_id,
|
|
world_key=world_key,
|
|
)
|
|
if trace_store.scope != expected_scope:
|
|
raise ValueError("MPS trace store scope does not match the draft scope")
|
|
validated = validate_mps_draft_binding(
|
|
world,
|
|
draft,
|
|
tenant_uuid=tenant_uuid,
|
|
project_id=project_id,
|
|
world_key=world_key,
|
|
)
|
|
input_summary = _input_summary(
|
|
validated,
|
|
tenant_uuid=tenant_uuid,
|
|
project_id=project_id,
|
|
world_key=world_key,
|
|
)
|
|
resolved_plan_id = plan_id or f"mps-{uuid.uuid4().hex[:12]}"
|
|
persisted_draft = copy.deepcopy(validated)
|
|
persisted_draft["planId"] = resolved_plan_id
|
|
envelope = trace_store.create(
|
|
plan_id=resolved_plan_id,
|
|
plan_version=1,
|
|
draft_id=persisted_draft.get("draftId"),
|
|
draft_snapshot=persisted_draft,
|
|
input_summary=input_summary,
|
|
world_fingerprint=str(persisted_draft.get("worldFingerprint") or ""),
|
|
created_by=actor,
|
|
)
|
|
try:
|
|
node = create_mps_plan_node(
|
|
validated,
|
|
plan_store=plan_store,
|
|
input_summary=input_summary,
|
|
plan_id=resolved_plan_id,
|
|
created_by=created_by,
|
|
)
|
|
except BaseException as exc:
|
|
try:
|
|
trace_store.discard_uncommitted(envelope["traceId"])
|
|
except BaseException as rollback_exc:
|
|
raise RuntimeError(
|
|
f"MPS Plan persistence failed and trace compensation failed: {rollback_exc}"
|
|
) from exc
|
|
raise
|
|
return {"planNode": node, "envelope": envelope, "draft": persisted_draft}
|
|
|
|
|
|
def get_mps_trace(trace_id: str, *, trace_store: MpsTraceStore) -> dict[str, Any]:
|
|
return trace_store.get(trace_id)
|
|
|
|
|
|
def replay_mps_trace(
|
|
trace_id: str,
|
|
*,
|
|
world: World,
|
|
trace_store: MpsTraceStore,
|
|
) -> dict[str, Any]:
|
|
return trace_store.replay(trace_id, world=world)
|
|
|
|
|
|
def _resolve_schedule_version(
|
|
world: World,
|
|
track: str,
|
|
schedule_id: Any = None,
|
|
) -> dict[str, Any] | None:
|
|
key = "flexScheduleVersions" if track == "flex" else "scheduleVersions"
|
|
versions = world.get(key) or []
|
|
if schedule_id is None:
|
|
return versions[-1] if versions else None
|
|
for version in versions:
|
|
try:
|
|
if int(version.get("id")) == int(schedule_id):
|
|
return version
|
|
except (TypeError, ValueError):
|
|
if str(version.get("id")) == str(schedule_id):
|
|
return version
|
|
return None
|
|
|
|
|
|
def _same_id(left: Any, right: Any) -> bool:
|
|
try:
|
|
return int(left) == int(right)
|
|
except (TypeError, ValueError):
|
|
return str(left) == str(right)
|
|
|
|
|
|
def _without_schedule_lifecycle(row: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
key: copy.deepcopy(value)
|
|
for key, value in row.items()
|
|
if key not in _SCHEDULE_LIFECYCLE_KEYS
|
|
}
|
|
|
|
|
|
def _schedule_projection(
|
|
world: World,
|
|
track: str,
|
|
version: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
normalized_track = str(track or "").lower()
|
|
if normalized_track not in {"fixed", "flex"}:
|
|
raise ValueError("MPS schedule track must be fixed or flex")
|
|
version_id = version.get("id")
|
|
if version_id is None:
|
|
raise ValueError("MPS schedule version is missing its id")
|
|
|
|
if normalized_track == "flex":
|
|
artifact_specs = (
|
|
("virtualLines", "flexVirtualLines", "versionId"),
|
|
("workOrders", "flexWorkOrders", "versionId"),
|
|
("conflicts", "flexConflicts", "versionId"),
|
|
)
|
|
production_orders: list[dict[str, Any]] = []
|
|
else:
|
|
production_orders = [
|
|
row
|
|
for row in (world.get("productionOrders") or [])
|
|
if _same_id(row.get("schedulingVersionId"), version_id)
|
|
]
|
|
production_order_ids = {str(row.get("id")) for row in production_orders}
|
|
artifact_specs = (
|
|
("workOrders", "workOrders", "productionOrderId"),
|
|
("conflicts", "conflicts", "versionId"),
|
|
)
|
|
|
|
projection: dict[str, Any] = {
|
|
"track": normalized_track,
|
|
"version": _without_schedule_lifecycle(version),
|
|
}
|
|
if normalized_track == "fixed":
|
|
projection["productionOrders"] = sorted(
|
|
(_without_schedule_lifecycle(row) for row in production_orders),
|
|
key=lambda row: str(row.get("id")),
|
|
)
|
|
|
|
for output_key, world_key, foreign_key in artifact_specs:
|
|
rows = world.get(world_key) or []
|
|
if normalized_track == "fixed" and world_key == "workOrders":
|
|
selected = [
|
|
row
|
|
for row in rows
|
|
if str(row.get(foreign_key)) in production_order_ids
|
|
]
|
|
else:
|
|
selected = [
|
|
row for row in rows if _same_id(row.get(foreign_key), version_id)
|
|
]
|
|
projection[output_key] = sorted(
|
|
(_without_schedule_lifecycle(row) for row in selected),
|
|
key=lambda row: str(row.get("id")),
|
|
)
|
|
return projection
|
|
|
|
|
|
def schedule_fingerprint(
|
|
world: World,
|
|
track: str,
|
|
version: dict[str, Any],
|
|
) -> str:
|
|
"""Stable hash of one exact local schedule and its version-owned artifacts."""
|
|
return _canonical_hash(_schedule_projection(world, track, version))
|
|
|
|
|
|
def _schedule_summary(
|
|
world: World,
|
|
track: str,
|
|
version: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"track": track,
|
|
"scheduleId": version.get("id"),
|
|
"scheduleVersionNo": version.get("versionNo"),
|
|
"status": version.get("status"),
|
|
"createdAt": version.get("createdAt"),
|
|
"publishedAt": version.get("publishedAt"),
|
|
"strategy": version.get("sortMode")
|
|
or version.get("strategy")
|
|
or version.get("note"),
|
|
"engineType": version.get("engineType"),
|
|
"orderCount": int(version.get("orderCount") or 0),
|
|
"woCount": int(version.get("woCount") or 0),
|
|
"conflictCount": int(version.get("conflictCount") or 0),
|
|
"scheduleFingerprint": schedule_fingerprint(world, track, version),
|
|
}
|
|
|
|
|
|
def list_mps_schedule_versions(world: World) -> list[dict[str, Any]]:
|
|
rows = [
|
|
_schedule_summary(world, track, version)
|
|
for track, key in (
|
|
("fixed", "scheduleVersions"),
|
|
("flex", "flexScheduleVersions"),
|
|
)
|
|
for version in (world.get(key) or [])
|
|
]
|
|
rows.sort(
|
|
key=lambda row: (
|
|
str(row.get("createdAt") or ""),
|
|
str(row.get("scheduleVersionNo") or ""),
|
|
str(row.get("track") or ""),
|
|
),
|
|
reverse=True,
|
|
)
|
|
return rows
|
|
|
|
|
|
def _require_schedule_version(
|
|
world: World,
|
|
*,
|
|
track: str,
|
|
schedule_id: Any,
|
|
schedule_version_no: str,
|
|
expected_fingerprint: str,
|
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
normalized_track = str(track or "").lower()
|
|
if normalized_track not in {"fixed", "flex"}:
|
|
raise ValueError("MPS schedule track must be fixed or flex")
|
|
version = _resolve_schedule_version(world, normalized_track, schedule_id)
|
|
if version is None:
|
|
raise ValueError("MPS schedule version does not exist on the requested track")
|
|
actual_version_no = str(version.get("versionNo") or "")
|
|
if not schedule_version_no or actual_version_no != str(schedule_version_no):
|
|
raise ValueError("MPS schedule version number does not match its id and track")
|
|
summary = _schedule_summary(world, normalized_track, version)
|
|
if summary["scheduleFingerprint"] != str(expected_fingerprint or ""):
|
|
raise ValueError("MPS schedule fingerprint is stale")
|
|
return version, summary
|
|
|
|
|
|
def link_mps_trace_local_fact(
|
|
world: World,
|
|
*,
|
|
trace_store: MpsTraceStore,
|
|
trace_id: str,
|
|
tenant_uuid: str,
|
|
project_id: str,
|
|
world_key: str,
|
|
draft_hash: str,
|
|
world_fingerprint: str,
|
|
schedule_id: Any,
|
|
schedule_version_no: str,
|
|
schedule_fingerprint: str,
|
|
track: str,
|
|
fact_type: str,
|
|
fact_id: str | None = None,
|
|
actor: str = "SYSTEM",
|
|
) -> dict[str, Any]:
|
|
"""Bind an exact local schedule and ADOPT/PUBLISH fact without world writes."""
|
|
expected_scope = _scope_payload(
|
|
tenant_uuid=tenant_uuid,
|
|
project_id=project_id,
|
|
world_key=world_key,
|
|
)
|
|
if trace_store.scope != expected_scope:
|
|
raise ValueError("MPS trace store scope does not match the local link scope")
|
|
trace = trace_store.get(trace_id)
|
|
if any(trace.get(key) != value for key, value in expected_scope.items()):
|
|
raise ValueError("MPS trace scope does not match the authenticated project")
|
|
if trace.get("draftHash") != str(draft_hash or ""):
|
|
raise ValueError("MPS local link draft hash does not match its trace")
|
|
if trace.get("worldFingerprint") != str(world_fingerprint or ""):
|
|
raise ValueError("MPS local link world fingerprint does not match its trace")
|
|
validate_mps_draft_binding(
|
|
world,
|
|
trace.get("draftSnapshot") or {},
|
|
tenant_uuid=tenant_uuid,
|
|
project_id=project_id,
|
|
world_key=world_key,
|
|
allow_legacy=True,
|
|
)
|
|
|
|
normalized_track = str(track or "").lower()
|
|
version, schedule = _require_schedule_version(
|
|
world,
|
|
track=normalized_track,
|
|
schedule_id=schedule_id,
|
|
schedule_version_no=schedule_version_no,
|
|
expected_fingerprint=schedule_fingerprint,
|
|
)
|
|
normalized_fact = str(fact_type or "").upper()
|
|
if normalized_fact not in {"ADOPT", "PUBLISH"}:
|
|
raise ValueError("MPS local fact must be ADOPT or PUBLISH")
|
|
schedule_status = str(version.get("status") or "").upper()
|
|
if schedule_status not in {"DRAFT", "PUBLISHED", "DISPATCHED"}:
|
|
raise ValueError("MPS local fact requires a usable local schedule version")
|
|
fact_at = None
|
|
if normalized_fact == "PUBLISH":
|
|
if schedule_status not in {"PUBLISHED", "DISPATCHED"}:
|
|
raise ValueError("MPS PUBLISH fact requires a published local version")
|
|
fact_at = str(version.get("publishedAt") or "")
|
|
if not fact_at:
|
|
raise ValueError("MPS PUBLISH fact requires the version publishedAt value")
|
|
|
|
resolved_fact_id = str(
|
|
fact_id
|
|
or f"{normalized_fact}:{normalized_track}:{schedule_version_no}"
|
|
)
|
|
linked = trace_store.link_local_fact(
|
|
trace_id,
|
|
schedule_id=schedule_id,
|
|
schedule_version_no=schedule_version_no,
|
|
track=normalized_track,
|
|
schedule_fingerprint=schedule_fingerprint,
|
|
schedule_status=schedule_status,
|
|
world_fingerprint=world_fingerprint,
|
|
fact_type=normalized_fact,
|
|
fact_id=resolved_fact_id,
|
|
fact_at=fact_at,
|
|
actor=actor,
|
|
)
|
|
envelope = linked["trace"]
|
|
at_key = "adoptedAt" if normalized_fact == "ADOPT" else "publishedAt"
|
|
return {
|
|
**linked,
|
|
"schedule": schedule,
|
|
"fact": {
|
|
"factId": resolved_fact_id,
|
|
"factType": normalized_fact,
|
|
"at": envelope.get(at_key),
|
|
"by": actor,
|
|
},
|
|
}
|
|
|
|
|
|
def _schedule_kpi(world: World, track: str, version: dict[str, Any]) -> dict[str, Any]:
|
|
order_count = int(version.get("orderCount") or 0)
|
|
on_time = int(version.get("onTimeCount") or 0)
|
|
return {
|
|
"versionNo": version.get("versionNo"),
|
|
"status": version.get("status"),
|
|
"orderCount": order_count,
|
|
"poCount": int(version.get("poCount") or 0),
|
|
"woCount": int(version.get("woCount") or 0),
|
|
"conflictCount": int(version.get("conflictCount") or 0),
|
|
"totalTardiness": round(float(version.get("totalTardiness") or 0), 1),
|
|
"avgUtilization": round(float(version.get("avgUtilization") or 0), 3),
|
|
"onTimeCount": on_time,
|
|
"onTimeRate": round(on_time / order_count, 3) if order_count else None,
|
|
"makespan": version.get("makespan"),
|
|
"bottleneck": version.get("bottleneck"),
|
|
"track": track,
|
|
}
|
|
|
|
|
|
def _planned_quantity(world: World, track: str, version: dict[str, Any]) -> float:
|
|
version_id = version.get("id")
|
|
if track == "flex":
|
|
rows = world.get("flexVirtualLines") or []
|
|
return float(
|
|
sum(
|
|
int(row.get("quantity") or 0)
|
|
for row in rows
|
|
if row.get("versionId") == version_id
|
|
)
|
|
)
|
|
rows = world.get("productionOrders") or []
|
|
return float(
|
|
sum(
|
|
int(row.get("quantity") or 0)
|
|
for row in rows
|
|
if row.get("schedulingVersionId") == version_id
|
|
)
|
|
)
|
|
|
|
|
|
def compare_mps_schedule_gap(
|
|
world: World,
|
|
draft: dict[str, Any],
|
|
*,
|
|
tenant_uuid: str,
|
|
project_id: str,
|
|
world_key: str,
|
|
schedule_id: Any = None,
|
|
track: str = "fixed",
|
|
trace: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Compare plan-layer MPS KPI with detailed scheduling KPI and summarize gaps."""
|
|
expected_scope = _scope_payload(
|
|
tenant_uuid=tenant_uuid,
|
|
project_id=project_id,
|
|
world_key=world_key,
|
|
)
|
|
trusted_trace_draft = (trace or {}).get("draftSnapshot") or None
|
|
allow_legacy_effective = bool(
|
|
trace and isinstance(trusted_trace_draft, dict) and draft == trusted_trace_draft
|
|
)
|
|
effective_draft = validate_mps_draft_binding(
|
|
world,
|
|
draft,
|
|
tenant_uuid=tenant_uuid,
|
|
project_id=project_id,
|
|
world_key=world_key,
|
|
allow_legacy=allow_legacy_effective,
|
|
)
|
|
tr = str(track or "fixed").lower()
|
|
if trace:
|
|
if any(trace.get(key) != value for key, value in expected_scope.items()):
|
|
raise ValueError("MPS trace scope does not match the authenticated project")
|
|
if trace.get("worldFingerprint") != effective_draft.get("worldFingerprint"):
|
|
raise ValueError("MPS trace is bound to a different world version")
|
|
trace_draft = validate_mps_draft_binding(
|
|
world,
|
|
trace.get("draftSnapshot") or {},
|
|
tenant_uuid=tenant_uuid,
|
|
project_id=project_id,
|
|
world_key=world_key,
|
|
allow_legacy=True,
|
|
)
|
|
if trace.get("draftHash") != effective_draft.get("draftHash"):
|
|
raise ValueError("MPS draft does not match the referenced trace")
|
|
if _draft_hash(trace_draft) != effective_draft.get("draftHash"):
|
|
raise ValueError("MPS trace draft snapshot is inconsistent")
|
|
linked_schedule_id = trace.get("scheduleId")
|
|
linked_track = str(trace.get("scheduleTrack") or "").lower()
|
|
if (
|
|
schedule_id is not None
|
|
and linked_schedule_id is not None
|
|
and not _same_id(linked_schedule_id, schedule_id)
|
|
):
|
|
raise ValueError("MPS scheduleId does not match the referenced trace")
|
|
if schedule_id is None:
|
|
schedule_id = linked_schedule_id
|
|
if tr == "auto":
|
|
tr = linked_track or "fixed"
|
|
elif linked_track and tr != linked_track:
|
|
raise ValueError("MPS schedule track does not match the referenced trace")
|
|
elif tr == "auto":
|
|
tr = "fixed"
|
|
if tr not in ("fixed", "flex"):
|
|
raise ValueError("MPS schedule track must be fixed, flex, or auto")
|
|
version = _resolve_schedule_version(world, tr, schedule_id)
|
|
if trace and trace.get("scheduleId") is not None:
|
|
if version is None:
|
|
raise ValueError("MPS linked schedule version no longer exists")
|
|
if str(version.get("versionNo") or "") != str(
|
|
trace.get("scheduleVersionNo") or ""
|
|
):
|
|
raise ValueError("MPS linked schedule version number has drifted")
|
|
expected_schedule_fingerprint = str(
|
|
trace.get("scheduleFingerprint") or ""
|
|
)
|
|
if not expected_schedule_fingerprint:
|
|
raise ValueError("MPS linked schedule is missing its fingerprint")
|
|
if schedule_fingerprint(world, tr, version) != expected_schedule_fingerprint:
|
|
raise ValueError("MPS linked schedule fingerprint has drifted")
|
|
summary = effective_draft.get("summary") or {}
|
|
capacity = effective_draft.get("capacity") or {}
|
|
plan_kpi = {
|
|
"weightedDemand": round(float(summary.get("weightedDemand") or 0), 1),
|
|
"lineCount": int(summary.get("lineCount") or 0),
|
|
"overallLoad": float(summary.get("overallLoad") or 0),
|
|
"feasibleRate": float(capacity.get("feasibleRate") or 0),
|
|
"overCount": int(summary.get("overCount") or 0),
|
|
"warnCount": int(summary.get("warnCount") or 0),
|
|
"peakLoadRatio": float(capacity.get("peakLoadRatio") or 0),
|
|
"horizonDays": int(effective_draft.get("horizonDays") or 0),
|
|
}
|
|
if version is None:
|
|
return {
|
|
"track": tr,
|
|
"plan": plan_kpi,
|
|
"schedule": None,
|
|
"gaps": None,
|
|
"rows": [],
|
|
"trace": {
|
|
"planId": (trace or {}).get("planId"),
|
|
"scheduleId": schedule_id,
|
|
"scheduleVersionNo": (trace or {}).get("scheduleVersionNo"),
|
|
"scheduleTrack": (trace or {}).get("scheduleTrack"),
|
|
"scheduleFingerprint": (trace or {}).get("scheduleFingerprint"),
|
|
"adoptId": (trace or {}).get("adoptId"),
|
|
"publishId": (trace or {}).get("publishId"),
|
|
"feedbackCount": len((trace or {}).get("executionFeedback") or []),
|
|
},
|
|
"message": "No schedule version available for detailed KPI comparison.",
|
|
}
|
|
|
|
schedule_kpi = _schedule_kpi(world, tr, version)
|
|
planned_qty = _planned_quantity(world, tr, version)
|
|
gaps = {
|
|
"demandQty": plan_kpi["weightedDemand"],
|
|
"plannedQty": round(planned_qty, 1),
|
|
"demandGapQty": round(plan_kpi["weightedDemand"] - planned_qty, 1),
|
|
"loadRatioGap": round(
|
|
plan_kpi["overallLoad"] - schedule_kpi["avgUtilization"], 3
|
|
),
|
|
"feasibleRateGap": round(
|
|
plan_kpi["feasibleRate"] - (schedule_kpi["onTimeRate"] or 0), 3
|
|
),
|
|
"riskCountGap": max(
|
|
0,
|
|
plan_kpi["overCount"]
|
|
+ plan_kpi["warnCount"]
|
|
- schedule_kpi["conflictCount"],
|
|
),
|
|
}
|
|
rows = [
|
|
{
|
|
"key": "demandQty",
|
|
"name": "weighted demand qty",
|
|
"plan": plan_kpi["weightedDemand"],
|
|
"schedule": round(planned_qty, 1),
|
|
"delta": gaps["demandGapQty"],
|
|
"lowerBetter": False,
|
|
},
|
|
{
|
|
"key": "loadRatio",
|
|
"name": "load ratio",
|
|
"plan": plan_kpi["overallLoad"],
|
|
"schedule": schedule_kpi["avgUtilization"],
|
|
"delta": gaps["loadRatioGap"],
|
|
"lowerBetter": True,
|
|
},
|
|
{
|
|
"key": "feasibleRate",
|
|
"name": "feasible rate",
|
|
"plan": plan_kpi["feasibleRate"],
|
|
"schedule": schedule_kpi["onTimeRate"],
|
|
"delta": gaps["feasibleRateGap"],
|
|
"lowerBetter": False,
|
|
},
|
|
{
|
|
"key": "riskCount",
|
|
"name": "risk count",
|
|
"plan": plan_kpi["overCount"] + plan_kpi["warnCount"],
|
|
"schedule": schedule_kpi["conflictCount"],
|
|
"delta": gaps["riskCountGap"],
|
|
"lowerBetter": True,
|
|
},
|
|
]
|
|
message = (
|
|
f"Plan demand {plan_kpi['weightedDemand']} vs scheduled qty {round(planned_qty, 1)}; "
|
|
f"rough-cut feasible rate {plan_kpi['feasibleRate']} vs detailed on-time rate "
|
|
f"{schedule_kpi['onTimeRate']}."
|
|
)
|
|
return {
|
|
"track": tr,
|
|
"plan": plan_kpi,
|
|
"schedule": schedule_kpi,
|
|
"gaps": gaps,
|
|
"rows": rows,
|
|
"trace": {
|
|
"planId": (trace or {}).get("planId"),
|
|
"scheduleId": schedule_id,
|
|
"scheduleVersionNo": (trace or {}).get("scheduleVersionNo"),
|
|
"scheduleTrack": (trace or {}).get("scheduleTrack"),
|
|
"scheduleFingerprint": (trace or {}).get("scheduleFingerprint"),
|
|
"adoptId": (trace or {}).get("adoptId"),
|
|
"publishId": (trace or {}).get("publishId"),
|
|
"feedbackCount": len((trace or {}).get("executionFeedback") or []),
|
|
},
|
|
"message": message,
|
|
}
|