From 020c30f9a65e5e887c6e62f8c012d8b7a896f04e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 18 May 2026 04:09:28 -0400 Subject: [PATCH] feat(admin): add ZTag/SAPID reservation pre-check to equipment CSV import (task #197) ApplyReservationPreCheckAsync on EquipmentImportBatchService queries active ExternalIdReservation rows in a single round-trip at parse time; rows whose ZTag or SAPID is claimed by a different EquipmentUuid are moved from AcceptedRows to RejectedRows with a descriptive reason. ImportEquipment.razor calls the check after EquipmentCsvImporter.Parse so conflicts appear in the preview before the operator clicks Stage + Finalise. Updated notice banner to reflect the pre-check is now live; 6 new unit tests cover conflict, no-conflict, same-UUID, released- reservation, and empty-input paths. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Pages/Clusters/ImportEquipment.razor | 16 +- .../Services/EquipmentImportBatchService.cs | 96 ++++++++++ .../EquipmentImportBatchServiceTests.cs | 176 ++++++++++++++++++ 3 files changed, 283 insertions(+), 5 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ImportEquipment.razor b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ImportEquipment.razor index 6401211..d3bf9c4 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ImportEquipment.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/ImportEquipment.razor @@ -34,9 +34,9 @@ Required columns: @string.Join(", ", EquipmentCsvImporter.RequiredColumns). Optional columns cover the OPC 40010 Identification fields. Paste the file contents or upload directly — the parser runs client-stream-side and shows a row-level preview - before anything lands in the draft. ZTag + SAPID uniqueness across the fleet is NOT - enforced here yet (see task #197); for now the finalise may fail at commit time if a - reservation conflict exists. + before anything lands in the draft. ZTag + SAPID reservation conflicts (task #197) are + checked at parse time: rows whose ZTag or SAPID is already reserved by a different + EquipmentUuid appear in the Rejected list so you can resolve them before finalising.
@@ -186,14 +186,20 @@ _csvText = await reader.ReadToEndAsync(); } - private void ParseAsync() + private async Task ParseAsync() { _parseError = null; _parseResult = null; _result = null; - try { _parseResult = EquipmentCsvImporter.Parse(_csvText); } + _busy = true; + try + { + var raw = EquipmentCsvImporter.Parse(_csvText); + _parseResult = await BatchSvc.ApplyReservationPreCheckAsync(raw, CancellationToken.None); + } catch (InvalidCsvFormatException ex) { _parseError = ex.Message; } catch (Exception ex) { _parseError = $"Parse failed: {ex.Message}"; } + finally { _busy = false; } } private async Task StageAndFinaliseAsync() diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentImportBatchService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentImportBatchService.cs index eb2c9ba..72ac927 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentImportBatchService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentImportBatchService.cs @@ -297,6 +297,102 @@ public sealed class EquipmentImportBatchService(OtOpcUaConfigDbContext db) return false; } + /// + /// Pre-checks active s for the accepted rows in + /// . Rows whose ZTag or SAPID is already reserved by a + /// different are moved from + /// to + /// with a descriptive reason so the + /// operator sees the conflict in the import preview rather than at finalise time. + /// + /// + /// Rows whose value matches a reservation owned by the same + /// are not flagged — that is the + /// normal re-publish of an asset keeping its identifier. + /// + /// Released reservations ( IS NOT NULL) + /// are ignored so an explicitly-released value is freely claimable. + /// + /// One DB round-trip fetches all relevant active reservations before the per-row scan. + /// + public async Task ApplyReservationPreCheckAsync( + EquipmentCsvParseResult parseResult, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(parseResult); + + var accepted = parseResult.AcceptedRows; + if (accepted.Count == 0) return parseResult; // nothing to check + + // Collect ZTag + SAPID values that are non-empty across all accepted rows. + var zTags = accepted + .Where(r => !string.IsNullOrWhiteSpace(r.ZTag)) + .Select(r => r.ZTag) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + var sapIds = accepted + .Where(r => !string.IsNullOrWhiteSpace(r.SAPID)) + .Select(r => r.SAPID) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (zTags.Count == 0 && sapIds.Count == 0) return parseResult; + + // Single round-trip: fetch all active reservations whose value appears in the import. + var activeReservations = await db.ExternalIdReservations + .AsNoTracking() + .Where(r => r.ReleasedAt == null && + ((r.Kind == ReservationKind.ZTag && zTags.Contains(r.Value)) || + (r.Kind == ReservationKind.SAPID && sapIds.Contains(r.Value)))) + .ToListAsync(ct) + .ConfigureAwait(false); + + if (activeReservations.Count == 0) return parseResult; + + // Build lookup: (kind, value-lower) → owning EquipmentUuid. + var reservedBy = activeReservations.ToDictionary( + r => (r.Kind, r.Value.ToLowerInvariant()), + r => r.EquipmentUuid); + + var stillAccepted = new List(); + var newRejections = new List(); + + foreach (var row in accepted) + { + var rowUuid = Guid.TryParse(row.EquipmentUuid, out var u) ? u : Guid.Empty; + string? conflictReason = null; + + if (!string.IsNullOrWhiteSpace(row.ZTag) && + reservedBy.TryGetValue((ReservationKind.ZTag, row.ZTag.ToLowerInvariant()), out var zOwner) && + zOwner != rowUuid) + { + conflictReason = + $"ZTag '{row.ZTag}' is already reserved by EquipmentUuid {zOwner}. " + + "Release that reservation via the Reservations admin page before re-assigning this ZTag."; + } + + if (conflictReason is null && + !string.IsNullOrWhiteSpace(row.SAPID) && + reservedBy.TryGetValue((ReservationKind.SAPID, row.SAPID.ToLowerInvariant()), out var sOwner) && + sOwner != rowUuid) + { + conflictReason = + $"SAPID '{row.SAPID}' is already reserved by EquipmentUuid {sOwner}. " + + "Release that reservation via the Reservations admin page before re-assigning this SAPID."; + } + + if (conflictReason is not null) + newRejections.Add(new EquipmentCsvRowError(LineNumber: 0, Reason: conflictReason)); + else + stillAccepted.Add(row); + } + + if (newRejections.Count == 0) return parseResult; // fast path — no conflicts + + return new EquipmentCsvParseResult( + AcceptedRows: stillAccepted, + RejectedRows: [..parseResult.RejectedRows, ..newRejections]); + } + /// 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) { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests/EquipmentImportBatchServiceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests/EquipmentImportBatchServiceTests.cs index d28db99..12246c0 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests/EquipmentImportBatchServiceTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Admin.Tests/EquipmentImportBatchServiceTests.cs @@ -253,4 +253,180 @@ public sealed class EquipmentImportBatchServiceTests : IDisposable _db.ExternalIdReservations.Count().ShouldBe(0); } + + // ── ApplyReservationPreCheck tests ────────────────────────────────────────── + + /// No active reservations → the parse result passes through unchanged. + [Fact] + public async Task PreCheck_NoReservations_ReturnsUnchanged() + { + var input = new EquipmentCsvParseResult( + AcceptedRows: [Row("z-1"), Row("z-2")], + RejectedRows: []); + + var result = await _svc.ApplyReservationPreCheckAsync(input, CancellationToken.None); + + result.AcceptedRows.Count.ShouldBe(2); + result.RejectedRows.Count.ShouldBe(0); + } + + /// + /// ZTag reserved by a DIFFERENT EquipmentUuid → row moves to rejected with a descriptive reason; + /// SAPID of that same row is ignored since the row is already conflicted. + /// + [Fact] + public async Task PreCheck_ZTagConflict_MovesRowToRejected() + { + // Seed an active reservation for "z-taken" owned by a different UUID. + var ownerUuid = Guid.NewGuid(); + _db.ExternalIdReservations.Add(new ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation + { + ReservationId = Guid.NewGuid(), + Kind = ZB.MOM.WW.OtOpcUa.Configuration.Enums.ReservationKind.ZTag, + Value = "z-taken", + EquipmentUuid = ownerUuid, + ClusterId = "c1", + FirstPublishedBy = "alice", + }); + await _db.SaveChangesAsync(); + + var importerUuid = Guid.NewGuid(); // different UUID — conflict + var conflictRow = new EquipmentCsvRow + { + ZTag = "z-taken", MachineCode = "mc", SAPID = "sap-ok", + EquipmentId = "eq-x", EquipmentUuid = importerUuid.ToString(), + Name = "x", UnsAreaName = "ar", UnsLineName = "ln", + }; + var cleanRow = Row("z-clean"); + + var input = new EquipmentCsvParseResult( + AcceptedRows: [conflictRow, cleanRow], + RejectedRows: [new EquipmentCsvRowError(99, "pre-existing parser rejection")]); + + var result = await _svc.ApplyReservationPreCheckAsync(input, CancellationToken.None); + + result.AcceptedRows.Count.ShouldBe(1, "only the clean row remains accepted"); + result.AcceptedRows[0].ZTag.ShouldBe("z-clean"); + + result.RejectedRows.Count.ShouldBe(2, "pre-existing + the new conflict rejection"); + var conflictError = result.RejectedRows.Single(e => e.Reason.Contains("z-taken")); + conflictError.Reason.ShouldContain(ownerUuid.ToString()); + conflictError.Reason.ShouldContain("ZTag"); + } + + /// SAPID reserved by a different EquipmentUuid → row is rejected with a SAPID-specific reason. + [Fact] + public async Task PreCheck_SAPIDConflict_MovesRowToRejected() + { + var ownerUuid = Guid.NewGuid(); + _db.ExternalIdReservations.Add(new ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation + { + ReservationId = Guid.NewGuid(), + Kind = ZB.MOM.WW.OtOpcUa.Configuration.Enums.ReservationKind.SAPID, + Value = "sap-taken", + EquipmentUuid = ownerUuid, + ClusterId = "c1", + FirstPublishedBy = "alice", + }); + await _db.SaveChangesAsync(); + + var importerUuid = Guid.NewGuid(); + var conflictRow = new EquipmentCsvRow + { + ZTag = "z-free", MachineCode = "mc", SAPID = "sap-taken", + EquipmentId = "eq-y", EquipmentUuid = importerUuid.ToString(), + Name = "y", UnsAreaName = "ar", UnsLineName = "ln", + }; + + var input = new EquipmentCsvParseResult(AcceptedRows: [conflictRow], RejectedRows: []); + + var result = await _svc.ApplyReservationPreCheckAsync(input, CancellationToken.None); + + result.AcceptedRows.Count.ShouldBe(0); + result.RejectedRows.Count.ShouldBe(1); + result.RejectedRows[0].Reason.ShouldContain("sap-taken"); + result.RejectedRows[0].Reason.ShouldContain("SAPID"); + result.RejectedRows[0].Reason.ShouldContain(ownerUuid.ToString()); + } + + /// + /// Reservation active for the SAME EquipmentUuid → row is NOT rejected (normal re-publish). + /// + [Fact] + public async Task PreCheck_SameEquipmentUuid_NotFlagged() + { + var sharedUuid = Guid.NewGuid(); + _db.ExternalIdReservations.Add(new ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation + { + ReservationId = Guid.NewGuid(), + Kind = ZB.MOM.WW.OtOpcUa.Configuration.Enums.ReservationKind.ZTag, + Value = "z-mine", + EquipmentUuid = sharedUuid, + ClusterId = "c1", + FirstPublishedBy = "alice", + }); + await _db.SaveChangesAsync(); + + var row = new EquipmentCsvRow + { + ZTag = "z-mine", MachineCode = "mc", SAPID = "sap-mine", + EquipmentId = "eq-z", EquipmentUuid = sharedUuid.ToString(), // same UUID + Name = "z", UnsAreaName = "ar", UnsLineName = "ln", + }; + + var input = new EquipmentCsvParseResult(AcceptedRows: [row], RejectedRows: []); + + var result = await _svc.ApplyReservationPreCheckAsync(input, CancellationToken.None); + + result.AcceptedRows.Count.ShouldBe(1, "same UUID → not a conflict"); + result.RejectedRows.Count.ShouldBe(0); + } + + /// A released reservation (ReleasedAt IS NOT NULL) does not block the import row. + [Fact] + public async Task PreCheck_ReleasedReservation_IsIgnored() + { + var oldOwner = Guid.NewGuid(); + _db.ExternalIdReservations.Add(new ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation + { + ReservationId = Guid.NewGuid(), + Kind = ZB.MOM.WW.OtOpcUa.Configuration.Enums.ReservationKind.ZTag, + Value = "z-released", + EquipmentUuid = oldOwner, + ClusterId = "c1", + FirstPublishedBy = "alice", + ReleasedAt = DateTime.UtcNow.AddDays(-1), + ReleasedBy = "bob", + ReleaseReason = "decommissioned", + }); + await _db.SaveChangesAsync(); + + var newImporterUuid = Guid.NewGuid(); + var row = new EquipmentCsvRow + { + ZTag = "z-released", MachineCode = "mc", SAPID = "sap-new", + EquipmentId = "eq-new", EquipmentUuid = newImporterUuid.ToString(), + Name = "new", UnsAreaName = "ar", UnsLineName = "ln", + }; + + var input = new EquipmentCsvParseResult(AcceptedRows: [row], RejectedRows: []); + + var result = await _svc.ApplyReservationPreCheckAsync(input, CancellationToken.None); + + result.AcceptedRows.Count.ShouldBe(1, "released reservation is free to claim"); + result.RejectedRows.Count.ShouldBe(0); + } + + /// Empty accepted list short-circuits without hitting the DB. + [Fact] + public async Task PreCheck_EmptyInput_ReturnsUnchanged() + { + var input = new EquipmentCsvParseResult( + AcceptedRows: [], + RejectedRows: [new EquipmentCsvRowError(1, "already rejected")]); + + var result = await _svc.ApplyReservationPreCheckAsync(input, CancellationToken.None); + + result.ShouldBeSameAs(input, "same instance when there is nothing to check"); + } }