feat: add legacy dispatch cycle demo

This commit is contained in:
zgw
2026-07-30 11:26:26 +08:00
parent 75ff7ec5de
commit 3860e3f014
6 changed files with 652 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
package com.ft546.wcs.demo.center;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public final class CycleResult {
private final int cycleNumber;
private final List<String> processedVehicleIds;
private final List<String> events;
public CycleResult(
int cycleNumber,
List<String> processedVehicleIds,
List<String> events
) {
this.cycleNumber = cycleNumber;
this.processedVehicleIds = Collections.unmodifiableList(
new ArrayList<String>(processedVehicleIds)
);
this.events = Collections.unmodifiableList(new ArrayList<String>(events));
}
public int getCycleNumber() {
return cycleNumber;
}
public List<String> getProcessedVehicleIds() {
return processedVehicleIds;
}
public List<String> getEvents() {
return events;
}
}

View File

@@ -0,0 +1,154 @@
package com.ft546.wcs.demo.center;
import com.ft546.wcs.demo.model.DecisionCode;
import com.ft546.wcs.demo.model.DispatchQueueEntry;
import com.ft546.wcs.demo.model.VehicleSnapshot;
import com.ft546.wcs.demo.repository.InMemoryWcsState;
import com.ft546.wcs.demo.simulation.SimulatedProtocolAdapter;
import com.ft546.wcs.demo.traffic.TrafficController;
import com.ft546.wcs.demo.traffic.TrafficDecision;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
public final class LegacyDispatchCenter {
private final InMemoryWcsState state;
private final QueueAdmissionService admission;
private final TrafficController traffic;
private final SimulatedProtocolAdapter protocol;
public LegacyDispatchCenter(
InMemoryWcsState state,
QueueAdmissionService admission,
TrafficController traffic,
SimulatedProtocolAdapter protocol
) {
if (state == null || admission == null || traffic == null || protocol == null) {
throw new IllegalArgumentException(
"state, admission, traffic and protocol are required"
);
}
this.state = state;
this.admission = admission;
this.traffic = traffic;
this.protocol = protocol;
}
public CycleResult runCycle() {
int eventStart = state.getEvents().size();
int cycleNumber = state.nextCycle();
state.addEvent("CYCLE " + cycleNumber);
admission.createPendingQueue(state);
List<VehicleSnapshot> vehicles =
new ArrayList<VehicleSnapshot>(state.getVehicles());
Collections.sort(vehicles, new Comparator<VehicleSnapshot>() {
@Override
public int compare(VehicleSnapshot left, VehicleSnapshot right) {
return left.getVehicleId().compareTo(right.getVehicleId());
}
});
List<String> processed = new ArrayList<String>();
for (VehicleSnapshot frozenVehicle : vehicles) {
processed.add(frozenVehicle.getVehicleId());
Optional<DispatchQueueEntry> optional =
state.findQueueByVehicle(frozenVehicle.getVehicleId());
if (!optional.isPresent()) {
continue;
}
DispatchQueueEntry queue = optional.get();
if (queue.isIssued()) {
processIssuedQueue(frozenVehicle, queue);
} else {
processUnissuedQueue(frozenVehicle, queue);
}
}
List<String> cycleEvents = new ArrayList<String>(
state.getEvents().subList(eventStart, state.getEvents().size())
);
return new CycleResult(cycleNumber, processed, cycleEvents);
}
private void processUnissuedQueue(
VehicleSnapshot vehicle,
DispatchQueueEntry queue
) {
TrafficDecision decision = traffic.plan(
vehicle,
queue,
state.getVehicles(),
state.getActiveQueues()
);
if (decision.getCode() == DecisionCode.AVOIDANCE_CREATED) {
DispatchQueueEntry avoidance = decision.getAvoidanceQueue().get();
state.addQueue(avoidance);
state.addEvent(
"AVOID queue=" + avoidance.getQueueId() + " reason="
+ decision.getReason()
);
return;
}
if (decision.getCode() != DecisionCode.PATH_READY) {
state.addEvent(
"HOLD " + decision.getCode() + " queue=" + queue.getQueueId()
+ " reason=" + decision.getReason()
);
return;
}
state.addEvent("PATH queue=" + queue.getQueueId() + " " + decision.getPath());
SimulatedProtocolAdapter.ProtocolResult result =
protocol.send(queue, decision.getPath());
if (!result.isSuccess()) {
state.addEvent(
"HOLD " + DecisionCode.HOLD_PROTOCOL_FAILURE + " queue="
+ queue.getQueueId() + " reason=" + result.getMessage()
);
return;
}
queue.markIssued(decision.getPath());
state.addEvent("SEND queue=" + queue.getQueueId() + " vehicle="
+ queue.getVehicleId());
}
private void processIssuedQueue(
VehicleSnapshot vehicle,
DispatchQueueEntry queue
) {
if (vehicle.getCurrentSite().equals(queue.getEndSite())) {
complete(queue);
return;
}
int index = queue.getPath().indexOf(vehicle.getCurrentSite());
if (index < 0 || index + 1 >= queue.getPath().size()) {
state.addEvent(
"HOLD " + DecisionCode.HOLD_NO_PATH + " queue=" + queue.getQueueId()
+ " reason=vehicle is outside issued path"
);
return;
}
String nextSite = queue.getPath().get(index + 1);
state.updateVehicle(vehicle.at(nextSite, vehicle.getFloor()));
state.addEvent(
"MOVE vehicle=" + vehicle.getVehicleId() + " site=" + nextSite
);
if (nextSite.equals(queue.getEndSite())) {
complete(queue);
}
}
private void complete(DispatchQueueEntry queue) {
if (state.completeQueue(queue.getQueueId())) {
state.addEvent(
"COMPLETE queue=" + queue.getQueueId() + " vehicle="
+ queue.getVehicleId()
);
}
}
}

