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
|
||||
}
|
||||
|
||||
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) {
|
||||
const { rows } = await pool.query(
|
||||
'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
|
||||
// + consumption and runs computeCost. `asOfDate` picks which historical
|
||||
// tariff applies (defaults to periodEnd).
|
||||
const ZERO_TOTALS = { usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, subtotal_pence: 0, vat_pence: 0, total_pence: 0 }
|
||||
|
||||
// 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) {
|
||||
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 cost = computeCost({ tariff, windows, consumption, daysInPeriod })
|
||||
return { consumption, has_data, first_reading: first, last_reading: last, tariff, windows, days_in_period: daysInPeriod, ...cost }
|
||||
const costed = await splitCostAcrossSegments(meterId, periodStart, periodEnd, consumption, asOfDate || periodEnd)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,44 +1,15 @@
|
|||
import { requireAuth, requireCap } from '../auth.js'
|
||||
import { pool, getConfig } from '../db.js'
|
||||
import {
|
||||
getPeriodConsumption, getTrailingDailyRate, getTariffForMeter, getRateWindows,
|
||||
computeCost, resolvePeriod, daysInclusive, daysBetween,
|
||||
} from '../lib/cost-calc.js'
|
||||
import { getMeterEstimateForPeriod, resolvePeriod } from '../lib/cost-calc.js'
|
||||
|
||||
const VALID_WINDOWS = [7, 14, 30]
|
||||
|
||||
async function estimateForMeter(meter, periodStart, periodEnd, windowDays) {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
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 })
|
||||
|
||||
const estimate = await getMeterEstimateForPeriod(meter.id, periodStart, periodEnd, windowDays)
|
||||
return {
|
||||
meter_id: meter.id, meter_name: meter.name,
|
||||
category_id: meter.category_id, category_name: meter.category_name, unit_label: meter.unit_label,
|
||||
trailing_window_days: windowDays,
|
||||
daily_rate,
|
||||
actual_to_date: has_data ? actualToDate : null,
|
||||
remaining_days: remainingDays,
|
||||
projected_consumption: projectedConsumption,
|
||||
...cost,
|
||||
...estimate,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
// internal API pattern (X-API-Key header, static key from env).
|
||||
import { pool } from '../db.js'
|
||||
import {
|
||||
getPeriodConsumption, getTariffForMeter, getRateWindows, computeCost,
|
||||
getTrailingDailyRate, resolvePeriod, daysInclusive, daysBetween,
|
||||
getPeriodConsumption, getMeterCostForPeriod, getMeterEstimateForPeriod,
|
||||
resolvePeriod, daysInclusive,
|
||||
} from '../lib/cost-calc.js'
|
||||
|
||||
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 }
|
||||
|
||||
for (const m of meters) {
|
||||
const { consumption, has_data } = await getPeriodConsumption(m.id, start, end)
|
||||
const tariff = await getTariffForMeter(m.id, end)
|
||||
const windows = tariff ? await getRateWindows(tariff.id) : []
|
||||
const cost = computeCost({ tariff, windows, consumption, daysInPeriod })
|
||||
if (has_data) catTotals.consumption += consumption
|
||||
const cost = await getMeterCostForPeriod(m.id, start, end)
|
||||
if (cost.has_data) catTotals.consumption += cost.consumption
|
||||
catTotals.usage_cost_pence += cost.usage_cost_pence
|
||||
catTotals.standing_cost_pence += cost.standing_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
|
||||
app.get('/api/internal/estimate', async (req) => {
|
||||
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 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 breakdown = []
|
||||
const totals = { total_pence: 0, projected_consumption: 0 }
|
||||
let remainingDays = 0
|
||||
|
||||
for (const cat of categories) {
|
||||
const windowDays = cat.estimate_trailing_days || globalDefault
|
||||
|
|
@ -111,18 +105,10 @@ export async function internalRoutes(app) {
|
|||
let catTotalPence = 0
|
||||
|
||||
for (const m of meters) {
|
||||
const { daily_rate } = await getTrailingDailyRate(m.id, windowDays)
|
||||
const { consumption: actualToDate, has_data } = await getPeriodConsumption(m.id, start, asOfDate)
|
||||
let projected = null
|
||||
if (daily_rate != null) projected = (has_data ? actualToDate : 0) + daily_rate * remainingDays
|
||||
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
|
||||
const estimate = await getMeterEstimateForPeriod(m.id, start, end, windowDays)
|
||||
remainingDays = estimate.remaining_days
|
||||
if (estimate.projected_consumption != null) catConsumption += estimate.projected_consumption
|
||||
catTotalPence += estimate.total_pence
|
||||
}
|
||||
|
||||
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>
|
||||
{report.meters.map(m => (
|
||||
<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 className="num">{formatUnits(m.consumption, m.unit_label)}</td>
|
||||
<td className="num">{formatMoney(m.usage_cost_pence)}</td>
|
||||
|
|
|
|||
|
|
@ -118,6 +118,20 @@ export interface Reading {
|
|||
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 {
|
||||
usage_cost_pence: number
|
||||
standing_cost_pence: number
|
||||
|
|
@ -126,6 +140,8 @@ export interface CostBreakdown {
|
|||
vat_pence: number
|
||||
total_pence: number
|
||||
split: Record<string, { share: number; rate: number; cost_pence: number }> | null
|
||||
segments: CostSegment[]
|
||||
rate_changed_mid_period: boolean
|
||||
}
|
||||
|
||||
export interface MeterCostRow extends CostBreakdown {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue