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 d36edfe..e3e2023 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 @@ -6,8 +6,13 @@ import com.ruoyi.wcs.application.device.DeviceExecutionService; import com.ruoyi.wcs.application.device.SnapshotCollector; import com.ruoyi.wcs.application.dispatch.DispatchCoordinator; import com.ruoyi.wcs.application.recovery.RecoveryGate; +import com.ruoyi.wcs.application.recovery.RecoveryApplicationService; +import com.ruoyi.wcs.domain.shared.IdGenerator; import com.ruoyi.wcs.domain.shared.WcsClock; import com.ruoyi.wcs.port.out.DispatchSnapshotProvider; +import com.ruoyi.wcs.port.out.RecoveryCaseRepository; +import com.ruoyi.wcs.query.WcsOperationsQueryService; +import com.ruoyi.wcs.query.WcsOperationsReadRepository; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -37,4 +42,18 @@ public class Ft546AdapterConfiguration { WcsRuntimeCoordinator coordinator) { return new Ft546WcsScheduler(coordinator); } + + @Bean + public WcsOperationsQueryService wcsOperationsQueryService( + WcsOperationsReadRepository repository) { + return new WcsOperationsQueryService(repository); + } + + @Bean + public RecoveryApplicationService recoveryApplicationService( + RecoveryCaseRepository repository, IdGenerator idGenerator, + WcsClock clock) { + return new RecoveryApplicationService(repository, idGenerator, + clock::now); + } } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/web/WcsDeviceController.java b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/web/WcsDeviceController.java new file mode 100644 index 0000000..40a23f1 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/web/WcsDeviceController.java @@ -0,0 +1,47 @@ +package com.ruoyi.project.ft546.web; + +import com.ruoyi.common.constant.HttpStatus; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.wcs.query.OperationsPage; +import com.ruoyi.wcs.query.WcsOperationsQueryService; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Objects; + +@RestController +@RequestMapping("/wcs/devices") +@ConditionalOnProperty(prefix = "ft546.wcs", name = "runtime-mode", + havingValue = "STANDARD") +public class WcsDeviceController extends BaseController { + private final WcsOperationsQueryService queries; + + public WcsDeviceController(WcsOperationsQueryService queries) { + this.queries = Objects.requireNonNull(queries, "queries"); + } + + @PreAuthorize("@ss.hasPermi('wcs:device:list')") + @GetMapping + public TableDataInfo list(@RequestParam(required = false) String state, + @RequestParam(defaultValue = "1") int pageNum, + @RequestParam(defaultValue = "20") int pageSize) { + OperationsPage page = queries.devices(state, + offset(pageNum, pageSize), pageSize); + TableDataInfo result = new TableDataInfo(page.getRows(), page.getTotal()); + result.setCode(HttpStatus.SUCCESS); + result.setMsg("查询成功"); + return result; + } + + private static int offset(int pageNum, int pageSize) { + if (pageNum <= 0 || pageSize <= 0) { + throw new IllegalArgumentException("invalid page request"); + } + return Math.multiplyExact(pageNum - 1, pageSize); + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/web/WcsOrderController.java b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/web/WcsOrderController.java new file mode 100644 index 0000000..a0e1018 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/web/WcsOrderController.java @@ -0,0 +1,97 @@ +package com.ruoyi.project.ft546.web; + +import com.ruoyi.common.constant.HttpStatus; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.wcs.application.task.TransportOrderResult; +import com.ruoyi.wcs.port.in.TransportOrderUseCase; +import com.ruoyi.wcs.query.OperationsPage; +import com.ruoyi.wcs.query.OrderExecutionView; +import com.ruoyi.wcs.query.WcsOperationsQueryService; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Objects; + +@RestController +@RequestMapping("/wcs/orders") +@ConditionalOnProperty(prefix = "ft546.wcs", name = "runtime-mode", + havingValue = "STANDARD") +public class WcsOrderController extends BaseController { + private final WcsOperationsQueryService queries; + private final TransportOrderUseCase orders; + + public WcsOrderController(WcsOperationsQueryService queries, + TransportOrderUseCase orders) { + this.queries = Objects.requireNonNull(queries, "queries"); + this.orders = Objects.requireNonNull(orders, "orders"); + } + + @PreAuthorize("@ss.hasPermi('wcs:order:list')") + @GetMapping + public TableDataInfo list( + @RequestParam(required = false) String state, + @RequestParam(defaultValue = "1") int pageNum, + @RequestParam(defaultValue = "20") int pageSize) { + return table(queries.orders(state, offset(pageNum, pageSize), pageSize)); + } + + @PreAuthorize("@ss.hasPermi('wcs:order:control')") + @PostMapping("/{id}/suspend") + public AjaxResult suspend(@PathVariable("id") String orderId, + @RequestBody OrderControlRequest request) { + TransportOrderResult result = orders.suspend(orderId, + requireText(request == null ? null : request.getReason(), + "reason")); + return success(result); + } + + @PreAuthorize("@ss.hasPermi('wcs:order:control')") + @PostMapping("/{id}/resume") + public AjaxResult resume(@PathVariable("id") String orderId) { + return success(orders.resume(orderId)); + } + + @PreAuthorize("@ss.hasPermi('wcs:order:control')") + @PostMapping("/{id}/cancel") + public AjaxResult cancel(@PathVariable("id") String orderId) { + OrderExecutionView view = queries.order(orderId); + return success(orders.cancel(requireText(view.getExternalRequestId(), + "externalRequestId"))); + } + + private static TableDataInfo table(OperationsPage page) { + TableDataInfo result = new TableDataInfo(page.getRows(), page.getTotal()); + result.setCode(HttpStatus.SUCCESS); + result.setMsg("查询成功"); + return result; + } + + private static int offset(int pageNum, int pageSize) { + if (pageNum <= 0 || pageSize <= 0) { + throw new IllegalArgumentException("invalid page request"); + } + return Math.multiplyExact(pageNum - 1, pageSize); + } + + private static String requireText(String value, String field) { + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException(field + " must not be blank"); + } + return value.trim(); + } + + public static final class OrderControlRequest { + private String reason; + public String getReason() { return reason; } + public void setReason(String reason) { this.reason = reason; } + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/web/WcsRecoveryController.java b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/web/WcsRecoveryController.java new file mode 100644 index 0000000..7335e8c --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/web/WcsRecoveryController.java @@ -0,0 +1,124 @@ +package com.ruoyi.project.ft546.web; + +import com.ruoyi.common.constant.HttpStatus; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.wcs.domain.recovery.RecoveryEvidence; +import com.ruoyi.wcs.domain.shared.WcsClock; +import com.ruoyi.wcs.port.in.RecoveryUseCase; +import com.ruoyi.wcs.query.OperationsPage; +import com.ruoyi.wcs.query.WcsOperationsQueryService; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Objects; + +@RestController +@RequestMapping("/wcs/recovery/cases") +@ConditionalOnProperty(prefix = "ft546.wcs", name = "runtime-mode", + havingValue = "STANDARD") +public class WcsRecoveryController extends BaseController { + private final WcsOperationsQueryService queries; + private final RecoveryUseCase recovery; + private final WcsClock clock; + + public WcsRecoveryController(WcsOperationsQueryService queries, + RecoveryUseCase recovery, WcsClock clock) { + this.queries = Objects.requireNonNull(queries, "queries"); + this.recovery = Objects.requireNonNull(recovery, "recovery"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + @PreAuthorize("@ss.hasPermi('wcs:recovery:list')") + @GetMapping + public TableDataInfo list( + @RequestParam(defaultValue = "OPEN") String state, + @RequestParam(defaultValue = "1") int pageNum, + @RequestParam(defaultValue = "20") int pageSize) { + OperationsPage page = queries.recoveryCasePage(state, + offset(pageNum, pageSize), pageSize); + TableDataInfo result = new TableDataInfo(page.getRows(), page.getTotal()); + result.setCode(HttpStatus.SUCCESS); + result.setMsg("查询成功"); + return result; + } + + @PreAuthorize("@ss.hasPermi('wcs:recovery:confirm')") + @PostMapping("/{id}/confirm") + public AjaxResult confirm(@PathVariable("id") String caseId, + @RequestBody RecoveryConfirmRequest request) { + if (request == null || request.getLoaded() == null) { + throw new IllegalArgumentException("loaded must be specified"); + } + RecoveryEvidence evidence = new RecoveryEvidence( + request.getCurrentSite(), + request.getLoaded() ? "LOADED" : "EMPTY", + request.getTaskNumber(), request.getDeviceMode(), clock.now(), + "OPERATOR_UI", request.getReason()); + recovery.confirmFacts(caseId, getUsername(), evidence); + return success(); + } + + @PreAuthorize("@ss.hasPermi('wcs:recovery:confirm')") + @PostMapping("/{id}/abort") + public AjaxResult abort(@PathVariable("id") String caseId, + @RequestBody RecoveryAbortRequest request) { + recovery.abort(caseId, getUsername(), + requireText(request == null ? null : request.getReason(), + "reason")); + return success(); + } + + private static int offset(int pageNum, int pageSize) { + if (pageNum <= 0 || pageSize <= 0) { + throw new IllegalArgumentException("invalid page request"); + } + return Math.multiplyExact(pageNum - 1, pageSize); + } + + private static String requireText(String value, String field) { + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException(field + " must not be blank"); + } + return value.trim(); + } + + public static final class RecoveryConfirmRequest { + private String reason; + private String currentSite; + private Boolean loaded; + private String taskNumber; + private String deviceMode; + + public String getReason() { return reason; } + public void setReason(String reason) { this.reason = reason; } + public String getCurrentSite() { return currentSite; } + public void setCurrentSite(String currentSite) { + this.currentSite = currentSite; + } + public Boolean getLoaded() { return loaded; } + public void setLoaded(Boolean loaded) { this.loaded = loaded; } + public String getTaskNumber() { return taskNumber; } + public void setTaskNumber(String taskNumber) { + this.taskNumber = taskNumber; + } + public String getDeviceMode() { return deviceMode; } + public void setDeviceMode(String deviceMode) { + this.deviceMode = deviceMode; + } + } + + public static final class RecoveryAbortRequest { + private String reason; + public String getReason() { return reason; } + public void setReason(String reason) { this.reason = reason; } + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/web/WcsTrafficController.java b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/web/WcsTrafficController.java new file mode 100644 index 0000000..f0c5c0e --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/web/WcsTrafficController.java @@ -0,0 +1,47 @@ +package com.ruoyi.project.ft546.web; + +import com.ruoyi.common.constant.HttpStatus; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.wcs.query.OperationsPage; +import com.ruoyi.wcs.query.WcsOperationsQueryService; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Objects; + +@RestController +@RequestMapping("/wcs/traffic/resources") +@ConditionalOnProperty(prefix = "ft546.wcs", name = "runtime-mode", + havingValue = "STANDARD") +public class WcsTrafficController extends BaseController { + private final WcsOperationsQueryService queries; + + public WcsTrafficController(WcsOperationsQueryService queries) { + this.queries = Objects.requireNonNull(queries, "queries"); + } + + @PreAuthorize("@ss.hasPermi('wcs:traffic:list')") + @GetMapping + public TableDataInfo list(@RequestParam(required = false) String state, + @RequestParam(defaultValue = "1") int pageNum, + @RequestParam(defaultValue = "20") int pageSize) { + OperationsPage page = queries.resources(state, + offset(pageNum, pageSize), pageSize); + TableDataInfo result = new TableDataInfo(page.getRows(), page.getTotal()); + result.setCode(HttpStatus.SUCCESS); + result.setMsg("查询成功"); + return result; + } + + private static int offset(int pageNum, int pageSize) { + if (pageNum <= 0 || pageSize <= 0) { + throw new IllegalArgumentException("invalid page request"); + } + return Math.multiplyExact(pageNum - 1, pageSize); + } +} diff --git a/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/web/WcsOperationsControllerTest.java b/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/web/WcsOperationsControllerTest.java new file mode 100644 index 0000000..7b5fc09 --- /dev/null +++ b/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/web/WcsOperationsControllerTest.java @@ -0,0 +1,299 @@ +package com.ruoyi.project.ft546.web; + +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.common.core.domain.model.LoginUser; +import com.ruoyi.wcs.application.task.CreateTransportOrderCommand; +import com.ruoyi.wcs.application.task.TransportOrderResult; +import com.ruoyi.wcs.domain.recovery.RecoveryEvidence; +import com.ruoyi.wcs.port.in.RecoveryUseCase; +import com.ruoyi.wcs.port.in.TransportOrderUseCase; +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.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.lang.reflect.Method; +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.assertNotNull; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +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; + +class WcsOperationsControllerTest { + private RecordingOrderUseCase orders; + private RecordingRecoveryUseCase recovery; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + orders = new RecordingOrderUseCase(); + recovery = new RecordingRecoveryUseCase(); + WcsOperationsQueryService queries = new WcsOperationsQueryService( + new OperationsReadRepository()); + mockMvc = MockMvcBuilders.standaloneSetup( + new WcsOrderController(queries, orders), + new WcsDeviceController(queries), + new WcsTrafficController(queries), + new WcsRecoveryController(queries, recovery, + () -> Instant.parse("2026-08-11T08:00:00Z"))) + .build(); + } + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void returnsPagedOrdersWithCanonicalAndExternalIdentifiers() + throws Exception { + mockMvc.perform(get("/wcs/orders") + .param("state", "EXECUTING") + .param("pageNum", "1") + .param("pageSize", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.total").value(1)) + .andExpect(jsonPath("$.rows[0].orderId").value("ORDER-1")) + .andExpect(jsonPath("$.rows[0].externalRequestId") + .value("TASK-1")) + .andExpect(jsonPath("$.rows[0].orderState") + .value("EXECUTING")); + } + + @Test + void returnsResourceCapacityProtectionAndHolderEvidence() + throws Exception { + mockMvc.perform(get("/wcs/traffic/resources") + .param("state", "OCCUPIED") + .param("pageNum", "1") + .param("pageSize", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.rows[0].resourceType").value("NODE")) + .andExpect(jsonPath("$.rows[0].capacity").value(2)) + .andExpect(jsonPath("$.rows[0].protectedCount").value(1)) + .andExpect(jsonPath("$.rows[0].holder").value("VEH-1")) + .andExpect(jsonPath("$.rows[0].reservationState") + .value("OCCUPIED")); + } + + @Test + void orderControlsUseTheCorrectCoreIdentifiers() throws Exception { + mockMvc.perform(post("/wcs/orders/ORDER-1/suspend") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"reason\":\"现场暂停\"}")) + .andExpect(status().isOk()); + mockMvc.perform(post("/wcs/orders/ORDER-1/resume")) + .andExpect(status().isOk()); + mockMvc.perform(post("/wcs/orders/ORDER-1/cancel")) + .andExpect(status().isOk()); + + assertEquals("ORDER-1", orders.suspendedOrderId); + assertEquals("现场暂停", orders.suspendReason); + assertEquals("ORDER-1", orders.resumedOrderId); + assertEquals("TASK-1", orders.cancelledExternalRequestId); + } + + @Test + void confirmsRecoveryWithAuthenticatedOperatorAndCompleteEvidence() + throws Exception { + authenticate("operator-a"); + + mockMvc.perform(post("/wcs/recovery/cases/RC-1/confirm") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"reason\":\"现场复核\"," + + "\"currentSite\":\"070501\"," + + "\"loaded\":false," + + "\"taskNumber\":\"30001\"," + + "\"deviceMode\":\"AUTO\"}")) + .andExpect(status().isOk()); + + assertEquals("RC-1", recovery.confirmedCaseId); + assertEquals("operator-a", recovery.operatorId); + assertEquals("070501", recovery.evidence.getObservedSite()); + assertEquals("EMPTY", recovery.evidence.getLoadState()); + assertEquals("30001", recovery.evidence.getTaskNumber()); + assertEquals("AUTO", recovery.evidence.getDeviceMode()); + assertEquals("现场复核", recovery.evidence.getOperatorReason()); + assertEquals(Instant.parse("2026-08-11T08:00:00Z"), + recovery.evidence.getEvidenceTime()); + } + + @Test + void declaresRecoveryConfirmationPermission() { + Method confirm = null; + for (Method method : WcsRecoveryController.class.getMethods()) { + if ("confirm".equals(method.getName())) { + confirm = method; + break; + } + } + assertNotNull(confirm); + PreAuthorize permission = confirm.getAnnotation(PreAuthorize.class); + assertNotNull(permission); + assertEquals("@ss.hasPermi('wcs:recovery:confirm')", + permission.value()); + } + + private static void authenticate(String username) { + SysUser user = new SysUser(); + user.setUserName(username); + LoginUser loginUser = new LoginUser(user, + Collections.singleton("wcs:recovery:confirm")); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(loginUser, null, + Collections.emptyList())); + } + + private static final class RecordingOrderUseCase + implements TransportOrderUseCase { + private String cancelledExternalRequestId; + private String suspendedOrderId; + private String suspendReason; + private String resumedOrderId; + + @Override + public TransportOrderResult create(CreateTransportOrderCommand command) { + return null; + } + + @Override + public TransportOrderResult cancel(String externalRequestId) { + cancelledExternalRequestId = externalRequestId; + return null; + } + + @Override + public TransportOrderResult suspend(String orderId, String reason) { + suspendedOrderId = orderId; + suspendReason = reason; + return null; + } + + @Override + public TransportOrderResult resume(String orderId) { + resumedOrderId = orderId; + return null; + } + } + + private static final class RecordingRecoveryUseCase + implements RecoveryUseCase { + private String confirmedCaseId; + private String operatorId; + private RecoveryEvidence evidence; + + @Override + public void confirmFacts(String caseId, String operator, + RecoveryEvidence recoveryEvidence) { + confirmedCaseId = caseId; + operatorId = operator; + evidence = recoveryEvidence; + } + + @Override + public void abort(String caseId, String operator, String reason) { + } + } + + private static final class OperationsReadRepository + implements WcsOperationsReadRepository { + @Override + public OrderExecutionView findOrder(String orderId) { + return "ORDER-1".equals(orderId) + ? new OrderExecutionView("ORDER-1", "TASK-1", + "EXECUTING", "WF-1", "STEP-1", "VEH-1", "CMD-1", + "SENT", null, Instant.EPOCH) : null; + } + + @Override + public OrderExecutionView findCommand(String commandKey) { + return findOrder("ORDER-1"); + } + + @Override + public DeviceOperationsView findDevice(String deviceId) { + return new DeviceOperationsView("VEH-1", "SHUTTLE", "CMD-1", + "SENT", "070501", true, null, null, Instant.EPOCH); + } + + @Override + public ResourceOperationsView findResource(String resourceKey) { + return new ResourceOperationsView("NODE:070501", "NODE", 2, 1, + "VEH-1", "OCCUPIED", "ORDER-1", "STEP-1", + "VEH-1", 11L, null, Instant.EPOCH); + } + + @Override + public RecoveryCaseView findRecoveryCase(String caseId) { + return recovery(caseId); + } + + @Override + public List findOrders(String state, int offset, + int limit) { + return Collections.singletonList(findOrder("ORDER-1")); + } + + @Override + public long countOrders(String state) { + return 1L; + } + + @Override + public List findDevices(String state, + int offset, int limit) { + return Collections.singletonList(findDevice("VEH-1")); + } + + @Override + public long countDevices(String state) { + return 1L; + } + + @Override + public List findResources(String state, + int offset, + int limit) { + return Collections.singletonList(findResource("NODE:070501")); + } + + @Override + public long countResources(String state) { + return 1L; + } + + @Override + public List findRecoveryCases(String state, + int offset, + int limit) { + return Arrays.asList(recovery("RC-1"), recovery("RC-2")); + } + + @Override + public long countRecoveryCases(String state) { + return 2L; + } + + private static RecoveryCaseView recovery(String caseId) { + return new RecoveryCaseView(caseId, "VEH-1", "CMD-1", "OPEN", + "TASK_NUMBER_MISMATCH", Instant.EPOCH, null, null); + } + } +} diff --git a/ruoyi-ui/src/api/wcs/device.js b/ruoyi-ui/src/api/wcs/device.js new file mode 100644 index 0000000..bd0532e --- /dev/null +++ b/ruoyi-ui/src/api/wcs/device.js @@ -0,0 +1,5 @@ +import request from '@/utils/request' + +export function listDevices(query) { + return request({ url: '/wcs/devices', method: 'get', params: query }) +} diff --git a/ruoyi-ui/src/api/wcs/order.js b/ruoyi-ui/src/api/wcs/order.js new file mode 100644 index 0000000..05679c5 --- /dev/null +++ b/ruoyi-ui/src/api/wcs/order.js @@ -0,0 +1,21 @@ +import request from '@/utils/request' + +export function listOrders(query) { + return request({ url: '/wcs/orders', method: 'get', params: query }) +} + +export function suspendOrder(orderId, reason) { + return request({ + url: `/wcs/orders/${orderId}/suspend`, + method: 'post', + data: { reason } + }) +} + +export function resumeOrder(orderId) { + return request({ url: `/wcs/orders/${orderId}/resume`, method: 'post' }) +} + +export function cancelOrder(orderId) { + return request({ url: `/wcs/orders/${orderId}/cancel`, method: 'post' }) +} diff --git a/ruoyi-ui/src/api/wcs/recovery.js b/ruoyi-ui/src/api/wcs/recovery.js new file mode 100644 index 0000000..0ccab7e --- /dev/null +++ b/ruoyi-ui/src/api/wcs/recovery.js @@ -0,0 +1,21 @@ +import request from '@/utils/request' + +export function listRecoveryCases(query) { + return request({ url: '/wcs/recovery/cases', method: 'get', params: query }) +} + +export function confirmRecovery(caseId, data) { + return request({ + url: `/wcs/recovery/cases/${caseId}/confirm`, + method: 'post', + data + }) +} + +export function abortRecovery(caseId, reason) { + return request({ + url: `/wcs/recovery/cases/${caseId}/abort`, + method: 'post', + data: { reason } + }) +} diff --git a/ruoyi-ui/src/api/wcs/traffic.js b/ruoyi-ui/src/api/wcs/traffic.js new file mode 100644 index 0000000..e4bc20f --- /dev/null +++ b/ruoyi-ui/src/api/wcs/traffic.js @@ -0,0 +1,9 @@ +import request from '@/utils/request' + +export function listResources(query) { + return request({ + url: '/wcs/traffic/resources', + method: 'get', + params: query + }) +} diff --git a/ruoyi-ui/src/views/wcs/device/index.vue b/ruoyi-ui/src/views/wcs/device/index.vue new file mode 100644 index 0000000..8fd5d15 --- /dev/null +++ b/ruoyi-ui/src/views/wcs/device/index.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/ruoyi-ui/src/views/wcs/order/index.vue b/ruoyi-ui/src/views/wcs/order/index.vue new file mode 100644 index 0000000..1fa08f9 --- /dev/null +++ b/ruoyi-ui/src/views/wcs/order/index.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/ruoyi-ui/src/views/wcs/recovery/index.vue b/ruoyi-ui/src/views/wcs/recovery/index.vue new file mode 100644 index 0000000..9ed07b0 --- /dev/null +++ b/ruoyi-ui/src/views/wcs/recovery/index.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/ruoyi-ui/src/views/wcs/traffic/index.vue b/ruoyi-ui/src/views/wcs/traffic/index.vue new file mode 100644 index 0000000..87ab00c --- /dev/null +++ b/ruoyi-ui/src/views/wcs/traffic/index.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/OperationsPage.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/OperationsPage.java new file mode 100644 index 0000000..24f22f5 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/OperationsPage.java @@ -0,0 +1,29 @@ +package com.ruoyi.wcs.query; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +public final class OperationsPage { + private final List rows; + private final long total; + private final int offset; + private final int limit; + + public OperationsPage(List rows, long total, int offset, int limit) { + Objects.requireNonNull(rows, "rows"); + if (total < 0L || offset < 0 || limit <= 0) { + throw new IllegalArgumentException("invalid operations page"); + } + this.rows = Collections.unmodifiableList(new ArrayList(rows)); + this.total = total; + this.offset = offset; + this.limit = limit; + } + + public List getRows() { return rows; } + public long getTotal() { return total; } + public int getOffset() { return offset; } + public int getLimit() { return limit; } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/OrderExecutionView.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/OrderExecutionView.java index 90c867b..9c8dc0a 100644 --- a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/OrderExecutionView.java +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/OrderExecutionView.java @@ -5,6 +5,8 @@ import java.util.Objects; public final class OrderExecutionView { private final String orderId; + private final String externalRequestId; + private final String orderState; private final String workflowId; private final String stepId; private final String deviceId; @@ -17,7 +19,18 @@ public final class OrderExecutionView { String deviceId, String currentCommandKey, String commandState, String recoveryCaseId, Instant updatedAt) { + this(orderId, null, null, workflowId, stepId, deviceId, + currentCommandKey, commandState, recoveryCaseId, updatedAt); + } + + public OrderExecutionView(String orderId, String externalRequestId, + String orderState, String workflowId, + String stepId, String deviceId, + String currentCommandKey, String commandState, + String recoveryCaseId, Instant updatedAt) { this.orderId = requireText(orderId, "orderId"); + this.externalRequestId = optional(externalRequestId); + this.orderState = optional(orderState); this.workflowId = optional(workflowId); this.stepId = optional(stepId); this.deviceId = optional(deviceId); @@ -28,6 +41,8 @@ public final class OrderExecutionView { } public String getOrderId() { return orderId; } + public String getExternalRequestId() { return externalRequestId; } + public String getOrderState() { return orderState; } public String getWorkflowId() { return workflowId; } public String getStepId() { return stepId; } public String getDeviceId() { return deviceId; } diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/ResourceOperationsView.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/ResourceOperationsView.java index 5bc308f..134432d 100644 --- a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/ResourceOperationsView.java +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/ResourceOperationsView.java @@ -5,6 +5,10 @@ import java.util.Objects; public final class ResourceOperationsView { private final String resourceKey; + private final String resourceType; + private final Integer capacity; + private final Integer protectedCount; + private final String holder; private final String reservationState; private final String orderId; private final String stepId; @@ -17,14 +21,44 @@ public final class ResourceOperationsView { String orderId, String stepId, String vehicleId, long fencingToken, String releaseEvidenceId, Instant updatedAt) { + this(resourceKey, null, null, null, vehicleId, reservationState, + orderId, stepId, vehicleId, fencingToken, releaseEvidenceId, + updatedAt); + } + + public ResourceOperationsView(String resourceKey, String resourceType, + int capacity, int protectedCount, + String holder, String reservationState, + String orderId, String stepId, + String vehicleId, long fencingToken, + String releaseEvidenceId, Instant updatedAt) { + this(resourceKey, requireText(resourceType, "resourceType"), + Integer.valueOf(capacity), Integer.valueOf(protectedCount), + holder, reservationState, orderId, stepId, vehicleId, + fencingToken, releaseEvidenceId, updatedAt); + } + + private ResourceOperationsView(String resourceKey, String resourceType, + Integer capacity, Integer protectedCount, + String holder, String reservationState, + String orderId, String stepId, + String vehicleId, long fencingToken, + String releaseEvidenceId, + Instant updatedAt) { this.resourceKey = requireText(resourceKey, "resourceKey"); + this.resourceType = optional(resourceType); + validateCapacity(capacity, protectedCount); + this.capacity = capacity; + this.protectedCount = protectedCount; + this.holder = optional(holder); this.reservationState = requireText(reservationState, "reservationState"); - this.orderId = requireText(orderId, "orderId"); - this.stepId = requireText(stepId, "stepId"); - this.vehicleId = requireText(vehicleId, "vehicleId"); - if (fencingToken <= 0L) { - throw new IllegalArgumentException("fencingToken must be positive"); + this.orderId = optional(orderId); + this.stepId = optional(stepId); + this.vehicleId = optional(vehicleId); + if (fencingToken < 0L) { + throw new IllegalArgumentException( + "fencingToken must not be negative"); } this.fencingToken = fencingToken; this.releaseEvidenceId = optional(releaseEvidenceId); @@ -32,6 +66,10 @@ public final class ResourceOperationsView { } public String getResourceKey() { return resourceKey; } + public String getResourceType() { return resourceType; } + public Integer getCapacity() { return capacity; } + public Integer getProtectedCount() { return protectedCount; } + public String getHolder() { return holder; } public String getReservationState() { return reservationState; } public String getOrderId() { return orderId; } public String getStepId() { return stepId; } @@ -40,6 +78,21 @@ public final class ResourceOperationsView { public String getReleaseEvidenceId() { return releaseEvidenceId; } public Instant getUpdatedAt() { return updatedAt; } + private static void validateCapacity(Integer capacity, + Integer protectedCount) { + if (capacity == null && protectedCount == null) { + return; + } + if (capacity == null || protectedCount == null || capacity <= 0) { + throw new IllegalArgumentException( + "capacity and protectedCount must be specified together"); + } + if (protectedCount < 0 || protectedCount > capacity) { + throw new IllegalArgumentException( + "protectedCount must be between zero and capacity"); + } + } + private static String optional(String value) { return value == null || value.trim().isEmpty() ? null : value.trim(); } diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/WcsOperationsQueryService.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/WcsOperationsQueryService.java index b6667e9..f23eee1 100644 --- a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/WcsOperationsQueryService.java +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/WcsOperationsQueryService.java @@ -2,8 +2,6 @@ package com.ruoyi.wcs.query; import com.ruoyi.wcs.domain.shared.DomainRuleViolation; -import java.util.ArrayList; -import java.util.Collections; import java.util.List; import java.util.Objects; @@ -21,6 +19,15 @@ public final class WcsOperationsQueryService { return requireResult(repository.findOrder(id), "order", id); } + public OperationsPage orders(String state, int offset, + int limit) { + validatePage(offset, limit); + String filter = optional(state); + return new OperationsPage( + repository.findOrders(filter, offset, limit), + repository.countOrders(filter), offset, limit); + } + public OrderExecutionView command(String commandKey) { String key = requireText(commandKey, "commandKey"); return requireResult(repository.findCommand(key), "command", key); @@ -31,11 +38,31 @@ public final class WcsOperationsQueryService { return requireResult(repository.findDevice(id), "device", id); } + public OperationsPage devices(String state, + int offset, + int limit) { + validatePage(offset, limit); + String filter = optional(state); + return new OperationsPage( + repository.findDevices(filter, offset, limit), + repository.countDevices(filter), offset, limit); + } + public ResourceOperationsView resource(String resourceKey) { String key = requireText(resourceKey, "resourceKey"); return requireResult(repository.findResource(key), "resource", key); } + public OperationsPage resources(String state, + int offset, + int limit) { + validatePage(offset, limit); + String filter = optional(state); + return new OperationsPage( + repository.findResources(filter, offset, limit), + repository.countResources(filter), offset, limit); + } + public RecoveryCaseView recoveryCase(String caseId) { String id = requireText(caseId, "caseId"); return requireResult(repository.findRecoveryCase(id), @@ -44,7 +71,21 @@ public final class WcsOperationsQueryService { public List recoveryCases(String state, int offset, int limit) { + return recoveryCasePage(state, offset, limit).getRows(); + } + + public OperationsPage recoveryCasePage( + String state, int offset, int limit) { String recoveryState = requireText(state, "state"); + validatePage(offset, limit); + List views = Objects.requireNonNull( + repository.findRecoveryCases(recoveryState, offset, limit), + "recovery case page"); + return new OperationsPage(views, + repository.countRecoveryCases(recoveryState), offset, limit); + } + + private static void validatePage(int offset, int limit) { if (offset < 0) { throw new IllegalArgumentException("offset must not be negative"); } @@ -52,11 +93,6 @@ public final class WcsOperationsQueryService { throw new IllegalArgumentException( "limit must be between 1 and " + MAX_PAGE_SIZE); } - List views = Objects.requireNonNull( - repository.findRecoveryCases(recoveryState, offset, limit), - "recovery case page"); - return Collections.unmodifiableList( - new ArrayList(views)); } private static T requireResult(T value, String type, String key) { @@ -72,4 +108,8 @@ public final class WcsOperationsQueryService { } return value.trim(); } + + private static String optional(String value) { + return value == null || value.trim().isEmpty() ? null : value.trim(); + } } diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/WcsOperationsReadRepository.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/WcsOperationsReadRepository.java index 1e36790..dcb0d69 100644 --- a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/WcsOperationsReadRepository.java +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/WcsOperationsReadRepository.java @@ -3,16 +3,32 @@ package com.ruoyi.wcs.query; import java.util.List; public interface WcsOperationsReadRepository { + List findOrders(String state, int offset, int limit); + + long countOrders(String state); + OrderExecutionView findOrder(String orderId); OrderExecutionView findCommand(String commandKey); DeviceOperationsView findDevice(String deviceId); + List findDevices(String state, int offset, + int limit); + + long countDevices(String state); + ResourceOperationsView findResource(String resourceKey); + List findResources(String state, int offset, + int limit); + + long countResources(String state); + RecoveryCaseView findRecoveryCase(String caseId); List findRecoveryCases(String state, int offset, int limit); + + long countRecoveryCases(String state); } diff --git a/ruoyi-wcs/src/test/java/com/ruoyi/wcs/query/WcsOperationsQueryServiceTest.java b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/query/WcsOperationsQueryServiceTest.java index 5bcf644..84d6994 100644 --- a/ruoyi-wcs/src/test/java/com/ruoyi/wcs/query/WcsOperationsQueryServiceTest.java +++ b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/query/WcsOperationsQueryServiceTest.java @@ -33,11 +33,29 @@ class WcsOperationsQueryServiceTest { new FakeReadRepository()); assertEquals("ALARM-1", queries.device("VEH-1").getActiveAlarmCode()); - assertEquals(77L, queries.resource("NODE:A").getFencingToken()); + ResourceOperationsView resource = queries.resource("NODE:A"); + assertEquals("NODE", resource.getResourceType()); + assertEquals(2, resource.getCapacity()); + assertEquals(1, resource.getProtectedCount()); + assertEquals("VEH-1", resource.getHolder()); + assertEquals(77L, resource.getFencingToken()); assertEquals("TASK_NUMBER_MISMATCH", queries.recoveryCase("RC-1").getReason()); } + @Test + void representsAFreeResourceWithoutFabricatingReservationOwnership() { + ResourceOperationsView resource = new ResourceOperationsView( + "NODE:B", "NODE", 2, 0, null, "FREE", null, null, + null, 0L, null, Instant.EPOCH); + + assertEquals("FREE", resource.getReservationState()); + assertEquals(0, resource.getProtectedCount()); + assertEquals(null, resource.getHolder()); + assertEquals(null, resource.getOrderId()); + assertEquals(0L, resource.getFencingToken()); + } + @Test void pagesRecoveryCasesByExplicitStateWithABoundedLimit() { WcsOperationsQueryService queries = new WcsOperationsQueryService( @@ -54,6 +72,31 @@ class WcsOperationsQueryServiceTest { () -> queries.recoveryCases("OPEN", 0, 501)); } + @Test + void pagesOperationalViewsWithStableTotalsAndOrderIdentifiers() { + WcsOperationsQueryService queries = new WcsOperationsQueryService( + new FakeReadRepository()); + + OperationsPage orders = queries.orders( + "EXECUTING", 0, 20); + OperationsPage devices = queries.devices( + "UNKNOWN", 0, 20); + OperationsPage resources = queries.resources( + "OCCUPIED", 0, 20); + OperationsPage recovery = queries.recoveryCasePage( + "OPEN", 0, 20); + + assertEquals(2L, orders.getTotal()); + assertEquals("TASK-1", orders.getRows().get(0) + .getExternalRequestId()); + assertEquals("EXECUTING", orders.getRows().get(0).getOrderState()); + assertEquals(1L, devices.getTotal()); + assertEquals(1L, resources.getTotal()); + assertEquals(2L, recovery.getTotal()); + assertThrows(UnsupportedOperationException.class, + () -> orders.getRows().add(orders.getRows().get(0))); + } + @Test void missingOrBlankOperationsLookupFailsExplicitly() { WcsOperationsQueryService queries = new WcsOperationsQueryService( @@ -71,8 +114,9 @@ class WcsOperationsQueryServiceTest { if (!"ORDER-1".equals(orderId)) { return null; } - return new OrderExecutionView("ORDER-1", "WF-1", "STEP-1", - "VEH-1", "CMD-1", "UNKNOWN", "RC-1", Instant.EPOCH); + return new OrderExecutionView("ORDER-1", "TASK-1", "EXECUTING", + "WF-1", "STEP-1", "VEH-1", "CMD-1", "UNKNOWN", + "RC-1", Instant.EPOCH); } @Override @@ -89,9 +133,9 @@ class WcsOperationsQueryServiceTest { @Override public ResourceOperationsView findResource(String resourceKey) { - return new ResourceOperationsView("NODE:A", "OCCUPIED", - "ORDER-1", "STEP-1", "VEH-1", 77L, null, - Instant.EPOCH); + return new ResourceOperationsView("NODE:A", "NODE", 2, 1, + "VEH-1", "OCCUPIED", "ORDER-1", "STEP-1", + "VEH-1", 77L, null, Instant.EPOCH); } @Override @@ -106,6 +150,48 @@ class WcsOperationsQueryServiceTest { return Arrays.asList(recovery("RC-1"), recovery("RC-2")); } + @Override + public List findOrders(String state, int offset, + int limit) { + return Arrays.asList(findOrder("ORDER-1"), + new OrderExecutionView("ORDER-2", "TASK-2", "PLANNED", + "WF-2", null, null, null, null, null, + Instant.EPOCH)); + } + + @Override + public long countOrders(String state) { + return 2L; + } + + @Override + public List findDevices(String state, + int offset, int limit) { + return Collections.singletonList(findDevice("VEH-1")); + } + + @Override + public long countDevices(String state) { + return 1L; + } + + @Override + public List findResources(String state, + int offset, + int limit) { + return Collections.singletonList(findResource("NODE:A")); + } + + @Override + public long countResources(String state) { + return 1L; + } + + @Override + public long countRecoveryCases(String state) { + return 2L; + } + private static RecoveryCaseView recovery(String caseId) { return new RecoveryCaseView(caseId, "VEH-1", "CMD-1", "OPEN", "TASK_NUMBER_MISMATCH", Instant.EPOCH, null, null);