feat(ft546): run shared dispatch kernel in shadow

This commit is contained in:
zgw
2026-08-14 15:40:31 +08:00
parent 08003407eb
commit 6864010832
22 changed files with 2346 additions and 8 deletions

View File

@@ -37,9 +37,23 @@ public final class Ft546SrgvCommandIntent {
this.routeVersion = routeVersion;
this.taskNo = taskNo;
this.taskType = taskType;
this.pathPoints = Collections.unmodifiableList(
new ArrayList<Integer>(Objects.requireNonNull(pathPoints,
"pathPoints")));
List<Integer> copiedPath = new ArrayList<Integer>(
Objects.requireNonNull(pathPoints, "pathPoints"));
if (copiedPath.size() < 2 || copiedPath.size() > 8) {
throw new IllegalArgumentException(
"legacy route must contain between 2 and 8 command points");
}
for (Integer point : copiedPath) {
if (point == null || point.intValue() <= 0) {
throw new IllegalArgumentException(
"route node must be positive");
}
if (point.intValue() > 999999) {
throw new IllegalArgumentException(
"route node exceeds six digits");
}
}
this.pathPoints = Collections.unmodifiableList(copiedPath);
this.crossFloorTarget = crossFloorTarget;
this.twoStageNumber = twoStageNumber;
}

View File

@@ -52,6 +52,12 @@
<version>1.2.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>2.2.224</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>

View File

@@ -1,5 +1,16 @@
package com.ruoyi.shadow.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
import com.ruoyi.ft546.protocol.Ft546SrgvCommandEncoder;
import com.ruoyi.ft546.protocol.Ft546SrgvCommandIntent;
import com.ruoyi.shadow.facts.ShadowFactBatchValidator;
import com.ruoyi.shadow.kernel.Ft546ShadowCycle;
import com.ruoyi.shadow.kernel.JdbcShadowTrafficStateRepository;
import com.ruoyi.shadow.kernel.RecordingFt546CommandGateway;
import com.ruoyi.shadow.kernel.ShadowCommandIntentFactory;
import com.ruoyi.shadow.kernel.ShadowDispatchSnapshotAssembler;
import com.ruoyi.wcs.application.ReadOnlyDispatchCycle;
import com.ruoyi.wcs.application.dispatch.DispatchCoordinator;
import com.ruoyi.wcs.domain.dispatch.DefaultVehicleAssignmentPolicy;
import com.ruoyi.wcs.domain.dispatch.VehicleAssignmentPolicy;
@@ -10,9 +21,20 @@ import com.ruoyi.wcs.domain.traffic.RoutePlanner;
import com.ruoyi.wcs.domain.traffic.WaitForGraph;
import com.ruoyi.wcs.port.out.DispatchPolicy;
import com.ruoyi.wcs.port.out.TrafficStateRepository;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.Clock;
import java.time.Duration;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
@Configuration(proxyBeanMethods = false)
@@ -40,12 +62,23 @@ public class ShadowKernelConfiguration {
return () -> "SHADOW-" + sequence.incrementAndGet();
}
@Bean(name = "shadowClock")
public Clock shadowClock() {
return Clock.systemUTC();
}
@Bean
public TrafficStateRepository disabledShadowTrafficStateRepository() {
return commit -> {
throw new IllegalStateException(
"SHADOW_TRAFFIC_STATE_REPOSITORY_NOT_CONFIGURED");
};
public JdbcShadowTrafficStateRepository shadowTrafficStateRepository(
@Qualifier("shadowEvidenceJdbc") JdbcTemplate evidenceJdbc,
@Qualifier("shadowEvidenceTx")
PlatformTransactionManager evidenceTx,
ObjectMapper objectMapper,
@Qualifier("shadowClock") Clock clock,
ShadowCommandIntentFactory intentFactory,
RecordingFt546CommandGateway commandGateway) {
return new JdbcShadowTrafficStateRepository(evidenceJdbc,
new TransactionTemplate(evidenceTx), objectMapper, clock,
intentFactory, commandGateway);
}
@Bean
@@ -57,4 +90,69 @@ public class ShadowKernelConfiguration {
return new DispatchCoordinator(assignmentPolicy, deadlockGuard,
trafficStateRepository, idGenerator);
}
@Bean
public ShadowCommandIntentFactory shadowCommandIntentFactory() {
return new ShadowCommandIntentFactory();
}
@Bean
public ShadowDispatchSnapshotAssembler shadowDispatchSnapshotAssembler(
JdbcShadowTrafficStateRepository trafficStateRepository,
@Qualifier("shadowClock") Clock clock) {
return new ShadowDispatchSnapshotAssembler("FT546", 1L,
trafficStateRepository::load, clock);
}
@Bean
public ReadOnlyDispatchCycle<Ft546SrgvCommandIntent>
shadowReadOnlyDispatchCycle(
DispatchCoordinator coordinator,
ShadowCommandIntentFactory intentFactory) {
return new ReadOnlyDispatchCycle<Ft546SrgvCommandIntent>(
coordinator::dispatch, intentFactory::create);
}
@Bean
public RecordingFt546CommandGateway recordingFt546CommandGateway() {
Ft546DeviceProfile profile = new Ft546DeviceProfile();
profile.setDeviceId("FT546_SHADOW_VIRTUAL_PLC");
profile.setDeviceType("FOUR_WAY_SHUTTLE");
profile.setAdapterType("FT546_LEGACY_SRGV_RECORDING");
profile.setProfileVersion(1L);
Set<String> capabilities = new LinkedHashSet<String>(Arrays.asList(
"POINT_TO_POINT_COMMAND", "TWO_STAGE_ATOMIC_TASK"));
profile.setCapabilities(capabilities);
Map<String, String> registers = new LinkedHashMap<String, String>();
registers.put("taskNo", "4000");
registers.put("taskType", "4002");
registers.put("path", "4004");
registers.put("trigger", "4021");
registers.put("crossFloorFlag", "4020");
registers.put("crossFloorTarget", "4022");
registers.put("secondTaskNo", "4400");
registers.put("secondTaskType", "4402");
registers.put("secondPath", "4404");
registers.put("secondStageFlag", "4422");
registers.put("secondComplete", "4423");
profile.setRegisters(registers);
return new RecordingFt546CommandGateway(
new Ft546SrgvCommandEncoder(), profile, capabilities);
}
@Bean
public ShadowFactBatchValidator shadowFactBatchValidator() {
return new ShadowFactBatchValidator(Duration.ofSeconds(4),
Duration.ofSeconds(30));
}
@Bean
public Ft546ShadowCycle ft546ShadowCycle(
ShadowFactBatchValidator validator,
ShadowDispatchSnapshotAssembler assembler,
ReadOnlyDispatchCycle<Ft546SrgvCommandIntent> dispatchCycle,
RecordingFt546CommandGateway commandGateway) {
return new Ft546ShadowCycle(validator, assembler::assemble,
dispatchCycle, commandGateway);
}
}

View File

@@ -0,0 +1,75 @@
package com.ruoyi.shadow.kernel;
import com.ruoyi.ft546.protocol.Ft546EncodedCommand;
import com.ruoyi.ft546.protocol.Ft546SrgvCommandIntent;
import com.ruoyi.shadow.facts.ShadowFactBatch;
import com.ruoyi.shadow.facts.ShadowFactBatchValidator;
import com.ruoyi.shadow.facts.ShadowFactValidation;
import com.ruoyi.wcs.application.ReadOnlyDispatchCycle;
import com.ruoyi.wcs.application.ShadowDispatchResult;
import com.ruoyi.wcs.application.dispatch.DispatchSnapshot;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
import java.util.function.Function;
public final class Ft546ShadowCycle {
private final ShadowFactBatchValidator validator;
private final Function<ShadowFactBatch, DispatchSnapshot> assembler;
private final ReadOnlyDispatchCycle<Ft546SrgvCommandIntent> dispatchCycle;
private final RecordingFt546CommandGateway commandGateway;
public Ft546ShadowCycle(
ShadowFactBatchValidator validator,
Function<ShadowFactBatch, DispatchSnapshot> assembler,
ReadOnlyDispatchCycle<Ft546SrgvCommandIntent> dispatchCycle,
RecordingFt546CommandGateway commandGateway) {
this.validator = Objects.requireNonNull(validator, "validator");
this.assembler = Objects.requireNonNull(assembler, "assembler");
this.dispatchCycle = Objects.requireNonNull(dispatchCycle,
"dispatchCycle");
this.commandGateway = Objects.requireNonNull(commandGateway,
"commandGateway");
}
public ShadowCycleResult run(ShadowFactBatch batch, Instant now) {
ShadowFactValidation validation = validator.validate(batch, now);
if (validation.getStatus()
!= ShadowFactValidation.Status.COMPARABLE) {
return ShadowCycleResult.insufficient(validation.getReasonCode());
}
final DispatchSnapshot snapshot;
try {
snapshot = assembler.apply(batch);
} catch (RuntimeException failure) {
return ShadowCycleResult.insufficient(
"INVALID_KERNEL_FACTS:" + message(failure));
}
ShadowDispatchResult<Ft546SrgvCommandIntent> result;
try {
result = dispatchCycle.run(snapshot);
} catch (IllegalArgumentException failure) {
return ShadowCycleResult.insufficient(
"INVALID_COMMAND_FACTS:" + message(failure));
}
if (result.getDecisions().isRetrySnapshotChanged()) {
return ShadowCycleResult.retry(result.getDecisions());
}
final List<Ft546EncodedCommand> encoded;
try {
encoded = commandGateway.recordAll(result.getIntents());
} catch (RuntimeException failure) {
return ShadowCycleResult.insufficient(
"INVALID_COMMAND_FACTS:" + message(failure));
}
return ShadowCycleResult.comparable(result.getDecisions(),
result.getIntents(), encoded);
}
private static String message(RuntimeException failure) {
String value = failure.getMessage();
return value == null || value.trim().isEmpty()
? failure.getClass().getSimpleName() : value.trim();
}
}

View File

