test(ft546): verify standard WCS end to end

This commit is contained in:
zgw
2026-08-12 08:27:40 +08:00
parent ceba465675
commit f66585f717
31 changed files with 2560 additions and 16 deletions

View File

@@ -2,15 +2,31 @@ 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.CommandDeliveryRecorder;
import com.ruoyi.wcs.application.device.DeviceExecutionService;
import com.ruoyi.wcs.application.device.FeedbackReconciler;
import com.ruoyi.wcs.application.device.SnapshotCollector;
import com.ruoyi.wcs.application.device.WmsNotificationOutboxDispatcher;
import com.ruoyi.wcs.application.device.WmsNotificationOutboxProjector;
import com.ruoyi.wcs.application.dispatch.DispatchCoordinator;
import com.ruoyi.wcs.application.recovery.RecoveryGate;
import com.ruoyi.wcs.application.recovery.RecoveryApplicationService;
import com.ruoyi.wcs.application.task.TransportOrderExecutionService;
import com.ruoyi.wcs.domain.shared.IdGenerator;
import com.ruoyi.wcs.domain.shared.WcsClock;
import com.ruoyi.wcs.port.out.CommandOutboxRepository;
import com.ruoyi.wcs.port.out.DeviceCommandGateway;
import com.ruoyi.wcs.port.out.DeviceCommandRepository;
import com.ruoyi.wcs.port.out.DeviceEventRepository;
import com.ruoyi.wcs.port.out.DeviceExecutionRepository;
import com.ruoyi.wcs.port.out.DispatchSnapshotProvider;
import com.ruoyi.wcs.port.out.DomainEventPublisher;
import com.ruoyi.wcs.port.out.RecoveryCaseRepository;
import com.ruoyi.wcs.port.out.RecoveryCaseOpener;
import com.ruoyi.wcs.port.out.ResourceReservationRepository;
import com.ruoyi.wcs.port.out.TransportOrderExecutionRepository;
import com.ruoyi.wcs.port.out.WmsNotificationGateway;
import com.ruoyi.wcs.port.out.WmsNotificationOutboxRepository;
import com.ruoyi.wcs.query.WcsOperationsQueryService;
import com.ruoyi.wcs.query.WcsOperationsReadRepository;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -23,6 +39,71 @@ import org.springframework.context.annotation.Configuration;
public class Ft546AdapterConfiguration {
private static final int OUTBOX_BATCH_LIMIT = 100;
@Bean
public WmsNotificationOutboxProjector wmsNotificationOutboxProjector(
WmsNotificationOutboxRepository repository) {
return new WmsNotificationOutboxProjector(repository);
}
@Bean
public TransportOrderExecutionService transportOrderExecutionService(
TransportOrderExecutionRepository repository,
DomainEventPublisher eventPublisher,
WmsNotificationOutboxProjector notificationProjector) {
return new TransportOrderExecutionService(repository, eventPublisher,
notificationProjector);
}
@Bean
public DeviceExecutionService deviceExecutionService(
DeviceCommandRepository commandRepository,
CommandOutboxRepository outboxRepository,
DeviceExecutionRepository executionRepository,
TransportOrderExecutionService lifecycle,
IdGenerator idGenerator, WcsClock clock) {
return new DeviceExecutionService(commandRepository, outboxRepository,
executionRepository, lifecycle, idGenerator, clock::now);
}
@Bean
public FeedbackReconciler feedbackReconciler(
DeviceCommandRepository commandRepository,
DeviceExecutionRepository executionRepository,
DeviceEventRepository eventRepository,
ResourceReservationRepository reservationRepository,
RecoveryCaseOpener recoveryCaseOpener,
TransportOrderExecutionService lifecycle) {
return new FeedbackReconciler(commandRepository, executionRepository,
eventRepository, reservationRepository, recoveryCaseOpener,
lifecycle);
}
@Bean
public CommandDeliveryRecorder commandDeliveryRecorder(
DeviceCommandRepository commandRepository,
CommandOutboxRepository outboxRepository,
RecoveryCaseOpener recoveryCaseOpener,
ResourceReservationRepository reservationRepository) {
return new CommandDeliveryRecorder(commandRepository, outboxRepository,
recoveryCaseOpener, reservationRepository);
}
@Bean
public CommandOutboxDispatcher commandOutboxDispatcher(
CommandOutboxRepository outboxRepository,
DeviceCommandGateway commandGateway,
CommandDeliveryRecorder recorder) {
return new CommandOutboxDispatcher(outboxRepository, commandGateway,
recorder);
}
@Bean
public WmsNotificationOutboxDispatcher wmsNotificationOutboxDispatcher(
WmsNotificationOutboxRepository repository,
WmsNotificationGateway gateway) {
return new WmsNotificationOutboxDispatcher(repository, gateway);
}
@Bean
public WcsRuntimeCoordinator ft546WcsRuntimeCoordinator(
SnapshotCollector snapshotCollector,
@@ -39,8 +120,11 @@ public class Ft546AdapterConfiguration {
@Bean
public Ft546WcsScheduler ft546WcsScheduler(
WcsRuntimeCoordinator coordinator) {
return new Ft546WcsScheduler(coordinator);
WcsRuntimeCoordinator coordinator,
WmsNotificationOutboxDispatcher notificationDispatcher,
WcsClock clock) {
return new Ft546WcsScheduler(coordinator, notificationDispatcher,
clock, OUTBOX_BATCH_LIMIT);
}
@Bean

View File

@@ -2,6 +2,8 @@ package com.ruoyi.project.ft546.runtime;
import com.ruoyi.wcs.application.RuntimeTickResult;
import com.ruoyi.wcs.application.WcsRuntimeCoordinator;
import com.ruoyi.wcs.application.device.WmsNotificationOutboxDispatcher;
import com.ruoyi.wcs.domain.shared.WcsClock;
import org.springframework.scheduling.annotation.Scheduled;
import java.util.Objects;
@@ -11,16 +13,34 @@ import java.util.function.Supplier;
public final class Ft546WcsScheduler {
private final Supplier<RuntimeTickResult> tick;
private final Runnable notificationDispatch;
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);
this(Objects.requireNonNull(coordinator, "coordinator")::tick,
() -> { });
}
public Ft546WcsScheduler(
WcsRuntimeCoordinator coordinator,
WmsNotificationOutboxDispatcher notificationDispatcher,
WcsClock clock, int outboxBatchLimit) {
this(Objects.requireNonNull(coordinator, "coordinator")::tick,
notificationDispatch(notificationDispatcher, clock,
outboxBatchLimit));
}
Ft546WcsScheduler(Supplier<RuntimeTickResult> tick) {
this(tick, () -> { });
}
Ft546WcsScheduler(Supplier<RuntimeTickResult> tick,
Runnable notificationDispatch) {
this.tick = Objects.requireNonNull(tick, "tick");
this.notificationDispatch = Objects.requireNonNull(
notificationDispatch, "notificationDispatch");
}
@Scheduled(fixedDelayString =
@@ -29,13 +49,28 @@ public final class Ft546WcsScheduler {
if (!tickInProgress.compareAndSet(false, true)) {
return;
}
Throwable failure = null;
try {
lastResult = Objects.requireNonNull(tick.get(),
"runtime coordinator returned null");
completedTickCount.incrementAndGet();
try {
lastResult = Objects.requireNonNull(tick.get(),
"runtime coordinator returned null");
completedTickCount.incrementAndGet();
} catch (RuntimeException | Error coreFailure) {
failure = coreFailure;
}
try {
notificationDispatch.run();
} catch (RuntimeException | Error notificationFailure) {
if (failure == null) {
failure = notificationFailure;
} else if (failure != notificationFailure) {
failure.addSuppressed(notificationFailure);
}
}
} finally {
tickInProgress.set(false);
}
rethrow(failure);
}
public long getCompletedTickCount() {
@@ -49,4 +84,33 @@ public final class Ft546WcsScheduler {
public boolean isTickInProgress() {
return tickInProgress.get();
}
private static void rethrow(Throwable failure) {
if (failure == null) {
return;
}
if (failure instanceof RuntimeException) {
throw (RuntimeException) failure;
}
throw (Error) failure;
}
private static int requirePositive(int value) {
if (value <= 0) {
throw new IllegalArgumentException(
"outboxBatchLimit must be positive");
}
return value;
}
private static Runnable notificationDispatch(
WmsNotificationOutboxDispatcher dispatcher, WcsClock clock,
int batchLimit) {
WmsNotificationOutboxDispatcher requiredDispatcher =
Objects.requireNonNull(dispatcher, "notificationDispatcher");
WcsClock requiredClock = Objects.requireNonNull(clock, "clock");
int requiredLimit = requirePositive(batchLimit);
return () -> requiredDispatcher.dispatchDue(requiredLimit,
requiredClock.now());
}
}

View File

@@ -11,5 +11,7 @@ devices:
taskType: "4002"
path: "4004"
trigger: "4021"
crossFloorFlag: "4020"
crossFloorTarget: "4022"
status: "4068"
execution: "4060"

View File

@@ -107,6 +107,40 @@ class Ft546StandardRuntimeTest {
assertEquals(1L, scheduler.getCompletedTickCount());
}
@Test
void wmsOutboxStillDispatchesWhenCoreTickFails() {
AtomicInteger notificationDispatches = new AtomicInteger();
Ft546WcsScheduler scheduler = new Ft546WcsScheduler(() -> {
throw new IllegalStateException("core tick failed");
}, notificationDispatches::incrementAndGet);
IllegalStateException failure = assertThrows(
IllegalStateException.class, scheduler::runScheduledTick);
assertEquals("core tick failed", failure.getMessage());
assertEquals(1, notificationDispatches.get());
assertEquals(0L, scheduler.getCompletedTickCount());
assertFalse(scheduler.isTickInProgress());
}
@Test
void coreTickFailureRemainsPrimaryWhenNotificationDispatchAlsoFails() {
Ft546WcsScheduler scheduler = new Ft546WcsScheduler(() -> {
throw new IllegalStateException("core tick failed");
}, () -> {
throw new IllegalArgumentException("notification dispatch failed");
});
IllegalStateException failure = assertThrows(
IllegalStateException.class, scheduler::runScheduledTick);
assertEquals("core tick failed", failure.getMessage());
assertEquals(1, failure.getSuppressed().length);
assertEquals("notification dispatch failed",
failure.getSuppressed()[0].getMessage());
assertFalse(scheduler.isTickInProgress());
}
@Test
void legacyModeDoesNotRequireStandardRuntimePorts() {
new ApplicationContextRunner()

View File

@@ -6,6 +6,7 @@ import com.ruoyi.wcs.domain.shared.WcsClock;
import com.ruoyi.wcs.infrastructure.persistence.task.MyBatisTransportOrderRepository;
import com.ruoyi.wcs.port.in.TransportOrderUseCase;
import com.ruoyi.wcs.port.out.TransportOrderRepository;
import com.ruoyi.wcs.port.out.TransportOrderExecutionRepository;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
@@ -28,6 +29,8 @@ class WcsCoreWiringTest {
instanceof TransportOrderApplicationService);
assertTrue(context.getBean(TransportOrderRepository.class)
instanceof MyBatisTransportOrderRepository);
assertTrue(context.getBean(TransportOrderExecutionRepository.class)
instanceof MyBatisTransportOrderRepository);
assertTrue(context.containsBeanDefinition("transportOrderMapper"));
WcsClock clock = context.getBean(WcsClock.class);

View File

@@ -0,0 +1,48 @@
{
"endpoint": "/wcs2Wms/api/receiveInMydoJob",
"request": {
"taskNo": "30001",
"cargoSpaceId": "070502",
"targetCargoSpaceId": null,
"exitId": null,
"trayCode": "TRAY-30001",
"entranceId": "070501",
"inOrOut": "10",
"jobId": "JOB-30001",
"roadwayId": "R1",
"classId": "7",
"priority": 5
},
"warehouseCode": "FT546",
"topologyVersion": 7,
"snapshotVersion": 11,
"vehicle": {
"deviceId": "1",
"floorCode": "F7",
"locationNodeId": "070501",
"batteryPercent": 80
},
"nodes": [
{"nodeId": "070501", "floorCode": "F7", "x": 0, "y": 0},
{"nodeId": "070502", "floorCode": "F7", "x": 1, "y": 0}
],
"edges": [
{"from": "070501", "to": "070502", "distanceCost": 1, "capacity": 1}
],
"commandParameters": {
"taskNo": "30001",
"taskType": "1"
},
"expectedCommandPayloadSuffix": "|vehicleId=1|route=070501>070502|taskNo=30001|taskType=1",
"expectedProtectedResources": [
"NODE:070501",
"EDGE:070501:070502",
"NODE:070502"
],
"expectedPlcWrites": [
{"address": "4004", "kind": "INT32", "values": [70501, 70502, 0, 0, 0, 0, 0, 0]},
{"address": "4000", "kind": "INT32", "values": [30001]},
{"address": "4002", "kind": "INT16", "values": [1]},
{"address": "4021", "kind": "INT16", "values": [1]}
]
}

View File

@@ -0,0 +1,49 @@
{
"endpoint": "/wcs2Wms/api/receiveInMydoJob",
"request": {
"taskNo": "30003",
"cargoSpaceId": "070503",
"targetCargoSpaceId": null,
"exitId": null,
"trayCode": "TRAY-30003",
"entranceId": "070501",
"inOrOut": "10",
"jobId": "JOB-30003",
"roadwayId": "R1",
"classId": "7",
"priority": 5
},
"warehouseCode": "FT546",
"topologyVersion": 9,
"snapshotVersion": 13,
"vehicle": {
"deviceId": "1",
"floorCode": "F7",
"locationNodeId": "070501",
"batteryPercent": 80
},
"nodes": [
{"nodeId": "070501", "floorCode": "F7", "x": 0, "y": 0},
{"nodeId": "070503", "floorCode": "F7", "x": 1, "y": 0}
],
"edges": [
{"from": "070501", "to": "070503", "distanceCost": 1, "capacity": 1}
],
"commandParameters": {
"taskNo": "30003",
"taskType": "1",
"stage": "1"
},
"expectedCommandPayloadSuffix": "|vehicleId=1|route=070501>070503|stage=1|taskNo=30003|taskType=1",
"expectedProtectedResources": [
"NODE:070501",
"EDGE:070501:070503",
"NODE:070503"
],
"expectedPlcWrites": [
{"address": "4404", "kind": "INT32", "values": [70501, 70503, 0, 0, 0, 0, 0, 0]},
{"address": "4422", "kind": "INT16", "values": [1]},
{"address": "4402", "kind": "INT16", "values": [1]},
{"address": "4400", "kind": "INT32", "values": [30003]}
]
}

View File

@@ -0,0 +1,50 @@
{
"endpoint": "/wcs2Wms/api/receiveInMydoJob",
"request": {
"taskNo": "30004",
"cargoSpaceId": "070504",
"targetCargoSpaceId": null,
"exitId": null,
"trayCode": "TRAY-30004",
"entranceId": "070503",
"inOrOut": "10",
"jobId": "JOB-30004",
"roadwayId": "R1",
"classId": "7",
"priority": 5
},
"warehouseCode": "FT546",
"topologyVersion": 10,
"snapshotVersion": 14,
"vehicle": {
"deviceId": "1",
"floorCode": "F7",
"locationNodeId": "070503",
"batteryPercent": 80
},
"nodes": [
{"nodeId": "070503", "floorCode": "F7", "x": 0, "y": 0},
{"nodeId": "070504", "floorCode": "F7", "x": 1, "y": 0}
],
"edges": [
{"from": "070503", "to": "070504", "distanceCost": 1, "capacity": 1}
],
"commandParameters": {
"taskNo": "30004",
"taskType": "9",
"stage": "2"
},
"expectedCommandPayloadSuffix": "|vehicleId=1|route=070503>070504|stage=2|taskNo=30004|taskType=9",
"expectedProtectedResources": [
"NODE:070503",
"EDGE:070503:070504",
"NODE:070504"
],
"expectedPlcWrites": [
{"address": "4404", "kind": "INT32", "values": [70503, 70504, 0, 0, 0, 0, 0, 0]},
{"address": "4422", "kind": "INT16", "values": [2]},
{"address": "4423", "kind": "INT16", "values": [99]},
{"address": "4402", "kind": "INT16", "values": [9]},
{"address": "4400", "kind": "INT32", "values": [30004]}
]
}

View File

@@ -0,0 +1,59 @@
{
"endpoint": "/wcs2Wms/api/receiveOutMydoJob",
"request": {
"taskNo": "30002",
"cargoSpaceId": "070501",
"targetCargoSpaceId": null,
"exitId": "080501",
"trayCode": "TRAY-30002",
"entranceId": null,
"inOrOut": "20",
"jobId": "JOB-30002",
"roadwayId": "R1",
"classId": "7",
"priority": 6
},
"warehouseCode": "FT546",
"topologyVersion": 8,
"snapshotVersion": 12,
"vehicle": {
"deviceId": "1",
"floorCode": "F7",
"locationNodeId": "070501",
"batteryPercent": 82
},
"nodes": [
{"nodeId": "070501", "floorCode": "F7", "x": 0, "y": 0},
{"nodeId": "070507", "floorCode": "F7", "x": 1, "y": 0},
{"nodeId": "080507", "floorCode": "F8", "x": 1, "y": 0},
{"nodeId": "080501", "floorCode": "F8", "x": 2, "y": 0}
],
"edges": [
{"from": "070501", "to": "070507", "distanceCost": 1, "capacity": 1},
{"from": "070507", "to": "080507", "distanceCost": 1, "capacity": 1},
{"from": "080507", "to": "080501", "distanceCost": 1, "capacity": 1}
],
"commandParameters": {
"taskNo": "30002",
"taskType": "8",
"crossFloorTarget": "8"
},
"expectedCommandPayloadSuffix": "|vehicleId=1|route=070501>070507>080507>080501|crossFloorTarget=8|taskNo=30002|taskType=8",
"expectedProtectedResources": [
"NODE:070501",
"EDGE:070501:070507",
"NODE:070507",
"EDGE:070507:080507",
"NODE:080507",
"EDGE:080501:080507",
"NODE:080501"
],
"expectedPlcWrites": [
{"address": "4004", "kind": "INT32", "values": [70501, 70507, 80507, 80501, 0, 0, 0, 0]},
{"address": "4000", "kind": "INT32", "values": [30002]},
{"address": "4002", "kind": "INT16", "values": [8]},
{"address": "4021", "kind": "INT16", "values": [1]},
{"address": "4020", "kind": "INT16", "values": [1]},
{"address": "4022", "kind": "INT16", "values": [8]}
]
}