Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs
T
Joseph Doherty dc0d7653b9 feat(v3-batch3): B3-WP4 — {{equip}}/<RefName> reference-relative resolution
Replace the deleted equipment-base-prefix {{equip}} mechanism (impossible now
that equipment is reference-only) with per-reference resolution:
{{equip}}/<RefName> resolves through the owning equipment's UnsTagReference rows
(by effective name) to the backing raw tag's RawPath. Every unresolved <RefName>
is a clear deploy error + authoring rejection + Monaco diagnostic — preserving
"editor accepts <=> publish accepts".

Commons:
- EquipmentScriptPaths: delete DeriveEquipmentBase; SubstituteEquipmentToken now
  takes the equipment's reference map (effectiveName -> RawPath) and substitutes
  {{equip}}/<RefName> inside path literals (unresolved left intact, never throws);
  add ExtractEquipReferenceNames (path-literal scoped) +
  ExtractEquipReferenceNamesFromText (message templates). Slash syntax replaces
  the v2 dot joint.
- New EquipmentReferenceMap: shared, input-shape-agnostic builder (entity + JSON
  sides) over RawPathResolver — the single authority both compose seams + the
  validator use, so resolved RawPaths agree byte-for-byte.

Compose seams (byte-parity kept):
- AddressSpaceComposer.Compose + DeploymentArtifact.ParseComposition both build
  the per-equipment reference map from UnsTagReferences + Tags + raw topology and
  substitute VirtualTag AND ScriptedAlarm-predicate sources before dependency
  extraction (runtime dep refs = resolved RawPaths).

Deploy gate + authoring + editor:
- DraftValidator.ValidateEquipReferenceResolution: EquipReferenceUnresolved error
  for unresolved {{equip}}/<RefName> in VirtualTag/ScriptedAlarm scripts + alarm
  message-template tokens (naming script, equipment, missing ref).
- UnsTreeService.ValidateEquipTokenAsync (Wave-A M1): reference-relative authoring
  rejection, replacing the stale DeriveEquipmentBase(empty)->reject-all logic.
- ScriptAnalysisService: {{equip}}/ completion offers the equipment's reference
  effective names; DiagnoseAsync flags unresolved refs (OTSCRIPT_EQUIPREF) same as
  the deploy gate. Requests carry optional EquipmentId (threaded MonacoEditor ->
  monaco-init.js -> fetch bodies). IScriptTagCatalog.GetEquipmentRelativeLeavesAsync
  repurposed to GetEquipmentReferenceNamesAsync(equipmentId, filter).

Tests: EquipmentScriptPaths substitution/extraction (slash pinned); composer<->
artifact reference-resolution byte-parity; DraftValidator unresolved-ref;
ScriptAnalysis completion+diagnostic; M1 authoring gate. Build clean (0/0); all
five affected suites green.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 06:58:17 -04:00

1375 lines
53 KiB
C#

