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.device.SnapshotCollector;
import com.ruoyi.wcs.application.dispatch.DispatchCoordinator; import com.ruoyi.wcs.application.dispatch.DispatchCoordinator;
import com.ruoyi.wcs.application.recovery.RecoveryGate; 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.domain.shared.WcsClock;
import com.ruoyi.wcs.port.out.DispatchSnapshotProvider; 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.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@@ -37,4 +42,18 @@ public class Ft546AdapterConfiguration {
WcsRuntimeCoordinator coordinator) { WcsRuntimeCoordinator coordinator) {
return new Ft546WcsScheduler(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);
}
}
}

View File

@@ -0,0 +1,5 @@
import request from '@/utils/request'
export function listDevices(query) {
return request({ url: '/wcs/devices', method: 'get', params: query })
}

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
import request from '@/utils/request'
export function listResources(query) {
return request({
url: '/wcs/traffic/resources',
method: 'get',
params: query
})
}

View File

@@ -0,0 +1,61 @@
<template>
<div class="app-container wcs-page">
<div class="wcs-page__bar"><h2>WCS 设备</h2><el-switch v-model="autoRefresh" active-text="自动刷新" /></div>
<el-form ref="queryForm" :model="queryParams" size="small" :inline="true">
<el-form-item label="命令状态" prop="state"><el-select v-model="queryParams.state" clearable placeholder="全部状态"><el-option v-for="state in commandStates" :key="state" :label="state" :value="state" /></el-select></el-form-item>
<el-form-item><el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">查询</el-button><el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button></el-form-item>
</el-form>
<el-table v-loading="loading" :data="rows" stripe @row-click="openDetail">
<el-table-column label="设备 ID" prop="deviceId" min-width="130" show-overflow-tooltip />
<el-table-column label="类型" prop="deviceType" width="120" />
<el-table-column label="连接" prop="connected" width="90" align="center"><template slot-scope="scope"><el-tag size="mini" :type="scope.row.connected ? 'success' : 'danger'">{{ scope.row.connected ? '在线' : '离线' }}</el-tag></template></el-table-column>
<el-table-column label="当前位置" prop="currentSite" min-width="130" />
<el-table-column label="命令状态" prop="commandState" width="130" align="center" />
<el-table-column label="当前命令" prop="currentCommandKey" min-width="180" show-overflow-tooltip />
<el-table-column label="活动告警" prop="activeAlarmCode" min-width="140"><template slot-scope="scope"><span :class="{ 'alarm-text': scope.row.activeAlarmCode }">{{ scope.row.activeAlarmCode || '-' }}</span></template></el-table-column>
<el-table-column label="更新时间" prop="updatedAt" width="180"><template slot-scope="scope">{{ parseTime(scope.row.updatedAt) }}</template></el-table-column>
</el-table>
<pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
<el-drawer title="设备详情" :visible.sync="detailOpen" size="420px"><dl v-if="current" class="wcs-detail"><template v-for="item in details"><dt :key="item.key + '-label'">{{ item.label }}</dt><dd :key="item.key">{{ current[item.key] === null || current[item.key] === undefined ? '-' : current[item.key] }}</dd></template></dl></el-drawer>
</div>
</template>
<script>
import { listDevices } from '@/api/wcs/device'
export default {
name: 'WcsDevice',
data() {
return {
loading: false, autoRefresh: true, timer: null, rows: [], total: 0, current: null, detailOpen: false,
queryParams: { pageNum: 1, pageSize: 20, state: null },
commandStates: ['CREATED', 'SENT', 'ACKNOWLEDGED', 'RUNNING', 'COMPLETED', 'FAILED', 'UNKNOWN'],
details: [
{ key: 'deviceId', label: '设备 ID' }, { key: 'deviceType', label: '类型' },
{ key: 'connected', label: '连接' }, { key: 'currentSite', label: '当前位置' },
{ key: 'currentCommandKey', label: '当前命令' }, { key: 'commandState', label: '命令状态' },
{ key: 'activeAlarmCode', label: '活动告警' }, { key: 'recoveryCaseId', label: '恢复案例' },
{ key: 'updatedAt', label: '更新时间' }
]
}
},
created() { this.getList() },
mounted() { this.timer = window.setInterval(() => { if (this.autoRefresh) this.getList(true) }, 5000) },
beforeDestroy() { window.clearInterval(this.timer) },
methods: {
getList(silent) { if (!silent) this.loading = true; return listDevices(this.queryParams).then(r => { this.rows = r.rows || []; this.total = r.total || 0 }).finally(() => { this.loading = false }) },
handleQuery() { this.queryParams.pageNum = 1; this.getList() },
resetQuery() { this.resetForm('queryForm'); this.handleQuery() },
openDetail(row) { this.current = row; this.detailOpen = true }
}
}
</script>
<style scoped>
.wcs-page__bar { display:flex; align-items:center; justify-content:space-between; min-height:58px; margin-bottom:16px; padding-bottom:12px; border-bottom:1px solid #dcdfe6; }
.wcs-page__bar h2 { margin:0; color:#17202a; font-size:20px; font-weight:600; letter-spacing:0; }
.alarm-text { color:#c0362c; font-weight:600; }
.wcs-detail { display:grid; grid-template-columns:110px minmax(0, 1fr); gap:0; margin:0 24px; border-top:1px solid #ebeef5; }
.wcs-detail dt,.wcs-detail dd { margin:0; padding:12px 8px; border-bottom:1px solid #ebeef5; overflow-wrap:anywhere; }
.wcs-detail dt { color:#667085; background:#f7f8fa; }.wcs-detail dd { color:#17202a; }
</style>

View File

@@ -0,0 +1,141 @@
<template>
<div class="app-container wcs-page">
<div class="wcs-page__bar">
<div>
<h2>WCS 订单</h2>
<span class="wcs-page__stamp">{{ refreshStamp || '-' }}</span>
</div>
<el-switch v-model="autoRefresh" active-text="自动刷新" />
</div>
<el-form ref="queryForm" :model="queryParams" size="small" :inline="true">
<el-form-item label="订单状态" prop="state">
<el-select v-model="queryParams.state" clearable placeholder="全部状态">
<el-option v-for="state in orderStates" :key="state" :label="state" :value="state" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">查询</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-table v-loading="loading" :data="rows" stripe @row-click="openDetail">
<el-table-column label="WMS 任务号" prop="externalRequestId" min-width="150" show-overflow-tooltip />
<el-table-column label="订单 ID" prop="orderId" min-width="180" show-overflow-tooltip />
<el-table-column label="订单状态" prop="orderState" width="140" align="center">
<template slot-scope="scope"><el-tag size="mini" :type="stateType(scope.row.orderState)">{{ scope.row.orderState || '-' }}</el-tag></template>
</el-table-column>
<el-table-column label="步骤" prop="stepId" min-width="130" show-overflow-tooltip />
<el-table-column label="设备" prop="deviceId" width="110" />
<el-table-column label="命令状态" prop="commandState" width="130" align="center" />
<el-table-column label="更新时间" prop="updatedAt" width="180">
<template slot-scope="scope">{{ parseTime(scope.row.updatedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="230" fixed="right" align="center">
<template slot-scope="scope">
<el-button v-if="canSuspend(scope.row)" type="text" icon="el-icon-video-pause" v-hasPermi="['wcs:order:control']" @click.stop="openSuspend(scope.row)">暂停</el-button>
<el-button v-if="scope.row.orderState === 'SUSPENDED'" type="text" icon="el-icon-video-play" v-hasPermi="['wcs:order:control']" @click.stop="resume(scope.row)">恢复</el-button>
<el-button v-if="canCancel(scope.row)" type="text" class="danger-action" icon="el-icon-circle-close" v-hasPermi="['wcs:order:control']" @click.stop="cancel(scope.row)">取消</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
<el-dialog title="暂停订单" :visible.sync="suspendOpen" width="460px" append-to-body>
<el-form ref="suspendForm" :model="suspendForm" :rules="suspendRules" label-width="80px">
<el-form-item label="原因" prop="reason"><el-input v-model="suspendForm.reason" type="textarea" :rows="3" maxlength="200" show-word-limit /></el-form-item>
</el-form>
<div slot="footer"><el-button @click="suspendOpen = false">取消</el-button><el-button type="primary" @click="submitSuspend">确认暂停</el-button></div>
</el-dialog>
<el-drawer title="订单详情" :visible.sync="detailOpen" size="420px">
<dl v-if="current" class="wcs-detail">
<template v-for="item in orderDetails"><dt :key="item.key + '-label'">{{ item.label }}</dt><dd :key="item.key">{{ current[item.key] || '-' }}</dd></template>
</dl>
</el-drawer>
</div>
</template>
<script>
import { listOrders, suspendOrder, resumeOrder, cancelOrder } from '@/api/wcs/order'
export default {
name: 'WcsOrder',
data() {
return {
loading: false,
autoRefresh: true,
timer: null,
refreshStamp: '',
rows: [],
total: 0,
current: null,
detailOpen: false,
suspendOpen: false,
suspendForm: { orderId: null, reason: '' },
suspendRules: { reason: [{ required: true, message: '请输入暂停原因', trigger: 'blur' }] },
queryParams: { pageNum: 1, pageSize: 20, state: null },
orderStates: ['ACCEPTED', 'PLANNED', 'EXECUTING', 'SUSPENDED', 'CANCEL_REQUESTED', 'COMPLETED', 'FAILED', 'CANCELLED'],
orderDetails: [
{ key: 'externalRequestId', label: 'WMS 任务号' }, { key: 'orderId', label: '订单 ID' },
{ key: 'orderState', label: '订单状态' }, { key: 'workflowId', label: '工作流' },
{ key: 'stepId', label: '当前步骤' }, { key: 'deviceId', label: '设备' },
{ key: 'currentCommandKey', label: '命令键' }, { key: 'commandState', label: '命令状态' },
{ key: 'recoveryCaseId', label: '恢复案例' }, { key: 'updatedAt', label: '更新时间' }
]
}
},
created() { this.getList() },
mounted() { this.timer = window.setInterval(() => { if (this.autoRefresh) this.getList(true) }, 5000) },
beforeDestroy() { window.clearInterval(this.timer) },
methods: {
getList(silent) {
if (!silent) this.loading = true
return listOrders(this.queryParams).then(response => {
this.rows = response.rows || []
this.total = response.total || 0
this.refreshStamp = this.parseTime(new Date())
}).finally(() => { this.loading = false })
},
handleQuery() { this.queryParams.pageNum = 1; this.getList() },
resetQuery() { this.resetForm('queryForm'); this.handleQuery() },
openDetail(row) { this.current = row; this.detailOpen = true },
stateType(state) {
if (state === 'COMPLETED') return 'success'
if (state === 'FAILED' || state === 'CANCELLED') return 'danger'
if (state === 'SUSPENDED' || state === 'CANCEL_REQUESTED') return 'warning'
return ''
},
canSuspend(row) { return ['ACCEPTED', 'PLANNED', 'EXECUTING'].includes(row.orderState) },
canCancel(row) { return ['ACCEPTED', 'PLANNED', 'CANCEL_REQUESTED'].includes(row.orderState) },
openSuspend(row) { this.suspendForm = { orderId: row.orderId, reason: '' }; this.suspendOpen = true },
submitSuspend() {
this.$refs.suspendForm.validate(valid => {
if (!valid) return
suspendOrder(this.suspendForm.orderId, this.suspendForm.reason).then(() => {
this.suspendOpen = false; this.$modal.msgSuccess('订单已暂停'); this.getList()
})
})
},
resume(row) { resumeOrder(row.orderId).then(() => { this.$modal.msgSuccess('订单已恢复'); this.getList() }) },
cancel(row) {
this.$modal.confirm(`确认取消 WMS 任务 ${row.externalRequestId || row.orderId}?`).then(() => cancelOrder(row.orderId)).then(() => {
this.$modal.msgSuccess('订单已取消'); this.getList()
}).catch(() => {})
}
}
}
</script>
<style scoped>
.wcs-page__bar { display:flex; align-items:center; justify-content:space-between; min-height:58px; margin-bottom:16px; padding-bottom:12px; border-bottom:1px solid #dcdfe6; }
.wcs-page__bar h2 { margin:0 0 5px; color:#17202a; font-size:20px; font-weight:600; letter-spacing:0; }
.wcs-page__stamp { color:#7b8794; font-size:12px; }
.danger-action { color:#d14343; }
.wcs-detail { display:grid; grid-template-columns:110px minmax(0, 1fr); gap:0; margin:0 24px; border-top:1px solid #ebeef5; }
.wcs-detail dt,.wcs-detail dd { margin:0; padding:12px 8px; border-bottom:1px solid #ebeef5; overflow-wrap:anywhere; }
.wcs-detail dt { color:#667085; background:#f7f8fa; }
.wcs-detail dd { color:#17202a; }
</style>

View File

@@ -0,0 +1,102 @@
<template>
<div class="app-container wcs-page">
<div class="wcs-page__bar"><h2>恢复处置</h2><el-switch v-model="autoRefresh" active-text="自动刷新" /></div>
<el-form ref="queryForm" :model="queryParams" size="small" :inline="true">
<el-form-item label="案例状态" prop="state"><el-select v-model="queryParams.state" placeholder="案例状态"><el-option v-for="state in recoveryStates" :key="state" :label="state" :value="state" /></el-select></el-form-item>
<el-form-item><el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">查询</el-button><el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button></el-form-item>
</el-form>
<el-table v-loading="loading" :data="rows" stripe @row-click="openDetail">
<el-table-column label="案例 ID" prop="caseId" min-width="150" show-overflow-tooltip />
<el-table-column label="设备" prop="deviceId" width="110" />
<el-table-column label="命令键" prop="commandKey" min-width="180" show-overflow-tooltip />
<el-table-column label="状态" prop="state" width="120" align="center"><template slot-scope="scope"><el-tag size="mini" :type="recoveryType(scope.row.state)">{{ scope.row.state }}</el-tag></template></el-table-column>
<el-table-column label="原因" prop="reason" min-width="210" show-overflow-tooltip />
<el-table-column label="创建时间" prop="createdAt" width="180"><template slot-scope="scope">{{ parseTime(scope.row.createdAt) }}</template></el-table-column>
<el-table-column label="操作" width="170" fixed="right" align="center"><template slot-scope="scope"><template v-if="scope.row.state === 'OPEN'"><el-button type="text" icon="el-icon-circle-check" v-hasPermi="['wcs:recovery:confirm']" @click.stop="openConfirm(scope.row)">确认事实</el-button><el-button type="text" class="danger-action" icon="el-icon-circle-close" v-hasPermi="['wcs:recovery:confirm']" @click.stop="openAbort(scope.row)">中止</el-button></template></template></el-table-column>
</el-table>
<pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
<el-dialog title="确认现场事实" :visible.sync="confirmOpen" width="520px" append-to-body>
<el-form ref="confirmForm" :model="confirmForm" :rules="confirmRules" label-width="90px">
<el-form-item label="当前位置" prop="currentSite"><el-input v-model="confirmForm.currentSite" /></el-form-item>
<el-form-item label="任务号" prop="taskNumber"><el-input v-model="confirmForm.taskNumber" /></el-form-item>
<el-form-item label="设备模式" prop="deviceMode"><el-select v-model="confirmForm.deviceMode"><el-option label="AUTO" value="AUTO" /><el-option label="MANUAL" value="MANUAL" /></el-select></el-form-item>
<el-form-item label="载货状态" prop="loaded"><el-switch v-model="confirmForm.loaded" active-text="有货" inactive-text="空载" /></el-form-item>
<el-form-item label="确认原因" prop="reason"><el-input v-model="confirmForm.reason" type="textarea" :rows="3" maxlength="200" show-word-limit /></el-form-item>
</el-form>
<div slot="footer"><el-button @click="confirmOpen = false">取消</el-button><el-button type="primary" @click="submitConfirm">提交事实</el-button></div>
</el-dialog>
<el-dialog title="中止恢复案例" :visible.sync="abortOpen" width="460px" append-to-body>
<el-form ref="abortForm" :model="abortForm" :rules="abortRules" label-width="80px"><el-form-item label="原因" prop="reason"><el-input v-model="abortForm.reason" type="textarea" :rows="3" maxlength="200" show-word-limit /></el-form-item></el-form>
<div slot="footer"><el-button @click="abortOpen = false">取消</el-button><el-button type="danger" @click="submitAbort">确认中止</el-button></div>
</el-dialog>
<el-drawer title="恢复案例详情" :visible.sync="detailOpen" size="420px"><dl v-if="current" class="wcs-detail"><template v-for="item in details"><dt :key="item.key + '-label'">{{ item.label }}</dt><dd :key="item.key">{{ current[item.key] || '-' }}</dd></template></dl></el-drawer>
</div>
</template>
<script>
import { listRecoveryCases, confirmRecovery, abortRecovery } from '@/api/wcs/recovery'
export default {
name: 'WcsRecovery',
data() {
return {
loading: false, autoRefresh: true, timer: null, rows: [], total: 0, current: null, detailOpen: false,
confirmOpen: false, abortOpen: false,
queryParams: { pageNum: 1, pageSize: 20, state: 'OPEN' },
recoveryStates: ['OPEN', 'CONFIRMED', 'ABORTED'],
confirmForm: { caseId: null, currentSite: '', taskNumber: '', deviceMode: 'AUTO', loaded: false, reason: '' },
abortForm: { caseId: null, reason: '' },
confirmRules: {
currentSite: [{ required: true, message: '请输入当前位置', trigger: 'blur' }],
taskNumber: [{ required: true, message: '请输入任务号', trigger: 'blur' }],
deviceMode: [{ required: true, message: '请选择设备模式', trigger: 'change' }],
reason: [{ required: true, message: '请输入确认原因', trigger: 'blur' }]
},
abortRules: { reason: [{ required: true, message: '请输入中止原因', trigger: 'blur' }] },
details: [
{ key: 'caseId', label: '案例 ID' }, { key: 'deviceId', label: '设备' },
{ key: 'commandKey', label: '命令键' }, { key: 'state', label: '状态' },
{ key: 'reason', label: '原因' }, { key: 'createdAt', label: '创建时间' },
{ key: 'resolvedBy', label: '处置人' }, { key: 'resolvedAt', label: '处置时间' }
]
}
},
created() { this.getList() },
mounted() { this.timer = window.setInterval(() => { if (this.autoRefresh) this.getList(true) }, 5000) },
beforeDestroy() { window.clearInterval(this.timer) },
methods: {
getList(silent) { if (!silent) this.loading = true; return listRecoveryCases(this.queryParams).then(r => { this.rows = r.rows || []; this.total = r.total || 0 }).finally(() => { this.loading = false }) },
handleQuery() { this.queryParams.pageNum = 1; this.getList() },
resetQuery() { this.resetForm('queryForm'); this.queryParams.state = 'OPEN'; this.handleQuery() },
openDetail(row) { this.current = row; this.detailOpen = true },
recoveryType(state) { if (state === 'CONFIRMED') return 'success'; if (state === 'ABORTED') return 'danger'; return 'warning' },
openConfirm(row) { this.confirmForm = { caseId: row.caseId, currentSite: '', taskNumber: '', deviceMode: 'AUTO', loaded: false, reason: '' }; this.confirmOpen = true },
submitConfirm() {
this.$refs.confirmForm.validate(valid => {
if (!valid) return
const data = { ...this.confirmForm }; delete data.caseId
confirmRecovery(this.confirmForm.caseId, data).then(() => { this.confirmOpen = false; this.$modal.msgSuccess('现场事实已确认'); this.getList() })
})
},
openAbort(row) { this.abortForm = { caseId: row.caseId, reason: '' }; this.abortOpen = true },
submitAbort() {
this.$refs.abortForm.validate(valid => {
if (!valid) return
abortRecovery(this.abortForm.caseId, this.abortForm.reason).then(() => { this.abortOpen = false; this.$modal.msgSuccess('恢复案例已中止'); this.getList() })
})
}
}
}
</script>
<style scoped>
.wcs-page__bar { display:flex; align-items:center; justify-content:space-between; min-height:58px; margin-bottom:16px; padding-bottom:12px; border-bottom:1px solid #dcdfe6; }
.wcs-page__bar h2 { margin:0; color:#17202a; font-size:20px; font-weight:600; letter-spacing:0; }
.danger-action { color:#d14343; }
.wcs-detail { display:grid; grid-template-columns:110px minmax(0, 1fr); gap:0; margin:0 24px; border-top:1px solid #ebeef5; }
.wcs-detail dt,.wcs-detail dd { margin:0; padding:12px 8px; border-bottom:1px solid #ebeef5; overflow-wrap:anywhere; }
.wcs-detail dt { color:#667085; background:#f7f8fa; }.wcs-detail dd { color:#17202a; }
</style>

View File

@@ -0,0 +1,63 @@
<template>
<div class="app-container wcs-page">
<div class="wcs-page__bar"><h2>交通资源</h2><el-switch v-model="autoRefresh" active-text="自动刷新" /></div>
<el-form ref="queryForm" :model="queryParams" size="small" :inline="true">
<el-form-item label="占用状态" prop="state"><el-select v-model="queryParams.state" clearable placeholder="全部状态"><el-option v-for="state in resourceStates" :key="state" :label="state" :value="state" /></el-select></el-form-item>
<el-form-item><el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">查询</el-button><el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button></el-form-item>
</el-form>
<el-table v-loading="loading" :data="rows" stripe @row-click="openDetail">
<el-table-column label="资源键" prop="resourceKey" min-width="210" show-overflow-tooltip />
<el-table-column label="类型" prop="resourceType" width="100" />
<el-table-column label="容量" prop="capacity" width="80" align="right" />
<el-table-column label="保护数" prop="protectedCount" width="90" align="right" />
<el-table-column label="持有者" prop="holder" width="110" show-overflow-tooltip />
<el-table-column label="状态" prop="reservationState" width="130" align="center"><template slot-scope="scope"><el-tag size="mini" :type="resourceType(scope.row.reservationState)">{{ scope.row.reservationState }}</el-tag></template></el-table-column>
<el-table-column label="释放证据" prop="releaseEvidenceId" min-width="160" show-overflow-tooltip />
<el-table-column label="更新时间" prop="updatedAt" width="180"><template slot-scope="scope">{{ parseTime(scope.row.updatedAt) }}</template></el-table-column>
</el-table>
<pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
<el-drawer title="资源详情" :visible.sync="detailOpen" size="420px"><dl v-if="current" class="wcs-detail"><template v-for="item in details"><dt :key="item.key + '-label'">{{ item.label }}</dt><dd :key="item.key">{{ detailValue(current[item.key]) }}</dd></template></dl></el-drawer>
</div>
</template>
<script>
import { listResources } from '@/api/wcs/traffic'
export default {
name: 'WcsTraffic',
data() {
return {
loading: false, autoRefresh: true, timer: null, rows: [], total: 0, current: null, detailOpen: false,
queryParams: { pageNum: 1, pageSize: 20, state: null },
resourceStates: ['FREE', 'RESERVED', 'OCCUPIED', 'RELEASING', 'UNKNOWN'],
details: [
{ key: 'resourceKey', label: '资源键' }, { key: 'resourceType', label: '类型' },
{ key: 'capacity', label: '容量' }, { key: 'protectedCount', label: '保护数' },
{ key: 'holder', label: '持有者' }, { key: 'reservationState', label: '状态' },
{ key: 'orderId', label: '订单' }, { key: 'stepId', label: '步骤' },
{ key: 'vehicleId', label: '车辆' }, { key: 'fencingToken', label: '围栏令牌' },
{ key: 'releaseEvidenceId', label: '释放证据' }, { key: 'updatedAt', label: '更新时间' }
]
}
},
created() { this.getList() },
mounted() { this.timer = window.setInterval(() => { if (this.autoRefresh) this.getList(true) }, 5000) },
beforeDestroy() { window.clearInterval(this.timer) },
methods: {
getList(silent) { if (!silent) this.loading = true; return listResources(this.queryParams).then(r => { this.rows = r.rows || []; this.total = r.total || 0 }).finally(() => { this.loading = false }) },
handleQuery() { this.queryParams.pageNum = 1; this.getList() },
resetQuery() { this.resetForm('queryForm'); this.handleQuery() },
openDetail(row) { this.current = row; this.detailOpen = true },
detailValue(value) { return value === null || value === undefined || value === '' ? '-' : value },
resourceType(state) { if (state === 'FREE') return 'success'; if (state === 'UNKNOWN') return 'danger'; if (state === 'RESERVED' || state === 'RELEASING') return 'warning'; return '' }
}
}
</script>
<style scoped>
.wcs-page__bar { display:flex; align-items:center; justify-content:space-between; min-height:58px; margin-bottom:16px; padding-bottom:12px; border-bottom:1px solid #dcdfe6; }
.wcs-page__bar h2 { margin:0; color:#17202a; font-size:20px; font-weight:600; letter-spacing:0; }
.wcs-detail { display:grid; grid-template-columns:110px minmax(0, 1fr); gap:0; margin:0 24px; border-top:1px solid #ebeef5; }
.wcs-detail dt,.wcs-detail dd { margin:0; padding:12px 8px; border-bottom:1px solid #ebeef5; overflow-wrap:anywhere; }
.wcs-detail dt { color:#667085; background:#f7f8fa; }.wcs-detail dd { color:#17202a; }
</style>

View File

@@ -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<T> {
private final List<T> rows;
private final long total;
private final int offset;
private final int limit;
public OperationsPage(List<T> 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<T>(rows));
this.total = total;
this.offset = offset;
this.limit = limit;
}
public List<T> getRows() { return rows; }
public long getTotal() { return total; }
public int getOffset() { return offset; }
public int getLimit() { return limit; }
}

View File

@@ -5,6 +5,8 @@ import java.util.Objects;
public final class OrderExecutionView { public final class OrderExecutionView {
private final String orderId; private final String orderId;
private final String externalRequestId;
private final String orderState;
private final String workflowId; private final String workflowId;
private final String stepId; private final String stepId;
private final String deviceId; private final String deviceId;
@@ -17,7 +19,18 @@ public final class OrderExecutionView {
String deviceId, String currentCommandKey, String deviceId, String currentCommandKey,
String commandState, String recoveryCaseId, String commandState, String recoveryCaseId,
Instant updatedAt) { 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.orderId = requireText(orderId, "orderId");
this.externalRequestId = optional(externalRequestId);
this.orderState = optional(orderState);
this.workflowId = optional(workflowId); this.workflowId = optional(workflowId);
this.stepId = optional(stepId); this.stepId = optional(stepId);
this.deviceId = optional(deviceId); this.deviceId = optional(deviceId);
@@ -28,6 +41,8 @@ public final class OrderExecutionView {
} }
public String getOrderId() { return orderId; } public String getOrderId() { return orderId; }
public String getExternalRequestId() { return externalRequestId; }
public String getOrderState() { return orderState; }
public String getWorkflowId() { return workflowId; } public String getWorkflowId() { return workflowId; }
public String getStepId() { return stepId; } public String getStepId() { return stepId; }
public String getDeviceId() { return deviceId; } public String getDeviceId() { return deviceId; }

View File

@@ -5,6 +5,10 @@ import java.util.Objects;
public final class ResourceOperationsView { public final class ResourceOperationsView {
private final String resourceKey; 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 reservationState;
private final String orderId; private final String orderId;
private final String stepId; private final String stepId;
@@ -17,14 +21,44 @@ public final class ResourceOperationsView {
String orderId, String stepId, String orderId, String stepId,
String vehicleId, long fencingToken, String vehicleId, long fencingToken,
String releaseEvidenceId, Instant updatedAt) { 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.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, this.reservationState = requireText(reservationState,
"reservationState"); "reservationState");
this.orderId = requireText(orderId, "orderId"); this.orderId = optional(orderId);
this.stepId = requireText(stepId, "stepId"); this.stepId = optional(stepId);
this.vehicleId = requireText(vehicleId, "vehicleId"); this.vehicleId = optional(vehicleId);
if (fencingToken <= 0L) { if (fencingToken < 0L) {
throw new IllegalArgumentException("fencingToken must be positive"); throw new IllegalArgumentException(
"fencingToken must not be negative");
} }
this.fencingToken = fencingToken; this.fencingToken = fencingToken;
this.releaseEvidenceId = optional(releaseEvidenceId); this.releaseEvidenceId = optional(releaseEvidenceId);
@@ -32,6 +66,10 @@ public final class ResourceOperationsView {
} }
public String getResourceKey() { return resourceKey; } 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 getReservationState() { return reservationState; }
public String getOrderId() { return orderId; } public String getOrderId() { return orderId; }
public String getStepId() { return stepId; } public String getStepId() { return stepId; }
@@ -40,6 +78,21 @@ public final class ResourceOperationsView {
public String getReleaseEvidenceId() { return releaseEvidenceId; } public String getReleaseEvidenceId() { return releaseEvidenceId; }
public Instant getUpdatedAt() { return updatedAt; } 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) { private static String optional(String value) {
return value == null || value.trim().isEmpty() ? null : value.trim(); return value == null || value.trim().isEmpty() ? null : value.trim();
} }

View File

@@ -2,8 +2,6 @@ package com.ruoyi.wcs.query;
import com.ruoyi.wcs.domain.shared.DomainRuleViolation; import com.ruoyi.wcs.domain.shared.DomainRuleViolation;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
@@ -21,6 +19,15 @@ public final class WcsOperationsQueryService {
return requireResult(repository.findOrder(id), "order", id); return requireResult(repository.findOrder(id), "order", id);
} }
public OperationsPage<OrderExecutionView> orders(String state, int offset,
int limit) {
validatePage(offset, limit);
String filter = optional(state);
return new OperationsPage<OrderExecutionView>(
repository.findOrders(filter, offset, limit),
repository.countOrders(filter), offset, limit);
}
public OrderExecutionView command(String commandKey) { public OrderExecutionView command(String commandKey) {
String key = requireText(commandKey, "commandKey"); String key = requireText(commandKey, "commandKey");
return requireResult(repository.findCommand(key), "command", key); return requireResult(repository.findCommand(key), "command", key);
@@ -31,11 +38,31 @@ public final class WcsOperationsQueryService {
return requireResult(repository.findDevice(id), "device", id); return requireResult(repository.findDevice(id), "device", id);
} }
public OperationsPage<DeviceOperationsView> devices(String state,
int offset,
int limit) {
validatePage(offset, limit);
String filter = optional(state);
return new OperationsPage<DeviceOperationsView>(
repository.findDevices(filter, offset, limit),
repository.countDevices(filter), offset, limit);
}
public ResourceOperationsView resource(String resourceKey) { public ResourceOperationsView resource(String resourceKey) {
String key = requireText(resourceKey, "resourceKey"); String key = requireText(resourceKey, "resourceKey");
return requireResult(repository.findResource(key), "resource", key); return requireResult(repository.findResource(key), "resource", key);
} }
public OperationsPage<ResourceOperationsView> resources(String state,
int offset,
int limit) {
validatePage(offset, limit);
String filter = optional(state);
return new OperationsPage<ResourceOperationsView>(
repository.findResources(filter, offset, limit),
repository.countResources(filter), offset, limit);
}
public RecoveryCaseView recoveryCase(String caseId) { public RecoveryCaseView recoveryCase(String caseId) {
String id = requireText(caseId, "caseId"); String id = requireText(caseId, "caseId");
return requireResult(repository.findRecoveryCase(id), return requireResult(repository.findRecoveryCase(id),
@@ -44,7 +71,21 @@ public final class WcsOperationsQueryService {
public List<RecoveryCaseView> recoveryCases(String state, int offset, public List<RecoveryCaseView> recoveryCases(String state, int offset,
int limit) { int limit) {
return recoveryCasePage(state, offset, limit).getRows();
}
public OperationsPage<RecoveryCaseView> recoveryCasePage(
String state, int offset, int limit) {
String recoveryState = requireText(state, "state"); String recoveryState = requireText(state, "state");
validatePage(offset, limit);
List<RecoveryCaseView> views = Objects.requireNonNull(
repository.findRecoveryCases(recoveryState, offset, limit),
"recovery case page");
return new OperationsPage<RecoveryCaseView>(views,
repository.countRecoveryCases(recoveryState), offset, limit);
}
private static void validatePage(int offset, int limit) {
if (offset < 0) { if (offset < 0) {
throw new IllegalArgumentException("offset must not be negative"); throw new IllegalArgumentException("offset must not be negative");
} }
@@ -52,11 +93,6 @@ public final class WcsOperationsQueryService {
throw new IllegalArgumentException( throw new IllegalArgumentException(
"limit must be between 1 and " + MAX_PAGE_SIZE); "limit must be between 1 and " + MAX_PAGE_SIZE);
} }
List<RecoveryCaseView> views = Objects.requireNonNull(
repository.findRecoveryCases(recoveryState, offset, limit),
"recovery case page");
return Collections.unmodifiableList(
new ArrayList<RecoveryCaseView>(views));
} }
private static <T> T requireResult(T value, String type, String key) { private static <T> T requireResult(T value, String type, String key) {
@@ -72,4 +108,8 @@ public final class WcsOperationsQueryService {
} }
return value.trim(); return value.trim();
} }
private static String optional(String value) {
return value == null || value.trim().isEmpty() ? null : value.trim();
}
} }

View File

@@ -3,16 +3,32 @@ package com.ruoyi.wcs.query;
import java.util.List; import java.util.List;
public interface WcsOperationsReadRepository { public interface WcsOperationsReadRepository {
List<OrderExecutionView> findOrders(String state, int offset, int limit);
long countOrders(String state);
OrderExecutionView findOrder(String orderId); OrderExecutionView findOrder(String orderId);
OrderExecutionView findCommand(String commandKey); OrderExecutionView findCommand(String commandKey);
DeviceOperationsView findDevice(String deviceId); DeviceOperationsView findDevice(String deviceId);
List<DeviceOperationsView> findDevices(String state, int offset,
int limit);
long countDevices(String state);
ResourceOperationsView findResource(String resourceKey); ResourceOperationsView findResource(String resourceKey);
List<ResourceOperationsView> findResources(String state, int offset,
int limit);
long countResources(String state);
RecoveryCaseView findRecoveryCase(String caseId); RecoveryCaseView findRecoveryCase(String caseId);
List<RecoveryCaseView> findRecoveryCases(String state, int offset, List<RecoveryCaseView> findRecoveryCases(String state, int offset,
int limit); int limit);
long countRecoveryCases(String state);
} }

View File

@@ -33,11 +33,29 @@ class WcsOperationsQueryServiceTest {
new FakeReadRepository()); new FakeReadRepository());
assertEquals("ALARM-1", queries.device("VEH-1").getActiveAlarmCode()); 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", assertEquals("TASK_NUMBER_MISMATCH",
queries.recoveryCase("RC-1").getReason()); 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 @Test
void pagesRecoveryCasesByExplicitStateWithABoundedLimit() { void pagesRecoveryCasesByExplicitStateWithABoundedLimit() {
WcsOperationsQueryService queries = new WcsOperationsQueryService( WcsOperationsQueryService queries = new WcsOperationsQueryService(
@@ -54,6 +72,31 @@ class WcsOperationsQueryServiceTest {
() -> queries.recoveryCases("OPEN", 0, 501)); () -> queries.recoveryCases("OPEN", 0, 501));
} }
@Test
void pagesOperationalViewsWithStableTotalsAndOrderIdentifiers() {
WcsOperationsQueryService queries = new WcsOperationsQueryService(
new FakeReadRepository());
OperationsPage<OrderExecutionView> orders = queries.orders(
"EXECUTING", 0, 20);
OperationsPage<DeviceOperationsView> devices = queries.devices(
"UNKNOWN", 0, 20);
OperationsPage<ResourceOperationsView> resources = queries.resources(
"OCCUPIED", 0, 20);
OperationsPage<RecoveryCaseView> 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 @Test
void missingOrBlankOperationsLookupFailsExplicitly() { void missingOrBlankOperationsLookupFailsExplicitly() {
WcsOperationsQueryService queries = new WcsOperationsQueryService( WcsOperationsQueryService queries = new WcsOperationsQueryService(
@@ -71,8 +114,9 @@ class WcsOperationsQueryServiceTest {
if (!"ORDER-1".equals(orderId)) { if (!"ORDER-1".equals(orderId)) {
return null; return null;
} }
return new OrderExecutionView("ORDER-1", "WF-1", "STEP-1", return new OrderExecutionView("ORDER-1", "TASK-1", "EXECUTING",
"VEH-1", "CMD-1", "UNKNOWN", "RC-1", Instant.EPOCH); "WF-1", "STEP-1", "VEH-1", "CMD-1", "UNKNOWN",
"RC-1", Instant.EPOCH);
} }
@Override @Override
@@ -89,9 +133,9 @@ class WcsOperationsQueryServiceTest {
@Override @Override
public ResourceOperationsView findResource(String resourceKey) { public ResourceOperationsView findResource(String resourceKey) {
return new ResourceOperationsView("NODE:A", "OCCUPIED", return new ResourceOperationsView("NODE:A", "NODE", 2, 1,
"ORDER-1", "STEP-1", "VEH-1", 77L, null, "VEH-1", "OCCUPIED", "ORDER-1", "STEP-1",
Instant.EPOCH); "VEH-1", 77L, null, Instant.EPOCH);
} }
@Override @Override
@@ -106,6 +150,48 @@ class WcsOperationsQueryServiceTest {
return Arrays.asList(recovery("RC-1"), recovery("RC-2")); return Arrays.asList(recovery("RC-1"), recovery("RC-2"));
} }
@Override
public List<OrderExecutionView> 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<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:A"));
}
@Override
public long countResources(String state) {
return 1L;
}
@Override
public long countRecoveryCases(String state) {
return 2L;
}
private static RecoveryCaseView recovery(String caseId) { private static RecoveryCaseView recovery(String caseId) {
return new RecoveryCaseView(caseId, "VEH-1", "CMD-1", "OPEN", return new RecoveryCaseView(caseId, "VEH-1", "CMD-1", "OPEN",
"TASK_NUMBER_MISMATCH", Instant.EPOCH, null, null); "TASK_NUMBER_MISMATCH", Instant.EPOCH, null, null);