From 42374c24a7630262a8ae7203105825b9a70f8d0a Mon Sep 17 00:00:00 2001 From: zgw <13623675548@163.com> Date: Mon, 10 Aug 2026 16:06:29 +0800 Subject: [PATCH] feat(wcs): add deterministic vehicle assignment --- .../DefaultVehicleAssignmentPolicy.java | 162 ++++++++++++++++++ .../wcs/domain/dispatch/DispatchTask.java | 82 +++++++++ .../dispatch/VehicleAssignmentDecision.java | 57 ++++++ .../dispatch/VehicleAssignmentPolicy.java | 12 ++ .../ruoyi/wcs/port/out/DispatchPolicy.java | 10 ++ .../DefaultVehicleAssignmentPolicyTest.java | 122 +++++++++++++ 6 files changed, 445 insertions(+) create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/dispatch/DefaultVehicleAssignmentPolicy.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/dispatch/DispatchTask.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/dispatch/VehicleAssignmentDecision.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/dispatch/VehicleAssignmentPolicy.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/DispatchPolicy.java create mode 100644 ruoyi-wcs/src/test/java/com/ruoyi/wcs/domain/dispatch/DefaultVehicleAssignmentPolicyTest.java diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/dispatch/DefaultVehicleAssignmentPolicy.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/dispatch/DefaultVehicleAssignmentPolicy.java new file mode 100644 index 0000000..449c96b --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/dispatch/DefaultVehicleAssignmentPolicy.java @@ -0,0 +1,162 @@ +package com.ruoyi.wcs.domain.dispatch; + +import com.ruoyi.wcs.domain.equipment.DeviceCapability; +import com.ruoyi.wcs.domain.equipment.DeviceType; +import com.ruoyi.wcs.domain.equipment.VehicleSnapshot; +import com.ruoyi.wcs.domain.shared.DomainRuleViolation; +import com.ruoyi.wcs.domain.topology.EffectiveTopology; +import com.ruoyi.wcs.domain.topology.TopologyNode; +import com.ruoyi.wcs.domain.traffic.RoutePlan; +import com.ruoyi.wcs.domain.traffic.RoutePlanner; +import com.ruoyi.wcs.port.out.DispatchPolicy; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +public final class DefaultVehicleAssignmentPolicy implements VehicleAssignmentPolicy { + private final RoutePlanner routePlanner; + private final DispatchPolicy dispatchPolicy; + + public DefaultVehicleAssignmentPolicy(RoutePlanner routePlanner, + DispatchPolicy dispatchPolicy) { + this.routePlanner = Objects.requireNonNull(routePlanner, "routePlanner"); + this.dispatchPolicy = Objects.requireNonNull(dispatchPolicy, "dispatchPolicy"); + } + + @Override + public VehicleAssignmentDecision assign(DispatchTask task, + List vehicles, + EffectiveTopology topology) { + Objects.requireNonNull(task, "task"); + Objects.requireNonNull(vehicles, "vehicles"); + Objects.requireNonNull(topology, "topology"); + if (!topology.containsNode(task.getSourceNodeId()) + || !topology.containsNode(task.getDestinationNodeId())) { + throw new DomainRuleViolation("dispatch task references unknown topology node"); + } + + List eligible = new ArrayList(); + for (VehicleSnapshot vehicle : vehicles) { + ScoredVehicle scored = scoreIfEligible(task, vehicle, topology); + if (scored != null) { + eligible.add(scored); + } + } + if (eligible.isEmpty()) { + return VehicleAssignmentDecision.unassigned("NO_ELIGIBLE_VEHICLE"); + } + Collections.sort(eligible, Comparator + .comparingLong(ScoredVehicle::getScore).reversed() + .thenComparing(ScoredVehicle::getVehicleId)); + ScoredVehicle selected = eligible.get(0); + return VehicleAssignmentDecision.assigned(selected.getVehicleId(), + selected.route, selected.score, selected.reasonCodes); + } + + private ScoredVehicle scoreIfEligible(DispatchTask task, + VehicleSnapshot vehicle, + EffectiveTopology topology) { + if (vehicle == null + || vehicle.getDescriptor().getDeviceType() != DeviceType.FOUR_WAY_SHUTTLE + || !vehicle.isDispatchable(task.getMinimumBattery()) + || !supportsAll(vehicle, task) + || !topology.containsNode(vehicle.getLocationNodeId())) { + return null; + } + + RoutePlan route; + try { + RoutePlan approach = routePlanner.plan(topology, + vehicle.getLocationNodeId(), task.getSourceNodeId(), + Collections.emptySet()); + RoutePlan execution = routePlanner.plan(topology, + task.getSourceNodeId(), task.getDestinationNodeId(), + Collections.emptySet()); + route = combine(approach, execution, topology); + } catch (DomainRuleViolation noRoute) { + return null; + } + + TopologyNode source = topology.node(task.getSourceNodeId()); + boolean sameFloor = vehicle.getFloorCode().equals(source.getFloorCode()); + long score = ((long) task.getPriority() * 1_000_000L) + + Math.min(task.getWaitingAgeSeconds(), 100_000L) * 100L + + (sameFloor ? 10_000L : 0L) + - ((long) route.getDistanceCost() * 1_000L) + + ((long) (vehicle.getBatteryPercent() - task.getMinimumBattery()) * 10L) + + dispatchPolicy.adjustment(task, vehicle); + List reasons = new ArrayList(Arrays.asList( + "DISPATCHABLE", "CAPABILITY_MATCH", "REACHABLE")); + if (sameFloor) { + reasons.add("SAME_FLOOR"); + } + reasons.add("STABLE_SCORE"); + return new ScoredVehicle(vehicle.getVehicleId(), route, score, reasons); + } + + private static boolean supportsAll(VehicleSnapshot vehicle, DispatchTask task) { + for (DeviceCapability capability : task.getRequiredCapabilities()) { + if (!vehicle.supports(capability)) { + return false; + } + } + return true; + } + + private static RoutePlan combine(RoutePlan approach, RoutePlan execution, + EffectiveTopology topology) { + List nodes = new ArrayList(approach.getNodeIds()); + nodes.addAll(execution.getNodeIds().subList(1, execution.getNodeIds().size())); + List resources = new ArrayList(approach.getResourceKeys()); + resources.addAll(execution.getResourceKeys().subList( + 1, execution.getResourceKeys().size())); + return new RoutePlan(nodes, resources, + approach.getDistanceCost() + execution.getDistanceCost(), + countTurns(nodes, topology), topology.version()); + } + + private static int countTurns(List nodes, EffectiveTopology topology) { + int turns = 0; + int previousX = 0; + int previousY = 0; + for (int i = 1; i < nodes.size(); i++) { + TopologyNode from = topology.node(nodes.get(i - 1)); + TopologyNode to = topology.node(nodes.get(i)); + int directionX = Integer.compare(to.getX(), from.getX()); + int directionY = Integer.compare(to.getY(), from.getY()); + if (i > 1 && (previousX != directionX || previousY != directionY)) { + turns++; + } + previousX = directionX; + previousY = directionY; + } + return turns; + } + + private static final class ScoredVehicle { + private final String vehicleId; + private final RoutePlan route; + private final long score; + private final List reasonCodes; + + private ScoredVehicle(String vehicleId, RoutePlan route, + long score, List reasonCodes) { + this.vehicleId = vehicleId; + this.route = route; + this.score = score; + this.reasonCodes = reasonCodes; + } + + private String getVehicleId() { + return vehicleId; + } + + private long getScore() { + return score; + } + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/dispatch/DispatchTask.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/dispatch/DispatchTask.java new file mode 100644 index 0000000..2303478 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/dispatch/DispatchTask.java @@ -0,0 +1,82 @@ +package com.ruoyi.wcs.domain.dispatch; + +import com.ruoyi.wcs.domain.equipment.DeviceCapability; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.Objects; +import java.util.Set; + +public final class DispatchTask { + private final String orderId; + private final String stepId; + private final String sourceNodeId; + private final String destinationNodeId; + private final int priority; + private final long waitingAgeSeconds; + private final int minimumBattery; + private final Set requiredCapabilities; + + public DispatchTask(String orderId, String stepId, + String sourceNodeId, String destinationNodeId, + int priority, long waitingAgeSeconds, int minimumBattery, + Set requiredCapabilities) { + this.orderId = requireText(orderId, "orderId"); + this.stepId = requireText(stepId, "stepId"); + this.sourceNodeId = requireText(sourceNodeId, "sourceNodeId"); + this.destinationNodeId = requireText(destinationNodeId, "destinationNodeId"); + this.priority = priority; + if (waitingAgeSeconds < 0L) { + throw new IllegalArgumentException("waitingAgeSeconds must not be negative"); + } + this.waitingAgeSeconds = waitingAgeSeconds; + if (minimumBattery < 0 || minimumBattery > 100) { + throw new IllegalArgumentException("minimumBattery must be between 0 and 100"); + } + this.minimumBattery = minimumBattery; + Objects.requireNonNull(requiredCapabilities, "requiredCapabilities"); + EnumSet copied = requiredCapabilities.isEmpty() + ? EnumSet.noneOf(DeviceCapability.class) + : EnumSet.copyOf(requiredCapabilities); + this.requiredCapabilities = Collections.unmodifiableSet(copied); + } + + public String getOrderId() { + return orderId; + } + + public String getStepId() { + return stepId; + } + + public String getSourceNodeId() { + return sourceNodeId; + } + + public String getDestinationNodeId() { + return destinationNodeId; + } + + public int getPriority() { + return priority; + } + + public long getWaitingAgeSeconds() { + return waitingAgeSeconds; + } + + public int getMinimumBattery() { + return minimumBattery; + } + + public Set getRequiredCapabilities() { + return requiredCapabilities; + } + + private static String requireText(String value, String field) { + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException(field + " must not be blank"); + } + return value.trim(); + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/dispatch/VehicleAssignmentDecision.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/dispatch/VehicleAssignmentDecision.java new file mode 100644 index 0000000..d791a11 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/dispatch/VehicleAssignmentDecision.java @@ -0,0 +1,57 @@ +package com.ruoyi.wcs.domain.dispatch; + +import com.ruoyi.wcs.domain.traffic.RoutePlan; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class VehicleAssignmentDecision { + private final String vehicleId; + private final RoutePlan route; + private final long totalScore; + private final List reasonCodes; + + private VehicleAssignmentDecision(String vehicleId, RoutePlan route, + long totalScore, List reasonCodes) { + this.vehicleId = vehicleId; + this.route = route; + this.totalScore = totalScore; + this.reasonCodes = Collections.unmodifiableList( + new ArrayList(reasonCodes)); + } + + public static VehicleAssignmentDecision assigned(String vehicleId, RoutePlan route, + long totalScore, + List reasonCodes) { + if (vehicleId == null || route == null) { + throw new IllegalArgumentException("assigned decision requires vehicle and route"); + } + return new VehicleAssignmentDecision(vehicleId, route, totalScore, reasonCodes); + } + + public static VehicleAssignmentDecision unassigned(String reasonCode) { + return new VehicleAssignmentDecision(null, null, Long.MIN_VALUE, + Collections.singletonList(reasonCode)); + } + + public boolean isAssigned() { + return vehicleId != null; + } + + public String getVehicleId() { + return vehicleId; + } + + public RoutePlan getRoute() { + return route; + } + + public long getTotalScore() { + return totalScore; + } + + public List getReasonCodes() { + return reasonCodes; + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/dispatch/VehicleAssignmentPolicy.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/dispatch/VehicleAssignmentPolicy.java new file mode 100644 index 0000000..e07c001 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/dispatch/VehicleAssignmentPolicy.java @@ -0,0 +1,12 @@ +package com.ruoyi.wcs.domain.dispatch; + +import com.ruoyi.wcs.domain.equipment.VehicleSnapshot; +import com.ruoyi.wcs.domain.topology.EffectiveTopology; + +import java.util.List; + +public interface VehicleAssignmentPolicy { + VehicleAssignmentDecision assign(DispatchTask task, + List vehicles, + EffectiveTopology topology); +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/DispatchPolicy.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/DispatchPolicy.java new file mode 100644 index 0000000..e50d5ce --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/DispatchPolicy.java @@ -0,0 +1,10 @@ +package com.ruoyi.wcs.port.out; + +import com.ruoyi.wcs.domain.dispatch.DispatchTask; +import com.ruoyi.wcs.domain.equipment.VehicleSnapshot; + +public interface DispatchPolicy { + DispatchPolicy NONE = (task, vehicle) -> 0; + + int adjustment(DispatchTask task, VehicleSnapshot vehicle); +} diff --git a/ruoyi-wcs/src/test/java/com/ruoyi/wcs/domain/dispatch/DefaultVehicleAssignmentPolicyTest.java b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/domain/dispatch/DefaultVehicleAssignmentPolicyTest.java new file mode 100644 index 0000000..5036963 --- /dev/null +++ b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/domain/dispatch/DefaultVehicleAssignmentPolicyTest.java @@ -0,0 +1,122 @@ +package com.ruoyi.wcs.domain.dispatch; + +import com.ruoyi.wcs.domain.equipment.DeviceCapability; +import com.ruoyi.wcs.domain.equipment.DeviceDescriptor; +import com.ruoyi.wcs.domain.equipment.DeviceType; +import com.ruoyi.wcs.domain.equipment.VehicleSnapshot; +import com.ruoyi.wcs.domain.topology.EffectiveTopology; +import com.ruoyi.wcs.domain.topology.ResourceDefinition; +import com.ruoyi.wcs.domain.topology.TopologyCompiler; +import com.ruoyi.wcs.domain.topology.TopologyDefinition; +import com.ruoyi.wcs.domain.topology.TopologyEdge; +import com.ruoyi.wcs.domain.topology.TopologyNode; +import com.ruoyi.wcs.domain.traffic.AStarRoutePlanner; +import com.ruoyi.wcs.port.out.DispatchPolicy; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.EnumSet; + +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 DefaultVehicleAssignmentPolicyTest { + private final EffectiveTopology topology = topology(); + private final VehicleAssignmentPolicy policy = new DefaultVehicleAssignmentPolicy( + new AStarRoutePlanner(), DispatchPolicy.NONE); + + @Test + void rejectsCloserVehicleWithoutRequiredCapability() { + VehicleAssignmentDecision decision = policy.assign(taskRequiringLoad(), + Arrays.asList( + vehicle("V1", "B", 90, false, VehicleSnapshot.ConnectionState.ONLINE), + vehicle("V2", "A", 70, true, VehicleSnapshot.ConnectionState.ONLINE)), + topology); + + assertEquals("V2", decision.getVehicleId()); + assertTrue(decision.getReasonCodes().contains("CAPABILITY_MATCH")); + assertEquals(Arrays.asList("A", "B", "C", "D"), + decision.getRoute().getNodeIds()); + } + + @Test + void breaksEqualScoresByVehicleId() { + VehicleAssignmentDecision decision = policy.assign(taskWithoutLoad(), + Arrays.asList( + vehicle("V2", "A", 80, true, VehicleSnapshot.ConnectionState.ONLINE), + vehicle("V1", "A", 80, true, VehicleSnapshot.ConnectionState.ONLINE)), + topology); + + assertEquals("V1", decision.getVehicleId()); + } + + @Test + void appliesHardDispatchabilityFilterBeforeScoring() { + VehicleAssignmentDecision decision = policy.assign(taskWithoutLoad(), + Arrays.asList( + vehicle("V1", "C", 100, true, VehicleSnapshot.ConnectionState.OFFLINE), + vehicle("V2", "A", 40, true, VehicleSnapshot.ConnectionState.ONLINE)), + topology); + + assertEquals("V2", decision.getVehicleId()); + } + + @Test + void reportsNoAssignmentWhenEveryVehicleFailsAHardCondition() { + VehicleAssignmentDecision decision = policy.assign(taskRequiringLoad(), + Collections.singletonList(vehicle("V1", "B", 90, false, + VehicleSnapshot.ConnectionState.ONLINE)), topology); + + assertFalse(decision.isAssigned()); + assertEquals(Collections.singletonList("NO_ELIGIBLE_VEHICLE"), + decision.getReasonCodes()); + } + + private static DispatchTask taskRequiringLoad() { + return new DispatchTask("O1", "S1", "C", "D", 10, 60L, 30, + Collections.singleton(DeviceCapability.LOAD_HANDLING)); + } + + private static DispatchTask taskWithoutLoad() { + return new DispatchTask("O1", "S1", "C", "D", 10, 60L, 30, + Collections.emptySet()); + } + + private static VehicleSnapshot vehicle(String id, String location, int battery, + boolean loadCapability, + VehicleSnapshot.ConnectionState connection) { + EnumSet capabilities = + EnumSet.of(DeviceCapability.FOUR_WAY_MOVE); + if (loadCapability) { + capabilities.add(DeviceCapability.LOAD_HANDLING); + } + DeviceDescriptor descriptor = new DeviceDescriptor(id, + DeviceType.FOUR_WAY_SHUTTLE, "F1", capabilities); + return new VehicleSnapshot(descriptor, connection, + VehicleSnapshot.OperatingMode.AUTO, + VehicleSnapshot.HealthState.NORMAL, + VehicleSnapshot.OccupationState.IDLE, + location, null, battery, null, "F1", + Instant.parse("2026-08-04T00:00:00Z")); + } + + private static EffectiveTopology topology() { + TopologyDefinition definition = new TopologyDefinition("FT546", 7L, true, + Arrays.asList(node("A", 0), node("B", 1), node("C", 2), node("D", 3)), + Arrays.asList(edge("A", "B"), edge("B", "C"), edge("C", "D")), + Collections.emptyList(), + Collections.emptyList(), Collections.emptyList()); + return new TopologyCompiler().compile(definition); + } + + private static TopologyNode node(String id, int x) { + return new TopologyNode(id, "F1", x, 0, 1, false, false); + } + + private static TopologyEdge edge(String source, String target) { + return new TopologyEdge(source, target, 1, 1); + } +}