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).
This commit is contained in:
parent
d6a787e244
commit
d223592aec
6 changed files with 72 additions and 24 deletions
|
|
@ -7,7 +7,7 @@ const VALID_WINDOWS = [7, 14, 30]
|
||||||
async function estimateForMeter(meter, periodStart, periodEnd, windowDays) {
|
async function estimateForMeter(meter, periodStart, periodEnd, windowDays) {
|
||||||
const estimate = await getMeterEstimateForPeriod(meter.id, periodStart, periodEnd, windowDays)
|
const estimate = await getMeterEstimateForPeriod(meter.id, periodStart, periodEnd, windowDays)
|
||||||
return {
|
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,
|
category_id: meter.category_id, category_name: meter.category_name, unit_label: meter.unit_label,
|
||||||
...estimate,
|
...estimate,
|
||||||
}
|
}
|
||||||
|
|
@ -29,7 +29,7 @@ export async function estimateRoutes(app) {
|
||||||
if (category_id) { params.push(category_id); conditions.push(`m.category_id = $${params.length}`) }
|
if (category_id) { params.push(category_id); conditions.push(`m.category_id = $${params.length}`) }
|
||||||
params.push(globalDefault)
|
params.push(globalDefault)
|
||||||
const { rows: meters } = await pool.query(
|
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
|
COALESCE(c.estimate_trailing_days, $${params.length}) AS trailing_days
|
||||||
FROM meters m JOIN meter_categories c ON c.id = m.category_id
|
FROM meters m JOIN meter_categories c ON c.id = m.category_id
|
||||||
WHERE ${conditions.join(' AND ')}`,
|
WHERE ${conditions.join(' AND ')}`,
|
||||||
|
|
@ -42,7 +42,12 @@ export async function estimateRoutes(app) {
|
||||||
results.push(await estimateForMeter(m, start, end, windowDays))
|
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,
|
total_pence: acc.total_pence + r.total_pence,
|
||||||
usage_cost_pence: acc.usage_cost_pence + r.usage_cost_pence,
|
usage_cost_pence: acc.usage_cost_pence + r.usage_cost_pence,
|
||||||
standing_cost_pence: acc.standing_cost_pence + r.standing_cost_pence,
|
standing_cost_pence: acc.standing_cost_pence + r.standing_cost_pence,
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ async function categoryCostBreakdown(start, end) {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const cat of categories) {
|
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
|
if (meters.length === 0) continue // no meters yet (e.g. Water/Oil seeded but unused) — nothing to report
|
||||||
|
|
||||||
const catTotals = {
|
const catTotals = {
|
||||||
|
|
@ -48,18 +48,26 @@ async function categoryCostBreakdown(start, end) {
|
||||||
|
|
||||||
for (const m of meters) {
|
for (const m of meters) {
|
||||||
const cost = await getMeterCostForPeriod(m.id, start, end)
|
const cost = await getMeterCostForPeriod(m.id, start, end)
|
||||||
if (cost.has_data) catTotals.consumption += cost.consumption
|
|
||||||
catTotals.usage_cost_pence += cost.usage_cost_pence
|
// Sub-meters (parent_meter_id set) measure a subset of their parent's
|
||||||
catTotals.standing_cost_pence += cost.standing_cost_pence
|
// own reading (e.g. the water softener sits inline on the main
|
||||||
catTotals.ccl_cost_pence += cost.ccl_cost_pence
|
// supply) — already counted via the parent, so excluded from the
|
||||||
catTotals.rab_levy_cost_pence += cost.rab_levy_cost_pence
|
// category totals to avoid double-counting. Still returned as its own
|
||||||
catTotals.metering_cost_pence += cost.metering_cost_pence
|
// row below for callers that want meter-level detail.
|
||||||
catTotals.other_charges_cost_pence += cost.other_charges_cost_pence
|
if (!m.parent_meter_id) {
|
||||||
catTotals.vat_pence += cost.vat_pence
|
if (cost.has_data) catTotals.consumption += cost.consumption
|
||||||
catTotals.total_pence += cost.total_pence
|
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({
|
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,
|
category_id: cat.id, category: cat.key, category_name: cat.name, unit_label: cat.unit_label,
|
||||||
consumption: cost.consumption, has_data: cost.has_data,
|
consumption: cost.consumption, has_data: cost.has_data,
|
||||||
status: cost.status, as_of_date: cost.as_of_date,
|
status: cost.status, as_of_date: cost.as_of_date,
|
||||||
|
|
@ -184,18 +192,26 @@ export async function internalRoutes(app) {
|
||||||
|
|
||||||
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, 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 catConsumption = 0
|
||||||
let catTotalPence = 0
|
let catTotalPence = 0
|
||||||
|
|
||||||
for (const m of meters) {
|
for (const m of meters) {
|
||||||
const estimate = await getMeterEstimateForPeriod(m.id, start, end, windowDays)
|
const estimate = await getMeterEstimateForPeriod(m.id, start, end, windowDays)
|
||||||
remainingDays = estimate.remaining_days
|
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({
|
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,
|
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,
|
trailing_window_days: estimate.trailing_window_days, daily_rate: estimate.daily_rate,
|
||||||
actual_to_date: estimate.actual_to_date, remaining_days: estimate.remaining_days,
|
actual_to_date: estimate.actual_to_date, remaining_days: estimate.remaining_days,
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export async function reportRoutes(app) {
|
||||||
let where = ''
|
let where = ''
|
||||||
if (category_id) { params.push(category_id); where = 'WHERE m.category_id = $1' }
|
if (category_id) { params.push(category_id); where = 'WHERE m.category_id = $1' }
|
||||||
const { rows: meters } = await pool.query(
|
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
|
FROM meters m JOIN meter_categories c ON c.id = m.category_id
|
||||||
${where} ORDER BY c.sort_order, m.name`,
|
${where} ORDER BY c.sort_order, m.name`,
|
||||||
params
|
params
|
||||||
|
|
@ -26,13 +26,18 @@ export async function reportRoutes(app) {
|
||||||
for (const m of meters) {
|
for (const m of meters) {
|
||||||
const cost = await getMeterCostForPeriod(m.id, start, end)
|
const cost = await getMeterCostForPeriod(m.id, start, end)
|
||||||
results.push({
|
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,
|
category_id: m.category_id, category_name: m.category_name, unit_label: m.unit_label,
|
||||||
...cost,
|
...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),
|
consumption: acc.consumption + (r.consumption || 0),
|
||||||
usage_cost_pence: acc.usage_cost_pence + r.usage_cost_pence,
|
usage_cost_pence: acc.usage_cost_pence + r.usage_cost_pence,
|
||||||
standing_cost_pence: acc.standing_cost_pence + r.standing_cost_pence,
|
standing_cost_pence: acc.standing_cost_pence + r.standing_cost_pence,
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,12 @@ import * as api from '../api'
|
||||||
|
|
||||||
const WINDOW_OPTIONS = [7, 14, 30]
|
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[]) {
|
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),
|
projected_consumption: acc.projected_consumption + (r.projected_consumption || 0),
|
||||||
total_pence: acc.total_pence + r.total_pence,
|
total_pence: acc.total_pence + r.total_pence,
|
||||||
}), { projected_consumption: 0, total_pence: 0 })
|
}), { projected_consumption: 0, total_pence: 0 })
|
||||||
|
|
@ -104,7 +108,14 @@ export default function Estimates() {
|
||||||
<Fragment key={group.category_id}>
|
<Fragment key={group.category_id}>
|
||||||
{group.rows.map(m => (
|
{group.rows.map(m => (
|
||||||
<tr key={m.meter_id}>
|
<tr key={m.meter_id}>
|
||||||
<td>{m.meter_name}</td>
|
<td>
|
||||||
|
{m.meter_name}
|
||||||
|
{m.parent_meter_id && (
|
||||||
|
<span className="badge" style={{ marginLeft: 6 }} title="Sub-meter — reads a subset of its parent meter, excluded from the subtotal below to avoid double-counting">
|
||||||
|
submeter
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="num">{m.trailing_window_days}d</td>
|
<td className="num">{m.trailing_window_days}d</td>
|
||||||
<td className="num">{m.daily_rate != null ? formatUnits(m.daily_rate, `${m.unit_label}/day`) : '—'}</td>
|
<td className="num">{m.daily_rate != null ? formatUnits(m.daily_rate, `${m.unit_label}/day`) : '—'}</td>
|
||||||
<td className="num">{formatUnits(m.actual_to_date, m.unit_label)}</td>
|
<td className="num">{formatUnits(m.actual_to_date, m.unit_label)}</td>
|
||||||
|
|
|
||||||
|
|
@ -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
|
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[]) {
|
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),
|
consumption: acc.consumption + (r.consumption || 0),
|
||||||
usage_cost_pence: acc.usage_cost_pence + r.usage_cost_pence,
|
usage_cost_pence: acc.usage_cost_pence + r.usage_cost_pence,
|
||||||
standing_cost_pence: acc.standing_cost_pence + r.standing_cost_pence,
|
standing_cost_pence: acc.standing_cost_pence + r.standing_cost_pence,
|
||||||
|
|
@ -97,6 +101,11 @@ export default function Reports() {
|
||||||
<tr key={m.meter_id}>
|
<tr key={m.meter_id}>
|
||||||
<td>
|
<td>
|
||||||
{m.meter_name}
|
{m.meter_name}
|
||||||
|
{m.parent_meter_id && (
|
||||||
|
<span className="badge" style={{ marginLeft: 6 }} title="Sub-meter — reads a subset of its parent meter, excluded from the subtotal below to avoid double-counting">
|
||||||
|
submeter
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{m.rate_changed_mid_period && (
|
{m.rate_changed_mid_period && (
|
||||||
<span className="badge badge-tou" style={{ marginLeft: 6 }} title={m.segments.map(s => `${s.tariff_name}: ${s.seg_start} – ${s.seg_end}`).join(', ')}>
|
<span className="badge badge-tou" style={{ marginLeft: 6 }} title={m.segments.map(s => `${s.tariff_name}: ${s.seg_start} – ${s.seg_end}`).join(', ')}>
|
||||||
rate changed
|
rate changed
|
||||||
|
|
|
||||||
|
|
@ -180,6 +180,7 @@ export type ConsumptionBasis = 'no_data' | 'partial' | 'complete' | 'distributed
|
||||||
export interface MeterCostRow extends CostBreakdown {
|
export interface MeterCostRow extends CostBreakdown {
|
||||||
meter_id: number
|
meter_id: number
|
||||||
meter_name: string
|
meter_name: string
|
||||||
|
parent_meter_id: number | null
|
||||||
category_id: number
|
category_id: number
|
||||||
category_name: string
|
category_name: string
|
||||||
unit_label: string
|
unit_label: string
|
||||||
|
|
@ -260,6 +261,7 @@ export interface RollupReport {
|
||||||
export interface EstimateRow extends CostBreakdown {
|
export interface EstimateRow extends CostBreakdown {
|
||||||
meter_id: number
|
meter_id: number
|
||||||
meter_name: string
|
meter_name: string
|
||||||
|
parent_meter_id: number | null
|
||||||
category_id: number
|
category_id: number
|
||||||
category_name: string
|
category_name: string
|
||||||
unit_label: string
|
unit_label: string
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue