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 8209ceb6..f9934fa5 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs
@@ -379,6 +379,33 @@ public interface IUnsTreeService
/// Success, a missing-row failure, a guard failure, or a concurrency failure.
Task UpdateTagAsync(string tagId, TagInput input, byte[] rowVersion, CancellationToken ct = default);
+ ///
+ /// Creates a new Galaxy alias tag on an equipment: an ordinary equipment-bound Tag bound to a
+ /// GalaxyMxGateway driver, with FolderPath null and a {"FullName":…} TagConfig
+ /// carrying the picked Galaxy reference. Fails on a duplicate TagId, an empty Galaxy reference,
+ /// an unknown equipment, a driver that is not a Galaxy gateway in the equipment's cluster, or a name
+ /// already used on the equipment.
+ ///
+ /// The owning equipment.
+ /// The operator-editable alias fields (id, name, gateway, type, access, reference).
+ /// A token to cancel the operation.
+ /// Success, or one of the guard failures.
+ Task CreateAliasTagAsync(string equipmentId, AliasTagInput input, CancellationToken ct = default);
+
+ ///
+ /// Updates a Galaxy alias tag's gateway binding, name, data type, access level, and Galaxy reference
+ /// (the {"FullName":…} TagConfig). The owning EquipmentId and the null FolderPath
+ /// are preserved. Re-runs the empty-reference and Galaxy-gateway-in-cluster guards against the alias's
+ /// existing equipment, and enforces name uniqueness on that equipment excluding this tag. Uses
+ /// last-write-wins optimistic concurrency on .
+ ///
+ /// The alias tag to update.
+ /// The new operator-editable alias 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 UpdateAliasTagAsync(string tagId, AliasTagInput input, byte[] rowVersion, CancellationToken ct = default);
+
///
/// Deletes a tag. A missing row is treated as success (already gone). Uses last-write-wins
/// optimistic concurrency on .
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 62d82b50..55d9a146 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs
@@ -893,6 +893,101 @@ public sealed class UnsTreeService(IDbContextFactory dbF
}
}
+ ///
+ public async Task CreateAliasTagAsync(
+ string equipmentId,
+ AliasTagInput 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 (string.IsNullOrWhiteSpace(input.FullName))
+ {
+ return new UnsMutationResult(false, "Alias is missing a Galaxy reference.");
+ }
+
+ 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 CheckAliasDriverGuardAsync(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(BuildAliasTag(equipmentId, input));
+ await db.SaveChangesAsync(ct);
+ return new UnsMutationResult(true, null);
+ }
+
+ ///
+ public async Task UpdateAliasTagAsync(
+ string tagId,
+ AliasTagInput 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 (string.IsNullOrWhiteSpace(input.FullName))
+ {
+ return new UnsMutationResult(false, "Alias is missing a Galaxy reference.");
+ }
+
+ var equipmentCluster = await ResolveEquipmentClusterAsync(db, entity.EquipmentId, ct);
+ var guard = await CheckAliasDriverGuardAsync(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 — alias tags are always equipment-bound.
+ entity.DriverInstanceId = input.DriverInstanceId;
+ entity.Name = input.Name;
+ entity.DataType = input.DataType;
+ entity.AccessLevel = input.AccessLevel;
+ entity.FolderPath = null;
+ entity.TagConfig = BuildAliasTagConfig(input.FullName);
+
+ 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.");
+ }
+ }
+
///
public async Task DeleteTagAsync(
string tagId,
@@ -1290,6 +1385,65 @@ public sealed class UnsTreeService(IDbContextFactory dbF
return null;
}
+ ///
+ /// Galaxy-aware driver guard for aliases: the driver must be a Galaxy gateway
+ /// (DriverType == "GalaxyMxGateway") in the equipment's cluster. Distinct from
+ /// , which requires an Equipment-kind namespace and would reject
+ /// the gateway. Returns null when the binding is allowed, or a populated failure otherwise.
+ ///
+ private static async Task CheckAliasDriverGuardAsync(
+ 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.");
+ }
+
+ if (driver.DriverType != "GalaxyMxGateway")
+ {
+ return new UnsMutationResult(false, $"Driver '{driverInstanceId}' is not a Galaxy gateway.");
+ }
+
+ if (driver.ClusterId != equipmentCluster)
+ {
+ return new UnsMutationResult(
+ false,
+ $"Galaxy gateway '{driverInstanceId}' is in cluster '{driver.ClusterId}' but the equipment is in cluster '{equipmentCluster}'.");
+ }
+
+ return null;
+ }
+
+ ///
+ /// Serialises a Galaxy reference into the alias TagConfig envelope {"FullName":"<ref>"}.
+ ///
+ private static string BuildAliasTagConfig(string fullName) =>
+ System.Text.Json.JsonSerializer.Serialize(new Dictionary { ["FullName"] = fullName });
+
+ ///
+ /// Builds the alias entity for an equipment: a Galaxy-gateway-bound, equipment-scoped
+ /// tag with a null FolderPath, no poll group, non-idempotent writes, and the
+ /// {"FullName":…} TagConfig. A pure builder — reused by the relay→alias converter so create and
+ /// conversion produce byte-identical rows.
+ ///
+ private static Tag BuildAliasTag(string equipmentId, AliasTagInput input) => new()
+ {
+ TagId = input.TagId,
+ DriverInstanceId = input.DriverInstanceId,
+ EquipmentId = equipmentId,
+ Name = input.Name,
+ FolderPath = null,
+ DataType = input.DataType,
+ AccessLevel = input.AccessLevel,
+ WriteIdempotent = false,
+ PollGroupId = null,
+ TagConfig = BuildAliasTagConfig(input.FullName),
+ };
+
///
/// Decision #122: an equipment may only bind to a driver in the same cluster as its line.
/// Policy:
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceAliasTagTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceAliasTagTests.cs
index fc32d4ab..37e88769 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceAliasTagTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceAliasTagTests.cs
@@ -207,6 +207,211 @@ public sealed class UnsTreeServiceAliasTagTests
alias.IsAlias.ShouldBeTrue();
alias.Source.ShouldBeNull();
}
+
+ // ---- Write-side: CreateAliasTagAsync / UpdateAliasTagAsync (T5) ----
+
+ private const string SecondClusterId = "ALT";
+ private const string SecondGatewayDriverId = "DRV-GALAXY-ALT";
+
+ ///
+ /// Seeds a second cluster (no UNS hierarchy needed) carrying its own GalaxyMxGateway driver, so a
+ /// cross-cluster guard test can reference a gateway that is NOT in the equipment's cluster.
+ ///
+ private static void SeedSecondClusterGateway(string dbName)
+ {
+ using var db = UnsTreeTestDb.CreateNamed(dbName);
+
+ db.ServerClusters.Add(new ServerCluster
+ {
+ ClusterId = SecondClusterId,
+ Name = "Alt",
+ Enterprise = "zb",
+ Site = "krakow",
+ RedundancyMode = RedundancyMode.None,
+ CreatedBy = "test",
+ });
+ db.Namespaces.Add(new Namespace
+ {
+ NamespaceId = "NS-SP-ALT",
+ ClusterId = SecondClusterId,
+ Kind = NamespaceKind.SystemPlatform,
+ NamespaceUri = "urn:zb:sp:alt",
+ });
+ db.DriverInstances.Add(new DriverInstance
+ {
+ DriverInstanceId = SecondGatewayDriverId,
+ ClusterId = SecondClusterId,
+ NamespaceId = "NS-SP-ALT",
+ Name = "alt galaxy gateway",
+ DriverType = "GalaxyMxGateway",
+ DriverConfig = "{}",
+ });
+
+ db.SaveChanges();
+ }
+
+ private static AliasTagInput Input(
+ string tagId = "TAG-NEW-ALIAS",
+ string name = "speed-alias",
+ string driverInstanceId = GatewayDriverId,
+ string dataType = "Float",
+ TagAccessLevel accessLevel = TagAccessLevel.Read,
+ string fullName = "TestMachine_020.Speed") =>
+ new(tagId, name, driverInstanceId, dataType, accessLevel, fullName);
+
+ ///
+ /// A valid create persists a Galaxy-gateway-bound, equipment-scoped alias Tag: FolderPath is
+ /// null, the supplied AccessLevel is honoured, and the TagConfig is the {"FullName":…} envelope.
+ ///
+ [Fact]
+ public async Task CreateAliasTag_persists_galaxy_alias_row()
+ {
+ var dbName = SeedCluster();
+ var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
+
+ var result = await service.CreateAliasTagAsync(
+ EquipmentId, Input(accessLevel: TagAccessLevel.ReadWrite, fullName: "TestMachine_020.Speed"));
+
+ result.Ok.ShouldBeTrue();
+ result.Error.ShouldBeNull();
+
+ using var db = UnsTreeTestDb.CreateNamed(dbName);
+ var tag = db.Tags.Single(t => t.TagId == "TAG-NEW-ALIAS");
+ tag.DriverInstanceId.ShouldBe(GatewayDriverId);
+ tag.EquipmentId.ShouldBe(EquipmentId);
+ tag.FolderPath.ShouldBeNull();
+ tag.AccessLevel.ShouldBe(TagAccessLevel.ReadWrite);
+ tag.Name.ShouldBe("speed-alias");
+ tag.DataType.ShouldBe("Float");
+
+ using var doc = System.Text.Json.JsonDocument.Parse(tag.TagConfig);
+ doc.RootElement.GetProperty("FullName").GetString().ShouldBe("TestMachine_020.Speed");
+ tag.TagConfig.ShouldContain("FullName");
+ tag.TagConfig.ShouldContain("TestMachine_020.Speed");
+ }
+
+ /// An empty/whitespace Galaxy reference is rejected before anything is written.
+ [Fact]
+ public async Task CreateAliasTag_rejects_empty_reference()
+ {
+ var dbName = SeedCluster();
+ var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
+
+ var result = await service.CreateAliasTagAsync(EquipmentId, Input(fullName: " "));
+
+ result.Ok.ShouldBeFalse();
+ result.Error!.ShouldContain("reference");
+
+ using var db = UnsTreeTestDb.CreateNamed(dbName);
+ db.Tags.Any(t => t.TagId == "TAG-NEW-ALIAS").ShouldBeFalse();
+ }
+
+ /// Binding the alias to a non-Galaxy driver (the cluster's Modbus driver) is rejected.
+ [Fact]
+ public async Task CreateAliasTag_rejects_non_galaxy_driver()
+ {
+ var dbName = SeedCluster();
+ var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
+
+ var result = await service.CreateAliasTagAsync(
+ EquipmentId, Input(driverInstanceId: ModbusDriverId));
+
+ result.Ok.ShouldBeFalse();
+ result.Error!.ShouldContain("Galaxy gateway");
+ }
+
+ /// A Galaxy gateway in a different cluster than the equipment is rejected (cluster mismatch).
+ [Fact]
+ public async Task CreateAliasTag_rejects_cross_cluster_gateway()
+ {
+ var dbName = SeedCluster();
+ SeedSecondClusterGateway(dbName);
+ var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
+
+ var result = await service.CreateAliasTagAsync(
+ EquipmentId, Input(driverInstanceId: SecondGatewayDriverId));
+
+ result.Ok.ShouldBeFalse();
+ result.Error!.ShouldContain("cluster");
+ }
+
+ /// A name already used by another tag on the equipment is rejected.
+ [Fact]
+ public async Task CreateAliasTag_rejects_duplicate_name()
+ {
+ var dbName = SeedCluster();
+
+ using (var db = UnsTreeTestDb.CreateNamed(dbName))
+ {
+ db.Tags.Add(new Tag
+ {
+ TagId = "TAG-EXISTING",
+ DriverInstanceId = ModbusDriverId,
+ EquipmentId = EquipmentId,
+ Name = "speed-alias",
+ DataType = "Int32",
+ AccessLevel = TagAccessLevel.Read,
+ TagConfig = "{}",
+ });
+ db.SaveChanges();
+ }
+
+ var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
+
+ var result = await service.CreateAliasTagAsync(EquipmentId, Input(name: "speed-alias"));
+
+ result.Ok.ShouldBeFalse();
+ result.Error!.ShouldContain("speed-alias");
+ }
+
+ ///
+ /// Update changes the alias's name, data type, access level, and Galaxy reference, returning Ok and
+ /// persisting the new values and the refreshed {"FullName":…} TagConfig.
+ ///
+ [Fact]
+ public async Task UpdateAliasTag_changes_fields_and_reference()
+ {
+ var dbName = SeedCluster();
+ byte[] rowVersion;
+
+ using (var db = UnsTreeTestDb.CreateNamed(dbName))
+ {
+ db.Tags.Add(new Tag
+ {
+ TagId = "TAG-UPD-ALIAS",
+ DriverInstanceId = GatewayDriverId,
+ EquipmentId = EquipmentId,
+ Name = "old-name",
+ DataType = "Float",
+ AccessLevel = TagAccessLevel.Read,
+ TagConfig = "{\"FullName\":\"TestMachine_020.Speed\"}",
+ });
+ db.SaveChanges();
+ rowVersion = db.Tags.Single(t => t.TagId == "TAG-UPD-ALIAS").RowVersion;
+ }
+
+ var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
+
+ var result = await service.UpdateAliasTagAsync(
+ "TAG-UPD-ALIAS",
+ Input(tagId: "TAG-UPD-ALIAS", name: "new-name", dataType: "Double",
+ accessLevel: TagAccessLevel.ReadWrite, fullName: "TestMachine_020.Setpoint"),
+ rowVersion);
+
+ result.Ok.ShouldBeTrue();
+ result.Error.ShouldBeNull();
+
+ using var verify = UnsTreeTestDb.CreateNamed(dbName);
+ var tag = verify.Tags.Single(t => t.TagId == "TAG-UPD-ALIAS");
+ tag.Name.ShouldBe("new-name");
+ tag.DataType.ShouldBe("Double");
+ tag.AccessLevel.ShouldBe(TagAccessLevel.ReadWrite);
+ tag.EquipmentId.ShouldBe(EquipmentId);
+ tag.FolderPath.ShouldBeNull();
+
+ using var doc = System.Text.Json.JsonDocument.Parse(tag.TagConfig);
+ doc.RootElement.GetProperty("FullName").GetString().ShouldBe("TestMachine_020.Setpoint");
+ }
}
/// Small Shouldly-style helper for "exactly one match" assertions used by these tests.