From 828a68aa6c294e4ee3b1ee8cb3d56a36a82f1a69 Mon Sep 17 00:00:00 2001 From: zgw <13623675548@163.com> Date: Thu, 30 Jul 2026 11:19:36 +0800 Subject: [PATCH] feat: add deterministic vehicle assignment demo --- .../center/VehicleAssignmentDecision.java | 51 ++++++++ .../demo/center/VehicleAssignmentService.java | 121 ++++++++++++++++++ .../ft546/wcs/demo/model/DecisionCode.java | 14 ++ .../demo/VehicleAssignmentServiceTest.java | 113 ++++++++++++++++ 4 files changed, 299 insertions(+) create mode 100644 javaTest/src/main/java/com/ft546/wcs/demo/center/VehicleAssignmentDecision.java create mode 100644 javaTest/src/main/java/com/ft546/wcs/demo/center/VehicleAssignmentService.java create mode 100644 javaTest/src/main/java/com/ft546/wcs/demo/model/DecisionCode.java create mode 100644 javaTest/src/test/java/com/ft546/wcs/demo/VehicleAssignmentServiceTest.java diff --git a/javaTest/src/main/java/com/ft546/wcs/demo/center/VehicleAssignmentDecision.java b/javaTest/src/main/java/com/ft546/wcs/demo/center/VehicleAssignmentDecision.java new file mode 100644 index 0000000..924e14b --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/demo/center/VehicleAssignmentDecision.java @@ -0,0 +1,51 @@ +package com.ft546.wcs.demo.center; + +import com.ft546.wcs.demo.model.DecisionCode; +import com.ft546.wcs.demo.model.VehicleSnapshot; + +import java.util.Optional; + +public final class VehicleAssignmentDecision { + private final DecisionCode code; + private final VehicleSnapshot vehicle; + private final String reason; + + private VehicleAssignmentDecision( + DecisionCode code, + VehicleSnapshot vehicle, + String reason + ) { + this.code = code; + this.vehicle = vehicle; + this.reason = reason; + } + + public static VehicleAssignmentDecision assigned( + VehicleSnapshot vehicle, + String reason + ) { + return new VehicleAssignmentDecision(DecisionCode.ASSIGNED, vehicle, reason); + } + + public static VehicleAssignmentDecision rejected( + DecisionCode code, + String reason + ) { + if (code == DecisionCode.ASSIGNED) { + throw new IllegalArgumentException("rejected decision cannot be ASSIGNED"); + } + return new VehicleAssignmentDecision(code, null, reason); + } + + public DecisionCode getCode() { + return code; + } + + public Optional getVehicle() { + return Optional.ofNullable(vehicle); + } + + public String getReason() { + return reason; + } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/demo/center/VehicleAssignmentService.java b/javaTest/src/main/java/com/ft546/wcs/demo/center/VehicleAssignmentService.java new file mode 100644 index 0000000..d6b300e --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/demo/center/VehicleAssignmentService.java @@ -0,0 +1,121 @@ +package com.ft546.wcs.demo.center; + +import com.ft546.wcs.demo.model.DecisionCode; +import com.ft546.wcs.demo.model.DispatchTask; +import com.ft546.wcs.demo.model.Site; +import com.ft546.wcs.demo.model.VehicleSnapshot; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +public final class VehicleAssignmentService { + + public VehicleAssignmentDecision select( + DispatchTask task, + List vehicles, + Map sites + ) { + if (task == null || vehicles == null || sites == null) { + throw new IllegalArgumentException("task, vehicles and sites are required"); + } + Site source = sites.get(task.getSourceSite()); + if (source == null) { + return VehicleAssignmentDecision.rejected( + DecisionCode.REJECT_INVALID_SITE, + "unknown task source site: " + task.getSourceSite() + ); + } + + List candidates = new ArrayList(); + boolean eligibleWithUnknownSite = false; + for (VehicleSnapshot vehicle : vehicles) { + if (!isEligible(vehicle)) { + continue; + } + Site current = sites.get(vehicle.getCurrentSite()); + if (current == null) { + eligibleWithUnknownSite = true; + continue; + } + candidates.add(new Candidate(vehicle, current, source)); + } + + if (candidates.isEmpty()) { + if (eligibleWithUnknownSite) { + return VehicleAssignmentDecision.rejected( + DecisionCode.REJECT_INVALID_SITE, + "eligible vehicle references an unknown current site" + ); + } + return VehicleAssignmentDecision.rejected( + DecisionCode.HOLD_NO_ELIGIBLE_VEHICLE, + "no connected automatic idle vehicle with battery > 30 is available" + ); + } + + Collections.sort(candidates, Candidate.ORDER); + Candidate selected = candidates.get(0); + return VehicleAssignmentDecision.assigned( + selected.vehicle, + "vehicle=" + selected.vehicle.getVehicleId() + + ",sameFloorPenalty=" + selected.sameFloorPenalty + + ",floorDistance=" + selected.floorDistance + + ",yDistance=" + selected.yDistance + + ",xDistance=" + selected.xDistance + ); + } + + private boolean isEligible(VehicleSnapshot vehicle) { + return vehicle != null + && vehicle.isConnected() + && vehicle.getWorkMode() == 5 + && vehicle.getStatus() == 1 + && vehicle.isAllowSend() + && vehicle.getBattery() > 30; + } + + private static final class Candidate { + private static final Comparator ORDER = new Comparator() { + @Override + public int compare(Candidate left, Candidate right) { + int result = Integer.compare( + left.sameFloorPenalty, + right.sameFloorPenalty + ); + if (result != 0) { + return result; + } + result = Integer.compare(left.floorDistance, right.floorDistance); + if (result != 0) { + return result; + } + result = Integer.compare(left.yDistance, right.yDistance); + if (result != 0) { + return result; + } + result = Integer.compare(left.xDistance, right.xDistance); + if (result != 0) { + return result; + } + return left.vehicle.getVehicleId().compareTo(right.vehicle.getVehicleId()); + } + }; + + private final VehicleSnapshot vehicle; + private final int sameFloorPenalty; + private final int floorDistance; + private final int yDistance; + private final int xDistance; + + private Candidate(VehicleSnapshot vehicle, Site current, Site source) { + this.vehicle = vehicle; + this.sameFloorPenalty = vehicle.getFloor() == source.getFloor() ? 0 : 1; + this.floorDistance = Math.abs(vehicle.getFloor() - source.getFloor()); + this.yDistance = Math.abs(current.getY() - source.getY()); + this.xDistance = Math.abs(current.getX() - source.getX()); + } + } +} diff --git a/javaTest/src/main/java/com/ft546/wcs/demo/model/DecisionCode.java b/javaTest/src/main/java/com/ft546/wcs/demo/model/DecisionCode.java new file mode 100644 index 0000000..a5ea71d --- /dev/null +++ b/javaTest/src/main/java/com/ft546/wcs/demo/model/DecisionCode.java @@ -0,0 +1,14 @@ +package com.ft546.wcs.demo.model; + +public enum DecisionCode { + ASSIGNED, + PATH_READY, + AVOIDANCE_CREATED, + HOLD_NO_ELIGIBLE_VEHICLE, + REJECT_INVALID_SITE, + HOLD_NO_PATH, + HOLD_CHANNEL_BLOCKED, + HOLD_PROTOCOL_FAILURE, + SENT, + COMPLETED +} diff --git a/javaTest/src/test/java/com/ft546/wcs/demo/VehicleAssignmentServiceTest.java b/javaTest/src/test/java/com/ft546/wcs/demo/VehicleAssignmentServiceTest.java new file mode 100644 index 0000000..19df7c6 --- /dev/null +++ b/javaTest/src/test/java/com/ft546/wcs/demo/VehicleAssignmentServiceTest.java @@ -0,0 +1,113 @@ +package com.ft546.wcs.demo; + +import com.ft546.wcs.demo.center.VehicleAssignmentDecision; +import com.ft546.wcs.demo.center.VehicleAssignmentService; +import com.ft546.wcs.demo.model.DecisionCode; +import com.ft546.wcs.demo.model.DispatchTask; +import com.ft546.wcs.demo.model.Site; +import com.ft546.wcs.demo.model.VehicleSnapshot; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; + +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 VehicleAssignmentServiceTest { + private final VehicleAssignmentService service = new VehicleAssignmentService(); + private Map sites; + + @BeforeEach + void setUp() { + sites = new LinkedHashMap(); + sites.put("SOURCE", new Site("SOURCE", 1, 10, 10, "PICK")); + sites.put("SAME", new Site("SAME", 1, 1, 1, "AISLE-1")); + sites.put("NEAR", new Site("NEAR", 2, 10, 9, "AISLE-2")); + sites.put("TIE", new Site("TIE", 1, 1, 1, "AISLE-1")); + } + + @Test + void sameFloorVehicleWinsBeforeCloserCrossFloorVehicle() { + DispatchTask task = task("SOURCE"); + VehicleSnapshot crossFloor = vehicle("1", 2, "NEAR", 80, true, true, 5, 1); + VehicleSnapshot sameFloor = vehicle("2", 1, "SAME", 80, true, true, 5, 1); + + VehicleAssignmentDecision decision = + service.select(task, Arrays.asList(crossFloor, sameFloor), sites); + + assertEquals(DecisionCode.ASSIGNED, decision.getCode()); + assertEquals("2", decision.getVehicle().get().getVehicleId()); + assertTrue(decision.getReason().contains("sameFloorPenalty=0")); + } + + @Test + void lowerVehicleIdBreaksAnExactDistanceTie() { + VehicleSnapshot vehicle2 = vehicle("2", 1, "TIE", 80, true, true, 5, 1); + VehicleSnapshot vehicle1 = vehicle("1", 1, "SAME", 80, true, true, 5, 1); + + VehicleAssignmentDecision decision = + service.select(task("SOURCE"), Arrays.asList(vehicle2, vehicle1), sites); + + assertEquals("1", decision.getVehicle().get().getVehicleId()); + } + + @Test + void onlyConnectedAutomaticIdleAllowedAndChargedVehicleIsEligible() { + VehicleSnapshot offline = vehicle("1", 1, "SAME", 80, false, true, 5, 1); + VehicleSnapshot lowBattery = vehicle("2", 1, "SAME", 30, true, true, 5, 1); + VehicleSnapshot manual = vehicle("3", 1, "SAME", 80, true, true, 4, 1); + VehicleSnapshot busy = vehicle("4", 1, "SAME", 80, true, true, 5, 2); + VehicleSnapshot blocked = vehicle("5", 1, "SAME", 80, true, false, 5, 1); + VehicleSnapshot eligible = vehicle("6", 1, "SAME", 31, true, true, 5, 1); + + VehicleAssignmentDecision decision = service.select( + task("SOURCE"), + Arrays.asList(offline, lowBattery, manual, busy, blocked, eligible), + sites + ); + + assertEquals("6", decision.getVehicle().get().getVehicleId()); + } + + @Test + void missingTaskSiteIsRejectedInsteadOfUsingZeroCoordinates() { + VehicleAssignmentDecision decision = + service.select(task("UNKNOWN"), Arrays.asList( + vehicle("1", 1, "SAME", 80, true, true, 5, 1) + ), sites); + + assertEquals(DecisionCode.REJECT_INVALID_SITE, decision.getCode()); + assertFalse(decision.getVehicle().isPresent()); + assertTrue(decision.getReason().contains("UNKNOWN")); + } + + private DispatchTask task(String sourceSite) { + return new DispatchTask( + "TASK-1", + DispatchTask.TaskType.OUTBOUND, + sourceSite, + "SOURCE", + 1, + 2 + ); + } + + private VehicleSnapshot vehicle( + String id, + int floor, + String site, + int battery, + boolean connected, + boolean allowSend, + int workMode, + int status + ) { + return new VehicleSnapshot( + id, floor, site, workMode, status, battery, connected, allowSend, false + ); + } +}