145 lines
3.9 KiB
TypeScript
145 lines
3.9 KiB
TypeScript
|
|
/**
|
||
|
|
* Maps scripts/nss-fasteners-single-pieces.csv into PocketBase
|
||
|
|
* `stock_items` records for review.
|
||
|
|
*
|
||
|
|
* Output: scripts/nss-fasteners-pb-import.json
|
||
|
|
*
|
||
|
|
* No data is written to PocketBase yet — this is for the user to
|
||
|
|
* inspect the field mapping before any ingest.
|
||
|
|
*
|
||
|
|
* Run: bun scripts/nss-fasteners-pb-transform.ts
|
||
|
|
* or: npx tsx scripts/nss-fasteners-pb-transform.ts
|
||
|
|
*/
|
||
|
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||
|
|
import { join } from 'node:path';
|
||
|
|
|
||
|
|
const SOURCE_CSV = join(import.meta.dirname, 'nss-fasteners-single-pieces.csv');
|
||
|
|
const OUTPUT_JSON = join(import.meta.dirname, 'nss-fasteners-pb-import.json');
|
||
|
|
|
||
|
|
// === supplied by operator on 2026-09-16 ===
|
||
|
|
const SUPPLIER_NSS_ID = '6xuu7a0pmmubtqd';
|
||
|
|
const CATEGORY_FASTENERS_ID = '4emvaanzogj0586';
|
||
|
|
const MARKUP_PERCENT = 80; // selling_price = cost_price * (1 + MARKUP_PERCENT / 100)
|
||
|
|
|
||
|
|
const SOURCE_URL =
|
||
|
|
'https://nssfasteners.co.za/nuts/hex-nuts/hex-nut-din934.html?gradematerial=771,773';
|
||
|
|
const SOURCE_LABEL = 'NSS Fasteners price list - hex nut DIN934 (Grade 8 + A2)';
|
||
|
|
const EXTRACTED_AT = '2026-09-16'; // YYYY-MM-DD
|
||
|
|
|
||
|
|
type CsvRow = {
|
||
|
|
SKU: string;
|
||
|
|
PosDescription: string;
|
||
|
|
Description: string;
|
||
|
|
Material: string;
|
||
|
|
Grade: string;
|
||
|
|
Diameter: string;
|
||
|
|
Thread: string;
|
||
|
|
Finish: string;
|
||
|
|
Pack: string;
|
||
|
|
UnitPrice: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
const parsePrice = (s: string): number | null => {
|
||
|
|
// "R 59.66" -> 59.66
|
||
|
|
const m = s.match(/([\d.]+)/);
|
||
|
|
if (!m) return null;
|
||
|
|
const n = parseFloat(m[1]);
|
||
|
|
return Number.isFinite(n) ? n : null;
|
||
|
|
};
|
||
|
|
|
||
|
|
const toPbRecord = (r: CsvRow) => {
|
||
|
|
const costPrice = parsePrice(r.UnitPrice);
|
||
|
|
const notes = [
|
||
|
|
`Material: ${r.Material}`,
|
||
|
|
`Grade: ${r.Grade}`,
|
||
|
|
`Diameter: ${r.Diameter}`,
|
||
|
|
`Thread: ${r.Thread}`,
|
||
|
|
`Finish: ${r.Finish}`,
|
||
|
|
`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',
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
return {
|
||
|
|
// === required ===
|
||
|
|
name: r.PosDescription,
|
||
|
|
|
||
|
|
// === identifying ===
|
||
|
|
sku: r.SKU,
|
||
|
|
description: r.Description,
|
||
|
|
|
||
|
|
// === sourcing / tracking (user's suggestion) ===
|
||
|
|
location: SOURCE_LABEL,
|
||
|
|
notes,
|
||
|
|
cost_history: costHistory,
|
||
|
|
|
||
|
|
// === pricing ===
|
||
|
|
cost_price: costPrice,
|
||
|
|
// selling_price and markup intentionally left for the user to set
|
||
|
|
// (depends on customer-facing markup policy, not supplier data).
|
||
|
|
|
||
|
|
// === inventory defaults (no source data) ===
|
||
|
|
unit: r.Pack || 'Each',
|
||
|
|
current_qty: 0,
|
||
|
|
min_qty: 0,
|
||
|
|
target_qty: 0,
|
||
|
|
is_active: true,
|
||
|
|
|
||
|
|
// === relations intentionally NULL ===
|
||
|
|
// supplier + category are relations that need IDs the user must look up.
|
||
|
|
// Do not invent them here.
|
||
|
|
supplier: null,
|
||
|
|
category: null,
|
||
|
|
};
|
||
|
|
};
|
||
|
|
|
||
|
|
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 => {
|
||
|
|
// naive CSV split - no embedded commas in current dataset, so safe.
|
||
|
|
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-fasteners-single-pieces.csv',
|
||
|
|
source_url: SOURCE_URL,
|
||
|
|
extracted_at: EXTRACTED_AT,
|
||
|
|
row_count: records.length,
|
||
|
|
pb_collection: 'stock_items',
|
||
|
|
notes:
|
||
|
|
'selling_price and markup are unset (null/0). supplier and category are null — look up IDs separately before ingest.',
|
||
|
|
},
|
||
|
|
records,
|
||
|
|
},
|
||
|
|
null,
|
||
|
|
2,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
|
||
|
|
console.log(`Wrote ${records.length} records to ${OUTPUT_JSON}`);
|