- 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.
168 lines
4.9 KiB
TypeScript
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})`);
|