aps-agent/server/aps_domain/mes.py

1754 lines
76 KiB
Python
Raw Normal View History

# ============================================================
# 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