From 1c73fda6720ae9c84b61d906613b5141dbbcb915 Mon Sep 17 00:00:00 2001 From: zgw <13623675548@163.com> Date: Mon, 10 Aug 2026 09:45:59 +0800 Subject: [PATCH] feat(javaTest): execute source-aligned business phases --- .../HistoricalDispatchSimulator.java | 438 ++++++++++++++++-- .../simulation/SimulationTaskResult.java | 57 +++ .../history/simulation/TaskPhaseEvidence.java | 59 +++ ...storicalDispatchSimulatorTwoStageTest.java | 134 ++++++ 4 files changed, 652 insertions(+), 36 deletions(-) create mode 100644 javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/TaskPhaseEvidence.java create mode 100644 javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulatorTwoStageTest.java 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 72d84a5..96dcb72 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 @@ -92,7 +92,8 @@ public final class HistoricalDispatchSimulator { for (HistoricalSimulationTask task : input.getTasks()) { long arrival = clock.toSimulationMillis(task.getCreatedAt()); queue.add(task, arrival); - executions.put(task.getSimulationTaskId(), new Execution(task, arrival)); + executions.put(task.getSimulationTaskId(), new Execution( + task, arrival, input.getExecutionMode())); } List events = new ArrayList(); @@ -450,9 +451,11 @@ public final class HistoricalDispatchSimulator { task.getSimulationTaskId(), vehicleId); List postTransferRoute = new ArrayList( postTransferApproach.getSelectedPath()); - for (int index = 1; - index < postTransferService.getSelectedPath().size(); index++) { - postTransferRoute.add(postTransferService.getSelectedPath().get(index)); + if (!execution.isBusinessMode()) { + for (int index = 1; + index < postTransferService.getSelectedPath().size(); index++) { + postTransferRoute.add(postTransferService.getSelectedPath().get(index)); + } } String destinationHold = destinationCommitmentReason( vehicleId, postTransferRoute.get(postTransferRoute.size() - 1), @@ -547,10 +550,12 @@ public final class HistoricalDispatchSimulator { RoutePlanDecision serviceDecision = routeEvaluator.select(input.getGridMap(), serviceCandidates, traffic, task.getSimulationTaskId(), vehicleId); - // 车辆先走到业务源点,再执行服务段;拼接时去掉重复源点。 + // 原始模式仍一次执行“接近 + 服务”;业务模式首段只让空车到取货点,后续载货段必须重新规划。 List route = new ArrayList(approachDecision.getSelectedPath()); List servicePath = serviceDecision.getSelectedPath(); - for (int i = 1; i < servicePath.size(); i++) route.add(servicePath.get(i)); + if (!execution.isBusinessMode()) { + for (int i = 1; i < servicePath.size(); i++) route.add(servicePath.get(i)); + } String destinationHold = destinationCommitmentReason( vehicleId, route.get(route.size() - 1), vehicles, active, floorTransfers, input.getGridMap()); @@ -582,18 +587,27 @@ public final class HistoricalDispatchSimulator { // 预约成功才正式交接给活动任务;这些字段同时构成“历史方案与新方案”的可审计差异。 recordAssignment(execution, vehicleId, now); if (execution.assignmentMode == null) execution.assignmentMode = AssignmentMode.DIRECT; - execution.plannedPath = new ArrayList(servicePath); execution.pathVersion++; execution.planningEvidence.add(new RoutePlanningEvidence( RoutePlanningEvidence.PlanningStage.APPROACH, execution.pathVersion, now, false, approachDecision)); - execution.planningEvidence.add(new RoutePlanningEvidence( - RoutePlanningEvidence.PlanningStage.SERVICE, - execution.pathVersion, now, false, serviceDecision, - historicalReferenceQuality(task, input, traffic, routePlanner, - routeEvaluator, vehicleId))); - active.put(vehicleId, new ActiveTask(execution, route, - approachDecision.getSelectedPath().size() - 1)); + if (execution.isBusinessMode()) { + // 第一阶段路径就是所选车辆从当前位置到真实取货点的接近路径。 + execution.plannedPath = new ArrayList(); + BusinessTaskLeg leg = execution.currentLeg(); + execution.beginPhase(leg, route.get(0), route, now); + active.put(vehicleId, new ActiveTask(execution, route, 0, + leg.getPhase(), execution.pathVersion)); + } else { + execution.plannedPath = new ArrayList(servicePath); + execution.planningEvidence.add(new RoutePlanningEvidence( + RoutePlanningEvidence.PlanningStage.SERVICE, + execution.pathVersion, now, false, serviceDecision, + historicalReferenceQuality(task, input, traffic, routePlanner, + routeEvaluator, vehicleId))); + active.put(vehicleId, new ActiveTask(execution, route, + approachDecision.getSelectedPath().size() - 1)); + } queue.remove(task.getSimulationTaskId()); events.add(event(tick, now, "ASSIGN", task.getSimulationTaskId(), vehicleId, decision.getReason(), route.get(0), route.get(route.size() - 1))); @@ -1017,8 +1031,10 @@ public final class HistoricalDispatchSimulator { serviceCandidates, traffic, task.getSimulationTaskId(), vehicle.getVehicleId()); List route = new ArrayList(approachDecision.getSelectedPath()); - for (int index = 1; index < serviceDecision.getSelectedPath().size(); index++) { - route.add(serviceDecision.getSelectedPath().get(index)); + if (!execution.isBusinessMode()) { + for (int index = 1; index < serviceDecision.getSelectedPath().size(); index++) { + route.add(serviceDecision.getSelectedPath().get(index)); + } } CorridorReservationManager.ReservationDecision reservation = reservations.replaceRoute( @@ -1033,18 +1049,26 @@ public final class HistoricalDispatchSimulator { } // 预约成功后才提交新路径版本;证据中的接近段必定从设备完成事实返回的实际出口开始。 - execution.plannedPath = new ArrayList(serviceDecision.getSelectedPath()); execution.pathVersion++; execution.planningEvidence.add(new RoutePlanningEvidence( RoutePlanningEvidence.PlanningStage.APPROACH, execution.pathVersion, now, false, approachDecision)); - execution.planningEvidence.add(new RoutePlanningEvidence( - RoutePlanningEvidence.PlanningStage.SERVICE, - execution.pathVersion, now, false, serviceDecision, - historicalReferenceQuality(task, input, traffic, routePlanner, - routeEvaluator, vehicle.getVehicleId()))); - active.put(vehicle.getVehicleId(), new ActiveTask(execution, route, - approachDecision.getSelectedPath().size() - 1)); + if (execution.isBusinessMode()) { + execution.plannedPath = new ArrayList(); + BusinessTaskLeg leg = execution.currentLeg(); + execution.beginPhase(leg, route.get(0), route, now); + active.put(vehicle.getVehicleId(), new ActiveTask(execution, route, 0, + leg.getPhase(), execution.pathVersion)); + } else { + execution.plannedPath = new ArrayList(serviceDecision.getSelectedPath()); + execution.planningEvidence.add(new RoutePlanningEvidence( + RoutePlanningEvidence.PlanningStage.SERVICE, + execution.pathVersion, now, false, serviceDecision, + historicalReferenceQuality(task, input, traffic, routePlanner, + routeEvaluator, vehicle.getVehicleId()))); + active.put(vehicle.getVehicleId(), new ActiveTask(execution, route, + approachDecision.getSelectedPath().size() - 1)); + } floorTransfers.remove(vehicle.getVehicleId()); events.add(event(tick, now, "POST_TRANSFER_REPLAN", task.getSimulationTaskId(), vehicle.getVehicleId(), @@ -1311,9 +1335,11 @@ public final class HistoricalDispatchSimulator { SimulatedVehicleRuntime vehicle = vehicles.get(vehicleId); if (!vehicle.isAssignmentEligible()) continue; if (current.index >= current.route.size() - 1) { - // 路线已经走完时不再申请资源,直接进入归档和 WMS 回调闭环。 - complete(tick, now, current, vehicle, active, terminal, plc, - archive, wms, invariants, reservations, events); + // 原始路线直接归档;业务路线只关闭当前阶段,并从车辆事实位置规划下一阶段。 + finishReachedRoute(tick, now, current, vehicle, input, config, queue, + vehicles, active, terminal, routePlanner, routeEvaluator, + ledger, plc, archive, wms, invariants, reservations, + clearanceMoves, floorTransfers, clearancePlanner, events); progress = true; continue; } @@ -1392,8 +1418,11 @@ public final class HistoricalDispatchSimulator { continue; } // 资源已预占后才下发 PLC;只有确认 ACK 才能提交车辆位置变化。 - SimulatedPlcGateway.PlcResponse sent = plc.send( - current.execution.task.getSimulationTaskId(), vehicle, from, to); + String parentTaskId = current.execution.task.getSimulationTaskId(); + String commandId = current.phase == null ? parentTaskId : current.commandId(); + SimulatedPlcGateway.PlcResponse sent = current.phase == null + ? plc.send(parentTaskId, vehicle, from, to) + : plc.send(parentTaskId, commandId, vehicle, from, to); SimulatedPlcGateway.PlcResponse accepted = plc.accept(sent, vehicle); if (accepted.getReason() != SimulatedPlcGateway.PlcReason.ACK) { // 命令未被确认时撤销本步新增占用,但保留当前位置和路线索引以便安全重试。 @@ -1402,8 +1431,10 @@ public final class HistoricalDispatchSimulator { events.add(event(tick, now, "PLC_RETRY", current.execution.task.getSimulationTaskId(), vehicleId, accepted.getReason().name(), from, to)); - if (plc.attemptsFor(current.execution.task.getSimulationTaskId()) - % config.getRetryLimit() == 0) { + int commandAttempts = current.phase == null + ? plc.attemptsForTask(parentTaskId) + : plc.attemptsForCommand(commandId); + if (commandAttempts % config.getRetryLimit() == 0) { // 重试额度耗尽后释放未来走廊并回队,避免单车协议异常无限阻塞全局交通。 active.remove(vehicleId); reservations.releaseTask(current.execution.task.getSimulationTaskId()); @@ -1429,14 +1460,235 @@ public final class HistoricalDispatchSimulator { "ACK", from, to)); progress = true; if (current.index >= current.route.size() - 1) { - // 最后一条边确认后立即关闭任务,避免下一个 tick 才释放剩余走廊和归档。 - complete(tick, now, current, vehicle, active, terminal, plc, - archive, wms, invariants, reservations, events); + // 最后一条边 ACK 后立即提交当前阶段,业务下一段从这个真实位置重新规划。 + finishReachedRoute(tick, now, current, vehicle, input, config, queue, + vehicles, active, terminal, routePlanner, routeEvaluator, + ledger, plc, archive, wms, invariants, reservations, + clearanceMoves, floorTransfers, clearancePlanner, events); } } return progress; } + /** + * 处理一条活动路线的终点:旧回放直接结束任务,业务回放只提交当前阶段并继续下一段。 + */ + private void finishReachedRoute( + long tick, long now, ActiveTask current, SimulatedVehicleRuntime vehicle, + HistoricalSimulationInput input, SimulationConfig config, + SimulationTaskQueue queue, + Map vehicles, + Map active, + Map terminal, + RouteCandidatePlanner routePlanner, RouteQualityEvaluator routeEvaluator, + ResourceLedger ledger, SimulatedPlcGateway plc, + SimulationArchive archive, IdempotentWmsCompletionReceiver wms, + SimulationInvariantSummary invariants, + CorridorReservationManager reservations, + Map clearanceMoves, + Map floorTransfers, + ClearanceMovePlanner clearancePlanner, + List events) { + if (!current.execution.isBusinessMode()) { + complete(tick, now, current, vehicle, active, terminal, plc, + archive, wms, invariants, reservations, events); + return; + } + completeBusinessPhase(tick, now, current, vehicle, input, config, queue, + vehicles, active, terminal, routePlanner, routeEvaluator, + ledger, plc, archive, wms, invariants, reservations, + clearanceMoves, floorTransfers, clearancePlanner, events); + } + + /** + * 在路线终点 ACK 后提交业务阶段;放货只归档一次,最后一段才解除车辆与父任务的绑定。 + */ + private void completeBusinessPhase( + long tick, long now, ActiveTask current, SimulatedVehicleRuntime vehicle, + HistoricalSimulationInput input, SimulationConfig config, + SimulationTaskQueue queue, + Map vehicles, + Map active, + Map terminal, + RouteCandidatePlanner routePlanner, RouteQualityEvaluator routeEvaluator, + ResourceLedger ledger, SimulatedPlcGateway plc, + SimulationArchive archive, IdempotentWmsCompletionReceiver wms, + SimulationInvariantSummary invariants, + CorridorReservationManager reservations, + Map clearanceMoves, + Map floorTransfers, + ClearanceMovePlanner clearancePlanner, + List events) { + Execution execution = current.execution; + String taskId = execution.task.getSimulationTaskId(); + + if (!current.phaseCompletionApplied) { + BusinessTaskLeg completedLeg = execution.currentLeg(); + reservations.releaseTask(taskId); + PhaseProgress phase = execution.currentPhaseProgress(); + if (phase == null || phase.phase != completedLeg.getPhase()) { + throw new IllegalStateException("missing active phase evidence for " + taskId); + } + String completionReason = phaseCompletionReason(completedLeg.getPhase()); + phase.complete(now, completionReason); + execution.cargoState = completedLeg.getCargoStateOnArrival(); + current.phaseCompletionApplied = true; + events.add(event(tick, now, completionReason, taskId, + vehicle.getVehicleId(), "ACK", phase.fromSite, phase.toSite)); + + // 货物送达业务目标是唯一归档和 WMS 回调点;后续空车回前端不会重复业务副作用。 + if (completedLeg.completesBusiness() + && execution.businessCompletedMillis == null) { + if (!archive.complete(taskId, vehicle.getVehicleId(), now)) { + invariants.duplicateArchiveAttempt(); + } + boolean first = wms.receive(taskId); + boolean duplicate = wms.receive(taskId); + if (!first || duplicate) invariants.duplicateWmsBusinessEffect(); + execution.businessCompletedMillis = now; + events.add(event(tick, now, "BUSINESS_ARCHIVED", taskId, + vehicle.getVehicleId(), "CARGO_DEPOSITED", + vehicle.getCurrentSite(), vehicle.getCurrentSite())); + events.add(event(tick, now, "WMS_CALLBACK", taskId, + vehicle.getVehicleId(), "IDEMPOTENT_EFFECT=1;DELIVERIES=2", + vehicle.getCurrentSite(), vehicle.getCurrentSite())); + } + + if (completedLeg.releasesVehicle()) { + execution.vehicleReleasedMillis = now; + execution.vehicleReleaseStatus = VehicleReleaseStatus.COMPLETED; + terminal.put(taskId, result(execution, + SimulationTaskStatus.COMPLETED, "COMPLETED", plc, now)); + active.remove(vehicle.getVehicleId()); + events.add(event(tick, now, "VEHICLE_RELEASED", taskId, + vehicle.getVehicleId(), "BUSINESS_PHASES_COMPLETED", + vehicle.getCurrentSite(), vehicle.getCurrentSite())); + events.add(event(tick, now, "COMPLETE", taskId, + vehicle.getVehicleId(), "ARCHIVED_WMS_ACK", + vehicle.getCurrentSite(), vehicle.getCurrentSite())); + return; + } + execution.phaseIndex++; + } + + // 阶段切换不复用上一段路径,从车辆已确认的实际站点重新执行候选规划和走廊预约。 + activateNextBusinessPhase(tick, now, current, vehicle, input, config, queue, + vehicles, active, terminal, routePlanner, routeEvaluator, + ledger, plc, reservations, clearanceMoves, floorTransfers, + clearancePlanner, events); + } + + /** 从车辆当前位置规划父任务下一业务阶段,并保持同一车辆绑定。 */ + private boolean activateNextBusinessPhase( + long tick, long now, ActiveTask completedRoute, + SimulatedVehicleRuntime vehicle, HistoricalSimulationInput input, + SimulationConfig config, SimulationTaskQueue queue, + Map vehicles, + Map active, + Map terminal, + RouteCandidatePlanner routePlanner, RouteQualityEvaluator routeEvaluator, + ResourceLedger ledger, SimulatedPlcGateway plc, + CorridorReservationManager reservations, + Map clearanceMoves, + Map floorTransfers, + ClearanceMovePlanner clearancePlanner, + List events) { + Execution execution = completedRoute.execution; + BusinessTaskLeg leg = execution.currentLeg(); + String taskId = execution.task.getSimulationTaskId(); + String fromSite = vehicle.getCurrentSite(); + + // 先建立阶段槽位,预约或终点承诺产生的 HOLD 才能准确归入即将执行的阶段。 + PhaseProgress phase = execution.beginPhase(leg, fromSite, + Collections.singletonList(fromSite), now); + RouteCandidateSet candidates = routePlanner.plan(input.getGridMap(), + 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; + } + + TrafficRouteSnapshot traffic = trafficSnapshot(vehicles, active, ledger, reservations); + RoutePlanDecision decision = routeEvaluator.select(input.getGridMap(), candidates, + traffic, taskId, vehicle.getVehicleId()); + List route = new ArrayList(decision.getSelectedPath()); + String destinationHold = destinationCommitmentReason( + vehicle.getVehicleId(), leg.getTargetSite(), vehicles, + active, floorTransfers, input.getGridMap()); + if (destinationHold != null) { + if ("DESTINATION_OCCUPIED".equals(destinationHold)) { + planDestinationClearance(tick, now, taskId, vehicle.getVehicleId(), + route, vehicles, active, clearanceMoves, floorTransfers, + clearancePlanner, ledger, input.getGridMap(), events); + } + hold(execution, destinationHold); + events.add(event(tick, now, "PHASE_HOLD", taskId, + vehicle.getVehicleId(), destinationHold, + fromSite, leg.getTargetSite())); + return false; + } + + CorridorReservationManager.ReservationDecision reservation = reservations.reserve( + taskId, route, queuePriority(execution, now, config), + execution.arrivalMillis); + if (!reservation.isGranted()) { + hold(execution, reservation.getReason()); + events.add(event(tick, now, "PHASE_HOLD", taskId, + vehicle.getVehicleId(), reservation.getReason(), + fromSite, leg.getTargetSite())); + return false; + } + + execution.pathVersion++; + execution.planningEvidence.add(new RoutePlanningEvidence( + RoutePlanningEvidence.PlanningStage.SERVICE, + execution.pathVersion, now, false, decision)); + phase.plannedPath = new ArrayList(route); + if (leg.getPhase() == BusinessTaskPhase.LOADED_RETURN_TO_SOURCE_FRONT + || leg.getPhase() == BusinessTaskPhase.LOADED_TO_PLACE) { + appendBusinessPath(execution.plannedPath, route); + } + active.put(vehicle.getVehicleId(), new ActiveTask(execution, route, 0, + leg.getPhase(), execution.pathVersion)); + events.add(event(tick, now, "PHASE_ASSIGN", taskId, + vehicle.getVehicleId(), leg.getPhase().name(), + fromSite, leg.getTargetSite())); + return true; + } + + /** 把阶段路径拼成兼容旧报告的总路线,同时保留 TaskPhaseEvidence 中的边界。 */ + private void appendBusinessPath(List completePath, List phasePath) { + if (phasePath.isEmpty()) return; + int start = !completePath.isEmpty() + && completePath.get(completePath.size() - 1).equals(phasePath.get(0)) ? 1 : 0; + for (int index = start; index < phasePath.size(); index++) { + completePath.add(phasePath.get(index)); + } + } + + /** 将移动阶段转换成用户可识别的业务事实事件。 */ + private String phaseCompletionReason(BusinessTaskPhase phase) { + if (phase == BusinessTaskPhase.EMPTY_TO_PICKUP) return "PICKUP_CONFIRMED"; + if (phase == BusinessTaskPhase.LOADED_RETURN_TO_SOURCE_FRONT) { + return "SOURCE_FRONT_REACHED"; + } + if (phase == BusinessTaskPhase.LOADED_TO_PLACE) return "CARGO_DEPOSITED"; + if (phase == BusinessTaskPhase.EMPTY_RETURN_TO_TARGET_FRONT) { + return "TARGET_FRONT_REACHED"; + } + return "PHASE_COMPLETED"; + } + /** * 逐段执行为主任务让路的清障移动,并施加与正常任务相同的方向、交通、资源和 PLC 约束。 * @@ -1946,7 +2198,10 @@ public final class HistoricalDispatchSimulator { plc.attemptsFor(execution.task.getSimulationTaskId()), execution.retryCount, execution.holdReasons, execution.retryReasons, execution.planningEvidence, execution.rerouteCount, - execution.assignmentMode, execution.floorTransferJobs); + execution.assignmentMode, execution.floorTransferJobs, + execution.phaseEvidence(), execution.cargoState, + execution.businessCompletedMillis, execution.vehicleReleasedMillis, + execution.vehicleReleaseStatus, execution.skippedStageReasons()); } /** @@ -1976,6 +2231,8 @@ public final class HistoricalDispatchSimulator { private static void hold(Execution execution, String reason) { execution.holdCount++; increment(execution.holdReasons, reason); + PhaseProgress phase = execution.currentPhaseProgress(); + if (phase != null) phase.holdCount++; } /** @@ -1986,6 +2243,8 @@ public final class HistoricalDispatchSimulator { private static void retry(Execution execution, String reason) { execution.retryCount++; increment(execution.retryReasons, reason); + PhaseProgress phase = execution.currentPhaseProgress(); + if (phase != null) phase.retryCount++; } /** @@ -2020,6 +2279,15 @@ public final class HistoricalDispatchSimulator { private static final class Execution { private final HistoricalSimulationTask task; private final long arrivalMillis; + private final SimulationExecutionMode executionMode; + private final BusinessPhasePlan phasePlan; + private int phaseIndex; + private CargoState cargoState = CargoState.EMPTY; + private Long businessCompletedMillis; + private Long vehicleReleasedMillis; + private VehicleReleaseStatus vehicleReleaseStatus; + private final List phaseProgress = + new ArrayList(); private Long firstAssignedMillis; private String lastVehicleId; private List plannedPath = new ArrayList(); @@ -2045,9 +2313,90 @@ public final class HistoricalDispatchSimulator { * @param task 执行该处理所需的 task 输入。 * @param arrivalMillis 执行该处理所需的 arrival millis 输入。 */ - private Execution(HistoricalSimulationTask task, long arrivalMillis) { + private Execution(HistoricalSimulationTask task, long arrivalMillis, + SimulationExecutionMode executionMode) { this.task = task; this.arrivalMillis = arrivalMillis; + this.executionMode = executionMode; + if (executionMode == SimulationExecutionMode.SOURCE_ALIGNED_BUSINESS) { + this.phasePlan = new BusinessPhasePlanner().plan(task); + this.vehicleReleaseStatus = VehicleReleaseStatus.PENDING; + } else { + this.phasePlan = null; + this.vehicleReleaseStatus = VehicleReleaseStatus.NOT_REQUIRED; + } + } + + private boolean isBusinessMode() { + return executionMode == SimulationExecutionMode.SOURCE_ALIGNED_BUSINESS; + } + + private BusinessTaskLeg currentLeg() { + return phasePlan.getLegs().get(phaseIndex); + } + + private PhaseProgress beginPhase(BusinessTaskLeg leg, String fromSite, + List path, long now) { + PhaseProgress existing = currentPhaseProgress(); + if (existing != null && existing.phase == leg.getPhase()) { + existing.plannedPath = new ArrayList(path); + return existing; + } + PhaseProgress created = new PhaseProgress( + leg.getPhase(), fromSite, leg.getTargetSite(), path, now); + phaseProgress.add(created); + return created; + } + + private PhaseProgress currentPhaseProgress() { + if (phaseProgress.isEmpty()) return null; + PhaseProgress current = phaseProgress.get(phaseProgress.size() - 1); + return current.completedMillis == null ? current : null; + } + + private List phaseEvidence() { + List evidence = new ArrayList(); + for (PhaseProgress phase : phaseProgress) evidence.add(phase.freeze()); + return evidence; + } + + private List skippedStageReasons() { + return phasePlan == null ? Collections.emptyList() + : phasePlan.getSkippedStageReasons(); + } + } + + /** 模拟器内部的可变阶段记录,只在生成结果时冻结为 TaskPhaseEvidence。 */ + private static final class PhaseProgress { + private final BusinessTaskPhase phase; + private final String fromSite; + private final String toSite; + private List plannedPath; + private final long startedMillis; + private Long completedMillis; + private int holdCount; + private int retryCount; + private String completionReason; + + private PhaseProgress(BusinessTaskPhase phase, String fromSite, String toSite, + List plannedPath, long startedMillis) { + this.phase = phase; + this.fromSite = fromSite; + this.toSite = toSite; + this.plannedPath = new ArrayList(plannedPath); + this.startedMillis = startedMillis; + } + + private void complete(long now, String reason) { + if (completedMillis != null) return; + completedMillis = now; + completionReason = reason; + } + + private TaskPhaseEvidence freeze() { + return new TaskPhaseEvidence(phase, fromSite, toSite, plannedPath, + startedMillis, completedMillis, holdCount, retryCount, + completionReason); } } @@ -2093,6 +2442,8 @@ public final class HistoricalDispatchSimulator { private static final class ActiveTask { private final Execution execution; + private final BusinessTaskPhase phase; + private final int routeVersion; private List route; private int index; private int serviceStartIndex; @@ -2100,6 +2451,7 @@ public final class HistoricalDispatchSimulator { private final TrafficHoldTracker trafficHolds = new TrafficHoldTracker(TRAFFIC_HOLD_REROUTE_THRESHOLD); private boolean movedSinceReroute = true; + private boolean phaseCompletionApplied; /** * 创建 {@code ActiveTask} 实例并初始化其内部状态。 @@ -2110,12 +2462,26 @@ public final class HistoricalDispatchSimulator { * @param serviceStartIndex 服务段源点在完整执行路线中的下标。 */ private ActiveTask(Execution execution, List route, int serviceStartIndex) { + this(execution, route, serviceStartIndex, null, + execution.pathVersion); + } + + /** 创建一段绑定父任务和业务阶段的活动路线,供 PLC 命令生成稳定幂等键。 */ + private ActiveTask(Execution execution, List route, int serviceStartIndex, + BusinessTaskPhase phase, int routeVersion) { this.execution = execution; + this.phase = phase; + this.routeVersion = routeVersion; this.route = Collections.unmodifiableList(new ArrayList(route)); this.serviceStartIndex = serviceStartIndex; if (serviceStartIndex == 0) traversedServicePath.add(route.get(0)); } + private String commandId() { + return execution.task.getSimulationTaskId() + ":" + phase + + ":v" + routeVersion + ":e" + index; + } + /** 返回车辆是否已经到达业务源点并开始执行服务段。 */ private boolean hasReachedServiceSource() { return index >= serviceStartIndex || !traversedServicePath.isEmpty(); diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulationTaskResult.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulationTaskResult.java index bd02e98..457a305 100644 --- a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulationTaskResult.java +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulationTaskResult.java @@ -28,6 +28,12 @@ public final class SimulationTaskResult { private final int rerouteCount; private final AssignmentMode assignmentMode; private final List floorTransferJobs; + private final List phaseEvidence; + private final CargoState cargoState; + private final Long businessCompletedMillis; + private final Long vehicleReleasedMillis; + private final VehicleReleaseStatus vehicleReleaseStatus; + private final List skippedStageReasons; /** * 创建 {@code SimulationTaskResult} 实例并初始化其内部状态。 @@ -64,6 +70,33 @@ public final class SimulationTaskResult { List planningEvidence, int rerouteCount, AssignmentMode assignmentMode, List floorTransferJobs) { + this(simulationTaskId, record, status, reason, simulatedVehicleId, + plannedPath, assignedVehicles, arrivalMillis, firstAssignedMillis, + completedMillis, holdCount, protocolAttempts, retryCount, + holdReasons, retryReasons, planningEvidence, rerouteCount, + assignmentMode, floorTransferJobs, + Collections.emptyList(), CargoState.EMPTY, + null, null, VehicleReleaseStatus.NOT_REQUIRED, + Collections.emptyList()); + } + + /** 创建同时表达业务完成、车辆释放和逐阶段路径证据的完整结果。 */ + SimulationTaskResult(String simulationTaskId, HistoricalQueueRecord record, + SimulationTaskStatus status, String reason, + String simulatedVehicleId, List plannedPath, + List assignedVehicles, long arrivalMillis, + Long firstAssignedMillis, Long completedMillis, + int holdCount, int protocolAttempts, int retryCount, + Map holdReasons, + Map retryReasons, + List planningEvidence, + int rerouteCount, AssignmentMode assignmentMode, + List floorTransferJobs, + List phaseEvidence, + CargoState cargoState, Long businessCompletedMillis, + Long vehicleReleasedMillis, + VehicleReleaseStatus vehicleReleaseStatus, + List skippedStageReasons) { this.simulationTaskId = simulationTaskId; this.record = record; this.status = status; @@ -83,6 +116,12 @@ public final class SimulationTaskResult { this.rerouteCount = rerouteCount; this.assignmentMode = assignmentMode; this.floorTransferJobs = immutable(floorTransferJobs); + this.phaseEvidence = immutable(phaseEvidence); + this.cargoState = cargoState; + this.businessCompletedMillis = businessCompletedMillis; + this.vehicleReleasedMillis = vehicleReleasedMillis; + this.vehicleReleaseStatus = vehicleReleaseStatus; + this.skippedStageReasons = immutable(skippedStageReasons); } /** @@ -205,6 +244,24 @@ public final class SimulationTaskResult { /** 返回该父任务产生的换层业务效果数;重复提交不会增加该计数。 */ public int getFloorTransferCount() { return floorTransferJobs.size(); } + + /** 返回按真实执行顺序冻结的每阶段路线、时间、HOLD 和重试证据。 */ + public List getPhaseEvidence() { return phaseEvidence; } + + /** 返回父任务货物的最终状态;业务模式完成时应为 {@code DEPOSITED}。 */ + public CargoState getCargoState() { return cargoState; } + + /** 返回货物入位并完成归档/WMS 回调的仿真时间。 */ + public Long getBusinessCompletedMillis() { return businessCompletedMillis; } + + /** 返回同一车辆完成必要回前端动作并解除占车关系的仿真时间。 */ + public Long getVehicleReleasedMillis() { return vehicleReleasedMillis; } + + /** 返回业务完成后的车辆清理动作是否仍待执行、已完成或失败。 */ + public VehicleReleaseStatus getVehicleReleaseStatus() { return vehicleReleaseStatus; } + + /** 返回未生成通道阶段的明确原因,不允许前端自行猜测货位分组。 */ + public List getSkippedStageReasons() { return skippedStageReasons; } /** * 返回当前对象保存的“waiting millis”信息。 * diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/TaskPhaseEvidence.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/TaskPhaseEvidence.java new file mode 100644 index 0000000..187ff40 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/TaskPhaseEvidence.java @@ -0,0 +1,59 @@ +package com.ft546.wcs.shadow.history.simulation; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * 一次业务移动阶段实际采用的路径和执行结果。 + * + *

每个阶段单独保存起终点、规划路径、时间、HOLD 和 PLC 重试数,页面无需把多段路线猜成一条; + * 未完成阶段允许 {@code completedMillis} 和 {@code completionReason} 为空,供失败/等待快照诊断。 + */ +public final class TaskPhaseEvidence { + private final BusinessTaskPhase phase; + private final String fromSite; + private final String toSite; + private final List plannedPath; + private final long startedMillis; + private final Long completedMillis; + private final int holdCount; + private final int retryCount; + private final String completionReason; + + public TaskPhaseEvidence(BusinessTaskPhase phase, String fromSite, String toSite, + List plannedPath, long startedMillis, + Long completedMillis, int holdCount, int retryCount, + String completionReason) { + if (phase == null || blank(fromSite) || blank(toSite) || plannedPath == null + || plannedPath.isEmpty() || startedMillis < 0L + || (completedMillis != null && completedMillis < startedMillis) + || holdCount < 0 || retryCount < 0) { + throw new IllegalArgumentException("valid task phase evidence is required"); + } + this.phase = phase; + this.fromSite = fromSite; + this.toSite = toSite; + this.plannedPath = Collections.unmodifiableList( + new ArrayList(plannedPath)); + this.startedMillis = startedMillis; + this.completedMillis = completedMillis; + this.holdCount = holdCount; + this.retryCount = retryCount; + this.completionReason = completionReason; + } + + public BusinessTaskPhase getPhase() { return phase; } + public String getFromSite() { return fromSite; } + public String getToSite() { return toSite; } + public List getPlannedPath() { return plannedPath; } + public long getStartedMillis() { return startedMillis; } + public Long getCompletedMillis() { return completedMillis; } + public int getHoldCount() { return holdCount; } + public int getRetryCount() { return retryCount; } + public String getCompletionReason() { return completionReason; } + + private static boolean blank(String value) { + return value == null || value.trim().isEmpty(); + } +} 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 new file mode 100644 index 0000000..2f4a514 --- /dev/null +++ b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/HistoricalDispatchSimulatorTwoStageTest.java @@ -0,0 +1,134 @@ +package com.ft546.wcs.shadow.history.simulation; + +import com.ft546.wcs.demo.model.DispatchTask; +import com.ft546.wcs.demo.model.Site; +import com.ft546.wcs.demo.model.VehicleSnapshot; +import com.ft546.wcs.demo.traffic.GridMap; +import com.ft546.wcs.shadow.history.HistoricalQueueRecord; +import com.ft546.wcs.shadow.map.EffectiveTrafficMap; +import com.ft546.wcs.shadow.map.TrafficMapCompiler; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +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 org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class HistoricalDispatchSimulatorTwoStageTest { + /** 入库在货物送入货位时归档和回调,随后同一车辆空车回前端才释放。 */ + @Test + void inboundCallbacksAtDepositThenReturnsTheSameVehicleEmptyToFront() { + HistoricalDispatchSimulationResult result = run(task( + DispatchTask.TaskType.INBOUND, "PICK", "BIN", null, "FRONT")); + SimulationTaskResult task = result.getTaskResults().get(0); + + assertEquals(Arrays.asList(EMPTY_TO_PICKUP, LOADED_TO_PLACE, + EMPTY_RETURN_TO_TARGET_FRONT), phases(task)); + assertEquals(Arrays.asList("PICK", "BIN", "FRONT"), targets(task)); + assertTrue(task.getBusinessCompletedMillis() < task.getVehicleReleasedMillis()); + assertEquals(CargoState.DEPOSITED, task.getCargoState()); + assertEquals(VehicleReleaseStatus.COMPLETED, task.getVehicleReleaseStatus()); + assertEquals(Collections.singletonList("1"), task.getAssignedVehicles()); + assertEquals(1, result.getArchiveCount()); + assertEquals(2, result.getWmsDeliveryAttempts()); + assertEquals(1, result.getWmsBusinessEffects()); + assertTrue(result.getEvents().stream().anyMatch(event -> + "VEHICLE_RELEASED".equals(event.getType()) + && "FRONT".equals(event.getToSite()))); + } + + /** 出库取货后先载货退出源货道前端,再到业务目标,送达即归档并释放车辆。 */ + @Test + void outboundCarriesCargoToSourceFrontBeforeBusinessDestination() { + HistoricalDispatchSimulationResult result = run(task( + DispatchTask.TaskType.OUTBOUND, "BIN", "EXIT", "MID", null)); + SimulationTaskResult task = result.getTaskResults().get(0); + + assertEquals(Arrays.asList(EMPTY_TO_PICKUP, + LOADED_RETURN_TO_SOURCE_FRONT, LOADED_TO_PLACE), phases(task)); + assertEquals(Arrays.asList("BIN", "MID", "EXIT"), targets(task)); + assertEquals(task.getBusinessCompletedMillis(), task.getVehicleReleasedMillis()); + assertEquals(VehicleReleaseStatus.COMPLETED, task.getVehicleReleaseStatus()); + assertEquals(1, result.getArchiveCount()); + assertEquals(1, result.getWmsBusinessEffects()); + } + + private HistoricalDispatchSimulationResult run(HistoricalSimulationTask task) { + GridMap grid = grid(); + 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()), + SimulationExecutionMode.SOURCE_ALIGNED_BUSINESS, + new HistoricalInputAudit(1, 1, 0, 1, 0), + ChannelFrontTopology.from(Collections.emptyList())); + return new HistoricalDispatchSimulator().simulate(input, + new SimulationConfig(100, 30_000L, 3, 100, 100), + DeterministicFaultPlan.none()); + } + + private HistoricalSimulationTask task(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, + 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, + created, created.plusMinutes(1), source, target, + Collections.singletonList("HISTORY_ONLY"), 0, + sourceFront, targetFront, + Collections.emptyList()); + } + + 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")); + } + grid.connect("V", "PICK"); + grid.connect("PICK", "MID"); + grid.connect("MID", "BIN"); + grid.connect("MID", "FRONT"); + grid.connect("FRONT", "EXIT"); + return grid; + } + + private EffectiveTrafficMap traffic() { + LinkedHashSet nodes = new LinkedHashSet( + Arrays.asList("V", "PICK", "MID", "BIN", "FRONT", "EXIT")); + 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")); + return new TrafficMapCompiler().compile(new TrafficMapCompiler.MapDefinition( + "TWO-STAGE", 1L, "TEST", true, "test", "test", + nodes, edges, Collections.emptyMap(), + Collections.emptySet(), Collections.emptySet())); + } + + private List phases(SimulationTaskResult task) { + return task.getPhaseEvidence().stream().map(TaskPhaseEvidence::getPhase) + .collect(Collectors.toList()); + } + + private List targets(SimulationTaskResult task) { + return task.getPhaseEvidence().stream().map(TaskPhaseEvidence::getToSite) + .collect(Collectors.toList()); + } +}