- 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.
188 lines
5.8 KiB
TypeScript
188 lines
5.8 KiB
TypeScript
/**
|
|
* One-off ingest: write the 57 NSS black-cut-washer DIN 126 items
|
|
* from `scripts/nss-washers-din126-pb-import.json` into the
|
|
* PocketBase `stock_items` collection.
|
|
*
|
|
* Mirror of `scripts/nss-fasteners-ingest.ts` but pointed at the
|
|
* washers payload. Same safety rails:
|
|
*
|
|
* - Reuses `applyDefaultMarkupToPatch` so the +80% markup rule
|
|
* fires exactly the same way as the nuts ingest (and the API
|
|
* create endpoint).
|
|
* - Idempotent: skips SKUs already in PB.
|
|
* - Per-row try/catch — failures are reported, the rest still
|
|
* write, the script exits non-zero so you can re-run for the
|
|
* failed SKUs only.
|
|
*
|
|
* Run:
|
|
* npx tsx scripts/nss-washers-ingest.ts --dry-run --limit 5
|
|
* npx tsx scripts/nss-washers-ingest.ts --limit 5
|
|
* npx tsx scripts/nss-washers-ingest.ts
|
|
*/
|
|
|
|
import "./_env"; // side-effect import: loads .env.local
|
|
|
|
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-washers-din126-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-washers-din126-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 % 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);
|
|
});
|