diff --git a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/runtime/Ft546AdapterConfiguration.java b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/runtime/Ft546AdapterConfiguration.java index e3e2023..f4331a8 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/runtime/Ft546AdapterConfiguration.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/runtime/Ft546AdapterConfiguration.java @@ -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 diff --git a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/runtime/Ft546WcsScheduler.java b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/runtime/Ft546WcsScheduler.java index 0cd5661..95673cb 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/runtime/Ft546WcsScheduler.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/runtime/Ft546WcsScheduler.java @@ -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 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 tick) { + this(tick, () -> { }); + } + + Ft546WcsScheduler(Supplier 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()); + } } diff --git a/ruoyi-admin/src/main/resources/ft546/wcs-device-profiles.yml b/ruoyi-admin/src/main/resources/ft546/wcs-device-profiles.yml index 7541742..e3b44b0 100644 --- a/ruoyi-admin/src/main/resources/ft546/wcs-device-profiles.yml +++ b/ruoyi-admin/src/main/resources/ft546/wcs-device-profiles.yml @@ -11,5 +11,7 @@ devices: taskType: "4002" path: "4004" trigger: "4021" + crossFloorFlag: "4020" + crossFloorTarget: "4022" status: "4068" execution: "4060" diff --git a/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/Ft546StandardWcsEndToEndTest.java b/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/Ft546StandardWcsEndToEndTest.java new file mode 100644 index 0000000..8c34c4d --- /dev/null +++ b/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/Ft546StandardWcsEndToEndTest.java @@ -0,0 +1,1392 @@ +package com.ruoyi.project.ft546; + +import cn.hutool.json.JSONUtil; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.ruoyi.api.controller.Wcs2WmsController; +import com.ruoyi.api.domain.RequestLog; +import com.ruoyi.api.service.IApiService; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.domain.PlcConnectInfo; +import com.ruoyi.device.srgv.domain.DeviceSrgv; +import com.ruoyi.project.ft546.config.Ft546CapabilityGate; +import com.ruoyi.project.ft546.config.Ft546CapabilityEvidence; +import com.ruoyi.project.ft546.config.Ft546DeviceProfile; +import com.ruoyi.project.ft546.config.Ft546WcsProperties; +import com.ruoyi.project.ft546.device.Ft546DeviceRuntimeRegistry; +import com.ruoyi.project.ft546.device.shuttle.Ft546LegacySrgvAdapter; +import com.ruoyi.project.ft546.device.shuttle.Ft546SrgvSnapshotMapper; +import com.ruoyi.project.ft546.runtime.Ft546AdapterConfiguration; +import com.ruoyi.project.ft546.runtime.Ft546WcsScheduler; +import com.ruoyi.project.ft546.wms.Ft546WmsAdapterService; +import com.ruoyi.project.ft546.wms.Ft546WmsNotificationGateway; +import com.ruoyi.schedule.plcRead.srgv.PlcSrgvRead; +import com.ruoyi.wcs.application.RuntimeTickResult; +import com.ruoyi.wcs.application.device.EventAcceptanceResult; +import com.ruoyi.wcs.application.device.FeedbackReconciler; +import com.ruoyi.wcs.application.device.CommandOutboxDispatcher; +import com.ruoyi.wcs.application.device.DeviceExecutionService; +import com.ruoyi.wcs.application.dispatch.DispatchCommit; +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.task.TransportOrderApplicationService; +import com.ruoyi.wcs.application.recovery.RecoveryGate; +import com.ruoyi.wcs.domain.dispatch.DefaultVehicleAssignmentPolicy; +import com.ruoyi.wcs.domain.dispatch.DispatchTask; +import com.ruoyi.wcs.domain.equipment.DeviceCapability; +import com.ruoyi.wcs.domain.equipment.DeviceDescriptor; +import com.ruoyi.wcs.domain.equipment.DeviceType; +import com.ruoyi.wcs.domain.equipment.VehicleSnapshot; +import com.ruoyi.wcs.domain.execution.CommandOutboxEntry; +import com.ruoyi.wcs.domain.execution.CommittedDeviceExecution; +import com.ruoyi.wcs.domain.execution.DeviceCommand; +import com.ruoyi.wcs.domain.execution.DeviceCommandState; +import com.ruoyi.wcs.domain.execution.DeviceEvent; +import com.ruoyi.wcs.domain.execution.OutboxDeliveryState; +import com.ruoyi.wcs.domain.execution.WmsNotificationOutboxEntry; +import com.ruoyi.wcs.domain.recovery.RecoveryCase; +import com.ruoyi.wcs.domain.shared.DomainEvent; +import com.ruoyi.wcs.domain.shared.IdGenerator; +import com.ruoyi.wcs.domain.shared.WcsClock; +import com.ruoyi.wcs.domain.task.TaskStep; +import com.ruoyi.wcs.domain.task.TaskStepType; +import com.ruoyi.wcs.domain.task.TransportOrder; +import com.ruoyi.wcs.domain.task.TransportOrderExecution; +import com.ruoyi.wcs.domain.task.TransportOrderState; +import com.ruoyi.wcs.domain.task.WorkflowDefinition; +import com.ruoyi.wcs.domain.task.WorkflowInstance; +import com.ruoyi.wcs.domain.task.WorkflowStepDefinition; +import com.ruoyi.wcs.domain.topology.EffectiveTopology; +import com.ruoyi.wcs.domain.topology.ResourceDefinition; +import com.ruoyi.wcs.domain.topology.TopologyCompiler; +import com.ruoyi.wcs.domain.topology.TopologyDefinition; +import com.ruoyi.wcs.domain.topology.TopologyEdge; +import com.ruoyi.wcs.domain.topology.TopologyNode; +import com.ruoyi.wcs.domain.traffic.AStarRoutePlanner; +import com.ruoyi.wcs.domain.traffic.DeadlockGuard; +import com.ruoyi.wcs.domain.traffic.ResourceLedger; +import com.ruoyi.wcs.domain.traffic.WaitForGraph; +import com.ruoyi.wcs.port.in.TransportOrderUseCase; +import com.ruoyi.wcs.port.out.CommandOutboxRepository; +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.DispatchPolicy; +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.ResourceReservationRepository; +import com.ruoyi.wcs.port.out.TrafficStateRepository; +import com.ruoyi.wcs.port.out.TransportOrderRepository; +import com.ruoyi.wcs.port.out.TransportOrderExecutionRepository; +import com.ruoyi.wcs.port.out.WmsNotificationOutboxRepository; +import com.ruoyi.wcs.port.out.WorkflowDefinitionProvider; +import com.ruoyi.wcs.query.DeviceOperationsView; +import com.ruoyi.wcs.query.OrderExecutionView; +import com.ruoyi.wcs.query.RecoveryCaseView; +import com.ruoyi.wcs.query.ResourceOperationsView; +import com.ruoyi.wcs.query.WcsOperationsQueryService; +import com.ruoyi.wcs.query.WcsOperationsReadRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.support.AbstractPlatformTransactionManager; +import org.springframework.transaction.support.DefaultTransactionStatus; + +import java.io.InputStream; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +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.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = { + Ft546StandardWcsEndToEndTest.TestConfiguration.class, + Ft546AdapterConfiguration.class +}) +@TestPropertySource(properties = "ft546.wcs.runtime-mode=STANDARD") +class Ft546StandardWcsEndToEndTest { + private static final Instant NOW = Instant.parse("2026-08-11T08:00:00Z"); + + @Autowired + private Wcs2WmsController wmsController; + @Autowired + private IApiService legacyApi; + @Autowired + private ScenarioState scenarioState; + @Autowired + private InMemoryWcsStore store; + @Autowired + private RecordingPlcSrgvRead plcRuntime; + @Autowired + private Ft546WcsScheduler scheduler; + @Autowired + private FeedbackReconciler feedbackReconciler; + @Autowired + private DeviceExecutionService executionService; + @Autowired + private CommandOutboxDispatcher commandOutboxDispatcher; + @Autowired + private DispatchCoordinator dispatchCoordinator; + @Autowired + private WcsOperationsQueryService operations; + @Autowired + private CountingRequestLogService callbackTransport; + @Autowired + private DeterministicIds ids; + @Autowired + private TestClock clock; + + private final ObjectMapper objectMapper = new ObjectMapper(); + private MockMvc mockMvc; + private Scenario scenario; + + @BeforeEach + void setUp() throws Exception { + useScenario("inbound-same-floor.json"); + mockMvc = MockMvcBuilders.standaloneSetup(wmsController).build(); + } + + private void useScenario(String name) throws Exception { + scenario = Scenario.load(name, objectMapper); + scenarioState.use(scenario); + store.reset(); + plcRuntime.reset(); + callbackTransport.reset(); + ids.reset(); + clock.reset(); + } + + @Test + void completesInboundThroughExistingWmsUrlAndLegacyShuttleAdapter() + throws Exception { + String orderId = postScenario(); + + scheduler.runScheduledTick(); + RuntimeTickResult tick = scheduler.getLastResult(); + DeviceCommand command = store.onlyCommand(); + GrantedDispatch grant = store.onlyGrant(); + + assertEquals(1, tick.getCreatedCommandCount()); + assertEquals(DeviceCommandState.SENT, command.getState()); + assertTrue(command.getPayload().startsWith("orderId=" + orderId + + "|stepId=" + grant.getStepId())); + assertTrue(command.getPayload().endsWith( + scenario.expectedCommandPayloadSuffix)); + assertEquals(scenario.expectedProtectedResources, + grant.getProtectedRoute().getResourceKeys()); + assertPlcWrites(scenario.expectedPlcWrites, plcRuntime.writes); + assertLegacyBusinessPathUnused(); + + EventAcceptanceResult acceptance = feedbackReconciler.accept( + scenario.completedEvent(command, grant)); + clock.advanceTo(Instant.parse("2100-01-01T00:00:00Z")); + scheduler.runScheduledTick(); + + assertEquals(EventAcceptanceResult.APPLIED, acceptance); + assertEquals("COMPLETED", + operations.order(orderId).getOrderState()); + assertEquals(1, + callbackTransport.businessEffectCount( + store.findByOrderId(orderId).getExternalRequestId())); + assertEquals(0, store.invariantViolations()); + } + + @Test + void reservesLiftRouteAndKeepsLegacyCrossFloorPlcValues() + throws Exception { + useScenario("outbound-cross-floor.json"); + String orderId = postScenario(); + + scheduler.runScheduledTick(); + DeviceCommand command = store.onlyCommand(); + GrantedDispatch grant = store.onlyGrant(); + + assertEquals(DeviceCommandState.SENT, command.getState()); + assertTrue(command.getPayload().endsWith( + scenario.expectedCommandPayloadSuffix)); + assertEquals(scenario.expectedProtectedResources, + grant.getProtectedRoute().getResourceKeys()); + for (String resourceKey : scenario.expectedProtectedResources) { + assertEquals(1, store.protectedUsage(resourceKey)); + } + assertPlcWrites(scenario.expectedPlcWrites, plcRuntime.writes); + assertLegacyBusinessPathUnused(); + + assertEquals(EventAcceptanceResult.APPLIED, + feedbackReconciler.accept( + scenario.completedEvent(command, grant))); + clock.advanceTo(Instant.parse("2100-01-01T00:00:00Z")); + scheduler.runScheduledTick(); + scheduler.runScheduledTick(); + + assertEquals("COMPLETED", + operations.order(orderId).getOrderState()); + assertEquals(1, callbackTransport.businessEffectCount( + store.findByOrderId(orderId).getExternalRequestId())); + assertEquals(0, store.invariantViolations()); + } + + @Test + void evidenceGatedTwoStageQueueKeepsBothLegacyRegisterSequences() + throws Exception { + assertTwoStageScenario("inbound-two-stage-one.json"); + assertTwoStageScenario("inbound-two-stage-two.json"); + } + + @Test + void sharedResourceContentionHoldsSecondOrderBeforePlcDelivery() + throws Exception { + ObjectNode firstRequest = inboundRequest("31001", "070501", + "070502"); + ObjectNode secondRequest = inboundRequest("31002", "070503", + "070502"); + String firstOrderId = postRequest(firstRequest); + String secondOrderId = postRequest(secondRequest); + EffectiveTopology topology = contentionTopology(); + + DispatchCycleResult result = dispatchCoordinator.dispatch( + new DispatchSnapshot(Arrays.asList( + task(firstOrderId, "31001", 2), + task(secondOrderId, "31002", 1)), + Arrays.asList(vehicle("1", "070501"), + vehicle("2", "070503")), + topology, new ResourceLedger(topology), 15L)); + + assertEquals(1, result.getGranted().size()); + assertEquals(firstOrderId, + result.getGranted().get(0).getOrderId()); + assertEquals("1", result.getGranted().get(0).getVehicleId()); + assertEquals(1, result.getHeld().size()); + assertEquals(secondOrderId, result.getHeld().get(0).getOrderId()); + assertEquals("NO_SAFE_YIELD_POINT", + result.getHeld().get(0).getReasonCode()); + + executionService.createCommand(result.getGranted().get(0)); + commandOutboxDispatcher.dispatchDue(100, NOW); + + assertEquals(1, store.commandCount()); + assertEquals(DeviceCommandState.SENT, store.onlyCommand().getState()); + assertPlcWrites(Arrays.asList( + new ExpectedWrite("4004", "INT32", Arrays.asList( + 70501, 70502, 0, 0, 0, 0, 0, 0)), + new ExpectedWrite("4000", "INT32", + Collections.singletonList(31001)), + new ExpectedWrite("4002", "INT16", + Collections.singletonList(1)), + new ExpectedWrite("4021", "INT16", + Collections.singletonList(1))), plcRuntime.writes); + assertEquals("EXECUTING", + operations.order(firstOrderId).getOrderState()); + assertEquals("PLANNED", + operations.order(secondOrderId).getOrderState()); + assertLegacyBusinessPathUnused(); + assertEquals(0, store.invariantViolations()); + } + + private void assertTwoStageScenario(String name) throws Exception { + useScenario(name); + String orderId = postScenario(); + + scheduler.runScheduledTick(); + DeviceCommand command = store.onlyCommand(); + GrantedDispatch grant = store.onlyGrant(); + + assertEquals(DeviceCommandState.SENT, command.getState()); + assertTrue(command.getPayload().endsWith( + scenario.expectedCommandPayloadSuffix)); + assertEquals(scenario.expectedProtectedResources, + grant.getProtectedRoute().getResourceKeys()); + assertPlcWrites(scenario.expectedPlcWrites, plcRuntime.writes); + assertLegacyBusinessPathUnused(); + + assertEquals(EventAcceptanceResult.APPLIED, + feedbackReconciler.accept( + scenario.completedEvent(command, grant))); + clock.advanceTo(Instant.parse("2100-01-01T00:00:00Z")); + scheduler.runScheduledTick(); + + assertEquals("COMPLETED", + operations.order(orderId).getOrderState()); + assertEquals(1, callbackTransport.businessEffectCount( + store.findByOrderId(orderId).getExternalRequestId())); + assertEquals(0, store.invariantViolations()); + } + + private String postScenario() throws Exception { + return postRequest(scenario.request); + } + + private String postRequest(JsonNode request) throws Exception { + MvcResult result = mockMvc.perform(post(scenario.endpoint) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes( + request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.state").value("PLANNED")) + .andReturn(); + return objectMapper.readTree(result.getResponse() + .getContentAsByteArray()) + .path(AjaxResult.DATA_TAG).path("orderId").asText(); + } + + private ObjectNode inboundRequest(String taskNo, String source, + String destination) { + ObjectNode request = (ObjectNode) scenario.request.deepCopy(); + request.put("taskNo", taskNo); + request.put("jobId", "JOB-" + taskNo); + request.put("trayCode", "TRAY-" + taskNo); + request.put("entranceId", source); + request.put("cargoSpaceId", destination); + return request; + } + + private DispatchTask task(String orderId, String taskNo, int priority) { + TaskStep step = store.workflow(orderId).readySteps().get(0); + TransportOrder order = store.findByOrderId(orderId); + Map parameters = new LinkedHashMap(); + parameters.put("taskNo", taskNo); + parameters.put("taskType", "1"); + return new DispatchTask(orderId, step.getStepId(), + order.getSourceCode(), order.getDestinationCode(), priority, + 60L, 30, Collections.emptySet(), + parameters); + } + + private static VehicleSnapshot vehicle(String deviceId, + String location) { + DeviceDescriptor descriptor = new DeviceDescriptor(deviceId, + DeviceType.FOUR_WAY_SHUTTLE, "F7", + EnumSet.of(DeviceCapability.FOUR_WAY_MOVE)); + return new VehicleSnapshot(descriptor, + VehicleSnapshot.ConnectionState.ONLINE, + VehicleSnapshot.OperatingMode.AUTO, + VehicleSnapshot.HealthState.NORMAL, + VehicleSnapshot.OccupationState.IDLE, + location, null, 80, null, "F7", NOW); + } + + private static EffectiveTopology contentionTopology() { + TopologyDefinition definition = new TopologyDefinition("FT546", 11L, + true, Arrays.asList( + new TopologyNode("070501", "F7", 0, 0, 1, false, false), + new TopologyNode("070502", "F7", 1, 0, 1, false, false), + new TopologyNode("070503", "F7", 2, 0, 1, false, false)), + Arrays.asList( + new TopologyEdge("070501", "070502", 1, 1), + new TopologyEdge("070503", "070502", 1, 1)), + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList()); + return new TopologyCompiler().compile(definition); + } + + private static void assertPlcWrites(List expected, + List actual) { + assertEquals(expected.size(), actual.size()); + for (int index = 0; index < expected.size(); index++) { + assertEquals(expected.get(index).address, actual.get(index).address); + assertEquals(expected.get(index).kind, actual.get(index).kind); + assertEquals(expected.get(index).values, actual.get(index).values); + } + } + + private void assertLegacyBusinessPathUnused() { + verify(legacyApi, never()).receiveInMydoJob( + org.mockito.ArgumentMatchers.any()); + verify(legacyApi, never()).receiveOutMydoJob( + org.mockito.ArgumentMatchers.any()); + } + + @Configuration + @EnableTransactionManagement(proxyTargetClass = true) + static class TestConfiguration { + @Bean + PlatformTransactionManager transactionManager() { + return new NoOpTransactionManager(); + } + + @Bean + DeterministicIds ids() { + return new DeterministicIds(); + } + + @Bean + TestClock clock() { + return new TestClock(); + } + + @Bean + ScenarioState scenarioState() { + return new ScenarioState(); + } + + @Bean + InMemoryWcsStore store() { + return new InMemoryWcsStore(); + } + + @Bean + InMemoryExecutionRepository executionRepository( + InMemoryWcsStore store) { + return new InMemoryExecutionRepository(store); + } + + @Bean + InMemoryNotificationOutboxRepository notificationOutboxRepository( + InMemoryWcsStore store) { + return new InMemoryNotificationOutboxRepository(store); + } + + @Bean + WorkflowDefinitionProvider workflowDefinitions() { + return orderType -> new WorkflowDefinition(orderType, + Collections.singletonList(new WorkflowStepDefinition( + "MOVE", TaskStepType.MOVE, true, true, + Collections.emptyList()))); + } + + @Bean + TransportOrderUseCase transportOrderUseCase( + InMemoryWcsStore store, + WorkflowDefinitionProvider definitions, + DeterministicIds ids, WcsClock clock) { + return new TransportOrderApplicationService(store, definitions, + store, ids, clock); + } + + @Bean + Ft546WmsAdapterService wmsAdapterService( + TransportOrderUseCase useCase) { + return new Ft546WmsAdapterService(useCase, + Collections::emptyList); + } + + @Bean + Ft546WcsProperties properties() { + Ft546WcsProperties properties = new Ft546WcsProperties(); + properties.setRuntimeMode( + Ft546WcsProperties.RuntimeMode.STANDARD); + return properties; + } + + @Bean + IApiService legacyApi() { + return mock(IApiService.class); + } + + @Bean + Wcs2WmsController wmsController(IApiService legacyApi, + Ft546WmsAdapterService adapter, + Ft546WcsProperties properties) { + return new Wcs2WmsController(legacyApi, adapter, properties); + } + + @Bean + DispatchSnapshotProvider snapshotProvider( + ScenarioState state, InMemoryWcsStore store) { + return new ScenarioSnapshotProvider(state, store); + } + + @Bean + DispatchCoordinator dispatchCoordinator( + InMemoryWcsStore store, DeterministicIds ids) { + AStarRoutePlanner planner = new AStarRoutePlanner(); + return new DispatchCoordinator( + new DefaultVehicleAssignmentPolicy(planner, + DispatchPolicy.NONE), + new DeadlockGuard(new WaitForGraph(), planner), + store, ids); + } + + @Bean + RecordingPlcSrgvRead plcRuntime() { + DeviceSrgv device = new DeviceSrgv(); + device.setDeviceId(1); + RecordingPlcSrgvRead runtime = new RecordingPlcSrgvRead(device); + runtime.setIsConnect(true); + return runtime; + } + + @Bean + Ft546LegacySrgvAdapter shuttleAdapter( + RecordingPlcSrgvRead runtime, InMemoryWcsStore store) { + Ft546DeviceRuntimeRegistry registry = + new Ft546DeviceRuntimeRegistry(); + registry.register("1", runtime); + Ft546DeviceProfile profile = integrationProfile(); + return new Ft546LegacySrgvAdapter(registry, profile, + passingEvidence(profile), + new Ft546CapabilityGate(() -> NOW), + store::hasBlockingMotionCommand, + deviceId -> new Ft546SrgvSnapshotMapper.Context( + "SNAP-" + deviceId, null, 7L, 1L, NOW)); + } + + @Bean + com.ruoyi.wcs.application.device.SnapshotCollector snapshotCollector() { + return () -> { }; + } + + @Bean + RecoveryGate recoveryGate() { + return () -> true; + } + + @Bean + RecoveryCaseRepository recoveryCaseRepository() { + return new NoOpRecoveryCaseRepository(); + } + + @Bean + CountingRequestLogService callbackTransport() { + return new CountingRequestLogService(); + } + + @Bean + Ft546WmsNotificationGateway notificationGateway( + CountingRequestLogService transport) { + return new Ft546WmsNotificationGateway(transport); + } + + } + + static final class NoOpTransactionManager + extends AbstractPlatformTransactionManager { + private static final long serialVersionUID = 1L; + + @Override + protected Object doGetTransaction() { + return new Object(); + } + + @Override + protected void doBegin(Object transaction, + TransactionDefinition definition) { + } + + @Override + protected void doCommit(DefaultTransactionStatus status) { + } + + @Override + protected void doRollback(DefaultTransactionStatus status) { + } + } + + private static Ft546DeviceProfile pointProfile() { + Ft546DeviceProfile profile = new Ft546DeviceProfile(); + profile.setDeviceId("1"); + profile.setDeviceType("SHUTTLE"); + profile.setAdapterType("FT546_LEGACY_SRGV"); + profile.setProfileVersion(1L); + profile.getCapabilities().add("POINT_TO_POINT_COMMAND"); + profile.getRegisters().put("taskNo", "4000"); + profile.getRegisters().put("taskType", "4002"); + profile.getRegisters().put("path", "4004"); + profile.getRegisters().put("trigger", "4021"); + profile.getRegisters().put("crossFloorFlag", "4020"); + profile.getRegisters().put("crossFloorTarget", "4022"); + profile.getRegisters().put("status", "4068"); + profile.getRegisters().put("execution", "4060"); + return profile; + } + + private static Ft546DeviceProfile integrationProfile() { + Ft546DeviceProfile profile = pointProfile(); + profile.getCapabilities().add("TWO_STAGE_ATOMIC_TASK"); + profile.setProtocolCorpusHash("sha256:ft546-two-stage-v1"); + profile.getRegisters().put("secondTaskNo", "4400"); + profile.getRegisters().put("secondTaskType", "4402"); + profile.getRegisters().put("secondPath", "4404"); + profile.getRegisters().put("secondStageFlag", "4422"); + profile.getRegisters().put("secondComplete", "4423"); + return profile; + } + + private static Ft546CapabilityEvidence passingEvidence( + Ft546DeviceProfile profile) { + return new Ft546CapabilityEvidence(profile.getProfileVersion(), + profile.getProtocolCorpusHash(), "E2E-TWO-STAGE-RUN", + "ceba465", true, NOW.minusSeconds(60), + NOW.plusSeconds(3600)); + } + + static final class DeterministicIds implements IdGenerator { + private final AtomicInteger sequence = new AtomicInteger(); + + @Override + public String nextId() { + return "E2E-" + sequence.incrementAndGet(); + } + + void reset() { + sequence.set(0); + } + } + + static final class TestClock implements WcsClock { + private Instant current = NOW; + + @Override + public Instant now() { + return current; + } + + void advanceTo(Instant value) { + current = value; + } + + void reset() { + current = NOW; + } + } + + static final class ScenarioState { + private Scenario scenario; + + void use(Scenario value) { + scenario = value; + } + + Scenario require() { + return java.util.Objects.requireNonNull(scenario, + "scenario not configured"); + } + } + + static final class ScenarioSnapshotProvider + implements DispatchSnapshotProvider { + private final ScenarioState state; + private final InMemoryWcsStore store; + + ScenarioSnapshotProvider(ScenarioState state, + InMemoryWcsStore store) { + this.state = state; + this.store = store; + } + + @Override + public DispatchSnapshot load() { + Scenario scenario = state.require(); + TransportOrder order = store.onlyOrderOrNull(); + List tasks = new ArrayList(); + if (order != null && store.commands.isEmpty()) { + WorkflowInstance workflow = store.workflow(order.getOrderId()); + List ready = workflow.readySteps(); + if (!ready.isEmpty()) { + TaskStep step = ready.get(0); + tasks.add(new DispatchTask(order.getOrderId(), + step.getStepId(), order.getSourceCode(), + order.getDestinationCode(), order.getPriority(), + 60L, 30, + Collections.emptySet(), + scenario.commandParameters)); + } + } + return new DispatchSnapshot(tasks, + Collections.singletonList(scenario.vehicleSnapshot()), + scenario.topology, store.ledger(scenario.topology), + scenario.snapshotVersion); + } + } + + static final class InMemoryWcsStore implements TransportOrderRepository, + TransportOrderExecutionRepository, + DomainEventPublisher, TrafficStateRepository, + DeviceCommandRepository, CommandOutboxRepository, + DeviceEventRepository, + ResourceReservationRepository, + WcsOperationsReadRepository { + private final Map orders = + new LinkedHashMap(); + private final Map workflows = + new LinkedHashMap(); + private final Map commands = + new LinkedHashMap(); + private final Map commandOutbox = + new LinkedHashMap(); + private final Map executions = + new LinkedHashMap(); + private final Set deviceEvents = new HashSet(); + private final Map notificationOutbox = + new LinkedHashMap(); + private final List events = new ArrayList(); + private final List recoveryReasons = new ArrayList(); + private DispatchCommit lastCommit; + private ResourceLedger ledger; + private int invariantViolations; + + void reset() { + orders.clear(); + workflows.clear(); + commands.clear(); + commandOutbox.clear(); + executions.clear(); + deviceEvents.clear(); + notificationOutbox.clear(); + events.clear(); + recoveryReasons.clear(); + lastCommit = null; + ledger = null; + invariantViolations = 0; + } + + @Override + public TransportOrder findByExternalRequestId(String externalRequestId) { + for (TransportOrder order : orders.values()) { + if (order.getExternalRequestId().equals(externalRequestId)) { + return order; + } + } + return null; + } + + @Override + public TransportOrder findByOrderId(String orderId) { + return orders.get(orderId); + } + + @Override + public void save(TransportOrder order, WorkflowInstance workflow) { + orders.put(order.getOrderId(), order); + workflows.put(order.getOrderId(), workflow); + } + + @Override + public void save(TransportOrder order) { + orders.put(order.getOrderId(), order); + } + + WorkflowInstance workflow(String orderId) { + return workflows.get(orderId); + } + + @Override + public TransportOrderExecution load(String orderId) { + TransportOrder order = orders.get(orderId); + WorkflowInstance workflow = workflows.get(orderId); + return order == null || workflow == null ? null + : TransportOrderExecution.loaded(order, workflow); + } + + @Override + public void save(TransportOrderExecution execution) { + TransportOrder order = execution.getOrder(); + orders.put(order.getOrderId(), order); + workflows.put(order.getOrderId(), execution.getWorkflow()); + } + + TransportOrder onlyOrderOrNull() { + return orders.isEmpty() ? null : orders.values().iterator().next(); + } + + @Override + public void publish(List domainEvents) { + events.addAll(domainEvents); + } + + @Override + public boolean commit(DispatchCommit commit) { + lastCommit = commit; + ledger = commit.getResultingLedger(); + return true; + } + + GrantedDispatch onlyGrant() { + assertNotNull(lastCommit); + assertEquals(1, lastCommit.getGranted().size()); + return lastCommit.getGranted().get(0); + } + + ResourceLedger ledger(EffectiveTopology topology) { + return ledger == null ? new ResourceLedger(topology) : ledger.copy(); + } + + @Override + public DeviceCommand findByCommandKey(String commandKey) { + for (DeviceCommand command : commands.values()) { + if (command.getCommandKey().equals(commandKey)) { + return command; + } + } + return null; + } + + @Override + public DeviceCommand findByCommandId(String commandId) { + return commands.get(commandId); + } + + @Override + public void save(DeviceCommand command) { + commands.put(command.getCommandId(), command); + } + + DeviceCommand onlyCommand() { + assertEquals(1, commands.size()); + return commands.values().iterator().next(); + } + + int commandCount() { + return commands.size(); + } + + boolean hasBlockingMotionCommand(String deviceId, + String candidateCommandId) { + for (DeviceCommand command : commands.values()) { + if (deviceId.equals(command.getDeviceId()) + && !candidateCommandId.equals(command.getCommandId()) + && !command.canSendMotionCommandAfterIt()) { + return true; + } + } + return false; + } + + @Override + public void saveIfAbsent(CommandOutboxEntry entry) { + commandOutbox.putIfAbsent(entry.getCommandId(), entry); + } + + @Override + public void save(CommandOutboxEntry entry) { + commandOutbox.put(entry.getCommandId(), entry); + } + + @Override + public List claimDue(int limit, Instant now) { + List claimed = + new ArrayList(); + for (CommandOutboxEntry entry : commandOutbox.values()) { + if (claimed.size() >= limit) { + break; + } + if (entry.claim(now)) { + claimed.add(entry); + } + } + return claimed; + } + + @Override + public boolean claim(DeviceEvent event) { + return deviceEvents.add(event.getEventId()); + } + + @Override + public void releasePassedResources(String commandKey, + List resourceKeys, + String releaseEvidenceId, + long fencingToken) { + if (lastCommit == null) { + invariantViolations++; + return; + } + ResourceLedger committed = lastCommit.getResultingLedger(); + for (String resourceKey : resourceKeys) { + if (committed.usage(resourceKey) <= 0) { + invariantViolations++; + } + } + } + + @Override + public void protectForUnknown(String commandKey, String evidenceId) { + } + + public void open(String deviceId, String commandKey, String reason, + List evidenceIds) { + recoveryReasons.add(reason); + } + + @Override + public OrderExecutionView findOrder(String orderId) { + TransportOrder order = orders.get(orderId); + if (order == null) { + return null; + } + WorkflowInstance workflow = workflows.get(orderId); + TaskStep step = workflow == null || workflow.getSteps().isEmpty() + ? null : workflow.getSteps().get(0); + DeviceCommand command = commandForOrder(orderId); + return new OrderExecutionView(order.getOrderId(), + order.getExternalRequestId(), order.getState().name(), + order.getWorkflowId(), + step == null ? null : step.getStepId(), + step == null ? null : step.getVehicleId(), + command == null ? null : command.getCommandKey(), + command == null ? null : command.getState().name(), + recoveryReasons.isEmpty() ? null : "RECOVERY-1", + order.getUpdatedAt()); + } + + @Override + public OrderExecutionView findCommand(String commandKey) { + DeviceCommand command = findByCommandKey(commandKey); + if (command == null) { + return null; + } + String[] parts = commandKey.split(":", 3); + return parts.length == 3 ? findOrder(parts[1]) : null; + } + + @Override + public DeviceOperationsView findDevice(String deviceId) { + return null; + } + + @Override + public ResourceOperationsView findResource(String resourceKey) { + return null; + } + + @Override + public RecoveryCaseView findRecoveryCase(String caseId) { + return null; + } + + @Override + public List findOrders(String state, int offset, + int limit) { + List result = + new ArrayList(); + for (TransportOrder order : orders.values()) { + if (state == null || state.trim().isEmpty() + || state.equals(order.getState().name())) { + result.add(findOrder(order.getOrderId())); + } + } + return result; + } + + @Override + public long countOrders(String state) { + return findOrders(state, 0, Integer.MAX_VALUE).size(); + } + + @Override + public List findDevices(String state, + int offset, int limit) { + return Collections.emptyList(); + } + + @Override + public long countDevices(String state) { + return 0; + } + + @Override + public List findResources(String state, + int offset, + int limit) { + return Collections.emptyList(); + } + + @Override + public long countResources(String state) { + return 0; + } + + @Override + public List findRecoveryCases(String state, + int offset, + int limit) { + return Collections.emptyList(); + } + + @Override + public long countRecoveryCases(String state) { + return 0; + } + + private DeviceCommand commandForOrder(String orderId) { + String prefix = "DISPATCH:" + orderId + ":"; + for (DeviceCommand command : commands.values()) { + if (command.getCommandKey().startsWith(prefix)) { + return command; + } + } + return null; + } + + int invariantViolations() { + return invariantViolations; + } + + int protectedUsage(String resourceKey) { + return lastCommit == null ? 0 + : lastCommit.getResultingLedger().usage(resourceKey); + } + } + + static final class InMemoryExecutionRepository + implements DeviceExecutionRepository { + private final InMemoryWcsStore store; + + InMemoryExecutionRepository(InMemoryWcsStore store) { + this.store = store; + } + + @Override + public CommittedDeviceExecution findByCommandKey(String commandKey) { + return store.executions.get(commandKey); + } + + @Override + public CommittedDeviceExecution findActiveByDeviceId(String deviceId) { + for (CommittedDeviceExecution execution + : store.executions.values()) { + if (deviceId.equals(execution.getDeviceId())) { + return execution; + } + } + return null; + } + + @Override + public void save(CommittedDeviceExecution execution) { + store.executions.put(execution.getCommandKey(), execution); + } + } + + static final class InMemoryNotificationOutboxRepository + implements WmsNotificationOutboxRepository { + private final InMemoryWcsStore store; + + InMemoryNotificationOutboxRepository(InMemoryWcsStore store) { + this.store = store; + } + + @Override + public void saveIfAbsent(WmsNotificationOutboxEntry entry) { + store.notificationOutbox.putIfAbsent(entry.getEventId(), entry); + } + + @Override + public void save(WmsNotificationOutboxEntry entry) { + store.notificationOutbox.put(entry.getEventId(), entry); + } + + @Override + public List claimDue(int limit, + Instant now) { + List claimed = + new ArrayList(); + for (WmsNotificationOutboxEntry entry + : store.notificationOutbox.values()) { + if (claimed.size() >= limit) { + break; + } + if (entry.claim(now)) { + claimed.add(entry); + } + } + return claimed; + } + } + + static final class NoOpRecoveryCaseRepository + implements RecoveryCaseRepository { + @Override + public RecoveryCase findById(String caseId) { + return null; + } + + @Override + public RecoveryCase findOpen(String deviceId, String commandKey) { + return null; + } + + @Override + public List findOpenCases() { + return Collections.emptyList(); + } + + @Override + public RecoveryCase saveIfAbsentOpen(RecoveryCase recoveryCase) { + return recoveryCase; + } + + @Override + public void save(RecoveryCase recoveryCase) { + } + } + + static final class RecordingPlcSrgvRead extends PlcSrgvRead { + private final List writes = + new ArrayList(); + + RecordingPlcSrgvRead(DeviceSrgv device) { + super(device, new PlcConnectInfo("127.0.0.1", 502)); + } + + void reset() { + writes.clear(); + setIsConnect(true); + } + + @Override + public boolean Write(String address, int[] values) { + List converted = new ArrayList(); + for (int value : values) { + converted.add(value); + } + writes.add(new RecordedWrite(address, "INT32", converted)); + return true; + } + + @Override + public boolean Write(String address, short[] values) { + List converted = new ArrayList(); + for (short value : values) { + converted.add((int) value); + } + writes.add(new RecordedWrite(address, "INT16", converted)); + return true; + } + } + + static final class CountingRequestLogService + implements com.ruoyi.api.service.IRequestLogService { + private final Set deliveredEvents = new HashSet(); + private final Map effectsByTask = + new LinkedHashMap(); + + void reset() { + deliveredEvents.clear(); + effectsByTask.clear(); + } + + @Override + public RequestLog getRequestLog(String url, String endpoint, + String body) { + cn.hutool.json.JSONObject payload = JSONUtil.parseObj(body); + String eventId = payload.getStr("eventId"); + String taskNo = payload.getStr("taskNo"); + if (deliveredEvents.add(eventId)) { + Integer current = effectsByTask.get(taskNo); + effectsByTask.put(taskNo, current == null ? 1 : current + 1); + } + RequestLog result = new RequestLog(); + result.setIsSuccessful("1"); + return result; + } + + @Override + public RequestLog selectRequestLogByRequestId(Long requestId) { + return null; + } + + @Override + public List selectRequestLogList(RequestLog requestLog) { + return Collections.emptyList(); + } + + @Override + public int insertRequestLog(RequestLog requestLog) { + return 0; + } + + @Override + public int updateRequestLog(RequestLog requestLog) { + return 0; + } + + @Override + public int deleteRequestLogByRequestIds(Long[] requestIds) { + return 0; + } + + @Override + public int deleteRequestLogByRequestId(Long requestId) { + return 0; + } + + @Override + public RequestLog getRequestLog(String url, String body) { + return null; + } + + @Override + public RequestLog getRequestLogs(String url, String param, + String endpoint) { + return getRequestLog(url, param, endpoint); + } + + int businessEffectCount(String taskNo) { + Integer count = effectsByTask.get(taskNo); + return count == null ? 0 : count; + } + } + + static final class Scenario { + private final String endpoint; + private final JsonNode request; + private final EffectiveTopology topology; + private final long snapshotVersion; + private final String vehicleId; + private final String vehicleFloor; + private final String vehicleLocation; + private final int battery; + private final Map commandParameters; + private final String expectedCommandPayloadSuffix; + private final List expectedProtectedResources; + private final List expectedPlcWrites; + + private Scenario(JsonNode root) { + endpoint = root.path("endpoint").asText(); + request = root.path("request"); + snapshotVersion = root.path("snapshotVersion").asLong(); + JsonNode vehicle = root.path("vehicle"); + vehicleId = vehicle.path("deviceId").asText(); + vehicleFloor = vehicle.path("floorCode").asText(); + vehicleLocation = vehicle.path("locationNodeId").asText(); + battery = vehicle.path("batteryPercent").asInt(); + commandParameters = stringMap(root.path("commandParameters")); + expectedCommandPayloadSuffix = root.path( + "expectedCommandPayloadSuffix").asText(); + expectedProtectedResources = strings(root.path( + "expectedProtectedResources")); + expectedPlcWrites = writes(root.path("expectedPlcWrites")); + topology = topology(root); + } + + static Scenario load(String name, ObjectMapper mapper) + throws Exception { + try (InputStream stream = Ft546StandardWcsEndToEndTest.class + .getResourceAsStream("/ft546/scenarios/" + name)) { + if (stream == null) { + throw new IllegalStateException("missing scenario " + name); + } + return new Scenario(mapper.readTree(stream)); + } + } + + VehicleSnapshot vehicleSnapshot() { + DeviceDescriptor descriptor = new DeviceDescriptor(vehicleId, + DeviceType.FOUR_WAY_SHUTTLE, vehicleFloor, + EnumSet.of(DeviceCapability.FOUR_WAY_MOVE)); + return new VehicleSnapshot(descriptor, + VehicleSnapshot.ConnectionState.ONLINE, + VehicleSnapshot.OperatingMode.AUTO, + VehicleSnapshot.HealthState.NORMAL, + VehicleSnapshot.OccupationState.IDLE, + vehicleLocation, null, battery, null, vehicleFloor, NOW); + } + + DeviceEvent completedEvent(DeviceCommand command, + GrantedDispatch grant) { + List route = grant.getProtectedRoute().getNodeIds(); + String target = route.get(route.size() - 1); + return new DeviceEvent("PLC-COMPLETED-" + command.getCommandId(), + vehicleId, command.getCommandKey(), 1L, + NOW.plusSeconds(5), NOW.plusSeconds(5), + DeviceEvent.Type.COMPLETED, + commandParameters.get("taskNo"), target, + Collections.singletonList(target)); + } + + private static EffectiveTopology topology(JsonNode root) { + List nodes = new ArrayList(); + for (JsonNode node : root.path("nodes")) { + nodes.add(new TopologyNode(node.path("nodeId").asText(), + node.path("floorCode").asText(), + node.path("x").asInt(), node.path("y").asInt(), + 1, false, false)); + } + List edges = new ArrayList(); + for (JsonNode edge : root.path("edges")) { + edges.add(new TopologyEdge(edge.path("from").asText(), + edge.path("to").asText(), + edge.path("distanceCost").asInt(), + edge.path("capacity").asInt())); + } + TopologyDefinition definition = new TopologyDefinition( + root.path("warehouseCode").asText(), + root.path("topologyVersion").asLong(), true, + nodes, edges, Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList()); + return new TopologyCompiler().compile(definition); + } + + private static Map stringMap(JsonNode node) { + Map values = new LinkedHashMap(); + node.fields().forEachRemaining(entry -> + values.put(entry.getKey(), entry.getValue().asText())); + return values; + } + + private static List strings(JsonNode node) { + List values = new ArrayList(); + node.forEach(value -> values.add(value.asText())); + return values; + } + + private static List writes(JsonNode node) { + List values = new ArrayList(); + for (JsonNode write : node) { + List encoded = new ArrayList(); + write.path("values").forEach( + value -> encoded.add(value.asInt())); + values.add(new ExpectedWrite(write.path("address").asText(), + write.path("kind").asText(), encoded)); + } + return values; + } + } + + static class ExpectedWrite { + final String address; + final String kind; + final List values; + + ExpectedWrite(String address, String kind, List values) { + this.address = address; + this.kind = kind; + this.values = Collections.unmodifiableList( + new ArrayList(values)); + } + } + + static final class RecordedWrite extends ExpectedWrite { + RecordedWrite(String address, String kind, List values) { + super(address, kind, values); + } + } +} diff --git a/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/runtime/Ft546StandardRuntimeTest.java b/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/runtime/Ft546StandardRuntimeTest.java index b88e2f4..8bc0f75 100644 --- a/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/runtime/Ft546StandardRuntimeTest.java +++ b/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/runtime/Ft546StandardRuntimeTest.java @@ -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() diff --git a/ruoyi-admin/src/test/java/com/ruoyi/wcs/WcsCoreWiringTest.java b/ruoyi-admin/src/test/java/com/ruoyi/wcs/WcsCoreWiringTest.java index f3e17b5..e997244 100644 --- a/ruoyi-admin/src/test/java/com/ruoyi/wcs/WcsCoreWiringTest.java +++ b/ruoyi-admin/src/test/java/com/ruoyi/wcs/WcsCoreWiringTest.java @@ -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); diff --git a/ruoyi-admin/src/test/resources/ft546/scenarios/inbound-same-floor.json b/ruoyi-admin/src/test/resources/ft546/scenarios/inbound-same-floor.json new file mode 100644 index 0000000..d7679c2 --- /dev/null +++ b/ruoyi-admin/src/test/resources/ft546/scenarios/inbound-same-floor.json @@ -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]} + ] +} diff --git a/ruoyi-admin/src/test/resources/ft546/scenarios/inbound-two-stage-one.json b/ruoyi-admin/src/test/resources/ft546/scenarios/inbound-two-stage-one.json new file mode 100644 index 0000000..0191bf9 --- /dev/null +++ b/ruoyi-admin/src/test/resources/ft546/scenarios/inbound-two-stage-one.json @@ -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]} + ] +} diff --git a/ruoyi-admin/src/test/resources/ft546/scenarios/inbound-two-stage-two.json b/ruoyi-admin/src/test/resources/ft546/scenarios/inbound-two-stage-two.json new file mode 100644 index 0000000..1c60920 --- /dev/null +++ b/ruoyi-admin/src/test/resources/ft546/scenarios/inbound-two-stage-two.json @@ -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]} + ] +} diff --git a/ruoyi-admin/src/test/resources/ft546/scenarios/outbound-cross-floor.json b/ruoyi-admin/src/test/resources/ft546/scenarios/outbound-cross-floor.json new file mode 100644 index 0000000..100999a --- /dev/null +++ b/ruoyi-admin/src/test/resources/ft546/scenarios/outbound-cross-floor.json @@ -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]} + ] +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/device/CommandDeliveryRecorder.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/device/CommandDeliveryRecorder.java index c3b66d1..0f385b5 100644 --- a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/device/CommandDeliveryRecorder.java +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/device/CommandDeliveryRecorder.java @@ -13,7 +13,7 @@ import java.time.Instant; import java.util.Collections; import java.util.Objects; -public final class CommandDeliveryRecorder { +public class CommandDeliveryRecorder { private final DeviceCommandRepository commandRepository; private final CommandOutboxRepository outboxRepository; private final RecoveryCaseOpener recoveryCaseOpener; diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/device/DeviceCommandCompletionHandler.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/device/DeviceCommandCompletionHandler.java new file mode 100644 index 0000000..190b521 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/device/DeviceCommandCompletionHandler.java @@ -0,0 +1,10 @@ +package com.ruoyi.wcs.application.device; + +import com.ruoyi.wcs.domain.execution.DeviceCommand; + +@FunctionalInterface +public interface DeviceCommandCompletionHandler { + DeviceCommandCompletionHandler NO_OP = command -> { }; + + void onSucceeded(DeviceCommand command); +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/device/DeviceExecutionService.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/device/DeviceExecutionService.java index d4f69c6..da31219 100644 --- a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/device/DeviceExecutionService.java +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/device/DeviceExecutionService.java @@ -2,12 +2,14 @@ package com.ruoyi.wcs.application.device; import com.ruoyi.wcs.application.dispatch.GrantedDispatch; import com.ruoyi.wcs.domain.execution.CommandOutboxEntry; +import com.ruoyi.wcs.domain.execution.CommittedDeviceExecution; import com.ruoyi.wcs.domain.execution.DeviceCommand; import com.ruoyi.wcs.domain.execution.DeviceCommandType; import com.ruoyi.wcs.domain.shared.DomainRuleViolation; import com.ruoyi.wcs.domain.shared.IdGenerator; import com.ruoyi.wcs.port.out.CommandOutboxRepository; import com.ruoyi.wcs.port.out.DeviceCommandRepository; +import com.ruoyi.wcs.port.out.DeviceExecutionRepository; import org.springframework.transaction.annotation.Transactional; import java.time.Instant; @@ -16,9 +18,11 @@ import java.util.Objects; import java.util.TreeMap; import java.util.function.Supplier; -public final class DeviceExecutionService { +public class DeviceExecutionService { private final DeviceCommandRepository commandRepository; private final CommandOutboxRepository outboxRepository; + private final DeviceExecutionRepository executionRepository; + private final DispatchGrantHandler dispatchGrantHandler; private final IdGenerator idGenerator; private final Supplier clock; @@ -26,10 +30,32 @@ public final class DeviceExecutionService { CommandOutboxRepository outboxRepository, IdGenerator idGenerator, Supplier clock) { + this(commandRepository, outboxRepository, null, + DispatchGrantHandler.NO_OP, idGenerator, clock); + } + + public DeviceExecutionService(DeviceCommandRepository commandRepository, + CommandOutboxRepository outboxRepository, + DeviceExecutionRepository executionRepository, + IdGenerator idGenerator, + Supplier clock) { + this(commandRepository, outboxRepository, executionRepository, + DispatchGrantHandler.NO_OP, idGenerator, clock); + } + + public DeviceExecutionService(DeviceCommandRepository commandRepository, + CommandOutboxRepository outboxRepository, + DeviceExecutionRepository executionRepository, + DispatchGrantHandler dispatchGrantHandler, + IdGenerator idGenerator, + Supplier clock) { this.commandRepository = Objects.requireNonNull(commandRepository, "commandRepository"); this.outboxRepository = Objects.requireNonNull(outboxRepository, "outboxRepository"); + this.executionRepository = executionRepository; + this.dispatchGrantHandler = Objects.requireNonNull(dispatchGrantHandler, + "dispatchGrantHandler"); this.idGenerator = Objects.requireNonNull(idGenerator, "idGenerator"); this.clock = Objects.requireNonNull(clock, "clock"); } @@ -50,6 +76,8 @@ public final class DeviceExecutionService { return existing; } + dispatchGrantHandler.onGranted(grant); + Instant now = Objects.requireNonNull(clock.get(), "clock returned null"); DeviceCommand command = DeviceCommand.pending( requireText(idGenerator.nextId(), "generated commandId"), @@ -59,10 +87,25 @@ public final class DeviceExecutionService { command.getCommandId(), command.getCommandKey(), payload, now, grant.getTopologyVersion(), grant.getFencingToken()); commandRepository.save(command); + if (executionRepository != null) { + executionRepository.save(execution(command, grant)); + } outboxRepository.saveIfAbsent(entry); return command; } + private static CommittedDeviceExecution execution( + DeviceCommand command, GrantedDispatch grant) { + return new CommittedDeviceExecution(command.getCommandId(), + command.getCommandKey(), command.getDeviceId(), + requireText(grant.getCommandParameters().get("taskNo"), + "taskNo"), + grant.getProtectedRoute().getNodeIds(), + grant.getProtectedRoute().getResourceKeys(), + grant.getTopologyVersion(), grant.getFencingToken(), + grant.getCommandParameters().get("expectedBootId")); + } + private static String encode(GrantedDispatch grant) { StringBuilder route = new StringBuilder(); for (String node : grant.getProtectedRoute().getNodeIds()) { diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/device/DispatchGrantHandler.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/device/DispatchGrantHandler.java new file mode 100644 index 0000000..7ad491f --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/device/DispatchGrantHandler.java @@ -0,0 +1,10 @@ +package com.ruoyi.wcs.application.device; + +import com.ruoyi.wcs.application.dispatch.GrantedDispatch; + +@FunctionalInterface +public interface DispatchGrantHandler { + DispatchGrantHandler NO_OP = grant -> { }; + + void onGranted(GrantedDispatch grant); +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/device/FeedbackReconciler.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/device/FeedbackReconciler.java index 7f32b88..061e6f3 100644 --- a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/device/FeedbackReconciler.java +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/device/FeedbackReconciler.java @@ -20,19 +20,31 @@ import java.time.Instant; import java.util.Collections; import java.util.List; -public final class FeedbackReconciler +public class FeedbackReconciler implements DeviceEventUseCase, DeviceSnapshotUseCase { private final DeviceCommandRepository commandRepository; private final DeviceExecutionRepository executionRepository; private final DeviceEventRepository eventRepository; private final ResourceReservationRepository reservationRepository; private final RecoveryCaseOpener recoveryCaseOpener; + private final DeviceCommandCompletionHandler completionHandler; public FeedbackReconciler(DeviceCommandRepository commandRepository, DeviceExecutionRepository executionRepository, DeviceEventRepository eventRepository, ResourceReservationRepository reservationRepository, RecoveryCaseOpener recoveryCaseOpener) { + this(commandRepository, executionRepository, eventRepository, + reservationRepository, recoveryCaseOpener, + DeviceCommandCompletionHandler.NO_OP); + } + + public FeedbackReconciler(DeviceCommandRepository commandRepository, + DeviceExecutionRepository executionRepository, + DeviceEventRepository eventRepository, + ResourceReservationRepository reservationRepository, + RecoveryCaseOpener recoveryCaseOpener, + DeviceCommandCompletionHandler completionHandler) { this.commandRepository = java.util.Objects.requireNonNull( commandRepository, "commandRepository"); this.executionRepository = java.util.Objects.requireNonNull( @@ -43,6 +55,8 @@ public final class FeedbackReconciler reservationRepository, "reservationRepository"); this.recoveryCaseOpener = java.util.Objects.requireNonNull( recoveryCaseOpener, "recoveryCaseOpener"); + this.completionHandler = java.util.Objects.requireNonNull( + completionHandler, "completionHandler"); } @Override @@ -70,6 +84,7 @@ public final class FeedbackReconciler } DeviceCommand command = requireCommand(execution.getCommandKey()); + boolean completed = false; try { switch (event.getEventType()) { case ACKNOWLEDGED: @@ -91,6 +106,7 @@ public final class FeedbackReconciler event.getCurrentNodeId(), event.getRemainingRouteNodes(), true, event.getEventId()); complete(command, event.getEventId(), event.getReceivedTime()); + completed = true; break; case REJECTED: requireActive(command, false); @@ -113,6 +129,9 @@ public final class FeedbackReconciler return EventAcceptanceResult.RECOVERY_REQUIRED; } commandRepository.save(command); + if (completed) { + completionHandler.onSucceeded(command); + } return EventAcceptanceResult.APPLIED; } @@ -134,8 +153,9 @@ public final class FeedbackReconciler return recovery(snapshot, execution, "TASK_NUMBER_MISMATCH"); } if (snapshot.hasDeviceBootSession() - && !execution.getExpectedBootId().equals( - snapshot.getDeviceBootId())) { + && (!execution.hasExpectedBootSession() + || !execution.getExpectedBootId().equals( + snapshot.getDeviceBootId()))) { return recovery(snapshot, execution, "DEVICE_RESTARTED"); } if (execution.getTopologyVersion() != snapshot.getTopologyVersion()) { @@ -174,6 +194,7 @@ public final class FeedbackReconciler if (snapshot.isCommandCompleted()) { complete(command, snapshot.getSnapshotId(), snapshot.getObservedAt()); commandRepository.save(command); + completionHandler.onSucceeded(command); } return new ReconcileResult(released.isEmpty() ? ReconcileStatus.CONSISTENT : ReconcileStatus.ADVANCED, diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/device/WmsNotificationOutboxProjector.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/device/WmsNotificationOutboxProjector.java index 1336403..fd87f68 100644 --- a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/device/WmsNotificationOutboxProjector.java +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/device/WmsNotificationOutboxProjector.java @@ -8,7 +8,7 @@ import java.time.Instant; import java.util.Objects; import org.springframework.transaction.annotation.Transactional; -public final class WmsNotificationOutboxProjector { +public class WmsNotificationOutboxProjector { private final WmsNotificationOutboxRepository repository; public WmsNotificationOutboxProjector( diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/recovery/RecoveryApplicationService.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/recovery/RecoveryApplicationService.java index 36cd2e1..eca07ad 100644 --- a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/recovery/RecoveryApplicationService.java +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/recovery/RecoveryApplicationService.java @@ -14,7 +14,7 @@ import java.util.List; import java.util.Objects; import java.util.function.Supplier; -public final class RecoveryApplicationService +public class RecoveryApplicationService implements RecoveryUseCase, RecoveryCaseOpener { private final RecoveryCaseRepository repository; private final IdGenerator idGenerator; diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/task/TransportOrderApplicationService.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/task/TransportOrderApplicationService.java index 2d3fdf0..39f1e7b 100644 --- a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/task/TransportOrderApplicationService.java +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/task/TransportOrderApplicationService.java @@ -16,7 +16,7 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Objects; -public final class TransportOrderApplicationService implements TransportOrderUseCase { +public class TransportOrderApplicationService implements TransportOrderUseCase { private final TransportOrderRepository repository; private final WorkflowDefinitionProvider workflowDefinitions; private final DomainEventPublisher eventPublisher; diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/task/TransportOrderExecutionService.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/task/TransportOrderExecutionService.java new file mode 100644 index 0000000..99821a6 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/application/task/TransportOrderExecutionService.java @@ -0,0 +1,166 @@ +package com.ruoyi.wcs.application.task; + +import com.ruoyi.wcs.application.device.DeviceCommandCompletionHandler; +import com.ruoyi.wcs.application.device.DispatchGrantHandler; +import com.ruoyi.wcs.application.device.WmsNotificationOutboxProjector; +import com.ruoyi.wcs.application.dispatch.GrantedDispatch; +import com.ruoyi.wcs.domain.execution.DeviceCommand; +import com.ruoyi.wcs.domain.shared.DomainEvent; +import com.ruoyi.wcs.domain.shared.DomainRuleViolation; +import com.ruoyi.wcs.domain.task.TaskStep; +import com.ruoyi.wcs.domain.task.TaskStepState; +import com.ruoyi.wcs.domain.task.TransportOrder; +import com.ruoyi.wcs.domain.task.TransportOrderExecution; +import com.ruoyi.wcs.domain.task.TransportOrderState; +import com.ruoyi.wcs.domain.task.WorkflowInstance; +import com.ruoyi.wcs.port.out.DomainEventPublisher; +import com.ruoyi.wcs.port.out.TransportOrderExecutionRepository; +import com.ruoyi.wcs.port.out.WmsNotification; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +public class TransportOrderExecutionService + implements DispatchGrantHandler, DeviceCommandCompletionHandler { + private static final String COMMAND_PREFIX = "DISPATCH:"; + + private final TransportOrderExecutionRepository repository; + private final DomainEventPublisher eventPublisher; + private final WmsNotificationOutboxProjector notificationProjector; + + public TransportOrderExecutionService( + TransportOrderExecutionRepository repository, + DomainEventPublisher eventPublisher, + WmsNotificationOutboxProjector notificationProjector) { + this.repository = Objects.requireNonNull(repository, "repository"); + this.eventPublisher = Objects.requireNonNull(eventPublisher, + "eventPublisher"); + this.notificationProjector = Objects.requireNonNull( + notificationProjector, "notificationProjector"); + } + + @Override + @Transactional + public void onGranted(GrantedDispatch grant) { + Objects.requireNonNull(grant, "grant"); + TransportOrderExecution execution = requireExecution( + grant.getOrderId()); + TransportOrder order = execution.getOrder(); + WorkflowInstance workflow = execution.getWorkflow(); + TaskStep step = workflow.stepById(grant.getStepId()); + + if (step.getState() == TaskStepState.RUNNING + && grant.getVehicleId().equals(step.getVehicleId())) { + return; + } + if (step.getState() != TaskStepState.READY) { + throw new DomainRuleViolation( + "dispatch grant requires a READY task step"); + } + if (order.getState() == TransportOrderState.PLANNED) { + order.start(); + } else if (order.getState() != TransportOrderState.EXECUTING) { + throw new DomainRuleViolation( + "dispatch grant requires a PLANNED or EXECUTING order"); + } + step.assign(grant.getVehicleId(), grant.getReservationIds()); + step.start(); + repository.save(execution); + publishAndClear(order, false); + } + + @Override + @Transactional + public void onSucceeded(DeviceCommand command) { + Objects.requireNonNull(command, "command"); + DispatchReference reference = reference(command.getCommandKey()); + if (reference == null) { + return; + } + TransportOrderExecution execution = requireExecution( + reference.orderId); + TransportOrder order = execution.getOrder(); + WorkflowInstance workflow = execution.getWorkflow(); + TaskStep step = workflow.stepById(reference.stepId); + + if (step.getState() == TaskStepState.SUCCEEDED + && order.getState() == TransportOrderState.COMPLETED) { + return; + } + if (step.getState() != TaskStepState.RUNNING) { + throw new DomainRuleViolation( + "command completion requires a RUNNING task step"); + } + step.succeed(); + workflow.onStepSucceeded(step.getStepKey()); + if (workflow.allRequiredStepsSucceeded()) { + order.complete(); + } + repository.save(execution); + publishAndClear(order, true); + } + + private TransportOrderExecution requireExecution(String orderId) { + TransportOrderExecution execution = repository.load(orderId); + if (execution == null) { + throw new DomainRuleViolation( + "transport order execution not found: " + orderId); + } + return execution; + } + + private void publishAndClear(TransportOrder order, + boolean projectTerminalEvents) { + List events = order.getUncommittedEvents(); + eventPublisher.publish(events); + if (projectTerminalEvents) { + for (DomainEvent event : events) { + if (isTerminal(event.getType())) { + notificationProjector.project(notification(order, event), + event.getOccurredAt()); + } + } + } + order.clearUncommittedEvents(); + } + + private static WmsNotification notification(TransportOrder order, + DomainEvent event) { + return new WmsNotification(event.getEventId(), + order.getExternalRequestId(), order.getOrderId(), + event.getOccurredAt(), event.getType(), + order.getResultCode(), order.getStateReason(), + Collections.emptyMap()); + } + + private static boolean isTerminal(String eventType) { + return "OrderCompleted".equals(eventType) + || "OrderFailed".equals(eventType) + || "OrderCancelled".equals(eventType); + } + + private static DispatchReference reference(String commandKey) { + if (commandKey == null || !commandKey.startsWith(COMMAND_PREFIX)) { + return null; + } + String value = commandKey.substring(COMMAND_PREFIX.length()); + int separator = value.indexOf(':'); + if (separator <= 0 || separator == value.length() - 1) { + throw new DomainRuleViolation("invalid dispatch command key"); + } + return new DispatchReference(value.substring(0, separator), + value.substring(separator + 1)); + } + + private static final class DispatchReference { + private final String orderId; + private final String stepId; + + private DispatchReference(String orderId, String stepId) { + this.orderId = orderId; + this.stepId = stepId; + } + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/CommittedDeviceExecution.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/CommittedDeviceExecution.java index 82fdbe5..4af524d 100644 --- a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/CommittedDeviceExecution.java +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/execution/CommittedDeviceExecution.java @@ -55,7 +55,7 @@ public final class CommittedDeviceExecution { } this.topologyVersion = topologyVersion; this.fencingToken = fencingToken; - this.expectedBootId = requireText(expectedBootId, "expectedBootId"); + this.expectedBootId = optionalText(expectedBootId); this.currentNodeIndex = currentNodeIndex; this.releasedResourceCount = releasedResourceCount; this.lastDeviceSequence = lastDeviceSequence; @@ -134,6 +134,7 @@ public final class CommittedDeviceExecution { public long getTopologyVersion() { return topologyVersion; } public long getFencingToken() { return fencingToken; } public String getExpectedBootId() { return expectedBootId; } + public boolean hasExpectedBootSession() { return expectedBootId != null; } public long getLastDeviceSequence() { return lastDeviceSequence; } public static final class Progress { @@ -209,4 +210,8 @@ public final class CommittedDeviceExecution { } return value.trim(); } + + private static String optionalText(String value) { + return value == null || value.trim().isEmpty() ? null : value.trim(); + } } diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/TaskStep.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/TaskStep.java index 5ca3c6e..013750e 100644 --- a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/TaskStep.java +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/TaskStep.java @@ -5,6 +5,7 @@ import com.ruoyi.wcs.domain.shared.DomainRuleViolation; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Objects; public final class TaskStep { private final String stepId; @@ -28,6 +29,30 @@ public final class TaskStep { } } + public static TaskStep restore(String stepId, String stepKey, + TaskStepType type, boolean required, + TaskStepState state, String reasonCode, + String vehicleId, + List reservationIds, + long stateVersion) { + if (stateVersion < 0L) { + throw new IllegalArgumentException( + "stateVersion must not be negative"); + } + WorkflowStepDefinition definition = new WorkflowStepDefinition( + stepKey, type, required, true, + Collections.emptyList()); + TaskStep step = new TaskStep(stepId, definition); + step.state = Objects.requireNonNull(state, "state"); + step.reasonCode = reasonCode; + step.vehicleId = vehicleId; + step.reservationIds = Collections.unmodifiableList( + new ArrayList(Objects.requireNonNull( + reservationIds, "reservationIds"))); + step.stateVersion = stateVersion; + return step; + } + public void markReady() { requireState(TaskStepState.WAITING); transitionTo(TaskStepState.READY); diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/TransportOrderExecution.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/TransportOrderExecution.java new file mode 100644 index 0000000..7c9f72d --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/TransportOrderExecution.java @@ -0,0 +1,54 @@ +package com.ruoyi.wcs.domain.task; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +public final class TransportOrderExecution { + private final TransportOrder order; + private final WorkflowInstance workflow; + private final long loadedOrderVersion; + private final Map loadedStepVersions; + + private TransportOrderExecution(TransportOrder order, + WorkflowInstance workflow) { + this.order = Objects.requireNonNull(order, "order"); + this.workflow = Objects.requireNonNull(workflow, "workflow"); + if (!order.getOrderId().equals(workflow.getOrderId())) { + throw new IllegalArgumentException( + "workflow belongs to another transport order"); + } + this.loadedOrderVersion = order.getStateVersion(); + Map versions = new LinkedHashMap(); + for (TaskStep step : workflow.getSteps()) { + versions.put(step.getStepId(), step.getStateVersion()); + } + this.loadedStepVersions = Collections.unmodifiableMap(versions); + } + + public static TransportOrderExecution loaded(TransportOrder order, + WorkflowInstance workflow) { + return new TransportOrderExecution(order, workflow); + } + + public TransportOrder getOrder() { + return order; + } + + public WorkflowInstance getWorkflow() { + return workflow; + } + + public long getLoadedOrderVersion() { + return loadedOrderVersion; + } + + public long getLoadedStepVersion(String stepId) { + Long version = loadedStepVersions.get(stepId); + if (version == null) { + throw new IllegalArgumentException("unknown loaded step " + stepId); + } + return version; + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/WorkflowInstance.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/WorkflowInstance.java index 40fbc8c..f0401b2 100644 --- a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/WorkflowInstance.java +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/domain/task/WorkflowInstance.java @@ -32,6 +32,33 @@ public final class WorkflowInstance { validateDependencies(); } + private WorkflowInstance(String workflowId, String orderId, + List restoredSteps, + Map> dependencies) { + this.workflowId = requireText(workflowId, "workflowId"); + this.orderId = requireText(orderId, "orderId"); + this.steps = new LinkedHashMap(); + for (TaskStep step : restoredSteps) { + if (steps.put(step.getStepKey(), step) != null) { + throw new IllegalArgumentException( + "duplicate workflow step " + step.getStepKey()); + } + } + if (steps.isEmpty()) { + throw new IllegalArgumentException("steps must not be empty"); + } + this.mandatoryDependencies = + new LinkedHashMap>(); + for (String stepKey : steps.keySet()) { + List values = dependencies.get(stepKey); + this.mandatoryDependencies.put(stepKey, + values == null ? Collections.emptyList() + : Collections.unmodifiableList( + new ArrayList(values))); + } + validateDependencies(); + } + public static WorkflowInstance create(String workflowId, String orderId, WorkflowDefinition definition) { if (definition == null) { @@ -40,6 +67,17 @@ public final class WorkflowInstance { return new WorkflowInstance(workflowId, orderId, definition); } + public static WorkflowInstance restore(String workflowId, String orderId, + List steps, + Map> dependencies) { + if (steps == null || dependencies == null) { + throw new IllegalArgumentException( + "restored workflow data must not be null"); + } + return new WorkflowInstance(workflowId, orderId, + new ArrayList(steps), dependencies); + } + public List readySteps() { List result = new ArrayList(); for (Map.Entry entry : steps.entrySet()) { @@ -73,6 +111,26 @@ public final class WorkflowInstance { return step; } + public TaskStep stepById(String stepId) { + String required = requireText(stepId, "stepId"); + for (TaskStep step : steps.values()) { + if (required.equals(step.getStepId())) { + return step; + } + } + throw new IllegalArgumentException("unknown workflow step id " + stepId); + } + + public boolean allRequiredStepsSucceeded() { + for (TaskStep step : steps.values()) { + if (step.isRequired() + && step.getState() != TaskStepState.SUCCEEDED) { + return false; + } + } + return true; + } + public String getWorkflowId() { return workflowId; } diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/infrastructure/persistence/task/MyBatisTransportOrderRepository.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/infrastructure/persistence/task/MyBatisTransportOrderRepository.java index e1e9e29..c820c5b 100644 --- a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/infrastructure/persistence/task/MyBatisTransportOrderRepository.java +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/infrastructure/persistence/task/MyBatisTransportOrderRepository.java @@ -2,15 +2,21 @@ package com.ruoyi.wcs.infrastructure.persistence.task; import com.ruoyi.wcs.domain.task.TaskStep; import com.ruoyi.wcs.domain.task.TransportOrder; +import com.ruoyi.wcs.domain.task.TransportOrderExecution; import com.ruoyi.wcs.domain.task.WorkflowInstance; +import com.ruoyi.wcs.port.out.TransportOrderExecutionRepository; import com.ruoyi.wcs.port.out.TransportOrderRepository; import org.springframework.dao.OptimisticLockingFailureException; import java.util.List; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; -public final class MyBatisTransportOrderRepository implements TransportOrderRepository { +public final class MyBatisTransportOrderRepository + implements TransportOrderRepository, TransportOrderExecutionRepository { private final TransportOrderMapper mapper; public MyBatisTransportOrderRepository(TransportOrderMapper mapper) { @@ -73,4 +79,66 @@ public final class MyBatisTransportOrderRepository implements TransportOrderRepo "transport order state changed concurrently: " + order.getOrderId()); } } + + @Override + public TransportOrderExecution load(String orderId) { + TransportOrderRow orderRow = mapper.selectByOrderId(orderId); + if (orderRow == null) { + return null; + } + WorkflowRow workflowRow = mapper.selectWorkflowByOrderId(orderId); + if (workflowRow == null) { + throw new IllegalStateException( + "transport order has no workflow: " + orderId); + } + if (!orderId.equals(workflowRow.getOrderId())) { + throw new IllegalStateException( + "workflow belongs to another transport order"); + } + List steps = new ArrayList(); + for (TaskStepRow row : mapper.selectTaskSteps( + workflowRow.getWorkflowId())) { + steps.add(row.toDomain()); + } + Map> dependencies = + new LinkedHashMap>(); + for (TaskDependencyRow row : mapper.selectTaskDependencies( + workflowRow.getWorkflowId())) { + List values = dependencies.get(row.getStepKey()); + if (values == null) { + values = new ArrayList(); + dependencies.put(row.getStepKey(), values); + } + values.add(row.getDependsOnStepKey()); + } + WorkflowInstance workflow = WorkflowInstance.restore( + workflowRow.getWorkflowId(), workflowRow.getOrderId(), + steps, dependencies); + return TransportOrderExecution.loaded(orderRow.toDomain(), workflow); + } + + @Override + public void save(TransportOrderExecution execution) { + Objects.requireNonNull(execution, "execution"); + TransportOrder order = execution.getOrder(); + if (order.getStateVersion() != execution.getLoadedOrderVersion() + && mapper.updateState(TransportOrderRow.fromDomain(order), + execution.getLoadedOrderVersion()) != 1) { + throw new OptimisticLockingFailureException( + "transport order state changed concurrently: " + + order.getOrderId()); + } + for (TaskStep step : execution.getWorkflow().getSteps()) { + long expectedVersion = execution.getLoadedStepVersion( + step.getStepId()); + if (step.getStateVersion() != expectedVersion + && mapper.updateTaskStep(TaskStepRow.fromDomain( + execution.getWorkflow().getWorkflowId(), step), + expectedVersion) != 1) { + throw new OptimisticLockingFailureException( + "task step state changed concurrently: " + + step.getStepId()); + } + } + } } diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/infrastructure/persistence/task/TaskStepRow.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/infrastructure/persistence/task/TaskStepRow.java index 220531f..169e52e 100644 --- a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/infrastructure/persistence/task/TaskStepRow.java +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/infrastructure/persistence/task/TaskStepRow.java @@ -1,6 +1,10 @@ package com.ruoyi.wcs.infrastructure.persistence.task; import com.ruoyi.wcs.domain.task.TaskStep; +import com.ruoyi.wcs.domain.task.TaskStepState; +import com.ruoyi.wcs.domain.task.TaskStepType; + +import java.util.Collections; public final class TaskStepRow { private String stepId; @@ -27,6 +31,13 @@ public final class TaskStepRow { return row; } + public TaskStep toDomain() { + return TaskStep.restore(stepId, stepKey, + TaskStepType.valueOf(stepType), required, + TaskStepState.valueOf(stepState), reasonCode, vehicleId, + Collections.emptyList(), stateVersion); + } + public String getStepId() { return stepId; } diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/infrastructure/persistence/task/TransportOrderMapper.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/infrastructure/persistence/task/TransportOrderMapper.java index ff45c14..845b9af 100644 --- a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/infrastructure/persistence/task/TransportOrderMapper.java +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/infrastructure/persistence/task/TransportOrderMapper.java @@ -3,6 +3,8 @@ package com.ruoyi.wcs.infrastructure.persistence.task; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; +import java.util.List; + @Mapper public interface TransportOrderMapper { TransportOrderRow selectByExternalRequestId( @@ -20,4 +22,15 @@ public interface TransportOrderMapper { int insertTaskStep(TaskStepRow row); int insertTaskDependency(TaskDependencyRow row); + + WorkflowRow selectWorkflowByOrderId(@Param("orderId") String orderId); + + List selectTaskSteps( + @Param("workflowId") String workflowId); + + List selectTaskDependencies( + @Param("workflowId") String workflowId); + + int updateTaskStep(@Param("row") TaskStepRow row, + @Param("expectedVersion") long expectedVersion); } diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/TransportOrderExecutionRepository.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/TransportOrderExecutionRepository.java new file mode 100644 index 0000000..8266ad7 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/port/out/TransportOrderExecutionRepository.java @@ -0,0 +1,9 @@ +package com.ruoyi.wcs.port.out; + +import com.ruoyi.wcs.domain.task.TransportOrderExecution; + +public interface TransportOrderExecutionRepository { + TransportOrderExecution load(String orderId); + + void save(TransportOrderExecution execution); +} diff --git a/ruoyi-wcs/src/main/resources/mapper/wcs/task/TransportOrderMapper.xml b/ruoyi-wcs/src/main/resources/mapper/wcs/task/TransportOrderMapper.xml index 105dc9c..e25ced3 100644 --- a/ruoyi-wcs/src/main/resources/mapper/wcs/task/TransportOrderMapper.xml +++ b/ruoyi-wcs/src/main/resources/mapper/wcs/task/TransportOrderMapper.xml @@ -21,6 +21,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + order_id, external_request_id, order_type, load_id, source_code, destination_code, priority, order_state, workflow_id, result_code, @@ -88,4 +116,35 @@ #{workflowId}, #{stepKey}, #{dependsOnStepKey} ) + + + + + + + + + UPDATE dbo.wcs_task_step + SET step_state = #{row.stepState}, + reason_code = #{row.reasonCode}, + vehicle_id = #{row.vehicleId}, + state_version = #{row.stateVersion} + WHERE step_id = #{row.stepId} + AND state_version = #{expectedVersion} + diff --git a/ruoyi-wcs/src/test/java/com/ruoyi/wcs/infrastructure/persistence/task/MyBatisTransportOrderRepositoryTest.java b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/infrastructure/persistence/task/MyBatisTransportOrderRepositoryTest.java index 40dbf17..425943e 100644 --- a/ruoyi-wcs/src/test/java/com/ruoyi/wcs/infrastructure/persistence/task/MyBatisTransportOrderRepositoryTest.java +++ b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/infrastructure/persistence/task/MyBatisTransportOrderRepositoryTest.java @@ -1,23 +1,77 @@ package com.ruoyi.wcs.infrastructure.persistence.task; import com.ruoyi.wcs.domain.task.TaskStepType; +import com.ruoyi.wcs.domain.task.TaskStepState; import com.ruoyi.wcs.domain.task.TransportOrder; +import com.ruoyi.wcs.domain.task.TransportOrderExecution; +import com.ruoyi.wcs.domain.task.TransportOrderState; import com.ruoyi.wcs.domain.task.TransportOrderType; import com.ruoyi.wcs.domain.task.WorkflowDefinition; import com.ruoyi.wcs.domain.task.WorkflowInstance; import com.ruoyi.wcs.domain.task.WorkflowStepDefinition; +import com.ruoyi.wcs.port.out.TransportOrderExecutionRepository; import org.junit.jupiter.api.Test; import org.springframework.dao.OptimisticLockingFailureException; import java.time.Instant; 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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; class MyBatisTransportOrderRepositoryTest { + @Test + void implementsTheOrderExecutionRepositoryContract() { + Object repository = new MyBatisTransportOrderRepository( + new RecordingMapper()); + + assertTrue(repository instanceof TransportOrderExecutionRepository); + } + + @Test + void loadsAndOptimisticallySavesOrderAndWorkflowExecution() { + RecordingMapper mapper = new RecordingMapper(); + TransportOrder order = order(); + WorkflowInstance workflow = WorkflowInstance.create("WF-1", "ORDER-1", + new WorkflowDefinition(TransportOrderType.INBOUND, + Collections.singletonList(new WorkflowStepDefinition( + "MOVE", TaskStepType.MOVE, true, true, + Collections.emptyList())))); + order.plan(workflow.getWorkflowId()); + order.start(); + workflow.readySteps().get(0).assign("VEH-1"); + workflow.step("MOVE").start(); + mapper.selected = TransportOrderRow.fromDomain(order); + mapper.selectedWorkflow = WorkflowRow.fromDomain(workflow); + mapper.selectedSteps = Collections.singletonList( + TaskStepRow.fromDomain(workflow.getWorkflowId(), + workflow.step("MOVE"))); + + Object candidate = new MyBatisTransportOrderRepository(mapper); + assertTrue(candidate instanceof TransportOrderExecutionRepository); + TransportOrderExecutionRepository repository = + (TransportOrderExecutionRepository) candidate; + + TransportOrderExecution execution = repository.load("ORDER-1"); + + assertEquals(TransportOrderState.EXECUTING, + execution.getOrder().getState()); + assertEquals(TaskStepState.RUNNING, + execution.getWorkflow().step("MOVE").getState()); + + execution.getWorkflow().step("MOVE").succeed(); + execution.getOrder().complete(); + repository.save(execution); + + assertEquals(2L, mapper.expectedVersion); + assertEquals(3L, mapper.expectedStepVersion); + assertEquals(1, mapper.stepUpdates); + } + @Test void rejectsAStateUpdateWhenThePersistedVersionChanged() { RecordingMapper mapper = new RecordingMapper(); @@ -72,6 +126,12 @@ class MyBatisTransportOrderRepositoryTest { private int workflowInserts; private int stepInserts; private int dependencyInserts; + private WorkflowRow selectedWorkflow; + private List selectedSteps = Collections.emptyList(); + private List selectedDependencies = + Collections.emptyList(); + private long expectedStepVersion = -1L; + private int stepUpdates; @Override public TransportOrderRow selectByExternalRequestId(String externalRequestId) { @@ -112,5 +172,28 @@ class MyBatisTransportOrderRepositoryTest { dependencyInserts++; return 1; } + + @Override + public WorkflowRow selectWorkflowByOrderId(String orderId) { + return selectedWorkflow; + } + + @Override + public List selectTaskSteps(String workflowId) { + return selectedSteps; + } + + @Override + public List selectTaskDependencies( + String workflowId) { + return selectedDependencies; + } + + @Override + public int updateTaskStep(TaskStepRow row, long expectedVersion) { + expectedStepVersion = expectedVersion; + stepUpdates++; + return 1; + } } } diff --git a/ruoyi-wcs/src/test/java/com/ruoyi/wcs/infrastructure/persistence/task/TransportOrderMapperXmlTest.java b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/infrastructure/persistence/task/TransportOrderMapperXmlTest.java index ded8c18..203c107 100644 --- a/ruoyi-wcs/src/test/java/com/ruoyi/wcs/infrastructure/persistence/task/TransportOrderMapperXmlTest.java +++ b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/infrastructure/persistence/task/TransportOrderMapperXmlTest.java @@ -29,5 +29,9 @@ class TransportOrderMapperXmlTest { assertTrue(configuration.hasStatement(namespace + "insertWorkflow")); assertTrue(configuration.hasStatement(namespace + "insertTaskStep")); assertTrue(configuration.hasStatement(namespace + "insertTaskDependency")); + assertTrue(configuration.hasStatement(namespace + "selectWorkflowByOrderId")); + assertTrue(configuration.hasStatement(namespace + "selectTaskSteps")); + assertTrue(configuration.hasStatement(namespace + "selectTaskDependencies")); + assertTrue(configuration.hasStatement(namespace + "updateTaskStep")); } } diff --git a/sql/wcs/V005__ft546_wcs_menus_and_permissions.sql b/sql/wcs/V005__ft546_wcs_menus_and_permissions.sql new file mode 100644 index 0000000..8c61e59 --- /dev/null +++ b/sql/wcs/V005__ft546_wcs_menus_and_permissions.sql @@ -0,0 +1,120 @@ +SET XACT_ABORT ON; +BEGIN TRANSACTION; + +IF OBJECT_ID('dbo.sys_menu', 'U') IS NULL +BEGIN + THROW 50001, 'dbo.sys_menu is required before installing FT546 WCS menus', 1; +END; + +DECLARE @wcs_menu_id bigint; +SELECT TOP (1) @wcs_menu_id = menu_id +FROM dbo.sys_menu +WHERE parent_id = 0 + AND path = 'wcs' + AND menu_type = 'M' +ORDER BY menu_id; + +IF @wcs_menu_id IS NULL +BEGIN + INSERT INTO dbo.sys_menu ( + menu_name, parent_id, order_num, path, component, query, route_name, + is_frame, is_cache, menu_type, visible, status, perms, icon, + create_by, create_time, update_by, update_time, remark) + VALUES ( + N'WCS调度', 0, 5, 'wcs', NULL, NULL, 'Wcs', + 1, 0, 'M', '0', '0', NULL, 'dashboard', + 'admin', GETDATE(), '', NULL, N'FT546 WCS调度目录'); + SET @wcs_menu_id = CONVERT(bigint, SCOPE_IDENTITY()); +END; + +IF NOT EXISTS ( + SELECT 1 FROM dbo.sys_menu WHERE perms = 'wcs:order:list') +BEGIN + INSERT INTO dbo.sys_menu ( + menu_name, parent_id, order_num, path, component, query, route_name, + is_frame, is_cache, menu_type, visible, status, perms, icon, + create_by, create_time, update_by, update_time, remark) + VALUES ( + N'任务监控', @wcs_menu_id, 1, 'order', 'wcs/order/index', NULL, + 'WcsOrder', 1, 0, 'C', '0', '0', 'wcs:order:list', 'list', + 'admin', GETDATE(), '', NULL, N'标准WCS任务监控'); +END; + +IF NOT EXISTS ( + SELECT 1 FROM dbo.sys_menu WHERE perms = 'wcs:device:list') +BEGIN + INSERT INTO dbo.sys_menu ( + menu_name, parent_id, order_num, path, component, query, route_name, + is_frame, is_cache, menu_type, visible, status, perms, icon, + create_by, create_time, update_by, update_time, remark) + VALUES ( + N'设备监控', @wcs_menu_id, 2, 'device', 'wcs/device/index', NULL, + 'WcsDevice', 1, 0, 'C', '0', '0', 'wcs:device:list', 'monitor', + 'admin', GETDATE(), '', NULL, N'标准WCS设备事实监控'); +END; + +IF NOT EXISTS ( + SELECT 1 FROM dbo.sys_menu WHERE perms = 'wcs:traffic:list') +BEGIN + INSERT INTO dbo.sys_menu ( + menu_name, parent_id, order_num, path, component, query, route_name, + is_frame, is_cache, menu_type, visible, status, perms, icon, + create_by, create_time, update_by, update_time, remark) + VALUES ( + N'交通资源', @wcs_menu_id, 3, 'traffic', 'wcs/traffic/index', NULL, + 'WcsTraffic', 1, 0, 'C', '0', '0', 'wcs:traffic:list', 'tree', + 'admin', GETDATE(), '', NULL, N'标准WCS资源占用与避让监控'); +END; + +IF NOT EXISTS ( + SELECT 1 FROM dbo.sys_menu WHERE perms = 'wcs:recovery:list') +BEGIN + INSERT INTO dbo.sys_menu ( + menu_name, parent_id, order_num, path, component, query, route_name, + is_frame, is_cache, menu_type, visible, status, perms, icon, + create_by, create_time, update_by, update_time, remark) + VALUES ( + N'恢复处置', @wcs_menu_id, 4, 'recovery', 'wcs/recovery/index', NULL, + 'WcsRecovery', 1, 0, 'C', '0', '0', 'wcs:recovery:list', 'skill', + 'admin', GETDATE(), '', NULL, N'标准WCS人工恢复处置'); +END; + +DECLARE @order_menu_id bigint; +SELECT TOP (1) @order_menu_id = menu_id +FROM dbo.sys_menu +WHERE perms = 'wcs:order:list' +ORDER BY menu_id; + +IF NOT EXISTS ( + SELECT 1 FROM dbo.sys_menu WHERE perms = 'wcs:order:control') +BEGIN + INSERT INTO dbo.sys_menu ( + menu_name, parent_id, order_num, path, component, query, route_name, + is_frame, is_cache, menu_type, visible, status, perms, icon, + create_by, create_time, update_by, update_time, remark) + VALUES ( + N'任务控制', @order_menu_id, 1, '#', NULL, NULL, '', + 1, 0, 'F', '0', '0', 'wcs:order:control', '#', + 'admin', GETDATE(), '', NULL, N'暂停、恢复和取消标准WCS任务'); +END; + +DECLARE @recovery_menu_id bigint; +SELECT TOP (1) @recovery_menu_id = menu_id +FROM dbo.sys_menu +WHERE perms = 'wcs:recovery:list' +ORDER BY menu_id; + +IF NOT EXISTS ( + SELECT 1 FROM dbo.sys_menu WHERE perms = 'wcs:recovery:confirm') +BEGIN + INSERT INTO dbo.sys_menu ( + menu_name, parent_id, order_num, path, component, query, route_name, + is_frame, is_cache, menu_type, visible, status, perms, icon, + create_by, create_time, update_by, update_time, remark) + VALUES ( + N'恢复确认', @recovery_menu_id, 1, '#', NULL, NULL, '', + 1, 0, 'F', '0', '0', 'wcs:recovery:confirm', '#', + 'admin', GETDATE(), '', NULL, N'确认或终止标准WCS恢复案例'); +END; + +COMMIT TRANSACTION;