M2: German CSV importer + reconciliation of all 4 fixtures
- German-dialect scalar parsers in Core (GermanNumber/Money/Date, ValueCell): decimal comma, thousands dot, unit suffixes, € currency, both date shapes. - Declarative MappingProfile + RowClassifier (skip summary/blank/all-zero rows) + CsvImporter (CsvHelper) staging readings/events/manual-costs, with auto swap detection on register decreases and month-end anchoring for interleaved oil dates. - Four built-in ReferenceProfiles (Strom/Wasser/Heizöl/Kosten). - ImportService: commit as revertible import_batch + wholesale consumption recompute per affected meter (NormalizationService/MeterConfigFactory), revert by batch. - Reconciliation tests: all 4 CSVs match the sheet's own columns within tolerance (electricity 5 meters + Netz Einsparung, water swap→12, oil tank incl. deliveries + burner hours, cost category totals). Commit/revert round-trip verified on Timescale. 69 tests green (53 Core + 16 integration). Known follow-up (polish): historical imports can contend with the 30-day compression policy's background job; tests pause it. Consider retry-on-deadlock or deferred compression for large historical imports in production. Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
using MeterVault.Core.Domain;
|
||||
using MeterVault.Core.Normalization;
|
||||
using MeterVault.Core.Parsing;
|
||||
using MeterVault.Infrastructure.Import;
|
||||
|
||||
namespace MeterVault.Integration.Tests.Reconciliation;
|
||||
|
||||
/// <summary>
|
||||
/// Shared helpers for the golden-fixture reconciliation tests. The CSVs are self-oracling: the
|
||||
/// same file carries both the input (registers/levels/hours) and the expected output
|
||||
/// (Verbrauch / Differenz Tank / Kosten columns). We parse input → normalize → compare against
|
||||
/// the sheet's own columns (SDD §0.3, §13). No database is involved.
|
||||
/// </summary>
|
||||
internal static class ReconciliationSupport
|
||||
{
|
||||
// Fixture file names (linked into fixtures/ in the test output).
|
||||
public const string Electricity = "Energiebilanz - Strom Verbrauch.csv";
|
||||
public const string Water = "Energiebilanz - Wasser.csv";
|
||||
public const string Oil = "Energiebilanz - Heizöl Verbrauch.csv";
|
||||
public const string Costs = "Energiebilanz - Kosten.csv";
|
||||
|
||||
public static string FixturePath(string fileName) =>
|
||||
Path.Combine(AppContext.BaseDirectory, "fixtures", fileName);
|
||||
|
||||
public static List<string[]> ReadRows(string fileName)
|
||||
{
|
||||
using var reader = new StreamReader(FixturePath(fileName));
|
||||
return CsvImporter.ReadRows(reader);
|
||||
}
|
||||
|
||||
public static StagedImport Stage(MappingProfile profile, string fileName)
|
||||
{
|
||||
using var reader = new StreamReader(FixturePath(fileName));
|
||||
return new CsvImporter().Stage(profile, reader);
|
||||
}
|
||||
|
||||
/// <summary>Normalizes one meter from a staged import using the given config.</summary>
|
||||
public static IReadOnlyList<Consumption> Normalize(StagedImport staged, MeterConfig config)
|
||||
{
|
||||
var engine = NormalizationEngine.CreateDefault();
|
||||
var context = new NormalizationContext
|
||||
{
|
||||
Meter = config,
|
||||
Readings = staged.Readings.Where(r => r.MeterId == config.MeterId).ToList(),
|
||||
Events = staged.Events.Where(e => e.MeterId == config.MeterId).ToList(),
|
||||
};
|
||||
|
||||
return engine.Normalize(context);
|
||||
}
|
||||
|
||||
/// <summary>Extracts a sheet oracle column keyed by month, using German number parsing.</summary>
|
||||
public static Dictionary<DateOnly, double> OracleByMonth(
|
||||
IReadOnlyList<string[]> rows, int dateColumn, int valueColumn, int firstDataRow)
|
||||
{
|
||||
var result = new Dictionary<DateOnly, double>();
|
||||
for (var r = firstDataRow; r < rows.Count; r++)
|
||||
{
|
||||
var row = rows[r];
|
||||
if (dateColumn >= row.Length || valueColumn >= row.Length)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!GermanDate.TryParse(row[dateColumn], out var date))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (GermanNumber.TryParse(row[valueColumn], out var value))
|
||||
{
|
||||
result[new DateOnly(date.Year, date.Month, 1)] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static DateOnly MonthKey(DateTimeOffset time) => new(time.Year, time.Month, 1);
|
||||
|
||||
/// <summary>
|
||||
/// Asserts every month present in both computed and oracle agrees within tolerance, and that
|
||||
/// a meaningful number of months were actually compared (so an empty result can't pass).
|
||||
/// </summary>
|
||||
public static void AssertReconciles(
|
||||
IReadOnlyDictionary<DateOnly, double> computed,
|
||||
IReadOnlyDictionary<DateOnly, double> oracle,
|
||||
double tolerance,
|
||||
string label,
|
||||
int minMatches)
|
||||
{
|
||||
var matched = 0;
|
||||
foreach (var (month, expected) in oracle)
|
||||
{
|
||||
if (!computed.TryGetValue(month, out var actual))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
matched++;
|
||||
Assert.True(
|
||||
Math.Abs(actual - expected) <= tolerance,
|
||||
$"{label} {month:yyyy-MM}: computed {actual:0.##} vs sheet {expected:0.##} (tol {tolerance}).");
|
||||
}
|
||||
|
||||
Assert.True(matched >= minMatches, $"{label}: only {matched} months reconciled (expected ≥ {minMatches}).");
|
||||
}
|
||||
|
||||
public static Dictionary<DateOnly, double> ByMonth(IReadOnlyList<Consumption> series) =>
|
||||
series.GroupBy(c => MonthKey(c.Time)).ToDictionary(g => g.Key, g => g.Sum(c => c.Amount));
|
||||
|
||||
/// <summary>Consumption keyed by exact reading date (oil rows are event-dated, sometimes two per month).</summary>
|
||||
public static Dictionary<DateOnly, double> ByDate(IReadOnlyList<Consumption> series) =>
|
||||
series.GroupBy(c => DateOnly.FromDateTime(c.Time.UtcDateTime))
|
||||
.ToDictionary(g => g.Key, g => g.Sum(c => c.Amount));
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a sheet oracle column keyed by exact date, optionally transformed. Uses the same
|
||||
/// month-end anchoring as the importer so day-dated and month-dated rows align.
|
||||
/// </summary>
|
||||
public static Dictionary<DateOnly, double> OracleByDate(
|
||||
IReadOnlyList<string[]> rows, int dateColumn, int valueColumn, int firstDataRow,
|
||||
Func<double, double>? transform = null, bool anchorMonthsToEnd = true)
|
||||
{
|
||||
var result = new Dictionary<DateOnly, double>();
|
||||
for (var r = firstDataRow; r < rows.Count; r++)
|
||||
{
|
||||
var row = rows[r];
|
||||
if (dateColumn >= row.Length || valueColumn >= row.Length)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ImportDate.TryResolve(row[dateColumn], anchorMonthsToEnd, out var date))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (GermanNumber.TryParse(row[valueColumn], out var value))
|
||||
{
|
||||
result[date] = transform is null ? value : transform(value);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user