ci / build-test (push) Successful in 1m19s
Three requested phases.
1) Admin section (SDD §8.7) — MudBlazor inline-dialog CRUD, consistent pattern,
delete guards, snackbar feedback, shared Confirm helper:
- Energy types: create/edit/delete (blocks delete when meters reference it).
- Meters: create/edit/delete; recomputes consumption when mode/baseline
changes (NormalizationService over a fresh factory context, in a tx);
delete cascades data (consumption+readings are Restrict → removed first).
- A meter's ingest sources: manage on the meter-detail Sources tab
(add/edit/delete MQTT/Tasmota/HA sources with typed config).
- Tariffs: full CRUD (scope/component/value/validity).
- Cost categories: CRUD + member management (meter or energy-type members).
- Connectors: ingestion_endpoint CRUD (MQTT broker + Home Assistant);
secrets referenced by env-var name only, never stored.
- Settings: read-only effective-config view (settings are env-driven and
reproducible, so an editable form would change nothing — kept honest).
PV role is now editable on meters (MeterMeta.SetRole can clear a role).
2) Read Home Assistant — extracted a shared public HaEndpointConfig (was a
private record in the worker), added HaConnectionTester (powers the connector
"Test connection": checks base URL + env-resolved token, optionally reads one
entity). Configuring an HA connector + an HA source on a meter drives the
existing REST-poll worker end to end. (WebSocket push stays a future
optimization; REST poll already reads HA.)
3) Wiring/placeholder audit — swept every OnClick/Href: all handlers are real,
all internal links resolve to real routes, no TODO/stub/placeholder code.
Fixed one genuine gap: MainLayout had no drawer toggle, so the nav was
unreachable on narrow screens — added a hamburger button.
Tests: +6 (MeterMeta.SetRole role-removal; HaConnectionTester fail-closed
guard branches with a throwing HttpClientFactory proving no network on bad
config); render test now covers all admin routes. 69 Core + 45 Integration =
114 green. Live-verified in Docker: all admin pages 200, drawer toggle present,
Settings shows real effective config.
Claude-Session: https://claude.ai/code/session_01Lz2RqAsnQhetqWNoCDfexK
84 lines
4.4 KiB
C#
84 lines
4.4 KiB
C#
using MeterVault.Core.Domain;
|
|
using MeterVault.Infrastructure.Persistence;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace MeterVault.Infrastructure.Dashboard;
|
|
|
|
/// <summary>
|
|
/// Read model for the meter-detail view (SDD §8.6). Bounds the raw-reading and consumption pulls
|
|
/// (this is the one place the UI touches raw rows) and gathers source status, the applicable tariff
|
|
/// timeline and lifecycle events. DbContext factory keeps it Blazor-circuit safe.
|
|
/// </summary>
|
|
public sealed class MeterDetailService(IDbContextFactory<MeterVaultDbContext> contextFactory)
|
|
{
|
|
private const int MaxRows = 200;
|
|
|
|
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
|
|
|
|
public async Task<MeterDetailView?> GetAsync(int meterId, CancellationToken cancellationToken = default)
|
|
{
|
|
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
var meter = await db.Meters.AsNoTracking()
|
|
.Include(m => m.EnergyType)
|
|
.Include(m => m.Sources)
|
|
.FirstOrDefaultAsync(m => m.Id == meterId, cancellationToken).ConfigureAwait(false);
|
|
if (meter is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var readingCount = await db.Readings.AsNoTracking().CountAsync(r => r.MeterId == meterId, cancellationToken).ConfigureAwait(false);
|
|
var consumptionCount = await db.Consumption.AsNoTracking().CountAsync(c => c.MeterId == meterId, cancellationToken).ConfigureAwait(false);
|
|
|
|
var first = await db.Readings.AsNoTracking().Where(r => r.MeterId == meterId)
|
|
.OrderBy(r => r.Time).Select(r => new { r.Time, r.Value })
|
|
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
|
var last = await db.Readings.AsNoTracking().Where(r => r.MeterId == meterId)
|
|
.OrderByDescending(r => r.Time).Select(r => new { r.Time, r.Value })
|
|
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
var totalConsumption = await db.Consumption.AsNoTracking()
|
|
.Where(c => c.MeterId == meterId && c.Kind == ConsumptionKind.Consumption)
|
|
.SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false) ?? 0;
|
|
var totalGeneration = await db.Consumption.AsNoTracking()
|
|
.Where(c => c.MeterId == meterId && c.Kind == ConsumptionKind.Generation)
|
|
.SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false) ?? 0;
|
|
|
|
var recentReadings = await db.Readings.AsNoTracking()
|
|
.Where(r => r.MeterId == meterId)
|
|
.OrderByDescending(r => r.Time).Take(MaxRows)
|
|
.Select(r => new ReadingRow(r.Time, r.Value, r.Quality, r.Flags))
|
|
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
var recentConsumption = await db.Consumption.AsNoTracking()
|
|
.Where(c => c.MeterId == meterId)
|
|
.OrderByDescending(c => c.Time).Take(MaxRows)
|
|
.Select(c => new ConsumptionDetailRow(c.Time, c.Amount, c.Kind, c.Quality))
|
|
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
var events = await db.MeterEvents.AsNoTracking()
|
|
.Where(e => e.MeterId == meterId)
|
|
.OrderByDescending(e => e.Time)
|
|
.Select(e => new EventRow(e.Time, e.EventType, e.Amount, e.PrevValue, e.NewValue, e.Unit, e.Notes))
|
|
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
var energyTypeId = meter.EnergyTypeId;
|
|
var tariffs = await db.Tariffs.AsNoTracking()
|
|
.Where(t => t.ScopeType == TariffScope.Global
|
|
|| (t.ScopeType == TariffScope.EnergyType && t.ScopeId == energyTypeId)
|
|
|| (t.ScopeType == TariffScope.Meter && t.ScopeId == meterId))
|
|
.OrderBy(t => t.Component).ThenBy(t => t.ValidFrom)
|
|
.Select(t => new TariffRow(t.ScopeType, t.ScopeId, t.Component, t.Value, t.Unit, t.ValidFrom, t.ValidTo))
|
|
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
return new MeterDetailView(
|
|
meter.Id, meter.Name, meter.EnergyType?.DisplayName ?? "—", meter.Mode, meter.Unit,
|
|
meter.Location, meter.SerialNumber, meter.Manufacturer, meter.Model, meter.InitialBaseline, meter.IsActive,
|
|
readingCount, consumptionCount,
|
|
first?.Time, last?.Time, first?.Value, last?.Value,
|
|
totalConsumption, totalGeneration,
|
|
recentReadings, recentConsumption, events, tariffs, meter.Sources.Count);
|
|
}
|
|
}
|