M5: Blazor dashboard (MudBlazor + ApexCharts)
- MudBlazor theme (dark default) + responsive drawer/appbar layout + nav. - DashboardService read model: KPIs with period-over-period deltas, category breakdown, "what cost more/less" difference view, monthly trends. - Pages: Overview (KPI cards + DeltaChip + donut + difference table), Trends (range-select bar chart), Meters (list + source status), Import (load reference dataset + CSV dry-run preview), Admin (energy types, tariffs). Charts isolated into components to avoid the ApexCharts/MudBlazor Color/Format name clashes. - ReferenceDataImporter: one-click load of all four sheets as a starter dataset (meters, tank, tariff history, category memberships) — bundled sample CSVs copied to app output. - End-to-end render test: import creates meters + consumption; overview/meters/trends/ import/admin pages all return 200 with KPI cards rendered. 92 tests green (56 Core + 36 integration). Deferred to polish: dedicated PV & oil/consumable panels, meter-detail page, full admin CRUD, prev-year trend overlay. Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
using MeterVault.Core.Domain;
|
||||
using MeterVault.Infrastructure.Normalization;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MeterVault.Infrastructure.Import;
|
||||
|
||||
/// <summary>
|
||||
/// Loads the four bundled <em>Energiebilanz</em> sheets as a ready-made demo/starter dataset:
|
||||
/// creates the reference meters, tank, tariff history and category memberships, then imports each
|
||||
/// sheet through the normal <see cref="ImportService"/>. Idempotent — a marker meter guards reruns.
|
||||
/// </summary>
|
||||
public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService importService, CsvImporter csvImporter)
|
||||
{
|
||||
public const string ElectricityFile = "Energiebilanz - Strom Verbrauch.csv";
|
||||
public const string WaterFile = "Energiebilanz - Wasser.csv";
|
||||
public const string OilFile = "Energiebilanz - Heizöl Verbrauch.csv";
|
||||
public const string CostsFile = "Energiebilanz - Kosten.csv";
|
||||
|
||||
private const string MarkerName = "Zähler Haus";
|
||||
|
||||
private readonly MeterVaultDbContext _db = db;
|
||||
private readonly ImportService _importService = importService;
|
||||
private readonly CsvImporter _csvImporter = csvImporter;
|
||||
|
||||
public async Task<bool> IsLoadedAsync(CancellationToken cancellationToken = default) =>
|
||||
await _db.Meters.AnyAsync(m => m.Name == MarkerName, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
public async Task LoadAsync(string sampleDataDirectory, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (await IsLoadedAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await DatabaseSeeder.SeedAsync(_db, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var electricity = await EnergyTypeIdAsync("electricity", cancellationToken).ConfigureAwait(false);
|
||||
var water = await EnergyTypeIdAsync("water", cancellationToken).ConfigureAwait(false);
|
||||
var oil = await EnergyTypeIdAsync("heating_oil", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var haus = Meter(MarkerName, electricity, MeterMode.CumulativeCounter, "kWh");
|
||||
var netz = Meter("Zähler Netz", electricity, MeterMode.CumulativeCounter, "kWh");
|
||||
var auto = Meter("Zähler Auto", electricity, MeterMode.CumulativeCounter, "kWh");
|
||||
var solar1 = Meter("Zähler Solar 1", electricity, MeterMode.GenerationCounter, "kWh");
|
||||
var solar2 = Meter("Zähler Solar 2", electricity, MeterMode.GenerationCounter, "kWh");
|
||||
var wasser = Meter("Zähler Wasser", water, MeterMode.CumulativeCounter, "m3", initialBaseline: 820);
|
||||
var oilTank = Meter("Öltank", oil, MeterMode.ConsumableBalance, "L");
|
||||
var burner = Meter("Brenner", oil, MeterMode.RuntimeCounter, "h");
|
||||
|
||||
_db.Meters.AddRange(haus, netz, auto, solar1, solar2, wasser, oilTank, burner);
|
||||
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_db.Tanks.Add(new Tank
|
||||
{
|
||||
MeterId = oilTank.Id,
|
||||
Capacity = 7000,
|
||||
Unit = "L",
|
||||
Calibration = $"{{\"volumePerUnit\":{ReferenceProfiles.OilLitresPerCm.ToString(System.Globalization.CultureInfo.InvariantCulture)}}}",
|
||||
});
|
||||
|
||||
AddElectricityTariffs(electricity);
|
||||
AddTariff(TariffScope.EnergyType, water, 5.00, "EUR/m3", new DateOnly(2022, 11, 1));
|
||||
await LinkCategoriesAsync(haus.Id, netz.Id, auto.Id, solar1.Id, solar2.Id, wasser.Id, oilTank.Id, cancellationToken).ConfigureAwait(false);
|
||||
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var meterIds = new ReferenceMeterIds(haus.Id, netz.Id, auto.Id, solar1.Id, solar2.Id, wasser.Id, oilTank.Id, burner.Id);
|
||||
var categoryIds = await CategoryIdsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await ImportSheetAsync(sampleDataDirectory, ElectricityFile, ReferenceProfiles.Electricity(meterIds), cancellationToken).ConfigureAwait(false);
|
||||
await ImportSheetAsync(sampleDataDirectory, WaterFile, ReferenceProfiles.Water(meterIds), cancellationToken).ConfigureAwait(false);
|
||||
await ImportSheetAsync(sampleDataDirectory, OilFile, ReferenceProfiles.HeatingOil(meterIds), cancellationToken).ConfigureAwait(false);
|
||||
await ImportSheetAsync(sampleDataDirectory, CostsFile, ReferenceProfiles.Costs(categoryIds), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task ImportSheetAsync(string dir, string file, MappingProfile profile, CancellationToken cancellationToken)
|
||||
{
|
||||
var path = Path.Combine(dir, file);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
StagedImport staged;
|
||||
using (var reader = new StreamReader(path))
|
||||
{
|
||||
staged = _csvImporter.Stage(profile, reader);
|
||||
}
|
||||
|
||||
var mappingJson = System.Text.Json.JsonSerializer.Serialize(new { profile = profile.Name });
|
||||
await _importService.CommitAsync(staged, file, mappingJson, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static Meter Meter(string name, short energyTypeId, MeterMode mode, string unit, double initialBaseline = 0) => new()
|
||||
{
|
||||
Name = name,
|
||||
EnergyTypeId = energyTypeId,
|
||||
Mode = mode,
|
||||
Unit = unit,
|
||||
InitialBaseline = initialBaseline,
|
||||
};
|
||||
|
||||
private void AddElectricityTariffs(short energyTypeId)
|
||||
{
|
||||
AddTariff(TariffScope.EnergyType, energyTypeId, 0.16, "EUR/kWh", new DateOnly(2022, 9, 1));
|
||||
AddTariff(TariffScope.EnergyType, energyTypeId, 0.44, "EUR/kWh", new DateOnly(2023, 1, 1));
|
||||
AddTariff(TariffScope.EnergyType, energyTypeId, 0.37, "EUR/kWh", new DateOnly(2023, 5, 1));
|
||||
AddTariff(TariffScope.EnergyType, energyTypeId, 0.27, "EUR/kWh", new DateOnly(2023, 11, 1));
|
||||
AddTariff(TariffScope.EnergyType, energyTypeId, 0.36, "EUR/kWh", new DateOnly(2025, 1, 1));
|
||||
AddTariff(TariffScope.EnergyType, energyTypeId, 0.27, "EUR/kWh", new DateOnly(2026, 1, 1));
|
||||
}
|
||||
|
||||
private void AddTariff(TariffScope scope, int scopeId, double value, string unit, DateOnly validFrom) =>
|
||||
_db.Tariffs.Add(new Tariff
|
||||
{
|
||||
ScopeType = scope,
|
||||
ScopeId = scopeId,
|
||||
Component = TariffComponent.UnitPrice,
|
||||
Value = value,
|
||||
Unit = unit,
|
||||
ValidFrom = validFrom,
|
||||
});
|
||||
|
||||
private async Task LinkCategoriesAsync(
|
||||
int haus, int netz, int auto, int solar1, int solar2, int wasser, int oilTank, CancellationToken cancellationToken)
|
||||
{
|
||||
var categories = await CategoryIdsAsync(cancellationToken).ConfigureAwait(false);
|
||||
foreach (var meterId in new[] { haus, netz, auto, solar1, solar2 })
|
||||
{
|
||||
_db.CostCategoryMembers.Add(new CostCategoryMember { CategoryId = categories.Strom, MeterId = meterId });
|
||||
}
|
||||
|
||||
_db.CostCategoryMembers.Add(new CostCategoryMember { CategoryId = categories.Wasser, MeterId = wasser });
|
||||
_db.CostCategoryMembers.Add(new CostCategoryMember { CategoryId = categories.Heizung, MeterId = oilTank });
|
||||
}
|
||||
|
||||
private async Task<ReferenceCategoryIds> CategoryIdsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var categories = await _db.CostCategories.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
int Find(string name) => categories.FirstOrDefault(c => c.Name == name)?.Id ?? 0;
|
||||
return new ReferenceCategoryIds(Find("Heizung"), Find("Strom"), Find("Wasser"), Find("Pool Betrieb"));
|
||||
}
|
||||
|
||||
private async Task<short> EnergyTypeIdAsync(string key, CancellationToken cancellationToken) =>
|
||||
(await _db.EnergyTypes.FirstAsync(t => t.Key == key, cancellationToken).ConfigureAwait(false)).Id;
|
||||
}
|
||||
Reference in New Issue
Block a user