/** * Driver stops — unified list + status update for the driver app. * * The driver app needs to see two kinds of stops: * * - **delivery** (a `order_items` row in `collection_pool` / * `assigned` state) — driver takes goods to a customer. * - **pickup** (a `stock_purchase_items` row in * `collection_pool` / `assigned_to_driver` state) — driver * picks up goods from a supplier and brings them to the * office. * * Both kinds render in the same list (one stop per row) with * a `kind` discriminator so the driver app can show different * copy + icons. The status update accepts `action: "collected"` * (happy path) or `action: "problem"` (driver flags an issue * at the supplier / customer) + the `qty_collected` the driver * actually got (mandatory for pickups, since suppliers short- * ship; default-to-line-qty for deliveries). * * Mirrors the union query on `/collection-pool/page.tsx:load()` * + the stock-out flow on `/collection-confirm/page.tsx:load()`. * The union is mirrored at the service level rather than * querying the legacy `collection_pool` collection (which was * deprecated in the v4 stock-order driver flow). */ import { getAdminPocketBase } from "./pocketbase-admin"; import type { OrderItem, StockPurchaseItem, Supplier } from "@/lib/types"; /* ============================================================================ * Shared types * ========================================================================== */ export type DriverStopKind = "delivery" | "pickup"; /** The lifecycle states a stop can be in from the driver's POV. */ export type DriverStopStatus = // Deliveries: | "collection_pool" | "assigned" | "collected" | "delivered" // Pickups: | "assigned_to_driver" | "booked"; /** What the driver reports when they tap "Mark collected" / "Flag problem". */ export type DriverStopAction = "collected" | "problem"; /** Structured problem categories — mirrors `order_items.problem_type` values. */ export const PROBLEM_TYPES = [ "short_supply", "wrong_item", "damaged", "no_show", "other", ] as const; export type ProblemType = (typeof PROBLEM_TYPES)[number]; /* ============================================================================ * Normalised stop shape (returned by `listDriverStops` and update endpoints) * ========================================================================== */ export interface DriverStop { /** PB id of the underlying row (order_item or stock_purchase_item). */ id: string; /** Discriminator for the driver app's UI. */ kind: DriverStopKind; /** Human-readable description of what's being delivered / picked up. */ description: string; /** What was ordered / what the supplier was supposed to ship. */ quantity_ordered: number; /** What the driver actually has / will deliver. For deliveries * this equals `quantity_ordered` on first collection (operator * adjusts at /collection-confirm). For pickups it's what the * driver types at the supplier door. */ quantity_collected: number; /** Lifecycle state from the underlying collection, normalised. */ status: DriverStopStatus; /** Driver bound to this stop, or `null` for unassigned. */ drv_id: string | null; /** ISO timestamp of the most recent assignment (date-only on stock side). */ assigned_at: string | null; /** ISO timestamp of when the underlying PB row was created. Useful * for "outstanding N days" reporting from the driver app / n8n. */ created_at: string | null; /** ISO timestamp of when the parent order / purchase-order was * created in WFDASH. This is the canonical "this thing came into * the system on day X" timestamp — distinct from `assigned_at` * (when a driver was put on it) and `created_at` (when the line * item was added, which can differ if the line was split). Use * this for "outstanding N days" reporting. */ outstanding_since: string | null; /** Parent PO number, formatted for display. */ po_number: string; /** Parent order id (delivery only — `null` for pickups). */ order_id: string | null; /** Customer name (delivery only) / supplier name (both). */ customer_name: string | null; company_name: string | null; /** Supplier for both kinds. */ supplier: { id: string; name: string; /** Geocoded coords for the supplier's physical address — pickup * stops anchor here. For deliveries the driver still passes * by the supplier to drop off (in our model the supplier * is the PO's destination) but the actual delivery address * is `customer.coords` below. */ coords: [number, number] | null; }; /** Customer + delivery address (delivery only). */ customer: { name: string; company: string | null; /** Geocoded coords for the customer's delivery address. */ coords: [number, number] | null; } | null; /** Problem flag — set by the driver via `action: "problem"`. */ problem: { flag: boolean; type: string | null; notes: string | null; }; } /* ============================================================================ * List * ========================================================================== */ export interface ListDriverStopsOpts { /** Filter by driver id. `null` = unassigned stops; `undefined` = all. */ drv_id?: string | null; /** Filter by kind. */ kind?: DriverStopKind; page?: number; perPage?: number; } export interface ListDriverStopsResult { items: DriverStop[]; totalItems: number; page: number; perPage: number; totalPages: number; } function parseCoords(raw: unknown): [number, number] | null { if (Array.isArray(raw)) { return finiteCoordinatePair(raw[0], raw[1]); } if (raw && typeof raw === "object") { const point = raw as Record; return finiteCoordinatePair( point.lat ?? point.latitude, point.lng ?? point.lon ?? point.longitude, ); } if (typeof raw !== "string" || !raw.trim()) return null; const parts = raw.split(",").map((value) => Number(value.trim())); return finiteCoordinatePair(parts[0], parts[1]); } function finiteCoordinatePair(firstValue: unknown, secondValue: unknown): [number, number] | null { const first = Number(firstValue); const second = Number(secondValue); if (!Number.isFinite(first) || !Number.isFinite(second)) return null; return [first, second]; } /** * Returns the driver-visible stops. Default scope: stops that * are still in flight (deliveries in `collection_pool` or * `assigned`; pickups in `collection_pool` or * `assigned_to_driver`). The driver app can additionally pass * `drv_id` to scope to a specific driver, or `null` for * unassigned. */ export async function listDriverStops( opts: ListDriverStopsOpts = {}, ): Promise { const pb = await getAdminPocketBase(); const page = Math.max(1, opts.page ?? 1); const perPage = Math.min(200, Math.max(1, opts.perPage ?? 50)); const wantDelivery = !opts.kind || opts.kind === "delivery"; const wantPickup = !opts.kind || opts.kind === "pickup"; // We run the two queries in parallel — PB can't union across // collections, so we normalise the two result shapes client-side. const [deliveryRes, pickupRes] = await Promise.all([ wantDelivery ? pb.collection("order_items").getList(page, perPage, { filter: deliveryFilter(opts.drv_id), sort: "assigned_at,created", expand: "purchase_order.supplier,order.customer,order.company", }) : Promise.resolve(emptyPage()), wantPickup ? pb.collection("stock_purchase_items").getList(page, perPage, { filter: pickupFilter(opts.drv_id), sort: "assigned_at,created", expand: "purchase.supplier,stock_item", }) : Promise.resolve(emptyPage()), ]); const items: DriverStop[] = []; for (const r of deliveryRes.items) items.push(normaliseDelivery(r)); for (const r of pickupRes.items) items.push(normalisePickup(r)); // Sort combined list by assigned_at (oldest first) so the // driver app's "next stop" hint works without extra client // logic. items.sort((a, b) => { const at = a.assigned_at ?? ""; const bt = b.assigned_at ?? ""; return at.localeCompare(bt); }); return { items, totalItems: deliveryRes.totalItems + pickupRes.totalItems, page, perPage, totalPages: Math.max( deliveryRes.totalPages, pickupRes.totalPages, ), }; } function deliveryFilter(drvId?: string | null): string { // Default: stop states the driver app surfaces. Excludes // terminal / pre-pool states (delivered, cancelled, ready_invoice, // invoiced, stock_booked). const parts = [ `(item_status = "collection_pool" || item_status = "assigned")`, `ready_for_collection = true`, ]; if (drvId === null) { parts.push(`(drv_id = "" || drv_id = null)`); } else if (drvId !== undefined) { parts.push(`drv_id = "${escapeForFilter(drvId)}"`); } return parts.join(" && "); } function pickupFilter(drvId?: string | null): string { const parts = [ `(status = "collection_pool" || status = "assigned_to_driver")`, ]; if (drvId === null) { parts.push(`(drv_id = "" || drv_id = null)`); } else if (drvId !== undefined) { parts.push(`drv_id = "${escapeForFilter(drvId)}"`); } return parts.join(" && "); } function escapeForFilter(s: string): string { return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); } function emptyPage(): { items: T[]; totalItems: number; page: number; perPage: number; totalPages: number } { return { items: [], totalItems: 0, page: 1, perPage: 50, totalPages: 0 }; } /* ============================================================================ * Normalisers * ========================================================================== */ type DeliveryRow = OrderItem & { expand?: { purchase_order?: { id: string; po_number?: string; expand?: { supplier?: Supplier }; }; order?: { id: string; order_number?: string; expand?: { customer?: { id: string; customer?: string; coordinates?: string }; company?: { id: string; company?: string; coordinates?: string }; }; }; }; }; type PickupRow = StockPurchaseItem & { expand?: { purchase?: { id: string; purchase_number?: string; expand?: { supplier?: Supplier }; }; stock_item?: { id: string; sku?: string; name?: string }; }; }; function normaliseDelivery(r: DeliveryRow): DriverStop { const supplier = r.expand?.purchase_order?.expand?.supplier; const order = r.expand?.order; const customer = order?.expand?.customer; const company = order?.expand?.company; return { id: r.id, kind: "delivery", description: r.description || "(no description)", quantity_ordered: Number(r.order_qty ?? r.quantity ?? 0), quantity_collected: Number(r.qty_collected ?? 0), status: (r.item_status ?? "collection_pool") as DriverStopStatus, drv_id: r.drv_id || null, assigned_at: r.assigned_at || null, created_at: r.created || null, outstanding_since: order?.created || null, po_number: r.expand?.purchase_order?.po_number || "", order_id: order?.id || null, customer_name: customer?.customer || null, company_name: company?.company || null, supplier: { id: supplier?.id || "", name: supplier?.suppliername || "Unknown supplier", coords: parseCoords(supplier?.coordinates), }, customer: customer ? { name: customer.customer || "", company: company?.company || null, coords: parseCoords(customer.coordinates) ?? parseCoords(company?.coordinates), } : null, problem: { flag: Boolean(r.problem_flag), type: r.problem_type || null, notes: r.problem_notes || null, }, }; } function normalisePickup(r: PickupRow): DriverStop { const supplier = r.expand?.purchase?.expand?.supplier; return { id: r.id, kind: "pickup", description: r.description || "(no description)", quantity_ordered: Number(r.quantity ?? 0), quantity_collected: Number(r.quantity_received ?? 0), status: (r.status ?? "collection_pool") as DriverStopStatus, drv_id: r.drv_id || null, assigned_at: r.assigned_at || null, created_at: r.created || null, outstanding_since: r.expand?.purchase?.created || null, po_number: r.expand?.purchase?.purchase_number || "", order_id: null, customer_name: null, company_name: null, supplier: { id: supplier?.id || "", name: supplier?.suppliername || "Unknown supplier", coords: parseCoords(supplier?.coordinates), }, customer: null, problem: { flag: Boolean(r.problem_flag), type: r.problem_type || null, notes: r.problem_notes || null, }, }; } /* ============================================================================ * Update * ========================================================================== */ export interface UpdateDriverStopInput { id: string; kind: DriverStopKind; action: DriverStopAction; /** Required for `kind=pickup, action=collected`. Optional for * `kind=delivery` (defaults to the line's `quantity_ordered`). */ qty_collected?: number; /** Required for `action=problem`. */ problem_type?: ProblemType; /** Optional free-text. Clamped to 1000 chars server-side. */ problem_notes?: string; /** Optional free-text on the underlying record. */ notes?: string; } export type UpdateDriverStopOk = { ok: true; stop: DriverStop; }; export type UpdateDriverStopRejected = { ok: false; reason: | "invalid_kind" | "invalid_action" | "invalid_qty" | "problem_requires_type" | "stock_line_missing" | "stock_line_wrong_status" | "delivery_line_missing" | "update_failed"; message: string; }; export type UpdateDriverStopResult = | UpdateDriverStopOk | UpdateDriverStopRejected; const NOTES_MAX = 1000; /** * Apply the driver's status update to the underlying row. * Branches on `kind` to write to `order_items` (delivery) or * `stock_purchase_items` (pickup). * * Audit BEFORE state mutation: we read the row first, validate * the action is legal for its current status, then write. This * keeps the error path simple and the operator's surface * self-explanatory. */ export async function updateDriverStopStatus( input: UpdateDriverStopInput, ): Promise { if (input.kind !== "delivery" && input.kind !== "pickup") { return { ok: false, reason: "invalid_kind", message: 'kind must be "delivery" or "pickup"', }; } if (input.action !== "collected" && input.action !== "problem") { return { ok: false, reason: "invalid_action", message: 'action must be "collected" or "problem"', }; } if (input.action === "problem" && !input.problem_type) { return { ok: false, reason: "problem_requires_type", message: "problem_type is required when action is problem", }; } const pb = await getAdminPocketBase(); if (input.kind === "pickup") { return updatePickup(pb, input); } return updateDelivery(pb, input); } async function updatePickup( pb: Awaited>, input: UpdateDriverStopInput, ): Promise { // Fetch the line so we can default the qty + validate status. let line: Record; try { line = await pb.collection("stock_purchase_items").getOne(input.id, { expand: "purchase.supplier,stock_item", }); } catch { return { ok: false, reason: "stock_line_missing", message: "Stock line not found.", }; } const lineTyped = line as unknown as PickupRow; const currentStatus = String(lineTyped.status ?? ""); if ( currentStatus !== "assigned_to_driver" && currentStatus !== "collection_pool" && currentStatus !== "collected" ) { return { ok: false, reason: "stock_line_wrong_status", message: `Cannot update stock line in status "${currentStatus}".`, }; } // Resolve the qty. Pickups REQUIRE an explicit qty — supplier // short-shipping is the whole point of this code path. If the // driver doesn't provide one, we reject rather than default. let qtyCollected: number; if (input.action === "collected") { if (input.qty_collected === undefined) { return { ok: false, reason: "invalid_qty", message: "qty_collected is required for pickup stops (supplier may short-ship).", }; } if ( typeof input.qty_collected !== "number" || !Number.isFinite(input.qty_collected) || input.qty_collected < 0 ) { return { ok: false, reason: "invalid_qty", message: "qty_collected must be a non-negative number.", }; } qtyCollected = Math.floor(input.qty_collected); } else { // action=problem — qty is optional; if not given, leave // quantity_received as-is (or 0). qtyCollected = input.qty_collected !== undefined ? Math.floor(input.qty_collected) : Number(lineTyped.quantity_received ?? 0); } // For "collected" we cap at the line's ordered qty — going // over means the supplier shipped extra, which is the // "surplus" flow (v1) on the office side, not a driver input. if (input.action === "collected") { const ordered = Number(lineTyped.quantity ?? 0); if (qtyCollected > ordered) { return { ok: false, reason: "invalid_qty", message: `Cannot collect more than the ordered qty (${ordered}). Excess becomes surplus — the office books that separately.`, }; } } // Compose the patch. const patch: Record = { quantity_received: qtyCollected, }; if (input.action === "collected") { // Happy path: status flips to "collected" so the office // sees it on /collection-confirm and can book to stock. patch.status = "collected"; // Clear any previous problem flag — the goods are in. patch.problem_flag = false; patch.problem_type = null; patch.problem_notes = null; } else { // Problem: keep the goods (if any qty was collected) but // flag the issue for office follow-up. We don't change the // status — the office decides what to do. patch.problem_flag = true; patch.problem_type = input.problem_type; patch.problem_notes = (input.problem_notes ?? "").slice(0, NOTES_MAX); } if (input.notes !== undefined) { // Mirror customer-side behaviour: don't overwrite existing // notes; just append a one-liner so the office can see the // driver's last update. const existing = String(lineTyped.notes ?? ""); const stamp = `[${new Date().toISOString()}] driver: ${input.notes}`; patch.notes = existing ? `${existing}\n${stamp}` : stamp; } try { await pb.collection("stock_purchase_items").update(input.id, patch); } catch (err) { return { ok: false, reason: "update_failed", message: err instanceof Error ? err.message : "Update failed", }; } // Re-read so the response includes the expanded relations // (supplier / stock_item) the driver app needs to render. const fresh = (await pb.collection("stock_purchase_items").getOne(input.id, { expand: "purchase.supplier,stock_item", })) as unknown as PickupRow; return { ok: true, stop: normalisePickup(fresh) }; } async function updateDelivery( pb: Awaited>, input: UpdateDriverStopInput, ): Promise { let line: Record; try { line = await pb.collection("order_items").getOne(input.id, { expand: "purchase_order.supplier,order.customer,order.company", }); } catch { return { ok: false, reason: "delivery_line_missing", message: "Order item not found.", }; } const lineTyped = line as unknown as DeliveryRow; const currentStatus = String(lineTyped.item_status ?? ""); if ( currentStatus !== "assigned" && currentStatus !== "collection_pool" ) { return { ok: false, reason: "delivery_line_missing", message: `Cannot update delivery in status "${currentStatus}".`, }; } // Compose the patch. For deliveries, `qty_collected` is // optional — if the driver doesn't provide it, default to // the line's `order_qty` (existing behaviour — operator can // still adjust at /collection-confirm). // // `order_qty` is deliberate here and must NOT be changed to // `quantity`: the driver is collecting from the SUPPLIER, so the // expected full pickup is what we put on that supplier's PO. On a // MOQ line that is the larger number, and collecting all of it is // correct — the overflow becomes stock further down the flow. // Customer-facing quantities live in `src/lib/order-item-qty.ts`. const qtyCollected = input.qty_collected !== undefined ? Math.floor(input.qty_collected) : Number(lineTyped.order_qty ?? lineTyped.quantity ?? 0); const patch: Record = { qty_collected: qtyCollected, }; if (input.action === "collected") { patch.item_status = "collected"; patch.problem_flag = false; patch.problem_type = null; patch.problem_notes = null; } else { patch.problem_flag = true; patch.problem_type = input.problem_type; patch.problem_notes = (input.problem_notes ?? "").slice(0, NOTES_MAX); } if (input.notes !== undefined) { const existing = String(lineTyped.notes ?? ""); const stamp = `[${new Date().toISOString()}] driver: ${input.notes}`; patch.notes = existing ? `${existing}\n${stamp}` : stamp; } try { await pb.collection("order_items").update(input.id, patch); } catch (err) { return { ok: false, reason: "update_failed", message: err instanceof Error ? err.message : "Update failed", }; } const fresh = (await pb.collection("order_items").getOne(input.id, { expand: "purchase_order.supplier,order.customer,order.company", })) as unknown as DeliveryRow; return { ok: true, stop: normaliseDelivery(fresh) }; }