@@ -0,0 +1,482 @@
package com.ruoyi.shadow.kernel;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.wcs.application.dispatch.DispatchCommit;
import com.ruoyi.wcs.application.dispatch.HeldDispatch;
import com.ruoyi.wcs.domain.topology.EffectiveTopology;
import com.ruoyi.wcs.domain.traffic.ReservationState;
import com.ruoyi.wcs.domain.traffic.ResourceLedger;
import com.ruoyi.wcs.domain.traffic.ResourceLedgerState;
import com.ruoyi.wcs.domain.traffic.ResourceReservationState;
import com.ruoyi.wcs.domain.traffic.WaitForGraph;
import com.ruoyi.wcs.port.out.TrafficStateRepository;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.support.TransactionTemplate;
import java.sql.Timestamp;
import java.time.Clock;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
public final class JdbcShadowTrafficStateRepository
implements TrafficStateRepository {
private static final String KEY_SEPARATOR = "\u001f";
private final JdbcTemplate jdbc;
private final TransactionTemplate transaction;
private final ObjectMapper objectMapper;
private final Clock clock;
private final ShadowCommandIntentFactory intentFactory;
private final RecordingFt546CommandGateway commandGateway;
public JdbcShadowTrafficStateRepository(
JdbcTemplate jdbc,
TransactionTemplate transaction,
ObjectMapper objectMapper,
Clock clock,
ShadowCommandIntentFactory intentFactory,
RecordingFt546CommandGateway commandGateway) {
this.jdbc = Objects.requireNonNull(jdbc, "jdbc");
this.transaction = Objects.requireNonNull(transaction, "transaction");
this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper");
this.clock = Objects.requireNonNull(clock, "clock");
this.intentFactory = Objects.requireNonNull(intentFactory,
"intentFactory");
this.commandGateway = Objects.requireNonNull(commandGateway,
"commandGateway");
}
@Override
public boolean commit(DispatchCommit commit) {
Objects.requireNonNull(commit, "commit");
commit.getGranted().forEach(grant -> commandGateway.preview(
intentFactory.create(grant)));
try {
Boolean result = transaction.execute(status -> doCommit(commit));
return Boolean.TRUE.equals(result);
} catch (DuplicateKeyException concurrentInsert) {
return false;
}
}
public ShadowTrafficState load(EffectiveTopology topology) {
Objects.requireNonNull(topology, "topology");
StoredRow row = read(stateKey(topology.getWarehouseCode(),
topology.version()));
if (row == null) {
return new ShadowTrafficState(new ResourceLedger(topology),
new WaitForGraph(), 0L,
Collections.<String, Integer>emptyMap());
}
PersistedState persisted = decode(row.stateJson);
ResourceLedger ledger = ResourceLedger.restore(persisted.ledger);
Map<String, Integer> topologyCapacities =
new ResourceLedger(topology).snapshotState().getCapacities();
if (!topologyCapacities.equals(
ledger.snapshotState().getCapacities())) {
throw new IllegalStateException(
"persisted traffic resources do not match topology");
}
return new ShadowTrafficState(ledger,
WaitForGraph.restore(persisted.waitForEdges), row.version,
persisted.holdCounts);
}
private boolean doCommit(DispatchCommit commit) {
String key = stateKey(commit.getWarehouseCode(),
commit.getTopologyVersion());
StoredRow current = read(key);
if (current == null && commit.getSnapshotVersion() != 0L) {
return false;
}
if (current != null
&& current.version != commit.getSnapshotVersion()) {
return false;
}
Map<String, Integer> previousHolds = current == null
? Collections.<String, Integer>emptyMap()
: decode(current.stateJson).holdCounts;
Map<String, Integer> nextHolds = nextHoldCounts(previousHolds,
commit.getHeld());
long nextVersion = increment(commit.getSnapshotVersion());
String stateJson = encode(commit.getResultingLedger().snapshotState(),
commit.getResultingWaitForGraph().snapshotEdges(), nextHolds);
Timestamp now = Timestamp.from(clock.instant());
if (current == null) {
jdbc.update("INSERT INTO shadow_traffic_state "
+ "(state_key,snapshot_version,state_json,updated_at) "
+ "VALUES (?,?,?,?)",
key, nextVersion, stateJson, now);
} else {
int updated = jdbc.update("UPDATE shadow_traffic_state "
+ "SET snapshot_version=?,state_json=?,updated_at=? "
+ "WHERE state_key=? AND snapshot_version=?",
nextVersion, stateJson, now, key,
commit.getSnapshotVersion());
if (updated != 1) {
return false;
}
}
synchronizeHolds(previousHolds, nextHolds, now);
return true;
}
private StoredRow read(String key) {
List<StoredRow> rows = jdbc.query(
"SELECT snapshot_version,state_json "
+ "FROM shadow_traffic_state WHERE state_key=?",
(resultSet, rowNumber) -> new StoredRow(
resultSet.getLong("snapshot_version"),
resultSet.getString("state_json")), key);
if (rows.size() > 1) {
throw new IllegalStateException(
"multiple shadow traffic states for key " + key);
}
return rows.isEmpty() ? null : rows.get(0);
}
private void synchronizeHolds(Map<String, Integer> previous,
Map<String, Integer> current,
Timestamp now) {
for (String key : previous.keySet()) {
if (!current.containsKey(key)) {
HoldIdentity identity = HoldIdentity.parse(key);
jdbc.update("DELETE FROM shadow_hold_history "
+ "WHERE parent_task_id=? AND vehicle_id=? "
+ "AND resource_key=? AND reason_code=?",
identity.parentTaskId, identity.vehicleId,
identity.resourceKey, identity.reasonCode);
}
}
for (Map.Entry<String, Integer> entry : current.entrySet()) {
HoldIdentity identity = HoldIdentity.parse(entry.getKey());
int updated = jdbc.update("UPDATE shadow_hold_history "
+ "SET consecutive_count=?,updated_at=? "
+ "WHERE parent_task_id=? AND vehicle_id=? "
+ "AND resource_key=? AND reason_code=?",
entry.getValue(), now, identity.parentTaskId,
identity.vehicleId, identity.resourceKey,
identity.reasonCode);
if (updated == 0) {
jdbc.update("INSERT INTO shadow_hold_history "
+ "(parent_task_id,vehicle_id,resource_key,"
+ "reason_code,consecutive_count,updated_at) "
+ "VALUES (?,?,?,?,?,?)",
identity.parentTaskId, identity.vehicleId,
identity.resourceKey, identity.reasonCode,
entry.getValue(), now);
}
}
}
private Map<String, Integer> nextHoldCounts(
Map<String, Integer> previous,
List<HeldDispatch> held) {
Map<String, Integer> next = new TreeMap<String, Integer>();
for (HeldDispatch decision : held) {
String key = HoldIdentity.from(decision).key();
if (next.containsKey(key)) {
continue;
}
Integer count = previous.get(key);
next.put(key, count == null ? 1 : increment(count.intValue()));
}
return Collections.unmodifiableMap(next);
}
private String encode(ResourceLedgerState ledger,
Map<String, Set<String>> waitForEdges,
Map<String, Integer> holdCounts) {
Map<String, Object> root = new LinkedHashMap<String, Object>();
root.put("schemaVersion", 1);
root.put("ledger", ledgerMap(ledger));
Map<String, Object> edges = new TreeMap<String, Object>();
for (Map.Entry<String, Set<String>> entry : waitForEdges.entrySet()) {
edges.put(entry.getKey(),
new ArrayList<String>(new TreeSet<String>(entry.getValue())));
}
root.put("waitForEdges", edges);
root.put("holdCounts", new TreeMap<String, Integer>(holdCounts));
try {
return objectMapper.writeValueAsString(root);
} catch (Exception failure) {
throw new IllegalStateException(
"cannot serialize shadow traffic state", failure);
}
}
private Map<String, Object> ledgerMap(ResourceLedgerState ledger) {
Map<String, Object> result = new LinkedHashMap<String, Object>();
result.put("capacities", new TreeMap<String, Integer>(
ledger.getCapacities()));
result.put("nextFencingToken", ledger.getNextFencingToken());
List<ResourceReservationState> reservations =
new ArrayList<ResourceReservationState>(
ledger.getReservations());
Collections.sort(reservations, Comparator.comparing(
ResourceReservationState::getReservationId));
List<Map<String, Object>> values =
new ArrayList<Map<String, Object>>(reservations.size());
for (ResourceReservationState reservation : reservations) {
Map<String, Object> value = new LinkedHashMap<String, Object>();
value.put("reservationId", reservation.getReservationId());
value.put("orderId", reservation.getOrderId());
value.put("stepId", reservation.getStepId());
value.put("vehicleId", reservation.getVehicleId());
value.put("resourceKeys", reservation.getResourceKeys());
value.put("state", reservation.getState().name());
value.put("fencingToken", reservation.getFencingToken());
value.put("terminalReason", reservation.getTerminalReason());
value.put("releaseEvidenceId", reservation.getReleaseEvidenceId());
value.put("commandUnknown", reservation.isCommandUnknown());
value.put("recoveryUnresolved",
reservation.isRecoveryUnresolved());
values.add(value);
}
result.put("reservations", values);
return result;
}
private PersistedState decode(String json) {
try {
JsonNode root = objectMapper.readTree(json);
if (root.path("schemaVersion").asInt(-1) != 1) {
throw new IllegalStateException(
"unsupported shadow traffic state schema");
}
JsonNode ledger = requiredObject(root, "ledger");
Map<String, Integer> capacities = integerMap(
requiredObject(ledger, "capacities"));
List<ResourceReservationState> reservations =
reservations(requiredArray(ledger, "reservations"));
long nextToken = requiredLong(ledger, "nextFencingToken");
ResourceLedgerState ledgerState = new ResourceLedgerState(
capacities, reservations, nextToken);
Map<String, Set<String>> edges = edgeMap(
requiredObject(root, "waitForEdges"));
Map<String, Integer> holds = integerMap(
requiredObject(root, "holdCounts"));
return new PersistedState(ledgerState, edges, holds);
} catch (RuntimeException failure) {
throw failure;
} catch (Exception failure) {
throw new IllegalStateException(
"cannot deserialize shadow traffic state", failure);
}
}
private static List<ResourceReservationState> reservations(JsonNode array) {
List<ResourceReservationState> result =
new ArrayList<ResourceReservationState>();
for (JsonNode node : array) {
List<String> resourceKeys = new ArrayList<String>();
for (JsonNode resource : requiredArray(node, "resourceKeys")) {
resourceKeys.add(requiredNodeText(resource, "resourceKey"));
}
result.add(new ResourceReservationState(
requiredText(node, "reservationId"),
requiredText(node, "orderId"),
requiredText(node, "stepId"),
requiredText(node, "vehicleId"), resourceKeys,
ReservationState.valueOf(requiredText(node, "state")),
requiredLong(node, "fencingToken"),
optionalText(node.get("terminalReason")),
optionalText(node.get("releaseEvidenceId")),
requiredBoolean(node, "commandUnknown"),
requiredBoolean(node, "recoveryUnresolved")));
}
return result;
}
private static Map<String, Set<String>> edgeMap(JsonNode object) {
Map<String, Set<String>> result =
new LinkedHashMap<String, Set<String>>();
object.fields().forEachRemaining(entry -> {
if (!entry.getValue().isArray()) {
throw new IllegalStateException("wait-for holders must be an array");
}
Set<String> holders = new LinkedHashSet<String>();
for (JsonNode holder : entry.getValue()) {
holders.add(requiredNodeText(holder, "holder"));
}
result.put(entry.getKey(), holders);
});
return result;
}
private static Map<String, Integer> integerMap(JsonNode object) {
Map<String, Integer> result = new LinkedHashMap<String, Integer>();
object.fields().forEachRemaining(entry -> {
if (!entry.getValue().canConvertToInt()) {
throw new IllegalStateException("state count must be int32");
}
result.put(entry.getKey(), entry.getValue().intValue());
});
return result;
}
private static JsonNode requiredObject(JsonNode parent, String field) {
JsonNode value = parent.get(field);
if (value == null || !value.isObject()) {
throw new IllegalStateException(field + " must be an object");
}
return value;
}
private static JsonNode requiredArray(JsonNode parent, String field) {
JsonNode value = parent.get(field);
if (value == null || !value.isArray()) {
throw new IllegalStateException(field + " must be an array");
}
return value;
}
private static String requiredText(JsonNode parent, String field) {
return requiredNodeText(parent.get(field), field);
}
private static String requiredNodeText(JsonNode value, String field) {
String text = optionalText(value);
if (text == null) {
throw new IllegalStateException(field + " must be text");
}
return text;
}
private static String optionalText(JsonNode value) {
if (value == null || value.isNull() || !value.isTextual()) {
return null;
}
String text = value.textValue();
return text == null || text.trim().isEmpty() ? null : text.trim();
}
private static long requiredLong(JsonNode parent, String field) {
JsonNode value = parent.get(field);
if (value == null || !value.canConvertToLong()) {
throw new IllegalStateException(field + " must be int64");
}
return value.longValue();
}
private static boolean requiredBoolean(JsonNode parent, String field) {
JsonNode value = parent.get(field);
if (value == null || !value.isBoolean()) {
throw new IllegalStateException(field + " must be boolean");
}
return value.booleanValue();
}
private static String stateKey(String warehouseCode, long topologyVersion) {
if (topologyVersion <= 0L) {
throw new IllegalArgumentException(
"topologyVersion must be positive");
}
return requiredPart(warehouseCode, "warehouseCode")
+ ":TOPOLOGY:" + topologyVersion;
}
private static String requiredPart(String value, String field) {
if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException(field + " must not be blank");
}
String normalized = value.trim();
if (normalized.contains(KEY_SEPARATOR)) {
throw new IllegalArgumentException(field + " contains a reserved character");
}
return normalized;
}
private static long increment(long value) {
if (value == Long.MAX_VALUE) {
throw new IllegalStateException("snapshotVersion overflow");
}
return value + 1L;
}
private static int increment(int value) {
if (value == Integer.MAX_VALUE) {
throw new IllegalStateException("consecutive HOLD count overflow");
}
return value + 1;
}
private static final class StoredRow {
private final long version;
private final String stateJson;
private StoredRow(long version, String stateJson) {
this.version = version;
this.stateJson = stateJson;
}
}
private static final class PersistedState {
private final ResourceLedgerState ledger;
private final Map<String, Set<String>> waitForEdges;
private final Map<String, Integer> holdCounts;
private PersistedState(ResourceLedgerState ledger,
Map<String, Set<String>> waitForEdges,
Map<String, Integer> holdCounts) {
this.ledger = ledger;
this.waitForEdges = waitForEdges;
this.holdCounts = holdCounts;
}
}
private static final class HoldIdentity {
private final String parentTaskId;
private final String vehicleId;
private final String resourceKey;
private final String reasonCode;
private HoldIdentity(String parentTaskId, String vehicleId,
String resourceKey, String reasonCode) {
this.parentTaskId = requiredPart(parentTaskId, "parentTaskId");
this.vehicleId = requiredPart(vehicleId, "vehicleId");
this.resourceKey = requiredPart(resourceKey, "resourceKey");
this.reasonCode = requiredPart(reasonCode, "reasonCode");
}
private static HoldIdentity from(HeldDispatch held) {
String vehicle = held.getVehicleId() == null
? "UNASSIGNED" : held.getVehicleId();
String resource;
if (held.getYieldNodeId() != null) {
resource = "NODE:" + held.getYieldNodeId();
} else if (!held.getBlockingVehicleIds().isEmpty()) {
resource = "BLOCKERS:" + String.join(",",
new TreeSet<String>(held.getBlockingVehicleIds()));
} else {
resource = "UNSPECIFIED";
}
return new HoldIdentity(held.getOrderId(), vehicle, resource,
held.getReasonCode());
}
private static HoldIdentity parse(String key) {
String[] values = key.split(KEY_SEPARATOR, -1);
if (values.length != 4) {
throw new IllegalStateException("invalid persisted HOLD key");
}
return new HoldIdentity(values[0], values[1], values[2], values[3]);
}
private String key() {
return parentTaskId + KEY_SEPARATOR + vehicleId + KEY_SEPARATOR
+ resourceKey + KEY_SEPARATOR + reasonCode;
}
}
}

