feat(wcs): persist and query execution recovery state
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user