/** * order-item-context-service — deterministic SKU-based context for * the order-sprint process modal + orders detail page. * * Mirrors the shape of `item-history-service.ts` but is anchored on * `order_items.id` instead of `quote_items.id`. The four rows * surfaced (last quote, last order, last invoiced, stock) plus a * supplier/cheapest aggregate give the operator the historical * record before they pick a supplier and cost. * * Pure helpers (`findBestDescriptionMatch`, `aggregateSuppliers`, * `computeCheapest`, `daysAgo`, `isValidPrice`) are imported from * `item-history-service.ts` — DO NOT duplicate them. */ import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; import { findBestDescriptionMatch, aggregateSuppliers, computeCheapest, daysAgo, isValidPrice, } from "@/lib/server/item-history-service"; import type { ItemHistoryMatch, ItemHistorySupplier, ItemHistoryCheapest, } from "@/lib/types"; export interface OrderItemLastQuote { quote_item_id: string; quote_id: string; quote_number: string; customer_name: string; company: string; status: string; cost_price: number; selling_price: number; markup_percent: number; days_ago: number; } export interface OrderItemLastOrder { order_item_id: string; order_id: string; order_number: string; company: string; cost_price: number; selling_price: number; markup_percent: number; item_status: string; updated_at: string; days_ago: number; } export interface OrderItemLastInvoiced { order_item_id: string; order_id: string; order_number: string; supplier_invoice_number: string; customer_invoice_number: string; invoiced_date: string; cost_price: number; quantity: number; qty_invoiced: number; days_ago: number; } export interface OrderItemStock { stock_item_id: string; name: string; sku: string; current_qty: number; min_qty: number | null; selling_price: number | null; markup_percent: number | null; status: "in_stock" | "low" | "out"; } export interface OrderItemContext { generated_at: string; match: ItemHistoryMatch; last_quote: OrderItemLastQuote | null; last_order: OrderItemLastOrder | null; last_invoiced: OrderItemLastInvoiced | null; stock: OrderItemStock | null; suppliers: ItemHistorySupplier[]; cheapest: ItemHistoryCheapest | null; } export interface OrderItemContextResponse extends OrderItemContext { error?: string; } export async function getOrderItemContext( orderItemId: string, ): Promise { const pb = await getAdminPocketBase(); const generated_at = new Date().toISOString(); const response: OrderItemContextResponse = { generated_at, match: { sku: "", match_kind: "none", match_confidence: 0 }, last_quote: null, last_order: null, last_invoiced: null, stock: null, suppliers: [], cheapest: null, }; // 1. Load the target row. SKU + description drive matching. const target = await pb.collection("order_items").getOne<{ id: string; sku?: string; description?: string; quote_item?: string; }>(orderItemId, { fields: "id,sku,description,quote_item" }); response.match.sku = target.sku || ""; // Bail early when there's nothing to match on. if (!target.sku && !target.description) return response; // 2. Description-fuzzy candidates — only when SKU is empty. const fuzzyCandidates = !target.sku && target.description ? pb.collection("order_items").getList<{ id: string; sku: string; description: string; }>(1, 500, { filter: `id != "${orderItemId}" && description != "" && sku != ""`, sort: "-created", fields: "id,sku,description", }) : Promise.resolve({ items: [] as Array<{ id: string; sku: string; description: string }>, }); const fuzzyResolved = !target.sku && target.description ? (await fuzzyCandidates).items : []; const fm = !target.sku && target.description ? findBestDescriptionMatch( { id: orderItemId, sku: "", description: target.description }, fuzzyResolved, 0.6, ) : null; // 3. Resolve effective SKU + match metadata. const effectiveSku = target.sku || fm?.item.sku || ""; if (target.sku) { response.match.match_kind = "sku_exact"; response.match.match_confidence = 1; } else if (effectiveSku && fm) { response.match.match_kind = "description_fuzzy"; response.match.match_confidence = fm.confidence; response.match.sku = effectiveSku; } const sku = effectiveSku; const skuFilter = sku ? `sku = "${sku.replace(/"/g, '\\"')}"` : ""; // 4. Fan out 5 PB reads in parallel. const [quoteRows, orderRows, invoicedRows, stockRows, suppliersRows] = await Promise.all([ sku ? pb.collection("quote_items").getList<{ id: string; quote: string; cost_price: number; selling_price: number; created: string; expand?: { quote?: { quote_number: string; status: string; customer_name?: string; company?: string; }; }; }>(1, 5, { filter: `${skuFilter} && id != "${target.quote_item ?? ""}"`, sort: "-created", expand: "quote", }) : Promise.resolve({ items: [] as Array<{ id: string; quote: string; cost_price: number; selling_price: number; created: string; expand?: { quote?: { quote_number: string; status: string; customer_name?: string; company?: string; }; }; }>, }), sku ? pb.collection("order_items").getList<{ id: string; order: string; supplier: string; cost_price: number; selling_price: number; markup_percent?: number; item_status: string; updated: string; expand?: { order?: { order_number: string; company?: string } }; }>(1, 5, { filter: `${skuFilter} && id != "${orderItemId}"`, sort: "-updated", expand: "order", }) : Promise.resolve({ items: [] as Array<{ id: string; order: string; supplier: string; cost_price: number; selling_price: number; markup_percent?: number; item_status: string; updated: string; expand?: { order?: { order_number: string; company?: string } }; }>, }), sku ? pb.collection("order_items").getList<{ id: string; order: string; supplier_invoice_number: string; customer_invoice_number: string; invoiced_date: string; cost_price: number; quantity: number; qty_invoiced: number; expand?: { order?: { order_number: string } }; }>(1, 1, { filter: `${skuFilter} && invoiced_date != null && invoiced_date != "" && supplier_invoice_number != ""`, sort: "-invoiced_date", expand: "order", }) : Promise.resolve({ items: [] as Array<{ id: string; order: string; supplier_invoice_number: string; customer_invoice_number: string; invoiced_date: string; cost_price: number; quantity: number; qty_invoiced: number; expand?: { order?: { order_number: string } }; }>, }), sku ? pb.collection("stock_items").getList<{ id: string; name: string; sku: string; current_qty: number; min_qty: number | null; selling_price: number | null; markup: number | null; }>(1, 5, { filter: skuFilter }) : Promise.resolve({ items: [] as Array<{ id: string; name: string; sku: string; current_qty: number; min_qty: number | null; selling_price: number | null; markup: number | null; }>, }), pb.collection("suppliers").getList<{ id: string; suppliername: string }>(1, 200), ]); const supplierNameById = new Map( suppliersRows.items.map((s) => [s.id, s.suppliername]), ); // 5. Last quote — most recent with a valid cost or selling. const q = quoteRows.items.find( (r) => isValidPrice(r.cost_price) || isValidPrice(r.selling_price), ); if (q) { const cost = q.cost_price || 0; const sell = q.selling_price || 0; response.last_quote = { quote_item_id: q.id, quote_id: q.quote, quote_number: q.expand?.quote?.quote_number || "—", customer_name: q.expand?.quote?.customer_name || "—", company: q.expand?.quote?.company || "—", status: q.expand?.quote?.status || "", cost_price: cost, selling_price: sell, markup_percent: cost > 0 && sell > 0 ? Math.round(((sell - cost) / cost) * 10000) / 100 : 0, days_ago: daysAgo(q.created), }; } // 6. Last order — most recent other row with valid pricing. const o = orderRows.items.find( (r) => isValidPrice(r.cost_price) || isValidPrice(r.selling_price), ); if (o) { const cost = o.cost_price || 0; const sell = o.selling_price || 0; response.last_order = { order_item_id: o.id, order_id: o.order, order_number: o.expand?.order?.order_number || "—", company: o.expand?.order?.company || "—", cost_price: cost, selling_price: sell, markup_percent: o.markup_percent ?? (cost > 0 && sell > 0 ? Math.round(((sell - cost) / cost) * 10000) / 100 : 0), item_status: o.item_status, updated_at: o.updated, days_ago: daysAgo(o.updated), }; } // 7. Last invoiced. const i = invoicedRows.items[0]; if (i) { response.last_invoiced = { order_item_id: i.id, order_id: i.order, order_number: i.expand?.order?.order_number || "—", supplier_invoice_number: i.supplier_invoice_number, customer_invoice_number: i.customer_invoice_number, invoiced_date: i.invoiced_date, cost_price: i.cost_price, quantity: i.quantity, qty_invoiced: i.qty_invoiced, days_ago: daysAgo(i.invoiced_date), }; } // 8. Stock — derive status from qty thresholds. const s = stockRows.items[0]; if (s) { let status: OrderItemStock["status"]; if (s.current_qty <= 0) status = "out"; else if (s.min_qty != null && s.current_qty <= s.min_qty) status = "low"; else status = "in_stock"; response.stock = { stock_item_id: s.id, name: s.name, sku: s.sku, current_qty: s.current_qty, min_qty: s.min_qty, selling_price: s.selling_price, markup_percent: s.markup, status, }; } // 9. Suppliers list — derived from order_rows; filter to priced. response.suppliers = aggregateSuppliers( orderRows.items.map((r) => ({ id: r.id, supplier: r.supplier, cost_price: r.cost_price, updated: r.updated, })), supplierNameById, ).filter((s) => s.last_cost > 0); response.cheapest = computeCheapest(response.suppliers); return response; }