View File

@@ -0,0 +1,61 @@
package com.ruoyi.shadow.kernel;
import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
import com.ruoyi.ft546.protocol.Ft546EncodedCommand;
import com.ruoyi.ft546.protocol.Ft546SrgvCommandEncoder;
import com.ruoyi.ft546.protocol.Ft546SrgvCommandIntent;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
public final class RecordingFt546CommandGateway {
private final Ft546SrgvCommandEncoder encoder;
private final Ft546DeviceProfile profile;
private final Set<String> capabilities;
private final List<Ft546EncodedCommand> recorded =
new ArrayList<Ft546EncodedCommand>();
public RecordingFt546CommandGateway(Ft546SrgvCommandEncoder encoder,
Ft546DeviceProfile profile,
Set<String> capabilities) {
this.encoder = Objects.requireNonNull(encoder, "encoder");
this.profile = Objects.requireNonNull(profile, "profile");
this.capabilities = Collections.unmodifiableSet(
new LinkedHashSet<String>(Objects.requireNonNull(capabilities,
"capabilities")));
}
public synchronized Ft546EncodedCommand record(
Ft546SrgvCommandIntent intent) {
Ft546EncodedCommand encoded = preview(intent);
recorded.add(encoded);
return encoded;
}
public synchronized Ft546EncodedCommand preview(
Ft546SrgvCommandIntent intent) {
return encoder.encode(Objects.requireNonNull(intent, "intent"),
profile, capabilities);
}
public synchronized List<Ft546EncodedCommand> recordAll(
List<Ft546SrgvCommandIntent> intents) {
Objects.requireNonNull(intents, "intents");
List<Ft546EncodedCommand> encoded =
new ArrayList<Ft546EncodedCommand>(intents.size());
for (Ft546SrgvCommandIntent intent : intents) {
encoded.add(preview(intent));
}
recorded.addAll(encoded);
return Collections.unmodifiableList(encoded);
}
public synchronized List<Ft546EncodedCommand> getRecorded() {
return Collections.unmodifiableList(
new ArrayList<Ft546EncodedCommand>(recorded));
}
}

View File

@@ -0,0 +1,133 @@
package com.ruoyi.shadow.kernel;
import com.ruoyi.ft546.protocol.Ft546SrgvCommandIntent;
import com.ruoyi.wcs.application.dispatch.GrantedDispatch;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
public final class ShadowCommandIntentFactory {
public Ft546SrgvCommandIntent create(GrantedDispatch grant) {
Objects.requireNonNull(grant, "grant");
Map<String, String> parameters = grant.getCommandParameters();
String parentTaskId = required(parameters, "parentTaskId");
String phase = required(parameters, "phase");
long routeVersion = positiveLong(parameters, "routeVersion");
int taskNo = positiveInt(parameters, "taskNo");
short taskType = signedShort(parameters, "taskType");
Integer stage = optionalStage(parameters.get("stage"));
Short crossFloorTarget = crossFloorTarget(parameters, taskType, stage);
List<Integer> path = path(grant);
String commandKey = parentTaskId + ':' + phase + ':' + routeVersion
+ ':' + requiredText(grant.getStepId(), "stepId");
return new Ft546SrgvCommandIntent(
requiredText(grant.getVehicleId(), "vehicleId"),
parentTaskId, phase, commandKey, routeVersion, taskNo,
taskType, path, crossFloorTarget, stage);
}
private static List<Integer> path(GrantedDispatch grant) {
List<Integer> points = new ArrayList<Integer>();
for (String nodeId : grant.getProtectedRoute().getNodeIds()) {
try {
points.add(Integer.valueOf(requiredText(nodeId, "route node")));
} catch (NumberFormatException failure) {
throw new IllegalArgumentException(
"route node must be a numeric FT546 site: " + nodeId,
failure);
}
}
return points;
}
private static Short crossFloorTarget(Map<String, String> parameters,
short taskType, Integer stage) {
String value = parameters.get("crossFloorTarget");
if (stage != null && value != null) {
throw new IllegalArgumentException(
"crossFloorTarget is not valid for a two-stage phase");
}
if (taskType != 8) {
if (value != null) {
throw new IllegalArgumentException(
"crossFloorTarget is valid only when taskType is 8");
}
return null;
}
if (stage != null) {
throw new IllegalArgumentException(
"taskType 8 is not valid for a two-stage phase");
}
short target = positiveShort(parameters, "crossFloorTarget");
return Short.valueOf(target);
}
private static Integer optionalStage(String value) {
if (value == null) {
return null;
}
int stage;
try {
stage = Integer.parseInt(requiredText(value, "stage"));
} catch (NumberFormatException failure) {
throw new IllegalArgumentException("stage must be 1 or 2", failure);
}
if (stage != 1 && stage != 2) {
throw new IllegalArgumentException("stage must be 1 or 2");
}
return Integer.valueOf(stage);
}
private static int positiveInt(Map<String, String> values, String key) {
long value = positiveLong(values, key);
if (value > Integer.MAX_VALUE) {
throw new IllegalArgumentException(key + " exceeds int32");
}
return (int) value;
}
private static long positiveLong(Map<String, String> values, String key) {
String text = required(values, key);
long value;
try {
value = Long.parseLong(text);
} catch (NumberFormatException failure) {
throw new IllegalArgumentException(key + " must be numeric", failure);
}
if (value <= 0L) {
throw new IllegalArgumentException(key + " must be positive");
}
return value;
}
private static short signedShort(Map<String, String> values, String key) {
String text = required(values, key);
try {
return Short.parseShort(text);
} catch (NumberFormatException failure) {
throw new IllegalArgumentException(key + " must be int16", failure);
}
}
private static short positiveShort(Map<String, String> values, String key) {
short value = signedShort(values, key);
if (value <= 0) {
throw new IllegalArgumentException(key + " must be positive");
}
return value;
}
private static String required(Map<String, String> values, String key) {
return requiredText(values.get(key), key);
}
private static String requiredText(String value, String field) {
if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException(field + " must not be blank");
}
return value.trim();
}
}

View File

@@ -0,0 +1,61 @@
package com.ruoyi.shadow.kernel;
import com.ruoyi.ft546.protocol.Ft546EncodedCommand;
import com.ruoyi.ft546.protocol.Ft546SrgvCommandIntent;
import com.ruoyi.wcs.application.dispatch.DispatchCycleResult;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public final class ShadowCycleResult {
private final String statusCode;
private final String reasonCode;
private final DispatchCycleResult decisions;
private final List<Ft546SrgvCommandIntent> intents;
private final List<Ft546EncodedCommand> encodedCommands;
private ShadowCycleResult(String statusCode, String reasonCode,
DispatchCycleResult decisions,
List<Ft546SrgvCommandIntent> intents,
List<Ft546EncodedCommand> encodedCommands) {
this.statusCode = statusCode;
this.reasonCode = reasonCode;
this.decisions = decisions;
this.intents = immutable(intents);
this.encodedCommands = immutable(encodedCommands);
}
public static ShadowCycleResult insufficient(String reasonCode) {
return new ShadowCycleResult("INSUFFICIENT_FACTS", reasonCode, null,
Collections.<Ft546SrgvCommandIntent>emptyList(),
Collections.<Ft546EncodedCommand>emptyList());
}
public static ShadowCycleResult retry(DispatchCycleResult decisions) {
return new ShadowCycleResult("RETRY_SNAPSHOT_CHANGED",
"SHADOW_TRAFFIC_STATE_CHANGED", decisions,
Collections.<Ft546SrgvCommandIntent>emptyList(),
Collections.<Ft546EncodedCommand>emptyList());
}
public static ShadowCycleResult comparable(
DispatchCycleResult decisions,
List<Ft546SrgvCommandIntent> intents,
List<Ft546EncodedCommand> encodedCommands) {
return new ShadowCycleResult("COMPARABLE", null, decisions, intents,
encodedCommands);
}
public String getStatusCode() { return statusCode; }
public String getReasonCode() { return reasonCode; }
public DispatchCycleResult getDecisions() { return decisions; }
public List<Ft546SrgvCommandIntent> getIntents() { return intents; }
public List<Ft546EncodedCommand> getEncodedCommands() {
return encodedCommands;
}
private static <T> List<T> immutable(List<T> source) {
return Collections.unmodifiableList(new ArrayList<T>(source));
}
}

