|
|
@@ -17,7 +17,9 @@ import { describe, it, expect } from 'vitest'
|
|
|
import {
|
|
|
lineTotal,
|
|
|
markupFromCostAndPrice,
|
|
|
+ isPriceOverridden,
|
|
|
parseQuantity,
|
|
|
+ repricePartRow,
|
|
|
priceFromCostAndMarkup,
|
|
|
priceFromCostAndMultiplier,
|
|
|
readPartsPricingSettings,
|
|
|
@@ -399,3 +401,195 @@ describe('settings to line total, end to end', () => {
|
|
|
expect(catalog).toBe(resolved)
|
|
|
})
|
|
|
})
|
|
|
+
|
|
|
+/**
|
|
|
+ * These drive the reducer the way the editor does: the number inputs fire on
|
|
|
+ * every keystroke, so entering "1500" is four separate edits carrying "1",
|
|
|
+ * "15", "150" and "1500". Testing whole values in one step hides anything that
|
|
|
+ * only goes wrong on the way there, which is exactly how a version of this
|
|
|
+ * shipped that multiplied a price of 1000 up to 1500000.
|
|
|
+ */
|
|
|
+type Row = {
|
|
|
+ unitCost: string | number
|
|
|
+ markupPercent: string | number
|
|
|
+ unitPrice: string | number
|
|
|
+ priceOverridden?: boolean
|
|
|
+}
|
|
|
+
|
|
|
+/** Applies one keystroke, as the editor's updatePart does. */
|
|
|
+function edit(row: Row, field: 'unitCost' | 'markupPercent' | 'unitPrice', value: string): Row {
|
|
|
+ const next = { ...row, [field]: value }
|
|
|
+ return { ...next, ...repricePartRow(next, field) }
|
|
|
+}
|
|
|
+
|
|
|
+/** Leaves the field, as the editor's onBlur does. */
|
|
|
+function commit(row: Row, field: 'unitCost' | 'markupPercent' | 'unitPrice'): Row {
|
|
|
+ return { ...row, ...repricePartRow(row, field, { commit: true }) }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Types a value one character at a time, reducing on each keystroke, and
|
|
|
+ * returns every state the row passes through.
|
|
|
+ *
|
|
|
+ * The intermediate states are the point. Asserting only the settled row is
|
|
|
+ * what let a markup of 99900% ship: it was on screen for three keystrokes and
|
|
|
+ * the final value was correct, so a test of the end state saw nothing wrong.
|
|
|
+ */
|
|
|
+function keystrokes(
|
|
|
+ row: Row,
|
|
|
+ field: 'unitCost' | 'markupPercent' | 'unitPrice',
|
|
|
+ text: string
|
|
|
+): Row[] {
|
|
|
+ const states: Row[] = []
|
|
|
+ let current = row
|
|
|
+ for (let i = 1; i <= text.length; i++) {
|
|
|
+ current = edit(current, field, text.slice(0, i))
|
|
|
+ states.push(current)
|
|
|
+ }
|
|
|
+ return states
|
|
|
+}
|
|
|
+
|
|
|
+/** The row as it settles, once the field is left. */
|
|
|
+function type(row: Row, field: 'unitCost' | 'markupPercent' | 'unitPrice', text: string): Row {
|
|
|
+ const states = keystrokes(row, field, text)
|
|
|
+ return commit(states[states.length - 1] ?? row, field)
|
|
|
+}
|
|
|
+
|
|
|
+const freshRow: Row = { unitCost: 0, markupPercent: 0, unitPrice: 0 }
|
|
|
+
|
|
|
+describe('repricePartRow, typed one keystroke at a time', () => {
|
|
|
+ it('keeps the price when the cost is entered after it', () => {
|
|
|
+ // The reported bug: 1000 entered as the price, then 1500 as the cost.
|
|
|
+ const row = type(type(freshRow, 'unitPrice', '1000'), 'unitCost', '1500')
|
|
|
+ expect(row.unitPrice).toBe(1000)
|
|
|
+ // 1000 sold on a cost of 1500 is a third under cost, not a 99900% margin.
|
|
|
+ expect(row.markupPercent).toBe(-33.3)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('is not disturbed by an intermediate cost that happens to explain the price', () => {
|
|
|
+ // Passing through a cost of 1 makes 1000 exactly what 99900% implies. A
|
|
|
+ // rule that re-reads the numbers each keystroke takes the bait here.
|
|
|
+ const afterFirstDigit = edit(type(freshRow, 'unitPrice', '1000'), 'unitCost', '1')
|
|
|
+ expect(afterFirstDigit.priceOverridden).toBe(true)
|
|
|
+ expect(edit(afterFirstDigit, 'unitCost', '15').unitPrice).toBe(1000)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('reaches the same row whichever of price and cost is entered first', () => {
|
|
|
+ const priceFirst = type(type(freshRow, 'unitPrice', '1000'), 'unitCost', '1500')
|
|
|
+ const costFirst = type(type(freshRow, 'unitCost', '1500'), 'unitPrice', '1000')
|
|
|
+ expect(priceFirst.unitPrice).toBe(costFirst.unitPrice)
|
|
|
+ expect(priceFirst.markupPercent).toBe(costFirst.markupPercent)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('prices from cost while nothing has been typed over it', () => {
|
|
|
+ const row = type(freshRow, 'unitCost', '1500')
|
|
|
+ expect(row.unitPrice).toBe(1500)
|
|
|
+ expect(row.markupPercent).toBe(0)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('follows the cost on a line that is sold at cost', () => {
|
|
|
+ const atCost = type(freshRow, 'unitCost', '50')
|
|
|
+ expect(type(atCost, 'unitCost', '60').unitPrice).toBe(60)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('follows the cost on a line priced by markup', () => {
|
|
|
+ const row = type(type(freshRow, 'unitCost', '50'), 'markupPercent', '100')
|
|
|
+ expect(row.unitPrice).toBe(100)
|
|
|
+ expect(type(row, 'unitCost', '60').unitPrice).toBe(120)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('hands pricing back to the formula when a markup is typed over an override', () => {
|
|
|
+ // Entering a markup says "price this from cost", which supersedes a price
|
|
|
+ // that was typed earlier.
|
|
|
+ const overridden = type(type(freshRow, 'unitPrice', '1000'), 'unitCost', '1500')
|
|
|
+ const remarked = type(overridden, 'markupPercent', '50')
|
|
|
+ expect(remarked.unitPrice).toBe(2250)
|
|
|
+ expect(remarked.priceOverridden).toBe(false)
|
|
|
+ expect(type(remarked, 'unitCost', '2000').unitPrice).toBe(3000)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('survives a cleared field mid-edit', () => {
|
|
|
+ // Selecting the cost and retyping it empties the box first.
|
|
|
+ const row = type(type(freshRow, 'unitPrice', '1000'), 'unitCost', '1500')
|
|
|
+ const cleared = edit(row, 'unitCost', '')
|
|
|
+ expect(cleared.unitPrice).toBe(1000)
|
|
|
+ expect(type(cleared, 'unitCost', '2000').unitPrice).toBe(1000)
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+describe('isPriceOverridden', () => {
|
|
|
+ it('recognises a price that its cost and markup explain', () => {
|
|
|
+ expect(isPriceOverridden({ unitCost: 50, markupPercent: 100, unitPrice: 100 })).toBe(false)
|
|
|
+ expect(isPriceOverridden({ unitCost: 50, markupPercent: 0, unitPrice: 50 })).toBe(false)
|
|
|
+ expect(isPriceOverridden({ unitCost: 0, markupPercent: 0, unitPrice: 0 })).toBe(false)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('recognises a price that they do not', () => {
|
|
|
+ expect(isPriceOverridden({ unitCost: 1500, markupPercent: 0, unitPrice: 1000 })).toBe(true)
|
|
|
+ expect(isPriceOverridden({ unitCost: 0, markupPercent: 0, unitPrice: 1000 })).toBe(true)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('reloads a hand-priced line as still hand-priced', () => {
|
|
|
+ const saved = { unitCost: 1500, markupPercent: -33.3, unitPrice: 1000 }
|
|
|
+ const row: Row = { ...saved, priceOverridden: isPriceOverridden(saved) }
|
|
|
+ expect(type(row, 'unitCost', '2000').unitPrice).toBe(1000)
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+describe('a line priced below its cost', () => {
|
|
|
+ it('is saveable, because the markup schemas accept a negative margin', async () => {
|
|
|
+ // Guards the fix above: entering a price under the cost now yields a
|
|
|
+ // negative markup, and a schema flooring markup at 0 would reject the row
|
|
|
+ // on save rather than at the point it was typed.
|
|
|
+ const { quotePartSchema } = await import('@/features/quotes/Schema/quoteSchema')
|
|
|
+ const { servicePartSchema } = await import('@/features/vehicles/Schema/serviceSchema')
|
|
|
+ const row = {
|
|
|
+ name: 'Brake pads',
|
|
|
+ quantity: 1,
|
|
|
+ unitCost: 1500,
|
|
|
+ markupPercent: -33.3,
|
|
|
+ unitPrice: 1000,
|
|
|
+ total: 1000,
|
|
|
+ }
|
|
|
+ expect(quotePartSchema.parse(row).markupPercent).toBe(-33.3)
|
|
|
+ expect(servicePartSchema.parse(row).markupPercent).toBe(-33.3)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('drops the client-only override flag rather than sending it on', async () => {
|
|
|
+ // It describes how the row was edited, and Prisma would reject the column.
|
|
|
+ const { quotePartSchema } = await import('@/features/quotes/Schema/quoteSchema')
|
|
|
+ const parsed = quotePartSchema.parse({
|
|
|
+ name: 'Brake pads',
|
|
|
+ unitPrice: 1000,
|
|
|
+ priceOverridden: true,
|
|
|
+ })
|
|
|
+ expect(parsed).not.toHaveProperty('priceOverridden')
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+describe('what the markup field shows while a cost is being typed', () => {
|
|
|
+ const withPrice = type(freshRow, 'unitPrice', '1000')
|
|
|
+
|
|
|
+ it('never shows a margin invented from a half-typed cost', () => {
|
|
|
+ // Typing 1500 passes through 1, 15 and 150. Deriving a margin from those
|
|
|
+ // put 99900% on screen, then 6566.7%, then 566.7%.
|
|
|
+ const shown = keystrokes(withPrice, 'unitCost', '1500').map((r) => r.markupPercent)
|
|
|
+ expect(shown).toEqual([0, 0, 0, 0])
|
|
|
+ })
|
|
|
+
|
|
|
+ it('states the real margin once the field is left', () => {
|
|
|
+ expect(type(withPrice, 'unitCost', '1500').markupPercent).toBe(-33.3)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('holds the price throughout', () => {
|
|
|
+ const shown = keystrokes(withPrice, 'unitCost', '1500').map((r) => r.unitPrice)
|
|
|
+ expect(shown).toEqual([1000, 1000, 1000, 1000])
|
|
|
+ })
|
|
|
+
|
|
|
+ it('still tracks the cost live when the price is following it', () => {
|
|
|
+ // Nothing typed over the price, so it should mirror the cost as it is
|
|
|
+ // entered rather than sitting still.
|
|
|
+ const shown = keystrokes(freshRow, 'unitCost', '1500').map((r) => r.unitPrice)
|
|
|
+ expect(shown).toEqual([1, 15, 150, 1500])
|
|
|
+ })
|
|
|
+})
|