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:
@@ -253,4 +253,180 @@ public sealed class EquipmentImportBatchServiceTests : IDisposable
|
||||
|
||||
_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