Clone from a named segment, shifted, with period dimensions re-derived

Three changes, one feature: a period with no baseline borrows a shape from
prior year or plan.

The generator handled one date group. sql_generator.js used find(), so a
source with order, requested and ship date groups derived the order one and
copied the other two raw -- shifted dates against unshifted period labels.
Every group now gets its own join, and they are LEFT rather than inner: an
order that has not shipped has no ship date, and an inner join would have
dropped the row from the load entirely rather than leaving its period
columns empty. dateGroupsOf() is now shared, like grainOf, so the routes and
the generator agree on membership and on join aliases.

Clone carries every date column rather than only the primary one, since it
is the operation that moves rows through time, and re-derives the period
dimensions from pf.dim_period against the shifted date instead of copying
them from the row being cloned.

from_logid names the segment to copy from, replacing the exclude clause for
that one entry rather than widening it. Rows written stay pf_iter = 'clone',
so scale applies to them afterwards as a second step.

set is no longer required: copying a segment forward unchanged is a
reasonable thing to ask for.

Needs dim_period_col set in Setup (fisc_year on oseas/rseas/sseas_e,
fisc_month_abbr on omon/rmon/smon_e) and Generate SQL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-17 01:21:29 -04:00
parent 2689e95b2c
commit 518a0ca5ba
4 changed files with 191 additions and 32 deletions

View File

