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,96 @@
|
||||
namespace MeterVault.Infrastructure.Import;
|
||||
|
||||
/// <summary>
|
||||
/// Built-in mapping profiles for the four reference <em>Energiebilanz</em> sheets (SDD §6.3).
|
||||
/// They ship as example imports and as the golden reconciliation fixtures. Meter and category ids
|
||||
/// are fixed conventions for the reference data; the import wizard maps to real ids at runtime.
|
||||
/// </summary>
|
||||
public static class ReferenceProfiles
|
||||
{
|
||||
// Reference meter ids.
|
||||
public const int Haus = 1;
|
||||
public const int Netz = 2;
|
||||
public const int Auto = 3;
|
||||
public const int Solar1 = 4;
|
||||
public const int Solar2 = 5;
|
||||
public const int Wasser = 10;
|
||||
public const int OilTank = 20;
|
||||
public const int Burner = 21;
|
||||
|
||||
// Reference category ids (match DatabaseSeeder order).
|
||||
public const int CategoryHeizung = 1;
|
||||
public const int CategoryStrom = 2;
|
||||
public const int CategoryWasser = 3;
|
||||
public const int CategoryPool = 4;
|
||||
|
||||
/// <summary>Linear heating-oil tank calibration: 7000 L / 150 cm ≈ 46.667 L/cm.</summary>
|
||||
public const double OilLitresPerCm = 7000d / 150d;
|
||||
|
||||
public static MappingProfile Electricity() => new()
|
||||
{
|
||||
Name = "Energiebilanz — Strom",
|
||||
DateColumn = 0,
|
||||
DateKind = DateKind.MonthName,
|
||||
FirstDataRowIndex = 1,
|
||||
Columns =
|
||||
[
|
||||
new ColumnMapping { Index = 1, Role = MappingRole.Reading, MeterId = Haus, Unit = "kWh" },
|
||||
new ColumnMapping { Index = 2, Role = MappingRole.Reading, MeterId = Netz, Unit = "kWh" },
|
||||
// Index 3 is a blank spacer column — left unmapped (Ignore).
|
||||
new ColumnMapping { Index = 4, Role = MappingRole.Reading, MeterId = Auto, Unit = "kWh" },
|
||||
new ColumnMapping { Index = 5, Role = MappingRole.Reading, MeterId = Solar1, Unit = "kWh" },
|
||||
new ColumnMapping { Index = 6, Role = MappingRole.Reading, MeterId = Solar2, Unit = "kWh" },
|
||||
],
|
||||
};
|
||||
|
||||
public static MappingProfile Water() => new()
|
||||
{
|
||||
Name = "Energiebilanz — Wasser",
|
||||
DateColumn = 0,
|
||||
DateKind = DateKind.MonthName,
|
||||
FirstDataRowIndex = 1,
|
||||
DetectCumulativeSwaps = true,
|
||||
Columns =
|
||||
[
|
||||
new ColumnMapping
|
||||
{
|
||||
Index = 1,
|
||||
Role = MappingRole.Reading,
|
||||
MeterId = Wasser,
|
||||
Unit = "m3",
|
||||
SwapConsumptionColumn = 2, // Wasserverbrauch supplies the swap-month consumption.
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
public static MappingProfile HeatingOil() => new()
|
||||
{
|
||||
Name = "Energiebilanz — Heizöl",
|
||||
DateColumn = 0,
|
||||
DateKind = DateKind.Auto, // early rows DD.MM.YYYY, later rows month names.
|
||||
AnchorMonthsToEnd = true, // a monthly snapshot sorts after same-month day-dated readings.
|
||||
HeaderRowIndex = 3,
|
||||
FirstDataRowIndex = 4,
|
||||
Columns =
|
||||
[
|
||||
new ColumnMapping { Index = 1, Role = MappingRole.Reading, MeterId = Burner, Unit = "h" },
|
||||
new ColumnMapping { Index = 4, Role = MappingRole.TankLevel, MeterId = OilTank, Unit = "cm" },
|
||||
new ColumnMapping { Index = 7, Role = MappingRole.Delivery, MeterId = OilTank, Unit = "L" },
|
||||
],
|
||||
};
|
||||
|
||||
public static MappingProfile Costs() => new()
|
||||
{
|
||||
Name = "Energiebilanz — Kosten",
|
||||
DateColumn = 0,
|
||||
DateKind = DateKind.MonthName,
|
||||
FirstDataRowIndex = 1,
|
||||
Columns =
|
||||
[
|
||||
new ColumnMapping { Index = 3, Role = MappingRole.ManualCost, CategoryId = CategoryHeizung },
|
||||
new ColumnMapping { Index = 4, Role = MappingRole.ManualCost, CategoryId = CategoryStrom },
|
||||
new ColumnMapping { Index = 5, Role = MappingRole.ManualCost, CategoryId = CategoryWasser },
|
||||
new ColumnMapping { Index = 6, Role = MappingRole.ManualCost, CategoryId = CategoryPool },
|
||||
],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user