aps-agent/server/aps_domain/mes.py

1599 lines
67 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 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 _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,
)
eligible_work_order_ids = sorted(
int(item["id"]) for item in work_orders if item.get("id") is not None
)
evidence_payload.update({
"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",
)
}
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"),
"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"),
"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") -> dict:
"""报工回流:更新 APS 工单进度并写 Mock MES。"""
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))
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
store.data.setdefault("mesLinks", []).append({
"kind": "report", "woId": wo_id, "externalWoId": ext_id,
"track": tr, "progressPct": pct, "status": status,
"syncedAt": fmt_date(today0()), "actor": actor,
})
write_audit(store.data, store.next_id, actor=actor, category="INTEGRATION",
action="mes.report", target={"type": "WORK_ORDER", "id": wo_id},
power="P1", rationale={"pct": pct, "status": status, "orderDone": order_done})
store.save()
msg = f"报工已回写 ✅ WO#{wo_id} → {pct}%({status})"
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