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:
Diseri Pearson 2026-05-18 11:45:44 +02:00
parent b654997fc9
commit 7627306800
7 changed files with 392 additions and 4 deletions

View File

@ -316,7 +316,7 @@ Database__ConnectionString=Host=timescaledb;... # central fleet DB (separ
| Seam | Where v2 picks it up | | 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`. | | 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. | | 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. | | 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. |

View File

@ -112,3 +112,46 @@ export async function fetchFleetCustomerDetail(id: string): Promise<FleetCustome
const { data } = await api.get<FleetCustomerDetail>(`/fleet/customers/${id}/detail`); const { data } = await api.get<FleetCustomerDetail>(`/fleet/customers/${id}/detail`);
return data; 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;
}

View File

@ -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 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 { useQuery } from '@tanstack/react-query';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import dayjs, { type Dayjs } from 'dayjs';
import { import {
fetchFleetCustomerDetail, type FleetSite, type FleetDevice, fetchFleetCustomerDetail, fetchFleetCustomerCost,
type FleetSite, type FleetDevice,
type FleetRecentMeasurement, type FleetIngestEvent, type FleetRecentMeasurement, type FleetIngestEvent,
type FleetTariffView, type FleetTariffPeriodView, type FleetTariffView, type FleetTariffPeriodView,
type FleetCostDay, type FleetCostDeviceRow,
} from '../api/fleet'; } from '../api/fleet';
import { fetchGrafanaConfig } from '../api/grafana'; import { fetchGrafanaConfig } from '../api/grafana';
const { Text } = Typography; const { Text } = Typography;
const { RangePicker } = DatePicker;
// UID of the customer-drilldown dashboard provisioned in grafana/dashboards-admin/. // UID of the customer-drilldown dashboard provisioned in grafana/dashboards-admin/.
// Coordinated change: rename here and in the JSON together. // Coordinated change: rename here and in the JSON together.
@ -135,6 +143,11 @@ export function AdminCustomerDetailPage() {
label: `Tariffs (${data.tariffs.length})`, label: `Tariffs (${data.tariffs.length})`,
children: <TariffsTab tariffs={data.tariffs} municipalitiesCount={data.municipalities.length} />, children: <TariffsTab tariffs={data.tariffs} municipalitiesCount={data.municipalities.length} />,
}, },
{
key: 'cost',
label: 'Cost',
children: <CostTab customerId={data.id} />,
},
]} ]}
/> />
</Card> </Card>
@ -210,3 +223,102 @@ const DAY_LABELS = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
function formatDays(mask: number): string { function formatDays(mask: number): string {
return DAY_LABELS.filter((_, i) => (mask & (1 << i)) !== 0).join('') || '—'; 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>
);
}

View 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);

View File

@ -20,6 +20,20 @@ public static class FleetDashboardEndpoints
return detail is null ? Results.NotFound() : Results.Ok(detail); 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; return app;
} }
} }

View File

@ -149,6 +149,7 @@ try
builder.Services.AddScoped<FleetIngestService>(); builder.Services.AddScoped<FleetIngestService>();
builder.Services.AddScoped<FleetTimescaleBootstrapper>(); builder.Services.AddScoped<FleetTimescaleBootstrapper>();
builder.Services.AddScoped<FleetQueryService>(); builder.Services.AddScoped<FleetQueryService>();
builder.Services.AddScoped<FleetCostService>();
} }
builder.Services.AddHealthChecks() builder.Services.AddHealthChecks()

View 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);
}