feat(javaTest): model idempotent floor transfer jobs

This commit is contained in:
zgw
2026-08-07 09:56:12 +08:00
parent 97e60087b0
commit 35e67e2455
7 changed files with 419 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
package com.ft546.wcs.shadow.history.simulation;
/**
* 表示中心调度选车后,车辆进入业务任务前需要采用的编排方式。
*/
public enum AssignmentMode {
/** 车辆与任务源点同层,可以直接执行接近路径和服务路径。 */
DIRECT,
/** 车辆需要先由现有 WCS 完成换层,再从实际出口重新规划。 */
FLOOR_TRANSFER
}

View File

@@ -0,0 +1,79 @@
package com.ft546.wcs.shadow.history.simulation;
import java.util.Optional;
/**
* 中心调度创建和查询换层作业的出站端口。
*
* <p>提交成功只代表现有 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<FloorTransferJob> 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; }
}
}

View File

@@ -0,0 +1,88 @@
package com.ft546.wcs.shadow.history.simulation;
import java.util.Objects;
/**
* 中心调度交给现有 WCS 的标准换层作业意图。
*
* <p>该对象只描述“哪辆车因哪个父任务从哪层到哪层”,不包含 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;
}
}

View File

@@ -0,0 +1,17 @@
package com.ft546.wcs.shadow.history.simulation;
/**
* 换层作业在中心调度与设备适配器之间使用的标准状态。
*/
public enum FloorTransferStatus {
/** 作业已经持久化或被适配器接受,但设备尚未报告开始。 */
SUBMITTED,
/** 现有 WCS 已经开始执行车辆换层。 */
RUNNING,
/** 完成事实已经确认车辆所在楼层和实际出口。 */
COMPLETED,
/** 适配器明确证明作业未开始并拒绝执行。 */
REJECTED,
/** 作业可能已执行但结果无法确认,必须保留车辆锁等待对账。 */
UNKNOWN
}

View File

@@ -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<Integer, String> portsByFloor;
/**
* 创建项目换层端口目录;每层最多配置一个首选端口,端口是否可达由中心路径规划器另行验证。
*/
public FloorTransferTopology(Map<Integer, String> portsByFloor) {
if (portsByFloor == null) throw new IllegalArgumentException("floor transfer ports are required");
Map<Integer, String> copy = new LinkedHashMap<Integer, String>();
for (Map.Entry<Integer, String> 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<String> 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<Integer, String> getPortsByFloor() { return portsByFloor; }
}

View File

@@ -0,0 +1,89 @@
package com.ft546.wcs.shadow.history.simulation;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 离线仿真使用的单提升机网关,按固定两个轮询周期产生开始和完成事实。
*
* <p>它实现与生产适配器相同的幂等提交契约,但不连接 SQL Server 或 PLC。换层不是地图移动,
* 因此该网关只在完成事实中返回实际出口,车辆位置由中心编排器消费事实后统一提交。
*/
public final class SimulatedFloorTransferGateway implements FloorTransferGateway {
private final Map<String, TransferSnapshot> snapshots =
new LinkedHashMap<String, TransferSnapshot>();
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;
}
}

View File

@@ -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<Integer, String> ports = new LinkedHashMap<Integer, String>();
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);
}
}