WFDASH-WEBAPP/src/lib/server/price-list-service.ts
Dave 7443776a14 feat(stock): seed pricing fields on create + sync price_list to stock_items
Closes the pre-Fix-#1 stock pricing gap so the till and the POS see
prices on day one.

What's new:

- price-list-service: addPriceListItems now mirrors the upserted
  selling_price + markup onto the matching stock_items row when
  (and only when) the stock row has no price yet. Sync failures
  are returned as warnings — never as a failed price_list write,
  so a single broken stock row can't poison a 500-item batch.
- stock-quick-draft-service: addQuickDraftLine seeds cost_price +
  bootstraps a one-entry cost_history on inline-create when the
  buyer supplies newStockItem.costPrice. Existing-item path
  backfills cost_price when zero and always appends a
  cost_history entry so the line addition shows up in the audit
  log without waiting on bookStockFromInvoice.
- stock/items/create (POST): accepts optional costPrice,
  sellingPrice, and markup on the standalone create endpoint so
  the operator can stamp a price and bootstrap cost_history in
  one round-trip. Empty bodies still produce an empty
  cost_history (legacy callers unchanged).
- scripts/backfill-stock-prices.ts: one-shot operator tool that
  mirrors price_list rows onto existing stock_items rows whose
  selling_price is still zero. Pure decision function exported
  for unit tests; --commit / --limit / --sku CLI; idempotent
  (never overwrites a non-zero shelf price or cost).
- vitest.config.ts: pull scripts/**/*.test.ts into the vitest
  include list so backfill-stock-prices.test.ts runs in
  `npx vitest run` without a per-script config.

Tests: 65 new test cases across price-list-service,
stock-quick-draft-service, stock/items create, and
backfill-stock-prices.
2026-09-14 13:04:08 +02:00

477 lines
No EOL
17 KiB
TypeScript

