Hold price unless somebody names it

Two defaults both moved price when nobody asked:

A qty edit held the dollars, so price absorbed the whole difference -- 10% more
cases at the same revenue is a 9% price cut, and nothing in the figure being
edited shows it. Dollars now follow qty at the current price:
`value = (curValue / curUnits) * unitsIncr`, on the server in resolveIncrs() and
mirrored in the ledger's Result line. A slice with no units has no price to
hold, so there the dollars stay put and the edit is a pure qty change.

`plug` defaulted to 'price' -- volume holds, price moves -- because that is what
the API did before the flag existed. It now defaults to 'volume'. The client's
stored preference is keyed pf_scale_plug_v2, since a browser carrying the old
'price' choice would otherwise go on moving price silently.

The deliberate routes are unchanged and both outrank the above: name the dollars
and the qty together and price falls out of the two, or name a target_price,
which is the Excel form's Edit Price mode. The ledger labels the column that
cannot move `· holds`, which now includes price on a qty edit.

One behaviour to know about: holding price is impossible on a slice whose value
is zero, so a dollar edit there answers 400 with an explanation where before it
quietly moved price. Fail-loud is the point.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-22 13:43:06 -04:00
parent 9e880a65be
commit 7fd07e52d2
4 changed files with 86 additions and 18 deletions

View File

@ -207,22 +207,48 @@ stored load templates write `label` and `bucket` onto the log row at all.
POST to `/api/versions/:id/{scale|recode|clone}` → SQL executed with `RETURNING *` → new rows returned as JSON → `pspTable.update(rows)` — no full reload. In grain mode the operation's final CTE aggregates its own new rows to grain first; since `pf_logid` is part of `pf_gkey` those keys are always new, so `update()` **appends** and the view re-sums. POST to `/api/versions/:id/{scale|recode|clone}` → SQL executed with `RETURNING *` → new rows returned as JSON → `pspTable.update(rows)` — no full reload. In grain mode the operation's final CTE aggregates its own new rows to grain first; since `pf_logid` is part of `pf_gkey` those keys are always new, so `update()` **appends** and the view re-sums.
### Price or volume (`plug`) ### Price or volume (`plug`)
**Price does not move unless it is named.** That is the rule the whole section
implements, and it is a rule because a price move is invisible in the figure
being edited: the dollars land on target whether volume followed or not, and
only the implied price says which happened.
A sales figure alone does not say which of price or volume moved, so scale takes A sales figure alone does not say which of price or volume moved, so scale takes
`plug` — `'price'` (default, volume holds) or `'volume'` (price holds, units scale `plug` — `'volume'` (the default: price holds, units scale in proportion) or
in proportion). Resolved in `resolveIncrs()` in `routes/operations.js`; the panel `'price'` (volume holds, price moves). Resolved in `resolveIncrs()` in
only offers it when the edit is dollars-only, because naming units or price has `routes/operations.js`; the panel only *offers* it when the edit is dollars-only,
already answered it. The semantics come from the predecessor Excel model, because naming units or price has already answered it, but it stays on screen
`/opt/forecast_api/VBA/fpvt.frm` → `calc_val` / `calc_price`: greyed out in the other cases rather than vanishing. The semantics come from the
predecessor Excel model, `/opt/forecast_api/VBA/fpvt.frm` → `calc_val` /
`calc_price`:
``` ```
plug volume: pchange = fVal/(pVal+bVal); fVol = (pVol+bVol)*pchange plug volume: pchange = fVal/(pVal+bVal); fVol = (pVol+bVol)*pchange
plug price: fVol = pVol + bVol plug price: fVol = pVol + bVol
``` ```
A `target_price` with a `target_units` alongside is that form's Edit Price mode, **A qty input follows the same rule.** With dollars unnamed they follow at the
where both are inputs and dollars fall out. The ledger's **Result** line previews current price rather than holding — `value = (curValue / curUnits) * unitsIncr`.
value, units and price together using the same rules, so what you see is what Holding them made every qty edit a price edit: 10% more cases at the same revenue
gets written. is a 9% price cut nobody asked for. A slice with no units has no price to hold,
so there the dollars do stay put and the edit is a pure qty change.
The two deliberate ways to move price, both of which outrank the above:
- **name the dollars and the qty together** — price falls out of the two
- **name the price** (`target_price`), which is the Excel form's Edit Price mode:
with a `target_units` alongside, both are inputs and dollars fall out
The ledger's **Result** line previews value, units and price together using the
same rules, so what you see is what gets written, and the column that cannot move
is labelled `· holds`.
**What this replaced.** `plug` defaulted to `'price'` — volume holds, price
absorbs — because that is what the API did before the flag existed. So an
unqualified dollar edit moved price, and a qty edit moved price, and the only
edit that did not was one where somebody had picked `'volume'` by hand. The
client's stored preference is keyed `pf_scale_plug_v2` for that reason: a browser
carrying the old `'price'` choice would have gone on silently moving price.
### Undo ### Undo
`DELETE /api/log/:logid` → removes rows by logid → `table.remove()` of the affected index values (`pf_gkeys` in grain mode, `pf_ids` in raw mode); the view re-sums. No full reload. `DELETE /api/log/:logid` → removes rows by logid → `table.remove()` of the affected index values (`pf_gkeys` in grain mode, `pf_ids` in raw mode); the view re-sums. No full reload.

