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>
158 lines
5.8 KiB
TypeScript
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);
|
|
});
|