From d5419729e547530fd6101c66ceb923af9d8899f0 Mon Sep 17 00:00:00 2001 From: Florian Schmidt Date: Mon, 13 Jul 2026 12:11:12 +0200 Subject: [PATCH] M5: Blazor dashboard (MudBlazor + ApexCharts) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/App/Components/App.razor | 4 + src/App/Components/Layout/MainLayout.razor | 35 +++- src/App/Components/Layout/NavMenu.razor | 11 ++ .../Components/Pages/Admin/EnergyTypes.razor | 37 +++++ src/App/Components/Pages/Admin/Tariffs.razor | 47 ++++++ src/App/Components/Pages/Dashboard.razor | 102 ++++++++++++ src/App/Components/Pages/Home.razor | 7 - src/App/Components/Pages/Import.razor | 128 +++++++++++++++ src/App/Components/Pages/Meters.razor | 64 ++++++++ src/App/Components/Pages/Trends.razor | 47 ++++++ src/App/Components/Shared/CategoryDonut.razor | 24 +++ src/App/Components/Shared/DeltaChip.razor | 19 +++ src/App/Components/Shared/TrendChart.razor | 31 ++++ src/App/Components/_Imports.razor | 7 +- src/App/Format.cs | 24 +++ src/App/MeterVault.App.csproj | 7 + src/App/Program.cs | 2 + src/App/Theme/MeterVaultTheme.cs | 37 +++++ src/App/wwwroot/app.css | 5 + .../Dashboard/DashboardModels.cs | 29 ++++ .../Dashboard/DashboardService.cs | 128 +++++++++++++++ src/Infrastructure/DependencyInjection.cs | 2 + .../Import/ReferenceDataImporter.cs | 146 +++++++++++++++++ .../Import/ReferenceProfiles.cs | 151 ++++++++++-------- .../Integration.Tests/DashboardRenderTests.cs | 74 +++++++++ 25 files changed, 1094 insertions(+), 74 deletions(-) create mode 100644 src/App/Components/Layout/NavMenu.razor create mode 100644 src/App/Components/Pages/Admin/EnergyTypes.razor create mode 100644 src/App/Components/Pages/Admin/Tariffs.razor create mode 100644 src/App/Components/Pages/Dashboard.razor delete mode 100644 src/App/Components/Pages/Home.razor create mode 100644 src/App/Components/Pages/Import.razor create mode 100644 src/App/Components/Pages/Meters.razor create mode 100644 src/App/Components/Pages/Trends.razor create mode 100644 src/App/Components/Shared/CategoryDonut.razor create mode 100644 src/App/Components/Shared/DeltaChip.razor create mode 100644 src/App/Components/Shared/TrendChart.razor create mode 100644 src/App/Format.cs create mode 100644 src/App/Theme/MeterVaultTheme.cs create mode 100644 src/Infrastructure/Dashboard/DashboardModels.cs create mode 100644 src/Infrastructure/Dashboard/DashboardService.cs create mode 100644 src/Infrastructure/Import/ReferenceDataImporter.cs create mode 100644 tests/Integration.Tests/DashboardRenderTests.cs diff --git a/src/App/Components/App.razor b/src/App/Components/App.razor index e802274..61bf5ac 100644 --- a/src/App/Components/App.razor +++ b/src/App/Components/App.razor @@ -7,6 +7,7 @@ + @@ -16,6 +17,9 @@ + + + diff --git a/src/App/Components/Layout/MainLayout.razor b/src/App/Components/Layout/MainLayout.razor index 96fbbe6..6334943 100644 --- a/src/App/Components/Layout/MainLayout.razor +++ b/src/App/Components/Layout/MainLayout.razor @@ -1,9 +1,40 @@ -@inherits LayoutComponentBase +@inherits LayoutComponentBase +@using MeterVault.App.Theme -@Body + + + + + + + + + MeterVault + + + + + + + + + + + + + @Body + + +
An unhandled error has occurred. Reload 🗙
+ +@code { + private bool _drawerOpen = true; + private bool _darkMode = true; +} diff --git a/src/App/Components/Layout/NavMenu.razor b/src/App/Components/Layout/NavMenu.razor new file mode 100644 index 0000000..ed77e41 --- /dev/null +++ b/src/App/Components/Layout/NavMenu.razor @@ -0,0 +1,11 @@ + + Overview + Trends + Meters + Import + + + Energy types + Tariffs + + diff --git a/src/App/Components/Pages/Admin/EnergyTypes.razor b/src/App/Components/Pages/Admin/EnergyTypes.razor new file mode 100644 index 0000000..594400a --- /dev/null +++ b/src/App/Components/Pages/Admin/EnergyTypes.razor @@ -0,0 +1,37 @@ +@page "/admin/energy-types" +@rendermode InteractiveServer +@inject MeterVault.Infrastructure.Persistence.MeterVaultDbContext Db +@using Microsoft.EntityFrameworkCore + +MeterVault — Energy types + +Energy types + +@if (_types is null) +{ + +} +else +{ + + + Key + Display name + Base unit + Default mode + + + @context.Key + @context.DisplayName + @context.BaseUnit + @context.DefaultMode + + +} + +@code { + private List? _types; + + protected override async Task OnInitializedAsync() => + _types = await Db.EnergyTypes.AsNoTracking().OrderBy(t => t.Id).ToListAsync(); +} diff --git a/src/App/Components/Pages/Admin/Tariffs.razor b/src/App/Components/Pages/Admin/Tariffs.razor new file mode 100644 index 0000000..0a5012b --- /dev/null +++ b/src/App/Components/Pages/Admin/Tariffs.razor @@ -0,0 +1,47 @@ +@page "/admin/tariffs" +@rendermode InteractiveServer +@inject MeterVault.Infrastructure.Persistence.MeterVaultDbContext Db +@using Microsoft.EntityFrameworkCore + +MeterVault — Tariffs + +Tariffs + +@if (_tariffs is null) +{ + +} +else if (_tariffs.Count == 0) +{ + No tariffs yet. Load the reference data from Import. +} +else +{ + + + Scope + Component + Value + Unit + Valid from + Valid to + + + @context.ScopeType @(context.ScopeId is { } id ? $"#{id}" : "") + @context.Component + @Format.Number(context.Value, 4) + @context.Unit + @context.ValidFrom.ToString("yyyy-MM-dd") + @(context.ValidTo?.ToString("yyyy-MM-dd") ?? "open") + + +} + +@code { + private List? _tariffs; + + protected override async Task OnInitializedAsync() => + _tariffs = await Db.Tariffs.AsNoTracking() + .OrderBy(t => t.Component).ThenBy(t => t.ValidFrom) + .ToListAsync(); +} diff --git a/src/App/Components/Pages/Dashboard.razor b/src/App/Components/Pages/Dashboard.razor new file mode 100644 index 0000000..5b50dde --- /dev/null +++ b/src/App/Components/Pages/Dashboard.razor @@ -0,0 +1,102 @@ +@page "/" +@rendermode InteractiveServer +@inject DashboardService Dash + +MeterVault — Overview + +Overview + +@if (_summary is null) +{ + +} +else +{ + + + + This month + @Format.Euro(_summary.Month.Current) + + + + + + This year + @Format.Euro(_summary.Year.Current) + + + + + + Latest month with data + @Format.Euro(_summary.LatestMonthCost) + + + + + + What costs most (this year) + @if (_breakdown is { Count: > 0 }) + { + + + + @foreach (var slice in _breakdown) + { + + @slice.Name + @Format.Euro(slice.Cost) + + } + + + } + else + { + No cost data yet — import a sheet or add tariffs. + } + + + + + + What cost more / less (year vs last year) + + + CategoryNowPrevΔ + + + @foreach (var row in _difference) + { + + @row.Name + @Format.Euro(row.Current) + @Format.Euro(row.Previous) + + @Format.DirectionIcon(Math.Sign(row.Delta)) @Format.Euro(Math.Abs(row.Delta)) + + + } + + + + + +} + +@code { + private DashboardSummary? _summary; + private IReadOnlyList _breakdown = []; + private IReadOnlyList _difference = []; + + protected override async Task OnInitializedAsync() + { + var asOf = DateOnly.FromDateTime(DateTime.UtcNow); + _summary = await Dash.GetSummaryAsync(asOf); + + var yearStart = new DateOnly(asOf.Year, 1, 1); + _breakdown = await Dash.GetCategoryBreakdownAsync(yearStart, asOf.AddMonths(1)); + _difference = await Dash.GetCategoryDifferenceAsync(yearStart, yearStart.AddYears(-1), asOf.AddMonths(1)); + } +} diff --git a/src/App/Components/Pages/Home.razor b/src/App/Components/Pages/Home.razor deleted file mode 100644 index 9001e0b..0000000 --- a/src/App/Components/Pages/Home.razor +++ /dev/null @@ -1,7 +0,0 @@ -@page "/" - -Home - -