View File

@@ -0,0 +1,71 @@
package com.ft546.wcs.demo.center;
import com.ft546.wcs.demo.model.DispatchQueueEntry;
import com.ft546.wcs.demo.model.DispatchTask;
import com.ft546.wcs.demo.model.VehicleSnapshot;
import com.ft546.wcs.demo.repository.InMemoryWcsState;
import com.ft546.wcs.demo.traffic.GridMap;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public final class QueueAdmissionService {
private final VehicleAssignmentService assignmentService;
private final GridMap map;
private int invocationCount;
public QueueAdmissionService(
VehicleAssignmentService assignmentService,
GridMap map
) {
if (assignmentService == null || map == null) {
throw new IllegalArgumentException("assignment service and map are required");
}
this.assignmentService = assignmentService;
this.map = map;
}
public Optional<DispatchQueueEntry> createPendingQueue(InMemoryWcsState state) {
if (state == null) {
throw new IllegalArgumentException("state must not be null");
}
invocationCount++;
if (state.getPendingTasks().isEmpty()) {
return Optional.empty();
}
DispatchTask task = state.getPendingTasks().get(0);
List<VehicleSnapshot> available = new ArrayList<VehicleSnapshot>();
for (VehicleSnapshot vehicle : state.getVehicles()) {
if (!state.findQueueByVehicle(vehicle.getVehicleId()).isPresent()) {
available.add(vehicle);
}
}
VehicleAssignmentDecision decision =
assignmentService.select(task, available, map.getSites());
if (!decision.getVehicle().isPresent()) {
state.addEvent(
"HOLD " + decision.getCode() + " task=" + task.getTaskId()
+ " reason=" + decision.getReason()
);
return Optional.empty();
}
VehicleSnapshot selected = decision.getVehicle().get();
DispatchQueueEntry queue = DispatchQueueEntry.business(
task,
selected.getVehicleId()
);
state.admitTask(task, queue);
state.addEvent(
"ASSIGN task=" + task.getTaskId() + " vehicle="
+ selected.getVehicleId() + " reason=" + decision.getReason()
);
return Optional.of(queue);
}
public int getInvocationCount() {
return invocationCount;
}
}

View File

