WFDASH-WEBAPP/scripts/nss-washers-pb-transform.ts

146 lines
4 KiB
TypeScript
Raw Normal View History

/**
* 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<string, string>)[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}`);