diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor index 841bc04b..836e10f6 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor @@ -21,7 +21,7 @@ @bind="_filter" @bind:event="oninput" /> - + @@ -89,6 +89,10 @@ OnSaved="OnEquipmentChildModalSavedAsync" OnCancel="CloseModals" /> + + @if (_confirmNode is not null) { @@ -158,6 +162,9 @@ private VirtualTagEditDto? _vtagModalExisting; private IReadOnlyList<(string Id, string Display)> _vtagModalScriptOptions = Array.Empty<(string, string)>(); + // --- Import-equipment-CSV modal state --- + private bool _importModalVisible; + // --- Owning equipment to refresh in place after a tag/virtual-tag mutation --- private string? _childRefreshEquipmentId; @@ -357,6 +364,24 @@ } } + /// Opens the bulk equipment-CSV import modal. + private void OpenImportModal() + { + CloseModals(); + _importModalVisible = true; + } + + /// + /// Handles the import modal's close after a run. A bulk import can add equipment across many + /// lines and clusters, so the whole structural tree is reloaded rather than refreshed in place. + /// + private async Task OnImportedAsync() + { + _roots = await Svc.LoadStructureAsync(); + CloseModals(); + StateHasChanged(); + } + /// Opens the delete-confirm modal for a node, stashing it as the pending target. private void HandleDelete(UnsNode node) { @@ -549,6 +574,7 @@ _vtagModalExisting = null; _vtagModalEquipmentId = null; _vtagModalScriptOptions = Array.Empty<(string, string)>(); + _importModalVisible = false; _childRefreshEquipmentId = null; _confirmNode = null; _confirmError = null; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/ImportEquipmentModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/ImportEquipmentModal.razor new file mode 100644 index 00000000..5de472e7 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/ImportEquipmentModal.razor @@ -0,0 +1,187 @@ +@* Bulk equipment import modal wired straight into IUnsTreeService. Paste a CSV (header + rows), + the modal parses it into EquipmentInput rows and calls ImportEquipmentAsync, then shows the + Inserted / Skipped / Errors summary in place. The host owns visibility; "Close" raises OnImported + so the page can reload the whole tree (an import can add equipment across many lines/clusters). + Required header columns (in order): Name, MachineCode, UnsLineId, DriverInstanceId. + Optional: ZTag, SAPID, Manufacturer, Model. Existing rows are detected by MachineCode and + skipped (additive-only — no updates), matching the retired /clusters/{id}/equipment/import page. *@ +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns +@inject IUnsTreeService Svc + +@if (Visible) +{ + + +} + +@code { + // Required, in order; DriverInstanceId may be left blank per row (driver-less equipment). + private static readonly string[] RequiredColumns = ["Name", "MachineCode", "UnsLineId", "DriverInstanceId"]; + + /// Whether the modal is shown. The host owns this flag. + [Parameter] public bool Visible { get; set; } + + /// + /// Raised when the user closes the modal after an import so the host can reload the tree and + /// dismiss the modal. Fired on close regardless of whether anything was inserted — closing always + /// returns control to the host. + /// + [Parameter] public EventCallback OnImported { get; set; } + + /// Raised when the user cancels before importing so the host can close. + [Parameter] public EventCallback OnCancel { get; set; } + + private string _csv = ""; + private bool _busy; + private string? _parseError; + private EquipmentImportResult? _result; + + protected override void OnParametersSet() + { + // Reset the working state each time the host (re)opens the modal. + if (!Visible) + { + _csv = ""; + _parseError = null; + _result = null; + } + } + + private async Task ImportAsync() + { + _busy = true; + _parseError = null; + _result = null; + try + { + var rows = ParseCsv(_csv); + if (rows is null) { return; } + + _result = await Svc.ImportEquipmentAsync(rows); + } + finally + { + _busy = false; + } + } + + /// + /// Closes the modal. Once an import has run (the user has seen the summary), closing raises + /// OnImported so the host reloads the tree; before any import it raises OnCancel. + /// + private Task CloseAsync() => + _result is not null ? OnImported.InvokeAsync() : OnCancel.InvokeAsync(); + + /// + /// Parses the pasted CSV into rows. Requires a header row whose first + /// four columns are Name, MachineCode, UnsLineId, DriverInstanceId (case-insensitive); the optional + /// ZTag/SAPID/Manufacturer/Model follow. Blank optional cells parse as null. Returns + /// null (and sets ) when the CSV is empty or the header is wrong. + /// + private List? ParseCsv(string csv) + { + if (string.IsNullOrWhiteSpace(csv)) { _parseError = "CSV is empty."; return null; } + + var lines = csv.Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries); + if (lines.Length < 2) { _parseError = "Need a header row and at least one data row."; return null; } + + var header = lines[0].Split(',').Select(c => c.Trim()).ToArray(); + for (var i = 0; i < RequiredColumns.Length; i++) + { + if (i >= header.Length || !string.Equals(header[i], RequiredColumns[i], StringComparison.OrdinalIgnoreCase)) + { + _parseError = $"Header column #{i + 1} must be '{RequiredColumns[i]}' (got '{(i < header.Length ? header[i] : "")}')."; + return null; + } + } + + var rows = new List(); + for (var lineIdx = 1; lineIdx < lines.Length; lineIdx++) + { + var parts = lines[lineIdx].Split(',').Select(c => c.Trim()).ToArray(); + if (parts.Length < RequiredColumns.Length) + { + _parseError = $"Row {lineIdx}: too few columns (got {parts.Length}, need {RequiredColumns.Length})."; + return null; + } + + rows.Add(new EquipmentInput( + Name: parts[0], + MachineCode: parts[1], + UnsLineId: parts[2], + DriverInstanceId: NullIfEmpty(parts, 3), + ZTag: NullIfEmpty(parts, 4), + SAPID: NullIfEmpty(parts, 5), + Manufacturer: NullIfEmpty(parts, 6), + Model: NullIfEmpty(parts, 7), + SerialNumber: null, + HardwareRevision: null, + SoftwareRevision: null, + YearOfConstruction: null, + AssetLocation: null, + ManufacturerUri: null, + DeviceManualUri: null, + Enabled: true)); + } + return rows; + } + + private static string? NullIfEmpty(string[] parts, int idx) => + idx < parts.Length && !string.IsNullOrWhiteSpace(parts[idx]) ? parts[idx] : null; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs index 548643d3..d6361d21 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs @@ -89,6 +89,17 @@ public sealed record TagEditDto(string TagId, string EquipmentId, string Name, s public sealed record VirtualTagEditDto(string VirtualTagId, string EquipmentId, string Name, string DataType, string ScriptId, bool ChangeTriggered, int? TimerIntervalMs, bool Historize, bool Enabled, byte[] RowVersion); +/// +/// The outcome of a bulk equipment CSV import: how many rows were inserted, how many were skipped +/// (existing MachineCode — the importer is additive-only, never an update), and a per-row error list +/// for rows that could not be inserted (unknown line, unknown driver, or a decision-#122 cluster +/// mismatch). Skipped rows never appear in . +/// +/// The count of new Equipment rows added. +/// The count of rows skipped because their MachineCode already exists. +/// The human-readable error strings for rows that failed validation. +public sealed record EquipmentImportResult(int Inserted, int Skipped, IReadOnlyList Errors); + /// /// Loads the structural portion of the unified-namespace (UNS) browse tree — /// Enterprise → Cluster → Area → Line → Equipment — from the config database. @@ -258,6 +269,21 @@ public interface IUnsTreeService /// Success, a missing-line failure, a duplicate-MachineCode failure, or a #122 guard failure. Task CreateEquipmentAsync(EquipmentInput input, CancellationToken ct = default); + /// + /// Bulk-imports equipment from a parsed set of rows in a single + /// context, applying the same rules as the single-add path: a row whose UnsLineId does not + /// exist is an error; a row whose DriverInstanceId is set but does not resolve is an error; + /// a driver-bound row whose driver is in a different cluster than its line fails the decision-#122 + /// guard; and a row whose MachineCode already exists in the DB or earlier in the + /// same batch is silently skipped (additive-only — never an update). Inserted rows get a + /// system-generated EQ- id and a fresh EquipmentUuid. All inserts are saved once at + /// the end. + /// + /// The parsed equipment rows to import. + /// A token to cancel the operation. + /// The insert/skip counts and the per-row error list. + Task ImportEquipmentAsync(IReadOnlyList rows, CancellationToken ct = default); + /// /// Updates an equipment's mutable fields (driver binding, line, name, MachineCode, external /// ids, and the OPC 40010 identification fields). The decision-#122 driver-cluster guard blocks diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs index 93f7c179..f6975a48 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs @@ -504,6 +504,81 @@ public sealed class UnsTreeService(IDbContextFactory dbF return new UnsMutationResult(true, null); } + /// + public async Task ImportEquipmentAsync( + IReadOnlyList rows, + CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + // Known lines and the MachineCodes already in the fleet, read once up front. The seen-set + // tracks MachineCodes inserted earlier in this same batch so two CSV rows that share a + // MachineCode skip the duplicate the same way the original ImportEquipment.razor did. + var lineSet = (await db.UnsLines.AsNoTracking().Select(l => l.UnsLineId).ToListAsync(ct)) + .ToHashSet(StringComparer.Ordinal); + var existing = (await db.Equipment.AsNoTracking().Select(e => e.MachineCode).ToListAsync(ct)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var inserted = 0; + var skipped = 0; + var errors = new List(); + + foreach (var row in rows) + { + // Existing MachineCode (in DB or earlier in this batch) → skip, never an error. + if (existing.Contains(row.MachineCode)) + { + skipped++; + continue; + } + + if (!lineSet.Contains(row.UnsLineId)) + { + errors.Add($"Row '{row.MachineCode}': UNS line '{row.UnsLineId}' not found."); + continue; + } + + // Reuse the #122 guard: it reports an unknown DriverInstanceId and a driver/line cluster + // mismatch, and is a no-op for driver-less rows. + var guard = await CheckDriverClusterGuardAsync(db, row, ct); + if (guard is not null) + { + errors.Add($"Row '{row.MachineCode}': {guard.Value.Error}"); + continue; + } + + var uuid = Guid.NewGuid(); + var equipmentId = $"EQ-{uuid.ToString("N")[..12]}"; + + db.Equipment.Add(new Equipment + { + EquipmentId = equipmentId, + EquipmentUuid = uuid, + DriverInstanceId = string.IsNullOrWhiteSpace(row.DriverInstanceId) ? null : row.DriverInstanceId, + UnsLineId = row.UnsLineId, + Name = row.Name, + MachineCode = row.MachineCode, + ZTag = string.IsNullOrWhiteSpace(row.ZTag) ? null : row.ZTag, + SAPID = string.IsNullOrWhiteSpace(row.SAPID) ? null : row.SAPID, + 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, + Enabled = row.Enabled, + }); + existing.Add(row.MachineCode); + inserted++; + } + + await db.SaveChangesAsync(ct); + return new EquipmentImportResult(inserted, skipped, errors); + } + /// public async Task UpdateEquipmentAsync( string equipmentId, diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceImportTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceImportTests.cs new file mode 100644 index 00000000..45ffd48f --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceImportTests.cs @@ -0,0 +1,200 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Verifies the bulk path: valid rows insert, +/// rows whose MachineCode already exists (in the DB or earlier in the same batch) are skipped, +/// rows referencing an unknown UNS line or unknown driver are reported as errors, and the +/// decision-#122 driver-cluster guard rejects a driver in a different cluster than the line. +/// +[Trait("Category", "Unit")] +public sealed class UnsTreeServiceImportTests +{ + private static (UnsTreeService Service, string DbName) Fresh() + { + var dbName = $"uns-import-{Guid.NewGuid():N}"; + return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName); + } + + /// + /// Seeds a line under an area in , plus an optional driver in + /// . The line id is always LINE-1; the driver (when + /// requested) is always DRV-1. + /// + private static void SeedLineAndDriver(string dbName, string lineCluster, string? driverCluster) + { + using var db = UnsTreeTestDb.CreateNamed(dbName); + db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = lineCluster, Name = "a" }); + db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "l" }); + if (driverCluster is not null) + { + db.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = "DRV-1", + ClusterId = driverCluster, + NamespaceId = "NS-1", + Name = "drv", + DriverType = "ModbusTcp", + DriverConfig = "{}", + }); + } + db.SaveChanges(); + } + + private static EquipmentInput Input(string name, string machineCode, string unsLineId, string? driverInstanceId) => + new(name, machineCode, unsLineId, driverInstanceId, + ZTag: null, SAPID: null, Manufacturer: null, Model: null, SerialNumber: null, + HardwareRevision: null, SoftwareRevision: null, YearOfConstruction: null, + AssetLocation: null, ManufacturerUri: null, DeviceManualUri: null, Enabled: true); + + /// Valid rows insert with system-generated EQ- ids and are counted in Inserted. + [Fact] + public async Task Import_inserts_valid_rows() + { + var (service, dbName) = Fresh(); + SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null); + + var result = await service.ImportEquipmentAsync( + [ + Input("machine-1", "mc_1", "LINE-1", null), + Input("machine-2", "mc_2", "LINE-1", null), + ]); + + result.Inserted.ShouldBe(2); + result.Skipped.ShouldBe(0); + result.Errors.ShouldBeEmpty(); + + using var db = UnsTreeTestDb.CreateNamed(dbName); + db.Equipment.Count().ShouldBe(2); + var eq = db.Equipment.Single(e => e.MachineCode == "mc_1"); + eq.EquipmentId.ShouldStartWith("EQ-"); + eq.EquipmentId.Length.ShouldBe(15); // "EQ-" + 12 hex chars + eq.Name.ShouldBe("machine-1"); + eq.UnsLineId.ShouldBe("LINE-1"); + eq.Enabled.ShouldBeTrue(); + } + + /// + /// A row whose MachineCode already exists in the DB is skipped (not an error), and a second row + /// later in the batch that reuses an earlier row's MachineCode is also skipped. + /// + [Fact] + public async Task Import_skips_duplicate_machinecode() + { + var (service, dbName) = Fresh(); + SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null); + // Pre-existing equipment with MachineCode "mc_existing". + await service.CreateEquipmentAsync(Input("machine-x", "mc_existing", "LINE-1", null)); + + var result = await service.ImportEquipmentAsync( + [ + Input("machine-1", "mc_existing", "LINE-1", null), // dup of DB row → skip + Input("machine-2", "mc_new", "LINE-1", null), // inserts + Input("machine-3", "mc_new", "LINE-1", null), // dup of in-batch row → skip + ]); + + result.Inserted.ShouldBe(1); + result.Skipped.ShouldBe(2); + result.Errors.ShouldBeEmpty(); + + using var db = UnsTreeTestDb.CreateNamed(dbName); + db.Equipment.Count(e => e.MachineCode == "mc_new").ShouldBe(1); + db.Equipment.Count(e => e.MachineCode == "mc_existing").ShouldBe(1); + } + + /// A row whose UnsLineId does not exist is reported as an error and not inserted. + [Fact] + public async Task Import_reports_unknown_line() + { + var (service, dbName) = Fresh(); + SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null); + + var result = await service.ImportEquipmentAsync( + [ + Input("machine-1", "mc_1", "LINE-BOGUS", null), + Input("machine-2", "mc_2", "LINE-1", null), + ]); + + result.Inserted.ShouldBe(1); + result.Skipped.ShouldBe(0); + result.Errors.Count.ShouldBe(1); + result.Errors[0].ShouldContain("mc_1"); + result.Errors[0].ShouldContain("LINE-BOGUS"); + + using var db = UnsTreeTestDb.CreateNamed(dbName); + db.Equipment.Any(e => e.MachineCode == "mc_1").ShouldBeFalse(); + db.Equipment.Any(e => e.MachineCode == "mc_2").ShouldBeTrue(); + } + + /// A driver-bound row whose DriverInstanceId does not resolve is reported as an error. + [Fact] + public async Task Import_reports_unknown_driver() + { + var (service, dbName) = Fresh(); + SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null); // no driver seeded + + var result = await service.ImportEquipmentAsync( + [ + Input("machine-1", "mc_1", "LINE-1", "DRV-GHOST"), + ]); + + result.Inserted.ShouldBe(0); + result.Skipped.ShouldBe(0); + result.Errors.Count.ShouldBe(1); + result.Errors[0].ShouldContain("mc_1"); + result.Errors[0].ShouldContain("DRV-GHOST"); + result.Errors[0].ShouldContain("not found"); + + using var db = UnsTreeTestDb.CreateNamed(dbName); + db.Equipment.Any(e => e.MachineCode == "mc_1").ShouldBeFalse(); + } + + /// + /// The decision-#122 guard rejects a row that binds a driver living in a different cluster than + /// the row's UNS line. + /// + [Fact] + public async Task Import_enforces_122_cluster() + { + var (service, dbName) = Fresh(); + SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: "SITE-A"); + + var result = await service.ImportEquipmentAsync( + [ + Input("machine-1", "mc_1", "LINE-1", "DRV-1"), + ]); + + result.Inserted.ShouldBe(0); + result.Skipped.ShouldBe(0); + result.Errors.Count.ShouldBe(1); + result.Errors[0].ShouldContain("mc_1"); + result.Errors[0].ShouldContain("decision #122"); + + using var db = UnsTreeTestDb.CreateNamed(dbName); + db.Equipment.Any(e => e.MachineCode == "mc_1").ShouldBeFalse(); + } + + /// A driver in the same cluster as the line imports cleanly. + [Fact] + public async Task Import_allows_driver_in_same_cluster() + { + var (service, dbName) = Fresh(); + SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: "MAIN"); + + var result = await service.ImportEquipmentAsync( + [ + Input("machine-1", "mc_1", "LINE-1", "DRV-1"), + ]); + + result.Inserted.ShouldBe(1); + result.Skipped.ShouldBe(0); + result.Errors.ShouldBeEmpty(); + + using var db = UnsTreeTestDb.CreateNamed(dbName); + db.Equipment.Single(e => e.MachineCode == "mc_1").DriverInstanceId.ShouldBe("DRV-1"); + } +}