v3 Batch 3 WP1: UNS Equipment Tags tab → reference list + raw-tag picker

Replace the Batch-1-stubbed authored-tag flow with a UnsTagReference list on the
equipment page's Tags tab (v3 reference-only equipment): columns are effective name,
computed raw path, inherited datatype/access (read-only), display-name override
(editable), and remove. The retired equipment-authored-tag editor (TagModal.razor)
is deleted; the VirtualTag and ScriptedAlarm flows are untouched.

- "+ Add reference" opens a new AddReferenceModal that reuses RawTree in a new opt-in
  PickerMode (Tag-leaf checkboxes + Device/TagGroup "select all tags below"), scoped
  structurally to the equipment's cluster via LoadReferencePickerRootAsync (a single
  cluster root — cross-cluster tags are unreachable, not merely hidden). Default /raw
  usage is byte-unchanged (PickerMode defaults false).
- UnsTreeService (+ IUnsTreeService) gain the reference mutations:
  LoadReferencesForEquipmentAsync (computes each RawPath via the shared RawPathResolver),
  AddReferencesAsync (cluster-checked, all-or-nothing), RemoveReferenceAsync,
  SetReferenceOverrideAsync, plus the picker helpers LoadReferencePickerRootAsync /
  LoadDescendantTagIdsAsync.
- Consume IEffectiveNameGuard (constructor-injected, no-op fallback when unregistered):
  AddReferences, SetReferenceOverride, and the VirtualTag + ScriptedAlarm create/update
  mutations now call CheckAsync before persisting and surface a collision as the failure.
  WP2 supplies the implementation + DI registration.
- Drop the retired equipment↔driver binding: remove DriverInstanceId from EquipmentInput,
  the ImportEquipmentModal CSV column, the EquipmentPage Details driver select, and the
  dead decision-#122 driver-cluster guard.

