Ask whether a dollar change is price or volume

A sales figure on its own does not say which of price or volume moved, and
scale was answering silently: it wrote the value increment and left units
alone, so volume stayed flat and price absorbed everything. That is one of
the three behaviours the Excel form offered, picked by default rather than
by the user.

/opt/forecast_api/VBA/fpvt.frm has the semantics. Edit Sales, then plug one
side:

    plug volume:  pchange = fVal/(pVal+bVal); fVol = (pVol+bVol)*pchange
    plug price:   fVol = pVol + bVol

So 'volume' moves units in the same proportion as the dollars, holding
price; 'price' leaves units alone, as now. Checked against those formulas:
+100 on 1000/500 gives units 500 -> 550 with price held at 2.0000, and
-400 on 2000/800 gives 800 -> 640 at 2.5000.

Price still defaults, so nothing changes for an existing caller that does
not send `plug`. The control only appears once the edit is dollars-only --
naming units or price has already settled the question. A price target now
also honours a units target alongside it, which is the form's Edit Price
mode where both are inputs and dollars fall out.

Holding price when the selection has no value is refused rather than
divided by zero, the same case the form guarded with "Zero times any number
is zero".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-16 23:11:05 -04:00
parent 35f9b7b048
commit 546af67639
3 changed files with 82 additions and 5 deletions

View File

@ -137,12 +137,44 @@ module.exports = function(pool) {
};
let value = resolve(tValue, vPct, vIncr, totals.value, fixedValue);
const units = resolve(tUnits, uPct, uIncr, totals.units, fixedUnits);
let units = resolve(tUnits, uPct, uIncr, totals.units, fixedUnits);
// a price target holds units constant: new value = price x current units.
// An explicit value target outranks it.
// A price target is the "edit price" mode of the Excel form: price and
// volume are the inputs and dollars fall out of them. With a units target
// alongside it, both move; without one, volume holds and price alone carries
// the change. An explicit value target outranks it either way.
if (tPrice !== null && tValue === null) {
value = (tPrice * (totals.units + fixedUnits)) - (totals.value + fixedValue);
const targetUnits = tUnits !== null
? (tUnits - fixedUnits) + 0 // the units target is already absolute
: (totals.units + fixedUnits);
value = (tPrice * targetUnits) - (totals.value + fixedValue);
}
// Which side of price x volume absorbs a dollar change.
//
// 'price' — volume holds, so price moves. This is what the API has always
// done, and stays the default so existing callers are unaffected.
// 'volume' — price holds, so volume scales with the dollars.
//
// Only meaningful when dollars were the input and units were not given
// explicitly; naming both means the caller has already decided.
const plug = body.plug === 'volume' ? 'volume' : 'price';
const unitsGiven = [tUnits, uIncr, uPct].some(v => v !== null);
if (plug === 'volume' && value !== 0 && !unitsGiven) {
const curValue = totals.value + fixedValue;
const curUnits = totals.units + fixedUnits;
if (curValue === 0) {
const err = new Error(
'Cannot hold price constant here: the selection currently has no value, ' +
'so there is no price to hold. Scale units directly, or let price absorb ' +
'the change.'
);
err.status = 400; throw err;
}
// price constant means value and units move by the same proportion:
// fVol = curVol * (fVal / curVal), so the units delta is curVol * value/curVal
units = curUnits * (value / curValue);
}
// the scale SQL divides by the slice total; with no rows there is

View File

