Hold the adjustable rows' own price on a dollar edit, and log the method

Under target_basis 'selected', resolveIncrs sized the units increment at
the price of adjustable and fixed (reference) rows blended, while the scale
SQL prorates it over the adjustable rows only. Qty therefore moved by a
different factor than dollars wherever the fixed rows were priced
differently, and a slice taken to its fixed total -- dollars to zero --
drove qty past zero: grains with positive sales and negative qty. Both the
dollar-edit and qty-edit paths now hold the adjustable rows' own price, and
the ledger's Result preview does the same so it still matches the write.

Each scale now records resolved.method (volume / price / both). The plug is
a per-browser preference and was never logged, so a price edit could only
be found afterwards by noticing qty had not moved.

The ledger also dropped a selected cell's own column whenever the pivot's
filter named that column, assuming the two could not disagree. Under
`partd contains 'Keystone'` a click on one part totalled every Keystone
part. It now drops only an exact `==` repeat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-28 21:05:51 -04:00
parent 0c9746d3be
commit 870f08f7de
3 changed files with 39 additions and 17 deletions

View File

@ -331,10 +331,14 @@ module.exports = function(pool) {
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); const valueGiven = [tValue, vIncr, vPct].some(v => v !== null);
// The price held is the *adjustable* rows' own, never a blend with the
// fixed ones: the scale SQL prorates both increments over the adjustable
// rows only, so a units increment sized at the blended price cuts qty
// faster than dollars whenever the fixed rows are priced differently.
// Taking a slice to its fixed total -- dollars to zero -- then drove qty
// past zero, leaving grains with positive sales and negative qty.
if (plug === 'volume' && value !== 0 && !unitsGiven) { if (plug === 'volume' && value !== 0 && !unitsGiven) {
const curValue = totals.value + fixedValue; if (totals.value === 0) {
const curUnits = totals.units + fixedUnits;
if (curValue === 0) {
const err = new Error( const err = new Error(
'Cannot hold price constant here: the selection currently has no value, ' + '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 ' + 'so there is no price to hold. Scale units directly, or let price absorb ' +
@ -343,8 +347,8 @@ module.exports = function(pool) {
err.status = 400; throw err; err.status = 400; throw err;
} }
// price constant means value and units move by the same proportion: // price constant means value and units move by the same proportion:
// fVol = curVol * (fVal / curVal), so the units delta is curVol * value/curVal // fVol = adjVol * (fVal / adjVal), so the units delta is adjVol * value/adjVal
units = curUnits * (value / curValue); units = totals.units * (value / totals.value);
} }
// The same question asked of a qty input, and the answer has to match: // The same question asked of a qty input, and the answer has to match:
@ -357,13 +361,12 @@ module.exports = function(pool) {
// deliberate forms are still available and both outrank this: name 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 // dollars alongside the qty (then price falls out of the two), or name
// the price outright. // the price outright.
// Again at the adjustable rows' own price, for the reason given above.
if (units !== 0 && !valueGiven && tPrice === null) { 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 // 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 // and the edit is a pure qty change -- adding units to a slice that
// has none cannot infer what they are worth // has none cannot infer what they are worth
if (curUnits !== 0) value = (curValue / curUnits) * units; if (totals.units !== 0) value = (totals.value / totals.units) * 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
@ -377,7 +380,19 @@ module.exports = function(pool) {
// offset each other is the usual cause, and 'each' handles that correctly. // offset each other is the usual cause, and 'each' handles that correctly.
assertProratable(totals, value, units); assertProratable(totals, value, units);
return { value: round(value, 6), units: round(units, 6) }; // Which of price or volume the edit moved, written onto the log entry.
// The request's own fields do not say -- `plug` is a per-browser
// preference, and an unqualified dollar figure is a price edit or a
// volume edit depending on it -- so without this the log could not tell
// the two apart, and the only way to find a price edit afterwards was to
// notice qty had not moved.
const method =
tPrice !== null && tValue === null ? 'price' // price named
: unitsGiven && valueGiven ? 'both' // dollars and qty named, price falls out
: unitsGiven ? 'volume' // qty named, dollars follow at price
: plug; // dollars only: the plug decides
return { value: round(value, 6), units: round(units, 6), method };
} }
// a pool is proratable only if its net is a meaningful fraction of its gross // a pool is proratable only if its net is a meaningful fraction of its gross
@ -874,7 +889,7 @@ module.exports = function(pool) {
slices: unit.slices, slices: unit.slices,
apply_mode: applyMode, apply_mode: applyMode,
...pickIntent(req.body), ...pickIntent(req.body),
resolved: { value_incr: incr.value, units_incr: incr.units } resolved: { value_incr: incr.value, units_incr: incr.units, method: incr.method }
})), })),
slice: esc(JSON.stringify(loggedSlice)), slice: esc(JSON.stringify(loggedSlice)),
where_clause: unit.where, where_clause: unit.where,

View File

@ -748,15 +748,19 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
// 10% more cases at the same revenue is a 9% price cut nobody asked for. // 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 // Pinning the dollars as well is how a price move is asked for, and it
// takes the branch below instead. // takes the branch below instead.
//
// The price held is the adjustable rows' own (`total`), not the blend with
// the fixed segments -- the write prorates over the adjustable rows only,
// and the server resolves it the same way (resolveIncrs).
newUnits = numOf('units', 'new') newUnits = numOf('units', 'new')
newValue = (curUnits === 0 || newUnits == null) newValue = (total.units === 0 || newUnits == null)
? curValue ? curValue
: curValue * (newUnits / curUnits) : curValue + total.value * ((newUnits - curUnits) / total.units)
} 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')
else if (dollarsOnly && scalePlug === 'volume') newUnits = curValue === 0 ? null else if (dollarsOnly && scalePlug === 'volume') newUnits = total.value === 0 ? null
: curUnits * (newValue / curValue) : curUnits + total.units * ((newValue - curValue) / total.value)
else newUnits = curUnits else newUnits = curUnits
} }

View File

@ -598,9 +598,12 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
...Object.entries(sliceObj) ...Object.entries(sliceObj)
.filter(([col]) => COMPUTED_SLICE_COLS.has(col) || dimNames.has(col) || dateNames.has(col) .filter(([col]) => COMPUTED_SLICE_COLS.has(col) || dimNames.has(col) || dateNames.has(col)
|| schema[col] !== undefined) || schema[col] !== undefined)
// a cell inside the filtered view cannot contradict it, so a repeated // Only an `==` on the same value is a repeat. Any other filter on the
// column is the same predicate twice and harmless // column is wider than the cell -- `partd contains 'Keystone'` with one
.filter(([col]) => !viewFilter.some(f => f[0] === col)) // part clicked -- and dropping the cell's own predicate for it totalled
// every part the filter admits. Perspective ANDs two on one column.
.filter(([col, val]) => !viewFilter.some(f =>
f[0] === col && f[1] === '==' && val !== null && String(f[2]) === String(val)))
// A null is filtered with `is null`, not `== null`: the engine matches // A null is filtered with `is null`, not `== null`: the engine matches
// on type, and a null threshold against a string column is the same // on type, and a null threshold against a string column is the same
// dropped predicate that widened the slice in the first place. The // dropped predicate that widened the slice in the first place. The