Cross-customer cost compute on the Admin side
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) <noreply@anthropic.com>
This commit is contained in:
parent
b654997fc9
commit
7627306800
@ -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. |
|
||||
|
||||
@ -112,3 +112,46 @@ export async function fetchFleetCustomerDetail(id: string): Promise<FleetCustome
|
||||
const { data } = await api.get<FleetCustomerDetail>(`/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<FleetCost> {
|
||||
const { data } = await api.get<FleetCost>(
|
||||
`/fleet/customers/${id}/cost`,
|
||||
{ params: { from: fromUtc, to: toUtc } },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
@ -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: <TariffsTab tariffs={data.tariffs} municipalitiesCount={data.municipalities.length} />,
|
||||
},
|
||||
{
|
||||
key: 'cost',
|
||||
label: 'Cost',
|
||||
children: <CostTab customerId={data.id} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
@ -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<FleetCostDay> = [
|
||||
{ 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) => <Text strong>{n.toFixed(2)}</Text> },
|
||||
];
|
||||
|
||||
const deviceCols: ColumnsType<FleetCostDeviceRow> = [
|
||||
{ title: 'Device', dataIndex: 'deviceName', key: 'd', render: (v: string) => <Text strong>{v}</Text> },
|
||||
{
|
||||
title: 'Municipality', dataIndex: 'municipalityName', key: 'm',
|
||||
render: (v: string | null) => v ?? <Text type="secondary">—</Text>
|
||||
},
|
||||
{ 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) => <Text strong>{n.toFixed(2)}</Text> },
|
||||
];
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Space>
|
||||
<Text>Range (UTC):</Text>
|
||||
<RangePicker
|
||||
allowClear={false}
|
||||
value={range}
|
||||
showTime={false}
|
||||
onChange={(v) => 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')],
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
{isLoading && <Spin />}
|
||||
{error && <Alert type="error" message="Failed to compute cost" description={(error as Error).message} />}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<Row gutter={16}>
|
||||
<Col span={6}><Card><Statistic title="Total kWh" value={data.totalKwh} precision={3} prefix={<ThunderboltOutlined />} /></Card></Col>
|
||||
<Col span={6}><Card><Statistic title="Base cost" value={data.totalBaseCost} precision={2} prefix={<DollarOutlined />} /></Card></Col>
|
||||
<Col span={6}><Card><Statistic title="VAT" value={data.totalVatAmount} precision={2} prefix={<DollarOutlined />} /></Card></Col>
|
||||
<Col span={6}><Card><Statistic title="Total" value={data.totalCost} precision={2} valueStyle={{ color: '#3f8600' }} prefix={<DollarOutlined />} /></Card></Col>
|
||||
</Row>
|
||||
|
||||
{data.bucketsWithoutTariff > 0 && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={`${data.bucketsWithoutTariff} of ${data.buckets} hourly buckets had no active tariff (no municipality FK on the site, or no effective tariff for that municipality on the bucket's date). Those buckets are excluded from cost.`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!data.fixedMonthlyChargesIncluded && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="Fixed monthly charges are not included in this view — they're a whole-month billing concept. Add them separately at invoice time."
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card title="Per day" size="small">
|
||||
<Table<FleetCostDay>
|
||||
rowKey="date" size="small" pagination={false}
|
||||
columns={dayCols} dataSource={data.daily}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="Per device" size="small">
|
||||
<Table<FleetCostDeviceRow>
|
||||
rowKey="deviceId" size="small" pagination={false}
|
||||
columns={deviceCols} dataSource={data.perDevice}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
29
portal/src/Tau.Acuvim.Portal/DTOs/FleetCostDtos.cs
Normal file
29
portal/src/Tau.Acuvim.Portal/DTOs/FleetCostDtos.cs
Normal file
@ -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<FleetCostDayDto> Daily,
|
||||
IReadOnlyList<FleetCostDeviceDto> PerDevice,
|
||||
bool FixedMonthlyChargesIncluded);
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -149,6 +149,7 @@ try
|
||||
builder.Services.AddScoped<FleetIngestService>();
|
||||
builder.Services.AddScoped<FleetTimescaleBootstrapper>();
|
||||
builder.Services.AddScoped<FleetQueryService>();
|
||||
builder.Services.AddScoped<FleetCostService>();
|
||||
}
|
||||
|
||||
builder.Services.AddHealthChecks()
|
||||
|
||||
189
portal/src/Tau.Acuvim.Portal/Services/FleetCostService.cs
Normal file
189
portal/src/Tau.Acuvim.Portal/Services/FleetCostService.cs
Normal file
@ -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<FleetCostService> 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<FleetCostDto> 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<DateOnly, (double Kwh, decimal Base, decimal Vat, decimal Total)>();
|
||||
var byDevice = new Dictionary<Guid, (double Kwh, decimal Base, decimal Total)>();
|
||||
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<FleetTariffPeriod> 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<List<(Guid DeviceId, DateTime Bucket, double Kwh)>> 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);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user