From ad131932d31c604a51c4bce036522552ca6afb67 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 19 Apr 2026 14:55:39 -0400 Subject: [PATCH] =?UTF-8?q?Phase=206.4=20Stream=20B.2-B.4=20server-side=20?= =?UTF-8?q?=E2=80=94=20EquipmentImportBatch=20staging=20+=20FinaliseBatch?= =?UTF-8?q?=20transaction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the server-side/data-layer piece of Phase 6.4 Stream B.2-B.4. The CSV-import preview + modal UI (Stream B.3/B.5) still belongs to the Admin UI follow-up — this PR owns the staging tables + atomic finalise alone. Configuration: - New EquipmentImportBatch entity (Id, ClusterId, CreatedBy, CreatedAtUtc, RowsStaged/Accepted/Rejected, FinalisedAtUtc?). Composite index on (CreatedBy, FinalisedAtUtc) powers the Admin preview modal's "my open batches" query. - New EquipmentImportRow entity — one row per CSV row, 8 required columns from decision #117 + 9 optional from decision #139 + IsAccepted flag + RejectReason. FK to EquipmentImportBatch with cascade delete so DropBatch collapses the whole tree. - EF migration 20260419_..._AddEquipmentImportBatch. - SchemaComplianceTests expected tables list gains the two new tables. Admin.Services.EquipmentImportBatchService: - CreateBatchAsync — new header row, caller-supplied ClusterId + CreatedBy. - StageRowsAsync(batchId, acceptedRows, rejectedRows) — bulk-inserts the parsed CSV rows into staging. Rejected rows carry LineNumberInFile + RejectReason for the preview modal. Throws when the batch is finalised. - DropBatchAsync — removes batch + cascaded rows. Throws when the batch was already finalised (rollback via staging is not a time machine). - FinaliseBatchAsync(batchId, generationId, driverInstanceId, unsLineId) — atomic apply. Opens an EF transaction when the provider supports it (SQL Server in prod; InMemory in tests skips the tx), bulk-inserts every accepted staging row into Equipment, stamps EquipmentImportBatch.FinalisedAtUtc, commits. Failure rolls back so Equipment never partially mutates. Idempotent-under-double-call: second finalise throws ImportBatchAlreadyFinalisedException. - ListByUserAsync(createdBy, includeFinalised) — the Admin preview modal's backing query. OrderByDescending on CreatedAtUtc so the most-recent batch shows first. - Two exception types: ImportBatchNotFoundException + ImportBatchAlreadyFinalisedException. ExternalIdReservation merging (ZTag + SAPID fleet-wide uniqueness) is NOT done here — a narrower follow-up wires it once the concurrent-insert test matrix is green. Tests (10 new EquipmentImportBatchServiceTests, all pass): - CreateBatch populates Id + CreatedAtUtc + zero-ed counters. - StageRows accepted + rejected both persist; counters advance. - DropBatch cascades row delete. - DropBatch after finalise throws. - Finalise translates accepted staging rows → Equipment under the target GenerationId + DriverInstanceId + UnsLineId. - Finalise twice throws. - Finalise of unknown batch throws. - Stage after finalise throws. - ListByUserAsync filters by creator + finalised flag. - Drop of unknown batch is a no-op (idempotent rollback). Full solution dotnet test: 1235 passing (was 1225, +10). Pre-existing Client.CLI Subscribe flake unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Services/EquipmentImportBatchService.cs | 207 +++ .../Entities/EquipmentImportBatch.cs | 68 + ...185124_AddEquipmentImportBatch.Designer.cs | 1505 +++++++++++++++++ .../20260419185124_AddEquipmentImportBatch.cs | 91 + .../OtOpcUaConfigDbContextModelSnapshot.cs | 158 ++ .../OtOpcUaConfigDbContext.cs | 51 + .../EquipmentImportBatchServiceTests.cs | 165 ++ .../SchemaComplianceTests.cs | 2 + 8 files changed, 2247 insertions(+) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentImportBatchService.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/EquipmentImportBatch.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419185124_AddEquipmentImportBatch.Designer.cs create mode 100644 src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419185124_AddEquipmentImportBatch.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/EquipmentImportBatchServiceTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentImportBatchService.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentImportBatchService.cs new file mode 100644 index 0000000..d5bb7dd --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentImportBatchService.cs @@ -0,0 +1,207 @@ +using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.OtOpcUa.Admin.Services; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +namespace ZB.MOM.WW.OtOpcUa.Admin.Services; + +/// +/// Staged-import orchestrator per Phase 6.4 Stream B.2-B.4. Covers the four operator +/// actions: CreateBatch → StageRows (chunked) → FinaliseBatch (atomic apply into +/// ) → DropBatch (rollback of pre-finalise state). +/// +/// +/// FinaliseBatch runs inside one EF transaction + bulk-inserts accepted rows into +/// . Rejected rows stay behind as audit evidence; the batch row +/// gains so future writes know it's +/// archived. DropBatch removes the batch + its cascaded rows. +/// +/// Idempotence: calling FinaliseBatch twice throws +/// rather than double-inserting. Operator refreshes the admin page to see the first +/// finalise completed. +/// +/// ExternalIdReservation merging (ZTag + SAPID uniqueness) is NOT done here — a +/// narrower follow-up wires it once the concurrent-insert test matrix is green. +/// +public sealed class EquipmentImportBatchService(OtOpcUaConfigDbContext db) +{ + /// Create a new empty batch header. Returns the row with Id populated. + public async Task CreateBatchAsync(string clusterId, string createdBy, CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(clusterId); + ArgumentException.ThrowIfNullOrWhiteSpace(createdBy); + + var batch = new EquipmentImportBatch + { + Id = Guid.NewGuid(), + ClusterId = clusterId, + CreatedBy = createdBy, + CreatedAtUtc = DateTime.UtcNow, + }; + db.EquipmentImportBatches.Add(batch); + await db.SaveChangesAsync(ct).ConfigureAwait(false); + return batch; + } + + /// + /// Stage one chunk of rows into the batch. Caller usually feeds + /// output here — each + /// becomes one accepted , + /// each rejected parser error becomes one row with false. + /// + public async Task StageRowsAsync( + Guid batchId, + IReadOnlyList acceptedRows, + IReadOnlyList rejectedRows, + CancellationToken ct) + { + var batch = await db.EquipmentImportBatches.FirstOrDefaultAsync(b => b.Id == batchId, ct).ConfigureAwait(false) + ?? throw new ImportBatchNotFoundException($"Batch {batchId} not found."); + + if (batch.FinalisedAtUtc is not null) + throw new ImportBatchAlreadyFinalisedException( + $"Batch {batchId} finalised at {batch.FinalisedAtUtc:o}; no more rows can be staged."); + + foreach (var row in acceptedRows) + { + db.EquipmentImportRows.Add(new EquipmentImportRow + { + Id = Guid.NewGuid(), + BatchId = batchId, + IsAccepted = true, + ZTag = row.ZTag, + MachineCode = row.MachineCode, + SAPID = row.SAPID, + EquipmentId = row.EquipmentId, + EquipmentUuid = row.EquipmentUuid, + Name = row.Name, + UnsAreaName = row.UnsAreaName, + UnsLineName = row.UnsLineName, + Manufacturer = row.Manufacturer, + Model = row.Model, + SerialNumber = row.SerialNumber, + HardwareRevision = row.HardwareRevision, + SoftwareRevision = row.SoftwareRevision, + YearOfConstruction = row.YearOfConstruction, + AssetLocation = row.AssetLocation, + ManufacturerUri = row.ManufacturerUri, + DeviceManualUri = row.DeviceManualUri, + }); + } + + foreach (var error in rejectedRows) + { + db.EquipmentImportRows.Add(new EquipmentImportRow + { + Id = Guid.NewGuid(), + BatchId = batchId, + IsAccepted = false, + RejectReason = error.Reason, + LineNumberInFile = error.LineNumber, + // Required columns need values for EF; reject rows use sentinel placeholders. + ZTag = "", MachineCode = "", SAPID = "", EquipmentId = "", EquipmentUuid = "", + Name = "", UnsAreaName = "", UnsLineName = "", + }); + } + + batch.RowsStaged += acceptedRows.Count + rejectedRows.Count; + batch.RowsAccepted += acceptedRows.Count; + batch.RowsRejected += rejectedRows.Count; + + await db.SaveChangesAsync(ct).ConfigureAwait(false); + } + + /// Drop the batch (pre-finalise rollback). Cascaded row delete removes staged rows. + public async Task DropBatchAsync(Guid batchId, CancellationToken ct) + { + var batch = await db.EquipmentImportBatches.FirstOrDefaultAsync(b => b.Id == batchId, ct).ConfigureAwait(false); + if (batch is null) return; + if (batch.FinalisedAtUtc is not null) + throw new ImportBatchAlreadyFinalisedException( + $"Batch {batchId} already finalised at {batch.FinalisedAtUtc:o}; cannot drop."); + + db.EquipmentImportBatches.Remove(batch); + await db.SaveChangesAsync(ct).ConfigureAwait(false); + } + + /// + /// Atomic finalise. Inserts every accepted row into the live + /// table under the target generation + stamps + /// . Failure rolls the whole tx + /// back — never partially mutates. + /// + public async Task FinaliseBatchAsync( + Guid batchId, long generationId, string driverInstanceIdForRows, string unsLineIdForRows, CancellationToken ct) + { + var batch = await db.EquipmentImportBatches + .Include(b => b.Rows) + .FirstOrDefaultAsync(b => b.Id == batchId, ct) + .ConfigureAwait(false) + ?? throw new ImportBatchNotFoundException($"Batch {batchId} not found."); + + if (batch.FinalisedAtUtc is not null) + throw new ImportBatchAlreadyFinalisedException( + $"Batch {batchId} already finalised at {batch.FinalisedAtUtc:o}."); + + // EF InMemory provider doesn't honour BeginTransaction; SQL Server provider does. + // Tests run the happy path under in-memory; production SQL Server runs the atomic tx. + var supportsTx = db.Database.IsRelational(); + Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction? tx = null; + if (supportsTx) + tx = await db.Database.BeginTransactionAsync(ct).ConfigureAwait(false); + + try + { + foreach (var row in batch.Rows.Where(r => r.IsAccepted)) + { + db.Equipment.Add(new Equipment + { + EquipmentRowId = Guid.NewGuid(), + GenerationId = generationId, + EquipmentId = row.EquipmentId, + EquipmentUuid = Guid.TryParse(row.EquipmentUuid, out var u) ? u : Guid.NewGuid(), + DriverInstanceId = driverInstanceIdForRows, + UnsLineId = unsLineIdForRows, + Name = row.Name, + MachineCode = row.MachineCode, + ZTag = row.ZTag, + SAPID = row.SAPID, + Manufacturer = row.Manufacturer, + Model = row.Model, + SerialNumber = row.SerialNumber, + HardwareRevision = row.HardwareRevision, + SoftwareRevision = row.SoftwareRevision, + YearOfConstruction = short.TryParse(row.YearOfConstruction, out var y) ? y : null, + AssetLocation = row.AssetLocation, + ManufacturerUri = row.ManufacturerUri, + DeviceManualUri = row.DeviceManualUri, + }); + } + + batch.FinalisedAtUtc = DateTime.UtcNow; + await db.SaveChangesAsync(ct).ConfigureAwait(false); + if (tx is not null) await tx.CommitAsync(ct).ConfigureAwait(false); + } + catch + { + if (tx is not null) await tx.RollbackAsync(ct).ConfigureAwait(false); + throw; + } + finally + { + if (tx is not null) await tx.DisposeAsync().ConfigureAwait(false); + } + } + + /// List batches created by the given user. Finalised batches are archived; include them on demand. + public async Task> ListByUserAsync(string createdBy, bool includeFinalised, CancellationToken ct) + { + var query = db.EquipmentImportBatches.AsNoTracking().Where(b => b.CreatedBy == createdBy); + if (!includeFinalised) + query = query.Where(b => b.FinalisedAtUtc == null); + return await query.OrderByDescending(b => b.CreatedAtUtc).ToListAsync(ct).ConfigureAwait(false); + } +} + +public sealed class ImportBatchNotFoundException(string message) : Exception(message); +public sealed class ImportBatchAlreadyFinalisedException(string message) : Exception(message); diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/EquipmentImportBatch.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/EquipmentImportBatch.cs new file mode 100644 index 0000000..c3b65f8 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/EquipmentImportBatch.cs @@ -0,0 +1,68 @@ +namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +/// +/// Staged equipment-import batch per Phase 6.4 Stream B.2. Rows land in the child +/// table under a batch header; operator reviews + either +/// drops (via DropImportBatch) or finalises (via FinaliseImportBatch) in one +/// bounded transaction. The live Equipment table never sees partial state. +/// +/// +/// User-scoped visibility: the preview modal only shows batches where +/// equals the current operator. Prevents accidental +/// cross-operator finalise during concurrent imports. An admin finalise / drop surface +/// can override this — tracked alongside the UI follow-up. +/// +/// stamps the moment the batch promoted from staging +/// into Equipment. Null = still in staging; non-null = archived / finalised. +/// +public sealed class EquipmentImportBatch +{ + public Guid Id { get; set; } + public required string ClusterId { get; set; } + public required string CreatedBy { get; set; } + public DateTime CreatedAtUtc { get; set; } + public int RowsStaged { get; set; } + public int RowsAccepted { get; set; } + public int RowsRejected { get; set; } + public DateTime? FinalisedAtUtc { get; set; } + + public ICollection Rows { get; set; } = []; +} + +/// +/// One staged row under an . Mirrors the decision #117 +/// + decision #139 columns from the CSV importer's output + an +/// flag + a string the preview modal +/// renders. +/// +public sealed class EquipmentImportRow +{ + public Guid Id { get; set; } + public Guid BatchId { get; set; } + public int LineNumberInFile { get; set; } + public bool IsAccepted { get; set; } + public string? RejectReason { get; set; } + + // Required (decision #117) + public required string ZTag { get; set; } + public required string MachineCode { get; set; } + public required string SAPID { get; set; } + public required string EquipmentId { get; set; } + public required string EquipmentUuid { get; set; } + public required string Name { get; set; } + public required string UnsAreaName { get; set; } + public required string UnsLineName { get; set; } + + // Optional (decision #139 — OPC 40010 Identification) + public string? Manufacturer { get; set; } + public string? Model { get; set; } + public string? SerialNumber { get; set; } + public string? HardwareRevision { get; set; } + public string? SoftwareRevision { get; set; } + public string? YearOfConstruction { get; set; } + public string? AssetLocation { get; set; } + public string? ManufacturerUri { get; set; } + public string? DeviceManualUri { get; set; } + + public EquipmentImportBatch? Batch { get; set; } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419185124_AddEquipmentImportBatch.Designer.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419185124_AddEquipmentImportBatch.Designer.cs new file mode 100644 index 0000000..a985f7d --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419185124_AddEquipmentImportBatch.Designer.cs @@ -0,0 +1,1505 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ZB.MOM.WW.OtOpcUa.Configuration; + +#nullable disable + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations +{ + [DbContext(typeof(OtOpcUaConfigDbContext))] + [Migration("20260419185124_AddEquipmentImportBatch")] + partial class AddEquipmentImportBatch + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ApplicationUri") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("DashboardPort") + .HasColumnType("int"); + + b.Property("DriverConfigOverridesJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("LastSeenAt") + .HasColumnType("datetime2(3)"); + + b.Property("OpcUaPort") + .HasColumnType("int"); + + b.Property("RedundancyRole") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("ServiceLevelBase") + .HasColumnType("tinyint"); + + b.HasKey("NodeId"); + + b.HasIndex("ApplicationUri") + .IsUnique() + .HasDatabaseName("UX_ClusterNode_ApplicationUri"); + + b.HasIndex("ClusterId") + .IsUnique() + .HasDatabaseName("UX_ClusterNode_Primary_Per_Cluster") + .HasFilter("[RedundancyRole] = 'Primary'"); + + b.ToTable("ClusterNode", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeCredential", b => + { + b.Property("CredentialId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("NodeId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RotatedAt") + .HasColumnType("datetime2(3)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.HasKey("CredentialId"); + + b.HasIndex("Kind", "Value") + .IsUnique() + .HasDatabaseName("UX_ClusterNodeCredential_Value") + .HasFilter("[Enabled] = 1"); + + b.HasIndex("NodeId", "Enabled") + .HasDatabaseName("IX_ClusterNodeCredential_NodeId"); + + b.ToTable("ClusterNodeCredential", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeGenerationState", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CurrentGenerationId") + .HasColumnType("bigint"); + + b.Property("LastAppliedAt") + .HasColumnType("datetime2(3)"); + + b.Property("LastAppliedError") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("LastAppliedStatus") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("LastSeenAt") + .HasColumnType("datetime2(3)"); + + b.HasKey("NodeId"); + + b.HasIndex("CurrentGenerationId") + .HasDatabaseName("IX_ClusterNodeGenerationState_Generation"); + + b.ToTable("ClusterNodeGenerationState", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigAuditLog", b => + { + b.Property("AuditId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("AuditId")); + + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DetailsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Principal") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Timestamp") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.HasKey("AuditId"); + + b.HasIndex("GenerationId") + .HasDatabaseName("IX_ConfigAuditLog_Generation") + .HasFilter("[GenerationId] IS NOT NULL"); + + b.HasIndex("ClusterId", "Timestamp") + .IsDescending(false, true) + .HasDatabaseName("IX_ConfigAuditLog_Cluster_Time"); + + b.ToTable("ConfigAuditLog", null, t => + { + t.HasCheckConstraint("CK_ConfigAuditLog_DetailsJson_IsJson", "DetailsJson IS NULL OR ISJSON(DetailsJson) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", b => + { + b.Property("GenerationId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("GenerationId")); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("ParentGenerationId") + .HasColumnType("bigint"); + + b.Property("PublishedAt") + .HasColumnType("datetime2(3)"); + + b.Property("PublishedBy") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.HasKey("GenerationId"); + + b.HasIndex("ClusterId") + .IsUnique() + .HasDatabaseName("UX_ConfigGeneration_Draft_Per_Cluster") + .HasFilter("[Status] = 'Draft'"); + + b.HasIndex("ParentGenerationId"); + + b.HasIndex("ClusterId", "Status", "GenerationId") + .IsDescending(false, false, true) + .HasDatabaseName("IX_ConfigGeneration_Cluster_Published"); + + SqlServerIndexBuilderExtensions.IncludeProperties(b.HasIndex("ClusterId", "Status", "GenerationId"), new[] { "PublishedAt" }); + + b.ToTable("ConfigGeneration", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Device", b => + { + b.Property("DeviceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("DeviceConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeviceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("DeviceRowId"); + + b.HasIndex("GenerationId", "DeviceId") + .IsUnique() + .HasDatabaseName("UX_Device_Generation_LogicalId") + .HasFilter("[DeviceId] IS NOT NULL"); + + b.HasIndex("GenerationId", "DriverInstanceId") + .HasDatabaseName("IX_Device_Generation_Driver"); + + b.ToTable("Device", null, t => + { + t.HasCheckConstraint("CK_Device_DeviceConfig_IsJson", "ISJSON(DeviceConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverHostStatus", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverInstanceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HostName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Detail") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("LastSeenUtc") + .HasColumnType("datetime2(3)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("StateChangedUtc") + .HasColumnType("datetime2(3)"); + + b.HasKey("NodeId", "DriverInstanceId", "HostName"); + + b.HasIndex("LastSeenUtc") + .HasDatabaseName("IX_DriverHostStatus_LastSeen"); + + b.HasIndex("NodeId") + .HasDatabaseName("IX_DriverHostStatus_Node"); + + b.ToTable("DriverHostStatus", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b => + { + b.Property("DriverInstanceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DriverInstanceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NamespaceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ResilienceConfig") + .HasColumnType("nvarchar(max)"); + + b.HasKey("DriverInstanceRowId"); + + b.HasIndex("ClusterId"); + + b.HasIndex("GenerationId", "ClusterId") + .HasDatabaseName("IX_DriverInstance_Generation_Cluster"); + + b.HasIndex("GenerationId", "DriverInstanceId") + .IsUnique() + .HasDatabaseName("UX_DriverInstance_Generation_LogicalId") + .HasFilter("[DriverInstanceId] IS NOT NULL"); + + b.HasIndex("GenerationId", "NamespaceId") + .HasDatabaseName("IX_DriverInstance_Generation_Namespace"); + + b.ToTable("DriverInstance", null, t => + { + t.HasCheckConstraint("CK_DriverInstance_DriverConfig_IsJson", "ISJSON(DriverConfig) = 1"); + + t.HasCheckConstraint("CK_DriverInstance_ResilienceConfig_IsJson", "ResilienceConfig IS NULL OR ISJSON(ResilienceConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstanceResilienceStatus", b => + { + b.Property("DriverInstanceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HostName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("BaselineFootprintBytes") + .HasColumnType("bigint"); + + b.Property("ConsecutiveFailures") + .HasColumnType("int"); + + b.Property("CurrentBulkheadDepth") + .HasColumnType("int"); + + b.Property("CurrentFootprintBytes") + .HasColumnType("bigint"); + + b.Property("LastCircuitBreakerOpenUtc") + .HasColumnType("datetime2(3)"); + + b.Property("LastRecycleUtc") + .HasColumnType("datetime2(3)"); + + b.Property("LastSampledUtc") + .HasColumnType("datetime2(3)"); + + b.HasKey("DriverInstanceId", "HostName"); + + b.HasIndex("LastSampledUtc") + .HasDatabaseName("IX_DriverResilience_LastSampled"); + + b.ToTable("DriverInstanceResilienceStatus", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Equipment", b => + { + b.Property("EquipmentRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("AssetLocation") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("DeviceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeviceManualUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("EquipmentClassRef") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EquipmentId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentUuid") + .HasColumnType("uniqueidentifier"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("HardwareRevision") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("MachineCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Manufacturer") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ManufacturerUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Model") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("SAPID") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SerialNumber") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SoftwareRevision") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("UnsLineId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("YearOfConstruction") + .HasColumnType("smallint"); + + b.Property("ZTag") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("EquipmentRowId"); + + b.HasIndex("GenerationId", "DriverInstanceId") + .HasDatabaseName("IX_Equipment_Generation_Driver"); + + b.HasIndex("GenerationId", "EquipmentId") + .IsUnique() + .HasDatabaseName("UX_Equipment_Generation_LogicalId") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("GenerationId", "EquipmentUuid") + .IsUnique() + .HasDatabaseName("UX_Equipment_Generation_Uuid"); + + b.HasIndex("GenerationId", "MachineCode") + .HasDatabaseName("IX_Equipment_Generation_MachineCode"); + + b.HasIndex("GenerationId", "SAPID") + .HasDatabaseName("IX_Equipment_Generation_SAPID") + .HasFilter("[SAPID] IS NOT NULL"); + + b.HasIndex("GenerationId", "UnsLineId") + .HasDatabaseName("IX_Equipment_Generation_Line"); + + b.HasIndex("GenerationId", "ZTag") + .HasDatabaseName("IX_Equipment_Generation_ZTag") + .HasFilter("[ZTag] IS NOT NULL"); + + b.HasIndex("GenerationId", "UnsLineId", "Name") + .IsUnique() + .HasDatabaseName("UX_Equipment_Generation_LinePath"); + + b.ToTable("Equipment", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("FinalisedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("RowsAccepted") + .HasColumnType("int"); + + b.Property("RowsRejected") + .HasColumnType("int"); + + b.Property("RowsStaged") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy", "FinalisedAtUtc") + .HasDatabaseName("IX_EquipmentImportBatch_Creator_Finalised"); + + b.ToTable("EquipmentImportBatch", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportRow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AssetLocation") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("BatchId") + .HasColumnType("uniqueidentifier"); + + b.Property("DeviceManualUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("EquipmentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentUuid") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HardwareRevision") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsAccepted") + .HasColumnType("bit"); + + b.Property("LineNumberInFile") + .HasColumnType("int"); + + b.Property("MachineCode") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Manufacturer") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ManufacturerUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Model") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RejectReason") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("SAPID") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SerialNumber") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("SoftwareRevision") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UnsAreaName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UnsLineName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("YearOfConstruction") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("ZTag") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .HasDatabaseName("IX_EquipmentImportRow_Batch"); + + b.ToTable("EquipmentImportRow", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation", b => + { + b.Property("ReservationId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentUuid") + .HasColumnType("uniqueidentifier"); + + b.Property("FirstPublishedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("FirstPublishedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("LastPublishedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("ReleaseReason") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("ReleasedAt") + .HasColumnType("datetime2(3)"); + + b.Property("ReleasedBy") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("ReservationId"); + + b.HasIndex("EquipmentUuid") + .HasDatabaseName("IX_ExternalIdReservation_Equipment"); + + b.HasIndex("Kind", "Value") + .IsUnique() + .HasDatabaseName("UX_ExternalIdReservation_KindValue_Active") + .HasFilter("[ReleasedAt] IS NULL"); + + b.ToTable("ExternalIdReservation", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.LdapGroupRoleMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("IsSystemWide") + .HasColumnType("bit"); + + b.Property("LdapGroup") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("LdapGroup") + .HasDatabaseName("IX_LdapGroupRoleMapping_Group"); + + b.HasIndex("LdapGroup", "ClusterId") + .IsUnique() + .HasDatabaseName("UX_LdapGroupRoleMapping_Group_Cluster") + .HasFilter("[ClusterId] IS NOT NULL"); + + b.ToTable("LdapGroupRoleMapping", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Namespace", b => + { + b.Property("NamespaceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("NamespaceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("NamespaceUri") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.HasKey("NamespaceRowId"); + + b.HasIndex("ClusterId"); + + b.HasIndex("GenerationId", "ClusterId") + .HasDatabaseName("IX_Namespace_Generation_Cluster"); + + b.HasIndex("GenerationId", "NamespaceId") + .IsUnique() + .HasDatabaseName("UX_Namespace_Generation_LogicalId") + .HasFilter("[NamespaceId] IS NOT NULL"); + + b.HasIndex("GenerationId", "NamespaceUri") + .IsUnique() + .HasDatabaseName("UX_Namespace_Generation_NamespaceUri"); + + b.HasIndex("GenerationId", "ClusterId", "Kind") + .IsUnique() + .HasDatabaseName("UX_Namespace_Generation_Cluster_Kind"); + + b.HasIndex("GenerationId", "NamespaceId", "ClusterId") + .IsUnique() + .HasDatabaseName("UX_Namespace_Generation_LogicalId_Cluster") + .HasFilter("[NamespaceId] IS NOT NULL"); + + b.ToTable("Namespace", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl", b => + { + b.Property("NodeAclRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("LdapGroup") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NodeAclId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("PermissionFlags") + .HasColumnType("int"); + + b.Property("ScopeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ScopeKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.HasKey("NodeAclRowId"); + + b.HasIndex("GenerationId", "ClusterId") + .HasDatabaseName("IX_NodeAcl_Generation_Cluster"); + + b.HasIndex("GenerationId", "LdapGroup") + .HasDatabaseName("IX_NodeAcl_Generation_Group"); + + b.HasIndex("GenerationId", "NodeAclId") + .IsUnique() + .HasDatabaseName("UX_NodeAcl_Generation_LogicalId") + .HasFilter("[NodeAclId] IS NOT NULL"); + + b.HasIndex("GenerationId", "ScopeKind", "ScopeId") + .HasDatabaseName("IX_NodeAcl_Generation_Scope") + .HasFilter("[ScopeId] IS NOT NULL"); + + b.HasIndex("GenerationId", "ClusterId", "LdapGroup", "ScopeKind", "ScopeId") + .IsUnique() + .HasDatabaseName("UX_NodeAcl_Generation_GroupScope") + .HasFilter("[ScopeId] IS NOT NULL"); + + b.ToTable("NodeAcl", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.PollGroup", b => + { + b.Property("PollGroupRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("IntervalMs") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PollGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("PollGroupRowId"); + + b.HasIndex("GenerationId", "DriverInstanceId") + .HasDatabaseName("IX_PollGroup_Generation_Driver"); + + b.HasIndex("GenerationId", "PollGroupId") + .IsUnique() + .HasDatabaseName("UX_PollGroup_Generation_LogicalId") + .HasFilter("[PollGroupId] IS NOT NULL"); + + b.ToTable("PollGroup", null, t => + { + t.HasCheckConstraint("CK_PollGroup_IntervalMs_Min", "IntervalMs >= 50"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b => + { + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Enterprise") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("ModifiedAt") + .HasColumnType("datetime2(3)"); + + b.Property("ModifiedBy") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NodeCount") + .HasColumnType("tinyint"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RedundancyMode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("Site") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("ClusterId"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("UX_ServerCluster_Name"); + + b.HasIndex("Site") + .HasDatabaseName("IX_ServerCluster_Site"); + + b.ToTable("ServerCluster", null, t => + { + t.HasCheckConstraint("CK_ServerCluster_RedundancyMode_NodeCount", "((NodeCount = 1 AND RedundancyMode = 'None') OR (NodeCount = 2 AND RedundancyMode IN ('Warm', 'Hot')))"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Tag", b => + { + b.Property("TagRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("AccessLevel") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("DeviceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FolderPath") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PollGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TagConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TagId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("WriteIdempotent") + .HasColumnType("bit"); + + b.HasKey("TagRowId"); + + b.HasIndex("GenerationId", "EquipmentId") + .HasDatabaseName("IX_Tag_Generation_Equipment") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("GenerationId", "TagId") + .IsUnique() + .HasDatabaseName("UX_Tag_Generation_LogicalId") + .HasFilter("[TagId] IS NOT NULL"); + + b.HasIndex("GenerationId", "DriverInstanceId", "DeviceId") + .HasDatabaseName("IX_Tag_Generation_Driver_Device"); + + b.HasIndex("GenerationId", "EquipmentId", "Name") + .IsUnique() + .HasDatabaseName("UX_Tag_Generation_EquipmentPath") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("GenerationId", "DriverInstanceId", "FolderPath", "Name") + .IsUnique() + .HasDatabaseName("UX_Tag_Generation_FolderPath") + .HasFilter("[EquipmentId] IS NULL"); + + b.ToTable("Tag", null, t => + { + t.HasCheckConstraint("CK_Tag_TagConfig_IsJson", "ISJSON(TagConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsArea", b => + { + b.Property("UnsAreaRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("UnsAreaId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("UnsAreaRowId"); + + b.HasIndex("ClusterId"); + + b.HasIndex("GenerationId", "ClusterId") + .HasDatabaseName("IX_UnsArea_Generation_Cluster"); + + b.HasIndex("GenerationId", "UnsAreaId") + .IsUnique() + .HasDatabaseName("UX_UnsArea_Generation_LogicalId") + .HasFilter("[UnsAreaId] IS NOT NULL"); + + b.HasIndex("GenerationId", "ClusterId", "Name") + .IsUnique() + .HasDatabaseName("UX_UnsArea_Generation_ClusterName"); + + b.ToTable("UnsArea", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsLine", b => + { + b.Property("UnsLineRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("UnsAreaId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UnsLineId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("UnsLineRowId"); + + b.HasIndex("GenerationId", "UnsAreaId") + .HasDatabaseName("IX_UnsLine_Generation_Area"); + + b.HasIndex("GenerationId", "UnsLineId") + .IsUnique() + .HasDatabaseName("UX_UnsLine_Generation_LogicalId") + .HasFilter("[UnsLineId] IS NOT NULL"); + + b.HasIndex("GenerationId", "UnsAreaId", "Name") + .IsUnique() + .HasDatabaseName("UX_UnsLine_Generation_AreaName"); + + b.ToTable("UnsLine", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany("Nodes") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeCredential", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", "Node") + .WithMany("Credentials") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeGenerationState", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "CurrentGeneration") + .WithMany() + .HasForeignKey("CurrentGenerationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", "Node") + .WithOne("GenerationState") + .HasForeignKey("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeGenerationState", "NodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CurrentGeneration"); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany("Generations") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Parent") + .WithMany() + .HasForeignKey("ParentGenerationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Cluster"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Device", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Equipment", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportRow", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", "Batch") + .WithMany("Rows") + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.LdapGroupRoleMapping", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Namespace", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany("Namespaces") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.PollGroup", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Tag", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsArea", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsLine", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.Navigation("Credentials"); + + b.Navigation("GenerationState"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", b => + { + b.Navigation("Rows"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b => + { + b.Navigation("Generations"); + + b.Navigation("Namespaces"); + + b.Navigation("Nodes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419185124_AddEquipmentImportBatch.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419185124_AddEquipmentImportBatch.cs new file mode 100644 index 0000000..0e76922 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419185124_AddEquipmentImportBatch.cs @@ -0,0 +1,91 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations +{ + /// + public partial class AddEquipmentImportBatch : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "EquipmentImportBatch", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + ClusterId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + CreatedBy = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + CreatedAtUtc = table.Column(type: "datetime2(3)", nullable: false), + RowsStaged = table.Column(type: "int", nullable: false), + RowsAccepted = table.Column(type: "int", nullable: false), + RowsRejected = table.Column(type: "int", nullable: false), + FinalisedAtUtc = table.Column(type: "datetime2(3)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_EquipmentImportBatch", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "EquipmentImportRow", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + BatchId = table.Column(type: "uniqueidentifier", nullable: false), + LineNumberInFile = table.Column(type: "int", nullable: false), + IsAccepted = table.Column(type: "bit", nullable: false), + RejectReason = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: true), + ZTag = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + MachineCode = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + SAPID = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + EquipmentId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + EquipmentUuid = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + Name = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + UnsAreaName = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + UnsLineName = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + Manufacturer = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + Model = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + SerialNumber = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + HardwareRevision = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + SoftwareRevision = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + YearOfConstruction = table.Column(type: "nvarchar(8)", maxLength: 8, nullable: true), + AssetLocation = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: true), + ManufacturerUri = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: true), + DeviceManualUri = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_EquipmentImportRow", x => x.Id); + table.ForeignKey( + name: "FK_EquipmentImportRow_EquipmentImportBatch_BatchId", + column: x => x.BatchId, + principalTable: "EquipmentImportBatch", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_EquipmentImportBatch_Creator_Finalised", + table: "EquipmentImportBatch", + columns: new[] { "CreatedBy", "FinalisedAtUtc" }); + + migrationBuilder.CreateIndex( + name: "IX_EquipmentImportRow_Batch", + table: "EquipmentImportRow", + column: "BatchId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "EquipmentImportRow"); + + migrationBuilder.DropTable( + name: "EquipmentImportBatch"); + } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs index b1dd394..1019e8e 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs @@ -604,6 +604,148 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations b.ToTable("Equipment", (string)null); }); + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("FinalisedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("RowsAccepted") + .HasColumnType("int"); + + b.Property("RowsRejected") + .HasColumnType("int"); + + b.Property("RowsStaged") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy", "FinalisedAtUtc") + .HasDatabaseName("IX_EquipmentImportBatch_Creator_Finalised"); + + b.ToTable("EquipmentImportBatch", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportRow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AssetLocation") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("BatchId") + .HasColumnType("uniqueidentifier"); + + b.Property("DeviceManualUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("EquipmentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentUuid") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HardwareRevision") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsAccepted") + .HasColumnType("bit"); + + b.Property("LineNumberInFile") + .HasColumnType("int"); + + b.Property("MachineCode") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Manufacturer") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ManufacturerUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Model") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RejectReason") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("SAPID") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SerialNumber") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("SoftwareRevision") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UnsAreaName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UnsLineName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("YearOfConstruction") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("ZTag") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .HasDatabaseName("IX_EquipmentImportRow_Batch"); + + b.ToTable("EquipmentImportRow", (string)null); + }); + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation", b => { b.Property("ReservationId") @@ -1231,6 +1373,17 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations b.Navigation("Generation"); }); + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportRow", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", "Batch") + .WithMany("Rows") + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Batch"); + }); + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.LdapGroupRoleMapping", b => { b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") @@ -1330,6 +1483,11 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations b.Navigation("GenerationState"); }); + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", b => + { + b.Navigation("Rows"); + }); + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b => { b.Navigation("Generations"); diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs index 0a4ac35..d2ca409 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs @@ -30,6 +30,8 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions DriverHostStatuses => Set(); public DbSet DriverInstanceResilienceStatuses => Set(); public DbSet LdapGroupRoleMappings => Set(); + public DbSet EquipmentImportBatches => Set(); + public DbSet EquipmentImportRows => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -53,6 +55,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions x.LdapGroup).HasDatabaseName("IX_LdapGroupRoleMapping_Group"); }); } + + private static void ConfigureEquipmentImportBatch(ModelBuilder modelBuilder) + { + modelBuilder.Entity(e => + { + e.ToTable("EquipmentImportBatch"); + e.HasKey(x => x.Id); + e.Property(x => x.ClusterId).HasMaxLength(64); + e.Property(x => x.CreatedBy).HasMaxLength(128); + e.Property(x => x.CreatedAtUtc).HasColumnType("datetime2(3)"); + e.Property(x => x.FinalisedAtUtc).HasColumnType("datetime2(3)"); + + // Admin preview modal filters by user; finalise / drop both hit this index. + e.HasIndex(x => new { x.CreatedBy, x.FinalisedAtUtc }) + .HasDatabaseName("IX_EquipmentImportBatch_Creator_Finalised"); + }); + + modelBuilder.Entity(e => + { + e.ToTable("EquipmentImportRow"); + e.HasKey(x => x.Id); + e.Property(x => x.ZTag).HasMaxLength(128); + e.Property(x => x.MachineCode).HasMaxLength(128); + e.Property(x => x.SAPID).HasMaxLength(128); + e.Property(x => x.EquipmentId).HasMaxLength(64); + e.Property(x => x.EquipmentUuid).HasMaxLength(64); + e.Property(x => x.Name).HasMaxLength(128); + e.Property(x => x.UnsAreaName).HasMaxLength(64); + e.Property(x => x.UnsLineName).HasMaxLength(64); + e.Property(x => x.Manufacturer).HasMaxLength(256); + e.Property(x => x.Model).HasMaxLength(256); + e.Property(x => x.SerialNumber).HasMaxLength(256); + e.Property(x => x.HardwareRevision).HasMaxLength(64); + e.Property(x => x.SoftwareRevision).HasMaxLength(64); + e.Property(x => x.YearOfConstruction).HasMaxLength(8); + e.Property(x => x.AssetLocation).HasMaxLength(512); + e.Property(x => x.ManufacturerUri).HasMaxLength(512); + e.Property(x => x.DeviceManualUri).HasMaxLength(512); + e.Property(x => x.RejectReason).HasMaxLength(512); + + e.HasOne(x => x.Batch) + .WithMany(b => b.Rows) + .HasForeignKey(x => x.BatchId) + .OnDelete(DeleteBehavior.Cascade); + + e.HasIndex(x => x.BatchId).HasDatabaseName("IX_EquipmentImportRow_Batch"); + }); + } } diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/EquipmentImportBatchServiceTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/EquipmentImportBatchServiceTests.cs new file mode 100644 index 0000000..efcee02 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/EquipmentImportBatchServiceTests.cs @@ -0,0 +1,165 @@ +using Microsoft.EntityFrameworkCore; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Admin.Services; +using ZB.MOM.WW.OtOpcUa.Configuration; + +namespace ZB.MOM.WW.OtOpcUa.Admin.Tests; + +[Trait("Category", "Unit")] +public sealed class EquipmentImportBatchServiceTests : IDisposable +{ + private readonly OtOpcUaConfigDbContext _db; + private readonly EquipmentImportBatchService _svc; + + public EquipmentImportBatchServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"import-batch-{Guid.NewGuid():N}") + .Options; + _db = new OtOpcUaConfigDbContext(options); + _svc = new EquipmentImportBatchService(_db); + } + + public void Dispose() => _db.Dispose(); + + private static EquipmentCsvRow Row(string zTag, string name = "eq-1") => new() + { + ZTag = zTag, + MachineCode = "mc", + SAPID = "sap", + EquipmentId = "eq-id", + EquipmentUuid = Guid.NewGuid().ToString(), + Name = name, + UnsAreaName = "area", + UnsLineName = "line", + }; + + [Fact] + public async Task CreateBatch_PopulatesId_AndTimestamp() + { + var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None); + + batch.Id.ShouldNotBe(Guid.Empty); + batch.CreatedAtUtc.ShouldBeGreaterThan(DateTime.UtcNow.AddMinutes(-1)); + batch.RowsStaged.ShouldBe(0); + } + + [Fact] + public async Task StageRows_AcceptedAndRejected_AllPersist() + { + var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None); + + await _svc.StageRowsAsync(batch.Id, + acceptedRows: [Row("z-1"), Row("z-2")], + rejectedRows: [new EquipmentCsvRowError(LineNumber: 5, Reason: "duplicate ZTag")], + CancellationToken.None); + + var reloaded = await _db.EquipmentImportBatches.Include(b => b.Rows).FirstAsync(b => b.Id == batch.Id); + reloaded.RowsStaged.ShouldBe(3); + reloaded.RowsAccepted.ShouldBe(2); + reloaded.RowsRejected.ShouldBe(1); + reloaded.Rows.Count.ShouldBe(3); + reloaded.Rows.Count(r => r.IsAccepted).ShouldBe(2); + reloaded.Rows.Single(r => !r.IsAccepted).RejectReason.ShouldBe("duplicate ZTag"); + } + + [Fact] + public async Task DropBatch_RemovesBatch_AndCascades_Rows() + { + var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None); + await _svc.StageRowsAsync(batch.Id, [Row("z-1")], [], CancellationToken.None); + + await _svc.DropBatchAsync(batch.Id, CancellationToken.None); + + (await _db.EquipmentImportBatches.AnyAsync(b => b.Id == batch.Id)).ShouldBeFalse(); + (await _db.EquipmentImportRows.AnyAsync(r => r.BatchId == batch.Id)).ShouldBeFalse("cascaded delete clears rows"); + } + + [Fact] + public async Task DropBatch_AfterFinalise_Throws() + { + var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None); + await _svc.StageRowsAsync(batch.Id, [Row("z-1")], [], CancellationToken.None); + await _svc.FinaliseBatchAsync(batch.Id, generationId: 1, driverInstanceIdForRows: "drv-1", unsLineIdForRows: "line-1", CancellationToken.None); + + await Should.ThrowAsync( + () => _svc.DropBatchAsync(batch.Id, CancellationToken.None)); + } + + [Fact] + public async Task Finalise_AcceptedRows_BecomeEquipment() + { + var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None); + await _svc.StageRowsAsync(batch.Id, + [Row("z-1", name: "alpha"), Row("z-2", name: "beta")], + rejectedRows: [new EquipmentCsvRowError(1, "rejected")], + CancellationToken.None); + + await _svc.FinaliseBatchAsync(batch.Id, 5, "drv-modbus", "line-warsaw", CancellationToken.None); + + var equipment = await _db.Equipment.Where(e => e.GenerationId == 5).ToListAsync(); + equipment.Count.ShouldBe(2); + equipment.Select(e => e.Name).ShouldBe(["alpha", "beta"], ignoreOrder: true); + equipment.All(e => e.DriverInstanceId == "drv-modbus").ShouldBeTrue(); + equipment.All(e => e.UnsLineId == "line-warsaw").ShouldBeTrue(); + + var reloaded = await _db.EquipmentImportBatches.FirstAsync(b => b.Id == batch.Id); + reloaded.FinalisedAtUtc.ShouldNotBeNull(); + } + + [Fact] + public async Task Finalise_Twice_Throws() + { + var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None); + await _svc.StageRowsAsync(batch.Id, [Row("z-1")], [], CancellationToken.None); + await _svc.FinaliseBatchAsync(batch.Id, 1, "drv", "line", CancellationToken.None); + + await Should.ThrowAsync( + () => _svc.FinaliseBatchAsync(batch.Id, 2, "drv", "line", CancellationToken.None)); + } + + [Fact] + public async Task Finalise_MissingBatch_Throws() + { + await Should.ThrowAsync( + () => _svc.FinaliseBatchAsync(Guid.NewGuid(), 1, "drv", "line", CancellationToken.None)); + } + + [Fact] + public async Task Stage_After_Finalise_Throws() + { + var batch = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None); + await _svc.StageRowsAsync(batch.Id, [Row("z-1")], [], CancellationToken.None); + await _svc.FinaliseBatchAsync(batch.Id, 1, "drv", "line", CancellationToken.None); + + await Should.ThrowAsync( + () => _svc.StageRowsAsync(batch.Id, [Row("z-2")], [], CancellationToken.None)); + } + + [Fact] + public async Task ListByUser_FiltersByCreator_AndFinalised() + { + var a = await _svc.CreateBatchAsync("c1", "alice", CancellationToken.None); + var b = await _svc.CreateBatchAsync("c1", "bob", CancellationToken.None); + await _svc.StageRowsAsync(a.Id, [Row("z-a")], [], CancellationToken.None); + await _svc.FinaliseBatchAsync(a.Id, 1, "d", "l", CancellationToken.None); + _ = b; + + var aliceOpen = await _svc.ListByUserAsync("alice", includeFinalised: false, CancellationToken.None); + aliceOpen.ShouldBeEmpty("alice's only batch is finalised"); + + var aliceAll = await _svc.ListByUserAsync("alice", includeFinalised: true, CancellationToken.None); + aliceAll.Count.ShouldBe(1); + + var bobOpen = await _svc.ListByUserAsync("bob", includeFinalised: false, CancellationToken.None); + bobOpen.Count.ShouldBe(1); + } + + [Fact] + public async Task DropBatch_Unknown_IsNoOp() + { + await _svc.DropBatchAsync(Guid.NewGuid(), CancellationToken.None); + // no throw + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs index dae0bf5..3b03f15 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs @@ -31,6 +31,8 @@ public sealed class SchemaComplianceTests "DriverHostStatus", "DriverInstanceResilienceStatus", "LdapGroupRoleMapping", + "EquipmentImportBatch", + "EquipmentImportRow", }; var actual = QueryStrings(@" -- 2.49.1