`order_items.order_qty` is the SUPPLIER-facing quantity — what prints
on the PO. Five call sites read it as the customer quantity, which is a
money bug in both directions:
- supplier MOQ (customer 4, we buy 10) → invoiced 10, over-billing 6
- shorted line (customer 100, supplier covers 40) → invoiced 40,
under-billing 60 until the operator splits the remainder
The invoicing screen never fetched `quantity` at all (zero occurrences
in page.tsx), so it could not bill the customer quantity even in
principle. Added to the PB field projection, which is load-bearing:
drop it and PB returns undefined and every line silently falls back to
order_qty. A page-level test now fails if the projection drifts.
`maxSurplusFor` measured the surplus as `collected - order_qty`, which
on an MOQ line is always 0 — the overflow was mathematically unbookable
and vanished from inventory by construction, not by operator error.
Four call sites built delivery-note lines by hand; two passed
`quantity` and two forgot it, so the same DN printed the customer's 4
via the service layer and the supplier's 10 via the API routes. All
four now share `buildDeliveryNoteLine`, so they cannot diverge again.
`order_qty` had no doc comment at all next to an 11-line one on
`quantity` — that absence is why five subsystems guessed differently.
Now documented, with a new `src/lib/order-item-qty.ts` holding
`customerQty` / `supplierQty` as the single definition.
driver-stops.ts and collection-confirm/po-card.tsx read `order_qty`
CORRECTLY (they check what the supplier delivered against what we
ordered from them) and carry comments saying so, so the next cleanup
doesn't "fix" them into breaking.
23 new tests. Full suite: no regressions — the 15 failures are
identical before and after (verified by stashing and re-running).
Audit: docs/superpowers/specs/2026-08-30-quantity-handling-audit.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
664 lines
22 KiB
TypeScript
664 lines
22 KiB
TypeScript
/**
|
|
* 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<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];
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
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<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).
|
|
//
|
|
// `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<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) };
|
|
}
|