feat(javaTest): model source-aligned task phases

This commit is contained in:
zgw
2026-08-07 14:07:54 +08:00
parent 93ef274b63
commit 48f0cad832
7 changed files with 323 additions and 0 deletions

View File

@@ -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<BusinessTaskLeg> legs;
private final List<String> skippedStageReasons;
public BusinessPhasePlan(List<BusinessTaskLeg> legs) {
this(legs, Collections.<String>emptyList());
}
public BusinessPhasePlan(List<BusinessTaskLeg> legs, List<String> skippedStageReasons) {
if (legs == null || legs.isEmpty() || skippedStageReasons == null) {
throw new IllegalArgumentException("business legs and skipped reasons are required");
}
List<BusinessTaskLeg> copy = new ArrayList<BusinessTaskLeg>(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<String>(skippedStageReasons));
}
/** 返回中心调度必须逐段规划和执行的业务顺序。 */
public List<BusinessTaskLeg> 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<String> getSkippedStageReasons() { return skippedStageReasons; }
}

View File

@@ -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<BusinessTaskLeg> legs = new ArrayList<BusinessTaskLeg>();
List<String> skipped = new ArrayList<String>();
// 所有业务都先让中心调度选车,并把空车送到真实取货点;到达 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();
}
}

View File

@@ -0,0 +1,64 @@
package com.ft546.wcs.shadow.history.simulation;
/**
* 业务父任务中一次需要真实规划和移动的路段。
*
* <p>路段结束语义随目标一起固定,模拟器到达目标并收到 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();
}
}

View File

@@ -0,0 +1,21 @@
package com.ft546.wcs.shadow.history.simulation;
/**
* 一条业务父任务在中心调度中的阶段名称。
*
* <p>其中移动阶段会进入 {@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
}

View File

@@ -0,0 +1,8 @@
package com.ft546.wcs.shadow.history.simulation;
/** 记录父任务货物是尚未取走、正在车上,还是已经放到业务目标。 */
public enum CargoState {
EMPTY,
LOADED,
DEPOSITED
}

View File

@@ -0,0 +1,9 @@
package com.ft546.wcs.shadow.history.simulation;
/** 区分业务完成后是否还需要车辆回前端,以及该清理动作的最终结果。 */
public enum VehicleReleaseStatus {
NOT_REQUIRED,
PENDING,
COMPLETED,
FAILED
}

View File

@@ -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.<String>emptyList(), 0,
sourceFront, targetFront,
Collections.<HistoricalTaskSnapshotEvidence>emptyList());
}
private List<BusinessTaskPhase> phases(BusinessPhasePlan plan) {
return plan.getLegs().stream().map(BusinessTaskLeg::getPhase)
.collect(Collectors.toList());
}
private List<String> targets(BusinessPhasePlan plan) {
return plan.getLegs().stream().map(BusinessTaskLeg::getTargetSite)
.collect(Collectors.toList());
}
}