WFDASH-WEBAPP/scripts/migrations/2026-08-29-add-order-delivery-note-match-fields.ts
Dave e0d1356da5 feat(orders): add delivery-note repeat-order match fields
Five new fields on the `orders` collection:

- potential_delivery_note (relation → delivery_notes, single) — the
  DN whose items exactly matched this order's items
- potential_delivery_note_number (text) — denormalized copy of the
  matched DN's delivery_note_number, written alongside the relation
  so the orders list can render the number without a second `expand`
  (PB issues a separate IN query per expanded relation —
  orders-service.ts:253 documents why this is avoided here)
- delivery_note_match_confidence (number) — always 1 when set, kept
  for shape parity with quote_match_confidence
- delivery_note_match_method (text) — 'exact_items' or 'no_match',
  text not select for the same forward-compatibility reason
  quote_match_method is text
- delivery_note_matching_attempted (bool) — audit flag

Idempotent — each step detects its own 'already done' state.
Verified by re-running after the first apply.

Plan-staleness fix: PB stores relation options FLAT at the top
level of the field object (collectionId / cascadeDelete /
minSelect / maxSelect), not nested under an `options` key. The
plan's snippet had the nested form and PB rejected it with
'fields.N.collectionId Cannot be blank.' Verified the correct
shape against the live orders.potential_quote field before fixing.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 12:57:16 +02:00

143 lines
4.6 KiB
TypeScript

/* eslint-disable */
/**
* Migration: add delivery-note repeat-order detection fields to `orders`.
*
* Adds five fields:
*
* 1. `potential_delivery_note` (relation → delivery_notes, single)
* The DN whose items exactly matched this order's items.
*
* 2. `potential_delivery_note_number` (text)
* Denormalized copy of the matched DN's `delivery_note_number`.
* Written alongside (1) so the orders list can render the number
* without a second `expand` — see the comment at
* orders-service.ts:253 for why that expand cost is avoided here
* (PB issues a separate IN query per expanded relation).
* `delivery_note_number` is immutable once assigned, so copying
* it carries no staleness risk.
*
* 3. `delivery_note_match_confidence` (number)
* Always 1 when set — the match is exact-or-nothing, not scored.
* Kept for shape parity with quote_match_confidence and so a
* future fuzzy extension doesn't need a second migration.
*
* 4. `delivery_note_match_method` (text)
* "exact_items" or "no_match". Text, not select, for the same
* forward-compatibility reason quote_match_method is text.
*
* 5. `delivery_note_matching_attempted` (bool)
* Audit flag mirroring quote_matching_attempted.
*
* Idempotent — safe to re-run. Each step detects its own "already
* done" state.
*
* Run with:
* npx tsx scripts/migrations/2026-08-29-add-order-delivery-note-match-fields.ts
*/
import PocketBase from "pocketbase";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
function loadEnvFile(name: string) {
try {
const raw = readFileSync(resolve(process.cwd(), name), "utf8");
for (const line of raw.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const m = trimmed.match(/^([A-Z0-9_]+)\s*=\s*(.+)$/i);
if (m && !process.env[m[1]]) {
process.env[m[1]] = m[2].replace(/^["']|["']$/g, "").trim();
}
}
} catch {
// file not present — fine, rely on shell env
}
}
loadEnvFile(".env");
loadEnvFile(".env.local");
async function main() {
const url = process.env.NEXT_PUBLIC_POCKETBASE_URL;
const email = process.env.PB_ADMIN_EMAIL;
const password = process.env.PB_ADMIN_PASSWORD;
if (!url || !email || !password) {
throw new Error(
"Set NEXT_PUBLIC_POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD (e.g. sourced from .env.local).",
);
}
const pb = new PocketBase(url);
await pb.collection("_superusers").authWithPassword(email, password);
console.log(`[order-delivery-note-match] Authenticated against ${url}`);
const deliveryNotes = await pb.collections.getOne("delivery_notes");
async function addFieldIfMissing(
fieldDef: Record<string, unknown> & { name: string },
) {
const orders = await pb.collections.getOne("orders");
if (orders.fields.some((f: { name: string }) => f.name === fieldDef.name)) {
console.log(`[order-delivery-note-match] orders.${fieldDef.name} already present — skipping.`);
return;
}
await pb.collections.update(orders.id, {
fields: [...orders.fields, fieldDef],
});
console.log(`[order-delivery-note-match] Added orders.${fieldDef.name}.`);
}
await addFieldIfMissing({
name: "potential_delivery_note",
type: "relation",
required: false,
presentable: false,
// PB stores relation options FLAT at the top level of the field
// object, not nested under `options`. Verified against the live
// schema by inspecting `orders.potential_quote`. The plan's
// snippet showed the nested form; PB rejects it with
// "fields.N.collectionId Cannot be blank."
collectionId: deliveryNotes.id,
cascadeDelete: false,
minSelect: 0,
maxSelect: 1,
});
await addFieldIfMissing({
name: "potential_delivery_note_number",
type: "text",
required: false,
presentable: false,
options: { max: 32 },
});
await addFieldIfMissing({
name: "delivery_note_match_confidence",
type: "number",
required: false,
presentable: false,
options: { min: 0, max: 1 },
});
await addFieldIfMissing({
name: "delivery_note_match_method",
type: "text",
required: false,
presentable: false,
options: { max: 32 },
});
await addFieldIfMissing({
name: "delivery_note_matching_attempted",
type: "bool",
required: false,
presentable: false,
options: {},
});
console.log("[order-delivery-note-match] Migration complete.");
}
main().catch((err) => {
console.error("[order-delivery-note-match] Migration failed:", err);
process.exit(1);
});