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
@@ -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;
}
}