Files
MeterVault/src/Infrastructure/Costing/CostService.cs
T
schmidt.florian a6edec2b12
ci / build-test (push) Successful in 2m45s
Polish/audit: fix bugs found by 3 subsystem audits
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
2026-07-13 12:56:51 +02:00

151 lines
6.6 KiB
C#

using Dapper;
using MeterVault.Core.Costing;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.Infrastructure.Costing;
/// <summary>
/// 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. Uses a DbContext
/// factory (short-lived context per operation) so it is safe from a Blazor circuit.
/// </summary>
public sealed class CostService(IDbContextFactory<MeterVaultDbContext> contextFactory)
{
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)
{
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(db, meterId, from, to, bucket, cancellationToken).ConfigureAwait(false);
var results = new List<MeterCostBucket>();
foreach (var period in series.Keys.OrderBy(k => k))
{
var (consumption, generation) = series[period];
var mid = RepresentativeDate(period, bucket);
var unitPrice = TariffResolver.ResolveValue(tariffs, TariffComponent.UnitPrice, meterId, meter.EnergyTypeId, mid);
var basePrice = TariffResolver.ResolveValue(tariffs, TariffComponent.BasePrice, meterId, meter.EnergyTypeId, mid);
var feedIn = TariffResolver.ResolveValue(tariffs, TariffComponent.FeedIn, meterId, meter.EnergyTypeId, mid);
// 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));
}
return results;
}
public async Task<IReadOnlyList<CategoryCostBucket>> GetCategoryCostsAsync(
int categoryId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken = default)
{
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);
var meterIds = new HashSet<int>();
foreach (var member in members)
{
if (member.MeterId is { } meterId)
{
meterIds.Add(meterId);
}
if (member.EnergyTypeId is { } energyTypeId)
{
var byType = await db.Meters.AsNoTracking().Where(m => m.EnergyTypeId == energyTypeId).Select(m => m.Id)
.ToListAsync(cancellationToken).ConfigureAwait(false);
meterIds.UnionWith(byType);
}
}
var totals = new Dictionary<DateOnly, double>();
foreach (var meterId in meterIds)
{
foreach (var mc in await GetMeterCostsAsync(meterId, from, to, CostBucket.Month, cancellationToken).ConfigureAwait(false))
{
totals[mc.Period] = totals.GetValueOrDefault(mc.Period) + mc.Cost;
}
}
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)
{
var period = new DateOnly(cost.PeriodStart.Year, cost.PeriodStart.Month, 1);
totals[period] = totals.GetValueOrDefault(period) + cost.Amount;
}
return [.. totals.OrderBy(kv => kv.Key).Select(kv => new CategoryCostBucket(kv.Key, kv.Value))];
}
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
{
CostBucket.Day => "1 day",
CostBucket.Year => "1 year",
_ => "1 month",
};
var sql =
$"SELECT (time_bucket(INTERVAL '{interval}', \"time\", 'Europe/Berlin') AT TIME ZONE 'Europe/Berlin')::date AS period, " +
"kind, sum(amount) AS amount " +
"FROM consumption WHERE meter_id = @meterId AND \"time\" >= @from AND \"time\" < @to " +
"GROUP BY period, kind";
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);
var result = new Dictionary<DateOnly, (double, double)>();
foreach (var row in rows)
{
var current = result.GetValueOrDefault(row.Period);
result[row.Period] = row.Kind == (short)ConsumptionKind.Generation
? (current.Item1, current.Item2 + row.Amount)
: (current.Item1 + row.Amount, current.Item2);
}
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,
CostBucket.Year => new DateOnly(period.Year, 7, 1),
_ => new DateOnly(period.Year, period.Month, 15),
};
private sealed record ConsumptionRow(DateOnly Period, short Kind, double Amount);
}