From 811e3e9c310676f5fc713d2ad55c8048e6d1e8ee Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Tue, 28 Jul 2026 14:56:21 +0000 Subject: [PATCH] Expose per-meter cost/estimate detail and a 12-month trend on the internal API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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 --- backend/src/routes/internal.js | 153 +++++++++++++++++++++++++-------- 1 file changed, 116 insertions(+), 37 deletions(-) diff --git a/backend/src/routes/internal.js b/backend/src/routes/internal.js index 0c5818c..add8e6f 100644 --- a/backend/src/routes/internal.js +++ b/backend/src/routes/internal.js @@ -23,6 +23,59 @@ async function resolveCategoryFilter(category) { 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) { app.addHook('preHandler', requireApiKey) @@ -56,46 +109,63 @@ export async function internalRoutes(app) { 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) => { const { start, end } = resolvePeriod(req.query.period) const daysInPeriod = daysInclusive(start, end) - - const { rows: categories } = await pool.query('SELECT * FROM meter_categories WHERE active = TRUE ORDER BY sort_order') - 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 } + const { categories, meters, totals } = await categoryCostBreakdown(start, end) + return { period: { start, end, days_in_period: daysInPeriod }, categories, meters, 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) => { 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 breakdown = [] + const meterRows = [] const totals = { total_pence: 0, projected_consumption: 0 } let remainingDays = 0 for (const cat of categories) { 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 catTotalPence = 0 @@ -118,6 +189,14 @@ export async function internalRoutes(app) { remainingDays = estimate.remaining_days if (estimate.projected_consumption != null) catConsumption += estimate.projected_consumption 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 }) @@ -125,6 +204,6 @@ export async function internalRoutes(app) { 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 } }) }