- 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.
275 lines
10 KiB
TypeScript
275 lines
10 KiB
TypeScript
/**
|
||
* One-off ingest: write the 99 NSS hex-nut-DIN934 items from
|
||
* `scripts/nss-fasteners-pb-import.json` into the PocketBase
|
||
* `stock_items` collection.
|
||
*
|
||
* This script reuses the in-app helpers so the writes match what
|
||
* the create route would do:
|
||
*
|
||
* - `applyDefaultMarkupToPatch` → seeds `selling_price = cost × 1.80`
|
||
* and `markup = 80` when those are unset
|
||
* (the operator's +80% policy).
|
||
* - The patch is shaped to mirror `/api/v1/stock/items/create`
|
||
* exactly, so any later audit ("how did this row get created?")
|
||
* looks the same whether the row came from the UI or from this
|
||
* script.
|
||
*
|
||
* Field mapping (CSV row → PB patch):
|
||
*
|
||
* name ← PosDescription (e.g. "M48 NUT 8 PLN")
|
||
* sku ← SKU
|
||
* description ← Description
|
||
* unit ← Pack or "Each"
|
||
* location ← "NSS Fasteners price list - hex nut DIN934 (Grade 8 + A2)"
|
||
* notes ← "Material … | Grade … | Diameter … | Thread … | Finish … | Source …"
|
||
* supplier ← "6xuu7a0pmmubtqd" (NSS supplier relation)
|
||
* category ← "4emvaanzogj0586" (Fasteners category relation)
|
||
* cost_price ← parsed UnitPrice number
|
||
* cost_history ← single entry: { cost_price, quantity:0,
|
||
* purchased_at: today, purchase_id,
|
||
* supplier_id, source, source_url }
|
||
* selling_price ← computed by helper (cost × 1.80)
|
||
* markup ← 80 (computed by helper)
|
||
* current_qty ← 0
|
||
* min_qty ← 0
|
||
* target_qty ← 0
|
||
* is_active ← true
|
||
*
|
||
* Why direct `pb.collection(...).create()` and not POST to the API:
|
||
*
|
||
* The existing `/api/v1/stock/items/create` route is a single-row
|
||
* endpoint. It doesn't accept `category` (the relation is set
|
||
* elsewhere) and it would mean 99 separate HTTP round-trips.
|
||
* For a one-off ingest of a fixed list, calling the admin SDK
|
||
* directly is simpler and faster. The business logic (`apply
|
||
* DefaultMarkupToPatch`) is still reused so the rows produced
|
||
* here are indistinguishable from rows produced by the API.
|
||
*
|
||
* Idempotency:
|
||
*
|
||
* The script SKIPS records whose `sku` already exists in PB.
|
||
* Re-running it on the same CSV is safe — it will only create
|
||
* the rows that haven't been created yet. This means you can
|
||
* re-run after a partial failure without duplicating anything.
|
||
*
|
||
* Run:
|
||
* npx tsx scripts/nss-fasteners-ingest.ts --dry-run --limit 5
|
||
* npx tsx scripts/nss-fasteners-ingest.ts --limit 5 # writes 5 rows
|
||
* npx tsx scripts/nss-fasteners-ingest.ts # writes all 99
|
||
*/
|
||
|
||
import "./_env"; // side-effect import: loads .env.local
|
||
|
||
import { getAdminPocketBase } from "../src/lib/server/pocketbase-admin";
|
||
import {
|
||
applyDefaultMarkupToPatch,
|
||
round2,
|
||
} from "../src/lib/server/stock-default-markup-service";
|
||
import { readFileSync } from "node:fs";
|
||
import { join } from "node:path";
|
||
|
||
// ─── Constants ────────────────────────────────────────────────────────
|
||
|
||
const IMPORT_JSON = join(import.meta.dirname, "nss-fasteners-pb-import.json");
|
||
const SUPPLIER_NSS_ID = "6xuu7a0pmmubtqd";
|
||
const CATEGORY_FASTENERS_ID = "4emvaanzogj0586";
|
||
const SOURCE_LABEL =
|
||
"NSS Fasteners price list - hex nut DIN934 (Grade 8 + A2)";
|
||
const SOURCE_URL =
|
||
"https://nssfasteners.co.za/nuts/hex-nuts/hex-nut-din934.html?gradematerial=771,773";
|
||
const PURCHASE_ID = "nss-price-list-2026-09-16"; // stable ID so re-runs don't append duplicates
|
||
const EXTRACTED_AT_ISO = new Date("2026-09-16T08:00:00Z").toISOString();
|
||
|
||
// ─── CLI flags ────────────────────────────────────────────────────────
|
||
|
||
const args = process.argv.slice(2);
|
||
const dryRun = args.includes("--dry-run");
|
||
const limitArg = args.find((a) => a.startsWith("--limit="));
|
||
const limit = limitArg ? Number(limitArg.split("=")[1]) : Infinity;
|
||
|
||
// ─── Types ────────────────────────────────────────────────────────────
|
||
|
||
type ImportRecord = {
|
||
name: string;
|
||
sku: string;
|
||
description: string;
|
||
location: string;
|
||
notes: string;
|
||
cost_history: Array<{
|
||
price: number;
|
||
currency: string;
|
||
source: string;
|
||
extracted_at: string;
|
||
source_url: string;
|
||
qty_basis: number;
|
||
extraction_method: string;
|
||
}>;
|
||
cost_price: number;
|
||
unit: string;
|
||
current_qty: number;
|
||
min_qty: number;
|
||
target_qty: number;
|
||
is_active: boolean;
|
||
supplier: string;
|
||
category: string;
|
||
};
|
||
|
||
// ─── PB patch builder ─────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Build the exact patch the create route would build, then run it
|
||
* through `applyDefaultMarkupToPatch` so the +80% markup rule fires
|
||
* the same way it does for any other code path.
|
||
*
|
||
* The output is what `pb.collection("stock_items").create()` will
|
||
* receive. We pass `undefined` for the existing row — there's no
|
||
* row yet (this is the create path).
|
||
*/
|
||
function buildPatch(r: ImportRecord): Record<string, unknown> {
|
||
// Translate our flat CSV-shaped cost_history into the in-app
|
||
// StockCostHistoryEntry shape, plus provenance fields PB stores
|
||
// opaquely because the field type is `json`.
|
||
const seedEntry = {
|
||
cost_price: r.cost_price,
|
||
quantity: 0,
|
||
purchased_at: EXTRACTED_AT_ISO,
|
||
purchase_id: PURCHASE_ID,
|
||
supplier_id: SUPPLIER_NSS_ID,
|
||
source: "nssfasteners.co.za",
|
||
source_url: SOURCE_URL,
|
||
currency: "ZAR",
|
||
qty_basis: 1,
|
||
extraction_method: "browser_automation",
|
||
notes: SOURCE_LABEL,
|
||
};
|
||
|
||
const rawPatch: Record<string, unknown> = {
|
||
sku: r.sku,
|
||
name: r.name,
|
||
description: r.description,
|
||
unit: r.unit,
|
||
location: r.location,
|
||
current_qty: r.current_qty,
|
||
min_qty: r.min_qty,
|
||
target_qty: r.target_qty,
|
||
supplier: r.supplier,
|
||
category: r.category,
|
||
is_active: r.is_active,
|
||
notes: r.notes,
|
||
cost_price: r.cost_price,
|
||
cost_history: [seedEntry], // non-empty → helper won't overwrite
|
||
};
|
||
|
||
return applyDefaultMarkupToPatch(rawPatch, undefined);
|
||
}
|
||
|
||
// ─── Existing-SKU lookup (idempotency) ────────────────────────────────
|
||
|
||
/**
|
||
* Pull every existing `sku` once at the start of the run, then
|
||
* check the in-memory set for each row. Avoids a per-row PB
|
||
* round-trip for the dedupe check.
|
||
*
|
||
* `getFullList` paginates internally, so this works regardless of
|
||
* catalogue size.
|
||
*/
|
||
async function loadExistingSkus(): Promise<Set<string>> {
|
||
const pb = await getAdminPocketBase();
|
||
const rows = await pb.collection("stock_items").getFullList<{ sku: string }>({
|
||
fields: "sku",
|
||
batch: 500,
|
||
});
|
||
return new Set(rows.map((r) => (r.sku || "").trim()).filter(Boolean));
|
||
}
|
||
|
||
// ─── Main ─────────────────────────────────────────────────────────────
|
||
|
||
async function main(): Promise<void> {
|
||
const t0 = Date.now();
|
||
|
||
const importDoc = JSON.parse(readFileSync(IMPORT_JSON, "utf8")) as {
|
||
records: ImportRecord[];
|
||
};
|
||
const allRecords = importDoc.records;
|
||
const records = Number.isFinite(limit) ? allRecords.slice(0, limit) : allRecords;
|
||
|
||
console.log(`Loaded ${allRecords.length} records from ${IMPORT_JSON}`);
|
||
if (records.length < allRecords.length) {
|
||
console.log(` --limit applied: running on first ${records.length}`);
|
||
}
|
||
console.log(`Mode: ${dryRun ? "DRY RUN (no writes)" : "WRITE"}`);
|
||
|
||
// Idempotency: skip SKUs already in PB. Read-only first, then
|
||
// either stop (dry-run) or proceed with the new ones only.
|
||
const existingSkus = await loadExistingSkus();
|
||
console.log(`Existing SKUs in PB: ${existingSkus.size}`);
|
||
|
||
const toIngest = records.filter((r) => !existingSkus.has(r.sku));
|
||
const skipped = records.filter((r) => existingSkus.has(r.sku));
|
||
|
||
console.log(`Records to ingest: ${toIngest.length}`);
|
||
if (skipped.length > 0) {
|
||
console.log(`Records skipped (already in PB): ${skipped.length}`);
|
||
if (skipped.length <= 10) {
|
||
for (const s of skipped) console.log(` - ${s.sku} (${s.name})`);
|
||
}
|
||
}
|
||
|
||
if (dryRun) {
|
||
console.log("\nDry run — preview of what would be written:\n");
|
||
let i = 0;
|
||
for (const r of toIngest.slice(0, 5)) {
|
||
const patch = buildPatch(r);
|
||
console.log(
|
||
` [${++i}] ${patch.sku} ${patch.name} cost=R ${patch.cost_price} sell=R ${patch.selling_price} markup=${patch.markup}% cat=${patch.category}`,
|
||
);
|
||
}
|
||
if (toIngest.length > 5) {
|
||
console.log(` ... and ${toIngest.length - 5} more`);
|
||
}
|
||
console.log(`\n duration: ${((Date.now() - t0) / 1000).toFixed(1)}s`);
|
||
return;
|
||
}
|
||
|
||
// Real write pass.
|
||
const pb = await getAdminPocketBase();
|
||
let created = 0;
|
||
let failed = 0;
|
||
const failures: Array<{ sku: string; error: string }> = [];
|
||
|
||
for (const r of toIngest) {
|
||
try {
|
||
const patch = buildPatch(r);
|
||
await pb.collection("stock_items").create(patch);
|
||
created++;
|
||
if (created % 10 === 0 || created === toIngest.length) {
|
||
console.log(
|
||
` ... ${created}/${toIngest.length} written (last: ${patch.sku})`,
|
||
);
|
||
}
|
||
} catch (err) {
|
||
failed++;
|
||
const message = err instanceof Error ? err.message : String(err);
|
||
failures.push({ sku: r.sku, error: message });
|
||
}
|
||
}
|
||
|
||
console.log("\n=== Ingest summary ===");
|
||
console.log(` Created: ${created}`);
|
||
console.log(` Failed: ${failed}`);
|
||
console.log(` Skipped: ${skipped.length} (already in PB)`);
|
||
console.log(` Duration: ${((Date.now() - t0) / 1000).toFixed(1)}s`);
|
||
|
||
if (failures.length > 0) {
|
||
console.log("\nFailures:");
|
||
for (const f of failures) {
|
||
console.log(` ${f.sku} → ${f.error}`);
|
||
}
|
||
process.exitCode = 1;
|
||
}
|
||
}
|
||
|
||
main().catch((err) => {
|
||
console.error("FATAL:", err);
|
||
process.exit(1);
|
||
});
|