feat(uns): area + line CRUD with #122 reassignment guard
This commit is contained in:
@@ -26,4 +26,78 @@ public interface IUnsTreeService
|
||||
/// <param name="ct">A token to cancel the load.</param>
|
||||
/// <returns>Tag nodes followed by VirtualTag nodes; empty if the equipment has none.</returns>
|
||||
Task<IReadOnlyList<UnsNode>> LoadEquipmentChildrenAsync(string equipmentId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new UNS area under a cluster. Fails if an area with the same id already exists.
|
||||
/// Whitespace-only notes are stored as <c>null</c>.
|
||||
/// </summary>
|
||||
/// <param name="clusterId">The owning cluster.</param>
|
||||
/// <param name="unsAreaId">The unique area id to create.</param>
|
||||
/// <param name="name">The area name.</param>
|
||||
/// <param name="notes">Optional notes; whitespace collapses to <c>null</c>.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>Success, or a duplicate-id failure.</returns>
|
||||
Task<UnsMutationResult> CreateAreaAsync(string clusterId, string unsAreaId, string name, string? notes, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates a UNS area's name, notes, and owning cluster. When the cluster changes, the
|
||||
/// decision-#122 reassignment guard blocks the move if any driver-bound equipment under the
|
||||
/// area is bound to a driver in a different cluster than the target. Uses last-write-wins
|
||||
/// optimistic concurrency on <see cref="Configuration.Entities.UnsArea.RowVersion"/>.
|
||||
/// </summary>
|
||||
/// <param name="unsAreaId">The area to update.</param>
|
||||
/// <param name="name">The new name.</param>
|
||||
/// <param name="notes">The new notes; whitespace collapses to <c>null</c>.</param>
|
||||
/// <param name="newClusterId">The target cluster (may equal the current one).</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> UpdateAreaAsync(string unsAreaId, string name, string? notes, string newClusterId, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a UNS area. A missing row is treated as success (already gone). Uses last-write-wins
|
||||
/// optimistic concurrency; a delete that fails because lines still reference the area surfaces a
|
||||
/// guidance message.
|
||||
/// </summary>
|
||||
/// <param name="unsAreaId">The area 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> DeleteAreaAsync(string unsAreaId, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new UNS line under an area. Fails if a line with the same id already exists.
|
||||
/// Whitespace-only notes are stored as <c>null</c>.
|
||||
/// </summary>
|
||||
/// <param name="unsAreaId">The owning area.</param>
|
||||
/// <param name="unsLineId">The unique line id to create.</param>
|
||||
/// <param name="name">The line name.</param>
|
||||
/// <param name="notes">Optional notes; whitespace collapses to <c>null</c>.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>Success, or a duplicate-id failure.</returns>
|
||||
Task<UnsMutationResult> CreateLineAsync(string unsAreaId, string unsLineId, string name, string? notes, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates a UNS line's owning area, name, and notes. Uses last-write-wins optimistic
|
||||
/// concurrency on <see cref="Configuration.Entities.UnsLine.RowVersion"/>.
|
||||
/// </summary>
|
||||
/// <param name="unsLineId">The line to update.</param>
|
||||
/// <param name="name">The new name.</param>
|
||||
/// <param name="notes">The new notes; whitespace collapses to <c>null</c>.</param>
|
||||
/// <param name="newUnsAreaId">The target parent area.</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, or a concurrency failure.</returns>
|
||||
Task<UnsMutationResult> UpdateLineAsync(string unsLineId, string name, string? notes, string newUnsAreaId, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a UNS line. A missing row is treated as success (already gone). Uses last-write-wins
|
||||
/// optimistic concurrency; a delete that fails because equipment still references the line
|
||||
/// surfaces a guidance message.
|
||||
/// </summary>
|
||||
/// <param name="unsLineId">The line 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> DeleteLineAsync(string unsLineId, byte[] rowVersion, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of a UNS structural mutation (create/update/delete of an area or line).
|
||||
/// On success <see cref="Ok"/> is <c>true</c> and <see cref="Error"/> is <c>null</c>;
|
||||
/// on a guarded or concurrency failure <see cref="Ok"/> is <c>false</c> and
|
||||
/// <see cref="Error"/> carries the operator-facing message the caller should surface.
|
||||
/// </summary>
|
||||
/// <param name="Ok">Whether the mutation was applied.</param>
|
||||
/// <param name="Error">The operator-facing failure message, or <c>null</c> on success.</param>
|
||||
public readonly record struct UnsMutationResult(bool Ok, string? Error);
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user