import { getAdminPocketBase } from "./pocketbase-admin";
import { escapeForPbString, normaliseSku } from "./price-list-match";
/**
* `price_list.add_items` — bulk upsert parsed line items into the
* `price_list` catalogue from the MCP server.
*
* Why this exists
* ───────────────
* The n8n workflow has historically been the only path for adding
* items to the catalogue: operator uploads a PDF via `/pricelist`,
* n8n picks up the `raw_price_list` row, runs AI extraction, writes
* structured rows to `price_list`. That flow is async, human-in-the-
* loop, and gates on n8n being healthy.
*
* This service takes the **already-parsed** line items straight from
* the MCP agent (which has its own native vision capability) and
* commits them in one round-trip. The agent does the OCR; this
* service does the upsert. No LLM work in the MCP server.
*
* ## What it does per item
*
* 1. Resolve the supplier — `price_list.supplier` is a free-text
* field (NOT a relation to `suppliers`), so we just write the
* agent's `supplier_name` verbatim. If the operator wants a
* relation-style FK later, that's a separate schema change.
* 2. Normalise the SKU — same canonical form `price-list-match.ts`
* uses (`uppercase`, strip non-alphanumeric). Catches
* `JEITD-06` vs `JEITD06` on dedupe.
* 3. Compute `search_text` — lowercase, space-separated concat of
* every searchable field (sku, description, brand, supplier,
* category, subcategory, specs synonyms). Same shape as the
* n8n workflow produces.
* 4. Upsert — if a row with `(sku, supplier)` already exists,
* patch `cost_price`, `description`, `selling_price`, `unit`,
* `category`, `subcategory`, `brand`, `markup`, `stock_qty`,
* `search_text`, `sku_normalised`. Else insert.
*
* ## Upsert policy
*
* cost_price OVERWRITES. The agent just parsed the latest price
* from a fresh document — that's the new truth. The previous
* price is preserved in the row's `updated` timestamp (the buyer
* can diff in the Catalogue tab) and (if `source_file_id` is
* set) the source PDF is still in PB.
*
* Skipped items get an explicit reason so the agent can report
* them back to the operator (missing supplier name, empty SKU,
* malformed cost price, etc.) — never silently dropped.
*
* ## Bulk size
*
* 500 items per call. Larger lists should be chunked by the
* agent. Rejecting with a clear error is cheaper than letting a
* 5,000-line sheet hit PB in one transaction.
*/
export type AddPriceListItemInput = {
/** Raw supplier name as printed on the source document. Stored
* verbatim on `price_list.supplier` (free-text field). */
supplier_name: string;
/** SKU exactly as printed. Empty/whitespace-only items are
* rejected with `skipped[].reason = "missing_sku"`. */
sku: string;
/** Human-readable description of the line item. */
description: string;
/** What the supplier charges (R). 0 or negative is rejected. */
cost_price: number;
/** Markup-based selling price (R). Optional — auto-computed from
* cost_price + markup if not supplied. */
selling_price?: number;
/** Markup percentage (e.g. 25 = 25%). Optional. */
markup?: number;
/** Unit string ("EA", "BOX OF 12", "L", etc). Optional. */
unit?: string;
/** Optional categorisation. */
category?: string;
subcategory?: string;
brand?: string;
/** Current on-hand qty for the catalogue row. Optional; default
* 0. The catalogue's stock_qty is a denormalised snapshot — the
* live `current_qty` lives on `stock_items`. */
stock_qty?: number;
};
export type AddPriceListItemsInput = {
/** One call's worth of line items. Hard cap 500. */
items: AddPriceListItemInput[];
/**
* Optional FK to `raw_price_list.id` so the new rows can be
* traced back to the source document. Pass when the agent
* already created a `raw_price_list` row for this upload;
* leave undefined for ad-hoc / paste-into-chat flows.
*/
source_file_id?: string;
/**
* Optional human-readable label for the source document
* ("Q-AFRISAN-2026-08", "AFRIQ-INA64721", etc.). Stored on each
* new row's `source_file` text field so the Catalogue tab can
* show "extracted from: …" without a join.
*/
source_label?: string;
};
export type AddPriceListItemsOk = {
ok: true;
added: number;
updated: number;
/** PB ids of the upserted rows (added + updated). */
ids: string[];
/** Items the service refused to commit, with the reason. */
skipped: Array<{ index: number; sku: string; reason: string }>;
/**
* Stock-item sync problems encountered after the price_list write
* landed. The price_list write itself always succeeds (or lands
* in `skipped`); the stock_items patch is a best-effort step
* that fires only when an `addPriceListItems` row matches a
* `stock_items` row by `sku_normalised` AND that stock row's
* `selling_price` is zero. Sync failures are collected here so a
* single broken stock row can never poison a 500-item batch.
*
* Empty when no sync attempt was needed (no matching stock_item)
* or every sync attempt succeeded.
*/
warnings?: Array<{
/** The sku whose sync attempt failed. */
sku: string;
/** The stock_items row id the sync was targeting. */
stock_item_id: string;
/** Short failure reason (PB error message). */
reason: string;
}>;
};
export type AddPriceListItemsErr = {
ok: false;
reason: "no_items" | "too_many_items" | "pb_unavailable";
message: string;
};
export type AddPriceListItemsResult =
| AddPriceListItemsOk
| AddPriceListItemsErr;
export const MAX_ADD_ITEMS = 500;
/**
* Build the `search_text` blob for a `price_list` row.
*
* Lowercased, space-separated tokens. Mirrors what the n8n
* extraction workflow produces (`src/app/(app)/pricelist/...`) so
* the catalogue search box hits the same denormalised column.
*
* Exported for unit tests so we can verify the token ordering +
* lowercasing without spinning up PB.
*/
export function buildSearchText(input: {
sku: string;
description: string;
brand?: string;
supplier: string;
category?: string;
subcategory?: string;
specifications?: { synonyms?: string[] };
}): string {
const parts: string[] = [];
if (input.sku) parts.push(input.sku);
if (input.description) parts.push(input.description);
if (input.brand) parts.push(input.brand);
if (input.supplier) parts.push(input.supplier);
if (input.category) parts.push(input.category);
if (input.subcategory) parts.push(input.subcategory);
const synonyms = input.specifications?.synonyms;
if (Array.isArray(synonyms)) {
for (const syn of synonyms) {
if (typeof syn === "string" && syn) parts.push(syn);
}
}
return parts.join(" ").toLowerCase().trim();
}
/**
* Compute the default selling price when the agent didn't supply
* one. markup is a percentage (e.g. 25 for 25%); cost_price is the
* supplier's price in R. Returns rounded-to-cents.
*
* If markup is missing or non-positive, returns cost_price (i.e.
* zero markup). The Catalogue tab still shows the cost as
* "selling_price" until the operator overwrites it.
*/
export function computeSellingPrice(
costPrice: number,
markupPercent: number | undefined,
): number {
if (typeof markupPercent !== "number" || !Number.isFinite(markupPercent) || markupPercent <= 0) {
return Math.max(0, Math.round(costPrice * 100) / 100);
}
const multiplier = 1 + markupPercent / 100;
return Math.max(0, Math.round(costPrice * multiplier * 100) / 100);
}
/**
* After a successful `price_list` upsert, mirror the row's
* selling_price + markup onto the matching `stock_items` row so
* the till (which reads `stock_items.selling_price`) sees the new
* price. The price_list catalogue is the source of truth for
* items that have *no* shelf price; the POS cashier or catalogue
* patch is the source once a price has been set — so we never
* overwrite a non-zero `stock_items.selling_price`.
*
* Sync rules:
* - Match by `sku_normalised` on `stock_items` (single row only;
* if multiple match, do nothing — ambiguous).
* - Match only when the stock row's `selling_price` is 0/null AND
* the price_list row has a positive `selling_price` to seed.
* - If `stock_items.markup` is 0/null AND `price_list.markup` is
* positive, also patch `markup`.
* - Sync failures are returned to the caller, never thrown. A
* broken sync must not poison a 500-item batch.
*
* Returns `null` when no sync was needed (no match, already priced,
* or missing selling_price on the price_list row); otherwise
* returns `{ stock_item_id, warnings? }` describing the outcome.
*/
async function syncPriceListToStockItem(
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
args: {
sku: string;
sellingPrice: number | undefined;
markup: number | undefined;
},
): Promise<
| { stock_item_id: string; warning?: { sku: string; stock_item_id: string; reason: string } }
| null
> {
// No point syncing if the price_list row doesn't carry a price.
if (
typeof args.sellingPrice !== "number" ||
!Number.isFinite(args.sellingPrice) ||
args.sellingPrice <= 0
) {
return null;
}
const skuNormalised = normaliseSku(args.sku);
if (!skuNormalised) return null;
// Look up the stock row by canonical SKU. We don't auto-create
// from price_list — supplier catalogues carry thousands of SKUs
// that have never been stocked.
let stockItems: Array<{ id: string; selling_price?: number | null; markup?: number | null }>;
try {
stockItems = (
await pb.collection("stock_items").getList<{
id: string;
selling_price?: number | null;
markup?: number | null;
}>(1, 2, {
filter: `sku_normalised = "${escapeForPbString(skuNormalised)}"`,
fields: "id,selling_price,markup",
})
).items;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
stock_item_id: "",
warning: { sku: args.sku, stock_item_id: "", reason: `lookup_failed: ${message}` },
};
}
// Ambiguous match → skip without error. The price_list still
// landed; the operator can resolve the dupes in the catalogue.
if (stockItems.length !== 1) return null;
const stockRow = stockItems[0];
const stockSellingPrice = Number(stockRow.selling_price ?? 0);
const stockMarkup = Number(stockRow.markup ?? 0);
const patch: { selling_price?: number; markup?: number } = {};
// Idempotency: never overwrite a non-zero stock shelf price.
if (!(stockSellingPrice > 0)) {
patch.selling_price = args.sellingPrice;
}
if (
!(stockMarkup > 0) &&
typeof args.markup === "number" &&
Number.isFinite(args.markup) &&
args.markup > 0
) {
patch.markup = args.markup;
}
if (Object.keys(patch).length === 0) return null;
try {
await pb.collection("stock_items").update(stockRow.id, patch);
return { stock_item_id: stockRow.id };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
stock_item_id: stockRow.id,
warning: {
sku: args.sku,
stock_item_id: stockRow.id,
reason: message,
},
};
}
}
export async function addPriceListItems(
input: AddPriceListItemsInput,
): Promise<AddPriceListItemsResult> {
const items = Array.isArray(input.items) ? input.items : [];
if (items.length === 0) {
return {
ok: false,
reason: "no_items",
message: "No items supplied — pass at least one item in `items[]`.",
};
}
if (items.length > MAX_ADD_ITEMS) {
return {
ok: false,
reason: "too_many_items",
message: `Too many items in one call: ${items.length} (max ${MAX_ADD_ITEMS}). Chunk the list into batches of ${MAX_ADD_ITEMS} or fewer.`,
};
}
let pb: Awaited<ReturnType<typeof getAdminPocketBase>>;
try {
pb = await getAdminPocketBase();
} catch {
return {
ok: false,
reason: "pb_unavailable",
message: "PB admin unavailable — check PB_ADMIN_EMAIL/PASSWORD env vars.",
};
}
const skipped: AddPriceListItemsOk["skipped"] = [];
const ids: string[] = [];
// Best-effort stock_items sync problems. Always empty when no
// sync was needed (no matching stock_item) or every sync
// succeeded. Populated when syncPriceListToStockItem returned
// a `warning` (lookup failure or PATCH rejection).
const syncWarnings: NonNullable<AddPriceListItemsOk["warnings"]> = [];
for (let index = 0; index < items.length; index += 1) {
const raw = items[index];
const sku = (raw.sku ?? "").trim();
const supplier = (raw.supplier_name ?? "").trim();
const description = (raw.description ?? "").trim();
if (!sku) {
skipped.push({ index, sku, reason: "missing_sku" });
continue;
}
if (!supplier) {
skipped.push({ index, sku, reason: "missing_supplier" });
continue;
}
if (
typeof raw.cost_price !== "number" ||
!Number.isFinite(raw.cost_price) ||
raw.cost_price <= 0
) {
skipped.push({ index, sku, reason: "invalid_cost_price" });
continue;
}
const skuNormalised = normaliseSku(sku);
const searchText = buildSearchText({
sku,
description,
brand: raw.brand?.trim(),
supplier,
category: raw.category?.trim(),
subcategory: raw.subcategory?.trim(),
});
const sellingPrice =
typeof raw.selling_price === "number" && Number.isFinite(raw.selling_price) && raw.selling_price > 0
? raw.selling_price
: computeSellingPrice(raw.cost_price, raw.markup);
const row: Record<string, unknown> = {
sku,
sku_normalised: skuNormalised,
description: description || sku,
category: (raw.category ?? "").trim(),
subcategory: (raw.subcategory ?? "").trim(),
brand: (raw.brand ?? "").trim(),
supplier,
cost_price: raw.cost_price,
selling_price: sellingPrice,
markup: typeof raw.markup === "number" && Number.isFinite(raw.markup) ? raw.markup : 0,
unit: (raw.unit ?? "").trim(),
stock_qty: typeof raw.stock_qty === "number" && Number.isFinite(raw.stock_qty) ? raw.stock_qty : 0,
search_text: searchText,
};
if (input.source_file_id) {
row.source_file_id = input.source_file_id;
}
if (input.source_label) {
row.source_file = input.source_label;
}
try {
// Upsert by (sku, supplier). PB filter for both fields is
// exact-equality on the raw strings — we don't try to
// dedupe across normalised SKUs (different suppliers can
// legitimately carry the same SKU; dedup is per-supplier).
const filter = `sku = "${escapeForPbString(sku)}" && supplier = "${escapeForPbString(supplier)}"`;
const existing = await pb.collection("price_list").getList<{ id: string }>(
1,
1,
{ filter, fields: "id" },
);
if (existing.items.length > 0) {
await pb.collection("price_list").update(existing.items[0].id, row);
ids.push(existing.items[0].id);
} else {
const created = await pb.collection("price_list").create(row);
ids.push(created.id);
}
// Mirror selling_price + markup onto the matching stock_items
// row when (and only when) the stock row has no price yet.
// Errors are captured into `syncWarnings` below — they must
// never fail the price_list write.
const syncOutcome = await syncPriceListToStockItem(pb, {
sku,
sellingPrice: sellingPrice,
markup: typeof raw.markup === "number" && Number.isFinite(raw.markup) ? raw.markup : undefined,
});
if (syncOutcome?.warning) {
syncWarnings.push(syncOutcome.warning);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
skipped.push({ index, sku, reason: `pb_write_failed: ${message}` });
}
}
// We don't know per-row whether it was an add or an update from
// `ids` alone — count created-vs-updated by re-querying the rows.
// For a typical 500-item call this is one PB round-trip with
// an `id IN (…)` filter.
let added = 0;
let updated = 0;
if (ids.length > 0) {
const filter = ids.map((id) => `id = "${id}"`).join(" || ");
const rows = await pb.collection("price_list").getList<{ id: string; created: string; updated: string }>(
1,
ids.length,
{ filter, fields: "id,created,updated" },
);
for (const r of rows.items) {
// PB stamps `created` once; `updated` every write. If they
// match to the second, the row is brand-new; otherwise it
// was an update.
if (r.created && r.updated && r.created === r.updated) {
added += 1;
} else {
updated += 1;
}
}
}
return {
ok: true,
added,
updated,
ids,
skipped,
...(syncWarnings.length > 0 ? { warnings: syncWarnings } : {}),
};
}