diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/BusinessPhasePlan.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/BusinessPhasePlan.java new file mode 100644 index 0000000..e41d7b6 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/BusinessPhasePlan.java @@ -0,0 +1,50 @@ +package com.ft546.wcs.shadow.history.simulation; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** 一条父任务不可变的移动阶段顺序,以及没有生成通道阶段的明确原因。 */ +public final class BusinessPhasePlan { + private final List legs; + private final List skippedStageReasons; + + public BusinessPhasePlan(List legs) { + this(legs, Collections.emptyList()); + } + + public BusinessPhasePlan(List legs, List skippedStageReasons) { + if (legs == null || legs.isEmpty() || skippedStageReasons == null) { + throw new IllegalArgumentException("business legs and skipped reasons are required"); + } + List copy = new ArrayList(legs); + for (int i = 0; i < copy.size(); i++) { + if (copy.get(i) == null) { + throw new IllegalArgumentException("business leg is required"); + } + // 相邻阶段到同一站点不会产生真实移动,通常说明错误地把业务目标当成通道前端。 + if (i > 0 && copy.get(i - 1).getTargetSite() + .equals(copy.get(i).getTargetSite())) { + throw new IllegalArgumentException("ZERO_LENGTH_PHASE:" + + copy.get(i).getPhase()); + } + } + this.legs = Collections.unmodifiableList(copy); + this.skippedStageReasons = Collections.unmodifiableList( + new ArrayList(skippedStageReasons)); + } + + /** 返回中心调度必须逐段规划和执行的业务顺序。 */ + public List getLegs() { return legs; } + + /** 返回指定阶段;阶段不适用于当前业务类型时明确报错,避免调用方误取其他路段。 */ + public BusinessTaskLeg leg(BusinessTaskPhase phase) { + for (BusinessTaskLeg leg : legs) { + if (leg.getPhase() == phase) return leg; + } + throw new IllegalArgumentException("phase is not planned: " + phase); + } + + /** 返回因货位不在通道组或端点本身就是前端而跳过的阶段原因。 */ + public List getSkippedStageReasons() { return skippedStageReasons; } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/BusinessPhasePlanner.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/BusinessPhasePlanner.java new file mode 100644 index 0000000..77f055e --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/BusinessPhasePlanner.java @@ -0,0 +1,67 @@ +package com.ft546.wcs.shadow.history.simulation; + +import com.ft546.wcs.demo.model.DispatchTask; + +import java.util.ArrayList; +import java.util.List; + +/** 根据父任务业务类型和权威通道前端,生成需要独立规划的移动阶段。 */ +public final class BusinessPhasePlanner { + public BusinessPhasePlan plan(HistoricalSimulationTask task) { + if (task == null || blank(task.getPhysicalSource()) || blank(task.getPhysicalTarget())) { + throw new IllegalArgumentException("business task and endpoints are required"); + } + if (task.getPhysicalSource().equals(task.getPhysicalTarget())) { + throw new IllegalArgumentException("business source and target must differ"); + } + + List legs = new ArrayList(); + List skipped = new ArrayList(); + + // 所有业务都先让中心调度选车,并把空车送到真实取货点;到达 ACK 后货物才转为 LOADED。 + legs.add(BusinessTaskLeg.emptyToPickup(task.getPhysicalSource())); + + boolean sourceOperation = task.getTaskType() == DispatchTask.TaskType.OUTBOUND + || task.getTaskType() == DispatchTask.TaskType.RELOCATION; + boolean targetOperation = task.getTaskType() == DispatchTask.TaskType.INBOUND + || task.getTaskType() == DispatchTask.TaskType.RELOCATION; + boolean returnToTargetFront = targetOperation && task.requiresTargetFrontMovement(); + + // 出库和移库必须载货退出源货道;没有源前端时保留为普通点对点作业而不猜测站点。 + if (sourceOperation) { + if (task.requiresSourceFrontMovement()) { + legs.add(BusinessTaskLeg.loadedReturnToSourceFront(task.getSourceFrontSite())); + } else if (task.getSourceFrontSite() == null) { + skipped.add("SOURCE_NOT_IN_CARGO_GROUP"); + } else { + skipped.add("SOURCE_IS_FRONT"); + } + } + + // 放货是唯一业务完成点;若之后还要空车回目标前端,此时只归档业务而不释放车辆。 + legs.add(BusinessTaskLeg.loadedToPlace( + task.getPhysicalTarget(), !returnToTargetFront)); + + // 入库和移库在放货后自动回目标通道前端,完成后才允许车辆接收下一条任务。 + if (targetOperation) { + if (returnToTargetFront) { + legs.add(BusinessTaskLeg.emptyReturnToTargetFront(task.getTargetFrontSite())); + } else if (task.getTargetFrontSite() == null) { + skipped.add("TARGET_NOT_IN_CARGO_GROUP"); + } else { + skipped.add("TARGET_IS_FRONT"); + } + } + + // A/B 两端都没有生产货位分组时,用一个稳定原因替代两个实现细节原因,便于 UI 审计。 + if (task.getSourceFrontSite() == null && task.getTargetFrontSite() == null) { + skipped.clear(); + skipped.add("NO_CHANNEL_OPERATION"); + } + return new BusinessPhasePlan(legs, skipped); + } + + private boolean blank(String value) { + return value == null || value.trim().isEmpty(); + } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/BusinessTaskLeg.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/BusinessTaskLeg.java new file mode 100644 index 0000000..b7fd403 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/BusinessTaskLeg.java @@ -0,0 +1,64 @@ +package com.ft546.wcs.shadow.history.simulation; + +/** + * 业务父任务中一次需要真实规划和移动的路段。 + * + *

路段结束语义随目标一起固定,模拟器到达目标并收到 PLC ACK 后,才能据此改变货物状态、 + * 归档业务或释放车辆,避免仅凭历史完成状态跳过真实移动。 + */ +public final class BusinessTaskLeg { + private final BusinessTaskPhase phase; + private final String targetSite; + private final CargoState cargoStateOnArrival; + private final boolean completesBusiness; + private final boolean releasesVehicle; + + private BusinessTaskLeg(BusinessTaskPhase phase, String targetSite, + CargoState cargoStateOnArrival, + boolean completesBusiness, boolean releasesVehicle) { + if (phase == null || cargoStateOnArrival == null || blank(targetSite)) { + throw new IllegalArgumentException( + "phase, target site and arrival cargo state are required"); + } + this.phase = phase; + this.targetSite = targetSite.trim(); + this.cargoStateOnArrival = cargoStateOnArrival; + this.completesBusiness = completesBusiness; + this.releasesVehicle = releasesVehicle; + } + + /** 空车到取货点并完成取货确认,因此下一段开始时货物已经绑定到该车。 */ + public static BusinessTaskLeg emptyToPickup(String targetSite) { + return new BusinessTaskLeg(BusinessTaskPhase.EMPTY_TO_PICKUP, + targetSite, CargoState.LOADED, false, false); + } + + /** 出库或移库取货后,载货车辆先退出源货道到通道前端。 */ + public static BusinessTaskLeg loadedReturnToSourceFront(String targetSite) { + return new BusinessTaskLeg(BusinessTaskPhase.LOADED_RETURN_TO_SOURCE_FRONT, + targetSite, CargoState.LOADED, false, false); + } + + /** 载货到业务目标并完成放货;是否立即释放取决于后面是否还有空车回前端。 */ + public static BusinessTaskLeg loadedToPlace(String targetSite, + boolean releasesVehicle) { + return new BusinessTaskLeg(BusinessTaskPhase.LOADED_TO_PLACE, + targetSite, CargoState.DEPOSITED, true, releasesVehicle); + } + + /** 入库或移库放货后,车辆空车回目标货道前端,此时才解除父任务占车关系。 */ + public static BusinessTaskLeg emptyReturnToTargetFront(String targetSite) { + return new BusinessTaskLeg(BusinessTaskPhase.EMPTY_RETURN_TO_TARGET_FRONT, + targetSite, CargoState.DEPOSITED, false, true); + } + + public BusinessTaskPhase getPhase() { return phase; } + public String getTargetSite() { return targetSite; } + public CargoState getCargoStateOnArrival() { return cargoStateOnArrival; } + public boolean completesBusiness() { return completesBusiness; } + public boolean releasesVehicle() { return releasesVehicle; } + + private static boolean blank(String value) { + return value == null || value.trim().isEmpty(); + } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/BusinessTaskPhase.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/BusinessTaskPhase.java new file mode 100644 index 0000000..2bc94b5 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/BusinessTaskPhase.java @@ -0,0 +1,21 @@ +package com.ft546.wcs.shadow.history.simulation; + +/** + * 一条业务父任务在中心调度中的阶段名称。 + * + *

其中移动阶段会进入 {@link BusinessTaskLeg};其余名称用于后续 PLC、换层、归档和故障证据, + * 使报告能区分“正在去取货”“货物已完成”和“车辆已释放”。 + */ +public enum BusinessTaskPhase { + WAITING_ASSIGNMENT, + FLOOR_TRANSFER_TO_PICKUP, + EMPTY_TO_PICKUP, + PICKUP_CONFIRMED, + LOADED_RETURN_TO_SOURCE_FRONT, + LOADED_TO_PLACE, + CARGO_DEPOSITED, + EMPTY_RETURN_TO_TARGET_FRONT, + BUSINESS_COMPLETED, + VEHICLE_RELEASED, + FAILED +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/CargoState.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/CargoState.java new file mode 100644 index 0000000..5c2edf3 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/CargoState.java @@ -0,0 +1,8 @@ +package com.ft546.wcs.shadow.history.simulation; + +/** 记录父任务货物是尚未取走、正在车上,还是已经放到业务目标。 */ +public enum CargoState { + EMPTY, + LOADED, + DEPOSITED +} diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/VehicleReleaseStatus.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/VehicleReleaseStatus.java new file mode 100644 index 0000000..9445fc9 --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/VehicleReleaseStatus.java @@ -0,0 +1,9 @@ +package com.ft546.wcs.shadow.history.simulation; + +/** 区分业务完成后是否还需要车辆回前端,以及该清理动作的最终结果。 */ +public enum VehicleReleaseStatus { + NOT_REQUIRED, + PENDING, + COMPLETED, + FAILED +} diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/BusinessPhasePlannerTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/BusinessPhasePlannerTest.java new file mode 100644 index 0000000..1730516 --- /dev/null +++ b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/BusinessPhasePlannerTest.java @@ -0,0 +1,104 @@ +package com.ft546.wcs.shadow.history.simulation; + +import com.ft546.wcs.demo.model.DispatchTask; +import com.ft546.wcs.shadow.history.HistoricalQueueRecord; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BusinessPhasePlannerTest { + private final BusinessPhasePlanner planner = new BusinessPhasePlanner(); + + /** 入库货物入位即完成业务,但车辆必须继续空车返回目标通道前端后才能释放。 */ + @Test + void inboundDepositsThenReturnsEmptyToTargetFront() { + BusinessPhasePlan plan = planner.plan(task(DispatchTask.TaskType.INBOUND, + "050101", "030401", null, "050401")); + + assertEquals(Arrays.asList(EMPTY_TO_PICKUP, LOADED_TO_PLACE, + EMPTY_RETURN_TO_TARGET_FRONT), phases(plan)); + assertTrue(plan.leg(LOADED_TO_PLACE).completesBusiness()); + assertFalse(plan.leg(LOADED_TO_PLACE).releasesVehicle()); + assertEquals(CargoState.DEPOSITED, + plan.leg(LOADED_TO_PLACE).getCargoStateOnArrival()); + assertTrue(plan.leg(EMPTY_RETURN_TO_TARGET_FRONT).releasesVehicle()); + } + + /** 出库载货后先退出源货道前端,再把货物送到业务目标,最后一步同时完成和释放。 */ + @Test + void outboundReturnsLoadedCargoToSourceFrontBeforeDelivery() { + BusinessPhasePlan plan = planner.plan(task(DispatchTask.TaskType.OUTBOUND, + "030301", "070101", "050301", null)); + + assertEquals(Arrays.asList(EMPTY_TO_PICKUP, + LOADED_RETURN_TO_SOURCE_FRONT, LOADED_TO_PLACE), phases(plan)); + assertEquals(Arrays.asList("030301", "050301", "070101"), targets(plan)); + assertEquals(CargoState.LOADED, + plan.leg(LOADED_RETURN_TO_SOURCE_FRONT).getCargoStateOnArrival()); + assertTrue(plan.leg(LOADED_TO_PLACE).completesBusiness()); + assertTrue(plan.leg(LOADED_TO_PLACE).releasesVehicle()); + } + + /** 移库既要从源货道退出,也要在目标货位放货后空车返回目标货道前端。 */ + @Test + void relocationAppliesBothChannelFrontMovements() { + BusinessPhasePlan plan = planner.plan(task(DispatchTask.TaskType.RELOCATION, + "030301", "040401", "050301", "050401")); + + assertEquals(Arrays.asList(EMPTY_TO_PICKUP, LOADED_RETURN_TO_SOURCE_FRONT, + LOADED_TO_PLACE, EMPTY_RETURN_TO_TARGET_FRONT), phases(plan)); + assertEquals(Arrays.asList("030301", "050301", "040401", "050401"), + targets(plan)); + assertTrue(plan.leg(EMPTY_RETURN_TO_TARGET_FRONT).releasesVehicle()); + } + + /** 外部站点不属于货位组时保持普通取放两段,不猜测任何通道前端。 */ + @Test + void noFrontMappingKeepsPointToPointFlowAndRecordsWhyStagesWereSkipped() { + BusinessPhasePlan plan = planner.plan(task(DispatchTask.TaskType.INBOUND, + "PICK", "EXIT", null, null)); + + assertEquals(Arrays.asList(EMPTY_TO_PICKUP, LOADED_TO_PLACE), phases(plan)); + assertEquals(Collections.singletonList("NO_CHANNEL_OPERATION"), + plan.getSkippedStageReasons()); + assertTrue(plan.leg(LOADED_TO_PLACE).releasesVehicle()); + assertThrows(UnsupportedOperationException.class, + () -> plan.getLegs().add(plan.getLegs().get(0))); + } + + private HistoricalSimulationTask task(DispatchTask.TaskType type, + String source, String target, + String sourceFront, String targetFront) { + HistoricalQueueRecord record = new HistoricalQueueRecord( + "GUID", "TASK", type.name(), 2, source, target, "", + "2026-08-07 08:00:00", null, null); + return new HistoricalSimulationTask("TASK", record, type, 2, + LocalDateTime.of(2026, 8, 7, 8, 0), null, + source, target, Collections.emptyList(), 0, + sourceFront, targetFront, + Collections.emptyList()); + } + + private List phases(BusinessPhasePlan plan) { + return plan.getLegs().stream().map(BusinessTaskLeg::getPhase) + .collect(Collectors.toList()); + } + + private List targets(BusinessPhasePlan plan) { + return plan.getLegs().stream().map(BusinessTaskLeg::getTargetSite) + .collect(Collectors.toList()); + } +}