diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentCsvImporter.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentCsvImporter.cs
new file mode 100644
index 0000000..99a8c58
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentCsvImporter.cs
@@ -0,0 +1,259 @@
+using System.Globalization;
+using System.Text;
+
+namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
+
+///
+/// RFC 4180 CSV parser for equipment import per decision #95 and Phase 6.4 Stream B.1.
+/// Produces a validated the caller (CSV import
+/// modal + staging tables) consumes. Pure-parser concern — no DB access, no staging
+/// writes; those live in the follow-up Stream B.2 work.
+///
+///
+/// Header contract: line 1 must be exactly # OtOpcUaCsv v1 (version
+/// marker). Line 2 is the column header row. Unknown columns are rejected; required
+/// columns must all be present. The version bump handshake lets future shapes parse
+/// without ambiguity — v2 files go through a different parser variant.
+///
+/// Required columns per decision #117: ZTag, MachineCode, SAPID,
+/// EquipmentId, EquipmentUuid, Name, UnsAreaName, UnsLineName.
+///
+/// Optional columns per decision #139: Manufacturer, Model, SerialNumber,
+/// HardwareRevision, SoftwareRevision, YearOfConstruction, AssetLocation,
+/// ManufacturerUri, DeviceManualUri.
+///
+/// Row validation: blank required field → rejected; duplicate ZTag within
+/// the same file → rejected. Duplicate against the DB isn't detected here — the
+/// staged-import finalize step (Stream B.4) catches that.
+///
+public static class EquipmentCsvImporter
+{
+ public const string VersionMarker = "# OtOpcUaCsv v1";
+
+ public static IReadOnlyList RequiredColumns { get; } = new[]
+ {
+ "ZTag", "MachineCode", "SAPID", "EquipmentId", "EquipmentUuid",
+ "Name", "UnsAreaName", "UnsLineName",
+ };
+
+ public static IReadOnlyList OptionalColumns { get; } = new[]
+ {
+ "Manufacturer", "Model", "SerialNumber", "HardwareRevision", "SoftwareRevision",
+ "YearOfConstruction", "AssetLocation", "ManufacturerUri", "DeviceManualUri",
+ };
+
+ public static EquipmentCsvParseResult Parse(string csvText)
+ {
+ ArgumentNullException.ThrowIfNull(csvText);
+
+ var rows = SplitLines(csvText);
+ if (rows.Count == 0)
+ throw new InvalidCsvFormatException("CSV is empty.");
+
+ if (!string.Equals(rows[0].Trim(), VersionMarker, StringComparison.Ordinal))
+ throw new InvalidCsvFormatException(
+ $"CSV header line 1 must be exactly '{VersionMarker}' — got '{rows[0]}'. " +
+ "Files without the version marker are rejected so future-format files don't parse ambiguously.");
+
+ if (rows.Count < 2)
+ throw new InvalidCsvFormatException("CSV has no column header row (line 2) or data rows.");
+
+ var headerCells = SplitCsvRow(rows[1]);
+ ValidateHeader(headerCells);
+
+ var accepted = new List();
+ var rejected = new List();
+ var ztagsSeen = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var colIndex = headerCells
+ .Select((name, idx) => (name, idx))
+ .ToDictionary(t => t.name, t => t.idx, StringComparer.OrdinalIgnoreCase);
+
+ for (var i = 2; i < rows.Count; i++)
+ {
+ if (string.IsNullOrWhiteSpace(rows[i])) continue;
+
+ try
+ {
+ var cells = SplitCsvRow(rows[i]);
+ if (cells.Length != headerCells.Length)
+ {
+ rejected.Add(new EquipmentCsvRowError(
+ LineNumber: i + 1,
+ Reason: $"Column count {cells.Length} != header count {headerCells.Length}."));
+ continue;
+ }
+
+ var row = BuildRow(cells, colIndex);
+ var missing = RequiredColumns.Where(c => string.IsNullOrWhiteSpace(GetCell(row, c))).ToList();
+ if (missing.Count > 0)
+ {
+ rejected.Add(new EquipmentCsvRowError(i + 1, $"Blank required column(s): {string.Join(", ", missing)}"));
+ continue;
+ }
+
+ if (!ztagsSeen.Add(row.ZTag))
+ {
+ rejected.Add(new EquipmentCsvRowError(i + 1, $"Duplicate ZTag '{row.ZTag}' within file."));
+ continue;
+ }
+
+ accepted.Add(row);
+ }
+ catch (InvalidCsvFormatException ex)
+ {
+ rejected.Add(new EquipmentCsvRowError(i + 1, ex.Message));
+ }
+ }
+
+ return new EquipmentCsvParseResult(accepted, rejected);
+ }
+
+ private static void ValidateHeader(string[] headerCells)
+ {
+ var seen = new HashSet(headerCells, StringComparer.OrdinalIgnoreCase);
+
+ // Missing required
+ var missingRequired = RequiredColumns.Where(r => !seen.Contains(r)).ToList();
+ if (missingRequired.Count > 0)
+ throw new InvalidCsvFormatException($"Header is missing required column(s): {string.Join(", ", missingRequired)}");
+
+ // Unknown columns (not in required ∪ optional)
+ var known = new HashSet(RequiredColumns.Concat(OptionalColumns), StringComparer.OrdinalIgnoreCase);
+ var unknown = headerCells.Where(c => !known.Contains(c)).ToList();
+ if (unknown.Count > 0)
+ throw new InvalidCsvFormatException(
+ $"Header has unknown column(s): {string.Join(", ", unknown)}. " +
+ "Bump the version marker to define a new shape before adding columns.");
+
+ // Duplicates
+ var dupe = headerCells.GroupBy(c => c, StringComparer.OrdinalIgnoreCase).FirstOrDefault(g => g.Count() > 1);
+ if (dupe is not null)
+ throw new InvalidCsvFormatException($"Header has duplicate column '{dupe.Key}'.");
+ }
+
+ private static EquipmentCsvRow BuildRow(string[] cells, Dictionary colIndex) => new()
+ {
+ ZTag = cells[colIndex["ZTag"]],
+ MachineCode = cells[colIndex["MachineCode"]],
+ SAPID = cells[colIndex["SAPID"]],
+ EquipmentId = cells[colIndex["EquipmentId"]],
+ EquipmentUuid = cells[colIndex["EquipmentUuid"]],
+ Name = cells[colIndex["Name"]],
+ UnsAreaName = cells[colIndex["UnsAreaName"]],
+ UnsLineName = cells[colIndex["UnsLineName"]],
+ Manufacturer = colIndex.TryGetValue("Manufacturer", out var mi) ? cells[mi] : null,
+ Model = colIndex.TryGetValue("Model", out var moi) ? cells[moi] : null,
+ SerialNumber = colIndex.TryGetValue("SerialNumber", out var si) ? cells[si] : null,
+ HardwareRevision = colIndex.TryGetValue("HardwareRevision", out var hi) ? cells[hi] : null,
+ SoftwareRevision = colIndex.TryGetValue("SoftwareRevision", out var swi) ? cells[swi] : null,
+ YearOfConstruction = colIndex.TryGetValue("YearOfConstruction", out var yi) ? cells[yi] : null,
+ AssetLocation = colIndex.TryGetValue("AssetLocation", out var ai) ? cells[ai] : null,
+ ManufacturerUri = colIndex.TryGetValue("ManufacturerUri", out var mui) ? cells[mui] : null,
+ DeviceManualUri = colIndex.TryGetValue("DeviceManualUri", out var dui) ? cells[dui] : null,
+ };
+
+ private static string GetCell(EquipmentCsvRow row, string colName) => colName switch
+ {
+ "ZTag" => row.ZTag,
+ "MachineCode" => row.MachineCode,
+ "SAPID" => row.SAPID,
+ "EquipmentId" => row.EquipmentId,
+ "EquipmentUuid" => row.EquipmentUuid,
+ "Name" => row.Name,
+ "UnsAreaName" => row.UnsAreaName,
+ "UnsLineName" => row.UnsLineName,
+ _ => string.Empty,
+ };
+
+ /// Split the raw text on line boundaries. Handles \r\n + \n + \r.
+ private static List SplitLines(string csv) =>
+ csv.Split(["\r\n", "\n", "\r"], StringSplitOptions.None).ToList();
+
+ /// Split one CSV row with RFC 4180 quoted-field handling.
+ private static string[] SplitCsvRow(string row)
+ {
+ var cells = new List();
+ var sb = new StringBuilder();
+ var inQuotes = false;
+
+ for (var i = 0; i < row.Length; i++)
+ {
+ var ch = row[i];
+ if (inQuotes)
+ {
+ if (ch == '"')
+ {
+ // Escaped quote "" inside quoted field.
+ if (i + 1 < row.Length && row[i + 1] == '"')
+ {
+ sb.Append('"');
+ i++;
+ }
+ else
+ {
+ inQuotes = false;
+ }
+ }
+ else
+ {
+ sb.Append(ch);
+ }
+ }
+ else
+ {
+ if (ch == ',')
+ {
+ cells.Add(sb.ToString());
+ sb.Clear();
+ }
+ else if (ch == '"' && sb.Length == 0)
+ {
+ inQuotes = true;
+ }
+ else
+ {
+ sb.Append(ch);
+ }
+ }
+ }
+
+ cells.Add(sb.ToString());
+ return cells.ToArray();
+ }
+}
+
+/// One parsed equipment row with required + optional fields.
+public sealed class EquipmentCsvRow
+{
+ // Required (decision #117)
+ public required string ZTag { get; init; }
+ public required string MachineCode { get; init; }
+ public required string SAPID { get; init; }
+ public required string EquipmentId { get; init; }
+ public required string EquipmentUuid { get; init; }
+ public required string Name { get; init; }
+ public required string UnsAreaName { get; init; }
+ public required string UnsLineName { get; init; }
+
+ // Optional (decision #139 — OPC 40010 Identification fields)
+ public string? Manufacturer { get; init; }
+ public string? Model { get; init; }
+ public string? SerialNumber { get; init; }
+ public string? HardwareRevision { get; init; }
+ public string? SoftwareRevision { get; init; }
+ public string? YearOfConstruction { get; init; }
+ public string? AssetLocation { get; init; }
+ public string? ManufacturerUri { get; init; }
+ public string? DeviceManualUri { get; init; }
+}
+
+/// One row-level rejection captured by the parser. Line-number is 1-based in the source file.
+public sealed record EquipmentCsvRowError(int LineNumber, string Reason);
+
+/// Parser output — accepted rows land in staging; rejected rows surface in the preview modal.
+public sealed record EquipmentCsvParseResult(
+ IReadOnlyList AcceptedRows,
+ IReadOnlyList RejectedRows);
+
+/// Thrown for file-level format problems (missing version marker, bad header, etc.).
+public sealed class InvalidCsvFormatException(string message) : Exception(message);
diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsImpactAnalyzer.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsImpactAnalyzer.cs
new file mode 100644
index 0000000..df76e45
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsImpactAnalyzer.cs
@@ -0,0 +1,213 @@
+namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
+
+///
+/// Pure-function impact preview for UNS structural moves per Phase 6.4 Stream A.2. Given
+/// a plus a snapshot of the draft's UNS tree and its
+/// equipment + tag counts, returns an the Admin UI shows
+/// in a confirmation modal before committing the move.
+///
+///
+/// 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
+/// 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).
+///
+/// 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.
+///
+public static class UnsImpactAnalyzer
+{
+ /// Run the analyzer. Returns a populated preview or throws for invalid operations.
+ 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();
+ 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();
+ 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.",
+ };
+ }
+}
+
+/// Kind of UNS structural move the analyzer understands.
+public enum UnsMoveKind
+{
+ /// Drag a whole line from one area to another.
+ LineMove,
+
+ /// Rename an area (cascades to the UNS paths of every equipment + tag below it).
+ AreaRename,
+
+ /// Merge two lines into one; source line's equipment + tags are re-parented.
+ LineMerge,
+}
+
+/// One UNS structural move request.
+/// Move variant — selects which source + target fields are required.
+/// Cluster of the source node. Must match (decision #82).
+/// Cluster of the target node.
+/// Source area id for .
+/// Source line id for / .
+/// Target area id for .
+/// Target line id for .
+/// New display name for .
+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);
+
+/// Snapshot of the UNS tree + counts the analyzer walks.
+public sealed class UnsTreeSnapshot
+{
+ public required long DraftGenerationId { get; init; }
+ public required DraftRevisionToken RevisionToken { get; init; }
+ public required IReadOnlyList Areas { get; init; }
+ public required IReadOnlyList 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 LineIds);
+
+public sealed record UnsLineSummary(string LineId, string Name, int EquipmentCount, int TagCount);
+
+///
+/// Opaque per-draft revision fingerprint. Preview fetches the current token + stores it
+/// in the . Confirm compares the token against
+/// the draft's live value; mismatch means another operator mutated the draft between
+/// preview + commit — raise 409 Conflict / refresh-required in the UI.
+///
+public sealed record DraftRevisionToken(string Value)
+{
+ /// Compare two tokens for equality; null-safe.
+ public bool Matches(DraftRevisionToken? other) =>
+ other is not null &&
+ string.Equals(Value, other.Value, StringComparison.Ordinal);
+}
+
+/// Output of .
+public sealed class UnsImpactPreview
+{
+ public required int AffectedEquipmentCount { get; init; }
+ public required int AffectedTagCount { get; init; }
+ public required IReadOnlyList CascadeWarnings { get; init; }
+ public required DraftRevisionToken RevisionToken { get; init; }
+ public required string HumanReadableSummary { get; init; }
+}
+
+/// Thrown when a move targets a different cluster than the source (decision #82).
+public sealed class CrossClusterMoveRejectedException(string message) : Exception(message);
+
+/// Thrown when the move operation references a source / target that doesn't exist in the draft.
+public sealed class UnsMoveValidationException(string message) : Exception(message);
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/EquipmentCsvImporterTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/EquipmentCsvImporterTests.cs
new file mode 100644
index 0000000..c3a32f1
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/EquipmentCsvImporterTests.cs
@@ -0,0 +1,169 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Admin.Services;
+
+namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
+
+[Trait("Category", "Unit")]
+public sealed class EquipmentCsvImporterTests
+{
+ private const string Header =
+ "# OtOpcUaCsv v1\n" +
+ "ZTag,MachineCode,SAPID,EquipmentId,EquipmentUuid,Name,UnsAreaName,UnsLineName";
+
+ [Fact]
+ public void EmptyFile_Throws()
+ {
+ Should.Throw(() => EquipmentCsvImporter.Parse(""));
+ }
+
+ [Fact]
+ public void MissingVersionMarker_Throws()
+ {
+ var csv = "ZTag,MachineCode,SAPID,EquipmentId,EquipmentUuid,Name,UnsAreaName,UnsLineName\nx,x,x,x,x,x,x,x";
+
+ var ex = Should.Throw(() => EquipmentCsvImporter.Parse(csv));
+ ex.Message.ShouldContain("# OtOpcUaCsv v1");
+ }
+
+ [Fact]
+ public void MissingRequiredColumn_Throws()
+ {
+ var csv = "# OtOpcUaCsv v1\n" +
+ "ZTag,MachineCode,SAPID,EquipmentId,Name,UnsAreaName,UnsLineName\n" +
+ "z1,mc,sap,eq1,Name1,area,line";
+
+ var ex = Should.Throw(() => EquipmentCsvImporter.Parse(csv));
+ ex.Message.ShouldContain("EquipmentUuid");
+ }
+
+ [Fact]
+ public void UnknownColumn_Throws()
+ {
+ var csv = Header + ",WeirdColumn\nz1,mc,sap,eq1,uu,Name1,area,line,value";
+
+ var ex = Should.Throw(() => EquipmentCsvImporter.Parse(csv));
+ ex.Message.ShouldContain("WeirdColumn");
+ }
+
+ [Fact]
+ public void DuplicateColumn_Throws()
+ {
+ var csv = "# OtOpcUaCsv v1\n" +
+ "ZTag,ZTag,MachineCode,SAPID,EquipmentId,EquipmentUuid,Name,UnsAreaName,UnsLineName\n" +
+ "z1,z1,mc,sap,eq,uu,Name,area,line";
+
+ Should.Throw(() => EquipmentCsvImporter.Parse(csv));
+ }
+
+ [Fact]
+ public void ValidSingleRow_RoundTrips()
+ {
+ var csv = Header + "\nz-001,MC-1,SAP-1,eq-001,uuid-1,Oven-A,Warsaw,Line-1";
+
+ var result = EquipmentCsvImporter.Parse(csv);
+
+ result.AcceptedRows.Count.ShouldBe(1);
+ result.RejectedRows.ShouldBeEmpty();
+ var row = result.AcceptedRows[0];
+ row.ZTag.ShouldBe("z-001");
+ row.MachineCode.ShouldBe("MC-1");
+ row.Name.ShouldBe("Oven-A");
+ row.UnsLineName.ShouldBe("Line-1");
+ }
+
+ [Fact]
+ public void OptionalColumns_Populated_WhenPresent()
+ {
+ var csv = "# OtOpcUaCsv v1\n" +
+ "ZTag,MachineCode,SAPID,EquipmentId,EquipmentUuid,Name,UnsAreaName,UnsLineName,Manufacturer,Model,SerialNumber,HardwareRevision,SoftwareRevision,YearOfConstruction,AssetLocation,ManufacturerUri,DeviceManualUri\n" +
+ "z-1,MC,SAP,eq,uuid,Oven,Warsaw,Line1,Siemens,S7-1500,SN123,Rev-1,Fw-2.3,2023,Bldg-3,https://siemens.example,https://siemens.example/manual";
+
+ var result = EquipmentCsvImporter.Parse(csv);
+
+ result.AcceptedRows.Count.ShouldBe(1);
+ var row = result.AcceptedRows[0];
+ row.Manufacturer.ShouldBe("Siemens");
+ row.Model.ShouldBe("S7-1500");
+ row.SerialNumber.ShouldBe("SN123");
+ row.YearOfConstruction.ShouldBe("2023");
+ row.ManufacturerUri.ShouldBe("https://siemens.example");
+ }
+
+ [Fact]
+ public void BlankRequiredField_Rejects_Row()
+ {
+ var csv = Header + "\nz-1,MC,SAP,eq,uuid,,Warsaw,Line1"; // Name blank
+
+ var result = EquipmentCsvImporter.Parse(csv);
+
+ result.AcceptedRows.ShouldBeEmpty();
+ result.RejectedRows.Count.ShouldBe(1);
+ result.RejectedRows[0].Reason.ShouldContain("Name");
+ }
+
+ [Fact]
+ public void DuplicateZTag_Rejects_SecondRow()
+ {
+ var csv = Header +
+ "\nz-1,MC1,SAP1,eq1,u1,N1,A,L1" +
+ "\nz-1,MC2,SAP2,eq2,u2,N2,A,L1";
+
+ var result = EquipmentCsvImporter.Parse(csv);
+
+ result.AcceptedRows.Count.ShouldBe(1);
+ result.RejectedRows.Count.ShouldBe(1);
+ result.RejectedRows[0].Reason.ShouldContain("Duplicate ZTag");
+ }
+
+ [Fact]
+ public void QuotedField_With_CommaAndQuote_Parses_Correctly()
+ {
+ // RFC 4180: "" inside a quoted field is an escaped quote.
+ var csv = Header +
+ "\n\"z-1\",\"MC\",\"SAP,with,commas\",\"eq\",\"uuid\",\"Oven \"\"Ultra\"\"\",\"Warsaw\",\"Line1\"";
+
+ var result = EquipmentCsvImporter.Parse(csv);
+
+ result.AcceptedRows.Count.ShouldBe(1);
+ result.AcceptedRows[0].SAPID.ShouldBe("SAP,with,commas");
+ result.AcceptedRows[0].Name.ShouldBe("Oven \"Ultra\"");
+ }
+
+ [Fact]
+ public void MismatchedColumnCount_Rejects_Row()
+ {
+ var csv = Header + "\nz-1,MC,SAP,eq,uuid,Name,Warsaw"; // missing UnsLineName cell
+
+ var result = EquipmentCsvImporter.Parse(csv);
+
+ result.AcceptedRows.ShouldBeEmpty();
+ result.RejectedRows.Count.ShouldBe(1);
+ result.RejectedRows[0].Reason.ShouldContain("Column count");
+ }
+
+ [Fact]
+ public void BlankLines_BetweenRows_AreIgnored()
+ {
+ var csv = Header +
+ "\nz-1,MC,SAP,eq1,u1,N1,A,L1" +
+ "\n" +
+ "\nz-2,MC,SAP,eq2,u2,N2,A,L1";
+
+ var result = EquipmentCsvImporter.Parse(csv);
+
+ result.AcceptedRows.Count.ShouldBe(2);
+ result.RejectedRows.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Header_Constants_Match_Decision_117_and_139()
+ {
+ EquipmentCsvImporter.RequiredColumns.ShouldBe(
+ ["ZTag", "MachineCode", "SAPID", "EquipmentId", "EquipmentUuid", "Name", "UnsAreaName", "UnsLineName"]);
+
+ EquipmentCsvImporter.OptionalColumns.ShouldBe(
+ ["Manufacturer", "Model", "SerialNumber", "HardwareRevision", "SoftwareRevision",
+ "YearOfConstruction", "AssetLocation", "ManufacturerUri", "DeviceManualUri"]);
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/UnsImpactAnalyzerTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/UnsImpactAnalyzerTests.cs
new file mode 100644
index 0000000..0c92769
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/UnsImpactAnalyzerTests.cs
@@ -0,0 +1,173 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Admin.Services;
+
+namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
+
+[Trait("Category", "Unit")]
+public sealed class UnsImpactAnalyzerTests
+{
+ private static UnsTreeSnapshot TwoAreaSnapshot() => new()
+ {
+ DraftGenerationId = 1,
+ RevisionToken = new DraftRevisionToken("rev-1"),
+ Areas =
+ [
+ new UnsAreaSummary("area-pack", "Packaging", ["line-oven", "line-wrap"]),
+ new UnsAreaSummary("area-asm", "Assembly", ["line-weld"]),
+ ],
+ Lines =
+ [
+ new UnsLineSummary("line-oven", "Oven-2", EquipmentCount: 14, TagCount: 237),
+ new UnsLineSummary("line-wrap", "Wrapper", EquipmentCount: 3, TagCount: 40),
+ new UnsLineSummary("line-weld", "Welder", EquipmentCount: 5, TagCount: 80),
+ ],
+ };
+
+ [Fact]
+ public void LineMove_Counts_Affected_Equipment_And_Tags()
+ {
+ var snapshot = TwoAreaSnapshot();
+ var move = new UnsMoveOperation(
+ Kind: UnsMoveKind.LineMove,
+ SourceClusterId: "c1", TargetClusterId: "c1",
+ SourceLineId: "line-oven",
+ TargetAreaId: "area-asm");
+
+ var preview = UnsImpactAnalyzer.Analyze(snapshot, move);
+
+ preview.AffectedEquipmentCount.ShouldBe(14);
+ preview.AffectedTagCount.ShouldBe(237);
+ preview.RevisionToken.Value.ShouldBe("rev-1");
+ preview.HumanReadableSummary.ShouldContain("'Oven-2'");
+ preview.HumanReadableSummary.ShouldContain("'Assembly'");
+ }
+
+ [Fact]
+ public void CrossCluster_LineMove_Throws()
+ {
+ var snapshot = TwoAreaSnapshot();
+ var move = new UnsMoveOperation(
+ Kind: UnsMoveKind.LineMove,
+ SourceClusterId: "c1", TargetClusterId: "c2",
+ SourceLineId: "line-oven",
+ TargetAreaId: "area-asm");
+
+ Should.Throw(
+ () => UnsImpactAnalyzer.Analyze(snapshot, move));
+ }
+
+ [Fact]
+ public void LineMove_With_UnknownSource_Throws_Validation()
+ {
+ var snapshot = TwoAreaSnapshot();
+ var move = new UnsMoveOperation(
+ UnsMoveKind.LineMove, "c1", "c1",
+ SourceLineId: "line-does-not-exist",
+ TargetAreaId: "area-asm");
+
+ Should.Throw(
+ () => UnsImpactAnalyzer.Analyze(snapshot, move));
+ }
+
+ [Fact]
+ public void LineMove_With_UnknownTarget_Throws_Validation()
+ {
+ var snapshot = TwoAreaSnapshot();
+ var move = new UnsMoveOperation(
+ UnsMoveKind.LineMove, "c1", "c1",
+ SourceLineId: "line-oven",
+ TargetAreaId: "area-nowhere");
+
+ Should.Throw(
+ () => UnsImpactAnalyzer.Analyze(snapshot, move));
+ }
+
+ [Fact]
+ public void LineMove_To_Area_WithSameName_Warns_AboutAmbiguity()
+ {
+ var snapshot = new UnsTreeSnapshot
+ {
+ DraftGenerationId = 1,
+ RevisionToken = new DraftRevisionToken("rev-1"),
+ Areas =
+ [
+ new UnsAreaSummary("area-a", "Packaging", ["line-1"]),
+ new UnsAreaSummary("area-b", "Assembly", ["line-2"]),
+ ],
+ Lines =
+ [
+ new UnsLineSummary("line-1", "Oven", 10, 100),
+ new UnsLineSummary("line-2", "Oven", 5, 50),
+ ],
+ };
+ var move = new UnsMoveOperation(
+ UnsMoveKind.LineMove, "c1", "c1",
+ SourceLineId: "line-1",
+ TargetAreaId: "area-b");
+
+ var preview = UnsImpactAnalyzer.Analyze(snapshot, move);
+
+ preview.CascadeWarnings.ShouldContain(w => w.Contains("already has a line named 'Oven'"));
+ }
+
+ [Fact]
+ public void AreaRename_Cascades_AcrossAllLines()
+ {
+ var snapshot = TwoAreaSnapshot();
+ var move = new UnsMoveOperation(
+ Kind: UnsMoveKind.AreaRename,
+ SourceClusterId: "c1", TargetClusterId: "c1",
+ SourceAreaId: "area-pack",
+ NewName: "Packaging-West");
+
+ var preview = UnsImpactAnalyzer.Analyze(snapshot, move);
+
+ preview.AffectedEquipmentCount.ShouldBe(14 + 3, "sum of lines in 'Packaging'");
+ preview.AffectedTagCount.ShouldBe(237 + 40);
+ preview.HumanReadableSummary.ShouldContain("'Packaging-West'");
+ }
+
+ [Fact]
+ public void LineMerge_CrossArea_Warns()
+ {
+ var snapshot = TwoAreaSnapshot();
+ var move = new UnsMoveOperation(
+ Kind: UnsMoveKind.LineMerge,
+ SourceClusterId: "c1", TargetClusterId: "c1",
+ SourceLineId: "line-oven",
+ TargetLineId: "line-weld");
+
+ var preview = UnsImpactAnalyzer.Analyze(snapshot, move);
+
+ preview.AffectedEquipmentCount.ShouldBe(14);
+ preview.CascadeWarnings.ShouldContain(w => w.Contains("different areas"));
+ }
+
+ [Fact]
+ public void LineMerge_SameArea_NoWarning()
+ {
+ var snapshot = TwoAreaSnapshot();
+ var move = new UnsMoveOperation(
+ Kind: UnsMoveKind.LineMerge,
+ SourceClusterId: "c1", TargetClusterId: "c1",
+ SourceLineId: "line-oven",
+ TargetLineId: "line-wrap");
+
+ var preview = UnsImpactAnalyzer.Analyze(snapshot, move);
+
+ preview.CascadeWarnings.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void DraftRevisionToken_Matches_OnEqualValues()
+ {
+ var a = new DraftRevisionToken("rev-1");
+ var b = new DraftRevisionToken("rev-1");
+ var c = new DraftRevisionToken("rev-2");
+
+ a.Matches(b).ShouldBeTrue();
+ a.Matches(c).ShouldBeFalse();
+ a.Matches(null).ShouldBeFalse();
+ }
+}