Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
214 lines
11 KiB
C#
214 lines
11 KiB
C#
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
|
|
|
/// <summary>
|
|
/// Pure-function impact preview for UNS structural moves per Phase 6.4 Stream A.2. Given
|
|
/// a <see cref="UnsMoveOperation"/> plus a snapshot of the draft's UNS tree and its
|
|
/// equipment + tag counts, returns an <see cref="UnsImpactPreview"/> the Admin UI shows
|
|
/// in a confirmation modal before committing the move.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>Stateless + deterministic — testable without EF or a live draft. The caller
|
|
/// (Razor page) loads the draft's snapshot via the normal Configuration services, passes
|
|
/// it in, and the analyzer counts + categorises the impact. The returned
|
|
/// <see cref="UnsImpactPreview.RevisionToken"/> is the token the caller must re-check at
|
|
/// confirm time; a mismatch means another operator mutated the draft between preview +
|
|
/// confirm and the operation needs to be refreshed (decision on concurrent-edit safety
|
|
/// in Phase 6.4 Scope).</para>
|
|
///
|
|
/// <para>Cross-cluster moves are rejected here (decision #82) — equipment is
|
|
/// cluster-scoped; the UI disables the drop target and surfaces an Export/Import workflow
|
|
/// toast instead.</para>
|
|
/// </remarks>
|
|
public static class UnsImpactAnalyzer
|
|
{
|
|
/// <summary>Run the analyzer. Returns a populated preview or throws for invalid operations.</summary>
|
|
public static UnsImpactPreview Analyze(UnsTreeSnapshot snapshot, UnsMoveOperation move)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(snapshot);
|
|
ArgumentNullException.ThrowIfNull(move);
|
|
|
|
// Cross-cluster guard — the analyzer refuses rather than silently re-homing.
|
|
if (!string.Equals(move.SourceClusterId, move.TargetClusterId, StringComparison.OrdinalIgnoreCase))
|
|
throw new CrossClusterMoveRejectedException(
|
|
"Equipment is cluster-scoped (decision #82). Use Export → Import to migrate equipment " +
|
|
"across clusters; drag/drop rejected.");
|
|
|
|
return move.Kind switch
|
|
{
|
|
UnsMoveKind.LineMove => AnalyzeLineMove(snapshot, move),
|
|
UnsMoveKind.AreaRename => AnalyzeAreaRename(snapshot, move),
|
|
UnsMoveKind.LineMerge => AnalyzeLineMerge(snapshot, move),
|
|
_ => throw new ArgumentOutOfRangeException(nameof(move), move.Kind, $"Unsupported move kind {move.Kind}"),
|
|
};
|
|
}
|
|
|
|
private static UnsImpactPreview AnalyzeLineMove(UnsTreeSnapshot snapshot, UnsMoveOperation move)
|
|
{
|
|
var line = snapshot.FindLine(move.SourceLineId!)
|
|
?? throw new UnsMoveValidationException($"Source line '{move.SourceLineId}' not found in draft {snapshot.DraftGenerationId}.");
|
|
|
|
var targetArea = snapshot.FindArea(move.TargetAreaId!)
|
|
?? throw new UnsMoveValidationException($"Target area '{move.TargetAreaId}' not found in draft {snapshot.DraftGenerationId}.");
|
|
|
|
var warnings = new List<string>();
|
|
if (targetArea.LineIds.Contains(line.LineId, StringComparer.OrdinalIgnoreCase))
|
|
warnings.Add($"Target area '{targetArea.Name}' already contains line '{line.Name}' — dropping a no-op move.");
|
|
|
|
// If the target area has a line with the same display name as the mover, warn about
|
|
// visual ambiguity even though the IDs differ (operators frequently reuse line names).
|
|
if (targetArea.LineIds.Any(lid =>
|
|
snapshot.FindLine(lid) is { } sibling &&
|
|
string.Equals(sibling.Name, line.Name, StringComparison.OrdinalIgnoreCase) &&
|
|
!string.Equals(sibling.LineId, line.LineId, StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
warnings.Add($"Target area '{targetArea.Name}' already has a line named '{line.Name}'. Consider renaming before the move.");
|
|
}
|
|
|
|
return new UnsImpactPreview
|
|
{
|
|
AffectedEquipmentCount = line.EquipmentCount,
|
|
AffectedTagCount = line.TagCount,
|
|
CascadeWarnings = warnings,
|
|
RevisionToken = snapshot.RevisionToken,
|
|
HumanReadableSummary =
|
|
$"Moving line '{line.Name}' from area '{snapshot.FindAreaByLineId(line.LineId)?.Name ?? "?"}' " +
|
|
$"to '{targetArea.Name}' will re-home {line.EquipmentCount} equipment + re-parent {line.TagCount} tags.",
|
|
};
|
|
}
|
|
|
|
private static UnsImpactPreview AnalyzeAreaRename(UnsTreeSnapshot snapshot, UnsMoveOperation move)
|
|
{
|
|
var area = snapshot.FindArea(move.SourceAreaId!)
|
|
?? throw new UnsMoveValidationException($"Source area '{move.SourceAreaId}' not found in draft {snapshot.DraftGenerationId}.");
|
|
|
|
var affectedEquipment = area.LineIds
|
|
.Select(lid => snapshot.FindLine(lid)?.EquipmentCount ?? 0)
|
|
.Sum();
|
|
var affectedTags = area.LineIds
|
|
.Select(lid => snapshot.FindLine(lid)?.TagCount ?? 0)
|
|
.Sum();
|
|
|
|
return new UnsImpactPreview
|
|
{
|
|
AffectedEquipmentCount = affectedEquipment,
|
|
AffectedTagCount = affectedTags,
|
|
CascadeWarnings = [],
|
|
RevisionToken = snapshot.RevisionToken,
|
|
HumanReadableSummary =
|
|
$"Renaming area '{area.Name}' → '{move.NewName}' cascades to {area.LineIds.Count} lines / " +
|
|
$"{affectedEquipment} equipment / {affectedTags} tags.",
|
|
};
|
|
}
|
|
|
|
private static UnsImpactPreview AnalyzeLineMerge(UnsTreeSnapshot snapshot, UnsMoveOperation move)
|
|
{
|
|
var src = snapshot.FindLine(move.SourceLineId!)
|
|
?? throw new UnsMoveValidationException($"Source line '{move.SourceLineId}' not found.");
|
|
var dst = snapshot.FindLine(move.TargetLineId!)
|
|
?? throw new UnsMoveValidationException($"Target line '{move.TargetLineId}' not found.");
|
|
|
|
var warnings = new List<string>();
|
|
if (!string.Equals(snapshot.FindAreaByLineId(src.LineId)?.AreaId,
|
|
snapshot.FindAreaByLineId(dst.LineId)?.AreaId,
|
|
StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
warnings.Add($"Lines '{src.Name}' and '{dst.Name}' are in different areas. The merge will re-parent equipment + tags into '{dst.Name}'s area.");
|
|
}
|
|
|
|
return new UnsImpactPreview
|
|
{
|
|
AffectedEquipmentCount = src.EquipmentCount,
|
|
AffectedTagCount = src.TagCount,
|
|
CascadeWarnings = warnings,
|
|
RevisionToken = snapshot.RevisionToken,
|
|
HumanReadableSummary =
|
|
$"Merging line '{src.Name}' into '{dst.Name}': {src.EquipmentCount} equipment + {src.TagCount} tags re-parent. " +
|
|
$"The source line is deleted at commit.",
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>Kind of UNS structural move the analyzer understands.</summary>
|
|
public enum UnsMoveKind
|
|
{
|
|
/// <summary>Drag a whole line from one area to another.</summary>
|
|
LineMove,
|
|
|
|
/// <summary>Rename an area (cascades to the UNS paths of every equipment + tag below it).</summary>
|
|
AreaRename,
|
|
|
|
/// <summary>Merge two lines into one; source line's equipment + tags are re-parented.</summary>
|
|
LineMerge,
|
|
}
|
|
|
|
/// <summary>One UNS structural move request.</summary>
|
|
/// <param name="Kind">Move variant — selects which source + target fields are required.</param>
|
|
/// <param name="SourceClusterId">Cluster of the source node. Must match <see cref="TargetClusterId"/> (decision #82).</param>
|
|
/// <param name="TargetClusterId">Cluster of the target node.</param>
|
|
/// <param name="SourceAreaId">Source area id for <see cref="UnsMoveKind.AreaRename"/>.</param>
|
|
/// <param name="SourceLineId">Source line id for <see cref="UnsMoveKind.LineMove"/> / <see cref="UnsMoveKind.LineMerge"/>.</param>
|
|
/// <param name="TargetAreaId">Target area id for <see cref="UnsMoveKind.LineMove"/>.</param>
|
|
/// <param name="TargetLineId">Target line id for <see cref="UnsMoveKind.LineMerge"/>.</param>
|
|
/// <param name="NewName">New display name for <see cref="UnsMoveKind.AreaRename"/>.</param>
|
|
public sealed record UnsMoveOperation(
|
|
UnsMoveKind Kind,
|
|
string SourceClusterId,
|
|
string TargetClusterId,
|
|
string? SourceAreaId = null,
|
|
string? SourceLineId = null,
|
|
string? TargetAreaId = null,
|
|
string? TargetLineId = null,
|
|
string? NewName = null);
|
|
|
|
/// <summary>Snapshot of the UNS tree + counts the analyzer walks.</summary>
|
|
public sealed class UnsTreeSnapshot
|
|
{
|
|
public required long DraftGenerationId { get; init; }
|
|
public required DraftRevisionToken RevisionToken { get; init; }
|
|
public required IReadOnlyList<UnsAreaSummary> Areas { get; init; }
|
|
public required IReadOnlyList<UnsLineSummary> Lines { get; init; }
|
|
|
|
public UnsAreaSummary? FindArea(string areaId) =>
|
|
Areas.FirstOrDefault(a => string.Equals(a.AreaId, areaId, StringComparison.OrdinalIgnoreCase));
|
|
|
|
public UnsLineSummary? FindLine(string lineId) =>
|
|
Lines.FirstOrDefault(l => string.Equals(l.LineId, lineId, StringComparison.OrdinalIgnoreCase));
|
|
|
|
public UnsAreaSummary? FindAreaByLineId(string lineId) =>
|
|
Areas.FirstOrDefault(a => a.LineIds.Contains(lineId, StringComparer.OrdinalIgnoreCase));
|
|
}
|
|
|
|
public sealed record UnsAreaSummary(string AreaId, string Name, IReadOnlyList<string> LineIds);
|
|
|
|
public sealed record UnsLineSummary(string LineId, string Name, int EquipmentCount, int TagCount);
|
|
|
|
/// <summary>
|
|
/// Opaque per-draft revision fingerprint. Preview fetches the current token + stores it
|
|
/// in the <see cref="UnsImpactPreview.RevisionToken"/>. Confirm compares the token against
|
|
/// the draft's live value; mismatch means another operator mutated the draft between
|
|
/// preview + commit — raise <c>409 Conflict / refresh-required</c> in the UI.
|
|
/// </summary>
|
|
public sealed record DraftRevisionToken(string Value)
|
|
{
|
|
/// <summary>Compare two tokens for equality; null-safe.</summary>
|
|
public bool Matches(DraftRevisionToken? other) =>
|
|
other is not null &&
|
|
string.Equals(Value, other.Value, StringComparison.Ordinal);
|
|
}
|
|
|
|
/// <summary>Output of <see cref="UnsImpactAnalyzer.Analyze"/>.</summary>
|
|
public sealed class UnsImpactPreview
|
|
{
|
|
public required int AffectedEquipmentCount { get; init; }
|
|
public required int AffectedTagCount { get; init; }
|
|
public required IReadOnlyList<string> CascadeWarnings { get; init; }
|
|
public required DraftRevisionToken RevisionToken { get; init; }
|
|
public required string HumanReadableSummary { get; init; }
|
|
}
|
|
|
|
/// <summary>Thrown when a move targets a different cluster than the source (decision #82).</summary>
|
|
public sealed class CrossClusterMoveRejectedException(string message) : Exception(message);
|
|
|
|
/// <summary>Thrown when the move operation references a source / target that doesn't exist in the draft.</summary>
|
|
public sealed class UnsMoveValidationException(string message) : Exception(message);
|