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:
2026-07-13 11:35:54 +02:00
parent d972f67bad
commit 5977c81002
24 changed files with 1571 additions and 0 deletions
@@ -35,6 +35,14 @@ public sealed class TimescaleFixture : IAsyncLifetime
await _db.StartAsync();
await using var ctx = CreateContext();
await ctx.Database.MigrateAsync();
// The fixtures are historical (20222023), so freshly-inserted chunks immediately fall
// past the 30-day compression horizon. Pause the compression job's scheduler so its
// background worker can't deadlock a test's import transaction. The policy still exists
// (SchemaTests verifies that); only its scheduling is disabled.
await ctx.Database.ExecuteSqlRawAsync(
"SELECT alter_job(job_id, scheduled => false) FROM timescaledb_information.jobs " +
"WHERE proc_name IN ('policy_compression', 'policy_columnstore');");
}
public async Task DisposeAsync() => await _db.DisposeAsync();