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:
parent
a41b1171d3
commit
811e3e9c31
1 changed files with 116 additions and 37 deletions
|
|
@ -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 }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue