317 lines
13 KiB
TypeScript
317 lines
13 KiB
TypeScript
|
|
/**
|
||
|
|
* 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<IrdaStockRow[]> {
|
||
|
|
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<IrdaStockRow>({
|
||
|
|
filter:
|
||
|
|
`(sku ~ "${safeTerm}" || name ~ "${safeTerm}" || description ~ "${safeTerm}") && is_active = true`,
|
||
|
|
expand: "supplier",
|
||
|
|
sort: "name",
|
||
|
|
batch: 500,
|
||
|
|
});
|
||
|
|
return rows;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Main ─────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
async function main(): Promise<void> {
|
||
|
|
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);
|
||
|
|
});
|