/** * One-off fix: the 99 NSS-imported rows have `name` set to the * SKU-style PosDescription (e.g. "M48 NUT 8 PLN") and `description` * set to the friendly product name (e.g. "Hex Nut DIN934"). The * POS displays `description`, so the user only ever sees the * friendly name — and reasonably expects `name` to match. * * What this script does: * * - Loads every NSS-imported row (filtered by `location`). * - Sets `name` = current `description` value, so both fields * show the friendly name regardless of which one the UI uses. * - Prepends the original PosCode to `notes` as * "PosCode: M48 NUT 8 PLN | …", preserving the short code * inside the row so it isn't lost. * - Idempotent: if a row already has `name == description` * and `notes` already starts with "PosCode:", it's skipped. * * Why direct PB admin SDK instead of the in-app update service: * * `STOCK_ITEM_PATCHABLE_FIELDS` doesn't include `name` (intentional * — `name` is the canonical label and the in-app API guards it * from bulk-rename footguns). For this one-off operator-initiated * backfill, calling the admin SDK directly is simpler and faster * than widening the public API surface for a single fix. * * Run: * npx tsx scripts/nss-fasteners-fix-name-description.ts --dry-run * npx tsx scripts/nss-fasteners-fix-name-description.ts --limit 5 * npx tsx scripts/nss-fasteners-fix-name-description.ts */ import "./_env"; // side-effect import: loads .env.local import { getAdminPocketBase } from "../src/lib/server/pocketbase-admin"; import { readFileSync } from "node:fs"; import { join } from "node:path"; const NSS_LOCATION = "NSS Fasteners price list - hex nut DIN934 (Grade 8 + A2)"; const IMPORT_JSON = join(import.meta.dirname, "nss-fasteners-pb-import.json"); const args = process.argv.slice(2); const dryRun = args.includes("--dry-run"); const limitArg = args.find((a) => a.startsWith("--limit=")); const limit = limitArg ? Number(limitArg.split("=")[1]) : Infinity; type ImportRecord = { sku: string; name: string; // current PosDescription, e.g. "M48 NUT 8 PLN" description: string; // friendly name, e.g. "Hex Nut DIN934" notes: string; }; type PbRow = { id: string; sku: string; name: string; description: string; notes: string; }; /** * Compose the new notes field: * - Take the existing notes (if any). * - If it already starts with "PosCode:", don't double-prepend * (idempotency on re-run). * - Otherwise prepend "PosCode: | " so the SKU-style * code travels with the row. */ function composeNotes(oldNotes: string, posCode: string): string { const tag = `PosCode: ${posCode}`; if (oldNotes.includes(tag)) { return oldNotes; } if (!oldNotes || oldNotes.trim() === "") { return tag; } return `${tag} | ${oldNotes}`; } async function main(): Promise { const t0 = Date.now(); // Load the original import JSON — gives us each row's current // name (the PosCode we want to preserve) by SKU. const importDoc = JSON.parse(readFileSync(IMPORT_JSON, "utf8")) as { records: ImportRecord[]; }; const posCodeBySku = new Map( importDoc.records.map((r) => [r.sku, r.name] as const), ); const pb = await getAdminPocketBase(); // Pull every NSS-imported row. The filter matches the location // string we wrote into every row on ingest, so this is a // surgical scope — nothing else in PB is touched. const allRows = await pb.collection("stock_items").getFullList({ filter: `location = "${NSS_LOCATION}"`, fields: "id,sku,name,description,notes", batch: 200, }); console.log(`Found ${allRows.length} NSS-imported rows in PB.`); const rowsToProcess = Number.isFinite(limit) ? allRows.slice(0, limit) : allRows; if (rowsToProcess.length < allRows.length) { console.log(` --limit applied: processing first ${rowsToProcess.length}`); } // Build the to-do list. Skip rows that already look right // (idempotency for re-runs). const todo: Array<{ id: string; sku: string; oldName: string; newName: string; newNotes: string; }> = []; const already = { name: 0, notes: 0 }; for (const r of rowsToProcess) { const posCode = posCodeBySku.get(r.sku) ?? r.name; // safe fallback const desiredName = r.description; // friendly name const desiredNotes = composeNotes(r.notes ?? "", posCode); const nameOk = r.name === desiredName; const notesOk = (r.notes ?? "").includes(`PosCode: ${posCode}`); if (nameOk) already.name++; if (notesOk) already.notes++; if (nameOk && notesOk) continue; todo.push({ id: r.id, sku: r.sku, oldName: r.name, newName: desiredName, newNotes: desiredNotes, }); } console.log(`Already up-to-date: ${already.name}/${rowsToProcess.length} name, ${already.notes}/${rowsToProcess.length} notes.`); console.log(`To update: ${todo.length}`); if (todo.length > 0) { console.log("\nSample diffs:"); for (const t of todo.slice(0, 5)) { console.log(` ${t.sku}`); console.log(` name: "${t.oldName}" → "${t.newName}"`); console.log(` notes: starts with "PosCode: ${posCodeBySku.get(t.sku) ?? "?"} | …"`); } if (todo.length > 5) { console.log(` … and ${todo.length - 5} more`); } } if (dryRun) { console.log(`\nDRY RUN — no writes. duration: ${((Date.now() - t0) / 1000).toFixed(1)}s`); return; } let updated = 0; let failed = 0; const failures: Array<{ sku: string; error: string }> = []; for (const t of todo) { try { await pb.collection("stock_items").update(t.id, { name: t.newName, notes: t.newNotes, }); updated++; if (updated % 25 === 0 || updated === todo.length) { console.log(` ... ${updated}/${todo.length} updated (last: ${t.sku})`); } } catch (err) { failed++; const message = err instanceof Error ? err.message : String(err); failures.push({ sku: t.sku, error: message }); } } console.log("\n=== Fix summary ==="); console.log(` Updated: ${updated}`); console.log(` Failed: ${failed}`); console.log(` Skipped (already correct): ${rowsToProcess.length - todo.length}`); console.log(` Duration: ${((Date.now() - t0) / 1000).toFixed(1)}s`); if (failures.length > 0) { console.log("\nFailures:"); for (const f of failures) { console.log(` ${f.sku} → ${f.error}`); } process.exitCode = 1; } } main().catch((err) => { console.error("FATAL:", err); process.exit(1); });