From 35e67e2455e56d8b315a7c0e55ea8ba2b3441320 Mon Sep 17 00:00:00 2001 From: zgw <13623675548@163.com> Date: Fri, 7 Aug 2026 09:56:12 +0800 Subject: [PATCH] feat(javaTest): model idempotent floor transfer jobs --- .../history/simulation/AssignmentMode.java | 11 +++ .../simulation/FloorTransferGateway.java | 79 ++++++++++++++++ .../history/simulation/FloorTransferJob.java | 88 ++++++++++++++++++ .../simulation/FloorTransferStatus.java | 17 ++++ .../simulation/FloorTransferTopology.java | 44 +++++++++ .../SimulatedFloorTransferGateway.java | 89 ++++++++++++++++++ .../SimulatedFloorTransferGatewayTest.java | 91 +++++++++++++++++++ 7 files changed, 419 insertions(+) create mode 100644 javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/AssignmentMode.java create mode 100644 javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/FloorTransferGateway.java create mode 100644 javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/FloorTransferJob.java create mode 100644 javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/FloorTransferStatus.java create mode 100644 javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/FloorTransferTopology.java create mode 100644 javaTest/src/main/java/com/ft546/wcs/shadow/history/simulation/SimulatedFloorTransferGateway.java create mode 100644 javaTest/src/test/java/com/ft546/wcs/shadow/history/simulation/SimulatedFloorTransferGatewayTest.java 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); + } +}