@ -224,7 +224,7 @@ function LedgerInput({ value, active, onChange, onFocus, suffix }) {
)
}
function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, targetBasis, setTargetBasis, logMeta = {}, multi, applyMode }) {
function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, setScalePlug, targetBasis, setTargetBasis, logMeta = {}, multi, applyMode }) {
const valueCol = currentTotals?.valueCol
const unitsCol = currentTotals?.unitsCol
const total = currentTotals?.total || { value: 0, units: 0 }
@ -288,6 +288,14 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, targetBasis,
measures.map(m => [m.key, derive(basisOf(m.key) ?? 0, scaleInputs[m.key])])
)
// an edit is dollars-only when value carries a number and neither units nor
// price does -- the one case where price and volume are both still unknown
const filled = (key) => {
const raw = scaleInputs[key]?.raw
return raw != null && raw !== '' && isFinite(parseFloat(raw))
}
const dollarsOnly = filled('value') && !filled('units') && !filled('price')
const setEdit = (key, field, raw) =>
setScaleInputs(prev => ({ ...prev, [key]: { field, raw } }))
@ -402,6 +410,30 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, targetBasis,
</tbody>
</table>
{/* A dollar figure on its own does not say whether price or volume moved.
Only ask once the edit is actually dollars-only -- naming units or price
has already answered it, and the control would just be noise. */}
{valueCol && unitsCol && dollarsOnly && (
<div className="flex flex-col gap-1 mt-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-gray-600 whitespace-nowrap">Absorbed by</span>
<Segmented
value={scalePlug}
onChange={setScalePlug}
options={[
['price', 'Price', `${unitsCol} holds; price moves to reach the number`],
['volume', 'Volume', `Price holds; ${unitsCol} scales with the dollars`],
]}
/>
</div>
<div className="text-[11px] text-gray-500 leading-snug">
{scalePlug === 'price'
? `${unitsCol} holds; price moves to reach the number.`
: `Price holds; ${unitsCol} scales with the dollars.`}
</div>
</div>
)}
{hasExcl && (
<div className="flex flex-col gap-1 mt-2">
<div className="flex items-center gap-2 flex-wrap">
@ -547,6 +579,7 @@ export default function OperationPanel({
currentTotals,
activeOp, setActiveOp,
scaleInputs, setScaleInputs,
scalePlug, setScalePlug,
targetBasis, setTargetBasis,
opTag, setOpTag, knownTags = [], logMeta = {},
scaleNote, setScaleNote,
@ -608,6 +641,7 @@ export default function OperationPanel({
<ScaleLedger
currentTotals={currentTotals}
scaleInputs={scaleInputs} setScaleInputs={setScaleInputs}
scalePlug={scalePlug} setScalePlug={setScalePlug}
targetBasis={targetBasis} setTargetBasis={setTargetBasis}
logMeta={logMeta}
multi={multi} applyMode={applyMode}

View File

@ -60,6 +60,10 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// you type in decides how the number is interpreted, and the other two rows of
// that measure are derived from it.
const [scaleInputs, setScaleInputs] = useState({})
// which side of price x volume absorbs a dollar-only change: 'price' keeps
// volume flat, 'volume' keeps price flat. Mirrors the Excel form's
// Plug Price / Plug Volume choice.
const [scalePlug, setScalePlug] = useState(() => localStorage.getItem('pf_scale_plug') || 'price')
// what a target/percentage is measured against: the rows this operation can
// write, or everything the pivot shows for the slice (excluded rows included)
const [targetBasis, setTargetBasis] = useState('selected')
@ -81,6 +85,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
const [panelWidth, setPanelWidth] = useState(() => Number(localStorage.getItem('pf_panel_w')) || 360)
const [panelHeight, setPanelHeight] = useState(() => Number(localStorage.getItem('pf_panel_h')) || 260)
useEffect(() => { localStorage.setItem('pf_dock', dock) }, [dock])
useEffect(() => { localStorage.setItem('pf_scale_plug', scalePlug) }, [scalePlug])
useEffect(() => { localStorage.setItem('pf_panel_open', panelOpen ? 'open' : 'closed') }, [panelOpen])
// Esc closes the panel the usual way out of the floating window
@ -1132,6 +1137,11 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
: p.field === 'change' ? curPrice + pn
: curPrice * (1 + pn / 100)
}
// A dollar change on its own is ambiguous -- price x volume, and the number
// says nothing about which moved. plug names the one that absorbs it. Only
// sent when dollars are the sole input; naming units or price has already
// answered the question.
if (vn != null && un == null && pn == null) body.plug = scalePlug
} else if (op === 'recode') {
const set = Object.fromEntries(Object.entries(recodeSet).filter(([, v]) => v.trim()))
body = { ...body, note: recodeNote || undefined, set }
@ -1230,6 +1240,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
currentTotals,
activeOp, setActiveOp,
scaleInputs, setScaleInputs,
scalePlug, setScalePlug,
targetBasis, setTargetBasis,
opTag, setOpTag, knownTags, logMeta,
scaleNote, setScaleNote,