/** * Maps scripts/nss-washers-din126-pilot.csv into PocketBase * `stock_items` records for review. * * Output: scripts/nss-washers-din126-pb-import.json * * No data is written to PocketBase — this is for the user to * inspect the field mapping before any ingest. * * Run: npx tsx scripts/nss-washers-pb-transform.ts */ import { readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; // === Configurable per source run === const SOURCE_CSV = join(import.meta.dirname, 'nss-washers-din126-pilot.csv'); const OUTPUT_JSON = join(import.meta.dirname, 'nss-washers-din126-pb-import.json'); const SOURCE_URL = 'https://nssfasteners.co.za/washers/flat-washers/black-cut-washer-din126.html'; const SOURCE_LABEL = 'NSS Fasteners price list - black cut washer DIN 126 (mild steel 4.8)'; const EXTRACTED_AT = '2026-09-16'; // === supplied by the operator === const SUPPLIER_NSS_ID = '6xuu7a0pmmubtqd'; const CATEGORY_FASTENERS_ID = '4emvaanzogj0586'; const MARKUP_PERCENT = 80; type CsvRow = { sku: string; posDescription: string; description: string; material: string; grade: string; diameter: string; metric: string; imperial: string; finish: string; pack: string; unitPrice: string; url: string; }; const parsePrice = (s: string): number | null => { const m = s.match(/([\d.]+)/); if (!m) return null; const n = parseFloat(m[1]); return Number.isFinite(n) ? n : null; }; const round2 = (n: number) => Math.round(n * 100) / 100; const toPbRecord = (r: CsvRow) => { const costPrice = parsePrice(r.unitPrice); // Washers have a "Sold in Multiples of X pieces" minimum-order // rule on the source. We preserve that in notes so the operator // can see it later when reordering. Not a separate PB field — // `unit` (the display unit) already captures "Single Piece" // vs "Pack" semantics elsewhere; the multiples rule is closer // to MOQ than unit. const notes = [ `Material: ${r.material}`, `Grade: ${r.grade}`, `Diameter: ${r.diameter}`, r.metric === 'Yes' ? 'Standard: Metric' : r.imperial === 'Yes' ? 'Standard: Imperial' : '', `Finish: ${r.finish}`, r.pack ? `Pack rule: ${r.pack}` : '', `Source: ${SOURCE_LABEL}`, ] .filter(Boolean) .join(' | '); const costHistory = [ { price: costPrice, currency: 'ZAR', source: 'nssfasteners.co.za', extracted_at: EXTRACTED_AT, source_url: SOURCE_URL, qty_basis: 1, extraction_method: 'browser_automation', notes: SOURCE_LABEL, }, ]; return { // SKU-style code in BOTH `name` and `description` — the POS // shows whichever the operator's UI pulls; both must carry // the SKU-style code so they always match. name: r.posDescription, description: r.posDescription, sku: r.sku, location: SOURCE_LABEL, notes, cost_history: costHistory, cost_price: costPrice, selling_price: costPrice != null ? round2(costPrice * (1 + MARKUP_PERCENT / 100)) : 0, markup: MARKUP_PERCENT, unit: 'Each', current_qty: 0, min_qty: 0, target_qty: 0, is_active: true, supplier: SUPPLIER_NSS_ID, category: CATEGORY_FASTENERS_ID, }; }; const csv = readFileSync(SOURCE_CSV, 'utf8').trim(); const [headerLine, ...dataLines] = csv.split('\n'); const headers = headerLine.split(',') as (keyof CsvRow)[]; const rows: CsvRow[] = dataLines.map((line) => { const cols = line.split(','); const obj = {} as CsvRow; headers.forEach((h, i) => { (obj as Record)[h] = cols[i] ?? ''; }); return obj; }); const records = rows.map(toPbRecord); writeFileSync( OUTPUT_JSON, JSON.stringify( { meta: { source_csv: 'scripts/nss-washers-din126-pilot.csv', source_url: SOURCE_URL, extracted_at: EXTRACTED_AT, row_count: records.length, pb_collection: 'stock_items', notes: 'selling_price = cost_price * (1 + 80%). name=description both carry the PosCode.', }, records, }, null, 2, ), ); console.log(`Wrote ${records.length} records to ${OUTPUT_JSON}`);