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
|
||||
}
|
||||
|
||||
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 }
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue