From 434cbe57b98268bed2b876665c559c8ce5a1e89d Mon Sep 17 00:00:00 2001 From: zgw <13623675548@163.com> Date: Tue, 11 Aug 2026 11:27:31 +0800 Subject: [PATCH] feat(wcs): persist and query execution recovery state --- .../ruoyi/wcs/query/DeviceOperationsView.java | 54 +++ .../ruoyi/wcs/query/OrderExecutionView.java | 50 +++ .../com/ruoyi/wcs/query/RecoveryCaseView.java | 49 +++ .../wcs/query/ResourceOperationsView.java | 54 +++ .../wcs/query/WcsOperationsQueryService.java | 75 ++++ .../query/WcsOperationsReadRepository.java | 18 + .../ExecutionRecoveryMigrationTest.java | 63 ++++ .../query/WcsOperationsQueryServiceTest.java | 114 ++++++ sql/wcs/V003__wcs_execution_and_recovery.sql | 341 ++++++++++++++++++ ...erify_V003__wcs_execution_and_recovery.sql | 40 ++ 10 files changed, 858 insertions(+) create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/DeviceOperationsView.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/OrderExecutionView.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/RecoveryCaseView.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/ResourceOperationsView.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/WcsOperationsQueryService.java create mode 100644 ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/WcsOperationsReadRepository.java create mode 100644 ruoyi-wcs/src/test/java/com/ruoyi/wcs/infrastructure/persistence/recovery/ExecutionRecoveryMigrationTest.java create mode 100644 ruoyi-wcs/src/test/java/com/ruoyi/wcs/query/WcsOperationsQueryServiceTest.java create mode 100644 sql/wcs/V003__wcs_execution_and_recovery.sql create mode 100644 sql/wcs/verify_V003__wcs_execution_and_recovery.sql diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/DeviceOperationsView.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/DeviceOperationsView.java new file mode 100644 index 0000000..611437c --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/DeviceOperationsView.java @@ -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; + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/OrderExecutionView.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/OrderExecutionView.java new file mode 100644 index 0000000..90c867b --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/OrderExecutionView.java @@ -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; + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/RecoveryCaseView.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/RecoveryCaseView.java new file mode 100644 index 0000000..bc65771 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/RecoveryCaseView.java @@ -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; + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/ResourceOperationsView.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/ResourceOperationsView.java new file mode 100644 index 0000000..5bc308f --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/ResourceOperationsView.java @@ -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; + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/WcsOperationsQueryService.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/WcsOperationsQueryService.java new file mode 100644 index 0000000..b6667e9 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/WcsOperationsQueryService.java @@ -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 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 views = Objects.requireNonNull( + repository.findRecoveryCases(recoveryState, offset, limit), + "recovery case page"); + return Collections.unmodifiableList( + new ArrayList(views)); + } + + private static T requireResult(T value, String type, String key) { + 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(); + } +} diff --git a/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/WcsOperationsReadRepository.java b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/WcsOperationsReadRepository.java new file mode 100644 index 0000000..1e36790 --- /dev/null +++ b/ruoyi-wcs/src/main/java/com/ruoyi/wcs/query/WcsOperationsReadRepository.java @@ -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 findRecoveryCases(String state, int offset, + int limit); +} diff --git a/ruoyi-wcs/src/test/java/com/ruoyi/wcs/infrastructure/persistence/recovery/ExecutionRecoveryMigrationTest.java b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/infrastructure/persistence/recovery/ExecutionRecoveryMigrationTest.java new file mode 100644 index 0000000..e7ed5e0 --- /dev/null +++ b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/infrastructure/persistence/recovery/ExecutionRecoveryMigrationTest.java @@ -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); + } +} diff --git a/ruoyi-wcs/src/test/java/com/ruoyi/wcs/query/WcsOperationsQueryServiceTest.java b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/query/WcsOperationsQueryServiceTest.java new file mode 100644 index 0000000..5bcf644 --- /dev/null +++ b/ruoyi-wcs/src/test/java/com/ruoyi/wcs/query/WcsOperationsQueryServiceTest.java @@ -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 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 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); + } + } +} diff --git a/sql/wcs/V003__wcs_execution_and_recovery.sql b/sql/wcs/V003__wcs_execution_and_recovery.sql new file mode 100644 index 0000000..ff4b227 --- /dev/null +++ b/sql/wcs/V003__wcs_execution_and_recovery.sql @@ -0,0 +1,341 @@ +SET XACT_ABORT ON; +BEGIN TRANSACTION; + +IF OBJECT_ID('dbo.wcs_control_lease', 'U') IS NULL +BEGIN + CREATE TABLE dbo.wcs_control_lease ( + lease_name varchar(64) NOT NULL PRIMARY KEY, + owner_id varchar(128) NULL, + fencing_token bigint NOT NULL, + lease_until datetime2(3) NULL, + state_version bigint NOT NULL, + CONSTRAINT ck_wcs_control_lease_fencing CHECK (fencing_token >= 0), + CONSTRAINT ck_wcs_control_lease_version CHECK (state_version >= 0) + ); +END; + +IF OBJECT_ID('dbo.wcs_device_command', 'U') IS NULL +BEGIN + CREATE TABLE dbo.wcs_device_command ( + command_id varchar(128) NOT NULL PRIMARY KEY, + command_key varchar(128) NOT NULL, + order_id varchar(64) NULL, + step_id varchar(128) NULL, + device_id varchar(64) NOT NULL, + command_type varchar(64) NOT NULL, + payload_json nvarchar(max) NOT NULL, + transport_attempt int NOT NULL, + command_state varchar(32) NOT NULL, + send_receipt_id varchar(128) NULL, + acknowledge_evidence_id varchar(128) NULL, + completion_evidence_id varchar(128) NULL, + status_reason nvarchar(512) NULL, + created_time datetime2(3) NOT NULL, + updated_time datetime2(3) NOT NULL, + state_version bigint NOT NULL, + CONSTRAINT uk_wcs_device_command_key UNIQUE (command_key), + CONSTRAINT ck_wcs_device_command_attempt CHECK (transport_attempt >= 0), + CONSTRAINT ck_wcs_device_command_version CHECK (state_version >= 0) + ); +END; + +IF NOT EXISTS ( + SELECT 1 FROM sys.indexes + WHERE object_id = OBJECT_ID('dbo.wcs_device_command') + AND name = 'ix_wcs_device_command_device_state') +BEGIN + CREATE INDEX ix_wcs_device_command_device_state + ON dbo.wcs_device_command(device_id, command_state); +END; + +IF NOT EXISTS ( + SELECT 1 FROM sys.indexes + WHERE object_id = OBJECT_ID('dbo.wcs_device_command') + AND name = 'ix_wcs_device_command_order_step') +BEGIN + CREATE INDEX ix_wcs_device_command_order_step + ON dbo.wcs_device_command(order_id, step_id); +END; + +IF OBJECT_ID('dbo.wcs_device_execution', 'U') IS NULL +BEGIN + CREATE TABLE dbo.wcs_device_execution ( + command_id varchar(128) NOT NULL PRIMARY KEY, + command_key varchar(128) NOT NULL, + device_id varchar(64) NOT NULL, + task_number varchar(128) NOT NULL, + route_nodes_json nvarchar(max) NOT NULL, + route_resources_json nvarchar(max) NOT NULL, + topology_version bigint NOT NULL, + fencing_token bigint NOT NULL, + expected_boot_id varchar(128) NOT NULL, + current_node_index int NOT NULL, + released_resource_count int NOT NULL, + last_device_sequence bigint NOT NULL, + active_flag bit NOT NULL + CONSTRAINT df_wcs_device_execution_active DEFAULT (1), + updated_time datetime2(3) NOT NULL, + state_version bigint NOT NULL, + CONSTRAINT fk_wcs_device_execution_command FOREIGN KEY (command_id) + REFERENCES dbo.wcs_device_command(command_id), + CONSTRAINT uk_wcs_device_execution_key UNIQUE (command_key), + CONSTRAINT ck_wcs_device_execution_topology CHECK (topology_version > 0), + CONSTRAINT ck_wcs_device_execution_fencing CHECK (fencing_token > 0), + CONSTRAINT ck_wcs_device_execution_indexes CHECK ( + current_node_index >= 0 AND released_resource_count >= 0), + CONSTRAINT ck_wcs_device_execution_version CHECK (state_version >= 0) + ); +END; + +IF NOT EXISTS ( + SELECT 1 FROM sys.indexes + WHERE object_id = OBJECT_ID('dbo.wcs_device_execution') + AND name = 'uk_wcs_device_execution_active_device') +BEGIN + CREATE UNIQUE INDEX uk_wcs_device_execution_active_device + ON dbo.wcs_device_execution(device_id) + WHERE active_flag = 1; +END; + +IF OBJECT_ID('dbo.wcs_command_outbox', 'U') IS NULL +BEGIN + CREATE TABLE dbo.wcs_command_outbox ( + command_id varchar(128) NOT NULL PRIMARY KEY, + command_key varchar(128) NOT NULL, + payload_json nvarchar(max) NOT NULL, + topology_version bigint NOT NULL, + fencing_token bigint NOT NULL, + delivery_state varchar(32) NOT NULL, + attempt_count int NOT NULL, + next_attempt_time datetime2(3) NOT NULL, + claimed_by varchar(128) NULL, + claimed_time datetime2(3) NULL, + last_error nvarchar(1024) NULL, + delivered_time datetime2(3) NULL, + state_version bigint NOT NULL, + CONSTRAINT fk_wcs_command_outbox_command FOREIGN KEY (command_id) + REFERENCES dbo.wcs_device_command(command_id), + CONSTRAINT uk_wcs_command_outbox_key UNIQUE (command_key), + CONSTRAINT ck_wcs_command_outbox_attempt CHECK (attempt_count >= 0), + CONSTRAINT ck_wcs_command_outbox_topology CHECK (topology_version > 0), + CONSTRAINT ck_wcs_command_outbox_fencing CHECK (fencing_token > 0), + CONSTRAINT ck_wcs_command_outbox_version CHECK (state_version >= 0) + ); +END; + +IF NOT EXISTS ( + SELECT 1 FROM sys.indexes + WHERE object_id = OBJECT_ID('dbo.wcs_command_outbox') + AND name = 'ix_wcs_command_outbox_due') +BEGIN + CREATE INDEX ix_wcs_command_outbox_due + ON dbo.wcs_command_outbox(delivery_state, next_attempt_time); +END; + +IF OBJECT_ID('dbo.wcs_wms_notification_outbox', 'U') IS NULL +BEGIN + CREATE TABLE dbo.wcs_wms_notification_outbox ( + outbox_id bigint IDENTITY(1,1) NOT NULL PRIMARY KEY, + event_id varchar(128) NOT NULL, + notification_type varchar(64) NOT NULL, + payload_json nvarchar(max) NOT NULL, + delivery_state varchar(32) NOT NULL, + attempt_count int NOT NULL, + next_attempt_time datetime2(3) NOT NULL, + claimed_by varchar(128) NULL, + claimed_time datetime2(3) NULL, + external_receipt varchar(256) NULL, + last_error nvarchar(1024) NULL, + created_time datetime2(3) NOT NULL, + delivered_time datetime2(3) NULL, + state_version bigint NOT NULL, + CONSTRAINT uk_wcs_wms_outbox_event UNIQUE (event_id), + CONSTRAINT ck_wcs_wms_outbox_attempt CHECK (attempt_count >= 0), + CONSTRAINT ck_wcs_wms_outbox_version CHECK (state_version >= 0) + ); +END; + +IF NOT EXISTS ( + SELECT 1 FROM sys.indexes + WHERE object_id = OBJECT_ID('dbo.wcs_wms_notification_outbox') + AND name = 'ix_wcs_wms_outbox_due') +BEGIN + CREATE INDEX ix_wcs_wms_outbox_due + ON dbo.wcs_wms_notification_outbox( + delivery_state, next_attempt_time); +END; + +IF OBJECT_ID('dbo.wcs_device_snapshot', 'U') IS NULL +BEGIN + CREATE TABLE dbo.wcs_device_snapshot ( + snapshot_id varchar(128) NOT NULL PRIMARY KEY, + device_id varchar(64) NOT NULL, + command_key varchar(128) NOT NULL, + task_number varchar(128) NOT NULL, + device_sequence bigint NOT NULL, + observed_time datetime2(3) NOT NULL, + connected_flag bit NOT NULL, + device_boot_id varchar(128) NOT NULL, + topology_version bigint NOT NULL, + fencing_token bigint NOT NULL, + current_node_id varchar(128) NOT NULL, + remaining_route_json nvarchar(max) NOT NULL, + command_completed_flag bit NOT NULL, + facts_json nvarchar(max) NULL, + received_time datetime2(3) NOT NULL, + CONSTRAINT ck_wcs_device_snapshot_sequence CHECK (device_sequence >= 0), + CONSTRAINT ck_wcs_device_snapshot_topology CHECK (topology_version > 0), + CONSTRAINT ck_wcs_device_snapshot_fencing CHECK (fencing_token > 0) + ); +END; + +IF NOT EXISTS ( + SELECT 1 FROM sys.indexes + WHERE object_id = OBJECT_ID('dbo.wcs_device_snapshot') + AND name = 'ix_wcs_device_snapshot_device_time') +BEGIN + CREATE INDEX ix_wcs_device_snapshot_device_time + ON dbo.wcs_device_snapshot(device_id, observed_time DESC); +END; + +IF OBJECT_ID('dbo.wcs_device_event', 'U') IS NULL +BEGIN + CREATE TABLE dbo.wcs_device_event ( + event_id varchar(128) NOT NULL PRIMARY KEY, + device_id varchar(64) NOT NULL, + command_key varchar(128) NOT NULL, + task_number varchar(128) NOT NULL, + device_sequence bigint NOT NULL, + event_type varchar(32) NOT NULL, + current_node_id varchar(128) NULL, + remaining_route_json nvarchar(max) NULL, + facts_json nvarchar(max) NULL, + device_time datetime2(3) NOT NULL, + received_time datetime2(3) NOT NULL, + CONSTRAINT ck_wcs_device_event_sequence CHECK (device_sequence >= 0) + ); +END; + +IF NOT EXISTS ( + SELECT 1 FROM sys.indexes + WHERE object_id = OBJECT_ID('dbo.wcs_device_event') + AND name = 'ix_wcs_device_event_device_sequence') +BEGIN + CREATE INDEX ix_wcs_device_event_device_sequence + ON dbo.wcs_device_event(device_id, device_sequence); +END; + +IF OBJECT_ID('dbo.wcs_alarm', 'U') IS NULL +BEGIN + CREATE TABLE dbo.wcs_alarm ( + alarm_id varchar(128) NOT NULL PRIMARY KEY, + device_id varchar(64) NULL, + command_key varchar(128) NULL, + recovery_case_id varchar(128) NULL, + alarm_code varchar(128) NOT NULL, + severity varchar(16) NOT NULL, + alarm_state varchar(32) NOT NULL, + detail nvarchar(1024) NULL, + source_event_id varchar(128) NULL, + opened_time datetime2(3) NOT NULL, + acknowledged_by varchar(128) NULL, + acknowledged_time datetime2(3) NULL, + closed_time datetime2(3) NULL, + state_version bigint NOT NULL, + CONSTRAINT ck_wcs_alarm_version CHECK (state_version >= 0) + ); +END; + +IF NOT EXISTS ( + SELECT 1 FROM sys.indexes + WHERE object_id = OBJECT_ID('dbo.wcs_alarm') + AND name = 'ix_wcs_alarm_device_state') +BEGIN + CREATE INDEX ix_wcs_alarm_device_state + ON dbo.wcs_alarm(device_id, alarm_state, opened_time DESC); +END; + +IF OBJECT_ID('dbo.wcs_recovery_case', 'U') IS NULL +BEGIN + CREATE TABLE dbo.wcs_recovery_case ( + case_id varchar(128) NOT NULL PRIMARY KEY, + device_id varchar(64) NOT NULL, + command_key varchar(128) NOT NULL, + reason_code varchar(128) NOT NULL, + evidence_ids_json nvarchar(max) NOT NULL, + expected_task_number varchar(128) NULL, + expected_site varchar(128) NULL, + expected_load_state varchar(32) NULL, + recovery_state varchar(32) NOT NULL, + created_time datetime2(3) NOT NULL, + resolved_by varchar(128) NULL, + resolved_time datetime2(3) NULL, + resolution_evidence_json nvarchar(max) NULL, + resolution_reason nvarchar(512) NULL, + state_version bigint NOT NULL, + CONSTRAINT ck_wcs_recovery_case_version CHECK (state_version >= 0), + CONSTRAINT ck_wcs_recovery_case_expected_facts CHECK ( + (expected_task_number IS NULL AND expected_site IS NULL + AND expected_load_state IS NULL) + OR + (expected_task_number IS NOT NULL AND expected_site IS NOT NULL + AND expected_load_state IS NOT NULL)) + ); +END; + +IF NOT EXISTS ( + SELECT 1 FROM sys.indexes + WHERE object_id = OBJECT_ID('dbo.wcs_recovery_case') + AND name = 'uk_wcs_recovery_case_open') +BEGIN + CREATE UNIQUE INDEX uk_wcs_recovery_case_open + ON dbo.wcs_recovery_case(device_id, command_key) + WHERE recovery_state = 'OPEN'; +END; + +IF NOT EXISTS ( + SELECT 1 FROM sys.indexes + WHERE object_id = OBJECT_ID('dbo.wcs_recovery_case') + AND name = 'ix_wcs_recovery_case_state_created') +BEGIN + CREATE INDEX ix_wcs_recovery_case_state_created + ON dbo.wcs_recovery_case(recovery_state, created_time); +END; + +IF OBJECT_ID('dbo.wcs_manual_action_audit', 'U') IS NULL +BEGIN + CREATE TABLE dbo.wcs_manual_action_audit ( + audit_id varchar(128) NOT NULL PRIMARY KEY, + case_id varchar(128) NOT NULL, + action_type varchar(32) NOT NULL, + operator_id varchar(128) NOT NULL, + action_reason nvarchar(512) NOT NULL, + before_state varchar(32) NOT NULL, + after_state varchar(32) NOT NULL, + evidence_json nvarchar(max) NULL, + occurred_time datetime2(3) NOT NULL, + CONSTRAINT fk_wcs_manual_action_case FOREIGN KEY (case_id) + REFERENCES dbo.wcs_recovery_case(case_id) + ); +END; + +IF NOT EXISTS ( + SELECT 1 FROM sys.indexes + WHERE object_id = OBJECT_ID('dbo.wcs_manual_action_audit') + AND name = 'ix_wcs_manual_action_case_time') +BEGIN + CREATE INDEX ix_wcs_manual_action_case_time + ON dbo.wcs_manual_action_audit(case_id, occurred_time); +END; + +IF OBJECT_ID('dbo.wcs_resource_reservation', 'U') IS NOT NULL +AND NOT EXISTS ( + SELECT 1 FROM sys.indexes + WHERE object_id = OBJECT_ID('dbo.wcs_resource_reservation') + AND name = 'ix_wcs_resource_reservation_resource_state') +BEGIN + CREATE INDEX ix_wcs_resource_reservation_resource_state + ON dbo.wcs_resource_reservation(resource_key, reservation_state); +END; + +COMMIT TRANSACTION; diff --git a/sql/wcs/verify_V003__wcs_execution_and_recovery.sql b/sql/wcs/verify_V003__wcs_execution_and_recovery.sql new file mode 100644 index 0000000..d0e45cd --- /dev/null +++ b/sql/wcs/verify_V003__wcs_execution_and_recovery.sql @@ -0,0 +1,40 @@ +SET NOCOUNT ON; + +SELECT name AS table_name +FROM sys.tables +WHERE name IN ( + 'wcs_control_lease', + 'wcs_device_command', + 'wcs_device_execution', + 'wcs_command_outbox', + 'wcs_wms_notification_outbox', + 'wcs_device_snapshot', + 'wcs_device_event', + 'wcs_alarm', + 'wcs_recovery_case', + 'wcs_manual_action_audit' +) +ORDER BY name; + +SELECT i.name AS index_name, t.name AS table_name, i.is_unique +FROM sys.indexes i +JOIN sys.tables t ON t.object_id = i.object_id +WHERE i.name IN ( + 'uk_wcs_recovery_case_open', + 'uk_wcs_wms_outbox_event', + 'ix_wcs_device_command_device_state', + 'ix_wcs_device_command_order_step', + 'ix_wcs_resource_reservation_resource_state', + 'ix_wcs_recovery_case_state_created' +) +ORDER BY i.name; + +SELECT c.name AS constraint_name +FROM sys.check_constraints c +WHERE c.name IN ( + 'ck_wcs_control_lease_fencing', + 'ck_wcs_device_execution_fencing', + 'ck_wcs_command_outbox_fencing', + 'ck_wcs_recovery_case_expected_facts' +) +ORDER BY c.name;