WFDASH-WEBAPP/scripts/migrations/2026-08-06-add-stock-purchase-booked-fields.ts
SJCoetz 9e118a3b5f feat(stock-purchase): split warehouse confirm from accountant booking
Repositioned booking responsibility: warehouse confirms physical
collection only (records quantity_received + flips header to
"received"); the accountant books into inventory via the new
invoicing flow once the supplier invoice is processed. This way
the payment-cycle reasoning lives at the booking step, not at
receipt.

Changes
- Migration: add booked_at + booked_by to stock_purchases
- Service: bookStockFromInvoice (accountant-side, splits from
  bookStockRows)
- API route: POST /api/v1/invoicing/stock-book
- UI: stock rows surfaced on invoicing page (StockBookingSection)
- UI: stock-purchase detail page
  - Driver picker on header (sentinel "delivery" user auto-collects
    every line, real drivers set assigned_to_driver)
  - "Confirm collection" replaces "Mark received" (warehouse-side
    only — no inventory writes)
  - Cancel button with confirmation dialog
- collection-confirm: confirmStockRows stripped of inventory writes;
  the prior /api/v1/collection-confirm/book-stock route was renamed
  to /confirm-stock and is now strictly the warehouse-side confirm
- Tests for all new flows (5/5 passing on the stock-order detail
  page; 10/10 on StockBookingSection)

Co-Authored-By: Claude <noreply@anthropic.com>

Co-authored-by: Orca <help@stably.ai>
2026-08-06 23:01:15 +02:00

158 lines
5.8 KiB
TypeScript

/* eslint-disable */
/**
* Migration: add `booked_at` + `booked_by` to stock_purchases.
*
* Why this exists
* ───────────────
* The stock-purchase lifecycle is being split: the warehouse-side
* "confirm collection" step stops writing into stock_items. The
* *booking* into inventory is now the accountant's job — they
* commit to it when they've processed the supplier invoice, which
* is also the moment the payment clock starts.
*
* The header needs two new fields to record that moment:
*
* booked_at date — the date the accountant booked the stock
* into inventory. Defaults to "today" but can
* be backdated by the accountant when
* processing an older invoice. The value
* feeds the payment-cycle reasoning
* ("booked last week → pay now"; "booked
* last month → already in last cycle").
*
* booked_by relation — the user who pressed the "Book into
* stock" button. Optional but useful for
* audit + who-do-I-ask-questions-of.
*
* Both are nullable. A row with `booked_at = null` is "awaiting
* booking"; the invoicing page filters on this to drive the active
* queue. We are NOT adding a new value to `stock_purchases.status` —
* the parent stays in `received` and the booking is indicated by
* the `booked_at` timestamp alone.
*
* Schema
* ──────
* stock_purchases.booked_at date (new, nullable)
* stock_purchases.booked_by relation (new, → users, nullable)
*
* Idempotent — safe to re-run. The migration checks for the fields
* before adding them; subsequent runs print "already present, skipping."
*
* Run with:
* npx tsx scripts/migrations/2026-08-06-add-stock-purchase-booked-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 {
// missing file — fine
}
}
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 and PB_ADMIN_PASSWORD.",
);
}
const pb = new PocketBase(url);
await pb.admins.authWithPassword(email, password);
// ─── 1. stock_purchases.booked_at ─────────────────────────────
const stockPurchases = await pb.collections.getOne("stock_purchases");
const stockPurchasesFields = (stockPurchases.fields ?? []) as Array<
Record<string, unknown>
>;
const existingNames = new Set(stockPurchasesFields.map((f) => f.name));
const additions: Array<Record<string, unknown>> = [];
if (!existingNames.has("booked_at")) {
additions.push({
name: "booked_at",
type: "date",
required: false,
options: { min: "", max: "" },
});
} else {
console.log("stock_purchases.booked_at already present, skipping.");
}
if (!existingNames.has("booked_by")) {
// Resolve the users collection id. We look it up by name from the
// full collection list because PB reserves the `users` name for
// the auth collection and the literal id is environment-specific.
const all = await pb.collections.getFullList();
const users = all.find((c) => c.name === "users");
if (!users) {
throw new Error("Required collection `users` not found.");
}
additions.push({
name: "booked_by",
type: "relation",
required: false,
collectionId: users.id,
cascadeDelete: false,
maxSelect: 1,
minSelect: 0,
});
} else {
console.log("stock_purchases.booked_by already present, skipping.");
}
if (additions.length > 0) {
await pb.collections.update(stockPurchases.id, {
fields: [...stockPurchasesFields, ...additions],
});
console.log(
`Added stock_purchases fields: ${additions.map((f) => f.name).join(", ")}`,
);
} else {
console.log("stock_purchases booked fields already present.");
}
// ─── 2. Index on `booked_at` ──────────────────────────────────
// The invoicing page's "active queue" filter is
// `booked_at = null AND status = "received"`. Without an index
// this is a full table scan on every load. The index is a
// expression-friendly variant: a plain B-tree on the column.
const INDEX_NAME = "idx_sp_booked_at";
const INDEX_SQL = `CREATE INDEX \`${INDEX_NAME}\` ON \`stock_purchases\` (\`booked_at\`)`;
const existingIndexes = (stockPurchases.indexes ?? []) as string[];
if (!existingIndexes.includes(INDEX_SQL)) {
await pb.collections.update(stockPurchases.id, {
indexes: [...existingIndexes, INDEX_SQL],
});
console.log("Added stock_purchases.booked_at index (idx_sp_booked_at).");
} else {
console.log("stock_purchases.booked_at index already present, skipping.");
}
console.log("Migration complete.");
console.log(
"Next step: implement src/lib/server/stock-booking-service.ts and the /api/v1/invoicing/stock-book route.",
);
}
main().catch((error) => {
console.error("[add-stock-purchase-booked-fields] failed:", error);
process.exit(1);
});