Wire real utility cost data into Weekly Actuals report
Replaces the Utilities placeholder with a cost-breakdown table by category (usage/standing/CCL/RAB levy/fixed extras/VAT/total) sourced from the utilities app's internal API, plus a projected-total note for the current month. Mirrors the existing forecasting integration pattern (best-effort fetch, doesn't break the rest of the report on failure). Also adds the missing UTILITIES_URL/UTILITIES_API_KEY env vars to docker-compose.yml, needed by both this and the pre-existing directors-forecast integration. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
932302ebe2
commit
dfd3a506d1
4 changed files with 142 additions and 6 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
import { requireAuth } from '../auth.js'
|
import { requireAuth } from '../auth.js'
|
||||||
import { getSetting } from '../db.js'
|
import { getSetting } from '../db.js'
|
||||||
|
import { getUtilityCosts, getUtilityEstimate } from '../lib/utilities-client.js'
|
||||||
|
|
||||||
async function fcFetch(path) {
|
async function fcFetch(path) {
|
||||||
const apiKey = await getSetting('forecasting_api_key') || process.env.FORECASTING_API_KEY
|
const apiKey = await getSetting('forecasting_api_key') || process.env.FORECASTING_API_KEY
|
||||||
|
|
@ -289,6 +290,25 @@ export async function weeklyActualRoutes(fastify) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Utilities (cost breakdown/estimate from the `utilities` app) ──────────
|
||||||
|
// Fetched best-effort for the report's calendar month — a failure here
|
||||||
|
// (e.g. utilities app not deployed, or key not configured) must not break
|
||||||
|
// the rest of the report.
|
||||||
|
let utilities = { costs: null, estimate: null, error: null }
|
||||||
|
try {
|
||||||
|
const period = `${year}-${String(month).padStart(2, '0')}`
|
||||||
|
const now = new Date()
|
||||||
|
const isCurrentPeriod = year === now.getFullYear() && month === now.getMonth() + 1
|
||||||
|
|
||||||
|
const [costs, estimate] = await Promise.all([
|
||||||
|
getUtilityCosts(period),
|
||||||
|
isCurrentPeriod ? getUtilityEstimate() : Promise.resolve(null),
|
||||||
|
])
|
||||||
|
utilities = { costs, estimate, error: null }
|
||||||
|
} catch (err) {
|
||||||
|
utilities = { costs: null, estimate: null, error: err.message }
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
week_ending: toDateStr(weekEndDate),
|
week_ending: toDateStr(weekEndDate),
|
||||||
week_start: toDateStr(weekStartDate),
|
week_start: toDateStr(weekStartDate),
|
||||||
|
|
@ -300,6 +320,7 @@ export async function weeklyActualRoutes(fastify) {
|
||||||
month_daily,
|
month_daily,
|
||||||
monthly_trend,
|
monthly_trend,
|
||||||
monthly_split,
|
monthly_split,
|
||||||
|
utilities,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ services:
|
||||||
- DATABASE_URL=${DATABASE_URL}
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
- FORECASTING_URL=${FORECASTING_URL:-http://10.10.10.113:3080}
|
- FORECASTING_URL=${FORECASTING_URL:-http://10.10.10.113:3080}
|
||||||
- FORECASTING_API_KEY=${FORECASTING_API_KEY:-}
|
- FORECASTING_API_KEY=${FORECASTING_API_KEY:-}
|
||||||
|
- UTILITIES_URL=${UTILITIES_URL:-http://10.10.10.127:3080}
|
||||||
|
- UTILITIES_API_KEY=${UTILITIES_API_KEY:-}
|
||||||
- CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
|
- CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
|
||||||
- SETTINGS_URL=${SETTINGS_URL}
|
- SETTINGS_URL=${SETTINGS_URL}
|
||||||
- SETTINGS_SECRET=${SETTINGS_SECRET}
|
- SETTINGS_SECRET=${SETTINGS_SECRET}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ import type { WeeklyActualData, WeekSummaryRow, MonthSplitEntry, MonthDailyEntry
|
||||||
const fmtCcy = (n: number | null | undefined, dp = 2) =>
|
const fmtCcy = (n: number | null | undefined, dp = 2) =>
|
||||||
n == null ? '—' : `£${n.toLocaleString('en-GB', { minimumFractionDigits: dp, maximumFractionDigits: dp })}`
|
n == null ? '—' : `£${n.toLocaleString('en-GB', { minimumFractionDigits: dp, maximumFractionDigits: dp })}`
|
||||||
|
|
||||||
|
const fmtUnits = (n: number | null | undefined, unit: string) =>
|
||||||
|
n == null ? '—' : `${n.toLocaleString('en-GB', { maximumFractionDigits: 1 })} ${unit}`
|
||||||
|
|
||||||
const fmtPct = (n: number | null | undefined) =>
|
const fmtPct = (n: number | null | undefined) =>
|
||||||
n == null ? '—' : `${n >= 0 ? '+' : ''}${n.toFixed(2)}%`
|
n == null ? '—' : `${n >= 0 ? '+' : ''}${n.toFixed(2)}%`
|
||||||
|
|
||||||
|
|
@ -470,16 +473,81 @@ function SalesHistorySection({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Section: Utilities Placeholder ─────────────────────────────────────────
|
// ── Section: Utilities ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function UtilitiesPlaceholder() {
|
function UtilitiesSection({ utilities }: { utilities: WeeklyActualData['utilities'] }) {
|
||||||
|
const { costs, estimate, error } = utilities
|
||||||
|
|
||||||
|
if (error || !costs) {
|
||||||
return (
|
return (
|
||||||
<div className="wa-utilities-placeholder">
|
<div className="wa-utilities-placeholder">
|
||||||
<p>Utilities report — pending meter readings app integration.</p>
|
<p>Utilities report unavailable{error ? ` — ${error}` : ''}.</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (costs.categories.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="wa-utilities-placeholder">
|
||||||
|
<p>No utility categories configured.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="wa-table-wrap">
|
||||||
|
<table className="wa-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th>Consumption</th>
|
||||||
|
<th>Usage</th>
|
||||||
|
<th>Standing</th>
|
||||||
|
<th>CCL</th>
|
||||||
|
<th>RAB Levy</th>
|
||||||
|
<th>Fixed Extras</th>
|
||||||
|
<th>VAT</th>
|
||||||
|
<th>Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{costs.categories.map(c => (
|
||||||
|
<tr key={c.category}>
|
||||||
|
<td className="wa-row-label">{c.category_name}</td>
|
||||||
|
<td className="wa-num">{fmtUnits(c.consumption, c.unit_label)}</td>
|
||||||
|
<td className="wa-num">{fmtCcy(c.usage_cost_pence / 100)}</td>
|
||||||
|
<td className="wa-num">{fmtCcy(c.standing_cost_pence / 100)}</td>
|
||||||
|
<td className="wa-num">{fmtCcy(c.ccl_cost_pence / 100)}</td>
|
||||||
|
<td className="wa-num">{fmtCcy(c.rab_levy_cost_pence / 100)}</td>
|
||||||
|
<td className="wa-num">{fmtCcy((c.metering_cost_pence + c.other_charges_cost_pence) / 100)}</td>
|
||||||
|
<td className="wa-num">{fmtCcy(c.vat_pence / 100)}</td>
|
||||||
|
<td className="wa-num">{fmtCcy(c.total_pence / 100)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
<tr className="wa-total-row">
|
||||||
|
<td className="wa-row-label">Total</td>
|
||||||
|
<td className="wa-num">{costs.totals.consumption.toLocaleString('en-GB', { maximumFractionDigits: 1 })}</td>
|
||||||
|
<td className="wa-num">{fmtCcy(costs.totals.usage_cost_pence / 100)}</td>
|
||||||
|
<td className="wa-num">{fmtCcy(costs.totals.standing_cost_pence / 100)}</td>
|
||||||
|
<td className="wa-num">{fmtCcy(costs.totals.ccl_cost_pence / 100)}</td>
|
||||||
|
<td className="wa-num">{fmtCcy(costs.totals.rab_levy_cost_pence / 100)}</td>
|
||||||
|
<td className="wa-num">{fmtCcy((costs.totals.metering_cost_pence + costs.totals.other_charges_cost_pence) / 100)}</td>
|
||||||
|
<td className="wa-num">{fmtCcy(costs.totals.vat_pence / 100)}</td>
|
||||||
|
<td className="wa-num">{fmtCcy(costs.totals.total_pence / 100)}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{estimate && (
|
||||||
|
<p className="wa-note">
|
||||||
|
Projected total for the month ({estimate.period.remaining_days} day{estimate.period.remaining_days === 1 ? '' : 's'} remaining to estimate): {fmtCcy(estimate.totals.total_pence / 100)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Main component ─────────────────────────────────────────────────────────
|
// ── Main component ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function WeeklyActual() {
|
export default function WeeklyActual() {
|
||||||
|
|
@ -580,7 +648,7 @@ export default function WeeklyActual() {
|
||||||
{/* ── Utilities ────────────────────────────────────────────── */}
|
{/* ── Utilities ────────────────────────────────────────────── */}
|
||||||
<section className="wa-section">
|
<section className="wa-section">
|
||||||
<h2 className="wa-section-title">Utilities</h2>
|
<h2 className="wa-section-title">Utilities</h2>
|
||||||
<UtilitiesPlaceholder />
|
<UtilitiesSection utilities={data.utilities} />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,50 @@ export interface MonthSplitEntry {
|
||||||
wet: number
|
wet: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UtilityCategoryCost {
|
||||||
|
category: string
|
||||||
|
category_name: string
|
||||||
|
unit_label: string
|
||||||
|
consumption: number
|
||||||
|
usage_cost_pence: number
|
||||||
|
standing_cost_pence: number
|
||||||
|
ccl_cost_pence: number
|
||||||
|
rab_levy_cost_pence: number
|
||||||
|
metering_cost_pence: number
|
||||||
|
other_charges_cost_pence: number
|
||||||
|
vat_pence: number
|
||||||
|
total_pence: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UtilityCostTotals {
|
||||||
|
consumption: number
|
||||||
|
usage_cost_pence: number
|
||||||
|
standing_cost_pence: number
|
||||||
|
ccl_cost_pence: number
|
||||||
|
rab_levy_cost_pence: number
|
||||||
|
metering_cost_pence: number
|
||||||
|
other_charges_cost_pence: number
|
||||||
|
vat_pence: number
|
||||||
|
total_pence: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UtilityCosts {
|
||||||
|
period: { start: string; end: string; days_in_period: number }
|
||||||
|
categories: UtilityCategoryCost[]
|
||||||
|
totals: UtilityCostTotals
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UtilityEstimate {
|
||||||
|
period: { year: number; month: number; start: string; end: string; remaining_days: number }
|
||||||
|
totals: { total_pence: number; projected_consumption: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UtilitiesReport {
|
||||||
|
costs: UtilityCosts | null
|
||||||
|
estimate: UtilityEstimate | null
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface WeeklyActualData {
|
export interface WeeklyActualData {
|
||||||
week_ending: string
|
week_ending: string
|
||||||
week_start: string
|
week_start: string
|
||||||
|
|
@ -139,6 +183,7 @@ export interface WeeklyActualData {
|
||||||
month_daily: MonthDailyEntry[]
|
month_daily: MonthDailyEntry[]
|
||||||
monthly_trend: MonthTrend[]
|
monthly_trend: MonthTrend[]
|
||||||
monthly_split: MonthSplitEntry[]
|
monthly_split: MonthSplitEntry[]
|
||||||
|
utilities: UtilitiesReport
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Directors Forecast types ────────────────────────────────────────────────
|
// ── Directors Forecast types ────────────────────────────────────────────────
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue