diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/AssignmentMode.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/AssignmentMode.java
new file mode 100644
index 0000000..4cf6f2d
--- /dev/null
+++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/AssignmentMode.java
@@ -0,0 +1,11 @@
+package com.ft546.wcs.shadow.history.simulation;
+
+/**
+ * 表示中心调度选车后,车辆进入业务任务前需要采用的编排方式。
+ */
+public enum AssignmentMode {
+ /** 车辆与任务源点同层,可以直接执行接近路径和服务路径。 */
+ DIRECT,
+ /** 车辆需要先由现有 WCS 完成换层,再从实际出口重新规划。 */
+ FLOOR_TRANSFER
+}
diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/FloorTransferGateway.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/FloorTransferGateway.java
new file mode 100644
index 0000000..2c03551
--- /dev/null
+++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/FloorTransferGateway.java
@@ -0,0 +1,79 @@
+package com.ft546.wcs.shadow.history.simulation;
+
+import java.util.Optional;
+
+/**
+ * 中心调度创建和查询换层作业的出站端口。
+ *
+ *
提交成功只代表现有 WCS 接受了作业,不代表车辆已经换层。中心必须继续消费状态,直到
+ * {@link FloorTransferStatus#COMPLETED} 携带实际出口事实后,才能更新车辆位置并规划父任务。
+ */
+public interface FloorTransferGateway {
+ /** 幂等提交一个换层作业;设备忙时返回 BUSY,调用方保持父任务等待。 */
+ Submission submit(FloorTransferJob job);
+
+ /**
+ * 推进或轮询指定作业的最新事实。生产适配器实现应查询持久化队列和设备快照,模拟器实现按 tick 推进。
+ */
+ TransferSnapshot advance(String idempotencyKey);
+
+ /** 只读获取当前事实,不产生状态迁移,用于重启恢复和重复回调对账。 */
+ TransferSnapshot snapshot(String idempotencyKey);
+
+ enum SubmissionCode {
+ CREATED,
+ EXISTING,
+ BUSY,
+ REJECTED
+ }
+
+ /** 换层提交的业务结果;BUSY/REJECTED 不携带已创建作业。 */
+ final class Submission {
+ private final SubmissionCode code;
+ private final FloorTransferJob job;
+ private final String reason;
+
+ public Submission(SubmissionCode code, FloorTransferJob job, String reason) {
+ if (code == null) throw new IllegalArgumentException("submission code is required");
+ this.code = code;
+ this.job = job;
+ this.reason = reason == null ? "" : reason;
+ }
+
+ public SubmissionCode getCode() { return code; }
+ public Optional getJob() { return Optional.ofNullable(job); }
+ public String getReason() { return reason; }
+ public boolean isAccepted() {
+ return code == SubmissionCode.CREATED || code == SubmissionCode.EXISTING;
+ }
+ }
+
+ /**
+ * 换层设备返回的标准事实。只有 COMPLETED 的 actualExitSite/actualFloor 可以更新车辆运行态。
+ */
+ final class TransferSnapshot {
+ private final FloorTransferJob job;
+ private final FloorTransferStatus status;
+ private final String actualExitSite;
+ private final int actualFloor;
+ private final String reason;
+
+ public TransferSnapshot(FloorTransferJob job, FloorTransferStatus status,
+ String actualExitSite, int actualFloor, String reason) {
+ if (job == null || status == null) {
+ throw new IllegalArgumentException("transfer job and status are required");
+ }
+ this.job = job;
+ this.status = status;
+ this.actualExitSite = actualExitSite;
+ this.actualFloor = actualFloor;
+ this.reason = reason == null ? "" : reason;
+ }
+
+ public FloorTransferJob getJob() { return job; }
+ public FloorTransferStatus getStatus() { return status; }
+ public String getActualExitSite() { return actualExitSite; }
+ public int getActualFloor() { return actualFloor; }
+ public String getReason() { return reason; }
+ }
+}
diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/FloorTransferJob.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/FloorTransferJob.java
new file mode 100644
index 0000000..aa7adab
--- /dev/null
+++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/FloorTransferJob.java
@@ -0,0 +1,88 @@
+package com.ft546.wcs.shadow.history.simulation;
+
+import java.util.Objects;
+
+/**
+ * 中心调度交给现有 WCS 的标准换层作业意图。
+ *
+ * 该对象只描述“哪辆车因哪个父任务从哪层到哪层”,不包含 PLC 地址、寄存器或提升机
+ * 握手细节。生产适配器可以把它映射为 {@code tsj_device_execution_queue},离线仿真则交给
+ * {@link SimulatedFloorTransferGateway} 执行相同契约。
+ */
+public final class FloorTransferJob {
+ private final String idempotencyKey;
+ private final String parentTaskId;
+ private final String vehicleId;
+ private final int sourceFloor;
+ private final int targetFloor;
+ private final String sourceSite;
+ private final String targetSite;
+ private final int priority;
+
+ private FloorTransferJob(String idempotencyKey, String parentTaskId,
+ String vehicleId, int sourceFloor, int targetFloor,
+ String sourceSite, String targetSite, int priority) {
+ this.idempotencyKey = requireText(idempotencyKey, "idempotency key");
+ this.parentTaskId = requireText(parentTaskId, "parent task id");
+ this.vehicleId = requireText(vehicleId, "vehicle id");
+ if (sourceFloor <= 0 || targetFloor <= 0 || sourceFloor == targetFloor) {
+ throw new IllegalArgumentException("floor transfer requires two different positive floors");
+ }
+ this.sourceFloor = sourceFloor;
+ this.targetFloor = targetFloor;
+ this.sourceSite = requireText(sourceSite, "source transfer site");
+ this.targetSite = requireText(targetSite, "target transfer site");
+ this.priority = priority;
+ }
+
+ /**
+ * 用稳定业务字段创建换层命令;相同父任务和车辆重试时会得到同一个幂等键。
+ */
+ public static FloorTransferJob create(String parentTaskId, String vehicleId,
+ int sourceFloor, int targetFloor,
+ String sourceSite, String targetSite,
+ int priority) {
+ String task = requireText(parentTaskId, "parent task id");
+ String vehicle = requireText(vehicleId, "vehicle id");
+ String key = task + "|" + vehicle + "|" + sourceFloor + "->" + targetFloor;
+ return new FloorTransferJob(key, task, vehicle, sourceFloor, targetFloor,
+ sourceSite, targetSite, priority);
+ }
+
+ public String getIdempotencyKey() { return idempotencyKey; }
+ public String getParentTaskId() { return parentTaskId; }
+ public String getVehicleId() { return vehicleId; }
+ public int getSourceFloor() { return sourceFloor; }
+ public int getTargetFloor() { return targetFloor; }
+ public String getSourceSite() { return sourceSite; }
+ public String getTargetSite() { return targetSite; }
+ public int getPriority() { return priority; }
+
+ @Override
+ public boolean equals(Object other) {
+ if (this == other) return true;
+ if (!(other instanceof FloorTransferJob)) return false;
+ FloorTransferJob job = (FloorTransferJob) other;
+ return idempotencyKey.equals(job.idempotencyKey)
+ && parentTaskId.equals(job.parentTaskId)
+ && vehicleId.equals(job.vehicleId)
+ && sourceFloor == job.sourceFloor
+ && targetFloor == job.targetFloor
+ && sourceSite.equals(job.sourceSite)
+ && targetSite.equals(job.targetSite)
+ && priority == job.priority;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(idempotencyKey, parentTaskId, vehicleId, sourceFloor,
+ targetFloor, sourceSite, targetSite, priority);
+ }
+
+ private static String requireText(String value, String name) {
+ if (value == null || value.trim().isEmpty()) {
+ throw new IllegalArgumentException(name + " must not be blank");
+ }
+ return value;
+ }
+}
diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/FloorTransferStatus.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/FloorTransferStatus.java
new file mode 100644
index 0000000..74ae204
--- /dev/null
+++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/FloorTransferStatus.java
@@ -0,0 +1,17 @@
+package com.ft546.wcs.shadow.history.simulation;
+
+/**
+ * 换层作业在中心调度与设备适配器之间使用的标准状态。
+ */
+public enum FloorTransferStatus {
+ /** 作业已经持久化或被适配器接受,但设备尚未报告开始。 */
+ SUBMITTED,
+ /** 现有 WCS 已经开始执行车辆换层。 */
+ RUNNING,
+ /** 完成事实已经确认车辆所在楼层和实际出口。 */
+ COMPLETED,
+ /** 适配器明确证明作业未开始并拒绝执行。 */
+ REJECTED,
+ /** 作业可能已执行但结果无法确认,必须保留车辆锁等待对账。 */
+ UNKNOWN
+}
diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/FloorTransferTopology.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/FloorTransferTopology.java
new file mode 100644
index 0000000..7d585a1
--- /dev/null
+++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/FloorTransferTopology.java
@@ -0,0 +1,44 @@
+package com.ft546.wcs.shadow.history.simulation;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * 保存项目层到提升机交接站点的映射,明确把换层端口与普通地图边分离。
+ */
+public final class FloorTransferTopology {
+ private final Map portsByFloor;
+
+ /**
+ * 创建项目换层端口目录;每层最多配置一个首选端口,端口是否可达由中心路径规划器另行验证。
+ */
+ public FloorTransferTopology(Map portsByFloor) {
+ if (portsByFloor == null) throw new IllegalArgumentException("floor transfer ports are required");
+ Map copy = new LinkedHashMap();
+ for (Map.Entry entry : portsByFloor.entrySet()) {
+ if (entry.getKey() == null || entry.getKey() <= 0
+ || entry.getValue() == null || entry.getValue().trim().isEmpty()) {
+ throw new IllegalArgumentException("valid floor and transfer site are required");
+ }
+ copy.put(entry.getKey(), entry.getValue());
+ }
+ this.portsByFloor = Collections.unmodifiableMap(copy);
+ }
+
+ /** 返回指定楼层的项目换层交接站点。 */
+ public Optional portFor(int floor) {
+ return Optional.ofNullable(portsByFloor.get(floor));
+ }
+
+ /** 两层都配置端口且不是同一层时,中心才允许生成换层作业。 */
+ public boolean supports(int sourceFloor, int targetFloor) {
+ return sourceFloor != targetFloor
+ && portsByFloor.containsKey(sourceFloor)
+ && portsByFloor.containsKey(targetFloor);
+ }
+
+ /** 返回不可变端口目录,供审计台输出项目换层配置。 */
+ public Map getPortsByFloor() { return portsByFloor; }
+}
diff --git a/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulatedFloorTransferGateway.java b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulatedFloorTransferGateway.java
new file mode 100644
index 0000000..00233f5
--- /dev/null
+++ b/javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulatedFloorTransferGateway.java
@@ -0,0 +1,89 @@
+package com.ft546.wcs.shadow.history.simulation;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 离线仿真使用的单提升机网关,按固定两个轮询周期产生开始和完成事实。
+ *
+ * 它实现与生产适配器相同的幂等提交契约,但不连接 SQL Server 或 PLC。换层不是地图移动,
+ * 因此该网关只在完成事实中返回实际出口,车辆位置由中心编排器消费事实后统一提交。
+ */
+public final class SimulatedFloorTransferGateway implements FloorTransferGateway {
+ private final Map snapshots =
+ new LinkedHashMap();
+ private String activeKey;
+ private int createdJobCount;
+ private int completedJobCount;
+
+ /**
+ * 幂等接受作业;同一键返回既有记录,其他作业在提升机忙时得到 BUSY 而不落入作业表。
+ */
+ @Override
+ public Submission submit(FloorTransferJob job) {
+ if (job == null) throw new IllegalArgumentException("floor transfer job is required");
+ TransferSnapshot existing = snapshots.get(job.getIdempotencyKey());
+ if (existing != null) {
+ if (!existing.getJob().equals(job)) {
+ return new Submission(SubmissionCode.REJECTED, null,
+ "IDEMPOTENCY_KEY_PAYLOAD_MISMATCH");
+ }
+ return new Submission(SubmissionCode.EXISTING, existing.getJob(),
+ "IDEMPOTENT_REPLAY");
+ }
+ if (activeKey != null) {
+ return new Submission(SubmissionCode.BUSY, null, "FLOOR_TRANSFER_BUSY");
+ }
+
+ // 只有真正创建新作业时才占用模拟提升机;重复提交不会增加设备侧业务效果。
+ TransferSnapshot submitted = new TransferSnapshot(job,
+ FloorTransferStatus.SUBMITTED, null, 0, "SUBMITTED");
+ snapshots.put(job.getIdempotencyKey(), submitted);
+ activeKey = job.getIdempotencyKey();
+ createdJobCount++;
+ return new Submission(SubmissionCode.CREATED, job, "SUBMITTED");
+ }
+
+ /**
+ * 模拟设备事实推进:首次轮询进入 RUNNING,第二次确认实际出口并完成,后续轮询保持同一终态。
+ */
+ @Override
+ public TransferSnapshot advance(String idempotencyKey) {
+ TransferSnapshot current = requireSnapshot(idempotencyKey);
+ if (current.getStatus() == FloorTransferStatus.SUBMITTED) {
+ TransferSnapshot running = new TransferSnapshot(current.getJob(),
+ FloorTransferStatus.RUNNING, null, 0, "DEVICE_ACCEPTED");
+ snapshots.put(idempotencyKey, running);
+ return running;
+ }
+ if (current.getStatus() == FloorTransferStatus.RUNNING) {
+ FloorTransferJob job = current.getJob();
+ TransferSnapshot completed = new TransferSnapshot(job,
+ FloorTransferStatus.COMPLETED, job.getTargetSite(),
+ job.getTargetFloor(), "DEVICE_COMPLETED");
+ snapshots.put(idempotencyKey, completed);
+ activeKey = null;
+ completedJobCount++;
+ return completed;
+ }
+ // 完成、拒绝或未知都是稳定事实,重复读取不得再次增加完成数或改变作业内容。
+ return current;
+ }
+
+ /** 返回当前设备事实而不推进状态,用于中心重启后的只读对账。 */
+ @Override
+ public TransferSnapshot snapshot(String idempotencyKey) {
+ return requireSnapshot(idempotencyKey);
+ }
+
+ public int createdJobCount() { return createdJobCount; }
+ public int completedJobCount() { return completedJobCount; }
+
+ private TransferSnapshot requireSnapshot(String idempotencyKey) {
+ TransferSnapshot snapshot = snapshots.get(idempotencyKey);
+ if (snapshot == null) {
+ throw new IllegalArgumentException("unknown floor transfer job " + idempotencyKey);
+ }
+ return snapshot;
+ }
+}
diff --git a/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/SimulatedFloorTransferGatewayTest.java b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/SimulatedFloorTransferGatewayTest.java
new file mode 100644
index 0000000..f963a58
--- /dev/null
+++ b/javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/SimulatedFloorTransferGatewayTest.java
@@ -0,0 +1,91 @@
+package com.ft546.wcs.shadow.history.simulation;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class SimulatedFloorTransferGatewayTest {
+ /**
+ * 验证同一父任务重试提交时只保留一个换层作业,防止 WCS 重试生成两条提升机队列。
+ */
+ @Test
+ void submitsTheSameTransferIdempotentlyAndCompletesOnlyOnce() {
+ SimulatedFloorTransferGateway gateway = new SimulatedFloorTransferGateway();
+ FloorTransferJob job = job("TASK-1", "1", 1, 2, "LIFT-1", "LIFT-2");
+
+ FloorTransferGateway.Submission first = gateway.submit(job);
+ FloorTransferGateway.Submission duplicate = gateway.submit(job);
+
+ assertEquals(FloorTransferGateway.SubmissionCode.CREATED, first.getCode());
+ assertEquals(FloorTransferGateway.SubmissionCode.EXISTING, duplicate.getCode());
+ assertEquals(first.getJob().get().getIdempotencyKey(),
+ duplicate.getJob().get().getIdempotencyKey());
+ assertEquals(1, gateway.createdJobCount());
+
+ FloorTransferGateway.TransferSnapshot running = gateway.advance(
+ job.getIdempotencyKey());
+ FloorTransferGateway.TransferSnapshot completed = gateway.advance(
+ job.getIdempotencyKey());
+ FloorTransferGateway.TransferSnapshot duplicateCompletion = gateway.advance(
+ job.getIdempotencyKey());
+
+ assertEquals(FloorTransferStatus.RUNNING, running.getStatus());
+ assertEquals(FloorTransferStatus.COMPLETED, completed.getStatus());
+ assertEquals(FloorTransferStatus.COMPLETED, duplicateCompletion.getStatus());
+ assertEquals("LIFT-2", completed.getActualExitSite());
+ assertEquals(2, completed.getActualFloor());
+ assertEquals(1, gateway.completedJobCount());
+ }
+
+ /**
+ * 验证单台模拟提升机执行未完成作业时拒绝其他父任务,中心调度只能 HOLD 而不能并发下发。
+ */
+ @Test
+ void reportsBusyUntilTheActiveTransferCompletes() {
+ SimulatedFloorTransferGateway gateway = new SimulatedFloorTransferGateway();
+ FloorTransferJob first = job("TASK-1", "1", 1, 2, "LIFT-1", "LIFT-2");
+ FloorTransferJob second = job("TASK-2", "2", 3, 1, "LIFT-3", "LIFT-1");
+
+ assertEquals(FloorTransferGateway.SubmissionCode.CREATED,
+ gateway.submit(first).getCode());
+ FloorTransferGateway.Submission busy = gateway.submit(second);
+ assertEquals(FloorTransferGateway.SubmissionCode.BUSY, busy.getCode());
+ assertFalse(busy.getJob().isPresent());
+
+ gateway.advance(first.getIdempotencyKey());
+ gateway.advance(first.getIdempotencyKey());
+
+ assertEquals(FloorTransferGateway.SubmissionCode.CREATED,
+ gateway.submit(second).getCode());
+ assertEquals(2, gateway.createdJobCount());
+ }
+
+ /**
+ * 验证换层端口目录独立于普通地图边,中心只能查询楼层到端口的项目配置。
+ */
+ @Test
+ void resolvesConfiguredPortsWithoutCreatingCrossFloorEdges() {
+ Map ports = new LinkedHashMap();
+ ports.put(1, "LIFT-1");
+ ports.put(2, "LIFT-2");
+ FloorTransferTopology topology = new FloorTransferTopology(ports);
+
+ assertTrue(topology.supports(1, 2));
+ assertEquals("LIFT-1", topology.portFor(1).get());
+ assertEquals("LIFT-2", topology.portFor(2).get());
+ assertFalse(topology.supports(1, 3));
+ }
+
+ /** 为测试构造与生产适配器契约等价的最小换层命令。 */
+ private FloorTransferJob job(String taskId, String vehicleId,
+ int sourceFloor, int targetFloor,
+ String sourceSite, String targetSite) {
+ return FloorTransferJob.create(taskId, vehicleId, sourceFloor, targetFloor,
+ sourceSite, targetSite, 1);
+ }
+}