Let a segment say what it counts toward, and pivot on it
There was no way to express "these segments together are the forecast". pf_iter cannot say it: it answers whether operations may write to a row, and Open Orders is loaded as reference precisely so nothing adjusts it while still being part of the forecast number. The two questions are independent, so one cannot be derived from the other. pf.log.bucket is the second axis. Free text with suggestions -- Forecast, Prior Year, Prior Prior Year, Plan -- rather than an enum, so another banner needs no migration. Blank by default, falling back in the pivot to the segment's own name, so nothing changes until something is labelled. /data and /agg emit it as pf_bucket beside pf_segment, through the pf.log join that is already there. Set it per segment in the Baseline list, which is where loads live now that the change log only shows adjustments. Needs 01_schema.sql for the column and Generate SQL for /agg to select it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
15d9ecf319
commit
0049a391c0
@ -151,6 +151,10 @@ SELECT
|
||||
,CASE WHEN l.operation IN ('baseline','reference')
|
||||
THEN COALESCE(NULLIF(l.tag, ''), NULLIF(l.note, ''), '(unlabeled load)')
|
||||
ELSE '(adjustment)' END AS pf_segment
|
||||
,COALESCE(NULLIF(l.bucket, ''),
|
||||
CASE WHEN l.operation IN ('baseline','reference')
|
||||
THEN COALESCE(NULLIF(l.tag, ''), NULLIF(l.note, ''), '(unlabeled load)')
|
||||
ELSE '(adjustment)' END) AS pf_bucket
|
||||
,CASE WHEN l.operation IN ('baseline','reference')
|
||||
THEN NULL
|
||||
ELSE COALESCE(NULLIF(l.tag, ''), NULLIF(l.note, '')) END AS pf_note
|
||||
@ -162,7 +166,8 @@ GROUP BY
|
||||
${grain.groupCols('t.').join('\n ,')}
|
||||
,l.operation
|
||||
,l.tag
|
||||
,l.note`.trim();
|
||||
,l.note
|
||||
,l.bucket`.trim();
|
||||
}
|
||||
|
||||
// grain columns + pf_gkey + summed measures, in the leading-comma style the
|
||||
|
||||
@ -131,9 +131,9 @@ module.exports = function(pool) {
|
||||
// a closed version, where relabelling history is still legitimate.
|
||||
router.patch('/log/:logid', async (req, res) => {
|
||||
const logId = parseInt(req.params.logid);
|
||||
const { note, tag } = req.body;
|
||||
if (note === undefined && tag === undefined) {
|
||||
return res.status(400).json({ error: 'Nothing to update — send note and/or tag' });
|
||||
const { note, tag, bucket } = req.body;
|
||||
if (note === undefined && tag === undefined && bucket === undefined) {
|
||||
return res.status(400).json({ error: 'Nothing to update — send note, tag and/or bucket' });
|
||||
}
|
||||
try {
|
||||
// COALESCE on the flag, not the value: an explicit null or '' must be
|
||||
@ -141,12 +141,14 @@ module.exports = function(pool) {
|
||||
const result = await pool.query(
|
||||
`UPDATE pf.log SET
|
||||
note = CASE WHEN $2::bool THEN $3::text ELSE note END,
|
||||
tag = CASE WHEN $4::bool THEN $5::text ELSE tag END
|
||||
tag = CASE WHEN $4::bool THEN $5::text ELSE tag END,
|
||||
bucket = CASE WHEN $6::bool THEN $7::text ELSE bucket END
|
||||
WHERE id = $1 RETURNING *`,
|
||||
[
|
||||
logId,
|
||||
note !== undefined, note === undefined ? null : (String(note).trim() || null),
|
||||
tag !== undefined, tag === undefined ? null : (String(tag).trim() || null),
|
||||
bucket !== undefined, bucket === undefined ? null : (String(bucket).trim() || null),
|
||||
]
|
||||
);
|
||||
if (!result.rows.length) return res.status(404).json({ error: 'Log entry not found' });
|
||||
|
||||
@ -319,6 +319,12 @@ module.exports = function(pool) {
|
||||
,CASE WHEN l.operation IN ('baseline','reference')
|
||||
THEN COALESCE(NULLIF(l.tag, ''), NULLIF(l.note, ''), '(unlabeled load)')
|
||||
ELSE '(adjustment)' END AS pf_segment
|
||||
-- what the segment counts towards, falling back to the segment
|
||||
-- itself so an unlabelled one still reads as something
|
||||
,COALESCE(NULLIF(l.bucket, ''),
|
||||
CASE WHEN l.operation IN ('baseline','reference')
|
||||
THEN COALESCE(NULLIF(l.tag, ''), NULLIF(l.note, ''), '(unlabeled load)')
|
||||
ELSE '(adjustment)' END) AS pf_bucket
|
||||
,CASE WHEN l.operation IN ('baseline','reference')
|
||||
THEN NULL
|
||||
ELSE COALESCE(NULLIF(l.tag, ''), NULLIF(l.note, '')) END AS pf_note
|
||||
@ -637,7 +643,7 @@ module.exports = function(pool) {
|
||||
await client.query('COMMIT');
|
||||
committed = true;
|
||||
const opLabel = (req.body.tag || '').trim() || note || null;
|
||||
const rows = allRows.map(r => ({ ...r, pf_segment: '(adjustment)', pf_note: opLabel, pf_op: 'scale' }));
|
||||
const rows = allRows.map(r => ({ ...r, pf_segment: '(adjustment)', pf_bucket: '(adjustment)', pf_note: opLabel, pf_op: 'scale' }));
|
||||
res.json({
|
||||
rows,
|
||||
rows_affected: rows.length,
|
||||
@ -697,7 +703,7 @@ module.exports = function(pool) {
|
||||
await client.query('COMMIT');
|
||||
committed = true;
|
||||
const opLabel = (req.body.tag || '').trim() || note || null;
|
||||
const rows = allRows.map(r => ({ ...r, pf_segment: '(adjustment)', pf_note: opLabel, pf_op: 'recode' }));
|
||||
const rows = allRows.map(r => ({ ...r, pf_segment: '(adjustment)', pf_bucket: '(adjustment)', pf_note: opLabel, pf_op: 'recode' }));
|
||||
res.json({ rows, rows_affected: rows.length, slices_applied: units.length });
|
||||
} finally {
|
||||
if (!committed) try { await client.query('ROLLBACK'); } catch {}
|
||||
@ -754,7 +760,7 @@ module.exports = function(pool) {
|
||||
await client.query('COMMIT');
|
||||
committed = true;
|
||||
const opLabel = (req.body.tag || '').trim() || note || null;
|
||||
const rows = allRows.map(r => ({ ...r, pf_segment: '(adjustment)', pf_note: opLabel, pf_op: 'clone' }));
|
||||
const rows = allRows.map(r => ({ ...r, pf_segment: '(adjustment)', pf_bucket: '(adjustment)', pf_note: opLabel, pf_op: 'clone' }));
|
||||
res.json({ rows, rows_affected: rows.length, slices_applied: units.length });
|
||||
} finally {
|
||||
if (!committed) try { await client.query('ROLLBACK'); } catch {}
|
||||
|
||||
@ -81,6 +81,19 @@ WHERE TRUE
|
||||
AND note <> ''
|
||||
AND operation IN ('baseline', 'reference');
|
||||
|
||||
-- What a segment contributes to, independent of pf_iter.
|
||||
--
|
||||
-- pf_iter answers "can operations write to these rows"; bucket answers "does this
|
||||
-- belong in the forecast number". Those are not the same question -- Open Orders is
|
||||
-- loaded as reference so nothing adjusts it, yet it is part of the forecast -- so
|
||||
-- neither can be derived from the other.
|
||||
--
|
||||
-- Free text with suggested values (Forecast / Prior Year / Prior Prior Year / Plan)
|
||||
-- rather than an enum, so a new banner does not need a migration. Blank by default:
|
||||
-- until a segment is labelled, the pivot falls back to showing its own name.
|
||||
ALTER TABLE pf.log ADD COLUMN IF NOT EXISTS bucket text;
|
||||
CREATE INDEX IF NOT EXISTS log_bucket_idx ON pf.log (bucket) WHERE bucket IS NOT NULL;
|
||||
|
||||
-- Master data for a dim_group: one row per key value, with its sibling columns.
|
||||
--
|
||||
-- The source is transactional and often a view over all history, so deriving a
|
||||
|
||||
@ -113,6 +113,28 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
loadLog()
|
||||
}, [versionId])
|
||||
|
||||
// A segment's banner: what it counts toward, independent of pf_iter. Held
|
||||
// locally while typing so the field does not fight the fetched value, and
|
||||
// written on blur.
|
||||
const [buckets, setBuckets] = useState({})
|
||||
|
||||
async function saveBucket(entry, value) {
|
||||
const next = value.trim()
|
||||
if (next === (entry.bucket || '')) return
|
||||
try {
|
||||
const res = await fetch(`/api/log/${entry.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bucket: next }),
|
||||
})
|
||||
if (!res.ok) { const d = await res.json(); flash(d.error, 'error'); return }
|
||||
loadLog()
|
||||
flash(next ? `Counts toward ${next} — reload the Forecast view to see it` : 'Banner cleared')
|
||||
} catch (err) {
|
||||
flash(err.message, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function loadLog() {
|
||||
fetch(`/api/versions/${versionId}/log`).then(r => r.json()).then(data => {
|
||||
setLog(data.filter(e => e.operation === 'baseline' || e.operation === 'reference'))
|
||||
@ -335,11 +357,18 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
<button onClick={clearBaseline} className="text-red-400 hover:text-red-600 text-xs normal-case font-normal">Clear all baseline</button>
|
||||
</div>
|
||||
<table className="w-full text-xs">
|
||||
<datalist id="pf-bucket-options">
|
||||
<option value="Forecast" />
|
||||
<option value="Prior Year" />
|
||||
<option value="Prior Prior Year" />
|
||||
<option value="Plan" />
|
||||
</datalist>
|
||||
<thead className="bg-gray-50">
|
||||
<tr className="text-left text-gray-400 border-b border-gray-100">
|
||||
<th className="px-3 py-1.5 font-medium w-6"></th>
|
||||
<th className="px-3 py-1.5 font-medium">#</th>
|
||||
<th className="px-3 py-1.5 font-medium">note</th>
|
||||
<th className="px-3 py-1.5 font-medium w-36">counts toward</th>
|
||||
<th className="px-3 py-1.5 font-medium text-right">rows</th>
|
||||
<th className="px-3 py-1.5 font-medium text-right">{log[0]?.value_col || 'value'}</th>
|
||||
<th className="px-3 py-1.5 font-medium">by</th>
|
||||
@ -349,11 +378,11 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
</thead>
|
||||
<tbody>
|
||||
{log.length === 0 && (
|
||||
<tr><td colSpan={8} className="px-3 py-3 text-gray-300 italic">No segments loaded yet</td></tr>
|
||||
<tr><td colSpan={9} className="px-3 py-3 text-gray-300 italic">No segments loaded yet</td></tr>
|
||||
)}
|
||||
{!showAddForm && !editingLogId && (
|
||||
<tr className="border-t border-gray-100">
|
||||
<td colSpan={8} className="p-0">
|
||||
<td colSpan={9} className="p-0">
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="w-full px-3 py-2 text-xs text-blue-600 hover:bg-blue-50 text-left font-medium"
|
||||
@ -381,6 +410,16 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
</span>
|
||||
{entry.note || <span className="text-gray-300">—</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2" onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
value={buckets[entry.id] ?? entry.bucket ?? ''}
|
||||
list="pf-bucket-options"
|
||||
onChange={e => setBuckets(b => ({ ...b, [entry.id]: e.target.value }))}
|
||||
onBlur={e => saveBucket(entry, e.target.value)}
|
||||
placeholder="—"
|
||||
className="w-full border border-transparent hover:border-gray-200 focus:border-blue-400
|
||||
rounded px-1 py-0.5 text-xs focus:outline-none bg-transparent" />
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-gray-700 font-mono">
|
||||
{entry.row_count != null ? entry.row_count.toLocaleString() : '—'}
|
||||
</td>
|
||||
@ -398,7 +437,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
|
||||
</tr>
|
||||
{isOpen && (
|
||||
<tr key={`${entry.id}-detail`} className="bg-blue-50 border-t border-blue-100">
|
||||
<td colSpan={6} className="px-2 py-2">
|
||||
<td colSpan={7} className="px-2 py-2">
|
||||
<div className="bg-white border border-gray-200 rounded">
|
||||
<SegmentForm mode="view" {...view} filterCols={filterCols} />
|
||||
</div>
|
||||
|
||||
@ -763,11 +763,11 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
||||
? [
|
||||
...grainMeta.map(c => c.cname),
|
||||
...meta.filter(c => ['value','units'].includes(c.role)).map(c => c.cname),
|
||||
'pf_gkey', 'pf_iter', 'pf_logid', 'pf_segment', 'pf_note', 'pf_op',
|
||||
'pf_gkey', 'pf_iter', 'pf_logid', 'pf_segment', 'pf_bucket', 'pf_note', 'pf_op',
|
||||
]
|
||||
: [
|
||||
...meta.filter(c => ['dimension','value','units','date'].includes(c.role)).map(c => c.cname),
|
||||
'pf_id', 'pf_iter', 'pf_logid', 'pf_user', 'created_at', 'pf_segment', 'pf_note', 'pf_op',
|
||||
'pf_id', 'pf_iter', 'pf_logid', 'pf_user', 'created_at', 'pf_segment', 'pf_bucket', 'pf_note', 'pf_op',
|
||||
])
|
||||
const tableName = `fc_${vid}`
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user