186 lines
5.5 KiB
TypeScript
186 lines
5.5 KiB
TypeScript
|
|
/**
|
||
|
|
* One-off ingest: write the 633 NSS hex set screw DIN933 items
|
||
|
|
* (Grade 8.8 + Grade A2) from `scripts/nss-set-screws-din933-pb-import.json`
|
||
|
|
* into the PocketBase `stock_items` collection.
|
||
|
|
*
|
||
|
|
* Same safety rails as the other ingest scripts (idempotent,
|
||
|
|
* applyDefaultMarkupToPatch, per-row try/catch).
|
||
|
|
*
|
||
|
|
* Run:
|
||
|
|
* npx tsx scripts/nss-set-screws-din933-ingest.ts --dry-run --limit 5
|
||
|
|
* npx tsx scripts/nss-set-screws-din933-ingest.ts --limit 5
|
||
|
|
* npx tsx scripts/nss-set-screws-din933-ingest.ts
|
||
|
|
*/
|
||
|
|
|
||
|
|
import "./_env";
|
||
|
|
|
||
|
|
import { getAdminPocketBase } from "../src/lib/server/pocketbase-admin";
|
||
|
|
import { applyDefaultMarkupToPatch } from "../src/lib/server/stock-default-markup-service";
|
||
|
|
import { readFileSync } from "node:fs";
|
||
|
|
import { join } from "node:path";
|
||
|
|
|
||
|
|
const IMPORT_JSON = join(
|
||
|
|
import.meta.dirname,
|
||
|
|
"nss-set-screws-din933-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 = {
|
||
|
|
name: string;
|
||
|
|
sku: string;
|
||
|
|
description: string;
|
||
|
|
location: string;
|
||
|
|
notes: string;
|
||
|
|
cost_history: Array<{
|
||
|
|
price: number;
|
||
|
|
currency: string;
|
||
|
|
source: string;
|
||
|
|
extracted_at: string;
|
||
|
|
source_url: string;
|
||
|
|
qty_basis: number;
|
||
|
|
extraction_method: string;
|
||
|
|
notes: string;
|
||
|
|
}>;
|
||
|
|
cost_price: number;
|
||
|
|
unit: string;
|
||
|
|
current_qty: number;
|
||
|
|
min_qty: number;
|
||
|
|
target_qty: number;
|
||
|
|
is_active: boolean;
|
||
|
|
supplier: string;
|
||
|
|
category: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
function buildPatch(r: ImportRecord): Record<string, unknown> {
|
||
|
|
const seedEntry = {
|
||
|
|
cost_price: r.cost_price,
|
||
|
|
quantity: 0,
|
||
|
|
purchased_at: r.cost_history[0]?.extracted_at ?? new Date().toISOString(),
|
||
|
|
purchase_id: "nss-set-screws-din933-2026-09-16",
|
||
|
|
supplier_id: r.supplier,
|
||
|
|
source: "nssfasteners.co.za",
|
||
|
|
source_url: r.cost_history[0]?.source_url ?? "",
|
||
|
|
currency: r.cost_history[0]?.currency ?? "ZAR",
|
||
|
|
qty_basis: r.cost_history[0]?.qty_basis ?? 1,
|
||
|
|
extraction_method:
|
||
|
|
r.cost_history[0]?.extraction_method ?? "browser_automation",
|
||
|
|
notes: r.cost_history[0]?.notes ?? r.location,
|
||
|
|
};
|
||
|
|
|
||
|
|
return applyDefaultMarkupToPatch(
|
||
|
|
{
|
||
|
|
sku: r.sku,
|
||
|
|
name: r.name,
|
||
|
|
description: r.description,
|
||
|
|
unit: r.unit,
|
||
|
|
location: r.location,
|
||
|
|
current_qty: r.current_qty,
|
||
|
|
min_qty: r.min_qty,
|
||
|
|
target_qty: r.target_qty,
|
||
|
|
supplier: r.supplier,
|
||
|
|
category: r.category,
|
||
|
|
is_active: r.is_active,
|
||
|
|
notes: r.notes,
|
||
|
|
cost_price: r.cost_price,
|
||
|
|
cost_history: [seedEntry],
|
||
|
|
},
|
||
|
|
undefined,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function loadExistingSkus(): Promise<Set<string>> {
|
||
|
|
const pb = await getAdminPocketBase();
|
||
|
|
const rows = await pb.collection("stock_items").getFullList<{ sku: string }>({
|
||
|
|
fields: "sku",
|
||
|
|
batch: 500,
|
||
|
|
});
|
||
|
|
return new Set(rows.map((r) => (r.sku || "").trim()).filter(Boolean));
|
||
|
|
}
|
||
|
|
|
||
|
|
async function main(): Promise<void> {
|
||
|
|
const t0 = Date.now();
|
||
|
|
const importDoc = JSON.parse(readFileSync(IMPORT_JSON, "utf8")) as {
|
||
|
|
records: ImportRecord[];
|
||
|
|
};
|
||
|
|
const allRecords = importDoc.records;
|
||
|
|
const records = Number.isFinite(limit)
|
||
|
|
? allRecords.slice(0, limit)
|
||
|
|
: allRecords;
|
||
|
|
|
||
|
|
console.log(`Loaded ${allRecords.length} records from ${IMPORT_JSON}`);
|
||
|
|
if (records.length < allRecords.length) {
|
||
|
|
console.log(` --limit applied: running on first ${records.length}`);
|
||
|
|
}
|
||
|
|
console.log(`Mode: ${dryRun ? "DRY RUN (no writes)" : "WRITE"}`);
|
||
|
|
|
||
|
|
const existingSkus = await loadExistingSkus();
|
||
|
|
console.log(`Existing SKUs in PB: ${existingSkus.size}`);
|
||
|
|
|
||
|
|
const toIngest = records.filter((r) => !existingSkus.has(r.sku));
|
||
|
|
const skipped = records.filter((r) => existingSkus.has(r.sku));
|
||
|
|
console.log(`Records to ingest: ${toIngest.length}`);
|
||
|
|
if (skipped.length > 0) {
|
||
|
|
console.log(`Records skipped (already in PB): ${skipped.length}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (dryRun) {
|
||
|
|
console.log("\nDry run — preview of what would be written:\n");
|
||
|
|
let i = 0;
|
||
|
|
for (const r of toIngest.slice(0, 5)) {
|
||
|
|
const patch = buildPatch(r);
|
||
|
|
console.log(
|
||
|
|
` [${++i}] ${patch.sku} ${patch.name} cost=R ${patch.cost_price} sell=R ${patch.selling_price} markup=${patch.markup}% cat=${patch.category}`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if (toIngest.length > 5) {
|
||
|
|
console.log(` ... and ${toIngest.length - 5} more`);
|
||
|
|
}
|
||
|
|
console.log(`\n duration: ${((Date.now() - t0) / 1000).toFixed(1)}s`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const pb = await getAdminPocketBase();
|
||
|
|
let created = 0;
|
||
|
|
let failed = 0;
|
||
|
|
const failures: Array<{ sku: string; error: string }> = [];
|
||
|
|
|
||
|
|
for (const r of toIngest) {
|
||
|
|
try {
|
||
|
|
const patch = buildPatch(r);
|
||
|
|
await pb.collection("stock_items").create(patch);
|
||
|
|
created++;
|
||
|
|
if (created % 50 === 0 || created === toIngest.length) {
|
||
|
|
console.log(
|
||
|
|
` ... ${created}/${toIngest.length} written (last: ${patch.sku})`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
failed++;
|
||
|
|
const message = err instanceof Error ? err.message : String(err);
|
||
|
|
failures.push({ sku: r.sku, error: message });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log("\n=== Ingest summary ===");
|
||
|
|
console.log(` Created: ${created}`);
|
||
|
|
console.log(` Failed: ${failed}`);
|
||
|
|
console.log(` Skipped: ${skipped.length} (already in PB)`);
|
||
|
|
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);
|
||
|
|
});
|