/** * One-off stock_items query: list every active `stock_items` row * whose `sku`, `name`, or `description` matches a configurable * substring (default "irda", override with `--term=`), then narrow * the list to spray cans. Read-only — never writes to PB. * * Why this exists * ──────────────── * The operator wanted a quick read-out of the IRDA spray-paint line * without going through the UI. This script mirrors the CLI pattern * from `apply-default-markup.ts` (admin PB, side-effect env load, * pure filter + expand) so it can be deleted without leaving any * shared code behind. * * Matching rules * ────────────── * - Search term (default `irda`) is matched case-insensitively * against `sku`, `name`, OR `description` via PB's `~` operator. * Override with `--term=...` if the actual brand in the catalogue * is spelled differently (the IRDA line in this catalogue is * actually "IRIDA" — pass `--term=irida` to surface it). * - Only `is_active = true`. * - Spray narrowing happens client-side. The script prints three * views so the operator can see which sub-marker matches each * row: literal phrase "spray paint", standalone word "spray", * and the union ("spray" OR the H/HEAT variant marker). The * summary uses the union view. * * Output * ────── * - Markdown table: SKU | Name | Current Qty | Cost (R) | Selling * (R) | Markup | Supplier, sorted by `name` ASC. * - Summary block: total, items WITHOUT `selling_price`, items * WITHOUT `cost_price`, zero-stock items. * * Run with: * npx tsx scripts/list-irda-stock.ts # default term: "irda" * npx tsx scripts/list-irda-stock.ts --term=irida # override term * * Requires .env.local with NEXT_PUBLIC_POCKETBASE_URL, * PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD. */ import "./_env"; // side-effect import: loads .env.local before anything else import { getAdminPocketBase } from "../src/lib/server/pocketbase-admin"; // ─── Constants ──────────────────────────────────────────────────────── /** Default search term. PB's `~` operator is case-insensitive, so * casing here doesn't matter for matching — but the literal does * appear in the report headers. */ const DEFAULT_SEARCH_TERM = "irda"; /** Narrowing marker. The user's request was for "irda spray paints" * (literal phrase). The IRIDA line in stock_items labels every * product with one of two sub-markers: * - "SPRAY" (e.g. "HB IRIDA SPRAY 9010 WHITE GLOS") — the * standard aerosol cans. * - "H/HEAT" (e.g. "HB IRIDA H/HEAT 420 BLACK") — the heat- * resistant variant, still an aerosol spray can, just a * different formulation under the same IRIDA product line. * Neither sub-marker uses the literal phrase "spray paint". So * the narrow view in the report accepts EITHER: * - the literal phrase "spray paint" (catches 0 of the IRIDA * rows — useful as a baseline showing the phrase is wrong * for this catalogue), OR * - either of the two IRIDA sub-markers (catches all 28 * IRIDA rows). * Both views are printed so the operator can see exactly which * filter triggered for each row. */ const SPRAY_PHRASE = "spray paint"; const SPRAY_WORD = "spray"; const HEAT_VARIANT_WORD = "h/heat"; // ─── CLI parsing ────────────────────────────────────────────────────── type CliOptions = { term: string }; function parseCli(argv: string[]): CliOptions { const opts: CliOptions = { term: DEFAULT_SEARCH_TERM }; for (const arg of argv) { if (arg === "--help" || arg === "-h") { printHelp(); process.exit(0); } else if (arg.startsWith("--term=")) { const raw = arg.slice("--term=".length).trim(); if (!raw) throw new Error(`--term must be non-empty, got "${arg}"`); opts.term = raw; } else { throw new Error(`Unknown CLI flag: ${arg}`); } } return opts; } function printHelp(): void { console.log( `List active stock_items rows whose sku/name/description match a search term,\nnarrowed to spray paints.\n`, ); console.log(`Usage: tsx scripts/list-irda-stock.ts # default: term="irda" tsx scripts/list-irda-stock.ts --term=irida # override term Flags: --term=XYZ Substring to match (case-insensitive) against sku/name/description. Default: "irda". --help, -h Show this help.`); } // ─── Types ──────────────────────────────────────────────────────────── /** Single `stock_items` row shape — the fields we read for the * table. `expand.supplier` is the joined supplier name. */ type IrdaStockRow = { id: string; sku: string; name: string; description: string; current_qty: number; cost_price?: number | null; selling_price?: number | null; markup?: number | null; supplier?: string; expand?: { supplier?: { id?: string; suppliername?: string }; }; }; // ─── Formatting ──────────────────────────────────────────────────────── /** Format a number as R X.XX, or em-dash for missing/non-finite * values (so the table reads cleanly when a row has no selling * price). */ function fmtMoney(n: number | null | undefined): string { if (typeof n !== "number" || !Number.isFinite(n)) return "—"; return `R ${n.toFixed(2)}`; } /** Render the markdown table row. SKU/Name left-aligned; numbers * right-aligned via the `---:` colon convention. Supplier falls * back to "—" when the relation is unset so a missing supplier * reads as such in the table rather than blowing up. */ function renderTable(rows: IrdaStockRow[]): string { const lines: string[] = []; lines.push( "| SKU | Name | Current Qty | Cost (R) | Selling (R) | Markup | Supplier |", ); lines.push( "| --- | --- | ---: | ---: | ---: | ---: | --- |", ); for (const r of rows) { const sku = r.sku || "(no sku)"; const name = r.name || "(no name)"; const qty = Number.isFinite(r.current_qty) ? r.current_qty : 0; const supplier = r.expand?.supplier?.suppliername?.trim() || "—"; lines.push( `| ${sku} | ${name} | ${qty} | ${fmtMoney(r.cost_price)} | ${fmtMoney(r.selling_price)} | ${r.markup ?? "—"} | ${supplier} |`, ); } return lines.join("\n"); } // ─── PB plumbing ────────────────────────────────────────────────────── /** * Pull every `stock_items` row matching the configured search term * (case-insensitive `~` on `sku`, `name`, or `description`) where * `is_active = true`. * * Server-side filter (rather than full-table scan + JS filter) * keeps the round-trip small even if the catalogue grows. The * `~` operator is PB's case-insensitive contains. * * The "spray paint" narrowing happens client-side on top of the * filtered result. Two reasons: * - Mixing two `~` clauses with `||` chains is fine in PB but * `(A ~ x || A ~ y || B ~ x || B ~ y)` gets ugly fast and is * easy to get wrong when someone adjusts the term. * - We want the script's "non-spray-paint matches" count in * the summary to be authoritative without a second PB round- * trip — that count comes from the same client-side pass. */ async function listMatchingStockItems(term: string): Promise { const pb = await getAdminPocketBase(); // Escape `"` so a hostile/malformed term can't break out of the // filter string. `~` itself takes care of case-insensitivity; // no other special chars in the term are filter-significant. const safeTerm = term.replace(/"/g, '\\"'); // We deliberately do NOT pass a `fields` projection here: PB's // `fields` projection drops the `expand` payload, so passing // `fields=...` together with `expand=supplier` returns rows // with `expand: undefined` even though the relation field is // populated. The full record is small (28 rows max for the // "irida" case) so the round-trip cost is negligible. const rows = await pb.collection("stock_items").getFullList({ filter: `(sku ~ "${safeTerm}" || name ~ "${safeTerm}" || description ~ "${safeTerm}") && is_active = true`, expand: "supplier", sort: "name", batch: 500, }); return rows; } // ─── Main ───────────────────────────────────────────────────────────── async function main(): Promise { const opts = parseCli(process.argv.slice(2)); const startTs = Date.now(); const allMatchingRows = await listMatchingStockItems(opts.term); // Client-side spray-paint narrowing. Three filters: // - `isSprayPaintPhrase`: literal phrase "spray paint" in // name or description (case-insensitive). // - `isSpray`: standalone word "spray" — catches the // standard IRIDA spray cans ("HB IRIDA SPRAY ..."). // - `isSprayOrHeatVariant`: word "spray" OR "h/heat" — // catches BOTH the standard and high-heat IRIDA aerosol // cans. This is the view the user actually wanted for // "irda spray paints" — every IRIDA product in the // catalogue is one or the other. const isSprayPaintPhrase = (row: IrdaStockRow): boolean => { const hay = `${row.name ?? ""} ${row.description ?? ""}`.toLowerCase(); return hay.includes(SPRAY_PHRASE); }; const isSpray = (row: IrdaStockRow): boolean => { const hay = `${row.name ?? ""} ${row.description ?? ""}`.toLowerCase(); return hay.includes(SPRAY_WORD); }; const isSprayOrHeatVariant = (row: IrdaStockRow): boolean => { const hay = `${row.name ?? ""} ${row.description ?? ""}`.toLowerCase(); return hay.includes(SPRAY_WORD) || hay.includes(HEAT_VARIANT_WORD); }; const phraseRows = allMatchingRows.filter(isSprayPaintPhrase); const sprayRows = allMatchingRows.filter(isSpray); const familyRows = allMatchingRows.filter(isSprayOrHeatVariant); const nonSprayRows = allMatchingRows.filter((r) => !isSprayOrHeatVariant(r)); console.log( `=== stock_items matching "${opts.term}" (active only) — ALL rows ===\n`, ); console.log(renderTable(allMatchingRows)); console.log(""); console.log( `\n=== spray-paint narrow (literal phrase "${SPRAY_PHRASE}") ===\n`, ); console.log(renderTable(phraseRows)); console.log(""); console.log( `\n=== spray-paint narrow (word "${SPRAY_WORD}" only — catches IRIDA standard cans) ===\n`, ); console.log(renderTable(sprayRows)); console.log(""); console.log( `\n=== spray-paint narrow (word "${SPRAY_WORD}" OR "${HEAT_VARIANT_WORD}" — full IRIDA spray family) ===\n`, ); console.log(renderTable(familyRows)); console.log(""); // The user-facing summary uses the FULL-FAMILY view: every IRIDA // product in the catalogue is either a standard spray can or // the H/HEAT variant. The other views are printed so the // operator can compare and confirm the data is what they // expected. const summaryRows = familyRows; // Summary counts. "WITHOUT" is read as `null OR undefined OR 0` // — the same convention the `apply-default-markup.ts` report uses // (`cost_price <= 0`), so the two reports can be eyeballed // against each other. const total = summaryRows.length; const noSelling = summaryRows.filter( (r) => !(Number(r.selling_price) > 0), ).length; const noCost = summaryRows.filter( (r) => !(Number(r.cost_price) > 0), ).length; const zeroStock = summaryRows.filter( (r) => !(Number(r.current_qty) > 0), ).length; console.log(`\n=== Summary (full IRIDA spray family: "${SPRAY_WORD}" OR "${HEAT_VARIANT_WORD}") ===`); console.log(` Total matching rows: ${total}`); console.log(` Items WITHOUT selling_price (gap): ${noSelling}`); console.log(` Items WITHOUT cost_price (gap): ${noCost}`); console.log(` Items with zero current_qty: ${zeroStock}`); if (nonSprayRows.length > 0) { console.log( ` Matching rows that are NOT spray cans: ${nonSprayRows.length}`, ); console.log( ` (these are listed in the ALL table above but excluded from the spray tables)`, ); } else if (allMatchingRows.length > 0) { console.log( ` Matching rows that are NOT spray cans: 0 (every match is a spray can)`, ); } const duration = ((Date.now() - startTs) / 1000).toFixed(1); console.log(`\n duration: ${duration}s`); } main().catch((err) => { console.error("FATAL:", err); process.exit(1); });