An account sale at the till used to produce a delivery-note-shaped slip that
was rendered entirely on the device and printed on the 80mm thermal printer.
Nothing reached the server, so the paperwork the client signed had no record
behind it. Cash / card / EFT are untouched and still get the thermal slip.
An account sale now creates a real `delivery_notes` record. The DN is raised
server-side INSIDE `checkoutSale` rather than by a follow-up call from the
till, because the offline queue (`flushQueue`) re-posts `/checkout` and
nothing else — a client-side second call would be silently dropped for every
sale taken while the network was down, which is exactly when a till is
flakiest.
`pos_sales.delivery_note` is the idempotency anchor that makes it
exactly-once: on a replay it hands back the existing DN instead of making a
second one, and a sale whose first attempt left the field blank retries. It
self-heals.
Deliberately non-fatal: if the DN can't be created the sale still succeeds.
The money and the stock movement are real by then; failing the checkout would
leave the drawer disagreeing with the stock ledger. The till is told instead.
Also in here:
- DN number collision retry. The generator reads the highest existing number
and adds one, which is not atomic, while PB holds a unique index on it. The
POS adds a second concurrent writer, so two creates could collide and 500
on a benign condition.
- GET /api/v1/pos/sales now expands customer, customer.company and
delivery_note so history.html can rebuild the DN card on a reprint. The
nested customer.company matters — customer.company is itself a relation,
so without it the card printed a raw PB id where the name belongs.
- "Print slip" is hidden for account sales; that tender produces a delivery
note, not a slip.
New GET /api/v1/pos/delivery-note/[number]/pdf streams the A4 original behind
assertPosAuth, so the till's bearer key stays its only credential. It does not
point at the CRM's unauthenticated invoicing route.
Verified end to end against a live note: DN26100401, correct company, both
lines with pricing, PDF attached, stock decremented once, and a replay of the
same client_ref returned the same DN without a duplicate.
108 lines
3.6 KiB
TypeScript
108 lines
3.6 KiB
TypeScript
/* eslint-disable */
|
|
/**
|
|
* Migration: add `delivery_note` to `pos_sales`.
|
|
*
|
|
* ## Why
|
|
*
|
|
* The POS used to render a delivery note entirely on the device
|
|
* (`renderDeliveryNote` in `public/pos/receipt.html`) and print it on
|
|
* the 80mm thermal printer. Nothing reached the server, so the DN the
|
|
* driver carried had no record behind it.
|
|
*
|
|
* Now an `account` tender creates a real `delivery_notes` record
|
|
* server-side, inside `checkoutSale`. This field is the idempotency
|
|
* anchor for that: the till's offline queue re-posts `/checkout` with
|
|
* the same `client_ref`, and without this field a replay would create a
|
|
* SECOND delivery note for one physical sale.
|
|
*
|
|
* `checkoutSale` reads this on the replay path — if it's set, the
|
|
* existing DN is returned untouched; if it's blank the creation is
|
|
* attempted (covers the case where the sale banked but DN creation
|
|
* failed).
|
|
*
|
|
* ## What it does
|
|
*
|
|
* Adds one field:
|
|
*
|
|
* - `delivery_note` (relation → delivery_notes, single, optional)
|
|
* The system delivery note raised for this account sale.
|
|
*
|
|
* Only set on `payment_method = "account"` sales that have a customer
|
|
* picked. Cash / card / EFT sales leave it blank forever.
|
|
*
|
|
* Idempotent — safe to re-run.
|
|
*
|
|
* Run with:
|
|
* npx tsx scripts/migrations/2026-10-03-add-pos-sales-delivery-note.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");
|
|
|
|
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(`[pos-sales-delivery-note] Authenticated against ${url}`);
|
|
|
|
const deliveryNotes = await pb.collections.getOne("delivery_notes");
|
|
const posSales = await pb.collections.getOne("pos_sales");
|
|
|
|
if (posSales.fields.some((f: { name: string }) => f.name === "delivery_note")) {
|
|
console.log("[pos-sales-delivery-note] pos_sales.delivery_note already present — nothing to do.");
|
|
return;
|
|
}
|
|
|
|
await pb.collections.update(posSales.id, {
|
|
fields: [
|
|
...posSales.fields,
|
|
{
|
|
name: "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 nested form is rejected
|
|
// with "fields.N.collectionId Cannot be blank."
|
|
collectionId: deliveryNotes.id,
|
|
cascadeDelete: false,
|
|
minSelect: 0,
|
|
maxSelect: 1,
|
|
},
|
|
],
|
|
});
|
|
console.log("[pos-sales-delivery-note] Added pos_sales.delivery_note.");
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error("[pos-sales-delivery-note] FAILED:", err);
|
|
process.exit(1);
|
|
});
|