@ -51,6 +51,42 @@ function grainOf(colMeta) {
return { cols, key, groupCols }; return { cols, key, groupCols };
} }
// Date columns that anchor a dim_group, each with the dimensions derived from
// pf.dim_period for it. Shared so the generator and the routes agree on both the
// membership and the join aliases -- the same reason grainOf is a single function.
function dateGroupsOf(colMeta) {
return colMeta
.filter(c => c.role === 'date' && c.is_key && c.dim_group)
.map((keyCol, i) => ({
alias: `dp${i + 1}`,
dateCol: keyCol.cname,
group: keyCol.dim_group,
derived: colMeta
.filter(c => c.role === 'dimension'
&& c.dim_group === keyCol.dim_group
&& c.dim_period_col)
.map(c => ({ cname: c.cname, periodCol: c.dim_period_col })),
}))
.filter(g => g.derived.length > 0);
}
// cname -> which join it comes from and which of its columns
function dimPeriodMapOf(dateGroups) {
return new Map(
dateGroups.flatMap(g => g.derived.map(d => [d.cname, { alias: g.alias, periodCol: d.periodCol }]))
);
}
// The joins themselves, against a date that has already had {{date_offset}}
// applied. LEFT because a null date -- an order not yet shipped has no ship date
// -- must leave the period columns empty rather than drop the row.
function dimPeriodJoins(dateGroups, alias = 's') {
return dateGroups.map(g =>
`\n LEFT JOIN pf.dim_period ${g.alias}`
+ ` ON ${g.alias}.drange @> (${alias}."${g.dateCol}" + '{{date_offset}}'::interval)::date`
).join('');
}
function generateSQL(source, colMeta) { function generateSQL(source, colMeta) {
const dims = colMeta const dims = colMeta
.filter(c => c.role === 'dimension') .filter(c => c.role === 'dimension')
@ -88,21 +124,31 @@ function generateSQL(source, colMeta) {
// Baseline and reference copy the source row wholesale, so they carry every // Baseline and reference copy the source row wholesale, so they carry every
// measure and every date — not just the primary one the operations act on. // measure and every date — not just the primary one the operations act on.
// Dropping the others would leave those columns null for the life of the version. // Dropping the others would leave those columns null for the life of the version.
// Clone carries every date column, not just the primary one, because it is the
// operation that moves rows through time: {{date_offset}} shifts them all
// together, and the period dimensions are re-derived from pf.dim_period against
// the shifted dates rather than copied from the row being cloned. Cloning last
// year's mix forward a year otherwise produces rows dated 2027 still labelled
// with 2026's periods.
const cloneCols = [...dims, ...dateCols, effectiveValue, effectiveUnits].filter(Boolean);
const cloneInsertCols = [...cloneCols.map(q), 'pf_iter', 'pf_logid', 'pf_user', 'pf_created_at'].join(', ');
const loadCols = [...dims, ...dateCols, ...valueCols, ...unitsCols]; const loadCols = [...dims, ...dateCols, ...valueCols, ...unitsCols];
const loadInsertCols = [...loadCols.map(q), 'pf_iter', 'pf_logid', 'pf_user', 'pf_created_at'].join(', '); const loadInsertCols = [...loadCols.map(q), 'pf_iter', 'pf_logid', 'pf_user', 'pf_created_at'].join(', ');
const dateColSet = new Set(dateCols); const dateColSet = new Set(dateCols);
// dim_period JOIN support: if the date column is the is_key of a dim_group, // dim_period JOIN support: a date column that is the is_key of a dim_group
// dimension siblings with dim_period_col set are derived from pf.dim_period // anchors that group, and dimension siblings with dim_period_col set are
// instead of being copied raw from the source on baseline/reference load. // derived from pf.dim_period instead of copied raw. Derivation is against the
const dateKeyGroup = colMeta.find(c => c.role === 'date' && c.is_key && c.dim_group)?.dim_group; // date *after* {{date_offset}}, which is the whole point -- shift a baseline
const dimPeriodMap = new Map( // forward a year and its period columns follow, rather than still naming the
dateKeyGroup // year it came from.
? colMeta //
.filter(c => c.role === 'dimension' && c.dim_group === dateKeyGroup && c.dim_period_col) // Every such group, not just the first. This used to be a find(), so a source
.map(c => [c.cname, c.dim_period_col]) // with order, requested and ship date groups derived the order one and copied
: [] // the other two raw -- shifted dates against unshifted period labels.
); const dateGroups = dateGroupsOf(colMeta);
const dimPeriodMap = dimPeriodMapOf(dateGroups);
const hasDimPeriod = dimPeriodMap.size > 0; const hasDimPeriod = dimPeriodMap.size > 0;
// display grain — when set, initial load and operations both return rows // display grain — when set, initial load and operations both return rows
@ -199,15 +245,17 @@ GROUP BY
// The offset shifts every date column, so order date and ship date stay in step. // The offset shifts every date column, so order date and ship date stay in step.
return loadCols.map(c => { return loadCols.map(c => {
if (dateColSet.has(c)) return `(${pfx}${q(c)} + '{{date_offset}}'::interval)::date`; if (dateColSet.has(c)) return `(${pfx}${q(c)} + '{{date_offset}}'::interval)::date`;
if (dimPeriodMap.has(c)) return `dp.${q(dimPeriodMap.get(c))} AS ${q(c)}`; if (dimPeriodMap.has(c)) {
const { alias, periodCol } = dimPeriodMap.get(c);
return `${alias}.${q(periodCol)} AS ${q(c)}`;
}
return `${pfx}${q(c)}`; return `${pfx}${q(c)}`;
}).join(',\n '); }).join(',\n ');
} }
function buildFromClause() { function buildFromClause() {
if (!hasDimPeriod) return srcTable; if (!hasDimPeriod) return srcTable;
return `${srcTable} s\n JOIN pf.dim_period dp` return srcTable + ' s' + dimPeriodJoins(dateGroups);
+ ` ON dp.drange @> (s.${q(dateCol)} + '{{date_offset}}'::interval)::date`;
} }
function buildBaseline() { function buildBaseline() {
@ -327,6 +375,16 @@ ${opTail('allrows')}` : 'SELECT * FROM neg UNION ALL SELECT * FROM ins'}`.trim()
} }
function buildClone() { function buildClone() {
const select = [
// dims: whatever {{set_clause}} resolves them to. The route builds it,
// and substitutes the dim_period expression for any derived dimension
// the caller has not overridden outright.
'{{set_clause}}',
...dateCols.map(c => `(s.${q(c)} + '{{date_offset}}'::interval)::date`),
effectiveValue ? `round(s.${q(effectiveValue)} * {{scale_factor}}, 2)` : null,
effectiveUnits ? `round(s.${q(effectiveUnits)} * {{scale_factor}}, 5)` : null,
].filter(Boolean).join(',\n ');
return ` return `
WITH WITH
ilog AS ( ilog AS (
@ -335,13 +393,11 @@ ilog AS (
RETURNING id RETURNING id
) )
,ins AS ( ,ins AS (
INSERT INTO {{fc_table}} (${insertCols}) INSERT INTO {{fc_table}} (${cloneInsertCols})
SELECT SELECT
{{set_clause}}, ${select},
${q(dateCol)},
${effectiveValue ? `round(${q(effectiveValue)} * {{scale_factor}}, 2)` : '0'}${effectiveUnits ? `,\n round(${q(effectiveUnits)} * {{scale_factor}}, 5)` : ''},
'clone', (SELECT id FROM ilog), '{{pf_user}}', now() 'clone', (SELECT id FROM ilog), '{{pf_user}}', now()
FROM {{fc_table}} FROM {{fc_table}} s${hasDimPeriod ? dimPeriodJoins(dateGroups) : ''}
WHERE {{where_clause}} WHERE {{where_clause}}
{{exclude_clause}} {{exclude_clause}}
RETURNING * RETURNING *
@ -430,12 +486,21 @@ function buildExcludeClause(excludeIters) {
// build the dimension columns portion of a SELECT for recode/clone // build the dimension columns portion of a SELECT for recode/clone
// replaces named dimensions with literal values, passes others through unchanged // replaces named dimensions with literal values, passes others through unchanged
function buildSetClause(dimCols, setObj) { // derivedExprs: cname -> a SQL expression to use when the caller has not set the
// column outright. Clone passes the dim_period expressions through here, so a
// cloned row's period dimensions come from the calendar against its shifted date
// rather than from the row it was copied from.
function buildSetClause(dimCols, setObj, opts = {}) {
const { derivedExprs, alias } = opts;
const pfx = alias ? `${alias}.` : '';
return dimCols.map(col => { return dimCols.map(col => {
if (setObj && setObj[col] !== undefined) { if (setObj && setObj[col] !== undefined) {
return `'${esc(setObj[col])}' AS "${col}"`; return `'${esc(setObj[col])}' AS "${col}"`;
} }
return `"${col}"`; if (derivedExprs && derivedExprs[col]) {
return `${derivedExprs[col]} AS "${col}"`;
}
return `${pfx}"${col}"`;
}).join(', '); }).join(', ');
} }
@ -479,4 +544,4 @@ function esc(val) {
return String(val).replace(/'/g, "''"); return String(val).replace(/'/g, "''");
} }
module.exports = { generateSQL, grainOf, applyTokens, buildWhere, buildWhereAny, buildExcludeClause, buildExcludePredicate, buildSetClause, buildFilterClause, esc }; module.exports = { generateSQL, grainOf, dateGroupsOf, dimPeriodMapOf, dimPeriodJoins, applyTokens, buildWhere, buildWhereAny, buildExcludeClause, buildExcludePredicate, buildSetClause, buildFilterClause, esc };

View File

@ -1,6 +1,6 @@
const express = require('express'); const express = require('express');
const { tableFromArrays, tableToIPC } = require('apache-arrow'); const { tableFromArrays, tableToIPC } = require('apache-arrow');
const { applyTokens, buildWhere, buildWhereAny, buildExcludeClause, buildExcludePredicate, buildSetClause, esc } = require('../lib/sql_generator'); const { applyTokens, buildWhere, buildWhereAny, buildExcludeClause, buildExcludePredicate, buildSetClause, dateGroupsOf, dimPeriodMapOf, esc } = require('../lib/sql_generator');
const { sessionUser } = require('../lib/auth'); const { sessionUser } = require('../lib/auth');
const { fcTable } = require('../lib/utils'); const { fcTable } = require('../lib/utils');
@ -721,11 +721,10 @@ module.exports = function(pool) {
// clone one or more slices as new business under new dimension values // clone one or more slices as new business under new dimension values
// does not offset the original slice // does not offset the original slice
router.post('/versions/:id/clone', async (req, res) => { router.post('/versions/:id/clone', async (req, res) => {
const { note, set, scale, apply_mode } = req.body; const { note, set, scale, apply_mode, from_logid, date_offset } = req.body;
const pf_user = sessionUser(req); const pf_user = sessionUser(req);
const slices = normalizeSlices(req.body); const slices = normalizeSlices(req.body);
if (slices.length === 0) return res.status(400).json({ error: 'slice is required' }); if (slices.length === 0) return res.status(400).json({ error: 'slice is required' });
if (!set || Object.keys(set).length === 0) return res.status(400).json({ error: 'set is required' });
try { try {
const ctx = await getContext(parseInt(req.params.id), 'clone'); const ctx = await getContext(parseInt(req.params.id), 'clone');
@ -733,8 +732,38 @@ module.exports = function(pool) {
assertSelective(slices, ctx); assertSelective(slices, ctx);
const scaleFactor = (scale != null) ? parseFloat(scale) : 1.0; const scaleFactor = (scale != null) ? parseFloat(scale) : 1.0;
const excludeClause = buildExcludeClause(ctx.version.exclude_iters); const dateOffset = (date_offset || '0 days').trim() || '0 days';
const setClause = buildSetClause(ctx.dimCols, set);
// Cloning from a named segment is how a period with no baseline gets a
// shape: pick the ledger line -- prior year, plan -- and copy its mix
// forward. That means reading rows exclude_iters normally keeps
// operations away from, so the entry is named explicitly and replaces
// the exclusion rather than widening it. The rows written are ordinary
// clone rows either way, so they are adjustable afterwards.
let excludeClause;
if (from_logid != null) {
const srcLog = await pool.query(
`SELECT id, operation, coalesce(nullif(tag, ''), note) AS label
FROM pf.log WHERE id = $1 AND version_id = $2`,
[parseInt(from_logid), ctx.version.id]
);
if (!srcLog.rows.length) {
return res.status(400).json({ error: `No log entry ${from_logid} on this version` });
}
excludeClause = `AND pf_logid = ${parseInt(from_logid)}`;
} else {
excludeClause = buildExcludeClause(ctx.version.exclude_iters);
}
// Period dimensions come from the calendar against the shifted date, not
// from the row being copied -- otherwise a mix moved forward a year
// keeps last year's period labels. An explicit set wins over both.
const dateGroups = dateGroupsOf(ctx.colMeta);
const derivedExprs = Object.fromEntries(
[...dimPeriodMapOf(dateGroups)].map(([cname, { alias, periodCol }]) =>
[cname, `${alias}."${periodCol}"`])
);
const setClause = buildSetClause(ctx.dimCols, set, { derivedExprs, alias: 's' });
const units = sliceUnits(slices, ctx, apply_mode === 'each' ? 'each' : 'prorate'); const units = sliceUnits(slices, ctx, apply_mode === 'each' ? 'each' : 'prorate');
const client = await pool.connect(); const client = await pool.connect();
@ -749,12 +778,17 @@ module.exports = function(pool) {
version_id: ctx.version.id, version_id: ctx.version.id,
pf_user: esc(pf_user || ''), pf_user: esc(pf_user || ''),
note: esc(note || ''), note: esc(note || ''),
params: esc(JSON.stringify({ slices: unit.slices, set, scale: scaleFactor, apply_mode: unit.mode })), params: esc(JSON.stringify({
slices: unit.slices, set, scale: scaleFactor, apply_mode: unit.mode,
date_offset: dateOffset,
...(from_logid != null ? { from_logid: parseInt(from_logid) } : {}),
})),
slice: esc(JSON.stringify(loggedSlice)), slice: esc(JSON.stringify(loggedSlice)),
where_clause: unit.where, where_clause: unit.where,
exclude_clause: excludeClause, exclude_clause: excludeClause,
set_clause: setClause, set_clause: setClause,
scale_factor: scaleFactor scale_factor: scaleFactor,
date_offset: esc(dateOffset)
}); });
const result = await runSQL(sql, client); const result = await runSQL(sql, client);
await tagLog(client, result.rows, req.body.tag); await tagLog(client, result.rows, req.body.tag);

View File

@ -715,6 +715,7 @@ export default function OperationPanel({
recodeNote, setRecodeNote, recodeNote, setRecodeNote,
cloneSet, setCloneSet, cloneSet, setCloneSet,
cloneScale, setCloneScale, cloneScale, setCloneScale,
cloneFrom, setCloneFrom, cloneOffset, setCloneOffset, cloneSources,
cloneNote, setCloneNote, cloneNote, setCloneNote,
dimCols, lookupDerivedCols, dimMembers, versionId, dimCols, lookupDerivedCols, dimMembers, versionId,
buildPayload, submitOp, buildPayload, submitOp,
@ -785,13 +786,53 @@ export default function OperationPanel({
slices={slices} lookupDerivedCols={lookupDerivedCols} dimMembers={dimMembers} versionId={versionId} slices={slices} lookupDerivedCols={lookupDerivedCols} dimMembers={dimMembers} versionId={versionId}
extra={ extra={
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{/* Where the mix comes from. Left on the adjustable rows,
clone behaves as it always did. Naming a segment reads
from that entry instead -- including the reference ones
operations are normally kept away from, which is the
point: a period with no baseline borrows a shape from
prior year or plan. */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-gray-500 whitespace-nowrap">copy from</span>
<select value={cloneFrom} onChange={e => setCloneFrom(e.target.value)}
className={`${INPUT} w-auto`}>
<option value="">current selection</option>
{(cloneSources || []).map(s => (
<option key={s.id} value={s.id}>
{s.label || `${s.operation} #${s.id}`}
</option>
))}
</select>
</div>
{/* Only meaningful when borrowing across time, so it
appears with the segment rather than always. */}
{cloneFrom && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-gray-500 whitespace-nowrap">shift dates by</span>
<input value={cloneOffset} list="pf-clone-offsets"
onChange={e => setCloneOffset(e.target.value)}
placeholder="0 days" className={`${INPUT} w-28`} />
<datalist id="pf-clone-offsets">
<option value="12 months" />
<option value="24 months" />
<option value="0 days" />
</datalist>
<span className="text-gray-400 text-[11px]">
every date column, with period dimensions re-derived
</span>
</div>
)}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-gray-500">scale cloned rows by</span> <span className="text-gray-500">scale cloned rows by</span>
<input type="number" step="any" value={cloneScale} <input type="number" step="any" value={cloneScale}
onChange={e => setCloneScale(e.target.value)} className={INPUT} /> onChange={e => setCloneScale(e.target.value)} className={INPUT} />
</div> </div>
{!cloneFrom && (
<MovingTotal currentTotals={currentTotals} verb="Copying" <MovingTotal currentTotals={currentTotals} verb="Copying"
factor={parseFloat(cloneScale) || 1} /> factor={parseFloat(cloneScale) || 1} />
)}
</div> </div>
} /> } />
)} )}

View File

@ -68,6 +68,9 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
const [expandedLog, setExpandedLog] = useState(null) const [expandedLog, setExpandedLog] = useState(null)
// master data per dim_group, fetched once per source: { part: { key_col, siblings, members } } // master data per dim_group, fetched once per source: { part: { key_col, siblings, members } }
const [dimMembers, setDimMembers] = useState({}) const [dimMembers, setDimMembers] = useState({})
// clone can borrow its mix from a named segment instead of the current selection
const [cloneFrom, setCloneFrom] = useState('')
const [cloneOffset, setCloneOffset] = useState('12 months')
// what a target/percentage is measured against: the rows this operation can // what a target/percentage is measured against: the rows this operation can
// write, or everything the pivot shows for the slice (excluded rows included) // write, or everything the pivot shows for the slice (excluded rows included)
const [targetBasis, setTargetBasis] = useState('selected') const [targetBasis, setTargetBasis] = useState('selected')
@ -1123,6 +1126,17 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
}) })
} }
// Segments worth copying a mix from: the loads. Adjustments are already in the
// forecast, so cloning one would double it rather than seed anything.
const cloneSources = Object.entries(logMeta)
.filter(([, m]) => ['baseline', 'reference'].includes(m.operation))
.map(([id, m]) => ({
id: Number(id),
operation: m.operation,
label: (m.tag || m.note || '').trim(),
}))
.sort((a, b) => a.id - b.id)
function buildEffectiveSlice(raw) { function buildEffectiveSlice(raw) {
const dimCols = new Set(colMetaRef.current.filter(c => c.role === 'dimension').map(c => c.cname)) const dimCols = new Set(colMetaRef.current.filter(c => c.role === 'dimension').map(c => c.cname))
const dateCols = new Set(colMetaRef.current.filter(c => c.role === 'date').map(c => c.cname)) const dateCols = new Set(colMetaRef.current.filter(c => c.role === 'date').map(c => c.cname))
@ -1206,6 +1220,10 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
} else if (op === 'clone') { } else if (op === 'clone') {
const set = Object.fromEntries(Object.entries(cloneSet).filter(([, v]) => v.trim())) const set = Object.fromEntries(Object.entries(cloneSet).filter(([, v]) => v.trim()))
body = { ...body, note: cloneNote || undefined, set, scale: parseFloat(cloneScale) || 1 } body = { ...body, note: cloneNote || undefined, set, scale: parseFloat(cloneScale) || 1 }
if (cloneFrom) {
body.from_logid = Number(cloneFrom)
body.date_offset = cloneOffset.trim() || '0 days'
}
} }
return body return body
} }
@ -1312,6 +1330,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
recodeNote, setRecodeNote, recodeNote, setRecodeNote,
cloneSet, setCloneSet, cloneSet, setCloneSet,
cloneScale, setCloneScale, cloneScale, setCloneScale,
cloneFrom, setCloneFrom, cloneOffset, setCloneOffset, cloneSources,
cloneNote, setCloneNote, cloneNote, setCloneNote,
dimCols, lookupDerivedCols, dimMembers, versionId, dimCols, lookupDerivedCols, dimMembers, versionId,
buildPayload, submitOp, buildPayload, submitOp,