View File

@@ -0,0 +1,288 @@
package com.ruoyi.shadow.kernel;
import com.ruoyi.shadow.facts.ShadowFactBatch;
import com.ruoyi.shadow.facts.ShadowMapFact;
import com.ruoyi.shadow.facts.ShadowQueueFact;
import com.ruoyi.shadow.facts.ShadowVehicleFact;
import com.ruoyi.wcs.application.dispatch.DispatchSnapshot;
import com.ruoyi.wcs.domain.dispatch.DispatchTask;
import com.ruoyi.wcs.domain.equipment.DeviceCapability;
import com.ruoyi.wcs.domain.equipment.DeviceDescriptor;
import com.ruoyi.wcs.domain.equipment.DeviceType;
import com.ruoyi.wcs.domain.equipment.VehicleSnapshot;
import com.ruoyi.wcs.domain.topology.EffectiveTopology;
import com.ruoyi.wcs.domain.topology.ResourceDefinition;
import com.ruoyi.wcs.domain.topology.TopologyCompiler;
import com.ruoyi.wcs.domain.topology.TopologyDefinition;
import com.ruoyi.wcs.domain.topology.TopologyEdge;
import com.ruoyi.wcs.domain.topology.TopologyNode;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.EnumSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
public final class ShadowDispatchSnapshotAssembler {
private static final String ACTIVE_QUEUE =
"shadow_ro.v_wcs_srgv_queue";
private final String warehouseCode;
private final long topologyVersion;
private final Function<EffectiveTopology, ShadowTrafficState> stateLoader;
private final Clock clock;
public ShadowDispatchSnapshotAssembler(
String warehouseCode,
long topologyVersion,
Function<EffectiveTopology, ShadowTrafficState> stateLoader,
Clock clock) {
this.warehouseCode = required(warehouseCode, "warehouseCode");
if (topologyVersion <= 0L) {
throw new IllegalArgumentException(
"topologyVersion must be positive");
}
this.topologyVersion = topologyVersion;
this.stateLoader = Objects.requireNonNull(stateLoader, "stateLoader");
this.clock = Objects.requireNonNull(clock, "clock");
}
public DispatchSnapshot assemble(ShadowFactBatch batch) {
Objects.requireNonNull(batch, "batch");
EffectiveTopology topology = topology(batch.getMaps());
ShadowTrafficState state = Objects.requireNonNull(
stateLoader.apply(topology), "stateLoader result");
return new DispatchSnapshot(tasks(batch, topology),
vehicles(batch, topology), topology, state.getLedger(),
state.getWaitForGraph(), state.getSnapshotVersion());
}
private EffectiveTopology topology(List<ShadowMapFact> facts) {
List<TopologyNode> nodes = new ArrayList<TopologyNode>();
Map<String, ShadowMapFact> coordinates =
new LinkedHashMap<String, ShadowMapFact>();
for (ShadowMapFact fact : facts) {
if (fact.getValue() != 1 && fact.getValue() != 2) {
continue;
}
nodes.add(new TopologyNode(fact.getSiteId(), floor(fact.getZ()),
fact.getX(), fact.getY(), 1, false, false));
String coordinate = coordinate(fact.getX(), fact.getY(), fact.getZ());
if (coordinates.put(coordinate, fact) != null) {
throw new IllegalArgumentException(
"duplicate passable map coordinate: " + coordinate);
}
}
if (nodes.isEmpty()) {
throw new IllegalArgumentException("map has no passable nodes");
}
List<TopologyEdge> edges = new ArrayList<TopologyEdge>();
for (ShadowMapFact fact : coordinates.values()) {
addEdge(edges, fact, coordinates.get(coordinate(
fact.getX() + 1, fact.getY(), fact.getZ())));
addEdge(edges, fact, coordinates.get(coordinate(
fact.getX(), fact.getY() + 1, fact.getZ())));
}
TopologyDefinition definition = new TopologyDefinition(warehouseCode,
topologyVersion, true, nodes, edges,
Collections.<ResourceDefinition>emptyList(),
Collections.<String>emptyList(),
Collections.<String>emptyList());
return new TopologyCompiler().compile(definition);
}
private List<DispatchTask> tasks(ShadowFactBatch batch,
EffectiveTopology topology) {
List<DispatchTask> tasks = new ArrayList<DispatchTask>();
Instant now = clock.instant();
for (ShadowQueueFact fact : batch.getQueues()) {
if (!ACTIVE_QUEUE.equals(fact.getTableName())) {
continue;
}
String source = required(fact.getStartSite(), "startSite");
String destination = required(fact.getEndSite(), "endSite");
if (!topology.containsNode(source)
|| !topology.containsNode(destination)) {
throw new IllegalArgumentException(
"active queue endpoint is outside passable topology: "
+ fact.getGuid());
}
Map<String, String> command = commandParameters(fact);
tasks.add(new DispatchTask(required(fact.getTaskNo(), "taskNo"),
fact.getGuid(), source, destination, priority(fact),
waitingAge(fact.getCreatedAt(), now), 31,
EnumSet.of(DeviceCapability.FOUR_WAY_MOVE), command));
}
return tasks;
}
private Map<String, String> commandParameters(ShadowQueueFact fact) {
Map<String, String> values = new LinkedHashMap<String, String>();
String parentTaskId = required(fact.getTaskNo(), "taskNo");
values.put("parentTaskId", parentTaskId);
values.put("phase", phase(required(fact.getTaskType(), "taskType")));
values.put("routeVersion", Long.toString(topologyVersion));
values.put("taskNo", positiveNumber(parentTaskId, "taskNo"));
short taskType = parsedShort(required(fact.getDefault02(), "default02"),
"default02");
values.put("taskType", Short.toString(taskType));
String stage = optional(fact.getDefault08());
if (stage != null) {
if (!"1".equals(stage) && !"2".equals(stage)) {
throw new IllegalArgumentException("default08 must be 1 or 2");
}
values.put("stage", stage);
}
String target = optional(fact.getDefault07());
if (taskType == 8) {
if (target == null) {
throw new IllegalArgumentException(
"default07 is required for cross-floor task");
}
values.put("crossFloorTarget", positiveNumber(target,
"default07"));
}
if (stage != null && taskType == 8) {
throw new IllegalArgumentException(
"cross-floor and two-stage parameters cannot be combined");
}
return values;
}
private List<VehicleSnapshot> vehicles(ShadowFactBatch batch,
EffectiveTopology topology) {
List<VehicleSnapshot> result = new ArrayList<VehicleSnapshot>();
for (ShadowVehicleFact fact : batch.getVehicles()) {
String location = required(fact.getCurrentSite(), "currentSite");
if (!topology.containsNode(location)) {
throw new IllegalArgumentException(
"vehicle is outside passable topology: "
+ fact.getDeviceId());
}
String floor = fact.getCurrentFloor() == null
? topology.node(location).getFloorCode()
: floor(fact.getCurrentFloor().intValue());
DeviceDescriptor descriptor = new DeviceDescriptor(
fact.getDeviceId(), DeviceType.FOUR_WAY_SHUTTLE, floor,
EnumSet.of(DeviceCapability.FOUR_WAY_MOVE,
DeviceCapability.LOAD_HANDLING,
DeviceCapability.LIFT_TRANSFER));
boolean connectedAndSendable = "1".equals(optional(
fact.getAgvStatus()))
&& Integer.valueOf(1).equals(fact.getAllowSend());
boolean healthy = zeroOrMissing(fact.getAlarm())
&& zeroOrMissing(fact.getErrorCode());
boolean idle = Integer.valueOf(1).equals(fact.getStatus());
result.add(new VehicleSnapshot(descriptor,
connectedAndSendable
? VehicleSnapshot.ConnectionState.ONLINE
: VehicleSnapshot.ConnectionState.OFFLINE,
Integer.valueOf(5).equals(fact.getWorkMode())
? VehicleSnapshot.OperatingMode.AUTO
: VehicleSnapshot.OperatingMode.MANUAL,
healthy ? VehicleSnapshot.HealthState.NORMAL
: VehicleSnapshot.HealthState.FAULT,
idle ? VehicleSnapshot.OccupationState.IDLE
: VehicleSnapshot.OccupationState.BUSY,
location,
Integer.valueOf(1).equals(fact.getCargoFlag())
? "LOAD_PRESENT" : null,
fact.getBattery().intValue(),
positive(fact.getTaskNo())
? Integer.toString(fact.getTaskNo()) : null,
floor, fact.getCapturedAt()));
}
return result;
}
private static void addEdge(List<TopologyEdge> edges,
ShadowMapFact first,
ShadowMapFact second) {
if (second != null) {
edges.add(new TopologyEdge(first.getSiteId(), second.getSiteId(),
1, 1));
}
}
private static int priority(ShadowQueueFact fact) {
if (fact.getPriority() == null) {
return Integer.MIN_VALUE;
}
return (int) Math.max(Integer.MIN_VALUE,
Math.min(Integer.MAX_VALUE, fact.getPriority().longValue()));
}
private static long waitingAge(Instant createdAt, Instant now) {
if (createdAt == null || createdAt.isAfter(now)) {
throw new IllegalArgumentException(
"active queue createdAt must not be missing or future");
}
return Duration.between(createdAt, now).getSeconds();
}
private static String phase(String taskType) {
if ("1".equals(taskType)) {
return "EMPTY_TO_PICKUP";
}
if ("2".equals(taskType)) {
return "LOADED_TO_PLACE";
}
return "LEGACY_PHASE_" + taskType;
}
private static String floor(int value) {
return "F" + value;
}
private static String coordinate(int x, int y, int z) {
return z + ":" + x + ":" + y;
}
private static boolean zeroOrMissing(Integer value) {
return value == null || value.intValue() == 0;
}
private static boolean positive(Integer value) {
return value != null && value.intValue() > 0;
}
private static String positiveNumber(String value, String field) {
long parsed;
try {
parsed = Long.parseLong(required(value, field));
} catch (NumberFormatException failure) {
throw new IllegalArgumentException(field + " must be numeric", failure);
}
if (parsed <= 0L) {
throw new IllegalArgumentException(field + " must be positive");
}
return Long.toString(parsed);
}
private static short parsedShort(String value, String field) {
try {
return Short.parseShort(value);
} catch (NumberFormatException failure) {
throw new IllegalArgumentException(field + " must be int16", failure);
}
}
private static String optional(String value) {
return value == null || value.trim().isEmpty() ? null : value.trim();
}
private static String required(String value, String field) {
String normalized = optional(value);
if (normalized == null) {
throw new IllegalArgumentException(field + " must not be blank");
}
return normalized;
}
}

View File

