198 lines
7 KiB
TypeScript
198 lines
7 KiB
TypeScript
|
|
/* 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);
|
||
|
|
});
|