1456 lines
55 KiB
C#
1456 lines
55 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 (tags with no equipment are excluded).
|
|
var tagCounts = (await db.Tags
|
|
.AsNoTracking()
|
|
.Where(t => t.EquipmentId != null)
|
|
.GroupBy(t => t.EquipmentId)
|
|
.Select(g => new { EquipmentId = g.Key!, Count = g.Count() })
|
|
.ToListAsync(ct))
|
|
.ToDictionary(x => x.EquipmentId, x => x.Count, 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 async Task<IReadOnlyList<EquipmentTagRow>> LoadTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default)
|
|
{
|
|
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
|
|
|
// Left-join each tag to its driver so we can tell Galaxy aliases apart while still surfacing a
|
|
// tag whose driver row is missing (it is simply treated as a non-alias). EF can't parse the
|
|
// TagConfig JSON in-query, so we materialise then map IsAlias/Source in memory.
|
|
var rows = await db.Tags.AsNoTracking()
|
|
.Where(t => t.EquipmentId == equipmentId)
|
|
.OrderBy(t => t.Name)
|
|
.GroupJoin(db.DriverInstances.AsNoTracking(), t => t.DriverInstanceId, d => d.DriverInstanceId,
|
|
(t, ds) => new { Tag = t, Drivers = ds })
|
|
.SelectMany(x => x.Drivers.DefaultIfEmpty(),
|
|
(x, d) => new
|
|
{
|
|
x.Tag.TagId,
|
|
x.Tag.Name,
|
|
x.Tag.DriverInstanceId,
|
|
x.Tag.DataType,
|
|
x.Tag.AccessLevel,
|
|
DriverType = d != null ? d.DriverType : null,
|
|
x.Tag.TagConfig,
|
|
})
|
|
.ToListAsync(ct);
|
|
|
|
return rows.Select(r =>
|
|
{
|
|
var isAlias = r.DriverType == "GalaxyMxGateway";
|
|
var fullName = isAlias ? ExtractTagConfigFullName(r.TagConfig) : null;
|
|
var source = fullName is not null ? $"galaxy:{fullName}" : null;
|
|
return new EquipmentTagRow(r.TagId, r.Name, r.DriverInstanceId, r.DataType, r.AccessLevel, isAlias, source);
|
|
}).ToList();
|
|
}
|
|
|
|
/// <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,
|
|
e.DriverInstanceId,
|
|
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,
|
|
t.EquipmentId!,
|
|
t.Name,
|
|
t.DriverInstanceId,
|
|
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;
|
|
|
|
// 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;
|
|
|
|
// Decision #122: a reparent to a different area must not orphan driver-bound equipment
|
|
// from its driver's cluster. Resolve the new area's cluster and check every bound
|
|
// equipment item under this line against it.
|
|
if (newUnsAreaId != entity.UnsAreaId)
|
|
{
|
|
var newAreaCluster = await db.UnsAreas
|
|
.Where(a => a.UnsAreaId == newUnsAreaId)
|
|
.Select(a => (string?)a.ClusterId)
|
|
.FirstOrDefaultAsync(ct);
|
|
|
|
if (newAreaCluster is not null)
|
|
{
|
|
var boundEquipment = await db.Equipment
|
|
.Where(eq => eq.UnsLineId == 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 != newAreaCluster)
|
|
{
|
|
return new UnsMutationResult(
|
|
false,
|
|
$"Cannot move line to area '{newUnsAreaId}': equipment '{eq.EquipmentId}' is bound to a driver in cluster '{driverCluster}' (decision #122). Re-home or unbind it first.");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
DriverInstanceId = string.IsNullOrWhiteSpace(input.DriverInstanceId) ? null : input.DriverInstanceId,
|
|
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 #122 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,
|
|
DriverInstanceId = string.IsNullOrWhiteSpace(row.DriverInstanceId) ? null : row.DriverInstanceId,
|
|
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;
|
|
}
|
|
|
|
entity.DriverInstanceId = string.IsNullOrWhiteSpace(input.DriverInstanceId) ? null : input.DriverInstanceId;
|
|
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<IReadOnlyList<(string DriverInstanceId, string Display, string DriverType)>> LoadTagDriversForEquipmentAsync(
|
|
string equipmentId,
|
|
CancellationToken ct = default)
|
|
{
|
|
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
|
|
|
var equipmentCluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
|
|
if (equipmentCluster is null)
|
|
{
|
|
return Array.Empty<(string, string, string)>();
|
|
}
|
|
|
|
// Drivers in the equipment's cluster whose namespace is Equipment-kind (decision #110).
|
|
var equipmentNamespaceIds = await db.Namespaces
|
|
.Where(n => n.ClusterId == equipmentCluster && n.Kind == NamespaceKind.Equipment)
|
|
.Select(n => n.NamespaceId)
|
|
.ToListAsync(ct);
|
|
|
|
var drivers = await db.DriverInstances
|
|
.Where(d => d.ClusterId == equipmentCluster && equipmentNamespaceIds.Contains(d.NamespaceId))
|
|
.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<IReadOnlyList<(string DriverInstanceId, string Display, string DriverConfig)>>
|
|
LoadGalaxyGatewaysForEquipmentAsync(string equipmentId, CancellationToken ct = default)
|
|
{
|
|
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
|
|
|
var cluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
|
|
if (cluster is null)
|
|
{
|
|
return Array.Empty<(string, string, string)>();
|
|
}
|
|
|
|
var gateways = await db.DriverInstances
|
|
.Where(d => d.ClusterId == cluster && d.DriverType == "GalaxyMxGateway")
|
|
.OrderBy(d => d.DriverInstanceId)
|
|
.Select(d => new { d.DriverInstanceId, d.Name, d.DriverConfig })
|
|
.ToListAsync(ct);
|
|
|
|
return gateways
|
|
.Select(d => (d.DriverInstanceId, Display: $"{d.DriverInstanceId} — {d.Name}", d.DriverConfig))
|
|
.ToList();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<UnsMutationResult> CreateTagAsync(
|
|
string equipmentId,
|
|
TagInput input,
|
|
CancellationToken ct = default)
|
|
{
|
|
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
|
|
|
if (await db.Tags.AnyAsync(t => t.TagId == input.TagId, ct))
|
|
{
|
|
return new UnsMutationResult(false, $"Tag '{input.TagId}' already exists.");
|
|
}
|
|
|
|
if (!IsValidJson(input.TagConfig))
|
|
{
|
|
return new UnsMutationResult(false, "TagConfig is not valid JSON.");
|
|
}
|
|
|
|
if (!await db.Equipment.AnyAsync(e => e.EquipmentId == equipmentId, ct))
|
|
return new UnsMutationResult(false, $"Equipment '{equipmentId}' not found.");
|
|
|
|
var equipmentCluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
|
|
var guard = await CheckTagDriverGuardAsync(db, input.DriverInstanceId, equipmentCluster, ct);
|
|
if (guard is not null)
|
|
{
|
|
return guard.Value;
|
|
}
|
|
|
|
if (await db.Tags.AnyAsync(t => t.EquipmentId == equipmentId && t.Name == input.Name, ct))
|
|
{
|
|
return new UnsMutationResult(false, $"A tag named '{input.Name}' already exists on this equipment.");
|
|
}
|
|
|
|
db.Tags.Add(new Tag
|
|
{
|
|
TagId = input.TagId,
|
|
DriverInstanceId = input.DriverInstanceId,
|
|
EquipmentId = equipmentId,
|
|
Name = input.Name,
|
|
FolderPath = null,
|
|
DataType = input.DataType,
|
|
AccessLevel = input.AccessLevel,
|
|
WriteIdempotent = input.WriteIdempotent,
|
|
PollGroupId = string.IsNullOrWhiteSpace(input.PollGroupId) ? null : input.PollGroupId,
|
|
TagConfig = input.TagConfig,
|
|
});
|
|
await db.SaveChangesAsync(ct);
|
|
return new UnsMutationResult(true, null);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<UnsMutationResult> UpdateTagAsync(
|
|
string tagId,
|
|
TagInput input,
|
|
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(false, "Row no longer exists.");
|
|
}
|
|
|
|
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
|
|
|
if (!IsValidJson(input.TagConfig))
|
|
{
|
|
return new UnsMutationResult(false, "TagConfig is not valid JSON.");
|
|
}
|
|
|
|
var equipmentCluster = await ResolveEquipmentClusterAsync(db, entity.EquipmentId, ct);
|
|
var guard = await CheckTagDriverGuardAsync(db, input.DriverInstanceId, equipmentCluster, ct);
|
|
if (guard is not null)
|
|
{
|
|
return guard.Value;
|
|
}
|
|
|
|
if (await db.Tags.AnyAsync(
|
|
t => t.EquipmentId == entity.EquipmentId && t.Name == input.Name && t.TagId != tagId,
|
|
ct))
|
|
{
|
|
return new UnsMutationResult(false, $"A tag named '{input.Name}' already exists on this equipment.");
|
|
}
|
|
|
|
// EquipmentId and FolderPath (null) are preserved — tree tags are always equipment-bound.
|
|
entity.DriverInstanceId = input.DriverInstanceId;
|
|
entity.Name = input.Name;
|
|
entity.DataType = input.DataType;
|
|
entity.AccessLevel = input.AccessLevel;
|
|
entity.WriteIdempotent = input.WriteIdempotent;
|
|
entity.PollGroupId = string.IsNullOrWhiteSpace(input.PollGroupId) ? null : input.PollGroupId;
|
|
entity.TagConfig = input.TagConfig;
|
|
|
|
try
|
|
{
|
|
await db.SaveChangesAsync(ct);
|
|
return new UnsMutationResult(true, null);
|
|
}
|
|
catch (DbUpdateConcurrencyException)
|
|
{
|
|
return new UnsMutationResult(false, "Another user changed this tag while you were editing.");
|
|
}
|
|
}
|
|
|
|
/// <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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// When the bound script uses the reserved <c>{{equip}}</c> token, the owning equipment must have
|
|
/// a derivable tag base (≥1 driver tag, all sharing one object prefix). Returns a rejection result
|
|
/// when the token is present but no base can be derived; <c>null</c> otherwise (token absent, or a
|
|
/// base exists). The script source is fetched from the supplied (in-scope) context.
|
|
/// </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);
|
|
if (!EquipmentScriptPaths.ContainsEquipToken(src)) return null;
|
|
|
|
var configs = await db.Tags.Where(t => t.EquipmentId == equipmentId)
|
|
.Select(t => t.TagConfig).ToListAsync(ct);
|
|
var fullNames = configs.Select(TagConfigFullName.Extract);
|
|
if (EquipmentScriptPaths.DeriveEquipmentBase(fullNames) is null)
|
|
{
|
|
return new UnsMutationResult(false,
|
|
$"Equipment '{equipmentId}' has no single tag base, so the "
|
|
+ $"{EquipmentScriptPaths.EquipToken} token can't be resolved. Add at least one driver tag under this "
|
|
+ $"equipment (all sharing one object prefix), or remove {EquipmentScriptPaths.EquipToken} from the script.");
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// <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 (plan decision #2).
|
|
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.</summary>
|
|
private static bool IsValidJson(string json)
|
|
{
|
|
try
|
|
{
|
|
using var _ = System.Text.Json.JsonDocument.Parse(json);
|
|
return true;
|
|
}
|
|
catch (System.Text.Json.JsonException)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracts the <c>FullName</c> string from a tag's <c>TagConfig</c> JSON (the Galaxy reference an
|
|
/// alias surfaces), or <c>null</c> when the config is empty, not a JSON object, lacks a string
|
|
/// <c>FullName</c>, or is malformed. A small local copy mirrors the composer's own extraction —
|
|
/// consistent with this codebase, where the composer and validator each keep their own.
|
|
/// </summary>
|
|
private static string? ExtractTagConfigFullName(string? tagConfig)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(tagConfig))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
try
|
|
{
|
|
using var doc = System.Text.Json.JsonDocument.Parse(tagConfig);
|
|
return doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object
|
|
&& doc.RootElement.TryGetProperty("FullName", out var fn)
|
|
&& fn.ValueKind == System.Text.Json.JsonValueKind.String ? fn.GetString() : null;
|
|
}
|
|
catch (System.Text.Json.JsonException)
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <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 (decision #110), and be in the same cluster as the owning equipment (decision #122).
|
|
/// 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.");
|
|
}
|
|
|
|
var ns = await db.Namespaces.FirstOrDefaultAsync(n => n.NamespaceId == driver.NamespaceId, ct);
|
|
if (ns?.Kind != NamespaceKind.Equipment)
|
|
{
|
|
return new UnsMutationResult(false, $"Driver '{driverInstanceId}' is not in an Equipment-kind namespace.");
|
|
}
|
|
|
|
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>
|
|
/// Decision #122: 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}.");
|
|
}
|
|
}
|
|
}
|