@@ -0,0 +1,152 @@
package com.ft546.wcs.demo.repository;
import com.ft546.wcs.demo.model.DispatchQueueEntry;
import com.ft546.wcs.demo.model.DispatchTask;
import com.ft546.wcs.demo.model.VehicleSnapshot;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
public final class InMemoryWcsState {
private final Map<String, VehicleSnapshot> vehicles =
new LinkedHashMap<String, VehicleSnapshot>();
private final Map<String, DispatchTask> pendingTasks =
new LinkedHashMap<String, DispatchTask>();
private final Set<String> knownTaskIds = new LinkedHashSet<String>();
private final Map<String, DispatchQueueEntry> activeQueues =
new LinkedHashMap<String, DispatchQueueEntry>();
private final List<DispatchQueueEntry> history =
new ArrayList<DispatchQueueEntry>();
private final List<String> events = new ArrayList<String>();
private int cycleNumber;
public void addVehicle(VehicleSnapshot vehicle) {
if (vehicle == null) {
throw new IllegalArgumentException("vehicle must not be null");
}
if (vehicles.containsKey(vehicle.getVehicleId())) {
throw new IllegalArgumentException(
"duplicate vehicle: " + vehicle.getVehicleId()
);
}
vehicles.put(vehicle.getVehicleId(), vehicle);
}
public void updateVehicle(VehicleSnapshot vehicle) {
if (vehicle == null || !vehicles.containsKey(vehicle.getVehicleId())) {
throw new IllegalArgumentException("cannot update unknown vehicle");
}
vehicles.put(vehicle.getVehicleId(), vehicle);
}
public void addTask(DispatchTask task) {
if (task == null) {
throw new IllegalArgumentException("task must not be null");
}
if (!knownTaskIds.add(task.getTaskId())) {
throw new IllegalArgumentException("duplicate task: " + task.getTaskId());
}
pendingTasks.put(task.getTaskId(), task);
}
public void admitTask(DispatchTask task, DispatchQueueEntry queue) {
if (task == null || !pendingTasks.containsKey(task.getTaskId())) {
throw new IllegalArgumentException("task is not pending: " + task);
}
addQueue(queue);
pendingTasks.remove(task.getTaskId());
}
public void addQueue(DispatchQueueEntry queue) {
if (queue == null) {
throw new IllegalArgumentException("queue must not be null");
}
if (activeQueues.containsKey(queue.getQueueId())) {
throw new IllegalArgumentException("duplicate queue: " + queue.getQueueId());
}
if (findQueueByVehicle(queue.getVehicleId()).isPresent()) {
throw new IllegalStateException(
"vehicle already has an active queue: " + queue.getVehicleId()
);
}
activeQueues.put(queue.getQueueId(), queue);
}
public boolean completeQueue(String queueId) {
DispatchQueueEntry queue = activeQueues.remove(queueId);
if (queue == null) {
return false;
}
queue.markCompleted();
history.add(queue);
return true;
}
public List<VehicleSnapshot> getVehicles() {
return Collections.unmodifiableList(
new ArrayList<VehicleSnapshot>(vehicles.values())
);
}
public List<DispatchTask> getPendingTasks() {
List<DispatchTask> result =
new ArrayList<DispatchTask>(pendingTasks.values());
Collections.sort(result, new Comparator<DispatchTask>() {
@Override
public int compare(DispatchTask left, DispatchTask right) {
int priority = Integer.compare(right.getPriority(), left.getPriority());
if (priority != 0) {
return priority;
}
return left.getTaskId().compareTo(right.getTaskId());
}
});
return Collections.unmodifiableList(result);
}
public List<DispatchQueueEntry> getActiveQueues() {
return Collections.unmodifiableList(
new ArrayList<DispatchQueueEntry>(activeQueues.values())
);
}
public List<DispatchQueueEntry> getHistory() {
return Collections.unmodifiableList(history);
}
public Optional<DispatchQueueEntry> findQueueByVehicle(String vehicleId) {
for (DispatchQueueEntry queue : activeQueues.values()) {
if (queue.getVehicleId().equals(vehicleId)) {
return Optional.of(queue);
}
}
return Optional.empty();
}
public void addEvent(String event) {
if (event == null || event.trim().isEmpty()) {
throw new IllegalArgumentException("event must not be blank");
}
events.add(event);
}
public List<String> getEvents() {
return Collections.unmodifiableList(events);
}
public int nextCycle() {
cycleNumber++;
return cycleNumber;
}
public int getCycleNumber() {
return cycleNumber;
}
}

