Ships the pure-logic data layer of Phase 6.4. Blazor UI pieces (UnsTab drag/drop, CSV import modal, preview table, FinaliseImportBatch txn, staging tables) are deferred to visual-compliance follow-ups (tasks #153, #155, #157). Admin.Services additions: - UnsImpactAnalyzer.Analyze(snapshot, move) — pure-function, no I/O. Three move variants: LineMove, AreaRename, LineMerge. Returns UnsImpactPreview with AffectedEquipmentCount + AffectedTagCount + CascadeWarnings + RevisionToken + HumanReadableSummary the Admin UI shows in the confirm modal. Cross-cluster moves rejected with CrossClusterMoveRejectedException per decision #82. Missing source/target throws UnsMoveValidationException. Surfaces sibling-line same-name ambiguity as a cascade warning. - DraftRevisionToken — opaque revision fingerprint. Preview captures the token; Confirm compares it. The 409-concurrent-edit UX plumbs through on the Razor-page follow-up (task #153). Matches(other) is null-safe. - UnsTreeSnapshot + UnsAreaSummary + UnsLineSummary — snapshot shape the caller hands to the analyzer. Tests build them in-memory without a DB. - EquipmentCsvImporter.Parse(csvText) — RFC 4180 CSV parser per decision #95. Version-marker contract: line 1 must be "# OtOpcUaCsv v1" (future shapes bump the version). Required columns from decision #117 + optional columns from decision #139. Rejects unknown columns, duplicate column names, blank required fields, duplicate ZTags within the file. Quoted-field handling supports embedded commas + escaped "" quotes. Returns EquipmentCsvParseResult { AcceptedRows, RejectedRows } so the preview modal renders accept/reject counts without re-parsing. Tests (22 new, all pass): - UnsImpactAnalyzerTests (9): line move counts equipment + tags; cross- cluster throws; unknown source/target throws validation; ambiguous same- name target raises warning; area rename sums across lines; line merge cross-area warns; same-area merge no warning; DraftRevisionToken matches semantics. - EquipmentCsvImporterTests (13): empty file throws; missing version marker; missing required column; unknown column; duplicate column; valid single row round-trips; optional columns populate when present; blank required field rejects row; duplicate ZTag rejects second; RFC 4180 quoted fields with commas + escaped quotes; mismatched column count rejects; blank lines between rows ignored; required + optional column constants match decisions #117 + #139 exactly. Full solution dotnet test: 1159 passing (Phase 6.3 = 1137, Phase 6.4 A+B data = +22). Pre-existing Client.CLI Subscribe flake unchanged. 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);
|