View File

@ -268,14 +268,20 @@ module.exports = function(pool) {
// Which side of price x volume absorbs a dollar change. // Which side of price x volume absorbs a dollar change.
// //
// 'price' — volume holds, so price moves. This is what the API has always // 'volume' — price holds, so volume scales with the dollars. The default.
// done, and stays the default so existing callers are unaffected. // 'price' — volume holds, so price moves.
// 'volume' — price holds, so volume scales with the dollars.
// //
// Only meaningful when dollars were the input and units were not given // Only meaningful when dollars were the input and units were not given
// explicitly; naming both means the caller has already decided. // explicitly; naming both means the caller has already decided.
const plug = body.plug === 'volume' ? 'volume' : 'price'; //
// 'price' was the default, being what the API did before the flag existed,
// and that made every unqualified dollar edit a price edit. A forecast
// that moves price when nobody asked is wrong in a way that is invisible
// -- the dollars land on target either way -- so the default is now the
// conservative one and moving price takes saying so.
const plug = body.plug === 'price' ? 'price' : 'volume';
const unitsGiven = [tUnits, uIncr, uPct].some(v => v !== null); const unitsGiven = [tUnits, uIncr, uPct].some(v => v !== null);
const valueGiven = [tValue, vIncr, vPct].some(v => v !== null);
if (plug === 'volume' && value !== 0 && !unitsGiven) { if (plug === 'volume' && value !== 0 && !unitsGiven) {
const curValue = totals.value + fixedValue; const curValue = totals.value + fixedValue;
@ -293,6 +299,25 @@ module.exports = function(pool) {
units = curUnits * (value / curValue); units = curUnits * (value / curValue);
} }
// The same question asked of a qty input, and the answer has to match:
// with dollars unnamed they follow at the current price, rather than
// holding while price absorbs the difference.
//
// Holding them made every qty edit a price edit -- move 10% more cases
// at the same revenue and the implied price drops 9% -- which is exactly
// the silent price change the plug default above exists to prevent. The
// deliberate forms are still available and both outrank this: name the
// dollars alongside the qty (then price falls out of the two), or name
// the price outright.
if (units !== 0 && !valueGiven && tPrice === null) {
const curValue = totals.value + fixedValue;
const curUnits = totals.units + fixedUnits;
// no units means no price to hold, so the dollars stay where they are
// and the edit is a pure qty change -- adding units to a slice that
// has none cannot infer what they are worth
if (curUnits !== 0) value = (curValue / curUnits) * units;
}
// the scale SQL divides by the slice total; with no rows there is // the scale SQL divides by the slice total; with no rows there is
// nothing to prorate across and the increment would vanish anyway // nothing to prorate across and the increment would vanish anyway
if (totals.value === 0 && totals.units === 0) return { value: 0, units: 0 }; if (totals.value === 0 && totals.units === 0) return { value: 0, units: 0 };

View File

@ -634,6 +634,16 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
const targetPrice = numOf('price', 'new') const targetPrice = numOf('price', 'new')
newUnits = filled('units') ? numOf('units', 'new') : curUnits newUnits = filled('units') ? numOf('units', 'new') : curUnits
newValue = targetPrice != null && newUnits != null ? targetPrice * newUnits : null newValue = targetPrice != null && newUnits != null ? targetPrice * newUnits : null
} else if (filled('units') && !filled('value')) {
// Qty is the input and dollars are unnamed, so price holds and the dollars
// follow it. They used to hold, which made every qty edit a price edit --
// 10% more cases at the same revenue is a 9% price cut nobody asked for.
// Pinning the dollars as well is how a price move is asked for, and it
// takes the branch below instead.
newUnits = numOf('units', 'new')
newValue = (curUnits === 0 || newUnits == null)
? curValue
: curValue * (newUnits / curUnits)
} else { } else {
newValue = filled('value') ? numOf('value', 'new') : curValue newValue = filled('value') ? numOf('value', 'new') : curValue
if (filled('units')) newUnits = numOf('units', 'new') if (filled('units')) newUnits = numOf('units', 'new')
@ -734,8 +744,11 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
// sole input -- that is the one case where price and volume are both unknown // sole input -- that is the one case where price and volume are both unknown
// and the toggle is answering for them. 'price' means price absorbs the // and the toggle is answering for them. 'price' means price absorbs the
// change, so qty holds; 'volume' means qty absorbs it, so price holds. // change, so qty holds; 'volume' means qty absorbs it, so price holds.
const heldKey = (valueCol && unitsCol && dollarsOnly) const unitsOnly = filled('units') && !filled('value') && !filled('price')
? (scalePlug === 'price' ? 'units' : 'price')
const heldKey = !(valueCol && unitsCol) ? null
: dollarsOnly ? (scalePlug === 'price' ? 'units' : 'price')
: unitsOnly ? 'price'
: null : null
// Which measure is absorbing the change, whether or not the toggle chose it. // Which measure is absorbing the change, whether or not the toggle chose it.
@ -754,7 +767,8 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
if (!(valueCol && unitsCol)) return null if (!(valueCol && unitsCol)) return null
const u = filled('units'), p = filled('price'), v = filled('value') const u = filled('units'), p = filled('price'), v = filled('value')
if (u && p) return { value: null, live: false, why: 'Both qty and price are named, so the dollars follow from them.' } if (u && p) return { value: null, live: false, why: 'Both qty and price are named, so the dollars follow from them.' }
if (u) return { value: 'price', live: false, why: `${unitsCol} is pinned, so price absorbs the change.`, unpin: 'units' } if (u && v) return { value: null, live: false, why: `Both ${valueCol} and ${unitsCol} are named, so price falls out of the two — this is the deliberate price change.` }
if (u) return { value: null, live: false, why: `Price holds; ${valueCol} follows ${unitsCol} in proportion. Name the dollars as well to move price instead.`, unpin: 'units' }
if (p) return { value: 'volume', live: false, why: `Price is pinned, so ${unitsCol} absorbs the change.`, unpin: 'price' } if (p) return { value: 'volume', live: false, why: `Price is pinned, so ${unitsCol} absorbs the change.`, unpin: 'price' }
if (!v) return { value: null, live: false, why: 'Applies to a dollar figure entered on its own — nothing else names price or qty.' } if (!v) return { value: null, live: false, why: 'Applies to a dollar figure entered on its own — nothing else names price or qty.' }
return { return {

View File

@ -132,7 +132,10 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// which side of price x volume absorbs a dollar-only change: 'price' keeps // which side of price x volume absorbs a dollar-only change: 'price' keeps
// volume flat, 'volume' keeps price flat. Mirrors the Excel form's // volume flat, 'volume' keeps price flat. Mirrors the Excel form's
// Plug Price / Plug Volume choice. // Plug Price / Plug Volume choice.
const [scalePlug, setScalePlug] = useState(() => localStorage.getItem('pf_scale_plug') || 'price') // Price holds unless somebody says otherwise. The key is versioned because the
// default flipped: a browser carrying the old 'price' choice would otherwise
// keep moving price silently, which is the behaviour being corrected.
const [scalePlug, setScalePlug] = useState(() => localStorage.getItem('pf_scale_plug_v2') || 'volume')
// which change-log row has its payload open, if any // which change-log row has its payload open, if any
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 } }
@ -161,7 +164,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
const [panelWidth, setPanelWidth] = useState(() => Number(localStorage.getItem('pf_panel_w')) || 360) const [panelWidth, setPanelWidth] = useState(() => Number(localStorage.getItem('pf_panel_w')) || 360)
const [panelHeight, setPanelHeight] = useState(() => Number(localStorage.getItem('pf_panel_h')) || 260) const [panelHeight, setPanelHeight] = useState(() => Number(localStorage.getItem('pf_panel_h')) || 260)
useEffect(() => { localStorage.setItem('pf_dock', dock) }, [dock]) useEffect(() => { localStorage.setItem('pf_dock', dock) }, [dock])
useEffect(() => { localStorage.setItem('pf_scale_plug', scalePlug) }, [scalePlug]) useEffect(() => { localStorage.setItem('pf_scale_plug_v2', scalePlug) }, [scalePlug])
useEffect(() => { localStorage.setItem('pf_panel_open', panelOpen ? 'open' : 'closed') }, [panelOpen]) useEffect(() => { localStorage.setItem('pf_panel_open', panelOpen ? 'open' : 'closed') }, [panelOpen])
// Esc closes the panel — the usual way out of the floating window // Esc closes the panel — the usual way out of the floating window