Polish/audit: fix bugs found by 3 subsystem audits
ci / build-test (push) Successful in 2m45s

Correctness/data:
- Fix demo cost double-count: reference importer no longer imports the Kosten Strom/Wasser
  columns for categories that are metered (only Heizung), so Wasser rollup is 70€ not 140€.
- Spurious-decrease guard: only a reset/swap in the window (prevReading, thisReading] explains
  a decrease — an old historical reset no longer permanently disables the guard.
- Gate swap auto-detection on MappingProfile.DetectCumulativeSwaps (flag was ignored).
- Prorate basePrice by bucket length (day/month/year); guard virtual expressions against NaN/Inf.

Concurrency/infra:
- Blazor: register a DbContextFactory; CostService/DashboardService and the read pages now use
  short-lived per-operation contexts (no shared circuit DbContext); guard Trends re-entrancy.
- /events: wrap event insert + consumption recompute in one transaction (atomic); 404 (not 500)
  on unknown meter.
- MQTT worker: subscribe to newly-added topics on each tick; move client cleanup into finally.
- Migrations: CREATE MATERIALIZED VIEW IF NOT EXISTS + if_not_exists on CAgg/compression/
  hypertable calls (re-run-safe after a mid-migration crash).
- HA worker: prune stale poll-schedule entries; export: null dangling ImportBatchIds on restore.

API/security:
- API fail-closed by default: with no keys and AllowAnonymousApi off, /api/v1 returns 401
  (protects /export and /import). New MeterVault:AllowAnonymousApi opt-in.
- Cap /readings batch at 5000; report ignored (unknown-meter) count; enums as strings in JSON.

+4 regression tests (guard window, API closed, /events 404, no demo double-count). 98 tests
green; Docker deploy re-verified healthy with the API fail-closed.

Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr
This commit is contained in:
2026-07-13 12:56:51 +02:00
parent e223278771
commit a6edec2b12
28 changed files with 326 additions and 109 deletions
+5 -5
View File
@@ -46,7 +46,7 @@ public sealed class CsvImporter
foreach (var column in profile.Columns)
{
StageColumn(column, row, time, r, staged, previousByMeter);
StageColumn(column, row, time, r, staged, previousByMeter, profile.DetectCumulativeSwaps);
}
}
@@ -54,7 +54,7 @@ public sealed class CsvImporter
}
private static void StageColumn(ColumnMapping column, IReadOnlyList<string> row, DateTimeOffset time,
int rowIndex, StagedImport staged, Dictionary<int, double> previousByMeter)
int rowIndex, StagedImport staged, Dictionary<int, double> previousByMeter, bool detectSwaps)
{
if (column.Role == MappingRole.Ignore || column.Index >= row.Count)
{
@@ -70,7 +70,7 @@ public sealed class CsvImporter
switch (column.Role)
{
case MappingRole.Reading:
StageReading(column, row, cell, time, rowIndex, staged, previousByMeter);
StageReading(column, row, cell, time, rowIndex, staged, previousByMeter, detectSwaps);
break;
case MappingRole.Delivery:
@@ -126,7 +126,7 @@ public sealed class CsvImporter
}
private static void StageReading(ColumnMapping column, IReadOnlyList<string> row, string cell,
DateTimeOffset time, int rowIndex, StagedImport staged, Dictionary<int, double> previousByMeter)
DateTimeOffset time, int rowIndex, StagedImport staged, Dictionary<int, double> previousByMeter, bool detectSwaps)
{
var split = ValueCell.Split(cell);
if (split is null)
@@ -144,7 +144,7 @@ public sealed class CsvImporter
$"Row {rowIndex + 1}: expected unit '{column.Unit}' but found '{split.Value.Unit}'.");
}
if (previousByMeter.TryGetValue(meterId, out var previous) && value < previous)
if (detectSwaps && previousByMeter.TryGetValue(meterId, out var previous) && value < previous)
{
var override_ = ReadSwapOverride(column, row);
staged.Events.Add(new MeterEvent
@@ -61,7 +61,10 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService
AddElectricityTariffs(electricity);
AddTariff(TariffScope.EnergyType, water, 5.00, "EUR/m3", new DateOnly(2022, 11, 1));
await LinkCategoriesAsync(haus.Id, netz.Id, auto.Id, solar1.Id, solar2.Id, wasser.Id, oilTank.Id, cancellationToken).ConfigureAwait(false);
// Strom and Wasser costs are computed from meters + tariffs; only Heizung comes from the
// Kosten sheet (oil has no tariff). Linking a meter AND importing its Kosten column into the
// same category would double-count, so we keep exactly one cost source per category.
await LinkStromAndWasserAsync(haus.Id, netz.Id, auto.Id, solar1.Id, solar2.Id, wasser.Id, cancellationToken).ConfigureAwait(false);
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
var meterIds = new ReferenceMeterIds(haus.Id, netz.Id, auto.Id, solar1.Id, solar2.Id, wasser.Id, oilTank.Id, burner.Id);
@@ -70,9 +73,19 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService
await ImportSheetAsync(sampleDataDirectory, ElectricityFile, ReferenceProfiles.Electricity(meterIds), cancellationToken).ConfigureAwait(false);
await ImportSheetAsync(sampleDataDirectory, WaterFile, ReferenceProfiles.Water(meterIds), cancellationToken).ConfigureAwait(false);
await ImportSheetAsync(sampleDataDirectory, OilFile, ReferenceProfiles.HeatingOil(meterIds), cancellationToken).ConfigureAwait(false);
await ImportSheetAsync(sampleDataDirectory, CostsFile, ReferenceProfiles.Costs(categoryIds), cancellationToken).ConfigureAwait(false);
await ImportSheetAsync(sampleDataDirectory, CostsFile, HeizungCostsProfile(categoryIds.Heizung), cancellationToken).ConfigureAwait(false);
}
/// <summary>Kosten profile that imports only the Heizung column — Strom/Wasser are metered.</summary>
private static MappingProfile HeizungCostsProfile(int heizungCategoryId) => new()
{
Name = "Energiebilanz — Kosten (Heizung)",
DateColumn = 0,
DateKind = DateKind.MonthName,
FirstDataRowIndex = 1,
Columns = [new ColumnMapping { Index = 3, Role = MappingRole.ManualCost, CategoryId = heizungCategoryId }],
};
private async Task ImportSheetAsync(string dir, string file, MappingProfile profile, CancellationToken cancellationToken)
{
var path = Path.Combine(dir, file);
@@ -121,8 +134,8 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService
ValidFrom = validFrom,
});
private async Task LinkCategoriesAsync(
int haus, int netz, int auto, int solar1, int solar2, int wasser, int oilTank, CancellationToken cancellationToken)
private async Task LinkStromAndWasserAsync(
int haus, int netz, int auto, int solar1, int solar2, int wasser, CancellationToken cancellationToken)
{
var categories = await CategoryIdsAsync(cancellationToken).ConfigureAwait(false);
foreach (var meterId in new[] { haus, netz, auto, solar1, solar2 })
@@ -131,7 +144,7 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService
}
_db.CostCategoryMembers.Add(new CostCategoryMember { CategoryId = categories.Wasser, MeterId = wasser });
_db.CostCategoryMembers.Add(new CostCategoryMember { CategoryId = categories.Heizung, MeterId = oilTank });
// Heizung (oil) cost comes from the imported Kosten column, not a meter — no link (avoids double-count).
}
private async Task<ReferenceCategoryIds> CategoryIdsAsync(CancellationToken cancellationToken)