- 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.
187 lines
No EOL
5.6 KiB
TypeScript
187 lines
No EOL
5.6 KiB
TypeScript
/**
|
|
* One-off ingest: write the 33 NSS grease nipple items
|
|
* (DIN71412A 180°, DIN71412B 45°, DIN71412C 90°, DIN3404 Button,
|
|
* DIN71412A Long) from `scripts/nss-grease-nipples-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-grease-nipples-ingest.ts --dry-run --limit 5
|
|
* npx tsx scripts/nss-grease-nipples-ingest.ts --limit 5
|
|
* npx tsx scripts/nss-grease-nipples-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-grease-nipples-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-grease-nipples-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);
|
|
}); |