2026-07-18 20:43:50 +00:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 20:33:47 +00:00
|
|
|
function parseCoords(raw: unknown): [number, number] | null {
|
|
|
|
|
if (Array.isArray(raw)) {
|
|
|
|
|
return finiteCoordinatePair(raw[0], raw[1]);
|
2026-07-18 20:43:50 +00:00
|
|
|
}
|
2026-08-17 20:33:47 +00:00
|
|
|
if (raw && typeof raw === "object") {
|
|
|
|
|
const point = raw as Record<string, unknown>;
|
|
|
|
|
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];
|
2026-07-18 20:43:50 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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) };
|
|
|
|
|
}
|