diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs
index 38de64b7..2916da49 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs
@@ -185,4 +185,48 @@ public interface IUnsTreeService
/// A token to cancel the operation.
/// Success, a concurrency failure, or a delete-failed failure.
Task DeleteTagAsync(string tagId, byte[] rowVersion, CancellationToken ct = default);
+
+ ///
+ /// Loads the scripts eligible to back a virtual tag, ordered by name. Each is projected to a
+ /// (ScriptId, Display) pair where Display is "{Name} ({Language})".
+ ///
+ /// A token to cancel the load.
+ /// The scripts projected to (ScriptId, Display) pairs.
+ Task> LoadScriptsAsync(CancellationToken ct = default);
+
+ ///
+ /// 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
+ /// VirtualTagId, or on a name already used on the equipment.
+ ///
+ /// The owning equipment.
+ /// The operator-editable virtual-tag fields.
+ /// A token to cancel the operation.
+ /// Success, or one of the guard failures.
+ Task CreateVirtualTagAsync(string equipmentId, VirtualTagInput input, CancellationToken ct = default);
+
+ ///
+ /// Updates an equipment-bound virtual tag's name, data type, script binding, triggers, historize,
+ /// and enabled flags. The owning EquipmentId 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
+ /// .
+ ///
+ /// The virtual tag to update.
+ /// The new operator-editable virtual-tag fields.
+ /// The concurrency token the caller last read.
+ /// A token to cancel the operation.
+ /// Success, a missing-row failure, a guard failure, or a concurrency failure.
+ Task UpdateVirtualTagAsync(string virtualTagId, VirtualTagInput input, byte[] rowVersion, CancellationToken ct = default);
+
+ ///
+ /// Deletes a virtual tag. A missing row is treated as success (already gone). Uses last-write-wins
+ /// optimistic concurrency on .
+ ///
+ /// The virtual tag to delete.
+ /// The concurrency token the caller last read.
+ /// A token to cancel the operation.
+ /// Success, a concurrency failure, or a delete-failed failure.
+ Task DeleteVirtualTagAsync(string virtualTagId, byte[] rowVersion, CancellationToken ct = default);
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs
index 7b3bc51f..1bef2435 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs
@@ -643,6 +643,174 @@ public sealed class UnsTreeService(IDbContextFactory dbF
}
}
+ ///
+ public async Task> 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();
+ }
+
+ ///
+ public async Task 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);
+ }
+
+ ///
+ public async Task 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.");
+ }
+ }
+
+ ///
+ public async Task 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}.");
+ }
+ }
+
+ ///
+ /// 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 null when the input is valid.
+ ///
+ 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;
+ }
+
/// Returns true if parses as a well-formed JSON document.
private static bool IsValidJson(string json)
{
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/VirtualTagInput.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/VirtualTagInput.cs
new file mode 100644
index 00000000..fed11be7
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/VirtualTagInput.cs
@@ -0,0 +1,26 @@
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
+
+///
+/// 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 EquipmentId is supplied separately and never changes on update. A virtual tag
+/// must have at least one evaluation trigger: or a non-null
+/// (which, when set, must be at least 50 ms).
+///
+/// Stable unique virtual-tag id; only honoured on create (immutable thereafter).
+/// Virtual-tag display name; unique within the owning equipment.
+/// OPC UA built-in type name (Boolean / Int32 / Float / etc.).
+/// The script that computes this tag's value; must be chosen.
+/// Re-evaluate when any referenced input tag changes.
+/// Optional periodic re-evaluation cadence in ms; null = no timer, otherwise >= 50.
+/// Whether this tag's values should be historized.
+/// Whether this virtual tag is spawned in deployments.
+public sealed record VirtualTagInput(
+ string VirtualTagId,
+ string Name,
+ string DataType,
+ string ScriptId,
+ bool ChangeTriggered,
+ int? TimerIntervalMs,
+ bool Historize,
+ bool Enabled);
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceVirtualTagTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceVirtualTagTests.cs
new file mode 100644
index 00000000..d7fafe1c
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceVirtualTagTests.cs
@@ -0,0 +1,266 @@
+using Microsoft.EntityFrameworkCore;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
+
+///
+/// Verifies the equipment-bound VirtualTag CRUD mutations on , including
+/// the equipment-existence guard, the script-chosen guard, the change-or-timer trigger rule, the
+/// 50 ms timer minimum, duplicate-id / duplicate-name guards, and the script-candidate loader.
+///
+///
+/// The EF InMemory provider enforces neither RowVersion concurrency nor the DbContext CHECK
+/// constraints, so the DbUpdateConcurrencyException branches are not exercised here by design,
+/// and the service's own trigger/timer guards are what protect the data in these tests.
+///
+[Trait("Category", "Unit")]
+public sealed class UnsTreeServiceVirtualTagTests
+{
+ private static (UnsTreeService Service, string DbName) Fresh()
+ {
+ var dbName = $"uns-vtag-{Guid.NewGuid():N}";
+ return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName);
+ }
+
+ ///
+ /// Seeds an area→line→equipment path (equipment id EQ-1) plus a single script
+ /// (SCRIPT-1, language CSharp) so the create/update paths have a valid script to bind.
+ ///
+ private static void SeedEquipmentAndScript(string dbName)
+ {
+ using var db = UnsTreeTestDb.CreateNamed(dbName);
+ db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = "MAIN", Name = "a" });
+ db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "l" });
+ db.Equipment.Add(new Equipment
+ {
+ EquipmentId = "EQ-1",
+ EquipmentUuid = Guid.NewGuid(),
+ UnsLineId = "LINE-1",
+ Name = "machine-1",
+ MachineCode = "machine_001",
+ });
+ db.Scripts.Add(new Script
+ {
+ ScriptId = "SCRIPT-1",
+ Name = "compute speed",
+ SourceCode = "return 1;",
+ SourceHash = "hash-1",
+ Language = "CSharp",
+ });
+ db.SaveChanges();
+ }
+
+ private static VirtualTagInput Input(
+ string virtualTagId,
+ string name,
+ string scriptId = "SCRIPT-1",
+ bool changeTriggered = true,
+ int? timerIntervalMs = null) =>
+ new(virtualTagId, name, DataType: "Double", scriptId,
+ changeTriggered, timerIntervalMs, Historize: false, Enabled: true);
+
+ // ----- CreateVirtualTag -----
+
+ /// A valid change-triggered virtual tag persists with EquipmentId set.
+ [Fact]
+ public async Task CreateVirtualTag_persists()
+ {
+ var (service, dbName) = Fresh();
+ SeedEquipmentAndScript(dbName);
+
+ var result = await service.CreateVirtualTagAsync("EQ-1", Input("VTAG-1", "computed"));
+
+ result.Ok.ShouldBeTrue();
+ result.Error.ShouldBeNull();
+
+ using var db = UnsTreeTestDb.CreateNamed(dbName);
+ var vtag = db.VirtualTags.Single(v => v.VirtualTagId == "VTAG-1");
+ vtag.EquipmentId.ShouldBe("EQ-1");
+ vtag.Name.ShouldBe("computed");
+ vtag.DataType.ShouldBe("Double");
+ vtag.ScriptId.ShouldBe("SCRIPT-1");
+ vtag.ChangeTriggered.ShouldBeTrue();
+ }
+
+ /// Creating a virtual tag for an equipment id that does not exist is blocked.
+ [Fact]
+ public async Task CreateVirtualTag_equipment_not_found_blocked()
+ {
+ var (service, dbName) = Fresh();
+ SeedEquipmentAndScript(dbName);
+
+ var result = await service.CreateVirtualTagAsync("EQ-NOPE", Input("VTAG-1", "computed"));
+
+ result.Ok.ShouldBeFalse();
+ result.Error.ShouldBe("Equipment 'EQ-NOPE' not found.");
+
+ using var db = UnsTreeTestDb.CreateNamed(dbName);
+ db.VirtualTags.Any(v => v.VirtualTagId == "VTAG-1").ShouldBeFalse();
+ }
+
+ /// A virtual tag with neither change-trigger nor a timer is blocked.
+ [Fact]
+ public async Task CreateVirtualTag_no_trigger_blocked()
+ {
+ var (service, dbName) = Fresh();
+ SeedEquipmentAndScript(dbName);
+
+ var result = await service.CreateVirtualTagAsync(
+ "EQ-1", Input("VTAG-1", "computed", changeTriggered: false, timerIntervalMs: null));
+
+ result.Ok.ShouldBeFalse();
+ result.Error.ShouldBe("Pick at least one trigger — change or timer.");
+
+ using var db = UnsTreeTestDb.CreateNamed(dbName);
+ db.VirtualTags.Any(v => v.VirtualTagId == "VTAG-1").ShouldBeFalse();
+ }
+
+ /// A virtual tag with a timer below the 50 ms minimum is blocked.
+ [Fact]
+ public async Task CreateVirtualTag_timer_below_50_blocked()
+ {
+ var (service, dbName) = Fresh();
+ SeedEquipmentAndScript(dbName);
+
+ var result = await service.CreateVirtualTagAsync(
+ "EQ-1", Input("VTAG-1", "computed", changeTriggered: false, timerIntervalMs: 10));
+
+ result.Ok.ShouldBeFalse();
+ result.Error.ShouldBe("TimerIntervalMs must be at least 50 ms.");
+
+ using var db = UnsTreeTestDb.CreateNamed(dbName);
+ db.VirtualTags.Any(v => v.VirtualTagId == "VTAG-1").ShouldBeFalse();
+ }
+
+ /// Creating a virtual tag with a VirtualTagId that already exists is blocked.
+ [Fact]
+ public async Task CreateVirtualTag_duplicate_id_blocked()
+ {
+ var (service, dbName) = Fresh();
+ SeedEquipmentAndScript(dbName);
+ await service.CreateVirtualTagAsync("EQ-1", Input("VTAG-1", "computed"));
+
+ var result = await service.CreateVirtualTagAsync("EQ-1", Input("VTAG-1", "another"));
+
+ result.Ok.ShouldBeFalse();
+ result.Error.ShouldBe("VirtualTag 'VTAG-1' already exists.");
+ }
+
+ /// Creating a virtual tag whose Name already exists on the same equipment is blocked.
+ [Fact]
+ public async Task CreateVirtualTag_duplicate_name_on_equipment_blocked()
+ {
+ var (service, dbName) = Fresh();
+ SeedEquipmentAndScript(dbName);
+ await service.CreateVirtualTagAsync("EQ-1", Input("VTAG-1", "computed"));
+
+ var result = await service.CreateVirtualTagAsync("EQ-1", Input("VTAG-2", "computed"));
+
+ result.Ok.ShouldBeFalse();
+ result.Error.ShouldBe("A virtual tag named 'computed' already exists on this equipment.");
+ }
+
+ // ----- UpdateVirtualTag -----
+
+ /// Updating a virtual tag changes its mutable fields and keeps EquipmentId.
+ [Fact]
+ public async Task UpdateVirtualTag_changes_fields()
+ {
+ var (service, dbName) = Fresh();
+ SeedEquipmentAndScript(dbName);
+ await service.CreateVirtualTagAsync("EQ-1", Input("VTAG-1", "computed"));
+
+ byte[] rv;
+ using (var db = UnsTreeTestDb.CreateNamed(dbName))
+ {
+ rv = db.VirtualTags.Single(v => v.VirtualTagId == "VTAG-1").RowVersion;
+ }
+
+ var updated = new VirtualTagInput("VTAG-1", "renamed", DataType: "Int32", ScriptId: "SCRIPT-1",
+ ChangeTriggered: false, TimerIntervalMs: 250, Historize: true, Enabled: false);
+
+ var result = await service.UpdateVirtualTagAsync("VTAG-1", updated, rv);
+
+ result.Ok.ShouldBeTrue();
+ result.Error.ShouldBeNull();
+
+ using var verify = UnsTreeTestDb.CreateNamed(dbName);
+ var after = verify.VirtualTags.Single(v => v.VirtualTagId == "VTAG-1");
+ after.Name.ShouldBe("renamed");
+ after.DataType.ShouldBe("Int32");
+ after.ChangeTriggered.ShouldBeFalse();
+ after.TimerIntervalMs.ShouldBe(250);
+ after.Historize.ShouldBeTrue();
+ after.Enabled.ShouldBeFalse();
+ after.EquipmentId.ShouldBe("EQ-1");
+ }
+
+ /// Updating a virtual tag that no longer exists returns the row-gone error.
+ [Fact]
+ public async Task UpdateVirtualTag_missing_row_returns_error()
+ {
+ var (service, dbName) = Fresh();
+ SeedEquipmentAndScript(dbName);
+
+ var result = await service.UpdateVirtualTagAsync("VTAG-nope", Input("VTAG-nope", "x"), []);
+
+ result.Ok.ShouldBeFalse();
+ result.Error.ShouldBe("Row no longer exists.");
+ }
+
+ // ----- DeleteVirtualTag -----
+
+ /// Deleting a virtual tag removes the row.
+ [Fact]
+ public async Task DeleteVirtualTag_removes_row()
+ {
+ var (service, dbName) = Fresh();
+ SeedEquipmentAndScript(dbName);
+ await service.CreateVirtualTagAsync("EQ-1", Input("VTAG-1", "computed"));
+
+ byte[] rv;
+ using (var db = UnsTreeTestDb.CreateNamed(dbName))
+ {
+ rv = db.VirtualTags.Single(v => v.VirtualTagId == "VTAG-1").RowVersion;
+ }
+
+ var result = await service.DeleteVirtualTagAsync("VTAG-1", rv);
+
+ result.Ok.ShouldBeTrue();
+ result.Error.ShouldBeNull();
+
+ using var verify = UnsTreeTestDb.CreateNamed(dbName);
+ verify.VirtualTags.Any(v => v.VirtualTagId == "VTAG-1").ShouldBeFalse();
+ }
+
+ /// Deleting a virtual tag that is already gone is a no-op success.
+ [Fact]
+ public async Task DeleteVirtualTag_already_gone_returns_ok()
+ {
+ var (service, _) = Fresh();
+
+ var result = await service.DeleteVirtualTagAsync("VTAG-ghost", []);
+
+ result.Ok.ShouldBeTrue();
+ result.Error.ShouldBeNull();
+ }
+
+ // ----- LoadScriptsAsync -----
+
+ /// The script loader returns the seeded scripts projected to (id, "Name (Language)").
+ [Fact]
+ public async Task LoadScripts_returns_scripts()
+ {
+ var (service, dbName) = Fresh();
+ SeedEquipmentAndScript(dbName);
+
+ var scripts = await service.LoadScriptsAsync();
+
+ scripts.Count.ShouldBe(1);
+ scripts[0].ScriptId.ShouldBe("SCRIPT-1");
+ scripts[0].Display.ShouldBe("compute speed (CSharp)");
+ }
+}