feat(uns): area + line CRUD with #122 reassignment guard

This commit is contained in:
Joseph Doherty
2026-06-08 12:35:58 -04:00
parent c9f59e4bd2
commit 47b1d2259f
4 changed files with 628 additions and 0 deletions
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
@@ -121,4 +122,218 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
result.AddRange(vtagNodes);
return result;
}
/// <inheritdoc />
public async Task<UnsMutationResult> CreateAreaAsync(
string clusterId,
string unsAreaId,
string name,
string? notes,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
if (await db.UnsAreas.AnyAsync(a => a.UnsAreaId == unsAreaId, ct))
{
return new UnsMutationResult(false, $"Area '{unsAreaId}' already exists.");
}
db.UnsAreas.Add(new UnsArea
{
UnsAreaId = unsAreaId,
ClusterId = clusterId,
Name = name,
Notes = string.IsNullOrWhiteSpace(notes) ? null : notes,
});
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
/// <inheritdoc />
public async Task<UnsMutationResult> UpdateAreaAsync(
string unsAreaId,
string name,
string? notes,
string newClusterId,
byte[] rowVersion,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var entity = await db.UnsAreas.FirstOrDefaultAsync(a => a.UnsAreaId == unsAreaId, ct);
if (entity is null)
{
return new UnsMutationResult(false, "Row no longer exists.");
}
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
// Decision #122: a cluster move must not orphan driver-bound equipment from its driver's
// cluster. Any equipment under this area that is bound to a driver in a different cluster
// than the target blocks the move.
if (newClusterId != entity.ClusterId)
{
var lineIds = await db.UnsLines
.Where(l => l.UnsAreaId == unsAreaId)
.Select(l => l.UnsLineId)
.ToListAsync(ct);
var boundEquipment = await db.Equipment
.Where(eq => lineIds.Contains(eq.UnsLineId) && eq.DriverInstanceId != null)
.Select(eq => new { eq.EquipmentId, eq.DriverInstanceId })
.ToListAsync(ct);
foreach (var eq in boundEquipment)
{
var driverCluster = await db.DriverInstances
.Where(d => d.DriverInstanceId == eq.DriverInstanceId)
.Select(d => d.ClusterId)
.FirstOrDefaultAsync(ct);
if (driverCluster is not null && driverCluster != newClusterId)
{
return new UnsMutationResult(
false,
$"Cannot move area to '{newClusterId}': equipment '{eq.EquipmentId}' is bound to a driver in cluster '{driverCluster}' (decision #122). Re-home or unbind it first.");
}
}
}
entity.Name = name;
entity.Notes = string.IsNullOrWhiteSpace(notes) ? null : notes;
entity.ClusterId = newClusterId;
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
catch (DbUpdateConcurrencyException)
{
return new UnsMutationResult(false, "Another user changed this area while you were editing. Reload to see the latest values.");
}
}
/// <inheritdoc />
public async Task<UnsMutationResult> DeleteAreaAsync(
string unsAreaId,
byte[] rowVersion,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var entity = await db.UnsAreas.FirstOrDefaultAsync(a => a.UnsAreaId == unsAreaId, ct);
if (entity is null)
{
return new UnsMutationResult(true, null);
}
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
db.UnsAreas.Remove(entity);
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
catch (DbUpdateConcurrencyException)
{
return new UnsMutationResult(false, "Another user changed this area while you were viewing it.");
}
catch (Exception ex)
{
return new UnsMutationResult(false, $"Delete failed: {ex.Message}. Likely because lines still reference this area — remove them first.");
}
}
/// <inheritdoc />
public async Task<UnsMutationResult> CreateLineAsync(
string unsAreaId,
string unsLineId,
string name,
string? notes,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
if (await db.UnsLines.AnyAsync(l => l.UnsLineId == unsLineId, ct))
{
return new UnsMutationResult(false, $"Line '{unsLineId}' already exists.");
}
db.UnsLines.Add(new UnsLine
{
UnsLineId = unsLineId,
UnsAreaId = unsAreaId,
Name = name,
Notes = string.IsNullOrWhiteSpace(notes) ? null : notes,
});
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
/// <inheritdoc />
public async Task<UnsMutationResult> UpdateLineAsync(
string unsLineId,
string name,
string? notes,
string newUnsAreaId,
byte[] rowVersion,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var entity = await db.UnsLines.FirstOrDefaultAsync(l => l.UnsLineId == unsLineId, ct);
if (entity is null)
{
return new UnsMutationResult(false, "Row no longer exists.");
}
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
entity.UnsAreaId = newUnsAreaId;
entity.Name = name;
entity.Notes = string.IsNullOrWhiteSpace(notes) ? null : notes;
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
catch (DbUpdateConcurrencyException)
{
return new UnsMutationResult(false, "Another user changed this line while you were editing.");
}
}
/// <inheritdoc />
public async Task<UnsMutationResult> DeleteLineAsync(
string unsLineId,
byte[] rowVersion,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var entity = await db.UnsLines.FirstOrDefaultAsync(l => l.UnsLineId == unsLineId, ct);
if (entity is null)
{
return new UnsMutationResult(true, null);
}
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
db.UnsLines.Remove(entity);
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
catch (DbUpdateConcurrencyException)
{
return new UnsMutationResult(false, "Another user changed this line while you were viewing it.");
}
catch (Exception ex)
{
return new UnsMutationResult(false, $"Delete failed: {ex.Message}. Likely because equipment still references this line — remove or re-home it first.");
}
}
}