Group Reports/Estimates by category, merge extras, add data-basis column

Reports and Estimates now group meters by category with a per-category
subtotal (in that category's own unit) instead of one flat list — the
grand-total row no longer sums consumption across categories, since
mixing kWh/m3/L is meaningless; money totals still sum fine.

On Reports: CCL, RAB levy and fixed extras collapse into one "Extras"
column/stat (still separately editable/stored, just merged for
display). New "Basis" column classifies how each meter's figure was
derived — Complete, Distributed (interpolated across a sparse-reading
gap), Up to date / As of [date] (real data short of period end), or
No data — via a new classifyBasis() in cost-calc.js.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-28 14:28:57 +00:00
parent 7a4627520c
commit a41b1171d3
5 changed files with 164 additions and 59 deletions

View file

@ -105,6 +105,25 @@ async function interpolatedValueAtDate(meterId, date) {
return { value, before, after }
}
// Classifies how a period's consumption figure was actually derived, for
// display next to a report row:
// - 'no_data' — no readings cover the period at all
// - 'partial' — real data only reaches as_of_date, short of periodEnd
// (the normal case for a still-open current period)
// - 'complete' — readings land exactly on both boundaries, no interpolation
// needed — either a lucky manual coincidence or, once a
// device feeds daily readings, the normal case
// - 'distributed' — full period covered, but one or both boundaries needed
// interpolating across a sparse-reading gap
function classifyBasis(hasData, periodStart, periodEnd, first, last) {
if (!hasData) return { status: 'no_data', as_of_date: null }
const lastDate = toISODate(last.reading_date)
if (lastDate < periodEnd) return { status: 'partial', as_of_date: lastDate }
const firstDate = toISODate(first.reading_date)
if (firstDate === periodStart && lastDate === periodEnd) return { status: 'complete', as_of_date: null }
return { status: 'distributed', as_of_date: null }
}
// Consumption for a meter over [periodStart, periodEnd] (inclusive). Interpolates
// the meter's value at each boundary between the real readings bracketing it,
// then takes the difference — so a period's consumption is prorated by days
@ -119,17 +138,21 @@ export async function getPeriodConsumption(meterId, periodStart, periodEnd) {
if (!startPoint || !endPoint) {
const first = (await readingOnOrAfter(meterId, periodStart)) || (await readingOnOrBefore(meterId, periodStart))
const last = await readingOnOrBefore(meterId, periodEnd)
if (!first || !last || last.reading_date <= first.reading_date) {
return { consumption: null, first, last, has_data: false }
const hasData = !!(first && last && last.reading_date > first.reading_date)
const basis = classifyBasis(hasData, periodStart, periodEnd, first, last)
if (!hasData) {
return { consumption: null, first, last, has_data: false, ...basis }
}
const rawConsumption = Number(last.reading_value) - Number(first.reading_value)
const consumption = await convertMeterConsumption(meterId, rawConsumption)
return { consumption, first, last, has_data: true }
return { consumption, first, last, has_data: true, ...basis }
}
const first = startPoint.before
const last = endPoint.after
const rawConsumption = endPoint.value - startPoint.value
const consumption = await convertMeterConsumption(meterId, rawConsumption)
return { consumption, first: startPoint.before, last: endPoint.after, has_data: true }
return { consumption, first, last, has_data: true, ...classifyBasis(true, periodStart, periodEnd, first, last) }
}
// Trailing average daily consumption as of the meter's latest reading,
@ -391,10 +414,13 @@ async function splitCostAcrossSegments(meterId, rangeStart, rangeEnd, consumptio
// or backdated after the fact) that falls inside the period. `asOfDate` is only
// used as a fallback when the meter has no tariff-assignment history at all.
export async function getMeterCostForPeriod(meterId, periodStart, periodEnd, asOfDate) {
const { consumption, first, last, has_data } = await getPeriodConsumption(meterId, periodStart, periodEnd)
const { consumption, first, last, has_data, status, as_of_date } = await getPeriodConsumption(meterId, periodStart, periodEnd)
const daysInPeriod = daysInclusive(periodStart, periodEnd)
const costed = await splitCostAcrossSegments(meterId, periodStart, periodEnd, consumption, asOfDate || periodEnd)
return { consumption, has_data, first_reading: first, last_reading: last, days_in_period: daysInPeriod, ...costed }
return {
consumption, has_data, status, as_of_date,
first_reading: first, last_reading: last, days_in_period: daysInPeriod, ...costed,
}
}
// Estimate for the remainder of an open period: actual consumption to date +