using System.Security.Cryptography; using System.Text; using Microsoft.EntityFrameworkCore; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; namespace ZB.MOM.WW.OtOpcUa.Admin.Services; public sealed class UnsService(OtOpcUaConfigDbContext db) { public Task> ListAreasAsync(long generationId, CancellationToken ct) => db.UnsAreas.AsNoTracking() .Where(a => a.GenerationId == generationId) .OrderBy(a => a.Name) .ToListAsync(ct); public Task> ListLinesAsync(long generationId, CancellationToken ct) => db.UnsLines.AsNoTracking() .Where(l => l.GenerationId == generationId) .OrderBy(l => l.Name) .ToListAsync(ct); public async Task AddAreaAsync(long draftId, string clusterId, string name, string? notes, CancellationToken ct) { var area = new UnsArea { GenerationId = draftId, UnsAreaId = $"area-{Guid.NewGuid():N}"[..20], ClusterId = clusterId, Name = name, Notes = notes, }; db.UnsAreas.Add(area); await db.SaveChangesAsync(ct); return area; } public async Task AddLineAsync(long draftId, string unsAreaId, string name, string? notes, CancellationToken ct) { var line = new UnsLine { GenerationId = draftId, UnsLineId = $"line-{Guid.NewGuid():N}"[..20], UnsAreaId = unsAreaId, Name = name, Notes = notes, }; db.UnsLines.Add(line); 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);