using MeterVault.Core.Domain; using MeterVault.Core.Normalization; using MeterVault.Infrastructure.Costing; using MeterVault.Infrastructure.Import; using MeterVault.Infrastructure.Normalization; using MeterVault.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; using static MeterVault.Integration.Tests.Reconciliation.ReconciliationSupport; namespace MeterVault.Integration.Tests.Costing; /// /// Reconciles the cost engine against the Wasser sheet's Kosten column (consumption × €/m³) and /// checks category rollups and the monthly consumption rollups that replaced the continuous /// aggregates (SDD §7.5, D-17). /// [Collection("Timescale")] public sealed class CostReconciliationTests(TimescaleFixture fx) { private static readonly DateTimeOffset From = new(2022, 11, 1, 0, 0, 0, TimeSpan.Zero); private static readonly DateTimeOffset To = new(2024, 1, 1, 0, 0, 0, TimeSpan.Zero); [Fact] public async Task Water_cost_matches_the_sheet() { await using var db = fx.CreateContext(); var meterId = await ImportWaterAsync(db); db.Tariffs.Add(new Tariff { ScopeType = TariffScope.Meter, ScopeId = meterId, Component = TariffComponent.UnitPrice, Value = 5.00, Unit = "EUR/m3", ValidFrom = new DateOnly(2022, 11, 1), }); await db.SaveChangesAsync(); var costs = await new CostService(fx).GetMeterCostsAsync(meterId, From, To); var computed = costs.ToDictionary(c => c.Period, c => c.Cost); var oracle = OracleByMonth(ReadRows(Water), dateColumn: 0, valueColumn: 4, firstDataRow: 1); // Kosten AssertReconciles(computed, oracle, tolerance: 0.02, "water cost", minMatches: 10); await CleanupAsync(db, meterId); } [Fact] public async Task Category_rollup_includes_meter_costs() { await using var db = fx.CreateContext(); var meterId = await ImportWaterAsync(db); var wasserCategory = await db.CostCategories.FirstAsync(c => c.Name == "Wasser"); db.Tariffs.Add(new Tariff { ScopeType = TariffScope.Meter, ScopeId = meterId, Component = TariffComponent.UnitPrice, Value = 5.00, Unit = "EUR/m3", ValidFrom = new DateOnly(2022, 11, 1), }); db.CostCategoryMembers.Add(new CostCategoryMember { CategoryId = wasserCategory.Id, MeterId = meterId }); await db.SaveChangesAsync(); var rollup = await new CostService(fx).GetCategoryCostsAsync(wasserCategory.Id, From, To); var dec2022 = rollup.Single(r => r.Period == new DateOnly(2022, 12, 1)); Assert.Equal(70d, dec2022.Cost, 2); // Dez 2022: 14 m³ × 5,00 € await db.CostCategoryMembers.Where(m => m.MeterId == meterId).ExecuteDeleteAsync(); await CleanupAsync(db, meterId); } [Fact] public async Task Monthly_rollup_equals_the_consumption_it_sums() { // D-17: the rollup written with the import, in the normalizer's zone (UTC here), is what month and year // reads use — no refresh step, no lag, and the same total the consumption rows add up to. await using var db = fx.CreateContext(); var meterId = await ImportWaterAsync(db); var months = await db.ConsumptionRollupMonths.AsNoTracking() .Where(r => r.MeterId == meterId) .ToDictionaryAsync(r => r.Month, r => r.Amount); var rows = await db.Consumption.AsNoTracking().Where(c => c.MeterId == meterId).ToListAsync(); var byMonth = rows .GroupBy(c => new DateOnly(c.Time.UtcDateTime.Year, c.Time.UtcDateTime.Month, 1)) .ToDictionary(g => g.Key, g => g.Sum(c => c.Amount)); Assert.Equal(14d, months[new DateOnly(2022, 12, 1)], 6); // Dez 2022 consumption Assert.Equal(byMonth.Keys.Order(), months.Keys.Order()); Assert.All(byMonth, m => Assert.Equal(m.Value, months[m.Key], 9)); // The day table holds the same December, on the day the month row is filed. var decemberDays = await db.ConsumptionRollups.AsNoTracking() .Where(r => r.MeterId == meterId && r.Day >= new DateOnly(2022, 12, 1) && r.Day < new DateOnly(2023, 1, 1)) .SumAsync(r => r.Amount); Assert.Equal(14d, decemberDays, 6); await CleanupAsync(db, meterId); } private static async Task ImportWaterAsync(MeterVaultDbContext db) { await DatabaseSeeder.SeedAsync(db); var waterType = await db.EnergyTypes.FirstAsync(t => t.Key == "water"); var meter = new Meter { Name = $"cost-water-{Guid.NewGuid():N}", EnergyTypeId = waterType.Id, Mode = MeterMode.CumulativeCounter, Unit = "m3", InitialBaseline = 820, }; db.Meters.Add(meter); await db.SaveChangesAsync(); var profile = new MappingProfile { Name = "cost-water", DateColumn = 0, DateKind = DateKind.MonthName, FirstDataRowIndex = 1, DetectCumulativeSwaps = true, Columns = [ new ColumnMapping { Index = 1, Role = MappingRole.Reading, MeterId = meter.Id, Unit = "m3", SwapConsumptionColumn = 2, }, ], }; StagedImport staged; using (var reader = new StreamReader(FixturePath(Water))) { staged = new CsvImporter().Stage(profile, reader); } var service = new ImportService(db, new NormalizationService(db, NormalizationEngine.CreateDefault())); await service.CommitAsync(staged, "Wasser.csv", null); return meter.Id; } private static async Task CleanupAsync(MeterVaultDbContext db, int meterId) { await db.Consumption.Where(c => c.MeterId == meterId).ExecuteDeleteAsync(); await db.Readings.Where(r => r.MeterId == meterId).ExecuteDeleteAsync(); await db.MeterEvents.Where(e => e.MeterId == meterId).ExecuteDeleteAsync(); await db.Tariffs.Where(t => t.ScopeId == meterId && t.ScopeType == TariffScope.Meter).ExecuteDeleteAsync(); await db.Meters.Where(m => m.Id == meterId).ExecuteDeleteAsync(); } }