2026-08-07 00:23:45 +00:00
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*
|
2026-08-07 01:04:28 +00:00
|
|
|
* Pure helpers (`findBestDescriptionMatch`, `aggregateSuppliers`,
|
|
|
|
|
* `computeCheapest`, `daysAgo`, `isValidPrice`) are imported from
|
|
|
|
|
* `item-history-service.ts` — DO NOT duplicate them.
|
2026-08-07 00:23:45 +00:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
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<OrderItemContextResponse> {
|
2026-08-07 00:34:45 +00:00
|
|
|
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;
|
2026-08-07 00:23:45 +00:00
|
|
|
}
|