WFDASH-WEBAPP/scripts/migrations/2026-07-06-order-items-pricing-fields.ts

198 lines
7 KiB
TypeScript
Raw Normal View History

/* eslint-disable */
/**
* Migration — add pricing-source fields to `order_items` and
* repopulate `item_status` select options.
*
* Why:
* The order process page needs to show the buyer which matched
* quote line a cost came from, and to round-trip those values when
* a price is copied from the quote (or re-priced from the catalogue).
* Today those fields only live on `quote_items`. The four we're
* adding to `order_items`:
*
* - sku text the supplier's SKU code (e.g.
* "M20-HEX-40-G88")
* - pricing_source text "quote" | "catalogue" |
* "manual" — drives the
* audit-trail chip on the
* process page.
* - matched_price_list_id text catalogue row id when sourced
* from `price_list`.
* - pricing_notes text free text from the operator
* ("cost confirmed via
* phone call 2026-07-06")
*
* Plus a fix-up: `item_status` was migrated to a select with empty
* `values: []` at some point in the past, which means PB will
* accept arbitrary string writes but the operator can no longer
* see/filter the canonical stages. Repopulating the values list
* restores both the dropdown UI and the canonical set.
*
* Idempotent — each step checks before mutating, so partial-success
* recovery is safe.
*
* Run with:
* npx tsx scripts/migrations/2026-07-06-order-items-pricing-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 {
/* fine — rely on shell env */
}
}
loadEnvFile(".env");
loadEnvFile(".env.local");
// Fields to add (text fields, all optional, no defaults).
const NEW_TEXT_FIELDS = [
{ name: "sku", type: "text", required: false, options: { max: 100 } },
{ name: "pricing_source", type: "text", required: false, options: { max: 50 } },
{ name: "matched_price_list_id", type: "text", required: false, options: { max: 50 } },
{ name: "pricing_notes", type: "text", required: false, options: { max: 2000 } },
] as const;
// Canonical item_status options (PB Select requires non-empty values).
// Mirrors what's in src/lib/types.ts union.
const ITEM_STATUS_VALUES = [
"pending",
"processed",
"po_generated",
"driver_pool",
"collected",
"assigned",
"delivered",
"cancelled",
];
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-items-pricing-fields] Authenticated against ${url}`);
const col = await pb.collections.getOne("order_items");
const fields = (col.fields as Array<{
name: string;
type: string;
required?: boolean;
options?: Record<string, unknown>;
}>).slice();
// ─── Step 1: add the four new text fields ─────────────────────────
let added = 0;
for (const target of NEW_TEXT_FIELDS) {
if (fields.some((f) => f.name === target.name)) {
console.log(
`[order-items-pricing-fields] ${target.name} already exists — skip`,
);
continue;
}
fields.push({ ...target });
added++;
}
console.log(
`[order-items-pricing-fields] New text fields added: ${added} of ${NEW_TEXT_FIELDS.length}`,
);
// ─── Step 2: repopulate item_status select values ─────────────────
let itemStatusTouched = false;
const itemStatusIdx = fields.findIndex((f) => f.name === "item_status");
if (itemStatusIdx === -1) {
console.warn(
"[order-items-pricing-fields] No `item_status` field found — skipping select-values fixup (manual check needed).",
);
} else {
const f = fields[itemStatusIdx];
const opts = (f.options ?? {}) as Record<string, unknown>;
const values = Array.isArray(opts.values) ? (opts.values as unknown[]) : [];
// Normalise: PB can accept either string[] or {value,label}[]; we
// pick the simplest representation the admin UI renders cleanly.
const existingStrings = values
.map((v) => (typeof v === "string" ? v : (v as { value?: string })?.value ?? ""))
.filter(Boolean);
const missing = ITEM_STATUS_VALUES.filter(
(v) => !existingStrings.includes(v),
);
if (missing.length === 0) {
console.log(
`[order-items-pricing-fields] item_status already has all ${ITEM_STATUS_VALUES.length} canonical values — skip`,
);
} else {
fields[itemStatusIdx] = {
...f,
required: true,
options: {
...opts,
values: ITEM_STATUS_VALUES,
},
};
itemStatusTouched = true;
console.log(
`[order-items-pricing-fields] item_status: added ${missing.length} missing values (${missing.join(", ")})`,
);
}
}
if (added === 0 && !itemStatusTouched) {
console.log(
"[order-items-pricing-fields] No fields needed updating — schema is already current.",
);
return;
}
await pb.collections.update(col.id, { fields });
console.log("[order-items-pricing-fields] Schema updated.");
// Re-fetch + verify
const fresh = await pb.collections.getOne("order_items");
const verifyFields = (fresh.fields as Array<{
name: string;
type: string;
required?: boolean;
options?: { values?: unknown[] };
}>)
.filter((f) =>
NEW_TEXT_FIELDS.some((t) => t.name === f.name) ||
f.name === "item_status",
)
.map((f) => {
if (f.name === "item_status") {
const vals = Array.isArray(f.options?.values)
? f.options!.values
.map((v) => (typeof v === "string" ? v : (v as { value?: string })?.value ?? ""))
.filter(Boolean)
: [];
return `${f.name}:${vals.length} values`;
}
return `${f.name}:${f.type}`;
})
.join(", ");
console.log(`[order-items-pricing-fields] Post-update → ${verifyFields}`);
console.log("[order-items-pricing-fields] Migration complete.");
}
main().catch((err) => {
console.error("[order-items-pricing-fields] Migration failed:", err);
process.exit(1);
});