From d223592aec628d364070fbf573c84c12ee3bc17f Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Mon, 17 Aug 2026 09:56:02 +0000 Subject: [PATCH] Exclude sub-meters from category subtotals in Estimates/Reports/internal API Water Softener is configured as a submeter (parent_meter_id) of Main Hotel Water, but estimates.js, reports.js, and internal.js's cost + estimate routes summed every active meter flatly regardless of parent/child, double- counting the softener's consumption on top of the main meter's own reading. Sub-meter rows are still shown individually (useful for diagnostics), just excluded from the category subtotal/total sums, with a 'submeter' badge on the row explaining why. Fixes the water figures on Estimates, Reports, and downstream consumers of internal.js (Directors report, Weekly Actuals). --- backend/src/routes/estimates.js | 11 +++++--- backend/src/routes/internal.js | 46 +++++++++++++++++++++----------- backend/src/routes/reports.js | 11 +++++--- frontend/src/pages/Estimates.tsx | 15 +++++++++-- frontend/src/pages/Reports.tsx | 11 +++++++- frontend/src/types.ts | 2 ++ 6 files changed, 72 insertions(+), 24 deletions(-) diff --git a/backend/src/routes/estimates.js b/backend/src/routes/estimates.js index 83bd90d..e043635 100644 --- a/backend/src/routes/estimates.js +++ b/backend/src/routes/estimates.js @@ -7,7 +7,7 @@ const VALID_WINDOWS = [7, 14, 30] async function estimateForMeter(meter, periodStart, periodEnd, windowDays) { const estimate = await getMeterEstimateForPeriod(meter.id, periodStart, periodEnd, windowDays) return { - meter_id: meter.id, meter_name: meter.name, + meter_id: meter.id, meter_name: meter.name, parent_meter_id: meter.parent_meter_id, category_id: meter.category_id, category_name: meter.category_name, unit_label: meter.unit_label, ...estimate, } @@ -29,7 +29,7 @@ export async function estimateRoutes(app) { if (category_id) { params.push(category_id); conditions.push(`m.category_id = $${params.length}`) } params.push(globalDefault) const { rows: meters } = await pool.query( - `SELECT m.id, m.name, m.category_id, c.name AS category_name, c.unit_label, + `SELECT m.id, m.name, m.category_id, m.parent_meter_id, c.name AS category_name, c.unit_label, COALESCE(c.estimate_trailing_days, $${params.length}) AS trailing_days FROM meters m JOIN meter_categories c ON c.id = m.category_id WHERE ${conditions.join(' AND ')}`, @@ -42,7 +42,12 @@ export async function estimateRoutes(app) { results.push(await estimateForMeter(m, start, end, windowDays)) } - const totals = results.reduce((acc, r) => ({ + // Sub-meters (parent_meter_id set) measure a subset of their parent's own + // reading — e.g. the water softener sits inline on the main supply — so + // their consumption/cost is already counted via the parent and must be + // excluded here to avoid double-counting. Still returned as their own row + // above so the projection is visible per-device. + const totals = results.filter(r => !r.parent_meter_id).reduce((acc, r) => ({ total_pence: acc.total_pence + r.total_pence, usage_cost_pence: acc.usage_cost_pence + r.usage_cost_pence, standing_cost_pence: acc.standing_cost_pence + r.standing_cost_pence, diff --git a/backend/src/routes/internal.js b/backend/src/routes/internal.js index f296d18..1318e4d 100644 --- a/backend/src/routes/internal.js +++ b/backend/src/routes/internal.js @@ -38,7 +38,7 @@ async function categoryCostBreakdown(start, end) { } 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 { rows: meters } = await pool.query('SELECT id, name, parent_meter_id FROM meters WHERE category_id = $1 AND active = TRUE', [cat.id]) if (meters.length === 0) continue // no meters yet (e.g. Water/Oil seeded but unused) — nothing to report const catTotals = { @@ -48,18 +48,26 @@ async function categoryCostBreakdown(start, end) { 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 + + // Sub-meters (parent_meter_id set) measure a subset of their parent's + // own reading (e.g. the water softener sits inline on the main + // supply) — already counted via the parent, so excluded from the + // category totals to avoid double-counting. Still returned as its own + // row below for callers that want meter-level detail. + if (!m.parent_meter_id) { + 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, + meter_id: m.id, meter_name: m.name, parent_meter_id: m.parent_meter_id, 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, @@ -184,18 +192,26 @@ export async function internalRoutes(app) { for (const cat of categories) { const windowDays = cat.estimate_trailing_days || globalDefault - const { rows: meters } = await pool.query('SELECT id, name FROM meters WHERE category_id = $1 AND active = TRUE', [cat.id]) + const { rows: meters } = await pool.query('SELECT id, name, parent_meter_id FROM meters WHERE category_id = $1 AND active = TRUE', [cat.id]) let catConsumption = 0 let catTotalPence = 0 for (const m of meters) { 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 + + // Sub-meters (parent_meter_id set) project a subset of their parent's + // own consumption (e.g. the water softener sits inline on the main + // supply) — already counted via the parent, so excluded from the + // category total to avoid double-counting. Still returned as its own + // row below for callers that want meter-level detail. + if (!m.parent_meter_id) { + if (estimate.projected_consumption != null) catConsumption += estimate.projected_consumption + catTotalPence += estimate.total_pence + } meterRows.push({ - meter_id: m.id, meter_name: m.name, + meter_id: m.id, meter_name: m.name, parent_meter_id: m.parent_meter_id, 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, diff --git a/backend/src/routes/reports.js b/backend/src/routes/reports.js index 2c39048..c437d1a 100644 --- a/backend/src/routes/reports.js +++ b/backend/src/routes/reports.js @@ -16,7 +16,7 @@ export async function reportRoutes(app) { let where = '' if (category_id) { params.push(category_id); where = 'WHERE m.category_id = $1' } const { rows: meters } = await pool.query( - `SELECT m.id, m.name, m.category_id, c.name AS category_name, c.unit_label + `SELECT m.id, m.name, m.category_id, m.parent_meter_id, c.name AS category_name, c.unit_label FROM meters m JOIN meter_categories c ON c.id = m.category_id ${where} ORDER BY c.sort_order, m.name`, params @@ -26,13 +26,18 @@ export async function reportRoutes(app) { for (const m of meters) { const cost = await getMeterCostForPeriod(m.id, start, end) results.push({ - meter_id: m.id, meter_name: m.name, + meter_id: m.id, meter_name: m.name, parent_meter_id: m.parent_meter_id, category_id: m.category_id, category_name: m.category_name, unit_label: m.unit_label, ...cost, }) } - const totals = results.reduce((acc, r) => ({ + // Sub-meters (parent_meter_id set) measure a subset of their parent's own + // reading (e.g. the water softener sits inline on the main supply), so + // their consumption/cost is already counted via the parent — excluded + // here to avoid double-counting; still shown as their own row above, and + // separately compared against their parent in the rollup report below. + const totals = results.filter(r => !r.parent_meter_id).reduce((acc, r) => ({ consumption: acc.consumption + (r.consumption || 0), usage_cost_pence: acc.usage_cost_pence + r.usage_cost_pence, standing_cost_pence: acc.standing_cost_pence + r.standing_cost_pence, diff --git a/frontend/src/pages/Estimates.tsx b/frontend/src/pages/Estimates.tsx index e775079..df00a7c 100644 --- a/frontend/src/pages/Estimates.tsx +++ b/frontend/src/pages/Estimates.tsx @@ -6,8 +6,12 @@ import * as api from '../api' const WINDOW_OPTIONS = [7, 14, 30] +// Sub-meters (parent_meter_id set) project a subset of their parent's own +// consumption (e.g. the water softener sits inline on the main supply) — their +// row is still shown for visibility, but excluding them here avoids the +// subtotal double-counting water/energy that's already in the parent's figure. function sumEstimateCosts(rows: EstimateRow[]) { - return rows.reduce((acc, r) => ({ + return rows.filter(r => !r.parent_meter_id).reduce((acc, r) => ({ projected_consumption: acc.projected_consumption + (r.projected_consumption || 0), total_pence: acc.total_pence + r.total_pence, }), { projected_consumption: 0, total_pence: 0 }) @@ -104,7 +108,14 @@ export default function Estimates() { {group.rows.map(m => ( - {m.meter_name} + + {m.meter_name} + {m.parent_meter_id && ( + + submeter + + )} + {m.trailing_window_days}d {m.daily_rate != null ? formatUnits(m.daily_rate, `${m.unit_label}/day`) : '—'} {formatUnits(m.actual_to_date, m.unit_label)} diff --git a/frontend/src/pages/Reports.tsx b/frontend/src/pages/Reports.tsx index a2ecd25..478bf4f 100644 --- a/frontend/src/pages/Reports.tsx +++ b/frontend/src/pages/Reports.tsx @@ -8,8 +8,12 @@ function extrasFor(m: { ccl_cost_pence: number; rab_levy_cost_pence: number; met return m.ccl_cost_pence + m.rab_levy_cost_pence + m.metering_cost_pence + m.other_charges_cost_pence } +// Sub-meters (parent_meter_id set) read a subset of their parent's own +// reading (e.g. the water softener sits inline on the main supply) — their +// row is still shown for visibility, but excluding them here avoids the +// subtotal double-counting water/energy that's already in the parent's figure. function sumMeterCosts(rows: MeterCostRow[]) { - return rows.reduce((acc, r) => ({ + return rows.filter(r => !r.parent_meter_id).reduce((acc, r) => ({ consumption: acc.consumption + (r.consumption || 0), usage_cost_pence: acc.usage_cost_pence + r.usage_cost_pence, standing_cost_pence: acc.standing_cost_pence + r.standing_cost_pence, @@ -97,6 +101,11 @@ export default function Reports() { {m.meter_name} + {m.parent_meter_id && ( + + submeter + + )} {m.rate_changed_mid_period && ( `${s.tariff_name}: ${s.seg_start} – ${s.seg_end}`).join(', ')}> rate changed diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 164c090..580073d 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -180,6 +180,7 @@ export type ConsumptionBasis = 'no_data' | 'partial' | 'complete' | 'distributed export interface MeterCostRow extends CostBreakdown { meter_id: number meter_name: string + parent_meter_id: number | null category_id: number category_name: string unit_label: string @@ -260,6 +261,7 @@ export interface RollupReport { export interface EstimateRow extends CostBreakdown { meter_id: number meter_name: string + parent_meter_id: number | null category_id: number category_name: string unit_label: string