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:
@@ -10,24 +10,28 @@ namespace MeterVault.Infrastructure.Costing;
|
||||
/// Computes cost by joining bucketed consumption with time-ranged tariffs (SDD §7.5). Consumption
|
||||
/// is aggregated in SQL (Dapper, local-timezone buckets); the active price for each bucket is
|
||||
/// resolved in C# via <see cref="TariffResolver"/> using the month's dominant price (SDD §14.3).
|
||||
/// Categories roll up their member meters' costs plus meterless manual costs.
|
||||
/// Categories roll up their member meters' costs plus meterless manual costs. Uses a DbContext
|
||||
/// factory (short-lived context per operation) so it is safe from a Blazor circuit.
|
||||
/// </summary>
|
||||
public sealed class CostService(MeterVaultDbContext db)
|
||||
public sealed class CostService(IDbContextFactory<MeterVaultDbContext> contextFactory)
|
||||
{
|
||||
private readonly MeterVaultDbContext _db = db;
|
||||
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
|
||||
|
||||
public async Task<IReadOnlyList<MeterCostBucket>> GetMeterCostsAsync(
|
||||
int meterId, DateTimeOffset from, DateTimeOffset to, CostBucket bucket = CostBucket.Month,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var meter = await _db.Meters.FirstOrDefaultAsync(m => m.Id == meterId, cancellationToken).ConfigureAwait(false);
|
||||
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var meter = await db.Meters.AsNoTracking()
|
||||
.FirstOrDefaultAsync(m => m.Id == meterId, cancellationToken).ConfigureAwait(false);
|
||||
if (meter is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var tariffs = await _db.Tariffs.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
var series = await QueryConsumptionAsync(meterId, from, to, bucket, cancellationToken).ConfigureAwait(false);
|
||||
var tariffs = await db.Tariffs.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
var series = await QueryConsumptionAsync(db, meterId, from, to, bucket, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var results = new List<MeterCostBucket>();
|
||||
foreach (var period in series.Keys.OrderBy(k => k))
|
||||
@@ -39,7 +43,8 @@ public sealed class CostService(MeterVaultDbContext db)
|
||||
var basePrice = TariffResolver.ResolveValue(tariffs, TariffComponent.BasePrice, meterId, meter.EnergyTypeId, mid);
|
||||
var feedIn = TariffResolver.ResolveValue(tariffs, TariffComponent.FeedIn, meterId, meter.EnergyTypeId, mid);
|
||||
|
||||
var cost = (consumption * unitPrice) + basePrice - (generation * feedIn);
|
||||
// Base price is a monthly standing charge: prorate it to the bucket length.
|
||||
var cost = (consumption * unitPrice) + (basePrice * MonthsInBucket(period, bucket)) - (generation * feedIn);
|
||||
results.Add(new MeterCostBucket(period, consumption, generation, cost));
|
||||
}
|
||||
|
||||
@@ -49,7 +54,9 @@ public sealed class CostService(MeterVaultDbContext db)
|
||||
public async Task<IReadOnlyList<CategoryCostBucket>> GetCategoryCostsAsync(
|
||||
int categoryId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var members = await _db.CostCategoryMembers
|
||||
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var members = await db.CostCategoryMembers.AsNoTracking()
|
||||
.Where(m => m.CategoryId == categoryId)
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -63,7 +70,7 @@ public sealed class CostService(MeterVaultDbContext db)
|
||||
|
||||
if (member.EnergyTypeId is { } energyTypeId)
|
||||
{
|
||||
var byType = await _db.Meters.Where(m => m.EnergyTypeId == energyTypeId).Select(m => m.Id)
|
||||
var byType = await db.Meters.AsNoTracking().Where(m => m.EnergyTypeId == energyTypeId).Select(m => m.Id)
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
meterIds.UnionWith(byType);
|
||||
}
|
||||
@@ -78,9 +85,10 @@ public sealed class CostService(MeterVaultDbContext db)
|
||||
}
|
||||
}
|
||||
|
||||
var manualCosts = await _db.ManualCosts
|
||||
.Where(c => c.CategoryId == categoryId && c.PeriodStart >= DateOnly.FromDateTime(from.UtcDateTime)
|
||||
&& c.PeriodStart < DateOnly.FromDateTime(to.UtcDateTime))
|
||||
var fromDate = DateOnly.FromDateTime(from.Date);
|
||||
var toDate = DateOnly.FromDateTime(to.Date);
|
||||
var manualCosts = await db.ManualCosts.AsNoTracking()
|
||||
.Where(c => c.CategoryId == categoryId && c.PeriodStart >= fromDate && c.PeriodStart < toDate)
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
foreach (var cost in manualCosts)
|
||||
{
|
||||
@@ -91,8 +99,9 @@ public sealed class CostService(MeterVaultDbContext db)
|
||||
return [.. totals.OrderBy(kv => kv.Key).Select(kv => new CategoryCostBucket(kv.Key, kv.Value))];
|
||||
}
|
||||
|
||||
private async Task<Dictionary<DateOnly, (double Consumption, double Generation)>> QueryConsumptionAsync(
|
||||
int meterId, DateTimeOffset from, DateTimeOffset to, CostBucket bucket, CancellationToken cancellationToken)
|
||||
private static async Task<Dictionary<DateOnly, (double Consumption, double Generation)>> QueryConsumptionAsync(
|
||||
MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, CostBucket bucket,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var interval = bucket switch
|
||||
{
|
||||
@@ -107,7 +116,7 @@ public sealed class CostService(MeterVaultDbContext db)
|
||||
"FROM consumption WHERE meter_id = @meterId AND \"time\" >= @from AND \"time\" < @to " +
|
||||
"GROUP BY period, kind";
|
||||
|
||||
var connection = _db.Database.GetDbConnection();
|
||||
var connection = db.Database.GetDbConnection();
|
||||
var command = new CommandDefinition(sql, new { meterId, from, to }, cancellationToken: cancellationToken);
|
||||
var rows = await connection.QueryAsync<ConsumptionRow>(command).ConfigureAwait(false);
|
||||
|
||||
@@ -123,6 +132,13 @@ public sealed class CostService(MeterVaultDbContext db)
|
||||
return result;
|
||||
}
|
||||
|
||||
private static double MonthsInBucket(DateOnly period, CostBucket bucket) => bucket switch
|
||||
{
|
||||
CostBucket.Day => 1.0 / DateTime.DaysInMonth(period.Year, period.Month),
|
||||
CostBucket.Year => 12.0,
|
||||
_ => 1.0,
|
||||
};
|
||||
|
||||
private static DateOnly RepresentativeDate(DateOnly period, CostBucket bucket) => bucket switch
|
||||
{
|
||||
CostBucket.Day => period,
|
||||
|
||||
Reference in New Issue
Block a user