WFDASH-WEBAPP/scripts/nss-fasteners-fix-name-description.ts

203 lines
6.5 KiB
TypeScript
Raw Normal View History

/**
* 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: <old name> | " 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<void> {
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<PbRow>({
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);
});