using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>
/// Default <see cref="IUnsTreeService"/>. Reads the structural rows with a handful of
/// untracked queries, computes per-equipment tag/virtual-tag counts, and hands the flat
/// rows to the pure <see cref="UnsTreeAssembly.Build"/> to nest into the browse tree.
/// </summary>
/// <remarks>
/// Each call creates and disposes its own context via the pooled factory — the same pattern
/// every AdminUI page uses — so the service is safe to register as Scoped and used per
/// Blazor circuit.
/// </remarks>
public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory) : IUnsTreeService
{
/// <inheritdoc />
public async Task<IReadOnlyList<UnsNode>> LoadStructureAsync(CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var clusters = await db.ServerClusters
.AsNoTracking()
.Select(c => new ClusterRow(c.ClusterId, c.Enterprise, c.Site, c.Name))
.ToListAsync(ct);
var areas = await db.UnsAreas
.AsNoTracking()
.Select(a => new AreaRow(a.UnsAreaId, a.ClusterId, a.Name))
.ToListAsync(ct);
var lines = await db.UnsLines
.AsNoTracking()
.Select(l => new LineRow(l.UnsLineId, l.UnsAreaId, l.Name))
.ToListAsync(ct);
var equipmentRows = await db.Equipment
.AsNoTracking()
.Select(e => new
{
e.EquipmentId,
e.UnsLineId,
e.MachineCode,
e.Name,
})
.ToListAsync(ct);
// Per-equipment driver-tag counts: the equipment↔Tag binding was retired in v3
// (Tag is now Raw-only; authoring moved to the Raw tree in Batch 2/3). No binding
// exists to count against, so every equipment reports zero driver tags.
var tagCounts = new Dictionary<string, int>(StringComparer.Ordinal);
// Per-equipment virtual-tag counts (virtual tags with no equipment are excluded).
var vtagCounts = (await db.VirtualTags
.AsNoTracking()
.Where(v => v.EquipmentId != null)
.GroupBy(v => v.EquipmentId)
.Select(g => new { EquipmentId = g.Key!, Count = g.Count() })
.ToListAsync(ct))
.ToDictionary(x => x.EquipmentId, x => x.Count, StringComparer.Ordinal);
var equipment = equipmentRows
.Select(e => new EquipmentRow(
e.EquipmentId,
e.UnsLineId,
e.MachineCode,
e.Name,
tagCounts.GetValueOrDefault(e.EquipmentId),
vtagCounts.GetValueOrDefault(e.EquipmentId)))
.ToList();
return UnsTreeAssembly.Build(clusters, areas, lines, equipment);
}
/// <inheritdoc />
public Task<IReadOnlyList<EquipmentTagRow>> LoadTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default)
{
// The equipment↔Tag binding was retired in v3 (Tag is now Raw-only; authoring moved to
// the Raw tree in Batch 2/3). No equipment-bound tags exist to list.
return Task.FromResult<IReadOnlyList<EquipmentTagRow>>(Array.Empty<EquipmentTagRow>());
}
/// <inheritdoc />
public async Task<IReadOnlyList<EquipmentVirtualTagRow>> LoadVirtualTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
return await db.VirtualTags.AsNoTracking()
.Where(v => v.EquipmentId == equipmentId)
.OrderBy(v => v.Name)
.Select(v => new EquipmentVirtualTagRow(v.VirtualTagId, v.Name, v.DataType, v.ScriptId, v.Enabled))
.ToListAsync(ct);
}
/// <inheritdoc />
public async Task<AreaEditDto?> LoadAreaAsync(string unsAreaId, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
return await db.UnsAreas
.AsNoTracking()
.Where(a => a.UnsAreaId == unsAreaId)
.Select(a => new AreaEditDto(a.UnsAreaId, a.Name, a.Notes, a.ClusterId, a.RowVersion))
.FirstOrDefaultAsync(ct);
}
/// <inheritdoc />
public async Task<LineEditDto?> LoadLineAsync(string unsLineId, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
return await db.UnsLines
.AsNoTracking()
.Where(l => l.UnsLineId == unsLineId)
.Select(l => new LineEditDto(l.UnsLineId, l.UnsAreaId, l.Name, l.Notes, l.RowVersion))
.FirstOrDefaultAsync(ct);
}
/// <inheritdoc />
public async Task<EquipmentEditDto?> LoadEquipmentAsync(string equipmentId, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
return await db.Equipment
.AsNoTracking()
.Where(e => e.EquipmentId == equipmentId)
.Select(e => new EquipmentEditDto(
e.EquipmentId,
e.Name,
e.MachineCode,
e.UnsLineId,
// Equipment↔driver binding retired in v3 — no DriverInstanceId column remains.
null,
e.ZTag,
e.SAPID,
e.Manufacturer,
e.Model,
e.SerialNumber,
e.HardwareRevision,
e.SoftwareRevision,
e.YearOfConstruction,
e.AssetLocation,
e.ManufacturerUri,
e.DeviceManualUri,
e.Enabled,
e.RowVersion))
.FirstOrDefaultAsync(ct);
}
/// <inheritdoc />
public async Task<TagEditDto?> LoadTagAsync(string tagId, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
return await db.Tags
.AsNoTracking()
.Where(t => t.TagId == tagId)
.Select(t => new TagEditDto(
t.TagId,
// Tag is Raw-only in v3 — equipment binding + driver binding retired.
string.Empty,
t.Name,
string.Empty,
t.DataType,
t.AccessLevel,
t.WriteIdempotent,
t.PollGroupId,
t.TagConfig,
t.RowVersion))
.FirstOrDefaultAsync(ct);
}
/// <inheritdoc />
public async Task<VirtualTagEditDto?> LoadVirtualTagAsync(string virtualTagId, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
return await db.VirtualTags
.AsNoTracking()
.Where(v => v.VirtualTagId == virtualTagId)
.Select(v => new VirtualTagEditDto(
v.VirtualTagId,
v.EquipmentId,
v.Name,
v.DataType,
v.ScriptId,
v.ChangeTriggered,
v.TimerIntervalMs,
v.Historize,
v.Enabled,
v.RowVersion))
.FirstOrDefaultAsync(ct);
}
/// <inheritdoc />
public async Task<IReadOnlyList<(string DriverInstanceId, string Display)>> LoadDriversForClusterAsync(
string clusterId,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var drivers = await db.DriverInstances
.AsNoTracking()
.Where(d => d.ClusterId == clusterId)
.OrderBy(d => d.DriverInstanceId)
.Select(d => new { d.DriverInstanceId, d.Name, d.DriverType })
.ToListAsync(ct);
return drivers
.Select(d => (d.DriverInstanceId, Display: $"{d.DriverInstanceId} — {d.Name} ({d.DriverType})"))
.ToList();
}
/// <inheritdoc />
public async Task<EquipmentPickContext> LoadEquipmentPickContextAsync(string? lineId, CancellationToken ct = default)
{
var empty = new EquipmentPickContext(Array.Empty<(string, string)>(), Array.Empty<(string, string)>());
if (string.IsNullOrEmpty(lineId)) return empty;
await using var db = await dbFactory.CreateDbContextAsync(ct);
// line -> area -> clusterId
var clusterId = await (from l in db.UnsLines.AsNoTracking()
join a in db.UnsAreas.AsNoTracking() on l.UnsAreaId equals a.UnsAreaId
where l.UnsLineId == lineId
select a.ClusterId).FirstOrDefaultAsync(ct);
if (string.IsNullOrEmpty(clusterId)) return empty;
// all lines in that cluster (for the line <select>)
var lines = await (from l in db.UnsLines.AsNoTracking()
join a in db.UnsAreas.AsNoTracking() on l.UnsAreaId equals a.UnsAreaId
where a.ClusterId == clusterId
orderby l.Name
select new { l.UnsLineId, l.Name }).ToListAsync(ct);
var lineOptions = lines.Select(x => (x.UnsLineId, x.Name)).ToList();
var drivers = await LoadDriversForClusterAsync(clusterId, ct);
return new EquipmentPickContext(lineOptions, drivers);
}
/// <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;
// The equipment↔driver binding was retired in v3, so a cluster move can no longer
// orphan driver-bound equipment — the old cross-cluster binding guard is gone.
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;
// The equipment↔driver binding was retired in v3, so a line reparent can no longer
// orphan driver-bound equipment — the old cross-cluster binding guard is gone.
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.");
}
}
/// <inheritdoc />
public async Task<UnsMutationResult> CreateEquipmentAsync(EquipmentInput input, CancellationToken ct = default)
{
if (string.IsNullOrEmpty(input.UnsLineId))
{
return new UnsMutationResult(false, "Pick a UNS line.");
}
await using var db = await dbFactory.CreateDbContextAsync(ct);
if (await db.Equipment.AnyAsync(e => e.MachineCode == input.MachineCode, ct))
{
return new UnsMutationResult(false, $"MachineCode '{input.MachineCode}' already exists in this fleet.");
}
var guard = await CheckDriverClusterGuardAsync(db, input, ct);
if (guard is not null)
{
return guard.Value;
}
var uuid = Guid.NewGuid();
var equipmentId = $"EQ-{uuid.ToString("N")[..12]}";
db.Equipment.Add(new Equipment
{
EquipmentId = equipmentId,
EquipmentUuid = uuid,
// Equipment↔driver binding retired in v3 — no DriverInstanceId column remains.
UnsLineId = input.UnsLineId,
Name = input.Name,
MachineCode = input.MachineCode,
ZTag = string.IsNullOrWhiteSpace(input.ZTag) ? null : input.ZTag,
SAPID = string.IsNullOrWhiteSpace(input.SAPID) ? null : input.SAPID,
Manufacturer = input.Manufacturer,
Model = input.Model,
SerialNumber = input.SerialNumber,
HardwareRevision = input.HardwareRevision,
SoftwareRevision = input.SoftwareRevision,
YearOfConstruction = input.YearOfConstruction,
AssetLocation = input.AssetLocation,
ManufacturerUri = input.ManufacturerUri,
DeviceManualUri = input.DeviceManualUri,
Enabled = input.Enabled,
});
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null, equipmentId);
}
/// <inheritdoc />
public async Task<EquipmentImportResult> ImportEquipmentAsync(
IReadOnlyList<EquipmentInput> rows,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
// Known lines and the MachineCodes already in the fleet, read once up front. The seen-set
// tracks MachineCodes inserted earlier in this same batch so two CSV rows that share a
// MachineCode skip the duplicate the same way the original ImportEquipment.razor did.
var lineSet = (await db.UnsLines.AsNoTracking().Select(l => l.UnsLineId).ToListAsync(ct))
.ToHashSet(StringComparer.Ordinal);
var existing = (await db.Equipment.AsNoTracking().Select(e => e.MachineCode).ToListAsync(ct))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var inserted = 0;
var skipped = 0;
var errors = new List<string>();
foreach (var row in rows)
{
// Existing MachineCode (in DB or earlier in this batch) → skip, never an error.
if (existing.Contains(row.MachineCode))
{
skipped++;
continue;
}
if (!lineSet.Contains(row.UnsLineId))
{
errors.Add($"Row '{row.MachineCode}': UNS line '{row.UnsLineId}' not found.");
continue;
}
// Reuse the driver/line cluster guard: it reports an unknown DriverInstanceId and a
// driver/line cluster mismatch, and is a no-op for driver-less rows.
var guard = await CheckDriverClusterGuardAsync(db, row, ct);
if (guard is not null)
{
errors.Add($"Row '{row.MachineCode}': {guard.Value.Error}");
continue;
}
var uuid = Guid.NewGuid();
var equipmentId = $"EQ-{uuid.ToString("N")[..12]}";
db.Equipment.Add(new Equipment
{
EquipmentId = equipmentId,
EquipmentUuid = uuid,
// Equipment↔driver binding retired in v3 — no DriverInstanceId column remains.
UnsLineId = row.UnsLineId,
Name = row.Name,
MachineCode = row.MachineCode,
ZTag = string.IsNullOrWhiteSpace(row.ZTag) ? null : row.ZTag,
SAPID = string.IsNullOrWhiteSpace(row.SAPID) ? null : row.SAPID,
Manufacturer = row.Manufacturer,
Model = row.Model,
SerialNumber = row.SerialNumber,
HardwareRevision = row.HardwareRevision,
SoftwareRevision = row.SoftwareRevision,
YearOfConstruction = row.YearOfConstruction,
AssetLocation = row.AssetLocation,
ManufacturerUri = row.ManufacturerUri,
DeviceManualUri = row.DeviceManualUri,
Enabled = row.Enabled,
});
existing.Add(row.MachineCode);
inserted++;
}
if (inserted > 0)
await db.SaveChangesAsync(ct);
return new EquipmentImportResult(inserted, skipped, errors);
}
/// <inheritdoc />
public async Task<UnsMutationResult> UpdateEquipmentAsync(
string equipmentId,
EquipmentInput input,
byte[] rowVersion,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var entity = await db.Equipment.FirstOrDefaultAsync(e => e.EquipmentId == equipmentId, ct);
if (entity is null)
{
return new UnsMutationResult(false, "Row no longer exists.");
}
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
if (await db.Equipment.AnyAsync(e => e.MachineCode == input.MachineCode && e.EquipmentId != equipmentId, ct))
{
return new UnsMutationResult(false, $"MachineCode '{input.MachineCode}' already exists in this fleet.");
}
var guard = await CheckDriverClusterGuardAsync(db, input, ct);
if (guard is not null)
{
return guard.Value;
}
// Equipment↔driver binding retired in v3 — no DriverInstanceId column remains.
entity.UnsLineId = input.UnsLineId;
entity.Name = input.Name;
entity.MachineCode = input.MachineCode;
entity.ZTag = string.IsNullOrWhiteSpace(input.ZTag) ? null : input.ZTag;
entity.SAPID = string.IsNullOrWhiteSpace(input.SAPID) ? null : input.SAPID;
entity.Manufacturer = input.Manufacturer;
entity.Model = input.Model;
entity.SerialNumber = input.SerialNumber;
entity.HardwareRevision = input.HardwareRevision;
entity.SoftwareRevision = input.SoftwareRevision;
entity.YearOfConstruction = input.YearOfConstruction;
entity.AssetLocation = input.AssetLocation;
entity.ManufacturerUri = input.ManufacturerUri;
entity.DeviceManualUri = input.DeviceManualUri;
entity.Enabled = input.Enabled;
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
catch (DbUpdateConcurrencyException)
{
return new UnsMutationResult(false, "Another user changed this equipment while you were editing.");
}
}
/// <inheritdoc />
public async Task<UnsMutationResult> DeleteEquipmentAsync(
string equipmentId,
byte[] rowVersion,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var entity = await db.Equipment.FirstOrDefaultAsync(e => e.EquipmentId == equipmentId, ct);
if (entity is null)
{
return new UnsMutationResult(true, null);
}
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
db.Equipment.Remove(entity);
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
catch (DbUpdateConcurrencyException)
{
return new UnsMutationResult(false, "Another user changed this equipment while you were viewing it.");
}
catch (Exception ex)
{
return new UnsMutationResult(false, $"Delete failed: {ex.Message}. Likely because tags or virtual tags reference this equipment — remove them first.");
}
}
/// <inheritdoc />
public async Task<UnsMutationResult> DeleteClusterAsync(string clusterId, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var entity = await db.ServerClusters.FirstOrDefaultAsync(c => c.ClusterId == clusterId, ct);
if (entity is null)
{
// Already gone — idempotent success, matching the Area/Line/Equipment delete path.
return new UnsMutationResult(true, null);
}
// Refuse-if-children. The DB FKs are Restrict, but we pre-check explicitly so the refusal is
// deterministic (the EF InMemory provider does not enforce Restrict) and so we can name the
// blocking child. LdapGroupRoleMapping.ClusterId is Cascade (auto-cleaned) and NodeAcl.ClusterId
// is nullable system-wide-or-cluster grants, so neither blocks a delete.
var blocker = await DescribeClusterChildrenAsync(db, clusterId, ct);
if (blocker is not null)
{
return new UnsMutationResult(false, ClusterBlockedMessage(clusterId, blocker));
}
db.ServerClusters.Remove(entity);
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
catch (Exception ex)
{
// Defensive net for a real SQL Server where a child slipped past the pre-check (race).
return new UnsMutationResult(false, $"Delete failed: {ex.Message}. Likely because nodes, namespaces, areas, or drivers still reference cluster '{clusterId}' — remove them first.");
}
}
/// <inheritdoc />
public async Task<UnsMutationResult> DeleteEnterpriseAsync(string enterpriseName, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var clusters = await db.ServerClusters
.Where(c => c.Enterprise == enterpriseName)
.ToListAsync(ct);
if (clusters.Count == 0)
{
// No clusters carry this label — nothing to delete (idempotent success).
return new UnsMutationResult(true, null);
}
// All-or-nothing: pre-check EVERY cluster before removing any, so a mid-way FK failure can
// never leave half the enterprise deleted. Refuse on the first cluster that still has children.
foreach (var cluster in clusters)
{
var blocker = await DescribeClusterChildrenAsync(db, cluster.ClusterId, ct);
if (blocker is not null)
{
return new UnsMutationResult(
false,
$"Cannot delete enterprise '{enterpriseName}': {ClusterBlockedMessage(cluster.ClusterId, blocker)} Nothing was deleted.");
}
}
db.ServerClusters.RemoveRange(clusters);
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
catch (Exception ex)
{
return new UnsMutationResult(false, $"Delete failed: {ex.Message}. A cluster under enterprise '{enterpriseName}' is still referenced — remove its children first. Nothing was deleted.");
}
}
/// <summary>
/// Returns a short description of the first child kind that blocks deleting the cluster (one of
/// nodes / namespaces / UNS areas / driver instances — the FK-<c>Restrict</c> relationships), or
/// <c>null</c> when the cluster is child-free. Cascade (<c>LdapGroupRoleMapping</c>) and nullable
/// (<c>NodeAcl</c>) references are intentionally not treated as blockers.
/// </summary>
private static async Task<string?> DescribeClusterChildrenAsync(
OtOpcUaConfigDbContext db,
string clusterId,
CancellationToken ct)
{
if (await db.ClusterNodes.AnyAsync(n => n.ClusterId == clusterId, ct))
{
return "cluster nodes";
}
// The Namespace entity was retired in v3 (Raw/UNS split); no namespaces child-check remains.
if (await db.UnsAreas.AnyAsync(a => a.ClusterId == clusterId, ct))
{
return "UNS areas";
}
if (await db.DriverInstances.AnyAsync(d => d.ClusterId == clusterId, ct))
{
return "driver instances";
}
return null;
}
/// <summary>Builds the refuse-if-children guidance message for a single blocked cluster.</summary>
private static string ClusterBlockedMessage(string clusterId, string blocker) =>
$"Delete failed: {blocker} still reference cluster '{clusterId}' — remove them first.";
/// <inheritdoc />
public Task<IReadOnlyList<(string DriverInstanceId, string Display, string DriverType, string DriverConfig)>> LoadTagDriversForEquipmentAsync(
string equipmentId,
CancellationToken ct = default)
{
// The equipment↔driver binding and the Namespace entity were retired in v3 (Raw/UNS split).
// Equipment no longer scopes a candidate-driver list; tag/driver authoring moved to the Raw
// tree in Batch 2/3. No candidate drivers to offer.
return Task.FromResult<IReadOnlyList<(string, string, string, string)>>(
Array.Empty<(string, string, string, string)>());
}
/// <inheritdoc />
public Task<UnsMutationResult> CreateTagAsync(
string equipmentId,
TagInput input,
CancellationToken ct = default)
{
// Equipment-bound tag authoring was retired in v3: Tag is now Raw-only and the equipment↔Tag
// binding no longer exists. Authoring moved to the Raw tree (/raw) in v3 Batch 2.
return Task.FromResult(new UnsMutationResult(
false, "Tag authoring moved to the Raw tree (/raw) in v3 Batch 2."));
}
/// <inheritdoc />
public Task<UnsMutationResult> UpdateTagAsync(
string tagId,
TagInput input,
byte[] rowVersion,
CancellationToken ct = default)
{
// Equipment-bound tag editing was retired in v3: Tag is now Raw-only and the equipment↔Tag
// binding no longer exists. Authoring moved to the Raw tree (/raw) in v3 Batch 2.
return Task.FromResult(new UnsMutationResult(
false, "Tag authoring moved to the Raw tree (/raw) in v3 Batch 2."));
}
/// <inheritdoc />
public async Task<UnsMutationResult> DeleteTagAsync(
string tagId,
byte[] rowVersion,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var entity = await db.Tags.FirstOrDefaultAsync(t => t.TagId == tagId, ct);
if (entity is null)
{
return new UnsMutationResult(true, null);
}
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
db.Tags.Remove(entity);
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
catch (DbUpdateConcurrencyException)
{
return new UnsMutationResult(false, "Another user changed this tag while you were viewing it.");
}
catch (Exception ex)
{
return new UnsMutationResult(false, $"Delete failed: {ex.Message}.");
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<(string ScriptId, string Display)>> LoadScriptsAsync(CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var scripts = await db.Scripts
.AsNoTracking()
.OrderBy(s => s.Name)
.Select(s => new { s.ScriptId, s.Name, s.Language })
.ToListAsync(ct);
return scripts
.Select(s => (s.ScriptId, Display: $"{s.Name} ({s.Language})"))
.ToList();
}
/// <inheritdoc />
public async Task<UnsMutationResult> CreateScriptAsync(string name, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
// System-generated id, mirroring the Script-edit page's "SC-{12 hex}" convention.
var scriptId = $"SC-{Guid.NewGuid().ToString("N")[..12]}";
var resolvedName = string.IsNullOrWhiteSpace(name) ? "New script" : name.Trim();
// Blank source; SourceHash is the SHA-256 of that blank body so it stays consistent with the
// inline save (UpdateScriptSourceAsync recomputes it the same way).
db.Scripts.Add(new Script
{
ScriptId = scriptId,
Name = resolvedName,
Language = "CSharp",
SourceCode = "",
SourceHash = HashSource(""),
});
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null, scriptId);
}
/// <summary>
/// v3 (M1): when the bound script uses <c>{{equip}}/&lt;RefName&gt;</c>, every <c>&lt;RefName&gt;</c> must
/// resolve to one of the owning equipment's <c>UnsTagReference</c> effective names (its
/// <c>DisplayNameOverride</c> else the backing raw tag's <c>Name</c>) — the same set the compose seams
/// substitute against and the deploy gate (<c>DraftValidator.ValidateEquipReferenceResolution</c>)
/// enforces. Returns a readable rejection naming the missing ref(s) when any is unresolved; <c>null</c>
/// otherwise (no slash-token, or all resolve). Replaces the v2 <c>DeriveEquipmentBase(empty)→null</c>
/// check, which rejected ALL <c>{{equip}}</c> VirtualTags with a now-impossible "add a driver tag" message.
/// </summary>
private static async Task<UnsMutationResult?> ValidateEquipTokenAsync(
OtOpcUaConfigDbContext db, string equipmentId, string scriptId, CancellationToken ct)
{
var src = await db.Scripts.Where(s => s.ScriptId == scriptId)
.Select(s => s.SourceCode).FirstOrDefaultAsync(ct);
var used = EquipmentScriptPaths.ExtractEquipReferenceNames(src);
if (used.Count == 0) return null;
// The equipment's reference effective-name set: DisplayNameOverride else the backing raw tag's Name.
var refs = await db.UnsTagReferences.AsNoTracking()
.Where(r => r.EquipmentId == equipmentId)
.Select(r => new { r.TagId, r.DisplayNameOverride })
.ToListAsync(ct);
var tagIds = refs.Select(r => r.TagId).ToList();
var tagNameById = await db.Tags.AsNoTracking()
.Where(t => tagIds.Contains(t.TagId))
.Select(t => new { t.TagId, t.Name })
.ToDictionaryAsync(t => t.TagId, t => t.Name, ct);
var effectiveNames = new HashSet<string>(StringComparer.Ordinal);
foreach (var r in refs)
{
var eff = r.DisplayNameOverride ?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null);
if (!string.IsNullOrEmpty(eff)) effectiveNames.Add(eff!);
}
var missing = used.Where(u => !effectiveNames.Contains(u)).ToList();
if (missing.Count == 0) return null;
return new UnsMutationResult(false,
$"Script uses {string.Join(", ", missing.Select(m => $"'{EquipmentScriptPaths.EquipTokenPrefix}{m}'"))} "
+ $"but equipment '{equipmentId}' has no reference with that effective name. "
+ "Add a matching reference on the Tags tab, or correct the script.");
}
/// <inheritdoc />
public async Task<UnsMutationResult> CreateVirtualTagAsync(
string equipmentId,
VirtualTagInput input,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
if (!await db.Equipment.AnyAsync(e => e.EquipmentId == equipmentId, ct))
{
return new UnsMutationResult(false, $"Equipment '{equipmentId}' not found.");
}
var guard = CheckVirtualTagRules(input);
if (guard is not null)
{
return guard.Value;
}
if (await db.VirtualTags.AnyAsync(v => v.VirtualTagId == input.VirtualTagId, ct))
{
return new UnsMutationResult(false, $"VirtualTag '{input.VirtualTagId}' already exists.");
}
if (await db.VirtualTags.AnyAsync(v => v.EquipmentId == equipmentId && v.Name == input.Name, ct))
{
return new UnsMutationResult(false, $"A virtual tag named '{input.Name}' already exists on this equipment.");
}
var equipGuard = await ValidateEquipTokenAsync(db, equipmentId, input.ScriptId, ct);
if (equipGuard is not null) return equipGuard.Value;
db.VirtualTags.Add(new VirtualTag
{
VirtualTagId = input.VirtualTagId,
EquipmentId = equipmentId,
Name = input.Name,
DataType = input.DataType,
ScriptId = input.ScriptId,
ChangeTriggered = input.ChangeTriggered,
TimerIntervalMs = input.TimerIntervalMs,
Historize = input.Historize,
Enabled = input.Enabled,
});
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
/// <inheritdoc />
public async Task<UnsMutationResult> UpdateVirtualTagAsync(
string virtualTagId,
VirtualTagInput input,
byte[] rowVersion,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var entity = await db.VirtualTags.FirstOrDefaultAsync(v => v.VirtualTagId == virtualTagId, ct);
if (entity is null)
{
return new UnsMutationResult(false, "Row no longer exists.");
}
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
var guard = CheckVirtualTagRules(input);
if (guard is not null)
{
return guard.Value;
}
if (await db.VirtualTags.AnyAsync(
v => v.EquipmentId == entity.EquipmentId && v.Name == input.Name && v.VirtualTagId != virtualTagId,
ct))
{
return new UnsMutationResult(false, $"A virtual tag named '{input.Name}' already exists on this equipment.");
}
var equipGuard = await ValidateEquipTokenAsync(db, entity.EquipmentId, input.ScriptId, ct);
if (equipGuard is not null) return equipGuard.Value;
// EquipmentId is preserved — virtual tags are always equipment-bound.
entity.Name = input.Name;
entity.DataType = input.DataType;
entity.ScriptId = input.ScriptId;
entity.ChangeTriggered = input.ChangeTriggered;
entity.TimerIntervalMs = input.TimerIntervalMs;
entity.Historize = input.Historize;
entity.Enabled = input.Enabled;
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
catch (DbUpdateConcurrencyException)
{
return new UnsMutationResult(false, "Another user changed this virtual tag while you were editing.");
}
}
/// <inheritdoc />
public async Task<UnsMutationResult> DeleteVirtualTagAsync(
string virtualTagId,
byte[] rowVersion,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var entity = await db.VirtualTags.FirstOrDefaultAsync(v => v.VirtualTagId == virtualTagId, ct);
if (entity is null)
{
return new UnsMutationResult(true, null);
}
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
db.VirtualTags.Remove(entity);
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
catch (DbUpdateConcurrencyException)
{
return new UnsMutationResult(false, "Another user changed this virtual tag while you were viewing it.");
}
catch (Exception ex)
{
return new UnsMutationResult(false, $"Delete failed: {ex.Message}.");
}
}
/// <inheritdoc />
public async Task<(string SourceCode, byte[] RowVersion, string Name)?> GetScriptSourceAsync(
string scriptId,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var row = await db.Scripts
.AsNoTracking()
.Where(s => s.ScriptId == scriptId)
.Select(s => new { s.SourceCode, s.RowVersion, s.Name })
.FirstOrDefaultAsync(ct);
return row is null ? null : (row.SourceCode, row.RowVersion, row.Name);
}
/// <inheritdoc />
public async Task<int> CountVirtualTagsUsingScriptAsync(string scriptId, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
return await db.VirtualTags.CountAsync(v => v.ScriptId == scriptId, ct);
}
/// <inheritdoc />
public async Task<(bool Ok, string? Error)> UpdateScriptSourceAsync(
string scriptId,
string sourceCode,
byte[] rowVersion,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var entity = await db.Scripts.FirstOrDefaultAsync(s => s.ScriptId == scriptId, ct);
if (entity is null)
{
return (false, "Script not found.");
}
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
entity.SourceCode = sourceCode;
entity.SourceHash = HashSource(sourceCode);
try
{
await db.SaveChangesAsync(ct);
return (true, null);
}
catch (DbUpdateConcurrencyException)
{
return (false, "This script was changed by someone else. Reload and try again.");
}
}
/// <summary>
/// Computes the SHA-256 of a script body as lower-case hex — the same algorithm the Script-edit
/// page uses, so a body saved from either surface yields an identical compile-cache key.
/// </summary>
private static string HashSource(string source) =>
Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(source)));
/// <summary>
/// Validates a virtual tag's script binding and trigger configuration (mirrors the DbContext CHECK
/// constraints): a script must be chosen, at least one of change-trigger / timer must be set, and a
/// set timer must be at least 50 ms. Returns <c>null</c> when the input is valid.
/// </summary>
private static UnsMutationResult? CheckVirtualTagRules(VirtualTagInput input)
{
if (string.IsNullOrEmpty(input.ScriptId))
{
return new UnsMutationResult(false, "Pick a script.");
}
if (!input.ChangeTriggered && input.TimerIntervalMs is null)
{
return new UnsMutationResult(false, "Pick at least one trigger — change or timer.");
}
if (input.TimerIntervalMs is not null && input.TimerIntervalMs < 50)
{
return new UnsMutationResult(false, "TimerIntervalMs must be at least 50 ms.");
}
return null;
}
/// <summary>Returns <c>true</c> if <paramref name="json"/> parses as a well-formed JSON document.
/// Null/blank input is treated as invalid (not well-formed JSON) so every caller gets the friendly
/// "not valid JSON" result rather than an unhandled <see cref="ArgumentNullException"/> from
/// <c>JsonDocument.Parse(null)</c>.</summary>
private static bool IsValidJson(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return false;
}
try
{
using var _ = System.Text.Json.JsonDocument.Parse(json);
return true;
}
catch (System.Text.Json.JsonException)
{
return false;
}
}
/// <summary>
/// Resolves an equipment to its cluster via <c>Equipment.UnsLineId → UnsLine.UnsAreaId →
/// UnsArea.ClusterId</c>. Returns <c>null</c> when the equipment, its line, or its area cannot be
/// resolved.
/// </summary>
private static async Task<string?> ResolveEquipmentClusterAsync(
OtOpcUaConfigDbContext db,
string? equipmentId,
CancellationToken ct)
{
if (string.IsNullOrEmpty(equipmentId))
{
return null;
}
var equipment = await db.Equipment.FirstOrDefaultAsync(e => e.EquipmentId == equipmentId, ct);
if (equipment is null)
{
return null;
}
var line = await db.UnsLines.FirstOrDefaultAsync(l => l.UnsLineId == equipment.UnsLineId, ct);
if (line is null)
{
return null;
}
var area = await db.UnsAreas.FirstOrDefaultAsync(a => a.UnsAreaId == line.UnsAreaId, ct);
return area?.ClusterId;
}
/// <summary>
/// Validates a tree tag's driver binding: the driver must exist, live in an Equipment-kind
/// namespace, and be in the same cluster as the owning equipment.
/// Returns <c>null</c> when the binding is allowed, or a populated failure otherwise.
/// </summary>
private static async Task<UnsMutationResult?> CheckTagDriverGuardAsync(
OtOpcUaConfigDbContext db,
string driverInstanceId,
string? equipmentCluster,
CancellationToken ct)
{
var driver = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct);
if (driver is null)
{
return new UnsMutationResult(false, $"Driver '{driverInstanceId}' not found.");
}
// The Namespace entity + NamespaceKind were retired in v3 (Raw/UNS split); the
// driver-in-Equipment-namespace check no longer applies.
if (driver.ClusterId != equipmentCluster)
{
return new UnsMutationResult(
false,
$"Driver '{driverInstanceId}' is in cluster '{driver.ClusterId}' but the equipment is in cluster '{equipmentCluster}' (decision #122).");
}
return null;
}
/// <summary>
/// An equipment may only bind to a driver in the same cluster as its line.
/// Policy:
/// <list type="bullet">
/// <item>Driver-less (<c>DriverInstanceId</c> empty) → always allowed (returns <c>null</c>).</item>
/// <item>Driver bound but the line/area does not resolve to a cluster → rejected (unresolvable
/// line cannot guarantee cluster alignment, so the bind is unsafe).</item>
/// <item>Driver bound, line resolves, clusters differ → rejected.</item>
/// <item>Driver bound, line resolves, clusters match → allowed (returns <c>null</c>).</item>
/// </list>
/// </summary>
private static async Task<UnsMutationResult?> CheckDriverClusterGuardAsync(
OtOpcUaConfigDbContext db,
EquipmentInput input,
CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(input.DriverInstanceId))
{
return null;
}
var line = await db.UnsLines.FirstOrDefaultAsync(l => l.UnsLineId == input.UnsLineId, ct);
var area = line is null ? null : await db.UnsAreas.FirstOrDefaultAsync(a => a.UnsAreaId == line.UnsAreaId, ct);
var lineCluster = area?.ClusterId;
if (lineCluster is null)
{
return new UnsMutationResult(
false,
$"Cannot bind driver '{input.DriverInstanceId}': UNS line '{input.UnsLineId}' does not resolve to a cluster (decision #122).");
}
var driverCluster = await db.DriverInstances
.Where(d => d.DriverInstanceId == input.DriverInstanceId)
.Select(d => d.ClusterId)
.FirstOrDefaultAsync(ct);
if (driverCluster is null)
{
return new UnsMutationResult(false, $"Driver '{input.DriverInstanceId}' not found.");
}
if (driverCluster != lineCluster)
{
return new UnsMutationResult(
false,
$"Driver '{input.DriverInstanceId}' is in cluster '{driverCluster}' but the line is in cluster '{lineCluster}' (decision #122).");
}
return null;
}
/// <inheritdoc />
public async Task<IReadOnlyList<EquipmentAlarmRow>> LoadAlarmsForEquipmentAsync(string equipmentId, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
return await db.ScriptedAlarms.AsNoTracking()
.Where(a => a.EquipmentId == equipmentId)
.OrderBy(a => a.Name)
.Select(a => new EquipmentAlarmRow(a.ScriptedAlarmId, a.Name, a.AlarmType, a.Severity, a.PredicateScriptId, a.Enabled))
.ToListAsync(ct);
}
/// <inheritdoc />
public async Task<ScriptedAlarmEditDto?> LoadScriptedAlarmAsync(string scriptedAlarmId, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
return await db.ScriptedAlarms.AsNoTracking()
.Where(a => a.ScriptedAlarmId == scriptedAlarmId)
.Select(a => new ScriptedAlarmEditDto(a.ScriptedAlarmId, a.EquipmentId, a.Name, a.AlarmType, a.Severity,
a.MessageTemplate, a.PredicateScriptId, a.HistorizeToAveva, a.Retain, a.Enabled, a.RowVersion))
.FirstOrDefaultAsync(ct);
}
/// <inheritdoc />
public async Task<UnsMutationResult> CreateScriptedAlarmAsync(string equipmentId, ScriptedAlarmInput input, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
if (await db.ScriptedAlarms.AnyAsync(a => a.ScriptedAlarmId == input.ScriptedAlarmId, ct))
return new UnsMutationResult(false, $"ScriptedAlarm '{input.ScriptedAlarmId}' already exists.");
if (await db.ScriptedAlarms.AnyAsync(a => a.EquipmentId == equipmentId && a.Name == input.Name, ct))
return new UnsMutationResult(false, $"A scripted alarm named '{input.Name}' already exists on this equipment.");
db.ScriptedAlarms.Add(new ScriptedAlarm
{
ScriptedAlarmId = input.ScriptedAlarmId,
EquipmentId = equipmentId,
Name = input.Name,
AlarmType = input.AlarmType,
Severity = input.Severity,
MessageTemplate = input.MessageTemplate,
PredicateScriptId = input.PredicateScriptId,
HistorizeToAveva = input.HistorizeToAveva,
Retain = input.Retain,
Enabled = input.Enabled,
});
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null, input.ScriptedAlarmId);
}
/// <inheritdoc />
public async Task<UnsMutationResult> UpdateScriptedAlarmAsync(string scriptedAlarmId, ScriptedAlarmInput input, byte[] rowVersion, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var entity = await db.ScriptedAlarms.FirstOrDefaultAsync(a => a.ScriptedAlarmId == scriptedAlarmId, ct);
if (entity is null)
{
return new UnsMutationResult(false, "Row no longer exists.");
}
if (await db.ScriptedAlarms.AnyAsync(a => a.EquipmentId == entity.EquipmentId && a.Name == input.Name && a.ScriptedAlarmId != scriptedAlarmId, ct))
return new UnsMutationResult(false, $"A scripted alarm named '{input.Name}' already exists on this equipment.");
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
entity.Name = input.Name;
entity.AlarmType = input.AlarmType;
entity.Severity = input.Severity;
entity.MessageTemplate = input.MessageTemplate;
entity.PredicateScriptId = input.PredicateScriptId;
entity.HistorizeToAveva = input.HistorizeToAveva;
entity.Retain = input.Retain;
entity.Enabled = input.Enabled;
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
catch (DbUpdateConcurrencyException)
{
return new UnsMutationResult(false, "Another user changed this scripted alarm while you were editing.");
}
}
/// <inheritdoc />
public async Task<UnsMutationResult> DeleteScriptedAlarmAsync(string scriptedAlarmId, byte[] rowVersion, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var entity = await db.ScriptedAlarms.FirstOrDefaultAsync(a => a.ScriptedAlarmId == scriptedAlarmId, ct);
if (entity is null)
{
return new UnsMutationResult(true, null);
}
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
db.ScriptedAlarms.Remove(entity);
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
catch (DbUpdateConcurrencyException)
{
return new UnsMutationResult(false, "Another user changed this alarm while you were viewing it.");
}
catch (Exception ex)
{
return new UnsMutationResult(false, $"Delete failed: {ex.Message}.");
}
}
}