From 6864010832d6e5b6db7c30ef48c6ce85d48ceb75 Mon Sep 17 00:00:00 2001 From: zgw <13623675548@163.com> Date: Fri, 14 Aug 2026 15:40:31 +0800 Subject: [PATCH] feat(ft546): run shared dispatch kernel in shadow --- .../protocol/Ft546SrgvCommandIntent.java | 20 +- ruoyi-wcs-shadow/pom.xml | 6 + .../config/ShadowKernelConfiguration.java | 108 +++- .../ruoyi/shadow/kernel/Ft546ShadowCycle.java | 75 +++ .../JdbcShadowTrafficStateRepository.java | 482 ++++++++++++++++++ .../kernel/RecordingFt546CommandGateway.java | 61 +++ .../kernel/ShadowCommandIntentFactory.java | 133 +++++ .../shadow/kernel/ShadowCycleResult.java | 61 +++ .../ShadowDispatchSnapshotAssembler.java | 288 +++++++++++ .../shadow/kernel/ShadowTrafficState.java | 49 ++ .../shadow/Ft546ShadowApplicationTest.java | 15 + .../shadow/kernel/Ft546ShadowCycleTest.java | 362 +++++++++++++ .../JdbcShadowTrafficStateRepositoryTest.java | 285 +++++++++++ .../application/ReadOnlyDispatchCycle.java | 34 ++ .../wcs/application/ShadowDispatchResult.java | 28 + .../wcs/domain/traffic/ResourceLedger.java | 54 ++ .../domain/traffic/ResourceLedgerState.java | 36 ++ .../domain/traffic/ResourceReservation.java | 42 ++ .../traffic/ResourceReservationState.java | 69 +++ .../wcs/domain/traffic/WaitForGraph.java | 21 + .../ReadOnlyDispatchCycleTest.java | 91 ++++ .../WcsRuntimeCoordinatorTest.java | 34 ++ 22 files changed, 2346 insertions(+), 8 deletions(-) create mode 100644 ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/Ft546ShadowCycle.java create mode 100644 ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/JdbcShadowTrafficStateRepository.java create mode 100644 ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/RecordingFt546CommandGateway.java create mode 100644 ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowCommandIntentFactory.java create mode 100644 ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowCycleResult.java create mode 100644 ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowDispatchSnapshotAssembler.java create mode 100644 ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowTrafficState.java create mode 100644 ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/Ft546ShadowCycleTest.java create mode 100644 ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/JdbcShadowTrafficStateRepositoryTest.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/ReadOnlyDispatchCycle.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/ShadowDispatchResult.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ResourceLedgerState.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ResourceReservationState.java create mode 100644 ruoyi-wcs/src/test/java/com/ruoyi/wcs/application/ReadOnlyDispatchCycleTest.java diff --git a/ruoyi-ft546-protocol/src/main/java/com/ruoyi/ft546/protocol/Ft546SrgvCommandIntent.java b/ruoyi-ft546-protocol/src/main/java/com/ruoyi/ft546/protocol/Ft546SrgvCommandIntent.java index b883b0c..ae2eb78 100644 --- a/ruoyi-ft546-protocol/src/main/java/com/ruoyi/ft546/protocol/Ft546SrgvCommandIntent.java +++ b/ruoyi-ft546-protocol/src/main/java/com/ruoyi/ft546/protocol/Ft546SrgvCommandIntent.java @@ -37,9 +37,23 @@ public final class Ft546SrgvCommandIntent { this.routeVersion = routeVersion; this.taskNo = taskNo; this.taskType = taskType; - this.pathPoints = Collections.unmodifiableList( - new ArrayList(Objects.requireNonNull(pathPoints, - "pathPoints"))); + List copiedPath = new ArrayList( + 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; } diff --git a/ruoyi-wcs-shadow/pom.xml b/ruoyi-wcs-shadow/pom.xml index 8c99458..56f0dc7 100644 --- a/ruoyi-wcs-shadow/pom.xml +++ b/ruoyi-wcs-shadow/pom.xml @@ -52,6 +52,12 @@ 1.2.1 test + + com.h2database + h2 + 2.2.224 + test + diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowKernelConfiguration.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowKernelConfiguration.java index af8b290..4b2a93a 100644 --- a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowKernelConfiguration.java +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/config/ShadowKernelConfiguration.java @@ -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 + shadowReadOnlyDispatchCycle( + DispatchCoordinator coordinator, + ShadowCommandIntentFactory intentFactory) { + return new ReadOnlyDispatchCycle( + 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 capabilities = new LinkedHashSet(Arrays.asList( + "POINT_TO_POINT_COMMAND", "TWO_STAGE_ATOMIC_TASK")); + profile.setCapabilities(capabilities); + Map registers = new LinkedHashMap(); + 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 dispatchCycle, + RecordingFt546CommandGateway commandGateway) { + return new Ft546ShadowCycle(validator, assembler::assemble, + dispatchCycle, commandGateway); + } } diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/Ft546ShadowCycle.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/Ft546ShadowCycle.java new file mode 100644 index 0000000..8fda292 --- /dev/null +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/Ft546ShadowCycle.java @@ -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 assembler; + private final ReadOnlyDispatchCycle dispatchCycle; + private final RecordingFt546CommandGateway commandGateway; + + public Ft546ShadowCycle( + ShadowFactBatchValidator validator, + Function assembler, + ReadOnlyDispatchCycle 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 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 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(); + } +} diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/JdbcShadowTrafficStateRepository.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/JdbcShadowTrafficStateRepository.java new file mode 100644 index 0000000..cb8e298 --- /dev/null +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/JdbcShadowTrafficStateRepository.java @@ -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.emptyMap()); + } + PersistedState persisted = decode(row.stateJson); + ResourceLedger ledger = ResourceLedger.restore(persisted.ledger); + Map 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 previousHolds = current == null + ? Collections.emptyMap() + : decode(current.stateJson).holdCounts; + Map 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 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 previous, + Map 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 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 nextHoldCounts( + Map previous, + List held) { + Map next = new TreeMap(); + 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> waitForEdges, + Map holdCounts) { + Map root = new LinkedHashMap(); + root.put("schemaVersion", 1); + root.put("ledger", ledgerMap(ledger)); + Map edges = new TreeMap(); + for (Map.Entry> entry : waitForEdges.entrySet()) { + edges.put(entry.getKey(), + new ArrayList(new TreeSet(entry.getValue()))); + } + root.put("waitForEdges", edges); + root.put("holdCounts", new TreeMap(holdCounts)); + try { + return objectMapper.writeValueAsString(root); + } catch (Exception failure) { + throw new IllegalStateException( + "cannot serialize shadow traffic state", failure); + } + } + + private Map ledgerMap(ResourceLedgerState ledger) { + Map result = new LinkedHashMap(); + result.put("capacities", new TreeMap( + ledger.getCapacities())); + result.put("nextFencingToken", ledger.getNextFencingToken()); + List reservations = + new ArrayList( + ledger.getReservations()); + Collections.sort(reservations, Comparator.comparing( + ResourceReservationState::getReservationId)); + List> values = + new ArrayList>(reservations.size()); + for (ResourceReservationState reservation : reservations) { + Map value = new LinkedHashMap(); + 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 capacities = integerMap( + requiredObject(ledger, "capacities")); + List reservations = + reservations(requiredArray(ledger, "reservations")); + long nextToken = requiredLong(ledger, "nextFencingToken"); + ResourceLedgerState ledgerState = new ResourceLedgerState( + capacities, reservations, nextToken); + Map> edges = edgeMap( + requiredObject(root, "waitForEdges")); + Map 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 reservations(JsonNode array) { + List result = + new ArrayList(); + for (JsonNode node : array) { + List resourceKeys = new ArrayList(); + 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> edgeMap(JsonNode object) { + Map> result = + new LinkedHashMap>(); + object.fields().forEachRemaining(entry -> { + if (!entry.getValue().isArray()) { + throw new IllegalStateException("wait-for holders must be an array"); + } + Set holders = new LinkedHashSet(); + for (JsonNode holder : entry.getValue()) { + holders.add(requiredNodeText(holder, "holder")); + } + result.put(entry.getKey(), holders); + }); + return result; + } + + private static Map integerMap(JsonNode object) { + Map result = new LinkedHashMap(); + 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> waitForEdges; + private final Map holdCounts; + + private PersistedState(ResourceLedgerState ledger, + Map> waitForEdges, + Map 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(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; + } + } +} diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/RecordingFt546CommandGateway.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/RecordingFt546CommandGateway.java new file mode 100644 index 0000000..83ae982 --- /dev/null +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/RecordingFt546CommandGateway.java @@ -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 capabilities; + private final List recorded = + new ArrayList(); + + public RecordingFt546CommandGateway(Ft546SrgvCommandEncoder encoder, + Ft546DeviceProfile profile, + Set capabilities) { + this.encoder = Objects.requireNonNull(encoder, "encoder"); + this.profile = Objects.requireNonNull(profile, "profile"); + this.capabilities = Collections.unmodifiableSet( + new LinkedHashSet(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 recordAll( + List intents) { + Objects.requireNonNull(intents, "intents"); + List encoded = + new ArrayList(intents.size()); + for (Ft546SrgvCommandIntent intent : intents) { + encoded.add(preview(intent)); + } + recorded.addAll(encoded); + return Collections.unmodifiableList(encoded); + } + + public synchronized List getRecorded() { + return Collections.unmodifiableList( + new ArrayList(recorded)); + } +} diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowCommandIntentFactory.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowCommandIntentFactory.java new file mode 100644 index 0000000..3bd4cc4 --- /dev/null +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowCommandIntentFactory.java @@ -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 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 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 path(GrantedDispatch grant) { + List points = new ArrayList(); + 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 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 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 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 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 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 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(); + } +} diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowCycleResult.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowCycleResult.java new file mode 100644 index 0000000..0396936 --- /dev/null +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowCycleResult.java @@ -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 intents; + private final List encodedCommands; + + private ShadowCycleResult(String statusCode, String reasonCode, + DispatchCycleResult decisions, + List intents, + List 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.emptyList(), + Collections.emptyList()); + } + + public static ShadowCycleResult retry(DispatchCycleResult decisions) { + return new ShadowCycleResult("RETRY_SNAPSHOT_CHANGED", + "SHADOW_TRAFFIC_STATE_CHANGED", decisions, + Collections.emptyList(), + Collections.emptyList()); + } + + public static ShadowCycleResult comparable( + DispatchCycleResult decisions, + List intents, + List 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 getIntents() { return intents; } + public List getEncodedCommands() { + return encodedCommands; + } + + private static List immutable(List source) { + return Collections.unmodifiableList(new ArrayList(source)); + } +} diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowDispatchSnapshotAssembler.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowDispatchSnapshotAssembler.java new file mode 100644 index 0000000..7e8435f --- /dev/null +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowDispatchSnapshotAssembler.java @@ -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 stateLoader; + private final Clock clock; + + public ShadowDispatchSnapshotAssembler( + String warehouseCode, + long topologyVersion, + Function 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 facts) { + List nodes = new ArrayList(); + Map coordinates = + new LinkedHashMap(); + 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 edges = new ArrayList(); + 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.emptyList(), + Collections.emptyList(), + Collections.emptyList()); + return new TopologyCompiler().compile(definition); + } + + private List tasks(ShadowFactBatch batch, + EffectiveTopology topology) { + List tasks = new ArrayList(); + 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 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 commandParameters(ShadowQueueFact fact) { + Map values = new LinkedHashMap(); + 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 vehicles(ShadowFactBatch batch, + EffectiveTopology topology) { + List result = new ArrayList(); + 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 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; + } +} diff --git a/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowTrafficState.java b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowTrafficState.java new file mode 100644 index 0000000..0162129 --- /dev/null +++ b/ruoyi-wcs-shadow/src/main/java/com/ruoyi/shadow/kernel/ShadowTrafficState.java @@ -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 consecutiveHoldCounts; + + public ShadowTrafficState(ResourceLedger ledger, + WaitForGraph waitForGraph, + long snapshotVersion, + Map 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(Objects.requireNonNull( + consecutiveHoldCounts, "consecutiveHoldCounts"))); + } + + public ResourceLedger getLedger() { + return ledger.copy(); + } + + public WaitForGraph getWaitForGraph() { + return waitForGraph.copy(); + } + + public long getSnapshotVersion() { + return snapshotVersion; + } + + public Map getConsecutiveHoldCounts() { + return consecutiveHoldCounts; + } +} diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowApplicationTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowApplicationTest.java index 78dbe9f..126df98 100644 --- a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowApplicationTest.java +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/Ft546ShadowApplicationTest.java @@ -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 persisted = context.getBean( RecordingShadowDatabaseGate.class).getPersistedChecks(); diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/Ft546ShadowCycleTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/Ft546ShadowCycleTest.java new file mode 100644 index 0000000..d7134fe --- /dev/null +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/Ft546ShadowCycleTest.java @@ -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 readOnly = + new ReadOnlyDispatchCycle( + 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 readOnly = + new ReadOnlyDispatchCycle( + 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 routeNodes, + Map 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 parameters(String taskType, + String crossFloorTarget, + String stage) { + Map values = new LinkedHashMap(); + 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 resourceKeys(List 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 registers = new LinkedHashMap(); + 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 capabilities(String... values) { + return new LinkedHashSet(Arrays.asList(values)); + } + + private static List points(String... values) { + return Arrays.asList(values); + } + + private static List points(Integer... values) { + return Arrays.asList(values); + } + + private static List addresses(Ft546EncodedCommand encoded) { + java.util.ArrayList values = new java.util.ArrayList(); + 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 maps() { + return Arrays.asList( + new ShadowMapFact("MAP-1", "70501", 0, 0, 1, 1), + new ShadowMapFact("MAP-2", "70502", 1, 0, 1, 1)); + } +} diff --git a/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/JdbcShadowTrafficStateRepositoryTest.java b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/JdbcShadowTrafficStateRepositoryTest.java new file mode 100644 index 0000000..8afe329 --- /dev/null +++ b/ruoyi-wcs-shadow/src/test/java/com/ruoyi/shadow/kernel/JdbcShadowTrafficStateRepositoryTest.java @@ -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 parameters = new LinkedHashMap(); + 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 parameters = new LinkedHashMap(); + 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 registers = new LinkedHashMap(); + 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(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(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 values = + new java.util.ArrayList(); + + private void add(String value) { + values.add(value); + } + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/ReadOnlyDispatchCycle.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/ReadOnlyDispatchCycle.java new file mode 100644 index 0000000..25b691b --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/ReadOnlyDispatchCycle.java @@ -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 { + private final Function dispatcher; + private final Function intentFactory; + + public ReadOnlyDispatchCycle( + Function dispatcher, + Function intentFactory) { + this.dispatcher = Objects.requireNonNull(dispatcher, "dispatcher"); + this.intentFactory = Objects.requireNonNull(intentFactory, + "intentFactory"); + } + + public ShadowDispatchResult run(DispatchSnapshot snapshot) { + DispatchCycleResult decisions = dispatcher.apply( + Objects.requireNonNull(snapshot, "snapshot")); + List intents = new ArrayList(); + for (GrantedDispatch grant : decisions.getGranted()) { + intents.add(Objects.requireNonNull(intentFactory.apply(grant), + "intentFactory result")); + } + return new ShadowDispatchResult(decisions, intents); + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/ShadowDispatchResult.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/ShadowDispatchResult.java new file mode 100644 index 0000000..ac32fc1 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/ShadowDispatchResult.java @@ -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 { + private final DispatchCycleResult decisions; + private final List intents; + + public ShadowDispatchResult(DispatchCycleResult decisions, + List intents) { + this.decisions = Objects.requireNonNull(decisions, "decisions"); + this.intents = Collections.unmodifiableList( + new ArrayList(Objects.requireNonNull(intents, "intents"))); + } + + public DispatchCycleResult getDecisions() { + return decisions; + } + + public List getIntents() { + return intents; + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ResourceLedger.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ResourceLedger.java index 0f9a5d7..7cadc82 100644 --- a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ResourceLedger.java +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ResourceLedger.java @@ -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 states = + new ArrayList(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); diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ResourceLedgerState.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ResourceLedgerState.java new file mode 100644 index 0000000..6c7cd0f --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ResourceLedgerState.java @@ -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 capacities; + private final List reservations; + private final long nextFencingToken; + + public ResourceLedgerState(Map capacities, + List reservations, + long nextFencingToken) { + this.capacities = Collections.unmodifiableMap( + new LinkedHashMap(Objects.requireNonNull( + capacities, "capacities"))); + this.reservations = Collections.unmodifiableList( + new ArrayList(Objects.requireNonNull( + reservations, "reservations"))); + if (nextFencingToken <= 0L) { + throw new IllegalArgumentException( + "nextFencingToken must be positive"); + } + this.nextFencingToken = nextFencingToken; + } + + public Map getCapacities() { return capacities; } + public List getReservations() { + return reservations; + } + public long getNextFencingToken() { return nextFencingToken; } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ResourceReservation.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ResourceReservation.java index ccc06d3..c247962 100644 --- a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ResourceReservation.java +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ResourceReservation.java @@ -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 diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ResourceReservationState.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ResourceReservationState.java new file mode 100644 index 0000000..b930581 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/ResourceReservationState.java @@ -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 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 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(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 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(); + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/WaitForGraph.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/WaitForGraph.java index 9733cd9..90bc9b5 100644 --- a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/WaitForGraph.java +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/WaitForGraph.java @@ -65,6 +65,27 @@ public final class WaitForGraph { return copy; } + public synchronized Map> snapshotEdges() { + Map> snapshot = + new LinkedHashMap>(); + for (Map.Entry> entry : edges.entrySet()) { + snapshot.put(entry.getKey(), Collections.unmodifiableSet( + new LinkedHashSet(entry.getValue()))); + } + return Collections.unmodifiableMap(snapshot); + } + + public static WaitForGraph restore(Map> persisted) { + if (persisted == null) { + throw new IllegalArgumentException("persisted edges are required"); + } + WaitForGraph graph = new WaitForGraph(); + for (Map.Entry> entry : persisted.entrySet()) { + graph.replaceWaits(entry.getKey(), entry.getValue()); + } + return graph; + } + private Map> copyEdges() { Map> copy = new LinkedHashMap>(); for (Map.Entry> entry : edges.entrySet()) { diff --git a/ruoyi-wcs/src/test/java/com/ruoyi/wcs/application/ReadOnlyDispatchCycleTest.java b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/application/ReadOnlyDispatchCycleTest.java new file mode 100644 index 0000000..a4ce50f --- /dev/null +++ b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/application/ReadOnlyDispatchCycleTest.java @@ -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 calls = new ArrayList(); + ReadOnlyDispatchCycle cycle = new ReadOnlyDispatchCycle( + snapshot -> { + calls.add("dispatch"); + return committedGrant(); + }, + grant -> { + calls.add("intent:" + grant.getVehicleId()); + return "INTENT:" + grant.getVehicleId(); + }); + + ShadowDispatchResult 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 calls = new ArrayList(); + ReadOnlyDispatchCycle cycle = new ReadOnlyDispatchCycle( + snapshot -> DispatchCycleResult.retrySnapshotChanged(), + grant -> { + calls.add("intent"); + return "UNEXPECTED"; + }); + + ShadowDispatchResult 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); + } +} diff --git a/ruoyi-wcs/src/test/java/com/ruoyi/wcs/application/WcsRuntimeCoordinatorTest.java b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/application/WcsRuntimeCoordinatorTest.java index a89ff18..678f6ee 100644 --- a/ruoyi-wcs/src/test/java/com/ruoyi/wcs/application/WcsRuntimeCoordinatorTest.java +++ b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/application/WcsRuntimeCoordinatorTest.java @@ -100,6 +100,40 @@ class WcsRuntimeCoordinatorTest { assertNull(result.getOutboxResult()); } + @Test + void tickStillDispatchesProductionOutboxWhenThereAreNoNewGrants() { + List calls = new ArrayList(); + 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"),