276 lines
10 KiB
TypeScript
276 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);
|
|||
|
|
});
|