WFDASH-WEBAPP/src/lib/server/collection-confirm-service.ts
2026-08-03 00:46:50 +02:00

314 lines
12 KiB
TypeScript

import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { getOrderItemsCollectionName } from "@/lib/server/orders-service";
import {
appendCostNote,
buildConfirmPatch,
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;
cost_price?: number | null;
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`) use the
* same admin client and preserve the collection-confirm audit trail.
*/
/**
* 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` and threaded
* through to `appendCostNote` verbatim (including `null` when the row
* has no prior cost). The `null === 0` check in `appendCostNote` is
* `false`, so `null → 0` is treated as a real change and a cost note
* IS appended with `{ prevCost: null, newCost: 0 }`.
*/
export async function updateOrderItemCost(
id: string,
args: { 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 === args.cost_price) {
return existing as ConfirmRow;
}
const newNotes = appendCostNote(
(existing as { notes?: string | null }).notes ?? null,
args.actor,
prevCost,
args.cost_price,
);
const updated = await pb
.collection(collectionName)
.update(id, { cost_price: args.cost_price, notes: newNotes });
return updated as ConfirmRow;
}
/**
* Confirm collected order_items for invoicing in one transactional batch.
*
* The rows are read first so each cost override can be compared with the
* current value and its audit note can be built from the correct previous
* cost. The SDK batch queues all updates and sends them together; if the
* batch request fails, PocketBase does not apply any individual update.
*/
export async function confirmOrderItemsForInvoicing(
ids: string[],
args: {
invoice: { date: string; number: string };
costOverrides: Record<string, number>;
actor: ConfirmActor;
},
): Promise<string[]> {
if (ids.length === 0) return [];
const pb = await getAdminPocketBase();
const collectionName = getOrderItemsCollectionName();
const rows = await pb.collection(collectionName).getList(1, ids.length, {
filter: ids.map((id) => `id="${id}"`).join(" || "),
});
const batch = pb.createBatch();
const batchCollection = batch.collection(collectionName);
for (const row of rows.items) {
const currentCost = (row as { cost_price?: number | null }).cost_price ?? null;
const override = args.costOverrides[row.id];
const costOverride =
typeof override === "number" && override !== currentCost
? override
: undefined;
// Build the standard confirmation patch first. Cost changes are appended
// below so the resulting notes feed contains both confirm and cost audit
// entries in that order.
const confirmPatch = buildConfirmPatch(
row as Parameters<typeof buildConfirmPatch>[0],
{ actor: args.actor, invoice: args.invoice },
);
let notes = confirmPatch.notes;
if (typeof costOverride === "number") {
notes = appendCostNote(
confirmPatch.notes,
args.actor,
currentCost,
costOverride,
);
}
const patch = {
item_status: confirmPatch.item_status,
notes,
order_item_details: confirmPatch.order_item_details,
...(typeof costOverride === "number"
? { cost_price: costOverride }
: {}),
};
batchCollection.update(row.id, patch);
}
await batch.send();
return rows.items.map((row) => row.id);
}