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);