WFDASH-WEBAPP/scripts/nss-fasteners-ingest.ts

276 lines
10 KiB
TypeScript
Raw Normal View History

/**
* 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);
});