using Dapper; using MeterVault.Core.Costing; using MeterVault.Core.Domain; using MeterVault.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; 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 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. /// public sealed class CostService(IDbContextFactory contextFactory) { private readonly IDbContextFactory _contextFactory = contextFactory; public async Task> 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(); 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> 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(); 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(); 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> 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(command).ConfigureAwait(false); var result = new Dictionary(); 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); }