using System.Security.Cryptography; using System.Text; using Microsoft.EntityFrameworkCore; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; /// /// Default . Reads the structural rows with a handful of /// untracked queries, computes per-equipment tag/virtual-tag counts, and hands the flat /// rows to the pure to nest into the browse tree. /// /// /// Each call creates and disposes its own context via the pooled factory — the same pattern /// every AdminUI page uses — so the service is safe to register as Scoped and used per /// Blazor circuit. /// public sealed class UnsTreeService( IDbContextFactory 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; /// public async Task> LoadStructureAsync(CancellationToken ct = default) { await using var db = await dbFactory.CreateDbContextAsync(ct); var clusters = await db.ServerClusters .AsNoTracking() .Select(c => new ClusterRow(c.ClusterId, c.Enterprise, c.Site, c.Name)) .ToListAsync(ct); var areas = await db.UnsAreas .AsNoTracking() .Select(a => new AreaRow(a.UnsAreaId, a.ClusterId, a.Name)) .ToListAsync(ct); var lines = await db.UnsLines .AsNoTracking() .Select(l => new LineRow(l.UnsLineId, l.UnsAreaId, l.Name)) .ToListAsync(ct); var equipmentRows = await db.Equipment .AsNoTracking() .Select(e => new { e.EquipmentId, e.UnsLineId, e.MachineCode, e.Name, }) .ToListAsync(ct); // Per-equipment driver-tag counts: the equipment↔Tag binding was retired in v3 // (Tag is now Raw-only; authoring moved to the Raw tree in Batch 2/3). No binding // exists to count against, so every equipment reports zero driver tags. var tagCounts = new Dictionary(StringComparer.Ordinal); // Per-equipment virtual-tag counts (virtual tags with no equipment are excluded). var vtagCounts = (await db.VirtualTags .AsNoTracking() .Where(v => v.EquipmentId != null) .GroupBy(v => v.EquipmentId) .Select(g => new { EquipmentId = g.Key!, Count = g.Count() }) .ToListAsync(ct)) .ToDictionary(x => x.EquipmentId, x => x.Count, StringComparer.Ordinal); var equipment = equipmentRows .Select(e => new EquipmentRow( e.EquipmentId, e.UnsLineId, e.MachineCode, e.Name, tagCounts.GetValueOrDefault(e.EquipmentId), vtagCounts.GetValueOrDefault(e.EquipmentId))) .ToList(); return UnsTreeAssembly.Build(clusters, areas, lines, equipment); } /// public Task> LoadTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default) { // The equipment↔Tag binding was retired in v3 (Tag is now Raw-only; authoring moved to // the Raw tree in Batch 2/3). No equipment-bound tags exist to list. return Task.FromResult>(Array.Empty()); } /// public async Task> LoadVirtualTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default) { await using var db = await dbFactory.CreateDbContextAsync(ct); return await db.VirtualTags.AsNoTracking() .Where(v => v.EquipmentId == equipmentId) .OrderBy(v => v.Name) .Select(v => new EquipmentVirtualTagRow(v.VirtualTagId, v.Name, v.DataType, v.ScriptId, v.Enabled)) .ToListAsync(ct); } // --------------------------------------------------------------------------------------------- // v3 UNS reference-only equipment (Batch 3): the Tags tab lists UnsTagReference rows. // --------------------------------------------------------------------------------------------- /// public async Task> 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(); // 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(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; } /// public async Task AddReferencesAsync( string equipmentId, IReadOnlyList 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(StringComparer.Ordinal); var pending = new List(); 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."); } } /// public async Task 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}."); } } /// public async Task 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."); } } /// public async Task 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, }; } /// public async Task> LoadDescendantTagIdsAsync(RawNode node, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(node); if (node.EntityId is null) return Array.Empty(); 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(); 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(); } } /// Returns plus all transitive descendant group ids (iterative walk). private static HashSet DescendantGroupsInclusive( string rootId, IReadOnlyDictionary> childrenByParent) { var result = new HashSet(StringComparer.Ordinal); var stack = new Stack(); 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; } /// /// Builds a 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 when the cluster is unknown/null (callers then fall back to bare name). /// private static async Task 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); } /// /// A no-op 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. /// private sealed class NoOpEffectiveNameGuard : IEffectiveNameGuard { public static readonly NoOpEffectiveNameGuard Instance = new(); public Task CheckAsync( string equipmentId, string proposedEffectiveName, EffectiveNameSourceKind kind, string? excludeSourceId, CancellationToken ct = default) => Task.FromResult(null); } /// public async Task LoadAreaAsync(string unsAreaId, CancellationToken ct = default) { await using var db = await dbFactory.CreateDbContextAsync(ct); return await db.UnsAreas .AsNoTracking() .Where(a => a.UnsAreaId == unsAreaId) .Select(a => new AreaEditDto(a.UnsAreaId, a.Name, a.Notes, a.ClusterId, a.RowVersion)) .FirstOrDefaultAsync(ct); } /// public async Task LoadLineAsync(string unsLineId, CancellationToken ct = default) { await using var db = await dbFactory.CreateDbContextAsync(ct); return await db.UnsLines .AsNoTracking() .Where(l => l.UnsLineId == unsLineId) .Select(l => new LineEditDto(l.UnsLineId, l.UnsAreaId, l.Name, l.Notes, l.RowVersion)) .FirstOrDefaultAsync(ct); } /// public async Task LoadEquipmentAsync(string equipmentId, CancellationToken ct = default) { await using var db = await dbFactory.CreateDbContextAsync(ct); return await db.Equipment .AsNoTracking() .Where(e => e.EquipmentId == equipmentId) .Select(e => new EquipmentEditDto( e.EquipmentId, e.Name, e.MachineCode, e.UnsLineId, // Equipment↔driver binding retired in v3 — no DriverInstanceId column remains. null, e.ZTag, e.SAPID, e.Manufacturer, e.Model, e.SerialNumber, e.HardwareRevision, e.SoftwareRevision, e.YearOfConstruction, e.AssetLocation, e.ManufacturerUri, e.DeviceManualUri, e.Enabled, e.RowVersion)) .FirstOrDefaultAsync(ct); } /// public async Task LoadTagAsync(string tagId, CancellationToken ct = default) { await using var db = await dbFactory.CreateDbContextAsync(ct); return await db.Tags .AsNoTracking() .Where(t => t.TagId == tagId) .Select(t => new TagEditDto( t.TagId, // Tag is Raw-only in v3 — equipment binding + driver binding retired. string.Empty, t.Name, string.Empty, t.DataType, t.AccessLevel, t.WriteIdempotent, t.PollGroupId, t.TagConfig, t.RowVersion)) .FirstOrDefaultAsync(ct); } /// public async Task LoadVirtualTagAsync(string virtualTagId, CancellationToken ct = default) { await using var db = await dbFactory.CreateDbContextAsync(ct); return await db.VirtualTags .AsNoTracking() .Where(v => v.VirtualTagId == virtualTagId) .Select(v => new VirtualTagEditDto( v.VirtualTagId, v.EquipmentId, v.Name, v.DataType, v.ScriptId, v.ChangeTriggered, v.TimerIntervalMs, v.Historize, v.Enabled, v.RowVersion)) .FirstOrDefaultAsync(ct); } /// public async Task> LoadDriversForClusterAsync( string clusterId, CancellationToken ct = default) { await using var db = await dbFactory.CreateDbContextAsync(ct); var drivers = await db.DriverInstances .AsNoTracking() .Where(d => d.ClusterId == clusterId) .OrderBy(d => d.DriverInstanceId) .Select(d => new { d.DriverInstanceId, d.Name, d.DriverType }) .ToListAsync(ct); return drivers .Select(d => (d.DriverInstanceId, Display: $"{d.DriverInstanceId} — {d.Name} ({d.DriverType})")) .ToList(); } /// public async Task LoadEquipmentPickContextAsync(string? lineId, CancellationToken ct = default) { var empty = new EquipmentPickContext(Array.Empty<(string, string)>(), Array.Empty<(string, string)>()); if (string.IsNullOrEmpty(lineId)) return empty; await using var db = await dbFactory.CreateDbContextAsync(ct); // line -> area -> clusterId var clusterId = await (from l in db.UnsLines.AsNoTracking() join a in db.UnsAreas.AsNoTracking() on l.UnsAreaId equals a.UnsAreaId where l.UnsLineId == lineId select a.ClusterId).FirstOrDefaultAsync(ct); if (string.IsNullOrEmpty(clusterId)) return empty; // all lines in that cluster (for the line