- Updated the `scoreQuote` function to incorporate price scoring, allowing quotes to be evaluated based on selling and cost prices when the query is numeric. - Introduced new weights for item selling and cost prices, ensuring they contribute to the overall score only when relevant. - Enhanced the test suite to cover new price matching scenarios, ensuring accurate scoring behavior for quotes based on price inputs. - Refactored related functions to support the new price scoring logic, maintaining consistency across the scoring process.
609 lines
25 KiB
TypeScript
609 lines
25 KiB
TypeScript
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
|
import { generatePONumber } from "@/lib/server/po-number";
|
|
import {
|
|
generatePurchaseOrderPdf,
|
|
PurchaseOrderPdfValidationError,
|
|
} from "@/lib/server/purchase-orders-service";
|
|
import type { PurchaseOrderEmailPayload } from "@/lib/types";
|
|
|
|
export type BuyerTaskTab = "needs_po" | "po_draft" | "po_sent";
|
|
|
|
/**
|
|
* Which collection the group came from. `customer` groups are
|
|
* derived from `order_items` (customer-PO flow); `stock` groups are
|
|
* derived from `stock_purchase_items` (supplier-pickup flow). The
|
|
* marker drives card styling, the mark-ready write target, and the
|
|
* `groupId` namespace so the two never collide on the same supplier.
|
|
*/
|
|
export type BuyerTaskGroupSource = "customer" | "stock";
|
|
|
|
export type BuyerTaskGroup = {
|
|
/** Encoded as `${source}::${headerId}::${supplierId}` so a stock
|
|
* group and a customer group for the same supplier are distinct. */
|
|
groupId: string;
|
|
/**
|
|
* Identifier of the parent record (customer order for
|
|
* `source="customer"`, stock purchase for `source="stock"`).
|
|
* Kept as `orderId` for back-compat with existing components.
|
|
*/
|
|
orderId: string;
|
|
orderNumber: string;
|
|
supplierId: string;
|
|
supplierName: string;
|
|
customerName: string;
|
|
companyName: string;
|
|
items: Array<{
|
|
id: string;
|
|
description: string;
|
|
quantity: number;
|
|
costPrice: number;
|
|
sellingPrice: number;
|
|
}>;
|
|
itemCount: number;
|
|
totalCost: number;
|
|
purchaseOrderId: string | null;
|
|
poStatus: "draft" | "sent" | "sending" | "received" | "mail_failed" | "cancelled" | null;
|
|
poNumber: string | null;
|
|
supplierEmail: string | null;
|
|
/** Last send-pipeline error. Null on a clean draft or after a successful send.
|
|
* Pulled from `purchase_orders.email_error` and truncated to 200 chars at
|
|
* the service boundary so a runaway SMTP error doesn't blow up the card
|
|
* layout. The full reason remains visible in the order page's send-modal
|
|
* banner. */
|
|
emailError: string | null;
|
|
/** Last composed outbound email, used to prefill the resend modal. */
|
|
emailPayload: PurchaseOrderEmailPayload | null;
|
|
/** File tokens for additional supplier attachments. */
|
|
additionalAttachments: string[];
|
|
step: BuyerTaskTab;
|
|
source: BuyerTaskGroupSource;
|
|
};
|
|
|
|
export type BuyerTaskMarkReadyItem = {
|
|
id: string;
|
|
source: BuyerTaskGroupSource;
|
|
};
|
|
|
|
/**
|
|
* List buyer task groups for the given tab.
|
|
*
|
|
* Filter strategy: PB filters can't traverse relations, so we fetch
|
|
* candidate items with the broadest filter we can build from the
|
|
* item_status / purchase_order fields, then post-filter by PO
|
|
* status (which lives on the related purchase_orders record).
|
|
*
|
|
* Free-text search: when `search` is non-empty, the groups are
|
|
* post-filtered against the operator-facing fields (order number,
|
|
* PO number, supplier, customer, company, item description, plus
|
|
* numeric price matches when the query parses as a number). Search
|
|
* runs after grouping so a single match anywhere in the group keeps
|
|
* the whole card visible — matching the operator's "I remember the
|
|
* order number" mental model. Empty / whitespace-only input is a
|
|
* no-op.
|
|
*/
|
|
export async function listBuyerTasks(
|
|
tab: BuyerTaskTab,
|
|
search: string = "",
|
|
): Promise<BuyerTaskGroup[]> {
|
|
const admin = await getAdminPocketBase();
|
|
|
|
// Item-level filter narrows the candidates. PO-status filtering
|
|
// happens post-fetch because the field lives on a related record.
|
|
// The item_status filter is tightened per tab to cut down the wire
|
|
// payload: PB filters can't traverse relations, so we can't ask for
|
|
// "items whose PO is draft" directly. Instead we ask for "items in
|
|
// the item_status states that imply a draft PO" (po_generated) for
|
|
// the po_draft tab, and the broader set of in-flight statuses for
|
|
// the po_sent tab.
|
|
const itemFilter = tab === "needs_po"
|
|
? `item_status = "processed" && purchase_order = ""`
|
|
: tab === "po_draft"
|
|
? `item_status = "po_generated" && purchase_order != ""`
|
|
// PO Sent: PO emailed, awaiting supplier. Every status outside
|
|
// `po_generated` represents a state the buyer no longer owns:
|
|
// • supplier confirmed ready → ready_delivery
|
|
// • marked ready for collection → collection_pool
|
|
// • driver assigned → assigned
|
|
// • driver picked up → collected
|
|
// • delivery-note issued → delivery_note
|
|
// • awaiting invoice / invoiced / delivered / cancelled → terminal
|
|
//
|
|
// This is a negative list by design (PB can't express "all
|
|
// statuses except these" positively), so it MUST be kept in
|
|
// lockstep with the ItemStatus union. The regression test in
|
|
// `buyer-tasks-service.test.ts` walks every union member and
|
|
// asserts the post-PB exclusion list covers it.
|
|
: `purchase_order != "" && item_status != "ready_delivery" && item_status != "collection_pool" && item_status != "assigned" && item_status != "collected" && item_status != "delivery_note" && item_status != "ready_invoice" && item_status != "invoiced" && item_status != "stock_booked" && item_status != "with_driver" && item_status != "checked_in" && item_status != "completed" && item_status != "delivered" && item_status != "cancelled" && item_status != "removed"`;
|
|
|
|
const items = await admin.collection("order_items").getFullList({
|
|
filter: itemFilter,
|
|
expand: "order,order.customer,order.customer.company,order.company,supplier,purchase_order",
|
|
});
|
|
|
|
// ─── Stock-purchase leg (only on the po_sent tab) ──────────────
|
|
// Stock POs don't go through the customer-order flow, so they
|
|
// get their own fetch. We only surface them under "PO Sent" —
|
|
// they don't have an unsent / draft-equivalent state in the
|
|
// buyer-tasks framing (operator drafts stock POs directly from
|
|
// /stock-orders/new, not from buyer-tasks).
|
|
//
|
|
// The stock-item filter excludes downstream statuses (collection_pool,
|
|
// assigned_to_driver, collected, booked) so once a supplier has
|
|
// confirmed pickup, the line leaves the buyer's po_sent tab. The
|
|
// mirror of the customer-order item_status exclusions a few lines
|
|
// up — without it, a fully-marked PO like PO26073109STK stays
|
|
// pinned on the list (operator reported this card "stuck" — the
|
|
// bug). Also: a stock PO with zero remaining items is dropped
|
|
// below so the parent doesn't surface an empty card.
|
|
const STOCK_ITEM_DOWNSTREAM = [
|
|
"collection_pool",
|
|
"assigned_to_driver",
|
|
"collected",
|
|
"booked",
|
|
];
|
|
let stockPurchases: Array<Record<string, unknown>> = [];
|
|
const stockItemMap = new Map<string, Array<{ id: string; description: string; quantity: number; cost_price: number }>>();
|
|
if (tab === "po_sent") {
|
|
stockPurchases = await admin.collection("stock_purchases").getFullList({
|
|
filter: `status = "sent" || status = "sending"`,
|
|
expand: "supplier",
|
|
});
|
|
if (stockPurchases.length > 0) {
|
|
const parentFilter = stockPurchases.map((sp) => `purchase = "${sp.id}"`).join(" || ");
|
|
const statusExclusions = STOCK_ITEM_DOWNSTREAM.map((s) => `status != "${s}"`).join(" && ");
|
|
const stockItems = await admin.collection("stock_purchase_items").getFullList({
|
|
filter: `(${parentFilter}) && ${statusExclusions}`,
|
|
});
|
|
// Defence in depth: filter in JS too. The PB filter cuts the
|
|
// wire payload for real backends, but mocks and backends with
|
|
// listRule quirks don't always honour `fields=` / filter
|
|
// combinations the same way — the empty-group skip below is
|
|
// gated on `stockItems.length === 0`, so any item we leave in
|
|
// here still gets surfaced in the group.
|
|
const downstream = new Set<string>(STOCK_ITEM_DOWNSTREAM);
|
|
for (const si of stockItems) {
|
|
const item = si as unknown as { id: string; purchase: string; description: string; quantity: number; cost_price: number; status?: string };
|
|
if (item.status && downstream.has(item.status)) continue;
|
|
const list = stockItemMap.get(item.purchase) ?? [];
|
|
list.push({
|
|
id: item.id,
|
|
description: item.description ?? "",
|
|
quantity: Number(item.quantity) || 0,
|
|
cost_price: Number(item.cost_price) || 0,
|
|
});
|
|
stockItemMap.set(item.purchase, list);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Group by (orderId, supplierId); skip items with no supplier.
|
|
const groups = new Map<string, BuyerTaskGroup>();
|
|
for (const item of items) {
|
|
const orderId = item.order;
|
|
const supplierId = item.supplier;
|
|
if (!orderId || !supplierId) continue;
|
|
|
|
const po = item.expand?.purchase_order as
|
|
| {
|
|
id: string;
|
|
status: BuyerTaskGroup["poStatus"];
|
|
po_number?: string;
|
|
email_error?: string;
|
|
email_payload?: PurchaseOrderEmailPayload;
|
|
additional_attachments?: string[] | string;
|
|
expand?: { supplier?: { email?: string } };
|
|
}
|
|
| undefined;
|
|
|
|
// Post-filter by PO status (tab-specific). The PB filter can't
|
|
// traverse to the purchase_orders relation, so we still need to
|
|
// slice by PO status here even though the item_status filter
|
|
// already narrowed the candidate set on the wire.
|
|
if (tab === "po_draft" && po?.status !== "draft" && po?.status !== "mail_failed") continue;
|
|
if (tab === "po_sent" && !["sent", "sending"].includes(po?.status ?? "")) continue;
|
|
|
|
const groupId = `customer::${orderId}::${supplierId}`;
|
|
let group = groups.get(groupId);
|
|
if (!group) {
|
|
const order = item.expand?.order as { order_number?: string } | undefined;
|
|
const supplier = item.expand?.supplier as { suppliername?: string } | undefined;
|
|
const customer = (item.expand?.order as { expand?: { customer?: { customer?: string; expand?: { company?: { company?: string } } } } })
|
|
?.expand?.customer;
|
|
const company = customer?.expand?.company
|
|
?? (item.expand?.order as { expand?: { company?: { company?: string } } })?.expand?.company;
|
|
const rawAdditionalAttachments = po?.additional_attachments;
|
|
const additionalAttachments = Array.isArray(rawAdditionalAttachments)
|
|
? rawAdditionalAttachments
|
|
: rawAdditionalAttachments
|
|
? [rawAdditionalAttachments]
|
|
: [];
|
|
|
|
group = {
|
|
groupId,
|
|
orderId,
|
|
orderNumber: order?.order_number ?? "",
|
|
supplierId,
|
|
supplierName: supplier?.suppliername ?? "",
|
|
customerName: customer?.customer ?? "",
|
|
companyName: company?.company ?? "",
|
|
items: [],
|
|
itemCount: 0,
|
|
totalCost: 0,
|
|
purchaseOrderId: po?.id ?? null,
|
|
poStatus: po?.status ?? null,
|
|
poNumber: po && typeof po === "object" && "po_number" in po ? String((po as { po_number?: string }).po_number ?? "") || null : null,
|
|
supplierEmail: (po as { expand?: { supplier?: { email?: string } } } | undefined)?.expand?.supplier?.email ?? null,
|
|
// Truncate the SMTP error to 200 chars at the service boundary so
|
|
// a runaway error string can't blow up the card layout. The full
|
|
// reason is still visible in the order page's send-modal banner.
|
|
emailError:
|
|
(po as { email_error?: string } | undefined)?.email_error
|
|
? String((po as { email_error?: string }).email_error).slice(0, 200)
|
|
: null,
|
|
emailPayload: po?.email_payload ?? null,
|
|
additionalAttachments,
|
|
step: tab,
|
|
source: "customer",
|
|
};
|
|
groups.set(groupId, group);
|
|
}
|
|
|
|
const quantity = Number(item.quantity) || 0;
|
|
const cost = Number(item.cost_price) || 0;
|
|
group.items.push({
|
|
id: item.id,
|
|
description: item.description ?? "",
|
|
quantity,
|
|
costPrice: cost,
|
|
sellingPrice: Number(item.selling_price) || 0,
|
|
});
|
|
group.itemCount += 1;
|
|
group.totalCost += quantity * cost;
|
|
}
|
|
|
|
// ─── Fold stock-purchase groups into the same map ─────────────
|
|
// Each stock PO becomes its own group (one PO = one supplier, so
|
|
// a single group is enough). Customer/company labels are empty
|
|
// for stock — they're supplier-pickup flows, not customer
|
|
// deliveries. Pre-existing groups are untouched because the
|
|
// `customer::` namespace prefix on customer groupIds prevents
|
|
// collision.
|
|
for (const sp of stockPurchases) {
|
|
const stock = sp as {
|
|
id: string;
|
|
purchase_number?: string;
|
|
supplier: string;
|
|
status: "draft" | "sent" | "sending" | "received" | "mail_failed" | "cancelled";
|
|
email_error?: string;
|
|
email_payload?: PurchaseOrderEmailPayload;
|
|
additional_attachments?: string[] | string;
|
|
expand?: { supplier?: { suppliername?: string; email?: string } };
|
|
};
|
|
const supplierId = stock.supplier;
|
|
if (!supplierId) continue;
|
|
const supplier = stock.expand?.supplier;
|
|
const stockItems = stockItemMap.get(stock.id) ?? [];
|
|
// Skip stock POs whose lines have all moved downstream (every
|
|
// item is collection_pool/assigned_to_driver/collected/booked).
|
|
// The parent record still has status="sent", but the buyer has
|
|
// nothing left to do — surfacing an empty card would be the
|
|
// "stuck PO" symptom.
|
|
if (stockItems.length === 0) continue;
|
|
const groupId = `stock::${stock.id}::${supplierId}`;
|
|
const totalCost = stockItems.reduce(
|
|
(sum, it) => sum + it.quantity * it.cost_price,
|
|
0,
|
|
);
|
|
const rawAdditionalAttachments = stock.additional_attachments;
|
|
const additionalAttachments = Array.isArray(rawAdditionalAttachments)
|
|
? rawAdditionalAttachments
|
|
: rawAdditionalAttachments
|
|
? [rawAdditionalAttachments]
|
|
: [];
|
|
groups.set(groupId, {
|
|
groupId,
|
|
orderId: stock.id,
|
|
orderNumber: stock.purchase_number ?? "",
|
|
supplierId,
|
|
supplierName: supplier?.suppliername ?? "",
|
|
customerName: "",
|
|
companyName: "",
|
|
items: stockItems.map((it) => ({
|
|
id: it.id,
|
|
description: it.description,
|
|
quantity: it.quantity,
|
|
costPrice: it.cost_price,
|
|
sellingPrice: 0,
|
|
})),
|
|
itemCount: stockItems.length,
|
|
totalCost,
|
|
purchaseOrderId: stock.id,
|
|
poStatus: stock.status,
|
|
poNumber: stock.purchase_number ?? null,
|
|
supplierEmail: supplier?.email ?? null,
|
|
emailError: stock.email_error ? stock.email_error.slice(0, 200) : null,
|
|
emailPayload: stock.email_payload ?? null,
|
|
additionalAttachments,
|
|
step: "po_sent",
|
|
source: "stock",
|
|
});
|
|
}
|
|
|
|
// ─── Apply free-text search (operator-facing fields) ──────────
|
|
// Single regex compile per call — needle is small so we don't
|
|
// bother memoising. `toLowerCase()` once per group per field,
|
|
// not per call.
|
|
if (search.trim()) {
|
|
const trimmedSearch = search.trim();
|
|
const needle = trimmedSearch.toLowerCase();
|
|
// Numeric needles match against per-line cost/selling prices,
|
|
// line totals (price * quantity), and the group's aggregate
|
|
// totalCost. We use a small epsilon for float comparison so
|
|
// "25.5" matches `cost_price: 25.50` reliably.
|
|
const numericNeedle = parseNumericNeedle(needle);
|
|
const candidates = Array.from(groups.values());
|
|
const filtered = candidates.filter((g) =>
|
|
matchesGroupSearch(g, needle, numericNeedle)
|
|
);
|
|
return sortGroups(filtered);
|
|
}
|
|
|
|
return Array.from(groups.values()).sort((a, b) => {
|
|
// Sort by supplier first, then order number. The buyer works
|
|
// one supplier at a time (phone, pricing, mark ready, next),
|
|
// so clustering every order under the same supplier together
|
|
// makes the list easier to scan than alphabetical-by-order.
|
|
if (a.supplierName !== b.supplierName) return a.supplierName.localeCompare(b.supplierName);
|
|
return a.orderNumber.localeCompare(b.orderNumber);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Extract a numeric needle from the search string, or `null` if
|
|
* the query isn't a clean number (decimals, leading `-`, no
|
|
* thousand-separators). Anything that isn't parseable falls back
|
|
* to text-only matching — "12.22" matches, "12.2abc" doesn't.
|
|
*/
|
|
function parseNumericNeedle(needle: string): number | null {
|
|
if (!/^-?\d+(\.\d+)?$/.test(needle)) return null;
|
|
const n = Number(needle);
|
|
return Number.isFinite(n) ? n : null;
|
|
}
|
|
|
|
/**
|
|
* Does this group match the active search needle?
|
|
*
|
|
* Header-level matches (order number, PO number, supplier, customer,
|
|
* company) are checked once per group. Item-level matches
|
|
* (description, prices) check every line — a single matching line
|
|
* keeps the group visible. Numeric needles additionally match
|
|
* against:
|
|
* - per-item cost_price and selling_price
|
|
* - per-item line total (cost * quantity, selling * quantity)
|
|
* - group.totalCost
|
|
*
|
|
* All comparisons use a 1-cent epsilon so floating-point rounding
|
|
* doesn't reject visually-identical numbers.
|
|
*/
|
|
function matchesGroupSearch(
|
|
group: BuyerTaskGroup,
|
|
needle: string,
|
|
numericNeedle: number | null,
|
|
): boolean {
|
|
// Header-level substring match — the common case.
|
|
if (group.orderNumber.toLowerCase().includes(needle)) return true;
|
|
if (group.poNumber && group.poNumber.toLowerCase().includes(needle)) return true;
|
|
if (group.supplierName.toLowerCase().includes(needle)) return true;
|
|
if (group.customerName.toLowerCase().includes(needle)) return true;
|
|
if (group.companyName.toLowerCase().includes(needle)) return true;
|
|
|
|
// Item-level matches — at least one line must hit.
|
|
for (const item of group.items) {
|
|
if (item.description.toLowerCase().includes(needle)) return true;
|
|
}
|
|
|
|
// Numeric price matches — opt-in only when the needle parses.
|
|
if (numericNeedle !== null) {
|
|
const PRICE_EPSILON = 0.01;
|
|
const close = (a: number) => Math.abs(a - numericNeedle) < PRICE_EPSILON;
|
|
if (close(group.totalCost)) return true;
|
|
for (const item of group.items) {
|
|
if (close(item.costPrice)) return true;
|
|
if (close(item.sellingPrice)) return true;
|
|
if (close(item.costPrice * item.quantity)) return true;
|
|
if (close(item.sellingPrice * item.quantity)) return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/** Sort groups by supplier then order number — the same comparator
|
|
* the unsorted branch uses, extracted so the search branch can
|
|
* call it without duplication. */
|
|
function sortGroups(groups: BuyerTaskGroup[]): BuyerTaskGroup[] {
|
|
return groups.sort((a, b) => {
|
|
if (a.supplierName !== b.supplierName) return a.supplierName.localeCompare(b.supplierName);
|
|
return a.orderNumber.localeCompare(b.orderNumber);
|
|
});
|
|
}
|
|
export interface GeneratePoForGroupResult {
|
|
poId: string;
|
|
/**
|
|
* PDF info when the v1 PDF was generated inline. `null` if the PDF
|
|
* step was skipped (no items) or failed — `pdfError` will carry the
|
|
* reason. Operators can still regenerate from the order page.
|
|
*/
|
|
pdf: {
|
|
version: number;
|
|
filename: string;
|
|
downloadUrl: string;
|
|
total: number;
|
|
} | null;
|
|
/** Human-readable reason the PDF step was skipped or failed. */
|
|
pdfError: string | null;
|
|
}
|
|
|
|
export async function generatePoForGroup(
|
|
orderId: string,
|
|
supplierId: string,
|
|
items: Array<{ id: string; costPrice: number; quantity: number }>
|
|
): Promise<GeneratePoForGroupResult | null> {
|
|
if (!orderId) throw new Error("orderId is required");
|
|
if (!supplierId) throw new Error("supplierId is required");
|
|
if (items.length === 0) return null;
|
|
|
|
const admin = await getAdminPocketBase();
|
|
// `purchase_orders.po_number` is required (PB schema enforces it),
|
|
// and `total_amount` / `order_item` are optional but useful: they
|
|
// back-link the items to the PO so the PB admin row shows the
|
|
// related lines without an extra getList call. Mirrors the
|
|
// canonical /orders/[id]/process create payload.
|
|
const poNumber = await generatePONumber(orderId);
|
|
const totalAmount = items.reduce(
|
|
(sum, it) => sum + it.costPrice * it.quantity,
|
|
0,
|
|
);
|
|
const po = await admin.collection("purchase_orders").create({
|
|
po_number: poNumber,
|
|
order: orderId,
|
|
supplier: supplierId,
|
|
status: "draft",
|
|
total_amount: totalAmount,
|
|
order_item: items.map((it) => it.id),
|
|
});
|
|
|
|
await Promise.all(
|
|
items.map((item) =>
|
|
admin.collection("order_items").update(item.id, {
|
|
purchase_order: po.id,
|
|
item_status: "po_generated",
|
|
})
|
|
)
|
|
);
|
|
|
|
// ─── Generate the v1 PDF inline ───────────────────────────
|
|
// Why here: the legacy behavior (before the v3 send-out refactor)
|
|
// was "Generate PO = create + PDF ready to send". Operators
|
|
// expected a file on the PO after clicking once. The refactor
|
|
// accidentally decoupled these two, so a freshly-generated PO
|
|
// had no file until the operator also clicked Send PO — and the
|
|
// SendPoModal's email pipeline has nothing to attach without
|
|
// one. Restoring the inline generation here keeps the original
|
|
// mental model: one click → one PO with one PDF. The status
|
|
// stays `draft` (PDF generation does NOT flip to `sent` — that's
|
|
// the explicit email-pipeline job), so Send PO still has work to
|
|
// do, but the file is already on `purchase_orders.documents`.
|
|
let pdf: GeneratePoForGroupResult["pdf"] = null;
|
|
let pdfError: string | null = null;
|
|
try {
|
|
const generated = await generatePurchaseOrderPdf(po.id);
|
|
pdf = {
|
|
version: generated.version,
|
|
filename: generated.filename,
|
|
downloadUrl: generated.downloadUrl,
|
|
total: generated.total,
|
|
};
|
|
} catch (err) {
|
|
// Don't roll back the PO — the operator can regenerate the
|
|
// PDF from the order page. Log + surface a soft error so the
|
|
// UI can show "PO created, PDF pending" instead of misleading
|
|
// success.
|
|
if (err instanceof PurchaseOrderPdfValidationError) {
|
|
pdfError = err.message;
|
|
console.warn(
|
|
`[generatePoForGroup] PO ${poNumber} created but PDF skipped:`,
|
|
err.code,
|
|
err.message,
|
|
);
|
|
} else {
|
|
pdfError =
|
|
err instanceof Error ? err.message : "PDF render failed";
|
|
console.error(
|
|
`[generatePoForGroup] PO ${poNumber} created but PDF render threw:`,
|
|
err,
|
|
);
|
|
}
|
|
}
|
|
|
|
return { poId: po.id, pdf, pdfError };
|
|
}
|
|
|
|
export async function markGroupReady(
|
|
orderId: string,
|
|
supplierId: string,
|
|
items: Array<BuyerTaskMarkReadyItem>,
|
|
note: string
|
|
): Promise<{ updated: number; failed: Array<{ id: string; error: string }> }> {
|
|
if (!orderId || !supplierId) {
|
|
throw new Error("orderId and supplierId are required");
|
|
}
|
|
const trimmedNote = note.trim();
|
|
if (!trimmedNote) {
|
|
throw new Error("A note is required when marking ready for collection");
|
|
}
|
|
|
|
const admin = await getAdminPocketBase();
|
|
const failed: Array<{ id: string; error: string }> = [];
|
|
let updated = 0;
|
|
|
|
for (const item of items) {
|
|
// `source` defaults to "customer" for backwards compatibility
|
|
// with the pre-stock-widening API surface. The mark-ready UI
|
|
// now passes it explicitly per item.
|
|
const source: BuyerTaskGroupSource = item.source ?? "customer";
|
|
try {
|
|
if (source === "stock") {
|
|
// Stock-purchase line: parent is a stock_purchase, not an
|
|
// order. The cross-parent check verifies the line belongs to
|
|
// the stock PO before we mutate it.
|
|
const current = await admin.collection("stock_purchase_items").getOne(item.id);
|
|
if (current.purchase !== orderId) {
|
|
failed.push({
|
|
id: item.id,
|
|
error: "Stock line does not belong to this stock purchase",
|
|
});
|
|
continue;
|
|
}
|
|
await admin.collection("stock_purchase_items").update(item.id, {
|
|
// Promotes the stock line straight into the collection
|
|
// pool so it shows up on /collection-pool, /collection-map
|
|
// and the driver stops feed. The driver-stops route
|
|
// filters on `status = "collection_pool" ||
|
|
// status = "assigned_to_driver"`, so a single write here
|
|
// is enough to unblock dispatch — same flow the customer
|
|
// MarkReadyDialog uses for `order_items`.
|
|
status: "collection_pool",
|
|
// Audit log of who the buyer spoke to / reference number.
|
|
// Mirrors the order_items.notes flow.
|
|
notes: trimmedNote,
|
|
});
|
|
} else {
|
|
// Customer-PO line: existing flow. Cross-order check then
|
|
// a single PB update that flips the item into the pool
|
|
// with the supplier's ready flag set.
|
|
const current = await admin.collection("order_items").getOne(item.id);
|
|
if (current.order !== orderId) {
|
|
failed.push({
|
|
id: item.id,
|
|
error: "Item does not belong to this order",
|
|
});
|
|
continue;
|
|
}
|
|
await admin.collection("order_items").update(item.id, {
|
|
item_status: "collection_pool",
|
|
ready_for_collection: true,
|
|
notes: trimmedNote,
|
|
});
|
|
}
|
|
updated += 1;
|
|
} catch (err) {
|
|
failed.push({
|
|
id: item.id,
|
|
error: err instanceof Error ? err.message : "Unknown error",
|
|
});
|
|
}
|
|
}
|
|
|
|
return { updated, failed };
|
|
}
|