WFDASH-WEBAPP/scripts/nss-fasteners-pb-transform.ts
Dave d8e8714720 feat(stock): update pricing logic and transform script for fasteners
- Enhanced `nss-fasteners-pb-import.json` to compute `selling_price` as `cost_price * (1 + 80%)` and included `markup` in the records.
- Updated `nss-fasteners-pb-transform.ts` to allow configurable CSV and output file names via environment variables, improving flexibility for different product runs.
- Added support for uploading photos in the repair detail page, including validation for file types and sizes, and implemented a dialog for creating customer quotes linked to repairs.
- Introduced a new `customer_quote` field in the `Repair` type to link generated quotes back to their respective repairs.

These changes streamline the pricing process and enhance the user experience in managing repairs and quotes.
2026-09-21 12:32:44 +02:00

168 lines
4.9 KiB
TypeScript

/**
* Maps scripts/nss-fasteners-pilot.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';
// === 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';
// === 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 (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.
name: r.PosDescription,
description: r.PosDescription,
// === identifying ===
sku: r.SKU,
// === sourcing / tracking (user's suggestion) ===
location: SOURCE_LABEL,
notes,
cost_history: costHistory,
// === pricing ===
cost_price: costPrice,
selling_price: costPrice != null ? round2(costPrice * (1 + MARKUP_PERCENT / 100)) : 0,
markup: MARKUP_PERCENT,
// === inventory defaults (no source data) ===
unit: r.Pack || 'Each',
current_qty: 0,
min_qty: 0,
target_qty: 0,
is_active: true,
// === relations (looked up by the operator) ===
supplier: SUPPLIER_NSS_ID,
category: CATEGORY_FASTENERS_ID,
};
};
const round2 = (n: number) => Math.round(n * 100) / 100;
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);
const SOURCE_URL_LINE = SOURCE_URL;
writeFileSync(
OUTPUT_JSON,
JSON.stringify(
{
meta: {
product: PRODUCT,
source_csv: SOURCE_CSV.replace(import.meta.dirname + '/', ''),
source_url: SOURCE_URL_LINE,
extracted_at: EXTRACTED_AT,
row_count: records.length,
pb_collection: 'stock_items',
notes:
'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).',
},
records,
},
null,
2,
),
);
console.log(`Wrote ${records.length} records to ${OUTPUT_JSON} (product: ${PRODUCT})`);