diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/PointToPointSimulationInputFactory.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/PointToPointSimulationInputFactory.java
new file mode 100644
index 0000000..511fe1b
--- /dev/null
+++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/PointToPointSimulationInputFactory.java
@@ -0,0 +1,65 @@
+package com.ft546.wcs.shadow.history.simulation;
+
+import com.ft546.wcs.demo.model.DispatchTask;
+import com.ft546.wcs.shadow.history.HistoricalQueueRecord;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * 将审计台的一次 A 到 B 请求转换为仿真器可执行的单任务输入。
+ *
+ *
该工厂位于 simulation 包中,只负责构建领域输入,不执行选车或路径规划。
+ */
+public final class PointToPointSimulationInputFactory {
+ public static final String TASK_ID = "VISUALIZER-TASK-1";
+ private static final LocalDateTime CREATED_AT = LocalDateTime.of(2026, 8, 5, 0, 0);
+ private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+ /**
+ * 基于权威地图和初始车队创建唯一的审计任务。
+ */
+ public HistoricalSimulationInput create(
+ HistoricalSimulationInput base, String sourceSite, String targetSite,
+ DispatchTask.TaskType taskType, int priority, String historicalVehicleId,
+ List historicalPath) {
+ if (base == null || taskType == null) {
+ throw new IllegalArgumentException("BAD_REQUEST: base input and task type are required");
+ }
+ if (base.getGridMap().getSite(sourceSite) == null) {
+ throw new IllegalArgumentException("UNKNOWN_SITE: source " + sourceSite);
+ }
+ if (base.getGridMap().getSite(targetSite) == null) {
+ throw new IllegalArgumentException("UNKNOWN_SITE: target " + targetSite);
+ }
+ if (sourceSite.equals(targetSite)) {
+ throw new IllegalArgumentException("SAME_ENDPOINT: source and target must differ");
+ }
+
+ List history = historicalPath == null
+ ? Collections.emptyList() : historicalPath;
+ String pathInfo = String.join(",", history);
+ HistoricalQueueRecord record = new HistoricalQueueRecord(
+ TASK_ID, TASK_ID, taskTypeCode(taskType), priority,
+ sourceSite, targetSite, pathInfo,
+ TIME.format(CREATED_AT), TIME.format(CREATED_AT.plusMinutes(1)),
+ historicalVehicleId);
+ HistoricalSimulationTask task = new HistoricalSimulationTask(
+ TASK_ID, record, taskType, priority, CREATED_AT,
+ CREATED_AT.plusMinutes(1), sourceSite, targetSite, history, 0);
+ return new HistoricalSimulationInput(
+ Collections.singletonList(task), Collections.emptyList(),
+ base.getGridMap(), base.getTrafficMap(), base.getInitialVehicles());
+ }
+
+ private String taskTypeCode(DispatchTask.TaskType taskType) {
+ switch (taskType) {
+ case INBOUND: return "1";
+ case OUTBOUND: return "2";
+ case RELOCATION: return "3";
+ default: throw new IllegalArgumentException("INVALID_TASK_TYPE: " + taskType);
+ }
+ }
+}
diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/PointToPointSimulationRequest.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/PointToPointSimulationRequest.java
new file mode 100644
index 0000000..33a54c0
--- /dev/null
+++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/PointToPointSimulationRequest.java
@@ -0,0 +1,128 @@
+package com.ft546.wcs.shadow.history.visualizer;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.ft546.wcs.demo.model.DispatchTask;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * 描述审计台发起的一次 A 到 B 单任务仿真请求。
+ *
+ * 请求中的历史车辆和历史路径只用于结果对照;调度中心仍会基于当前车辆快照重新选车,
+ * 并在完整地图上重新规划路径。
+ */
+public final class PointToPointSimulationRequest {
+ /** 审计台支持的确定性故障场景。 */
+ public enum FaultPreset {
+ NONE,
+ PLC_FIRST_SEND_FAILURE,
+ VEHICLE_RESTART,
+ LOW_BATTERY,
+ DISCONNECT_RECONNECT
+ }
+
+ @JsonProperty("sourceSite")
+ private String sourceSite;
+ @JsonProperty("targetSite")
+ private String targetSite;
+ @JsonProperty("taskType")
+ private String taskType;
+ @JsonProperty("priority")
+ private Integer priority;
+ @JsonProperty("acceleration")
+ private Integer acceleration;
+ @JsonProperty("historicalVehicleId")
+ private String historicalVehicleId;
+ @JsonProperty("historicalPath")
+ private List historicalPath;
+ @JsonProperty("faultPreset")
+ private String faultPreset;
+
+ /** 供 Jackson 反序列化请求体使用。 */
+ public PointToPointSimulationRequest() {
+ }
+
+ /**
+ * 创建并立即校验一个请求,供测试和非 HTTP 调用方使用。
+ */
+ public static PointToPointSimulationRequest valid(
+ String sourceSite, String targetSite, String taskType,
+ int priority, int acceleration, String historicalVehicleId,
+ List historicalPath, String faultPreset) {
+ PointToPointSimulationRequest request = new PointToPointSimulationRequest();
+ request.sourceSite = sourceSite;
+ request.targetSite = targetSite;
+ request.taskType = taskType;
+ request.priority = priority;
+ request.acceleration = acceleration;
+ request.historicalVehicleId = historicalVehicleId;
+ request.historicalPath = historicalPath;
+ request.faultPreset = faultPreset;
+ request.validate();
+ return request;
+ }
+
+ /**
+ * 校验并规范化来自页面的业务输入,避免非法参数进入调度器。
+ */
+ public void validate() {
+ sourceSite = required(sourceSite, "sourceSite");
+ targetSite = required(targetSite, "targetSite");
+ taskType = required(taskType, "taskType").toUpperCase();
+ faultPreset = faultPreset == null || faultPreset.trim().isEmpty()
+ ? FaultPreset.NONE.name() : faultPreset.trim().toUpperCase();
+ if (priority == null || priority < 0 || priority > 9) {
+ throw new IllegalArgumentException("INVALID_PRIORITY: priority must be between 0 and 9");
+ }
+ if (acceleration == null || (acceleration != 10 && acceleration != 100)) {
+ throw new IllegalArgumentException("INVALID_ACCELERATION: acceleration must be 10 or 100");
+ }
+ try {
+ DispatchTask.TaskType.valueOf(taskType);
+ } catch (IllegalArgumentException exception) {
+ throw new IllegalArgumentException("INVALID_TASK_TYPE: " + taskType, exception);
+ }
+ try {
+ FaultPreset.valueOf(faultPreset);
+ } catch (IllegalArgumentException exception) {
+ throw new IllegalArgumentException("INVALID_FAULT_PRESET: " + faultPreset, exception);
+ }
+ historicalVehicleId = trimToNull(historicalVehicleId);
+ historicalPath = immutablePath(historicalPath);
+ }
+
+ private static String required(String value, String name) {
+ String normalized = trimToNull(value);
+ if (normalized == null) {
+ throw new IllegalArgumentException("BAD_REQUEST: " + name + " is required");
+ }
+ return normalized;
+ }
+
+ private static String trimToNull(String value) {
+ if (value == null) return null;
+ String normalized = value.trim();
+ return normalized.isEmpty() ? null : normalized;
+ }
+
+ private static List immutablePath(List source) {
+ if (source == null || source.isEmpty()) return Collections.emptyList();
+ List result = new ArrayList();
+ for (String node : source) {
+ String normalized = trimToNull(node);
+ if (normalized != null) result.add(normalized);
+ }
+ return Collections.unmodifiableList(result);
+ }
+
+ public String getSourceSite() { return sourceSite; }
+ public String getTargetSite() { return targetSite; }
+ public DispatchTask.TaskType getTaskType() { return DispatchTask.TaskType.valueOf(taskType); }
+ public int getPriority() { return priority; }
+ public int getAcceleration() { return acceleration; }
+ public String getHistoricalVehicleId() { return historicalVehicleId; }
+ public List getHistoricalPath() { return historicalPath; }
+ public FaultPreset getFaultPreset() { return FaultPreset.valueOf(faultPreset); }
+}
diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/PointToPointSimulationService.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/PointToPointSimulationService.java
new file mode 100644
index 0000000..7e46fa4
--- /dev/null
+++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/visualizer/PointToPointSimulationService.java
@@ -0,0 +1,73 @@
+package com.ft546.wcs.shadow.history.visualizer;
+
+import com.ft546.wcs.demo.model.VehicleSnapshot;
+import com.ft546.wcs.shadow.history.simulation.DeterministicFaultPlan;
+import com.ft546.wcs.shadow.history.simulation.HistoricalDispatchSimulationResult;
+import com.ft546.wcs.shadow.history.simulation.HistoricalDispatchSimulator;
+import com.ft546.wcs.shadow.history.simulation.HistoricalSimulationInput;
+import com.ft546.wcs.shadow.history.simulation.PointToPointSimulationInputFactory;
+import com.ft546.wcs.shadow.history.simulation.SimulatedPlcGateway;
+import com.ft546.wcs.shadow.history.simulation.SimulationConfig;
+
+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.LOW_BATTERY;
+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;
+
+/** 调用现有中心调度器执行审计台单任务仿真。 */
+public final class PointToPointSimulationService {
+ private final HistoricalSimulationInput baseInput;
+ private final PointToPointSimulationInputFactory inputFactory;
+ private final HistoricalDispatchSimulator simulator;
+
+ public PointToPointSimulationService(HistoricalSimulationInput baseInput) {
+ if (baseInput == null) {
+ throw new IllegalArgumentException("base input is required");
+ }
+ this.baseInput = baseInput;
+ this.inputFactory = new PointToPointSimulationInputFactory();
+ this.simulator = new HistoricalDispatchSimulator();
+ }
+
+ /**
+ * 校验页面输入,构造一个任务,并完整执行选车、规划、资源、PLC、移动和归档流程。
+ */
+ public HistoricalDispatchSimulationResult run(PointToPointSimulationRequest request) {
+ if (request == null) throw new IllegalArgumentException("BAD_REQUEST: request is required");
+ request.validate();
+ HistoricalSimulationInput input = inputFactory.create(
+ baseInput, request.getSourceSite(), request.getTargetSite(),
+ request.getTaskType(), request.getPriority(),
+ request.getHistoricalVehicleId(), request.getHistoricalPath());
+ SimulationConfig config = new SimulationConfig(
+ request.getAcceleration(), 30_000L, 3, 500, 20_000);
+ return simulator.simulate(input, config, faultPlan(request.getFaultPreset()));
+ }
+
+ private DeterministicFaultPlan faultPlan(PointToPointSimulationRequest.FaultPreset preset) {
+ if (preset == PointToPointSimulationRequest.FaultPreset.NONE) {
+ return DeterministicFaultPlan.none();
+ }
+ DeterministicFaultPlan.Builder builder = DeterministicFaultPlan.builder();
+ if (preset == PointToPointSimulationRequest.FaultPreset.PLC_FIRST_SEND_FAILURE) {
+ return builder.plcFailure(PointToPointSimulationInputFactory.TASK_ID, 1,
+ SimulatedPlcGateway.PlcReason.SEND_FAILURE).build();
+ }
+ for (VehicleSnapshot vehicle : baseInput.getInitialVehicles()) {
+ String vehicleId = vehicle.getVehicleId();
+ if (preset == PointToPointSimulationRequest.FaultPreset.VEHICLE_RESTART) {
+ builder.vehicleEvent(0, vehicleId, RESTART)
+ .vehicleEvent(2, vehicleId, SYNCHRONIZE);
+ } else if (preset == PointToPointSimulationRequest.FaultPreset.LOW_BATTERY) {
+ builder.vehicleEvent(0, vehicleId, LOW_BATTERY)
+ .vehicleEvent(2, vehicleId, RESTORE_BATTERY);
+ } else if (preset == PointToPointSimulationRequest.FaultPreset.DISCONNECT_RECONNECT) {
+ builder.vehicleEvent(0, vehicleId, DISCONNECT)
+ .vehicleEvent(2, vehicleId, CONNECT);
+ }
+ }
+ return builder.build();
+ }
+}
diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/PointToPointSimulationServiceTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/PointToPointSimulationServiceTest.java
new file mode 100644
index 0000000..8befa3b
--- /dev/null
+++ b/javaTest/src/test/java/com/ft546/wcs/shadow/history/visualizer/PointToPointSimulationServiceTest.java
@@ -0,0 +1,109 @@
+package com.ft546.wcs.shadow.history.visualizer;
+
+import com.ft546.wcs.shadow.history.HistoricalQueueDataset;
+import com.ft546.wcs.shadow.history.HistoricalQueueLoader;
+import com.ft546.wcs.shadow.history.simulation.HistoricalSimulationInput;
+import com.ft546.wcs.shadow.history.simulation.HistoricalSimulationInputFactory;
+import com.ft546.wcs.shadow.history.simulation.HistoricalSimulationTask;
+import com.ft546.wcs.shadow.history.simulation.HistoricalDispatchSimulationResult;
+import com.ft546.wcs.shadow.history.simulation.SimulationTaskResult;
+import com.ft546.wcs.shadow.history.simulation.SimulationTaskStatus;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+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 PointToPointSimulationServiceTest {
+ private static HistoricalSimulationInput baseInput;
+ private static HistoricalSimulationTask sampleTask;
+
+ @BeforeAll
+ static void loadBaseInput() {
+ HistoricalQueueDataset dataset = new HistoricalQueueLoader().load(
+ PointToPointSimulationServiceTest.class.getClassLoader(), "shadow/history");
+ baseInput = new HistoricalSimulationInputFactory().create(dataset);
+ sampleTask = baseInput.getTasks().stream()
+ .filter(task -> !task.getPhysicalSource().equals(task.getPhysicalTarget()))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("a point-to-point sample is required"));
+ }
+
+ @Test
+ void runsOneTaskWithTheRealPlannerAndIgnoresHistoricalPath() {
+ PointToPointSimulationRequest request = PointToPointSimulationRequest.valid(
+ sampleTask.getPhysicalSource(), sampleTask.getPhysicalTarget(),
+ "INBOUND", 1, 100, "HISTORY-CAR",
+ Arrays.asList("WRONG", "REFERENCE"), "NONE");
+
+ HistoricalDispatchSimulationResult result = service().run(request);
+ SimulationTaskResult task = result.getTaskResults().get(0);
+
+ assertEquals(1, result.getTaskResults().size());
+ assertEquals(SimulationTaskStatus.COMPLETED, task.getStatus());
+ assertEquals(sampleTask.getPhysicalSource(), task.getPlannedPath().get(0));
+ assertEquals(sampleTask.getPhysicalTarget(),
+ task.getPlannedPath().get(task.getPlannedPath().size() - 1));
+ assertFalse(task.getPlannedPath().equals(Arrays.asList("WRONG", "REFERENCE")));
+ assertEquals("HISTORY-CAR", task.getRecord().getAgvId());
+ assertEquals(0, result.getInvariants().totalSafetyViolations());
+ }
+
+ @Test
+ void rejectsUnknownAndIdenticalEndpointsBeforeSimulation() {
+ PointToPointSimulationRequest unknown = PointToPointSimulationRequest.valid(
+ "UNKNOWN-SITE", sampleTask.getPhysicalTarget(), "INBOUND", 1,
+ 100, null, Collections.emptyList(), "NONE");
+ PointToPointSimulationRequest identical = PointToPointSimulationRequest.valid(
+ sampleTask.getPhysicalSource(), sampleTask.getPhysicalSource(), "INBOUND", 1,
+ 100, null, Collections.emptyList(), "NONE");
+
+ IllegalArgumentException unknownError = assertThrows(
+ IllegalArgumentException.class, () -> service().run(unknown));
+ IllegalArgumentException identicalError = assertThrows(
+ IllegalArgumentException.class, () -> service().run(identical));
+
+ assertTrue(unknownError.getMessage().contains("UNKNOWN_SITE"));
+ assertTrue(identicalError.getMessage().contains("SAME_ENDPOINT"));
+ }
+
+ @Test
+ void plcFirstSendFailureProducesRetryEvidenceAndStillCompletes() {
+ PointToPointSimulationRequest request = PointToPointSimulationRequest.valid(
+ sampleTask.getPhysicalSource(), sampleTask.getPhysicalTarget(),
+ "OUTBOUND", 2, 100, null, Collections.emptyList(),
+ "PLC_FIRST_SEND_FAILURE");
+
+ HistoricalDispatchSimulationResult result = service().run(request);
+
+ assertEquals(1, result.getCompletedCount());
+ assertTrue(result.getRetryCount() > 0);
+ assertTrue(result.getEvents().stream()
+ .anyMatch(event -> "PLC_RETRY".equals(event.getType())));
+ }
+
+ @Test
+ void validatesPriorityAccelerationTaskTypeAndFaultPreset() {
+ assertThrows(IllegalArgumentException.class, () -> PointToPointSimulationRequest.valid(
+ "A", "B", "INBOUND", 10, 100, null,
+ Collections.emptyList(), "NONE"));
+ assertThrows(IllegalArgumentException.class, () -> PointToPointSimulationRequest.valid(
+ "A", "B", "INBOUND", 1, 20, null,
+ Collections.emptyList(), "NONE"));
+ assertThrows(IllegalArgumentException.class, () -> PointToPointSimulationRequest.valid(
+ "A", "B", "UNKNOWN", 1, 100, null,
+ Collections.emptyList(), "NONE"));
+ assertThrows(IllegalArgumentException.class, () -> PointToPointSimulationRequest.valid(
+ "A", "B", "INBOUND", 1, 100, null,
+ Collections.emptyList(), "UNKNOWN"));
+ }
+
+ private PointToPointSimulationService service() {
+ return new PointToPointSimulationService(baseInput);
+ }
+}