This commit replaces the legacy item history and AI insights system with a standardized, fuzzy-matched item intel workflow: - Update AiItemHistoryTrigger component to support both quote and order items via the `collection` prop, replacing the old `insights` prop with `intel` - Add full `ItemIntel` type definitions to `src/lib/types.ts` for the new standardized insight payload - Create new REST API endpoints for fetching and computing item intel for `quote_items` and `order_items` - Rewrite the MCP insight endpoint to use the new shared `item-intel-service` instead of legacy individual insight functions - Reorder quotes page tabs to place Delivery Note quotes alongside New quotes for better daily operator workflow - Add item intel triggers to quote process and order process pages - Remove deprecated document-level `AiInsightsPanel` components from quote and order detail pages - Add bulk backfill endpoint for populating intel across existing active items
253 lines
No EOL
9.9 KiB
TypeScript
253 lines
No EOL
9.9 KiB
TypeScript
/**
|
|
* 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);
|
|
}); |