From 76273068003a9581005e54b72d5e4ccb4b75863b Mon Sep 17 00:00:00 2001 From: Diseri Pearson Date: Mon, 18 May 2026 11:45:44 +0200 Subject: [PATCH] Cross-customer cost compute on the Admin side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-customer cost over a UTC time range, joining the fleet hierarchy: hourly_per_device CA × Devices × Sites × Municipalities × active Tariff × period (in the municipality's local time) → per-bucket kWh × rate. Per-day rollup + per-device breakdown. VAT applied per bucket so cost stays correct across mid-window tariff changes. Backend - FleetCostService.ComputeAsync(customerId, fromUtc, toUtc): - Loads device→site→municipality mapping (small per customer). - Loads tariffs (with periods) grouped by municipality, ordered by EffectiveFrom desc for active-tariff lookup. - Reads hourly buckets in range from fleet.hourly_per_device via raw ADO (the CA isn't an EF entity). - For each bucket: pick active tariff for the bucket's date, convert bucket start to local time via Municipality.TimeZoneId, pick the matching period (or default rate), compute base + VAT. - Rolls up per UTC date + per device. Tracks BucketsWithoutTariff when site has no muni or no tariff covers the bucket date. - New DTOs: FleetCostDto / FleetCostDayDto / FleetCostDeviceDto. - Endpoint: GET /api/fleet/customers/{id}/cost?from&to. AdminOnly. Validation: to > from, range <= 366 days (BadRequest on violation). - Period-selection helper duplicated from CostCalculator (5 lines; generic abstraction across TariffPeriod / FleetTariffPeriod is more code than the duplication). - Fixed monthly charges deliberately NOT applied (whole-month billing concept; FixedMonthlyChargesIncluded=false in the response). Frontend - AdminCustomerDetailPage gets a Cost tab: - RangePicker with quick ranges (Today, Last 7d, Last 30d, This month). Default last 7 days. - 4 Statistic cards: total kWh, base cost, VAT, total. - Warning alerts: when buckets-without-tariff > 0; always-on info that fixed monthly charges aren't included. - Per-day table + per-device table. Verified end-to-end on the running stack - Patched DEV0001's existing site to LocalMunicipalityId=1 (Phase 23 test municipality with Domestic TOU tariff). - Ingested 3 measurements at 10:00 / 10:20 / 10:40 UTC with kWh totals 2000 / 2020 / 2050 → hourly CA bucket has delta = 50 kWh. - Total kWh in window = 56 (50 from new bucket + 6 from earlier Phase 14 backfill bucket). - Tariff resolution: 10:00-12:00 UTC = 12:00-14:00 SAST, which is neither Peak (17:00-20:00 weekdays) nor Off-Peak (weekends only) → defaults to 2.50/kWh. - 56 × 2.50 = 140.00 base ✓ - 140 × 0.15 = 21.00 VAT ✓ - Total 161.00 ✓ exactly what the API returned. Docs: FLEET-DESIGN.md §11 row updated — tariff sync + cost compute both marked as shipped. Co-Authored-By: Claude Opus 4.7 (1M context) --- portal/docs/FLEET-DESIGN.md | 2 +- portal/frontend/src/api/fleet.ts | 43 ++++ .../src/pages/AdminCustomerDetailPage.tsx | 118 ++++++++++- .../Tau.Acuvim.Portal/DTOs/FleetCostDtos.cs | 29 +++ .../Endpoints/FleetDashboardEndpoints.cs | 14 ++ portal/src/Tau.Acuvim.Portal/Program.cs | 1 + .../Services/FleetCostService.cs | 189 ++++++++++++++++++ 7 files changed, 392 insertions(+), 4 deletions(-) create mode 100644 portal/src/Tau.Acuvim.Portal/DTOs/FleetCostDtos.cs create mode 100644 portal/src/Tau.Acuvim.Portal/Services/FleetCostService.cs diff --git a/portal/docs/FLEET-DESIGN.md b/portal/docs/FLEET-DESIGN.md index 447ead7..17e3e42 100644 --- a/portal/docs/FLEET-DESIGN.md +++ b/portal/docs/FLEET-DESIGN.md @@ -316,7 +316,7 @@ Database__ConnectionString=Host=timescaledb;... # central fleet DB (separ | Seam | Where v2 picks it up | |---|---| -| ~~Tariff sync~~ + cross-customer cost | **Tariff sync shipped.** `fleet.Municipalities` + `fleet.Tariffs` + `fleet.TariffPeriods` mirror the customer-side rate hierarchy scoped by `CustomerId`. New push batch types: `municipalities`, `tariffs` (with periods nested). Per-municipality rates preserved. **Cost compute on Admin side is the natural next step** — a `FleetCostService` joining `fleet.hourly_per_device` × tariffs → per-customer kWh × rate. | +| ~~Tariff sync + cross-customer cost~~ | **Both shipped.** `fleet.Municipalities` + `fleet.Tariffs` + `fleet.TariffPeriods` mirror the customer-side rate hierarchy scoped by `CustomerId` (per-customer per-municipality rates preserved). `FleetCostService` joins `fleet.hourly_per_device` × Devices × Sites × Municipalities × active Tariff × period (in municipality local time) → per-bucket cost. Endpoint: `GET /api/fleet/customers/{id}/cost?from&to`. Per-day rollup + per-device breakdown. VAT applied per bucket from the active tariff. Fixed monthly charges intentionally excluded (whole-month billing concept). 366-day range cap. UI: **Cost** tab on customer detail with RangePicker, summary cards, daily + per-device tables. | | Per-customer Postgres RLS for multi-Admin-user setups | Add `current_setting('app.customer_filter')`-based RLS policies on `fleet.*` tables; Admin role to customer-scope mapping in `IdentityRole.CustomerId`. | | Bidirectional Admin → Customer commands | New WebSocket or long-poll channel on customer side; gated by mutual cert or a second token. | | Branding sync (for the "Admin sees customer's brand when drilling in" niceness) | Push branding row from customer; Admin renders the customer's brand on Customer-detail pages. | diff --git a/portal/frontend/src/api/fleet.ts b/portal/frontend/src/api/fleet.ts index b0e22fa..2e28ca7 100644 --- a/portal/frontend/src/api/fleet.ts +++ b/portal/frontend/src/api/fleet.ts @@ -112,3 +112,46 @@ export async function fetchFleetCustomerDetail(id: string): Promise(`/fleet/customers/${id}/detail`); return data; } + +export interface FleetCostDay { + date: string; + kwh: number; + baseCost: number; + vatAmount: number; + totalCost: number; +} + +export interface FleetCostDeviceRow { + deviceId: string; + deviceName: string; + municipalityName: string | null; + kwh: number; + baseCost: number; + totalCost: number; +} + +export interface FleetCost { + fromUtc: string; + toUtc: string; + totalKwh: number; + totalBaseCost: number; + totalVatAmount: number; + totalCost: number; + buckets: number; + bucketsWithoutTariff: number; + daily: FleetCostDay[]; + perDevice: FleetCostDeviceRow[]; + fixedMonthlyChargesIncluded: boolean; +} + +export async function fetchFleetCustomerCost( + id: string, + fromUtc: string, + toUtc: string, +): Promise { + const { data } = await api.get( + `/fleet/customers/${id}/cost`, + { params: { from: fromUtc, to: toUtc } }, + ); + return data; +} diff --git a/portal/frontend/src/pages/AdminCustomerDetailPage.tsx b/portal/frontend/src/pages/AdminCustomerDetailPage.tsx index 49799fc..d8f2aaa 100644 --- a/portal/frontend/src/pages/AdminCustomerDetailPage.tsx +++ b/portal/frontend/src/pages/AdminCustomerDetailPage.tsx @@ -1,16 +1,24 @@ -import { Card, Descriptions, Tabs, Table, Tag, Typography, Button, Space, Spin, Result, Tooltip } from 'antd'; +import { useState } from 'react'; +import { + Card, Descriptions, Tabs, Table, Tag, Typography, Button, Space, Spin, Result, Tooltip, + DatePicker, Statistic, Row, Col, Alert, +} from 'antd'; import type { ColumnsType } from 'antd/es/table'; -import { ArrowLeftOutlined, LineChartOutlined } from '@ant-design/icons'; +import { ArrowLeftOutlined, LineChartOutlined, ThunderboltOutlined, DollarOutlined } from '@ant-design/icons'; import { useQuery } from '@tanstack/react-query'; import { useNavigate, useParams } from 'react-router-dom'; +import dayjs, { type Dayjs } from 'dayjs'; import { - fetchFleetCustomerDetail, type FleetSite, type FleetDevice, + fetchFleetCustomerDetail, fetchFleetCustomerCost, + type FleetSite, type FleetDevice, type FleetRecentMeasurement, type FleetIngestEvent, type FleetTariffView, type FleetTariffPeriodView, + type FleetCostDay, type FleetCostDeviceRow, } from '../api/fleet'; import { fetchGrafanaConfig } from '../api/grafana'; const { Text } = Typography; +const { RangePicker } = DatePicker; // UID of the customer-drilldown dashboard provisioned in grafana/dashboards-admin/. // Coordinated change: rename here and in the JSON together. @@ -135,6 +143,11 @@ export function AdminCustomerDetailPage() { label: `Tariffs (${data.tariffs.length})`, children: , }, + { + key: 'cost', + label: 'Cost', + children: , + }, ]} /> @@ -210,3 +223,102 @@ const DAY_LABELS = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; function formatDays(mask: number): string { return DAY_LABELS.filter((_, i) => (mask & (1 << i)) !== 0).join('') || '—'; } + +// ── Cost tab: per-customer cost over a chosen UTC range ──────────────────── +function CostTab({ customerId }: { customerId: string }) { + const [range, setRange] = useState<[Dayjs, Dayjs]>(() => [ + dayjs().subtract(7, 'day').startOf('day'), + dayjs().add(1, 'day').startOf('day'), + ]); + + const fromIso = range[0].toISOString(); + const toIso = range[1].toISOString(); + + const { data, isLoading, error } = useQuery({ + queryKey: ['fleet-customer-cost', customerId, fromIso, toIso], + queryFn: () => fetchFleetCustomerCost(customerId, fromIso, toIso), + }); + + const dayCols: ColumnsType = [ + { title: 'Date (UTC)', dataIndex: 'date', key: 'date' }, + { title: 'kWh', dataIndex: 'kwh', key: 'kwh', render: (n: number) => n.toFixed(3) }, + { title: 'Base cost', dataIndex: 'baseCost', key: 'b', render: (n: number) => n.toFixed(2) }, + { title: 'VAT', dataIndex: 'vatAmount', key: 'v', render: (n: number) => n.toFixed(2) }, + { title: 'Total', dataIndex: 'totalCost', key: 't', render: (n: number) => {n.toFixed(2)} }, + ]; + + const deviceCols: ColumnsType = [ + { title: 'Device', dataIndex: 'deviceName', key: 'd', render: (v: string) => {v} }, + { + title: 'Municipality', dataIndex: 'municipalityName', key: 'm', + render: (v: string | null) => v ?? + }, + { title: 'kWh', dataIndex: 'kwh', key: 'kwh', render: (n: number) => n.toFixed(3) }, + { title: 'Base cost', dataIndex: 'baseCost', key: 'b', render: (n: number) => n.toFixed(2) }, + { title: 'Total', dataIndex: 'totalCost', key: 't', render: (n: number) => {n.toFixed(2)} }, + ]; + + return ( + + + Range (UTC): + v && v[0] && v[1] && setRange([v[0].startOf('day'), v[1].endOf('day')])} + ranges={{ + 'Today': [dayjs().startOf('day'), dayjs().add(1, 'day').startOf('day')], + 'Last 7d': [dayjs().subtract(7, 'day').startOf('day'), dayjs().add(1, 'day').startOf('day')], + 'Last 30d': [dayjs().subtract(30, 'day').startOf('day'), dayjs().add(1, 'day').startOf('day')], + 'This month': [dayjs().startOf('month'), dayjs().add(1, 'day').startOf('day')], + }} + /> + + + {isLoading && } + {error && } + + {data && ( + <> + + } /> + } /> + } /> + } /> + + + {data.bucketsWithoutTariff > 0 && ( + + )} + + {!data.fixedMonthlyChargesIncluded && ( + + )} + + + + rowKey="date" size="small" pagination={false} + columns={dayCols} dataSource={data.daily} + /> + + + + + rowKey="deviceId" size="small" pagination={false} + columns={deviceCols} dataSource={data.perDevice} + /> + + + )} + + ); +} diff --git a/portal/src/Tau.Acuvim.Portal/DTOs/FleetCostDtos.cs b/portal/src/Tau.Acuvim.Portal/DTOs/FleetCostDtos.cs new file mode 100644 index 0000000..c62b619 --- /dev/null +++ b/portal/src/Tau.Acuvim.Portal/DTOs/FleetCostDtos.cs @@ -0,0 +1,29 @@ +namespace Tau.Acuvim.Portal.DTOs; + +public sealed record FleetCostDayDto( + DateOnly Date, + double Kwh, + decimal BaseCost, + decimal VatAmount, + decimal TotalCost); + +public sealed record FleetCostDeviceDto( + Guid DeviceId, + string DeviceName, + string? MunicipalityName, + double Kwh, + decimal BaseCost, + decimal TotalCost); + +public sealed record FleetCostDto( + DateTime FromUtc, + DateTime ToUtc, + double TotalKwh, + decimal TotalBaseCost, + decimal TotalVatAmount, + decimal TotalCost, + int Buckets, + int BucketsWithoutTariff, + IReadOnlyList Daily, + IReadOnlyList PerDevice, + bool FixedMonthlyChargesIncluded); diff --git a/portal/src/Tau.Acuvim.Portal/Endpoints/FleetDashboardEndpoints.cs b/portal/src/Tau.Acuvim.Portal/Endpoints/FleetDashboardEndpoints.cs index d53b941..04298e3 100644 --- a/portal/src/Tau.Acuvim.Portal/Endpoints/FleetDashboardEndpoints.cs +++ b/portal/src/Tau.Acuvim.Portal/Endpoints/FleetDashboardEndpoints.cs @@ -20,6 +20,20 @@ public static class FleetDashboardEndpoints return detail is null ? Results.NotFound() : Results.Ok(detail); }); + group.MapGet("/customers/{id:guid}/cost", + async (Guid id, DateTime from, DateTime to, FleetCostService svc, CancellationToken ct) => + { + try + { + var result = await svc.ComputeAsync(id, from, to, ct); + return Results.Ok(result); + } + catch (ArgumentException ex) + { + return Results.BadRequest(new { error = ex.Message }); + } + }); + return app; } } diff --git a/portal/src/Tau.Acuvim.Portal/Program.cs b/portal/src/Tau.Acuvim.Portal/Program.cs index 59d6fb7..40cbbe3 100644 --- a/portal/src/Tau.Acuvim.Portal/Program.cs +++ b/portal/src/Tau.Acuvim.Portal/Program.cs @@ -149,6 +149,7 @@ try builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); } builder.Services.AddHealthChecks() diff --git a/portal/src/Tau.Acuvim.Portal/Services/FleetCostService.cs b/portal/src/Tau.Acuvim.Portal/Services/FleetCostService.cs new file mode 100644 index 0000000..5354f7a --- /dev/null +++ b/portal/src/Tau.Acuvim.Portal/Services/FleetCostService.cs @@ -0,0 +1,189 @@ +using Microsoft.EntityFrameworkCore; +using Npgsql; +using Tau.Acuvim.Portal.Data; +using Tau.Acuvim.Portal.Domain.Fleet; +using Tau.Acuvim.Portal.Domain.Rates; +using Tau.Acuvim.Portal.DTOs; + +namespace Tau.Acuvim.Portal.Services; + +public sealed class FleetCostService(AdminDbContext db, ILogger log) +{ + public static readonly TimeSpan MaxRange = TimeSpan.FromDays(366); + + // Computes per-customer cost over [fromUtc, toUtc) by joining the hourly continuous + // aggregate against the device → site → municipality → active tariff → period chain. + // + // Per-bucket: rate is selected by converting the bucket start to the municipality's + // local time and matching against the tariff's TOU periods. Falls back to the tariff's + // DefaultRatePerKwh when no period matches. Buckets whose device's municipality has no + // active tariff are counted in BucketsWithoutTariff and excluded from cost. + // + // VAT is applied per bucket so cost stays correct across mid-window tariff changes. + // Fixed monthly charge is NOT applied (whole-month billing concept; out of scope here). + public async Task ComputeAsync( + Guid customerId, DateTime fromUtc, DateTime toUtc, CancellationToken ct = default) + { + if (toUtc <= fromUtc) throw new ArgumentException("'to' must be after 'from'."); + if (toUtc - fromUtc > MaxRange) throw new ArgumentException($"Range must be <= {MaxRange.TotalDays:F0} days."); + + // 1. Device → site → muni mapping (one query per customer, small data). + var deviceJoins = await ( + from d in db.FleetDevices.AsNoTracking() + join s in db.FleetSites.AsNoTracking() + on new { d.CustomerId, SiteRef = d.SiteId } equals new { s.CustomerId, SiteRef = s.Id } + where d.CustomerId == customerId + select new { d.Id, d.Name, s.LocalMunicipalityId } + ).ToListAsync(ct); + var deviceMap = deviceJoins.ToDictionary(x => x.Id, x => (x.Name, x.LocalMunicipalityId)); + + // 2. Municipalities (TimeZoneId for period selection). + var munis = await db.FleetMunicipalities.AsNoTracking() + .Where(m => m.CustomerId == customerId) + .ToListAsync(ct); + var muniMap = munis.ToDictionary(m => m.Id); + + // 3. Tariffs (with periods) per municipality, ordered by EffectiveFrom desc for + // active-tariff lookup. + var tariffs = await db.FleetTariffs.AsNoTracking() + .Include(t => t.Periods) + .Where(t => t.CustomerId == customerId && t.IsActive) + .ToListAsync(ct); + var tariffsByMuni = tariffs.GroupBy(t => t.MunicipalityId) + .ToDictionary(g => g.Key, g => g.OrderByDescending(t => t.EffectiveFrom).ToList()); + + // 4. Hourly buckets in range from the realtime CA. + var buckets = await ReadBucketsAsync(customerId, fromUtc, toUtc, ct); + + // 5. Period-by-period accumulation. + var byDay = new Dictionary(); + var byDevice = new Dictionary(); + int withoutTariff = 0; + + foreach (var b in buckets) + { + if (!deviceMap.TryGetValue(b.DeviceId, out var deviceInfo)) + { + withoutTariff++; + continue; + } + if (deviceInfo.LocalMunicipalityId is not int muniId + || !tariffsByMuni.TryGetValue(muniId, out var tariffsForMuni)) + { + withoutTariff++; + continue; + } + + var bucketDate = DateOnly.FromDateTime(b.Bucket); + var tariff = tariffsForMuni.FirstOrDefault(t => + t.EffectiveFrom <= bucketDate && + (t.EffectiveTo == null || t.EffectiveTo >= bucketDate)); + if (tariff is null) { withoutTariff++; continue; } + + // Local time in the municipality's timezone for period matching. + DateTime localBucket; + try + { + var tz = muniMap.TryGetValue(muniId, out var m) && !string.IsNullOrWhiteSpace(m.TimeZoneId) + ? TimeZoneInfo.FindSystemTimeZoneById(m.TimeZoneId) + : TimeZoneInfo.Utc; + localBucket = TimeZoneInfo.ConvertTimeFromUtc( + DateTime.SpecifyKind(b.Bucket, DateTimeKind.Utc), tz); + } + catch (TimeZoneNotFoundException) + { + log.LogWarning("Unknown TimeZoneId for muni {Muni}; falling back to UTC", muniId); + localBucket = b.Bucket; + } + + var rate = SelectRate(tariff.Periods, localBucket, tariff.DefaultRatePerKwh); + var baseCost = (decimal)b.Kwh * rate; + var vatAmount = Round(baseCost * tariff.VatPercentage / 100m); + var total = baseCost + vatAmount; + + var dayKey = DateOnly.FromDateTime(b.Bucket); + byDay.TryGetValue(dayKey, out var dayAcc); + byDay[dayKey] = (dayAcc.Kwh + b.Kwh, dayAcc.Base + baseCost, + dayAcc.Vat + vatAmount, dayAcc.Total + total); + + byDevice.TryGetValue(b.DeviceId, out var devAcc); + byDevice[b.DeviceId] = (devAcc.Kwh + b.Kwh, devAcc.Base + baseCost, devAcc.Total + total); + } + + // 6. Build response. + var daily = byDay.OrderBy(kv => kv.Key) + .Select(kv => new FleetCostDayDto( + kv.Key, kv.Value.Kwh, Round(kv.Value.Base), Round(kv.Value.Vat), Round(kv.Value.Total))) + .ToList(); + + var perDevice = byDevice.OrderByDescending(kv => kv.Value.Total) + .Select(kv => + { + var info = deviceMap[kv.Key]; + string? muniName = info.LocalMunicipalityId is int mid && muniMap.TryGetValue(mid, out var mm) ? mm.Name : null; + return new FleetCostDeviceDto(kv.Key, info.Name, muniName, + kv.Value.Kwh, Round(kv.Value.Base), Round(kv.Value.Total)); + }) + .ToList(); + + return new FleetCostDto( + FromUtc: fromUtc, + ToUtc: toUtc, + TotalKwh: daily.Sum(d => d.Kwh), + TotalBaseCost: Round(daily.Sum(d => d.BaseCost)), + TotalVatAmount: Round(daily.Sum(d => d.VatAmount)), + TotalCost: Round(daily.Sum(d => d.TotalCost)), + Buckets: buckets.Count, + BucketsWithoutTariff: withoutTariff, + Daily: daily, + PerDevice: perDevice, + FixedMonthlyChargesIncluded: false); + } + + // ── Period selection: matches CostCalculator.SelectRate behaviour but operates on + // FleetTariffPeriod (different type, same shape). Intentionally duplicated; the + // method is 5 lines and a generic abstraction across the two types is more code + // than the duplication. + private static decimal SelectRate( + IEnumerable periods, DateTime localTime, decimal defaultRate) + { + var flag = localTime.DayOfWeek.ToFlag(); + var t = TimeOnly.FromDateTime(localTime); + foreach (var p in periods.OrderBy(p => p.StartTime)) + { + if ((p.DaysOfWeek & flag) == 0) continue; + if (t >= p.StartTime && t < p.EndTime) return p.RatePerKwh; + } + return defaultRate; + } + + private async Task> ReadBucketsAsync( + Guid customerId, DateTime fromUtc, DateTime toUtc, 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 "DeviceId", bucket, COALESCE(kwh_imported_delta, 0) AS kwh + FROM fleet.hourly_per_device + WHERE "CustomerId" = @cust + AND bucket >= @from + AND bucket < @to + ORDER BY bucket; + """; + cmd.Parameters.Add(new NpgsqlParameter("@cust", customerId)); + cmd.Parameters.Add(new NpgsqlParameter("@from", DateTime.SpecifyKind(fromUtc, DateTimeKind.Utc))); + cmd.Parameters.Add(new NpgsqlParameter("@to", DateTime.SpecifyKind(toUtc, DateTimeKind.Utc))); + + var rows = new List<(Guid, DateTime, double)>(); + await using var reader = await cmd.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + { + rows.Add((reader.GetGuid(0), reader.GetDateTime(1), reader.GetDouble(2))); + } + return rows; + } + + private static decimal Round(decimal v) => Math.Round(v, 4, MidpointRounding.AwayFromZero); +}