using Microsoft.EntityFrameworkCore; using Npgsql; using Tau.Acuvim.Portal.Data; using Tau.Acuvim.Portal.DTOs; namespace Tau.Acuvim.Portal.Services; public sealed class FleetQueryService(AdminDbContext db) { private static readonly TimeSpan ActiveWindow = TimeSpan.FromHours(1); public async Task GetDashboardAsync(CancellationToken ct = default) { var nowUtc = DateTime.UtcNow; var activeThreshold = nowUtc - ActiveWindow; var dayStart = DateTime.SpecifyKind(nowUtc.Date, DateTimeKind.Utc); var customers = await db.Customers.AsNoTracking() .OrderBy(c => c.Code) .Select(c => new { c.Id, c.Code, c.Name, c.IsActive, c.LastSeenAt }) .ToListAsync(ct); var siteCounts = await db.FleetSites.AsNoTracking() .GroupBy(s => s.CustomerId) .Select(g => new { CustomerId = g.Key, N = g.Count() }) .ToDictionaryAsync(x => x.CustomerId, x => x.N, ct); var deviceCounts = await db.FleetDevices.AsNoTracking() .GroupBy(d => d.CustomerId) .Select(g => new { CustomerId = g.Key, N = g.Count() }) .ToDictionaryAsync(x => x.CustomerId, x => x.N, ct); var todayStats = await GetTodayStatsAsync(dayStart, ct); var summaries = customers.Select(c => new FleetCustomerSummary( c.Id, c.Code, c.Name, c.IsActive, c.LastSeenAt, siteCounts.GetValueOrDefault(c.Id, 0), deviceCounts.GetValueOrDefault(c.Id, 0), todayStats.TryGetValue(c.Id, out var s) ? s.Measurements : 0, todayStats.TryGetValue(c.Id, out s) ? s.KwhImported : null )).ToList(); var activeCount = customers.Count(c => c.LastSeenAt >= activeThreshold); var oldestActive = customers .Where(c => c.LastSeenAt is not null && c.LastSeenAt >= activeThreshold) .Min(c => c.LastSeenAt); return new FleetDashboardDto( TotalCustomers: customers.Count, ActiveCustomers: activeCount, TotalMeasurementsToday: summaries.Sum(x => x.MeasurementsToday), TotalKwhImportedToday: summaries.Sum(x => x.KwhImportedToday ?? 0), OldestActiveLastSeenAt: oldestActive, Customers: summaries); } public async Task GetCustomerDetailAsync(Guid id, CancellationToken ct = default) { var c = await db.Customers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); if (c is null) return null; var sites = await db.FleetSites.AsNoTracking() .Where(s => s.CustomerId == id) .OrderBy(s => s.Name) .Select(s => new FleetSiteDto(s.Id, s.Name, s.Address, s.LocalMunicipalityId, s.IsActive)) .ToListAsync(ct); var devices = await db.FleetDevices.AsNoTracking() .Where(d => d.CustomerId == id) .OrderBy(d => d.Name) .Select(d => new FleetDeviceDto(d.Id, d.SiteId, d.Name, d.ExternalId, d.Description, d.IsActive)) .ToListAsync(ct); var deviceNameMap = devices.ToDictionary(d => d.Id, d => d.Name); var recent = await db.FleetPowerMeasurements.AsNoTracking() .Where(m => m.CustomerId == id) .OrderByDescending(m => m.Time) .Take(50) .Select(m => new { m.Time, m.DeviceId, m.ActivePowerKw, m.EnergyImportedKwh }) .ToListAsync(ct); var recentDtos = recent.Select(r => new FleetRecentMeasurementDto( r.Time, r.DeviceId, deviceNameMap.GetValueOrDefault(r.DeviceId, "(unknown)"), r.ActivePowerKw, r.EnergyImportedKwh)).ToList(); var events = await db.IngestEvents.AsNoTracking() .Where(e => e.CustomerId == id) .OrderByDescending(e => e.ReceivedAt) .Take(20) .Select(e => new FleetIngestEventDto( e.ReceivedAt, e.BatchType, e.RowsAccepted, e.RowsRejected, e.BatchBytes, e.ClientHwm, e.TimeSpread, e.Error)) .ToListAsync(ct); return new FleetCustomerDetailDto( c.Id, c.Code, c.Name, c.IsActive, c.FirstSeenAt, c.LastSeenAt, c.CreatedAt, sites, devices, recentDtos, events); } // Per-customer today: measurement count + kWh imported delta via raw SQL hitting // the realtime continuous aggregate (falls back to live raw scan for the unmaterialized tail). private async Task> GetTodayStatsAsync( DateTime dayStartUtc, CancellationToken ct) { var conn = db.Database.GetDbConnection(); if (conn.State != System.Data.ConnectionState.Open) await conn.OpenAsync(ct); await using var cmd = conn.CreateCommand(); cmd.CommandText = """ SELECT "CustomerId", SUM(samples)::bigint AS rows, SUM(COALESCE(kwh_imported_delta, 0))::double precision AS kwh FROM fleet.hourly_per_device WHERE bucket >= @dayStart GROUP BY "CustomerId"; """; cmd.Parameters.Add(new NpgsqlParameter("@dayStart", DateTime.SpecifyKind(dayStartUtc, DateTimeKind.Utc))); var map = new Dictionary(); await using var reader = await cmd.ExecuteReaderAsync(ct); while (await reader.ReadAsync(ct)) { var custId = reader.GetGuid(0); var rows = reader.GetInt64(1); var kwh = reader.IsDBNull(2) ? (double?)null : reader.GetDouble(2); map[custId] = (rows, kwh); } return map; } }