181 lines
7.5 KiB
C#
181 lines
7.5 KiB
C#
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<List<UnsArea>> ListAreasAsync(long generationId, CancellationToken ct) =>
|
|
db.UnsAreas.AsNoTracking()
|
|
.Where(a => a.GenerationId == generationId)
|
|
.OrderBy(a => a.Name)
|
|
.ToListAsync(ct);
|
|
|
|
public Task<List<UnsLine>> ListLinesAsync(long generationId, CancellationToken ct) =>
|
|
db.UnsLines.AsNoTracking()
|
|
.Where(l => l.GenerationId == generationId)
|
|
.OrderBy(l => l.Name)
|
|
.ToListAsync(ct);
|
|
|
|
public async Task<UnsArea> 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<UnsLine> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task<UnsTreeSnapshot> 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,
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// <see cref="DraftRevisionConflictException"/> so the UI can show the 409 concurrent-edit
|
|
/// modal instead of silently overwriting a peer's work.
|
|
/// </summary>
|
|
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<UnsArea> areas, IReadOnlyList<UnsLine> 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]);
|
|
}
|
|
}
|
|
|
|
/// <summary>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.</summary>
|
|
public sealed class DraftRevisionConflictException(string message) : Exception(message);
|