Build the bridge on buckets, and let it start from a comparison basis
The bridge filtered on exclude_iters, so it dropped every reference row --
including Open Orders, which is loaded as reference precisely so nothing
adjusts it and is still part of the forecast. Membership is pf_bucket now,
which is the axis that answers this question; pf_iter answers a different
one.
What you compare against depends on what you are building: an AOP is built
off a prior period, a forecast off an update to the AOP. So the basis is
chosen rather than assumed, and the walk stays exact either way because
Forecast - Basis = (Forecast loads - Basis) + adjustments
The opening step is that first term, every tagged adjustment explains the
rest, and the bars sum to the endpoint by construction. No residual to
explain away -- which is what a bridge from prior year to forecast would
otherwise be, since the adjustments describe movement from the forecast's
own loads and not from last year.
With no basis it is the composition instead: the forecast's loads as the
opening anchor, then the adjustments. The picker only appears once something
carries a bucket, so a version that has not been labelled behaves as before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1904428fbb
commit
5acac2738a
@ -49,25 +49,66 @@ function niceTicks(min, max, count = 5) {
|
||||
// Turn raw forecast rows into the walk: baseline anchor, one floating step per
|
||||
// initiative tag, current anchor. Pure and exported so the arithmetic can be
|
||||
// checked against real data without a browser.
|
||||
export function buildSteps(rows, { valueCol, unitsCol, logMeta = {}, excludeIters = ['reference'] }) {
|
||||
// The walk from a basis to the current forecast.
|
||||
//
|
||||
// Without a basis this is the forecast's own composition: its loads as the
|
||||
// opening anchor, then one step per adjustment tag.
|
||||
//
|
||||
// With one -- Plan when building a forecast, Prior Year when building the AOP --
|
||||
// it starts there instead, and the identity that makes it exact is
|
||||
//
|
||||
// Forecast - Basis = (Forecast loads - Basis) + adjustments
|
||||
//
|
||||
// so the opening step is the difference between the forecast's own loads and the
|
||||
// basis, and every tagged adjustment explains the rest. No residual, no plug: the
|
||||
// bars sum to the endpoint by construction rather than by hoping the tags cover
|
||||
// everything.
|
||||
//
|
||||
// Membership comes from pf_bucket, not pf_iter. Those answer different questions
|
||||
// -- Open Orders is loaded as reference so nothing adjusts it, and is still part
|
||||
// of the forecast -- so a bridge keyed on iter silently dropped it.
|
||||
export function buildSteps(rows, {
|
||||
valueCol, unitsCol, logMeta = {},
|
||||
excludeIters = ['reference'], // kept for callers with no bucket data
|
||||
basis = null, // a pf_bucket name, or null for composition
|
||||
forecastBucket = 'Forecast',
|
||||
}) {
|
||||
const hasBuckets = rows.some(r => r.pf_bucket != null)
|
||||
const excl = new Set(excludeIters)
|
||||
const baseline = { value: 0, units: 0, rows: 0 }
|
||||
|
||||
const num = (r, col) => (col ? (parseFloat(r[col]) || 0) : 0)
|
||||
const blank = () => ({ value: 0, units: 0, rows: 0 })
|
||||
|
||||
const loads = blank() // the forecast's own segments
|
||||
const basisT = blank() // the comparison bucket
|
||||
const byTag = new Map()
|
||||
const buckets = new Map() // every bucket, for the picker and the markers
|
||||
|
||||
for (const r of rows) {
|
||||
const iter = r.pf_iter
|
||||
if (excl.has(iter)) continue
|
||||
const v = parseFloat(r[valueCol]) || 0
|
||||
const u = unitsCol ? (parseFloat(r[unitsCol]) || 0) : 0
|
||||
const v = num(r, valueCol)
|
||||
const u = num(r, unitsCol)
|
||||
const bucket = hasBuckets ? (r.pf_bucket || '') : null
|
||||
|
||||
if (iter === 'baseline') {
|
||||
baseline.value += v; baseline.units += u; baseline.rows += 1
|
||||
if (bucket != null) {
|
||||
const b = buckets.get(bucket) || blank()
|
||||
b.value += v; b.units += u; b.rows += 1
|
||||
buckets.set(bucket, b)
|
||||
}
|
||||
|
||||
// Anything outside the forecast is a comparison, never a step.
|
||||
if (hasBuckets && bucket !== forecastBucket) {
|
||||
if (basis && bucket === basis) { basisT.value += v; basisT.units += u; basisT.rows += 1 }
|
||||
continue
|
||||
}
|
||||
if (!hasBuckets && excl.has(r.pf_iter)) continue
|
||||
|
||||
const isLoad = r.pf_iter === 'baseline' || r.pf_iter === 'reference'
|
||||
if (isLoad) { loads.value += v; loads.units += u; loads.rows += 1; continue }
|
||||
|
||||
const meta = logMeta[r.pf_logid] || {}
|
||||
const tag = (meta.tag || '').trim()
|
||||
const label = tag || (meta.note || '').trim() ||
|
||||
`${(meta.operation || iter || 'adj')}${r.pf_logid != null ? ` #${r.pf_logid}` : ''}`
|
||||
`${(meta.operation || r.pf_iter || 'adj')}${r.pf_logid != null ? ` #${r.pf_logid}` : ''}`
|
||||
const key = tag ? `tag:${tag}` : `log:${r.pf_logid}`
|
||||
const g = byTag.get(key) ||
|
||||
{ key, label, tagged: !!tag, value: 0, units: 0, rows: 0, logIds: new Set(), first: r.pf_logid }
|
||||
@ -77,25 +118,49 @@ export function buildSteps(rows, { valueCol, unitsCol, logMeta = {}, excludeIter
|
||||
}
|
||||
|
||||
const mid = [...byTag.values()].sort((a, b) => (a.first ?? 0) - (b.first ?? 0))
|
||||
const useBasis = !!basis && basisT.rows > 0
|
||||
|
||||
let running = baseline.value
|
||||
const out = [{
|
||||
key: 'baseline', label: 'Baseline', kind: 'anchor',
|
||||
delta: baseline.value, start: 0, end: baseline.value,
|
||||
units: baseline.units, rows: baseline.rows, entries: 1,
|
||||
}]
|
||||
const out = []
|
||||
if (useBasis) {
|
||||
out.push({
|
||||
key: 'basis', label: basis, kind: 'anchor',
|
||||
delta: basisT.value, start: 0, end: basisT.value,
|
||||
units: basisT.units, rows: basisT.rows, entries: 1,
|
||||
})
|
||||
const gap = loads.value - basisT.value
|
||||
out.push({
|
||||
key: 'loads-vs-basis', label: `Loads vs ${basis}`, kind: 'step',
|
||||
delta: gap, start: basisT.value, end: loads.value,
|
||||
units: loads.units - basisT.units, rows: loads.rows, entries: 1,
|
||||
})
|
||||
} else {
|
||||
out.push({
|
||||
key: 'baseline', label: hasBuckets ? forecastBucket + ' loads' : 'Baseline', kind: 'anchor',
|
||||
delta: loads.value, start: 0, end: loads.value,
|
||||
units: loads.units, rows: loads.rows, entries: 1,
|
||||
})
|
||||
}
|
||||
|
||||
let running = loads.value
|
||||
for (const g of mid) {
|
||||
const start = running
|
||||
running += g.value
|
||||
out.push({ ...g, kind: 'step', delta: g.value, start, end: running, entries: g.logIds.size })
|
||||
}
|
||||
|
||||
out.push({
|
||||
key: 'current', label: 'Current', kind: 'anchor',
|
||||
key: 'current', label: hasBuckets ? forecastBucket : 'Current', kind: 'anchor',
|
||||
delta: running, start: 0, end: running,
|
||||
units: out.reduce((a, s) => a + (s.kind === 'anchor' ? 0 : s.units || 0), baseline.units),
|
||||
rows: rows.filter(r => !excl.has(r.pf_iter)).length,
|
||||
units: loads.units + mid.reduce((a, g) => a + (g.units || 0), 0),
|
||||
rows: loads.rows + mid.reduce((a, g) => a + g.rows, 0),
|
||||
entries: mid.reduce((a, g) => a + g.logIds.size, 0) + 1,
|
||||
})
|
||||
|
||||
// Every bucket present, so the view can offer them as bases and show the ones
|
||||
// that are not the basis as comparison markers.
|
||||
out.buckets = [...buckets.entries()]
|
||||
.map(([name, t]) => ({ name, ...t }))
|
||||
.sort((a, b) => b.value - a.value)
|
||||
return out
|
||||
}
|
||||
|
||||
@ -132,6 +197,9 @@ export default function BridgeView({
|
||||
const hasSelection = slices.length > 0
|
||||
// 'selection' | 'filtered' | 'all'
|
||||
const [scope, setScope] = useState(hasSelection ? 'selection' : 'filtered')
|
||||
// Which bucket the walk starts from. Plan when building a forecast, Prior Year
|
||||
// when building the AOP; empty means show the forecast's own composition.
|
||||
const [basis, setBasis] = useState(() => localStorage.getItem('pf_bridge_basis') || '')
|
||||
const [asTable, setAsTable] = useState(false)
|
||||
const [steps, setSteps] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
@ -180,19 +248,20 @@ export default function BridgeView({
|
||||
await view.delete()
|
||||
}
|
||||
|
||||
setSteps(buildSteps(rows, { valueCol, unitsCol, logMeta, excludeIters }))
|
||||
setSteps(buildSteps(rows, { valueCol, unitsCol, logMeta, excludeIters, basis: basis || null }))
|
||||
} catch (err) {
|
||||
setError(err.message || String(err))
|
||||
setSteps(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [tableRef, viewerRef, scope, logMeta, valueCol, unitsCol, excludeIters, slices, colMeta])
|
||||
}, [tableRef, viewerRef, scope, logMeta, valueCol, unitsCol, excludeIters, slices, colMeta, basis])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasSelection && scope === 'selection') setScope('filtered')
|
||||
}, [hasSelection, scope])
|
||||
|
||||
useEffect(() => { try { localStorage.setItem('pf_bridge_basis', basis) } catch {} }, [basis])
|
||||
useEffect(() => { if (open) compute() }, [open, compute])
|
||||
|
||||
useEffect(() => {
|
||||
@ -247,6 +316,23 @@ export default function BridgeView({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Offered only once something carries a bucket, and only listing buckets
|
||||
other than the forecast itself -- a walk from Forecast to Forecast is
|
||||
the composition view, which is what the empty option gives. */}
|
||||
{(steps?.buckets || []).some(b => b.name && b.name !== 'Forecast') && (
|
||||
<>
|
||||
<div className="w-px h-4 bg-gray-200" />
|
||||
<span className="text-gray-600">Compare to</span>
|
||||
<select value={basis} onChange={e => setBasis(e.target.value)}
|
||||
className="border border-gray-200 rounded px-2 py-1 text-gray-700 bg-white">
|
||||
<option value="">nothing — show composition</option>
|
||||
{(steps?.buckets || [])
|
||||
.filter(b => b.name && b.name !== 'Forecast')
|
||||
.map(b => <option key={b.name} value={b.name}>{b.name}</option>)}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="w-px h-4 bg-gray-200" />
|
||||
<button onClick={() => setAsTable(t => !t)}
|
||||
className="border border-gray-200 rounded px-2 py-1 text-gray-700 hover:bg-gray-50">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user