Split cost/estimate across mid-period tariff changes
Rate changes are often scheduled ahead of time or confirmed by finance after the fact and backdated — assign-tariff already supported any effective_from date, but cost-calc previously priced a whole period at a single tariff (whichever was in force on the period-end date), silently mispricing the days on the other side of a change. getTariffSegments() finds every tariff assignment overlapping a date range; getMeterCostForPeriod/getMeterEstimateForPeriod now split consumption pro-rata by day count across segments and price each against its own tariff, summing to the period total. Estimates project the remaining days across a future-scheduled change the same way, rather than assuming today's tariff holds for the rest of the period. Also consolidates internal.js's cost/estimate routes (previously their own duplicate calc) onto the same shared functions reports.js and estimates.js use, so the Directors' report can't drift from the in-app numbers. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
4fc5230d79
commit
7ab048931f
5 changed files with 151 additions and 63 deletions
|
|
@ -108,6 +108,37 @@ export async function getTariffForMeter(meterId, date) {
|
||||||
return open[0] || null
|
return open[0] || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toISODate(d) {
|
||||||
|
return new Date(d).toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every tariff assignment whose range overlaps [periodStart, periodEnd], clipped
|
||||||
|
// to the period and ordered chronologically. A rate change — scheduled ahead of
|
||||||
|
// time or entered late/backdated once finance confirms it — shows up here as two
|
||||||
|
// (or more) segments instead of a single tariff covering the whole period, so
|
||||||
|
// cost calculations split correctly at the change boundary rather than pricing
|
||||||
|
// the whole period at whichever tariff happens to apply on one edge date.
|
||||||
|
export async function getTariffSegments(meterId, periodStart, periodEnd) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT mt.effective_from AS mt_from, mt.effective_to AS mt_to, t.* FROM meter_tariffs mt
|
||||||
|
JOIN tariffs t ON t.id = mt.tariff_id
|
||||||
|
WHERE mt.meter_id = $1
|
||||||
|
AND mt.effective_from <= $3
|
||||||
|
AND (mt.effective_to IS NULL OR mt.effective_to >= $2)
|
||||||
|
ORDER BY mt.effective_from ASC`,
|
||||||
|
[meterId, periodStart, periodEnd]
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
.map(r => {
|
||||||
|
const from = toISODate(r.mt_from)
|
||||||
|
const to = r.mt_to ? toISODate(r.mt_to) : null
|
||||||
|
const segStart = from > periodStart ? from : periodStart
|
||||||
|
const segEnd = to && to < periodEnd ? to : periodEnd
|
||||||
|
return { ...r, seg_start: segStart, seg_end: segEnd }
|
||||||
|
})
|
||||||
|
.filter(s => s.seg_start <= s.seg_end)
|
||||||
|
}
|
||||||
|
|
||||||
export async function getRateWindows(tariffId) {
|
export async function getRateWindows(tariffId) {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
'SELECT * FROM tariff_rate_windows WHERE tariff_id = $1 ORDER BY sort_order, id',
|
'SELECT * FROM tariff_rate_windows WHERE tariff_id = $1 ORDER BY sort_order, id',
|
||||||
|
|
@ -163,16 +194,92 @@ export function computeCost({ tariff, windows, consumption, daysInPeriod }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full cost breakdown for one meter over a period — fetches tariff + windows
|
const ZERO_TOTALS = { usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, subtotal_pence: 0, vat_pence: 0, total_pence: 0 }
|
||||||
// + consumption and runs computeCost. `asOfDate` picks which historical
|
|
||||||
// tariff applies (defaults to periodEnd).
|
// Cost a single amount of consumption over [rangeStart, rangeEnd], splitting it
|
||||||
|
// pro-rata (by day count) across every tariff segment active in that range. Used
|
||||||
|
// both for a closed historical period and for a projected future range — in both
|
||||||
|
// cases we only have a total consumption figure (from readings or a trailing
|
||||||
|
// average), never a day-by-day breakdown, so a rate change inside the range is
|
||||||
|
// apportioned by day count rather than known per-day usage.
|
||||||
|
async function splitCostAcrossSegments(meterId, rangeStart, rangeEnd, consumption, asOfDate) {
|
||||||
|
const rangeDays = daysInclusive(rangeStart, rangeEnd)
|
||||||
|
const segments = await getTariffSegments(meterId, rangeStart, rangeEnd)
|
||||||
|
|
||||||
|
if (!segments.length) {
|
||||||
|
const tariff = await getTariffForMeter(meterId, asOfDate || rangeEnd)
|
||||||
|
const windows = tariff ? await getRateWindows(tariff.id) : []
|
||||||
|
const cost = computeCost({ tariff, windows, consumption, daysInPeriod: rangeDays })
|
||||||
|
return { tariff, windows, segments: [], rate_changed_mid_period: false, ...cost }
|
||||||
|
}
|
||||||
|
|
||||||
|
const dailyAvg = consumption == null ? null : consumption / rangeDays
|
||||||
|
const segmentResults = []
|
||||||
|
const agg = { ...ZERO_TOTALS }
|
||||||
|
|
||||||
|
for (const seg of segments) {
|
||||||
|
const segDays = daysInclusive(seg.seg_start, seg.seg_end)
|
||||||
|
const segConsumption = dailyAvg == null ? null : dailyAvg * segDays
|
||||||
|
const windows = await getRateWindows(seg.id)
|
||||||
|
const cost = computeCost({ tariff: seg, windows, consumption: segConsumption, daysInPeriod: segDays })
|
||||||
|
for (const k of Object.keys(agg)) agg[k] += cost[k]
|
||||||
|
segmentResults.push({
|
||||||
|
tariff_id: seg.id, tariff_name: seg.name,
|
||||||
|
seg_start: seg.seg_start, seg_end: seg.seg_end, days: segDays,
|
||||||
|
consumption: segConsumption, ...cost,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSeg = segments[segments.length - 1]
|
||||||
|
const lastWindows = await getRateWindows(lastSeg.id)
|
||||||
|
const rateChanged = segments.length > 1
|
||||||
|
return {
|
||||||
|
tariff: lastSeg, windows: lastWindows, segments: segmentResults,
|
||||||
|
...agg, split: rateChanged ? null : segmentResults[0]?.split ?? null,
|
||||||
|
rate_changed_mid_period: rateChanged,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full cost breakdown for one meter over a period — fetches consumption from
|
||||||
|
// readings, then splits cost across any tariff change (scheduled ahead of time
|
||||||
|
// or backdated after the fact) that falls inside the period. `asOfDate` is only
|
||||||
|
// used as a fallback when the meter has no tariff-assignment history at all.
|
||||||
export async function getMeterCostForPeriod(meterId, periodStart, periodEnd, asOfDate) {
|
export async function getMeterCostForPeriod(meterId, periodStart, periodEnd, asOfDate) {
|
||||||
const { consumption, first, last, has_data } = await getPeriodConsumption(meterId, periodStart, periodEnd)
|
const { consumption, first, last, has_data } = await getPeriodConsumption(meterId, periodStart, periodEnd)
|
||||||
const tariff = await getTariffForMeter(meterId, asOfDate || periodEnd)
|
|
||||||
const windows = tariff ? await getRateWindows(tariff.id) : []
|
|
||||||
const daysInPeriod = daysInclusive(periodStart, periodEnd)
|
const daysInPeriod = daysInclusive(periodStart, periodEnd)
|
||||||
const cost = computeCost({ tariff, windows, consumption, daysInPeriod })
|
const costed = await splitCostAcrossSegments(meterId, periodStart, periodEnd, consumption, asOfDate || periodEnd)
|
||||||
return { consumption, has_data, first_reading: first, last_reading: last, tariff, windows, days_in_period: daysInPeriod, ...cost }
|
return { consumption, has_data, first_reading: first, last_reading: last, days_in_period: daysInPeriod, ...costed }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimate for the remainder of an open period: actual consumption to date +
|
||||||
|
// trailing-average projection for the remaining days, cost-split across any
|
||||||
|
// tariff segments covering the whole period — including a rate change already
|
||||||
|
// scheduled for later in the period, which a same-tariff-for-everything
|
||||||
|
// projection would otherwise miss.
|
||||||
|
export async function getMeterEstimateForPeriod(meterId, periodStart, periodEnd, windowDays) {
|
||||||
|
const today = toISODate(new Date())
|
||||||
|
const asOfDate = today < periodEnd ? today : periodEnd
|
||||||
|
const daysInPeriod = daysInclusive(periodStart, periodEnd)
|
||||||
|
const remainingDays = Math.max(daysBetween(asOfDate, periodEnd), 0)
|
||||||
|
|
||||||
|
const { daily_rate } = await getTrailingDailyRate(meterId, windowDays)
|
||||||
|
const { consumption: actualToDate, has_data } = await getPeriodConsumption(meterId, periodStart, asOfDate)
|
||||||
|
|
||||||
|
let projectedConsumption = null
|
||||||
|
if (daily_rate != null) {
|
||||||
|
projectedConsumption = (has_data ? actualToDate : 0) + daily_rate * remainingDays
|
||||||
|
} else if (has_data) {
|
||||||
|
projectedConsumption = actualToDate
|
||||||
|
}
|
||||||
|
|
||||||
|
const costed = await splitCostAcrossSegments(meterId, periodStart, periodEnd, projectedConsumption, asOfDate)
|
||||||
|
|
||||||
|
return {
|
||||||
|
trailing_window_days: windowDays, daily_rate,
|
||||||
|
actual_to_date: has_data ? actualToDate : null,
|
||||||
|
remaining_days: remainingDays, days_in_period: daysInPeriod,
|
||||||
|
projected_consumption: projectedConsumption, ...costed,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// [periodStart, periodEnd] for a YYYY-MM period string, or the current
|
// [periodStart, periodEnd] for a YYYY-MM period string, or the current
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,15 @@
|
||||||
import { requireAuth, requireCap } from '../auth.js'
|
import { requireAuth, requireCap } from '../auth.js'
|
||||||
import { pool, getConfig } from '../db.js'
|
import { pool, getConfig } from '../db.js'
|
||||||
import {
|
import { getMeterEstimateForPeriod, resolvePeriod } from '../lib/cost-calc.js'
|
||||||
getPeriodConsumption, getTrailingDailyRate, getTariffForMeter, getRateWindows,
|
|
||||||
computeCost, resolvePeriod, daysInclusive, daysBetween,
|
|
||||||
} from '../lib/cost-calc.js'
|
|
||||||
|
|
||||||
const VALID_WINDOWS = [7, 14, 30]
|
const VALID_WINDOWS = [7, 14, 30]
|
||||||
|
|
||||||
async function estimateForMeter(meter, periodStart, periodEnd, windowDays) {
|
async function estimateForMeter(meter, periodStart, periodEnd, windowDays) {
|
||||||
const today = new Date().toISOString().slice(0, 10)
|
const estimate = await getMeterEstimateForPeriod(meter.id, periodStart, periodEnd, windowDays)
|
||||||
const asOfDate = today < periodEnd ? today : periodEnd
|
|
||||||
|
|
||||||
const { daily_rate } = await getTrailingDailyRate(meter.id, windowDays)
|
|
||||||
const { consumption: actualToDate, has_data } = await getPeriodConsumption(meter.id, periodStart, asOfDate)
|
|
||||||
|
|
||||||
const daysInPeriod = daysInclusive(periodStart, periodEnd)
|
|
||||||
const remainingDays = Math.max(daysBetween(asOfDate, periodEnd), 0)
|
|
||||||
|
|
||||||
let projectedConsumption = null
|
|
||||||
if (daily_rate != null) {
|
|
||||||
const base = has_data ? actualToDate : 0
|
|
||||||
projectedConsumption = base + daily_rate * remainingDays
|
|
||||||
} else if (has_data) {
|
|
||||||
// No trailing rate available (too few readings) — fall back to actuals only
|
|
||||||
projectedConsumption = actualToDate
|
|
||||||
}
|
|
||||||
|
|
||||||
const tariff = await getTariffForMeter(meter.id, asOfDate)
|
|
||||||
const windows = tariff ? await getRateWindows(tariff.id) : []
|
|
||||||
const cost = computeCost({ tariff, windows, consumption: projectedConsumption, daysInPeriod })
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
meter_id: meter.id, meter_name: meter.name,
|
meter_id: meter.id, meter_name: meter.name,
|
||||||
category_id: meter.category_id, category_name: meter.category_name, unit_label: meter.unit_label,
|
category_id: meter.category_id, category_name: meter.category_name, unit_label: meter.unit_label,
|
||||||
trailing_window_days: windowDays,
|
...estimate,
|
||||||
daily_rate,
|
|
||||||
actual_to_date: has_data ? actualToDate : null,
|
|
||||||
remaining_days: remainingDays,
|
|
||||||
projected_consumption: projectedConsumption,
|
|
||||||
...cost,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
// internal API pattern (X-API-Key header, static key from env).
|
// internal API pattern (X-API-Key header, static key from env).
|
||||||
import { pool } from '../db.js'
|
import { pool } from '../db.js'
|
||||||
import {
|
import {
|
||||||
getPeriodConsumption, getTariffForMeter, getRateWindows, computeCost,
|
getPeriodConsumption, getMeterCostForPeriod, getMeterEstimateForPeriod,
|
||||||
getTrailingDailyRate, resolvePeriod, daysInclusive, daysBetween,
|
resolvePeriod, daysInclusive,
|
||||||
} from '../lib/cost-calc.js'
|
} from '../lib/cost-calc.js'
|
||||||
|
|
||||||
async function requireApiKey(req, reply) {
|
async function requireApiKey(req, reply) {
|
||||||
|
|
@ -70,11 +70,8 @@ export async function internalRoutes(app) {
|
||||||
const catTotals = { usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, vat_pence: 0, total_pence: 0, consumption: 0 }
|
const catTotals = { usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, vat_pence: 0, total_pence: 0, consumption: 0 }
|
||||||
|
|
||||||
for (const m of meters) {
|
for (const m of meters) {
|
||||||
const { consumption, has_data } = await getPeriodConsumption(m.id, start, end)
|
const cost = await getMeterCostForPeriod(m.id, start, end)
|
||||||
const tariff = await getTariffForMeter(m.id, end)
|
if (cost.has_data) catTotals.consumption += cost.consumption
|
||||||
const windows = tariff ? await getRateWindows(tariff.id) : []
|
|
||||||
const cost = computeCost({ tariff, windows, consumption, daysInPeriod })
|
|
||||||
if (has_data) catTotals.consumption += consumption
|
|
||||||
catTotals.usage_cost_pence += cost.usage_cost_pence
|
catTotals.usage_cost_pence += cost.usage_cost_pence
|
||||||
catTotals.standing_cost_pence += cost.standing_cost_pence
|
catTotals.standing_cost_pence += cost.standing_cost_pence
|
||||||
catTotals.ccl_cost_pence += cost.ccl_cost_pence
|
catTotals.ccl_cost_pence += cost.ccl_cost_pence
|
||||||
|
|
@ -92,10 +89,6 @@ export async function internalRoutes(app) {
|
||||||
// GET /api/internal/estimate?period=current — projected cost for the open period
|
// GET /api/internal/estimate?period=current — projected cost for the open period
|
||||||
app.get('/api/internal/estimate', async (req) => {
|
app.get('/api/internal/estimate', async (req) => {
|
||||||
const { start, end, year, month } = resolvePeriod(req.query.period)
|
const { start, end, year, month } = resolvePeriod(req.query.period)
|
||||||
const today = new Date().toISOString().slice(0, 10)
|
|
||||||
const asOfDate = today < end ? today : end
|
|
||||||
const daysInPeriod = daysInclusive(start, end)
|
|
||||||
const remainingDays = Math.max(daysBetween(asOfDate, end), 0)
|
|
||||||
|
|
||||||
const { rows: config } = await pool.query("SELECT value FROM config WHERE key = 'estimate_trailing_days'")
|
const { rows: config } = await pool.query("SELECT value FROM config WHERE key = 'estimate_trailing_days'")
|
||||||
const globalDefault = config[0]?.value ?? 30
|
const globalDefault = config[0]?.value ?? 30
|
||||||
|
|
@ -103,6 +96,7 @@ export async function internalRoutes(app) {
|
||||||
const { rows: categories } = await pool.query('SELECT * FROM meter_categories WHERE active = TRUE ORDER BY sort_order')
|
const { rows: categories } = await pool.query('SELECT * FROM meter_categories WHERE active = TRUE ORDER BY sort_order')
|
||||||
const breakdown = []
|
const breakdown = []
|
||||||
const totals = { total_pence: 0, projected_consumption: 0 }
|
const totals = { total_pence: 0, projected_consumption: 0 }
|
||||||
|
let remainingDays = 0
|
||||||
|
|
||||||
for (const cat of categories) {
|
for (const cat of categories) {
|
||||||
const windowDays = cat.estimate_trailing_days || globalDefault
|
const windowDays = cat.estimate_trailing_days || globalDefault
|
||||||
|
|
@ -111,18 +105,10 @@ export async function internalRoutes(app) {
|
||||||
let catTotalPence = 0
|
let catTotalPence = 0
|
||||||
|
|
||||||
for (const m of meters) {
|
for (const m of meters) {
|
||||||
const { daily_rate } = await getTrailingDailyRate(m.id, windowDays)
|
const estimate = await getMeterEstimateForPeriod(m.id, start, end, windowDays)
|
||||||
const { consumption: actualToDate, has_data } = await getPeriodConsumption(m.id, start, asOfDate)
|
remainingDays = estimate.remaining_days
|
||||||
let projected = null
|
if (estimate.projected_consumption != null) catConsumption += estimate.projected_consumption
|
||||||
if (daily_rate != null) projected = (has_data ? actualToDate : 0) + daily_rate * remainingDays
|
catTotalPence += estimate.total_pence
|
||||||
else if (has_data) projected = actualToDate
|
|
||||||
|
|
||||||
const tariff = await getTariffForMeter(m.id, asOfDate)
|
|
||||||
const windows = tariff ? await getRateWindows(tariff.id) : []
|
|
||||||
const cost = computeCost({ tariff, windows, consumption: projected, daysInPeriod })
|
|
||||||
|
|
||||||
if (projected != null) catConsumption += projected
|
|
||||||
catTotalPence += cost.total_pence
|
|
||||||
}
|
}
|
||||||
|
|
||||||
breakdown.push({ category: cat.key, category_name: cat.name, unit_label: cat.unit_label, projected_consumption: catConsumption, total_pence: catTotalPence })
|
breakdown.push({ category: cat.key, category_name: cat.name, unit_label: cat.unit_label, projected_consumption: catConsumption, total_pence: catTotalPence })
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,15 @@ export default function Reports() {
|
||||||
<tbody>
|
<tbody>
|
||||||
{report.meters.map(m => (
|
{report.meters.map(m => (
|
||||||
<tr key={m.meter_id}>
|
<tr key={m.meter_id}>
|
||||||
<td>{m.meter_name}{!m.has_data && <span className="badge badge-outline" style={{ marginLeft: 6 }}>no data</span>}</td>
|
<td>
|
||||||
|
{m.meter_name}
|
||||||
|
{!m.has_data && <span className="badge badge-outline" style={{ marginLeft: 6 }}>no data</span>}
|
||||||
|
{m.rate_changed_mid_period && (
|
||||||
|
<span className="badge badge-tou" style={{ marginLeft: 6 }} title={m.segments.map(s => `${s.tariff_name}: ${s.seg_start} – ${s.seg_end}`).join(', ')}>
|
||||||
|
rate changed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td>{m.category_name}</td>
|
<td>{m.category_name}</td>
|
||||||
<td className="num">{formatUnits(m.consumption, m.unit_label)}</td>
|
<td className="num">{formatUnits(m.consumption, m.unit_label)}</td>
|
||||||
<td className="num">{formatMoney(m.usage_cost_pence)}</td>
|
<td className="num">{formatMoney(m.usage_cost_pence)}</td>
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,20 @@ export interface Reading {
|
||||||
warning?: string | null
|
warning?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CostSegment {
|
||||||
|
tariff_id: number
|
||||||
|
tariff_name: string
|
||||||
|
seg_start: string
|
||||||
|
seg_end: string
|
||||||
|
days: number
|
||||||
|
consumption: number | null
|
||||||
|
usage_cost_pence: number
|
||||||
|
standing_cost_pence: number
|
||||||
|
ccl_cost_pence: number
|
||||||
|
vat_pence: number
|
||||||
|
total_pence: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface CostBreakdown {
|
export interface CostBreakdown {
|
||||||
usage_cost_pence: number
|
usage_cost_pence: number
|
||||||
standing_cost_pence: number
|
standing_cost_pence: number
|
||||||
|
|
@ -126,6 +140,8 @@ export interface CostBreakdown {
|
||||||
vat_pence: number
|
vat_pence: number
|
||||||
total_pence: number
|
total_pence: number
|
||||||
split: Record<string, { share: number; rate: number; cost_pence: number }> | null
|
split: Record<string, { share: number; rate: number; cost_pence: number }> | null
|
||||||
|
segments: CostSegment[]
|
||||||
|
rate_changed_mid_period: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MeterCostRow extends CostBreakdown {
|
export interface MeterCostRow extends CostBreakdown {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue