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;