203 lines
6.5 KiB
TypeScript
203 lines
6.5 KiB
TypeScript
|
|
/**
|
||
|
|
* 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);
|
||
|
|
});
|