From 46763032ddc77611212002b8dab017a1ac0f19c5 Mon Sep 17 00:00:00 2001 From: zgw <13623675548@163.com> Date: Mon, 10 Aug 2026 10:17:42 +0800 Subject: [PATCH] feat(javaTest): keep loaded tasks bound through recovery --- .../simulation/DeterministicFaultPlan.java | 19 + .../history/simulation/FloorTransferJob.java | 31 +- .../HistoricalDispatchSimulator.java | 526 +++++++++++++++--- .../HistoricalDispatchFaultMatrixTest.java | 23 + ...storicalDispatchSimulatorTwoStageTest.java | 332 ++++++++++- 5 files changed, 814 insertions(+), 117 deletions(-) diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/DeterministicFaultPlan.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/DeterministicFaultPlan.java index 24ee0db..ad50e1f 100644 --- a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/DeterministicFaultPlan.java +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/DeterministicFaultPlan.java @@ -118,6 +118,25 @@ public final class DeterministicFaultPlan { return events == null ? Collections.emptyList() : events; } + /** + * 判断故障车辆在当前或后续 tick 是否配置了恢复事件。 + * + *

载货车辆发生 {@code FAULT} 时,中心必须据此区分“稍后原车恢复”和“没有恢复计划、转人工 + * 接管”两种业务结果,不能为了让仿真继续而把货物自动改派给另一辆车。 + */ + boolean willRecoverVehicleAtOrAfter(long eventNumber, String vehicleId) { + for (Map.Entry> entry : vehicleEvents.entrySet()) { + if (entry.getKey() < eventNumber) continue; + for (VehicleEvent event : entry.getValue()) { + if (vehicleId.equals(event.getVehicleId()) + && event.getType() == VehicleEventType.RECOVER) { + return true; + } + } + } + return false; + } + public static final class Builder { private final Map> plcTaskFailures = new LinkedHashMap>(); diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/FloorTransferJob.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/FloorTransferJob.java index aa7adab..8c9f93c 100644 --- a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/FloorTransferJob.java +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/FloorTransferJob.java @@ -13,6 +13,7 @@ public final class FloorTransferJob { private final String idempotencyKey; private final String parentTaskId; private final String vehicleId; + private final BusinessTaskPhase businessPhase; private final int sourceFloor; private final int targetFloor; private final String sourceSite; @@ -20,11 +21,13 @@ public final class FloorTransferJob { private final int priority; private FloorTransferJob(String idempotencyKey, String parentTaskId, - String vehicleId, int sourceFloor, int targetFloor, + String vehicleId, BusinessTaskPhase businessPhase, + int sourceFloor, int targetFloor, String sourceSite, String targetSite, int priority) { this.idempotencyKey = requireText(idempotencyKey, "idempotency key"); this.parentTaskId = requireText(parentTaskId, "parent task id"); this.vehicleId = requireText(vehicleId, "vehicle id"); + this.businessPhase = businessPhase; if (sourceFloor <= 0 || targetFloor <= 0 || sourceFloor == targetFloor) { throw new IllegalArgumentException("floor transfer requires two different positive floors"); } @@ -42,16 +45,31 @@ public final class FloorTransferJob { int sourceFloor, int targetFloor, String sourceSite, String targetSite, int priority) { + return create(parentTaskId, vehicleId, null, sourceFloor, targetFloor, + sourceSite, targetSite, priority); + } + + /** + * 创建绑定业务阶段的换层意图;阶段进入幂等键,避免同一父任务后续再次换层时复用旧作业。 + */ + public static FloorTransferJob create(String parentTaskId, String vehicleId, + BusinessTaskPhase businessPhase, + int sourceFloor, int targetFloor, + String sourceSite, String targetSite, + int priority) { String task = requireText(parentTaskId, "parent task id"); String vehicle = requireText(vehicleId, "vehicle id"); - String key = task + "|" + vehicle + "|" + sourceFloor + "->" + targetFloor; - return new FloorTransferJob(key, task, vehicle, sourceFloor, targetFloor, - sourceSite, targetSite, priority); + String phase = businessPhase == null ? "UNSCOPED" : businessPhase.name(); + String key = task + "|" + vehicle + "|" + phase + "|" + + sourceFloor + "->" + targetFloor; + return new FloorTransferJob(key, task, vehicle, businessPhase, + sourceFloor, targetFloor, sourceSite, targetSite, priority); } public String getIdempotencyKey() { return idempotencyKey; } public String getParentTaskId() { return parentTaskId; } public String getVehicleId() { return vehicleId; } + public BusinessTaskPhase getBusinessPhase() { return businessPhase; } public int getSourceFloor() { return sourceFloor; } public int getTargetFloor() { return targetFloor; } public String getSourceSite() { return sourceSite; } @@ -66,6 +84,7 @@ public final class FloorTransferJob { return idempotencyKey.equals(job.idempotencyKey) && parentTaskId.equals(job.parentTaskId) && vehicleId.equals(job.vehicleId) + && businessPhase == job.businessPhase && sourceFloor == job.sourceFloor && targetFloor == job.targetFloor && sourceSite.equals(job.sourceSite) @@ -75,8 +94,8 @@ public final class FloorTransferJob { @Override public int hashCode() { - return Objects.hash(idempotencyKey, parentTaskId, vehicleId, sourceFloor, - targetFloor, sourceSite, targetSite, priority); + return Objects.hash(idempotencyKey, parentTaskId, vehicleId, businessPhase, + sourceFloor, targetFloor, sourceSite, targetSite, priority); } private static String requireText(String value, String name) { diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulator.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulator.java index 96dcb72..5a6ab21 100644 --- a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulator.java +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulator.java @@ -136,7 +136,8 @@ public final class HistoricalDispatchSimulator { while (terminal.size() < expected && tick < config.getTickLimit()) { // 固定 tick 顺序保证故障、选车、清障和正常移动在相同输入下具有确定性结果。 applyVehicleEvents(tick, now, faults, vehicles, active, - clearanceMoves, queue, executions, reservations, events); + clearanceMoves, floorTransfers, queue, executions, terminal, + plc, ledger, reservations, events); assignReadyTasks(tick, now, input, queue, vehicles, active, clearanceMoves, floorTransfers, executions, terminal, routePlanner, routeEvaluator, assignment, clearancePlanner, @@ -155,8 +156,8 @@ public final class HistoricalDispatchSimulator { tick, now, input, config, queue, vehicles, active, executions, terminal, routePlanner, routeEvaluator, ledger, plc, archive, wms, invariants, reservations, - clearanceMoves, floorTransfers, clearancePlanner, events, - traversedEdges); + clearanceMoves, floorTransfers, floorTransferGateway, + clearancePlanner, events, traversedEdges); verifyLedger(ledger, invariants); // 尚未到达的未来任务不应被误判为死锁,因为它只是在等待 create_date 对应的时刻。 @@ -188,8 +189,10 @@ public final class HistoricalDispatchSimulator { throw new IllegalStateException("simulation tick limit reached: tick=" + tick + ", terminal=" + terminal.size() + ", expected=" + expected + ", queued=" + queue.size() + ", active=" + active.keySet() + + ", activeDetails=" + activeDiagnostics(active, vehicles) + ", transfer=" + floorTransfers.keySet() - + ", clearance=" + clearanceMoves.keySet()); + + ", clearance=" + clearanceMoves.keySet() + + ", recentEvents=" + recentEventDiagnostics(events, 80)); } // 对终态按稳定任务标识排序,保证报告、快照和回放比较不受 Map 插入顺序影响。 @@ -258,8 +261,12 @@ public final class HistoricalDispatchSimulator { Map vehicles, Map active, Map clearanceMoves, + Map floorTransfers, SimulationTaskQueue queue, Map executions, + Map terminal, + SimulatedPlcGateway plc, + ResourceLedger ledger, CorridorReservationManager reservations, List events) { boolean changed = false; @@ -289,21 +296,96 @@ public final class HistoricalDispatchSimulator { vehicle.getVehicleId(), event.getType().name(), vehicle.getCurrentSite(), null)); } - ActiveTask stopped = active.remove(vehicle.getVehicleId()); + ActiveTask stopped = active.get(vehicle.getVehicleId()); if (stopped != null) { - // 正在执行的业务任务释放未来走廊后回队,恢复时允许中心重新选车和规划路径。 - reservations.releaseTask(stopped.execution.task.getSimulationTaskId()); - queue.requeue(stopped.execution.task); - hold(stopped.execution, event.getType().name()); + String reason = unavailableVehicleReason(stopped.execution, vehicle); + if (mayRedispatch(stopped.execution)) { + // 只有尚未取货的空车任务可以撤销绑定,恢复后重新参加中心选车。 + active.remove(vehicle.getVehicleId()); + reservations.releaseTask(stopped.execution.task.getSimulationTaskId()); + queue.requeue(stopped.execution.task); + } + hold(stopped.execution, reason); events.add(event(tick, now, "HOLD", stopped.execution.task.getSimulationTaskId(), - vehicle.getVehicleId(), event.getType().name(), + vehicle.getVehicleId(), reason, vehicle.getCurrentSite(), null)); + if (isPermanentLoadedFault(tick, event, stopped.execution, faults)) { + // 货物已经在故障车上且没有后续恢复事件,只能关闭为人工接管,禁止自动换车。 + active.remove(vehicle.getVehicleId()); + reservations.releaseTask(stopped.execution.task.getSimulationTaskId()); + stopped.execution.vehicleReleaseStatus = VehicleReleaseStatus.FAILED; + terminal.put(stopped.execution.task.getSimulationTaskId(), + result(stopped.execution, SimulationTaskStatus.FAILED, + "MANUAL_CARGO_RECOVERY_REQUIRED", plc, now)); + events.add(event(tick, now, "PHASE_FAILED", + stopped.execution.task.getSimulationTaskId(), + vehicle.getVehicleId(), "MANUAL_CARGO_RECOVERY_REQUIRED", + vehicle.getCurrentSite(), null)); + } + } + + FloorTransferExecution transferring = floorTransfers.get(vehicle.getVehicleId()); + if (transferring != null + && isPermanentLoadedFault(tick, event, + transferring.execution, faults)) { + // 换层作业期间同样保持货物与原车绑定;永久故障仅撤销未来资源,不释放实际车位。 + floorTransfers.remove(vehicle.getVehicleId()); + reservations.releaseTask( + transferring.execution.task.getSimulationTaskId()); + if (!transferring.completionApplied) { + ledger.release(vehicle.getVehicleId(), Collections.singletonList( + ResourceKey.node(transferring.job.getTargetSite()))); + } + String reason = "MANUAL_CARGO_RECOVERY_REQUIRED"; + hold(transferring.execution, + unavailableVehicleReason(transferring.execution, vehicle)); + transferring.execution.vehicleReleaseStatus = VehicleReleaseStatus.FAILED; + terminal.put(transferring.execution.task.getSimulationTaskId(), + result(transferring.execution, SimulationTaskStatus.FAILED, + reason, plc, now)); + events.add(event(tick, now, "PHASE_FAILED", + transferring.execution.task.getSimulationTaskId(), + vehicle.getVehicleId(), reason, + vehicle.getCurrentSite(), transferring.job.getTargetSite())); } } } return changed; } + /** 取货前允许中心撤销车辆绑定;RAW 兼容模式保持原有故障重派行为。 */ + private boolean mayRedispatch(Execution execution) { + if (!execution.isBusinessMode()) return true; + PhaseProgress phase = execution.currentPhaseProgress(); + return execution.cargoState == CargoState.EMPTY + && phase != null + && phase.phase == BusinessTaskPhase.EMPTY_TO_PICKUP; + } + + /** 将车辆运行事实转换成带货物边界的 HOLD 原因,报告可以直接看出为什么不能换车。 */ + private String unavailableVehicleReason(Execution execution, + SimulatedVehicleRuntime vehicle) { + String reason; + if (!vehicle.isConnected()) reason = "DISCONNECTED"; + else if (vehicle.isFaulted()) reason = "FAULT"; + else if (!vehicle.isSynchronizedAfterBoot()) reason = "RESTART_NOT_SYNCHRONIZED"; + else if (vehicle.getBattery() <= 30) reason = "LOW_BATTERY"; + else reason = "VEHICLE_NOT_ELIGIBLE"; + if (execution.cargoState == CargoState.LOADED) return reason + "_LOADED"; + if (execution.cargoState == CargoState.DEPOSITED) return reason + "_VEHICLE_BOUND"; + return reason; + } + + /** 只有无后续 RECOVER 的载货 FAULT 才需要立即转人工,断线和重启仍等待原车恢复。 */ + private boolean isPermanentLoadedFault( + long tick, DeterministicFaultPlan.VehicleEvent event, + Execution execution, DeterministicFaultPlan faults) { + return event.getType() == DeterministicFaultPlan.VehicleEventType.FAULT + && execution.cargoState == CargoState.LOADED + && !faults.willRecoverVehicleAtOrAfter( + tick, event.getVehicleId()); + } + /** * 为已到达的历史任务执行中心调度:重新规划服务路径、筛选可用车队、自动选车并申请走廊方向预约。 * @@ -353,10 +435,27 @@ public final class HistoricalDispatchSimulator { queue.readyAt(now)); for (HistoricalSimulationTask task : ready) { if (terminal.containsKey(task.getSimulationTaskId())) continue; + Execution execution = executions.get(task.getSimulationTaskId()); + + // 权威通道前端必须真实存在于项目地图;该错误在派车前关闭,避免货物入位后才无法回前端。 + String mapFailure = businessMapFailure(execution, input); + if (mapFailure != null) { + queue.remove(task.getSimulationTaskId()); + execution.vehicleReleaseStatus = VehicleReleaseStatus.NOT_REQUIRED; + terminal.put(task.getSimulationTaskId(), result(execution, + SimulationTaskStatus.FAILED, mapFailure, plc, now)); + events.add(event(tick, now, "TASK_FAILED", task.getSimulationTaskId(), + null, mapFailure, task.getPhysicalSource(), + task.getPhysicalTarget())); + progress = true; + continue; + } // 先生成服务段物理候选。这里只使用永久地图,临时占用不能把任务误关为 NO_ROUTE。 + String serviceTarget = execution.isBusinessMode() + ? task.getPhysicalSource() : task.getPhysicalTarget(); RouteCandidateSet serviceCandidates = routePlanner.plan(input.getGridMap(), - task.getPhysicalSource(), task.getPhysicalTarget(), + task.getPhysicalSource(), serviceTarget, Collections.emptySet(), RoutePlanningPolicy.defaultPolicy()); if (serviceCandidates.getCandidates().isEmpty()) { queue.remove(task.getSimulationTaskId()); @@ -416,7 +515,6 @@ public final class HistoricalDispatchSimulator { dispatchTask, candidates, input.getGridMap().getSites()); if (!decision.getVehicle().isPresent()) continue; String vehicleId = decision.getVehicle().get().getVehicleId(); - Execution execution = executions.get(task.getSimulationTaskId()); ClearanceMove pendingDestinationClearance = clearanceForTask( task.getSimulationTaskId(), clearanceMoves); if (pendingDestinationClearance != null) { @@ -433,6 +531,8 @@ public final class HistoricalDispatchSimulator { if (transfer != null) { FloorTransferJob job = FloorTransferJob.create( task.getSimulationTaskId(), vehicleId, + execution.isBusinessMode() + ? execution.currentLeg().getPhase() : null, transfer.sourceFloor, transfer.targetFloor, transfer.sourcePort, transfer.targetPort, queue.effectivePriorityFor(task, now)); @@ -532,7 +632,9 @@ public final class HistoricalDispatchSimulator { execution.floorTransferJobs.add(job); } floorTransfers.put(vehicleId, new FloorTransferExecution( - execution, job, postTransferRoute)); + execution, job, postTransferRoute, + execution.isBusinessMode() + ? execution.currentLeg().getPhase() : null)); queue.remove(task.getSimulationTaskId()); events.add(event(tick, now, "FLOOR_TRANSFER_SUBMITTED", task.getSimulationTaskId(), vehicleId, @@ -616,6 +718,25 @@ public final class HistoricalDispatchSimulator { return progress; } + /** 返回业务输入中第一个不属于当前项目地图的端点类型;合法输入返回 {@code null}。 */ + private String businessMapFailure(Execution execution, + HistoricalSimulationInput input) { + if (!execution.isBusinessMode()) return null; + HistoricalSimulationTask task = execution.task; + if (input.getGridMap().getSite(task.getPhysicalSource()) == null + || input.getGridMap().getSite(task.getPhysicalTarget()) == null) { + return "UNKNOWN_MAP_SITE:TASK_ENDPOINT"; + } + for (BusinessTaskLeg leg : execution.phasePlan.getLegs()) { + if ((leg.getPhase() == BusinessTaskPhase.LOADED_RETURN_TO_SOURCE_FRONT + || leg.getPhase() == BusinessTaskPhase.EMPTY_RETURN_TO_TARGET_FRONT) + && input.getGridMap().getSite(leg.getTargetSite()) == null) { + return "UNKNOWN_MAP_SITE:CHANNEL_FRONT"; + } + } + return null; + } + /** * 判断车队是否还有任意车辆能抵达任务源点,用于区分暂时等车和永久无路两种情况。 * @@ -806,27 +927,34 @@ public final class HistoricalDispatchSimulator { private TransferFeasibility transferFeasibility( HistoricalSimulationTask task, SimulatedVehicleRuntime vehicle, HistoricalSimulationInput input, RouteCandidatePlanner planner) { - Site taskSource = input.getGridMap().getSite(task.getPhysicalSource()); - if (taskSource == null || vehicle.getFloor() == taskSource.getFloor()) return null; + return transferFeasibility(task.getPhysicalSource(), vehicle, input, planner); + } + + /** 检查原车能否通过两层端口继续到达当前业务阶段终点。 */ + private TransferFeasibility transferFeasibility( + String phaseTarget, SimulatedVehicleRuntime vehicle, + HistoricalSimulationInput input, RouteCandidatePlanner planner) { + Site target = input.getGridMap().getSite(phaseTarget); + if (target == null || vehicle.getFloor() == target.getFloor()) return null; FloorTransferTopology topology = input.getFloorTransferTopology(); - if (!topology.supports(vehicle.getFloor(), taskSource.getFloor())) return null; + if (!topology.supports(vehicle.getFloor(), target.getFloor())) return null; String sourcePort = topology.portFor(vehicle.getFloor()).get(); - String targetPort = topology.portFor(taskSource.getFloor()).get(); + String targetPort = topology.portFor(target.getFloor()).get(); Site sourcePortSite = input.getGridMap().getSite(sourcePort); Site targetPortSite = input.getGridMap().getSite(targetPort); if (sourcePortSite == null || targetPortSite == null || sourcePortSite.getFloor() != vehicle.getFloor() - || targetPortSite.getFloor() != taskSource.getFloor()) return null; + || targetPortSite.getFloor() != target.getFloor()) return null; RouteCandidateSet toTransfer = planner.plan(input.getGridMap(), vehicle.getCurrentSite(), sourcePort, Collections.emptySet(), RoutePlanningPolicy.defaultPolicy()); RouteCandidateSet fromTransfer = planner.plan(input.getGridMap(), - targetPort, task.getPhysicalSource(), Collections.emptySet(), + targetPort, phaseTarget, Collections.emptySet(), RoutePlanningPolicy.defaultPolicy()); if (toTransfer.getCandidates().isEmpty() || fromTransfer.getCandidates().isEmpty()) return null; - return new TransferFeasibility(vehicle.getFloor(), taskSource.getFloor(), + return new TransferFeasibility(vehicle.getFloor(), target.getFloor(), sourcePort, targetPort); } @@ -873,7 +1001,7 @@ public final class HistoricalDispatchSimulator { if (!vehicle.isAssignmentEligible()) { // 已提交作业可能已到 PLC,无法证明未执行时必须保留原车辆和父任务绑定。 - String reason = unavailableTransferReason(vehicle); + String reason = unavailableVehicleReason(transfer.execution, vehicle); hold(transfer.execution, reason); events.add(event(tick, now, "FLOOR_TRANSFER_HOLD", transfer.execution.task.getSimulationTaskId(), vehicleId, @@ -896,17 +1024,31 @@ public final class HistoricalDispatchSimulator { continue; } if (snapshot.getStatus() == FloorTransferStatus.REJECTED) { - // 明确未执行的拒绝可以安全释放锁并让父任务保留原到达资历重新参加选车。 + // 明确未执行的拒绝可以释放设备出口;只有尚未取货的任务允许回到全局选车队列。 floorTransfers.remove(vehicleId); reservations.releaseTask(transfer.execution.task.getSimulationTaskId()); ledger.release(vehicleId, Collections.singletonList( ResourceKey.node(transfer.job.getTargetSite()))); - queue.requeue(transfer.execution.task); hold(transfer.execution, snapshot.getReason()); events.add(event(tick, now, "FLOOR_TRANSFER_HOLD", transfer.execution.task.getSimulationTaskId(), vehicleId, snapshot.getReason(), transfer.job.getSourceSite(), transfer.job.getTargetSite())); + if (mayRedispatch(transfer.execution)) { + queue.requeue(transfer.execution.task); + } else { + // 载货换层被设备明确拒绝时仍不能自动换车;关闭给人工处理,且不产生 WMS 副作用。 + String reason = "FLOOR_TRANSFER_REJECTED_" + + transfer.pendingPhase; + transfer.execution.vehicleReleaseStatus = VehicleReleaseStatus.FAILED; + terminal.put(transfer.execution.task.getSimulationTaskId(), + result(transfer.execution, SimulationTaskStatus.FAILED, + reason, plc, now)); + events.add(event(tick, now, "PHASE_FAILED", + transfer.execution.task.getSimulationTaskId(), vehicleId, + reason, vehicle.getCurrentSite(), + transfer.job.getTargetSite())); + } progress = true; continue; } @@ -1004,34 +1146,55 @@ public final class HistoricalDispatchSimulator { List events) { Execution execution = transfer.execution; HistoricalSimulationTask task = execution.task; - RouteCandidateSet approachCandidates = routePlanner.plan(input.getGridMap(), - vehicle.getCurrentSite(), task.getPhysicalSource(), - Collections.emptySet(), RoutePlanningPolicy.defaultPolicy()); - RouteCandidateSet serviceCandidates = routePlanner.plan(input.getGridMap(), - task.getPhysicalSource(), task.getPhysicalTarget(), - Collections.emptySet(), RoutePlanningPolicy.defaultPolicy()); - if (approachCandidates.getCandidates().isEmpty() - || serviceCandidates.getCandidates().isEmpty()) { - floorTransfers.remove(vehicle.getVehicleId()); - reservations.releaseTask(task.getSimulationTaskId()); - terminal.put(task.getSimulationTaskId(), result(execution, - SimulationTaskStatus.FAILED, "NO_ROUTE_AFTER_TRANSFER", plc, now)); - events.add(event(tick, now, "FLOOR_TRANSFER_HOLD", - task.getSimulationTaskId(), vehicle.getVehicleId(), - "NO_ROUTE_AFTER_TRANSFER", vehicle.getCurrentSite(), - task.getPhysicalSource())); - return true; - } - TrafficRouteSnapshot traffic = trafficSnapshot(vehicles, active, ledger, reservations); - RoutePlanDecision approachDecision = routeEvaluator.select(input.getGridMap(), - approachCandidates, traffic, task.getSimulationTaskId(), - vehicle.getVehicleId()); - RoutePlanDecision serviceDecision = routeEvaluator.select(input.getGridMap(), - serviceCandidates, traffic, task.getSimulationTaskId(), - vehicle.getVehicleId()); - List route = new ArrayList(approachDecision.getSelectedPath()); - if (!execution.isBusinessMode()) { + RoutePlanDecision approachDecision = null; + RoutePlanDecision serviceDecision; + List route; + BusinessTaskLeg businessLeg = null; + if (execution.isBusinessMode()) { + businessLeg = execution.currentLeg(); + if (transfer.pendingPhase != businessLeg.getPhase()) { + throw new IllegalStateException("floor transfer phase changed for " + + task.getSimulationTaskId()); + } + RouteCandidateSet phaseCandidates = routePlanner.plan(input.getGridMap(), + vehicle.getCurrentSite(), businessLeg.getTargetSite(), + Collections.emptySet(), RoutePlanningPolicy.defaultPolicy()); + if (phaseCandidates.getCandidates().isEmpty()) { + floorTransfers.remove(vehicle.getVehicleId()); + return failBusinessPhase(tick, now, execution, businessLeg, + vehicle, active, terminal, plc, reservations, events); + } + serviceDecision = routeEvaluator.select(input.getGridMap(), + phaseCandidates, traffic, task.getSimulationTaskId(), + vehicle.getVehicleId()); + route = new ArrayList(serviceDecision.getSelectedPath()); + } else { + RouteCandidateSet approachCandidates = routePlanner.plan(input.getGridMap(), + vehicle.getCurrentSite(), task.getPhysicalSource(), + Collections.emptySet(), RoutePlanningPolicy.defaultPolicy()); + RouteCandidateSet serviceCandidates = routePlanner.plan(input.getGridMap(), + task.getPhysicalSource(), task.getPhysicalTarget(), + Collections.emptySet(), RoutePlanningPolicy.defaultPolicy()); + if (approachCandidates.getCandidates().isEmpty() + || serviceCandidates.getCandidates().isEmpty()) { + floorTransfers.remove(vehicle.getVehicleId()); + reservations.releaseTask(task.getSimulationTaskId()); + terminal.put(task.getSimulationTaskId(), result(execution, + SimulationTaskStatus.FAILED, "NO_ROUTE_AFTER_TRANSFER", plc, now)); + events.add(event(tick, now, "FLOOR_TRANSFER_HOLD", + task.getSimulationTaskId(), vehicle.getVehicleId(), + "NO_ROUTE_AFTER_TRANSFER", vehicle.getCurrentSite(), + task.getPhysicalSource())); + return true; + } + approachDecision = routeEvaluator.select(input.getGridMap(), + approachCandidates, traffic, task.getSimulationTaskId(), + vehicle.getVehicleId()); + serviceDecision = routeEvaluator.select(input.getGridMap(), + serviceCandidates, traffic, task.getSimulationTaskId(), + vehicle.getVehicleId()); + route = new ArrayList(approachDecision.getSelectedPath()); for (int index = 1; index < serviceDecision.getSelectedPath().size(); index++) { route.add(serviceDecision.getSelectedPath().get(index)); } @@ -1050,16 +1213,24 @@ public final class HistoricalDispatchSimulator { // 预约成功后才提交新路径版本;证据中的接近段必定从设备完成事实返回的实际出口开始。 execution.pathVersion++; - execution.planningEvidence.add(new RoutePlanningEvidence( - RoutePlanningEvidence.PlanningStage.APPROACH, - execution.pathVersion, now, false, approachDecision)); if (execution.isBusinessMode()) { - execution.plannedPath = new ArrayList(); - BusinessTaskLeg leg = execution.currentLeg(); - execution.beginPhase(leg, route.get(0), route, now); + RoutePlanningEvidence.PlanningStage stage = businessLeg.getPhase() + == BusinessTaskPhase.EMPTY_TO_PICKUP + ? RoutePlanningEvidence.PlanningStage.APPROACH + : RoutePlanningEvidence.PlanningStage.SERVICE; + execution.planningEvidence.add(new RoutePlanningEvidence( + stage, execution.pathVersion, now, false, serviceDecision)); + execution.beginPhase(businessLeg, route.get(0), route, now); + if (businessLeg.getPhase() == BusinessTaskPhase.LOADED_RETURN_TO_SOURCE_FRONT + || businessLeg.getPhase() == BusinessTaskPhase.LOADED_TO_PLACE) { + appendBusinessPath(execution.plannedPath, route); + } active.put(vehicle.getVehicleId(), new ActiveTask(execution, route, 0, - leg.getPhase(), execution.pathVersion)); + businessLeg.getPhase(), execution.pathVersion)); } else { + execution.planningEvidence.add(new RoutePlanningEvidence( + RoutePlanningEvidence.PlanningStage.APPROACH, + execution.pathVersion, now, false, approachDecision)); execution.plannedPath = new ArrayList(serviceDecision.getSelectedPath()); execution.planningEvidence.add(new RoutePlanningEvidence( RoutePlanningEvidence.PlanningStage.SERVICE, @@ -1081,15 +1252,6 @@ public final class HistoricalDispatchSimulator { return true; } - /** 把车辆运行态转换为换层 HOLD 的稳定业务原因。 */ - private String unavailableTransferReason(SimulatedVehicleRuntime vehicle) { - if (!vehicle.isConnected()) return "DISCONNECTED"; - if (vehicle.isFaulted()) return "FAULT"; - if (!vehicle.isSynchronizedAfterBoot()) return "RESTART_NOT_SYNCHRONIZED"; - if (vehicle.getBattery() <= 30) return "LOW_BATTERY"; - return "VEHICLE_NOT_ELIGIBLE"; - } - /** * 在 watchdog 失败时输出换层作业、车辆事实和目标出口资源持有者,定位阻塞发生在哪个边界。 */ @@ -1322,6 +1484,7 @@ public final class HistoricalDispatchSimulator { CorridorReservationManager reservations, Map clearanceMoves, Map floorTransfers, + FloorTransferGateway floorTransferGateway, ClearanceMovePlanner clearancePlanner, List events, Set traversedEdges) { @@ -1339,7 +1502,8 @@ public final class HistoricalDispatchSimulator { finishReachedRoute(tick, now, current, vehicle, input, config, queue, vehicles, active, terminal, routePlanner, routeEvaluator, ledger, plc, archive, wms, invariants, reservations, - clearanceMoves, floorTransfers, clearancePlanner, events); + clearanceMoves, floorTransfers, floorTransferGateway, + clearancePlanner, events); progress = true; continue; } @@ -1435,14 +1599,23 @@ public final class HistoricalDispatchSimulator { ? plc.attemptsForTask(parentTaskId) : plc.attemptsForCommand(commandId); if (commandAttempts % config.getRetryLimit() == 0) { - // 重试额度耗尽后释放未来走廊并回队,避免单车协议异常无限阻塞全局交通。 - active.remove(vehicleId); - reservations.releaseTask(current.execution.task.getSimulationTaskId()); - queue.requeue(current.execution.task); - hold(current.execution, "RETRY_EXHAUSTED_REDISPATCH"); + String holdReason; + if (mayRedispatch(current.execution)) { + // 空车取货前可以撤销绑定,让中心重新选择可用车辆和路径。 + active.remove(vehicleId); + reservations.releaseTask(current.execution.task.getSimulationTaskId()); + queue.requeue(current.execution.task); + holdReason = "RETRY_EXHAUSTED_REDISPATCH"; + } else { + // 取货后即使协议重试达到一轮上限也保留原车、路线索引和预约,等待原命令恢复。 + holdReason = current.execution.cargoState == CargoState.LOADED + ? "RETRY_EXHAUSTED_LOADED" + : "RETRY_EXHAUSTED_VEHICLE_BOUND"; + } + hold(current.execution, holdReason); events.add(event(tick, now, "HOLD", current.execution.task.getSimulationTaskId(), vehicleId, - "RETRY_EXHAUSTED_REDISPATCH", from, to)); + holdReason, from, to)); } continue; } @@ -1464,7 +1637,8 @@ public final class HistoricalDispatchSimulator { finishReachedRoute(tick, now, current, vehicle, input, config, queue, vehicles, active, terminal, routePlanner, routeEvaluator, ledger, plc, archive, wms, invariants, reservations, - clearanceMoves, floorTransfers, clearancePlanner, events); + clearanceMoves, floorTransfers, floorTransferGateway, + clearancePlanner, events); } } return progress; @@ -1487,6 +1661,7 @@ public final class HistoricalDispatchSimulator { CorridorReservationManager reservations, Map clearanceMoves, Map floorTransfers, + FloorTransferGateway floorTransferGateway, ClearanceMovePlanner clearancePlanner, List events) { if (!current.execution.isBusinessMode()) { @@ -1497,7 +1672,8 @@ public final class HistoricalDispatchSimulator { completeBusinessPhase(tick, now, current, vehicle, input, config, queue, vehicles, active, terminal, routePlanner, routeEvaluator, ledger, plc, archive, wms, invariants, reservations, - clearanceMoves, floorTransfers, clearancePlanner, events); + clearanceMoves, floorTransfers, floorTransferGateway, + clearancePlanner, events); } /** @@ -1517,6 +1693,7 @@ public final class HistoricalDispatchSimulator { CorridorReservationManager reservations, Map clearanceMoves, Map floorTransfers, + FloorTransferGateway floorTransferGateway, ClearanceMovePlanner clearancePlanner, List events) { Execution execution = current.execution; @@ -1575,7 +1752,7 @@ public final class HistoricalDispatchSimulator { activateNextBusinessPhase(tick, now, current, vehicle, input, config, queue, vehicles, active, terminal, routePlanner, routeEvaluator, ledger, plc, reservations, clearanceMoves, floorTransfers, - clearancePlanner, events); + floorTransferGateway, clearancePlanner, events); } /** 从车辆当前位置规划父任务下一业务阶段,并保持同一车辆绑定。 */ @@ -1591,6 +1768,7 @@ public final class HistoricalDispatchSimulator { CorridorReservationManager reservations, Map clearanceMoves, Map floorTransfers, + FloorTransferGateway floorTransferGateway, ClearanceMovePlanner clearancePlanner, List events) { Execution execution = completedRoute.execution; @@ -1605,17 +1783,18 @@ public final class HistoricalDispatchSimulator { fromSite, leg.getTargetSite(), Collections.emptySet(), RoutePlanningPolicy.defaultPolicy()); if (candidates.getCandidates().isEmpty()) { - String reason = "NO_ROUTE_" + leg.getPhase(); - phase.complete(now, reason); - reservations.releaseTask(taskId); - active.remove(vehicle.getVehicleId()); - execution.vehicleReleaseStatus = execution.businessCompletedMillis == null - ? VehicleReleaseStatus.PENDING : VehicleReleaseStatus.FAILED; - terminal.put(taskId, result(execution, - SimulationTaskStatus.FAILED, reason, plc, now)); - events.add(event(tick, now, "PHASE_FAILED", taskId, - vehicle.getVehicleId(), reason, fromSite, leg.getTargetSite())); - return true; + TransferFeasibility transfer = transferFeasibility( + leg.getTargetSite(), vehicle, input, routePlanner); + if (transfer != null) { + return activateBusinessPhaseFloorTransfer( + tick, now, execution, leg, vehicle, input, config, + vehicles, active, clearanceMoves, floorTransfers, + routePlanner, routeEvaluator, floorTransferGateway, + ledger, plc, terminal, reservations, + clearancePlanner, events, transfer); + } + return failBusinessPhase(tick, now, execution, leg, vehicle, + active, terminal, plc, reservations, events); } TrafficRouteSnapshot traffic = trafficSnapshot(vehicles, active, ledger, reservations); @@ -1666,6 +1845,148 @@ public final class HistoricalDispatchSimulator { return true; } + /** + * 当前阶段目标位于另一楼层时,保持原车绑定并先创建阶段级换层作业。 + * + *

作业提交前只规划目标层出口到本阶段终点的同层路径,中间楼层变化继续由现有 WCS 设备作业 + * 表达。换层完成后还会从设备返回的实际出口再次规划,预规划路径仅用于走廊和终点承诺保护。 + */ + private boolean activateBusinessPhaseFloorTransfer( + long tick, long now, Execution execution, BusinessTaskLeg leg, + SimulatedVehicleRuntime vehicle, HistoricalSimulationInput input, + SimulationConfig config, + Map vehicles, + Map active, + Map clearanceMoves, + Map floorTransfers, + RouteCandidatePlanner routePlanner, + RouteQualityEvaluator routeEvaluator, + FloorTransferGateway floorTransferGateway, + ResourceLedger ledger, + SimulatedPlcGateway plc, + Map terminal, + CorridorReservationManager reservations, + ClearanceMovePlanner clearancePlanner, + List events, + TransferFeasibility transfer) { + String taskId = execution.task.getSimulationTaskId(); + String vehicleId = vehicle.getVehicleId(); + TrafficRouteSnapshot traffic = trafficSnapshot( + vehicles, active, ledger, reservations); + RouteCandidateSet postTransferCandidates = routePlanner.plan( + input.getGridMap(), transfer.targetPort, leg.getTargetSite(), + Collections.emptySet(), RoutePlanningPolicy.defaultPolicy()); + if (postTransferCandidates.getCandidates().isEmpty()) { + return failBusinessPhase(tick, now, execution, leg, vehicle, + active, terminal, plc, reservations, events); + } + RoutePlanDecision decision = routeEvaluator.select(input.getGridMap(), + postTransferCandidates, traffic, taskId, vehicleId); + List postTransferRoute = new ArrayList( + decision.getSelectedPath()); + + String destinationHold = destinationCommitmentReason( + vehicleId, leg.getTargetSite(), vehicles, active, + floorTransfers, input.getGridMap()); + if (destinationHold != null) { + if ("DESTINATION_OCCUPIED".equals(destinationHold)) { + planDestinationClearance(tick, now, taskId, vehicleId, + postTransferRoute, vehicles, active, clearanceMoves, + floorTransfers, clearancePlanner, ledger, + input.getGridMap(), events); + } + hold(execution, destinationHold); + events.add(event(tick, now, "PHASE_HOLD", taskId, vehicleId, + destinationHold, vehicle.getCurrentSite(), leg.getTargetSite())); + return false; + } + if (!transferTargetPrepared(transfer.targetPort, active, + floorTransfers, ledger)) { + hold(execution, "TRANSFER_TARGET_NOT_PREPARED"); + events.add(event(tick, now, "PHASE_HOLD", taskId, vehicleId, + "TRANSFER_TARGET_NOT_PREPARED", transfer.sourcePort, + transfer.targetPort)); + return false; + } + + CorridorReservationManager.ReservationDecision preparedRoute = + reservations.reserve(taskId, postTransferRoute, + queuePriority(execution, now, config), + execution.arrivalMillis); + if (!preparedRoute.isGranted()) { + hold(execution, preparedRoute.getReason()); + events.add(event(tick, now, "PHASE_HOLD", taskId, vehicleId, + preparedRoute.getReason(), transfer.sourcePort, + transfer.targetPort)); + return false; + } + ResourceLedger.ProtectionResult preparedExit = ledger.evaluate( + vehicleId, Collections.singletonList( + ResourceKey.node(transfer.targetPort))); + if (!preparedExit.isGranted()) { + reservations.releaseTask(taskId); + hold(execution, "TRANSFER_TARGET_NOT_PREPARED"); + events.add(event(tick, now, "PHASE_HOLD", taskId, vehicleId, + "TRANSFER_TARGET_NOT_PREPARED", transfer.sourcePort, + transfer.targetPort)); + return false; + } + + FloorTransferJob job = FloorTransferJob.create( + taskId, vehicleId, leg.getPhase(), + transfer.sourceFloor, transfer.targetFloor, + transfer.sourcePort, transfer.targetPort, + queuePriority(execution, now, config)); + FloorTransferGateway.Submission submission = floorTransferGateway.submit(job); + if (!submission.isAccepted()) { + reservations.releaseTask(taskId); + ledger.release(vehicleId, Collections.singletonList( + ResourceKey.node(transfer.targetPort))); + hold(execution, submission.getReason()); + events.add(event(tick, now, "PHASE_HOLD", taskId, vehicleId, + submission.getReason(), transfer.sourcePort, + transfer.targetPort)); + return false; + } + + // 作业被设备接受后,从普通活动路线中移除,但父任务和货物仍锁在原车辆上。 + execution.assignmentMode = AssignmentMode.FLOOR_TRANSFER; + if (!execution.floorTransferJobs.contains(job)) { + execution.floorTransferJobs.add(job); + } + active.remove(vehicleId); + floorTransfers.put(vehicleId, new FloorTransferExecution( + execution, job, postTransferRoute, leg.getPhase())); + events.add(event(tick, now, "FLOOR_TRANSFER_SUBMITTED", + taskId, vehicleId, leg.getPhase().name(), + transfer.sourcePort, transfer.targetPort)); + return true; + } + + /** 将永久无路的当前阶段冻结为失败,并保留此前已经发生的归档/WMS业务事实。 */ + private boolean failBusinessPhase( + long tick, long now, Execution execution, BusinessTaskLeg leg, + SimulatedVehicleRuntime vehicle, Map active, + Map terminal, + SimulatedPlcGateway plc, + CorridorReservationManager reservations, + List events) { + String taskId = execution.task.getSimulationTaskId(); + String reason = "NO_ROUTE_" + leg.getPhase(); + PhaseProgress phase = execution.currentPhaseProgress(); + if (phase != null) phase.complete(now, reason); + reservations.releaseTask(taskId); + active.remove(vehicle.getVehicleId()); + execution.vehicleReleaseStatus = execution.businessCompletedMillis == null + ? VehicleReleaseStatus.PENDING : VehicleReleaseStatus.FAILED; + terminal.put(taskId, result(execution, + SimulationTaskStatus.FAILED, reason, plc, now)); + events.add(event(tick, now, "PHASE_FAILED", taskId, + vehicle.getVehicleId(), reason, vehicle.getCurrentSite(), + leg.getTargetSite())); + return true; + } + /** 把阶段路径拼成兼容旧报告的总路线,同时保留 TaskPhaseEvidence 中的边界。 */ private void appendBusinessPath(List completePath, List phasePath) { if (phasePath.isEmpty()) return; @@ -1956,9 +2277,28 @@ public final class HistoricalDispatchSimulator { reason + ";resource=" + blockingResource + ";count=" + observation.getConsecutiveCount(), vehicle.getCurrentSite(), null)); - return attemptReroute(tick, now, current, vehicle, queue, input, config, + boolean rerouted = attemptReroute(tick, now, current, vehicle, queue, input, config, routePlanner, routeEvaluator, vehicles, active, ledger, reservations, events); + if (rerouted) return true; + String taskId = current.execution.task.getSimulationTaskId(); + String holdReason; + if (mayRedispatch(current.execution)) { + // 取货前没有更好路线时可以撤销当前空车方案,重新参加全局选车和路径比较。 + active.remove(vehicle.getVehicleId()); + reservations.releaseTask(taskId); + queue.requeue(current.execution.task); + holdReason = "REROUTE_EXHAUSTED_REDISPATCH"; + } else { + // 取货后的货物不能因交通重规划失败跳到另一辆车,只保留原车等待资源变化。 + holdReason = current.execution.cargoState == CargoState.LOADED + ? "REROUTE_REJECTED_LOADED" + : "REROUTE_REJECTED_VEHICLE_BOUND"; + } + hold(current.execution, holdReason); + events.add(event(tick, now, "HOLD", taskId, vehicle.getVehicleId(), + holdReason, vehicle.getCurrentSite(), null)); + return mayRedispatch(current.execution); } /** @@ -2018,8 +2358,11 @@ public final class HistoricalDispatchSimulator { RoutePlanningEvidence.PlanningStage.APPROACH, nextVersion, now, true, approachDecision)); } else { + String rerouteTarget = current.phase == null + ? current.execution.task.getPhysicalTarget() + : current.execution.currentLeg().getTargetSite(); RouteCandidateSet serviceCandidates = routePlanner.plan(input.getGridMap(), - vehicle.getCurrentSite(), current.execution.task.getPhysicalTarget(), + vehicle.getCurrentSite(), rerouteTarget, Collections.emptySet(), RoutePlanningPolicy.defaultPolicy()); if (serviceCandidates.getCandidates().isEmpty()) { return rejectReroute(tick, now, current, vehicle, @@ -2428,15 +2771,18 @@ public final class HistoricalDispatchSimulator { private final Execution execution; private final FloorTransferJob job; private final List preparedPostTransferRoute; + private final BusinessTaskPhase pendingPhase; private FloorTransferStatus lastStatus = FloorTransferStatus.SUBMITTED; private boolean completionApplied; private FloorTransferExecution(Execution execution, FloorTransferJob job, - List preparedPostTransferRoute) { + List preparedPostTransferRoute, + BusinessTaskPhase pendingPhase) { this.execution = execution; this.job = job; this.preparedPostTransferRoute = Collections.unmodifiableList( new ArrayList(preparedPostTransferRoute)); + this.pendingPhase = pendingPhase; } } diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchFaultMatrixTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchFaultMatrixTest.java index af9dab5..cf86b7b 100644 --- a/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchFaultMatrixTest.java +++ b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchFaultMatrixTest.java @@ -12,6 +12,8 @@ import static com.ft546.wcs.shadow.history.simulation.DeterministicFaultPlan.Veh import static com.ft546.wcs.shadow.history.simulation.DeterministicFaultPlan.VehicleEventType.SYNCHRONIZE; import static com.ft546.wcs.shadow.history.simulation.SimulatedPlcGateway.PlcReason.ACK_LOST; import static com.ft546.wcs.shadow.history.simulation.SimulatedPlcGateway.PlcReason.ACK_TIMEOUT; +import static com.ft546.wcs.shadow.history.simulation.SimulationTaskStatus.FAILED; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -67,4 +69,25 @@ class HistoricalDispatchFaultMatrixTest { assertTrue(result.getRetryCount() >= 2); assertEquals(0, result.getInvariants().totalSafetyViolations()); } + + /** 载货车辆发生无恢复事件的永久故障时,任务进入人工接管,不能把货物自动改派给其他车。 */ + @Test + void permanentLoadedFaultRequiresManualCargoRecovery() { + HistoricalDispatchSimulationResult result = + HistoricalDispatchSimulatorTwoStageTest.runOutboundWithFaults( + DeterministicFaultPlan.builder() + .vehicleEvent(1, "1", FAULT) + .build()); + SimulationTaskResult task = result.getTaskResults().get(0); + + assertEquals(FAILED, task.getStatus()); + assertEquals("MANUAL_CARGO_RECOVERY_REQUIRED", task.getReason()); + assertEquals(java.util.Collections.singletonList("1"), + task.getAssignedVehicles()); + assertEquals(VehicleReleaseStatus.FAILED, + task.getVehicleReleaseStatus()); + assertEquals(0, result.getArchiveCount()); + assertEquals(0, result.getWmsBusinessEffects()); + assertFalse(task.getAssignedVehicles().contains("2")); + } } diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulatorTwoStageTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulatorTwoStageTest.java index 2f4a514..c82366a 100644 --- a/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulatorTwoStageTest.java +++ b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulatorTwoStageTest.java @@ -12,15 +12,30 @@ import org.junit.jupiter.api.Test; import java.time.LocalDateTime; import java.util.Arrays; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; import static com.ft546.wcs.shadow.history.simulation.BusinessTaskPhase.EMPTY_RETURN_TO_TARGET_FRONT; import static com.ft546.wcs.shadow.history.simulation.BusinessTaskPhase.EMPTY_TO_PICKUP; import static com.ft546.wcs.shadow.history.simulation.BusinessTaskPhase.LOADED_RETURN_TO_SOURCE_FRONT; import static com.ft546.wcs.shadow.history.simulation.BusinessTaskPhase.LOADED_TO_PLACE; +import static com.ft546.wcs.shadow.history.simulation.DeterministicFaultPlan.VehicleEventType.CONNECT; +import static com.ft546.wcs.shadow.history.simulation.DeterministicFaultPlan.VehicleEventType.DISCONNECT; +import static com.ft546.wcs.shadow.history.simulation.DeterministicFaultPlan.VehicleEventType.FAULT; +import static com.ft546.wcs.shadow.history.simulation.DeterministicFaultPlan.VehicleEventType.LOW_BATTERY; +import static com.ft546.wcs.shadow.history.simulation.DeterministicFaultPlan.VehicleEventType.RECOVER; +import static com.ft546.wcs.shadow.history.simulation.DeterministicFaultPlan.VehicleEventType.RESTART; +import static com.ft546.wcs.shadow.history.simulation.DeterministicFaultPlan.VehicleEventType.RESTORE_BATTERY; +import static com.ft546.wcs.shadow.history.simulation.DeterministicFaultPlan.VehicleEventType.SYNCHRONIZE; +import static com.ft546.wcs.shadow.history.simulation.SimulatedPlcGateway.PlcReason.SEND_FAILURE; +import static com.ft546.wcs.shadow.history.simulation.SimulationTaskStatus.COMPLETED; +import static com.ft546.wcs.shadow.history.simulation.SimulationTaskStatus.FAILED; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; class HistoricalDispatchSimulatorTwoStageTest { @@ -62,31 +77,251 @@ class HistoricalDispatchSimulatorTwoStageTest { assertEquals(1, result.getWmsBusinessEffects()); } + /** 车辆已经取到货后,断线和重启只能暂停原车,不能把同一件货重新派给第二辆车。 */ + @Test + void loadedVehicleRemainsBoundAcrossDisconnectRestartAndSynchronize() { + DeterministicFaultPlan faults = DeterministicFaultPlan.builder() + .vehicleEvent(1, "1", DISCONNECT) + .vehicleEvent(3, "1", CONNECT) + .vehicleEvent(4, "1", RESTART) + .vehicleEvent(6, "1", SYNCHRONIZE) + .build(); + + HistoricalDispatchSimulationResult result = runOutboundWithFaults(faults); + SimulationTaskResult task = result.getTaskResults().get(0); + + assertEquals(Collections.singletonList("1"), task.getAssignedVehicles()); + assertTrue(task.getHoldReasons().containsKey("DISCONNECTED_LOADED")); + assertTrue(task.getHoldReasons().containsKey( + "RESTART_NOT_SYNCHRONIZED_LOADED")); + assertEquals(COMPLETED, task.getStatus()); + assertEquals(1, result.getArchiveCount()); + assertEquals(1, result.getWmsBusinessEffects()); + } + + /** 低电量和可恢复车辆故障同样只暂停载货原车,恢复事件后从实际节点继续执行。 */ + @Test + void loadedVehicleRemainsBoundAcrossLowBatteryAndRecoverableFault() { + DeterministicFaultPlan faults = DeterministicFaultPlan.builder() + .vehicleEvent(1, "1", LOW_BATTERY) + .vehicleEvent(3, "1", RESTORE_BATTERY) + .vehicleEvent(4, "1", FAULT) + .vehicleEvent(6, "1", RECOVER) + .build(); + + HistoricalDispatchSimulationResult result = runOutboundWithFaults(faults); + SimulationTaskResult task = result.getTaskResults().get(0); + + assertEquals(Collections.singletonList("1"), task.getAssignedVehicles()); + assertTrue(task.getHoldReasons().containsKey("LOW_BATTERY_LOADED")); + assertTrue(task.getHoldReasons().containsKey("FAULT_LOADED")); + assertEquals(COMPLETED, task.getStatus()); + assertEquals(1, result.getArchiveCount()); + assertEquals(1, result.getWmsBusinessEffects()); + } + + /** 阶段 PLC 首发失败时车辆仍停在原节点,只有该阶段的重试证据增加。 */ + @Test + void phaseCommandFailureRetriesWithoutMovingOrRepeatingBusinessEffects() { + String commandId = "BUSINESS-1:LOADED_TO_PLACE:v3:e0"; + DeterministicFaultPlan faults = DeterministicFaultPlan.builder() + .plcCommandFailure(commandId, 1, SEND_FAILURE) + .build(); + + HistoricalDispatchSimulationResult result = run(task( + DispatchTask.TaskType.OUTBOUND, "PICK", "EXIT", "MID", null), + faults); + SimulationTaskResult task = result.getTaskResults().get(0); + SimulationTickEvent retry = result.getEvents().stream() + .filter(event -> "PLC_RETRY".equals(event.getType()) + && "MID".equals(event.getFromSite()) + && "FRONT".equals(event.getToSite())) + .findFirst().orElseThrow(AssertionError::new); + + assertFalse(result.getEvents().stream().anyMatch(event -> + event.getTick() == retry.getTick() + && "MOVE".equals(event.getType()) + && "MID".equals(event.getFromSite()) + && "FRONT".equals(event.getToSite()))); + assertEquals(1, phase(task, LOADED_TO_PLACE).getRetryCount()); + assertEquals(COMPLETED, task.getStatus()); + assertEquals(1, result.getArchiveCount()); + assertEquals(1, result.getWmsBusinessEffects()); + } + + /** 载货阶段达到一轮 PLC 重试上限后仍保留原车,下一次 ACK 可从同一节点继续。 */ + @Test + void loadedPhaseRetryExhaustionDoesNotRedispatchCargo() { + String commandId = "BUSINESS-1:LOADED_TO_PLACE:v3:e0"; + DeterministicFaultPlan faults = DeterministicFaultPlan.builder() + .plcCommandFailure(commandId, 1, SEND_FAILURE) + .plcCommandFailure(commandId, 2, SEND_FAILURE) + .plcCommandFailure(commandId, 3, SEND_FAILURE) + .build(); + + HistoricalDispatchSimulationResult result = runOutboundWithFaults(faults); + SimulationTaskResult task = result.getTaskResults().get(0); + + assertEquals(Collections.singletonList("1"), task.getAssignedVehicles()); + assertEquals(Integer.valueOf(1), + task.getHoldReasons().get("RETRY_EXHAUSTED_LOADED")); + assertEquals(3, phase(task, LOADED_TO_PLACE).getRetryCount()); + assertEquals(COMPLETED, task.getStatus()); + assertEquals(1, result.getArchiveCount()); + assertEquals(1, result.getWmsBusinessEffects()); + } + + /** 载货阶段跨层时只创建一个绑定父任务、原车和业务阶段的幂等换层作业。 */ + @Test + void loadedCrossFloorLegUsesOneIdempotentTransferJobForThatPhase() { + GridMap grid = crossFloorGrid(); + Map ports = new LinkedHashMap(); + ports.put(1, "PORT-1"); + ports.put(2, "PORT-2"); + SimulatedFloorTransferGateway gateway = new SimulatedFloorTransferGateway(); + HistoricalDispatchSimulationResult result = run( + Collections.singletonList(task(DispatchTask.TaskType.OUTBOUND, + "PICK", "EXIT", null, null)), grid, traffic(grid), + Collections.singletonList(vehicle("1", 1, "V")), + new FloorTransferTopology(ports), DeterministicFaultPlan.none(), + gateway, new SimulationConfig(100, 30_000L, 3, 100, 200)); + SimulationTaskResult task = result.getTaskResults().get(0); + + assertEquals(COMPLETED, task.getStatus()); + assertEquals(1, task.getFloorTransferCount()); + assertEquals(LOADED_TO_PLACE, + task.getFloorTransferJobs().get(0).getBusinessPhase()); + assertEquals(task.getSimulatedVehicleId(), + task.getFloorTransferJobs().get(0).getVehicleId()); + assertEquals(1, gateway.createdJobCount()); + assertEquals(1, gateway.completedJobCount()); + } + + /** 两条入库任务共用同一通道前端时,后车阶段等待,前车清出后两条任务都能安全完成。 */ + @Test + void sharedInboundTargetFrontProducesPhaseHoldWithoutDoubleOccupancy() { + GridMap grid = sharedFrontGrid(); + HistoricalDispatchSimulationResult result = run(Arrays.asList( + task("BUSINESS-1", DispatchTask.TaskType.INBOUND, + "PICK-1", "BIN-1", null, "FRONT"), + task("BUSINESS-2", DispatchTask.TaskType.INBOUND, + "PICK-2", "BIN-2", null, "FRONT")), + grid, traffic(grid), Arrays.asList( + vehicle("1", 1, "V-1"), vehicle("2", 1, "V-2")), + emptyFloorTopology(), DeterministicFaultPlan.none(), + new SimulatedFloorTransferGateway(), + new SimulationConfig(100, 30_000L, 3, 100, 500)); + + assertEquals(2, result.getCompletedCount()); + assertEquals(0, result.getFailedCount()); + assertTrue(result.getEvents().stream().anyMatch(event -> + "PHASE_HOLD".equals(event.getType()) + && ("DESTINATION_COMMITTED".equals(event.getReason()) + || "DESTINATION_OCCUPIED".equals(event.getReason())))); + assertEquals(0, result.getInvariants().getNodeDoubleOccupancyViolations()); + assertEquals(0, result.getInvariants().totalSafetyViolations()); + } + + /** 通道前端配置不在项目地图时必须在选车前关闭,不能先送货再暴露地图错误。 */ + @Test + void unknownConfiguredChannelFrontClosesBeforeAssignment() { + HistoricalDispatchSimulationResult result = run(task( + DispatchTask.TaskType.INBOUND, "PICK", "BIN", null, "UNKNOWN-FRONT")); + SimulationTaskResult task = result.getTaskResults().get(0); + + assertEquals(FAILED, task.getStatus()); + assertEquals("UNKNOWN_MAP_SITE:CHANNEL_FRONT", task.getReason()); + assertNull(task.getFirstAssignedMillis()); + assertTrue(task.getAssignedVehicles().isEmpty()); + assertEquals(0, result.getArchiveCount()); + assertEquals(0, result.getWmsBusinessEffects()); + } + + /** 前端站点有效但与当前货位永久断开时,当前阶段明确失败而不是无限 HOLD。 */ + @Test + void disconnectedValidFrontClosesAsPhaseNoRoute() { + GridMap grid = new GridMap(); + addSite(grid, "V", 1, 1); + addSite(grid, "PICK", 1, 2); + addSite(grid, "BIN", 1, 3); + addSite(grid, "FRONT", 1, 4); + grid.connect("V", "PICK"); + grid.connect("PICK", "BIN"); + HistoricalDispatchSimulationResult result = run( + Collections.singletonList(task(DispatchTask.TaskType.INBOUND, + "PICK", "BIN", null, "FRONT")), grid, traffic(grid), + Collections.singletonList(vehicle("1", 1, "V")), + emptyFloorTopology(), DeterministicFaultPlan.none(), + new SimulatedFloorTransferGateway(), + new SimulationConfig(100, 30_000L, 3, 20, 100)); + SimulationTaskResult task = result.getTaskResults().get(0); + + assertEquals(FAILED, task.getStatus()); + assertEquals("NO_ROUTE_EMPTY_RETURN_TO_TARGET_FRONT", task.getReason()); + assertEquals(1, result.getArchiveCount()); + assertEquals(1, result.getWmsBusinessEffects()); + assertEquals(VehicleReleaseStatus.FAILED, task.getVehicleReleaseStatus()); + } + private HistoricalDispatchSimulationResult run(HistoricalSimulationTask task) { + return run(task, DeterministicFaultPlan.none()); + } + + private HistoricalDispatchSimulationResult run(HistoricalSimulationTask task, + DeterministicFaultPlan faults) { GridMap grid = grid(); + return run(Collections.singletonList(task), grid, traffic(grid), + Collections.singletonList(vehicle("1", 1, "V")), + emptyFloorTopology(), faults, new SimulatedFloorTransferGateway(), + new SimulationConfig(100, 30_000L, 3, 100, 100)); + } + + static HistoricalDispatchSimulationResult runOutboundWithFaults( + DeterministicFaultPlan faults) { + HistoricalDispatchSimulatorTwoStageTest fixture = + new HistoricalDispatchSimulatorTwoStageTest(); + GridMap grid = fixture.grid(); + return fixture.run(Collections.singletonList(fixture.task( + DispatchTask.TaskType.OUTBOUND, + "PICK", "EXIT", "MID", null)), + grid, fixture.traffic(grid), Arrays.asList( + fixture.vehicle("1", 1, "V"), + fixture.vehicle("2", 1, "ALT")), + fixture.emptyFloorTopology(), faults, + new SimulatedFloorTransferGateway(), + new SimulationConfig(100, 30_000L, 3, 100, 300)); + } + + private HistoricalDispatchSimulationResult run( + List tasks, GridMap grid, + EffectiveTrafficMap traffic, List vehicles, + FloorTransferTopology floorTopology, DeterministicFaultPlan faults, + SimulatedFloorTransferGateway gateway, SimulationConfig config) { HistoricalSimulationInput input = new HistoricalSimulationInput( - Collections.singletonList(task), Collections.emptyList(), - grid, traffic(), Collections.singletonList( - new VehicleSnapshot("1", 1, "V", 5, 1, - 100, true, true, false)), - new FloorTransferTopology(Collections.emptyMap()), + tasks, Collections.emptyList(), grid, traffic, vehicles, + floorTopology, SimulationExecutionMode.SOURCE_ALIGNED_BUSINESS, - new HistoricalInputAudit(1, 1, 0, 1, 0), + new HistoricalInputAudit(tasks.size(), tasks.size(), 0, + tasks.size(), 0), ChannelFrontTopology.from(Collections.emptyList())); - return new HistoricalDispatchSimulator().simulate(input, - new SimulationConfig(100, 30_000L, 3, 100, 100), - DeterministicFaultPlan.none()); + return new HistoricalDispatchSimulator().simulate(input, config, faults, gateway); } private HistoricalSimulationTask task(DispatchTask.TaskType type, String source, String target, String sourceFront, String targetFront) { + return task("BUSINESS-1", type, source, target, sourceFront, targetFront); + } + + private HistoricalSimulationTask task(String taskId, DispatchTask.TaskType type, + String source, String target, + String sourceFront, String targetFront) { LocalDateTime created = LocalDateTime.of(2026, 8, 10, 8, 0); HistoricalQueueRecord record = new HistoricalQueueRecord( - "GUID", "BUSINESS-1", type.name(), 2, + "GUID-" + taskId, taskId, type.name(), 2, source, target, "HISTORY_ONLY", "2026-08-10 08:00:00", "2026-08-10 08:01:00", "HISTORY-VEHICLE"); - return new HistoricalSimulationTask("BUSINESS-1", record, type, 2, + return new HistoricalSimulationTask(taskId, record, type, 2, created, created.plusMinutes(1), source, target, Collections.singletonList("HISTORY_ONLY"), 0, sourceFront, targetFront, @@ -95,33 +330,81 @@ class HistoricalDispatchSimulatorTwoStageTest { private GridMap grid() { GridMap grid = new GridMap(); - for (String id : Arrays.asList("V", "PICK", "MID", "BIN", "FRONT", "EXIT")) { - grid.addSite(new Site(id, 1, id.hashCode(), 1, "TEST")); + int coordinate = 1; + for (String id : Arrays.asList( + "V", "PICK", "MID", "BIN", "FRONT", "EXIT", "ALT")) { + addSite(grid, id, 1, coordinate++); } grid.connect("V", "PICK"); grid.connect("PICK", "MID"); grid.connect("MID", "BIN"); grid.connect("MID", "FRONT"); grid.connect("FRONT", "EXIT"); + grid.connect("BIN", "ALT"); return grid; } - private EffectiveTrafficMap traffic() { - LinkedHashSet nodes = new LinkedHashSet( - Arrays.asList("V", "PICK", "MID", "BIN", "FRONT", "EXIT")); + private GridMap crossFloorGrid() { + GridMap grid = new GridMap(); + addSite(grid, "V", 1, 1); + addSite(grid, "PICK", 1, 2); + addSite(grid, "PORT-1", 1, 3); + addSite(grid, "PORT-2", 2, 4); + addSite(grid, "EXIT", 2, 5); + grid.connect("V", "PICK"); + grid.connect("PICK", "PORT-1"); + grid.connect("PORT-2", "EXIT"); + return grid; + } + + private GridMap sharedFrontGrid() { + GridMap grid = new GridMap(); + int coordinate = 1; + for (String id : Arrays.asList("V-1", "PICK-1", "BIN-1", + "V-2", "PICK-2", "BIN-2", "JUNCTION", "FRONT", "SAFE")) { + addSite(grid, id, 1, coordinate++); + } + grid.connect("V-1", "PICK-1"); + grid.connect("PICK-1", "BIN-1"); + grid.connect("BIN-1", "JUNCTION"); + grid.connect("V-2", "PICK-2"); + grid.connect("PICK-2", "BIN-2"); + grid.connect("BIN-2", "JUNCTION"); + grid.connect("JUNCTION", "FRONT"); + grid.connect("JUNCTION", "SAFE"); + return grid; + } + + private EffectiveTrafficMap traffic(GridMap grid) { + LinkedHashSet nodes = new LinkedHashSet(grid.getSites().keySet()); LinkedHashSet edges = new LinkedHashSet(); - edges.add(new TrafficMapCompiler.EdgeDefinition("V", "PICK")); - edges.add(new TrafficMapCompiler.EdgeDefinition("PICK", "MID")); - edges.add(new TrafficMapCompiler.EdgeDefinition("MID", "BIN")); - edges.add(new TrafficMapCompiler.EdgeDefinition("MID", "FRONT")); - edges.add(new TrafficMapCompiler.EdgeDefinition("FRONT", "EXIT")); + for (String from : nodes) { + for (String to : grid.getNeighbors(from)) { + if (from.compareTo(to) < 0) { + edges.add(new TrafficMapCompiler.EdgeDefinition(from, to)); + } + } + } return new TrafficMapCompiler().compile(new TrafficMapCompiler.MapDefinition( "TWO-STAGE", 1L, "TEST", true, "test", "test", nodes, edges, Collections.emptyMap(), Collections.emptySet(), Collections.emptySet())); } + private VehicleSnapshot vehicle(String id, int floor, String site) { + return new VehicleSnapshot(id, floor, site, 5, 1, + 100, true, true, false); + } + + private FloorTransferTopology emptyFloorTopology() { + return new FloorTransferTopology(Collections.emptyMap()); + } + + private void addSite(GridMap grid, String id, int floor, int coordinate) { + grid.addSite(new Site(id, floor, coordinate, 1, "TEST")); + } + private List phases(SimulationTaskResult task) { return task.getPhaseEvidence().stream().map(TaskPhaseEvidence::getPhase) .collect(Collectors.toList()); @@ -131,4 +414,11 @@ class HistoricalDispatchSimulatorTwoStageTest { return task.getPhaseEvidence().stream().map(TaskPhaseEvidence::getToSite) .collect(Collectors.toList()); } + + private TaskPhaseEvidence phase(SimulationTaskResult task, + BusinessTaskPhase expected) { + return task.getPhaseEvidence().stream() + .filter(value -> value.getPhase() == expected) + .findFirst().orElseThrow(AssertionError::new); + } }