feat(uns): scripted-alarm CRUD in IUnsTreeService for the equipment Alarms tab

This commit is contained in:
Joseph Doherty
2026-06-11 14:25:59 -04:00
parent 7c22861598
commit 705ed6234f
5 changed files with 226 additions and 0 deletions
@@ -457,4 +457,59 @@ public interface IUnsTreeService
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a missing-row failure, or a concurrency failure.</returns>
Task<(bool Ok, string? Error)> UpdateScriptSourceAsync(string scriptId, string sourceCode, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Loads the scripted alarms owned by a single equipment as flat row projections for the equipment
/// page's Alarms tab table, ordered by Name. Each row carries the display columns plus the
/// <c>ScriptedAlarmId</c> the table uses to open the edit modal. Reads untracked. Returns an empty
/// list when the equipment has no scripted alarms.
/// </summary>
/// <param name="equipmentId">The equipment whose scripted alarms to load.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The equipment's scripted-alarm rows ordered by Name; empty if it has none.</returns>
Task<IReadOnlyList<EquipmentAlarmRow>> LoadAlarmsForEquipmentAsync(string equipmentId, CancellationToken ct = default);
/// <summary>
/// Loads a single scripted alarm projected for editing, or <c>null</c> if it no longer exists.
/// Reads untracked and captures the current concurrency token for last-write-wins saves.
/// </summary>
/// <param name="scriptedAlarmId">The scripted alarm to load.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The scripted alarm's edit projection, or <c>null</c> when missing.</returns>
Task<ScriptedAlarmEditDto?> LoadScriptedAlarmAsync(string scriptedAlarmId, CancellationToken ct = default);
/// <summary>
/// Creates a new scripted alarm under an equipment. The <c>ScriptedAlarmId</c> is operator-typed
/// (not system-generated). Fails on a duplicate <c>ScriptedAlarmId</c>. Field validation (severity
/// range, required fields) is enforced client-side in the modal. On success the operator-typed id is
/// echoed back via <see cref="UnsMutationResult.CreatedId"/>.
/// </summary>
/// <param name="equipmentId">The owning equipment.</param>
/// <param name="input">The operator-editable scripted-alarm fields.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success carrying the created id, or a duplicate-id failure.</returns>
Task<UnsMutationResult> CreateScriptedAlarmAsync(string equipmentId, ScriptedAlarmInput input, CancellationToken ct = default);
/// <summary>
/// Updates a scripted alarm's mutable fields (name, alarm type, severity, message template,
/// predicate script, and the historize/retain/enabled flags). The owning <c>EquipmentId</c> is
/// preserved (the Alarms tab fixes it). Uses last-write-wins optimistic concurrency on
/// <see cref="Configuration.Entities.ScriptedAlarm.RowVersion"/>.
/// </summary>
/// <param name="scriptedAlarmId">The scripted alarm to update.</param>
/// <param name="input">The new operator-editable scripted-alarm 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, or a concurrency failure.</returns>
Task<UnsMutationResult> UpdateScriptedAlarmAsync(string scriptedAlarmId, ScriptedAlarmInput input, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Deletes a scripted alarm. A missing row is treated as success (already gone). Uses last-write-wins
/// optimistic concurrency on <see cref="Configuration.Entities.ScriptedAlarm.RowVersion"/>.
/// </summary>
/// <param name="scriptedAlarmId">The scripted alarm 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> DeleteScriptedAlarmAsync(string scriptedAlarmId, byte[] rowVersion, CancellationToken ct = default);
}
@@ -0,0 +1,9 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>A scripted-alarm row for the Alarms-tab table.</summary>
public sealed record EquipmentAlarmRow(string ScriptedAlarmId, string Name, string AlarmType, int Severity, string PredicateScriptId, bool Enabled);
/// <summary>A scripted alarm projected for editing, with the concurrency token the modal echoes back.</summary>
public sealed record ScriptedAlarmEditDto(
string ScriptedAlarmId, string EquipmentId, string Name, string AlarmType, int Severity, string MessageTemplate,
string PredicateScriptId, bool HistorizeToAveva, bool Retain, bool Enabled, byte[] RowVersion);
@@ -0,0 +1,7 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>Operator-editable fields for a scripted-alarm create/update on the equipment Alarms tab.
/// The owning equipment is supplied separately (the tab fixes it), so there is no equipment field.</summary>
public sealed record ScriptedAlarmInput(
string ScriptedAlarmId, string Name, string AlarmType, int Severity, string MessageTemplate,
string PredicateScriptId, bool HistorizeToAveva, bool Retain, bool Enabled);
@@ -1286,4 +1286,82 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
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.");
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.");
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}."); }
}
}
@@ -0,0 +1,77 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
[Trait("Category", "Unit")]
public sealed class UnsTreeServiceScriptedAlarmTests
{
private static UnsTreeService SeededService()
{
var dbName = $"uns-alarm-{Guid.NewGuid():N}";
UnsTreeTestDb.SeedNamed(dbName);
return new UnsTreeService(UnsTreeTestDb.Factory(dbName));
}
private static ScriptedAlarmInput Sample(string id = "SA-1") =>
new(id, "Over-temp", "LimitAlarm", 700, "{TagPath} hot", "SCRIPT-1", HistorizeToAveva: true, Retain: true, Enabled: true);
[Fact]
public async Task Create_then_list_and_load_roundtrips()
{
var svc = SeededService();
var create = await svc.CreateScriptedAlarmAsync(UnsTreeTestDb.SeededEquipmentId, Sample());
create.Ok.ShouldBeTrue();
create.CreatedId.ShouldBe("SA-1");
var rows = await svc.LoadAlarmsForEquipmentAsync(UnsTreeTestDb.SeededEquipmentId);
rows.Count.ShouldBe(1);
rows[0].Name.ShouldBe("Over-temp");
rows[0].Severity.ShouldBe(700);
var dto = await svc.LoadScriptedAlarmAsync("SA-1");
dto.ShouldNotBeNull();
dto!.EquipmentId.ShouldBe(UnsTreeTestDb.SeededEquipmentId);
dto.HistorizeToAveva.ShouldBeTrue();
dto.RowVersion.ShouldNotBeNull();
}
[Fact]
public async Task Create_rejects_duplicate_id()
{
var svc = SeededService();
(await svc.CreateScriptedAlarmAsync(UnsTreeTestDb.SeededEquipmentId, Sample())).Ok.ShouldBeTrue();
var dup = await svc.CreateScriptedAlarmAsync(UnsTreeTestDb.SeededEquipmentId, Sample());
dup.Ok.ShouldBeFalse();
dup.Error.ShouldNotBeNull();
}
[Fact]
public async Task Update_changes_fields()
{
var svc = SeededService();
await svc.CreateScriptedAlarmAsync(UnsTreeTestDb.SeededEquipmentId, Sample());
var dto = await svc.LoadScriptedAlarmAsync("SA-1");
var upd = await svc.UpdateScriptedAlarmAsync("SA-1",
Sample() with { Name = "Renamed", Severity = 250, HistorizeToAveva = false }, dto!.RowVersion);
upd.Ok.ShouldBeTrue();
var after = await svc.LoadScriptedAlarmAsync("SA-1");
after!.Name.ShouldBe("Renamed");
after.Severity.ShouldBe(250);
after.HistorizeToAveva.ShouldBeFalse();
}
[Fact]
public async Task Delete_removes_row()
{
var svc = SeededService();
await svc.CreateScriptedAlarmAsync(UnsTreeTestDb.SeededEquipmentId, Sample());
var dto = await svc.LoadScriptedAlarmAsync("SA-1");
(await svc.DeleteScriptedAlarmAsync("SA-1", dto!.RowVersion)).Ok.ShouldBeTrue();
(await svc.LoadAlarmsForEquipmentAsync(UnsTreeTestDb.SeededEquipmentId)).ShouldBeEmpty();
}
}