diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/UnsTab.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/UnsTab.razor index 6c29a82..d1e0612 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/UnsTab.razor +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters/UnsTab.razor @@ -2,6 +2,13 @@ @using ZB.MOM.WW.OtOpcUa.Configuration.Entities @inject UnsService UnsSvc +
+ Drag any line in the UNS Lines table onto an area row in UNS Areas + to re-parent it. A preview modal shows the impact (equipment re-home count) + lets you confirm + or cancel. If another operator modifies the draft while you're confirming, you'll see a 409 + refresh-required modal instead of clobbering their work. +
+
@@ -14,11 +21,20 @@ else { - + @foreach (var a in _areas) { - + + + + + }
AreaIdName
AreaIdName(drop target)
@a.UnsAreaId@a.Name
@a.UnsAreaId@a.Namedrop here
@@ -35,6 +51,7 @@
}
+

UNS Lines

@@ -50,7 +67,14 @@ @foreach (var l in _lines) { - @l.UnsLineId@l.UnsAreaId@l.Name + + @l.UnsLineId + @l.UnsAreaId + @l.Name + } @@ -75,6 +99,64 @@
+@* Preview / confirm modal for a pending drag-drop move *@ +@if (_pendingPreview is not null) +{ + +} + +@* 409 concurrent-edit modal — another operator changed the draft between preview + commit *@ +@if (_conflictMessage is not null) +{ + +} + @code { [Parameter] public long GenerationId { get; set; } [Parameter] public string ClusterId { get; set; } = string.Empty; @@ -87,6 +169,13 @@ private string _newLineName = string.Empty; private string _newLineAreaId = string.Empty; + private string? _dragLineId; + private string? _hoverAreaId; + private UnsImpactPreview? _pendingPreview; + private UnsMoveOperation? _pendingMove; + private bool _committing; + private string? _conflictMessage; + protected override async Task OnParametersSetAsync() => await ReloadAsync(); private async Task ReloadAsync() @@ -112,4 +201,72 @@ _showLineForm = false; await ReloadAsync(); } + + private void OnAreaDragOver(DragEventArgs _, string areaId) => _hoverAreaId = areaId; + + private async Task OnLineDroppedAsync(string targetAreaId) + { + var lineId = _dragLineId; + _hoverAreaId = null; + _dragLineId = null; + if (string.IsNullOrWhiteSpace(lineId)) return; + + var line = _lines?.FirstOrDefault(l => l.UnsLineId == lineId); + if (line is null || line.UnsAreaId == targetAreaId) return; + + var snapshot = await UnsSvc.LoadSnapshotAsync(GenerationId, CancellationToken.None); + var move = new UnsMoveOperation( + Kind: UnsMoveKind.LineMove, + SourceClusterId: ClusterId, + TargetClusterId: ClusterId, + SourceLineId: lineId, + TargetAreaId: targetAreaId); + try + { + _pendingPreview = UnsImpactAnalyzer.Analyze(snapshot, move); + _pendingMove = move; + } + catch (Exception ex) + { + _conflictMessage = ex.Message; // CrossCluster or validation failure surfaces here + } + } + + private void CancelMove() + { + _pendingPreview = null; + _pendingMove = null; + } + + private async Task ConfirmMoveAsync() + { + if (_pendingPreview is null || _pendingMove is null) return; + _committing = true; + try + { + await UnsSvc.MoveLineAsync( + GenerationId, + _pendingPreview.RevisionToken, + _pendingMove.SourceLineId!, + _pendingMove.TargetAreaId!, + CancellationToken.None); + + _pendingPreview = null; + _pendingMove = null; + await ReloadAsync(); + } + catch (DraftRevisionConflictException ex) + { + _pendingPreview = null; + _pendingMove = null; + _conflictMessage = ex.Message; + } + finally { _committing = false; } + } + + private async Task ReloadAfterConflict() + { + _conflictMessage = null; + await ReloadAsync(); + } } diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsService.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsService.cs index c66ff17..c71a14c 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsService.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsService.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; +using System.Text; using Microsoft.EntityFrameworkCore; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; @@ -47,4 +49,132 @@ public sealed class UnsService(OtOpcUaConfigDbContext db) await db.SaveChangesAsync(ct); return line; } + + /// + /// Build the full UNS tree snapshot for the analyzer. Walks areas + lines in the draft + /// and counts equipment + tags per line. Returns the snapshot plus a deterministic + /// revision token computed by SHA-256'ing the sorted (kind, id, parent, name) tuples — + /// stable across processes + changes whenever any row is added / modified / deleted. + /// + public async Task LoadSnapshotAsync(long generationId, CancellationToken ct) + { + var areas = await db.UnsAreas.AsNoTracking() + .Where(a => a.GenerationId == generationId) + .OrderBy(a => a.UnsAreaId) + .ToListAsync(ct); + + var lines = await db.UnsLines.AsNoTracking() + .Where(l => l.GenerationId == generationId) + .OrderBy(l => l.UnsLineId) + .ToListAsync(ct); + + var equipmentCounts = await db.Equipment.AsNoTracking() + .Where(e => e.GenerationId == generationId) + .GroupBy(e => e.UnsLineId) + .Select(g => new { LineId = g.Key, Count = g.Count() }) + .ToListAsync(ct); + var equipmentByLine = equipmentCounts.ToDictionary(x => x.LineId, x => x.Count, StringComparer.OrdinalIgnoreCase); + + var lineSummaries = lines.Select(l => + new UnsLineSummary( + LineId: l.UnsLineId, + Name: l.Name, + EquipmentCount: equipmentByLine.GetValueOrDefault(l.UnsLineId), + TagCount: 0)).ToList(); + + var areaSummaries = areas.Select(a => + new UnsAreaSummary( + AreaId: a.UnsAreaId, + Name: a.Name, + LineIds: lines.Where(l => string.Equals(l.UnsAreaId, a.UnsAreaId, StringComparison.OrdinalIgnoreCase)) + .Select(l => l.UnsLineId).ToList())).ToList(); + + return new UnsTreeSnapshot + { + DraftGenerationId = generationId, + RevisionToken = ComputeRevisionToken(areas, lines), + Areas = areaSummaries, + Lines = lineSummaries, + }; + } + + /// + /// Atomic re-parent of a line to a new area inside the same draft. The caller must pass + /// the revision token it observed at preview time — a mismatch raises + /// so the UI can show the 409 concurrent-edit + /// modal instead of silently overwriting a peer's work. + /// + public async Task MoveLineAsync( + long generationId, + DraftRevisionToken expected, + string lineId, + string targetAreaId, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(expected); + ArgumentException.ThrowIfNullOrWhiteSpace(lineId); + ArgumentException.ThrowIfNullOrWhiteSpace(targetAreaId); + + var supportsTx = db.Database.IsRelational(); + Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction? tx = null; + if (supportsTx) tx = await db.Database.BeginTransactionAsync(ct).ConfigureAwait(false); + + try + { + var areas = await db.UnsAreas + .Where(a => a.GenerationId == generationId) + .OrderBy(a => a.UnsAreaId) + .ToListAsync(ct); + + var lines = await db.UnsLines + .Where(l => l.GenerationId == generationId) + .OrderBy(l => l.UnsLineId) + .ToListAsync(ct); + + var current = ComputeRevisionToken(areas, lines); + if (!current.Matches(expected)) + throw new DraftRevisionConflictException( + $"Draft {generationId} changed since preview. Expected revision {expected.Value}, saw {current.Value}. " + + "Refresh + redo the move."); + + var line = lines.FirstOrDefault(l => string.Equals(l.UnsLineId, lineId, StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidOperationException($"Line '{lineId}' not found in draft {generationId}."); + + if (!areas.Any(a => string.Equals(a.UnsAreaId, targetAreaId, StringComparison.OrdinalIgnoreCase))) + throw new InvalidOperationException($"Target area '{targetAreaId}' not found in draft {generationId}."); + + if (string.Equals(line.UnsAreaId, targetAreaId, StringComparison.OrdinalIgnoreCase)) + return; // no-op drop — same area + + line.UnsAreaId = targetAreaId; + await db.SaveChangesAsync(ct); + if (tx is not null) await tx.CommitAsync(ct).ConfigureAwait(false); + } + catch + { + if (tx is not null) await tx.RollbackAsync(ct).ConfigureAwait(false); + throw; + } + finally + { + if (tx is not null) await tx.DisposeAsync().ConfigureAwait(false); + } + } + + private static DraftRevisionToken ComputeRevisionToken(IReadOnlyList areas, IReadOnlyList lines) + { + var sb = new StringBuilder(capacity: 256 + (areas.Count + lines.Count) * 80); + foreach (var a in areas.OrderBy(a => a.UnsAreaId, StringComparer.Ordinal)) + sb.Append("A:").Append(a.UnsAreaId).Append('|').Append(a.Name).Append('|').Append(a.Notes ?? "").Append(';'); + foreach (var l in lines.OrderBy(l => l.UnsLineId, StringComparer.Ordinal)) + sb.Append("L:").Append(l.UnsLineId).Append('|').Append(l.UnsAreaId).Append('|').Append(l.Name).Append('|').Append(l.Notes ?? "").Append(';'); + + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString())); + return new DraftRevisionToken(Convert.ToHexStringLower(hash)[..16]); + } } + +/// Thrown when a UNS move's expected revision token no longer matches the live draft +/// — another operator mutated the draft between preview + commit. Caller surfaces a 409-style +/// "refresh required" modal in the Admin UI. +public sealed class DraftRevisionConflictException(string message) : Exception(message); diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/UnsServiceMoveTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/UnsServiceMoveTests.cs new file mode 100644 index 0000000..45415f5 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/UnsServiceMoveTests.cs @@ -0,0 +1,130 @@ +using Microsoft.EntityFrameworkCore; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Admin.Services; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +namespace ZB.MOM.WW.OtOpcUa.Admin.Tests; + +[Trait("Category", "Unit")] +public sealed class UnsServiceMoveTests +{ + [Fact] + public async Task LoadSnapshotAsync_ReturnsAllAreasAndLines_WithEquipmentCounts() + { + using var ctx = NewContext(); + Seed(ctx, draftId: 1, areas: new[] { "area-1", "area-2" }, + lines: new[] { ("line-a", "area-1"), ("line-b", "area-1"), ("line-c", "area-2") }, + equipmentLines: new[] { "line-a", "line-a", "line-b" }); + var svc = new UnsService(ctx); + + var snap = await svc.LoadSnapshotAsync(1, CancellationToken.None); + + snap.Areas.Count.ShouldBe(2); + snap.Lines.Count.ShouldBe(3); + snap.FindLine("line-a")!.EquipmentCount.ShouldBe(2); + snap.FindLine("line-b")!.EquipmentCount.ShouldBe(1); + snap.FindLine("line-c")!.EquipmentCount.ShouldBe(0); + } + + [Fact] + public async Task LoadSnapshotAsync_RevisionToken_IsStable_BetweenTwoReads() + { + using var ctx = NewContext(); + Seed(ctx, draftId: 1, areas: new[] { "area-1" }, lines: new[] { ("line-a", "area-1") }); + var svc = new UnsService(ctx); + + var first = await svc.LoadSnapshotAsync(1, CancellationToken.None); + var second = await svc.LoadSnapshotAsync(1, CancellationToken.None); + + second.RevisionToken.Matches(first.RevisionToken).ShouldBeTrue(); + } + + [Fact] + public async Task LoadSnapshotAsync_RevisionToken_Changes_When_LineAdded() + { + using var ctx = NewContext(); + Seed(ctx, draftId: 1, areas: new[] { "area-1" }, lines: new[] { ("line-a", "area-1") }); + var svc = new UnsService(ctx); + + var before = await svc.LoadSnapshotAsync(1, CancellationToken.None); + await svc.AddLineAsync(1, "area-1", "new-line", null, CancellationToken.None); + var after = await svc.LoadSnapshotAsync(1, CancellationToken.None); + + after.RevisionToken.Matches(before.RevisionToken).ShouldBeFalse(); + } + + [Fact] + public async Task MoveLineAsync_WithMatchingToken_Reparents_Line() + { + using var ctx = NewContext(); + Seed(ctx, draftId: 1, areas: new[] { "area-1", "area-2" }, + lines: new[] { ("line-a", "area-1") }); + var svc = new UnsService(ctx); + + var snap = await svc.LoadSnapshotAsync(1, CancellationToken.None); + await svc.MoveLineAsync(1, snap.RevisionToken, "line-a", "area-2", CancellationToken.None); + + var moved = await ctx.UnsLines.AsNoTracking().FirstAsync(l => l.UnsLineId == "line-a"); + moved.UnsAreaId.ShouldBe("area-2"); + } + + [Fact] + public async Task MoveLineAsync_WithStaleToken_Throws_DraftRevisionConflict() + { + using var ctx = NewContext(); + Seed(ctx, draftId: 1, areas: new[] { "area-1", "area-2" }, + lines: new[] { ("line-a", "area-1") }); + var svc = new UnsService(ctx); + + // Simulate a peer operator's concurrent edit between our preview + commit. + var stale = new DraftRevisionToken("0000000000000000"); + + await Should.ThrowAsync(() => + svc.MoveLineAsync(1, stale, "line-a", "area-2", CancellationToken.None)); + + var row = await ctx.UnsLines.AsNoTracking().FirstAsync(l => l.UnsLineId == "line-a"); + row.UnsAreaId.ShouldBe("area-1"); + } + + private static void Seed(OtOpcUaConfigDbContext ctx, long draftId, + IEnumerable areas, + IEnumerable<(string line, string area)> lines, + IEnumerable? equipmentLines = null) + { + foreach (var a in areas) + { + ctx.UnsAreas.Add(new UnsArea + { + GenerationId = draftId, UnsAreaId = a, ClusterId = "c1", Name = a, + }); + } + foreach (var (line, area) in lines) + { + ctx.UnsLines.Add(new UnsLine + { + GenerationId = draftId, UnsLineId = line, UnsAreaId = area, Name = line, + }); + } + foreach (var lineId in equipmentLines ?? []) + { + ctx.Equipment.Add(new Equipment + { + EquipmentRowId = Guid.NewGuid(), GenerationId = draftId, + EquipmentId = $"EQ-{Guid.NewGuid():N}"[..15], + EquipmentUuid = Guid.NewGuid(), DriverInstanceId = "drv", + UnsLineId = lineId, Name = "x", MachineCode = "m", + }); + } + ctx.SaveChanges(); + } + + private static OtOpcUaConfigDbContext NewContext() + { + var opts = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new OtOpcUaConfigDbContext(opts); + } +}