Tests: new UnsTreeServiceReferenceTests (add/remove/override, cross-cluster + duplicate
rejection, guard-consumed rejection via a fake guard, RawPath projection, picker helpers);
Equipment/Import test suites rewritten to drop the retired driver-guard cases. AdminUI
suite green (639 passed, 3 skipped); full solution builds 0 errors.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
Joseph Doherty
2026-07-16 06:15:51 -04:00
parent 6dc5af7aa2
commit 77bc010ba9
12 changed files with 1161 additions and 1060 deletions
@@ -9,3 +9,28 @@ public sealed record EquipmentTagRow(
/// <summary>A virtual-tag row for the equipment page's Virtual Tags tab table.</summary>
public sealed record EquipmentVirtualTagRow(string VirtualTagId, string Name, string DataType, string ScriptId, bool Enabled);
/// <summary>
/// A UNS tag-reference row for the equipment page's Tags tab table (v3 reference-only equipment). Each
/// row projects a <see cref="Configuration.Entities.UnsTagReference"/> together with the backing raw
/// tag's inherited (read-only) datatype/access and computed RawPath. The effective name is the
/// <c>DisplayNameOverride</c> when set, else the backing raw tag's <c>Name</c>. <c>RowVersion</c> is the
/// reference row's concurrency token, echoed on the override-set / remove mutations.
/// </summary>
/// <param name="UnsTagReferenceId">The reference's stable logical id (the mutation key).</param>
/// <param name="EffectiveName">Override else the raw tag's Name — the UNS leaf name.</param>
/// <param name="RawPath">The backing raw tag's computed RawPath (folder/driver/device/group/tag).</param>
/// <param name="DataType">Inherited OPC UA built-in type name from the raw tag (read-only).</param>
/// <param name="AccessLevel">Inherited access level from the raw tag (read-only).</param>
/// <param name="DisplayNameOverride">The optional display-name override; <c>null</c> = use the raw name.</param>
/// <param name="SortOrder">Sibling display ordering within the equipment's Tags list.</param>
/// <param name="RowVersion">The reference row's optimistic-concurrency token.</param>
public sealed record EquipmentReferenceRow(
string UnsTagReferenceId,
string EffectiveName,
string RawPath,
string DataType,
TagAccessLevel AccessLevel,
string? DisplayNameOverride,
int SortOrder,
byte[] RowVersion);
@@ -11,7 +11,6 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <param name="Name">UNS level-5 segment; matches <c>^[a-z0-9-]{1,32}$</c>.</param>
/// <param name="MachineCode">Operator colloquial id; unique fleet-wide.</param>
/// <param name="UnsLineId">Logical FK to the owning <see cref="Configuration.Entities.UnsLine"/>.</param>
/// <param name="DriverInstanceId">Optional driver binding; whitespace/empty means driver-less.</param>
/// <param name="ZTag">Optional ERP equipment id.</param>
/// <param name="SAPID">Optional SAP PM equipment id.</param>
/// <param name="Manufacturer">Optional OPC 40010 manufacturer name.</param>
@@ -28,7 +27,6 @@ public sealed record EquipmentInput(
string Name,
string MachineCode,
string UnsLineId,
string? DriverInstanceId,
string? ZTag,
string? SAPID,
string? Manufacturer,
@@ -139,6 +139,82 @@ public interface IUnsTreeService
/// <returns>The equipment's virtual-tag rows ordered by Name; empty if it has none.</returns>
Task<IReadOnlyList<EquipmentVirtualTagRow>> LoadVirtualTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default);
// ---------------------------------------------------------------------------------------------
// v3 UNS reference-only equipment: the Tags tab lists UnsTagReference rows pointing at raw tags.
// ---------------------------------------------------------------------------------------------
/// <summary>
/// Loads the <see cref="Configuration.Entities.UnsTagReference"/> rows for a single equipment as flat
/// projections for the equipment page's Tags tab, ordered by <c>SortOrder</c> then effective name.
/// Each row carries the effective name (override else the backing raw tag's <c>Name</c>), the backing
/// tag's computed RawPath, its inherited (read-only) datatype/access, the optional display-name
/// override, and the reference's concurrency token. Reads untracked. Returns an empty list when the
/// equipment has no references.
/// </summary>
/// <param name="equipmentId">The equipment whose references to load.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The equipment's reference rows; empty if it has none.</returns>
Task<IReadOnlyList<EquipmentReferenceRow>> LoadReferencesForEquipmentAsync(string equipmentId, CancellationToken ct = default);
/// <summary>
/// Adds one <see cref="Configuration.Entities.UnsTagReference"/> per supplied raw <c>TagId</c> to an
/// equipment. Every backing tag MUST live in the same cluster as the equipment (cross-cluster
/// references are rejected). Each new reference's effective name (the raw tag's <c>Name</c> — no
/// override on add) must be unique within the equipment across references, VirtualTags, and
/// ScriptedAlarms (enforced via <see cref="IEffectiveNameGuard"/>), and a tag already referenced by
/// the equipment is rejected. The batch is all-or-nothing.
/// </summary>
/// <param name="equipmentId">The referencing equipment.</param>
/// <param name="tagIds">The raw tag ids to reference.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, or the first guard/cluster/duplicate failure (nothing is added).</returns>
Task<UnsMutationResult> AddReferencesAsync(string equipmentId, IReadOnlyList<string> tagIds, CancellationToken ct = default);
/// <summary>
/// Removes a UNS tag reference. A missing row is treated as success (already gone). Uses last-write-wins
/// optimistic concurrency on <see cref="Configuration.Entities.UnsTagReference.RowVersion"/>.
/// </summary>
/// <param name="unsTagReferenceId">The reference to remove.</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> RemoveReferenceAsync(string unsTagReferenceId, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Sets (or clears) a reference's <c>DisplayNameOverride</c>. A whitespace-only override collapses to
/// <c>null</c> (use the raw name). The resulting effective name (override else the backing raw tag's
/// <c>Name</c>) must be unique within the equipment across references, VirtualTags, and ScriptedAlarms
/// (enforced via <see cref="IEffectiveNameGuard"/>, excluding this reference). Uses last-write-wins
/// optimistic concurrency.
/// </summary>
/// <param name="unsTagReferenceId">The reference to update.</param>
/// <param name="displayNameOverride">The new override, or <c>null</c>/blank to clear it.</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 name-collision failure, or a concurrency failure.</returns>
Task<UnsMutationResult> SetReferenceOverrideAsync(string unsTagReferenceId, string? displayNameOverride, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Builds the single cluster-rooted <see cref="RawNode"/> that seeds the "+ Add reference" raw-tag
/// picker for an equipment, structurally scoped to the equipment's own cluster (raw tags in any other
/// cluster are unreachable through this root — the scope is enforced by the query, not merely hidden).
/// The picker's <c>RawTree</c> lazily expands it via <see cref="IRawTreeService.LoadChildrenAsync"/>.
/// Returns <c>null</c> when the equipment cannot be resolved to a cluster.
/// </summary>
/// <param name="equipmentId">The equipment whose cluster scopes the picker.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The cluster root node, or <c>null</c> when the equipment/cluster can't be resolved.</returns>
Task<RawNode?> LoadReferencePickerRootAsync(string equipmentId, CancellationToken ct = default);
/// <summary>
/// Enumerates every raw <c>TagId</c> beneath a Device or TagGroup picker node (the whole subtree), for
/// the picker's "select all tags below" affordance. Returns an empty list for non-container node kinds.
/// </summary>
/// <param name="node">The picker node (Device or TagGroup) to enumerate tags under.</param>
/// <param name="ct">A token to cancel the query.</param>
/// <returns>The tag ids in the node's subtree; empty for unsupported kinds.</returns>
Task<IReadOnlyList<string>> LoadDescendantTagIdsAsync(RawNode node, CancellationToken ct = default);
/// <summary>
/// Loads a single UNS area 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.
@@ -284,24 +360,22 @@ public interface IUnsTreeService
/// <summary>
/// Creates a new equipment under a UNS line. The <c>EquipmentId</c> is system-generated
/// (<c>EQ-</c> + the first 12 hex chars of a fresh <c>EquipmentUuid</c>).
/// Fails if the line is unset, if the MachineCode is already used fleet-wide, or if the
/// driver-cluster guard trips. Whitespace-only DriverInstanceId/ZTag/SAPID
/// collapse to <c>null</c>.
/// Fails if the line is unset or if the MachineCode is already used fleet-wide. Whitespace-only
/// ZTag/SAPID collapse to <c>null</c>. (v3: equipment no longer binds a driver — the reference-only
/// Tags tab points at raw tags instead — so no driver-cluster guard applies.)
/// </summary>
/// <param name="input">The operator-editable equipment fields.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a missing-line failure, a duplicate-MachineCode failure, or a cluster-guard failure.</returns>
/// <returns>Success, a missing-line failure, or a duplicate-MachineCode failure.</returns>
Task<UnsMutationResult> CreateEquipmentAsync(EquipmentInput input, CancellationToken ct = default);
/// <summary>
/// Bulk-imports equipment from a parsed set of <see cref="EquipmentInput"/> rows in a single
/// context, applying the same rules as the single-add path: a row whose <c>UnsLineId</c> does not
/// exist is an error; a row whose <c>DriverInstanceId</c> is set but does not resolve is an error;
/// a driver-bound row whose driver is in a different cluster than its line fails the cluster
/// guard; and a row whose <c>MachineCode</c> already exists in the DB <em>or</em> earlier in the
/// same batch is silently skipped (additive-only — never an update). Inserted rows get a
/// exist is an error; and a row whose <c>MachineCode</c> already exists in the DB <em>or</em> earlier
/// in the same batch is silently skipped (additive-only — never an update). Inserted rows get a
/// system-generated <c>EQ-</c> id and a fresh <c>EquipmentUuid</c>. All inserts are saved once at
/// the end.
/// the end. (v3: equipment no longer binds a driver, so there is no per-row driver-cluster guard.)
/// </summary>
/// <param name="rows">The parsed equipment rows to import.</param>
/// <param name="ct">A token to cancel the operation.</param>
@@ -309,16 +383,16 @@ public interface IUnsTreeService
Task<EquipmentImportResult> ImportEquipmentAsync(IReadOnlyList<EquipmentInput> rows, CancellationToken ct = default);
/// <summary>
/// Updates an equipment's mutable fields (driver binding, line, name, MachineCode, external
/// ids, and the OPC 40010 identification fields). The driver-cluster guard blocks
/// binding to a driver in a different cluster than the equipment's line. Uses last-write-wins
/// optimistic concurrency on <see cref="Configuration.Entities.Equipment.RowVersion"/>.
/// Updates an equipment's mutable fields (line, name, MachineCode, external ids, and the OPC 40010
/// identification fields). Fails on a MachineCode already used by another row. Uses last-write-wins
/// optimistic concurrency on <see cref="Configuration.Entities.Equipment.RowVersion"/>. (v3: equipment
/// no longer binds a driver, so there is no driver-cluster guard.)
/// </summary>
/// <param name="equipmentId">The equipment to update.</param>
/// <param name="input">The new operator-editable equipment 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 cluster-guard failure, or a concurrency failure.</returns>
/// <returns>Success, a missing-row failure, a duplicate-MachineCode failure, or a concurrency failure.</returns>
Task<UnsMutationResult> UpdateEquipmentAsync(string equipmentId, EquipmentInput input, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
@@ -18,8 +18,17 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// 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
public sealed class UnsTreeService(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
IEffectiveNameGuard? guard = null) : IUnsTreeService
{
// The v3 Batch-3 effective-name uniqueness guard (references / VirtualTags / ScriptedAlarms share the
// equipment's UNS NodeId space). WP2 registers the real implementation; production DI always injects it.
// A null (unregistered) guard falls back to a no-op — collisions are then caught only at the deploy
// gate — which also lets unit tests that don't exercise the guard construct the service with just a
// db factory. Tests that DO exercise the guard pass a fake.
private readonly IEffectiveNameGuard _guard = guard ?? NoOpEffectiveNameGuard.Instance;
/// <inheritdoc />
public async Task<IReadOnlyList<UnsNode>> LoadStructureAsync(CancellationToken ct = default)
{
@@ -97,6 +106,369 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
.ToListAsync(ct);
}
// ---------------------------------------------------------------------------------------------
// v3 UNS reference-only equipment (Batch 3): the Tags tab lists UnsTagReference rows.
// ---------------------------------------------------------------------------------------------
/// <inheritdoc />
public async Task<IReadOnlyList<EquipmentReferenceRow>> LoadReferencesForEquipmentAsync(
string equipmentId, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var refs = await db.UnsTagReferences.AsNoTracking()
.Where(r => r.EquipmentId == equipmentId)
.OrderBy(r => r.SortOrder).ThenBy(r => r.UnsTagReferenceId)
.Select(r => new
{
r.UnsTagReferenceId,
r.TagId,
r.DisplayNameOverride,
r.SortOrder,
r.RowVersion,
})
.ToListAsync(ct);
if (refs.Count == 0) return Array.Empty<EquipmentReferenceRow>();
// Load the backing raw tags (name + inherited datatype/access), then compute each RawPath from the
// equipment cluster's raw topology through the shared RawPathResolver (identity authority).
var tagIds = refs.Select(r => r.TagId).Distinct().ToList();
var tags = (await db.Tags.AsNoTracking()
.Where(t => tagIds.Contains(t.TagId))
.Select(t => new { t.TagId, t.DeviceId, t.TagGroupId, t.Name, t.DataType, t.AccessLevel })
.ToListAsync(ct))
.ToDictionary(t => t.TagId, StringComparer.Ordinal);
var cluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
var resolver = await BuildClusterRawPathResolverAsync(db, cluster, ct);
var rows = new List<EquipmentReferenceRow>(refs.Count);
foreach (var r in refs)
{
if (!tags.TryGetValue(r.TagId, out var tag))
{
// Backing tag missing (should not happen — raw-tag delete is reference-blocked); surface a
// clearly-degraded row rather than dropping it so the operator can remove the dangling ref.
rows.Add(new EquipmentReferenceRow(
r.UnsTagReferenceId, r.DisplayNameOverride ?? "(missing tag)", "(missing tag)",
"", TagAccessLevel.Read, r.DisplayNameOverride, r.SortOrder, r.RowVersion));
continue;
}
var rawPath = resolver?.TryBuildTagPath(tag.DeviceId, tag.TagGroupId, tag.Name) ?? tag.Name;
var effectiveName = string.IsNullOrEmpty(r.DisplayNameOverride) ? tag.Name : r.DisplayNameOverride;
rows.Add(new EquipmentReferenceRow(
r.UnsTagReferenceId, effectiveName, rawPath, tag.DataType, tag.AccessLevel,
r.DisplayNameOverride, r.SortOrder, r.RowVersion));
}
return rows;
}
/// <inheritdoc />
public async Task<UnsMutationResult> AddReferencesAsync(
string equipmentId, IReadOnlyList<string> tagIds, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(tagIds);
var distinctTagIds = tagIds.Where(t => !string.IsNullOrEmpty(t)).Distinct(StringComparer.Ordinal).ToList();
if (distinctTagIds.Count == 0) return new UnsMutationResult(false, "Pick at least one raw tag to reference.");
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 equipmentCluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
if (equipmentCluster is null)
return new UnsMutationResult(false, $"Equipment '{equipmentId}' does not resolve to a cluster.");
// Load the backing tags + the cluster each lives in (Tag → Device → DriverInstance.ClusterId).
var tags = await db.Tags.AsNoTracking()
.Where(t => distinctTagIds.Contains(t.TagId))
.Join(db.Devices.AsNoTracking(), t => t.DeviceId, dev => dev.DeviceId, (t, dev) => new { t, dev.DriverInstanceId })
.Join(db.DriverInstances.AsNoTracking(), x => x.DriverInstanceId, d => d.DriverInstanceId,
(x, d) => new { x.t.TagId, x.t.Name, DriverCluster = d.ClusterId })
.ToListAsync(ct);
var tagById = tags.ToDictionary(t => t.TagId, StringComparer.Ordinal);
// Existing references on the equipment (to reject re-referencing the same tag).
var alreadyReferenced = (await db.UnsTagReferences.AsNoTracking()
.Where(r => r.EquipmentId == equipmentId)
.Select(r => r.TagId)
.ToListAsync(ct))
.ToHashSet(StringComparer.Ordinal);
var maxSort = await db.UnsTagReferences.AsNoTracking()
.Where(r => r.EquipmentId == equipmentId)
.Select(r => (int?)r.SortOrder)
.MaxAsync(ct) ?? -1;
// Track effective names proposed within THIS batch so two raw tags sharing a Name are caught (the
// guard sees only persisted rows; a same-batch collision would otherwise slip through).
var batchNames = new HashSet<string>(StringComparer.Ordinal);
var pending = new List<UnsTagReference>();
foreach (var tagId in distinctTagIds)
{
if (!tagById.TryGetValue(tagId, out var tag))
return new UnsMutationResult(false, $"Raw tag '{tagId}' not found.");
if (!string.Equals(tag.DriverCluster, equipmentCluster, StringComparison.Ordinal))
return new UnsMutationResult(false,
$"Raw tag '{tag.Name}' is in cluster '{tag.DriverCluster}' but the equipment is in cluster '{equipmentCluster}' — cross-cluster references are not allowed.");
if (alreadyReferenced.Contains(tagId))
return new UnsMutationResult(false, $"Raw tag '{tag.Name}' is already referenced by this equipment.");
// Effective name at add = the raw tag's Name (no override yet).
if (!batchNames.Add(tag.Name))
return new UnsMutationResult(false,
$"Two selected raw tags share the effective name '{tag.Name}'; set a display-name override so they stay unique within the equipment.");
var collision = await _guard.CheckAsync(equipmentId, tag.Name, EffectiveNameSourceKind.Reference, null, ct);
if (collision is not null) return new UnsMutationResult(false, collision);
pending.Add(new UnsTagReference
{
UnsTagReferenceId = $"UTR-{Guid.NewGuid():N}"[..16],
EquipmentId = equipmentId,
TagId = tagId,
DisplayNameOverride = null,
SortOrder = ++maxSort,
});
}
db.UnsTagReferences.AddRange(pending);
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
catch (DbUpdateException)
{
// A concurrent add slipped a same-(equipment,tag) or same-name row past the pre-check.
return new UnsMutationResult(false, "Add failed — a reference at one of these tags was created concurrently. Reload and retry.");
}
}
/// <inheritdoc />
public async Task<UnsMutationResult> RemoveReferenceAsync(
string unsTagReferenceId, byte[] rowVersion, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var entity = await db.UnsTagReferences.FirstOrDefaultAsync(r => r.UnsTagReferenceId == unsTagReferenceId, ct);
if (entity is null) return new UnsMutationResult(true, null);
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
db.UnsTagReferences.Remove(entity);
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
catch (DbUpdateConcurrencyException)
{
return new UnsMutationResult(false, "Another user changed this reference while you were viewing it.");
}
catch (Exception ex)
{
return new UnsMutationResult(false, $"Remove failed: {ex.Message}.");
}
}
/// <inheritdoc />
public async Task<UnsMutationResult> SetReferenceOverrideAsync(
string unsTagReferenceId, string? displayNameOverride, byte[] rowVersion, CancellationToken ct = default)
{
var normalized = string.IsNullOrWhiteSpace(displayNameOverride) ? null : displayNameOverride.Trim();
await using var db = await dbFactory.CreateDbContextAsync(ct);
var entity = await db.UnsTagReferences.FirstOrDefaultAsync(r => r.UnsTagReferenceId == unsTagReferenceId, ct);
if (entity is null) return new UnsMutationResult(false, "Row no longer exists.");
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
// Effective name = the override when set, else the backing raw tag's Name.
var rawName = await db.Tags.AsNoTracking()
.Where(t => t.TagId == entity.TagId)
.Select(t => t.Name)
.FirstOrDefaultAsync(ct);
var effectiveName = normalized ?? rawName ?? string.Empty;
var collision = await _guard.CheckAsync(
entity.EquipmentId, effectiveName, EffectiveNameSourceKind.Reference, entity.UnsTagReferenceId, ct);
if (collision is not null) return new UnsMutationResult(false, collision);
entity.DisplayNameOverride = normalized;
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
catch (DbUpdateConcurrencyException)
{
return new UnsMutationResult(false, "Another user changed this reference while you were editing.");
}
}
/// <inheritdoc />
public async Task<RawNode?> LoadReferencePickerRootAsync(string equipmentId, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var clusterId = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
if (clusterId is null) return null;
var cluster = await db.ServerClusters.AsNoTracking()
.FirstOrDefaultAsync(c => c.ClusterId == clusterId, ct);
if (cluster is null) return null;
// Root child count = cluster-root folders + cluster-root drivers (the RawTree lazily loads the rest
// via IRawTreeService.LoadChildrenAsync, keyed on this node's Cluster kind + EntityId).
var rootFolders = await db.RawFolders.AsNoTracking()
.CountAsync(f => f.ClusterId == clusterId && f.ParentRawFolderId == null, ct);
var rootDrivers = await db.DriverInstances.AsNoTracking()
.CountAsync(d => d.ClusterId == clusterId && d.RawFolderId == null, ct);
return new RawNode
{
Kind = RawNodeKind.Cluster,
Key = $"clu:{clusterId}",
DisplayName = cluster.Name,
ClusterId = clusterId,
EntityId = clusterId,
HasLazyChildren = true,
ChildCount = rootFolders + rootDrivers,
};
}
/// <inheritdoc />
public async Task<IReadOnlyList<string>> LoadDescendantTagIdsAsync(RawNode node, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(node);
if (node.EntityId is null) return Array.Empty<string>();
await using var db = await dbFactory.CreateDbContextAsync(ct);
switch (node.Kind)
{
case RawNodeKind.Device:
// Every tag under the device (across all its tag groups).
return await db.Tags.AsNoTracking()
.Where(t => t.DeviceId == node.EntityId)
.Select(t => t.TagId)
.ToListAsync(ct);
case RawNodeKind.TagGroup:
{
// The group + all descendant groups, then every tag in that group set.
var group = await db.TagGroups.AsNoTracking()
.FirstOrDefaultAsync(g => g.TagGroupId == node.EntityId, ct);
if (group is null) return Array.Empty<string>();
var deviceGroups = await db.TagGroups.AsNoTracking()
.Where(g => g.DeviceId == group.DeviceId)
.Select(g => new { g.TagGroupId, g.ParentTagGroupId })
.ToListAsync(ct);
var childrenByParent = deviceGroups
.Where(g => g.ParentTagGroupId is not null)
.GroupBy(g => g.ParentTagGroupId!, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.Select(x => x.TagGroupId).ToList(), StringComparer.Ordinal);
var groupIds = DescendantGroupsInclusive(node.EntityId, childrenByParent);
return await db.Tags.AsNoTracking()
.Where(t => t.TagGroupId != null && groupIds.Contains(t.TagGroupId))
.Select(t => t.TagId)
.ToListAsync(ct);
}
default:
// The picker only offers "select all" on Device / TagGroup containers.
return Array.Empty<string>();
}
}
/// <summary>Returns <paramref name="rootId"/> plus all transitive descendant group ids (iterative walk).</summary>
private static HashSet<string> DescendantGroupsInclusive(
string rootId, IReadOnlyDictionary<string, List<string>> childrenByParent)
{
var result = new HashSet<string>(StringComparer.Ordinal);
var stack = new Stack<string>();
stack.Push(rootId);
while (stack.Count > 0)
{
var id = stack.Pop();
if (!result.Add(id)) continue;
if (childrenByParent.TryGetValue(id, out var kids))
{
foreach (var kid in kids) stack.Push(kid);
}
}
return result;
}
/// <summary>
/// Builds a <see cref="RawPathResolver"/> over a cluster's raw topology (folders / drivers / devices /
/// tag-groups), so a referenced tag's RawPath can be computed the same way the deploy validator does.
/// Returns <see langword="null"/> when the cluster is unknown/null (callers then fall back to bare name).
/// </summary>
private static async Task<RawPathResolver?> BuildClusterRawPathResolverAsync(
OtOpcUaConfigDbContext db, string? clusterId, CancellationToken ct)
{
if (string.IsNullOrEmpty(clusterId)) return null;
var folders = (await db.RawFolders.AsNoTracking()
.Where(f => f.ClusterId == clusterId)
.Select(f => new { f.RawFolderId, f.ParentRawFolderId, f.Name })
.ToListAsync(ct))
.ToDictionary(f => f.RawFolderId, f => ((string?)f.ParentRawFolderId, f.Name), StringComparer.Ordinal);
var drivers = (await db.DriverInstances.AsNoTracking()
.Where(d => d.ClusterId == clusterId)
.Select(d => new { d.DriverInstanceId, d.RawFolderId, d.Name })
.ToListAsync(ct))
.ToDictionary(d => d.DriverInstanceId, d => ((string?)d.RawFolderId, d.Name), StringComparer.Ordinal);
var driverIds = drivers.Keys.ToList();
var devices = (await db.Devices.AsNoTracking()
.Where(dev => driverIds.Contains(dev.DriverInstanceId))
.Select(dev => new { dev.DeviceId, dev.DriverInstanceId, dev.Name })
.ToListAsync(ct))
.ToDictionary(dev => dev.DeviceId, dev => (dev.DriverInstanceId, dev.Name), StringComparer.Ordinal);
var deviceIds = devices.Keys.ToList();
var groups = (await db.TagGroups.AsNoTracking()
.Where(g => deviceIds.Contains(g.DeviceId))
.Select(g => new { g.TagGroupId, g.ParentTagGroupId, g.Name })
.ToListAsync(ct))
.ToDictionary(g => g.TagGroupId, g => ((string?)g.ParentTagGroupId, g.Name), StringComparer.Ordinal);
return new RawPathResolver(folders, drivers, devices, groups);
}
/// <summary>
/// A no-op <see cref="IEffectiveNameGuard"/> used only when no guard was injected (unit tests that do
/// not exercise the guard; a mis-wired DI). It always reports "free" — the deploy gate remains the
/// backstop. Production always injects the real WP2 guard.
/// </summary>
private sealed class NoOpEffectiveNameGuard : IEffectiveNameGuard
{
public static readonly NoOpEffectiveNameGuard Instance = new();
public Task<string?> CheckAsync(
string equipmentId, string proposedEffectiveName, EffectiveNameSourceKind kind,
string? excludeSourceId, CancellationToken ct = default) => Task.FromResult<string?>(null);
}
/// <inheritdoc />
public async Task<AreaEditDto?> LoadAreaAsync(string unsAreaId, CancellationToken ct = default)
{
@@ -446,11 +818,8 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
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;
}
// v3: equipment no longer binds a driver — the reference-only Tags tab points at raw tags — so
// the old decision-#122 driver-cluster guard is gone.
var uuid = Guid.NewGuid();
var equipmentId = $"EQ-{uuid.ToString("N")[..12]}";
@@ -514,14 +883,7 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
continue;
}
// Reuse the driver/line cluster 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;
}
// v3: equipment no longer binds a driver, so there is no per-row driver-cluster guard.
var uuid = Guid.NewGuid();
var equipmentId = $"EQ-{uuid.ToString("N")[..12]}";
@@ -578,11 +940,7 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
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;
}
// v3: equipment no longer binds a driver — no decision-#122 driver-cluster guard.
// Equipment↔driver binding retired in v3 — no DriverInstanceId column remains.
entity.UnsLineId = input.UnsLineId;
@@ -920,6 +1278,11 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
return new UnsMutationResult(false, $"A virtual tag named '{input.Name}' already exists on this equipment.");
}
// v3 Batch 3: the effective name must also be unique against this equipment's references + scripted
// alarms (they share the equipment's UNS NodeId space) — the authoring mirror of the deploy gate.
var nameGuard = await _guard.CheckAsync(equipmentId, input.Name, EffectiveNameSourceKind.VirtualTag, null, ct);
if (nameGuard is not null) return new UnsMutationResult(false, nameGuard);
var equipGuard = await ValidateEquipTokenAsync(db, equipmentId, input.ScriptId, ct);
if (equipGuard is not null) return equipGuard.Value;
@@ -969,6 +1332,10 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
return new UnsMutationResult(false, $"A virtual tag named '{input.Name}' already exists on this equipment.");
}
// v3 Batch 3: effective-name uniqueness across references + scripted alarms (excluding this VT).
var nameGuard = await _guard.CheckAsync(entity.EquipmentId, input.Name, EffectiveNameSourceKind.VirtualTag, virtualTagId, ct);
if (nameGuard is not null) return new UnsMutationResult(false, nameGuard);
var equipGuard = await ValidateEquipTokenAsync(db, entity.EquipmentId, input.ScriptId, ct);
if (equipGuard is not null) return equipGuard.Value;
@@ -1193,58 +1560,6 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
return null;
}
/// <summary>
/// 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)
{
@@ -1277,6 +1592,10 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
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.");
// v3 Batch 3: effective-name uniqueness across references + VirtualTags on this equipment.
var nameGuard = await _guard.CheckAsync(equipmentId, input.Name, EffectiveNameSourceKind.ScriptedAlarm, null, ct);
if (nameGuard is not null) return new UnsMutationResult(false, nameGuard);
db.ScriptedAlarms.Add(new ScriptedAlarm
{
ScriptedAlarmId = input.ScriptedAlarmId,
@@ -1307,6 +1626,10 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
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.");
// v3 Batch 3: effective-name uniqueness across references + VirtualTags (excluding this alarm).
var nameGuard = await _guard.CheckAsync(entity.EquipmentId, input.Name, EffectiveNameSourceKind.ScriptedAlarm, scriptedAlarmId, ct);
if (nameGuard is not null) return new UnsMutationResult(false, nameGuard);
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
entity.Name = input.Name;
entity.AlarmType = input.AlarmType;