2026-07-27 06:21:20 +00:00
|
|
|
/**
|
|
|
|
|
* Server-side helpers for the /deliveries page.
|
|
|
|
|
*
|
|
|
|
|
* Two operations:
|
|
|
|
|
* 1. `loadItems` — bulk flip `invoiced → with_driver` and stamp
|
|
|
|
|
* `loaded_at` to now. Used by the "To Load" tab.
|
|
|
|
|
* 2. `checkInItems` — bulk flip `with_driver → checked_in`,
|
|
|
|
|
* write the typed `delivery_reference`, and stamp
|
|
|
|
|
* `checked_in_at` + `checked_in_by` (current authed user).
|
|
|
|
|
* Used by the "Back from Driver" tab.
|
|
|
|
|
*
|
|
|
|
|
* Both are tolerant: an item that's already in the target state is
|
|
|
|
|
* silently skipped (idempotent on retry), and an item that's in an
|
|
|
|
|
* unexpected source state is also skipped + reported in the result's
|
|
|
|
|
* `skipped` array. The UI uses the skipped list to surface a
|
|
|
|
|
* "some items couldn't be moved" toast so the operator can re-check.
|
|
|
|
|
*
|
|
|
|
|
* Why a service module instead of inline route logic: the bulk
|
|
|
|
|
* update is a non-trivial loop over `pb.collection().update()` per
|
|
|
|
|
* id — a single 500 from one bad row would otherwise abort the
|
|
|
|
|
* whole batch. Centralising the loop lets us add per-row error
|
|
|
|
|
* handling + a structured result without bloating the route file.
|
|
|
|
|
*/
|
|
|
|
|
import { getAdminPocketBase } from "./pocketbase-admin";
|
|
|
|
|
import { getOrderItemsCollectionName } from "./orders-service";
|
2026-08-16 23:27:03 +00:00
|
|
|
import type { DeliveryNote } from "@/lib/types";
|
|
|
|
|
|
|
|
|
|
const DELIVERY_NOTES = "delivery_notes";
|
2026-07-27 06:21:20 +00:00
|
|
|
|
|
|
|
|
export type DeliveryListTab = "to_load" | "back_from_driver" | "checked_in";
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The PB item_status values each tab filters on.
|
|
|
|
|
*
|
|
|
|
|
* to_load → invoiced (waiting for warehouse to load)
|
|
|
|
|
* back_from_driver → with_driver (driver brought the paper back,
|
|
|
|
|
* operator needs to type the reference)
|
|
|
|
|
* checked_in → checked_in (operator verified, waiting for
|
|
|
|
|
* n8n to upload the signed copy + flip to
|
|
|
|
|
* `completed`)
|
|
|
|
|
*
|
|
|
|
|
* `completed` is intentionally NOT a tab here — those items are
|
|
|
|
|
* considered "done" and the office doesn't need to act on them.
|
|
|
|
|
* The 4th state will get its own tab once the n8n workflow ships.
|
|
|
|
|
*/
|
|
|
|
|
export const TAB_TO_ITEM_STATUS: Record<DeliveryListTab, string[]> = {
|
|
|
|
|
to_load: ["invoiced"],
|
|
|
|
|
back_from_driver: ["with_driver"],
|
|
|
|
|
checked_in: ["checked_in"],
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-16 23:27:03 +00:00
|
|
|
/**
|
|
|
|
|
* The PB delivery_notes.status values each tab filters on for the
|
|
|
|
|
* DN rows. The DN paper tracks the same three stops as the items,
|
|
|
|
|
* with `draft` added to the To Load tab so every DN that hasn't
|
|
|
|
|
* been invoiced surfaces on /deliveries (the operator's request):
|
|
|
|
|
*
|
|
|
|
|
* to_load → draft, sent
|
|
|
|
|
* (paper on the desk; needs to
|
|
|
|
|
* be sent + loaded for driver)
|
|
|
|
|
* back_from_driver → loaded, po_received
|
|
|
|
|
* (paper is with the driver —
|
|
|
|
|
* either on the way out or
|
|
|
|
|
* back with the customer PO;
|
|
|
|
|
* signed copy still pending)
|
|
|
|
|
* checked_in → invoiced (invoice was created; signed
|
|
|
|
|
* copy still pending)
|
|
|
|
|
*
|
|
|
|
|
* `invoiced` is excluded from the To Load tab — once the invoice
|
|
|
|
|
* is created, the DN shows on the Checked In tab instead. Items
|
|
|
|
|
* take over from there. `completed` (signed copy received) is
|
|
|
|
|
* excluded everywhere — same reason completed items are excluded.
|
|
|
|
|
* `cancelled` is excluded everywhere too: those DNs are out of
|
|
|
|
|
* office scope.
|
|
|
|
|
*/
|
|
|
|
|
export const TAB_TO_DN_STATUS: Record<DeliveryListTab, string[]> = {
|
|
|
|
|
to_load: ["draft", "sent"],
|
|
|
|
|
back_from_driver: ["loaded", "po_received"],
|
|
|
|
|
checked_in: ["invoiced"],
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-27 06:21:20 +00:00
|
|
|
export function isDeliveryListTab(s: string): s is DeliveryListTab {
|
|
|
|
|
return s === "to_load" || s === "back_from_driver" || s === "checked_in";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type DeliveryListResult = {
|
|
|
|
|
items: Array<{
|
|
|
|
|
id: string;
|
|
|
|
|
description: string;
|
|
|
|
|
quantity: number | null;
|
|
|
|
|
selling_price: number | null;
|
|
|
|
|
cost_price: number | null;
|
|
|
|
|
item_status: string;
|
|
|
|
|
delivery_reference: string | null;
|
2026-09-14 10:58:43 +00:00
|
|
|
/**
|
|
|
|
|
* Human-readable DN id (e.g. `DN26091405`) this item
|
|
|
|
|
* belongs to. The /deliveries page's DN expand panel
|
|
|
|
|
* joins on this field against each delivery note's
|
|
|
|
|
* `delivery_note_number`. The PB schema's relation
|
|
|
|
|
* field `delivery_note` exists but nothing writes to
|
|
|
|
|
* it; the text column is the actual join key.
|
|
|
|
|
*/
|
|
|
|
|
delivery_note_number: string | null;
|
2026-07-27 06:21:20 +00:00
|
|
|
/**
|
|
|
|
|
* QuickBooks invoice number, extracted from the nested
|
|
|
|
|
* `order_item_details.invoice.number` JSON. Null for rows
|
|
|
|
|
* that haven't been through the QB invoicing flow yet
|
|
|
|
|
* (older rows, or counter-pickup ad-hoc items that were
|
|
|
|
|
* never sent to QB).
|
|
|
|
|
*/
|
|
|
|
|
invoice_number: string | null;
|
|
|
|
|
loaded_at: string | null;
|
|
|
|
|
checked_in_at: string | null;
|
|
|
|
|
order: {
|
|
|
|
|
id: string;
|
|
|
|
|
order_number: string;
|
|
|
|
|
customer_id: string;
|
|
|
|
|
customer_name: string | null;
|
|
|
|
|
company_id: string;
|
|
|
|
|
company_name: string | null;
|
|
|
|
|
} | null;
|
|
|
|
|
checked_in_by: { id: string; name?: string; email?: string } | null;
|
|
|
|
|
}>;
|
2026-08-16 23:27:03 +00:00
|
|
|
/**
|
|
|
|
|
* Delivery-note rows that ride the same tab mapping as the items
|
|
|
|
|
* (sent / po_received / invoiced → to_load / back_from_driver /
|
|
|
|
|
* checked_in). The operator needs to track the signed-copy return
|
|
|
|
|
* for every DN whose paper is out — that's the whole reason this
|
|
|
|
|
* surface exists on /deliveries.
|
|
|
|
|
*/
|
|
|
|
|
delivery_notes: Array<{
|
|
|
|
|
id: string;
|
|
|
|
|
delivery_note_number: string;
|
|
|
|
|
status: string;
|
|
|
|
|
customer_po: string | null;
|
|
|
|
|
delivery_date: string | null;
|
|
|
|
|
signed_copy: string[] | null;
|
|
|
|
|
order: {
|
|
|
|
|
id: string;
|
|
|
|
|
order_number: string;
|
|
|
|
|
customer_id: string;
|
|
|
|
|
customer_name: string | null;
|
|
|
|
|
company_id: string;
|
|
|
|
|
company_name: string | null;
|
|
|
|
|
} | null;
|
|
|
|
|
}>;
|
2026-09-14 10:58:43 +00:00
|
|
|
/**
|
|
|
|
|
* Items linked to the page's DNs regardless of `item_status`.
|
|
|
|
|
* Feeds the DN expand panel so it shows every item on the DN
|
|
|
|
|
* (same scope as the full /delivery-notes/{id} page). The
|
|
|
|
|
* main items list on the page uses `items` only — `dn_items`
|
|
|
|
|
* is never rendered as a top-level row.
|
|
|
|
|
*/
|
|
|
|
|
dn_items: Array<{
|
|
|
|
|
id: string;
|
|
|
|
|
description: string;
|
|
|
|
|
quantity: number | null;
|
|
|
|
|
selling_price: number | null;
|
|
|
|
|
cost_price: number | null;
|
|
|
|
|
item_status: string;
|
|
|
|
|
delivery_reference: string | null;
|
|
|
|
|
delivery_note_number: string | null;
|
|
|
|
|
invoice_number: string | null;
|
|
|
|
|
loaded_at: string | null;
|
|
|
|
|
checked_in_at: string | null;
|
|
|
|
|
order: {
|
|
|
|
|
id: string;
|
|
|
|
|
order_number: string;
|
|
|
|
|
customer_id: string;
|
|
|
|
|
customer_name: string | null;
|
|
|
|
|
company_id: string;
|
|
|
|
|
company_name: string | null;
|
|
|
|
|
} | null;
|
|
|
|
|
checked_in_by: { id: string; name?: string; email?: string } | null;
|
|
|
|
|
}>;
|
2026-07-27 06:21:20 +00:00
|
|
|
totalItems: number;
|
2026-08-16 23:27:03 +00:00
|
|
|
totalDeliveryNotes: number;
|
2026-07-27 06:21:20 +00:00
|
|
|
page: number;
|
|
|
|
|
perPage: number;
|
|
|
|
|
totalPages: number;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Load the order_items for one of the three tabs, with the
|
|
|
|
|
* expanded order (number + company + customer id) and the
|
|
|
|
|
* operator who checked the item in (for the Checked In tab).
|
|
|
|
|
*
|
|
|
|
|
* Why we return a flat list instead of pre-grouping by customer:
|
|
|
|
|
* the client already has the orders / customers collections loaded
|
|
|
|
|
* for the dashboard; grouping on the client keeps the server
|
|
|
|
|
* response shape simple + lets the operator re-sort the page
|
|
|
|
|
* without a round trip.
|
|
|
|
|
*/
|
|
|
|
|
export async function listDeliveryItems(params: {
|
|
|
|
|
tab: DeliveryListTab;
|
|
|
|
|
page?: number;
|
|
|
|
|
perPage?: number;
|
|
|
|
|
search?: string;
|
|
|
|
|
}): Promise<DeliveryListResult> {
|
|
|
|
|
const pb = await getAdminPocketBase();
|
|
|
|
|
const page = params.page || 1;
|
|
|
|
|
const perPage = params.perPage || 200; // one page covers the
|
|
|
|
|
// office for a full week
|
|
|
|
|
// in practice
|
|
|
|
|
const statuses = TAB_TO_ITEM_STATUS[params.tab];
|
|
|
|
|
const statusFilter = statuses
|
|
|
|
|
.map((s) => `item_status = "${s}"`)
|
|
|
|
|
.join(" || ");
|
|
|
|
|
|
|
|
|
|
// The order_items.order relation is optional as of the
|
|
|
|
|
// 2026-07-19-delivery-notes-v3 migration (ad-hoc counter-pickup
|
|
|
|
|
// rows have no parent order). We still want those rows on the
|
|
|
|
|
// page — they came off the shelf with no order backing them,
|
|
|
|
|
// and they still need to be loaded for the customer. So we
|
|
|
|
|
// DON'T filter on `order != ""` here.
|
|
|
|
|
//
|
|
|
|
|
// Search: a single search box hits everything the office
|
|
|
|
|
// might type. The fields covered are split between server-side
|
|
|
|
|
// (where PB's filter can reach them) and client-side (where
|
|
|
|
|
// the join is too deep for PB to filter on directly):
|
|
|
|
|
//
|
|
|
|
|
// Server-side (PB filter):
|
|
|
|
|
// - `description` — text, `~` substring
|
|
|
|
|
// - `order_item_details.invoice.number`
|
|
|
|
|
// — JSON path, `~` substring;
|
|
|
|
|
// the QB invoice number is
|
|
|
|
|
// nested inside the
|
|
|
|
|
// `order_item_details` JSON
|
|
|
|
|
// column. PB's dot-notation
|
|
|
|
|
// filter handles it.
|
|
|
|
|
// - `selling_price` — number, exact match.
|
|
|
|
|
// Activated only when the
|
|
|
|
|
// search string parses as a
|
|
|
|
|
// number (see below).
|
|
|
|
|
// - `cost_price` — number, exact match.
|
|
|
|
|
//
|
|
|
|
|
// Client-side (the page filters again after fetch):
|
|
|
|
|
// - `order.order_number` — 2nd-level expand; PB can't
|
|
|
|
|
// filter on it directly in
|
|
|
|
|
// this version. The page
|
|
|
|
|
// does a case-insensitive
|
|
|
|
|
// `includes` over the
|
|
|
|
|
// already-fetched rows.
|
|
|
|
|
// - `order.customer.customer` — same reason. The customer
|
|
|
|
|
// name is on the `customers`
|
|
|
|
|
// collection, 2 relations
|
|
|
|
|
// deep from order_items.
|
|
|
|
|
//
|
|
|
|
|
// Why not push everything to the server: PB's `~` on JSON
|
|
|
|
|
// paths and numbers works fine, but it can't reach across
|
|
|
|
|
// 2 expanded relations. A `getList(200)` then a 5ms in-memory
|
|
|
|
|
// filter is the same wire cost as a complex server filter that
|
|
|
|
|
// PB might not be able to plan an index for.
|
|
|
|
|
const search = (params.search || "").trim();
|
|
|
|
|
let searchFilter = "";
|
|
|
|
|
if (search) {
|
|
|
|
|
const q = search.replace(/"/g, '\\"');
|
|
|
|
|
// PB `~` is case-insensitive for ASCII; for our purposes
|
|
|
|
|
// (descriptions are mostly English / numbers) that's fine.
|
|
|
|
|
const textClauses = [
|
|
|
|
|
`description ~ "${q}"`,
|
|
|
|
|
// The invoice number lives in the `order_item_details`
|
|
|
|
|
// JSON column. PB's dot-notation filter lets us reach
|
|
|
|
|
// it without expanding a relation.
|
|
|
|
|
`order_item_details.invoice.number ~ "${q}"`,
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// If the search string parses cleanly as a number, also
|
|
|
|
|
// match `selling_price` and `cost_price` exactly. We use
|
|
|
|
|
// `=` (not `~`) because PB's number filter doesn't support
|
|
|
|
|
// substring — "120" matches 120.0 but not 120.50. That's
|
|
|
|
|
// intentional: a partial number match would be confusing
|
|
|
|
|
// (typing "1" would match 100s of lines).
|
|
|
|
|
//
|
|
|
|
|
// parseFloat is permissive ("12.5abc" → 12.5); we
|
|
|
|
|
// additionally check that the entire string is a valid
|
|
|
|
|
// number via the regex. This avoids `selling_price = 12.5`
|
|
|
|
|
// when the user typed "12.5 boxes" — the regex rejects it
|
|
|
|
|
// and we skip the price match.
|
|
|
|
|
const numericMatch = search.match(/^-?\d+(\.\d+)?$/);
|
|
|
|
|
if (numericMatch) {
|
|
|
|
|
textClauses.push(`selling_price = ${search}`);
|
|
|
|
|
textClauses.push(`cost_price = ${search}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
searchFilter = `(${textClauses.join(" || ")})`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const filter = searchFilter
|
|
|
|
|
? `(${statusFilter}) && ${searchFilter}`
|
|
|
|
|
: `(${statusFilter})`;
|
|
|
|
|
|
2026-08-16 23:27:03 +00:00
|
|
|
// Two parallel queries: items (existing flow) + delivery_notes
|
|
|
|
|
// (the new signed-copy tracking). PB doesn't have a UNION, so
|
|
|
|
|
// we run both and merge in the service. `Promise.all` keeps
|
|
|
|
|
// the latency at the slower of the two rather than summing.
|
|
|
|
|
//
|
|
|
|
|
// The DN query has its own server-side filter so the search box
|
|
|
|
|
// can find DNs by delivery_note_number / customer_po too — same
|
|
|
|
|
// trade-off as the items query: PB can reach text fields on the
|
|
|
|
|
// root collection but not 2nd-level expanded joins, so the
|
|
|
|
|
// remaining order / customer / company name filtering happens
|
|
|
|
|
// client-side in the page.
|
|
|
|
|
const dnStatuses = TAB_TO_DN_STATUS[params.tab];
|
|
|
|
|
const dnStatusFilter = dnStatuses
|
|
|
|
|
.map((s) => `status = "${s}"`)
|
|
|
|
|
.join(" || ");
|
|
|
|
|
let dnSearchFilter = "";
|
|
|
|
|
if (search) {
|
|
|
|
|
const q = search.replace(/"/g, '\\"');
|
|
|
|
|
const dnTextClauses = [
|
|
|
|
|
`delivery_note_number ~ "${q}"`,
|
|
|
|
|
`customer_po ~ "${q}"`,
|
|
|
|
|
];
|
|
|
|
|
dnSearchFilter = `(${dnTextClauses.join(" || ")})`;
|
|
|
|
|
}
|
|
|
|
|
const dnFilter = dnSearchFilter
|
|
|
|
|
? `(${dnStatusFilter}) && ${dnSearchFilter}`
|
|
|
|
|
: `(${dnStatusFilter})`;
|
|
|
|
|
|
|
|
|
|
const [itemsResult, dnsResult] = await Promise.all([
|
|
|
|
|
pb.collection(getOrderItemsCollectionName()).getList(page, perPage, {
|
2026-07-27 06:21:20 +00:00
|
|
|
filter,
|
|
|
|
|
sort: "-updated",
|
|
|
|
|
// `order.customer` and `order.company` are 2nd-level
|
|
|
|
|
// expands: we want the order header's customer/company
|
|
|
|
|
// relations resolved in the same fetch so the page can
|
|
|
|
|
// render "Customer X — Order ORD-..." without a second
|
|
|
|
|
// round trip per row. PB's expand syntax is dot-separated
|
|
|
|
|
// paths; commas separate siblings.
|
2026-08-12 13:36:31 +00:00
|
|
|
//
|
|
|
|
|
// IMPORTANT: when `fields=` is set, PB also whitelists
|
|
|
|
|
// top-level response keys — and the `expand` block is a
|
|
|
|
|
// top-level key. If the expanded relation isn't added to
|
|
|
|
|
// the whitelist (as `expand.<path>`), PB silently drops the
|
|
|
|
|
// whole `expand` object. Without `expand.order` here, every
|
|
|
|
|
// row comes back with `r.expand` undefined and the page
|
|
|
|
|
// falls into its "(NO ORDER) / Counter pickup" bucket for
|
|
|
|
|
// all items. Verified live against PB 2026-08-12.
|
2026-07-27 06:21:20 +00:00
|
|
|
expand: "order.customer,order.company,checked_in_by",
|
|
|
|
|
fields:
|
2026-09-01 10:15:35 +00:00
|
|
|
"id,description,quantity,unit,selling_price,cost_price,item_status,delivery_reference," +
|
2026-09-14 10:58:43 +00:00
|
|
|
// `delivery_note_number` is the human-readable DN id (e.g.
|
|
|
|
|
// `DN26091405`). The PB schema also has a relation field
|
|
|
|
|
// `delivery_note` but nothing currently writes to it — the
|
|
|
|
|
// join key everywhere else (the full DN detail page, the
|
|
|
|
|
// delivery-notes service) is the text column. We only
|
|
|
|
|
// request the column that's actually populated.
|
|
|
|
|
"delivery_note_number,loaded_at,checked_in_at,checked_in_by,order,updated,order_item_details," +
|
2026-08-12 13:36:31 +00:00
|
|
|
"expand.order,expand.order.customer,expand.order.company,expand.checked_in_by",
|
2026-08-16 23:27:03 +00:00
|
|
|
}),
|
|
|
|
|
pb.collection(DELIVERY_NOTES).getList(page, perPage, {
|
|
|
|
|
filter: dnFilter,
|
|
|
|
|
sort: "-updated",
|
|
|
|
|
expand: "order.customer,order.company",
|
|
|
|
|
fields:
|
|
|
|
|
"id,delivery_note_number,status,customer_po,delivery_date,signed_copy," +
|
|
|
|
|
"order,updated,expand.order,expand.order.customer,expand.order.company",
|
|
|
|
|
}),
|
|
|
|
|
]);
|
2026-07-27 06:21:20 +00:00
|
|
|
|
2026-09-14 10:58:43 +00:00
|
|
|
// Query #3 — every order_item that has a `delivery_note_number`
|
|
|
|
|
// set to one of the DNs in this response, regardless of the
|
|
|
|
|
// item's own `item_status`. The DN expand panel needs these so
|
|
|
|
|
// it can show every item on the DN — same scope as the full
|
|
|
|
|
// /delivery-notes/{id} page (which joins by `delivery_note_number`
|
|
|
|
|
// without filtering on item_status). Without this, a DN whose
|
|
|
|
|
// items are still in `delivery_note` status (paper sent, invoice
|
|
|
|
|
// not yet created) shows "no items loaded" on the expand panel
|
|
|
|
|
// even though the same DN has items on the full detail page.
|
|
|
|
|
// The main items list (query #1) is what drives the page-level
|
|
|
|
|
// bulk actions; this third query only feeds the per-DN expand
|
|
|
|
|
// panel — so its status-unfiltered scope doesn't leak into the
|
|
|
|
|
// bulk-action rows. Runs after the first two so we have the DN
|
|
|
|
|
// numbers in hand; the wire cost is one extra PB read on top
|
|
|
|
|
// of the first two parallel queries.
|
|
|
|
|
//
|
|
|
|
|
// PB rejects filter strings longer than ~8 KB. ~40 chars per
|
|
|
|
|
// `delivery_note_number = "DN..." || ` — 200 candidates keeps
|
|
|
|
|
// us under the limit. Same pattern as
|
|
|
|
|
// order-delivery-note-link-service.ts.
|
|
|
|
|
const dnNumbers = (dnsResult.items as unknown as Array<{ delivery_note_number?: string }>)
|
|
|
|
|
.map((d) => d.delivery_note_number)
|
|
|
|
|
.filter((n): n is string => typeof n === "string" && n.length > 0);
|
|
|
|
|
let dnItemsResult: { items: Array<Record<string, unknown>>; totalItems: number } = {
|
|
|
|
|
items: [],
|
|
|
|
|
totalItems: 0,
|
|
|
|
|
};
|
|
|
|
|
if (dnNumbers.length > 0) {
|
|
|
|
|
const batch = dnNumbers.slice(0, 200);
|
|
|
|
|
const dnItemsFilter = batch
|
|
|
|
|
.map((n) => `delivery_note_number = "${n.replace(/"/g, '\\"')}"`)
|
|
|
|
|
.join(" || ");
|
|
|
|
|
dnItemsResult = (await pb
|
|
|
|
|
.collection(getOrderItemsCollectionName())
|
|
|
|
|
.getList(1, perPage, {
|
|
|
|
|
filter: dnItemsFilter,
|
|
|
|
|
sort: "-updated",
|
|
|
|
|
expand: "order.customer,order.company",
|
|
|
|
|
fields:
|
|
|
|
|
"id,description,quantity,unit,selling_price,cost_price,item_status,delivery_reference," +
|
|
|
|
|
"delivery_note_number,loaded_at,checked_in_at,checked_in_by,order,updated,order_item_details," +
|
|
|
|
|
"expand.order,expand.order.customer,expand.order.company",
|
|
|
|
|
})) as unknown as { items: Array<Record<string, unknown>>; totalItems: number };
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 06:21:20 +00:00
|
|
|
type Row = {
|
|
|
|
|
id: string;
|
|
|
|
|
description: string;
|
|
|
|
|
quantity: number | null;
|
|
|
|
|
selling_price: number | null;
|
|
|
|
|
cost_price: number | null;
|
|
|
|
|
item_status: string;
|
|
|
|
|
delivery_reference: string | null;
|
2026-09-14 10:58:43 +00:00
|
|
|
/**
|
|
|
|
|
* Human-readable DN id (e.g. `DN26091405`). The /deliveries
|
|
|
|
|
* page's DN expand panel uses this to filter the items that
|
|
|
|
|
* belong to a given DN — the same join key the full DN detail
|
|
|
|
|
* page uses (see `pb-helpers.getDeliveryNoteItems`). The
|
|
|
|
|
* relation field `delivery_note` in the schema is never
|
|
|
|
|
* populated; this is the column we actually care about.
|
|
|
|
|
*/
|
|
|
|
|
delivery_note_number: string | null;
|
2026-09-01 10:15:35 +00:00
|
|
|
unit: string | null;
|
2026-07-27 06:21:20 +00:00
|
|
|
loaded_at: string | null;
|
|
|
|
|
checked_in_at: string | null;
|
|
|
|
|
checked_in_by: string | null;
|
|
|
|
|
order: string | null;
|
|
|
|
|
updated: string;
|
|
|
|
|
/**
|
|
|
|
|
* JSON column. The shape is `{ invoice: { number, date, ... },
|
|
|
|
|
* print: { ... } }` — only the invoice.number is read for the
|
|
|
|
|
* /deliveries page. Defensive-typed as `unknown` because PB
|
|
|
|
|
* returns whatever the operator / n8n wrote; a missing or
|
|
|
|
|
* malformed JSON should never crash the page.
|
|
|
|
|
*/
|
|
|
|
|
order_item_details?: unknown;
|
|
|
|
|
expand?: {
|
|
|
|
|
order?: {
|
|
|
|
|
id: string;
|
|
|
|
|
order_number: string;
|
|
|
|
|
company: string;
|
|
|
|
|
customer: string;
|
|
|
|
|
expand?: {
|
|
|
|
|
customer?: { id: string; customer?: string };
|
|
|
|
|
company?: { id: string; company?: string };
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
checked_in_by?: { id: string; name?: string; email?: string };
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Read the QB invoice number from the nested JSON. The shape
|
|
|
|
|
// is `order_item_details.invoice.number` — we tolerate missing
|
|
|
|
|
// keys at every level because older rows (pre-QB-integration)
|
|
|
|
|
// don't have it, and the page renders "—" in that case.
|
|
|
|
|
function readInvoiceNumber(details: unknown): string | null {
|
|
|
|
|
if (!details || typeof details !== "object") return null;
|
|
|
|
|
const inv = (details as { invoice?: unknown }).invoice;
|
|
|
|
|
if (!inv || typeof inv !== "object") return null;
|
|
|
|
|
const n = (inv as { number?: unknown }).number;
|
|
|
|
|
return typeof n === "string" && n.length > 0 ? n : null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-14 10:58:43 +00:00
|
|
|
const mapRow = (r: Row) => ({
|
2026-07-27 06:21:20 +00:00
|
|
|
id: r.id,
|
|
|
|
|
description: r.description ?? "",
|
|
|
|
|
quantity: r.quantity ?? null,
|
|
|
|
|
selling_price: r.selling_price ?? null,
|
|
|
|
|
cost_price: r.cost_price ?? null,
|
|
|
|
|
item_status: r.item_status,
|
|
|
|
|
delivery_reference: r.delivery_reference ?? null,
|
2026-09-14 10:58:43 +00:00
|
|
|
delivery_note_number: r.delivery_note_number ?? null,
|
2026-09-01 10:15:35 +00:00
|
|
|
unit: r.unit ?? null,
|
2026-07-27 06:21:20 +00:00
|
|
|
invoice_number: readInvoiceNumber(r.order_item_details),
|
|
|
|
|
loaded_at: r.loaded_at ?? null,
|
|
|
|
|
checked_in_at: r.checked_in_at ?? null,
|
|
|
|
|
order: r.expand?.order
|
|
|
|
|
? {
|
|
|
|
|
id: r.expand.order.id,
|
|
|
|
|
order_number: r.expand.order.order_number,
|
|
|
|
|
// Customer + company names. `customer` / `company` on the
|
|
|
|
|
// order row is the relation id; the expand gives us the
|
|
|
|
|
// record. We use `?.customer` (singular) because that's
|
|
|
|
|
// the field name on the `customers` collection (not
|
|
|
|
|
// `name`). Same for `companies.company`. Ad-hoc rows
|
|
|
|
|
// (no parent order) get nulls and the page renders
|
|
|
|
|
// "Counter pickup" or similar.
|
|
|
|
|
customer_id: r.expand.order.customer,
|
|
|
|
|
customer_name: r.expand.order.expand?.customer?.customer ?? null,
|
|
|
|
|
company_id: r.expand.order.company,
|
|
|
|
|
company_name: r.expand.order.expand?.company?.company ?? null,
|
|
|
|
|
}
|
|
|
|
|
: null,
|
|
|
|
|
checked_in_by: r.expand?.checked_in_by
|
|
|
|
|
? {
|
|
|
|
|
id: r.expand.checked_in_by.id,
|
|
|
|
|
name: r.expand.checked_in_by.name,
|
|
|
|
|
email: r.expand.checked_in_by.email,
|
|
|
|
|
}
|
|
|
|
|
: null,
|
2026-09-14 10:58:43 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const items = (itemsResult.items as unknown as Row[]).map(mapRow);
|
|
|
|
|
|
|
|
|
|
// Map the third query (DN-linked items, any status) through the
|
|
|
|
|
// same mapper. Deduped by item id so the per-DN expand panel
|
|
|
|
|
// doesn't render the same row twice when an item already
|
|
|
|
|
// appears in `items` (status-filtered) AND `dn_items`
|
|
|
|
|
// (DN-linked, any status). A `Map` keyed by item id preserves
|
|
|
|
|
// the first occurrence — the status-filtered one, which is the
|
|
|
|
|
// version the page renders in the main list. The expand panel
|
|
|
|
|
// looks up by item id via `dnItemsById` and never sees a dup.
|
|
|
|
|
const dnItemsRaw = (dnItemsResult.items as unknown as Row[]).map(mapRow);
|
|
|
|
|
const dnItemsById = new Map<string, (typeof dnItemsRaw)[number]>();
|
|
|
|
|
for (const it of items) dnItemsById.set(it.id, it);
|
|
|
|
|
for (const it of dnItemsRaw) if (!dnItemsById.has(it.id)) dnItemsById.set(it.id, it);
|
|
|
|
|
const dn_items = [...dnItemsById.values()];
|
2026-07-27 06:21:20 +00:00
|
|
|
|
2026-08-16 23:27:03 +00:00
|
|
|
// Map DN rows. The expand shape mirrors the items query so the
|
|
|
|
|
// page can group both row kinds by customer → order with the
|
|
|
|
|
// same renderer.
|
|
|
|
|
type DNRow = {
|
|
|
|
|
id: string;
|
|
|
|
|
delivery_note_number: string;
|
|
|
|
|
status: string;
|
|
|
|
|
customer_po: string | null;
|
|
|
|
|
delivery_date: string | null;
|
|
|
|
|
signed_copy: string[] | null;
|
|
|
|
|
order: string | null;
|
|
|
|
|
expand?: {
|
|
|
|
|
order?: {
|
|
|
|
|
id: string;
|
|
|
|
|
order_number: string;
|
|
|
|
|
company: string;
|
|
|
|
|
customer: string;
|
|
|
|
|
expand?: {
|
|
|
|
|
customer?: { id: string; customer?: string };
|
|
|
|
|
company?: { id: string; company?: string };
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const delivery_notes = (dnsResult.items as unknown as DNRow[]).map((r) => ({
|
|
|
|
|
id: r.id,
|
|
|
|
|
delivery_note_number: r.delivery_note_number ?? "",
|
|
|
|
|
status: r.status,
|
|
|
|
|
customer_po: r.customer_po ?? null,
|
|
|
|
|
delivery_date: r.delivery_date ?? null,
|
|
|
|
|
signed_copy: r.signed_copy ?? null,
|
|
|
|
|
order: r.expand?.order
|
|
|
|
|
? {
|
|
|
|
|
id: r.expand.order.id,
|
|
|
|
|
order_number: r.expand.order.order_number,
|
|
|
|
|
customer_id: r.expand.order.customer,
|
|
|
|
|
customer_name: r.expand.order.expand?.customer?.customer ?? null,
|
|
|
|
|
company_id: r.expand.order.company,
|
|
|
|
|
company_name: r.expand.order.expand?.company?.company ?? null,
|
|
|
|
|
}
|
|
|
|
|
: null,
|
|
|
|
|
}));
|
|
|
|
|
|
2026-07-27 06:21:20 +00:00
|
|
|
return {
|
|
|
|
|
items,
|
2026-08-16 23:27:03 +00:00
|
|
|
delivery_notes,
|
2026-09-14 10:58:43 +00:00
|
|
|
/**
|
|
|
|
|
* Items linked to the page's DNs regardless of `item_status`.
|
|
|
|
|
* Feeds the DN expand panel so it shows every item on the
|
|
|
|
|
* DN (same scope as the full /delivery-notes/{id} page).
|
|
|
|
|
* The main items list on the page uses `items` only — this
|
|
|
|
|
* field is never rendered as a top-level row.
|
|
|
|
|
*/
|
|
|
|
|
dn_items,
|
2026-08-16 23:27:03 +00:00
|
|
|
totalItems: itemsResult.totalItems,
|
|
|
|
|
totalDeliveryNotes: dnsResult.totalItems,
|
|
|
|
|
page: itemsResult.page,
|
|
|
|
|
perPage: itemsResult.perPage,
|
|
|
|
|
totalPages: itemsResult.totalPages,
|
2026-07-27 06:21:20 +00:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type LoadResult = {
|
|
|
|
|
loaded: string[];
|
|
|
|
|
skipped: Array<{ id: string; reason: string }>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const MAX_BATCH = 200;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Bulk flip `invoiced → with_driver` and stamp `loaded_at = now()`.
|
|
|
|
|
* Used by the "To Load" tab's "Load N selected" buttons.
|
|
|
|
|
*
|
|
|
|
|
* Idempotent on the source state: an item that's already
|
|
|
|
|
* `with_driver` (or anything else other than `invoiced`) is skipped
|
|
|
|
|
* with a `reason` instead of being moved. This makes the call safe
|
|
|
|
|
* to retry on a flaky network without double-stamping `loaded_at`.
|
|
|
|
|
*
|
|
|
|
|
* We do not enforce a single source state for the whole batch — if
|
|
|
|
|
* the operator checks 5 items and 1 of them is already in
|
|
|
|
|
* `with_driver`, we move the 4 and report the 1. The UI uses the
|
|
|
|
|
* `skipped` array to surface "4 loaded, 1 was already with the
|
|
|
|
|
* driver — skipped" so the operator can re-check.
|
|
|
|
|
*/
|
|
|
|
|
export async function loadItems(itemIds: string[]): Promise<LoadResult> {
|
|
|
|
|
const pb = await getAdminPocketBase();
|
|
|
|
|
const collection = getOrderItemsCollectionName();
|
|
|
|
|
|
|
|
|
|
if (itemIds.length === 0) {
|
|
|
|
|
return { loaded: [], skipped: [] };
|
|
|
|
|
}
|
|
|
|
|
if (itemIds.length > MAX_BATCH) {
|
|
|
|
|
throw new Error(`Too many items in one batch (${itemIds.length} > ${MAX_BATCH}).`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Snapshot the source state per id so we don't read+write the
|
|
|
|
|
// same row from the loop (PB will return the post-update value
|
|
|
|
|
// otherwise). The `fields` param keeps the fetch cheap.
|
|
|
|
|
const orFilter = itemIds.map((id) => `id="${id}"`).join(" || ");
|
|
|
|
|
const rows = await pb.collection(collection).getFullList<{
|
|
|
|
|
id: string;
|
|
|
|
|
item_status: string;
|
|
|
|
|
}>({
|
|
|
|
|
filter: orFilter,
|
|
|
|
|
fields: "id,item_status",
|
|
|
|
|
});
|
|
|
|
|
const stateById = new Map<string, string>();
|
|
|
|
|
for (const r of rows) stateById.set(r.id, r.item_status);
|
|
|
|
|
|
|
|
|
|
const loaded: string[] = [];
|
|
|
|
|
const skipped: Array<{ id: string; reason: string }> = [];
|
|
|
|
|
const now = new Date().toISOString();
|
|
|
|
|
|
|
|
|
|
for (const id of itemIds) {
|
|
|
|
|
const current = stateById.get(id);
|
|
|
|
|
if (current === undefined) {
|
|
|
|
|
skipped.push({ id, reason: "Item not found" });
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (current !== "invoiced") {
|
|
|
|
|
skipped.push({
|
|
|
|
|
id,
|
|
|
|
|
reason: `Expected item_status=invoiced, found ${current || "empty"}`,
|
|
|
|
|
});
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
await pb.collection(collection).update(id, {
|
|
|
|
|
item_status: "with_driver",
|
|
|
|
|
loaded_at: now,
|
|
|
|
|
});
|
|
|
|
|
loaded.push(id);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
skipped.push({
|
|
|
|
|
id,
|
|
|
|
|
reason: err instanceof Error ? err.message : "Update failed",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { loaded, skipped };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type CheckInInput = {
|
|
|
|
|
itemIds: string[];
|
|
|
|
|
/**
|
|
|
|
|
* The invoice/DN number from the signed paper. Trimmed +
|
|
|
|
|
* length-checked (1..64 chars to match the schema max). Empty
|
|
|
|
|
* strings are rejected — the whole batch fails if the operator
|
|
|
|
|
* submits an empty reference.
|
|
|
|
|
*/
|
|
|
|
|
reference: string;
|
|
|
|
|
/**
|
|
|
|
|
* The authed user id who typed the reference. Required: the
|
|
|
|
|
* `checked_in_by` field on the item is a relation, not a free-
|
|
|
|
|
* text audit. We pull this from the PocketBase auth record on
|
|
|
|
|
* the request, not the body, so the client can't spoof another
|
|
|
|
|
* user.
|
|
|
|
|
*/
|
|
|
|
|
checkedInByUserId: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type CheckInResult = {
|
|
|
|
|
checkedIn: string[];
|
|
|
|
|
skipped: Array<{ id: string; reason: string }>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Bulk flip `with_driver → checked_in`, write the reference, and
|
|
|
|
|
* stamp `checked_in_at` + `checked_in_by`. Used by the "Back from
|
|
|
|
|
* Driver" tab.
|
|
|
|
|
*
|
|
|
|
|
* Reference handling: trimmed. Empty/whitespace-only references
|
|
|
|
|
* fail the whole batch with a single clear error. We don't try
|
|
|
|
|
* to "be clever" with auto-generated fallback refs — the operator
|
|
|
|
|
* is the one who has the paper in their hand, and a wrong
|
|
|
|
|
* reference is worse than a rejected batch.
|
|
|
|
|
*
|
|
|
|
|
* Idempotency: items already in `checked_in` are skipped with a
|
|
|
|
|
* reason rather than re-stamped. This means a partial network
|
|
|
|
|
* failure mid-batch can be retried — already-checked-in items
|
|
|
|
|
* stay checked in, the rest get the new reference + timestamp.
|
|
|
|
|
*/
|
|
|
|
|
export async function checkInItems(
|
|
|
|
|
input: CheckInInput
|
|
|
|
|
): Promise<CheckInResult> {
|
|
|
|
|
const ref = (input.reference || "").trim();
|
|
|
|
|
if (ref.length === 0) {
|
|
|
|
|
throw new Error("Reference cannot be empty.");
|
|
|
|
|
}
|
|
|
|
|
if (ref.length > 64) {
|
|
|
|
|
throw new Error("Reference is too long (max 64 chars).");
|
|
|
|
|
}
|
|
|
|
|
if (input.itemIds.length === 0) {
|
|
|
|
|
return { checkedIn: [], skipped: [] };
|
|
|
|
|
}
|
|
|
|
|
if (input.itemIds.length > MAX_BATCH) {
|
|
|
|
|
throw new Error(`Too many items in one batch (${input.itemIds.length} > ${MAX_BATCH}).`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const pb = await getAdminPocketBase();
|
|
|
|
|
const collection = getOrderItemsCollectionName();
|
|
|
|
|
|
|
|
|
|
const orFilter = input.itemIds.map((id) => `id="${id}"`).join(" || ");
|
|
|
|
|
const rows = await pb.collection(collection).getFullList<{
|
|
|
|
|
id: string;
|
|
|
|
|
item_status: string;
|
|
|
|
|
}>({
|
|
|
|
|
filter: orFilter,
|
|
|
|
|
fields: "id,item_status",
|
|
|
|
|
});
|
|
|
|
|
const stateById = new Map<string, string>();
|
|
|
|
|
for (const r of rows) stateById.set(r.id, r.item_status);
|
|
|
|
|
|
|
|
|
|
const checkedIn: string[] = [];
|
|
|
|
|
const skipped: Array<{ id: string; reason: string }> = [];
|
|
|
|
|
const now = new Date().toISOString();
|
|
|
|
|
|
|
|
|
|
for (const id of input.itemIds) {
|
|
|
|
|
const current = stateById.get(id);
|
|
|
|
|
if (current === undefined) {
|
|
|
|
|
skipped.push({ id, reason: "Item not found" });
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (current !== "with_driver") {
|
|
|
|
|
skipped.push({
|
|
|
|
|
id,
|
|
|
|
|
reason: `Expected item_status=with_driver, found ${current || "empty"}`,
|
|
|
|
|
});
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
await pb.collection(collection).update(id, {
|
|
|
|
|
item_status: "checked_in",
|
|
|
|
|
delivery_reference: ref,
|
|
|
|
|
checked_in_at: now,
|
|
|
|
|
checked_in_by: input.checkedInByUserId,
|
|
|
|
|
});
|
|
|
|
|
checkedIn.push(id);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
skipped.push({
|
|
|
|
|
id,
|
|
|
|
|
reason: err instanceof Error ? err.message : "Update failed",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { checkedIn, skipped };
|
|
|
|
|
}
|
2026-08-16 23:27:03 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
export type SignedCopyInput = {
|
|
|
|
|
deliveryNoteId: string;
|
|
|
|
|
file: File;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type SignedCopyResult = {
|
|
|
|
|
deliveryNoteId: string;
|
|
|
|
|
filename: string;
|
|
|
|
|
status: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Stamp the signed copy onto a delivery note AND flip its status
|
|
|
|
|
* to `completed` in a single PATCH.
|
|
|
|
|
*/
|
|
|
|
|
export async function markSignedCopyReceived(
|
|
|
|
|
input: SignedCopyInput
|
|
|
|
|
): Promise<SignedCopyResult> {
|
|
|
|
|
const pb = await getAdminPocketBase();
|
|
|
|
|
|
|
|
|
|
const dn = await pb
|
|
|
|
|
.collection(DELIVERY_NOTES)
|
|
|
|
|
.getOne<DeliveryNote>(input.deliveryNoteId);
|
|
|
|
|
|
|
|
|
|
if (dn.status === "completed") {
|
|
|
|
|
return {
|
|
|
|
|
deliveryNoteId: dn.id,
|
|
|
|
|
filename: Array.isArray(dn.signed_copy) ? dn.signed_copy[0] ?? "" : "",
|
|
|
|
|
status: dn.status,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Allow signed-copy upload on ANY active status (per the
|
|
|
|
|
// operator's request: "regardless of the status"). The only
|
|
|
|
|
// exception is `cancelled` — that's an abandoned DN, so
|
|
|
|
|
// uploading a signed copy to it would be misleading.
|
|
|
|
|
if (dn.status === "cancelled") {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Cannot mark signed copy received on a cancelled delivery note.`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const updated = await pb.collection(DELIVERY_NOTES).update<DeliveryNote>(
|
|
|
|
|
dn.id,
|
|
|
|
|
{
|
|
|
|
|
signed_copy: [input.file],
|
|
|
|
|
status: "completed",
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const filename =
|
|
|
|
|
(Array.isArray(updated.signed_copy) && updated.signed_copy[0]) ||
|
|
|
|
|
(input.file as { name?: string }).name ||
|
|
|
|
|
"signed-copy";
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
deliveryNoteId: updated.id,
|
|
|
|
|
filename,
|
|
|
|
|
status: updated.status,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Mark a delivery note as `loaded` (paper is on the truck, going
|
|
|
|
|
* out for delivery).
|
|
|
|
|
*
|
|
|
|
|
* Accepts `draft` (paper was prepared and is being loaded
|
|
|
|
|
* directly, e.g. counter pickup) and `sent` (paper has been sent
|
|
|
|
|
* to the customer and is now en route back via the driver). The
|
|
|
|
|
* `loaded` state is a new lifecycle stop on the operator's
|
|
|
|
|
* request — they want a visible "out with the driver" status
|
|
|
|
|
* distinct from "sent" (paper at the customer) and
|
|
|
|
|
* "po_received" (paper back with PO).
|
|
|
|
|
*
|
|
|
|
|
* Idempotent on `loaded`: a re-marked DN is a no-op (the
|
|
|
|
|
* `updated` autodate timestamp already records the operator's
|
|
|
|
|
* click). This lets the page retry safely on a flaky network
|
|
|
|
|
* without double-stamping.
|
|
|
|
|
*
|
|
|
|
|
* Other transitions: `po_received`, `invoiced`, `completed` are
|
|
|
|
|
* downstream of `loaded` and accept the signed-copy upload
|
|
|
|
|
* separately. `cancelled` is rejected — the operator has to
|
|
|
|
|
* un-cancel first.
|
|
|
|
|
*
|
|
|
|
|
* Why no `loaded_at` field: the operator opted to use PB's
|
|
|
|
|
* built-in `updated` autodate as the timestamp instead of
|
|
|
|
|
* adding a dedicated field. The trade-off is that any future
|
|
|
|
|
* edit to the DN will shift the "loaded at" reading — acceptable
|
|
|
|
|
* for an audit trail that nobody currently queries by timestamp.
|
|
|
|
|
*/
|
|
|
|
|
export async function markDeliveryNoteLoaded(
|
|
|
|
|
deliveryNoteId: string,
|
|
|
|
|
): Promise<DeliveryNote> {
|
|
|
|
|
const pb = await getAdminPocketBase();
|
|
|
|
|
|
|
|
|
|
const dn = await pb
|
|
|
|
|
.collection(DELIVERY_NOTES)
|
|
|
|
|
.getOne<DeliveryNote>(deliveryNoteId);
|
|
|
|
|
|
|
|
|
|
// Idempotent: re-marking a loaded DN is a no-op.
|
|
|
|
|
if (dn.status === "loaded") {
|
|
|
|
|
return dn;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (dn.status !== "draft" && dn.status !== "sent") {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Cannot mark delivery note as loaded in status "${dn.status}" (must be draft or sent).`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return pb.collection(DELIVERY_NOTES).update<DeliveryNote>(dn.id, {
|
|
|
|
|
status: "loaded",
|
|
|
|
|
});
|
|
|
|
|
}
|