feat(javaTest): model source-aligned task phases
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.ft546.wcs.shadow.history.simulation;
|
||||
|
||||
/** 记录父任务货物是尚未取走、正在车上,还是已经放到业务目标。 */
|
||||
public enum CargoState {
|
||||
EMPTY,
|
||||
LOADED,
|
||||
DEPOSITED
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.ft546.wcs.shadow.history.simulation;
|
||||
|
||||
/** 区分业务完成后是否还需要车辆回前端,以及该清理动作的最终结果。 */
|
||||
public enum VehicleReleaseStatus {
|
||||
NOT_REQUIRED,
|
||||
PENDING,
|
||||
COMPLETED,
|
||||
FAILED
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user