Expose per-meter cost/estimate detail and a 12-month trend on the internal API

/api/internal/costs and /estimate now include a meters array (same
consumption/cost-split/basis rows as the in-app Reports/Estimates
pages) alongside the existing category totals, so external callers
(reports app's Weekly Actuals) can render a per-meter table instead of
just category rollups.

Adds /api/internal/trend?months=12 — per-category cost/consumption for
the last N months plus the same N months a year earlier, computed in
one call for a "this year vs last year" chart.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-28 14:56:21 +00:00
parent a41b1171d3
commit 811e3e9c31

View file

@ -23,6 +23,59 @@ async function resolveCategoryFilter(category) {
return rows[0]?.id ?? null return rows[0]?.id ?? null
} }
const MONTH_LABELS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
// Per-category cost breakdown (with per-meter rows) for one [start, end]
// period — shared by /costs (single period) and /trend (called once per
// month in the trend window).
async function categoryCostBreakdown(start, end) {
const { rows: categories } = await pool.query('SELECT * FROM meter_categories WHERE active = TRUE ORDER BY sort_order')
const breakdown = []
const meterRows = []
const totals = {
usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, rab_levy_cost_pence: 0,
metering_cost_pence: 0, other_charges_cost_pence: 0, vat_pence: 0, total_pence: 0, consumption: 0,
}
for (const cat of categories) {
const { rows: meters } = await pool.query('SELECT id, name FROM meters WHERE category_id = $1 AND active = TRUE', [cat.id])
const catTotals = {
usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, rab_levy_cost_pence: 0,
metering_cost_pence: 0, other_charges_cost_pence: 0, vat_pence: 0, total_pence: 0, consumption: 0,
}
for (const m of meters) {
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
catTotals.rab_levy_cost_pence += cost.rab_levy_cost_pence
catTotals.metering_cost_pence += cost.metering_cost_pence
catTotals.other_charges_cost_pence += cost.other_charges_cost_pence
catTotals.vat_pence += cost.vat_pence
catTotals.total_pence += cost.total_pence
meterRows.push({
meter_id: m.id, meter_name: m.name,
category_id: cat.id, category: cat.key, category_name: cat.name, unit_label: cat.unit_label,
consumption: cost.consumption, has_data: cost.has_data,
status: cost.status, as_of_date: cost.as_of_date,
usage_cost_pence: cost.usage_cost_pence, standing_cost_pence: cost.standing_cost_pence,
ccl_cost_pence: cost.ccl_cost_pence, rab_levy_cost_pence: cost.rab_levy_cost_pence,
metering_cost_pence: cost.metering_cost_pence, other_charges_cost_pence: cost.other_charges_cost_pence,
vat_pence: cost.vat_pence, total_pence: cost.total_pence,
rate_changed_mid_period: cost.rate_changed_mid_period,
})
}
breakdown.push({ category: cat.key, category_name: cat.name, unit_label: cat.unit_label, ...catTotals })
for (const k of Object.keys(totals)) totals[k] += catTotals[k]
}
return { categories: breakdown, meters: meterRows, totals }
}
export async function internalRoutes(app) { export async function internalRoutes(app) {
app.addHook('preHandler', requireApiKey) app.addHook('preHandler', requireApiKey)
@ -56,46 +109,63 @@ export async function internalRoutes(app) {
return { period: { start, end }, readings } return { period: { start, end }, readings }
}) })
// GET /api/internal/costs?period=YYYY-MM — cost breakdown by category // GET /api/internal/costs?period=YYYY-MM — cost breakdown by category, plus
// the same per-meter rows (consumption, cost split, basis) shown on the
// in-app Reports page, for callers that want meter-level detail.
app.get('/api/internal/costs', async (req) => { app.get('/api/internal/costs', async (req) => {
const { start, end } = resolvePeriod(req.query.period) const { start, end } = resolvePeriod(req.query.period)
const daysInPeriod = daysInclusive(start, end) const daysInPeriod = daysInclusive(start, end)
const { categories, meters, totals } = await categoryCostBreakdown(start, end)
const { rows: categories } = await pool.query('SELECT * FROM meter_categories WHERE active = TRUE ORDER BY sort_order') return { period: { start, end, days_in_period: daysInPeriod }, categories, meters, totals }
const breakdown = []
const totals = {
usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, rab_levy_cost_pence: 0,
metering_cost_pence: 0, other_charges_cost_pence: 0, vat_pence: 0, total_pence: 0, consumption: 0,
}
for (const cat of categories) {
const { rows: meters } = await pool.query('SELECT id FROM meters WHERE category_id = $1 AND active = TRUE', [cat.id])
const catTotals = {
usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, rab_levy_cost_pence: 0,
metering_cost_pence: 0, other_charges_cost_pence: 0, vat_pence: 0, total_pence: 0, consumption: 0,
}
for (const m of meters) {
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
catTotals.rab_levy_cost_pence += cost.rab_levy_cost_pence
catTotals.metering_cost_pence += cost.metering_cost_pence
catTotals.other_charges_cost_pence += cost.other_charges_cost_pence
catTotals.vat_pence += cost.vat_pence
catTotals.total_pence += cost.total_pence
}
breakdown.push({ category: cat.key, category_name: cat.name, unit_label: cat.unit_label, ...catTotals })
for (const k of Object.keys(totals)) totals[k] += catTotals[k]
}
return { period: { start, end, days_in_period: daysInPeriod }, categories: breakdown, totals }
}) })
// GET /api/internal/estimate?period=current — projected cost for the open period // GET /api/internal/trend?months=12&end=YYYY-MM — per-category (and total)
// cost/consumption for the last N calendar months plus the same N months
// one year earlier, for a "this year vs last year" trend chart. Computed
// in one call (rather than N+N round trips) since it's the same DB the
// category breakdown itself already queries.
app.get('/api/internal/trend', async (req) => {
const monthsCount = Math.min(parseInt(req.query.months) || 12, 24)
const { rows: categories } = await pool.query(
'SELECT key, name, unit_label FROM meter_categories WHERE active = TRUE ORDER BY sort_order'
)
const now = new Date()
const endRef = req.query.end
? new Date(`${req.query.end}-01T00:00:00`)
: new Date(now.getFullYear(), now.getMonth(), 1)
const monthDefs = []
for (let i = monthsCount - 1; i >= 0; i--) {
const d = new Date(endRef.getFullYear(), endRef.getMonth() - i, 1)
monthDefs.push({ year: d.getFullYear(), month: d.getMonth() + 1 })
}
async function monthEntry({ year, month }) {
const start = `${year}-${String(month).padStart(2, '0')}-01`
const end = new Date(year, month, 0).toISOString().slice(0, 10)
const { categories: catBreakdown, totals } = await categoryCostBreakdown(start, end)
const by_category = {}
for (const c of catBreakdown) by_category[c.category] = { consumption: c.consumption, total_pence: c.total_pence }
return {
year, month, label: `${MONTH_LABELS[month - 1]}-${String(year).slice(2)}`,
total_pence: totals.total_pence, by_category,
}
}
const [thisYear, lastYear] = await Promise.all([
Promise.all(monthDefs.map(monthEntry)),
Promise.all(monthDefs.map(({ year, month }) => monthEntry({ year: year - 1, month }))),
])
return {
categories: categories.map(c => ({ category: c.key, category_name: c.name, unit_label: c.unit_label })),
this_year: thisYear, last_year: lastYear,
}
})
// GET /api/internal/estimate?period=current — projected cost for the open
// period, by category and per-meter (mirrors the in-app Estimates page).
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)
@ -104,12 +174,13 @@ 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 meterRows = []
const totals = { total_pence: 0, projected_consumption: 0 } const totals = { total_pence: 0, projected_consumption: 0 }
let remainingDays = 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
const { rows: meters } = await pool.query('SELECT id FROM meters WHERE category_id = $1 AND active = TRUE', [cat.id]) const { rows: meters } = await pool.query('SELECT id, name FROM meters WHERE category_id = $1 AND active = TRUE', [cat.id])
let catConsumption = 0 let catConsumption = 0
let catTotalPence = 0 let catTotalPence = 0
@ -118,6 +189,14 @@ export async function internalRoutes(app) {
remainingDays = estimate.remaining_days remainingDays = estimate.remaining_days
if (estimate.projected_consumption != null) catConsumption += estimate.projected_consumption if (estimate.projected_consumption != null) catConsumption += estimate.projected_consumption
catTotalPence += estimate.total_pence catTotalPence += estimate.total_pence
meterRows.push({
meter_id: m.id, meter_name: m.name,
category_id: cat.id, category: cat.key, category_name: cat.name, unit_label: cat.unit_label,
trailing_window_days: estimate.trailing_window_days, daily_rate: estimate.daily_rate,
actual_to_date: estimate.actual_to_date, remaining_days: estimate.remaining_days,
projected_consumption: estimate.projected_consumption, total_pence: estimate.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 })
@ -125,6 +204,6 @@ export async function internalRoutes(app) {
totals.total_pence += catTotalPence totals.total_pence += catTotalPence
} }
return { period: { year, month, start, end, remaining_days: remainingDays }, categories: breakdown, totals } return { period: { year, month, start, end, remaining_days: remainingDays }, categories: breakdown, meters: meterRows, totals }
}) })
} }