feat(ft546): run standard WCS as the single scheduler

This commit is contained in:
zgw
2026-08-11 16:57:55 +08:00
parent f53b250336
commit c80acf0d99
18 changed files with 737 additions and 4 deletions

View File

@@ -0,0 +1,40 @@
package com.ruoyi.project.ft546.runtime;
import com.ruoyi.wcs.application.WcsRuntimeCoordinator;
import com.ruoyi.wcs.application.device.CommandOutboxDispatcher;
import com.ruoyi.wcs.application.device.DeviceExecutionService;
import com.ruoyi.wcs.application.device.SnapshotCollector;
import com.ruoyi.wcs.application.dispatch.DispatchCoordinator;
import com.ruoyi.wcs.application.recovery.RecoveryGate;
import com.ruoyi.wcs.domain.shared.WcsClock;
import com.ruoyi.wcs.port.out.DispatchSnapshotProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@ConditionalOnProperty(prefix = "ft546.wcs", name = "runtime-mode",
havingValue = "STANDARD")
public class Ft546AdapterConfiguration {
private static final int OUTBOX_BATCH_LIMIT = 100;
@Bean
public WcsRuntimeCoordinator ft546WcsRuntimeCoordinator(
SnapshotCollector snapshotCollector,
RecoveryGate recoveryGate,
DispatchSnapshotProvider snapshotProvider,
DispatchCoordinator dispatchCoordinator,
DeviceExecutionService executionService,
CommandOutboxDispatcher outboxDispatcher,
WcsClock clock) {
return new WcsRuntimeCoordinator(snapshotCollector, recoveryGate,
snapshotProvider, dispatchCoordinator, executionService,
outboxDispatcher, clock, OUTBOX_BATCH_LIMIT);
}
@Bean
public Ft546WcsScheduler ft546WcsScheduler(
WcsRuntimeCoordinator coordinator) {
return new Ft546WcsScheduler(coordinator);
}
}

View File

@@ -0,0 +1,52 @@
package com.ruoyi.project.ft546.runtime;
import com.ruoyi.wcs.application.RuntimeTickResult;
import com.ruoyi.wcs.application.WcsRuntimeCoordinator;
import org.springframework.scheduling.annotation.Scheduled;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Supplier;
public final class Ft546WcsScheduler {
private final Supplier<RuntimeTickResult> tick;
private final AtomicBoolean tickInProgress = new AtomicBoolean();
private final AtomicLong completedTickCount = new AtomicLong();
private volatile RuntimeTickResult lastResult;
public Ft546WcsScheduler(WcsRuntimeCoordinator coordinator) {
this(Objects.requireNonNull(coordinator, "coordinator")::tick);
}
Ft546WcsScheduler(Supplier<RuntimeTickResult> tick) {
this.tick = Objects.requireNonNull(tick, "tick");
}
@Scheduled(fixedDelayString =
"${ft546.wcs.dispatch-interval-ms:1000}")
public void runScheduledTick() {
if (!tickInProgress.compareAndSet(false, true)) {
return;
}
try {
lastResult = Objects.requireNonNull(tick.get(),
"runtime coordinator returned null");
completedTickCount.incrementAndGet();
} finally {
tickInProgress.set(false);
}
}
public long getCompletedTickCount() {
return completedTickCount.get();
}
public RuntimeTickResult getLastResult() {
return lastResult;
}
public boolean isTickInProgress() {
return tickInProgress.get();
}
}

View File

