269 lines
10 KiB
TypeScript
269 lines
10 KiB
TypeScript
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
|
import { getOrderItemsCollectionName } from "@/lib/server/orders-service";
|
|
import {
|
|
appendCostNote,
|
|
type ConfirmActor,
|
|
} from "@/app/(app)/collection-confirm/view-model";
|
|
|
|
/**
|
|
* Where the row came from. The collection-confirm / collection-pool
|
|
* pages now read from BOTH `order_items` (customer deliveries) and
|
|
* `stock_purchase_items` (supplier pickups). The two have different
|
|
* field names + state machines, so we normalise to a `ConfirmRow`
|
|
* with a `source` discriminator and conditional fields:
|
|
* - `source: "customer"` → `expand.order` is the customer order
|
|
* - `source: "stock"` → `expand.order` is null, `stock_purchase_id` holds the parent
|
|
*
|
|
* This type is the new authoritative source for the shape consumed by
|
|
* the collection-confirm page. The duplicate in `page.tsx` will be
|
|
* migrated in a follow-up task.
|
|
*/
|
|
export type ConfirmRowSource = "customer" | "stock";
|
|
|
|
export type ConfirmRow = {
|
|
id: string;
|
|
/**
|
|
* Which collection this row came from. Drives the routing on
|
|
* the collection-confirm page (customer lines → `ready_invoice`,
|
|
* stock lines → `booked` to `stock_items`).
|
|
*/
|
|
source?: ConfirmRowSource;
|
|
description?: string;
|
|
po_description?: string;
|
|
order_qty?: number;
|
|
qty_collected?: number;
|
|
quantity?: number;
|
|
item_status?: string;
|
|
problem_flag?: boolean | null;
|
|
problem_type?: string | null;
|
|
problem_notes?: string | null;
|
|
notes?: string | null;
|
|
drv_collected_at?: string | null;
|
|
collection_photo?: string;
|
|
// Canonical PocketBase field name has the typo (`attachements`). Read
|
|
// both names for back-compat with any pre-existing rows that were
|
|
// written under the spelled-correct variant.
|
|
order_item_attachments?: string[];
|
|
order_item_attachements?: string[];
|
|
order_item_details?: string | null;
|
|
// Surplus split fields (added by 2026-07-14-create-stock migration).
|
|
// The chip + confirm button only render when the row has positive
|
|
// headroom AND has not yet been booked (surplus_qty undefined/null).
|
|
surplus_qty?: number | null;
|
|
stock_booked_from?: string | null;
|
|
// Match-key fields consumed by `findMatchingStockItem` when the
|
|
// surplus-book flow auto-picks a target stock_item.
|
|
sku?: string;
|
|
supplier?: string;
|
|
poNumber?: string;
|
|
/**
|
|
* Stock-only: PB id of the parent `stock_purchase`. Always set
|
|
* for `source: "stock"` rows; null for `source: "customer"` rows.
|
|
* Used by the driver-assign / book-to-stock / status-flow paths
|
|
* to know which table to write back to.
|
|
*/
|
|
stock_purchase_id?: string | null;
|
|
/**
|
|
* Stock-only: the per-line `status` value copied from
|
|
* `stock_purchase_items.status` at fetch time. Possible values:
|
|
* `collection_pool` | `assigned_to_driver` | `collected` | `booked`.
|
|
*/
|
|
stock_line_status?: string | null;
|
|
/**
|
|
* Stock-only: timestamp the line was bound to a driver. Mirrors
|
|
* `stock_purchase_items.assigned_at`.
|
|
*/
|
|
assigned_at?: string | null;
|
|
expand?: {
|
|
purchase_order?: { id: string; po_number?: string; expand?: { supplier?: { id: string; suppliername?: string } } };
|
|
order?: { id: string; order_number?: string; expand?: { company?: { company?: string }; customer?: { customer?: string } } };
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Read-side service for the collection-confirm page.
|
|
*
|
|
* Mirrors the union query the page's `load()` performs client-side, but
|
|
* runs through the admin PocketBase client so it can be exposed via
|
|
* `/api/v1/collection-confirm` once the page is migrated. The write
|
|
* helpers (`updateOrderItemCost`, `confirmOrderItemsForInvoicing`) come
|
|
* in follow-up plan tasks — they are stubbed here so the file shape is
|
|
* committed and downstream tasks can build on top.
|
|
*/
|
|
|
|
/**
|
|
* Return every collected row (customer + stock legs) that the
|
|
* collection-confirm page needs to render.
|
|
*
|
|
* Customer leg: `order_items` filtered by `item_status="collected"`,
|
|
* expanded with `purchase_order.supplier,order.company,order.customer`.
|
|
* Stock leg: `stock_purchase_items` filtered by `status="collected"`,
|
|
* expanded with `purchase.supplier,purchase,stock_item`.
|
|
*
|
|
* Both legs are normalised into `ConfirmRow[]`. Customer rows are
|
|
* tagged `source: "customer"`, stock rows are mapped onto the shared
|
|
* `expand.purchase_order` shape (so the page's supplier link still
|
|
* works without branching) and tagged `source: "stock"`.
|
|
*/
|
|
export async function listCollectedItems(): Promise<ConfirmRow[]> {
|
|
const pb = await getAdminPocketBase();
|
|
|
|
const [customerRows, stockRows] = await Promise.all([
|
|
pb.collection(getOrderItemsCollectionName()).getFullList<ConfirmRow>({
|
|
filter: 'item_status = "collected"',
|
|
sort: "-drv_collected_at",
|
|
expand: "purchase_order.supplier,order.company,order.customer",
|
|
}),
|
|
pb
|
|
.collection("stock_purchase_items")
|
|
.getFullList<
|
|
ConfirmRow & {
|
|
id: string;
|
|
stock_item: string;
|
|
quantity: number;
|
|
quantity_received: number;
|
|
cost_price: number;
|
|
status?: string | null;
|
|
assigned_at?: string | null;
|
|
expand?: {
|
|
purchase?: {
|
|
id: string;
|
|
purchase_number: string;
|
|
expand?: { supplier?: { id: string; suppliername?: string } };
|
|
};
|
|
stock_item?: { id: string; sku?: string; name?: string; description?: string };
|
|
};
|
|
}
|
|
>({
|
|
filter: 'status = "collected"',
|
|
sort: "-created",
|
|
expand: "purchase.supplier,purchase,stock_item",
|
|
})
|
|
// Fail-soft: matches the page's `load()` at page.tsx:294 — a
|
|
// stock-side outage must not wipe the customer-deliveries view.
|
|
.catch(() => [] as unknown[]),
|
|
]);
|
|
|
|
// Normalise stock rows into ConfirmRow shape. Stock fields that don't
|
|
// map (problem_flag, drv_collected_at, attachments) are left undefined
|
|
// so the type stays honest.
|
|
const stockAsConfirm: ConfirmRow[] = (stockRows as unknown as Array<{
|
|
id: string;
|
|
stock_item: string;
|
|
description: string;
|
|
quantity: number;
|
|
quantity_received: number;
|
|
cost_price: number;
|
|
status?: string | null;
|
|
assigned_at?: string | null;
|
|
expand?: ConfirmRow["expand"] & {
|
|
purchase?: {
|
|
id: string;
|
|
purchase_number: string;
|
|
expand?: { supplier?: { id: string; suppliername?: string } };
|
|
};
|
|
stock_item?: { id: string; sku?: string; name?: string; description?: string };
|
|
};
|
|
}>).map((s) => ({
|
|
id: s.id,
|
|
source: "stock" as const,
|
|
description: s.description,
|
|
qty_collected: s.quantity_received ?? 0,
|
|
order_qty: s.quantity,
|
|
quantity: s.quantity,
|
|
item_status: s.status ?? undefined,
|
|
sku: s.expand?.stock_item?.sku,
|
|
supplier: s.expand?.purchase?.expand?.supplier?.id ?? undefined,
|
|
stock_line_status: s.status ?? null,
|
|
assigned_at: s.assigned_at ?? null,
|
|
stock_purchase_id: s.expand?.purchase?.id ?? null,
|
|
poNumber: s.expand?.purchase?.purchase_number ?? undefined,
|
|
expand: {
|
|
// Reuse the same shape so the supplier link works (the page
|
|
// looks at `expand.purchase_order.expand.supplier.suppliername`).
|
|
purchase_order: s.expand?.purchase
|
|
? {
|
|
id: s.expand.purchase.id,
|
|
po_number: s.expand.purchase.purchase_number,
|
|
expand: { supplier: s.expand.purchase.expand?.supplier },
|
|
}
|
|
: undefined,
|
|
},
|
|
}));
|
|
|
|
// Mark customer rows with their source so downstream code can
|
|
// branch without inspecting field presence.
|
|
const customerAsConfirm: ConfirmRow[] = customerRows.map((r) => ({
|
|
...r,
|
|
source: "customer" as const,
|
|
}));
|
|
|
|
// Sort the union by created-desc-ish — both legs return at most ~500
|
|
// rows; using id-desc gives a stable, predictable order across loads.
|
|
const merged = [...customerAsConfirm, ...stockAsConfirm].sort(
|
|
(a, b) => (b.id > a.id ? 1 : -1),
|
|
);
|
|
return merged;
|
|
}
|
|
|
|
/**
|
|
* Update the cost price on a single collected order_item and stamp a
|
|
* `cost`-category note onto the row so the audit trail is preserved.
|
|
*
|
|
* No-op when the new cost equals the existing cost — `prevCost === newCost`
|
|
* returns the existing row unchanged (no write, no note append). For a
|
|
* genuine change, the patch writes both the new `cost_price` and the
|
|
* newly-appended notes feed (preserving any prior entries) in a single
|
|
* round-trip.
|
|
*
|
|
* `prevCost` is read from the existing row's `cost_price`. When the row
|
|
* has no prior cost (null), `appendCostNote` receives `0` as the
|
|
* previous cost — the early-return check still treats `null → 0` as a
|
|
* change (since `null === 0` is `false`), and the note records the
|
|
* transition as `prevCost: 0`. For `null → X > 0` the note correctly
|
|
* captures the move; for `null → 0` the `appendCostNote` internal
|
|
* short-circuit returns the existing notes unchanged, so the only
|
|
* side-effect is the `cost_price: 0` write.
|
|
*/
|
|
export async function updateOrderItemCost(
|
|
id: string,
|
|
body: { cost_price: number; actor: ConfirmActor },
|
|
): Promise<ConfirmRow> {
|
|
const pb = await getAdminPocketBase();
|
|
const collectionName = getOrderItemsCollectionName();
|
|
const existing = await pb.collection(collectionName).getOne(id);
|
|
const prevCost = (existing as { cost_price?: number | null }).cost_price ?? null;
|
|
if (prevCost === body.cost_price) {
|
|
return existing as ConfirmRow;
|
|
}
|
|
const newNotes = appendCostNote(
|
|
(existing as { notes?: string | null }).notes ?? null,
|
|
body.actor,
|
|
prevCost ?? 0,
|
|
body.cost_price,
|
|
);
|
|
const updated = await pb
|
|
.collection(collectionName)
|
|
.update(id, { cost_price: body.cost_price, notes: newNotes });
|
|
return updated as ConfirmRow;
|
|
}
|
|
|
|
/**
|
|
* Flip a set of order_items into `ready_invoice` (or whichever next
|
|
* status the view-model picks) and stamp the supplier-invoice
|
|
* details into `order_item_details` so invoicing can pick the rows up.
|
|
*
|
|
* Stubbed — implementation lands in plan task 5.
|
|
*/
|
|
export async function confirmOrderItemsForInvoicing(
|
|
ids: string[],
|
|
body: {
|
|
invoice: { date: string; number: string };
|
|
costOverrides: Record<string, number>;
|
|
actor: ConfirmActor;
|
|
},
|
|
): Promise<{ updated: number; failed: Array<{ id: string; error: string }> }> {
|
|
void ids;
|
|
void body;
|
|
throw new Error("not implemented");
|
|
}
|