@@ -0,0 +1,49 @@
package com.ruoyi.shadow.kernel;
import com.ruoyi.wcs.domain.traffic.ResourceLedger;
import com.ruoyi.wcs.domain.traffic.WaitForGraph;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
public final class ShadowTrafficState {
private final ResourceLedger ledger;
private final WaitForGraph waitForGraph;
private final long snapshotVersion;
private final Map<String, Integer> consecutiveHoldCounts;
public ShadowTrafficState(ResourceLedger ledger,
WaitForGraph waitForGraph,
long snapshotVersion,
Map<String, Integer> consecutiveHoldCounts) {
if (snapshotVersion < 0L) {
throw new IllegalArgumentException(
"snapshotVersion must not be negative");
}
this.ledger = Objects.requireNonNull(ledger, "ledger").copy();
this.waitForGraph = Objects.requireNonNull(waitForGraph,
"waitForGraph").copy();
this.snapshotVersion = snapshotVersion;
this.consecutiveHoldCounts = Collections.unmodifiableMap(
new LinkedHashMap<String, Integer>(Objects.requireNonNull(
consecutiveHoldCounts, "consecutiveHoldCounts")));
}
public ResourceLedger getLedger() {
return ledger.copy();
}
public WaitForGraph getWaitForGraph() {
return waitForGraph.copy();
}
public long getSnapshotVersion() {
return snapshotVersion;
}
public Map<String, Integer> getConsecutiveHoldCounts() {
return consecutiveHoldCounts;
}
}

View File

