From 74f2de59cd00dcdf2e29dc7a0797fa1ddf473886 Mon Sep 17 00:00:00 2001
From: zgw <13623675548@163.com>
Date: Tue, 11 Aug 2026 09:54:26 +0800
Subject: [PATCH] feat(wcs): coordinate and persist traffic decisions
---
ruoyi-wcs/pom.xml | 5 +
.../application/dispatch/DispatchCommit.java | 82 +++++
.../dispatch/DispatchCoordinator.java | 187 +++++++++++
.../dispatch/DispatchCycleResult.java | 68 ++++
.../dispatch/DispatchDecisionEvent.java | 68 ++++
.../dispatch/DispatchSnapshot.java | 79 +++++
.../application/dispatch/GrantedDispatch.java | 58 ++++
.../application/dispatch/HeldDispatch.java | 66 ++++
.../wcs/domain/traffic/DeadlockGuard.java | 8 +
.../wcs/domain/traffic/WaitForGraph.java | 8 +
.../wcs/port/out/DecisionEventRepository.java | 9 +
.../port/out/DispatchSnapshotProvider.java | 7 +
.../wcs/port/out/TrafficStateRepository.java | 7 +
.../dispatch/DispatchCoordinatorTest.java | 140 +++++++++
.../traffic/TrafficConcurrencyIT.java | 248 +++++++++++++++
sql/wcs/V002__wcs_topology_and_traffic.sql | 297 ++++++++++++++++++
.../verify_V002__wcs_topology_and_traffic.sql | 32 ++
17 files changed, 1369 insertions(+)
create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/dispatch/DispatchCommit.java
create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/dispatch/DispatchCoordinator.java
create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/dispatch/DispatchCycleResult.java
create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/dispatch/DispatchDecisionEvent.java
create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/dispatch/DispatchSnapshot.java
create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/dispatch/GrantedDispatch.java
create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/dispatch/HeldDispatch.java
create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/DecisionEventRepository.java
create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/DispatchSnapshotProvider.java
create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/TrafficStateRepository.java
create mode 100644 ruoyi-wcs/src/test/java/com/ruoyi/wcs/application/dispatch/DispatchCoordinatorTest.java
create mode 100644 ruoyi-wcs/src/test/java/com/ruoyi/wcs/infrastructure/persistence/traffic/TrafficConcurrencyIT.java
create mode 100644 sql/wcs/V002__wcs_topology_and_traffic.sql
create mode 100644 sql/wcs/verify_V002__wcs_topology_and_traffic.sql
diff --git a/ruoyi-wcs/pom.xml b/ruoyi-wcs/pom.xml
index 6884c49..1f22ff3 100644
--- a/ruoyi-wcs/pom.xml
+++ b/ruoyi-wcs/pom.xml
@@ -34,5 +34,10 @@
1.2.1
test
+
+ com.microsoft.sqlserver
+ mssql-jdbc
+ test
+
diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/dispatch/DispatchCommit.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/dispatch/DispatchCommit.java
new file mode 100644
index 0000000..19bbc62
--- /dev/null
+++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/dispatch/DispatchCommit.java
@@ -0,0 +1,82 @@
+package com.ruoyi.wcs.application.dispatch;
+
+import com.ruoyi.wcs.domain.traffic.ResourceLedger;
+import com.ruoyi.wcs.domain.traffic.WaitForGraph;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public final class DispatchCommit {
+ private final String warehouseCode;
+ private final long topologyVersion;
+ private final long snapshotVersion;
+ private final List granted;
+ private final List held;
+ private final List yielded;
+ private final List decisionEvents;
+ private final ResourceLedger resultingLedger;
+ private final WaitForGraph resultingWaitForGraph;
+
+ public DispatchCommit(String warehouseCode,
+ long topologyVersion, long snapshotVersion,
+ List granted,
+ List held,
+ List yielded,
+ List decisionEvents,
+ ResourceLedger resultingLedger,
+ WaitForGraph resultingWaitForGraph) {
+ if (warehouseCode == null || warehouseCode.trim().isEmpty()) {
+ throw new IllegalArgumentException("warehouseCode must not be blank");
+ }
+ this.warehouseCode = warehouseCode.trim();
+ this.topologyVersion = topologyVersion;
+ this.snapshotVersion = snapshotVersion;
+ this.granted = immutable(granted);
+ this.held = immutable(held);
+ this.yielded = immutable(yielded);
+ this.decisionEvents = immutable(decisionEvents);
+ this.resultingLedger = resultingLedger.copy();
+ this.resultingWaitForGraph = resultingWaitForGraph.copy();
+ }
+
+ public String getWarehouseCode() {
+ return warehouseCode;
+ }
+
+ public long getTopologyVersion() {
+ return topologyVersion;
+ }
+
+ public long getSnapshotVersion() {
+ return snapshotVersion;
+ }
+
+ public List getGranted() {
+ return granted;
+ }
+
+ public List getHeld() {
+ return held;
+ }
+
+ public List getYielded() {
+ return yielded;
+ }
+
+ public List getDecisionEvents() {
+ return decisionEvents;
+ }
+
+ public ResourceLedger getResultingLedger() {
+ return resultingLedger.copy();
+ }
+
+ public WaitForGraph getResultingWaitForGraph() {
+ return resultingWaitForGraph.copy();
+ }
+
+ private static List immutable(List source) {
+ return Collections.unmodifiableList(new ArrayList(source));
+ }
+}
diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/dispatch/DispatchCoordinator.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/dispatch/DispatchCoordinator.java
new file mode 100644
index 0000000..2666c38
--- /dev/null
+++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/dispatch/DispatchCoordinator.java
@@ -0,0 +1,187 @@
+package com.ruoyi.wcs.application.dispatch;
+
+import com.ruoyi.wcs.domain.dispatch.DispatchTask;
+import com.ruoyi.wcs.domain.dispatch.VehicleAssignmentDecision;
+import com.ruoyi.wcs.domain.dispatch.VehicleAssignmentPolicy;
+import com.ruoyi.wcs.domain.equipment.VehicleSnapshot;
+import com.ruoyi.wcs.domain.shared.IdGenerator;
+import com.ruoyi.wcs.domain.shared.WcsClock;
+import com.ruoyi.wcs.domain.traffic.DeadlockGuard;
+import com.ruoyi.wcs.domain.traffic.ReservationDecision;
+import com.ruoyi.wcs.domain.traffic.ReservationRequest;
+import com.ruoyi.wcs.domain.traffic.ResourceLedger;
+import com.ruoyi.wcs.domain.traffic.TrafficCandidate;
+import com.ruoyi.wcs.domain.traffic.TrafficDecision;
+import com.ruoyi.wcs.domain.traffic.TrafficDecisionType;
+import com.ruoyi.wcs.port.out.DispatchSnapshotProvider;
+import com.ruoyi.wcs.port.out.TrafficStateRepository;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+public final class DispatchCoordinator {
+ private final DispatchSnapshotProvider snapshotProvider;
+ private final VehicleAssignmentPolicy assignmentPolicy;
+ private final DeadlockGuard deadlockGuard;
+ private final TrafficStateRepository trafficStateRepository;
+ private final IdGenerator idGenerator;
+ private final WcsClock clock;
+
+ public DispatchCoordinator(VehicleAssignmentPolicy assignmentPolicy,
+ DeadlockGuard deadlockGuard,
+ TrafficStateRepository trafficStateRepository,
+ IdGenerator idGenerator) {
+ this(null, assignmentPolicy, deadlockGuard, trafficStateRepository,
+ idGenerator, Instant::now);
+ }
+
+ public DispatchCoordinator(DispatchSnapshotProvider snapshotProvider,
+ VehicleAssignmentPolicy assignmentPolicy,
+ DeadlockGuard deadlockGuard,
+ TrafficStateRepository trafficStateRepository,
+ IdGenerator idGenerator,
+ WcsClock clock) {
+ this.snapshotProvider = snapshotProvider;
+ this.assignmentPolicy = Objects.requireNonNull(assignmentPolicy,
+ "assignmentPolicy");
+ this.deadlockGuard = Objects.requireNonNull(deadlockGuard, "deadlockGuard");
+ this.trafficStateRepository = Objects.requireNonNull(
+ trafficStateRepository, "trafficStateRepository");
+ this.idGenerator = Objects.requireNonNull(idGenerator, "idGenerator");
+ this.clock = Objects.requireNonNull(clock, "clock");
+ }
+
+ public DispatchCycleResult dispatch() {
+ if (snapshotProvider == null) {
+ throw new IllegalStateException("dispatch snapshot provider is not configured");
+ }
+ return dispatch(snapshotProvider.load());
+ }
+
+ public DispatchCycleResult dispatch(DispatchSnapshot snapshot) {
+ Objects.requireNonNull(snapshot, "snapshot");
+ List tasks = new ArrayList(snapshot.getReadySteps());
+ Collections.sort(tasks, Comparator
+ .comparingInt(DispatchTask::getPriority).reversed()
+ .thenComparing(Comparator
+ .comparingLong(DispatchTask::getWaitingAgeSeconds)
+ .reversed())
+ .thenComparing(DispatchTask::getOrderId)
+ .thenComparing(DispatchTask::getStepId));
+ List available = new ArrayList(
+ snapshot.getVehicleSnapshots());
+ ResourceLedger ledger = snapshot.workingLedger();
+ DeadlockGuard cycleGuard = deadlockGuard.withGraph(
+ snapshot.workingWaitForGraph());
+
+ List granted = new ArrayList();
+ List held = new ArrayList();
+ List yielded = new ArrayList();
+ List events = new ArrayList();
+ List violations = detectInputViolations(tasks, available);
+
+ for (DispatchTask task : tasks) {
+ VehicleAssignmentDecision assignment = assignmentPolicy.assign(
+ task, available, snapshot.getTopology());
+ if (!assignment.isAssigned()) {
+ HeldDispatch noVehicle = new HeldDispatch(task.getOrderId(),
+ task.getStepId(), null, TrafficDecisionType.HOLD,
+ "NO_ELIGIBLE_VEHICLE", Collections.emptySet(),
+ null, null);
+ held.add(noVehicle);
+ events.add(event(task, null, TrafficDecisionType.HOLD,
+ noVehicle.getReasonCode(), snapshot));
+ continue;
+ }
+
+ VehicleSnapshot vehicle = removeVehicle(
+ available, assignment.getVehicleId());
+ String reservationId = requireGeneratedId(idGenerator.nextId());
+ ReservationRequest request = new ReservationRequest(reservationId,
+ task.getOrderId(), task.getStepId(), assignment.getVehicleId(),
+ assignment.getRoute().getResourceKeys());
+ TrafficDecision traffic = cycleGuard.decide(new TrafficCandidate(
+ request, vehicle.getLocationNodeId(), assignment.getRoute()),
+ ledger, snapshot.getTopology());
+ if (traffic.getType() == TrafficDecisionType.GRANT) {
+ ReservationDecision reservation = traffic.getReservationDecision();
+ granted.add(new GrantedDispatch(task.getOrderId(), task.getStepId(),
+ assignment.getVehicleId(), assignment.getRoute(),
+ Collections.singletonList(reservation.getReservationId()),
+ snapshot.getTopology().version(), reservation.getFencingToken()));
+ } else {
+ HeldDispatch blocked = new HeldDispatch(task.getOrderId(),
+ task.getStepId(), assignment.getVehicleId(), traffic.getType(),
+ traffic.getReasonCode(), traffic.getBlockingVehicleIds(),
+ traffic.getYieldNodeId(), traffic.getYieldRoute());
+ if (traffic.getType() == TrafficDecisionType.YIELD) {
+ yielded.add(blocked);
+ } else {
+ held.add(blocked);
+ }
+ }
+ events.add(event(task, assignment.getVehicleId(), traffic.getType(),
+ traffic.getReasonCode(), snapshot));
+ }
+
+ DispatchCommit commit = new DispatchCommit(
+ snapshot.getTopology().getWarehouseCode(),
+ snapshot.getTopology().version(),
+ snapshot.getSnapshotVersion(), granted, held, yielded, events,
+ ledger, cycleGuard.snapshotWaitForGraph());
+ if (!trafficStateRepository.commit(commit)) {
+ return DispatchCycleResult.retrySnapshotChanged();
+ }
+ return DispatchCycleResult.committed(granted, held, yielded, violations);
+ }
+
+ private DispatchDecisionEvent event(DispatchTask task, String vehicleId,
+ TrafficDecisionType type, String reason,
+ DispatchSnapshot snapshot) {
+ return new DispatchDecisionEvent(requireGeneratedId(idGenerator.nextId()),
+ task.getOrderId(), task.getStepId(), vehicleId, type, reason,
+ snapshot.getTopology().version(), snapshot.getSnapshotVersion(),
+ clock.now());
+ }
+
+ private static VehicleSnapshot removeVehicle(List vehicles,
+ String vehicleId) {
+ for (int i = 0; i < vehicles.size(); i++) {
+ if (vehicleId.equals(vehicles.get(i).getVehicleId())) {
+ return vehicles.remove(i);
+ }
+ }
+ throw new IllegalStateException("assigned vehicle missing from snapshot: " + vehicleId);
+ }
+
+ private static List detectInputViolations(
+ List tasks, List vehicles) {
+ List violations = new ArrayList();
+ Set stepIds = new HashSet();
+ for (DispatchTask task : tasks) {
+ if (!stepIds.add(task.getStepId())) {
+ violations.add("DUPLICATE_READY_STEP:" + task.getStepId());
+ }
+ }
+ Set vehicleIds = new HashSet();
+ for (VehicleSnapshot vehicle : vehicles) {
+ if (!vehicleIds.add(vehicle.getVehicleId())) {
+ violations.add("DUPLICATE_VEHICLE_SNAPSHOT:" + vehicle.getVehicleId());
+ }
+ }
+ return violations;
+ }
+
+ private static String requireGeneratedId(String value) {
+ if (value == null || value.trim().isEmpty()) {
+ throw new IllegalStateException("id generator returned a blank id");
+ }
+ return value.trim();
+ }
+}
diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/dispatch/DispatchCycleResult.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/dispatch/DispatchCycleResult.java
new file mode 100644
index 0000000..fef2bdf
--- /dev/null
+++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/dispatch/DispatchCycleResult.java
@@ -0,0 +1,68 @@
+package com.ruoyi.wcs.application.dispatch;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public final class DispatchCycleResult {
+ private final String statusCode;
+ private final List granted;
+ private final List held;
+ private final List yielded;
+ private final List invariantViolations;
+
+ private DispatchCycleResult(String statusCode,
+ List granted,
+ List held,
+ List yielded,
+ List invariantViolations) {
+ this.statusCode = statusCode;
+ this.granted = immutable(granted);
+ this.held = immutable(held);
+ this.yielded = immutable(yielded);
+ this.invariantViolations = immutable(invariantViolations);
+ }
+
+ public static DispatchCycleResult committed(List granted,
+ List held,
+ List yielded,
+ List violations) {
+ return new DispatchCycleResult("COMMITTED", granted, held, yielded, violations);
+ }
+
+ public static DispatchCycleResult retrySnapshotChanged() {
+ return new DispatchCycleResult("RETRY_SNAPSHOT_CHANGED",
+ Collections.emptyList(),
+ Collections.emptyList(),
+ Collections.emptyList(),
+ Collections.emptyList());
+ }
+
+ public String getStatusCode() {
+ return statusCode;
+ }
+
+ public boolean isRetrySnapshotChanged() {
+ return "RETRY_SNAPSHOT_CHANGED".equals(statusCode);
+ }
+
+ public List getGranted() {
+ return granted;
+ }
+
+ public List getHeld() {
+ return held;
+ }
+
+ public List getYielded() {
+ return yielded;
+ }
+
+ public List getInvariantViolations() {
+ return invariantViolations;
+ }
+
+ private static List immutable(List source) {
+ return Collections.unmodifiableList(new ArrayList(source));
+ }
+}
diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/dispatch/DispatchDecisionEvent.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/dispatch/DispatchDecisionEvent.java
new file mode 100644
index 0000000..6db70a0
--- /dev/null
+++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/dispatch/DispatchDecisionEvent.java
@@ -0,0 +1,68 @@
+package com.ruoyi.wcs.application.dispatch;
+
+import com.ruoyi.wcs.domain.traffic.TrafficDecisionType;
+
+import java.time.Instant;
+
+public final class DispatchDecisionEvent {
+ private final String eventId;
+ private final String orderId;
+ private final String stepId;
+ private final String vehicleId;
+ private final TrafficDecisionType decisionType;
+ private final String reasonCode;
+ private final long topologyVersion;
+ private final long snapshotVersion;
+ private final Instant occurredAt;
+
+ public DispatchDecisionEvent(String eventId, String orderId, String stepId,
+ String vehicleId, TrafficDecisionType decisionType,
+ String reasonCode, long topologyVersion,
+ long snapshotVersion, Instant occurredAt) {
+ this.eventId = eventId;
+ this.orderId = orderId;
+ this.stepId = stepId;
+ this.vehicleId = vehicleId;
+ this.decisionType = decisionType;
+ this.reasonCode = reasonCode;
+ this.topologyVersion = topologyVersion;
+ this.snapshotVersion = snapshotVersion;
+ this.occurredAt = occurredAt;
+ }
+
+ public String getEventId() {
+ return eventId;
+ }
+
+ public String getOrderId() {
+ return orderId;
+ }
+
+ public String getStepId() {
+ return stepId;
+ }
+
+ public String getVehicleId() {
+ return vehicleId;
+ }
+
+ public TrafficDecisionType getDecisionType() {
+ return decisionType;
+ }
+
+ public String getReasonCode() {
+ return reasonCode;
+ }
+
+ public long getTopologyVersion() {
+ return topologyVersion;
+ }
+
+ public long getSnapshotVersion() {
+ return snapshotVersion;
+ }
+
+ public Instant getOccurredAt() {
+ return occurredAt;
+ }
+}
diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/dispatch/DispatchSnapshot.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/dispatch/DispatchSnapshot.java
new file mode 100644
index 0000000..2ad5dae
--- /dev/null
+++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/dispatch/DispatchSnapshot.java
@@ -0,0 +1,79 @@
+package com.ruoyi.wcs.application.dispatch;
+
+import com.ruoyi.wcs.domain.dispatch.DispatchTask;
+import com.ruoyi.wcs.domain.equipment.VehicleSnapshot;
+import com.ruoyi.wcs.domain.topology.EffectiveTopology;
+import com.ruoyi.wcs.domain.traffic.ResourceLedger;
+import com.ruoyi.wcs.domain.traffic.WaitForGraph;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+public final class DispatchSnapshot {
+ private final List readySteps;
+ private final List vehicleSnapshots;
+ private final EffectiveTopology topology;
+ private final ResourceLedger resourceLedger;
+ private final WaitForGraph waitForGraph;
+ private final long snapshotVersion;
+
+ public DispatchSnapshot(List readySteps,
+ List vehicleSnapshots,
+ EffectiveTopology topology,
+ ResourceLedger resourceLedger,
+ long snapshotVersion) {
+ this(readySteps, vehicleSnapshots, topology, resourceLedger,
+ new WaitForGraph(), snapshotVersion);
+ }
+
+ public DispatchSnapshot(List readySteps,
+ List vehicleSnapshots,
+ EffectiveTopology topology,
+ ResourceLedger resourceLedger,
+ WaitForGraph waitForGraph,
+ long snapshotVersion) {
+ this.readySteps = immutable(readySteps, "readySteps");
+ this.vehicleSnapshots = immutable(vehicleSnapshots, "vehicleSnapshots");
+ this.topology = Objects.requireNonNull(topology, "topology");
+ this.resourceLedger = Objects.requireNonNull(resourceLedger,
+ "resourceLedger").copy();
+ this.waitForGraph = Objects.requireNonNull(waitForGraph, "waitForGraph").copy();
+ if (snapshotVersion < 0L) {
+ throw new IllegalArgumentException("snapshotVersion must not be negative");
+ }
+ this.snapshotVersion = snapshotVersion;
+ }
+
+ public List getReadySteps() {
+ return readySteps;
+ }
+
+ public List getVehicleSnapshots() {
+ return vehicleSnapshots;
+ }
+
+ public EffectiveTopology getTopology() {
+ return topology;
+ }
+
+ public ResourceLedger workingLedger() {
+ return resourceLedger.copy();
+ }
+
+ public WaitForGraph workingWaitForGraph() {
+ return waitForGraph.copy();
+ }
+
+ public long getSnapshotVersion() {
+ return snapshotVersion;
+ }
+
+ private static List immutable(List source, String field) {
+ if (source == null) {
+ throw new IllegalArgumentException(field + " must not be null");
+ }
+ return Collections.unmodifiableList(new ArrayList(source));
+ }
+}
diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/dispatch/GrantedDispatch.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/dispatch/GrantedDispatch.java
new file mode 100644
index 0000000..e1c1a17
--- /dev/null
+++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/dispatch/GrantedDispatch.java
@@ -0,0 +1,58 @@
+package com.ruoyi.wcs.application.dispatch;
+
+import com.ruoyi.wcs.domain.traffic.RoutePlan;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public final class GrantedDispatch {
+ private final String orderId;
+ private final String stepId;
+ private final String vehicleId;
+ private final RoutePlan protectedRoute;
+ private final List reservationIds;
+ private final long topologyVersion;
+ private final long fencingToken;
+
+ public GrantedDispatch(String orderId, String stepId, String vehicleId,
+ RoutePlan protectedRoute, List reservationIds,
+ long topologyVersion, long fencingToken) {
+ this.orderId = orderId;
+ this.stepId = stepId;
+ this.vehicleId = vehicleId;
+ this.protectedRoute = protectedRoute;
+ this.reservationIds = Collections.unmodifiableList(
+ new ArrayList(reservationIds));
+ this.topologyVersion = topologyVersion;
+ this.fencingToken = fencingToken;
+ }
+
+ public String getOrderId() {
+ return orderId;
+ }
+
+ public String getStepId() {
+ return stepId;
+ }
+
+ public String getVehicleId() {
+ return vehicleId;
+ }
+
+ public RoutePlan getProtectedRoute() {
+ return protectedRoute;
+ }
+
+ public List getReservationIds() {
+ return reservationIds;
+ }
+
+ public long getTopologyVersion() {
+ return topologyVersion;
+ }
+
+ public long getFencingToken() {
+ return fencingToken;
+ }
+}
diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/dispatch/HeldDispatch.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/dispatch/HeldDispatch.java
new file mode 100644
index 0000000..4387fbd
--- /dev/null
+++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/dispatch/HeldDispatch.java
@@ -0,0 +1,66 @@
+package com.ruoyi.wcs.application.dispatch;
+
+import com.ruoyi.wcs.domain.traffic.RoutePlan;
+import com.ruoyi.wcs.domain.traffic.TrafficDecisionType;
+
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+public final class HeldDispatch {
+ private final String orderId;
+ private final String stepId;
+ private final String vehicleId;
+ private final TrafficDecisionType decisionType;
+ private final String reasonCode;
+ private final Set blockingVehicleIds;
+ private final String yieldNodeId;
+ private final RoutePlan yieldRoute;
+
+ public HeldDispatch(String orderId, String stepId, String vehicleId,
+ TrafficDecisionType decisionType, String reasonCode,
+ Set blockingVehicleIds,
+ String yieldNodeId, RoutePlan yieldRoute) {
+ this.orderId = orderId;
+ this.stepId = stepId;
+ this.vehicleId = vehicleId;
+ this.decisionType = decisionType;
+ this.reasonCode = reasonCode;
+ this.blockingVehicleIds = Collections.unmodifiableSet(
+ new LinkedHashSet(blockingVehicleIds));
+ this.yieldNodeId = yieldNodeId;
+ this.yieldRoute = yieldRoute;
+ }
+
+ public String getOrderId() {
+ return orderId;
+ }
+
+ public String getStepId() {
+ return stepId;
+ }
+
+ public String getVehicleId() {
+ return vehicleId;
+ }
+
+ public TrafficDecisionType getDecisionType() {
+ return decisionType;
+ }
+
+ public String getReasonCode() {
+ return reasonCode;
+ }
+
+ public Set getBlockingVehicleIds() {
+ return blockingVehicleIds;
+ }
+
+ public String getYieldNodeId() {
+ return yieldNodeId;
+ }
+
+ public RoutePlan getYieldRoute() {
+ return yieldRoute;
+ }
+}
diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/DeadlockGuard.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/DeadlockGuard.java
index 06b0fce..d6a88a1 100644
--- a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/DeadlockGuard.java
+++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/traffic/DeadlockGuard.java
@@ -21,6 +21,14 @@ public final class DeadlockGuard {
this.routePlanner = Objects.requireNonNull(routePlanner, "routePlanner");
}
+ public DeadlockGuard withGraph(WaitForGraph graph) {
+ return new DeadlockGuard(graph, routePlanner);
+ }
+
+ public WaitForGraph snapshotWaitForGraph() {
+ return waitForGraph.copy();
+ }
+
public TrafficDecision decide(TrafficCandidate candidate,
ResourceLedger ledger,
EffectiveTopology topology) {
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 9d28e93..9733cd9 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
@@ -57,6 +57,14 @@ public final class WaitForGraph {
return Collections.unmodifiableSet(new LinkedHashSet(holders));
}
+ public synchronized WaitForGraph copy() {
+ WaitForGraph copy = new WaitForGraph();
+ for (Map.Entry> entry : edges.entrySet()) {
+ copy.edges.put(entry.getKey(), new TreeSet(entry.getValue()));
+ }
+ return copy;
+ }
+
private Map> copyEdges() {
Map> copy = new LinkedHashMap>();
for (Map.Entry> entry : edges.entrySet()) {
diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/DecisionEventRepository.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/DecisionEventRepository.java
new file mode 100644
index 0000000..ebaa45c
--- /dev/null
+++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/DecisionEventRepository.java
@@ -0,0 +1,9 @@
+package com.ruoyi.wcs.port.out;
+
+import com.ruoyi.wcs.application.dispatch.DispatchDecisionEvent;
+
+import java.util.List;
+
+public interface DecisionEventRepository {
+ void saveAllWithinTrafficCommit(List events);
+}
diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/DispatchSnapshotProvider.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/DispatchSnapshotProvider.java
new file mode 100644
index 0000000..e11cb2e
--- /dev/null
+++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/DispatchSnapshotProvider.java
@@ -0,0 +1,7 @@
+package com.ruoyi.wcs.port.out;
+
+import com.ruoyi.wcs.application.dispatch.DispatchSnapshot;
+
+public interface DispatchSnapshotProvider {
+ DispatchSnapshot load();
+}
diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/TrafficStateRepository.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/TrafficStateRepository.java
new file mode 100644
index 0000000..6de6fa1
--- /dev/null
+++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/TrafficStateRepository.java
@@ -0,0 +1,7 @@
+package com.ruoyi.wcs.port.out;
+
+import com.ruoyi.wcs.application.dispatch.DispatchCommit;
+
+public interface TrafficStateRepository {
+ boolean commit(DispatchCommit commit);
+}
diff --git a/ruoyi-wcs/src/test/java/com/ruoyi/wcs/application/dispatch/DispatchCoordinatorTest.java b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/application/dispatch/DispatchCoordinatorTest.java
new file mode 100644
index 0000000..5702444
--- /dev/null
+++ b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/application/dispatch/DispatchCoordinatorTest.java
@@ -0,0 +1,140 @@
+package com.ruoyi.wcs.application.dispatch;
+
+import com.ruoyi.wcs.domain.dispatch.DefaultVehicleAssignmentPolicy;
+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.shared.IdGenerator;
+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 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.WaitForGraph;
+import com.ruoyi.wcs.port.out.DispatchPolicy;
+import com.ruoyi.wcs.port.out.TrafficStateRepository;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class DispatchCoordinatorTest {
+
+ @Test
+ void grantsOneVehicleAndHoldsTheOtherForTheSameIntersection() {
+ EffectiveTopology topology = topology();
+ RecordingTrafficRepository repository = new RecordingTrafficRepository(true);
+ DispatchCoordinator coordinator = coordinator(repository);
+
+ DispatchCycleResult result = coordinator.dispatch(new DispatchSnapshot(
+ Arrays.asList(task("O1", "S1", "A", 2),
+ task("O2", "S2", "B", 1)),
+ Arrays.asList(vehicle("V1", "A"), vehicle("V2", "B")),
+ topology, new ResourceLedger(topology), 11L));
+
+ assertEquals(1, result.getGranted().size());
+ assertEquals("S1", result.getGranted().get(0).getStepId());
+ assertEquals("V1", result.getGranted().get(0).getVehicleId());
+ assertEquals(Collections.singletonList("R-1"),
+ result.getGranted().get(0).getReservationIds());
+ assertEquals(1, result.getHeld().size());
+ assertEquals("NO_SAFE_YIELD_POINT", result.getHeld().get(0).getReasonCode());
+ assertEquals(0, result.getInvariantViolations().size());
+ assertEquals(1, repository.commitCount);
+ assertEquals("FT546", repository.lastCommit.getWarehouseCode());
+ assertEquals(2, repository.lastCommit.getDecisionEvents().size());
+ assertFalse(result.isRetrySnapshotChanged());
+ }
+
+ @Test
+ void returnsRetryWithoutExposingPartialGrantWhenSnapshotChanged() {
+ EffectiveTopology topology = topology();
+ RecordingTrafficRepository repository = new RecordingTrafficRepository(false);
+ DispatchCoordinator coordinator = coordinator(repository);
+
+ DispatchCycleResult result = coordinator.dispatch(new DispatchSnapshot(
+ Collections.singletonList(task("O1", "S1", "A", 1)),
+ Collections.singletonList(vehicle("V1", "A")),
+ topology, new ResourceLedger(topology), 11L));
+
+ assertTrue(result.isRetrySnapshotChanged());
+ assertEquals("RETRY_SNAPSHOT_CHANGED", result.getStatusCode());
+ assertEquals(0, result.getGranted().size());
+ assertEquals(0, result.getHeld().size());
+ assertEquals(1, repository.commitCount);
+ }
+
+ private static DispatchCoordinator coordinator(TrafficStateRepository repository) {
+ AStarRoutePlanner routePlanner = new AStarRoutePlanner();
+ AtomicInteger sequence = new AtomicInteger();
+ IdGenerator ids = () -> "R-" + sequence.incrementAndGet();
+ return new DispatchCoordinator(
+ new DefaultVehicleAssignmentPolicy(routePlanner, DispatchPolicy.NONE),
+ new DeadlockGuard(new WaitForGraph(), routePlanner),
+ repository, ids);
+ }
+
+ private static DispatchTask task(String orderId, String stepId,
+ String source, int priority) {
+ return new DispatchTask(orderId, stepId, source, "X", priority,
+ 60L, 30, Collections.emptySet());
+ }
+
+ private static VehicleSnapshot vehicle(String id, String location) {
+ DeviceDescriptor descriptor = new DeviceDescriptor(id,
+ DeviceType.FOUR_WAY_SHUTTLE, "F1",
+ EnumSet.of(DeviceCapability.FOUR_WAY_MOVE));
+ return new VehicleSnapshot(descriptor,
+ VehicleSnapshot.ConnectionState.ONLINE,
+ VehicleSnapshot.OperatingMode.AUTO,
+ VehicleSnapshot.HealthState.NORMAL,
+ VehicleSnapshot.OccupationState.IDLE,
+ location, null, 80, null, "F1",
+ Instant.parse("2026-08-04T00:00:00Z"));
+ }
+
+ private static EffectiveTopology topology() {
+ TopologyDefinition definition = new TopologyDefinition("FT546", 7L, true,
+ Arrays.asList(
+ new TopologyNode("A", "F1", 0, 0, 1, false, false),
+ new TopologyNode("X", "F1", 1, 0, 1, false, false),
+ new TopologyNode("B", "F1", 2, 0, 1, false, false)),
+ Arrays.asList(
+ new TopologyEdge("A", "X", 1, 1),
+ new TopologyEdge("B", "X", 1, 1)),
+ Collections.emptyList(),
+ Collections.emptyList(), Collections.emptyList());
+ return new TopologyCompiler().compile(definition);
+ }
+
+ private static final class RecordingTrafficRepository
+ implements TrafficStateRepository {
+ private final boolean commitResult;
+ private int commitCount;
+ private DispatchCommit lastCommit;
+
+ private RecordingTrafficRepository(boolean commitResult) {
+ this.commitResult = commitResult;
+ }
+
+ @Override
+ public boolean commit(DispatchCommit commit) {
+ commitCount++;
+ lastCommit = commit;
+ return commitResult;
+ }
+ }
+}
diff --git a/ruoyi-wcs/src/test/java/com/ruoyi/wcs/infrastructure/persistence/traffic/TrafficConcurrencyIT.java b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/infrastructure/persistence/traffic/TrafficConcurrencyIT.java
new file mode 100644
index 0000000..02fbc39
--- /dev/null
+++ b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/infrastructure/persistence/traffic/TrafficConcurrencyIT.java
@@ -0,0 +1,248 @@
+package com.ruoyi.wcs.infrastructure.persistence.traffic;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+@Tag("sqlserver")
+class TrafficConcurrencyIT {
+ private String jdbcUrl;
+ private String username;
+ private String password;
+ private String warehouseCode;
+ private String floorId;
+ private String resourceKey;
+
+ @Test
+ void packagesTheTrafficMigrationUsedByTheSqlServerVerification() throws Exception {
+ String root = System.getProperty("maven.multiModuleProjectDirectory", ".");
+ String migration = readMigration(root, "V002__wcs_topology_and_traffic.sql");
+
+ assertFalse(migration.trim().isEmpty());
+ }
+
+ @AfterEach
+ void cleanFixture() throws Exception {
+ if (jdbcUrl == null || resourceKey == null) {
+ return;
+ }
+ try (Connection connection = connect()) {
+ delete(connection, "DELETE FROM dbo.wcs_resource_reservation WHERE resource_key = ?", resourceKey);
+ delete(connection, "DELETE FROM dbo.wcs_resource_state WHERE resource_key = ?", resourceKey);
+ delete(connection, "DELETE FROM dbo.wcs_resource_definition WHERE resource_key = ?", resourceKey);
+ delete(connection, "DELETE FROM dbo.wcs_topology_version WHERE warehouse_code = ?", warehouseCode);
+ delete(connection, "DELETE FROM dbo.wcs_floor WHERE floor_id = ?", floorId);
+ delete(connection, "DELETE FROM dbo.wcs_warehouse WHERE warehouse_code = ?", warehouseCode);
+ }
+ }
+
+ @Test
+ void onlyOneConcurrentReservationCommitsForCapacityOneResource() throws Exception {
+ configureIsolatedDatabase();
+ applyMigration();
+ createCapacityOneFixture();
+
+ ExecutorService executor = Executors.newFixedThreadPool(2);
+ CountDownLatch ready = new CountDownLatch(2);
+ CountDownLatch start = new CountDownLatch(1);
+ try {
+ Future first = executor.submit(() -> reserve("IT-R1", "IT-V1", ready, start));
+ Future second = executor.submit(() -> reserve("IT-R2", "IT-V2", ready, start));
+ ready.await();
+ start.countDown();
+
+ int committed = (first.get() ? 1 : 0) + (second.get() ? 1 : 0);
+ assertEquals(1, committed);
+ assertEquals(1, scalarInt(
+ "SELECT reserved_count FROM dbo.wcs_resource_state WHERE resource_key = ?",
+ resourceKey));
+ assertEquals(1, scalarInt(
+ "SELECT COUNT(*) FROM dbo.wcs_resource_reservation "
+ + "WHERE resource_key = ? AND reservation_state = 'GRANTED'",
+ resourceKey));
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+
+ private void configureIsolatedDatabase() {
+ Assumptions.assumeTrue("true".equalsIgnoreCase(
+ System.getenv("WCS_SQLSERVER_TEST_ENABLED")),
+ "set WCS_SQLSERVER_TEST_ENABLED=true only for an isolated SQL Server database");
+ jdbcUrl = requireEnvironment("WCS_SQLSERVER_TEST_URL");
+ username = requireEnvironment("WCS_SQLSERVER_TEST_USER");
+ password = requireEnvironment("WCS_SQLSERVER_TEST_PASSWORD");
+ }
+
+ private void applyMigration() throws Exception {
+ String root = System.getProperty("maven.multiModuleProjectDirectory", ".");
+ try (Connection connection = connect(); Statement statement = connection.createStatement()) {
+ statement.execute(readMigration(root, "V001__wcs_task_foundation.sql"));
+ statement.execute(readMigration(root, "V002__wcs_topology_and_traffic.sql"));
+ }
+ }
+
+ private static String readMigration(String root, String filename) throws Exception {
+ Path current = Paths.get(root).toAbsolutePath().normalize();
+ while (current != null) {
+ Path migration = current.resolve(Paths.get("sql", "wcs", filename));
+ if (Files.isRegularFile(migration)) {
+ return new String(Files.readAllBytes(migration), StandardCharsets.UTF_8);
+ }
+ current = current.getParent();
+ }
+ throw new java.nio.file.NoSuchFileException(filename);
+ }
+
+ private void createCapacityOneFixture() throws Exception {
+ String suffix = UUID.randomUUID().toString().replace("-", "");
+ warehouseCode = "IT" + suffix;
+ floorId = "IT-F-" + suffix;
+ resourceKey = "IT:RESOURCE:" + suffix;
+ try (Connection connection = connect()) {
+ insert(connection,
+ "INSERT INTO dbo.wcs_warehouse "
+ + "(warehouse_code, warehouse_name, active_configuration_version, state_version) "
+ + "VALUES (?, ?, NULL, 0)",
+ warehouseCode, "traffic concurrency test");
+ insert(connection,
+ "INSERT INTO dbo.wcs_floor "
+ + "(floor_id, warehouse_code, floor_code, floor_level) VALUES (?, ?, ?, 1)",
+ floorId, warehouseCode, "IT-F1");
+ insert(connection,
+ "INSERT INTO dbo.wcs_topology_version "
+ + "(warehouse_code, topology_version, publication_state, published_time) "
+ + "VALUES (?, 1, 'PUBLISHED', ?)",
+ warehouseCode, Instant.now());
+ insert(connection,
+ "INSERT INTO dbo.wcs_resource_definition "
+ + "(resource_key, warehouse_code, topology_version, resource_type, capacity) "
+ + "VALUES (?, ?, 1, 'NODE', 1)",
+ resourceKey, warehouseCode);
+ insert(connection,
+ "INSERT INTO dbo.wcs_resource_state "
+ + "(warehouse_code, topology_version, resource_key, capacity, "
+ + "reserved_count, occupied_count, fencing_token, state_version) "
+ + "VALUES (?, 1, ?, 1, 0, 0, 0, 0)",
+ warehouseCode, resourceKey);
+ }
+ }
+
+ private boolean reserve(String reservationId, String vehicleId,
+ CountDownLatch ready, CountDownLatch start) throws Exception {
+ try (Connection connection = connect()) {
+ connection.setAutoCommit(false);
+ connection.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);
+ ready.countDown();
+ start.await();
+ try {
+ int claimed;
+ try (PreparedStatement statement = connection.prepareStatement(
+ "UPDATE dbo.wcs_resource_state WITH (UPDLOCK, ROWLOCK) "
+ + "SET reserved_count = reserved_count + 1, "
+ + "fencing_token = fencing_token + 1, state_version = state_version + 1 "
+ + "WHERE warehouse_code = ? AND topology_version = 1 "
+ + "AND resource_key = ? "
+ + "AND reserved_count + occupied_count < capacity")) {
+ statement.setString(1, warehouseCode);
+ statement.setString(2, resourceKey);
+ claimed = statement.executeUpdate();
+ }
+ if (claimed == 0) {
+ connection.rollback();
+ return false;
+ }
+ try (PreparedStatement statement = connection.prepareStatement(
+ "INSERT INTO dbo.wcs_resource_reservation "
+ + "(reservation_id, warehouse_code, topology_version, resource_key, "
+ + "order_id, step_id, vehicle_id, reservation_state, fencing_token, "
+ + "created_time, updated_time) "
+ + "SELECT ?, ?, 1, ?, 'IT-O', 'IT-S', ?, 'GRANTED', fencing_token, ?, ? "
+ + "FROM dbo.wcs_resource_state WHERE warehouse_code = ? "
+ + "AND topology_version = 1 AND resource_key = ?")) {
+ Instant now = Instant.now();
+ statement.setString(1, reservationId + '-' + warehouseCode);
+ statement.setString(2, warehouseCode);
+ statement.setString(3, resourceKey);
+ statement.setString(4, vehicleId);
+ statement.setTimestamp(5, Timestamp.from(now));
+ statement.setTimestamp(6, Timestamp.from(now));
+ statement.setString(7, warehouseCode);
+ statement.setString(8, resourceKey);
+ statement.executeUpdate();
+ }
+ connection.commit();
+ return true;
+ } catch (Exception error) {
+ connection.rollback();
+ throw error;
+ }
+ }
+ }
+
+ private int scalarInt(String sql, String value) throws Exception {
+ try (Connection connection = connect();
+ PreparedStatement statement = connection.prepareStatement(sql)) {
+ statement.setString(1, value);
+ try (ResultSet result = statement.executeQuery()) {
+ if (!result.next()) {
+ throw new AssertionError("query returned no rows: " + sql);
+ }
+ return result.getInt(1);
+ }
+ }
+ }
+
+ private Connection connect() throws SQLException {
+ return DriverManager.getConnection(jdbcUrl, username, password);
+ }
+
+ private static void insert(Connection connection, String sql,
+ Object... values) throws SQLException {
+ try (PreparedStatement statement = connection.prepareStatement(sql)) {
+ for (int index = 0; index < values.length; index++) {
+ Object value = values[index];
+ statement.setObject(index + 1,
+ value instanceof Instant ? Timestamp.from((Instant) value) : value);
+ }
+ statement.executeUpdate();
+ }
+ }
+
+ private static void delete(Connection connection, String sql,
+ String value) throws SQLException {
+ try (PreparedStatement statement = connection.prepareStatement(sql)) {
+ statement.setString(1, value);
+ statement.executeUpdate();
+ }
+ }
+
+ private static String requireEnvironment(String name) {
+ String value = System.getenv(name);
+ Assumptions.assumeTrue(value != null && !value.trim().isEmpty(),
+ name + " must point to the isolated SQL Server test database");
+ return value.trim();
+ }
+}
diff --git a/sql/wcs/V002__wcs_topology_and_traffic.sql b/sql/wcs/V002__wcs_topology_and_traffic.sql
new file mode 100644
index 0000000..c444e60
--- /dev/null
+++ b/sql/wcs/V002__wcs_topology_and_traffic.sql
@@ -0,0 +1,297 @@
+SET XACT_ABORT ON;
+BEGIN TRANSACTION;
+
+IF OBJECT_ID('dbo.wcs_warehouse', 'U') IS NULL
+BEGIN
+ CREATE TABLE dbo.wcs_warehouse (
+ warehouse_code varchar(64) NOT NULL PRIMARY KEY,
+ warehouse_name nvarchar(128) NOT NULL,
+ active_configuration_version bigint NULL,
+ state_version bigint NOT NULL CONSTRAINT df_wcs_warehouse_state_version DEFAULT (0)
+ );
+END;
+
+IF OBJECT_ID('dbo.wcs_floor', 'U') IS NULL
+BEGIN
+ CREATE TABLE dbo.wcs_floor (
+ floor_id varchar(64) NOT NULL PRIMARY KEY,
+ warehouse_code varchar(64) NOT NULL,
+ floor_code varchar(64) NOT NULL,
+ floor_level int NOT NULL,
+ CONSTRAINT fk_wcs_floor_warehouse FOREIGN KEY (warehouse_code)
+ REFERENCES dbo.wcs_warehouse(warehouse_code),
+ CONSTRAINT uk_wcs_floor_code UNIQUE (warehouse_code, floor_code),
+ CONSTRAINT uk_wcs_floor_level UNIQUE (warehouse_code, floor_level)
+ );
+END;
+
+IF OBJECT_ID('dbo.wcs_topology_version', 'U') IS NULL
+BEGIN
+ CREATE TABLE dbo.wcs_topology_version (
+ warehouse_code varchar(64) NOT NULL,
+ topology_version bigint NOT NULL,
+ publication_state varchar(32) NOT NULL,
+ published_time datetime2(3) NULL,
+ snapshot_version bigint NOT NULL
+ CONSTRAINT df_wcs_topology_snapshot_version DEFAULT (0),
+ CONSTRAINT pk_wcs_topology_version PRIMARY KEY (
+ warehouse_code, topology_version),
+ CONSTRAINT fk_wcs_topology_version_warehouse FOREIGN KEY (warehouse_code)
+ REFERENCES dbo.wcs_warehouse(warehouse_code)
+ );
+END;
+
+IF OBJECT_ID('dbo.wcs_topology_node', 'U') IS NULL
+BEGIN
+ CREATE TABLE dbo.wcs_topology_node (
+ warehouse_code varchar(64) NOT NULL,
+ topology_version bigint NOT NULL,
+ node_id varchar(128) NOT NULL,
+ floor_id varchar(64) NOT NULL,
+ coordinate_x int NOT NULL,
+ coordinate_y int NOT NULL,
+ capacity int NOT NULL,
+ safe_wait_flag bit NOT NULL CONSTRAINT df_wcs_node_safe_wait DEFAULT (0),
+ safe_yield_flag bit NOT NULL CONSTRAINT df_wcs_node_safe_yield DEFAULT (0),
+ CONSTRAINT pk_wcs_topology_node PRIMARY KEY (
+ warehouse_code, topology_version, node_id),
+ CONSTRAINT fk_wcs_topology_node_version FOREIGN KEY (
+ warehouse_code, topology_version)
+ REFERENCES dbo.wcs_topology_version(warehouse_code, topology_version),
+ CONSTRAINT fk_wcs_topology_node_floor FOREIGN KEY (floor_id)
+ REFERENCES dbo.wcs_floor(floor_id),
+ CONSTRAINT ck_wcs_topology_node_capacity CHECK (capacity > 0),
+ CONSTRAINT uk_wcs_topology_node_coordinate UNIQUE (
+ warehouse_code, topology_version, floor_id, coordinate_x, coordinate_y)
+ );
+END;
+
+IF OBJECT_ID('dbo.wcs_topology_edge', 'U') IS NULL
+BEGIN
+ CREATE TABLE dbo.wcs_topology_edge (
+ warehouse_code varchar(64) NOT NULL,
+ topology_version bigint NOT NULL,
+ resource_key varchar(320) NOT NULL,
+ source_node_id varchar(128) NOT NULL,
+ target_node_id varchar(128) NOT NULL,
+ distance_cost int NOT NULL,
+ capacity int NOT NULL,
+ CONSTRAINT pk_wcs_topology_edge PRIMARY KEY (
+ warehouse_code, topology_version, resource_key),
+ CONSTRAINT fk_wcs_topology_edge_version FOREIGN KEY (
+ warehouse_code, topology_version)
+ REFERENCES dbo.wcs_topology_version(warehouse_code, topology_version),
+ CONSTRAINT ck_wcs_topology_edge_distance CHECK (distance_cost >= 0),
+ CONSTRAINT ck_wcs_topology_edge_capacity CHECK (capacity > 0),
+ CONSTRAINT ck_wcs_topology_edge_direction CHECK (
+ source_node_id <> target_node_id)
+ );
+END;
+
+IF OBJECT_ID('dbo.wcs_traffic_zone', 'U') IS NULL
+BEGIN
+ CREATE TABLE dbo.wcs_traffic_zone (
+ warehouse_code varchar(64) NOT NULL,
+ topology_version bigint NOT NULL,
+ zone_id varchar(128) NOT NULL,
+ zone_name nvarchar(128) NOT NULL,
+ coordination_mode varchar(32) NOT NULL,
+ CONSTRAINT pk_wcs_traffic_zone PRIMARY KEY (
+ warehouse_code, topology_version, zone_id),
+ CONSTRAINT fk_wcs_traffic_zone_version FOREIGN KEY (
+ warehouse_code, topology_version)
+ REFERENCES dbo.wcs_topology_version(warehouse_code, topology_version)
+ );
+END;
+
+IF OBJECT_ID('dbo.wcs_resource_definition', 'U') IS NULL
+BEGIN
+ CREATE TABLE dbo.wcs_resource_definition (
+ resource_key varchar(320) NOT NULL,
+ warehouse_code varchar(64) NOT NULL,
+ topology_version bigint NOT NULL,
+ resource_type varchar(32) NOT NULL,
+ capacity int NOT NULL,
+ zone_id varchar(128) NULL,
+ CONSTRAINT pk_wcs_resource_definition PRIMARY KEY (
+ warehouse_code, topology_version, resource_key),
+ CONSTRAINT fk_wcs_resource_definition_version FOREIGN KEY (
+ warehouse_code, topology_version)
+ REFERENCES dbo.wcs_topology_version(warehouse_code, topology_version),
+ CONSTRAINT ck_wcs_resource_definition_capacity CHECK (capacity > 0)
+ );
+END;
+
+IF OBJECT_ID('dbo.wcs_device', 'U') IS NULL
+BEGIN
+ CREATE TABLE dbo.wcs_device (
+ warehouse_code varchar(64) NOT NULL,
+ topology_version bigint NOT NULL,
+ device_id varchar(64) NOT NULL,
+ device_type varchar(32) NOT NULL,
+ floor_id varchar(64) NULL,
+ enabled_flag bit NOT NULL CONSTRAINT df_wcs_device_enabled DEFAULT (1),
+ CONSTRAINT pk_wcs_device PRIMARY KEY (
+ warehouse_code, topology_version, device_id),
+ CONSTRAINT fk_wcs_device_version FOREIGN KEY (
+ warehouse_code, topology_version)
+ REFERENCES dbo.wcs_topology_version(warehouse_code, topology_version),
+ CONSTRAINT fk_wcs_device_floor FOREIGN KEY (floor_id)
+ REFERENCES dbo.wcs_floor(floor_id)
+ );
+END;
+
+IF OBJECT_ID('dbo.wcs_device_capability', 'U') IS NULL
+BEGIN
+ CREATE TABLE dbo.wcs_device_capability (
+ warehouse_code varchar(64) NOT NULL,
+ topology_version bigint NOT NULL,
+ device_id varchar(64) NOT NULL,
+ capability_code varchar(64) NOT NULL,
+ CONSTRAINT pk_wcs_device_capability PRIMARY KEY (
+ warehouse_code, topology_version, device_id, capability_code),
+ CONSTRAINT fk_wcs_device_capability_device FOREIGN KEY (
+ warehouse_code, topology_version, device_id)
+ REFERENCES dbo.wcs_device(warehouse_code, topology_version, device_id)
+ );
+END;
+
+IF OBJECT_ID('dbo.wcs_device_port', 'U') IS NULL
+BEGIN
+ CREATE TABLE dbo.wcs_device_port (
+ warehouse_code varchar(64) NOT NULL,
+ topology_version bigint NOT NULL,
+ port_id varchar(64) NOT NULL,
+ device_id varchar(64) NOT NULL,
+ node_id varchar(128) NOT NULL,
+ port_role varchar(32) NOT NULL,
+ CONSTRAINT pk_wcs_device_port PRIMARY KEY (
+ warehouse_code, topology_version, port_id),
+ CONSTRAINT fk_wcs_device_port_device FOREIGN KEY (
+ warehouse_code, topology_version, device_id)
+ REFERENCES dbo.wcs_device(warehouse_code, topology_version, device_id)
+ );
+END;
+
+IF OBJECT_ID('dbo.wcs_charger', 'U') IS NULL
+BEGIN
+ CREATE TABLE dbo.wcs_charger (
+ warehouse_code varchar(64) NOT NULL,
+ topology_version bigint NOT NULL,
+ charger_id varchar(64) NOT NULL,
+ node_id varchar(128) NOT NULL,
+ enabled_flag bit NOT NULL CONSTRAINT df_wcs_charger_enabled DEFAULT (1),
+ CONSTRAINT pk_wcs_charger PRIMARY KEY (
+ warehouse_code, topology_version, charger_id),
+ CONSTRAINT fk_wcs_charger_version FOREIGN KEY (
+ warehouse_code, topology_version)
+ REFERENCES dbo.wcs_topology_version(warehouse_code, topology_version)
+ );
+END;
+
+IF OBJECT_ID('dbo.wcs_vehicle_assignment', 'U') IS NULL
+BEGIN
+ CREATE TABLE dbo.wcs_vehicle_assignment (
+ assignment_id varchar(128) NOT NULL PRIMARY KEY,
+ warehouse_code varchar(64) NOT NULL,
+ topology_version bigint NOT NULL,
+ snapshot_version bigint NOT NULL,
+ order_id varchar(64) NOT NULL,
+ step_id varchar(128) NOT NULL,
+ vehicle_id varchar(64) NOT NULL,
+ fencing_token bigint NOT NULL,
+ assignment_state varchar(32) NOT NULL,
+ created_time datetime2(3) NOT NULL,
+ CONSTRAINT uk_wcs_vehicle_assignment_step UNIQUE (step_id),
+ CONSTRAINT ck_wcs_vehicle_assignment_fencing CHECK (fencing_token > 0)
+ );
+END;
+
+IF OBJECT_ID('dbo.wcs_route_plan', 'U') IS NULL
+BEGIN
+ CREATE TABLE dbo.wcs_route_plan (
+ route_plan_id varchar(128) NOT NULL PRIMARY KEY,
+ assignment_id varchar(128) NOT NULL,
+ warehouse_code varchar(64) NOT NULL,
+ topology_version bigint NOT NULL,
+ node_path_json nvarchar(max) NOT NULL,
+ resource_path_json nvarchar(max) NOT NULL,
+ distance_cost int NOT NULL,
+ turn_count int NOT NULL,
+ route_state varchar(32) NOT NULL,
+ created_time datetime2(3) NOT NULL,
+ CONSTRAINT fk_wcs_route_plan_assignment FOREIGN KEY (assignment_id)
+ REFERENCES dbo.wcs_vehicle_assignment(assignment_id),
+ CONSTRAINT uk_wcs_route_plan_assignment UNIQUE (assignment_id),
+ CONSTRAINT ck_wcs_route_plan_distance CHECK (distance_cost >= 0),
+ CONSTRAINT ck_wcs_route_plan_turns CHECK (turn_count >= 0)
+ );
+END;
+
+IF OBJECT_ID('dbo.wcs_resource_state', 'U') IS NULL
+BEGIN
+ CREATE TABLE dbo.wcs_resource_state (
+ warehouse_code varchar(64) NOT NULL,
+ topology_version bigint NOT NULL,
+ resource_key varchar(320) NOT NULL,
+ capacity int NOT NULL,
+ reserved_count int NOT NULL,
+ occupied_count int NOT NULL,
+ fencing_token bigint NOT NULL,
+ state_version bigint NOT NULL,
+ CONSTRAINT pk_wcs_resource_state PRIMARY KEY (
+ warehouse_code, topology_version, resource_key),
+ CONSTRAINT fk_wcs_resource_state_definition FOREIGN KEY (
+ warehouse_code, topology_version, resource_key)
+ REFERENCES dbo.wcs_resource_definition(
+ warehouse_code, topology_version, resource_key),
+ CONSTRAINT ck_wcs_resource_state_counts CHECK (
+ capacity > 0 AND reserved_count >= 0 AND occupied_count >= 0
+ AND reserved_count + occupied_count <= capacity),
+ CONSTRAINT ck_wcs_resource_state_fencing CHECK (fencing_token >= 0),
+ CONSTRAINT ck_wcs_resource_state_version CHECK (state_version >= 0)
+ );
+END;
+
+IF OBJECT_ID('dbo.wcs_resource_reservation', 'U') IS NULL
+BEGIN
+ CREATE TABLE dbo.wcs_resource_reservation (
+ reservation_id varchar(128) NOT NULL,
+ warehouse_code varchar(64) NOT NULL,
+ topology_version bigint NOT NULL,
+ resource_key varchar(320) NOT NULL,
+ order_id varchar(64) NOT NULL,
+ step_id varchar(128) NOT NULL,
+ vehicle_id varchar(64) NOT NULL,
+ reservation_state varchar(32) NOT NULL,
+ fencing_token bigint NOT NULL,
+ release_evidence_id varchar(128) NULL,
+ created_time datetime2(3) NOT NULL,
+ updated_time datetime2(3) NOT NULL,
+ CONSTRAINT pk_wcs_resource_reservation PRIMARY KEY (
+ reservation_id, resource_key),
+ CONSTRAINT fk_wcs_resource_reservation_state FOREIGN KEY (
+ warehouse_code, topology_version, resource_key)
+ REFERENCES dbo.wcs_resource_state(
+ warehouse_code, topology_version, resource_key),
+ CONSTRAINT ck_wcs_resource_reservation_fencing CHECK (fencing_token > 0)
+ );
+END;
+
+IF OBJECT_ID('dbo.wcs_traffic_decision_event', 'U') IS NULL
+BEGIN
+ CREATE TABLE dbo.wcs_traffic_decision_event (
+ event_id varchar(128) NOT NULL PRIMARY KEY,
+ warehouse_code varchar(64) NOT NULL,
+ topology_version bigint NOT NULL,
+ snapshot_version bigint NOT NULL,
+ order_id varchar(64) NOT NULL,
+ step_id varchar(128) NOT NULL,
+ vehicle_id varchar(64) NULL,
+ decision_type varchar(16) NOT NULL,
+ reason_code varchar(128) NOT NULL,
+ occurred_time datetime2(3) NOT NULL
+ );
+END;
+
+COMMIT TRANSACTION;
diff --git a/sql/wcs/verify_V002__wcs_topology_and_traffic.sql b/sql/wcs/verify_V002__wcs_topology_and_traffic.sql
new file mode 100644
index 0000000..c6c6b1c
--- /dev/null
+++ b/sql/wcs/verify_V002__wcs_topology_and_traffic.sql
@@ -0,0 +1,32 @@
+SET NOCOUNT ON;
+
+SELECT name AS table_name
+FROM sys.tables
+WHERE name IN (
+ 'wcs_warehouse',
+ 'wcs_floor',
+ 'wcs_topology_version',
+ 'wcs_topology_node',
+ 'wcs_topology_edge',
+ 'wcs_traffic_zone',
+ 'wcs_resource_definition',
+ 'wcs_device',
+ 'wcs_device_capability',
+ 'wcs_device_port',
+ 'wcs_charger',
+ 'wcs_vehicle_assignment',
+ 'wcs_route_plan',
+ 'wcs_resource_state',
+ 'wcs_resource_reservation',
+ 'wcs_traffic_decision_event'
+)
+ORDER BY name;
+
+SELECT c.name AS constraint_name
+FROM sys.check_constraints c
+WHERE c.name IN (
+ 'ck_wcs_resource_definition_capacity',
+ 'ck_wcs_resource_state_counts',
+ 'ck_wcs_resource_reservation_fencing'
+)
+ORDER BY c.name;