14930bc3d8
New /import/wizard: upload an arbitrary CSV, preview the column grid, map each column to a role + target meter/category + unit, dry-run, then commit as a revertible import_batch. The /import page now lists recent imports with one-click revert. Adds CsvImporter.ReadRawRows (raw preview) and Confirm.ConfirmAsync (generic confirm). Verified end-to-end in a real browser + direct DB checks. Claude-Session: https://claude.ai/code/session_01Kib2MniVFbD95fkgLgBBnB
251 lines
8.3 KiB
C#
251 lines
8.3 KiB
C#
using System.Globalization;
|
|
using CsvHelper;
|
|
using CsvHelper.Configuration;
|
|
using MeterVault.Core.Domain;
|
|
using MeterVault.Core.Parsing;
|
|
|
|
namespace MeterVault.Infrastructure.Import;
|
|
|
|
/// <summary>
|
|
/// Reads a CSV in the reference German dialect and stages readings/events/manual-costs per a
|
|
/// <see cref="MappingProfile"/> (SDD §6.3). Pure transformation — no persistence; feed the result
|
|
/// to <see cref="ImportService"/> to commit as a revertible batch, or to the normalization engine
|
|
/// for a dry-run preview.
|
|
/// </summary>
|
|
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<int, double>();
|
|
|
|
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<string> row, DateTimeOffset time,
|
|
int rowIndex, StagedImport staged, Dictionary<int, double> 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<string> row, string cell,
|
|
DateTimeOffset time, int rowIndex, StagedImport staged, Dictionary<int, double> 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<string> 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<string> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
public static IReadOnlyList<string[]> ReadRawRows(TextReader reader) => ReadRows(reader);
|
|
|
|
internal static List<string[]> 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<string[]>();
|
|
while (parser.Read())
|
|
{
|
|
rows.Add(parser.Record ?? []);
|
|
}
|
|
|
|
return rows;
|
|
}
|
|
}
|