/** * One-off ingest: write the 99 NSS hex-nut-DIN934 items from * `scripts/nss-fasteners-pb-import.json` into the PocketBase * `stock_items` collection. * * This script reuses the in-app helpers so the writes match what * the create route would do: * * - `applyDefaultMarkupToPatch` → seeds `selling_price = cost × 1.80` * and `markup = 80` when those are unset * (the operator's +80% policy). * - The patch is shaped to mirror `/api/v1/stock/items/create` * exactly, so any later audit ("how did this row get created?") * looks the same whether the row came from the UI or from this * script. * * Field mapping (CSV row → PB patch): * * name ← PosDescription (e.g. "M48 NUT 8 PLN") * sku ← SKU * description ← Description * unit ← Pack or "Each" * location ← "NSS Fasteners price list - hex nut DIN934 (Grade 8 + A2)" * notes ← "Material … | Grade … | Diameter … | Thread … | Finish … | Source …" * supplier ← "6xuu7a0pmmubtqd" (NSS supplier relation) * category ← "4emvaanzogj0586" (Fasteners category relation) * cost_price ← parsed UnitPrice number * cost_history ← single entry: { cost_price, quantity:0, * purchased_at: today, purchase_id, * supplier_id, source, source_url } * selling_price ← computed by helper (cost × 1.80) * markup ← 80 (computed by helper) * current_qty ← 0 * min_qty ← 0 * target_qty ← 0 * is_active ← true * * Why direct `pb.collection(...).create()` and not POST to the API: * * The existing `/api/v1/stock/items/create` route is a single-row * endpoint. It doesn't accept `category` (the relation is set * elsewhere) and it would mean 99 separate HTTP round-trips. * For a one-off ingest of a fixed list, calling the admin SDK * directly is simpler and faster. The business logic (`apply * DefaultMarkupToPatch`) is still reused so the rows produced * here are indistinguishable from rows produced by the API. * * Idempotency: * * The script SKIPS records whose `sku` already exists in PB. * Re-running it on the same CSV is safe — it will only create * the rows that haven't been created yet. This means you can * re-run after a partial failure without duplicating anything. * * Run: * npx tsx scripts/nss-fasteners-ingest.ts --dry-run --limit 5 * npx tsx scripts/nss-fasteners-ingest.ts --limit 5 # writes 5 rows * npx tsx scripts/nss-fasteners-ingest.ts # writes all 99 */ import "./_env"; // side-effect import: loads .env.local import { getAdminPocketBase } from "../src/lib/server/pocketbase-admin"; import { applyDefaultMarkupToPatch, round2, } from "../src/lib/server/stock-default-markup-service"; import { readFileSync } from "node:fs"; import { join } from "node:path"; // ─── Constants ──────────────────────────────────────────────────────── const IMPORT_JSON = join(import.meta.dirname, "nss-fasteners-pb-import.json"); const SUPPLIER_NSS_ID = "6xuu7a0pmmubtqd"; const CATEGORY_FASTENERS_ID = "4emvaanzogj0586"; const SOURCE_LABEL = "NSS Fasteners price list - hex nut DIN934 (Grade 8 + A2)"; const SOURCE_URL = "https://nssfasteners.co.za/nuts/hex-nuts/hex-nut-din934.html?gradematerial=771,773"; const PURCHASE_ID = "nss-price-list-2026-09-16"; // stable ID so re-runs don't append duplicates const EXTRACTED_AT_ISO = new Date("2026-09-16T08:00:00Z").toISOString(); // ─── CLI flags ──────────────────────────────────────────────────────── 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; // ─── Types ──────────────────────────────────────────────────────────── 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; }>; cost_price: number; unit: string; current_qty: number; min_qty: number; target_qty: number; is_active: boolean; supplier: string; category: string; }; // ─── PB patch builder ───────────────────────────────────────────────── /** * Build the exact patch the create route would build, then run it * through `applyDefaultMarkupToPatch` so the +80% markup rule fires * the same way it does for any other code path. * * The output is what `pb.collection("stock_items").create()` will * receive. We pass `undefined` for the existing row — there's no * row yet (this is the create path). */ function buildPatch(r: ImportRecord): Record { // Translate our flat CSV-shaped cost_history into the in-app // StockCostHistoryEntry shape, plus provenance fields PB stores // opaquely because the field type is `json`. const seedEntry = { cost_price: r.cost_price, quantity: 0, purchased_at: EXTRACTED_AT_ISO, purchase_id: PURCHASE_ID, supplier_id: SUPPLIER_NSS_ID, source: "nssfasteners.co.za", source_url: SOURCE_URL, currency: "ZAR", qty_basis: 1, extraction_method: "browser_automation", notes: SOURCE_LABEL, }; const rawPatch: Record = { 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], // non-empty → helper won't overwrite }; return applyDefaultMarkupToPatch(rawPatch, undefined); } // ─── Existing-SKU lookup (idempotency) ──────────────────────────────── /** * Pull every existing `sku` once at the start of the run, then * check the in-memory set for each row. Avoids a per-row PB * round-trip for the dedupe check. * * `getFullList` paginates internally, so this works regardless of * catalogue size. */ async function loadExistingSkus(): Promise> { 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)); } // ─── Main ───────────────────────────────────────────────────────────── async function main(): Promise { 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"}`); // Idempotency: skip SKUs already in PB. Read-only first, then // either stop (dry-run) or proceed with the new ones only. 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 (skipped.length <= 10) { for (const s of skipped) console.log(` - ${s.sku} (${s.name})`); } } 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; } // Real write pass. 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 % 10 === 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); });