- Enhanced `nss-fasteners-pb-import.json` to compute `selling_price` as `cost_price * (1 + 80%)` and included `markup` in the records. - Updated `nss-fasteners-pb-transform.ts` to allow configurable CSV and output file names via environment variables, improving flexibility for different product runs. - Added support for uploading photos in the repair detail page, including validation for file types and sizes, and implemented a dialog for creating customer quotes linked to repairs. - Introduced a new `customer_quote` field in the `Repair` type to link generated quotes back to their respective repairs. These changes streamline the pricing process and enhance the user experience in managing repairs and quotes.
202 lines
6.5 KiB
TypeScript
202 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);
|
|
});
|