diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/ImportEquipmentModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/ImportEquipmentModal.razor
index 3a4420b1..86da0727 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/ImportEquipmentModal.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/ImportEquipmentModal.razor
@@ -75,8 +75,6 @@
}
@code {
- // Required, in order. v3: equipment no longer binds a driver, so DriverInstanceId is gone.
- private static readonly string[] RequiredColumns = ["Name", "MachineCode", "UnsLineId"];
/// Whether the modal is shown. The host owns this flag.
[Parameter] public bool Visible { get; set; }
@@ -134,60 +132,12 @@
private Task CloseAsync() =>
_result is not null ? OnImported.InvokeAsync() : OnCancel.InvokeAsync();
- ///
- /// Parses the pasted CSV into rows. Requires a header row whose first
- /// three columns are Name, MachineCode, UnsLineId (case-insensitive); the optional
- /// ZTag/SAPID/Manufacturer/Model follow. Blank optional cells parse as null. Returns
- /// null (and sets ) when the CSV is empty or the header is wrong.
- ///
+ /// Parses the pasted CSV via (RFC 4180, R04 S6); sets
+ /// and returns null on an empty/malformed CSV or a wrong header.
private List? ParseCsv(string csv)
{
- if (string.IsNullOrWhiteSpace(csv)) { _parseError = "CSV is empty."; return null; }
-
- var lines = csv.Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries);
- if (lines.Length < 2) { _parseError = "Need a header row and at least one data row."; return null; }
-
- var header = lines[0].Split(',').Select(c => c.Trim()).ToArray();
- for (var i = 0; i < RequiredColumns.Length; i++)
- {
- if (i >= header.Length || !string.Equals(header[i], RequiredColumns[i], StringComparison.OrdinalIgnoreCase))
- {
- _parseError = $"Header column #{i + 1} must be '{RequiredColumns[i]}' (got '{(i < header.Length ? header[i] : "")}').";
- return null;
- }
- }
-
- var rows = new List();
- for (var lineIdx = 1; lineIdx < lines.Length; lineIdx++)
- {
- // NOTE: simple comma split — no RFC4180 quoting; values with commas are not supported.
- var parts = lines[lineIdx].Split(',').Select(c => c.Trim()).ToArray();
- if (parts.Length < RequiredColumns.Length)
- {
- _parseError = $"Row {lineIdx}: too few columns (got {parts.Length}, need {RequiredColumns.Length}).";
- return null;
- }
-
- rows.Add(new EquipmentInput(
- Name: parts[0],
- MachineCode: parts[1],
- UnsLineId: parts[2],
- ZTag: NullIfEmpty(parts, 3),
- SAPID: NullIfEmpty(parts, 4),
- Manufacturer: NullIfEmpty(parts, 5),
- Model: NullIfEmpty(parts, 6),
- SerialNumber: null,
- HardwareRevision: null,
- SoftwareRevision: null,
- YearOfConstruction: null,
- AssetLocation: null,
- ManufacturerUri: null,
- DeviceManualUri: null,
- Enabled: true));
- }
+ var (rows, error) = EquipmentCsvParser.Parse(csv);
+ _parseError = error;
return rows;
}
-
- private static string? NullIfEmpty(string[] parts, int idx) =>
- idx < parts.Length && !string.IsNullOrWhiteSpace(parts[idx]) ? parts[idx] : null;
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentCsvParser.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentCsvParser.cs
new file mode 100644
index 00000000..f3b44e68
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/EquipmentCsvParser.cs
@@ -0,0 +1,85 @@
+using ZB.MOM.WW.OtOpcUa.Commons.Csv;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
+
+///
+/// Parses the equipment-import CSV (R04 S6). It used to be a naive comma split, so a quoted
+/// Manufacturer containing a comma silently shifted every later column with no error. It now
+/// reads through the RFC 4180 — the same authority the raw-tag importer uses —
+/// so quoted fields, embedded commas/newlines and doubled quotes parse correctly, and malformed quoting
+/// is reported instead of guessed at.
+///
+public static class EquipmentCsvParser
+{
+ /// The leading header columns the import requires, in order (case-insensitive).
+ public static readonly IReadOnlyList RequiredColumns = ["Name", "MachineCode", "UnsLineId"];
+
+ ///
+ /// Parses the CSV. Requires a header row whose first three columns are Name, MachineCode, UnsLineId;
+ /// the optional ZTag, SAPID, Manufacturer, Model follow. Cells are trimmed; blank optional cells parse
+ /// as null; blank lines are skipped.
+ ///
+ /// The pasted CSV text.
+ /// The rows, or a null list with an operator-facing error.
+ public static (List? Rows, string? Error) Parse(string? csv)
+ {
+ if (string.IsNullOrWhiteSpace(csv)) return (null, "CSV is empty.");
+
+ IReadOnlyList records;
+ try
+ {
+ records = CsvParser.Parse(csv);
+ }
+ catch (FormatException ex)
+ {
+ return (null, $"The CSV is malformed: {ex.Message}");
+ }
+
+ var lines = records
+ .Where(r => r.Length > 1 || r[0].Trim().Length > 0)
+ .Select(r => r.Select(c => c.Trim()).ToArray())
+ .ToList();
+ if (lines.Count < 2) return (null, "Need a header row and at least one data row.");
+
+ var header = lines[0];
+ for (var i = 0; i < RequiredColumns.Count; i++)
+ {
+ if (i >= header.Length || !string.Equals(header[i], RequiredColumns[i], StringComparison.OrdinalIgnoreCase))
+ {
+ return (null, $"Header column #{i + 1} must be '{RequiredColumns[i]}' (got '{(i < header.Length ? header[i] : "")}').");
+ }
+ }
+
+ var rows = new List(lines.Count - 1);
+ for (var lineIdx = 1; lineIdx < lines.Count; lineIdx++)
+ {
+ var parts = lines[lineIdx];
+ if (parts.Length < RequiredColumns.Count)
+ {
+ return (null, $"Row {lineIdx}: too few columns (got {parts.Length}, need {RequiredColumns.Count}).");
+ }
+
+ rows.Add(new EquipmentInput(
+ Name: parts[0],
+ MachineCode: parts[1],
+ UnsLineId: parts[2],
+ ZTag: NullIfEmpty(parts, 3),
+ SAPID: NullIfEmpty(parts, 4),
+ Manufacturer: NullIfEmpty(parts, 5),
+ Model: NullIfEmpty(parts, 6),
+ SerialNumber: null,
+ HardwareRevision: null,
+ SoftwareRevision: null,
+ YearOfConstruction: null,
+ AssetLocation: null,
+ ManufacturerUri: null,
+ DeviceManualUri: null,
+ Enabled: true));
+ }
+
+ return (rows, null);
+ }
+
+ private static string? NullIfEmpty(string[] parts, int idx) =>
+ idx < parts.Length && !string.IsNullOrWhiteSpace(parts[idx]) ? parts[idx] : null;
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/EquipmentCsvParserTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/EquipmentCsvParserTests.cs
new file mode 100644
index 00000000..d1b2c65c
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/EquipmentCsvParserTests.cs
@@ -0,0 +1,62 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
+
+/// R04 S6: the equipment CSV import reads RFC 4180 through the shared CsvParser.
+[Trait("Category", "Unit")]
+public sealed class EquipmentCsvParserTests
+{
+ private const string Header = "Name,MachineCode,UnsLineId,ZTag,SAPID,Manufacturer,Model";
+
+ [Fact]
+ public void A_quoted_comma_stays_in_its_column()
+ {
+ var (rows, error) = EquipmentCsvParser.Parse(
+ Header + "\nPress 1,MC-1,LINE-1,Z1,S1,\"Acme, Inc.\",X200\n");
+
+ error.ShouldBeNull();
+ rows!.Single().Manufacturer.ShouldBe("Acme, Inc.");
+ // The column after the quoted comma did not shift.
+ rows!.Single().Model.ShouldBe("X200");
+ }
+
+ [Fact]
+ public void Doubled_quotes_crlf_and_blank_lines_parse()
+ {
+ var (rows, error) = EquipmentCsvParser.Parse(
+ Header + "\r\n\r\n\"Press \"\"A\"\"\",MC-1,LINE-1,,,,\r\nPress B,MC-2,LINE-1\r\n");
+
+ error.ShouldBeNull();
+ rows!.Select(r => r.Name).ShouldBe(["Press \"A\"", "Press B"]);
+ rows![0].ZTag.ShouldBeNull();
+ rows[1].Manufacturer.ShouldBeNull();
+ }
+
+ [Fact]
+ public void Header_and_shape_errors_are_reported()
+ {
+ EquipmentCsvParser.Parse("").Error.ShouldBe("CSV is empty.");
+ EquipmentCsvParser.Parse(Header).Error.ShouldBe("Need a header row and at least one data row.");
+ EquipmentCsvParser.Parse("Name,Code,UnsLineId\na,b,c").Error!.ShouldContain("Header column #2 must be 'MachineCode'");
+ EquipmentCsvParser.Parse(Header + "\nonly,two").Error!.ShouldContain("Row 1: too few columns");
+ }
+
+ [Fact]
+ public void Malformed_quoting_is_an_error_not_a_guess()
+ {
+ var (rows, error) = EquipmentCsvParser.Parse(Header + "\nPress,MC-1,LINE-1,\"unterminated\n");
+
+ rows.ShouldBeNull();
+ error!.ShouldStartWith("The CSV is malformed:");
+ }
+
+ [Fact]
+ public void Modal_delegates_to_the_parser()
+ {
+ var src = AdminUiSource.Read("Components", "Shared", "Uns", "ImportEquipmentModal.razor");
+ src.ShouldContain("EquipmentCsvParser.Parse(csv)");
+ src.ShouldNotContain(".Split(',')");
+ }
+}