WFDASH-WEBAPP/src/lib/server/deliveries-service.ts

887 lines
33 KiB
TypeScript
Raw Normal View History

/**
* 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";
import type { DeliveryNote } from "@/lib/types";
const DELIVERY_NOTES = "delivery_notes";
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"],
};
/**
* 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"],
};
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;
/**
* 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;
/**
* 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;
}>;
/**
* 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;
}>;
/**
* 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;
}>;
totalItems: number;
totalDeliveryNotes: number;
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})`;
// 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, {
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.
//
// 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.
expand: "order.customer,order.company,checked_in_by",
fields:
"id,description,quantity,unit,selling_price,cost_price,item_status,delivery_reference," +
// `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," +
"expand.order,expand.order.customer,expand.order.company,expand.checked_in_by",
}),
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",
}),
]);
// 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 };
}
type Row = {
id: string;
description: string;
quantity: number | null;
selling_price: number | null;
cost_price: number | null;
item_status: string;
delivery_reference: string | null;
/**
* 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;
unit: string | null;
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;
}
const mapRow = (r: Row) => ({
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,
delivery_note_number: r.delivery_note_number ?? null,
unit: r.unit ?? null,
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,
});
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()];
// 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,
}));
return {
items,
delivery_notes,
/**
* 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,
totalItems: itemsResult.totalItems,
totalDeliveryNotes: dnsResult.totalItems,
page: itemsResult.page,
perPage: itemsResult.perPage,
totalPages: itemsResult.totalPages,
};
}
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 };
}
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",
});
}