feat(javaTest): model idempotent floor transfer jobs
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
package com.ft546.wcs.shadow.history.simulation;
|
||||
|
||||
/**
|
||||
* 表示中心调度选车后,车辆进入业务任务前需要采用的编排方式。
|
||||
*/
|
||||
public enum AssignmentMode {
|
||||
/** 车辆与任务源点同层,可以直接执行接近路径和服务路径。 */
|
||||
DIRECT,
|
||||
/** 车辆需要先由现有 WCS 完成换层,再从实际出口重新规划。 */
|
||||
FLOOR_TRANSFER
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.ft546.wcs.shadow.history.simulation;
|
||||
|
||||
/**
|
||||
* 换层作业在中心调度与设备适配器之间使用的标准状态。
|
||||
*/
|
||||
public enum FloorTransferStatus {
|
||||
/** 作业已经持久化或被适配器接受,但设备尚未报告开始。 */
|
||||
SUBMITTED,
|
||||
/** 现有 WCS 已经开始执行车辆换层。 */
|
||||
RUNNING,
|
||||
/** 完成事实已经确认车辆所在楼层和实际出口。 */
|
||||
COMPLETED,
|
||||
/** 适配器明确证明作业未开始并拒绝执行。 */
|
||||
REJECTED,
|
||||
/** 作业可能已执行但结果无法确认,必须保留车辆锁等待对账。 */
|
||||
UNKNOWN
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user