Hello, world!

- -Welcome to your new app. diff --git a/src/App/Components/Pages/Import.razor b/src/App/Components/Pages/Import.razor new file mode 100644 index 0000000..c75d7d0 --- /dev/null +++ b/src/App/Components/Pages/Import.razor @@ -0,0 +1,128 @@ +@page "/import" +@rendermode InteractiveServer +@inject MeterVault.Infrastructure.Import.ReferenceDataImporter ReferenceImporter +@inject MeterVault.Infrastructure.Import.CsvImporter CsvImporter +@inject ISnackbar Snackbar +@using MeterVault.Infrastructure.Import + +MeterVault — Import + +Import + + + + + Reference dataset + + Load the bundled Energiebilanz sheets (electricity, water, heating oil, costs) as a + starter dataset with meters, tariffs and categories. + + + @(_referenceLoaded ? "Loaded" : "Load reference data") + + @if (_loadingReference) + { + + } + + + + + + Dry-run a CSV + + Upload a sheet and preview what would be staged (no changes are made). + + + Electricity (Strom) + Water (Wasser) + Heating oil (Heizöl) + Costs (Kosten) + + + Choose CSV + + + + + @if (_preview is not null) + { + + + Preview +
+ Readings: @_preview.Readings.Count + Events: @_preview.Events.Count + Manual costs: @_preview.ManualCosts.Count + Skipped rows: @_preview.SkippedRows +
+ @if (_preview.Warnings.Count > 0) + { + + + @foreach (var warning in _preview.Warnings.Take(50)) + { + @warning + } + + + } +
+
+ } +
+ +@code { + private bool _loadingReference; + private bool _referenceLoaded; + private string _profileName = "Strom"; + private StagedImport? _preview; + + protected override async Task OnInitializedAsync() => + _referenceLoaded = await ReferenceImporter.IsLoadedAsync(); + + private async Task LoadReferenceAsync() + { + _loadingReference = true; + try + { + var dir = Path.Combine(AppContext.BaseDirectory, "sampledata"); + await ReferenceImporter.LoadAsync(dir); + _referenceLoaded = true; + Snackbar.Add("Reference data loaded.", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"Import failed: {ex.Message}", Severity.Error); + } + finally + { + _loadingReference = false; + } + } + + private async Task PreviewAsync(InputFileChangeEventArgs args) + { + var file = args.File; + if (file is null) + { + return; + } + + var profile = SelectedProfile(); + using var reader = new StreamReader(file.OpenReadStream(maxAllowedSize: 5 * 1024 * 1024)); + var content = await reader.ReadToEndAsync(); + using var stringReader = new StringReader(content); + _preview = CsvImporter.Stage(profile, stringReader); + } + + private MappingProfile SelectedProfile() => _profileName switch + { + "Wasser" => ReferenceProfiles.Water(), + "Heizöl" => ReferenceProfiles.HeatingOil(), + "Kosten" => ReferenceProfiles.Costs(), + _ => ReferenceProfiles.Electricity(), + }; +} diff --git a/src/App/Components/Pages/Meters.razor b/src/App/Components/Pages/Meters.razor new file mode 100644 index 0000000..f4a6932 --- /dev/null +++ b/src/App/Components/Pages/Meters.razor @@ -0,0 +1,64 @@ +@page "/meters" +@rendermode InteractiveServer +@inject MeterVault.Infrastructure.Persistence.MeterVaultDbContext Db +@using Microsoft.EntityFrameworkCore + +MeterVault — Meters + +Meters + +@if (_meters is null) +{ + +} +else +{ + + + Name + Type + Mode + Unit + Sources + Last seen + + + @context.Name + @context.EnergyType?.DisplayName + @context.Mode + @context.Unit + @context.Sources.Count + + @{ + var lastSeen = context.Sources + .Where(s => s.LastSeenAt != null) + .Select(s => s.LastSeenAt) + .DefaultIfEmpty(null) + .Max(); + } + @(lastSeen?.ToString("yyyy-MM-dd HH:mm") ?? "—") + + + + + @if (_meters.Count == 0) + { + + No meters yet. Go to Import to load the reference data. + + } +} + +@code { + private List? _meters; + + protected override async Task OnInitializedAsync() + { + _meters = await Db.Meters + .AsNoTracking() + .Include(m => m.EnergyType) + .Include(m => m.Sources) + .OrderBy(m => m.EnergyTypeId).ThenBy(m => m.Name) + .ToListAsync(); + } +} diff --git a/src/App/Components/Pages/Trends.razor b/src/App/Components/Pages/Trends.razor new file mode 100644 index 0000000..d617345 --- /dev/null +++ b/src/App/Components/Pages/Trends.razor @@ -0,0 +1,47 @@ +@page "/trends" +@rendermode InteractiveServer +@inject DashboardService Dash + +MeterVault — Trends + +Cost trend + + +
+ + Last 12 months + Last 24 months + Last 48 months + + Apply +
+ + @if (_loading) + { + + } + else + { + + + Total over range: @Format.Euro(_points.Sum(p => p.Cost)) + + } +
+ +@code { + private int _months = 24; + private bool _loading = true; + private IReadOnlyList _points = []; + + protected override Task OnInitializedAsync() => LoadAsync(); + + private async Task LoadAsync() + { + _loading = true; + var asOf = DateOnly.FromDateTime(DateTime.UtcNow); + var from = asOf.AddMonths(-_months); + _points = await Dash.GetMonthlyTrendAsync(new DateOnly(from.Year, from.Month, 1), asOf.AddMonths(1)); + _loading = false; + } +} diff --git a/src/App/Components/Shared/CategoryDonut.razor b/src/App/Components/Shared/CategoryDonut.razor new file mode 100644 index 0000000..5f1d2b0 --- /dev/null +++ b/src/App/Components/Shared/CategoryDonut.razor @@ -0,0 +1,24 @@ +@using ApexCharts + +@if (Slices is { Count: > 0 }) +{ + + + +} + +@code { + [Parameter, EditorRequired] + public IReadOnlyList Slices { get; set; } = []; + + private readonly ApexChartOptions _options = new() + { + Legend = new Legend { Position = LegendPosition.Bottom }, + Theme = new Theme { Mode = Mode.Dark }, + }; +} diff --git a/src/App/Components/Shared/DeltaChip.razor b/src/App/Components/Shared/DeltaChip.razor new file mode 100644 index 0000000..52b3560 --- /dev/null +++ b/src/App/Components/Shared/DeltaChip.razor @@ -0,0 +1,19 @@ +@if (Kpi.Previous != 0 || Kpi.Current != 0) +{ + + @Format.DirectionIcon(Kpi.Direction) @Format.Euro(Math.Abs(Kpi.Delta)) (@Format.Percent(Kpi.DeltaPercent)) + +} + +@code { + [Parameter, EditorRequired] + public CostKpi Kpi { get; set; } = new(0, 0); + + // For cost, up (more expensive) is bad → red; down is good → green. + private Color ChipColor => Kpi.Direction switch + { + > 0 => Color.Error, + < 0 => Color.Success, + _ => Color.Default, + }; +} diff --git a/src/App/Components/Shared/TrendChart.razor b/src/App/Components/Shared/TrendChart.razor new file mode 100644 index 0000000..8ebbce1 --- /dev/null +++ b/src/App/Components/Shared/TrendChart.razor @@ -0,0 +1,31 @@ +@using ApexCharts +@using System.Globalization + +@if (Points is { Count: > 0 }) +{ + + + +} +else +{ + No data in this range. +} + +@code { + [Parameter, EditorRequired] + public IReadOnlyList Points { get; set; } = []; + + private readonly ApexChartOptions _options = new() + { + Theme = new Theme { Mode = Mode.Dark }, + DataLabels = new DataLabels { Enabled = false }, + }; + + private static object Label(TrendPoint p) => p.Period.ToString("MMM yy", CultureInfo.InvariantCulture); +} diff --git a/src/App/Components/_Imports.razor b/src/App/Components/_Imports.razor index d16e1b2..ff95347 100644 --- a/src/App/Components/_Imports.razor +++ b/src/App/Components/_Imports.razor @@ -1,4 +1,4 @@ -@using System.Net.Http +@using System.Net.Http @using System.Net.Http.Json @using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Routing @@ -6,6 +6,11 @@ @using static Microsoft.AspNetCore.Components.Web.RenderMode @using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.JSInterop +@using MudBlazor @using MeterVault.App @using MeterVault.App.Components @using MeterVault.App.Components.Layout +@using MeterVault.App.Components.Shared +@using MeterVault.Core.Domain +@using MeterVault.Infrastructure.Costing +@using MeterVault.Infrastructure.Dashboard diff --git a/src/App/Format.cs b/src/App/Format.cs new file mode 100644 index 0000000..5c60083 --- /dev/null +++ b/src/App/Format.cs @@ -0,0 +1,24 @@ +using System.Globalization; + +namespace MeterVault.App; + +/// Small display formatters for the UI (locale-aware formatting arrives with i18n in M7). +public static class Format +{ + private static readonly CultureInfo Culture = CultureInfo.GetCultureInfo("de-DE"); + + public static string Euro(double value) => value.ToString("N2", Culture) + " €"; + + public static string Number(double value, int decimals = 0) => + value.ToString("N" + decimals.ToString(CultureInfo.InvariantCulture), Culture); + + public static string Percent(double value) => + (value >= 0 ? "+" : "") + value.ToString("N1", Culture) + " %"; + + public static string DirectionIcon(int direction) => direction switch + { + > 0 => "▲", + < 0 => "▼", + _ => "—", + }; +} diff --git a/src/App/MeterVault.App.csproj b/src/App/MeterVault.App.csproj index 3d5ecfd..c0df808 100644 --- a/src/App/MeterVault.App.csproj +++ b/src/App/MeterVault.App.csproj @@ -12,10 +12,17 @@ + + + + + + + diff --git a/src/App/Program.cs b/src/App/Program.cs index de49a1e..4ff29b6 100644 --- a/src/App/Program.cs +++ b/src/App/Program.cs @@ -3,6 +3,7 @@ using MeterVault.Infrastructure; using MeterVault.Infrastructure.Options; using MeterVault.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; +using MudBlazor.Services; using Serilog; Log.Logger = new LoggerConfiguration() @@ -33,6 +34,7 @@ try builder.Services.AddMeterVaultIngestion(); } + builder.Services.AddMudServices(); builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); diff --git a/src/App/Theme/MeterVaultTheme.cs b/src/App/Theme/MeterVaultTheme.cs new file mode 100644 index 0000000..01a6cf8 --- /dev/null +++ b/src/App/Theme/MeterVaultTheme.cs @@ -0,0 +1,37 @@ +using MudBlazor; + +namespace MeterVault.App.Theme; + +/// The MeterVault MudBlazor theme — an energy-dashboard palette, dark by default. +public static class MeterVaultTheme +{ + public const string PrimaryTeal = "#14B8A6"; + public const string SecondaryAmber = "#F6C445"; + + public static readonly MudTheme Instance = new() + { + PaletteLight = new PaletteLight + { + Primary = PrimaryTeal, + Secondary = SecondaryAmber, + AppbarBackground = "#0E7C6B", + Background = "#F7F8FA", + Surface = "#FFFFFF", + }, + PaletteDark = new PaletteDark + { + Primary = PrimaryTeal, + Secondary = SecondaryAmber, + Background = "#0D1117", + Surface = "#161B22", + AppbarBackground = "#10151C", + DrawerBackground = "#10151C", + TextPrimary = "#E6EDF3", + }, + LayoutProperties = new LayoutProperties + { + DrawerWidthLeft = "250px", + DefaultBorderRadius = "8px", + }, + }; +} diff --git a/src/App/wwwroot/app.css b/src/App/wwwroot/app.css index 5388357..fa65787 100644 --- a/src/App/wwwroot/app.css +++ b/src/App/wwwroot/app.css @@ -2,6 +2,11 @@ h1:focus { outline: none; } +/* MeterVault helpers */ +.mv-up { color: #ef5350; } +.mv-down { color: #66bb6a; } +.mv-main { max-width: 1400px; } + .valid.modified:not([type=checkbox]) { outline: 1px solid #26b050; } diff --git a/src/Infrastructure/Dashboard/DashboardModels.cs b/src/Infrastructure/Dashboard/DashboardModels.cs new file mode 100644 index 0000000..32e93df --- /dev/null +++ b/src/Infrastructure/Dashboard/DashboardModels.cs @@ -0,0 +1,29 @@ +namespace MeterVault.Infrastructure.Dashboard; + +/// KPI card figures for a period plus the delta versus the previous comparable period. +public sealed record CostKpi(double Current, double Previous) +{ + public double Delta => Current - Previous; + + public double DeltaPercent => Previous == 0 ? 0 : (Current - Previous) / Math.Abs(Previous) * 100.0; + + /// +1 up, -1 down, 0 flat. + public int Direction => Math.Sign(Math.Round(Delta, 2)); +} + +/// The overview KPIs: month and year cost, each with its previous-period comparison. +public sealed record DashboardSummary(DateOnly AsOf, CostKpi Month, CostKpi Year, double LatestMonthCost); + +/// One slice of the cost breakdown / "what costs most" view. +public sealed record CategorySlice(string Name, string? ColorHex, double Cost); + +/// A point on a monthly cost/consumption trend. +public sealed record TrendPoint(DateOnly Period, double Cost); + +/// A row of the "what cost more / less" difference view. +public sealed record DifferenceRow(string Name, double Current, double Previous) +{ + public double Delta => Current - Previous; + + public double DeltaPercent => Previous == 0 ? 0 : (Current - Previous) / Math.Abs(Previous) * 100.0; +} diff --git a/src/Infrastructure/Dashboard/DashboardService.cs b/src/Infrastructure/Dashboard/DashboardService.cs new file mode 100644 index 0000000..9a0142f --- /dev/null +++ b/src/Infrastructure/Dashboard/DashboardService.cs @@ -0,0 +1,128 @@ +using MeterVault.Infrastructure.Costing; +using MeterVault.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace MeterVault.Infrastructure.Dashboard; + +/// +/// Read model for the dashboard (SDD §8): overview KPIs with period-over-period deltas, the cost +/// breakdown by category, the "what cost more / less" difference view, and monthly trends. Reads +/// only aggregated cost — never the raw hypertable. +/// +public sealed class DashboardService(MeterVaultDbContext db, CostService costService) +{ + private readonly MeterVaultDbContext _db = db; + private readonly CostService _costService = costService; + + public async Task GetSummaryAsync(DateOnly asOf, CancellationToken cancellationToken = default) + { + var monthStart = new DateOnly(asOf.Year, asOf.Month, 1); + var prevMonthStart = monthStart.AddMonths(-1); + var yearStart = new DateOnly(asOf.Year, 1, 1); + var prevYearStart = yearStart.AddYears(-1); + + var month = new CostKpi( + await TotalCostAsync(monthStart, monthStart.AddMonths(1), cancellationToken).ConfigureAwait(false), + await TotalCostAsync(prevMonthStart, monthStart, cancellationToken).ConfigureAwait(false)); + + var year = new CostKpi( + await TotalCostAsync(yearStart, yearStart.AddYears(1), cancellationToken).ConfigureAwait(false), + await TotalCostAsync(prevYearStart, yearStart, cancellationToken).ConfigureAwait(false)); + + var latest = await LatestMonthCostAsync(cancellationToken).ConfigureAwait(false); + return new DashboardSummary(asOf, month, year, latest); + } + + public async Task> GetCategoryBreakdownAsync( + DateOnly from, DateOnly to, CancellationToken cancellationToken = default) + { + var categories = await _db.CostCategories.OrderBy(c => c.Sort).ToListAsync(cancellationToken).ConfigureAwait(false); + var slices = new List(); + foreach (var category in categories) + { + var rollup = await _costService + .GetCategoryCostsAsync(category.Id, ToUtc(from), ToUtc(to), cancellationToken).ConfigureAwait(false); + var total = rollup.Sum(r => r.Cost); + if (Math.Abs(total) > 0.005) + { + slices.Add(new CategorySlice(category.Name, category.ColorHex, total)); + } + } + + return [.. slices.OrderByDescending(s => s.Cost)]; + } + + public async Task> GetCategoryDifferenceAsync( + DateOnly currentStart, DateOnly previousStart, DateOnly span, CancellationToken cancellationToken = default) + { + // span length in months from currentStart. + var months = ((span.Year - currentStart.Year) * 12) + span.Month - currentStart.Month; + var currentEnd = currentStart.AddMonths(Math.Max(1, months)); + var previousEnd = previousStart.AddMonths(Math.Max(1, months)); + + var categories = await _db.CostCategories.OrderBy(c => c.Sort).ToListAsync(cancellationToken).ConfigureAwait(false); + var rows = new List(); + foreach (var category in categories) + { + var current = (await _costService.GetCategoryCostsAsync(category.Id, ToUtc(currentStart), ToUtc(currentEnd), cancellationToken).ConfigureAwait(false)).Sum(r => r.Cost); + var previous = (await _costService.GetCategoryCostsAsync(category.Id, ToUtc(previousStart), ToUtc(previousEnd), cancellationToken).ConfigureAwait(false)).Sum(r => r.Cost); + if (Math.Abs(current) > 0.005 || Math.Abs(previous) > 0.005) + { + rows.Add(new DifferenceRow(category.Name, current, previous)); + } + } + + return [.. rows.OrderByDescending(r => Math.Abs(r.Delta))]; + } + + public async Task> GetMonthlyTrendAsync( + DateOnly from, DateOnly to, CancellationToken cancellationToken = default) + { + var totals = new Dictionary(); + foreach (var meterId in await ActiveMeterIdsAsync(cancellationToken).ConfigureAwait(false)) + { + foreach (var bucket in await _costService.GetMeterCostsAsync(meterId, ToUtc(from), ToUtc(to), CostBucket.Month, cancellationToken).ConfigureAwait(false)) + { + totals[bucket.Period] = totals.GetValueOrDefault(bucket.Period) + bucket.Cost; + } + } + + return [.. totals.OrderBy(kv => kv.Key).Select(kv => new TrendPoint(kv.Key, kv.Value))]; + } + + private async Task TotalCostAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken) + { + double total = 0; + foreach (var meterId in await ActiveMeterIdsAsync(cancellationToken).ConfigureAwait(false)) + { + var costs = await _costService.GetMeterCostsAsync(meterId, ToUtc(from), ToUtc(to), CostBucket.Month, cancellationToken).ConfigureAwait(false); + total += costs.Sum(c => c.Cost); + } + + var manual = await _db.ManualCosts + .Where(c => c.PeriodStart >= from && c.PeriodStart < to) + .SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false); + + return total + (manual ?? 0); + } + + private async Task LatestMonthCostAsync(CancellationToken cancellationToken) + { + var latest = await _db.Consumption + .OrderByDescending(c => c.Time) + .Select(c => (DateTimeOffset?)c.Time) + .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + if (latest is null) + { + return 0; + } + + var monthStart = new DateOnly(latest.Value.Year, latest.Value.Month, 1); + return await TotalCostAsync(monthStart, monthStart.AddMonths(1), cancellationToken).ConfigureAwait(false); + } + + private async Task> ActiveMeterIdsAsync(CancellationToken cancellationToken) => + await _db.Meters.Select(m => m.Id).ToListAsync(cancellationToken).ConfigureAwait(false); + + private static DateTimeOffset ToUtc(DateOnly date) => new(date.Year, date.Month, date.Day, 0, 0, 0, TimeSpan.Zero); +} diff --git a/src/Infrastructure/DependencyInjection.cs b/src/Infrastructure/DependencyInjection.cs index 491548c..b1a8205 100644 --- a/src/Infrastructure/DependencyInjection.cs +++ b/src/Infrastructure/DependencyInjection.cs @@ -25,9 +25,11 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/src/Infrastructure/Import/ReferenceDataImporter.cs b/src/Infrastructure/Import/ReferenceDataImporter.cs new file mode 100644 index 0000000..ba89f20 --- /dev/null +++ b/src/Infrastructure/Import/ReferenceDataImporter.cs @@ -0,0 +1,146 @@ +using MeterVault.Core.Domain; +using MeterVault.Infrastructure.Normalization; +using MeterVault.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace MeterVault.Infrastructure.Import; + +/// +/// Loads the four bundled Energiebilanz 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 . Idempotent — a marker meter guards reruns. +/// +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 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 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 EnergyTypeIdAsync(string key, CancellationToken cancellationToken) => + (await _db.EnergyTypes.FirstAsync(t => t.Key == key, cancellationToken).ConfigureAwait(false)).Id; +} diff --git a/src/Infrastructure/Import/ReferenceProfiles.cs b/src/Infrastructure/Import/ReferenceProfiles.cs index 7380e4b..ec9dbbe 100644 --- a/src/Infrastructure/Import/ReferenceProfiles.cs +++ b/src/Infrastructure/Import/ReferenceProfiles.cs @@ -1,13 +1,25 @@ namespace MeterVault.Infrastructure.Import; +/// Meter ids the reference profiles map columns to. Defaults are the fixed conventions +/// used by the golden reconciliation tests; the importer supplies real database ids at runtime. +public sealed record ReferenceMeterIds( + int Haus, int Netz, int Auto, int Solar1, int Solar2, int Wasser, int OilTank, int Burner) +{ + public static readonly ReferenceMeterIds Default = new(1, 2, 3, 4, 5, 10, 20, 21); +} + +/// Category ids the Kosten profile maps cost columns to. +public sealed record ReferenceCategoryIds(int Heizung, int Strom, int Wasser, int Pool) +{ + public static readonly ReferenceCategoryIds Default = new(1, 2, 3, 4); +} + /// /// Built-in mapping profiles for the four reference Energiebilanz sheets (SDD §6.3). -/// They ship as example imports and as the golden reconciliation fixtures. Meter and category ids -/// are fixed conventions for the reference data; the import wizard maps to real ids at runtime. +/// They ship as example imports and as the golden reconciliation fixtures. /// public static class ReferenceProfiles { - // Reference meter ids. public const int Haus = 1; public const int Netz = 2; public const int Auto = 3; @@ -17,7 +29,6 @@ public static class ReferenceProfiles public const int OilTank = 20; public const int Burner = 21; - // Reference category ids (match DatabaseSeeder order). public const int CategoryHeizung = 1; public const int CategoryStrom = 2; public const int CategoryWasser = 3; @@ -26,71 +37,83 @@ public static class ReferenceProfiles /// Linear heating-oil tank calibration: 7000 L / 150 cm ≈ 46.667 L/cm. public const double OilLitresPerCm = 7000d / 150d; - public static MappingProfile Electricity() => new() + public static MappingProfile Electricity(ReferenceMeterIds? ids = null) { - Name = "Energiebilanz — Strom", - DateColumn = 0, - DateKind = DateKind.MonthName, - FirstDataRowIndex = 1, - Columns = - [ - new ColumnMapping { Index = 1, Role = MappingRole.Reading, MeterId = Haus, Unit = "kWh" }, - new ColumnMapping { Index = 2, Role = MappingRole.Reading, MeterId = Netz, Unit = "kWh" }, - // Index 3 is a blank spacer column — left unmapped (Ignore). - new ColumnMapping { Index = 4, Role = MappingRole.Reading, MeterId = Auto, Unit = "kWh" }, - new ColumnMapping { Index = 5, Role = MappingRole.Reading, MeterId = Solar1, Unit = "kWh" }, - new ColumnMapping { Index = 6, Role = MappingRole.Reading, MeterId = Solar2, Unit = "kWh" }, - ], - }; + ids ??= ReferenceMeterIds.Default; + return new MappingProfile + { + Name = "Energiebilanz — Strom", + DateColumn = 0, + DateKind = DateKind.MonthName, + FirstDataRowIndex = 1, + Columns = + [ + new ColumnMapping { Index = 1, Role = MappingRole.Reading, MeterId = ids.Haus, Unit = "kWh" }, + new ColumnMapping { Index = 2, Role = MappingRole.Reading, MeterId = ids.Netz, Unit = "kWh" }, + // Index 3 is a blank spacer column — left unmapped (Ignore). + new ColumnMapping { Index = 4, Role = MappingRole.Reading, MeterId = ids.Auto, Unit = "kWh" }, + new ColumnMapping { Index = 5, Role = MappingRole.Reading, MeterId = ids.Solar1, Unit = "kWh" }, + new ColumnMapping { Index = 6, Role = MappingRole.Reading, MeterId = ids.Solar2, Unit = "kWh" }, + ], + }; + } - public static MappingProfile Water() => new() + public static MappingProfile Water(ReferenceMeterIds? ids = null) { - Name = "Energiebilanz — Wasser", - DateColumn = 0, - DateKind = DateKind.MonthName, - FirstDataRowIndex = 1, - DetectCumulativeSwaps = true, - Columns = - [ - new ColumnMapping - { - Index = 1, - Role = MappingRole.Reading, - MeterId = Wasser, - Unit = "m3", - SwapConsumptionColumn = 2, // Wasserverbrauch supplies the swap-month consumption. - }, - ], - }; + ids ??= ReferenceMeterIds.Default; + return new MappingProfile + { + Name = "Energiebilanz — Wasser", + DateColumn = 0, + DateKind = DateKind.MonthName, + FirstDataRowIndex = 1, + DetectCumulativeSwaps = true, + Columns = + [ + new ColumnMapping + { + Index = 1, Role = MappingRole.Reading, MeterId = ids.Wasser, Unit = "m3", SwapConsumptionColumn = 2, + }, + ], + }; + } - public static MappingProfile HeatingOil() => new() + public static MappingProfile HeatingOil(ReferenceMeterIds? ids = null) { - Name = "Energiebilanz — Heizöl", - DateColumn = 0, - DateKind = DateKind.Auto, // early rows DD.MM.YYYY, later rows month names. - AnchorMonthsToEnd = true, // a monthly snapshot sorts after same-month day-dated readings. - HeaderRowIndex = 3, - FirstDataRowIndex = 4, - Columns = - [ - new ColumnMapping { Index = 1, Role = MappingRole.Reading, MeterId = Burner, Unit = "h" }, - new ColumnMapping { Index = 4, Role = MappingRole.TankLevel, MeterId = OilTank, Unit = "cm" }, - new ColumnMapping { Index = 7, Role = MappingRole.Delivery, MeterId = OilTank, Unit = "L" }, - ], - }; + ids ??= ReferenceMeterIds.Default; + return new MappingProfile + { + Name = "Energiebilanz — Heizöl", + DateColumn = 0, + DateKind = DateKind.Auto, // early rows DD.MM.YYYY, later rows month names. + AnchorMonthsToEnd = true, // a monthly snapshot sorts after same-month day-dated readings. + HeaderRowIndex = 3, + FirstDataRowIndex = 4, + Columns = + [ + new ColumnMapping { Index = 1, Role = MappingRole.Reading, MeterId = ids.Burner, Unit = "h" }, + new ColumnMapping { Index = 4, Role = MappingRole.TankLevel, MeterId = ids.OilTank, Unit = "cm" }, + new ColumnMapping { Index = 7, Role = MappingRole.Delivery, MeterId = ids.OilTank, Unit = "L" }, + ], + }; + } - public static MappingProfile Costs() => new() + public static MappingProfile Costs(ReferenceCategoryIds? ids = null) { - Name = "Energiebilanz — Kosten", - DateColumn = 0, - DateKind = DateKind.MonthName, - FirstDataRowIndex = 1, - Columns = - [ - new ColumnMapping { Index = 3, Role = MappingRole.ManualCost, CategoryId = CategoryHeizung }, - new ColumnMapping { Index = 4, Role = MappingRole.ManualCost, CategoryId = CategoryStrom }, - new ColumnMapping { Index = 5, Role = MappingRole.ManualCost, CategoryId = CategoryWasser }, - new ColumnMapping { Index = 6, Role = MappingRole.ManualCost, CategoryId = CategoryPool }, - ], - }; + ids ??= ReferenceCategoryIds.Default; + return new MappingProfile + { + Name = "Energiebilanz — Kosten", + DateColumn = 0, + DateKind = DateKind.MonthName, + FirstDataRowIndex = 1, + Columns = + [ + new ColumnMapping { Index = 3, Role = MappingRole.ManualCost, CategoryId = ids.Heizung }, + new ColumnMapping { Index = 4, Role = MappingRole.ManualCost, CategoryId = ids.Strom }, + new ColumnMapping { Index = 5, Role = MappingRole.ManualCost, CategoryId = ids.Wasser }, + new ColumnMapping { Index = 6, Role = MappingRole.ManualCost, CategoryId = ids.Pool }, + ], + }; + } } diff --git a/tests/Integration.Tests/DashboardRenderTests.cs b/tests/Integration.Tests/DashboardRenderTests.cs new file mode 100644 index 0000000..b69b120 --- /dev/null +++ b/tests/Integration.Tests/DashboardRenderTests.cs @@ -0,0 +1,74 @@ +using MeterVault.Infrastructure.Import; +using MeterVault.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace MeterVault.Integration.Tests; + +/// +/// End-to-end M5 check: load the reference dataset, then confirm the dashboard and admin pages +/// render (server prerender) without error and show real data. Cleans up the shared container. +/// +[Collection("Timescale")] +public sealed class DashboardRenderTests(TimescaleFixture fx) +{ + [Fact] + public async Task Pages_render_with_reference_data() + { + using var factory = new MeterVaultAppFactory(fx.ConnectionString); + + using (var scope = factory.Services.CreateScope()) + { + var importer = scope.ServiceProvider.GetRequiredService(); + await importer.LoadAsync(Path.Combine(AppContext.BaseDirectory, "fixtures")); + } + + try + { + // The import created the reference meters and their normalized consumption. + await using (var db = fx.CreateContext()) + { + Assert.Contains(await db.Meters.Select(m => m.Name).ToListAsync(), n => n == "Zähler Haus"); + Assert.True(await db.Meters.CountAsync() >= 8); + Assert.True(await db.Consumption.AnyAsync()); + } + + using var client = factory.CreateClient(); + + var overview = await client.GetAsync(new Uri("/", UriKind.Relative)); + overview.EnsureSuccessStatusCode(); + var html = await overview.Content.ReadAsStringAsync(); + Assert.Contains("Overview", html, StringComparison.Ordinal); + // These labels live only in the rendered-KPI-card branch, so their presence proves the + // summary loaded and the cards rendered (non-ASCII like € is HTML-entity-encoded). + Assert.Contains("This month", html, StringComparison.Ordinal); + Assert.Contains("This year", html, StringComparison.Ordinal); + Assert.Contains("Latest month with data", html, StringComparison.Ordinal); + + foreach (var path in new[] { "/meters", "/trends", "/import", "/admin/tariffs", "/admin/energy-types" }) + { + var response = await client.GetAsync(new Uri(path, UriKind.Relative)); + response.EnsureSuccessStatusCode(); + } + } + finally + { + await using var db = fx.CreateContext(); + await ClearDataAsync(db); + } + } + + private static async Task ClearDataAsync(MeterVaultDbContext db) + { + await db.Consumption.ExecuteDeleteAsync(); + await db.Readings.ExecuteDeleteAsync(); + await db.MeterEvents.ExecuteDeleteAsync(); + await db.ManualCosts.ExecuteDeleteAsync(); + await db.CostCategoryMembers.ExecuteDeleteAsync(); + await db.Tariffs.ExecuteDeleteAsync(); + await db.Tanks.ExecuteDeleteAsync(); + await db.MeterSources.ExecuteDeleteAsync(); + await db.Meters.ExecuteDeleteAsync(); + await db.ImportBatches.ExecuteDeleteAsync(); + } +}