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

@@ -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;
}
}