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();
}
}