feat(uns): equipment-bound virtual-tag CRUD

This commit is contained in:
Joseph Doherty
2026-06-08 13:11:12 -04:00
parent 77024f87da
commit d8fba02a5e
4 changed files with 504 additions and 0 deletions
@@ -185,4 +185,48 @@ public interface IUnsTreeService
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a concurrency failure, or a delete-failed failure.</returns>
Task<UnsMutationResult> DeleteTagAsync(string tagId, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Loads the scripts eligible to back a virtual tag, ordered by name. Each is projected to a
/// <c>(ScriptId, Display)</c> pair where <c>Display</c> is <c>"{Name} ({Language})"</c>.
/// </summary>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The scripts projected to <c>(ScriptId, Display)</c> pairs.</returns>
Task<IReadOnlyList<(string ScriptId, string Display)>> LoadScriptsAsync(CancellationToken ct = default);
/// <summary>
/// Creates a new equipment-bound virtual tag (plan decision #2 — virtual tags are always scoped
/// to an equipment). Fails if the equipment does not exist, if no script is chosen, if neither a
/// change trigger nor a timer is set, if the timer is below the 50 ms minimum, on a duplicate
/// <c>VirtualTagId</c>, or on a name already used on the equipment.
/// </summary>
/// <param name="equipmentId">The owning equipment.</param>
/// <param name="input">The operator-editable virtual-tag fields.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, or one of the guard failures.</returns>
Task<UnsMutationResult> CreateVirtualTagAsync(string equipmentId, VirtualTagInput input, CancellationToken ct = default);
/// <summary>
/// Updates an equipment-bound virtual tag's name, data type, script binding, triggers, historize,
/// and enabled flags. The owning <c>EquipmentId</c> is preserved. Re-runs the script-chosen,
/// change-or-timer, and 50 ms timer-minimum guards, and enforces name uniqueness on the tag's
/// existing equipment excluding this virtual tag. Uses last-write-wins optimistic concurrency on
/// <see cref="Configuration.Entities.VirtualTag.RowVersion"/>.
/// </summary>
/// <param name="virtualTagId">The virtual tag to update.</param>
/// <param name="input">The new operator-editable virtual-tag fields.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a missing-row failure, a guard failure, or a concurrency failure.</returns>
Task<UnsMutationResult> UpdateVirtualTagAsync(string virtualTagId, VirtualTagInput input, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Deletes a virtual tag. A missing row is treated as success (already gone). Uses last-write-wins
/// optimistic concurrency on <see cref="Configuration.Entities.VirtualTag.RowVersion"/>.
/// </summary>
/// <param name="virtualTagId">The virtual tag to delete.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a concurrency failure, or a delete-failed failure.</returns>
Task<UnsMutationResult> DeleteVirtualTagAsync(string virtualTagId, byte[] rowVersion, CancellationToken ct = default);
}
@@ -643,6 +643,174 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
}
}
/// <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> 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.");
}
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.");
}
// 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}.");
}
}
/// <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)
{
@@ -0,0 +1,26 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>
/// Parameter object carrying the operator-editable fields for an equipment-bound VirtualTag create
/// or update via the UNS tree. Virtual tags are always scoped to an equipment (plan decision #2), so
/// the owning <c>EquipmentId</c> is supplied separately and never changes on update. A virtual tag
/// must have at least one evaluation trigger: <see cref="ChangeTriggered"/> or a non-null
/// <see cref="TimerIntervalMs"/> (which, when set, must be at least 50 ms).
/// </summary>
/// <param name="VirtualTagId">Stable unique virtual-tag id; only honoured on create (immutable thereafter).</param>
/// <param name="Name">Virtual-tag display name; unique within the owning equipment.</param>
/// <param name="DataType">OPC UA built-in type name (Boolean / Int32 / Float / etc.).</param>
/// <param name="ScriptId">The script that computes this tag's value; must be chosen.</param>
/// <param name="ChangeTriggered">Re-evaluate when any referenced input tag changes.</param>
/// <param name="TimerIntervalMs">Optional periodic re-evaluation cadence in ms; <c>null</c> = no timer, otherwise &gt;= 50.</param>
/// <param name="Historize">Whether this tag's values should be historized.</param>
/// <param name="Enabled">Whether this virtual tag is spawned in deployments.</param>
public sealed record VirtualTagInput(
string VirtualTagId,
string Name,
string DataType,
string ScriptId,
bool ChangeTriggered,
int? TimerIntervalMs,
bool Historize,
bool Enabled);