diff --git a/backend/src/lib/cost-calc.js b/backend/src/lib/cost-calc.js index 4c4bb8b..2731509 100644 --- a/backend/src/lib/cost-calc.js +++ b/backend/src/lib/cost-calc.js @@ -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 diff --git a/backend/src/routes/estimates.js b/backend/src/routes/estimates.js index 3e7b92b..9536373 100644 --- a/backend/src/routes/estimates.js +++ b/backend/src/routes/estimates.js @@ -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, } } diff --git a/backend/src/routes/internal.js b/backend/src/routes/internal.js index 210b755..f3d996c 100644 --- a/backend/src/routes/internal.js +++ b/backend/src/routes/internal.js @@ -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 }) diff --git a/frontend/src/pages/Reports.tsx b/frontend/src/pages/Reports.tsx index 2ea2cad..d469271 100644 --- a/frontend/src/pages/Reports.tsx +++ b/frontend/src/pages/Reports.tsx @@ -76,7 +76,15 @@ export default function Reports() {
{report.meters.map(m => (