feat: add deterministic vehicle assignment demo

This commit is contained in:
zgw
2026-07-30 11:19:36 +08:00
parent 010bea9e5e
commit 828a68aa6c
4 changed files with 299 additions and 0 deletions

View File

@@ -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<VehicleSnapshot> getVehicle() {
return Optional.ofNullable(vehicle);
}
public String getReason() {
return reason;
}
}

View File

@@ -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<VehicleSnapshot> vehicles,
Map<String, Site> 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<Candidate> candidates = new ArrayList<Candidate>();
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<Candidate> ORDER = new Comparator<Candidate>() {
@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());
}
}
}

View File

@@ -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
}

View File

@@ -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<String, Site> sites;
@BeforeEach
void setUp() {
sites = new LinkedHashMap<String, Site>();
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
);
}
}