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:
parent
9e880a65be
commit
7fd07e52d2
44
CLAUDE.md
44
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.
|
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.
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user