- Modified the filter criteria in the `listCollectedItems` function to exclude stock lines that have been fully received, ensuring only pending items are displayed. - Updated related tests to reflect the new filtering logic, enhancing clarity on the conditions under which stock items are included in the list. - Improved documentation comments to explain the filtering behavior and its implications for the stock confirmation process.
884 lines
33 KiB
TypeScript
884 lines
33 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";
|
|
|
|
/**
|
|
* Validation failure raised by the collection-confirm service when
|
|
* caller-supplied arguments don't match the expected shape.
|
|
*
|
|
* Mirrors the `OrderItemSplitError` pattern (`src/lib/server/order-items-service.ts`):
|
|
* a small dedicated Error subclass with a `status` property the route
|
|
* handler can map directly to an HTTP response. The `code` field is a
|
|
* stable machine-readable tag the client can switch on; the `message`
|
|
* is human-readable.
|
|
*
|
|
* Routes are expected to validate first and return 400 directly; this
|
|
* class is the defence-in-depth layer for any caller (the MCP
|
|
* endpoint, a future internal tool, etc.) that bypasses the route
|
|
* validation.
|
|
*/
|
|
export class CollectionConfirmValidationError extends Error {
|
|
readonly code: string;
|
|
readonly status: number;
|
|
constructor(message: string, code: string, status: number) {
|
|
super(message);
|
|
this.name = "CollectionConfirmValidationError";
|
|
this.code = code;
|
|
this.status = status;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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[];
|
|
/**
|
|
* Stock-only: filenames on the PARENT stock_purchase's
|
|
* `additional_attachments` file field. The stock-side invoice
|
|
* upload (handled by `handleStockInvoiceUpload` on the page)
|
|
* writes here, not on the line itself. The page's
|
|
* `dedupeAttachments` reads BOTH this field (for stock rows) and
|
|
* `order_item_attachements` (for customer rows) so the chip
|
|
* strip surfaces the agent's just-uploaded invoice.
|
|
*/
|
|
stock_purchase_attachments?: 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;
|
|
// Stock-only (customer order items have no such field). Read by the
|
|
// collection-confirm page's supplier-SKU pencil and written via
|
|
// `updateStockPurchaseItem` (patchable whitelist member).
|
|
supplier_sku?: string;
|
|
supplier?: string;
|
|
poNumber?: string;
|
|
/**
|
|
* Lightweight summary of the most recent `price_diff_report` entry
|
|
* the `po-agent` wrote to this PO's `notes` field. Populated by
|
|
* `listCollectedItems` so the operator (and the agent) can see at a
|
|
* glance which POs the agent has already triaged — the verdict +
|
|
* summary drive a small badge on each PoCard.
|
|
*
|
|
* `exists` is true when at least one `category: "price_diff_report"`
|
|
* entry is in the notes feed; `verdict` / `summary` come from the
|
|
* most recent one (newest `ts`). Null when the agent hasn't
|
|
* reviewed the PO yet.
|
|
*/
|
|
agent_report?: {
|
|
exists: boolean;
|
|
verdict?: string;
|
|
summary?: string;
|
|
recorded_at?: string;
|
|
} | null;
|
|
/**
|
|
* 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;
|
|
/**
|
|
* Multi-file field on the parent `purchase_orders` record.
|
|
* Surfaces the PO PDF (and any operator-uploaded scans) as
|
|
* chips on the collection-confirm page. The collection-confirm
|
|
* page's `dedupeAttachments` reads this so a PO whose file was
|
|
* uploaded directly to the parent PO (rather than the line's
|
|
* `order_item_attachements`) still shows a document chip.
|
|
*/
|
|
documents?: 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 type UploadInvoiceResult = {
|
|
filename: string;
|
|
itemIds: string[];
|
|
};
|
|
|
|
/**
|
|
* Store a buyer-supplied invoice on every customer line in a collected PO.
|
|
* The existing multi-file field is deliberately reused so invoices stay
|
|
* attached to the order item and the document lookup remains stable.
|
|
*/
|
|
export async function uploadInvoiceToOrderItems(
|
|
ids: string[],
|
|
file: File,
|
|
): Promise<UploadInvoiceResult> {
|
|
if (ids.length === 0) {
|
|
throw new CollectionConfirmValidationError(
|
|
"itemIds must be a non-empty array",
|
|
"invalid_item_ids",
|
|
400,
|
|
);
|
|
}
|
|
if (!file || file.size === 0) {
|
|
throw new CollectionConfirmValidationError("file is required", "missing_file", 400);
|
|
}
|
|
if (file.size > 25 * 1024 * 1024) {
|
|
throw new CollectionConfirmValidationError("File too large (max 25 MB)", "file_too_large", 413);
|
|
}
|
|
|
|
const pb = await getAdminPocketBase();
|
|
const collectionName = getOrderItemsCollectionName();
|
|
const rows = await pb.collection(collectionName).getFullList<ConfirmRow>({
|
|
filter: ids.map((id) => `id = "${id.replace(/"/g, '\\\"')}"`).join(" || "),
|
|
fields: "id,item_status",
|
|
});
|
|
const validIds = rows.filter((row) => row.item_status === "collected").map((row) => row.id);
|
|
if (validIds.length === 0) {
|
|
throw new CollectionConfirmValidationError(
|
|
"No collected customer items matched",
|
|
"items_not_collected",
|
|
409,
|
|
);
|
|
}
|
|
|
|
// Prefix the operator upload so it is distinguishable from a driver's
|
|
// delivery note, even when both documents came in with generic filenames.
|
|
const taggedFile = new File([await file.arrayBuffer()], `[invoice] ${file.name || "invoice"}`, {
|
|
type: file.type || "application/octet-stream",
|
|
});
|
|
for (const id of validIds) {
|
|
const form = new FormData();
|
|
form.append("order_item_attachements", taggedFile);
|
|
await pb.collection(collectionName).update(id, form);
|
|
}
|
|
return { filename: taggedFile.name, itemIds: validIds };
|
|
}
|
|
|
|
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 is "collected" AND the warehouse hasn't
|
|
// fully received the line yet. The warehouse-side
|
|
// `confirmStockRows` call snaps `quantity_received` to
|
|
// `quantity`; the qty pencil (collection-confirm page) does
|
|
// the same when the operator manually snaps the qty to the
|
|
// full amount. Either path lands `quantity_received >=
|
|
// quantity` and the row drops off the list. Conversely, a
|
|
// line where `quantity_received` is below `quantity` is
|
|
// still pending the warehouse's action and stays on the
|
|
// page. The status field is deliberately left at
|
|
// "collected" — only the accountant's booking flow flips
|
|
// it to "booked" (and is gated on the warehouse's
|
|
// `confirmStockRows` having run first).
|
|
filter: 'status = "collected" && quantity_received < quantity',
|
|
sort: "-created",
|
|
expand: "purchase.supplier,purchase.stock_item,purchase.additional_attachments",
|
|
})
|
|
// 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,
|
|
// Filenames on the parent stock_purchase.additional_attachments
|
|
// (the agent / operator writes the supplier invoice here via
|
|
// `/api/v1/stock-purchases/[id]/attachments/upload`). The page
|
|
// exposes these through the same chip strip as customer
|
|
// attachments so the operator doesn't have to navigate to the
|
|
// detail page just to see the latest upload.
|
|
//
|
|
// PB file fields normally return string[]. A few legacy rows
|
|
// were written under a text-typed variant of this column; coerce
|
|
// string-to-array here so the chip strip doesn't iterate the
|
|
// string character-by-character (the v1 dev bug that surfaced
|
|
// 2026-08-14). `null`/`undefined` collapses to [].
|
|
stock_purchase_attachments: (() => {
|
|
const raw = (
|
|
s.expand?.purchase as { additional_attachments?: unknown } | undefined
|
|
)?.additional_attachments;
|
|
if (raw == null) return [];
|
|
return Array.isArray(raw) ? raw : [raw];
|
|
})(),
|
|
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,
|
|
);
|
|
|
|
// Augment each row with the agent's most recent `price_diff_report`
|
|
// entry, if any. The agent writes to the parent PO's `notes` field
|
|
// (not the line's), so we fetch one notes-read per unique parent
|
|
// PO and attach the parsed entry to every line that points at it.
|
|
// For rows with `source: "stock"` the parent PO id lives at
|
|
// `stock_purchase_id`; for customer rows it's the `expand.purchase_order.id`
|
|
// (or, if the customer's PO is missing, the row's `order` relation
|
|
// — but that maps to `orders` not `purchase_orders`, so we ignore it
|
|
// for the report lookup).
|
|
const parentPoIds = new Set<string>();
|
|
for (const row of merged) {
|
|
if (row.source === "stock" && row.stock_purchase_id) {
|
|
parentPoIds.add(row.stock_purchase_id);
|
|
} else if (row.expand?.purchase_order?.id) {
|
|
parentPoIds.add(row.expand.purchase_order.id);
|
|
}
|
|
}
|
|
if (parentPoIds.size > 0) {
|
|
try {
|
|
const notesByPoId = new Map<string, ReturnType<typeof latestPriceDiffReport>>();
|
|
// Stock PO notes live on stock_purchases; customer PO notes
|
|
// live on purchase_orders. The two collections don't share a
|
|
// schema field, so we fetch both in parallel — but we don't know
|
|
// which collection each id belongs to without a probe. The
|
|
// simpler path: try both per id, taking whichever returns a row.
|
|
// (Stock PO ids start with `PO...STK`; customer PO ids start
|
|
// with `PO-...` — that's a quick discriminator.)
|
|
const stockIds = [...parentPoIds].filter((id) => /^PO\d/.test(id));
|
|
const customerIds = [...parentPoIds].filter((id) => /^PO-/.test(id));
|
|
const [stockNotes, customerNotes] = await Promise.all([
|
|
stockIds.length > 0
|
|
? pb.collection("stock_purchases").getFullList({
|
|
filter: stockIds.map((id) => `id = "${id}"`).join(" || "),
|
|
fields: "id,notes",
|
|
})
|
|
: Promise.resolve([] as Array<{ id: string; notes?: string }>),
|
|
customerIds.length > 0
|
|
? pb.collection("purchase_orders").getFullList({
|
|
filter: customerIds.map((id) => `id = "${id}"`).join(" || "),
|
|
fields: "id,notes",
|
|
})
|
|
: Promise.resolve([] as Array<{ id: string; notes?: string }>),
|
|
]);
|
|
for (const row of stockNotes as Array<{ id: string; notes?: string }>) {
|
|
notesByPoId.set(row.id, latestPriceDiffReport(row.notes ?? ""));
|
|
}
|
|
for (const row of customerNotes as Array<{ id: string; notes?: string }>) {
|
|
notesByPoId.set(row.id, latestPriceDiffReport(row.notes ?? ""));
|
|
}
|
|
for (const row of merged) {
|
|
const parentId =
|
|
row.source === "stock"
|
|
? row.stock_purchase_id
|
|
: row.expand?.purchase_order?.id;
|
|
if (parentId && notesByPoId.has(parentId)) {
|
|
row.agent_report = notesByPoId.get(parentId) ?? null;
|
|
} else {
|
|
row.agent_report = null;
|
|
}
|
|
}
|
|
} catch {
|
|
// Fail-soft: a notes-read outage must not wipe the collected-rows
|
|
// view. Just leave agent_report undefined.
|
|
for (const row of merged) {
|
|
row.agent_report = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
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> {
|
|
// Defence-in-depth validation. The route is the primary gate
|
|
// (returns 400 on bad input) but a non-numeric / NaN / negative
|
|
// cost_price reaching PB would silently coerce, the prev/new cost
|
|
// audit note would get `NaN`, and we'd hand the operator a broken
|
|
// audit trail. Refuse upfront instead.
|
|
if (
|
|
typeof args.cost_price !== "number" ||
|
|
!Number.isFinite(args.cost_price)
|
|
) {
|
|
throw new CollectionConfirmValidationError(
|
|
"cost_price must be a finite number",
|
|
"invalid_cost_price",
|
|
400,
|
|
);
|
|
}
|
|
if (args.cost_price < 0) {
|
|
throw new CollectionConfirmValidationError(
|
|
"cost_price must be a non-negative number",
|
|
"invalid_cost_price",
|
|
400,
|
|
);
|
|
}
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Update the SKU on a single collected order_item and stamp a
|
|
* `cost`-category note onto the row so the audit trail records the
|
|
* before/after values.
|
|
*
|
|
* Mirrors `updateOrderItemCost`: a no-op when the new SKU is
|
|
* byte-for-byte equal to the existing one (so the route returns
|
|
* 200 without writing a new audit entry). For a genuine change,
|
|
* the patch writes both the new `sku` and the appended notes feed
|
|
* in a single round-trip.
|
|
*
|
|
* SKU is required (the column is a PB text field but the rest of
|
|
* the app reads it as the canonical join key with `stock_items`).
|
|
* Empty / whitespace-only input is rejected with 400. We also
|
|
* cap length at 64 characters to match the existing schema and
|
|
* keep the data type sane.
|
|
*/
|
|
export async function updateOrderItemSku(
|
|
id: string,
|
|
args: { sku: string; actor: ConfirmActor },
|
|
): Promise<ConfirmRow> {
|
|
const trimmed = args.sku.trim();
|
|
if (trimmed.length === 0) {
|
|
throw new CollectionConfirmValidationError(
|
|
"sku must not be empty",
|
|
"invalid_sku",
|
|
400,
|
|
);
|
|
}
|
|
if (trimmed.length > 64) {
|
|
throw new CollectionConfirmValidationError(
|
|
"sku must be 64 characters or fewer",
|
|
"invalid_sku",
|
|
400,
|
|
);
|
|
}
|
|
const pb = await getAdminPocketBase();
|
|
const collectionName = getOrderItemsCollectionName();
|
|
const existing = await pb.collection(collectionName).getOne(id);
|
|
const prevSku = ((existing as { sku?: string | null }).sku ?? "").trim();
|
|
if (prevSku === trimmed) {
|
|
return existing as ConfirmRow;
|
|
}
|
|
const existingNotes = (existing as { notes?: string | null }).notes ?? "";
|
|
const skuNote = {
|
|
id: `sku-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
ts: new Date().toISOString(),
|
|
actorId: args.actor.id,
|
|
actorName: args.actor.name,
|
|
category: "cost" as const,
|
|
body: { skuChange: { prev: prevSku || null, next: trimmed } },
|
|
};
|
|
const updated = await pb.collection(collectionName).update(id, {
|
|
sku: trimmed,
|
|
notes: JSON.stringify([...parseStoredNotesForSku(existingNotes), skuNote]),
|
|
});
|
|
return updated as ConfirmRow;
|
|
}
|
|
|
|
function parseStoredNotesForSku(raw: string): Array<Record<string, unknown>> {
|
|
const trimmed = (raw ?? "").trim();
|
|
if (!trimmed) return [];
|
|
try {
|
|
const parsed = JSON.parse(trimmed);
|
|
if (!Array.isArray(parsed)) return [];
|
|
return parsed.filter((entry) => entry && typeof entry === "object");
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update the `qty_collected` value on a single collected order_item
|
|
* and stamp a `quantity`-category note onto the row so the audit trail
|
|
* records the before/after values.
|
|
*
|
|
* Mirrors `updateOrderItemCost`: a no-op when the new qty is
|
|
* byte-for-byte equal to the existing one (so the route returns 200
|
|
* without writing a new audit entry). For a genuine change the
|
|
* patch writes both the new `qty_collected` and the appended notes
|
|
* feed in a single round-trip.
|
|
*
|
|
* Validates non-negative integer input (PB stores `qty_collected`
|
|
* as a number; we treat fractions as 400 because the UI's pencil
|
|
* is integer-only and the downstream invoices expect whole units).
|
|
* Upper bound: we deliberately do NOT cap at the ordered quantity
|
|
* — a row can be legitimately over-collected (driver delivered
|
|
* +extras, supplier shipped more than PO'd). The "all received"
|
|
* badge in the items table is purely informational; the qty pencil
|
|
* never silently clamps.
|
|
*/
|
|
export async function updateOrderItemQty(
|
|
id: string,
|
|
args: { qty_collected: number; actor: ConfirmActor },
|
|
): Promise<ConfirmRow> {
|
|
if (
|
|
typeof args.qty_collected !== "number" ||
|
|
!Number.isFinite(args.qty_collected)
|
|
) {
|
|
throw new CollectionConfirmValidationError(
|
|
"qty_collected must be a finite number",
|
|
"invalid_qty_collected",
|
|
400,
|
|
);
|
|
}
|
|
if (args.qty_collected < 0) {
|
|
throw new CollectionConfirmValidationError(
|
|
"qty_collected must be a non-negative number",
|
|
"invalid_qty_collected",
|
|
400,
|
|
);
|
|
}
|
|
if (!Number.isInteger(args.qty_collected)) {
|
|
throw new CollectionConfirmValidationError(
|
|
"qty_collected must be a whole number",
|
|
"invalid_qty_collected",
|
|
400,
|
|
);
|
|
}
|
|
const pb = await getAdminPocketBase();
|
|
const collectionName = getOrderItemsCollectionName();
|
|
const existing = await pb.collection(collectionName).getOne(id);
|
|
const prevQty = Number(
|
|
(existing as { qty_collected?: number | null }).qty_collected ?? 0,
|
|
);
|
|
if (prevQty === args.qty_collected) {
|
|
return existing as ConfirmRow;
|
|
}
|
|
const existingNotes =
|
|
(existing as { notes?: string | null }).notes ?? "";
|
|
const qtyNote = {
|
|
id: `qty-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
ts: new Date().toISOString(),
|
|
actorId: args.actor.id,
|
|
actorName: args.actor.name,
|
|
category: "quantity" as const,
|
|
body: { qtyChange: { prev: prevQty, next: args.qty_collected } },
|
|
};
|
|
const updated = await pb.collection(collectionName).update(id, {
|
|
qty_collected: args.qty_collected,
|
|
notes: JSON.stringify([...parseStoredNotesForSku(existingNotes), qtyNote]),
|
|
});
|
|
return updated as ConfirmRow;
|
|
}
|
|
|
|
/**
|
|
* Pick the most recent `price_diff_report` entry from a notes feed.
|
|
* The `po-agent` writes one of these per scanned PO (see
|
|
* `docs/agents/purchase-order-agent.md`); the UI uses the verdict +
|
|
* summary on the latest one to badge the PoCard "Agent reviewed".
|
|
*
|
|
* Returns `null` when the feed is empty / no price_diff_report
|
|
* entries. The function is intentionally lenient — corrupt feeds
|
|
* return null rather than throwing, because the UI only uses the
|
|
* result for a badge.
|
|
*/
|
|
function latestPriceDiffReport(raw: string): {
|
|
exists: boolean;
|
|
verdict?: string;
|
|
summary?: string;
|
|
recorded_at?: string;
|
|
} | null {
|
|
const entries = parseStoredNotesForSku(raw);
|
|
const reports = entries.filter(
|
|
(e) => (e as Record<string, unknown>).category === "price_diff_report",
|
|
) as Array<Record<string, unknown>>;
|
|
if (reports.length === 0) return null;
|
|
// The feed isn't formally sorted, so sort by `ts` descending and
|
|
// take the first. Fall back to array order if `ts` is missing on
|
|
// every entry.
|
|
const sorted = reports.slice().sort((a, b) => {
|
|
const ta = String(a.ts ?? "");
|
|
const tb = String(b.ts ?? "");
|
|
return tb.localeCompare(ta);
|
|
});
|
|
const latest = sorted[0]!;
|
|
const body = (latest.body ?? {}) as Record<string, unknown>;
|
|
return {
|
|
exists: true,
|
|
verdict: typeof body.verdict === "string" ? body.verdict : undefined,
|
|
summary: typeof body.summary === "string" ? body.summary : undefined,
|
|
recorded_at: typeof latest.ts === "string" ? latest.ts : undefined,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Confirm collection of the given stock-source rows.
|
|
*
|
|
* History: this function used to *book* the rows into `stock_items`
|
|
* (ledger row → cache increment → line status → header auto-derive).
|
|
* That's been moved to the accountant's invoicing flow
|
|
* (`bookStockFromInvoice` in `stock-booking-service.ts`) so the
|
|
* payment-cycle reasoning lines up with the moment the supplier
|
|
* invoice is processed. The warehouse-side flow now only records
|
|
* what physically arrived — `quantity_received` on each line.
|
|
*
|
|
* For each id:
|
|
* 1. Reads the `stock_purchase_items` row to get `stock_item`,
|
|
* `quantity`, `quantity_received`.
|
|
* 2. Validates: has `quantity > 0`, has a parent
|
|
* `stock_purchase_id`, and has not already been fully received
|
|
* (a partial receipt is allowed — operators may have to leave
|
|
* a damaged-out line unconfirmed and confirm the rest).
|
|
* 3. Snaps `quantity_received` to `quantity` on the line.
|
|
*
|
|
* The line status is deliberately left untouched — the driver app
|
|
* sets it to `collected`; the accountant's `bookStockFromInvoice`
|
|
* flips it to `booked` once payment is committed. The header status
|
|
* (`sent` → `received`) is also stamped by the accountant's flow,
|
|
* because that's the moment the row is closed for stock purposes.
|
|
*
|
|
* Each row is processed independently — a failure on one row leaves
|
|
* the others untouched and surfaces the error to the caller.
|
|
*
|
|
* Returns the list of row ids that were successfully confirmed. An
|
|
* empty `ids` array is a no-op (`[]`).
|
|
*/
|
|
export async function confirmStockRows(
|
|
ids: string[],
|
|
_args: { actor: ConfirmActor },
|
|
): Promise<string[]> {
|
|
if (ids.length === 0) return [];
|
|
const pb = await getAdminPocketBase();
|
|
const updatedIds: string[] = [];
|
|
for (const id of ids) {
|
|
const ok = await confirmStockRowForId(pb, id);
|
|
if (ok) updatedIds.push(id);
|
|
}
|
|
return updatedIds;
|
|
}
|
|
|
|
/**
|
|
* Per-row confirm-collection implementation. Throws whenever
|
|
* validation fails so the route returns a 500 with the message; the
|
|
* caller (the service) is responsible for collecting a boolean back
|
|
* into the `updatedIds` list.
|
|
*
|
|
* `pb` is the admin PocketBase client; the function reads / updates
|
|
* the relevant collections directly.
|
|
*
|
|
* ## Idempotency
|
|
*
|
|
* If the line is already fully received (`quantity_received >=
|
|
* quantity`), `confirmStockRowForId` returns `false` so the caller
|
|
* drops it from the `updatedIds` list. The route surfaces this as a
|
|
* 200 with the row omitted from `updatedIds` — the warehouse-side
|
|
* "Confirm collection" button is the operator's explicit close-of-
|
|
* day action; a previous confirm (or the qty pencil that snapped
|
|
* `quantity_received` up to the full qty) means there's nothing
|
|
* left to advance. The previous behaviour threw a plain Error →
|
|
* 500, which was a foot-gun once the qty pencil arrived: setting
|
|
* the qty to the full value would leave the confirm-stock button
|
|
* inoperable for that card.
|
|
*/
|
|
async function confirmStockRowForId(
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|
id: string,
|
|
): Promise<boolean> {
|
|
// (a) Read the line so we have quantity, quantity_received, and
|
|
// the parent purchase id (the parent id is required for the row
|
|
// to be a valid stock_purchase_item — guards against orphan rows).
|
|
const line = (await pb
|
|
.collection("stock_purchase_items")
|
|
.getOne(id)) as unknown as {
|
|
id: string;
|
|
stock_item: string;
|
|
quantity: number;
|
|
quantity_received: number;
|
|
purchase?: string;
|
|
};
|
|
const qty = Number(line.quantity ?? 0);
|
|
if (!(qty > 0)) {
|
|
throw new Error(`Stock line ${id} has zero quantity — nothing to confirm.`);
|
|
}
|
|
const alreadyReceived = Number(line.quantity_received ?? 0);
|
|
if (alreadyReceived >= qty) {
|
|
// Idempotent no-op: the line is already at full received. The
|
|
// qty pencil is one way to land here (operator snapped the qty
|
|
// up to the full qty before clicking confirm); a previous
|
|
// confirm-stock is another. Either way there's nothing to do.
|
|
return false;
|
|
}
|
|
if (!line.purchase) {
|
|
throw new Error(
|
|
`Stock line ${id} is missing the parent purchase id — cannot confirm.`,
|
|
);
|
|
}
|
|
|
|
// (b) Snap quantity_received to the full qty. The line status is
|
|
// left as-is — the driver app will have set it to `collected`,
|
|
// and the accountant's `bookStockFromInvoice` is what flips it
|
|
// to `booked` once the supplier invoice is processed.
|
|
await pb.collection("stock_purchase_items").update(id, {
|
|
quantity_received: qty,
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|