This commit enhances the stock item management by introducing automatic calculation of selling price and markup based on the cost price. Key changes include: - Integration of `applyDefaultMarkupToPatch` to automatically set `selling_price` and `markup` at 80% of the cost price when they are not explicitly provided during stock item creation and updates. - Updates to the `create` and `update` functions in the stock items service to ensure that existing values are respected and not overwritten if they are already set by the operator. - Tests added to verify the correct behavior of auto-markup during both creation and updates of stock items, ensuring that the functionality works as intended across various scenarios. This feature aims to streamline the pricing process for stock items, reducing manual input and potential errors.
220 lines
9.5 KiB
TypeScript
220 lines
9.5 KiB
TypeScript
/**
|
|
* Stock default-markup helper — auto-populate `selling_price` and
|
|
* `markup` on any `stock_items` write that leaves those fields at
|
|
* zero (or absent) while setting a positive `cost_price`.
|
|
*
|
|
* Why this exists
|
|
* ───────────────
|
|
* The catalogue has a long-standing gap: rows that have a known
|
|
* `cost_price` but never had `selling_price` / `markup` set. These
|
|
* items have a known input cost but no shelf price — the till
|
|
* can't ring them up. The companion `scripts/apply-default-markup.ts`
|
|
* CLI backfills those rows in bulk; this helper does the same
|
|
* thing inline for every service-side write that creates or
|
|
* updates a stock_item.
|
|
*
|
|
* The helper is pure (no PB I/O). Callers merge its output into
|
|
* the patch they're about to send to PB; the helper decides
|
|
* "should this patch also set selling_price + markup?" and
|
|
* (optionally) bootstraps the `cost_history` array when a brand-
|
|
* new row is being given a cost for the first time.
|
|
*
|
|
* Rules (mirrored exactly from `scripts/apply-default-markup.ts`
|
|
* — keep the two in lockstep)
|
|
* ─────
|
|
* 1. Helper fires ONLY when the post-write `cost_price` is > 0.
|
|
* Without a cost, there's nothing to base a markup on.
|
|
* 2. Helper fires ONLY when the post-write `selling_price` is
|
|
* zero or absent (no operator override of the shelf price).
|
|
* 3. Helper fires ONLY when the post-write `markup` is zero or
|
|
* absent (no operator override of the POS markup field).
|
|
* 4. Never overwrite a non-zero `selling_price` or `markup`
|
|
* anywhere — neither on the existing row, nor in the patch.
|
|
* Idempotency is the whole point.
|
|
* 5. Optionally seeds `cost_history` with a single entry when:
|
|
* - the proposed patch sets `cost_price` to a positive value, AND
|
|
* - the existing row has no `cost_history` at all (null/undefined
|
|
* or empty array), AND
|
|
* - the patch hasn't already set a non-empty `cost_history`
|
|
* (the patch wins — if the caller is appending to an existing
|
|
* history, don't overwrite it).
|
|
* When the existing row already has history, preserve it (the
|
|
* booking flow keeps appending to it). When the patch doesn't
|
|
* set `cost_price` but the existing row has one, leave the
|
|
* existing history alone.
|
|
* 6. The amount we compare for the cost_history bootstrap uses
|
|
* the post-write `cost_price` (i.e. the patch's value if
|
|
* provided, otherwise the existing row's value).
|
|
*
|
|
* Defaults — keep these aligned with the operator script
|
|
* ──────────────────────────────────────────────────────
|
|
* `DEFAULT_MARKUP_PERCENT = 80` and `DEFAULT_MARKUP_MULTIPLIER
|
|
* = 1.80`. Tweak only this one constant if the rate changes;
|
|
* re-run the operator script with `--commit` to backfill any
|
|
* existing rows that pre-date the change.
|
|
*/
|
|
import type { StockCostHistoryEntry } from "@/lib/types";
|
|
|
|
export const DEFAULT_MARKUP_PERCENT = 80;
|
|
export const DEFAULT_MARKUP_MULTIPLIER = 1.80;
|
|
|
|
/**
|
|
* Matches the seed shape used by `stock-quick-draft-service.ts` and
|
|
* `/api/v1/stock/items/create` — `purchase_id` is the empty string
|
|
* for "we knew the price when we typed it, no PO row to point at
|
|
* yet"; the accountant's booking flow appends a second entry with a
|
|
* real `purchase_id` when the supplier invoice is booked.
|
|
*/
|
|
export const DEFAULT_COST_HISTORY_PURCHASE_ID = "";
|
|
|
|
/**
|
|
* The patch shape every caller hands us. Loose so callers can pass
|
|
* an existing `Record<string, unknown>` (the same shape `pb.update`
|
|
* and `pb.create` accept) without us forcing them to cast.
|
|
*/
|
|
export type StockPricingPatch = Record<string, unknown>;
|
|
|
|
/**
|
|
* The minimal existing-row shape the helper needs. Every field is
|
|
* optional because (a) on a create there's no existing row at all
|
|
* (pass `undefined`), and (b) PB's typed SDK may surface unset
|
|
* numerics as `null` / `undefined`. The helper normalises those
|
|
* to zero internally.
|
|
*/
|
|
export type ExistingStockPricingRow = {
|
|
cost_price?: number | null | undefined;
|
|
selling_price?: number | null | undefined;
|
|
markup?: number | null | undefined;
|
|
cost_history?: unknown;
|
|
};
|
|
|
|
/** Round to 2 decimal places (R cents). Uses the rounding-to-even
|
|
* behaviour of `Math.round` after a scale shift — sufficient for
|
|
* currency display; PB stores the number as a plain float
|
|
* underneath. Mirrors `apply-default-markup.ts`. */
|
|
export function round2(n: number): number {
|
|
if (!Number.isFinite(n)) return 0;
|
|
return Math.round(n * 100) / 100;
|
|
}
|
|
|
|
/**
|
|
* Decide whether the proposed write should auto-populate
|
|
* `selling_price` and `markup`, and (optionally) seed
|
|
* `cost_history` on a brand-new row that has never had a cost
|
|
* history.
|
|
*
|
|
* Pure: no `Date.now()` at module level. The `now` argument lets
|
|
* tests pin the `purchased_at` timestamp; production callers pass
|
|
* `undefined` to get the current wall-clock.
|
|
*
|
|
* The function MUTATES the input patch (returning the same
|
|
* reference for chaining convenience) — callers that want a
|
|
* fresh object should pass a clone. We mutate (instead of
|
|
* returning a new object) so the existing test pattern of
|
|
* "inspect the captured patch" at the PB boundary keeps working
|
|
* with one fewer copy step.
|
|
*
|
|
* @param patch The patch the caller is about to send to PB.
|
|
* May already set `selling_price` / `markup` /
|
|
* `cost_history` / `cost_price` — those values
|
|
* are honoured. The function ONLY writes the
|
|
* pricing fields when they're zero or absent.
|
|
* @param existing The row being updated, or `undefined` on
|
|
* create. Used for the idempotency check
|
|
* (don't clobber an operator-set price) and
|
|
* for the cost_history bootstrap (only seed
|
|
* when the row has never had a history).
|
|
* @param now ISO timestamp for the optional `cost_history`
|
|
* seed entry's `purchased_at`. Defaults to
|
|
* `new Date().toISOString()`.
|
|
*/
|
|
export function applyDefaultMarkupToPatch(
|
|
patch: StockPricingPatch,
|
|
existing?: ExistingStockPricingRow,
|
|
now?: string,
|
|
): StockPricingPatch {
|
|
// 1. Resolve the post-write cost_price. The patch wins if it
|
|
// sets the field; otherwise we use the existing row's
|
|
// value. This is the "what cost are we committing to on
|
|
// this write?" — the helper fires only when that's > 0.
|
|
const patchedCostRaw = patch.cost_price;
|
|
const existingCost = existing ? Number(existing.cost_price ?? 0) : 0;
|
|
const postWriteCost =
|
|
patchedCostRaw === undefined ? existingCost : Number(patchedCostRaw ?? 0);
|
|
|
|
// Guard 1: no cost → no markup. Nothing to base a price on.
|
|
if (!(postWriteCost > 0)) {
|
|
return patch;
|
|
}
|
|
|
|
// 2. Resolve the post-write selling_price. Patch wins;
|
|
// otherwise the existing row's value (or 0).
|
|
const patchedSellingRaw = patch.selling_price;
|
|
const existingSelling = existing ? Number(existing.selling_price ?? 0) : 0;
|
|
const postWriteSelling =
|
|
patchedSellingRaw === undefined
|
|
? existingSelling
|
|
: Number(patchedSellingRaw ?? 0);
|
|
|
|
// Resolve the post-write markup. Same pattern.
|
|
const patchedMarkupRaw = patch.markup;
|
|
const existingMarkup = existing ? Number(existing.markup ?? 0) : 0;
|
|
const postWriteMarkup =
|
|
patchedMarkupRaw === undefined
|
|
? existingMarkup
|
|
: Number(patchedMarkupRaw ?? 0);
|
|
|
|
// Guard 2 + 3: operator override on the shelf price OR the
|
|
// POS markup field short-circuits the helper. We never
|
|
// overwrite either — they're both authoritative the moment
|
|
// they go non-zero.
|
|
const patchSetsSellingExplicitly =
|
|
patchedSellingRaw !== undefined && postWriteSelling > 0;
|
|
const patchSetsMarkupExplicitly =
|
|
patchedMarkupRaw !== undefined && postWriteMarkup > 0;
|
|
const existingSellingAlreadySet = postWriteSelling > 0;
|
|
const existingMarkupAlreadySet = postWriteMarkup > 0;
|
|
|
|
const shouldAutoMarkup =
|
|
!patchSetsSellingExplicitly &&
|
|
!patchSetsMarkupExplicitly &&
|
|
!existingSellingAlreadySet &&
|
|
!existingMarkupAlreadySet;
|
|
|
|
if (shouldAutoMarkup) {
|
|
patch.selling_price = round2(postWriteCost * DEFAULT_MARKUP_MULTIPLIER);
|
|
patch.markup = DEFAULT_MARKUP_PERCENT;
|
|
}
|
|
|
|
// 3. Optionally seed cost_history. Only when:
|
|
// - the PATCH is setting cost_price to a positive value
|
|
// (not when we're just inheriting an existing cost), AND
|
|
// - the existing row has no cost_history at all, AND
|
|
// - the patch itself hasn't already set a non-empty
|
|
// `cost_history` array (the patch wins — if the caller is
|
|
// appending to an existing history, don't overwrite it).
|
|
// The seed entry uses the post-write cost (so the patch's
|
|
// value wins on a create, the existing row's wins on an
|
|
// update). `purchase_id` is the empty string for the seed —
|
|
// the booking flow will append a real `purchase_id` later.
|
|
const existingHistory = existing?.cost_history;
|
|
const hasExistingHistory =
|
|
Array.isArray(existingHistory) && existingHistory.length > 0;
|
|
const patchIsSettingCost =
|
|
patchedCostRaw !== undefined && postWriteCost > 0;
|
|
const patchOwnsHistory =
|
|
Array.isArray(patch.cost_history) &&
|
|
(patch.cost_history as unknown[]).length > 0;
|
|
|
|
if (patchIsSettingCost && !hasExistingHistory && !patchOwnsHistory) {
|
|
const seedEntry: StockCostHistoryEntry = {
|
|
cost_price: postWriteCost,
|
|
quantity: 0,
|
|
purchased_at: now ?? new Date().toISOString(),
|
|
purchase_id: DEFAULT_COST_HISTORY_PURCHASE_ID,
|
|
};
|
|
patch.cost_history = [seedEntry];
|
|
}
|
|
|
|
return patch;
|
|
}
|