feat(ft546): add WCS operations console

This commit is contained in:
zgw
2026-08-11 19:25:29 +08:00
parent c80acf0d99
commit ceba465675
20 changed files with 1313 additions and 18 deletions

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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);
}
}

View File

@@ -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<OrderExecutionView> findOrders(String state, int offset,
int limit) {
return Collections.singletonList(findOrder("ORDER-1"));
}
@Override
public long countOrders(String state) {
return 1L;
}
@Override
public List<DeviceOperationsView> findDevices(String state,
int offset, int limit) {
return Collections.singletonList(findDevice("VEH-1"));
}
@Override
public long countDevices(String state) {
return 1L;
}
@Override
public List<ResourceOperationsView> findResources(String state,
int offset,
int limit) {
return Collections.singletonList(findResource("NODE:070501"));
}
@Override
public long countResources(String state) {
return 1L;
}
@Override
public List<RecoveryCaseView> 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);
}
}
}