M4: aggregation + tariff-aware cost engine
- ContinuousAggregates migration: consumption_daily/monthly/yearly in Europe/Berlin buckets via migrationBuilder.Sql(..., suppressTransaction: true), with refresh policies (end_offset >= 1 bucket; current bucket covered by real-time aggregation). - TariffResolver (Core): time-ranged price resolution, scope precedence meter > type > global. - CostService: Dapper-aggregated monthly consumption × resolved unit price (+base, -feed-in), month-dominant pricing; category rollups over member meters + meterless manual costs. - Tests: water cost reconciles to the sheet's Kosten column, Wasser category rollup (Dez=70€), monthly CAgg refresh matches base, TariffResolver unit tests. 91 tests green (56 Core + 35 integration). Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
namespace MeterVault.Infrastructure.Costing;
|
||||
|
||||
/// <summary>Cost and consumption/generation for one meter in one time bucket.</summary>
|
||||
public sealed record MeterCostBucket(DateOnly Period, double Consumption, double Generation, double Cost);
|
||||
|
||||
/// <summary>Rolled-up cost for a category in one time bucket (meters + manual costs).</summary>
|
||||
public sealed record CategoryCostBucket(DateOnly Period, double Cost);
|
||||
|
||||
/// <summary>Bucket granularity for cost/consumption queries.</summary>
|
||||
public enum CostBucket
|
||||
{
|
||||
Day,
|
||||
Month,
|
||||
Year,
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
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.
|
||||
/// </summary>
|
||||
public sealed class CostService(MeterVaultDbContext db)
|
||||
{
|
||||
private readonly MeterVaultDbContext _db = db;
|
||||
|
||||
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);
|
||||
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 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);
|
||||
|
||||
var cost = (consumption * unitPrice) + basePrice - (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)
|
||||
{
|
||||
var members = await _db.CostCategoryMembers
|
||||
.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.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 manualCosts = await _db.ManualCosts
|
||||
.Where(c => c.CategoryId == categoryId && c.PeriodStart >= DateOnly.FromDateTime(from.UtcDateTime)
|
||||
&& c.PeriodStart < DateOnly.FromDateTime(to.UtcDateTime))
|
||||
.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 async Task<Dictionary<DateOnly, (double Consumption, double Generation)>> QueryConsumptionAsync(
|
||||
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 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);
|
||||
}
|
||||
Reference in New Issue
Block a user