- Added an audit trail feature that logs every create, update, and delete action on delivery notes and orders, capturing detailed changes per field. - Introduced the `AuditTimeline` component to display the history of changes for delivery notes and orders, enhancing traceability. - Updated the `page.tsx` files for delivery notes and orders to fetch and display audit logs, ensuring users can view the history of modifications. - Enhanced the `package.json` with a new migration script for setting up the audit log functionality. This feature improves accountability and transparency in the management of delivery notes and orders.
891 lines
34 KiB
TypeScript
891 lines
34 KiB
TypeScript
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
|
import { getOrderItemsCollectionName } from "@/lib/server/orders-service";
|
|
import { compressFile } from "@/lib/server/attachment-compressor";
|
|
|
|
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",
|
|
});
|
|
// Compress once outside the loop — every item in the group gets
|
|
// the same (compressed) bytes, so we only pay Ghostscript/sips
|
|
// once per upload. New uploads are born compressed so the PB
|
|
// token they get assigned is stable from day one — see
|
|
// `attachment-compressor.ts` for the URL-churn rationale.
|
|
const { file: compressedTaggedFile } = await compressFile(taggedFile);
|
|
for (const id of validIds) {
|
|
const form = new FormData();
|
|
form.append("order_item_attachements", compressedTaggedFile);
|
|
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);
|
|
}
|