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) <noreply@anthropic.com>
This commit is contained in:
@@ -34,9 +34,9 @@
|
|||||||
Required columns: @string.Join(", ", EquipmentCsvImporter.RequiredColumns).
|
Required columns: @string.Join(", ", EquipmentCsvImporter.RequiredColumns).
|
||||||
Optional columns cover the OPC 40010 Identification fields. Paste the file contents
|
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
|
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
|
before anything lands in the draft. ZTag + SAPID reservation conflicts (task #197) are
|
||||||
enforced here yet (see task #197); for now the finalise may fail at commit time if a
|
checked at parse time: rows whose ZTag or SAPID is already reserved by a different
|
||||||
reservation conflict exists.
|
EquipmentUuid appear in the Rejected list so you can resolve them before finalising.
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel notice rise mt-2" style="animation-delay:.08s">
|
<section class="panel notice rise mt-2" style="animation-delay:.08s">
|
||||||
@@ -186,14 +186,20 @@
|
|||||||
_csvText = await reader.ReadToEndAsync();
|
_csvText = await reader.ReadToEndAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ParseAsync()
|
private async Task ParseAsync()
|
||||||
{
|
{
|
||||||
_parseError = null;
|
_parseError = null;
|
||||||
_parseResult = null;
|
_parseResult = null;
|
||||||
_result = 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 (InvalidCsvFormatException ex) { _parseError = ex.Message; }
|
||||||
catch (Exception ex) { _parseError = $"Parse failed: {ex.Message}"; }
|
catch (Exception ex) { _parseError = $"Parse failed: {ex.Message}"; }
|
||||||
|
finally { _busy = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task StageAndFinaliseAsync()
|
private async Task StageAndFinaliseAsync()
|
||||||
|
|||||||
@@ -297,6 +297,102 @@ public sealed class EquipmentImportBatchService(OtOpcUaConfigDbContext db)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pre-checks active <see cref="ExternalIdReservation"/>s for the accepted rows in
|
||||||
|
/// <paramref name="parseResult"/>. Rows whose ZTag or SAPID is already reserved by a
|
||||||
|
/// <em>different</em> <see cref="ExternalIdReservation.EquipmentUuid"/> are moved from
|
||||||
|
/// <see cref="EquipmentCsvParseResult.AcceptedRows"/> to
|
||||||
|
/// <see cref="EquipmentCsvParseResult.RejectedRows"/> with a descriptive reason so the
|
||||||
|
/// operator sees the conflict in the import preview rather than at finalise time.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Rows whose value matches a reservation owned by the <em>same</em>
|
||||||
|
/// <see cref="ExternalIdReservation.EquipmentUuid"/> are not flagged — that is the
|
||||||
|
/// normal re-publish of an asset keeping its identifier.
|
||||||
|
///
|
||||||
|
/// Released reservations (<see cref="ExternalIdReservation.ReleasedAt"/> 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.
|
||||||
|
/// </remarks>
|
||||||
|
public async Task<EquipmentCsvParseResult> 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<EquipmentCsvRow>();
|
||||||
|
var newRejections = new List<EquipmentCsvRowError>();
|
||||||
|
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>List batches created by the given user. Finalised batches are archived; include them on demand.</summary>
|
/// <summary>List batches created by the given user. Finalised batches are archived; include them on demand.</summary>
|
||||||
public async Task<IReadOnlyList<EquipmentImportBatch>> ListByUserAsync(string createdBy, bool includeFinalised, CancellationToken ct)
|
public async Task<IReadOnlyList<EquipmentImportBatch>> ListByUserAsync(string createdBy, bool includeFinalised, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -253,4 +253,180 @@ public sealed class EquipmentImportBatchServiceTests : IDisposable
|
|||||||
|
|
||||||
_db.ExternalIdReservations.Count().ShouldBe(0);
|
_db.ExternalIdReservations.Count().ShouldBe(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── ApplyReservationPreCheck tests ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>No active reservations → the parse result passes through unchanged.</summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>SAPID reserved by a different EquipmentUuid → row is rejected with a SAPID-specific reason.</summary>
|
||||||
|
[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());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reservation active for the SAME EquipmentUuid → row is NOT rejected (normal re-publish).
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A released reservation (ReleasedAt IS NOT NULL) does not block the import row.</summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Empty accepted list short-circuits without hitting the DB.</summary>
|
||||||
|
[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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user