WFDASH-WEBAPP/src/lib/server/buyer-tasks-service.ts
Dave 139a3f3bfe feat(quotes): enhance scoring logic to include price matching
- 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.
2026-08-24 07:09:39 +02:00

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 };
}