using System.Globalization; using CsvHelper; using CsvHelper.Configuration; using MeterVault.Core.Domain; using MeterVault.Core.Parsing; namespace MeterVault.Infrastructure.Import; /// /// Reads a CSV in the reference German dialect and stages readings/events/manual-costs per a /// (SDD §6.3). Pure transformation — no persistence; feed the result /// to to commit as a revertible batch, or to the normalization engine /// for a dry-run preview. /// public sealed class CsvImporter { public StagedImport Stage(MappingProfile profile, TextReader reader) { ArgumentNullException.ThrowIfNull(profile); ArgumentNullException.ThrowIfNull(reader); var rows = ReadRows(reader); var staged = new StagedImport(); var previousByMeter = new Dictionary(); for (var r = profile.FirstDataRowIndex; r < rows.Count; r++) { var row = rows[r]; if (RowClassifier.ShouldSkip(row, profile, out var reason)) { staged.SkippedRows++; if (!reason.StartsWith("blank", StringComparison.Ordinal)) { staged.Warnings.Add($"Row {r + 1}: skipped ({reason})."); } continue; } if (!TryGetDate(row, profile, out var time)) { staged.SkippedRows++; staged.Warnings.Add($"Row {r + 1}: unparseable date, skipped."); continue; } foreach (var column in profile.Columns) { StageColumn(column, row, time, r, staged, previousByMeter, profile.DetectCumulativeSwaps); } } return staged; } private static void StageColumn(ColumnMapping column, IReadOnlyList row, DateTimeOffset time, int rowIndex, StagedImport staged, Dictionary previousByMeter, bool detectSwaps) { if (column.Role == MappingRole.Ignore || column.Index >= row.Count) { return; } var cell = row[column.Index]; if (string.IsNullOrWhiteSpace(cell)) { return; } switch (column.Role) { case MappingRole.Reading: StageReading(column, row, cell, time, rowIndex, staged, previousByMeter, detectSwaps); break; case MappingRole.Delivery: if (GermanNumber.TryParse(cell, out var litres) && litres > 0) { staged.Events.Add(new MeterEvent { MeterId = column.MeterId!.Value, Time = time, EventType = MeterEventType.Delivery, Amount = litres, Unit = column.Unit ?? "L", }); } break; case MappingRole.TankLevel: var split = ValueCell.Split(cell); if (split is not null) { staged.Events.Add(new MeterEvent { MeterId = column.MeterId!.Value, Time = time, EventType = MeterEventType.TankLevel, Amount = split.Value.Value, Unit = column.Unit ?? split.Value.Unit, }); } break; case MappingRole.ManualCost: if (GermanMoney.TryParse(cell, out var amount, out var currency)) { staged.ManualCosts.Add(new ManualCost { CategoryId = column.CategoryId, PeriodStart = DateOnly.FromDateTime(time.UtcDateTime), PeriodEnd = DateOnly.FromDateTime(time.UtcDateTime.AddMonths(1).AddDays(-1)), Amount = (double)amount, Currency = currency, }); } break; case MappingRole.Ignore: default: break; } } private static void StageReading(ColumnMapping column, IReadOnlyList row, string cell, DateTimeOffset time, int rowIndex, StagedImport staged, Dictionary previousByMeter, bool detectSwaps) { var split = ValueCell.Split(cell); if (split is null) { return; } var meterId = column.MeterId!.Value; var value = split.Value.Value; if (column.Unit is not null && split.Value.Unit is not null && !string.Equals(column.Unit, split.Value.Unit, StringComparison.OrdinalIgnoreCase)) { staged.Warnings.Add( $"Row {rowIndex + 1}: expected unit '{column.Unit}' but found '{split.Value.Unit}'."); } if (detectSwaps && previousByMeter.TryGetValue(meterId, out var previous) && value < previous) { var override_ = ReadSwapOverride(column, row); staged.Events.Add(new MeterEvent { MeterId = meterId, Time = time, EventType = MeterEventType.MeterSwap, PrevValue = previous, NewValue = value, Amount = override_, Notes = "Auto-detected register decrease (meter swap / reset candidate).", }); staged.Warnings.Add( $"Row {rowIndex + 1}: register for meter {meterId} dropped {previous}→{value}; " + (override_ is null ? "swap event created (verify)." : $"swap consumption set to {override_}.")); } staged.Readings.Add(new Reading { MeterId = meterId, Time = time, Value = value, Quality = ReadingQuality.Imported, }); previousByMeter[meterId] = value; } private static double? ReadSwapOverride(ColumnMapping column, IReadOnlyList row) { if (column.SwapConsumptionColumn is not { } source || source >= row.Count) { return null; } return GermanNumber.TryParse(row[source], out var value) ? value : null; } private static bool TryGetDate(IReadOnlyList row, MappingProfile profile, out DateTimeOffset time) { time = default; if (profile.DateColumn >= row.Count) { return false; } var raw = row[profile.DateColumn]; DateOnly date; var parsed = profile.DateKind switch { DateKind.MonthName => TryParseMonthAnchored(raw, profile.AnchorMonthsToEnd, out date), DateKind.DayDotMonthYear => GermanDate.TryParseDay(raw, out date), _ => ImportDate.TryResolve(raw, profile.AnchorMonthsToEnd, out date), }; if (!parsed) { return false; } time = new DateTimeOffset(date.Year, date.Month, date.Day, 0, 0, 0, TimeSpan.Zero); return true; } private static bool TryParseMonthAnchored(string? raw, bool anchorToEnd, out DateOnly date) { if (!GermanDate.TryParseMonth(raw, out var month)) { date = default; return false; } date = anchorToEnd ? new DateOnly(month.Year, month.Month, DateTime.DaysInMonth(month.Year, month.Month)) : month; return true; } /// /// Reads the raw CSV grid (no profile applied) so the import wizard can show a column preview /// and let the user map columns before staging. Rows are ragged (their own field counts). /// public static IReadOnlyList ReadRawRows(TextReader reader) => ReadRows(reader); internal static List ReadRows(TextReader reader) { var config = new CsvConfiguration(CultureInfo.InvariantCulture) { HasHeaderRecord = false, DetectColumnCountChanges = false, MissingFieldFound = null, BadDataFound = null, }; using var parser = new CsvParser(reader, config); var rows = new List(); while (parser.Read()) { rows.Add(parser.Record ?? []); } return rows; } }