- 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.
185 lines
5.5 KiB
TypeScript
185 lines
5.5 KiB
TypeScript
/**
|
|
* One-off ingest: write the 812 NSS hex-bolt DIN931 items
|
|
* (Grade 8.8 + Grade A2) from `scripts/nss-hex-bolts-din931-pb-import.json`
|
|
* into the PocketBase `stock_items` collection.
|
|
*
|
|
* Same safety rails as the other ingest scripts (idempotent,
|
|
* applyDefaultMarkupToPatch, per-row try/catch).
|
|
*
|
|
* Run:
|
|
* npx tsx scripts/nss-hex-bolts-din931-ingest.ts --dry-run --limit 5
|
|
* npx tsx scripts/nss-hex-bolts-din931-ingest.ts
|
|
*/
|
|
|
|
import "./_env";
|
|
|
|
import { getAdminPocketBase } from "../src/lib/server/pocketbase-admin";
|
|
import { applyDefaultMarkupToPatch } from "../src/lib/server/stock-default-markup-service";
|
|
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
|
|
const IMPORT_JSON = join(
|
|
import.meta.dirname,
|
|
"nss-hex-bolts-din931-pb-import.json",
|
|
);
|
|
|
|
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;
|
|
|
|
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;
|
|
notes: string;
|
|
}>;
|
|
cost_price: number;
|
|
unit: string;
|
|
current_qty: number;
|
|
min_qty: number;
|
|
target_qty: number;
|
|
is_active: boolean;
|
|
supplier: string;
|
|
category: string;
|
|
};
|
|
|
|
function buildPatch(r: ImportRecord): Record<string, unknown> {
|
|
const seedEntry = {
|
|
cost_price: r.cost_price,
|
|
quantity: 0,
|
|
purchased_at: r.cost_history[0]?.extracted_at ?? new Date().toISOString(),
|
|
purchase_id: "nss-hex-bolts-din931-2026-09-16",
|
|
supplier_id: r.supplier,
|
|
source: "nssfasteners.co.za",
|
|
source_url: r.cost_history[0]?.source_url ?? "",
|
|
currency: r.cost_history[0]?.currency ?? "ZAR",
|
|
qty_basis: r.cost_history[0]?.qty_basis ?? 1,
|
|
extraction_method:
|
|
r.cost_history[0]?.extraction_method ?? "browser_automation",
|
|
notes: r.cost_history[0]?.notes ?? r.location,
|
|
};
|
|
|
|
return applyDefaultMarkupToPatch(
|
|
{
|
|
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],
|
|
},
|
|
undefined,
|
|
);
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
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"}`);
|
|
|
|
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 (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;
|
|
}
|
|
|
|
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 % 50 === 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);
|
|
});
|