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.
477 lines
No EOL
17 KiB
TypeScript
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 } : {}),
|
|
};
|
|
} |