feat(wcs): persist and query execution recovery state

This commit is contained in:
zgw
2026-08-11 11:27:31 +08:00
parent 9782800b0b
commit 434cbe57b9
10 changed files with 858 additions and 0 deletions

View File

@@ -0,0 +1,54 @@
package com.ruoyi.wcs.query;
import java.time.Instant;
import java.util.Objects;
public final class DeviceOperationsView {
private final String deviceId;
private final String deviceType;
private final String currentCommandKey;
private final String commandState;
private final String currentSite;
private final boolean connected;
private final String activeAlarmCode;
private final String recoveryCaseId;
private final Instant updatedAt;
public DeviceOperationsView(String deviceId, String deviceType,
String currentCommandKey, String commandState,
String currentSite, boolean connected,
String activeAlarmCode, String recoveryCaseId,
Instant updatedAt) {
this.deviceId = requireText(deviceId, "deviceId");
this.deviceType = requireText(deviceType, "deviceType");
this.currentCommandKey = optional(currentCommandKey);
this.commandState = optional(commandState);
this.currentSite = optional(currentSite);
this.connected = connected;
this.activeAlarmCode = optional(activeAlarmCode);
this.recoveryCaseId = optional(recoveryCaseId);
this.updatedAt = Objects.requireNonNull(updatedAt, "updatedAt");
}
public String getDeviceId() { return deviceId; }
public String getDeviceType() { return deviceType; }
public String getCurrentCommandKey() { return currentCommandKey; }
public String getCommandState() { return commandState; }
public String getCurrentSite() { return currentSite; }
public boolean isConnected() { return connected; }
public String getActiveAlarmCode() { return activeAlarmCode; }
public String getRecoveryCaseId() { return recoveryCaseId; }
public Instant getUpdatedAt() { return updatedAt; }
private static String optional(String value) {
return value == null || value.trim().isEmpty() ? null : value.trim();
}
private static String requireText(String value, String field) {
String text = optional(value);
if (text == null) {
throw new IllegalArgumentException(field + " must not be blank");
}
return text;
}
}

View File

@@ -0,0 +1,50 @@
package com.ruoyi.wcs.query;
import java.time.Instant;
import java.util.Objects;
public final class OrderExecutionView {
private final String orderId;
private final String workflowId;
private final String stepId;
private final String deviceId;
private final String currentCommandKey;
private final String commandState;
private final String recoveryCaseId;
private final Instant updatedAt;
public OrderExecutionView(String orderId, String workflowId, String stepId,
String deviceId, String currentCommandKey,
String commandState, String recoveryCaseId,
Instant updatedAt) {
this.orderId = requireText(orderId, "orderId");
this.workflowId = optional(workflowId);
this.stepId = optional(stepId);
this.deviceId = optional(deviceId);
this.currentCommandKey = optional(currentCommandKey);
this.commandState = optional(commandState);
this.recoveryCaseId = optional(recoveryCaseId);
this.updatedAt = Objects.requireNonNull(updatedAt, "updatedAt");
}
public String getOrderId() { return orderId; }
public String getWorkflowId() { return workflowId; }
public String getStepId() { return stepId; }
public String getDeviceId() { return deviceId; }
public String getCurrentCommandKey() { return currentCommandKey; }
public String getCommandState() { return commandState; }
public String getRecoveryCaseId() { return recoveryCaseId; }
public Instant getUpdatedAt() { return updatedAt; }
private static String optional(String value) {
return value == null || value.trim().isEmpty() ? null : value.trim();
}
private static String requireText(String value, String field) {
String text = optional(value);
if (text == null) {
throw new IllegalArgumentException(field + " must not be blank");
}
return text;
}
}

View File

@@ -0,0 +1,49 @@
package com.ruoyi.wcs.query;
import java.time.Instant;
import java.util.Objects;
public final class RecoveryCaseView {
private final String caseId;
private final String deviceId;
private final String commandKey;
private final String state;
private final String reason;
private final Instant createdAt;
private final String resolvedBy;
private final Instant resolvedAt;
public RecoveryCaseView(String caseId, String deviceId, String commandKey,
String state, String reason, Instant createdAt,
String resolvedBy, Instant resolvedAt) {
this.caseId = requireText(caseId, "caseId");
this.deviceId = requireText(deviceId, "deviceId");
this.commandKey = requireText(commandKey, "commandKey");
this.state = requireText(state, "state");
this.reason = requireText(reason, "reason");
this.createdAt = Objects.requireNonNull(createdAt, "createdAt");
this.resolvedBy = optional(resolvedBy);
this.resolvedAt = resolvedAt;
}
public String getCaseId() { return caseId; }
public String getDeviceId() { return deviceId; }
public String getCommandKey() { return commandKey; }
public String getState() { return state; }
public String getReason() { return reason; }
public Instant getCreatedAt() { return createdAt; }
public String getResolvedBy() { return resolvedBy; }
public Instant getResolvedAt() { return resolvedAt; }
private static String optional(String value) {
return value == null || value.trim().isEmpty() ? null : value.trim();
}
private static String requireText(String value, String field) {
String text = optional(value);
if (text == null) {
throw new IllegalArgumentException(field + " must not be blank");
}
return text;
}
}

