WFDASH-WEBAPP/src/lib/server/order-item-context-service.ts
SJCoetz 2de8cf1a56 chore(context): lint + build adjustments
Co-authored-by: Orca <help@stably.ai>
2026-08-07 03:04:28 +02:00

377 lines
11 KiB
TypeScript

/**
* 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<OrderItemContextResponse> {
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;
}