Initial commit — utilities app (meter readings, tariffs, cost tracking)
Fastify + pg backend, React/TS/Vite frontend. Categories (electric, gas, oil, water), meters with sub-metering rollup, tariffs with time-of-use rate windows, standing charges, Climate Change Levy and VAT, manual reading entry, consumption/cost reports, period cost estimates, and an API-key-gated /api/internal/* surface for the reports app's Directors Report. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
4fc5230d79
44 changed files with 10249 additions and 0 deletions
175
backend/src/routes/tariffs.js
Normal file
175
backend/src/routes/tariffs.js
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import { requireAuth, requireCap } from '../auth.js'
|
||||
import { pool } from '../db.js'
|
||||
|
||||
export async function tariffRoutes(app) {
|
||||
app.addHook('preHandler', requireAuth)
|
||||
|
||||
// GET /api/tariffs — reference data needed by meters/readings/reports pages too
|
||||
app.get('/api/tariffs', async (req) => {
|
||||
const { category_id } = req.query
|
||||
const params = []
|
||||
let where = ''
|
||||
if (category_id) { params.push(category_id); where = 'WHERE t.category_id = $1' }
|
||||
const { rows } = await pool.query(
|
||||
`SELECT t.*, c.name AS category_name,
|
||||
(SELECT COUNT(*)::int FROM tariff_rate_windows w WHERE w.tariff_id = t.id) AS window_count
|
||||
FROM tariffs t JOIN meter_categories c ON c.id = t.category_id
|
||||
${where}
|
||||
ORDER BY t.effective_from DESC`,
|
||||
params
|
||||
)
|
||||
return rows
|
||||
})
|
||||
|
||||
app.get('/api/tariffs/:id', async (req, reply) => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT t.*, c.name AS category_name FROM tariffs t
|
||||
JOIN meter_categories c ON c.id = t.category_id WHERE t.id = $1`,
|
||||
[req.params.id]
|
||||
)
|
||||
if (!rows.length) return reply.status(404).send({ error: 'Tariff not found' })
|
||||
const { rows: windows } = await pool.query(
|
||||
'SELECT * FROM tariff_rate_windows WHERE tariff_id = $1 ORDER BY sort_order, id',
|
||||
[req.params.id]
|
||||
)
|
||||
return { ...rows[0], windows }
|
||||
})
|
||||
|
||||
// POST /api/tariffs — create tariff + its rate windows in one call.
|
||||
// Non-TOU tariff: send a single window (label 'Standard') covering all hours.
|
||||
app.post('/api/tariffs', { preHandler: requireCap('tariffs') }, async (req, reply) => {
|
||||
const b = req.body || {}
|
||||
if (!b.category_id || !b.name || !b.effective_from) {
|
||||
return reply.status(400).send({ error: 'category_id, name and effective_from required' })
|
||||
}
|
||||
if (!Array.isArray(b.windows) || b.windows.length === 0) {
|
||||
return reply.status(400).send({ error: 'At least one rate window is required' })
|
||||
}
|
||||
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
const { rows } = await client.query(
|
||||
`INSERT INTO tariffs (category_id, name, supplier, effective_from, effective_to,
|
||||
standing_charge_pence_per_day, ccl_rate_pence_per_unit, ccl_exempt,
|
||||
vat_rate_pct, is_time_of_use, fallback_split_pct)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING *`,
|
||||
[
|
||||
b.category_id, b.name, b.supplier || null, b.effective_from, b.effective_to || null,
|
||||
b.standing_charge_pence_per_day || 0, b.ccl_rate_pence_per_unit || null, b.ccl_exempt === true,
|
||||
b.vat_rate_pct ?? 20, b.is_time_of_use === true, JSON.stringify(b.fallback_split_pct || {}),
|
||||
]
|
||||
)
|
||||
const tariff = rows[0]
|
||||
|
||||
for (const [i, w] of b.windows.entries()) {
|
||||
await client.query(
|
||||
`INSERT INTO tariff_rate_windows (tariff_id, label, start_time, end_time, days_of_week, unit_rate_pence_per_unit, sort_order)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||
[tariff.id, w.label, w.start_time || null, w.end_time || null, w.days_of_week || null, w.unit_rate_pence_per_unit, w.sort_order ?? i]
|
||||
)
|
||||
}
|
||||
await client.query('COMMIT')
|
||||
return tariff
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK')
|
||||
throw err
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
})
|
||||
|
||||
app.patch('/api/tariffs/:id', { preHandler: requireCap('tariffs') }, async (req, reply) => {
|
||||
const { rows: existing } = await pool.query('SELECT * FROM tariffs WHERE id = $1', [req.params.id])
|
||||
if (!existing.length) return reply.status(404).send({ error: 'Tariff not found' })
|
||||
const t = existing[0]
|
||||
const b = req.body || {}
|
||||
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,
|
||||
vat_rate_pct = $8, is_time_of_use = $9, fallback_split_pct = $10
|
||||
WHERE id = $11 RETURNING *`,
|
||||
[
|
||||
b.name ?? t.name,
|
||||
b.supplier !== undefined ? b.supplier : t.supplier,
|
||||
b.effective_from ?? t.effective_from,
|
||||
b.effective_to !== undefined ? b.effective_to : t.effective_to,
|
||||
b.standing_charge_pence_per_day ?? t.standing_charge_pence_per_day,
|
||||
b.ccl_rate_pence_per_unit !== undefined ? b.ccl_rate_pence_per_unit : t.ccl_rate_pence_per_unit,
|
||||
b.ccl_exempt ?? t.ccl_exempt,
|
||||
b.vat_rate_pct ?? t.vat_rate_pct,
|
||||
b.is_time_of_use ?? t.is_time_of_use,
|
||||
JSON.stringify(b.fallback_split_pct ?? t.fallback_split_pct),
|
||||
req.params.id,
|
||||
]
|
||||
)
|
||||
return rows[0]
|
||||
})
|
||||
|
||||
// PUT /api/tariffs/:id/windows — replace the whole rate-window set (simpler
|
||||
// than per-window CRUD for a "rate window editor" saved as one form)
|
||||
app.put('/api/tariffs/:id/windows', { preHandler: requireCap('tariffs') }, async (req, reply) => {
|
||||
const { rows: existing } = await pool.query('SELECT id FROM tariffs WHERE id = $1', [req.params.id])
|
||||
if (!existing.length) return reply.status(404).send({ error: 'Tariff not found' })
|
||||
const windows = req.body?.windows
|
||||
if (!Array.isArray(windows) || windows.length === 0) {
|
||||
return reply.status(400).send({ error: 'At least one rate window is required' })
|
||||
}
|
||||
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
await client.query('DELETE FROM tariff_rate_windows WHERE tariff_id = $1', [req.params.id])
|
||||
for (const [i, w] of windows.entries()) {
|
||||
await client.query(
|
||||
`INSERT INTO tariff_rate_windows (tariff_id, label, start_time, end_time, days_of_week, unit_rate_pence_per_unit, sort_order)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||
[req.params.id, w.label, w.start_time || null, w.end_time || null, w.days_of_week || null, w.unit_rate_pence_per_unit, w.sort_order ?? i]
|
||||
)
|
||||
}
|
||||
await client.query('COMMIT')
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK')
|
||||
throw err
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
'SELECT * FROM tariff_rate_windows WHERE tariff_id = $1 ORDER BY sort_order, id',
|
||||
[req.params.id]
|
||||
)
|
||||
return rows
|
||||
})
|
||||
|
||||
// POST /api/meters/:id/assign-tariff — close the current open meter_tariffs
|
||||
// row (if any) and open a new one from effective_from
|
||||
app.post('/api/meters/:id/assign-tariff', { preHandler: requireCap('tariffs') }, async (req, reply) => {
|
||||
const { tariff_id, effective_from } = req.body || {}
|
||||
if (!tariff_id || !effective_from) return reply.status(400).send({ error: 'tariff_id and effective_from required' })
|
||||
|
||||
const { rows: meter } = await pool.query('SELECT id FROM meters WHERE id = $1', [req.params.id])
|
||||
if (!meter.length) return reply.status(404).send({ error: 'Meter not found' })
|
||||
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
await client.query(
|
||||
`UPDATE meter_tariffs SET effective_to = ($1::date - INTERVAL '1 day')
|
||||
WHERE meter_id = $2 AND effective_to IS NULL`,
|
||||
[effective_from, req.params.id]
|
||||
)
|
||||
const { rows } = await client.query(
|
||||
`INSERT INTO meter_tariffs (meter_id, tariff_id, effective_from) VALUES ($1,$2,$3) RETURNING *`,
|
||||
[req.params.id, tariff_id, effective_from]
|
||||
)
|
||||
await client.query('COMMIT')
|
||||
return rows[0]
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK')
|
||||
throw err
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue