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:
Joseph Doherty
2026-05-18 04:09:28 -04:00
parent a8dabc47f9
commit 020c30f9a6
3 changed files with 283 additions and 5 deletions

View File

@@ -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.
</section>
<section class="panel notice rise mt-2" style="animation-delay:.08s">
@@ -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()

View File

@@ -297,6 +297,102 @@ public sealed class EquipmentImportBatchService(OtOpcUaConfigDbContext db)
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>
public async Task<IReadOnlyList<EquipmentImportBatch>> ListByUserAsync(string createdBy, bool includeFinalised, CancellationToken ct)
{