WFDASH-WEBAPP/scripts/backfill-item-intel.ts

253 lines
9.9 KiB
TypeScript
Raw Normal View History

/**
* 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<string, unknown>;
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<string, unknown> = {};
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);
});