diff --git a/routes/operations.js b/routes/operations.js index b25bfe9..dff4942 100644 --- a/routes/operations.js +++ b/routes/operations.js @@ -331,10 +331,14 @@ module.exports = function(pool) { const unitsGiven = [tUnits, uIncr, uPct].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) { - const curValue = totals.value + fixedValue; - const curUnits = totals.units + fixedUnits; - if (curValue === 0) { + if (totals.value === 0) { const err = new Error( '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 ' + @@ -343,8 +347,8 @@ module.exports = function(pool) { err.status = 400; throw err; } // price constant means value and units move by the same proportion: - // fVol = curVol * (fVal / curVal), so the units delta is curVol * value/curVal - units = curUnits * (value / curValue); + // fVol = adjVol * (fVal / adjVal), so the units delta is adjVol * value/adjVal + units = totals.units * (value / totals.value); } // 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 // dollars alongside the qty (then price falls out of the two), or name // the price outright. + // Again at the adjustable rows' own price, for the reason given above. 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; + if (totals.units !== 0) value = (totals.value / totals.units) * units; } // 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. 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 @@ -874,7 +889,7 @@ module.exports = function(pool) { slices: unit.slices, apply_mode: applyMode, ...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)), where_clause: unit.where, diff --git a/ui/src/components/OperationPanel.jsx b/ui/src/components/OperationPanel.jsx index 77d4c98..277a9a4 100644 --- a/ui/src/components/OperationPanel.jsx +++ b/ui/src/components/OperationPanel.jsx @@ -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. // Pinning the dollars as well is how a price move is asked for, and it // 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') - newValue = (curUnits === 0 || newUnits == null) + newValue = (total.units === 0 || newUnits == null) ? curValue - : curValue * (newUnits / curUnits) + : curValue + total.value * ((newUnits - curUnits) / total.units) } else { newValue = filled('value') ? numOf('value', 'new') : curValue if (filled('units')) newUnits = numOf('units', 'new') - else if (dollarsOnly && scalePlug === 'volume') newUnits = curValue === 0 ? null - : curUnits * (newValue / curValue) + else if (dollarsOnly && scalePlug === 'volume') newUnits = total.value === 0 ? null + : curUnits + total.units * ((newValue - curValue) / total.value) else newUnits = curUnits } diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index dfbd0f7..bb6bd9b 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -598,9 +598,12 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio ...Object.entries(sliceObj) .filter(([col]) => COMPUTED_SLICE_COLS.has(col) || dimNames.has(col) || dateNames.has(col) || schema[col] !== undefined) - // a cell inside the filtered view cannot contradict it, so a repeated - // column is the same predicate twice and harmless - .filter(([col]) => !viewFilter.some(f => f[0] === col)) + // Only an `==` on the same value is a repeat. Any other filter on the + // column is wider than the cell -- `partd contains 'Keystone'` with one + // 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 // on type, and a null threshold against a string column is the same // dropped predicate that widened the slice in the first place. The