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 continuous-aggregate refresh (SDD §7.5, §5.4). /// [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_continuous_aggregate_refreshes_and_matches_base() { await using var db = fx.CreateContext(); var meterId = await ImportWaterAsync(db); // refresh_continuous_aggregate cannot run inside a transaction — use the raw connection. var connection = db.Database.GetDbConnection(); await connection.OpenAsync(); await using (var cmd = connection.CreateCommand()) { cmd.CommandText = "CALL refresh_continuous_aggregate('consumption_monthly', NULL, NULL);"; await cmd.ExecuteNonQueryAsync(); } double aggregated; await using (var cmd = connection.CreateCommand()) { cmd.CommandText = "SELECT sum(amount) FROM consumption_monthly WHERE meter_id = @m " + "AND (bucket AT TIME ZONE 'Europe/Berlin')::date = DATE '2022-12-01';"; var p = cmd.CreateParameter(); p.ParameterName = "m"; p.Value = meterId; cmd.Parameters.Add(p); aggregated = Convert.ToDouble(await cmd.ExecuteScalarAsync()); } Assert.Equal(14d, aggregated, 1); // Dez 2022 consumption 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(); } }