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.
### 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
`plug` — `'price'` (default, volume holds) or `'volume'` (price holds, units scale
in proportion). Resolved in `resolveIncrs()` in `routes/operations.js`; the panel
only offers it when the edit is dollars-only, because naming units or price has
already answered it. The semantics come from the predecessor Excel model,
`/opt/forecast_api/VBA/fpvt.frm` → `calc_val` / `calc_price`:
`plug` — `'volume'` (the default: price holds, units scale in proportion) or
`'price'` (volume holds, price moves). Resolved in `resolveIncrs()` in
`routes/operations.js`; the panel only *offers* it when the edit is dollars-only,
because naming units or price has already answered it, but it stays on screen
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 price: fVol = pVol + bVol
```
A `target_price` with a `target_units` alongside is that form's Edit Price mode,
where 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.
**A qty input follows the same rule.** With dollars unnamed they follow at the
current price rather than holding — `value = (curValue / curUnits) * unitsIncr`.
Holding them made every qty edit a price edit: 10% more cases at the same revenue
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
`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.
//
// '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.
// 'volume' — price holds, so volume scales with the dollars. The default.
// 'price' — volume holds, so price moves.
//
// 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';
//
// '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 valueGiven = [tValue, vIncr, vPct].some(v => v !== null);
if (plug === 'volume' && value !== 0 && !unitsGiven) {
const curValue = totals.value + fixedValue;
@ -293,6 +299,25 @@ module.exports = function(pool) {
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
// nothing to prorate across and the increment would vanish anyway
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')
newUnits = filled('units') ? numOf('units', 'new') : curUnits
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 {
newValue = filled('value') ? numOf('value', 'new') : curValue
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
// and the toggle is answering for them. 'price' means price absorbs the
// change, so qty holds; 'volume' means qty absorbs it, so price holds.
const heldKey = (valueCol && unitsCol && dollarsOnly)
? (scalePlug === 'price' ? 'units' : 'price')
const unitsOnly = filled('units') && !filled('value') && !filled('price')
const heldKey = !(valueCol && unitsCol) ? null
: dollarsOnly ? (scalePlug === 'price' ? 'units' : 'price')
: unitsOnly ? 'price'
: null
// 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
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) 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 (!v) return { value: null, live: false, why: 'Applies to a dollar figure entered on its own — nothing else names price or qty.' }
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
// 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')
// 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
const [expandedLog, setExpandedLog] = useState(null)
// 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 [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_scale_plug_v2', scalePlug) }, [scalePlug])
useEffect(() => { localStorage.setItem('pf_panel_open', panelOpen ? 'open' : 'closed') }, [panelOpen])
// Esc closes the panel — the usual way out of the floating window