WFDASH-WEBAPP/src/lib/server/collection-confirm-service.ts
SJCoetz 2997b8dcba feat(collection-confirm): implement updateOrderItemCost service
Co-authored-by: Orca <help@stably.ai>
2026-08-03 00:23:37 +02:00

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");
}