@@ -14,6 +14,7 @@ import com.ruoyi.device.tsj.domain.DeviceTsj;
import com.ruoyi.device.tsj.service.IDeviceTsjService; import com.ruoyi.device.tsj.service.IDeviceTsjService;
import com.ruoyi.project.ft546.device.Ft546DeviceRuntimeRegistry; import com.ruoyi.project.ft546.device.Ft546DeviceRuntimeRegistry;
import com.ruoyi.project.ft546.device.shuttle.Ft546SrgvRuntimeFactory; import com.ruoyi.project.ft546.device.shuttle.Ft546SrgvRuntimeFactory;
import com.ruoyi.project.ft546.config.Ft546WcsProperties;
import com.ruoyi.schedule.plcRead.cdz.PlcCdzRead; import com.ruoyi.schedule.plcRead.cdz.PlcCdzRead;
import com.ruoyi.schedule.plcRead.ddj.PlcDdjRead; import com.ruoyi.schedule.plcRead.ddj.PlcDdjRead;
import com.ruoyi.schedule.plcRead.srgv.PlcSrgvRead; import com.ruoyi.schedule.plcRead.srgv.PlcSrgvRead;
@@ -27,6 +28,8 @@ import com.ruoyi.schedule.scheduleTask.ssj.SsjThread;
import com.ruoyi.schedule.scheduleTask.tsj.TsjThread; import com.ruoyi.schedule.scheduleTask.tsj.TsjThread;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct; import javax.annotation.PostConstruct;
@@ -66,6 +69,8 @@ public class ScheduleThread {
private Ft546DeviceRuntimeRegistry deviceRuntimeRegistry; private Ft546DeviceRuntimeRegistry deviceRuntimeRegistry;
@Autowired @Autowired
private Ft546SrgvRuntimeFactory srgvRuntimeFactory; private Ft546SrgvRuntimeFactory srgvRuntimeFactory;
@Autowired
private Ft546WcsProperties ft546WcsProperties;
private volatile boolean isRun = false; private volatile boolean isRun = false;
private volatile boolean isRun2 = false; private volatile boolean isRun2 = false;
@@ -90,10 +95,20 @@ public class ScheduleThread {
} }
@EventListener(ApplicationReadyEvent.class)
public void startStandardFactReadersAfterApplicationReady() {
if (isStandardMode()) {
startAllThread();
}
}
/** /**
* 调度初始化 * 调度初始化
*/ */
private void scheduleInit() { private void scheduleInit() {
if (isStandardMode()) {
return;
}
MonitoringThread monitoringThread = new MonitoringThread(); MonitoringThread monitoringThread = new MonitoringThread();
monitoringCustomThread = new CustomThread<MonitoringThread>(new Thread(monitoringThread), monitoringThread); monitoringCustomThread = new CustomThread<MonitoringThread>(new Thread(monitoringThread), monitoringThread);
SsjThread ssjThread=new SsjThread(); SsjThread ssjThread=new SsjThread();
@@ -196,6 +211,10 @@ public class ScheduleThread {
* 开启线程2 * 开启线程2
*/ */
public void startAllThread2() { public void startAllThread2() {
if (isStandardMode()) {
throw new IllegalStateException(
"legacy business scheduler is disabled in STANDARD mode");
}
monitoringCustomThread.thread.start(); monitoringCustomThread.thread.start();
ssjThreadCustomThread.thread.start(); ssjThreadCustomThread.thread.start();
tsjThreadCustomThread.thread.start(); tsjThreadCustomThread.thread.start();
@@ -322,6 +341,10 @@ public class ScheduleThread {
* 停止线程2 * 停止线程2
*/ */
public void stopAllThread2() { public void stopAllThread2() {
if (isStandardMode()) {
isRun2 = false;
return;
}
monitoringCustomThread.runnable.isRun = false; monitoringCustomThread.runnable.isRun = false;
// ddj1CustomThread.runnable.isRun = false; // ddj1CustomThread.runnable.isRun = false;
/*ddj2CustomThread.runnable.isRun = false; /*ddj2CustomThread.runnable.isRun = false;
@@ -342,4 +365,10 @@ public class ScheduleThread {
map.put("isRun2",isRun2); map.put("isRun2",isRun2);
return map; return map;
} }
private boolean isStandardMode() {
return ft546WcsProperties != null
&& ft546WcsProperties.getRuntimeMode()
== Ft546WcsProperties.RuntimeMode.STANDARD;
}
} }

View File

@@ -27,6 +27,7 @@ import com.ruoyi.job.service.impl.JobCallBackServiceImpl;
import com.ruoyi.queue.srgv.domain.WcsSrgvQueue; import com.ruoyi.queue.srgv.domain.WcsSrgvQueue;
import com.ruoyi.queue.srgv.service.IWcsSrgvQueueFinishService; import com.ruoyi.queue.srgv.service.IWcsSrgvQueueFinishService;
import com.ruoyi.queue.srgv.service.IWcsSrgvQueueService; import com.ruoyi.queue.srgv.service.IWcsSrgvQueueService;
import com.ruoyi.project.ft546.config.Ft546WcsProperties;
import com.ruoyi.project.ft546.device.Ft546DeviceRuntimeRegistry; import com.ruoyi.project.ft546.device.Ft546DeviceRuntimeRegistry;
import com.ruoyi.schedule.plcRead.srgv.PlcSrgvRead; import com.ruoyi.schedule.plcRead.srgv.PlcSrgvRead;
import com.ruoyi.schedule.plcRead.srgv.roadUtil.PathFinder; import com.ruoyi.schedule.plcRead.srgv.roadUtil.PathFinder;
@@ -94,6 +95,10 @@ public class BaseSRgvThread implements Runnable, SRgvThreadInterface {
@Override @Override
public void run() { public void run() {
if (isStandardRuntime()) {
log.warn("STANDARD mode owns scheduling; legacy SRGV thread will not run");
return;
}
init(); init();
while (isRun) { while (isRun) {
@@ -136,6 +141,13 @@ public class BaseSRgvThread implements Runnable, SRgvThreadInterface {
} }
} }
private boolean isStandardRuntime() {
Ft546WcsProperties properties =
SpringUtils.getBean(Ft546WcsProperties.class);
return properties.getRuntimeMode()
== Ft546WcsProperties.RuntimeMode.STANDARD;
}
/** /**
* 离开电桩 * 离开电桩
* *

View File

@@ -0,0 +1,175 @@
package com.ruoyi.project.ft546.runtime;
import com.ruoyi.project.ft546.config.Ft546WcsProperties;
import com.ruoyi.common.utils.spring.SpringUtils;
import com.ruoyi.schedule.ScheduleThread;
import com.ruoyi.schedule.scheduleTask.srgv.BaseSRgvThread;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class Ft546StandardRuntimeTest {
@Test
void standardModeDoesNotConstructLegacyBusinessThreads() {
ScheduleThread runtime = runtime(Ft546WcsProperties.RuntimeMode.STANDARD);
ReflectionTestUtils.invokeMethod(runtime, "scheduleInit");
assertNull(runtime.ssjThreadCustomThread);
assertNull(runtime.sRgvThreadCustomThread);
assertNull(runtime.tsjThreadCustomThread);
assertNull(runtime.cdzThreadCustomThread);
}
@Test
void legacyModeKeepsExistingBusinessThreadInitialization() {
ScheduleThread runtime = runtime(Ft546WcsProperties.RuntimeMode.LEGACY);
ReflectionTestUtils.invokeMethod(runtime, "scheduleInit");
assertNotNull(runtime.ssjThreadCustomThread);
assertNotNull(runtime.sRgvThreadCustomThread);
assertNotNull(runtime.tsjThreadCustomThread);
assertNotNull(runtime.cdzThreadCustomThread);
}
@Test
void standardModeRejectsManualLegacySchedulerStart() {
ScheduleThread runtime = runtime(Ft546WcsProperties.RuntimeMode.STANDARD);
IllegalStateException failure = assertThrows(IllegalStateException.class,
runtime::startAllThread2);
assertEquals("legacy business scheduler is disabled in STANDARD mode",
failure.getMessage());
}
@Test
void baseSrgvThreadRefusesToRunInStandardMode() {
Object originalFactory = ReflectionTestUtils.getField(
SpringUtils.class, "beanFactory");
DefaultListableBeanFactory factory =
new DefaultListableBeanFactory();
Ft546WcsProperties properties = new Ft546WcsProperties();
properties.setRuntimeMode(Ft546WcsProperties.RuntimeMode.STANDARD);
factory.registerSingleton("ft546WcsProperties", properties);
ReflectionTestUtils.setField(SpringUtils.class, "beanFactory",
factory);
AtomicBoolean initialized = new AtomicBoolean();
BaseSRgvThread legacyThread = new BaseSRgvThread() {
@Override
public void init() {
initialized.set(true);
isRun = false;
}
};
try {
legacyThread.run();
} finally {
ReflectionTestUtils.setField(SpringUtils.class, "beanFactory",
originalFactory);
}
assertFalse(initialized.get());
}
@Test
void runtimeModeDefaultsToLegacy() {
assertEquals(Ft546WcsProperties.RuntimeMode.LEGACY,
new Ft546WcsProperties().getRuntimeMode());
}
@Test
void standardSchedulerOwnsTheCoreTick() {
AtomicInteger ticks = new AtomicInteger();
Ft546WcsScheduler scheduler = new Ft546WcsScheduler(() -> {
ticks.incrementAndGet();
return com.ruoyi.wcs.application.RuntimeTickResult
.recoveryBlocked();
});
scheduler.runScheduledTick();
assertEquals(1, ticks.get());
assertEquals(1L, scheduler.getCompletedTickCount());
}
@Test
void legacyModeDoesNotRequireStandardRuntimePorts() {
new ApplicationContextRunner()
.withUserConfiguration(Ft546AdapterConfiguration.class)
.withPropertyValues("ft546.wcs.runtime-mode=LEGACY")
.run(context -> {
assertNull(context.getStartupFailure());
assertTrue(context.getBeansOfType(
Ft546WcsScheduler.class).isEmpty());
});
}
@Test
void standardModeFailsFastWhenPersistentRuntimePortsAreMissing() {
new ApplicationContextRunner()
.withUserConfiguration(Ft546AdapterConfiguration.class)
.withPropertyValues("ft546.wcs.runtime-mode=STANDARD")
.run(context -> assertNotNull(context.getStartupFailure()));
}
@Test
void standardFactReadersStartOnlyAfterApplicationIsReady() {
AtomicInteger starts = new AtomicInteger();
ScheduleThread runtime = new ScheduleThread() {
@Override
public synchronized void startAllThread() {
starts.incrementAndGet();
}
};
Ft546WcsProperties properties = new Ft546WcsProperties();
properties.setRuntimeMode(Ft546WcsProperties.RuntimeMode.STANDARD);
ReflectionTestUtils.setField(runtime, "ft546WcsProperties",
properties);
runtime.startStandardFactReadersAfterApplicationReady();
assertEquals(1, starts.get());
}
@Test
void legacyFactReadersRemainOperatorControlledAfterApplicationReady() {
AtomicInteger starts = new AtomicInteger();
ScheduleThread runtime = new ScheduleThread() {
@Override
public synchronized void startAllThread() {
starts.incrementAndGet();
}
};
ReflectionTestUtils.setField(runtime, "ft546WcsProperties",
new Ft546WcsProperties());
runtime.startStandardFactReadersAfterApplicationReady();
assertEquals(0, starts.get());
}
private static ScheduleThread runtime(
Ft546WcsProperties.RuntimeMode mode) {
Ft546WcsProperties properties = new Ft546WcsProperties();
properties.setRuntimeMode(mode);
ScheduleThread runtime = new ScheduleThread();
ReflectionTestUtils.setField(runtime, "ft546WcsProperties",
properties);
return runtime;
}
}

View File

@@ -0,0 +1,57 @@
package com.ruoyi.wcs.application;
import com.ruoyi.wcs.application.device.OutboxDispatchResult;
import com.ruoyi.wcs.application.dispatch.DispatchCycleResult;
public final class RuntimeTickResult {
public enum Status {
COMPLETED,
RECOVERY_BLOCKED
}
private final Status status;
private final DispatchCycleResult dispatchResult;
private final OutboxDispatchResult outboxResult;
private final int createdCommandCount;
private RuntimeTickResult(Status status,
DispatchCycleResult dispatchResult,
OutboxDispatchResult outboxResult,
int createdCommandCount) {
this.status = status;
this.dispatchResult = dispatchResult;
this.outboxResult = outboxResult;
this.createdCommandCount = createdCommandCount;
}
public static RuntimeTickResult completed(
DispatchCycleResult dispatchResult,
OutboxDispatchResult outboxResult, int createdCommandCount) {
if (dispatchResult == null || outboxResult == null
|| createdCommandCount < 0) {
throw new IllegalArgumentException("completed tick result is invalid");
}
return new RuntimeTickResult(Status.COMPLETED, dispatchResult,
outboxResult, createdCommandCount);
}
public static RuntimeTickResult recoveryBlocked() {
return new RuntimeTickResult(Status.RECOVERY_BLOCKED, null, null, 0);
}
public Status getStatus() {
return status;
}
public DispatchCycleResult getDispatchResult() {
return dispatchResult;
}
public OutboxDispatchResult getOutboxResult() {
return outboxResult;
}
public int getCreatedCommandCount() {
return createdCommandCount;
}
}

View File

@@ -0,0 +1,96 @@
package com.ruoyi.wcs.application;
import com.ruoyi.wcs.application.device.CommandOutboxDispatcher;
import com.ruoyi.wcs.application.device.DeviceExecutionService;
import com.ruoyi.wcs.application.device.OutboxDispatchResult;
import com.ruoyi.wcs.application.device.SnapshotCollector;
import com.ruoyi.wcs.application.dispatch.DispatchCoordinator;
import com.ruoyi.wcs.application.dispatch.DispatchCycleResult;
import com.ruoyi.wcs.application.dispatch.DispatchSnapshot;
import com.ruoyi.wcs.application.dispatch.GrantedDispatch;
import com.ruoyi.wcs.application.recovery.RecoveryGate;
import com.ruoyi.wcs.domain.shared.WcsClock;
import com.ruoyi.wcs.port.out.DispatchSnapshotProvider;
import java.time.Instant;
import java.util.Objects;
import java.util.function.BiFunction;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
public final class WcsRuntimeCoordinator {
private final SnapshotCollector snapshotCollector;
private final RecoveryGate recoveryGate;
private final Supplier<DispatchSnapshot> snapshotLoader;
private final Function<DispatchSnapshot, DispatchCycleResult> dispatcher;
private final Consumer<GrantedDispatch> commandCreator;
private final BiFunction<Integer, Instant, OutboxDispatchResult> outboxSender;
private final WcsClock clock;
private final int outboxBatchLimit;
public WcsRuntimeCoordinator(SnapshotCollector snapshotCollector,
RecoveryGate recoveryGate,
DispatchSnapshotProvider snapshotProvider,
DispatchCoordinator dispatchCoordinator,
DeviceExecutionService executionService,
CommandOutboxDispatcher outboxDispatcher,
WcsClock clock, int outboxBatchLimit) {
this(snapshotCollector, recoveryGate,
Objects.requireNonNull(snapshotProvider,
"snapshotProvider")::load,
Objects.requireNonNull(dispatchCoordinator,
"dispatchCoordinator")::dispatch,
grant -> Objects.requireNonNull(executionService,
"executionService").createCommand(grant),
(limit, now) -> Objects.requireNonNull(outboxDispatcher,
"outboxDispatcher").dispatchDue(limit, now),
clock, outboxBatchLimit);
}
WcsRuntimeCoordinator(SnapshotCollector snapshotCollector,
RecoveryGate recoveryGate,
Supplier<DispatchSnapshot> snapshotLoader,
Function<DispatchSnapshot, DispatchCycleResult> dispatcher,
Consumer<GrantedDispatch> commandCreator,
BiFunction<Integer, Instant, OutboxDispatchResult> outboxSender,
WcsClock clock, int outboxBatchLimit) {
this.snapshotCollector = Objects.requireNonNull(snapshotCollector,
"snapshotCollector");
this.recoveryGate = Objects.requireNonNull(recoveryGate,
"recoveryGate");
this.snapshotLoader = Objects.requireNonNull(snapshotLoader,
"snapshotLoader");
this.dispatcher = Objects.requireNonNull(dispatcher, "dispatcher");
this.commandCreator = Objects.requireNonNull(commandCreator,
"commandCreator");
this.outboxSender = Objects.requireNonNull(outboxSender,
"outboxSender");
this.clock = Objects.requireNonNull(clock, "clock");
if (outboxBatchLimit <= 0) {
throw new IllegalArgumentException(
"outboxBatchLimit must be positive");
}
this.outboxBatchLimit = outboxBatchLimit;
}
public RuntimeTickResult tick() {
snapshotCollector.collectAndAccept();
if (!recoveryGate.allowsDispatch()) {
return RuntimeTickResult.recoveryBlocked();
}
DispatchCycleResult dispatch = Objects.requireNonNull(
dispatcher.apply(Objects.requireNonNull(snapshotLoader.get(),
"snapshot provider returned null")),
"dispatch coordinator returned null");
int createdCommands = 0;
for (GrantedDispatch grant : dispatch.getGranted()) {
commandCreator.accept(grant);
createdCommands++;
}
OutboxDispatchResult outbox = Objects.requireNonNull(
outboxSender.apply(outboxBatchLimit, clock.now()),
"outbox dispatcher returned null");
return RuntimeTickResult.completed(dispatch, outbox, createdCommands);
}
}

View File

@@ -11,7 +11,9 @@ import com.ruoyi.wcs.port.out.DeviceCommandRepository;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.Instant; import java.time.Instant;
import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.TreeMap;
import java.util.function.Supplier; import java.util.function.Supplier;
public final class DeviceExecutionService { public final class DeviceExecutionService {
@@ -69,10 +71,24 @@ public final class DeviceExecutionService {
} }
route.append(requireSafe(node, "route node")); route.append(requireSafe(node, "route node"));
} }
return "orderId=" + requireSafe(grant.getOrderId(), "orderId") StringBuilder payload = new StringBuilder(
"orderId=" + requireSafe(grant.getOrderId(), "orderId")
+ "|stepId=" + requireSafe(grant.getStepId(), "stepId") + "|stepId=" + requireSafe(grant.getStepId(), "stepId")
+ "|vehicleId=" + requireSafe(grant.getVehicleId(), "vehicleId") + "|vehicleId=" + requireSafe(grant.getVehicleId(), "vehicleId")
+ "|route=" + route; + "|route=" + route);
for (Map.Entry<String, String> entry : new TreeMap<String, String>(
grant.getCommandParameters()).entrySet()) {
String key = requireSafe(entry.getKey(), "command parameter key");
if ("orderId".equals(key) || "stepId".equals(key)
|| "vehicleId".equals(key) || "route".equals(key)) {
throw new IllegalArgumentException(
"command parameter overrides reserved field " + key);
}
payload.append('|').append(key).append('=')
.append(requireSafe(entry.getValue(),
"command parameter " + key));
}
return payload.toString();
} }
private static String requireSafe(String value, String field) { private static String requireSafe(String value, String field) {

View File

@@ -133,7 +133,9 @@ public final class FeedbackReconciler
if (!execution.getTaskNumber().equals(snapshot.getTaskNumber())) { if (!execution.getTaskNumber().equals(snapshot.getTaskNumber())) {
return recovery(snapshot, execution, "TASK_NUMBER_MISMATCH"); return recovery(snapshot, execution, "TASK_NUMBER_MISMATCH");
} }
if (!execution.getExpectedBootId().equals(snapshot.getDeviceBootId())) { if (snapshot.hasDeviceBootSession()
&& !execution.getExpectedBootId().equals(
snapshot.getDeviceBootId())) {
return recovery(snapshot, execution, "DEVICE_RESTARTED"); return recovery(snapshot, execution, "DEVICE_RESTARTED");
} }
if (execution.getTopologyVersion() != snapshot.getTopologyVersion()) { if (execution.getTopologyVersion() != snapshot.getTopologyVersion()) {
@@ -142,6 +144,11 @@ public final class FeedbackReconciler
if (execution.getFencingToken() != snapshot.getFencingToken()) { if (execution.getFencingToken() != snapshot.getFencingToken()) {
return recovery(snapshot, execution, "FENCING_TOKEN_MISMATCH"); return recovery(snapshot, execution, "FENCING_TOKEN_MISMATCH");
} }
if (!snapshot.hasOrderedDeviceSequence()) {
return new ReconcileResult(ReconcileStatus.CONSISTENT,
execution.getCommandKey(), Collections.<String>emptyList(),
"LEGACY_UNORDERED_DIAGNOSTIC_ONLY");
}
if (snapshot.getDeviceSequence() <= execution.getLastDeviceSequence()) { if (snapshot.getDeviceSequence() <= execution.getLastDeviceSequence()) {
return new ReconcileResult(ReconcileStatus.IGNORED_STALE, return new ReconcileResult(ReconcileStatus.IGNORED_STALE,
execution.getCommandKey(), Collections.<String>emptyList(), null); execution.getCommandKey(), Collections.<String>emptyList(), null);

View File

@@ -0,0 +1,6 @@
package com.ruoyi.wcs.application.device;
@FunctionalInterface
public interface SnapshotCollector {
void collectAndAccept();
}

View File

@@ -114,7 +114,9 @@ public final class DispatchCoordinator {
granted.add(new GrantedDispatch(task.getOrderId(), task.getStepId(), granted.add(new GrantedDispatch(task.getOrderId(), task.getStepId(),
assignment.getVehicleId(), assignment.getRoute(), assignment.getVehicleId(), assignment.getRoute(),
Collections.singletonList(reservation.getReservationId()), Collections.singletonList(reservation.getReservationId()),
snapshot.getTopology().version(), reservation.getFencingToken())); snapshot.getTopology().version(),
reservation.getFencingToken(),
task.getCommandParameters()));
} else { } else {
HeldDispatch blocked = new HeldDispatch(task.getOrderId(), HeldDispatch blocked = new HeldDispatch(task.getOrderId(),
task.getStepId(), assignment.getVehicleId(), traffic.getType(), task.getStepId(), assignment.getVehicleId(), traffic.getType(),

View File

@@ -4,7 +4,10 @@ import com.ruoyi.wcs.domain.traffic.RoutePlan;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Objects;
public final class GrantedDispatch { public final class GrantedDispatch {
private final String orderId; private final String orderId;
@@ -14,10 +17,20 @@ public final class GrantedDispatch {
private final List<String> reservationIds; private final List<String> reservationIds;
private final long topologyVersion; private final long topologyVersion;
private final long fencingToken; private final long fencingToken;
private final Map<String, String> commandParameters;
public GrantedDispatch(String orderId, String stepId, String vehicleId, public GrantedDispatch(String orderId, String stepId, String vehicleId,
RoutePlan protectedRoute, List<String> reservationIds, RoutePlan protectedRoute, List<String> reservationIds,
long topologyVersion, long fencingToken) { long topologyVersion, long fencingToken) {
this(orderId, stepId, vehicleId, protectedRoute, reservationIds,
topologyVersion, fencingToken,
Collections.<String, String>emptyMap());
}
public GrantedDispatch(String orderId, String stepId, String vehicleId,
RoutePlan protectedRoute, List<String> reservationIds,
long topologyVersion, long fencingToken,
Map<String, String> commandParameters) {
this.orderId = orderId; this.orderId = orderId;
this.stepId = stepId; this.stepId = stepId;
this.vehicleId = vehicleId; this.vehicleId = vehicleId;
@@ -26,6 +39,9 @@ public final class GrantedDispatch {
new ArrayList<String>(reservationIds)); new ArrayList<String>(reservationIds));
this.topologyVersion = topologyVersion; this.topologyVersion = topologyVersion;
this.fencingToken = fencingToken; this.fencingToken = fencingToken;
this.commandParameters = Collections.unmodifiableMap(
new LinkedHashMap<String, String>(Objects.requireNonNull(
commandParameters, "commandParameters")));
} }
public String getOrderId() { public String getOrderId() {
@@ -55,4 +71,8 @@ public final class GrantedDispatch {
public long getFencingToken() { public long getFencingToken() {
return fencingToken; return fencingToken;
} }
public Map<String, String> getCommandParameters() {
return commandParameters;
}
} }

View File

@@ -0,0 +1,6 @@
package com.ruoyi.wcs.application.recovery;
@FunctionalInterface
public interface RecoveryGate {
boolean allowsDispatch();
}

View File

@@ -4,6 +4,8 @@ import com.ruoyi.wcs.domain.equipment.DeviceCapability;
import java.util.Collections; import java.util.Collections;
import java.util.EnumSet; import java.util.EnumSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
@@ -16,11 +18,22 @@ public final class DispatchTask {
private final long waitingAgeSeconds; private final long waitingAgeSeconds;
private final int minimumBattery; private final int minimumBattery;
private final Set<DeviceCapability> requiredCapabilities; private final Set<DeviceCapability> requiredCapabilities;
private final Map<String, String> commandParameters;
public DispatchTask(String orderId, String stepId, public DispatchTask(String orderId, String stepId,
String sourceNodeId, String destinationNodeId, String sourceNodeId, String destinationNodeId,
int priority, long waitingAgeSeconds, int minimumBattery, int priority, long waitingAgeSeconds, int minimumBattery,
Set<DeviceCapability> requiredCapabilities) { Set<DeviceCapability> requiredCapabilities) {
this(orderId, stepId, sourceNodeId, destinationNodeId, priority,
waitingAgeSeconds, minimumBattery, requiredCapabilities,
Collections.<String, String>emptyMap());
}
public DispatchTask(String orderId, String stepId,
String sourceNodeId, String destinationNodeId,
int priority, long waitingAgeSeconds, int minimumBattery,
Set<DeviceCapability> requiredCapabilities,
Map<String, String> commandParameters) {
this.orderId = requireText(orderId, "orderId"); this.orderId = requireText(orderId, "orderId");
this.stepId = requireText(stepId, "stepId"); this.stepId = requireText(stepId, "stepId");
this.sourceNodeId = requireText(sourceNodeId, "sourceNodeId"); this.sourceNodeId = requireText(sourceNodeId, "sourceNodeId");
@@ -39,6 +52,7 @@ public final class DispatchTask {
? EnumSet.noneOf(DeviceCapability.class) ? EnumSet.noneOf(DeviceCapability.class)
: EnumSet.copyOf(requiredCapabilities); : EnumSet.copyOf(requiredCapabilities);
this.requiredCapabilities = Collections.unmodifiableSet(copied); this.requiredCapabilities = Collections.unmodifiableSet(copied);
this.commandParameters = immutableParameters(commandParameters);
} }
public String getOrderId() { public String getOrderId() {
@@ -73,6 +87,21 @@ public final class DispatchTask {
return requiredCapabilities; return requiredCapabilities;
} }
public Map<String, String> getCommandParameters() {
return commandParameters;
}
private static Map<String, String> immutableParameters(
Map<String, String> source) {
Objects.requireNonNull(source, "commandParameters");
Map<String, String> copy = new LinkedHashMap<String, String>();
for (Map.Entry<String, String> entry : source.entrySet()) {
copy.put(requireText(entry.getKey(), "command parameter key"),
requireText(entry.getValue(), "command parameter value"));
}
return Collections.unmodifiableMap(copy);
}
private static String requireText(String value, String field) { private static String requireText(String value, String field) {
if (value == null || value.trim().isEmpty()) { if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException(field + " must not be blank"); throw new IllegalArgumentException(field + " must not be blank");

View File

@@ -0,0 +1,125 @@
package com.ruoyi.wcs.application;
import com.ruoyi.wcs.application.device.OutboxDispatchResult;
import com.ruoyi.wcs.application.dispatch.DispatchCycleResult;
import com.ruoyi.wcs.application.dispatch.DispatchSnapshot;
import com.ruoyi.wcs.application.dispatch.GrantedDispatch;
import com.ruoyi.wcs.application.device.SnapshotCollector;
import com.ruoyi.wcs.application.recovery.RecoveryGate;
import com.ruoyi.wcs.domain.topology.EffectiveTopology;
import com.ruoyi.wcs.domain.topology.TopologyCompiler;
import com.ruoyi.wcs.domain.topology.TopologyDefinition;
import com.ruoyi.wcs.domain.topology.TopologyNode;
import com.ruoyi.wcs.domain.traffic.ResourceLedger;
import com.ruoyi.wcs.domain.traffic.RoutePlan;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
class WcsRuntimeCoordinatorTest {
@Test
void tickRunsSnapshotRecoveryDispatchCommandAndOutboxInOrder() {
List<String> calls = new ArrayList<String>();
SnapshotCollector snapshots = () -> calls.add("snapshot");
RecoveryGate recovery = () -> {
calls.add("recovery");
return true;
};
GrantedDispatch grant = grant();
DispatchCycleResult dispatchResult = DispatchCycleResult.committed(
Collections.singletonList(grant),
Collections.emptyList(), Collections.emptyList(),
Collections.emptyList());
OutboxDispatchResult outboxResult = new OutboxDispatchResult();
WcsRuntimeCoordinator coordinator = new WcsRuntimeCoordinator(
snapshots, recovery,
() -> {
calls.add("load");
return snapshot();
},
value -> {
calls.add("dispatch");
return dispatchResult;
},
value -> calls.add("command:" + value.getStepId()),
(limit, now) -> {
calls.add("outbox:" + limit);
return outboxResult;
},
() -> Instant.parse("2026-08-11T08:00:00Z"), 100);
RuntimeTickResult result = coordinator.tick();
assertEquals(Arrays.asList("snapshot", "recovery", "load",
"dispatch", "command:STEP-1", "outbox:100"), calls);
assertEquals(RuntimeTickResult.Status.COMPLETED, result.getStatus());
assertEquals(1, result.getCreatedCommandCount());
assertEquals(dispatchResult, result.getDispatchResult());
assertEquals(outboxResult, result.getOutboxResult());
}
@Test
void recoveryGateStopsBeforeDispatchAndOutbox() {
List<String> calls = new ArrayList<String>();
WcsRuntimeCoordinator coordinator = new WcsRuntimeCoordinator(
() -> calls.add("snapshot"),
() -> {
calls.add("recovery");
return false;
},
() -> {
calls.add("load");
return snapshot();
},
value -> {
calls.add("dispatch");
return DispatchCycleResult.retrySnapshotChanged();
},
value -> calls.add("command"),
(limit, now) -> {
calls.add("outbox");
return new OutboxDispatchResult();
},
Instant::now, 100);
RuntimeTickResult result = coordinator.tick();
assertEquals(Arrays.asList("snapshot", "recovery"), calls);
assertEquals(RuntimeTickResult.Status.RECOVERY_BLOCKED,
result.getStatus());
assertEquals(0, result.getCreatedCommandCount());
assertNull(result.getDispatchResult());
assertNull(result.getOutboxResult());
}
private static GrantedDispatch grant() {
RoutePlan route = new RoutePlan(Arrays.asList("A", "B"),
Arrays.asList("NODE:A", "EDGE:A:B", "NODE:B"),
1, 0, 7L);
return new GrantedDispatch("ORDER-1", "STEP-1", "VEH-1", route,
Collections.singletonList("RES-1"), 7L, 11L);
}
private static DispatchSnapshot snapshot() {
EffectiveTopology topology = new TopologyCompiler().compile(
new TopologyDefinition("FT546", 7L, true,
Arrays.asList(
new TopologyNode("A", "F1", 0, 0, 1,
false, false),
new TopologyNode("B", "F1", 1, 0, 1,
false, false)),
Collections.emptyList(), Collections.emptyList(),
Collections.emptyList(), Collections.emptyList()));
return new DispatchSnapshot(Collections.emptyList(),
Collections.emptyList(), topology,
new ResourceLedger(topology), 1L);
}
}

View File

@@ -68,6 +68,26 @@ class CommandOutboxDispatcherTest {
assertEquals(0, gateway.sendCount); assertEquals(0, gateway.sendCount);
} }
@Test
void explicitTaskNumberAndTypeReachTheDevicePayload() {
Map<String, String> commandParameters =
new LinkedHashMap<String, String>();
commandParameters.put("taskNo", "30001");
commandParameters.put("taskType", "1");
RoutePlan route = new RoutePlan(Arrays.asList("A", "B"),
Arrays.asList("NODE:A", "EDGE:A:B", "NODE:B"),
1, 0, 7L);
GrantedDispatch grant = new GrantedDispatch("ORDER-1", "STEP-1",
"VEH-1", route, Collections.singletonList("RES-1"), 7L,
11L, commandParameters);
DeviceCommand command = service.createCommand(grant);
assertEquals("orderId=ORDER-1|stepId=STEP-1|vehicleId=VEH-1"
+ "|route=A>B|taskNo=30001|taskType=1",
command.getPayload());
}
@Test @Test
void acceptedSendIsNotMarkedCompleted() { void acceptedSendIsNotMarkedCompleted() {
service.createCommand(grantedDispatch()); service.createCommand(grantedDispatch());

View File

@@ -83,6 +83,24 @@ class FeedbackReconcilerTest {
recovery.reasons); recovery.reasons);
} }
@Test
void unorderedSnapshotWithoutBootSessionIsDiagnosticOnly() {
DeviceSnapshot snapshot = DeviceSnapshot.unorderedWithoutBootSession(
"SNAP-LEGACY-1", "VEH-1", "CMD-KEY-1", "TASK-1",
at(5), true, 7L, 11L, "B", Arrays.asList("B", "C"),
false, Collections.<String, String>emptyMap());
ReconcileResult result = reconciler.reconcile(snapshot);
assertEquals(ReconcileStatus.CONSISTENT, result.getStatus());
assertEquals("LEGACY_UNORDERED_DIAGNOSTIC_ONLY",
result.getRecoveryReason());
assertTrue(recovery.reasons.isEmpty());
assertEquals(0, reservations.releaseCallCount);
assertEquals(-1L, executions.findByCommandKey("CMD-KEY-1")
.getLastDeviceSequence());
}
@Test @Test
void releasesOnlyResourcesStrictlyPassedByMatchingProgress() { void releasesOnlyResourcesStrictlyPassedByMatchingProgress() {
DeviceSnapshot snapshot = snapshot("TASK-1", "B", DeviceSnapshot snapshot = snapshot("TASK-1", "B",

View File

@@ -25,6 +25,8 @@ import java.time.Instant;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.EnumSet; import java.util.EnumSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -77,6 +79,27 @@ class DispatchCoordinatorTest {
assertEquals(1, repository.commitCount); assertEquals(1, repository.commitCount);
} }
@Test
void carriesExplicitDeviceCommandParametersIntoTheGrant() {
EffectiveTopology topology = topology();
Map<String, String> commandParameters =
new LinkedHashMap<String, String>();
commandParameters.put("taskNo", "30001");
commandParameters.put("taskType", "1");
DispatchTask task = new DispatchTask("O1", "S1", "A", "X", 1,
60L, 30, Collections.<DeviceCapability>emptySet(),
commandParameters);
DispatchCycleResult result = coordinator(
new RecordingTrafficRepository(true)).dispatch(
new DispatchSnapshot(Collections.singletonList(task),
Collections.singletonList(vehicle("V1", "A")),
topology, new ResourceLedger(topology), 11L));
assertEquals(commandParameters,
result.getGranted().get(0).getCommandParameters());
}
private static DispatchCoordinator coordinator(TrafficStateRepository repository) { private static DispatchCoordinator coordinator(TrafficStateRepository repository) {
AStarRoutePlanner routePlanner = new AStarRoutePlanner(); AStarRoutePlanner routePlanner = new AStarRoutePlanner();
AtomicInteger sequence = new AtomicInteger(); AtomicInteger sequence = new AtomicInteger();