- Introduced a new `handleQtyChange` function to manage per-row quantity updates for both customer and stock items, ensuring correct data submission to the appropriate endpoints. - Enhanced the `dedupeAttachments` function to handle additional document sources, including parent purchase orders, and skip empty filenames to prevent 404 errors. - Updated the `ItemsTable` and `PoCard` components to support quantity editing, allowing users to modify collected quantities directly in the UI. - Added tests to validate the new quantity handling logic and attachment deduplication, ensuring robust functionality across various scenarios.
643 lines
No EOL
21 KiB
TypeScript
643 lines
No EOL
21 KiB
TypeScript
/**
|
|
* Per-line writes for `stock_purchase_items` — service layer.
|
|
*
|
|
* Pure per-line helpers that the MCP `stock_purchase_items.*`
|
|
* tools (and the future operator-side REST routes) call. The
|
|
* parent `stock_purchases` collection is NOT touched here — only
|
|
* the line rows.
|
|
*
|
|
* ## Public API
|
|
*
|
|
* - `updateStockPurchaseItem({id, patch, forceOverwrite})` —
|
|
* patch whitelisted pricing fields on a single line.
|
|
* - `bulkUpdateStockPurchaseItems({items, dry_run})` —
|
|
* per-row patch with the same whitelist, hand-edit guard on
|
|
* `cost_price`, and the `bulk_update_quote_items` response
|
|
* shape (`{updated, skipped, errors, results}`).
|
|
* - `assignDriver({id, drv_id, assigned_at?})` — flips line
|
|
* status `collection_pool → assigned_to_driver`.
|
|
* - `markLineCollected({id, quantity_collected?})` — flips line
|
|
* status to `collected` and bumps `quantity_received`. Does
|
|
* NOT book into stock — that requires the invoice-receive
|
|
* path.
|
|
*
|
|
* ## Why a separate module
|
|
*
|
|
* `stock-purchases-service.ts` is already 800 lines and owns the
|
|
* `receiveStockPurchase` legacy flow (the one being fixed in this
|
|
* PR). Per-line writes belong here so the partial-receive flow
|
|
* (`receive_invoice`) can call them without pulling in the
|
|
* header-flip and full-book legacy code.
|
|
*
|
|
* Mirrors the `quotes-service.ts` shape — keep it boring.
|
|
*/
|
|
|
|
import type { StockPurchaseItem } from "@/lib/types";
|
|
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
|
|
|
const STOCK_PURCHASE_ITEMS = "stock_purchase_items" as const;
|
|
const STOCK_PURCHASES = "stock_purchases" as const;
|
|
|
|
/**
|
|
* Fields a caller may patch on a stock_purchase_item. Anything
|
|
* outside this set returns `forbidden_field` per row.
|
|
*
|
|
* The whitelisted fields cover the supplier-invoice reconciliation
|
|
* use case (the agent overrides the supplier's sku / cost_price /
|
|
* description when the invoice differs from the original PO).
|
|
* Quantity moves through `receive_invoice`, status moves through
|
|
* `assign_driver` / `mark_collected`. Relation fields
|
|
* (`purchase`, `stock_item`) are immutable after creation.
|
|
*/
|
|
export const STOCK_PURCHASE_ITEM_PATCHABLE_FIELDS = [
|
|
"cost_price",
|
|
"sku",
|
|
"description",
|
|
"notes",
|
|
"unit",
|
|
"supplier_sku",
|
|
"quantity",
|
|
] as const;
|
|
|
|
/**
|
|
* PB client type kept loose so both the real `PocketBase` instance
|
|
* and the test stub satisfy the contract. The service uses
|
|
* `getOne`, `update`, and (in `markLineCollected` for the auto-flip
|
|
* check) `getFullList` on `stock_purchase_items`.
|
|
*/
|
|
export type StockPurchaseItemsPb = {
|
|
collection: (id: string) => {
|
|
getOne: <T = unknown>(
|
|
id: string,
|
|
opts?: Record<string, unknown>,
|
|
) => Promise<T>;
|
|
update: (
|
|
id: string,
|
|
patch: Record<string, unknown>,
|
|
) => Promise<unknown>;
|
|
getFullList: <T = unknown>(opts?: Record<string, unknown>) => Promise<T[]>;
|
|
};
|
|
};
|
|
|
|
export type UpdateStockPurchaseItemInput = {
|
|
id: string;
|
|
patch: Partial<
|
|
Pick<
|
|
StockPurchaseItem,
|
|
| "cost_price"
|
|
| "sku"
|
|
| "description"
|
|
| "notes"
|
|
| "unit"
|
|
| "quantity"
|
|
>
|
|
> & { supplier_sku?: string };
|
|
/**
|
|
* Allow overwriting a line whose `cost_price > 0` already. Default
|
|
* false (matches `bulk_update_quote_items` semantics — protects
|
|
* lines the operator already hand-priced against silent AI
|
|
* overwrite).
|
|
*/
|
|
force_overwrite?: boolean;
|
|
};
|
|
|
|
export type UpdateStockPurchaseItemResult = {
|
|
item: StockPurchaseItem;
|
|
previous: Partial<StockPurchaseItem>;
|
|
};
|
|
|
|
/**
|
|
* Patch whitelisted fields on a single stock_purchase_item.
|
|
*
|
|
* Throws `forbidden_field: <name>` for any key outside the whitelist
|
|
* so the caller knows exactly which key was rejected.
|
|
*/
|
|
export async function updateStockPurchaseItem(
|
|
input: UpdateStockPurchaseItemInput,
|
|
): Promise<UpdateStockPurchaseItemResult> {
|
|
const pb = await getAdminPocketBase();
|
|
return updateStockPurchaseItemWithPb(pb, input);
|
|
}
|
|
|
|
/**
|
|
* PB-injectable variant for testing. Public so the route can pick
|
|
* the live admin PB in production.
|
|
*/
|
|
export async function updateStockPurchaseItemWithPb(
|
|
pb: StockPurchaseItemsPb,
|
|
input: UpdateStockPurchaseItemInput,
|
|
): Promise<UpdateStockPurchaseItemResult> {
|
|
if (!input.id) throw new Error("id is required");
|
|
if (!input.patch || typeof input.patch !== "object") {
|
|
throw new Error("patch object is required");
|
|
}
|
|
|
|
// 1. Validate the patch against the whitelist.
|
|
for (const key of Object.keys(input.patch)) {
|
|
if (
|
|
!STOCK_PURCHASE_ITEM_PATCHABLE_FIELDS.includes(
|
|
key as (typeof STOCK_PURCHASE_ITEM_PATCHABLE_FIELDS)[number],
|
|
)
|
|
) {
|
|
throw new Error(`forbidden_field: ${key}`);
|
|
}
|
|
}
|
|
|
|
// 2. Load the existing line for the hand-edit guard + return value.
|
|
const before = (await pb
|
|
.collection(STOCK_PURCHASE_ITEMS)
|
|
.getOne(input.id)) as StockPurchaseItem;
|
|
|
|
const previous: Partial<StockPurchaseItem> = {};
|
|
for (const key of STOCK_PURCHASE_ITEM_PATCHABLE_FIELDS) {
|
|
if (key in input.patch) {
|
|
// `StockPurchaseItem` has no string index signature, so the
|
|
// direct `as Record<string, unknown>` cast is rejected (TS2352).
|
|
// Detour through `unknown` — same pattern as the stock-items
|
|
// service.
|
|
(previous as unknown as Record<string, unknown>)[key] = (
|
|
before as unknown as Record<string, unknown>
|
|
)[key];
|
|
}
|
|
}
|
|
|
|
// 3. Hand-edit guard on cost_price (matches bulk_update_quote_items).
|
|
if (
|
|
"cost_price" in input.patch &&
|
|
Number(before.cost_price ?? 0) > 0 &&
|
|
input.force_overwrite !== true
|
|
) {
|
|
throw new Error(
|
|
"already_priced: line already has cost_price > 0; pass force_overwrite: true to overwrite",
|
|
);
|
|
}
|
|
|
|
// 4. Write the patch.
|
|
const updated = (await pb
|
|
.collection(STOCK_PURCHASE_ITEMS)
|
|
.update(input.id, input.patch as Record<string, unknown>)) as StockPurchaseItem;
|
|
|
|
return { item: updated, previous };
|
|
}
|
|
|
|
// ─── Bulk update ────────────────────────────────────────────────────
|
|
|
|
export type BulkUpdateStockPurchaseItemRow = {
|
|
id: string;
|
|
patch: UpdateStockPurchaseItemInput["patch"];
|
|
force_overwrite?: boolean;
|
|
};
|
|
|
|
export type BulkUpdateStockPurchaseItemsInput = {
|
|
items: BulkUpdateStockPurchaseItemRow[];
|
|
dry_run?: boolean;
|
|
};
|
|
|
|
export type BulkUpdateStockPurchaseItemResultRow = {
|
|
id: string;
|
|
status: "ok" | "skipped" | "dry_run" | "error";
|
|
reason?: string;
|
|
message?: string;
|
|
previous?: Partial<StockPurchaseItem>;
|
|
item?: StockPurchaseItem;
|
|
};
|
|
|
|
export type BulkUpdateStockPurchaseItemsResult = {
|
|
updated: number;
|
|
skipped: number;
|
|
errors: Array<{ row: number; id: string; reason: string; message: string }>;
|
|
results: BulkUpdateStockPurchaseItemResultRow[];
|
|
};
|
|
|
|
const BULK_UPDATE_MAX_ROWS = 100;
|
|
|
|
/**
|
|
* Batch patch stock_purchase_items. Mirrors the
|
|
* `bulk_update_quote_items` shape exactly:
|
|
*
|
|
* { updated, skipped, errors, results: [...] }
|
|
*
|
|
* Per-row independence — one row's failure never affects another.
|
|
* Cap is 100 rows per call (request larger batches by paging).
|
|
* `dry_run: true` returns the same shape with `status: "dry_run"`
|
|
* per row and writes nothing.
|
|
*/
|
|
export async function bulkUpdateStockPurchaseItems(
|
|
input: BulkUpdateStockPurchaseItemsInput,
|
|
): Promise<BulkUpdateStockPurchaseItemsResult> {
|
|
const pb = await getAdminPocketBase();
|
|
return bulkUpdateStockPurchaseItemsWithPb(pb, input);
|
|
}
|
|
|
|
export async function bulkUpdateStockPurchaseItemsWithPb(
|
|
pb: StockPurchaseItemsPb,
|
|
input: BulkUpdateStockPurchaseItemsInput,
|
|
): Promise<BulkUpdateStockPurchaseItemsResult> {
|
|
const items = Array.isArray(input.items) ? input.items : [];
|
|
if (items.length === 0) {
|
|
throw new Error("items array must contain at least one row");
|
|
}
|
|
if (items.length > BULK_UPDATE_MAX_ROWS) {
|
|
throw new Error(
|
|
`bulk update capped at ${BULK_UPDATE_MAX_ROWS} rows per call (got ${items.length})`,
|
|
);
|
|
}
|
|
|
|
const results: BulkUpdateStockPurchaseItemResultRow[] = [];
|
|
const errors: BulkUpdateStockPurchaseItemsResult["errors"] = [];
|
|
let updated = 0;
|
|
let skipped = 0;
|
|
|
|
for (let i = 0; i < items.length; i++) {
|
|
const row = items[i];
|
|
try {
|
|
if (!row?.id) {
|
|
throw new Error("id is required on every row");
|
|
}
|
|
if (!row.patch || typeof row.patch !== "object") {
|
|
throw new Error("patch object is required on every row");
|
|
}
|
|
|
|
// Whitelist check (per row, before any side effect).
|
|
for (const key of Object.keys(row.patch)) {
|
|
if (
|
|
!STOCK_PURCHASE_ITEM_PATCHABLE_FIELDS.includes(
|
|
key as (typeof STOCK_PURCHASE_ITEM_PATCHABLE_FIELDS)[number],
|
|
)
|
|
) {
|
|
throw new Error(`forbidden_field: ${key}`);
|
|
}
|
|
}
|
|
|
|
if (input.dry_run === true) {
|
|
results.push({ id: row.id, status: "dry_run" });
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
|
|
const result = await updateStockPurchaseItemWithPb(pb, {
|
|
id: row.id,
|
|
patch: row.patch,
|
|
force_overwrite: row.force_overwrite === true,
|
|
});
|
|
results.push({
|
|
id: row.id,
|
|
status: "ok",
|
|
previous: result.previous,
|
|
item: result.item,
|
|
});
|
|
updated += 1;
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
const reason = message.split(":")[0]?.trim() || "error";
|
|
results.push({
|
|
id: row?.id ?? "",
|
|
status: "error",
|
|
reason,
|
|
message,
|
|
});
|
|
errors.push({
|
|
row: i,
|
|
id: row?.id ?? "",
|
|
reason,
|
|
message,
|
|
});
|
|
}
|
|
}
|
|
|
|
return { updated, skipped, errors, results };
|
|
}
|
|
|
|
// ─── Driver assignment + collected ──────────────────────────────────
|
|
|
|
export type AssignDriverInput = {
|
|
id: string;
|
|
drv_id: string;
|
|
assigned_at?: string;
|
|
};
|
|
|
|
export type AssignDriverResult = { item: StockPurchaseItem };
|
|
|
|
/**
|
|
* Bind a stock_purchase_item to a driver. Transitions the line
|
|
* status `null → assigned_to_driver` or
|
|
* `collection_pool → assigned_to_driver`. Anything else
|
|
* (`assigned_to_driver`, `collected`, `booked`) returns
|
|
* `wrong_status` — the line is already past the assignment step.
|
|
*/
|
|
export async function assignDriver(
|
|
input: AssignDriverInput,
|
|
): Promise<AssignDriverResult> {
|
|
const pb = await getAdminPocketBase();
|
|
return assignDriverWithPb(pb, input);
|
|
}
|
|
|
|
export async function assignDriverWithPb(
|
|
pb: StockPurchaseItemsPb,
|
|
input: AssignDriverInput,
|
|
): Promise<AssignDriverResult> {
|
|
if (!input.id) throw new Error("id is required");
|
|
if (!input.drv_id) throw new Error("drv_id is required");
|
|
|
|
const before = (await pb
|
|
.collection(STOCK_PURCHASE_ITEMS)
|
|
.getOne(input.id)) as StockPurchaseItem;
|
|
|
|
const status = before.status ?? null;
|
|
if (status !== null && status !== "collection_pool") {
|
|
throw new Error(
|
|
`wrong_status: cannot assign driver to a line in status "${status}" (expected null or collection_pool)`,
|
|
);
|
|
}
|
|
|
|
const patch: Record<string, unknown> = {
|
|
status: "assigned_to_driver",
|
|
drv_id: input.drv_id,
|
|
assigned_at: input.assigned_at ?? new Date().toISOString(),
|
|
};
|
|
|
|
const updated = (await pb
|
|
.collection(STOCK_PURCHASE_ITEMS)
|
|
.update(input.id, patch)) as StockPurchaseItem;
|
|
|
|
return { item: updated };
|
|
}
|
|
|
|
export type UpdateStockPurchaseItemQtyInput = {
|
|
id: string;
|
|
/**
|
|
* New value for `quantity_received`. Must be a non-negative
|
|
* integer. No upper bound — a row can be legitimately
|
|
* over-collected (supplier shipped more than PO'd) so we don't
|
|
* clamp at `quantity`.
|
|
*/
|
|
quantity_received: number;
|
|
actor: { id: string; name: string };
|
|
};
|
|
|
|
export type UpdateStockPurchaseItemQtyResult = {
|
|
item: StockPurchaseItem;
|
|
previous_quantity_received: number;
|
|
};
|
|
|
|
/**
|
|
* Edit `quantity_received` on a single collected stock_purchase_item
|
|
* from the collection-confirm page's Qty pencil.
|
|
*
|
|
* The pencil is wired when the operator notices the warehouse-side
|
|
* count disagrees with what the driver originally reported (e.g.
|
|
* re-counted at intake, found a damaged unit, etc.). The change is
|
|
* stamped onto the line's `notes` feed as a `quantity`-category
|
|
* entry so the audit trail records the before/after values.
|
|
*
|
|
* Validation: non-negative integer (PB stores quantity_received as
|
|
* a number; fractions are rejected because the UI's pencil is
|
|
* integer-only and downstream invoices expect whole units). No
|
|
* upper bound — see input type.
|
|
*
|
|
* No-op when the new value equals the existing one (route returns
|
|
* 200 without writing a new audit entry). Mirrors
|
|
* `updateOrderItemQty` in the order-items service.
|
|
*/
|
|
export async function updateStockPurchaseItemQty(
|
|
input: UpdateStockPurchaseItemQtyInput,
|
|
): Promise<UpdateStockPurchaseItemQtyResult> {
|
|
if (
|
|
typeof input.quantity_received !== "number" ||
|
|
!Number.isFinite(input.quantity_received)
|
|
) {
|
|
throw new Error("quantity_received must be a finite number");
|
|
}
|
|
if (input.quantity_received < 0) {
|
|
throw new Error("quantity_received must be a non-negative number");
|
|
}
|
|
if (!Number.isInteger(input.quantity_received)) {
|
|
throw new Error("quantity_received must be a whole number");
|
|
}
|
|
const pb = await getAdminPocketBase();
|
|
const before = (await pb
|
|
.collection(STOCK_PURCHASE_ITEMS)
|
|
.getOne(input.id)) as StockPurchaseItem;
|
|
const previous_quantity_received = Number(
|
|
before.quantity_received ?? 0,
|
|
);
|
|
if (previous_quantity_received === input.quantity_received) {
|
|
return { item: before, previous_quantity_received };
|
|
}
|
|
const existingNotes = (before as { notes?: string | null }).notes ?? "";
|
|
const qtyNote = {
|
|
id: `qty-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
ts: new Date().toISOString(),
|
|
actorId: input.actor.id,
|
|
actorName: input.actor.name,
|
|
category: "quantity" as const,
|
|
body: {
|
|
qtyChange: {
|
|
prev: previous_quantity_received,
|
|
next: input.quantity_received,
|
|
},
|
|
},
|
|
};
|
|
const updated = (await pb.collection(STOCK_PURCHASE_ITEMS).update(input.id, {
|
|
quantity_received: input.quantity_received,
|
|
notes: JSON.stringify([
|
|
...parseStoredNotesForQty(existingNotes),
|
|
qtyNote,
|
|
]),
|
|
})) as StockPurchaseItem;
|
|
return { item: updated, previous_quantity_received };
|
|
}
|
|
|
|
function parseStoredNotesForQty(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 [];
|
|
}
|
|
}
|
|
|
|
export type MarkLineCollectedInput = {
|
|
id: string;
|
|
/**
|
|
* Defaults to `quantity - quantity_received` (remaining qty).
|
|
* Must be ≥ 0 and ≤ remaining.
|
|
*/
|
|
quantity_collected?: number;
|
|
};
|
|
|
|
export type MarkLineCollectedResult = {
|
|
item: StockPurchaseItem;
|
|
previous_quantity_received: number;
|
|
new_quantity_received: number;
|
|
/**
|
|
* The PO header status, after the auto-flip check. Either the
|
|
* value the header had before the call, or `"received"` if this
|
|
* call was the one that completed the PO. `null` when the line's
|
|
* PO couldn't be located (e.g. orphaned line).
|
|
*/
|
|
header_status_after: string | null;
|
|
/**
|
|
* `true` when this call was the one that flipped the header
|
|
* from `"sent"` to `"received"`. `false` otherwise (no flip —
|
|
* either other lines still pending, or header already received).
|
|
*/
|
|
header_flipped: boolean;
|
|
};
|
|
|
|
/**
|
|
* Mark a stock_purchase_item as physically delivered.
|
|
*
|
|
* Transitions the line status to `collected` and bumps
|
|
* `quantity_received`. Does NOT book into stock — that requires
|
|
* the accountant's `bookStockFromInvoice` flow.
|
|
*
|
|
* ## Header auto-flip
|
|
*
|
|
* After the line update, the service loads every line on the
|
|
* parent PO. If every line has `quantity_received >= quantity`,
|
|
* the parent PO's header is flipped from `"sent"` to `"received"`.
|
|
* This is the workflow Hermes drives: it processes supplier
|
|
* invoices one at a time, marking lines collected as each invoice
|
|
* arrives. The header flip happens automatically on the call that
|
|
* completes the PO — no separate `close_po` step.
|
|
*
|
|
* The flip is conditional:
|
|
* - header must currently be `"sent"` (idempotent — a second call
|
|
* after the header is already `"received"` is a no-op);
|
|
* - the line quantity check is inclusive (`>= quantity`), so a line
|
|
* partially marked collected across multiple invoices still
|
|
* counts toward the header-flip completion.
|
|
*
|
|
* Accepts the line from either `assigned_to_driver` (the normal
|
|
* driver pickup path) or `collection_pool` (the delivery sentinel
|
|
* auto-collect path lets the agent skip the assignment step).
|
|
*/
|
|
export async function markLineCollected(
|
|
input: MarkLineCollectedInput,
|
|
): Promise<MarkLineCollectedResult> {
|
|
const pb = await getAdminPocketBase();
|
|
return markLineCollectedWithPb(pb, input);
|
|
}
|
|
|
|
export async function markLineCollectedWithPb(
|
|
pb: StockPurchaseItemsPb,
|
|
input: MarkLineCollectedInput,
|
|
): Promise<MarkLineCollectedResult> {
|
|
if (!input.id) throw new Error("id is required");
|
|
|
|
const before = (await pb
|
|
.collection(STOCK_PURCHASE_ITEMS)
|
|
.getOne(input.id)) as StockPurchaseItem;
|
|
|
|
const status = before.status ?? null;
|
|
if (status !== "assigned_to_driver" && status !== "collection_pool") {
|
|
throw new Error(
|
|
`wrong_status: cannot mark collected from status "${status}" (expected assigned_to_driver or collection_pool)`,
|
|
);
|
|
}
|
|
|
|
const previous_quantity_received = Number(before.quantity_received ?? 0);
|
|
const quantity = Number(before.quantity ?? 0);
|
|
const remaining = Math.max(0, quantity - previous_quantity_received);
|
|
const quantity_collected =
|
|
input.quantity_collected === undefined
|
|
? remaining
|
|
: input.quantity_collected;
|
|
|
|
if (quantity_collected < 0) {
|
|
throw new Error(
|
|
`quantity_collected must be >= 0 (got ${quantity_collected})`,
|
|
);
|
|
}
|
|
if (quantity_collected > remaining) {
|
|
throw new Error(
|
|
`quantity_collected ${quantity_collected} exceeds remaining ${remaining}`,
|
|
);
|
|
}
|
|
|
|
const new_quantity_received = previous_quantity_received + quantity_collected;
|
|
|
|
// Note: `drv_collected_at` (the moment the driver handed the goods
|
|
// to the warehouse) does NOT exist on the `stock_purchase_items`
|
|
// PB collection — only `drv_id` + `assigned_at`. We track the
|
|
// collection moment via the line's `status` flip to `collected`
|
|
// and PB's `updated` timestamp. Adding a dedicated date field is
|
|
// a PB-admin task (FEATURE-REQUESTS gap #drv_collected_at_field).
|
|
const patch: Record<string, unknown> = {
|
|
status: "collected",
|
|
quantity_received: new_quantity_received,
|
|
};
|
|
|
|
const updated = (await pb
|
|
.collection(STOCK_PURCHASE_ITEMS)
|
|
.update(input.id, patch)) as StockPurchaseItem;
|
|
|
|
// Auto-flip: if every line on this PO is now fully collected,
|
|
// flip the parent header from "sent" → "received". Mirrors the
|
|
// user's "if we have all the invoices for that PO its done
|
|
// complete flipped" workflow — Hermes processes invoices one
|
|
// at a time, and the header closes itself when the last line
|
|
// lands.
|
|
const purchaseId = (updated as StockPurchaseItem).purchase ?? before.purchase;
|
|
let header_status_after: string | null = null;
|
|
let header_flipped = false;
|
|
|
|
if (purchaseId) {
|
|
const allLines = (await pb.collection(STOCK_PURCHASE_ITEMS).getFullList({
|
|
filter: `purchase = "${purchaseId}"`,
|
|
})) as StockPurchaseItem[];
|
|
|
|
const allDone =
|
|
allLines.length > 0 &&
|
|
allLines.every(
|
|
(l) =>
|
|
(l.status === "collected" || l.status === "booked") &&
|
|
Number(l.quantity_received ?? 0) >= Number(l.quantity ?? 0),
|
|
);
|
|
|
|
if (allDone) {
|
|
try {
|
|
const header = (await pb
|
|
.collection(STOCK_PURCHASES)
|
|
.getOne(purchaseId)) as { status?: string };
|
|
header_status_after = header.status ?? null;
|
|
if (header_status_after === "sent") {
|
|
await pb.collection(STOCK_PURCHASES).update(purchaseId, {
|
|
status: "received",
|
|
});
|
|
header_status_after = "received";
|
|
header_flipped = true;
|
|
}
|
|
} catch {
|
|
// Orphan line — the PO header isn't readable. The line
|
|
// update already succeeded; the auto-flip just can't run.
|
|
// Surface as null so the caller knows the check was
|
|
// inconclusive rather than silently succeeding.
|
|
header_status_after = null;
|
|
header_flipped = false;
|
|
}
|
|
} else {
|
|
// Header status is informational even when we don't flip.
|
|
try {
|
|
const header = (await pb
|
|
.collection(STOCK_PURCHASES)
|
|
.getOne(purchaseId)) as { status?: string };
|
|
header_status_after = header.status ?? null;
|
|
} catch {
|
|
header_status_after = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
item: updated,
|
|
previous_quantity_received,
|
|
new_quantity_received,
|
|
header_status_after,
|
|
header_flipped,
|
|
};
|
|
} |