View File

@@ -0,0 +1,62 @@
package com.ft546.wcs.demo.simulation;
import com.ft546.wcs.demo.model.DispatchQueueEntry;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public final class SimulatedProtocolAdapter {
private final List<String> attemptedQueueIds = new ArrayList<String>();
private String nextFailure;
public ProtocolResult send(DispatchQueueEntry queue, List<String> path) {
if (queue == null || path == null || path.isEmpty()) {
throw new IllegalArgumentException("queue and non-empty path are required");
}
attemptedQueueIds.add(queue.getQueueId());
if (nextFailure != null) {
String failure = nextFailure;
nextFailure = null;
return ProtocolResult.failure(failure);
}
return ProtocolResult.success();
}
public void failNextSend(String reason) {
if (reason == null || reason.trim().isEmpty()) {
throw new IllegalArgumentException("failure reason must not be blank");
}
nextFailure = reason;
}
public List<String> getAttemptedQueueIds() {
return Collections.unmodifiableList(attemptedQueueIds);
}
public static final class ProtocolResult {
private final boolean success;
private final String message;
private ProtocolResult(boolean success, String message) {
this.success = success;
this.message = message;
}
public static ProtocolResult success() {
return new ProtocolResult(true, "accepted");
}
public static ProtocolResult failure(String message) {
return new ProtocolResult(false, message);
}
public boolean isSuccess() {
return success;
}
public String getMessage() {
return message;
}
}
}

View File

