WFDASH-WEBAPP/scripts/list-irda-stock.ts

317 lines
13 KiB
TypeScript
Raw Normal View History

feat(stock): inline qty editing + column sort + pricing-gap operator scripts Doubles down on the /stock list to make catalogue and inventory edits one-click for the operator. What's new: - /stock: clickable column headers on Cost / Markup / Selling cycle none → asc → desc → none and feed through to the PB sort param. The apply bar now commits pricing AND qty in parallel (Promise.all against /set-pricing and /set-quantities) with per-row failure tracking and focused error toasts. In edit mode the row is no longer a button — inputs render in-place for current_qty, min_qty, and target_qty alongside the existing pricing inputs, and the row click is gated off so clicks on an input never navigate to the detail page. - stock-quantities-service + /api/v1/stock/items/set-quantities (POST): bulk-update current_qty / min_qty / target_qty on a set of stock_items rows. Whitelist-validated patches, per-row results with stable failure reasons, and a stock_ledger row written FIRST whenever current_qty actually moves so the audit trail stays ahead of the cached total (same pattern as stock-surplus and stock-booking-service). - scripts/apply-default-markup.ts: one-shot operator tool that seeds selling_price = round(cost_price * 1.80, 2) and markup = 80 on stock_items rows whose cost_price is set but selling_price is zero. Bypasses STOCK_ITEM_PATCHABLE_FIELDS (operator-initiated bulk action); idempotent (never overwrites a non-zero shelf price or markup). - scripts/list-irda-stock.ts: read-only inspector for the IRIDA spray-caint line with summary counts (without selling_price, without cost_price, zero stock) so the operator can see exactly which rows the backfill tools above will touch. Tests: 81 new test cases across /stock page, stock-quantities service + route, and apply-default-markup.
2026-09-14 11:04:26 +00:00
/**
* 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);
});