A segment only existed once it had been loaded -- a pf.log entry with forecast rows behind it -- so there was nowhere to hold "this is the cut I want, but the dates need changing first", and rebuilding a scenario meant retyping every segment by hand. pf.load_plan is the definition on its own, with run_logid naming the log entry it produced once it has been run. Not in pf.log deliberately: every row there means a write happened, and unexecuted intent would cost that table its meaning. The FK is ON DELETE SET NULL, so undoing a load returns its plan to unrun rather than taking the definition with it. POST /sources/:id/versions now takes clone_from: settings, version-scoped layouts, and the segments as plans, running none of them. Plans are read from the template's own pf.load_plan rows, or derived from its baseline/reference log entries when it has none -- every version built before this -- since params already stores the filters and the offset. Adjustments are not copied in any form. A scale's target was sized against the rows that were there. Shift years exists because cloning a definition forward a year is the reason to clone one, and doing it by hand across eight segments is where the mistakes come from. Filter values only, and only a bare 20xx: a month reads "04 - Sep" and a part number that looks like a year must not move. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
185 lines
7.9 KiB
JavaScript
185 lines
7.9 KiB
JavaScript
const express = require('express');
|
|
const { sessionUser } = require('../lib/auth');
|
|
|
|
// Planned load segments — a version's definition, before it has been run.
|
|
//
|
|
// A plan carries exactly what POST /versions/:id/{baseline|reference} takes, so
|
|
// running one is that same call with the plan's fields; the client then stamps
|
|
// run_logid. It is two round trips rather than one because the alternative is a
|
|
// second copy of the load route's token substitution, and a plan that reads
|
|
// unrun while its rows exist is visible and fixable, where a divergent copy of
|
|
// the load SQL is neither.
|
|
module.exports = function(pool) {
|
|
const router = express.Router();
|
|
|
|
const FIELDS = ['seq', 'kind', 'label', 'bucket', 'tag', 'note',
|
|
'filters', 'raw_where', 'date_offset'];
|
|
|
|
// A plan whose log entry has been undone is a plan again: the FK is
|
|
// ON DELETE SET NULL, so run_logid clears itself. The join is for the
|
|
// row count, which is what makes "already run" worth seeing.
|
|
const SELECT_PLANS = `
|
|
SELECT
|
|
p.*
|
|
,l.row_count AS run_rows
|
|
,l.stamp AS run_at
|
|
FROM pf.load_plan p
|
|
LEFT JOIN pf.log l
|
|
ON l.id = p.run_logid
|
|
WHERE TRUE
|
|
AND p.version_id = $1
|
|
ORDER BY
|
|
p.seq
|
|
,p.id`;
|
|
|
|
router.get('/versions/:id/plans', async (req, res) => {
|
|
try {
|
|
const { rows } = await pool.query(SELECT_PLANS, [parseInt(req.params.id)]);
|
|
res.json(rows);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.post('/versions/:id/plans', async (req, res) => {
|
|
const versionId = parseInt(req.params.id);
|
|
const b = req.body || {};
|
|
if (!['baseline', 'reference'].includes(b.kind)) {
|
|
return res.status(400).json({ error: 'kind must be baseline or reference' });
|
|
}
|
|
try {
|
|
// Append by default: a new plan runs after the ones already there.
|
|
const next = await pool.query(
|
|
`SELECT COALESCE(max(seq), -1) + 1 AS seq FROM pf.load_plan WHERE version_id = $1`,
|
|
[versionId]
|
|
);
|
|
const { rows } = await pool.query(
|
|
`INSERT INTO pf.load_plan
|
|
(version_id, seq, kind, label, bucket, tag, note, filters, raw_where,
|
|
date_offset, created_by)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, COALESCE($10, '0 days'), $11)
|
|
RETURNING *`,
|
|
[versionId, b.seq ?? next.rows[0].seq, b.kind,
|
|
b.label || null, b.bucket || null, b.tag || null, b.note || null,
|
|
b.filters ? JSON.stringify(b.filters) : null, b.raw_where || null,
|
|
b.date_offset || null, sessionUser(req)]
|
|
);
|
|
res.status(201).json(rows[0]);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Any subset of the fields, plus run_logid when a run has just happened.
|
|
// The flag-and-value pair rather than COALESCE on the value: clearing a
|
|
// label to blank has to be distinguishable from not mentioning it.
|
|
router.patch('/plans/:pid', async (req, res) => {
|
|
const pid = parseInt(req.params.pid);
|
|
const b = req.body || {};
|
|
const keys = [...FIELDS, 'run_logid'].filter(k => b[k] !== undefined);
|
|
if (!keys.length) return res.status(400).json({ error: 'Nothing to update' });
|
|
if (b.kind !== undefined && !['baseline', 'reference'].includes(b.kind)) {
|
|
return res.status(400).json({ error: 'kind must be baseline or reference' });
|
|
}
|
|
try {
|
|
const sets = [];
|
|
const params = [pid];
|
|
for (const k of keys) {
|
|
let v = k === 'filters'
|
|
? (b[k] === null ? null : JSON.stringify(b[k]))
|
|
: (typeof b[k] === 'string' ? (b[k].trim() || null) : b[k]);
|
|
// date_offset is NOT NULL, so a blank one means the default.
|
|
// Coerced here rather than in the statement: a second assignment
|
|
// to the same column is an error, not a fallback.
|
|
if (k === 'date_offset' && !v) v = '0 days';
|
|
params.push(v);
|
|
sets.push(`${k} = $${params.length}${k === 'filters' ? '::jsonb' : ''}`);
|
|
}
|
|
const { rows } = await pool.query(
|
|
`UPDATE pf.load_plan
|
|
SET ${sets.join(', ')}, updated_at = now()
|
|
WHERE id = $1
|
|
RETURNING *`,
|
|
params
|
|
);
|
|
if (!rows.length) return res.status(404).json({ error: 'Plan not found' });
|
|
res.json(rows[0]);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.delete('/plans/:pid', async (req, res) => {
|
|
try {
|
|
const { rows } = await pool.query(
|
|
`DELETE FROM pf.load_plan WHERE id = $1 RETURNING id`, [parseInt(req.params.pid)]
|
|
);
|
|
if (!rows.length) return res.status(404).json({ error: 'Plan not found' });
|
|
res.json({ deleted: rows[0].id });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Shift every unrun plan's date_offset and any four-digit year in its filter
|
|
// values by a whole number of years. Cloning a definition forward is the
|
|
// reason plans exist, and doing it by hand across eight segments is where
|
|
// the mistakes come from.
|
|
//
|
|
// Years in filter *values* only, and only a bare four-digit 20xx: a season
|
|
// reads "2027", a month reads "04 - Sep", and a part number that happens to
|
|
// look like a year would be corrupted by anything looser.
|
|
router.post('/versions/:id/plans/shift-years', async (req, res) => {
|
|
const versionId = parseInt(req.params.id);
|
|
const years = parseInt(req.body?.years);
|
|
if (!Number.isInteger(years) || years === 0) {
|
|
return res.status(400).json({ error: 'years must be a non-zero whole number' });
|
|
}
|
|
const bumpYear = (v) => (typeof v === 'string' && /^20\d{2}$/.test(v.trim()))
|
|
? String(parseInt(v, 10) + years)
|
|
: v;
|
|
const bumpOffset = (off) => {
|
|
// '0 days' stays put: an offset of nothing is not a date to shift.
|
|
const m = /^\s*(-?\d+)\s+year/.exec(off || '');
|
|
if (!m) return off;
|
|
return `${parseInt(m[1], 10) + years} years`;
|
|
};
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
const { rows } = await client.query(
|
|
`SELECT * FROM pf.load_plan
|
|
WHERE version_id = $1 AND run_logid IS NULL
|
|
ORDER BY seq, id`,
|
|
[versionId]
|
|
);
|
|
const out = [];
|
|
for (const p of rows) {
|
|
const filters = Array.isArray(p.filters)
|
|
? p.filters.map(group => group.map(f => ({
|
|
...f,
|
|
values: Array.isArray(f.values) ? f.values.map(bumpYear) : f.values
|
|
})))
|
|
: p.filters;
|
|
const upd = await client.query(
|
|
`UPDATE pf.load_plan
|
|
SET filters = $2::jsonb, date_offset = $3, updated_at = now()
|
|
WHERE id = $1
|
|
RETURNING *`,
|
|
[p.id, filters ? JSON.stringify(filters) : null, bumpOffset(p.date_offset)]
|
|
);
|
|
out.push(upd.rows[0]);
|
|
}
|
|
await client.query('COMMIT');
|
|
res.json({ shifted: out.length, plans: out });
|
|
} catch (err) {
|
|
await client.query('ROLLBACK').catch(() => {});
|
|
res.status(500).json({ error: err.message });
|
|
} finally {
|
|
client.release();
|
|
}
|
|
});
|
|
|
|
return router;
|
|
};
|