1754 lines
76 KiB
Python
1754 lines
76 KiB
Python
# ============================================================
|
||
# MES 下发与报工(moduleId: domain-mes, 可重生 ✅)
|
||
# EX-05 下发 / EX-09 报工回流:经 Mock MES 桩,幂等键防重复。
|
||
# ============================================================
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
import os
|
||
from copy import deepcopy
|
||
from datetime import datetime
|
||
from typing import Any
|
||
|
||
from server.integrations.mes_stub import get_mes_client
|
||
from server.timeutil import fmt_date, today0
|
||
|
||
World = dict[str, Any]
|
||
|
||
|
||
def _get_active_client():
|
||
"""MES 客户端解析:显式配置 MES_HTTP_BASE_URL → HTTP 适配器;否则 stub(默认)。"""
|
||
if os.environ.get("MES_HTTP_BASE_URL"):
|
||
from server.integrations.mes_http import get_http_mes_client
|
||
return get_http_mes_client()
|
||
return get_mes_client()
|
||
|
||
|
||
def mes_connection_status() -> dict:
|
||
return _get_active_client().status()
|
||
|
||
|
||
def _latest_flex(world: World) -> dict | None:
|
||
vers = world.get("flexScheduleVersions") or []
|
||
return vers[-1] if vers else None
|
||
|
||
|
||
def _latest_fixed(world: World, *, published_only: bool = True) -> dict | None:
|
||
vers = world.get("scheduleVersions") or []
|
||
if published_only:
|
||
pubs = [v for v in vers if v.get("status") == "PUBLISHED"]
|
||
return pubs[-1] if pubs else None
|
||
return vers[-1] if vers else None
|
||
|
||
|
||
def _linked_keys(world: World) -> set[str]:
|
||
return {
|
||
link.get("idemKey")
|
||
for link in world.get("mesLinks", [])
|
||
if link.get("kind") == "dispatch"
|
||
}
|
||
|
||
|
||
def _fixed_work_orders_for_version(world: World, version_id: int) -> list[dict]:
|
||
production_order_ids = {
|
||
order["id"] for order in world.get("productionOrders", [])
|
||
if order.get("schedulingVersionId") == version_id
|
||
}
|
||
return [
|
||
work_order for work_order in world.get("workOrders", [])
|
||
if work_order.get("productionOrderId") in production_order_ids
|
||
]
|
||
|
||
|
||
def _fixed_segment_error(work_order: dict[str, Any]) -> str | None:
|
||
segments = work_order.get("plannedSegments")
|
||
requires_segments = work_order.get("cpTimingSource") == "operationSlots"
|
||
if segments is None and not requires_segments:
|
||
return None
|
||
if not isinstance(segments, list) or not segments:
|
||
return "plannedSegments 必须是非空数组"
|
||
normalized: list[tuple[datetime, datetime, int]] = []
|
||
previous_end: datetime | None = None
|
||
for segment in segments:
|
||
if not isinstance(segment, dict):
|
||
return "plannedSegments 含非对象条目"
|
||
try:
|
||
start = datetime.fromisoformat(str(segment["startTime"]))
|
||
end = datetime.fromisoformat(str(segment["endTime"]))
|
||
duration_value = segment["durationMin"]
|
||
except (KeyError, TypeError, ValueError):
|
||
return "plannedSegments 时间或 durationMin 非法"
|
||
if isinstance(duration_value, bool) or not isinstance(duration_value, int):
|
||
return "plannedSegments.durationMin 必须为整数"
|
||
duration = duration_value
|
||
actual_duration = round((end - start).total_seconds() / 60.0)
|
||
if start >= end or duration <= 0 or duration != actual_duration:
|
||
return "plannedSegments 存在非正区间或 durationMin 漂移"
|
||
if previous_end is not None and start < previous_end:
|
||
return "plannedSegments 未排序或相互重叠"
|
||
previous_end = end
|
||
normalized.append((start, end, duration))
|
||
try:
|
||
envelope_start = datetime.fromisoformat(str(work_order["plannedStartTime"]))
|
||
envelope_end = datetime.fromisoformat(str(work_order["plannedEndTime"]))
|
||
processing = work_order["processingMinutes"]
|
||
elapsed = work_order["elapsedSpanMinutes"]
|
||
pause = work_order["pauseMinutes"]
|
||
segment_count = work_order["segmentCount"]
|
||
except (KeyError, TypeError, ValueError):
|
||
return "CP 分段工单缺少 processing/elapsed/pause/segmentCount"
|
||
if not all(
|
||
isinstance(value, int) and not isinstance(value, bool)
|
||
for value in (processing, elapsed, pause, segment_count)
|
||
):
|
||
return "CP 分段工单 processing/elapsed/pause/segmentCount 必须为整数"
|
||
summed = sum(duration for _start, _end, duration in normalized)
|
||
if (
|
||
normalized[0][0] != envelope_start
|
||
or normalized[-1][1] != envelope_end
|
||
or segment_count != len(normalized)
|
||
or processing != summed
|
||
or elapsed != round((envelope_end - envelope_start).total_seconds() / 60.0)
|
||
or pause != elapsed - processing
|
||
):
|
||
return "CP 分段汇总与工单包络或 processing/pause 不一致"
|
||
return None
|
||
|
||
|
||
|
||
def _canonical_evidence_payload(value: Any) -> Any:
|
||
"""Recursively normalize complete V2 payloads without enumerating schema fields."""
|
||
|
||
if hasattr(value, "model_dump"):
|
||
value = value.model_dump(mode="json")
|
||
if isinstance(value, datetime):
|
||
return value.isoformat()
|
||
if isinstance(value, dict):
|
||
return {
|
||
str(key): _canonical_evidence_payload(item)
|
||
for key, item in sorted(value.items(), key=lambda pair: str(pair[0]))
|
||
}
|
||
if isinstance(value, (list, tuple, set, frozenset)):
|
||
normalized = [_canonical_evidence_payload(item) for item in value]
|
||
return sorted(
|
||
normalized,
|
||
key=lambda item: json.dumps(
|
||
item,
|
||
ensure_ascii=False,
|
||
sort_keys=True,
|
||
default=str,
|
||
separators=(",", ":"),
|
||
),
|
||
)
|
||
enum_value = getattr(value, "value", None)
|
||
if enum_value is not None and not isinstance(value, (str, bytes, int, float, bool)):
|
||
return _canonical_evidence_payload(enum_value)
|
||
return value
|
||
|
||
|
||
def _stable_evidence_digest(payload: Any) -> str:
|
||
normalized = _canonical_evidence_payload(payload)
|
||
return hashlib.sha256(
|
||
json.dumps(
|
||
normalized,
|
||
ensure_ascii=False,
|
||
sort_keys=True,
|
||
default=str,
|
||
separators=(",", ":"),
|
||
).encode("utf-8")
|
||
).hexdigest()
|
||
|
||
|
||
def _minute_key(value: Any) -> str | None:
|
||
if value in (None, ""):
|
||
return None
|
||
raw = str(value).strip().replace("Z", "+00:00")
|
||
try:
|
||
parsed = datetime.fromisoformat(raw)
|
||
except ValueError:
|
||
return raw
|
||
return parsed.strftime("%Y-%m-%d %H:%M")
|
||
|
||
|
||
def _is_explicit_demo_world(world: World) -> bool:
|
||
return any(
|
||
str(factory.get("name") or "").strip() == "演示工厂"
|
||
for factory in world.get("factories", []) or []
|
||
if isinstance(factory, dict)
|
||
)
|
||
|
||
|
||
def _resource_constraint_fields(resource: dict, *, available: bool = True) -> dict[str, Any]:
|
||
# Preserve every current/future dispatch-related Resource field automatically.
|
||
# Human labels and source provenance are evidenced elsewhere but do not change
|
||
# whether a resource can execute an activity.
|
||
excluded = {"name", "sourceRef", "sourceRevision", "sourceHash"}
|
||
snapshot = {
|
||
str(key): deepcopy(value)
|
||
for key, value in resource.items()
|
||
if key not in excluded
|
||
}
|
||
snapshot["available"] = available
|
||
return _canonical_evidence_payload(snapshot)
|
||
|
||
|
||
def _resource_collection(world: World, kind: str) -> list[dict]:
|
||
keys = {
|
||
"FACTORY": ("factories",),
|
||
"WORKSHOP": ("workshops", "factoryWorkshops"),
|
||
"LINE": ("lines", "productionLines", "flexLines"),
|
||
"WORKSTATION": ("workstations", "stations", "flexWorkstations"),
|
||
"EQUIPMENT": ("flexEquipment", "equipment"),
|
||
"TEAM": ("flexTeams", "teams"),
|
||
"TOOLING": ("flexMolds", "molds", "tooling"),
|
||
}.get(kind, ())
|
||
rows: list[dict] = []
|
||
seen: set[tuple[str, str, str]] = set()
|
||
for key in keys:
|
||
collection = world.get(key)
|
||
if not isinstance(collection, list):
|
||
continue
|
||
for row in collection:
|
||
if not isinstance(row, dict):
|
||
continue
|
||
identity = (
|
||
str(row.get("resourceId") or ""),
|
||
str(row.get("id") or ""),
|
||
str(row.get("code") or ""),
|
||
)
|
||
if identity in seen:
|
||
continue
|
||
seen.add(identity)
|
||
rows.append(row)
|
||
return rows
|
||
|
||
|
||
def _match_current_resource(world: World, planned: dict) -> dict | None:
|
||
resource_id = str(planned.get("resourceId") or "")
|
||
suffix = resource_id.split(":", 1)[-1]
|
||
code = str(planned.get("code") or "")
|
||
for row in _resource_collection(world, str(planned.get("kind") or "").upper()):
|
||
candidates = {
|
||
str(row.get("id") or ""),
|
||
str(row.get("code") or ""),
|
||
str(row.get("resourceId") or ""),
|
||
}
|
||
if resource_id in candidates or suffix in candidates or (code and code in candidates):
|
||
return row
|
||
return None
|
||
|
||
|
||
def _current_v2_resource_view(
|
||
world: World,
|
||
problem_payload: dict[str, Any],
|
||
work_orders: list[dict] | None = None,
|
||
) -> tuple[
|
||
list[dict[str, Any]],
|
||
list[dict[str, Any]],
|
||
list[dict[str, Any]],
|
||
list[dict[str, Any]],
|
||
]:
|
||
"""Return effective resources plus planned/current/observed snapshots."""
|
||
|
||
from server.aps_domain.closed_loop_runtime import _calendar_intervals, _maintenance_intervals
|
||
|
||
planned_start = datetime.fromisoformat(str(problem_payload.get("planningStart")).replace("Z", "+00:00"))
|
||
planned_end = datetime.fromisoformat(str(problem_payload.get("planningEnd")).replace("Z", "+00:00"))
|
||
horizon_days = max(1, int((planned_end - planned_start).total_seconds() // 86400))
|
||
global_calendar = [
|
||
interval.model_dump(mode="json")
|
||
for interval in _calendar_intervals(world, planned_start.date(), horizon_days)
|
||
]
|
||
|
||
problem_resources = [
|
||
deepcopy(row)
|
||
for row in problem_payload.get("resources") or []
|
||
if isinstance(row, dict)
|
||
]
|
||
resource_lookup: dict[tuple[str, str], str] = {}
|
||
for resource in problem_resources:
|
||
kind = str(resource.get("kind") or "").upper()
|
||
resource_id = str(resource.get("resourceId") or "")
|
||
for token in (
|
||
resource_id,
|
||
resource_id.split(":", 1)[-1],
|
||
str(resource.get("code") or ""),
|
||
):
|
||
if token:
|
||
resource_lookup[(kind, token)] = resource_id
|
||
|
||
def resolve_resource_ids(values: Any, target_kind: str) -> list[str]:
|
||
resolved: set[str] = set()
|
||
for value in values or []:
|
||
token = str(value)
|
||
resource_id = resource_lookup.get((target_kind, token))
|
||
if resource_id is None and ":" in token:
|
||
resource_id = resource_lookup.get((target_kind, token.split(":", 1)[-1]))
|
||
resolved.add(resource_id or f"{target_kind.lower()}:{token}")
|
||
return sorted(resolved)
|
||
|
||
def positive_number(*values: Any) -> float | None:
|
||
for value in values:
|
||
if value in (None, ""):
|
||
continue
|
||
try:
|
||
number = float(value)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if number > 0:
|
||
return number
|
||
return None
|
||
|
||
effective_resources: list[dict[str, Any]] = []
|
||
planned_constraints: list[dict[str, Any]] = []
|
||
current_constraints: list[dict[str, Any]] = []
|
||
observed_resources: list[dict[str, Any]] = []
|
||
known_kinds = {"FACTORY", "WORKSHOP", "LINE", "WORKSTATION", "EQUIPMENT", "TEAM", "TOOLING"}
|
||
blocked_statuses = {
|
||
"DOWN", "STOPPED", "MAINTENANCE", "DISABLED", "INACTIVE",
|
||
"RETIRED", "SCRAPPED", "UNAVAILABLE", "LOCKED", "CLOSED",
|
||
}
|
||
|
||
for planned in problem_resources:
|
||
kind = str(planned.get("kind") or "").upper()
|
||
current_row = _match_current_resource(world, planned)
|
||
planned_constraints.append(_resource_constraint_fields(planned, available=True))
|
||
|
||
if current_row is None:
|
||
if kind in known_kinds:
|
||
missing = deepcopy(planned)
|
||
missing["capabilities"] = []
|
||
missing["compatibleResourceIds"] = []
|
||
missing["calendarIntervals"] = []
|
||
missing["maintenanceIntervals"] = []
|
||
current_constraints.append(_resource_constraint_fields(missing, available=False))
|
||
observed_resources.append({
|
||
"resource": _resource_constraint_fields(missing, available=False),
|
||
"sourceRow": None,
|
||
})
|
||
continue
|
||
effective_resources.append(planned)
|
||
current_constraints.append(_resource_constraint_fields(planned, available=True))
|
||
observed_resources.append({
|
||
"resource": _resource_constraint_fields(planned, available=True),
|
||
"sourceRow": None,
|
||
})
|
||
continue
|
||
|
||
default_status = "RUNNING" if kind == "EQUIPMENT" else ("AVAILABLE" if kind == "TOOLING" else "ACTIVE")
|
||
status = str(current_row.get("status") or default_status).upper()
|
||
available = status not in blocked_statuses and (kind != "EQUIPMENT" or status == "RUNNING")
|
||
current = deepcopy(planned)
|
||
|
||
# Overlay every same-named schema field. New Resource fields therefore
|
||
# enter the evidence automatically without another hard-coded list.
|
||
immutable = {"resourceId", "kind", "sourceRef", "sourceRevision", "sourceHash"}
|
||
for field in tuple(current):
|
||
if field not in immutable and field in current_row:
|
||
current[field] = deepcopy(current_row[field])
|
||
|
||
capability_values: Any = current_row.get("capabilities")
|
||
if kind == "TEAM" and "supportOps" in current_row:
|
||
capability_values = current_row.get("supportOps")
|
||
if kind == "TOOLING":
|
||
capability_values = list(capability_values or [])
|
||
if current_row.get("operationCode"):
|
||
capability_values.append(current_row.get("operationCode"))
|
||
if capability_values is not None:
|
||
current["capabilities"] = sorted({str(value) for value in capability_values if value})
|
||
|
||
if "compatibleResourceIds" in current_row:
|
||
current["compatibleResourceIds"] = resolve_resource_ids(
|
||
current_row.get("compatibleResourceIds"),
|
||
"TOOLING" if kind == "EQUIPMENT" else "EQUIPMENT",
|
||
)
|
||
elif kind == "EQUIPMENT" and "adaptableMolds" in current_row:
|
||
current["compatibleResourceIds"] = resolve_resource_ids(current_row.get("adaptableMolds"), "TOOLING")
|
||
elif kind == "TOOLING" and "adaptableEquipment" in current_row:
|
||
current["compatibleResourceIds"] = resolve_resource_ids(current_row.get("adaptableEquipment"), "EQUIPMENT")
|
||
|
||
capacity = positive_number(
|
||
current_row.get("cumulativeCapacity"),
|
||
current_row.get("memberCount") if kind == "TEAM" else None,
|
||
current_row.get("quantity") if kind == "TOOLING" else None,
|
||
current_row.get("parallelCapacity"),
|
||
current_row.get("capacity"),
|
||
current_row.get("availableCapacity"),
|
||
)
|
||
if capacity is not None:
|
||
current["cumulativeCapacity"] = capacity
|
||
daily_capacity = positive_number(
|
||
current_row.get("dailyCapacityMinutes"),
|
||
current_row.get("capacityMinutesPerDay"),
|
||
current_row.get("capacityPerDayMin"),
|
||
current_row.get("capacityPerDay"),
|
||
)
|
||
if "dailyCapacityMinutes" in current and daily_capacity is not None:
|
||
current["dailyCapacityMinutes"] = daily_capacity
|
||
if "lifeTotal" in current:
|
||
current["lifeTotal"] = positive_number(current_row.get("lifeTotal"), current_row.get("ratedLife"))
|
||
if "lifeUsed" in current:
|
||
try:
|
||
current["lifeUsed"] = max(0.0, float(current_row.get("lifeUsed") or 0.0))
|
||
except (TypeError, ValueError):
|
||
current["lifeUsed"] = current_row.get("lifeUsed")
|
||
|
||
if current_row.get("parentResourceId") not in (None, ""):
|
||
current["parentResourceId"] = current_row.get("parentResourceId")
|
||
else:
|
||
parent_specs = {
|
||
"WORKSHOP": ("FACTORY", ("factoryId", "factoryCode")),
|
||
"LINE": ("WORKSHOP", ("workshopId", "workshopCode")),
|
||
"WORKSTATION": ("LINE", ("lineId", "lineCode")),
|
||
}
|
||
parent_kind, parent_fields = parent_specs.get(kind, (None, ()))
|
||
for parent_field in parent_fields:
|
||
parent_value = current_row.get(parent_field)
|
||
if parent_value not in (None, "") and parent_kind is not None:
|
||
current["parentResourceId"] = resource_lookup.get((parent_kind, str(parent_value)))
|
||
break
|
||
|
||
explicit_calendar = current_row.get("calendarIntervals")
|
||
if isinstance(explicit_calendar, list):
|
||
current["calendarIntervals"] = deepcopy(explicit_calendar)
|
||
elif kind in known_kinds:
|
||
current["calendarIntervals"] = deepcopy(global_calendar)
|
||
|
||
explicit_maintenance = current_row.get("maintenanceIntervals")
|
||
if isinstance(explicit_maintenance, list):
|
||
current["maintenanceIntervals"] = deepcopy(explicit_maintenance)
|
||
elif kind in known_kinds:
|
||
current["maintenanceIntervals"] = [
|
||
interval.model_dump(mode="json")
|
||
for interval in _maintenance_intervals(world, current_row)
|
||
]
|
||
|
||
observed_current = deepcopy(current)
|
||
observed_resources.append({
|
||
"resource": _resource_constraint_fields(observed_current, available=available),
|
||
"sourceRow": _canonical_evidence_payload(current_row),
|
||
})
|
||
|
||
# PoolEngine reserves tooling life immediately in flexMolds. Normalize
|
||
# that version-owned reservation back to the scheduling baseline before
|
||
# V2 revalidation, while keeping the observed value in evidence.
|
||
if kind == "TOOLING" and "lifeUsed" in current:
|
||
code = str(current.get("code") or current.get("resourceId") or "").split(":", 1)[-1]
|
||
reserved_quantity = sum(
|
||
float(item.get("quantity") or 0.0)
|
||
for item in work_orders or []
|
||
if str(item.get("moldCode") or item.get("toolingCode") or "") == code
|
||
)
|
||
try:
|
||
planned_life = float(planned.get("lifeUsed") or 0.0)
|
||
observed_life = float(current.get("lifeUsed") or 0.0)
|
||
if abs(observed_life - (planned_life + reserved_quantity)) <= 1e-9:
|
||
current["lifeUsed"] = planned_life
|
||
except (TypeError, ValueError):
|
||
pass
|
||
|
||
if available:
|
||
effective_resources.append(current)
|
||
else:
|
||
current["calendarIntervals"] = []
|
||
current_constraints.append(_resource_constraint_fields(current, available=available))
|
||
|
||
def resource_key(item: dict[str, Any]) -> str:
|
||
return str(item.get("resourceId") or "")
|
||
|
||
return (
|
||
effective_resources,
|
||
sorted(planned_constraints, key=resource_key),
|
||
sorted(current_constraints, key=resource_key),
|
||
sorted(
|
||
observed_resources,
|
||
key=lambda item: str((item.get("resource") or {}).get("resourceId") or ""),
|
||
),
|
||
)
|
||
|
||
|
||
def _validate_v2_dispatch_evidence(
|
||
world: World,
|
||
version: dict[str, Any],
|
||
work_orders: list[dict],
|
||
) -> dict[str, Any]:
|
||
"""Fail-closed V2 identity/resource validation without mutating the world."""
|
||
|
||
markers = (
|
||
"planningProblemId",
|
||
"planningSourceHash",
|
||
"solveStatus",
|
||
"schedulingSolutionV2",
|
||
"validationReport",
|
||
)
|
||
has_v2_marker = any(version.get(key) not in (None, "", [], {}) for key in markers)
|
||
if not has_v2_marker and _is_explicit_demo_world(world):
|
||
return {
|
||
"strict": False,
|
||
"reasons": [],
|
||
"evidence": {
|
||
"mode": "LEGACY_DEMO_COMPAT",
|
||
"planningProblemId": None,
|
||
"planningSourceHash": None,
|
||
"solveStatus": None,
|
||
},
|
||
}
|
||
|
||
reasons: list[dict[str, Any]] = []
|
||
|
||
def add(code: str, message: str, *, entity_type: str = "VERSION", entity_id: Any = None) -> None:
|
||
reasons.append({
|
||
"code": code,
|
||
"message": message,
|
||
"entityType": entity_type,
|
||
"entityId": version.get("id") if entity_id is None else entity_id,
|
||
"structural": True,
|
||
})
|
||
|
||
if not has_v2_marker:
|
||
add("V2_EVIDENCE_REQUIRED", "非演示柔性版本缺少 SchedulingProblemV2/SchedulingSolutionV2 证据")
|
||
|
||
missing_version_fields = [key for key in markers if version.get(key) in (None, "", [], {})]
|
||
if missing_version_fields:
|
||
add(
|
||
"INCOMPLETE_V2_EVIDENCE",
|
||
f"V2 版本证据缺少字段:{', '.join(missing_version_fields)}",
|
||
)
|
||
|
||
planning_problem_id = str(version.get("planningProblemId") or "")
|
||
planning_source_hash = str(version.get("planningSourceHash") or "")
|
||
solve_status = str(version.get("solveStatus") or "").upper()
|
||
solution_payload = version.get("schedulingSolutionV2")
|
||
validation_payload = version.get("validationReport")
|
||
evidence: dict[str, Any] = {
|
||
"mode": "V2_FAIL_CLOSED",
|
||
"planningProblemId": planning_problem_id or None,
|
||
"planningSourceHash": planning_source_hash or None,
|
||
"solveStatus": solve_status or None,
|
||
"solutionDigest": _stable_evidence_digest(solution_payload),
|
||
"solutionActivityDigest": _stable_evidence_digest(
|
||
solution_payload.get("activities") if isinstance(solution_payload, dict) else None
|
||
),
|
||
"validationDigest": _stable_evidence_digest(validation_payload),
|
||
}
|
||
|
||
if solve_status not in {"FEASIBLE", "OPTIMAL"}:
|
||
add("V2_SOLUTION_NOT_FEASIBLE", f"V2 求解状态为 {solve_status or 'UNKNOWN'},不可发布/下发")
|
||
if not isinstance(validation_payload, dict) or validation_payload.get("valid") is not True:
|
||
add("V2_VALIDATION_NOT_VALID", "V2 validationReport 未证明当前解有效")
|
||
elif validation_payload.get("hardViolations"):
|
||
add("V2_VALIDATION_HAS_HARD_VIOLATIONS", "V2 validationReport 仍含硬约束违反")
|
||
|
||
closed_loop_row = next(
|
||
(
|
||
row for row in world.get("closedLoopProblems", []) or []
|
||
if isinstance(row, dict)
|
||
and str(row.get("problemId") or "") == planning_problem_id
|
||
),
|
||
None,
|
||
)
|
||
problem_payload = (
|
||
closed_loop_row.get("schedulingProblemV2")
|
||
if isinstance(closed_loop_row, dict)
|
||
else None
|
||
)
|
||
if not isinstance(problem_payload, dict):
|
||
add("V2_PROBLEM_NOT_FOUND", "当前世界找不到与版本绑定的 SchedulingProblemV2")
|
||
evidence["problemDigest"] = _stable_evidence_digest(problem_payload)
|
||
evidence["workOrderIdentityDigest"] = _stable_evidence_digest([
|
||
{
|
||
key: item.get(key)
|
||
for key in ("id", "activityId", "activityIdentity", "closedLoopRequirementId")
|
||
}
|
||
for item in work_orders
|
||
])
|
||
return {"strict": True, "reasons": reasons, "evidence": evidence}
|
||
|
||
assert isinstance(closed_loop_row, dict)
|
||
evidence["problemDigest"] = _stable_evidence_digest(problem_payload)
|
||
if str(closed_loop_row.get("sourceHash") or "") != planning_source_hash:
|
||
add("V2_PROBLEM_SOURCE_DRIFT", "闭环问题 sourceHash 与排产版本 planningSourceHash 不一致")
|
||
if str(problem_payload.get("problemId") or "") != planning_problem_id:
|
||
add("V2_PROBLEM_ID_DRIFT", "SchedulingProblemV2.problemId 与排产版本不一致")
|
||
if str(problem_payload.get("sourceRevision") or "") != planning_source_hash:
|
||
add("V2_PROBLEM_REVISION_DRIFT", "SchedulingProblemV2.sourceRevision 与排产版本不一致")
|
||
|
||
try:
|
||
from server.aps_domain.scheduling_problem_v2 import SchedulingProblemV2, SchedulingSolutionV2
|
||
from server.aps_domain.scheduling_validator import validate_solution
|
||
|
||
solution = SchedulingSolutionV2.model_validate(solution_payload)
|
||
if solution.problemId != planning_problem_id:
|
||
add("V2_SOLUTION_PROBLEM_DRIFT", "SchedulingSolutionV2.problemId 与排产版本不一致")
|
||
if solution.provenance.sourceRevision != planning_source_hash:
|
||
add("V2_SOLUTION_SOURCE_DRIFT", "SchedulingSolutionV2.sourceRevision 与排产版本不一致")
|
||
if solution.solveStatus.value != solve_status:
|
||
add("V2_SOLUTION_STATUS_DRIFT", "SchedulingSolutionV2.solveStatus 与排产版本不一致")
|
||
|
||
(
|
||
effective_resources,
|
||
planned_constraints,
|
||
current_constraints,
|
||
observed_resources,
|
||
) = _current_v2_resource_view(
|
||
world,
|
||
problem_payload,
|
||
work_orders,
|
||
)
|
||
current_problem_payload = deepcopy(problem_payload)
|
||
current_problem_payload["resources"] = effective_resources
|
||
current_problem = SchedulingProblemV2.model_validate(current_problem_payload)
|
||
current_report = validate_solution(current_problem, solution)
|
||
evidence.update({
|
||
"plannedResourceConstraintDigest": _stable_evidence_digest(planned_constraints),
|
||
"currentResourceConstraintDigest": _stable_evidence_digest(current_constraints),
|
||
"currentResourceSnapshotDigest": _stable_evidence_digest(observed_resources),
|
||
"effectiveResourceDigest": _stable_evidence_digest(effective_resources),
|
||
"currentResourceKinds": sorted({
|
||
str(item.get("kind") or "") for item in current_constraints if item.get("kind")
|
||
}),
|
||
"currentResourceCount": len(current_constraints),
|
||
"currentValidationDigest": _stable_evidence_digest(current_report.model_dump(mode="json")),
|
||
"currentOperationIdentityHash": current_report.operationIdentityHash,
|
||
})
|
||
if planned_constraints != current_constraints:
|
||
add("CURRENT_RESOURCE_CONSTRAINT_DRIFT", "当前资源能力/日历/维保/容量已偏离排产时快照")
|
||
for violation in current_report.hardViolations:
|
||
add(
|
||
f"CURRENT_{violation.code}",
|
||
violation.message,
|
||
entity_type="V2_VALIDATION",
|
||
entity_id="|".join(violation.entityRefs) or planning_problem_id,
|
||
)
|
||
if isinstance(validation_payload, dict):
|
||
if validation_payload.get("problemId") != planning_problem_id:
|
||
add("V2_VALIDATION_PROBLEM_DRIFT", "validationReport.problemId 与排产版本不一致")
|
||
if validation_payload.get("operationIdentityHash") != current_report.operationIdentityHash:
|
||
add("V2_VALIDATION_IDENTITY_DRIFT", "validationReport 操作身份摘要与当前复核不一致")
|
||
except Exception as exc:
|
||
add("INVALID_V2_EVIDENCE", f"V2 证据无法解析或复核:{type(exc).__name__}: {exc}")
|
||
solution = None
|
||
current_problem = None
|
||
|
||
work_order_identity_rows = sorted(
|
||
[
|
||
{
|
||
key: item.get(key)
|
||
for key in (
|
||
"id", "versionId", "activityId", "activityIdentity", "closedLoopRequirementId",
|
||
"operationCode", "seq", "equipmentId", "equipmentCode", "moldCode", "teamCode",
|
||
"plannedStartTime", "plannedEndTime",
|
||
)
|
||
}
|
||
for item in work_orders
|
||
],
|
||
key=lambda item: (item.get("id") is None, item.get("id")),
|
||
)
|
||
evidence["workOrderIdentityDigest"] = _stable_evidence_digest(work_order_identity_rows)
|
||
evidence["workOrderSnapshotDigest"] = _stable_evidence_digest(work_orders)
|
||
|
||
if solution is not None and current_problem is not None:
|
||
solution_by_activity = {item.activityId: item for item in solution.activities}
|
||
problem_by_activity = {item.activityId: item for item in current_problem.activities}
|
||
work_order_activity_ids: list[str] = []
|
||
for work_order in work_orders:
|
||
work_order_id = work_order.get("id")
|
||
missing = [
|
||
field
|
||
for field in ("activityId", "activityIdentity", "closedLoopRequirementId")
|
||
if work_order.get(field) in (None, "")
|
||
]
|
||
if missing:
|
||
add(
|
||
"INCOMPLETE_V2_WORK_ORDER_IDENTITY",
|
||
f"工单 {work_order_id} 缺少 V2 身份字段:{', '.join(missing)}",
|
||
entity_type="WORK_ORDER",
|
||
entity_id=work_order_id,
|
||
)
|
||
continue
|
||
activity_id = str(work_order.get("activityId"))
|
||
work_order_activity_ids.append(activity_id)
|
||
scheduled = solution_by_activity.get(activity_id)
|
||
definition = problem_by_activity.get(activity_id)
|
||
if scheduled is None:
|
||
add(
|
||
"WORK_ORDER_ACTIVITY_NOT_IN_SOLUTION",
|
||
f"工单 {work_order_id} 的 activityId 不在 SchedulingSolutionV2 中",
|
||
entity_type="WORK_ORDER",
|
||
entity_id=work_order_id,
|
||
)
|
||
continue
|
||
if definition is None:
|
||
add(
|
||
"WORK_ORDER_ACTIVITY_NOT_IN_PROBLEM",
|
||
f"工单 {work_order_id} 的 activityId 不在 SchedulingProblemV2 中",
|
||
entity_type="WORK_ORDER",
|
||
entity_id=work_order_id,
|
||
)
|
||
continue
|
||
if str(work_order.get("activityIdentity")).lower() != scheduled.activityIdentity.lower():
|
||
add(
|
||
"WORK_ORDER_ACTIVITY_IDENTITY_DRIFT",
|
||
f"工单 {work_order_id} 的 activityIdentity 与 V2 解不一致",
|
||
entity_type="WORK_ORDER",
|
||
entity_id=work_order_id,
|
||
)
|
||
if str(work_order.get("closedLoopRequirementId")) != scheduled.requirementId:
|
||
add(
|
||
"WORK_ORDER_REQUIREMENT_ID_DRIFT",
|
||
f"工单 {work_order_id} 的 closedLoopRequirementId 与 V2 解不一致",
|
||
entity_type="WORK_ORDER",
|
||
entity_id=work_order_id,
|
||
)
|
||
if str(work_order.get("operationCode") or "") != definition.operationCode or int(work_order.get("seq") or 0) != definition.sequence:
|
||
add(
|
||
"WORK_ORDER_OPERATION_DRIFT",
|
||
f"工单 {work_order_id} 的工序身份与 V2 问题不一致",
|
||
entity_type="WORK_ORDER",
|
||
entity_id=work_order_id,
|
||
)
|
||
resource_by_id = {item.resourceId: item for item in current_problem.resources}
|
||
allocation_refs = [
|
||
(scheduled.resourceId, "EQUIPMENT"),
|
||
*(
|
||
(allocation.resourceId, allocation.kind.value)
|
||
for allocation in scheduled.resourceAllocations
|
||
if allocation.resourceId != scheduled.resourceId
|
||
),
|
||
]
|
||
resource_markers = (
|
||
"resource", "equipment", "team", "mold", "tool",
|
||
"workstation", "line", "workshop", "factory",
|
||
)
|
||
work_order_resource_tokens = {
|
||
str(value)
|
||
for key, value in work_order.items()
|
||
if value not in (None, "")
|
||
and any(marker in str(key).lower() for marker in resource_markers)
|
||
}
|
||
for allocated_resource_id, allocated_kind in allocation_refs:
|
||
resource = resource_by_id.get(allocated_resource_id)
|
||
resource_tokens = {
|
||
allocated_resource_id,
|
||
allocated_resource_id.split(":", 1)[-1],
|
||
str(resource.code if resource is not None else ""),
|
||
} - {""}
|
||
if not resource_tokens & work_order_resource_tokens:
|
||
add(
|
||
"WORK_ORDER_RESOURCE_ALLOCATION_DRIFT",
|
||
f"?? {work_order_id} ??? V2 {allocated_kind} ?? {allocated_resource_id}",
|
||
entity_type="WORK_ORDER",
|
||
entity_id=f"{work_order_id}:{allocated_kind}",
|
||
)
|
||
if _minute_key(work_order.get("plannedStartTime")) != _minute_key(scheduled.start):
|
||
add(
|
||
"WORK_ORDER_START_DRIFT",
|
||
f"工单 {work_order_id} 的开始时间与 V2 解不一致",
|
||
entity_type="WORK_ORDER",
|
||
entity_id=work_order_id,
|
||
)
|
||
if _minute_key(work_order.get("plannedEndTime")) != _minute_key(scheduled.end):
|
||
add(
|
||
"WORK_ORDER_END_DRIFT",
|
||
f"工单 {work_order_id} 的结束时间与 V2 解不一致",
|
||
entity_type="WORK_ORDER",
|
||
entity_id=work_order_id,
|
||
)
|
||
if len(work_order_activity_ids) != len(set(work_order_activity_ids)):
|
||
add("DUPLICATE_WORK_ORDER_ACTIVITY_ID", "多个工单复用了同一 V2 activityId")
|
||
if set(work_order_activity_ids) != set(solution_by_activity):
|
||
add("V2_ACTIVITY_COVERAGE_MISMATCH", "工单 activityId 集合未完整覆盖 SchedulingSolutionV2")
|
||
|
||
return {"strict": True, "reasons": reasons, "evidence": evidence}
|
||
|
||
|
||
def validate_dispatchable_version(world: World, track: str, version_id: int | None) -> dict:
|
||
"""只读校验排产版本是否具备 P2 发布与 P3 MES 下发资格。
|
||
|
||
`publishBlockingReasons` 可由柔性 P2 发布直接复用;DRAFT 本身不会进入该列表。
|
||
`blockingReasons` 是 P3 下发的完整 fail-closed 原因集合。
|
||
"""
|
||
tr = (track or "flex").lower()
|
||
dispatch_reasons: list[dict[str, Any]] = []
|
||
publish_reasons: list[dict[str, Any]] = []
|
||
structural_codes: set[str] = set()
|
||
|
||
def add_reason(
|
||
code: str,
|
||
message: str,
|
||
*,
|
||
entity_type: str | None = None,
|
||
entity_id: Any = None,
|
||
publish: bool = True,
|
||
structural: bool = False,
|
||
) -> None:
|
||
reason: dict[str, Any] = {"code": code, "message": message}
|
||
if entity_type:
|
||
reason["entityType"] = entity_type
|
||
if entity_id is not None:
|
||
reason["entityId"] = entity_id
|
||
dispatch_reasons.append(reason)
|
||
if publish:
|
||
publish_reasons.append(reason)
|
||
if structural:
|
||
structural_codes.add(code)
|
||
|
||
if tr not in {"flex", "fixed"}:
|
||
add_reason("INVALID_TRACK", f"未知排产轨道:{tr}", publish=True, structural=True)
|
||
return {
|
||
"track": tr,
|
||
"exists": False,
|
||
"versionId": version_id,
|
||
"versionNo": None,
|
||
"versionStatus": None,
|
||
"structuralValid": False,
|
||
"publishReady": False,
|
||
"dispatchReady": False,
|
||
"blockingReasons": dispatch_reasons,
|
||
"publishBlockingReasons": publish_reasons,
|
||
"eligibleWorkOrderIds": [],
|
||
"evidenceRef": None,
|
||
}
|
||
|
||
version_key = "flexScheduleVersions" if tr == "flex" else "scheduleVersions"
|
||
versions = world.get(version_key, []) or []
|
||
version = (
|
||
next((item for item in versions if item.get("id") == version_id), None)
|
||
if version_id is not None
|
||
else (versions[-1] if versions else None)
|
||
)
|
||
if not version:
|
||
add_reason(
|
||
"VERSION_NOT_FOUND",
|
||
f"{('柔性' if tr == 'flex' else '固定')}排产版本 #{version_id} 不存在",
|
||
publish=True,
|
||
structural=True,
|
||
)
|
||
return {
|
||
"track": tr,
|
||
"exists": False,
|
||
"versionId": version_id,
|
||
"versionNo": None,
|
||
"versionStatus": None,
|
||
"structuralValid": False,
|
||
"publishReady": False,
|
||
"dispatchReady": False,
|
||
"blockingReasons": dispatch_reasons,
|
||
"publishBlockingReasons": publish_reasons,
|
||
"eligibleWorkOrderIds": [],
|
||
"evidenceRef": None,
|
||
}
|
||
|
||
vid = version.get("id")
|
||
status = str(version.get("status") or "").upper()
|
||
evidence_payload: dict[str, Any] = {
|
||
"track": tr,
|
||
"version": {
|
||
key: version.get(key)
|
||
for key in (
|
||
"id", "versionNo", "status", "orderCount", "vlCount", "woCount",
|
||
"conflictCount", "deferredCount", "constraintProfile",
|
||
"planningProblemId", "planningSourceHash", "solveStatus",
|
||
)
|
||
},
|
||
}
|
||
eligible_work_order_ids: list[int] = []
|
||
evidence_summary: dict[str, Any] | None = None
|
||
|
||
if tr == "flex":
|
||
virtual_lines = [
|
||
item for item in world.get("flexVirtualLines", []) if item.get("versionId") == vid
|
||
]
|
||
work_orders = [
|
||
item for item in world.get("flexWorkOrders", []) if item.get("versionId") == vid
|
||
]
|
||
conflicts = [
|
||
item for item in world.get("flexConflicts", []) if item.get("versionId") == vid
|
||
]
|
||
routings = world.get("flexRoutings", []) or []
|
||
v2_gate = _validate_v2_dispatch_evidence(world, version, work_orders)
|
||
evidence_summary = deepcopy(v2_gate["evidence"])
|
||
for v2_reason in v2_gate["reasons"]:
|
||
add_reason(
|
||
str(v2_reason["code"]),
|
||
str(v2_reason["message"]),
|
||
entity_type=v2_reason.get("entityType"),
|
||
entity_id=v2_reason.get("entityId"),
|
||
publish=True,
|
||
structural=bool(v2_reason.get("structural", True)),
|
||
)
|
||
|
||
if status not in {"PUBLISHED", "DISPATCHED"}:
|
||
add_reason(
|
||
"VERSION_NOT_PUBLISHED",
|
||
f"柔性版本 {version.get('versionNo') or vid} 状态为 {status or 'UNKNOWN'},必须先完成 P2 发布",
|
||
publish=False,
|
||
)
|
||
if status != "DRAFT":
|
||
reason = {
|
||
"code": "VERSION_NOT_DRAFT",
|
||
"message": f"版本状态为 {status or 'UNKNOWN'},不是可发布的 DRAFT",
|
||
"entityType": "VERSION",
|
||
"entityId": vid,
|
||
}
|
||
publish_reasons.append(reason)
|
||
|
||
expected_vl_count = version.get("vlCount")
|
||
expected_wo_count = version.get("woCount")
|
||
if expected_vl_count is not None and int(expected_vl_count) != len(virtual_lines):
|
||
add_reason(
|
||
"VL_COUNT_MISMATCH",
|
||
f"版本记录 VL={expected_vl_count},实际为 {len(virtual_lines)}",
|
||
entity_type="VERSION", entity_id=vid, structural=True,
|
||
)
|
||
if expected_wo_count is not None and int(expected_wo_count) != len(work_orders):
|
||
add_reason(
|
||
"WO_COUNT_MISMATCH",
|
||
f"版本记录 WO={expected_wo_count},实际为 {len(work_orders)}",
|
||
entity_type="VERSION", entity_id=vid, structural=True,
|
||
)
|
||
order_count = int(version.get("orderCount") or 0)
|
||
if order_count and order_count != len(virtual_lines):
|
||
add_reason(
|
||
"ORDER_COVERAGE_MISMATCH",
|
||
f"版本包含 {order_count} 个待排对象,但只有 {len(virtual_lines)} 个完整生产对象",
|
||
entity_type="VERSION", entity_id=vid, structural=True,
|
||
)
|
||
if int(version.get("deferredCount") or 0) > 0:
|
||
add_reason(
|
||
"UNSCHEDULED_REQUIREMENTS",
|
||
f"版本仍有 {int(version.get('deferredCount') or 0)} 个延期/未排对象",
|
||
entity_type="VERSION", entity_id=vid, structural=True,
|
||
)
|
||
if not work_orders:
|
||
add_reason(
|
||
"NO_WORK_ORDERS", "版本没有可执行工单",
|
||
entity_type="VERSION", entity_id=vid, structural=True,
|
||
)
|
||
|
||
vl_groups: dict[Any, list[dict]] = {}
|
||
for virtual_line in virtual_lines:
|
||
vl_groups.setdefault(virtual_line.get("id"), []).append(virtual_line)
|
||
for vl_id, rows in vl_groups.items():
|
||
if vl_id is None or len(rows) != 1:
|
||
add_reason(
|
||
"DUPLICATE_OR_MISSING_VL_ID",
|
||
f"虚拟产线 ID {vl_id!r} 不唯一",
|
||
entity_type="VIRTUAL_LINE", entity_id=vl_id, structural=True,
|
||
)
|
||
|
||
wo_groups: dict[Any, list[dict]] = {}
|
||
for work_order in work_orders:
|
||
wo_groups.setdefault(work_order.get("id"), []).append(work_order)
|
||
for wo_id, rows in wo_groups.items():
|
||
if wo_id is None or len(rows) != 1:
|
||
add_reason(
|
||
"DUPLICATE_OR_MISSING_WO_ID",
|
||
f"工单 ID {wo_id!r} 不唯一",
|
||
entity_type="WORK_ORDER", entity_id=wo_id, structural=True,
|
||
)
|
||
|
||
unique_vl_ids = {vl_id for vl_id, rows in vl_groups.items() if vl_id is not None and len(rows) == 1}
|
||
for work_order in work_orders:
|
||
wo_id = work_order.get("id")
|
||
if work_order.get("vlId") not in unique_vl_ids:
|
||
add_reason(
|
||
"ORPHAN_WORK_ORDER",
|
||
f"工单 {wo_id} 没有唯一的虚拟产线归属",
|
||
entity_type="WORK_ORDER", entity_id=wo_id, structural=True,
|
||
)
|
||
required_fields = (
|
||
"operationCode", "equipmentCode", "plannedStartTime", "plannedEndTime", "seq",
|
||
)
|
||
missing = [field for field in required_fields if work_order.get(field) in (None, "")]
|
||
if missing:
|
||
add_reason(
|
||
"INCOMPLETE_WORK_ORDER",
|
||
f"工单 {wo_id} 缺少字段:{', '.join(missing)}",
|
||
entity_type="WORK_ORDER", entity_id=wo_id, structural=True,
|
||
)
|
||
|
||
for vl_id in unique_vl_ids:
|
||
virtual_line = vl_groups[vl_id][0]
|
||
owned = [item for item in work_orders if item.get("vlId") == vl_id]
|
||
if not owned:
|
||
add_reason(
|
||
"ORPHAN_VIRTUAL_LINE",
|
||
f"虚拟产线 {vl_id} 没有工单",
|
||
entity_type="VIRTUAL_LINE", entity_id=vl_id, structural=True,
|
||
)
|
||
continue
|
||
product_code = virtual_line.get("productCode")
|
||
expected = sorted(
|
||
[
|
||
(item.get("seq"), item.get("operationCode"))
|
||
for item in routings if item.get("productCode") == product_code
|
||
],
|
||
key=lambda item: (item[0] is None, item[0], str(item[1])),
|
||
)
|
||
actual = sorted(
|
||
[(item.get("seq"), item.get("operationCode")) for item in owned],
|
||
key=lambda item: (item[0] is None, item[0], str(item[1])),
|
||
)
|
||
assignments = sorted(
|
||
[
|
||
(item.get("seq"), item.get("operationCode"))
|
||
for item in (virtual_line.get("assignments") or [])
|
||
],
|
||
key=lambda item: (item[0] is None, item[0], str(item[1])),
|
||
)
|
||
if not expected:
|
||
add_reason(
|
||
"NO_ROUTING",
|
||
f"虚拟产线 {vl_id} 的产品 {product_code} 没有工艺路线",
|
||
entity_type="VIRTUAL_LINE", entity_id=vl_id, structural=True,
|
||
)
|
||
elif actual != expected or assignments != expected:
|
||
add_reason(
|
||
"INCOMPLETE_ROUTING",
|
||
f"虚拟产线 {vl_id} 的工单/分配与完整工艺路线不一致",
|
||
entity_type="VIRTUAL_LINE", entity_id=vl_id, structural=True,
|
||
)
|
||
|
||
for conflict in conflicts:
|
||
severity = str(conflict.get("severity") or "").upper()
|
||
is_hard = (
|
||
not conflict.get("isResolved")
|
||
and (severity in {"CRITICAL", "HARD", "FATAL"} or conflict.get("hard") is True)
|
||
)
|
||
if is_hard:
|
||
add_reason(
|
||
"HARD_CONFLICT",
|
||
str(conflict.get("description") or conflict.get("conflictType") or "存在未解决硬冲突"),
|
||
entity_type="CONFLICT", entity_id=conflict.get("id"), structural=False,
|
||
)
|
||
for blocker in version.get("hardBlockers") or []:
|
||
add_reason(
|
||
"HARD_BLOCKER", str(blocker), entity_type="VERSION", entity_id=vid,
|
||
)
|
||
validation_report = version.get("validationReport") or {}
|
||
for violation in validation_report.get("hardViolations") or []:
|
||
add_reason(
|
||
"HARD_VALIDATION_VIOLATION", str(violation), entity_type="VERSION", entity_id=vid,
|
||
)
|
||
|
||
eligible_work_order_ids = sorted(
|
||
int(item["id"])
|
||
for item in work_orders
|
||
if item.get("id") is not None and not item.get("frozen")
|
||
)
|
||
evidence_payload.update({
|
||
"virtualLines": sorted(
|
||
[
|
||
{
|
||
key: item.get(key)
|
||
for key in (
|
||
"id", "versionId", "orderNo", "productCode", "quantity",
|
||
"plannedStart", "plannedEnd", "assignments",
|
||
)
|
||
}
|
||
for item in virtual_lines
|
||
],
|
||
key=lambda item: (item.get("id") is None, item.get("id")),
|
||
),
|
||
"workOrders": sorted(
|
||
[
|
||
{
|
||
key: item.get(key)
|
||
for key in (
|
||
"id", "versionId", "vlId", "flexOrderNo", "productCode", "quantity",
|
||
"seq", "operationCode", "equipmentId", "equipmentCode", "moldCode",
|
||
"teamCode", "plannedStartTime", "plannedEndTime", "frozen",
|
||
"activityId", "activityIdentity", "closedLoopRequirementId",
|
||
)
|
||
}
|
||
for item in work_orders
|
||
],
|
||
key=lambda item: (item.get("id") is None, item.get("id")),
|
||
),
|
||
"routings": sorted(
|
||
[
|
||
{
|
||
key: item.get(key)
|
||
for key in ("productCode", "seq", "operationCode", "stdTimePerUnit", "requireMold")
|
||
}
|
||
for item in routings
|
||
if item.get("productCode") in {vl.get("productCode") for vl in virtual_lines}
|
||
],
|
||
key=lambda item: (str(item.get("productCode")), item.get("seq") is None, item.get("seq")),
|
||
),
|
||
"v2Evidence": v2_gate["evidence"],
|
||
"hardConflicts": sorted(
|
||
[
|
||
{
|
||
key: item.get(key)
|
||
for key in ("id", "conflictType", "severity", "isResolved", "description")
|
||
}
|
||
for item in conflicts
|
||
if not item.get("isResolved")
|
||
and (str(item.get("severity") or "").upper() in {"CRITICAL", "HARD", "FATAL"}
|
||
or item.get("hard") is True)
|
||
],
|
||
key=lambda item: (item.get("id") is None, item.get("id")),
|
||
),
|
||
})
|
||
else:
|
||
if status != "PUBLISHED":
|
||
add_reason(
|
||
"VERSION_NOT_PUBLISHED",
|
||
f"固定版本 {version.get('versionNo') or vid} 状态为 {status or 'UNKNOWN'},必须先发布",
|
||
publish=False,
|
||
)
|
||
production_orders = [
|
||
item for item in world.get("productionOrders", [])
|
||
if item.get("schedulingVersionId") == vid
|
||
]
|
||
production_order_ids = {item.get("id") for item in production_orders}
|
||
work_orders = [
|
||
item for item in world.get("workOrders", [])
|
||
if item.get("productionOrderId") in production_order_ids
|
||
]
|
||
if not work_orders:
|
||
add_reason(
|
||
"NO_WORK_ORDERS", "固定版本没有可执行工单",
|
||
entity_type="VERSION", entity_id=vid, structural=True,
|
||
)
|
||
solver_meta = version.get("solverMeta") or {}
|
||
engine_type = str(version.get("engineType") or "").upper()
|
||
solver_status = str(solver_meta.get("status") or "").upper()
|
||
if version.get("publishReady") is False:
|
||
add_reason(
|
||
"VERSION_MARKED_NOT_PUBLISHABLE", "固定版本已被排产引擎标记为不可发布",
|
||
entity_type="VERSION", entity_id=vid, structural=True,
|
||
)
|
||
if engine_type in {"CP", "HYBRID"}:
|
||
if solver_status not in {"OPTIMAL", "FEASIBLE"}:
|
||
add_reason(
|
||
"CP_SOLVE_NOT_MATERIALIZABLE",
|
||
f"固定版本求解状态为 {solver_status or 'UNKNOWN'},不能发布 CP/HYBRID 结果",
|
||
entity_type="VERSION", entity_id=vid, structural=True,
|
||
)
|
||
elif solver_meta.get("directlyConsumedByMaterializer") is not True:
|
||
add_reason(
|
||
"CP_TIMING_NOT_DIRECTLY_MATERIALIZED",
|
||
"固定版本未直接物化经验证的 CP operationSlots",
|
||
entity_type="VERSION", entity_id=vid, structural=True,
|
||
)
|
||
if solver_meta.get("directlyConsumedByMaterializer") is True:
|
||
timing_validation = solver_meta.get("operationTimingValidation") or {}
|
||
c3_meta = solver_meta.get("c3Calendar") or {}
|
||
c3_validation = solver_meta.get("materializedC3Validation") or {}
|
||
c7_validation = solver_meta.get("materializedC7Validation") or {}
|
||
if timing_validation.get("passed") is not True:
|
||
add_reason(
|
||
"CP_TIMING_NOT_VALIDATED", "固定版本的 CP 时间未通过父进程完整校验",
|
||
entity_type="VERSION", entity_id=vid, structural=True,
|
||
)
|
||
if c3_validation.get("cpTimingApplied") is not True or (
|
||
c3_meta.get("active") is True and c3_validation.get("passed") is not True
|
||
):
|
||
add_reason(
|
||
"CP_C3_MATERIALIZATION_INVALID", "固定版本的 CP 分段未通过 C3 精确物化校验",
|
||
entity_type="VERSION", entity_id=vid, structural=True,
|
||
)
|
||
if c7_validation.get("checked") is True and c7_validation.get("passed") is not True:
|
||
add_reason(
|
||
"CP_C7_MATERIALIZATION_INVALID", "固定版本的 CP processing 负荷未通过 C7 校验",
|
||
entity_type="VERSION", entity_id=vid, structural=True,
|
||
)
|
||
for work_order in work_orders:
|
||
segment_error = _fixed_segment_error(work_order)
|
||
if segment_error:
|
||
add_reason(
|
||
"INVALID_PLANNED_SEGMENTS",
|
||
f"固定工单 {work_order.get('id')}:{segment_error}",
|
||
entity_type="WORK_ORDER", entity_id=work_order.get("id"), structural=True,
|
||
)
|
||
from server.aps_domain.constraints import hard_blocking_conflicts
|
||
|
||
for blocker in hard_blocking_conflicts(world, version_id=int(vid), track="fixed"):
|
||
add_reason(
|
||
"HARD_CONFLICT",
|
||
str(blocker.get("description") or blocker.get("conflictType") or "存在未解决硬冲突"),
|
||
entity_type="CONFLICT", entity_id=blocker.get("id"), structural=False,
|
||
)
|
||
eligible_work_order_ids = sorted(
|
||
int(item["id"]) for item in work_orders if item.get("id") is not None
|
||
)
|
||
evidence_payload.update({
|
||
"fixedSolverGate": {
|
||
"engineType": engine_type,
|
||
"publishReady": version.get("publishReady"),
|
||
"dispatchReady": version.get("dispatchReady"),
|
||
"status": solver_status,
|
||
"directlyConsumedByMaterializer": solver_meta.get("directlyConsumedByMaterializer"),
|
||
"operationTimingValidation": deepcopy(solver_meta.get("operationTimingValidation")),
|
||
"c3CalendarActive": (solver_meta.get("c3Calendar") or {}).get("active"),
|
||
"materializedC3Validation": deepcopy(solver_meta.get("materializedC3Validation")),
|
||
"materializedC7Validation": deepcopy(solver_meta.get("materializedC7Validation")),
|
||
},
|
||
"productionOrders": sorted(
|
||
[
|
||
{key: item.get(key) for key in ("id", "productionOrderNo", "schedulingVersionId")}
|
||
for item in production_orders
|
||
],
|
||
key=lambda item: (item.get("id") is None, item.get("id")),
|
||
),
|
||
"workOrders": sorted(
|
||
[
|
||
{
|
||
key: item.get(key)
|
||
for key in (
|
||
"id", "productionOrderId", "productionOrderNo", "operationCode",
|
||
"operationName", "workstationName", "lineName",
|
||
"plannedStartTime", "plannedEndTime",
|
||
"plannedSegments", "processingMinutes", "elapsedSpanMinutes",
|
||
"pauseMinutes", "segmentCount", "cpTimingSource",
|
||
)
|
||
}
|
||
for item in work_orders
|
||
],
|
||
key=lambda item: (item.get("id") is None, item.get("id")),
|
||
),
|
||
})
|
||
|
||
evidence_digest = _stable_evidence_digest(evidence_payload)
|
||
evidence_ref = f"schedule-evidence:{evidence_digest}"
|
||
return {
|
||
"track": tr,
|
||
"exists": True,
|
||
"versionId": vid,
|
||
"versionNo": version.get("versionNo"),
|
||
"versionStatus": status,
|
||
"structuralValid": not structural_codes,
|
||
"publishReady": not publish_reasons,
|
||
"dispatchReady": not dispatch_reasons,
|
||
"blockingReasons": dispatch_reasons,
|
||
"publishBlockingReasons": publish_reasons,
|
||
"eligibleWorkOrderIds": eligible_work_order_ids,
|
||
"evidenceRef": evidence_ref,
|
||
"evidenceSummary": evidence_summary,
|
||
}
|
||
|
||
def preview_dispatch(
|
||
world: World,
|
||
track: str = "flex",
|
||
version_id: int | None = None,
|
||
*,
|
||
limit: int | None = 80,
|
||
) -> dict:
|
||
"""只读预览可下发工单;所有轨道统一经过版本完整性门禁。"""
|
||
client = _get_active_client()
|
||
tr = (track or "flex").lower()
|
||
selected_version_id = version_id
|
||
if selected_version_id is None:
|
||
selected = _latest_flex(world) if tr == "flex" else _latest_fixed(world, published_only=True)
|
||
selected_version_id = selected.get("id") if selected else None
|
||
|
||
validation = validate_dispatchable_version(world, tr, selected_version_id)
|
||
base = {
|
||
"track": tr,
|
||
"connection": client.status(),
|
||
"versionId": validation.get("versionId"),
|
||
"versionNo": validation.get("versionNo"),
|
||
"versionStatus": validation.get("versionStatus"),
|
||
"dispatchReady": validation.get("dispatchReady", False),
|
||
"publishReady": validation.get("publishReady", False),
|
||
"structuralValid": validation.get("structuralValid", False),
|
||
"blockingReasons": validation.get("blockingReasons") or [],
|
||
"publishBlockingReasons": validation.get("publishBlockingReasons") or [],
|
||
"evidenceRef": validation.get("evidenceRef"),
|
||
}
|
||
if not validation.get("dispatchReady"):
|
||
reasons = validation.get("blockingReasons") or []
|
||
detail = ";".join(str(reason.get("message") or reason.get("code")) for reason in reasons[:5])
|
||
return {
|
||
**base,
|
||
"items": [],
|
||
"newCount": 0,
|
||
"totalCount": 0,
|
||
"displayedCount": 0,
|
||
"truncated": False,
|
||
"summary": f"不可下发:{detail or '排产版本未通过完整性校验'}",
|
||
}
|
||
|
||
vid = validation["versionId"]
|
||
eligible_ids = set(validation.get("eligibleWorkOrderIds") or [])
|
||
if tr == "flex":
|
||
work_orders = [
|
||
work_order for work_order in world.get("flexWorkOrders", [])
|
||
if work_order.get("versionId") == vid and work_order.get("id") in eligible_ids
|
||
]
|
||
else:
|
||
work_orders = [
|
||
work_order for work_order in _fixed_work_orders_for_version(world, vid)
|
||
if work_order.get("id") in eligible_ids
|
||
]
|
||
|
||
linked = _linked_keys(world)
|
||
all_items: list[dict[str, Any]] = []
|
||
for work_order in work_orders:
|
||
idem = f"{tr}:{vid}:{work_order['id']}"
|
||
all_items.append({
|
||
"woId": work_order["id"],
|
||
"orderNo": (
|
||
work_order.get("flexOrderNo")
|
||
if tr == "flex"
|
||
else work_order.get("productionOrderNo") or work_order.get("orderNo")
|
||
),
|
||
"operation": work_order.get("operationCode") or work_order.get("operationName"),
|
||
"equipment": (
|
||
work_order.get("equipmentCode")
|
||
if tr == "flex"
|
||
else work_order.get("workstationName") or work_order.get("lineName")
|
||
),
|
||
"start": work_order.get("plannedStartTime"),
|
||
"end": work_order.get("plannedEndTime"),
|
||
"segments": deepcopy(work_order.get("plannedSegments") or []),
|
||
"processingMinutes": work_order.get("processingMinutes"),
|
||
"elapsedSpanMinutes": work_order.get("elapsedSpanMinutes"),
|
||
"pauseMinutes": work_order.get("pauseMinutes"),
|
||
"idemKey": idem,
|
||
"already": idem in linked,
|
||
"mesExternalId": work_order.get("mesExternalId"),
|
||
})
|
||
all_items.sort(key=lambda item: int(item["woId"]))
|
||
new_count = sum(1 for item in all_items if not item["already"])
|
||
items = all_items if limit is None else all_items[:max(0, limit)]
|
||
track_name = "柔性" if tr == "flex" else "固定"
|
||
return {
|
||
**base,
|
||
"items": items,
|
||
"newCount": new_count,
|
||
"totalCount": len(all_items),
|
||
"displayedCount": len(items),
|
||
"truncated": len(items) < len(all_items),
|
||
"summary": (
|
||
f"{track_name}版本 {validation.get('versionNo')} 可新下发 {new_count} / "
|
||
f"共 {len(all_items)} 条工序"
|
||
),
|
||
}
|
||
|
||
|
||
def apply_dispatch(
|
||
store,
|
||
track: str = "flex",
|
||
actor: str = "web",
|
||
*,
|
||
confirm_id: str,
|
||
execution_grant: str,
|
||
version_id: int,
|
||
before_snapshot: str,
|
||
evidence_refs: list[str],
|
||
checkpoint_store,
|
||
) -> dict:
|
||
"""执行 P3 MES 下发;版本、结构与证据任一漂移都 fail-closed。"""
|
||
if not confirm_id or not execution_grant or not before_snapshot or not evidence_refs:
|
||
raise PermissionError("MES P3 下发缺少确认令牌、执行凭据、前置快照或证据引用")
|
||
expected_version_ref = f"schedule-version:{version_id}"
|
||
if expected_version_ref not in evidence_refs:
|
||
raise PermissionError("MES P3 下发证据与审批版本不一致")
|
||
if checkpoint_store is None:
|
||
raise PermissionError("MES P3 下发的前置快照不存在")
|
||
checkpoint = checkpoint_store.get(before_snapshot)
|
||
if checkpoint is None or not isinstance(checkpoint.get("world"), dict):
|
||
raise PermissionError("MES P3 下发的前置快照不存在")
|
||
|
||
from server.agent_core.audit import write_audit
|
||
from server.agent_core import harness
|
||
|
||
tr = (track or "flex").lower()
|
||
world = store.data
|
||
validation = validate_dispatchable_version(world, tr, version_id)
|
||
if validation.get("versionId") != version_id or not validation.get("dispatchReady"):
|
||
reasons = validation.get("blockingReasons") or []
|
||
detail = ";".join(str(reason.get("message") or reason.get("code")) for reason in reasons[:5])
|
||
raise PermissionError(f"MES P3 下发版本未发布或不可执行:{detail}")
|
||
|
||
current_evidence_ref = validation.get("evidenceRef")
|
||
supplied_snapshot_refs = [ref for ref in evidence_refs if str(ref).startswith("schedule-evidence:")]
|
||
if supplied_snapshot_refs and current_evidence_ref not in supplied_snapshot_refs:
|
||
raise PermissionError("MES P3 下发版本/证据已漂移")
|
||
|
||
checkpoint_validation = validate_dispatchable_version(checkpoint["world"], tr, version_id)
|
||
if (
|
||
not checkpoint_validation.get("exists")
|
||
or checkpoint_validation.get("evidenceRef") != current_evidence_ref
|
||
):
|
||
raise PermissionError("MES P3 下发版本/证据已漂移")
|
||
|
||
if not harness.consume_execution_grant(
|
||
execution_grant,
|
||
confirm_id=confirm_id,
|
||
action="mes.dispatch",
|
||
params={
|
||
"track": tr,
|
||
"versionId": version_id,
|
||
"evidenceRefs": list(evidence_refs),
|
||
},
|
||
):
|
||
raise PermissionError("MES P3 下发缺少有效的最终批准凭据")
|
||
|
||
preview = preview_dispatch(world, tr, version_id=version_id, limit=None)
|
||
if not preview.get("dispatchReady") or preview.get("versionId") != version_id:
|
||
raise PermissionError("MES P3 下发版本未发布或不可执行")
|
||
|
||
client = _get_active_client()
|
||
created: list[str] = []
|
||
duplicated: list[str] = []
|
||
if tr == "flex":
|
||
work_orders_by_id = {item["id"]: item for item in world.get("flexWorkOrders", [])}
|
||
else:
|
||
work_orders_by_id = {item["id"]: item for item in world.get("workOrders", [])}
|
||
linked_keys = _linked_keys(world)
|
||
|
||
for item in preview.get("items") or []:
|
||
if item.get("already"):
|
||
duplicated.append(item["idemKey"])
|
||
continue
|
||
payload = {
|
||
"track": tr,
|
||
"apsWoId": item["woId"],
|
||
"orderNo": item.get("orderNo"),
|
||
"operation": item.get("operation"),
|
||
"equipment": item.get("equipment"),
|
||
"start": item.get("start"),
|
||
"end": item.get("end"),
|
||
"segments": deepcopy(item.get("segments") or []),
|
||
"processingMinutes": item.get("processingMinutes"),
|
||
"elapsedSpanMinutes": item.get("elapsedSpanMinutes"),
|
||
"pauseMinutes": item.get("pauseMinutes"),
|
||
"versionId": preview.get("versionId"),
|
||
"versionNo": preview.get("versionNo"),
|
||
}
|
||
result = client.create_work_order(payload, item["idemKey"])
|
||
external_work_order = result["externalWo"]
|
||
if result["duplicate"]:
|
||
duplicated.append(item["idemKey"])
|
||
else:
|
||
created.append(external_work_order["id"])
|
||
work_order = work_orders_by_id.get(item["woId"])
|
||
if work_order:
|
||
work_order["mesExternalId"] = external_work_order["id"]
|
||
work_order["mesIdemKey"] = item["idemKey"]
|
||
if work_order.get("status") in (None, "PENDING", "DRAFT"):
|
||
work_order["status"] = "RELEASED"
|
||
work_order.setdefault("progressPct", 0)
|
||
work_order.setdefault("qtyDone", 0)
|
||
if item["idemKey"] not in linked_keys:
|
||
world.setdefault("mesLinks", []).append({
|
||
"kind": "dispatch",
|
||
"idemKey": item["idemKey"],
|
||
"externalWoId": external_work_order["id"],
|
||
"woId": item["woId"],
|
||
"track": tr,
|
||
"versionId": preview.get("versionId"),
|
||
"syncedAt": fmt_date(today0()),
|
||
"actor": actor,
|
||
})
|
||
linked_keys.add(item["idemKey"])
|
||
|
||
remaining = preview_dispatch(world, tr, version_id=version_id, limit=None)
|
||
fully_dispatched = remaining.get("dispatchReady") and remaining.get("newCount", 0) == 0
|
||
|
||
if fully_dispatched and tr == "flex":
|
||
for version in world.get("flexScheduleVersions", []):
|
||
if version.get("id") == version_id:
|
||
version["status"] = "DISPATCHED"
|
||
version["dispatchedAt"] = fmt_date(today0())
|
||
break
|
||
elif fully_dispatched and tr == "fixed":
|
||
for version in world.get("scheduleVersions", []):
|
||
if version.get("id") == version_id:
|
||
version["dispatchedAt"] = fmt_date(today0())
|
||
version["mesDispatched"] = True
|
||
break
|
||
|
||
journal = {
|
||
"id": store.next_id("mesJournal"),
|
||
"direction": "dispatch",
|
||
"track": tr,
|
||
"at": fmt_date(today0()),
|
||
"actor": actor,
|
||
"created": len(created),
|
||
"duplicates": len(duplicated),
|
||
"remaining": remaining.get("newCount", 0),
|
||
"versionNo": preview.get("versionNo"),
|
||
"summary": f"下发 {len(created)} 新 / {len(duplicated)} 跳过",
|
||
}
|
||
world.setdefault("mesSyncJournal", []).append(journal)
|
||
write_audit(
|
||
world,
|
||
store.next_id,
|
||
actor=actor,
|
||
category="INTEGRATION",
|
||
action="mes.dispatch",
|
||
target={"type": "MES", "id": tr},
|
||
power="P3",
|
||
rationale={
|
||
"created": len(created),
|
||
"duped": len(duplicated),
|
||
"confirmId": confirm_id,
|
||
"validationEvidence": current_evidence_ref,
|
||
},
|
||
before_snapshot=before_snapshot,
|
||
evidence_refs=evidence_refs,
|
||
)
|
||
store.save()
|
||
return {
|
||
"journal": journal,
|
||
"created": created,
|
||
"duplicates": duplicated,
|
||
"fullyDispatched": bool(fully_dispatched),
|
||
"message": (
|
||
f"MES 下发完成 ✅ 新外部工单 {len(created)},"
|
||
f"幂等跳过 {len(duplicated)}({preview.get('versionNo')})。"
|
||
),
|
||
}
|
||
|
||
|
||
def list_execution(world: World, track: str = "flex") -> dict:
|
||
"""已下发工单进度清单(EX-09)。"""
|
||
from server.state.seed import ensure_flex_seed
|
||
tr = (track or "flex").lower()
|
||
if tr == "flex":
|
||
ensure_flex_seed(world)
|
||
ver = _latest_flex(world)
|
||
vid = ver["id"] if ver else None
|
||
wos = [w for w in world.get("flexWorkOrders", [])
|
||
if (not vid or w.get("versionId") == vid) and w.get("mesExternalId")]
|
||
else:
|
||
ver = _latest_fixed(world, published_only=False)
|
||
vid = ver["id"] if ver else None
|
||
wos = [w for w in _fixed_work_orders_for_version(world, vid)
|
||
if w.get("mesExternalId")] if vid else []
|
||
rows = [{
|
||
"woId": w["id"], "orderNo": w.get("flexOrderNo") or w.get("productionOrderNo"),
|
||
"operation": w.get("operationCode") or w.get("operationName"),
|
||
"equipment": w.get("equipmentCode") or w.get("workstationName"),
|
||
"status": w.get("status"), "progressPct": w.get("progressPct", 0),
|
||
"qtyDone": w.get("qtyDone", 0), "mesExternalId": w.get("mesExternalId"),
|
||
"start": w.get("plannedStartTime"), "end": w.get("plannedEndTime"),
|
||
} for w in wos]
|
||
done = sum(1 for r in rows if r["status"] == "COMPLETED" or (r["progressPct"] or 0) >= 100)
|
||
return {
|
||
"track": tr, "versionNo": (ver or {}).get("versionNo"),
|
||
"total": len(rows), "completed": done,
|
||
"rows": rows,
|
||
"connection": _get_active_client().status(),
|
||
}
|
||
|
||
|
||
def apply_report(store, wo_id: int, *, track: str = "flex",
|
||
progress_pct: int | None = None, finish: bool = False,
|
||
actor: str = "web", offline_booking: bool = False) -> dict:
|
||
"""报工回流:更新 APS 工单进度并写 Mock MES。
|
||
|
||
offline_booking=False(默认,在线路径):原语义逐字节不变——先 post_report
|
||
同步推 MES,再本地落账;MES 断连时 post_report 抛错、整体失败(调用方回滚)。
|
||
offline_booking=True(S6 断连补录的显式离线落账模式):跳过 post_report,
|
||
本地落账并在 mesLinks 报工记录上打 syncStatus=PENDING_SYNC 可识别标记,
|
||
恢复后由对账/补推闭环。调用方必须先核验「显式声明 + 断连事实」双成立
|
||
(fallback_lane 出卡闸与执行端双重探测),本函数不自行判断连通性。
|
||
"""
|
||
from server.agent_core import harness
|
||
from server.agent_core.audit import write_audit
|
||
from server.state.seed import ensure_flex_seed
|
||
|
||
def _run():
|
||
tr = (track or "flex").lower()
|
||
if tr == "flex":
|
||
ensure_flex_seed(store.data)
|
||
wos = store.data.get("flexWorkOrders", [])
|
||
else:
|
||
wos = store.data.get("workOrders", [])
|
||
wo = next((w for w in wos if w["id"] == wo_id), None)
|
||
if not wo:
|
||
raise ValueError(f"工单 #{wo_id} 不存在")
|
||
ext_id = wo.get("mesExternalId")
|
||
if not ext_id:
|
||
raise ValueError(f"工单 #{wo_id} 尚未下发 MES,无法报工")
|
||
|
||
pct = 100 if finish else int(progress_pct if progress_pct is not None else 50)
|
||
pct = max(0, min(100, pct))
|
||
status = "COMPLETED" if pct >= 100 else "RUNNING"
|
||
qty = wo.get("qtyDone") or 0
|
||
# 柔性订单数量作参考
|
||
if tr == "flex":
|
||
fo = next((o for o in store.data.get("flexOrders", [])
|
||
if o.get("orderNo") == wo.get("flexOrderNo")), None)
|
||
target_qty = int((fo or {}).get("quantity") or 1)
|
||
else:
|
||
target_qty = 1
|
||
qty_done = target_qty if pct >= 100 else max(qty, int(target_qty * pct / 100))
|
||
|
||
if not offline_booking:
|
||
# 在线路径(原语义逐字节不变):先同步推 MES,断连时此处抛错、整体失败
|
||
client = _get_active_client()
|
||
client.post_report(ext_id, {
|
||
"progressPct": pct, "qtyDone": qty_done, "status": status, "actor": actor,
|
||
})
|
||
wo["progressPct"] = pct
|
||
wo["qtyDone"] = qty_done
|
||
wo["status"] = status
|
||
if status == "COMPLETED":
|
||
wo["actualEndTime"] = wo.get("plannedEndTime")
|
||
|
||
# 同订单工序全完工 → 柔性订单完成
|
||
order_done = False
|
||
if tr == "flex" and status == "COMPLETED":
|
||
order_no = wo.get("flexOrderNo")
|
||
vid = wo.get("versionId")
|
||
sibs = [w for w in store.data.get("flexWorkOrders", [])
|
||
if w.get("flexOrderNo") == order_no and w.get("versionId") == vid
|
||
and not w.get("frozen")]
|
||
if sibs and all((w.get("status") == "COMPLETED" or (w.get("progressPct") or 0) >= 100)
|
||
for w in sibs):
|
||
fo = next((o for o in store.data.get("flexOrders", [])
|
||
if o.get("orderNo") == order_no), None)
|
||
if fo:
|
||
fo["status"] = "COMPLETED"
|
||
order_done = True
|
||
|
||
link = {
|
||
"kind": "report", "woId": wo_id, "externalWoId": ext_id,
|
||
"track": tr, "progressPct": pct, "status": status,
|
||
"syncedAt": fmt_date(today0()), "actor": actor,
|
||
}
|
||
rationale = {"pct": pct, "status": status, "orderDone": order_done}
|
||
if offline_booking:
|
||
# 离线落账的可识别状态:恢复后对账/补推以此识别待同步记录
|
||
link["syncStatus"] = "PENDING_SYNC"
|
||
link["pendingReason"] = "MES 断连,本地落账待同步"
|
||
rationale["offlineBooking"] = True
|
||
rationale["syncStatus"] = "PENDING_SYNC"
|
||
store.data.setdefault("mesLinks", []).append(link)
|
||
write_audit(store.data, store.next_id, actor=actor, category="INTEGRATION",
|
||
action="mes.report", target={"type": "WORK_ORDER", "id": wo_id},
|
||
power="P1", rationale=rationale)
|
||
store.save()
|
||
msg = f"报工已回写 ✅ WO#{wo_id} → {pct}%({status})"
|
||
if offline_booking:
|
||
msg += ";MES 断连,本地落账待同步(syncStatus=PENDING_SYNC)"
|
||
if order_done:
|
||
msg += f";订单 {wo.get('flexOrderNo')} 已完工"
|
||
return {"woId": wo_id, "progressPct": pct, "status": status,
|
||
"orderDone": order_done, "message": msg}
|
||
|
||
return harness.guard("mes.report", {"woId": wo_id}, _run)
|
||
|
||
|
||
def confirmation_for_dispatch(world: World, track: str = "flex") -> tuple[str, list[str]]:
|
||
p = preview_dispatch(world, track)
|
||
tr_cn = "柔性" if (track or "flex").lower() == "flex" else "固定"
|
||
return f"MES 下发确认({tr_cn})", [
|
||
p["summary"],
|
||
f"系统 {p['connection'].get('system')} / 工厂 {p['connection'].get('plant')}(Mock)",
|
||
"外部副作用:写入 Mock MES;幂等键防重复工单(P3)",
|
||
]
|
||
|
||
|
||
def stage_dispatch(store, track: str = "flex", *, session_id: str, actor: str = "web") -> dict:
|
||
from server.agent_core import harness
|
||
from server.agent_core.audit import write_audit
|
||
|
||
tr = (track or "flex").lower()
|
||
preview = preview_dispatch(store.data, tr)
|
||
if not preview.get("dispatchReady"):
|
||
return {
|
||
"staged": False,
|
||
"message": preview.get("summary") or "排产版本未通过 MES 下发门禁",
|
||
"block": None,
|
||
"blockingReasons": preview.get("blockingReasons") or [],
|
||
}
|
||
if not preview.get("items"):
|
||
return {"staged": False, "message": preview.get("summary") or "无可下发工单", "block": None}
|
||
if preview.get("newCount", 0) == 0:
|
||
return {"staged": False, "message": "全部工序已下发(幂等),无需重复。", "block": None}
|
||
|
||
title, lines = confirmation_for_dispatch(store.data, tr)
|
||
evidence_refs = [f"schedule-version:{preview.get('versionId')}"]
|
||
if preview.get("evidenceRef"):
|
||
evidence_refs.append(str(preview["evidenceRef"]))
|
||
block = harness.stage_confirmation(
|
||
session_id,
|
||
"mes.dispatch",
|
||
{
|
||
"track": tr,
|
||
"versionId": preview.get("versionId"),
|
||
"evidenceRefs": evidence_refs,
|
||
},
|
||
title=title,
|
||
summary_lines=lines,
|
||
)
|
||
write_audit(
|
||
store.data,
|
||
store.next_id,
|
||
actor=actor,
|
||
category="GATE",
|
||
action="mes.dispatch.stage",
|
||
target={"type": "MES", "id": tr},
|
||
power="P3",
|
||
rationale={"confirmId": block.props["confirmId"]},
|
||
evidence_refs=evidence_refs,
|
||
)
|
||
store.save()
|
||
return {
|
||
"staged": True,
|
||
"message": f"{title} 属于 P3,需要你确认后执行。",
|
||
"block": block,
|
||
"validation": {
|
||
"versionId": preview.get("versionId"),
|
||
"evidenceRef": preview.get("evidenceRef"),
|
||
},
|
||
}
|
||
|
||
|
||
def cancel_dispatch(store, *, external_wo_ids: list[str] | None = None,
|
||
idem_key: str | None = None, actor: str = "saga",
|
||
reason: str = "saga-compensation") -> dict:
|
||
"""MES 下发补偿(幂等):撤销外部工单(Mock MES → CANCELLED)。
|
||
|
||
- 幂等:同一 idem_key 已撤销过 → 直接返回记录(duplicate);
|
||
- 外部工单按 external_wo_ids 或 mesLinks(idem_key)反查;已撤销/不存在不重复撤销;
|
||
- 本地世界回滚(链接/版本状态/回执镜像)由 Saga 的写前快照恢复承担,这里只做外部撤销 + 留痕。
|
||
"""
|
||
world = store.data
|
||
client = _get_active_client()
|
||
world.setdefault("mesCancellations", {})
|
||
if idem_key and idem_key in world["mesCancellations"]:
|
||
return {**world["mesCancellations"][idem_key], "duplicate": True}
|
||
external_ids = list(external_wo_ids or [])
|
||
if not external_ids and idem_key:
|
||
external_ids = [link["externalWoId"] for link in world.get("mesLinks", [])
|
||
if link.get("idemKey") == idem_key and link.get("externalWoId")]
|
||
from server.integrations.mes_http import MesHttpError
|
||
cancelled: list[str] = []
|
||
skipped: list[str] = []
|
||
failed: list[str] = []
|
||
for eid in external_ids:
|
||
try:
|
||
r = client.cancel_work_order(eid, reason=reason)
|
||
(skipped if r.get("duplicate") else cancelled).append(r["externalWo"]["id"])
|
||
except ValueError:
|
||
skipped.append(f"not-found:{eid}")
|
||
except MesHttpError as exc:
|
||
failed.append(f"{exc.code}:{eid}")
|
||
from server.agent_core.audit import write_audit
|
||
write_audit(world, store.next_id, actor=actor, category="INTEGRATION",
|
||
action="mes.cancel_dispatch", target={"type": "MES", "id": "dispatch-compensation"},
|
||
power="P1", rationale={"cancelled": len(cancelled), "skipped": len(skipped), "failed": len(failed),
|
||
"externalWoIds": external_ids, "reason": reason,
|
||
"idemKey": idem_key},
|
||
result="SUCCESS",
|
||
evidence_refs=[f"mes-dispatch-cancel:{idem_key or 'manual'}"])
|
||
result = {
|
||
"cancelled": cancelled, "skipped": skipped, "failed": failed, "reason": reason,
|
||
"message": f"撤销 MES 下发 ✅ 外部工单 {len(cancelled)} 张(已撤销跳过 {len(skipped)},失败 {len(failed)})。",
|
||
}
|
||
if idem_key:
|
||
world["mesCancellations"][idem_key] = result
|
||
store.save()
|
||
return result
|