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,
};
}
}