Fix TOU rate-window mismatch and enforce fallback-split validation
Rate windows were matched by substring (label.includes(key)), so overlapping labels like "Day"/"Weekday" could silently cross-match to the wrong rate. Switched to an exact match. The "split must total 100%" check was advisory-only in the UI and never validated server-side, letting a tariff save with a split that doesn't sum to 100% and permanently mis-cost that meter's usage. Enforced on both the API (POST/PATCH /api/tariffs) and the Save button. Also aligned the app's portal category to 'Hotel' (already correct in auth/src/db.js) here and in stack-init/install-stack.sh, which had both drifted to 'Operations'. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
7ab048931f
commit
f1ce0f06d8
4 changed files with 38 additions and 3 deletions
|
|
@ -162,7 +162,7 @@ export function computeCost({ tariff, windows, consumption, daysInPeriod }) {
|
|||
if (tariff.is_time_of_use && windows.length > 1 && tariff.fallback_split_pct && Object.keys(tariff.fallback_split_pct).length) {
|
||||
split = {}
|
||||
for (const [key, pct] of Object.entries(tariff.fallback_split_pct)) {
|
||||
const win = windows.find(w => w.label.toLowerCase().includes(key.toLowerCase()))
|
||||
const win = windows.find(w => w.label.toLowerCase() === key.toLowerCase())
|
||||
if (!win) continue
|
||||
const share = consumption * (Number(pct) / 100)
|
||||
const cost = share * Number(win.unit_rate_pence_per_unit)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,16 @@
|
|||
import { requireAuth, requireCap } from '../auth.js'
|
||||
import { pool } from '../db.js'
|
||||
|
||||
// Mirrors computeCost()'s condition in cost-calc.js — the split is only actually
|
||||
// applied (and only actually needs to sum to 100) once there's more than one
|
||||
// window to divide consumption across.
|
||||
function splitPctError(isTimeOfUse, fallbackSplitPct, windowCount) {
|
||||
if (!isTimeOfUse || windowCount <= 1 || !fallbackSplitPct || !Object.keys(fallbackSplitPct).length) return null
|
||||
const total = Object.values(fallbackSplitPct).reduce((s, v) => s + Number(v), 0)
|
||||
if (Math.round(total) !== 100) return `Fallback split must total 100% (currently ${total}%)`
|
||||
return null
|
||||
}
|
||||
|
||||
export async function tariffRoutes(app) {
|
||||
app.addHook('preHandler', requireAuth)
|
||||
|
||||
|
|
@ -45,6 +55,8 @@ export async function tariffRoutes(app) {
|
|||
if (!Array.isArray(b.windows) || b.windows.length === 0) {
|
||||
return reply.status(400).send({ error: 'At least one rate window is required' })
|
||||
}
|
||||
const splitErr = splitPctError(b.is_time_of_use === true, b.fallback_split_pct, b.windows.length)
|
||||
if (splitErr) return reply.status(400).send({ error: splitErr })
|
||||
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
|
|
@ -84,6 +96,19 @@ export async function tariffRoutes(app) {
|
|||
if (!existing.length) return reply.status(404).send({ error: 'Tariff not found' })
|
||||
const t = existing[0]
|
||||
const b = req.body || {}
|
||||
|
||||
const isTimeOfUse = b.is_time_of_use ?? t.is_time_of_use
|
||||
const fallbackSplitPct = b.fallback_split_pct ?? t.fallback_split_pct
|
||||
let windowCount
|
||||
if (Array.isArray(b.windows)) {
|
||||
windowCount = b.windows.length
|
||||
} else {
|
||||
const { rows: wc } = await pool.query('SELECT COUNT(*)::int AS n FROM tariff_rate_windows WHERE tariff_id = $1', [req.params.id])
|
||||
windowCount = wc[0].n
|
||||
}
|
||||
const splitErr = splitPctError(isTimeOfUse, fallbackSplitPct, windowCount)
|
||||
if (splitErr) return reply.status(400).send({ error: splitErr })
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE tariffs SET name = $1, supplier = $2, effective_from = $3, effective_to = $4,
|
||||
standing_charge_pence_per_day = $5, ccl_rate_pence_per_unit = $6, ccl_exempt = $7,
|
||||
|
|
|
|||
|
|
@ -93,6 +93,10 @@ export default function Tariffs() {
|
|||
async function save() {
|
||||
if (!form) return
|
||||
if (!form.name.trim() || !form.category_id) { setError('Name and category are required'); return }
|
||||
if (form.is_time_of_use && form.windows.length > 1) {
|
||||
const total = form.windows.reduce((s, w) => s + (Number(w.split_pct) || 0), 0)
|
||||
if (Math.round(total) !== 100) { setError(`Fallback split must total 100% (currently ${total}%)`); return }
|
||||
}
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
|
|
@ -271,7 +275,13 @@ export default function Tariffs() {
|
|||
|
||||
<div className="modal-actions">
|
||||
<button className="btn" onClick={() => setForm(null)}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={save}
|
||||
disabled={saving || (form.is_time_of_use && form.windows.length > 1 && Math.round(splitTotal) !== 100)}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const pool = new Pool({ connectionString: process.env.DATABASE_URL })
|
|||
|
||||
await pool.query(`
|
||||
INSERT INTO apps (slug, name, description, base_path, icon, theme_color, category, internal_host, internal_port)
|
||||
VALUES ('utilities', 'Utilities', 'Meter readings, tariffs and energy cost tracking', '/utilities', 'Zap', '#1e6091', 'Operations', '10.10.10.127', 3080)
|
||||
VALUES ('utilities', 'Utilities', 'Meter readings, tariffs and energy cost tracking', '/utilities', 'Zap', '#1e6091', 'Hotel', '10.10.10.127', 3080)
|
||||
ON CONFLICT (slug) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
description = EXCLUDED.description,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue