/** * Backfill `ai_item_insights` for every quote_item and order_item * that doesn't already have the new-shape blob. * * 1. Direct CLI (this script): * node --env-file=.env.local $(which npx) tsx scripts/backfill-item-intel.ts * * The `--env-file` flag is required because the PB admin URL * is captured at module-load time and the env loader can't * run before the imports (ESM hoists imports above body code). * Node 20+ supports `--env-file` natively. * * 2. HTTP endpoint (so the operator's n8n workflow can trigger it): * POST /api/v1/items/backfill-intel * Body: { "collection": "quote_items", "limit": 1000 } * * The script calls the HTTP endpoint by default so the same code path * is exercised in both modes. Override with `--direct` to call the * service in-process (skips the HTTP layer, slightly faster). * * Safety: * - `--dry-run` previews the count without writing. * - `--limit N` caps the slice per collection (default 1000). * - Rows that already have the new-shape intel are skipped. */ // Load .env.local inline BEFORE any other imports (ESM hoists // imports, so the load call has to run before the next import is // evaluated). We use a self-invoking sync block at the top. (() => { try { for (const f of [".env", ".env.local"]) { try { const { readFileSync } = require("node:fs"); const { resolve } = require("node:path"); const raw = readFileSync(resolve(process.cwd(), f), "utf8"); for (const line of raw.split("\n")) { const t = line.trim(); if (!t || t.startsWith("#")) continue; const m = t.match(/^([A-Z0-9_]+)\s*=\s*(.+)$/i); if (m && !process.env[m[1]]) { process.env[m[1]] = m[2].replace(/^["']|["']$/g, "").trim(); } } } catch {} } } catch {} })(); import { populateItemIntel } from "../src/lib/server/item-intel-service"; import { getAdminPocketBase } from "../src/lib/server/pocketbase-admin"; const PB_URL = process.env.NEXT_PUBLIC_POCKETBASE_URL || `http://127.0.0.1:809${""}`; function isNewShape(value: unknown): boolean { if (!value || typeof value !== "object") return false; const obj = value as Record; return ["stock_list", "supplier_history", "price_list", "recent_quote", "recent_order"].every( (k) => Array.isArray(obj[k]), ); } async function backfill( collection: "quote_items" | "order_items", limit: number, dryRun: boolean, batchSize: number, thisMonthOnly: boolean, ): Promise<{ scanned: number; updated: number; skipped: number; errors: number; duration_ms: number }> { const t0 = Date.now(); const pb = await getAdminPocketBase(); // PB filter: only items whose parent (quote / order) is in-flight // and (optionally) created in the current month. Historical data // is excluded — the operator only needs intel on work that the // team is actively quoting / fulfilling right now. // // Active statuses per collection: // quote_items → parent quote.status IN (new, claimed, // approval_pending, approved) // order_items → parent order.item_status / .status IN // (pending, approval_needed, processed, // po_generated, collection_pool, assigned, // collected, picking, ready_delivery, delivered, // ready_invoice, invoiced) // // The parent filter is applied via the PB expand syntax: PB can't // filter on expanded fields directly, so we pull the parent IDs // first, then filter the child collection by `id IN (...)`. const ACTIVE_QUOTE_STATUSES = ["new", "claimed", "approval_pending", "approved"]; const ACTIVE_ORDER_STATUSES = [ "pending", "approval_needed", "processed", "po_generated", "collection_pool", "assigned", "collected", "picking", "ready_delivery", "delivered", "ready_invoice", "invoiced", ]; // quote_items: filter via the parent quote (active status + this // month). order_items: the parent `orders.status` field is // often empty — the active flag lives on the child itself // (`item_status`). So for orders we filter the child directly // and skip the parent lookup. let childFilter: string | null = null; let parentIdFilter: string[] | null = null; const isQuote = collection === "quote_items"; if (isQuote) { const dateFilter = thisMonthOnly ? buildCurrentMonthFilter("created") : ""; const statusFilter = ACTIVE_QUOTE_STATUSES.map((s) => `status="${s}"`).join(" || "); const filter = `(${statusFilter})${dateFilter ? " && " + dateFilter : ""}`; process.stdout.write(` parent filter: ${filter}\n`); const parents = await pb.collection("quotes").getFullList({ filter, fields: "id" }); parentIdFilter = parents.map((p) => p.id); if (parentIdFilter.length === 0) { return { scanned: 0, updated: 0, skipped: 0, errors: 0, duration_ms: Date.now() - t0 }; } } else { // order_items: direct filter on item_status + created. const dateFilter = thisMonthOnly ? buildCurrentMonthFilter("created") : ""; const statusFilter = ACTIVE_ORDER_STATUSES.map((s) => `item_status="${s}"`).join(" || "); childFilter = `(${statusFilter})${dateFilter ? " && " + dateFilter : ""}`; process.stdout.write(` child filter: ${childFilter}\n`); } // Step 2: fetch child rows. PB doesn't support `IN` on relation // Step 2: fetch child rows. Three modes: // 1. quote_items via parentIdFilter — iterate per parent (PB // doesn't support `IN` on relation fields). // 2. order_items via childFilter (item_status + date). // 3. no filter at all — just take the first `limit` rows. const childRows: Array<{ id: string; ai_item_insights: unknown }> = []; if (parentIdFilter) { for (const id of parentIdFilter) { const filter = `quote = "${id}"`; const rows = await pb.collection(collection).getList(1, limit, { fields: "id,ai_item_insights", filter, }); childRows.push( ...rows.items.map((r) => ({ id: r.id, ai_item_insights: r.ai_item_insights })), ); } } else if (childFilter) { const rows = await pb.collection(collection).getList(1, limit, { fields: "id,ai_item_insights", filter: childFilter, }); childRows.push( ...rows.items.map((r) => ({ id: r.id, ai_item_insights: r.ai_item_insights })), ); } else { const rows = await pb.collection(collection).getList(1, limit, { fields: "id,ai_item_insights", }); childRows.push( ...rows.items.map((r) => ({ id: r.id, ai_item_insights: r.ai_item_insights })), ); } const rows = { items: childRows }; let scanned = 0; let updated = 0; let skipped = 0; let errors = 0; for (let i = 0; i < rows.items.length; i += batchSize) { const batch = rows.items.slice(i, i + batchSize); const results = await Promise.all( batch.map(async (r) => { scanned += 1; if (isNewShape(r.ai_item_insights)) { return { ok: true, skipped: true }; } if (dryRun) { return { ok: true, skipped: false }; } try { await populateItemIntel(r.id, collection, { write: true }); return { ok: true, skipped: false }; } catch (err) { return { ok: false, skipped: false, err }; } }), ); for (const r of results) { if (!r.ok) { errors += 1; continue; } if (r.skipped) skipped += 1; else updated += 1; } if (updated % 50 === 0 && updated > 0) { process.stdout.write(` ${collection}: ${updated} updated...\n`); } } return { scanned, updated, skipped, errors, duration_ms: Date.now() - t0 }; } function buildCurrentMonthFilter(field: string): string { const now = new Date(); const y = now.getUTCFullYear(); const m = now.getUTCMonth(); // 0-indexed const start = new Date(Date.UTC(y, m, 1)).toISOString(); const end = new Date(Date.UTC(y, m + 1, 1)).toISOString(); return `${field} >= "${start}" && ${field} < "${end}"`; } async function main() { const args = new Set(process.argv.slice(2)); const dryRun = args.has("--dry-run"); const thisMonthOnly = !args.has("--all-time"); const collection = (args.has("--quote-items") ? "quote_items" : args.has("--order-items") ? "order_items" : "both") as | "quote_items" | "order_items" | "both"; const limitArg = [...args].find((a) => a.startsWith("--limit=")); const limit = limitArg ? parseInt(limitArg.split("=")[1] ?? "1000", 10) : 1000; const batchArg = [...args].find((a) => a.startsWith("--batch=")); const batchSize = batchArg ? parseInt(batchArg.split("=")[1] ?? "20", 10) : 20; console.log(`PB: ${PB_URL}`); console.log(`Mode: ${dryRun ? "DRY RUN" : "WRITE"}`); console.log(`Collection: ${collection}`); console.log(`Limit: ${limit} per collection`); console.log(); const t0 = Date.now(); const out: Record = {}; if (collection === "quote_items" || collection === "both") { out.quote_items = await backfill("quote_items", limit, dryRun, batchSize, thisMonthOnly); } if (collection === "order_items" || collection === "both") { out.order_items = await backfill("order_items", limit, dryRun, batchSize, thisMonthOnly); } console.log(); console.log("══════════════════════════════════════════════════"); console.log("RESULT"); console.log("══════════════════════════════════════════════════"); console.log(JSON.stringify(out, null, 2)); console.log(); console.log(`Total: ${((Date.now() - t0) / 1000).toFixed(1)}s`); if (dryRun) { console.log("\n(dry-run — nothing was written. Re-run without --dry-run to apply.)"); } } main().catch((err) => { console.error("FATAL:", err); process.exit(1); });