feat(uns): equipment CRUD with #122 driver-cluster guard

This commit is contained in:
Joseph Doherty
2026-06-08 12:47:19 -04:00
parent 8b1d3de806
commit 2836a0704b
4 changed files with 523 additions and 0 deletions
@@ -0,0 +1,43 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>
/// Parameter object carrying the operator-editable fields for an equipment create or update,
/// so <see cref="IUnsTreeService.CreateEquipmentAsync"/> and
/// <see cref="IUnsTreeService.UpdateEquipmentAsync"/> avoid an unwieldy positional signature.
/// The <c>EquipmentId</c> and <c>EquipmentUuid</c> are system-generated (decision #125) and are
/// therefore not part of this input. Optional string fields that arrive whitespace-only are
/// collapsed to <c>null</c> by the service.
/// </summary>
/// <param name="Name">UNS level-5 segment; matches <c>^[a-z0-9-]{1,32}$</c>.</param>
/// <param name="MachineCode">Operator colloquial id; unique fleet-wide.</param>
/// <param name="UnsLineId">Logical FK to the owning <see cref="Configuration.Entities.UnsLine"/>.</param>
/// <param name="DriverInstanceId">Optional driver binding; whitespace/empty means driver-less.</param>
/// <param name="ZTag">Optional ERP equipment id.</param>
/// <param name="SAPID">Optional SAP PM equipment id.</param>
/// <param name="Manufacturer">Optional OPC 40010 manufacturer name.</param>
/// <param name="Model">Optional OPC 40010 model designation.</param>
/// <param name="SerialNumber">Optional OPC 40010 serial number.</param>
/// <param name="HardwareRevision">Optional OPC 40010 hardware revision.</param>
/// <param name="SoftwareRevision">Optional OPC 40010 software revision.</param>
/// <param name="YearOfConstruction">Optional OPC 40010 year of construction.</param>
/// <param name="AssetLocation">Optional OPC 40010 asset location.</param>
/// <param name="ManufacturerUri">Optional OPC 40010 manufacturer URI.</param>
/// <param name="DeviceManualUri">Optional OPC 40010 device-manual URI.</param>
/// <param name="Enabled">Whether the equipment is surfaced in deployments.</param>
public sealed record EquipmentInput(
string Name,
string MachineCode,
string UnsLineId,
string? DriverInstanceId,
string? ZTag,
string? SAPID,
string? Manufacturer,
string? Model,
string? SerialNumber,
string? HardwareRevision,
string? SoftwareRevision,
short? YearOfConstruction,
string? AssetLocation,
string? ManufacturerUri,
string? DeviceManualUri,
bool Enabled);
@@ -100,4 +100,40 @@ public interface IUnsTreeService
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a concurrency failure, or a delete-failed failure.</returns>
Task<UnsMutationResult> DeleteLineAsync(string unsLineId, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Creates a new equipment under a UNS line. The <c>EquipmentId</c> is system-generated
/// (decision #125: <c>EQ-</c> + the first 12 hex chars of a fresh <c>EquipmentUuid</c>).
/// Fails if the line is unset, if the MachineCode is already used fleet-wide, or if the
/// decision-#122 driver-cluster guard trips. Whitespace-only DriverInstanceId/ZTag/SAPID
/// collapse to <c>null</c>.
/// </summary>
/// <param name="input">The operator-editable equipment fields.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a missing-line failure, a duplicate-MachineCode failure, or a #122 guard failure.</returns>
Task<UnsMutationResult> CreateEquipmentAsync(EquipmentInput input, CancellationToken ct = default);
/// <summary>
/// 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
/// binding to a driver in a different cluster than the equipment's line. Uses last-write-wins
/// optimistic concurrency on <see cref="Configuration.Entities.Equipment.RowVersion"/>.
/// </summary>
/// <param name="equipmentId">The equipment to update.</param>
/// <param name="input">The new operator-editable equipment fields.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a missing-row failure, a #122 guard failure, or a concurrency failure.</returns>
Task<UnsMutationResult> UpdateEquipmentAsync(string equipmentId, EquipmentInput input, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Deletes an equipment. A missing row is treated as success (already gone). Uses last-write-wins
/// optimistic concurrency; a delete that fails because tags or virtual tags still reference the
/// equipment surfaces a guidance message.
/// </summary>
/// <param name="equipmentId">The equipment to delete.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a concurrency failure, or a delete-failed failure.</returns>
Task<UnsMutationResult> DeleteEquipmentAsync(string equipmentId, byte[] rowVersion, CancellationToken ct = default);
}
@@ -336,4 +336,171 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
return new UnsMutationResult(false, $"Delete failed: {ex.Message}. Likely because equipment still references this line — remove or re-home it first.");
}
}
/// <inheritdoc />
public async Task<UnsMutationResult> CreateEquipmentAsync(EquipmentInput input, CancellationToken ct = default)
{
if (string.IsNullOrEmpty(input.UnsLineId))
{
return new UnsMutationResult(false, "Pick a UNS line.");
}
await using var db = await dbFactory.CreateDbContextAsync(ct);
if (await db.Equipment.AnyAsync(e => e.MachineCode == input.MachineCode, ct))
{
return new UnsMutationResult(false, $"MachineCode '{input.MachineCode}' already exists in this fleet.");
}
var guard = await CheckDriverClusterGuardAsync(db, input, ct);
if (guard is not null)
{
return guard.Value;
}
var uuid = Guid.NewGuid();
var equipmentId = $"EQ-{uuid.ToString("N")[..12]}";
db.Equipment.Add(new Equipment
{
EquipmentId = equipmentId,
EquipmentUuid = uuid,
DriverInstanceId = string.IsNullOrWhiteSpace(input.DriverInstanceId) ? null : input.DriverInstanceId,
UnsLineId = input.UnsLineId,
Name = input.Name,
MachineCode = input.MachineCode,
ZTag = string.IsNullOrWhiteSpace(input.ZTag) ? null : input.ZTag,
SAPID = string.IsNullOrWhiteSpace(input.SAPID) ? null : input.SAPID,
Manufacturer = input.Manufacturer,
Model = input.Model,
SerialNumber = input.SerialNumber,
HardwareRevision = input.HardwareRevision,
SoftwareRevision = input.SoftwareRevision,
YearOfConstruction = input.YearOfConstruction,
AssetLocation = input.AssetLocation,
ManufacturerUri = input.ManufacturerUri,
DeviceManualUri = input.DeviceManualUri,
Enabled = input.Enabled,
});
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
/// <inheritdoc />
public async Task<UnsMutationResult> UpdateEquipmentAsync(
string equipmentId,
EquipmentInput input,
byte[] rowVersion,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var entity = await db.Equipment.FirstOrDefaultAsync(e => e.EquipmentId == equipmentId, ct);
if (entity is null)
{
return new UnsMutationResult(false, "Row no longer exists.");
}
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
var guard = await CheckDriverClusterGuardAsync(db, input, ct);
if (guard is not null)
{
return guard.Value;
}
entity.DriverInstanceId = string.IsNullOrWhiteSpace(input.DriverInstanceId) ? null : input.DriverInstanceId;
entity.UnsLineId = input.UnsLineId;
entity.Name = input.Name;
entity.MachineCode = input.MachineCode;
entity.ZTag = string.IsNullOrWhiteSpace(input.ZTag) ? null : input.ZTag;
entity.SAPID = string.IsNullOrWhiteSpace(input.SAPID) ? null : input.SAPID;
entity.Manufacturer = input.Manufacturer;
entity.Model = input.Model;
entity.SerialNumber = input.SerialNumber;
entity.HardwareRevision = input.HardwareRevision;
entity.SoftwareRevision = input.SoftwareRevision;
entity.YearOfConstruction = input.YearOfConstruction;
entity.AssetLocation = input.AssetLocation;
entity.ManufacturerUri = input.ManufacturerUri;
entity.DeviceManualUri = input.DeviceManualUri;
entity.Enabled = input.Enabled;
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
catch (DbUpdateConcurrencyException)
{
return new UnsMutationResult(false, "Another user changed this equipment while you were editing.");
}
}
/// <inheritdoc />
public async Task<UnsMutationResult> DeleteEquipmentAsync(
string equipmentId,
byte[] rowVersion,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var entity = await db.Equipment.FirstOrDefaultAsync(e => e.EquipmentId == equipmentId, ct);
if (entity is null)
{
return new UnsMutationResult(true, null);
}
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
db.Equipment.Remove(entity);
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
catch (DbUpdateConcurrencyException)
{
return new UnsMutationResult(false, "Another user changed this equipment while you were viewing it.");
}
catch (Exception ex)
{
return new UnsMutationResult(false, $"Delete failed: {ex.Message}. Likely because tags or virtual tags reference this equipment — remove them first.");
}
}
/// <summary>
/// Decision #122: an equipment may only bind to a driver in the same cluster as its line.
/// Only runs when a driver is requested. Resolves the line's cluster (line → area → cluster)
/// and the driver's cluster; when both are known and differ, returns the guard failure.
/// Returns <c>null</c> when the bind is allowed (driver-less, unresolvable cluster, or match).
/// </summary>
private static async Task<UnsMutationResult?> CheckDriverClusterGuardAsync(
OtOpcUaConfigDbContext db,
EquipmentInput input,
CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(input.DriverInstanceId))
{
return null;
}
var line = await db.UnsLines.FirstOrDefaultAsync(l => l.UnsLineId == input.UnsLineId, ct);
var area = line is null ? null : await db.UnsAreas.FirstOrDefaultAsync(a => a.UnsAreaId == line.UnsAreaId, ct);
var lineCluster = area?.ClusterId;
var driverCluster = await db.DriverInstances
.Where(d => d.DriverInstanceId == input.DriverInstanceId)
.Select(d => d.ClusterId)
.FirstOrDefaultAsync(ct);
if (driverCluster is not null && lineCluster is not null && driverCluster != lineCluster)
{
return new UnsMutationResult(
false,
$"Driver '{input.DriverInstanceId}' is in cluster '{driverCluster}' but the line is in cluster '{lineCluster}' (decision #122).");
}
return null;
}
}
@@ -0,0 +1,277 @@
using Microsoft.EntityFrameworkCore;
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;
/// <summary>
/// Verifies the Equipment CRUD mutations on <see cref="UnsTreeService"/>, including the
/// system-generated <c>EQ-</c> id, fleet-wide MachineCode uniqueness, and the decision-#122
/// driver-cluster guard that blocks binding equipment to a driver in a different cluster than
/// the equipment's line.
/// </summary>
/// <remarks>
/// The EF InMemory provider does not enforce <c>RowVersion</c> concurrency, so the
/// <c>DbUpdateConcurrencyException</c> branches are not exercised here by design.
/// </remarks>
[Trait("Category", "Unit")]
public sealed class UnsTreeServiceEquipmentTests
{
private static (UnsTreeService Service, string DbName) Fresh()
{
var dbName = $"uns-equip-{Guid.NewGuid():N}";
return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName);
}
/// <summary>
/// Seeds a line under an area in <paramref name="lineCluster"/>, plus an optional driver in
/// <paramref name="driverCluster"/>. The line id is always <c>LINE-1</c>; the driver (when
/// requested) is always <c>DRV-1</c>.
/// </summary>
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);
// ----- CreateEquipment -----
/// <summary>A new equipment gets a system-generated EQ- id (EQ- + 12 hex chars) and persists.</summary>
[Fact]
public async Task CreateEquipment_generates_EQ_id_and_persists()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", null));
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
using var db = UnsTreeTestDb.CreateNamed(dbName);
var eq = db.Equipment.Single(e => e.MachineCode == "machine_001");
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();
}
/// <summary>Creating equipment with a MachineCode already in the fleet is blocked.</summary>
[Fact]
public async Task CreateEquipment_duplicate_machinecode_blocked()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
await service.CreateEquipmentAsync(Input("machine-1", "machine_dup", "LINE-1", null));
var result = await service.CreateEquipmentAsync(Input("machine-2", "machine_dup", "LINE-1", null));
result.Ok.ShouldBeFalse();
result.Error.ShouldBe("MachineCode 'machine_dup' already exists in this fleet.");
}
/// <summary>Creating equipment with no UNS line returns the pick-a-line error.</summary>
[Fact]
public async Task CreateEquipment_missing_line_blocked()
{
var (service, _) = Fresh();
var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "", null));
result.Ok.ShouldBeFalse();
result.Error.ShouldBe("Pick a UNS line.");
}
/// <summary>The #122 guard blocks binding equipment to a driver in a different cluster than the line.</summary>
[Fact]
public async Task CreateEquipment_driver_in_other_cluster_blocked()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: "SITE-A");
var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", "DRV-1"));
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("decision #122");
result.Error.ShouldContain("DRV-1");
result.Error.ShouldContain("SITE-A");
result.Error.ShouldContain("MAIN");
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Equipment.Any(e => e.MachineCode == "machine_001").ShouldBeFalse();
}
/// <summary>Binding equipment to a driver in the same cluster as the line is allowed.</summary>
[Fact]
public async Task CreateEquipment_driver_in_same_cluster_allowed()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: "MAIN");
var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", "DRV-1"));
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Equipment.Single(e => e.MachineCode == "machine_001").DriverInstanceId.ShouldBe("DRV-1");
}
/// <summary>Driver-less equipment is allowed regardless of cluster (no #122 guard applies).</summary>
[Fact]
public async Task CreateEquipment_driverless_allowed()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
var result = await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", null));
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
using var db = UnsTreeTestDb.CreateNamed(dbName);
db.Equipment.Single(e => e.MachineCode == "machine_001").DriverInstanceId.ShouldBeNull();
}
// ----- UpdateEquipment -----
/// <summary>Updating equipment changes its mutable fields (name, MachineCode, a 40010 field).</summary>
[Fact]
public async Task UpdateEquipment_changes_fields()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", null));
string equipmentId;
byte[] rv;
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
var eq = db.Equipment.Single(e => e.MachineCode == "machine_001");
equipmentId = eq.EquipmentId;
rv = eq.RowVersion;
}
var updated = new EquipmentInput("machine-renamed", "machine_renamed", "LINE-1", null,
ZTag: null, SAPID: null, Manufacturer: "Acme", Model: null, SerialNumber: null,
HardwareRevision: null, SoftwareRevision: null, YearOfConstruction: (short)2021,
AssetLocation: null, ManufacturerUri: null, DeviceManualUri: null, Enabled: false);
var result = await service.UpdateEquipmentAsync(equipmentId, updated, rv);
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
using var verify = UnsTreeTestDb.CreateNamed(dbName);
var after = verify.Equipment.Single(e => e.EquipmentId == equipmentId);
after.Name.ShouldBe("machine-renamed");
after.MachineCode.ShouldBe("machine_renamed");
after.Manufacturer.ShouldBe("Acme");
after.YearOfConstruction.ShouldBe((short)2021);
after.Enabled.ShouldBeFalse();
}
/// <summary>Updating equipment that no longer exists returns the row-gone error.</summary>
[Fact]
public async Task UpdateEquipment_missing_row_returns_error()
{
var (service, _) = Fresh();
var result = await service.UpdateEquipmentAsync("EQ-nope", Input("n", "mc", "LINE-1", null), []);
result.Ok.ShouldBeFalse();
result.Error.ShouldBe("Row no longer exists.");
}
/// <summary>The #122 guard blocks an update that binds equipment to a driver in another cluster.</summary>
[Fact]
public async Task UpdateEquipment_driver_in_other_cluster_blocked()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: "SITE-A");
await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", null));
string equipmentId;
byte[] rv;
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
var eq = db.Equipment.Single(e => e.MachineCode == "machine_001");
equipmentId = eq.EquipmentId;
rv = eq.RowVersion;
}
var result = await service.UpdateEquipmentAsync(
equipmentId, Input("machine-1", "machine_001", "LINE-1", "DRV-1"), rv);
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
result.Error.ShouldContain("decision #122");
using var verify = UnsTreeTestDb.CreateNamed(dbName);
verify.Equipment.Single(e => e.EquipmentId == equipmentId).DriverInstanceId.ShouldBeNull();
}
// ----- DeleteEquipment -----
/// <summary>Deleting equipment removes the row.</summary>
[Fact]
public async Task DeleteEquipment_removes_row()
{
var (service, dbName) = Fresh();
SeedLineAndDriver(dbName, lineCluster: "MAIN", driverCluster: null);
await service.CreateEquipmentAsync(Input("machine-1", "machine_001", "LINE-1", null));
string equipmentId;
byte[] rv;
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
var eq = db.Equipment.Single(e => e.MachineCode == "machine_001");
equipmentId = eq.EquipmentId;
rv = eq.RowVersion;
}
var result = await service.DeleteEquipmentAsync(equipmentId, rv);
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
using var verify = UnsTreeTestDb.CreateNamed(dbName);
verify.Equipment.Any(e => e.EquipmentId == equipmentId).ShouldBeFalse();
}
/// <summary>Deleting equipment that is already gone is a no-op success.</summary>
[Fact]
public async Task DeleteEquipment_already_gone_returns_ok()
{
var (service, _) = Fresh();
var result = await service.DeleteEquipmentAsync("EQ-ghost", []);
result.Ok.ShouldBeTrue();
result.Error.ShouldBeNull();
}
}