2026-09-16 07:49:52 +00:00
/ * *
2026-09-21 10:32:44 +00:00
* Maps scripts / nss - fasteners - pilot . csv into PocketBase
2026-09-16 07:49:52 +00:00
* ` 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' ;
2026-09-21 10:32:44 +00:00
// === Configurable per source run ===
// Override at the command line with NSS_PRODUCT=washers NSS_GRADE=...
// to point this transform at a different CSV / category without
// editing the file. Defaults below are for the hex nut DIN934 run.
const PRODUCT = process . env . NSS_PRODUCT ? ? 'hex-nut-din934' ;
const SOURCE_CSV = join (
import . meta . dirname ,
process . env . NSS_CSV ? ? 'nss-fasteners-single-pieces.csv' ,
) ;
const OUTPUT_JSON = join (
import . meta . dirname ,
process . env . NSS_OUTPUT ? ? 'nss-fasteners-pb-import.json' ,
) ;
const SOURCE_URL =
process . env . NSS_URL ? ?
'https://nssfasteners.co.za/nuts/hex-nuts/hex-nut-din934.html?gradematerial=771,773' ;
const SOURCE_LABEL =
process . env . NSS_LABEL ? ? 'NSS Fasteners price list - hex nut DIN934 (Grade 8 + A2)' ;
const EXTRACTED_AT = process . env . NSS_EXTRACTED_AT ? ? '2026-09-16' ;
2026-09-16 07:49:52 +00:00
// === 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 {
2026-09-21 10:32:44 +00:00
// === required (SKU-style code — what the POS shows) ===
// Both `name` and `description` carry the SKU-style code so the
// POS sees the same string regardless of which field it pulls.
// The friendly product type ("Black Cut Washer DIN126", "Hex
// Nut DIN934", etc.) lives in `notes` for human-readable audit.
2026-09-16 07:49:52 +00:00
name : r.PosDescription ,
2026-09-21 10:32:44 +00:00
description : r.PosDescription ,
2026-09-16 07:49:52 +00:00
// === identifying ===
sku : r.SKU ,
// === sourcing / tracking (user's suggestion) ===
location : SOURCE_LABEL ,
notes ,
cost_history : costHistory ,
// === pricing ===
cost_price : costPrice ,
2026-09-21 10:32:44 +00:00
selling_price : costPrice != null ? round2 ( costPrice * ( 1 + MARKUP_PERCENT / 100 ) ) : 0 ,
markup : MARKUP_PERCENT ,
2026-09-16 07:49:52 +00:00
// === inventory defaults (no source data) ===
unit : r.Pack || 'Each' ,
current_qty : 0 ,
min_qty : 0 ,
target_qty : 0 ,
is_active : true ,
2026-09-21 10:32:44 +00:00
// === relations (looked up by the operator) ===
supplier : SUPPLIER_NSS_ID ,
category : CATEGORY_FASTENERS_ID ,
2026-09-16 07:49:52 +00:00
} ;
} ;
2026-09-21 10:32:44 +00:00
const round2 = ( n : number ) = > Math . round ( n * 100 ) / 100 ;
2026-09-16 07:49:52 +00:00
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 ) ;
2026-09-21 10:32:44 +00:00
const SOURCE_URL_LINE = SOURCE_URL ;
2026-09-16 07:49:52 +00:00
writeFileSync (
OUTPUT_JSON ,
JSON . stringify (
{
meta : {
2026-09-21 10:32:44 +00:00
product : PRODUCT ,
source_csv : SOURCE_CSV.replace ( import . meta . dirname + '/' , '' ) ,
source_url : SOURCE_URL_LINE ,
2026-09-16 07:49:52 +00:00
extracted_at : EXTRACTED_AT ,
row_count : records.length ,
pb_collection : 'stock_items' ,
notes :
2026-09-21 10:32:44 +00:00
'selling_price computed as cost_price * (1 + ' + MARKUP_PERCENT + '%). Re-run with updated cost_price to refresh prices. name=description both carry the SKU-style PosCode (POS-friendly).' ,
2026-09-16 07:49:52 +00:00
} ,
records ,
} ,
null ,
2 ,
) ,
) ;
2026-09-21 10:32:44 +00:00
console . log ( ` Wrote ${ records . length } records to ${ OUTPUT_JSON } (product: ${ PRODUCT } ) ` ) ;