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