@@ -4,6 +4,11 @@ import com.ruoyi.project.shadowtest.ForbiddenFactoryBean;
import com.ruoyi.shadow.startup.ShadowDatabaseGate;
import com.ruoyi.shadow.startup.ShadowStartupReport;
import com.ruoyi.shadow.web.ShadowStatusController;
import com.ruoyi.shadow.kernel.Ft546ShadowCycle;
import com.ruoyi.shadow.kernel.JdbcShadowTrafficStateRepository;
import com.ruoyi.shadow.kernel.RecordingFt546CommandGateway;
import com.ruoyi.shadow.kernel.ShadowDispatchSnapshotAssembler;
import com.ruoyi.wcs.application.ReadOnlyDispatchCycle;
import com.ruoyi.wcs.application.dispatch.DispatchCoordinator;
import com.ruoyi.wcs.port.out.DeviceCommandGateway;
import com.ruoyi.wcs.port.out.WmsNotificationGateway;
@@ -63,6 +68,16 @@ class Ft546ShadowApplicationTest {
assertEquals("PASS", context.getBean(ShadowStartupReport.class)
.getStartupChecks().get("EVIDENCE_DATABASE_VERIFIED"));
assertNotNull(context.getBean(DispatchCoordinator.class));
assertNotNull(context.getBean(ReadOnlyDispatchCycle.class));
assertNotNull(context.getBean(Ft546ShadowCycle.class));
assertNotNull(context.getBean(ShadowDispatchSnapshotAssembler.class));
assertNotNull(context.getBean(RecordingFt546CommandGateway.class));
assertNotNull(context.getBean(
JdbcShadowTrafficStateRepository.class));
assertEquals(context.getBean(
JdbcShadowTrafficStateRepository.class),
context.getBean(com.ruoyi.wcs.port.out
.TrafficStateRepository.class));
assertNotNull(context.getBean(ShadowStatusController.class));
Map<String, String> persisted = context.getBean(
RecordingShadowDatabaseGate.class).getPersistedChecks();

View File

@@ -0,0 +1,362 @@
package com.ruoyi.shadow.kernel;
import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
import com.ruoyi.ft546.protocol.Ft546EncodedCommand;
import com.ruoyi.ft546.protocol.Ft546SrgvCommandEncoder;
import com.ruoyi.ft546.protocol.Ft546SrgvCommandIntent;
import com.ruoyi.shadow.facts.ShadowFactBatch;
import com.ruoyi.shadow.facts.ShadowFactBatchValidator;
import com.ruoyi.shadow.facts.ShadowMapFact;
import com.ruoyi.shadow.facts.ShadowQueueFact;
import com.ruoyi.shadow.facts.ShadowVehicleFact;
import com.ruoyi.wcs.application.ReadOnlyDispatchCycle;
import com.ruoyi.wcs.application.ShadowDispatchResult;
import com.ruoyi.wcs.application.dispatch.DispatchCoordinator;
import com.ruoyi.wcs.application.dispatch.DispatchCycleResult;
import com.ruoyi.wcs.application.dispatch.DispatchSnapshot;
import com.ruoyi.wcs.application.dispatch.GrantedDispatch;
import com.ruoyi.wcs.domain.dispatch.DefaultVehicleAssignmentPolicy;
import com.ruoyi.wcs.domain.traffic.AStarRoutePlanner;
import com.ruoyi.wcs.domain.traffic.DeadlockGuard;
import com.ruoyi.wcs.domain.traffic.ResourceLedger;
import com.ruoyi.wcs.domain.traffic.RoutePlan;
import com.ruoyi.wcs.domain.traffic.RoutePlanner;
import com.ruoyi.wcs.domain.traffic.WaitForGraph;
import com.ruoyi.wcs.port.out.DispatchPolicy;
import org.junit.jupiter.api.Test;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class Ft546ShadowCycleTest {
@Test
void ordinaryGrantUsesTheLegacyMainRegisterSequence() {
Ft546EncodedCommand encoded = encode(grant("VEH-2", points("70511", "70512"),
parameters("1", null, null)));
assertEquals(Arrays.asList("4004", "4000", "4002", "4021"),
addresses(encoded));
assertEquals("VEH-2", encoded.getDeviceId());
assertEquals(points(70511, 70512), encoded.getPathPoints());
}
@Test
void crossFloorGrantAddsOnlyTheLegacyCrossFloorRegisters() {
Ft546EncodedCommand encoded = encode(grant("VEH-3", points("70501", "70502"),
parameters("8", "8", null)));
assertEquals(Arrays.asList("4004", "4000", "4002", "4021",
"4020", "4022"), addresses(encoded));
}
@Test
void twoStageGrantsUseTheMatchingLegacyRegisterSequences() {
Ft546EncodedCommand first = encode(grant("VEH-1",
points("70501", "70503"), parameters("1", null, "1")));
Ft546EncodedCommand second = encode(grant("VEH-1",
points("70503", "70504"), parameters("1", null, "2")));
assertEquals(Arrays.asList("4404", "4422", "4402", "4400"),
addresses(first));
assertEquals(Arrays.asList("4404", "4422", "4423", "4402", "4400"),
addresses(second));
}
@Test
void malformedExplicitPhaseParametersFailBeforeAnythingIsRecorded() {
RecordingFt546CommandGateway gateway = gateway();
ShadowCommandIntentFactory factory = new ShadowCommandIntentFactory();
GrantedDispatch invalid = grant("VEH-1", points("70501", "70502"),
parameters("8", null, null));
assertThrows(IllegalArgumentException.class,
() -> gateway.record(factory.create(invalid)));
assertEquals(Collections.emptyList(), gateway.getRecorded());
}
@Test
void recorderKeepsImmutableEncodedEvidenceWithoutSending() {
RecordingFt546CommandGateway gateway = gateway();
Ft546SrgvCommandIntent intent = new ShadowCommandIntentFactory().create(
grant("VEH-1", points("70501", "70502"),
parameters("1", null, null)));
Ft546EncodedCommand encoded = gateway.record(intent);
assertEquals(Collections.singletonList(encoded), gateway.getRecorded());
assertThrows(UnsupportedOperationException.class,
() -> gateway.getRecorded().clear());
}
@Test
void assemblerUsesOnlyActiveQueueAndIndependentVehicleFacts() {
Instant now = Instant.parse("2026-08-14T06:00:00Z");
ShadowFactBatch batch = ShadowFactBatch.create(now.minusSeconds(1), now,
Arrays.asList(queue("shadow_ro.v_wcs_srgv_queue", "ACTIVE", now),
queue("shadow_ro.v_wcs_srgv_queue_finish", "FINISH", now)),
Collections.singletonList(vehicle(now)), maps());
ShadowDispatchSnapshotAssembler assembler =
new ShadowDispatchSnapshotAssembler("FT546", 3L,
topology -> new ShadowTrafficState(
new ResourceLedger(topology),
new WaitForGraph(), 9L,
Collections.emptyMap()),
Clock.fixed(now, ZoneOffset.UTC));
DispatchSnapshot snapshot = assembler.assemble(batch);
assertEquals(1, snapshot.getReadySteps().size());
assertEquals("ACTIVE", snapshot.getReadySteps().get(0).getStepId());
assertEquals("70501",
snapshot.getReadySteps().get(0).getSourceNodeId());
assertEquals("70502",
snapshot.getReadySteps().get(0).getDestinationNodeId());
assertEquals("30001", snapshot.getReadySteps().get(0)
.getCommandParameters().get("parentTaskId"));
assertEquals(1, snapshot.getVehicleSnapshots().size());
assertEquals("VEH-9", snapshot.getVehicleSnapshots().get(0)
.getVehicleId());
assertEquals(9L, snapshot.getSnapshotVersion());
assertEquals(Arrays.asList("70502"),
snapshot.getTopology().neighbors("70501"));
}
@Test
void invalidFactsProduceNoDispatchIntentOrEncodedEvidence() {
Instant now = Instant.parse("2026-08-14T06:00:00Z");
ShadowFactBatch invalid = ShadowFactBatch.create(now.minusSeconds(1), now,
Collections.singletonList(
queue("shadow_ro.v_wcs_srgv_queue", "ACTIVE", now)),
Collections.emptyList(), maps());
AtomicInteger dispatchCalls = new AtomicInteger();
ReadOnlyDispatchCycle<Ft546SrgvCommandIntent> readOnly =
new ReadOnlyDispatchCycle<Ft546SrgvCommandIntent>(
snapshot -> {
dispatchCalls.incrementAndGet();
return DispatchCycleResult.retrySnapshotChanged();
}, new ShadowCommandIntentFactory()::create);
RecordingFt546CommandGateway gateway = gateway();
Ft546ShadowCycle cycle = new Ft546ShadowCycle(
new ShadowFactBatchValidator(Duration.ofSeconds(4),
Duration.ofSeconds(30)),
batch -> {
throw new AssertionError("invalid facts must not assemble");
}, readOnly, gateway);
ShadowCycleResult result = cycle.run(invalid, now);
assertEquals("INSUFFICIENT_FACTS", result.getStatusCode());
assertEquals("MISSING_VEHICLE_FACTS", result.getReasonCode());
assertEquals(0, dispatchCalls.get());
assertEquals(Collections.emptyList(), result.getIntents());
assertEquals(Collections.emptyList(), result.getEncodedCommands());
assertEquals(Collections.emptyList(), gateway.getRecorded());
}
@Test
void validFactsRunTheRealKernelThroughVirtualRegisterEvidence() {
Instant now = Instant.parse("2026-08-14T06:00:00Z");
ShadowFactBatch batch = ShadowFactBatch.create(now.minusSeconds(1), now,
Collections.singletonList(queueWithoutLegacyAssignment(now)),
Collections.singletonList(vehicle(now)), maps());
AtomicInteger commitCalls = new AtomicInteger();
AtomicInteger generatedIds = new AtomicInteger();
RoutePlanner routePlanner = new AStarRoutePlanner();
DispatchCoordinator coordinator = new DispatchCoordinator(
new DefaultVehicleAssignmentPolicy(routePlanner,
DispatchPolicy.NONE),
new DeadlockGuard(new WaitForGraph(), routePlanner),
commit -> {
commitCalls.incrementAndGet();
return true;
},
() -> "SHADOW-" + generatedIds.incrementAndGet());
ReadOnlyDispatchCycle<Ft546SrgvCommandIntent> readOnly =
new ReadOnlyDispatchCycle<Ft546SrgvCommandIntent>(
coordinator::dispatch,
new ShadowCommandIntentFactory()::create);
ShadowDispatchSnapshotAssembler assembler =
new ShadowDispatchSnapshotAssembler("FT546", 3L,
topology -> new ShadowTrafficState(
new ResourceLedger(topology),
new WaitForGraph(), 0L,
Collections.emptyMap()),
Clock.fixed(now, ZoneOffset.UTC));
RecordingFt546CommandGateway gateway = gateway();
Ft546ShadowCycle cycle = new Ft546ShadowCycle(
new ShadowFactBatchValidator(Duration.ofSeconds(4),
Duration.ofSeconds(30)),
assembler::assemble, readOnly, gateway);
ShadowCycleResult result = cycle.run(batch, now);
assertEquals("COMPARABLE", result.getStatusCode());
assertEquals(1, result.getDecisions().getGranted().size());
assertEquals("VEH-9", result.getDecisions().getGranted().get(0)
.getVehicleId());
assertEquals("ACTIVE", result.getDecisions().getGranted().get(0)
.getStepId());
assertEquals(1, commitCalls.get());
assertEquals(1, result.getIntents().size());
assertEquals(1, result.getEncodedCommands().size());
assertEquals("VEH-9", result.getEncodedCommands().get(0)
.getDeviceId());
assertEquals(Arrays.asList("4004", "4000", "4002", "4021"),
addresses(result.getEncodedCommands().get(0)));
assertEquals(1, gateway.getRecorded().size());
}
private static Ft546EncodedCommand encode(GrantedDispatch grant) {
RecordingFt546CommandGateway gateway = gateway();
Ft546SrgvCommandIntent intent =
new ShadowCommandIntentFactory().create(grant);
return gateway.record(intent);
}
private static RecordingFt546CommandGateway gateway() {
return new RecordingFt546CommandGateway(new Ft546SrgvCommandEncoder(),
profile(), capabilities("POINT_TO_POINT_COMMAND",
"TWO_STAGE_ATOMIC_TASK"));
}
private static GrantedDispatch grant(String vehicleId,
List<String> routeNodes,
Map<String, String> parameters) {
RoutePlan route = new RoutePlan(routeNodes,
resourceKeys(routeNodes), 1, 0, 7L);
return new GrantedDispatch("PARENT-1", "STEP-1", vehicleId, route,
Collections.singletonList("RES-1"), 7L, 11L, parameters);
}
private static Map<String, String> parameters(String taskType,
String crossFloorTarget,
String stage) {
Map<String, String> values = new LinkedHashMap<String, String>();
values.put("parentTaskId", "PARENT-1");
values.put("phase", stage == null ? "LOADED_TO_PLACE"
: "TWO_STAGE_" + stage);
values.put("routeVersion", "3");
values.put("taskNo", "30001");
values.put("taskType", taskType);
if (crossFloorTarget != null) {
values.put("crossFloorTarget", crossFloorTarget);
}
if (stage != null) {
values.put("stage", stage);
}
return values;
}
private static List<String> resourceKeys(List<String> nodes) {
return Arrays.asList("NODE:" + nodes.get(0),
"EDGE:" + nodes.get(0) + ':' + nodes.get(1),
"NODE:" + nodes.get(1));
}
private static Ft546DeviceProfile profile() {
Ft546DeviceProfile profile = new Ft546DeviceProfile();
profile.setDeviceId("SHADOW");
Map<String, String> registers = new LinkedHashMap<String, String>();
registers.put("taskNo", "4000");
registers.put("taskType", "4002");
registers.put("path", "4004");
registers.put("trigger", "4021");
registers.put("crossFloorFlag", "4020");
registers.put("crossFloorTarget", "4022");
registers.put("secondTaskNo", "4400");
registers.put("secondTaskType", "4402");
registers.put("secondPath", "4404");
registers.put("secondStageFlag", "4422");
registers.put("secondComplete", "4423");
profile.setRegisters(registers);
profile.setCapabilities(capabilities("POINT_TO_POINT_COMMAND",
"TWO_STAGE_ATOMIC_TASK"));
return profile;
}
private static Set<String> capabilities(String... values) {
return new LinkedHashSet<String>(Arrays.asList(values));
}
private static List<String> points(String... values) {
return Arrays.asList(values);
}
private static List<Integer> points(Integer... values) {
return Arrays.asList(values);
}
private static List<String> addresses(Ft546EncodedCommand encoded) {
java.util.ArrayList<String> values = new java.util.ArrayList<String>();
encoded.getWrites().forEach(write -> values.add(write.getAddress()));
return values;
}
private static ShadowQueueFact queue(String table, String guid,
Instant createdAt) {
return ShadowQueueFact.builder(table, guid)
.taskNo("30001")
.taskType("2")
.priority(2L)
.cargoPickSite("70501")
.cargoPlaceSite("70502")
.startSite("70501")
.endSite("70502")
.createdAt(createdAt.minusSeconds(10))
.agvId("LEGACY-VEHICLE-MUST-NOT-BE-CANDIDATE")
.pathInfo("99999,99998")
.turnPath("99997")
.default02("1")
.build();
}
private static ShadowQueueFact queueWithoutLegacyAssignment(
Instant createdAt) {
return ShadowQueueFact.builder("shadow_ro.v_wcs_srgv_queue", "ACTIVE")
.taskNo("30001")
.taskType("2")
.priority(2L)
.cargoPickSite("70501")
.cargoPlaceSite("70502")
.startSite("70501")
.endSite("70502")
.createdAt(createdAt.minusSeconds(10))
.default02("1")
.build();
}
private static ShadowVehicleFact vehicle(Instant capturedAt) {
return ShadowVehicleFact.builder("VEH-GUID", "VEH-9")
.agvStatus("1")
.currentSite("70501")
.battery(80)
.workMode(5)
.alarm(0)
.status(1)
.errorCode(0)
.allowSend(1)
.currentFloor(1)
.capturedAt(capturedAt)
.build();
}
private static List<ShadowMapFact> maps() {
return Arrays.asList(
new ShadowMapFact("MAP-1", "70501", 0, 0, 1, 1),
new ShadowMapFact("MAP-2", "70502", 1, 0, 1, 1));
}
}

View File

@@ -0,0 +1,285 @@
package com.ruoyi.shadow.kernel;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.ft546.protocol.Ft546DeviceProfile;
import com.ruoyi.ft546.protocol.Ft546SrgvCommandEncoder;
import com.ruoyi.wcs.application.dispatch.DispatchCommit;
import com.ruoyi.wcs.application.dispatch.GrantedDispatch;
import com.ruoyi.wcs.application.dispatch.HeldDispatch;
import com.ruoyi.wcs.domain.topology.EffectiveTopology;
import com.ruoyi.wcs.domain.topology.TopologyCompiler;
import com.ruoyi.wcs.domain.topology.TopologyDefinition;
import com.ruoyi.wcs.domain.topology.TopologyEdge;
import com.ruoyi.wcs.domain.topology.TopologyNode;
import com.ruoyi.wcs.domain.traffic.ReservationDecision;
import com.ruoyi.wcs.domain.traffic.ReservationRequest;
import com.ruoyi.wcs.domain.traffic.ReservationState;
import com.ruoyi.wcs.domain.traffic.ResourceLedger;
import com.ruoyi.wcs.domain.traffic.RoutePlan;
import com.ruoyi.wcs.domain.traffic.TrafficDecisionType;
import com.ruoyi.wcs.domain.traffic.WaitForGraph;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
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 JdbcShadowTrafficStateRepositoryTest {
private JdbcTemplate jdbc;
private TransactionTemplate transaction;
private Clock clock;
private EffectiveTopology topology;
@BeforeEach
void setUp() {
DriverManagerDataSource dataSource = new DriverManagerDataSource(
"jdbc:h2:mem:" + UUID.randomUUID()
+ ";MODE=MSSQLServer;DB_CLOSE_DELAY=-1", "sa", "");
jdbc = new JdbcTemplate(dataSource);
transaction = new TransactionTemplate(
new DataSourceTransactionManager(dataSource));
clock = Clock.fixed(Instant.parse("2026-08-14T06:00:00Z"),
ZoneOffset.UTC);
jdbc.execute("CREATE TABLE shadow_traffic_state ("
+ "state_key nvarchar(256) PRIMARY KEY,"
+ "snapshot_version bigint NOT NULL,"
+ "state_json nvarchar(max) NOT NULL,"
+ "updated_at timestamp NOT NULL)");
jdbc.execute("CREATE TABLE shadow_hold_history ("
+ "parent_task_id nvarchar(128) NOT NULL,"
+ "vehicle_id nvarchar(64) NOT NULL,"
+ "resource_key nvarchar(256) NOT NULL,"
+ "reason_code nvarchar(512) NOT NULL,"
+ "consecutive_count int NOT NULL,"
+ "updated_at timestamp NOT NULL,"
+ "PRIMARY KEY(parent_task_id,vehicle_id,resource_key,reason_code))");
topology = topology();
}
@Test
void restartReloadPreservesReservationsWaitEdgesHoldsAndFencing() {
JdbcShadowTrafficStateRepository first = repository();
DispatchCommit initial = commit(0L, protectedLedger(), waitGraph(),
held("CAPACITY_CONFLICT"));
assertTrue(first.commit(initial));
JdbcShadowTrafficStateRepository restarted = repository();
ShadowTrafficState loaded = restarted.load(topology);
assertEquals(1L, loaded.getSnapshotVersion());
assertEquals(ReservationState.OCCUPIED,
loaded.getLedger().reservation("RES-1").getState());
assertTrue(loaded.getLedger().reservation("RES-1").isCommandUnknown());
assertTrue(loaded.getLedger().reservation("RES-1")
.isRecoveryUnresolved());
assertEquals(Collections.singleton("VEH-2"),
loaded.getWaitForGraph().holdersWaitedOnBy("VEH-1"));
assertEquals(Collections.singletonList(Integer.valueOf(1)),
Arrays.asList(loaded.getConsecutiveHoldCounts().values()
.toArray(new Integer[0])));
assertTrue(restarted.commit(commit(1L, loaded.getLedger(),
loaded.getWaitForGraph(), held("CAPACITY_CONFLICT"))));
ShadowTrafficState twice = repository().load(topology);
assertEquals(2L, twice.getSnapshotVersion());
assertEquals(Collections.singletonList(Integer.valueOf(2)),
Arrays.asList(twice.getConsecutiveHoldCounts().values()
.toArray(new Integer[0])));
ReservationDecision next = twice.getLedger().reserve(
new ReservationRequest("RES-2", "ORDER-2", "STEP-2",
"VEH-2", Collections.singletonList("NODE:B")));
assertTrue(next.isGranted());
assertEquals(2L, next.getFencingToken());
}
@Test
void staleSnapshotVersionIsRejectedWithoutChangingStoredState() {
JdbcShadowTrafficStateRepository repository = repository();
DispatchCommit initial = commit(0L, protectedLedger(), waitGraph(),
held("CAPACITY_CONFLICT"));
assertTrue(repository.commit(initial));
assertFalse(repository.commit(initial));
assertEquals(1L, repository.load(topology).getSnapshotVersion());
}
@Test
void holdWriteFailureRollsBackTrafficStateUpdate() {
JdbcShadowTrafficStateRepository repository = repository();
assertTrue(repository.commit(commit(0L, protectedLedger(), waitGraph(),
held("CAPACITY_CONFLICT"))));
String tooLong = String.join("", Collections.nCopies(600, "X"));
assertThrows(RuntimeException.class, () -> repository.commit(
commit(1L, repository.load(topology).getLedger(), waitGraph(),
held(tooLong))));
assertEquals(1L, repository.load(topology).getSnapshotVersion());
assertEquals(1, jdbc.queryForObject(
"SELECT consecutive_count FROM shadow_hold_history",
Integer.class).intValue());
}
@Test
void unencodableGrantedRouteNeverAdvancesTrafficState() {
JdbcShadowTrafficStateRepository repository = repository();
ListBuilder route = new ListBuilder();
for (int index = 1; index <= 9; index++) {
route.add(Integer.toString(70000 + index));
}
Map<String, String> parameters = new LinkedHashMap<String, String>();
parameters.put("parentTaskId", "PARENT-1");
parameters.put("phase", "LOADED_TO_PLACE");
parameters.put("routeVersion", "3");
parameters.put("taskNo", "30001");
parameters.put("taskType", "1");
GrantedDispatch invalid = new GrantedDispatch("PARENT-1", "STEP-1",
"VEH-1", new RoutePlan(route.values,
Collections.singletonList("NODE:A"), 8, 0, 7L),
Collections.singletonList("RES-1"), 7L, 1L, parameters);
DispatchCommit commit = new DispatchCommit("FT546", 7L, 0L,
Collections.singletonList(invalid), Collections.emptyList(),
Collections.emptyList(), Collections.emptyList(),
new ResourceLedger(topology), new WaitForGraph());
assertThrows(IllegalArgumentException.class,
() -> repository.commit(commit));
assertEquals(0L, repository.load(topology).getSnapshotVersion());
}
@Test
void protocolEncoderRejectionNeverAdvancesTrafficState() {
ShadowCommandIntentFactory intentFactory =
new ShadowCommandIntentFactory();
Ft546DeviceProfile missingPathRegister = new Ft546DeviceProfile();
missingPathRegister.setRegisters(Collections.emptyMap());
RecordingFt546CommandGateway rejectingGateway =
new RecordingFt546CommandGateway(
new Ft546SrgvCommandEncoder(), missingPathRegister,
Collections.singleton("POINT_TO_POINT_COMMAND"));
JdbcShadowTrafficStateRepository repository =
new JdbcShadowTrafficStateRepository(jdbc, transaction,
new ObjectMapper(), clock, intentFactory,
rejectingGateway);
Map<String, String> parameters = new LinkedHashMap<String, String>();
parameters.put("parentTaskId", "PARENT-1");
parameters.put("phase", "LOADED_TO_PLACE");
parameters.put("routeVersion", "3");
parameters.put("taskNo", "30001");
parameters.put("taskType", "1");
GrantedDispatch grant = new GrantedDispatch("PARENT-1", "STEP-1",
"VEH-1", new RoutePlan(Arrays.asList("70001", "70002"),
Collections.singletonList("NODE:A"), 1, 0, 7L),
Collections.singletonList("RES-1"), 7L, 1L, parameters);
DispatchCommit commit = new DispatchCommit("FT546", 7L, 0L,
Collections.singletonList(grant), Collections.emptyList(),
Collections.emptyList(), Collections.emptyList(),
new ResourceLedger(topology), new WaitForGraph());
assertThrows(IllegalArgumentException.class,
() -> repository.commit(commit));
assertEquals(0L, repository.load(topology).getSnapshotVersion());
assertEquals(Collections.emptyList(), rejectingGateway.getRecorded());
}
private JdbcShadowTrafficStateRepository repository() {
ShadowCommandIntentFactory intentFactory =
new ShadowCommandIntentFactory();
return new JdbcShadowTrafficStateRepository(jdbc, transaction,
new ObjectMapper(), clock, intentFactory,
commandGateway());
}
private static RecordingFt546CommandGateway commandGateway() {
Ft546DeviceProfile profile = new Ft546DeviceProfile();
Map<String, String> registers = new LinkedHashMap<String, String>();
registers.put("taskNo", "4000");
registers.put("taskType", "4002");
registers.put("path", "4004");
registers.put("trigger", "4021");
registers.put("crossFloorFlag", "4020");
registers.put("crossFloorTarget", "4022");
registers.put("secondTaskNo", "4400");
registers.put("secondTaskType", "4402");
registers.put("secondPath", "4404");
registers.put("secondStageFlag", "4422");
registers.put("secondComplete", "4423");
profile.setRegisters(registers);
return new RecordingFt546CommandGateway(
new Ft546SrgvCommandEncoder(), profile,
new LinkedHashSet<String>(Arrays.asList(
"POINT_TO_POINT_COMMAND",
"TWO_STAGE_ATOMIC_TASK")));
}
private DispatchCommit commit(long snapshotVersion, ResourceLedger ledger,
WaitForGraph graph, HeldDispatch held) {
return new DispatchCommit("FT546", topology.version(), snapshotVersion,
Collections.emptyList(), Collections.singletonList(held),
Collections.emptyList(), Collections.emptyList(), ledger, graph);
}
private ResourceLedger protectedLedger() {
ResourceLedger ledger = new ResourceLedger(topology);
ReservationDecision reserved = ledger.reserve(new ReservationRequest(
"RES-1", "ORDER-1", "STEP-1", "VEH-1",
Collections.singletonList("NODE:A")));
ledger.occupy("RES-1", reserved.getFencingToken());
ledger.markCommandUnknown("RES-1");
ledger.markRecoveryUnresolved("RES-1");
return ledger;
}
private static WaitForGraph waitGraph() {
WaitForGraph graph = new WaitForGraph();
graph.addWait("VEH-1", "VEH-2");
return graph;
}
private static HeldDispatch held(String reason) {
return new HeldDispatch("PARENT-1", "STEP-1", "VEH-1",
TrafficDecisionType.HOLD, reason,
new LinkedHashSet<String>(Collections.singleton("VEH-2")),
null, null);
}
private static EffectiveTopology topology() {
return new TopologyCompiler().compile(new TopologyDefinition("FT546",
7L, true,
Arrays.asList(
new TopologyNode("A", "F1", 0, 0, 1, false, false),
new TopologyNode("B", "F1", 1, 0, 1, false, false)),
Collections.singletonList(new TopologyEdge("A", "B", 1, 1)),
Collections.emptyList(), Collections.emptyList(),
Collections.emptyList()));
}
private static final class ListBuilder {
private final java.util.List<String> values =
new java.util.ArrayList<String>();
private void add(String value) {
values.add(value);
}
}
}

View File

@@ -0,0 +1,34 @@
package com.ruoyi.wcs.application;
import com.ruoyi.wcs.application.dispatch.DispatchCycleResult;
import com.ruoyi.wcs.application.dispatch.DispatchSnapshot;
import com.ruoyi.wcs.application.dispatch.GrantedDispatch;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.function.Function;
public final class ReadOnlyDispatchCycle<T> {
private final Function<DispatchSnapshot, DispatchCycleResult> dispatcher;
private final Function<GrantedDispatch, T> intentFactory;
public ReadOnlyDispatchCycle(
Function<DispatchSnapshot, DispatchCycleResult> dispatcher,
Function<GrantedDispatch, T> intentFactory) {
this.dispatcher = Objects.requireNonNull(dispatcher, "dispatcher");
this.intentFactory = Objects.requireNonNull(intentFactory,
"intentFactory");
}
public ShadowDispatchResult<T> run(DispatchSnapshot snapshot) {
DispatchCycleResult decisions = dispatcher.apply(
Objects.requireNonNull(snapshot, "snapshot"));
List<T> intents = new ArrayList<T>();
for (GrantedDispatch grant : decisions.getGranted()) {
intents.add(Objects.requireNonNull(intentFactory.apply(grant),
"intentFactory result"));
}
return new ShadowDispatchResult<T>(decisions, intents);
}
}

View File

@@ -0,0 +1,28 @@
package com.ruoyi.wcs.application;
import com.ruoyi.wcs.application.dispatch.DispatchCycleResult;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
public final class ShadowDispatchResult<T> {
private final DispatchCycleResult decisions;
private final List<T> intents;
public ShadowDispatchResult(DispatchCycleResult decisions,
List<T> intents) {
this.decisions = Objects.requireNonNull(decisions, "decisions");
this.intents = Collections.unmodifiableList(
new ArrayList<T>(Objects.requireNonNull(intents, "intents")));
}
public DispatchCycleResult getDecisions() {
return decisions;
}
public List<T> getIntents() {
return intents;
}
}

View File

@@ -53,6 +53,51 @@ public final class ResourceLedger {
this.nextFencingToken = source.nextFencingToken;
}
public static ResourceLedger restore(ResourceLedgerState persisted) {
if (persisted == null) {
throw new IllegalArgumentException("persisted state is required");
}
ResourceLedger restored = new ResourceLedger(
persisted.getCapacities());
long greatestToken = 0L;
for (ResourceReservationState reservationState
: persisted.getReservations()) {
ResourceReservation reservation = ResourceReservation.restore(
reservationState);
if (restored.reservations.put(reservation.getReservationId(),
reservation) != null) {
throw new IllegalArgumentException(
"duplicate persisted reservation: "
+ reservation.getReservationId());
}
for (String resourceKey : reservation.getResourceKeys()) {
if (!restored.capacities.containsKey(resourceKey)) {
throw new IllegalArgumentException(
"persisted reservation references unknown resource: "
+ resourceKey);
}
if (reservation.getState() == ReservationState.GRANTED
|| reservation.getState() == ReservationState.OCCUPIED) {
int next = restored.usage.get(resourceKey) + 1;
if (next > restored.capacities.get(resourceKey)) {
throw new IllegalArgumentException(
"persisted resource usage exceeds capacity: "
+ resourceKey);
}
restored.usage.put(resourceKey, next);
}
}
greatestToken = Math.max(greatestToken,
reservation.getFencingToken());
}
if (persisted.getNextFencingToken() <= greatestToken) {
throw new IllegalArgumentException(
"nextFencingToken must exceed every persisted token");
}
restored.nextFencingToken = persisted.getNextFencingToken();
return restored;
}
public synchronized ReservationDecision reserve(ReservationRequest request) {
if (request == null) {
throw new IllegalArgumentException("request must not be null");
@@ -192,6 +237,15 @@ public final class ResourceLedger {
return new ResourceLedger(this);
}
public synchronized ResourceLedgerState snapshotState() {
List<ResourceReservationState> states =
new ArrayList<ResourceReservationState>(reservations.size());
for (ResourceReservation reservation : reservations.values()) {
states.add(reservation.snapshotState());
}
return new ResourceLedgerState(capacities, states, nextFencingToken);
}
private void decrementUsage(ResourceReservation reservation) {
for (String resourceKey : reservation.getResourceKeys()) {
int current = usage.get(resourceKey);

View File

@@ -0,0 +1,36 @@
package com.ruoyi.wcs.domain.traffic;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
public final class ResourceLedgerState {
private final Map<String, Integer> capacities;
private final List<ResourceReservationState> reservations;
private final long nextFencingToken;
public ResourceLedgerState(Map<String, Integer> capacities,
List<ResourceReservationState> reservations,
long nextFencingToken) {
this.capacities = Collections.unmodifiableMap(
new LinkedHashMap<String, Integer>(Objects.requireNonNull(
capacities, "capacities")));
this.reservations = Collections.unmodifiableList(
new ArrayList<ResourceReservationState>(Objects.requireNonNull(
reservations, "reservations")));
if (nextFencingToken <= 0L) {
throw new IllegalArgumentException(
"nextFencingToken must be positive");
}
this.nextFencingToken = nextFencingToken;
}
public Map<String, Integer> getCapacities() { return capacities; }
public List<ResourceReservationState> getReservations() {
return reservations;
}
public long getNextFencingToken() { return nextFencingToken; }
}

View File

@@ -28,6 +28,22 @@ public final class ResourceReservation {
this.recoveryUnresolved = source.recoveryUnresolved;
}
static ResourceReservation restore(ResourceReservationState persisted) {
ReservationRequest request = new ReservationRequest(
persisted.getReservationId(), persisted.getOrderId(),
persisted.getStepId(), persisted.getVehicleId(),
persisted.getResourceKeys());
ResourceReservation restored = new ResourceReservation(request);
restored.state = persisted.getState();
restored.fencingToken = persisted.getFencingToken();
restored.terminalReason = persisted.getTerminalReason();
restored.releaseEvidenceId = persisted.getReleaseEvidenceId();
restored.commandUnknown = persisted.isCommandUnknown();
restored.recoveryUnresolved = persisted.isRecoveryUnresolved();
restored.validateRestoredState();
return restored;
}
void grant(long token) {
requireState(ReservationState.REQUESTED);
if (token <= 0L) {
@@ -97,6 +113,13 @@ public final class ResourceReservation {
return new ResourceReservation(this);
}
ResourceReservationState snapshotState() {
return new ResourceReservationState(getReservationId(), getOrderId(),
getStepId(), getVehicleId(), getResourceKeys(), state,
fencingToken, terminalReason, releaseEvidenceId,
commandUnknown, recoveryUnresolved);
}
public String getReservationId() {
return request.getReservationId();
}
@@ -145,6 +168,25 @@ public final class ResourceReservation {
return request.sameAs(candidate);
}
private void validateRestoredState() {
boolean tokenRequired = state == ReservationState.GRANTED
|| state == ReservationState.OCCUPIED
|| state == ReservationState.RELEASED;
if (tokenRequired && fencingToken <= 0L) {
throw new IllegalArgumentException(
"active or completed reservation requires a fencing token");
}
if ((commandUnknown || recoveryUnresolved)
&& state != ReservationState.OCCUPIED) {
throw new IllegalArgumentException(
"reservation protection flags require OCCUPIED state");
}
if (releaseEvidenceId != null && state != ReservationState.RELEASED) {
throw new IllegalArgumentException(
"release evidence requires RELEASED state");
}
}
private void requireState(ReservationState required) {
if (state != required) {
throw new DomainRuleViolation("reservation state must be " + required

View File

@@ -0,0 +1,69 @@
package com.ruoyi.wcs.domain.traffic;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
public final class ResourceReservationState {
private final String reservationId;
private final String orderId;
private final String stepId;
private final String vehicleId;
private final List<String> resourceKeys;
private final ReservationState state;
private final long fencingToken;
private final String terminalReason;
private final String releaseEvidenceId;
private final boolean commandUnknown;
private final boolean recoveryUnresolved;
public ResourceReservationState(
String reservationId, String orderId, String stepId,
String vehicleId, List<String> resourceKeys,
ReservationState state, long fencingToken,
String terminalReason, String releaseEvidenceId,
boolean commandUnknown, boolean recoveryUnresolved) {
this.reservationId = required(reservationId, "reservationId");
this.orderId = required(orderId, "orderId");
this.stepId = required(stepId, "stepId");
this.vehicleId = required(vehicleId, "vehicleId");
this.resourceKeys = Collections.unmodifiableList(
new ArrayList<String>(Objects.requireNonNull(resourceKeys,
"resourceKeys")));
this.state = Objects.requireNonNull(state, "state");
if (fencingToken < 0L) {
throw new IllegalArgumentException(
"fencingToken must not be negative");
}
this.fencingToken = fencingToken;
this.terminalReason = normalized(terminalReason);
this.releaseEvidenceId = normalized(releaseEvidenceId);
this.commandUnknown = commandUnknown;
this.recoveryUnresolved = recoveryUnresolved;
}
public String getReservationId() { return reservationId; }
public String getOrderId() { return orderId; }
public String getStepId() { return stepId; }
public String getVehicleId() { return vehicleId; }
public List<String> getResourceKeys() { return resourceKeys; }
public ReservationState getState() { return state; }
public long getFencingToken() { return fencingToken; }
public String getTerminalReason() { return terminalReason; }
public String getReleaseEvidenceId() { return releaseEvidenceId; }
public boolean isCommandUnknown() { return commandUnknown; }
public boolean isRecoveryUnresolved() { return recoveryUnresolved; }
private static String required(String value, String field) {
String normalized = normalized(value);
if (normalized == null) {
throw new IllegalArgumentException(field + " must not be blank");
}
return normalized;
}
private static String normalized(String value) {
return value == null || value.trim().isEmpty() ? null : value.trim();
}
}

View File

@@ -65,6 +65,27 @@ public final class WaitForGraph {
return copy;
}
public synchronized Map<String, Set<String>> snapshotEdges() {
Map<String, Set<String>> snapshot =
new LinkedHashMap<String, Set<String>>();
for (Map.Entry<String, Set<String>> entry : edges.entrySet()) {
snapshot.put(entry.getKey(), Collections.unmodifiableSet(
new LinkedHashSet<String>(entry.getValue())));
}
return Collections.unmodifiableMap(snapshot);
}
public static WaitForGraph restore(Map<String, Set<String>> persisted) {
if (persisted == null) {
throw new IllegalArgumentException("persisted edges are required");
}
WaitForGraph graph = new WaitForGraph();
for (Map.Entry<String, Set<String>> entry : persisted.entrySet()) {
graph.replaceWaits(entry.getKey(), entry.getValue());
}
return graph;
}
private Map<String, Set<String>> copyEdges() {
Map<String, Set<String>> copy = new LinkedHashMap<String, Set<String>>();
for (Map.Entry<String, Set<String>> entry : edges.entrySet()) {

View File

@@ -0,0 +1,91 @@
package com.ruoyi.wcs.application;
import com.ruoyi.wcs.application.dispatch.DispatchCycleResult;
import com.ruoyi.wcs.application.dispatch.DispatchSnapshot;
import com.ruoyi.wcs.application.dispatch.GrantedDispatch;
import com.ruoyi.wcs.domain.topology.EffectiveTopology;
import com.ruoyi.wcs.domain.topology.TopologyCompiler;
import com.ruoyi.wcs.domain.topology.TopologyDefinition;
import com.ruoyi.wcs.domain.topology.TopologyNode;
import com.ruoyi.wcs.domain.traffic.ResourceLedger;
import com.ruoyi.wcs.domain.traffic.RoutePlan;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class ReadOnlyDispatchCycleTest {
@Test
void usesSharedDispatchResultAndCreatesOnlyInMemoryIntents() {
List<String> calls = new ArrayList<String>();
ReadOnlyDispatchCycle<String> cycle = new ReadOnlyDispatchCycle<String>(
snapshot -> {
calls.add("dispatch");
return committedGrant();
},
grant -> {
calls.add("intent:" + grant.getVehicleId());
return "INTENT:" + grant.getVehicleId();
});
ShadowDispatchResult<String> result = cycle.run(snapshot());
assertEquals(Arrays.asList("dispatch", "intent:VEH-1"), calls);
assertEquals(Collections.singletonList("INTENT:VEH-1"),
result.getIntents());
assertEquals("COMMITTED", result.getDecisions().getStatusCode());
assertThrows(UnsupportedOperationException.class,
() -> result.getIntents().add("SIDE_EFFECT"));
}
@Test
void retryResultNeverCreatesAnIntent() {
List<String> calls = new ArrayList<String>();
ReadOnlyDispatchCycle<String> cycle = new ReadOnlyDispatchCycle<String>(
snapshot -> DispatchCycleResult.retrySnapshotChanged(),
grant -> {
calls.add("intent");
return "UNEXPECTED";
});
ShadowDispatchResult<String> result = cycle.run(snapshot());
assertEquals(Collections.emptyList(), calls);
assertEquals(Collections.emptyList(), result.getIntents());
assertEquals("RETRY_SNAPSHOT_CHANGED",
result.getDecisions().getStatusCode());
}
private static DispatchCycleResult committedGrant() {
RoutePlan route = new RoutePlan(Arrays.asList("A", "B"),
Arrays.asList("NODE:A", "EDGE:A:B", "NODE:B"),
1, 0, 7L);
GrantedDispatch grant = new GrantedDispatch("ORDER-1", "STEP-1",
"VEH-1", route, Collections.singletonList("RES-1"),
7L, 11L);
return DispatchCycleResult.committed(Collections.singletonList(grant),
Collections.emptyList(), Collections.emptyList(),
Collections.emptyList());
}
private static DispatchSnapshot snapshot() {
EffectiveTopology topology = new TopologyCompiler().compile(
new TopologyDefinition("FT546", 7L, true,
Arrays.asList(
new TopologyNode("A", "F1", 0, 0, 1,
false, false),
new TopologyNode("B", "F1", 1, 0, 1,
false, false)),
Collections.emptyList(), Collections.emptyList(),
Collections.emptyList(), Collections.emptyList()));
return new DispatchSnapshot(Collections.emptyList(),
Collections.emptyList(), topology,
new ResourceLedger(topology), 1L);
}
}

View File

@@ -100,6 +100,40 @@ class WcsRuntimeCoordinatorTest {
assertNull(result.getOutboxResult());
}
@Test
void tickStillDispatchesProductionOutboxWhenThereAreNoNewGrants() {
List<String> calls = new ArrayList<String>();
WcsRuntimeCoordinator coordinator = new WcsRuntimeCoordinator(
() -> calls.add("snapshot"),
() -> {
calls.add("recovery");
return true;
},
() -> {
calls.add("load");
return snapshot();
},
value -> {
calls.add("dispatch");
return DispatchCycleResult.committed(
Collections.emptyList(), Collections.emptyList(),
Collections.emptyList(), Collections.emptyList());
},
value -> calls.add("command"),
(limit, now) -> {
calls.add("outbox:" + limit);
return new OutboxDispatchResult();
},
() -> Instant.parse("2026-08-11T08:00:00Z"), 100);
RuntimeTickResult result = coordinator.tick();
assertEquals(Arrays.asList("snapshot", "recovery", "load",
"dispatch", "outbox:100"), calls);
assertEquals(RuntimeTickResult.Status.COMPLETED, result.getStatus());
assertEquals(0, result.getCreatedCommandCount());
}
private static GrantedDispatch grant() {
RoutePlan route = new RoutePlan(Arrays.asList("A", "B"),
Arrays.asList("NODE:A", "EDGE:A:B", "NODE:B"),