diff --git a/CLAUDE.md b/CLAUDE.md index c27ce20..f8f1f0f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/routes/operations.js b/routes/operations.js index 04d22b7..c4a5107 100644 --- a/routes/operations.js +++ b/routes/operations.js @@ -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 }; diff --git a/ui/src/components/OperationPanel.jsx b/ui/src/components/OperationPanel.jsx index 723f8af..629d9f6 100644 --- a/ui/src/components/OperationPanel.jsx +++ b/ui/src/components/OperationPanel.jsx @@ -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 { diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index cee9086..eb96dc5 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -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