View File

@@ -0,0 +1,54 @@
package com.ruoyi.wcs.query;
import java.time.Instant;
import java.util.Objects;
public final class ResourceOperationsView {
private final String resourceKey;
private final String reservationState;
private final String orderId;
private final String stepId;
private final String vehicleId;
private final long fencingToken;
private final String releaseEvidenceId;
private final Instant updatedAt;
public ResourceOperationsView(String resourceKey, String reservationState,
String orderId, String stepId,
String vehicleId, long fencingToken,
String releaseEvidenceId, Instant updatedAt) {
this.resourceKey = requireText(resourceKey, "resourceKey");
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.fencingToken = fencingToken;
this.releaseEvidenceId = optional(releaseEvidenceId);
this.updatedAt = Objects.requireNonNull(updatedAt, "updatedAt");
}
public String getResourceKey() { return resourceKey; }
public String getReservationState() { return reservationState; }
public String getOrderId() { return orderId; }
public String getStepId() { return stepId; }
public String getVehicleId() { return vehicleId; }
public long getFencingToken() { return fencingToken; }
public String getReleaseEvidenceId() { return releaseEvidenceId; }
public Instant getUpdatedAt() { return updatedAt; }
private static String optional(String value) {
return value == null || value.trim().isEmpty() ? null : value.trim();
}
private static String requireText(String value, String field) {
String text = optional(value);
if (text == null) {
throw new IllegalArgumentException(field + " must not be blank");
}
return text;
}
}

View File

