diff --git a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/CapabilityGateResult.java b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/CapabilityGateResult.java new file mode 100644 index 0000000..c2053d3 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/CapabilityGateResult.java @@ -0,0 +1,28 @@ +package com.ruoyi.project.ft546.config; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + +public final class CapabilityGateResult { + private final boolean publishable; + private final String reasonCode; + private final Set enabledCapabilities; + + CapabilityGateResult(boolean publishable, String reasonCode, + Set enabledCapabilities) { + this.publishable = publishable; + this.reasonCode = reasonCode; + this.enabledCapabilities = Collections.unmodifiableSet( + new LinkedHashSet(enabledCapabilities)); + } + + public boolean isPublishable() { return publishable; } + public String getReasonCode() { return reasonCode; } + public boolean isEnabled(String capability) { + return enabledCapabilities.contains(capability); + } + public Set getEnabledCapabilities() { + return enabledCapabilities; + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546CapabilityEvidence.java b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546CapabilityEvidence.java new file mode 100644 index 0000000..6d8d9ca --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546CapabilityEvidence.java @@ -0,0 +1,55 @@ +package com.ruoyi.project.ft546.config; + +import java.time.Instant; +import java.util.Objects; + +public final class Ft546CapabilityEvidence { + private final long profileVersion; + private final String corpusManifestHash; + private final String passingTestRunId; + private final String gitCommit; + private final boolean protocolRegressionPassed; + private final Instant approvedAt; + private final Instant expiresAt; + + public Ft546CapabilityEvidence(long profileVersion, + String corpusManifestHash, + String passingTestRunId, + String gitCommit, + boolean protocolRegressionPassed, + Instant approvedAt, Instant expiresAt) { + if (profileVersion <= 0L) { + throw new IllegalArgumentException( + "profileVersion must be positive"); + } + this.profileVersion = profileVersion; + this.corpusManifestHash = requireText(corpusManifestHash, + "corpusManifestHash"); + this.passingTestRunId = requireText(passingTestRunId, + "passingTestRunId"); + this.gitCommit = requireText(gitCommit, "gitCommit"); + this.protocolRegressionPassed = protocolRegressionPassed; + this.approvedAt = Objects.requireNonNull(approvedAt, "approvedAt"); + this.expiresAt = Objects.requireNonNull(expiresAt, "expiresAt"); + if (!expiresAt.isAfter(approvedAt)) { + throw new IllegalArgumentException("evidence expiry is invalid"); + } + } + + public long getProfileVersion() { return profileVersion; } + public String getCorpusManifestHash() { return corpusManifestHash; } + public String getPassingTestRunId() { return passingTestRunId; } + public String getGitCommit() { return gitCommit; } + public boolean isProtocolRegressionPassed() { + return protocolRegressionPassed; + } + public Instant getApprovedAt() { return approvedAt; } + public Instant getExpiresAt() { return expiresAt; } + + 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-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546CapabilityGate.java b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546CapabilityGate.java new file mode 100644 index 0000000..d162328 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546CapabilityGate.java @@ -0,0 +1,50 @@ +package com.ruoyi.project.ft546.config; + +import java.time.Instant; +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; +import java.util.function.Supplier; + +public final class Ft546CapabilityGate { + private final Supplier clock; + private final Ft546ConfigurationValidator validator = + new Ft546ConfigurationValidator(); + + public Ft546CapabilityGate(Supplier clock) { + this.clock = Objects.requireNonNull(clock, "clock"); + } + + public CapabilityGateResult evaluate(Ft546DeviceProfile profile, + Ft546CapabilityEvidence evidence) { + validator.validate(profile); + Set enabled = new LinkedHashSet( + profile.getCapabilities()); + enabled.remove("TWO_STAGE_ATOMIC_TASK"); + if (!profile.getCapabilities().contains("TWO_STAGE_ATOMIC_TASK")) { + return new CapabilityGateResult(true, "PUBLISHABLE", enabled); + } + if (!matches(profile, evidence, now())) { + return new CapabilityGateResult(false, + "TWO_STAGE_EVIDENCE_REQUIRED", enabled); + } + enabled.add("TWO_STAGE_ATOMIC_TASK"); + return new CapabilityGateResult(true, "PUBLISHABLE", enabled); + } + + private static boolean matches(Ft546DeviceProfile profile, + Ft546CapabilityEvidence evidence, + Instant now) { + return evidence != null + && evidence.isProtocolRegressionPassed() + && evidence.getProfileVersion() == profile.getProfileVersion() + && evidence.getCorpusManifestHash().equals( + profile.getProtocolCorpusHash()) + && !evidence.getApprovedAt().isAfter(now) + && evidence.getExpiresAt().isAfter(now); + } + + private Instant now() { + return Objects.requireNonNull(clock.get(), "clock value"); + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546ConfigurationValidator.java b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546ConfigurationValidator.java new file mode 100644 index 0000000..9a9a8c4 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546ConfigurationValidator.java @@ -0,0 +1,91 @@ +package com.ruoyi.project.ft546.config; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +public final class Ft546ConfigurationValidator { + private static final Set LEGACY_FORBIDDEN = new HashSet( + Arrays.asList("COMMAND_RESULT_QUERY", "ORDERED_DEVICE_SNAPSHOT", + "CANDIDATE_BUFFER", "PREPARE_COMMIT_SEGMENT", + "IDEMPOTENT_COMMAND_KEY", "BOOT_SESSION_ID")); + private static final Set LEGACY_REQUIRED_REGISTERS = + new LinkedHashSet(Arrays.asList("taskNo", "taskType", + "path", "trigger", "status", "execution")); + + public void validate(Ft546WcsProperties properties) { + if (properties == null || properties.getRuntimeMode() == null) { + throw new IllegalArgumentException("runtimeMode must be configured"); + } + if (properties.getDispatchIntervalMs() <= 0L + || properties.getCommandPollIntervalMs() <= 0L) { + throw new IllegalArgumentException( + "runtime intervals must be positive"); + } + } + + public void validate(Ft546DeviceProfile profile) { + if (profile == null) { + throw new IllegalArgumentException("profile must not be null"); + } + requireText(profile.getDeviceId(), "deviceId"); + requireText(profile.getDeviceType(), "deviceType"); + String adapterType = requireText(profile.getAdapterType(), + "adapterType"); + if (profile.getProfileVersion() <= 0L) { + throw new IllegalArgumentException( + "profileVersion must be positive"); + } + if (profile.getPort() != null + && (profile.getPort() <= 0 || profile.getPort() > 65535)) { + throw new IllegalArgumentException("port is invalid"); + } + if (profile.getStation() != null && profile.getStation() < 0) { + throw new IllegalArgumentException("station is invalid"); + } + validateUniqueRegisterOwnership(profile.getRegisters()); + if ("FT546_LEGACY_SRGV".equals(adapterType)) { + if (!profile.getRegisters().keySet().containsAll( + LEGACY_REQUIRED_REGISTERS)) { + throw new IllegalArgumentException( + "legacy shuttle profile is missing required registers"); + } + for (String forbidden : LEGACY_FORBIDDEN) { + if (profile.getCapabilities().contains(forbidden)) { + throw new IllegalArgumentException( + "legacy adapter cannot claim " + forbidden); + } + } + } + if (profile.getCapabilities().contains("TWO_STAGE_ATOMIC_TASK")) { + requireText(profile.getProtocolCorpusHash(), + "protocolCorpusHash"); + } + } + + private static void validateUniqueRegisterOwnership( + Map registers) { + if (registers == null || registers.isEmpty()) { + throw new IllegalArgumentException("registers must not be empty"); + } + Set addresses = new HashSet(); + for (Map.Entry register : registers.entrySet()) { + requireText(register.getKey(), "register name"); + String address = requireText(register.getValue(), + "register address"); + if (!addresses.add(address)) { + throw new IllegalArgumentException( + "duplicate register ownership: " + address); + } + } + } + + 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-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546DeviceProfile.java b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546DeviceProfile.java new file mode 100644 index 0000000..ea55a8a --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546DeviceProfile.java @@ -0,0 +1,53 @@ +package com.ruoyi.project.ft546.config; + +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +public class Ft546DeviceProfile { + private String deviceId; + private String deviceType; + private String adapterType; + private String ip; + private Integer port; + private Integer station; + private Set capabilities = new LinkedHashSet(); + private Map registers = + new LinkedHashMap(); + private long profileVersion; + private String protocolCorpusHash; + + public String getDeviceId() { return deviceId; } + public void setDeviceId(String deviceId) { this.deviceId = deviceId; } + public String getDeviceType() { return deviceType; } + public void setDeviceType(String deviceType) { this.deviceType = deviceType; } + public String getAdapterType() { return adapterType; } + public void setAdapterType(String adapterType) { this.adapterType = adapterType; } + public String getIp() { return ip; } + public void setIp(String ip) { this.ip = ip; } + public Integer getPort() { return port; } + public void setPort(Integer port) { this.port = port; } + public Integer getStation() { return station; } + public void setStation(Integer station) { this.station = station; } + public Set getCapabilities() { return capabilities; } + public void setCapabilities(Set capabilities) { + this.capabilities = capabilities == null + ? new LinkedHashSet() + : new LinkedHashSet(capabilities); + } + public Map getRegisters() { return registers; } + public void setRegisters(Map registers) { + this.registers = registers == null + ? new LinkedHashMap() + : new LinkedHashMap(registers); + } + public long getProfileVersion() { return profileVersion; } + public void setProfileVersion(long profileVersion) { + this.profileVersion = profileVersion; + } + public String getProtocolCorpusHash() { return protocolCorpusHash; } + public void setProtocolCorpusHash(String protocolCorpusHash) { + this.protocolCorpusHash = protocolCorpusHash; + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546WcsProperties.java b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546WcsProperties.java new file mode 100644 index 0000000..5ba1342 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/project/ft546/config/Ft546WcsProperties.java @@ -0,0 +1,31 @@ +package com.ruoyi.project.ft546.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "ft546.wcs") +public class Ft546WcsProperties { + public enum RuntimeMode { + LEGACY, + SHADOW, + STANDARD + } + + private RuntimeMode runtimeMode = RuntimeMode.LEGACY; + private long dispatchIntervalMs = 1000L; + private long commandPollIntervalMs = 200L; + + public RuntimeMode getRuntimeMode() { return runtimeMode; } + public void setRuntimeMode(RuntimeMode runtimeMode) { + this.runtimeMode = runtimeMode; + } + public long getDispatchIntervalMs() { return dispatchIntervalMs; } + public void setDispatchIntervalMs(long dispatchIntervalMs) { + this.dispatchIntervalMs = dispatchIntervalMs; + } + public long getCommandPollIntervalMs() { return commandPollIntervalMs; } + public void setCommandPollIntervalMs(long commandPollIntervalMs) { + this.commandPollIntervalMs = commandPollIntervalMs; + } +} diff --git a/ruoyi-admin/src/main/resources/ft546/wcs-device-profiles.yml b/ruoyi-admin/src/main/resources/ft546/wcs-device-profiles.yml new file mode 100644 index 0000000..7541742 --- /dev/null +++ b/ruoyi-admin/src/main/resources/ft546/wcs-device-profiles.yml @@ -0,0 +1,15 @@ +devices: + - deviceId: "1" + deviceType: SHUTTLE + adapterType: FT546_LEGACY_SRGV + profileVersion: 1 + protocolCorpusHash: "UNAPPROVED_TWO_STAGE_CORPUS" + capabilities: + - POINT_TO_POINT_COMMAND + registers: + taskNo: "4000" + taskType: "4002" + path: "4004" + trigger: "4021" + status: "4068" + execution: "4060" diff --git a/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/config/Ft546ConfigurationValidatorTest.java b/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/config/Ft546ConfigurationValidatorTest.java new file mode 100644 index 0000000..3d2a8a1 --- /dev/null +++ b/ruoyi-admin/src/test/java/com/ruoyi/project/ft546/config/Ft546ConfigurationValidatorTest.java @@ -0,0 +1,155 @@ +package com.ruoyi.project.ft546.config; + +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 java.time.Instant; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class Ft546ConfigurationValidatorTest { + + @Test + void defaultsToLegacySoDeploymentDoesNotTakeSchedulingOwnership() { + Ft546WcsProperties properties = new Ft546WcsProperties(); + + assertEquals(Ft546WcsProperties.RuntimeMode.LEGACY, + properties.getRuntimeMode()); + assertTrue(properties.getDispatchIntervalMs() > 0L); + assertTrue(properties.getCommandPollIntervalMs() > 0L); + } + + @Test + void rejectsLegacyProfileThatClaimsPrepareCommit() { + Ft546DeviceProfile profile = legacySrgvProfile(); + profile.getCapabilities().add("PREPARE_COMMIT_SEGMENT"); + + assertThrows(IllegalArgumentException.class, + () -> new Ft546ConfigurationValidator().validate(profile)); + } + + @Test + void rejectsDuplicateRegisterOwnershipWithinOneDevice() { + Ft546DeviceProfile profile = legacySrgvProfile(); + profile.getRegisters().put("taskType", "4000"); + + assertThrows(IllegalArgumentException.class, + () -> new Ft546ConfigurationValidator().validate(profile)); + } + + @Test + void rejectsTwoStageCapabilityWithoutPassingEvidenceForExactProfile() { + Ft546DeviceProfile profile = legacySrgvProfile(); + profile.getCapabilities().add("TWO_STAGE_ATOMIC_TASK"); + Ft546CapabilityGate gate = gate(); + + CapabilityGateResult result = gate.evaluate(profile, null); + + assertFalse(result.isPublishable()); + assertEquals("TWO_STAGE_EVIDENCE_REQUIRED", result.getReasonCode()); + assertFalse(result.isEnabled("TWO_STAGE_ATOMIC_TASK")); + } + + @Test + void enablesTwoStageOnlyForPassingUnexpiredEvidenceBoundToProfile() { + Ft546DeviceProfile profile = legacySrgvProfile(); + profile.getCapabilities().add("TWO_STAGE_ATOMIC_TASK"); + Ft546CapabilityEvidence evidence = new Ft546CapabilityEvidence( + 1L, "CORPUS-SHA256", "RUN-1", "COMMIT-1", true, + Instant.parse("2026-08-10T00:00:00Z"), + Instant.parse("2026-09-10T00:00:00Z")); + + CapabilityGateResult result = gate().evaluate(profile, evidence); + + assertTrue(result.isPublishable()); + assertTrue(result.isEnabled("TWO_STAGE_ATOMIC_TASK")); + } + + @Test + void legacyProfileKeepsUnsupportedCapabilitiesDisabled() { + CapabilityGateResult result = gate().evaluate(legacySrgvProfile(), null); + + assertFalse(result.isEnabled("COMMAND_RESULT_QUERY")); + assertFalse(result.isEnabled("ORDERED_DEVICE_SNAPSHOT")); + assertFalse(result.isEnabled("CANDIDATE_BUFFER")); + assertFalse(result.isEnabled("PREPARE_COMMIT_SEGMENT")); + assertFalse(result.isEnabled("IDEMPOTENT_COMMAND_KEY")); + assertFalse(result.isEnabled("BOOT_SESSION_ID")); + assertTrue(result.isEnabled("POINT_TO_POINT_COMMAND")); + } + + @Test + void checkedInProfileKeepsEndpointsOutAndLegacyAddressesExact() + throws Exception { + String yaml = readProjectFile("ruoyi-admin/src/main/resources/ft546/" + + "wcs-device-profiles.yml"); + + assertFalse(yaml.contains("192.168.")); + assertFalse(yaml.toLowerCase().contains("password")); + assertTrue(yaml.contains("taskNo: \"4000\"")); + assertTrue(yaml.contains("taskType: \"4002\"")); + assertTrue(yaml.contains("path: \"4004\"")); + assertTrue(yaml.contains("trigger: \"4021\"")); + } + + @Test + void packagesAdminOwnedAdapterConfigurationMigration() throws Exception { + String sql = readProjectFile( + "sql/wcs/V004__ft546_project_adapter_config.sql"); + + assertTrue(sql.contains("project_device_endpoint")); + assertTrue(sql.contains("project_plc_address")); + assertTrue(sql.contains("project_protocol_profile")); + assertTrue(sql.contains("project_protocol_capability_evidence")); + assertTrue(sql.contains("project_wms_mapping")); + } + + private static Ft546CapabilityGate gate() { + return new Ft546CapabilityGate(() -> + Instant.parse("2026-08-11T00:00:00Z")); + } + + private static Ft546DeviceProfile legacySrgvProfile() { + Ft546DeviceProfile profile = new Ft546DeviceProfile(); + profile.setDeviceId("1"); + profile.setDeviceType("SHUTTLE"); + profile.setAdapterType("FT546_LEGACY_SRGV"); + profile.setProfileVersion(1L); + profile.setProtocolCorpusHash("CORPUS-SHA256"); + profile.setCapabilities(new LinkedHashSet( + Collections.singletonList("POINT_TO_POINT_COMMAND"))); + LinkedHashMap registers = new LinkedHashMap(); + registers.put("taskNo", "4000"); + registers.put("taskType", "4002"); + registers.put("path", "4004"); + registers.put("trigger", "4021"); + registers.put("status", "4068"); + registers.put("execution", "4060"); + profile.setRegisters(registers); + return profile; + } + + private static String readProjectFile(String relative) throws Exception { + Path current = Paths.get(System.getProperty( + "maven.multiModuleProjectDirectory", ".")) + .toAbsolutePath().normalize(); + while (current != null) { + Path file = current.resolve(relative); + if (Files.isRegularFile(file)) { + return new String(Files.readAllBytes(file), + StandardCharsets.UTF_8); + } + current = current.getParent(); + } + throw new java.nio.file.NoSuchFileException(relative); + } +} diff --git a/sql/wcs/V004__ft546_project_adapter_config.sql b/sql/wcs/V004__ft546_project_adapter_config.sql new file mode 100644 index 0000000..e9d7b82 --- /dev/null +++ b/sql/wcs/V004__ft546_project_adapter_config.sql @@ -0,0 +1,127 @@ +SET XACT_ABORT ON; +BEGIN TRANSACTION; + +IF OBJECT_ID('dbo.project_device_endpoint', 'U') IS NULL +BEGIN + CREATE TABLE dbo.project_device_endpoint ( + device_id varchar(64) NOT NULL PRIMARY KEY, + device_type varchar(32) NOT NULL, + adapter_type varchar(64) NOT NULL, + endpoint_ip varchar(128) NOT NULL, + endpoint_port int NOT NULL, + protocol_station int NULL, + enabled_flag bit NOT NULL + CONSTRAINT df_project_device_endpoint_enabled DEFAULT (0), + state_version bigint NOT NULL, + updated_time datetime2(3) NOT NULL, + CONSTRAINT ck_project_device_endpoint_port CHECK ( + endpoint_port > 0 AND endpoint_port <= 65535), + CONSTRAINT ck_project_device_endpoint_station CHECK ( + protocol_station IS NULL OR protocol_station >= 0), + CONSTRAINT ck_project_device_endpoint_version CHECK (state_version >= 0) + ); +END; + +IF OBJECT_ID('dbo.project_protocol_profile', 'U') IS NULL +BEGIN + CREATE TABLE dbo.project_protocol_profile ( + device_id varchar(64) NOT NULL, + profile_version bigint NOT NULL, + device_type varchar(32) NOT NULL, + adapter_type varchar(64) NOT NULL, + corpus_manifest_hash varchar(128) NOT NULL, + profile_json nvarchar(max) NOT NULL, + active_flag bit NOT NULL + CONSTRAINT df_project_protocol_profile_active DEFAULT (0), + created_time datetime2(3) NOT NULL, + activated_time datetime2(3) NULL, + CONSTRAINT pk_project_protocol_profile PRIMARY KEY ( + device_id, profile_version), + CONSTRAINT fk_project_protocol_profile_endpoint FOREIGN KEY (device_id) + REFERENCES dbo.project_device_endpoint(device_id), + CONSTRAINT ck_project_protocol_profile_version CHECK ( + profile_version > 0) + ); +END; + +IF NOT EXISTS ( + SELECT 1 FROM sys.indexes + WHERE object_id = OBJECT_ID('dbo.project_protocol_profile') + AND name = 'uk_project_protocol_profile_active') +BEGIN + CREATE UNIQUE INDEX uk_project_protocol_profile_active + ON dbo.project_protocol_profile(device_id) + WHERE active_flag = 1; +END; + +IF OBJECT_ID('dbo.project_plc_address', 'U') IS NULL +BEGIN + CREATE TABLE dbo.project_plc_address ( + device_id varchar(64) NOT NULL, + profile_version bigint NOT NULL, + register_name varchar(64) NOT NULL, + plc_address varchar(64) NOT NULL, + value_type varchar(32) NOT NULL, + semantic_code varchar(128) NOT NULL, + write_order int NULL, + CONSTRAINT pk_project_plc_address PRIMARY KEY ( + device_id, profile_version, register_name), + CONSTRAINT fk_project_plc_address_profile FOREIGN KEY ( + device_id, profile_version) + REFERENCES dbo.project_protocol_profile( + device_id, profile_version), + CONSTRAINT uk_project_plc_address_owner UNIQUE ( + device_id, profile_version, plc_address), + CONSTRAINT ck_project_plc_address_order CHECK ( + write_order IS NULL OR write_order > 0) + ); +END; + +IF OBJECT_ID('dbo.project_protocol_capability_evidence', 'U') IS NULL +BEGIN + CREATE TABLE dbo.project_protocol_capability_evidence ( + evidence_id varchar(128) NOT NULL PRIMARY KEY, + device_id varchar(64) NOT NULL, + profile_version bigint NOT NULL, + capability_code varchar(64) NOT NULL, + corpus_manifest_hash varchar(128) NOT NULL, + passing_test_run_id varchar(128) NOT NULL, + git_commit varchar(128) NOT NULL, + regression_passed_flag bit NOT NULL, + approved_by varchar(128) NOT NULL, + approved_time datetime2(3) NOT NULL, + expires_time datetime2(3) NOT NULL, + created_time datetime2(3) NOT NULL, + CONSTRAINT fk_project_capability_evidence_profile FOREIGN KEY ( + device_id, profile_version) + REFERENCES dbo.project_protocol_profile( + device_id, profile_version), + CONSTRAINT uk_project_capability_evidence_binding UNIQUE ( + device_id, profile_version, capability_code, + corpus_manifest_hash, passing_test_run_id), + CONSTRAINT ck_project_capability_evidence_expiry CHECK ( + expires_time > approved_time) + ); +END; + +IF OBJECT_ID('dbo.project_wms_mapping', 'U') IS NULL +BEGIN + CREATE TABLE dbo.project_wms_mapping ( + mapping_id varchar(128) NOT NULL PRIMARY KEY, + external_operation varchar(64) NOT NULL, + external_order_type varchar(64) NULL, + canonical_order_type varchar(32) NOT NULL, + source_field varchar(128) NOT NULL, + destination_field varchar(128) NOT NULL, + callback_url_config_key varchar(128) NULL, + active_flag bit NOT NULL + CONSTRAINT df_project_wms_mapping_active DEFAULT (0), + state_version bigint NOT NULL, + updated_time datetime2(3) NOT NULL, + CONSTRAINT uk_project_wms_mapping_operation UNIQUE ( + external_operation, external_order_type), + CONSTRAINT ck_project_wms_mapping_version CHECK (state_version >= 0) + ); +END; + +COMMIT TRANSACTION;