using MeterVault.Core.Domain; using MeterVault.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; namespace MeterVault.Infrastructure.Dashboard; /// /// 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. /// public sealed class MeterDetailService(IDbContextFactory contextFactory) { private const int MaxRows = 200; private readonly IDbContextFactory _contextFactory = contextFactory; public async Task 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); } }