WFDASH-WEBAPP/scripts/2026-08-13-heal-item-intel.ts
Dave 43de97501c feat(tv): add TV dashboard + refine workflows
Add full PIN-protected TV dashboard stack including auth, redirects, map view, APIs, and server services.

Also include these critical refinements:
- Overhaul PO number generation to use per-company monthly sequences
- Rework quote rejection to bounce quotes back to operator queues instead of marking them terminal
- Add post_processing order tab group for clearer workflow visibility
- Fix collection pool tooltip positioning, overflow clipping, and default collapsed state
- Refactor customer list API, clean up unused form props, and fix item intel popover target ID handling
- Add comprehensive tests for new components and update existing test expectations
2026-08-13 20:55:22 +02:00

149 lines
No EOL
5.2 KiB
TypeScript

/**
* Backfill: heal broken `ai_item_insights.item.id` fields.
*
* Context
* ────────
* The `ItemIntelPopover` apply button uses
* `intel.item.id || itemId`
* to decide which PB record to PATCH. The user-side `itemId` prop is
* the authoritative target, but the stored blobl was historically
* written without that field (or written for the wrong collection).
*
* Live data:
* - 246 items have `intel.item.id = ""` (empty string)
* - 32 items have `intel.item` missing entirely
* - Of those, many also have `intel.item.collection = "quote_items"`
* on an order_item record (the blob was carried over from a quote
* without the field being re-stamped)
*
* The `??` operator in the popover only catches null/undefined, so an
* empty string ids bypasses the fallback and the apply fails. The
* popover now uses `||` which catches empty strings too — this script
* heals the existing data so the blob is correct end-to-end.
*
* What it does
* ────────────
* For every `order_items` and `quote_items` row that has a non-null
* `ai_item_insights` blob:
* - If `intel.item` is missing OR `intel.item.id` is empty OR
* `intel.item.collection` doesn't match the source collection:
* rebuild `intel.item` from the item's own id/description/sku/etc.
* - Writes the corrected blob back to the row.
* - Leaves the rest of the blob (stock_list, supplier_history,
* price_list, recent_quote, recent_order, summary, generated_at,
* verified) untouched.
*
* Idempotent: re-running produces no further changes once everything
* is healed. Add `--dry-run` to preview without writing.
*
* Run with:
* npx tsx --env-file=.env.local scripts/2026-08-13-heal-item-intel.ts
*
* Requires .env.local with NEXT_PUBLIC_POCKETBASE_URL, PB_ADMIN_EMAIL,
* PB_ADMIN_PASSWORD.
*/
import "./_env"; // side-effect import: loads .env.local before anything else
import { getAdminPocketBase } from "../src/lib/server/pocketbase-admin";
const DRY_RUN = process.argv.includes("--dry-run");
async function healCollection(
collection: "order_items" | "quote_items",
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
): Promise<{ scanned: number; repaired: number; skipped: number }> {
let scanned = 0;
let repaired = 0;
let skipped = 0;
// Pull every row with a non-null blob. We deliberately keep all
// fields on the blobs we read so we can write them back unchanged.
const rows = await pb.collection(collection).getFullList<{
id: string;
description: string;
sku: string;
quantity: number;
ai_item_insights: Record<string, unknown> | null;
}>({
filter: `ai_item_insights != "" && ai_item_insights != null`,
fields: "id,description,sku,quantity,ai_item_insights",
batch: 500,
});
for (const row of rows) {
scanned++;
const blob = row.ai_item_insights;
if (!blob || typeof blob !== "object") {
skipped++;
continue;
}
const item = (blob as { item?: unknown }).item;
const itemObj = item && typeof item === "object"
? (item as Record<string, unknown>)
: null;
const itemId = itemObj?.id;
const itemCollection = itemObj?.collection;
const needsFix =
!itemObj ||
typeof itemId !== "string" ||
itemId === "" ||
itemCollection !== collection;
if (!needsFix) {
continue;
}
const fixedItem = {
id: row.id,
collection,
description: (itemObj?.description as string) || row.description || "",
sku: (itemObj?.sku as string) || row.sku || "",
quantity: (itemObj?.quantity as number) || row.quantity || 0,
};
if (DRY_RUN) {
console.log(` [DRY] ${collection} ${row.id}: would fix item.id "" → "${row.id}"`);
repaired++;
continue;
}
await pb.collection(collection).update(row.id, {
ai_item_insights: { ...blob, item: fixedItem },
});
repaired++;
if (repaired % 25 === 0) {
console.log(` ... ${repaired} repaired so far`);
}
}
return { scanned, repaired, skipped };
}
async function main() {
const startedAt = Date.now();
console.log(`=== Heal broken item-intel.item.id fields ===\n`);
if (DRY_RUN) console.log(" (DRY RUN — no writes)\n");
const pb = await getAdminPocketBase();
const orderResult = await healCollection("order_items", pb);
console.log(`order_items: scanned=${orderResult.scanned} repaired=${orderResult.repaired} skipped=${orderResult.skipped}`);
const quoteResult = await healCollection("quote_items", pb);
console.log(`quote_items: scanned=${quoteResult.scanned} repaired=${quoteResult.repaired} skipped=${quoteResult.skipped}`);
const totalScanned = orderResult.scanned + quoteResult.scanned;
const totalRepaired = orderResult.repaired + quoteResult.repaired;
console.log(`\n=== Done ===`);
console.log(` Total scanned: ${totalScanned}`);
console.log(` Total repaired: ${totalRepaired}`);
console.log(` Total skipped: ${totalScanned - totalRepaired}`);
console.log(` Duration: ${((Date.now() - startedAt) / 1000).toFixed(1)}s`);
if (DRY_RUN) console.log(` (Run without --dry-run to write)`);
}
main().catch((err) => {
console.error("FATAL:", err);
process.exit(1);
});