namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; /// /// The kind of node within the unified-namespace (UNS) browse tree. Determines /// how the renderer styles the row and which entity (if any) it links to. /// public enum UnsNodeKind { Enterprise, Cluster, Area, Line, Equipment, Tag, VirtualTag, } /// /// View-model for a single node in the UNS browse tree. Carries the stable /// identity, display text and lazy-load metadata the renderer needs, plus /// transient UI state (expansion/loading) that is never persisted. /// public sealed class UnsNode { /// The kind of node — drives styling and entity linking. public required UnsNodeKind Kind { get; init; } /// Stable per-node identifier, unique within the tree (e.g. eq:{equipmentId}). public required string Key { get; init; } /// Human-readable label shown in the tree. public required string DisplayName { get; init; } /// Owning cluster id for Area/Line/Equipment (and the cluster itself); null for Enterprise. public string? ClusterId { get; init; } /// This node's own logical entity id (UnsAreaId/UnsLineId/EquipmentId/TagId/VirtualTagId); null for Enterprise. public string? EntityId { get; init; } /// Badge count. For equipment this is tag + virtual-tag count; for container nodes it is the direct child count. public int ChildCount { get; set; } /// /// Gets a value indicating whether this node has children that load lazily (equipment with tags/virtual tags). /// Structural nodes (Cluster/Area/Line) always carry false even when they have eager children, so a /// renderer must decide whether to show an expand chevron with Children.Count > 0 || HasLazyChildren, /// not on HasLazyChildren alone. /// public bool HasLazyChildren { get; init; } /// Eagerly-materialised children. Empty for lazy-loaded equipment until expanded. public List Children { get; } = new(); // --- Runtime UI state (not persisted) --- /// Whether the node is currently expanded in the UI. public bool Expanded { get; set; } /// Whether the node's lazy children have been loaded. public bool Loaded { get; set; } /// Whether a lazy-load is currently in flight for this node. public bool Loading { get; set; } /// Last load error message, if any. public string? Error { get; set; } } /// Flat structural row describing a cluster and its enterprise/site placement. public readonly record struct ClusterRow(string ClusterId, string Enterprise, string Site, string Name); /// Flat structural row describing a UNS area and its owning cluster. public readonly record struct AreaRow(string UnsAreaId, string ClusterId, string Name); /// Flat structural row describing a UNS line and its owning area. public readonly record struct LineRow(string UnsLineId, string UnsAreaId, string Name); /// Flat structural row describing an equipment node, its owning line and its tag/virtual-tag counts. public readonly record struct EquipmentRow( string EquipmentId, string UnsLineId, string MachineCode, string Name, int TagCount, int VirtualTagCount); /// /// Pure, EF-free assembly of the UNS browse tree from flat structural rows. /// Builds Enterprise → Cluster → Area → Line → Equipment with deterministic /// ordinal ordering and equipment lazy-load metadata. /// public static class UnsTreeAssembly { /// /// Builds the Enterprise→Cluster→Area→Line→Equipment tree. Empty clusters /// (and enterprises whose clusters have no areas) are retained. Ordering is /// deterministic and ordinal at every level. /// public static IReadOnlyList Build( IReadOnlyList clusters, IReadOnlyList areas, IReadOnlyList lines, IReadOnlyList equipment) { // Index children by their parent key for O(1) lookup during nesting. var areasByCluster = areas .GroupBy(a => a.ClusterId, StringComparer.Ordinal) .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal); var linesByArea = lines .GroupBy(l => l.UnsAreaId, StringComparer.Ordinal) .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal); var equipmentByLine = equipment .GroupBy(e => e.UnsLineId, StringComparer.Ordinal) .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal); // Top level: distinct enterprises, ordered ordinally. var enterprises = clusters .GroupBy(c => c.Enterprise, StringComparer.Ordinal) .OrderBy(g => g.Key, StringComparer.Ordinal) .Select(entGroup => { var clusterNodes = entGroup .OrderBy(c => c.Name, StringComparer.Ordinal) .ThenBy(c => c.ClusterId, StringComparer.Ordinal) .Select(c => BuildCluster(c, areasByCluster, linesByArea, equipmentByLine)) .ToList(); var ent = new UnsNode { Kind = UnsNodeKind.Enterprise, Key = $"ent:{entGroup.Key}", DisplayName = entGroup.Key, ClusterId = null, EntityId = null, HasLazyChildren = false, }; ent.Children.AddRange(clusterNodes); ent.ChildCount = ent.Children.Count; return ent; }) .ToList(); return enterprises; } private static UnsNode BuildCluster( ClusterRow cluster, IReadOnlyDictionary> areasByCluster, IReadOnlyDictionary> linesByArea, IReadOnlyDictionary> equipmentByLine) { var areaNodes = (areasByCluster.TryGetValue(cluster.ClusterId, out var clusterAreas) ? clusterAreas : Enumerable.Empty()) .OrderBy(a => a.Name, StringComparer.Ordinal) .ThenBy(a => a.UnsAreaId, StringComparer.Ordinal) .Select(a => BuildArea(a, cluster.ClusterId, linesByArea, equipmentByLine)) .ToList(); var node = new UnsNode { Kind = UnsNodeKind.Cluster, Key = $"clu:{cluster.ClusterId}", DisplayName = string.IsNullOrEmpty(cluster.Site) ? cluster.Name : $"{cluster.Site} ({cluster.Name})", ClusterId = cluster.ClusterId, EntityId = cluster.ClusterId, // Cluster's own logical id IS its ClusterId — EntityId mirrors it for uniform navigation. HasLazyChildren = false, }; node.Children.AddRange(areaNodes); node.ChildCount = node.Children.Count; return node; } private static UnsNode BuildArea( AreaRow area, string clusterId, IReadOnlyDictionary> linesByArea, IReadOnlyDictionary> equipmentByLine) { var lineNodes = (linesByArea.TryGetValue(area.UnsAreaId, out var areaLines) ? areaLines : Enumerable.Empty()) .OrderBy(l => l.Name, StringComparer.Ordinal) .ThenBy(l => l.UnsLineId, StringComparer.Ordinal) .Select(l => BuildLine(l, clusterId, equipmentByLine)) .ToList(); var node = new UnsNode { Kind = UnsNodeKind.Area, Key = $"area:{area.UnsAreaId}", DisplayName = area.Name, ClusterId = clusterId, EntityId = area.UnsAreaId, HasLazyChildren = false, }; node.Children.AddRange(lineNodes); node.ChildCount = node.Children.Count; return node; } private static UnsNode BuildLine( LineRow line, string clusterId, IReadOnlyDictionary> equipmentByLine) { var equipmentNodes = (equipmentByLine.TryGetValue(line.UnsLineId, out var lineEquipment) ? lineEquipment : Enumerable.Empty()) .OrderBy(e => e.Name, StringComparer.Ordinal) .ThenBy(e => e.EquipmentId, StringComparer.Ordinal) .Select(e => BuildEquipment(e, clusterId)) .ToList(); var node = new UnsNode { Kind = UnsNodeKind.Line, Key = $"line:{line.UnsLineId}", DisplayName = line.Name, ClusterId = clusterId, EntityId = line.UnsLineId, HasLazyChildren = false, }; node.Children.AddRange(equipmentNodes); node.ChildCount = node.Children.Count; return node; } private static UnsNode BuildEquipment(EquipmentRow equipment, string clusterId) { var childCount = equipment.TagCount + equipment.VirtualTagCount; return new UnsNode { Kind = UnsNodeKind.Equipment, Key = $"eq:{equipment.EquipmentId}", DisplayName = equipment.Name, ClusterId = clusterId, EntityId = equipment.EquipmentId, ChildCount = childCount, HasLazyChildren = childCount > 0, }; } }