WFDASH-WEBAPP/src/lib/server/driver-stops.ts

629 lines
20 KiB
TypeScript
Raw Normal View History

/**
* 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;
/** 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: string | null | undefined): [number, number] | null {
if (!raw) return null;
const parts = raw.split(",").map((s) => Number(s.trim()));
if (parts.length !== 2 || !Number.isFinite(parts[0]) || !Number.isFinite(parts[1])) {
return null;
}
return [parts[0], parts[1]];
}
/**
* 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<ListDriverStopsResult> {
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<DeliveryRow>(page, perPage, {
filter: deliveryFilter(opts.drv_id),
sort: "assigned_at,created",
expand: "purchase_order.supplier,order.customer,order.company",
})
: Promise.resolve(emptyPage<DeliveryRow>()),
wantPickup
? pb.collection("stock_purchase_items").getList<PickupRow>(page, perPage, {
filter: pickupFilter(opts.drv_id),
sort: "assigned_at,created",
expand: "purchase.supplier,stock_item",
})
: Promise.resolve(emptyPage<PickupRow>()),
]);
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<T>(): { 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,
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,
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<UpdateDriverStopResult> {
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<ReturnType<typeof getAdminPocketBase>>,
input: UpdateDriverStopInput,
): Promise<UpdateDriverStopResult> {
// Fetch the line so we can default the qty + validate status.
let line: Record<string, unknown>;
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<string, unknown> = {
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<ReturnType<typeof getAdminPocketBase>>,
input: UpdateDriverStopInput,
): Promise<UpdateDriverStopResult> {
let line: Record<string, unknown>;
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).
const qtyCollected =
input.qty_collected !== undefined
? Math.floor(input.qty_collected)
: Number(lineTyped.order_qty ?? lineTyped.quantity ?? 0);
const patch: Record<string, unknown> = {
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) };
}