@@ -0,0 +1,75 @@
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;
public final class WcsOperationsQueryService {
private static final int MAX_PAGE_SIZE = 500;
private final WcsOperationsReadRepository repository;
public WcsOperationsQueryService(WcsOperationsReadRepository repository) {
this.repository = Objects.requireNonNull(repository, "repository");
}
public OrderExecutionView order(String orderId) {
String id = requireText(orderId, "orderId");
return requireResult(repository.findOrder(id), "order", id);
}
public OrderExecutionView command(String commandKey) {
String key = requireText(commandKey, "commandKey");
return requireResult(repository.findCommand(key), "command", key);
}
public DeviceOperationsView device(String deviceId) {
String id = requireText(deviceId, "deviceId");
return requireResult(repository.findDevice(id), "device", id);
}
public ResourceOperationsView resource(String resourceKey) {
String key = requireText(resourceKey, "resourceKey");
return requireResult(repository.findResource(key), "resource", key);
}
public RecoveryCaseView recoveryCase(String caseId) {
String id = requireText(caseId, "caseId");
return requireResult(repository.findRecoveryCase(id),
"recovery case", id);
}
public List<RecoveryCaseView> recoveryCases(String state, int offset,
int limit) {
String recoveryState = requireText(state, "state");
if (offset < 0) {
throw new IllegalArgumentException("offset must not be negative");
}
if (limit <= 0 || limit > MAX_PAGE_SIZE) {
throw new IllegalArgumentException(
"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) {
if (value == null) {
throw new DomainRuleViolation(type + " not found: " + key);
}
return value;
}
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();
}
}

View File

@@ -0,0 +1,18 @@
package com.ruoyi.wcs.query;
import java.util.List;
public interface WcsOperationsReadRepository {
OrderExecutionView findOrder(String orderId);
OrderExecutionView findCommand(String commandKey);
DeviceOperationsView findDevice(String deviceId);
ResourceOperationsView findResource(String resourceKey);
RecoveryCaseView findRecoveryCase(String caseId);
List<RecoveryCaseView> findRecoveryCases(String state, int offset,
int limit);
}

View File

@@ -0,0 +1,63 @@
package com.ruoyi.wcs.infrastructure.persistence.recovery;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ExecutionRecoveryMigrationTest {
@Test
void packagesExecutionRecoveryTablesAndIdempotencyIndexes() throws Exception {
String migration = readSql("V003__wcs_execution_and_recovery.sql");
assertContains(migration, "wcs_device_command");
assertContains(migration, "wcs_device_execution");
assertContains(migration, "wcs_command_outbox");
assertContains(migration, "wcs_wms_notification_outbox");
assertContains(migration, "wcs_device_snapshot");
assertContains(migration, "wcs_device_event");
assertContains(migration, "wcs_alarm");
assertContains(migration, "wcs_recovery_case");
assertContains(migration, "wcs_manual_action_audit");
assertContains(migration, "wcs_control_lease");
assertContains(migration, "uk_wcs_recovery_case_open");
assertContains(migration, "WHERE recovery_state = 'OPEN'");
assertContains(migration, "uk_wcs_wms_outbox_event");
assertContains(migration, "ix_wcs_device_command_order_step");
assertContains(migration, "ix_wcs_resource_reservation_resource_state");
}
@Test
void packagesTheExecutionRecoveryVerificationScript() throws Exception {
String verification = readSql(
"verify_V003__wcs_execution_and_recovery.sql");
assertContains(verification, "wcs_device_command");
assertContains(verification, "uk_wcs_recovery_case_open");
assertContains(verification, "uk_wcs_wms_outbox_event");
}
private static void assertContains(String sql, String token) {
assertTrue(sql.contains(token), "missing SQL token: " + token);
}
private static String readSql(String filename) throws Exception {
Path current = Paths.get(System.getProperty(
"maven.multiModuleProjectDirectory", "."))
.toAbsolutePath().normalize();
while (current != null) {
Path file = current.resolve(Paths.get("sql", "wcs", filename));
if (Files.isRegularFile(file)) {
return new String(Files.readAllBytes(file),
StandardCharsets.UTF_8);
}
current = current.getParent();
}
throw new java.nio.file.NoSuchFileException(filename);
}
}

View File

@@ -0,0 +1,114 @@
package com.ruoyi.wcs.query;
import com.ruoyi.wcs.domain.shared.DomainRuleViolation;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class WcsOperationsQueryServiceTest {
@Test
void returnsCommandAndRecoveryCorrelationForAnOrder() {
WcsOperationsQueryService queries = new WcsOperationsQueryService(
new FakeReadRepository());
OrderExecutionView view = queries.order("ORDER-1");
assertEquals("CMD-1", view.getCurrentCommandKey());
assertEquals("RC-1", view.getRecoveryCaseId());
assertEquals("UNKNOWN", view.getCommandState());
assertEquals("VEH-1", view.getDeviceId());
assertEquals("ORDER-1", queries.command("CMD-1").getOrderId());
}
@Test
void exposesStableDeviceResourceAndRecoveryViews() {
WcsOperationsQueryService queries = new WcsOperationsQueryService(
new FakeReadRepository());
assertEquals("ALARM-1", queries.device("VEH-1").getActiveAlarmCode());
assertEquals(77L, queries.resource("NODE:A").getFencingToken());
assertEquals("TASK_NUMBER_MISMATCH",
queries.recoveryCase("RC-1").getReason());
}
@Test
void pagesRecoveryCasesByExplicitStateWithABoundedLimit() {
WcsOperationsQueryService queries = new WcsOperationsQueryService(
new FakeReadRepository());
List<RecoveryCaseView> page = queries.recoveryCases(
"OPEN", 0, 50);
assertEquals(Arrays.asList("RC-1", "RC-2"), Arrays.asList(
page.get(0).getCaseId(), page.get(1).getCaseId()));
assertThrows(UnsupportedOperationException.class,
() -> page.add(page.get(0)));
assertThrows(IllegalArgumentException.class,
() -> queries.recoveryCases("OPEN", 0, 501));
}
@Test
void missingOrBlankOperationsLookupFailsExplicitly() {
WcsOperationsQueryService queries = new WcsOperationsQueryService(
new FakeReadRepository());
assertThrows(IllegalArgumentException.class, () -> queries.order(" "));
assertThrows(DomainRuleViolation.class,
() -> queries.order("ORDER-MISSING"));
}
private static final class FakeReadRepository
implements WcsOperationsReadRepository {
@Override
public OrderExecutionView findOrder(String orderId) {
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);
}
@Override
public OrderExecutionView findCommand(String commandKey) {
return "CMD-1".equals(commandKey) ? findOrder("ORDER-1") : null;
}
@Override
public DeviceOperationsView findDevice(String deviceId) {
return new DeviceOperationsView("VEH-1", "RGV", "CMD-1",
"UNKNOWN", "NODE:A", true, "ALARM-1", "RC-1",
Instant.EPOCH);
}
@Override
public ResourceOperationsView findResource(String resourceKey) {
return new ResourceOperationsView("NODE:A", "OCCUPIED",
"ORDER-1", "STEP-1", "VEH-1", 77L, null,
Instant.EPOCH);
}
@Override
public RecoveryCaseView findRecoveryCase(String caseId) {
return recovery(caseId);
}
@Override
public List<RecoveryCaseView> findRecoveryCases(String state,
int offset,
int limit) {
return Arrays.asList(recovery("RC-1"), recovery("RC-2"));
}
private static RecoveryCaseView recovery(String caseId) {
return new RecoveryCaseView(caseId, "VEH-1", "CMD-1", "OPEN",
"TASK_NUMBER_MISMATCH", Instant.EPOCH, null, null);
}
}
}