Found by driving the real app in a browser as David Els, not by reading the code. Two of these were invisible to typecheck, build and 3670 unit tests. 1. The action icons were clipped off the right edge `preview-dn` and `download-pdf` were the SAME action — on the list both opened the DN PDF preview, on the detail both called `setPreviewOpen(true)`. A duplicate I had introduced in the canonical set. Even after removing it the row still overflowed: the Company column was ~294px, the widest thing in the table, and every row already sits inside a company group header spelling the same name out. Removing the column removed the redundancy AND the overflow. Table is now 1140 in a 1140 container, 0px clipped. 2. Entering a first cost produced a ZERO-MARGIN line A counter note opens with no cost on file, so the line starts at cost 0 / markup 0 / selling 0. Typing a cost left the markup at 0, which derived a selling price exactly equal to the cost — a line that looks priced and earns nothing, on the commonest path in this workflow. Entering a first cost now seeds the default markup, so the operator gets R125 selling on R100 cost and a visible 20% margin. Seeding only happens when the line had no cost AND no markup, so it can never overwrite a margin someone chose. Verified in the browser: cost 100 -> markup 25 -> selling R125,00 -> line total R1 250,00 -> margin R250,00 (20.0%). The 20% on a 25% markup is the distinction the whole pricing module exists for, and it renders correctly. 3. The actions column also gets `w-px whitespace-nowrap` so the cell sizes to its icons instead of being squeezed by its neighbours. Also pinned with 6 tests (repriceWithCostEntry): seeds on first cost, yields a margin, does NOT reseed on an already-costed line, does not override an explicit 0, collapses when the cost is cleared, and is a no-op without a cost. Verified they bite: removing the seed fails 2. Known limitation, reported rather than hidden: item DESCRIPTIONS are not searchable. The list endpoint returns headers only and never loads items, so "brazing" finds nothing while "kobus" (the customer on the same row) finds 9. Making descriptions searchable means loading items for every row. Full suite 3676 passing, tsc clean, build compiles.
223 lines
7.3 KiB
TypeScript
223 lines
7.3 KiB
TypeScript
/**
|
|
* Quote pricing maths for the "create quote from a delivery note"
|
|
* review modal.
|
|
*
|
|
* Pulled into its own module because this is the one place in the
|
|
* create-quote flow where a wrong answer is invisible: a selling
|
|
* price that's out by a few cents still renders as a plausible
|
|
* number, the total still sums, and nothing anywhere says "error".
|
|
* The operator only finds out when the customer is invoiced.
|
|
*
|
|
* The house rule, used consistently across `quotes.create`,
|
|
* `bulk_update_quote_items` and `quote_items.create`:
|
|
*
|
|
* selling = cost x (1 + markup / 100)
|
|
*
|
|
* Markup is the INPUT; selling price is DERIVED. Letting both be
|
|
* typed independently is how a 25% markup and a 30% selling price
|
|
* end up disagreeing on the same line with nothing to flag it.
|
|
*/
|
|
|
|
/** Round to 2dp, avoiding the float drift that makes 100*1.15 come
|
|
* out as 114.99999999999999. */
|
|
export function round2(value: number): number {
|
|
if (!Number.isFinite(value)) return 0;
|
|
return Math.round((value + Number.EPSILON) * 100) / 100;
|
|
}
|
|
|
|
/**
|
|
* Selling price for a cost + markup pair. Markup is a percentage, so
|
|
* 25 means 25% (i.e. cost * 1.25), NOT 25x — the same convention the
|
|
* rest of the codebase uses.
|
|
*/
|
|
export function sellingFromCostAndMarkup(
|
|
cost: number,
|
|
markupPercent: number,
|
|
): number {
|
|
const costNum = Number(cost) || 0;
|
|
const markupNum = Number(markupPercent) || 0;
|
|
return round2(costNum * (1 + markupNum / 100));
|
|
}
|
|
|
|
/**
|
|
* The markup percentage implied by a cost + selling pair. The inverse
|
|
* of the above. Returns 0 for a zero cost rather than dividing by
|
|
* zero — a line with no cost price genuinely has no margin to quote.
|
|
*/
|
|
export function markupFromCostAndSelling(
|
|
cost: number,
|
|
selling: number,
|
|
): number {
|
|
const costNum = Number(cost) || 0;
|
|
if (costNum <= 0) return 0;
|
|
const sellingNum = Number(selling) || 0;
|
|
return round2(((sellingNum - costNum) / costNum) * 100);
|
|
}
|
|
|
|
/** True when a line has a usable, fixed cost price to work from. */
|
|
export function hasCostPrice(cost: number | null | undefined): boolean {
|
|
const n = Number(cost);
|
|
return Number.isFinite(n) && n > 0;
|
|
}
|
|
|
|
/**
|
|
* Default markup to seed a line with when the operator hasn't chosen
|
|
* one. Deliberately a constant rather than a per-customer or
|
|
* per-category lookup: there is no markup table in the schema today,
|
|
* and inventing a "smart" default would be guessing at money.
|
|
*/
|
|
export const DEFAULT_MARKUP_PERCENT = 25;
|
|
|
|
export interface LinePricing {
|
|
orderItemId: string;
|
|
description: string;
|
|
quantity: number;
|
|
/** Cost carried from the order item. 0 means "nothing on file". */
|
|
costPrice: number;
|
|
/** Operator-editable. */
|
|
markupPercent: number;
|
|
/** Derived from cost + markup; never typed independently. */
|
|
sellingPrice: number;
|
|
}
|
|
|
|
export interface LineTotals {
|
|
costTotal: number;
|
|
sellingTotal: number;
|
|
/** sellingTotal - costTotal */
|
|
marginValue: number;
|
|
/** margin as a percentage of selling total. 0 when there's no revenue. */
|
|
marginPercent: number;
|
|
}
|
|
|
|
/** Roll a set of lines up into the footer figures. */
|
|
export function totalsForLines(lines: LinePricing[]): LineTotals {
|
|
let costTotal = 0;
|
|
let sellingTotal = 0;
|
|
for (const line of lines) {
|
|
const qty = Number(line.quantity) || 0;
|
|
costTotal += (Number(line.costPrice) || 0) * qty;
|
|
sellingTotal += (Number(line.sellingPrice) || 0) * qty;
|
|
}
|
|
const marginValue = round2(sellingTotal - costTotal);
|
|
return {
|
|
costTotal: round2(costTotal),
|
|
sellingTotal: round2(sellingTotal),
|
|
marginValue,
|
|
marginPercent:
|
|
sellingTotal > 0 ? round2((marginValue / sellingTotal) * 100) : 0,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build the opening price grid for a delivery note's lines.
|
|
*
|
|
* `fallbackMarkup` is applied only where a real cost exists — a line
|
|
* with no cost gets markup 0 and selling 0, because deriving a selling
|
|
* price from a zero cost produces a confidently wrong R0.00 that looks
|
|
* like a deliberate free-of-charge item. Those lines are surfaced as
|
|
* "no cost on file" in the UI instead.
|
|
*/
|
|
export function buildLinePricing(
|
|
items: Array<{
|
|
id: string;
|
|
description?: string | null;
|
|
quantity?: number | null;
|
|
cost_price?: number | null;
|
|
selling_price?: number | null;
|
|
}>,
|
|
fallbackMarkup: number = DEFAULT_MARKUP_PERCENT,
|
|
): LinePricing[] {
|
|
return items.map((it) => {
|
|
const costPrice = round2(Number(it.cost_price) || 0);
|
|
const quantity = Number(it.quantity) || 1;
|
|
|
|
// A line that already carries a selling price keeps it — the
|
|
// office may have priced it deliberately before the DN existed,
|
|
// and silently re-marking it to a default would override a human
|
|
// decision. Only cost-only lines get the default markup.
|
|
const existingSelling = round2(Number(it.selling_price) || 0);
|
|
const hasCost = hasCostPrice(costPrice);
|
|
|
|
if (existingSelling > 0) {
|
|
return {
|
|
orderItemId: it.id,
|
|
description: it.description ?? "",
|
|
quantity,
|
|
costPrice,
|
|
markupPercent: markupFromCostAndSelling(costPrice, existingSelling),
|
|
sellingPrice: existingSelling,
|
|
};
|
|
}
|
|
|
|
const markupPercent = hasCost ? fallbackMarkup : 0;
|
|
return {
|
|
orderItemId: it.id,
|
|
description: it.description ?? "",
|
|
quantity,
|
|
costPrice,
|
|
markupPercent,
|
|
sellingPrice: hasCost
|
|
? sellingFromCostAndMarkup(costPrice, markupPercent)
|
|
: 0,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Re-derive one line's selling price after its cost or markup changed.
|
|
* Kept as a pure function so the modal's edit handler is a single
|
|
* call and the derivation rule lives in exactly one place.
|
|
*/
|
|
export function repriceLine(
|
|
line: LinePricing,
|
|
patch: { costPrice?: number; markupPercent?: number },
|
|
): LinePricing {
|
|
const costPrice =
|
|
patch.costPrice !== undefined ? round2(Number(patch.costPrice) || 0) : line.costPrice;
|
|
const markupPercent =
|
|
patch.markupPercent !== undefined
|
|
? Number(patch.markupPercent) || 0
|
|
: line.markupPercent;
|
|
return {
|
|
...line,
|
|
costPrice,
|
|
markupPercent,
|
|
sellingPrice: hasCostPrice(costPrice)
|
|
? sellingFromCostAndMarkup(costPrice, markupPercent)
|
|
: 0,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Entering the FIRST cost on a line seeds the default markup.
|
|
*
|
|
* Found in the browser, not in a test: a counter note arrives with no
|
|
* cost on file, so the line opens at cost 0 / markup 0 / selling 0.
|
|
* Typing a cost used to leave the markup at 0, which derived a selling
|
|
* price exactly equal to the cost — a line that *looked* priced, with
|
|
* a margin of R0.00. That is the worst possible default: the operator
|
|
* sees a plausible number and has been told the selling price is set.
|
|
*
|
|
* Seeding only happens when the line had no cost AND no markup yet, so
|
|
* it can never overwrite a margin the operator chose, and never
|
|
* touches a line that already had a price.
|
|
*/
|
|
export function repriceWithCostEntry(
|
|
line: LinePricing,
|
|
patch: { costPrice?: number; markupPercent?: number },
|
|
fallbackMarkup: number = DEFAULT_MARKUP_PERCENT,
|
|
): LinePricing {
|
|
const wasUncosted = !hasCostPrice(line.costPrice);
|
|
const enteringFirstCost =
|
|
patch.costPrice !== undefined &&
|
|
wasUncosted &&
|
|
hasCostPrice(patch.costPrice);
|
|
// An explicit markup in the same patch always wins.
|
|
const seeding =
|
|
enteringFirstCost && patch.markupPercent === undefined && line.markupPercent === 0;
|
|
|
|
return repriceLine(line, {
|
|
...patch,
|
|
...(seeding ? { markupPercent: fallbackMarkup } : {}),
|
|
});
|
|
}
|