@@ -0,0 +1,178 @@
package com.ft546.wcs.demo;
import com.ft546.wcs.demo.center.CycleResult;
import com.ft546.wcs.demo.center.LegacyDispatchCenter;
import com.ft546.wcs.demo.center.QueueAdmissionService;
import com.ft546.wcs.demo.center.VehicleAssignmentService;
import com.ft546.wcs.demo.model.DispatchQueueEntry;
import com.ft546.wcs.demo.model.DispatchTask;
import com.ft546.wcs.demo.model.Site;
import com.ft546.wcs.demo.model.VehicleSnapshot;
import com.ft546.wcs.demo.repository.InMemoryWcsState;
import com.ft546.wcs.demo.simulation.SimulatedProtocolAdapter;
import com.ft546.wcs.demo.traffic.GridMap;
import com.ft546.wcs.demo.traffic.GridPathPlanner;
import com.ft546.wcs.demo.traffic.TrafficController;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
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 LegacyDispatchCenterTest {
@Test
void eachCycleAdmitsOnceAndProcessesVehiclesInStableOrder() {
Fixture fixture = fixtureWithThreeVehicles();
CycleResult result = fixture.center.runCycle();
assertEquals(1, fixture.admission.getInvocationCount());
assertEquals(Arrays.asList("1", "2", "3"), result.getProcessedVehicleIds());
assertEquals(1, fixture.state.getActiveQueues().size());
assertTrue(fixture.state.getActiveQueues().get(0).isIssued());
}
@Test
void failedProtocolSendRemainsPendingAndRetriesNextCycle() {
Fixture fixture = fixtureWithOneVehicle();
fixture.protocol.failNextSend("simulated PLC rejection");
fixture.center.runCycle();
DispatchQueueEntry pending = fixture.state.getActiveQueues().get(0);
assertEquals(0, pending.getIssued());
assertEquals(1, pending.getExecutionStatus());
assertTrue(fixture.state.getEvents().toString().contains("HOLD_PROTOCOL_FAILURE"));
fixture.center.runCycle();
assertEquals(1, pending.getIssued());
assertEquals(3, pending.getExecutionStatus());
assertEquals(2, fixture.protocol.getAttemptedQueueIds().size());
}
@Test
void vehicleAtDestinationArchivesAndDeletesQueueExactlyOnce() {
GridMap map = simpleMap();
InMemoryWcsState state = new InMemoryWcsState();
state.addVehicle(vehicle("1", "TARGET"));
DispatchQueueEntry queue = DispatchQueueEntry.business(task(), "1");
queue.markIssued(Arrays.asList("A", "TARGET"));
state.addQueue(queue);
Fixture fixture = fixture(map, state);
fixture.center.runCycle();
fixture.center.runCycle();
assertTrue(fixture.state.getActiveQueues().isEmpty());
assertEquals(1, fixture.state.getHistory().size());
assertEquals(4, fixture.state.getHistory().get(0).getExecutionStatus());
}
@Test
void channelConflictAddsAvoidanceQueueForBlockingVehicle() {
GridMap map = simpleMap();
map.setCacheSite("TARGET-CHANNEL", "CACHE");
InMemoryWcsState state = new InMemoryWcsState();
state.addVehicle(vehicle("1", "A"));
state.addVehicle(vehicle("2", "BLOCKER"));
state.addTask(task());
Fixture fixture = fixture(map, state);
fixture.center.runCycle();
assertEquals(2, fixture.state.getActiveQueues().size());
assertTrue(fixture.state.findQueueByVehicle("2").isPresent());
assertEquals(
DispatchQueueEntry.QueueType.AVOIDANCE,
fixture.state.findQueueByVehicle("2").get().getQueueType()
);
assertTrue(fixture.state.getEvents().toString().contains("AVOID"));
}
private Fixture fixtureWithThreeVehicles() {
GridMap map = simpleMap();
InMemoryWcsState state = new InMemoryWcsState();
state.addVehicle(vehicle("3", "V3"));
state.addVehicle(vehicle("2", "V2"));
state.addVehicle(vehicle("1", "A"));
state.addTask(task());
return fixture(map, state);
}
private Fixture fixtureWithOneVehicle() {
GridMap map = simpleMap();
InMemoryWcsState state = new InMemoryWcsState();
state.addVehicle(vehicle("1", "A"));
state.addTask(task());
return fixture(map, state);
}
private Fixture fixture(GridMap map, InMemoryWcsState state) {
QueueAdmissionService admission = new QueueAdmissionService(
new VehicleAssignmentService(),
map
);
SimulatedProtocolAdapter protocol = new SimulatedProtocolAdapter();
LegacyDispatchCenter center = new LegacyDispatchCenter(
state,
admission,
new TrafficController(map, new GridPathPlanner()),
protocol
);
return new Fixture(state, admission, protocol, center);
}
private GridMap simpleMap() {
GridMap map = new GridMap();
map.addSite(new Site("A", 1, 0, 0, "START"));
map.addSite(new Site("V2", 1, 0, 1, "V2-CHANNEL"));
map.addSite(new Site("V3", 1, 0, 2, "V3-CHANNEL"));
map.addSite(new Site("BLOCKER", 1, 1, 0, "TARGET-CHANNEL"));
map.addSite(new Site("TARGET", 1, 2, 0, "TARGET-CHANNEL"));
map.addSite(new Site("CACHE", 1, 1, 1, "CACHE"));
map.connect("A", "BLOCKER");
map.connect("BLOCKER", "TARGET");
map.connect("BLOCKER", "CACHE");
map.connect("V2", "A");
map.connect("V3", "V2");
return map;
}
private DispatchTask task() {
return new DispatchTask(
"TASK-1",
DispatchTask.TaskType.OUTBOUND,
"A",
"TARGET",
1,
2
);
}
private VehicleSnapshot vehicle(String id, String site) {
return new VehicleSnapshot(id, 1, site, 5, 1, 80, true, true, false);
}
private static final class Fixture {
private final InMemoryWcsState state;
private final QueueAdmissionService admission;
private final SimulatedProtocolAdapter protocol;
private final LegacyDispatchCenter center;
private Fixture(
InMemoryWcsState state,
QueueAdmissionService admission,
SimulatedProtocolAdapter protocol,
LegacyDispatchCenter center
) {
this.state = state;
this.admission = admission;
this.protocol = protocol;
this.center = center;
}
}
}