Add complete end-to-end AI-powered quote extraction workflow: - Disable PB SDK auto-cancellation to support parallel batch requests - Add required runtime dependencies for AI processing and document parsing - Implement AI settings backend, operator UI, and REST API routes - Add quote ingestion, insight population, and manual re-run AI endpoints - Build service layers for quote items, AI settings, and structured item insights - Fix attachment upload handling and quote item history blob parsing - Add supporting migrations, smoke tests, and shared utility functions - Export previously private helper functions for cross-service reuse
778 lines
26 KiB
TypeScript
778 lines
26 KiB
TypeScript
/**
|
|
* quote-items-insights-service — deterministic compute of
|
|
* `quote_items.ai_item_insights` so the agent (Hermes / Giellie) can
|
|
* populate every quote item with structured historical context
|
|
* without an LLM round-trip.
|
|
*
|
|
* Replaces the snapshot-from-n8n pattern. Same field, same renderer
|
|
* (`components/ai-insights-renderer.tsx` is shape agnostic), but the
|
|
* content is computed locally by reading PB directly.
|
|
*
|
|
* ## What it answers for one quote_item
|
|
*
|
|
* - "When did we last quote this SKU?" → `last_quoted`
|
|
* - "Is this SKU on our price list?" → `on_price_list` + `price_list_entries[]`
|
|
* - "Did we place an order for this SKU?" → `last_order` (any item_status past `po_generated`)
|
|
* - "When did we confirm an invoice for it?" → `last_invoiced`
|
|
*
|
|
* Plus the deterministic side the existing `item-history-service`
|
|
* already surfaces: stock status and supplier cost history.
|
|
*
|
|
* ## Matching strategy (SKU first, description as fallback)
|
|
*
|
|
* 1. If the target quote_item has a SKU, use it directly
|
|
* (`sku_exact`). Also try normalised-SKU (whitespace/_ → "-",
|
|
* leading zeros stripped) against price_list rows whose SKU
|
|
* doesn't match exactly — that catches `DRL-6MM` vs `DRL 6MM`
|
|
* and `BOLT-006` vs `BOLT-6` (`sku_normalised`).
|
|
* 2. If no SKU or no matches, run Jaccard token-set similarity on
|
|
* `description` against recent quote_items / order_items /
|
|
* stock_items / price_list rows (`description_fuzzy`,
|
|
* threshold 0.6). Use the matched row's SKU as the effective
|
|
* filter, then re-run the SKU matches.
|
|
* 3. If still nothing, return `match_kind: "none"` with an empty
|
|
* blob and `summary: "No history yet for this item."` so the
|
|
* renderer doesn't show a blank card.
|
|
*
|
|
* ## Auth + writes
|
|
*
|
|
* Uses the admin PB client. Hermes calls this through the REST
|
|
* endpoint (`POST /api/v1/quotes/[id]/populate-insights`) using the
|
|
* `AGENT_API_KEY` bearer. Writes `quote_items.ai_item_insights`
|
|
* directly. The MCP whitelist intentionally excludes this field —
|
|
* this service is the only sanctioned writer.
|
|
*
|
|
* Idempotent — safe to re-run. Overwrites the previous blob.
|
|
*/
|
|
|
|
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
|
import { tokenize, jaccard } from "@/lib/server/order-quote-match";
|
|
import type {
|
|
QuoteItemInsights,
|
|
QuoteItemInsightsBatchResult,
|
|
QuoteItemInsightsLastInvoiced,
|
|
QuoteItemInsightsLastOrder,
|
|
QuoteItemInsightsLastQuoted,
|
|
QuoteItemInsightsMatch,
|
|
QuoteItemInsightsPriceListEntry,
|
|
QuoteItemInsightsResult,
|
|
QuoteItemInsightsStock,
|
|
QuoteItemInsightsSupplier,
|
|
} from "@/lib/types";
|
|
|
|
// ─── Pure matching helpers ───────────────────────────────────────────
|
|
|
|
const DESCRIPTION_MATCH_THRESHOLD = 0.6;
|
|
const PRICE_LIST_CANDIDATE_LIMIT = 500;
|
|
|
|
/** Uppercase, trim, collapse whitespace/underscore to dash, strip leading zero runs after dash. */
|
|
export function normaliseInsightSku(sku: string): string {
|
|
if (!sku) return "";
|
|
return sku
|
|
.trim()
|
|
.toUpperCase()
|
|
.replace(/[\s_]+/g, "-")
|
|
.replace(/(?<=^|-)0{2,}(?=\d)/g, "");
|
|
}
|
|
|
|
/** Jaccard score on tokenised descriptions. Reuses the same primitives as `order-quote-match`. */
|
|
function descriptionScore(a: string, b: string): number {
|
|
const at = tokenize(a);
|
|
const bt = tokenize(b);
|
|
if (at.size === 0 || bt.size === 0) return 0;
|
|
return jaccard(at, bt);
|
|
}
|
|
|
|
/**
|
|
* Overlap coefficient: |A ∩ B| / min(|A|, |B|).
|
|
*
|
|
* Better than Jaccard for the "short query against long candidate"
|
|
* case (which is most of real data — customer writes "MTS TOOL TRUNK",
|
|
* catalogue has "SERMAT Tool Trunk Steel 610x315x230 blue"). Jaccard
|
|
* penalises extra tokens on the candidate side; overlap only cares
|
|
* that the query's tokens are all present somewhere in the candidate.
|
|
*
|
|
* Returns 0 when either side is empty.
|
|
*/
|
|
function overlapScore(a: string, b: string): number {
|
|
const at = tokenize(a);
|
|
const bt = tokenize(b);
|
|
if (at.size === 0 || bt.size === 0) return 0;
|
|
let inter = 0;
|
|
for (const t of at) if (bt.has(t)) inter++;
|
|
return inter / Math.min(at.size, bt.size);
|
|
}
|
|
|
|
/**
|
|
* Combined score: max of Jaccard and overlap coefficient. Lets the
|
|
* fuzzy fallback work for both "near-identical" descriptions (high
|
|
* Jaccard) and "short query vs long description" (high overlap).
|
|
*/
|
|
function descriptionScoreCombined(a: string, b: string): number {
|
|
return Math.max(descriptionScore(a, b), overlapScore(a, b));
|
|
}
|
|
|
|
/**
|
|
* Pick the candidate with the best combined score above the threshold.
|
|
* Returns the row + confidence, or null. Skips rows with empty
|
|
* descriptions.
|
|
*/
|
|
function bestDescriptionMatch<T extends { description?: string | null }>(
|
|
targetDescription: string,
|
|
candidates: T[]
|
|
): { item: T; confidence: number; score_kind: "jaccard" | "overlap" } | null {
|
|
if (!targetDescription.trim()) return null;
|
|
let best: { item: T; score: number; score_kind: "jaccard" | "overlap" } | null = null;
|
|
for (const c of candidates) {
|
|
const desc = (c.description || "").trim();
|
|
if (!desc) continue;
|
|
const j = descriptionScore(targetDescription, desc);
|
|
const o = overlapScore(targetDescription, desc);
|
|
const score_kind: "jaccard" | "overlap" = j >= o ? "jaccard" : "overlap";
|
|
const score = Math.max(j, o);
|
|
if (!best || score > best.score) best = { item: c, score, score_kind };
|
|
}
|
|
if (!best || best.score < DESCRIPTION_MATCH_THRESHOLD) return null;
|
|
return { item: best.item, confidence: best.score, score_kind: best.score_kind };
|
|
}
|
|
|
|
// ─── Aggregation helpers ─────────────────────────────────────────────
|
|
|
|
/** Pick the cheapest supplier with a positive last_cost. */
|
|
function pickCheapest(
|
|
suppliers: QuoteItemInsightsSupplier[]
|
|
): QuoteItemInsightsSupplier | null {
|
|
const valid = suppliers.filter((s) => s.last_cost > 0);
|
|
if (valid.length === 0) return null;
|
|
return valid.reduce((min, s) => (s.last_cost < min.last_cost ? s : min));
|
|
}
|
|
|
|
/** Group order_items by supplier. Track latest cost, latest date, count, sample order id. */
|
|
function aggregateSuppliers(
|
|
rows: Array<{
|
|
id: string;
|
|
supplier: string;
|
|
cost_price: number;
|
|
updated: string;
|
|
}>,
|
|
nameById: Map<string, string>
|
|
): QuoteItemInsightsSupplier[] {
|
|
const bySupplier = new Map<
|
|
string,
|
|
{ last_cost: number; last_bought_at: string; last_order_id: string; times_bought: number }
|
|
>();
|
|
for (const r of rows) {
|
|
if (!r.supplier) continue;
|
|
const prev = bySupplier.get(r.supplier);
|
|
if (!prev) {
|
|
bySupplier.set(r.supplier, {
|
|
last_cost: r.cost_price,
|
|
last_bought_at: r.updated,
|
|
last_order_id: r.id,
|
|
times_bought: 1,
|
|
});
|
|
} else {
|
|
prev.times_bought += 1;
|
|
if (new Date(r.updated).getTime() > new Date(prev.last_bought_at).getTime()) {
|
|
prev.last_cost = r.cost_price;
|
|
prev.last_bought_at = r.updated;
|
|
prev.last_order_id = r.id;
|
|
}
|
|
}
|
|
}
|
|
return Array.from(bySupplier.entries()).map(([id, agg]) => ({
|
|
supplier_id: id,
|
|
supplier_name: nameById.get(id) || id,
|
|
last_cost: agg.last_cost,
|
|
last_bought_at: agg.last_bought_at,
|
|
times_bought: agg.times_bought,
|
|
last_order_id: agg.last_order_id,
|
|
}));
|
|
}
|
|
|
|
function daysAgo(iso: string | null | undefined): number {
|
|
if (!iso) return 0;
|
|
const t = new Date(iso).getTime();
|
|
if (Number.isNaN(t)) return 0;
|
|
return Math.max(0, Math.floor((Date.now() - t) / 86_400_000));
|
|
}
|
|
|
|
/** Compose the short human-readable summary that goes at the top of the renderer. */
|
|
function composeSummary(match: QuoteItemInsightsMatch, found: {
|
|
last_quoted?: QuoteItemInsightsLastQuoted;
|
|
last_order?: QuoteItemInsightsLastOrder;
|
|
last_invoiced?: QuoteItemInsightsLastInvoiced;
|
|
on_price_list?: boolean;
|
|
stock?: QuoteItemInsightsStock;
|
|
}): string {
|
|
if (match.match_kind === "none") return "No history yet for this item.";
|
|
const bits: string[] = [];
|
|
if (found.last_quoted) {
|
|
bits.push(`last quoted R${found.last_quoted.selling_price.toFixed(2)} ${found.last_quoted.days_ago}d ago (${found.last_quoted.quote_number})`);
|
|
} else {
|
|
bits.push("no prior quote");
|
|
}
|
|
if (found.last_order) {
|
|
bits.push(`last ordered ${found.last_order.days_ago}d ago @ ${found.last_order.item_status}`);
|
|
}
|
|
if (found.last_invoiced) {
|
|
bits.push(`invoiced ${found.last_invoiced.days_ago}d ago (${found.last_invoiced.supplier_invoice_number || "?"})`);
|
|
}
|
|
if (found.on_price_list) {
|
|
bits.push("on price list");
|
|
}
|
|
if (found.stock) {
|
|
bits.push(`stock ${found.stock.status} (${found.stock.current_qty})`);
|
|
}
|
|
return bits.join(" · ");
|
|
}
|
|
|
|
// ─── Service entry points ────────────────────────────────────────────
|
|
|
|
/**
|
|
* Compute and write `ai_item_insights` for one quote_item.
|
|
*
|
|
* Returns the structured result so the caller (REST handler, MCP
|
|
* tool, batch driver) can echo it back or roll it up into a batch
|
|
* response.
|
|
*
|
|
* Throws on PB I/O failures. Returns a `match_kind: "none"` result
|
|
* (still written to PB) when there is genuinely no history.
|
|
*/
|
|
export async function populateQuoteItemInsights(
|
|
itemId: string,
|
|
options: { write?: boolean } = {}
|
|
): Promise<QuoteItemInsightsResult> {
|
|
const write = options.write !== false;
|
|
const pb = await getAdminPocketBase();
|
|
const generatedAt = new Date().toISOString();
|
|
|
|
// 1. Load target. Anchors everything.
|
|
const target = await pb.collection("quote_items").getOne<{
|
|
id: string;
|
|
sku?: string;
|
|
description?: string;
|
|
cost_price?: number;
|
|
selling_price?: number;
|
|
quantity?: number;
|
|
}>(itemId, { fields: "id,sku,description,cost_price,selling_price,quantity" });
|
|
|
|
const targetSku = (target.sku || "").trim();
|
|
const targetDesc = (target.description || "").trim();
|
|
const targetNorm = normaliseInsightSku(targetSku);
|
|
|
|
const match: QuoteItemInsightsMatch = {
|
|
sku: targetSku,
|
|
match_kind: "none",
|
|
match_confidence: 0,
|
|
};
|
|
|
|
// 2. If we don't have a SKU but DO have a description, run the
|
|
// description-fuzzy fallback to recover a SKU.
|
|
let effectiveSku = targetSku;
|
|
if (!effectiveSku && targetDesc) {
|
|
// Fan out across three collections. We pull recent rows that have
|
|
// a description AND something to seed the SKU queries with — either
|
|
// their own SKU (quote_items / order_items) or a normalised-SKU
|
|
// lookup (price_list has `sku_normalised`). Items without any of
|
|
// these don't help us recover a SKU.
|
|
const [quoteFuzz, orderFuzz, priceFuzz] = await Promise.all([
|
|
safeList(
|
|
() =>
|
|
pb.collection("quote_items").getList<{ id: string; sku: string; description: string }>(
|
|
1,
|
|
PRICE_LIST_CANDIDATE_LIMIT,
|
|
{
|
|
filter: `id != "${itemId}" && description != ""`,
|
|
sort: "-created",
|
|
fields: "id,sku,description",
|
|
}
|
|
),
|
|
{ items: [] }
|
|
),
|
|
safeList(
|
|
() =>
|
|
pb.collection("order_items").getList<{ id: string; sku: string; description: string }>(
|
|
1,
|
|
PRICE_LIST_CANDIDATE_LIMIT,
|
|
{
|
|
filter: `description != ""`,
|
|
sort: "-updated",
|
|
fields: "id,sku,description",
|
|
}
|
|
),
|
|
{ items: [] }
|
|
),
|
|
safeList(
|
|
() =>
|
|
pb.collection("price_list").getList<{ id: string; sku: string; sku_normalised?: string; description: string }>(
|
|
1,
|
|
PRICE_LIST_CANDIDATE_LIMIT,
|
|
{
|
|
filter: `description != ""`,
|
|
sort: "-updated",
|
|
fields: "id,sku,sku_normalised,description",
|
|
}
|
|
),
|
|
{ items: [] }
|
|
),
|
|
]);
|
|
|
|
const fuzzyPool = [
|
|
...quoteFuzz.items,
|
|
...orderFuzz.items,
|
|
...priceFuzz.items,
|
|
];
|
|
const fm = bestDescriptionMatch(targetDesc, fuzzyPool);
|
|
if (fm && (fm.item.sku || "").trim()) {
|
|
effectiveSku = (fm.item.sku || "").trim();
|
|
match.sku = effectiveSku;
|
|
match.match_kind = "description_fuzzy";
|
|
match.match_confidence = fm.confidence;
|
|
}
|
|
} else if (effectiveSku) {
|
|
match.match_kind = "sku_exact";
|
|
match.match_confidence = 1;
|
|
}
|
|
|
|
const skuFilter = effectiveSku
|
|
? `sku = "${effectiveSku.replace(/"/g, '\\"')}"`
|
|
: "";
|
|
const normFilter =
|
|
!targetSku && effectiveSku && match.match_kind === "description_fuzzy"
|
|
? "" // we already have the SKU from fuzzy
|
|
: targetNorm && effectiveSku && normaliseInsightSku(effectiveSku) !== targetNorm
|
|
? "" // description fallback gave us a SKU; don't bother with normalised re-query
|
|
: targetNorm
|
|
? `(sku = "${targetNorm.replace(/"/g, '\\"')}" || sku = "${targetSku.replace(/"/g, '\\"')}")`
|
|
: "";
|
|
|
|
// 3. Fan out reads. Each branch returns [] if we have no usable SKU.
|
|
//
|
|
// The invoiced_rows query needs fields that don't exist on every
|
|
// PB instance yet (live 2026-08-09: `invoiced_date`,
|
|
// `supplier_invoice_number`, `customer_invoice_number`, `qty_invoiced`
|
|
// are not on `order_items`). PB rejects the filter with HTTP 400
|
|
// "Something went wrong while processing your request." until the
|
|
// migration lands. We try-catch that one branch and degrade
|
|
// gracefully — the other three answers still populate, and the
|
|
// summary says "no invoiced history yet" until the schema is
|
|
// upgraded. The same defensive pattern would apply to any field
|
|
// the deployment hasn't yet migrated.
|
|
const hasFilter = !!skuFilter;
|
|
type PbListResult<T> = { items: T[]; totalItems?: number };
|
|
|
|
async function safeList<T>(
|
|
buildQuery: () => Promise<PbListResult<T>>,
|
|
fallback: PbListResult<T>
|
|
): Promise<PbListResult<T>> {
|
|
try {
|
|
return await buildQuery();
|
|
} catch (err) {
|
|
// 400 = field referenced by the filter doesn't exist on this
|
|
// collection yet. Anything else (5xx, network) we let bubble so
|
|
// the operator sees the real failure.
|
|
const status =
|
|
err && typeof err === "object" && "status" in err
|
|
? (err as { status?: number }).status
|
|
: undefined;
|
|
if (status === 400) {
|
|
console.warn(
|
|
`[populateQuoteItemInsights] PB rejected one branch with 400 — likely a field the live schema is missing. Treating as no data.`,
|
|
);
|
|
return fallback;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
const [
|
|
priceListExactRows,
|
|
priceListNormalisedRows,
|
|
otherQuoteRows,
|
|
orderRows,
|
|
invoicedRows,
|
|
stockRows,
|
|
posRows,
|
|
suppliersRows,
|
|
] = await Promise.all([
|
|
safeList(
|
|
() =>
|
|
hasFilter
|
|
? pb.collection("price_list").getList<{
|
|
id: string;
|
|
sku: string;
|
|
description: string;
|
|
cost_price: number;
|
|
selling_price: number;
|
|
supplier: string;
|
|
brand: string;
|
|
category: string;
|
|
subcategory: string;
|
|
updated: string;
|
|
}>(1, 10, { filter: skuFilter, sort: "-updated" })
|
|
: Promise.resolve({ items: [] }),
|
|
{ items: [] }
|
|
),
|
|
|
|
safeList(
|
|
() =>
|
|
normFilter
|
|
? pb.collection("price_list").getList<{
|
|
id: string;
|
|
sku: string;
|
|
description: string;
|
|
cost_price: number;
|
|
selling_price: number;
|
|
supplier: string;
|
|
brand: string;
|
|
category: string;
|
|
subcategory: string;
|
|
updated: string;
|
|
}>(1, 10, { filter: normFilter, sort: "-updated" })
|
|
: Promise.resolve({ items: [] }),
|
|
{ items: [] }
|
|
),
|
|
|
|
safeList(
|
|
() =>
|
|
hasFilter
|
|
? pb.collection("quote_items").getList<{
|
|
id: string;
|
|
quote: string;
|
|
cost_price: number;
|
|
selling_price: number;
|
|
created: string;
|
|
expand?: { quote?: { quote_number: string; customer?: string } };
|
|
}>(1, 5, {
|
|
filter: `${skuFilter} && id != "${itemId}"`,
|
|
sort: "-created",
|
|
expand: "quote",
|
|
})
|
|
: Promise.resolve({ items: [] }),
|
|
{ items: [] }
|
|
),
|
|
|
|
safeList(
|
|
() =>
|
|
hasFilter
|
|
? pb.collection("order_items").getList<{
|
|
id: string;
|
|
order: string;
|
|
supplier: string;
|
|
cost_price: number;
|
|
selling_price: number;
|
|
item_status: string;
|
|
updated: string;
|
|
delivered_at?: string;
|
|
expand?: { order?: { order_number: string } };
|
|
}>(1, 5, {
|
|
filter: `${skuFilter} && item_status != "cancelled" && item_status != "removed"`,
|
|
sort: "-updated",
|
|
expand: "order",
|
|
})
|
|
: Promise.resolve({ items: [] }),
|
|
{ items: [] }
|
|
),
|
|
|
|
safeList(
|
|
() =>
|
|
hasFilter
|
|
? 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 != "" && invoiced_date != "0001-01-01T00:00:00Z" && (supplier_invoice_number != "" || customer_invoice_number != "")`,
|
|
sort: "-invoiced_date",
|
|
expand: "order",
|
|
})
|
|
: Promise.resolve({ items: [] }),
|
|
{ items: [] }
|
|
),
|
|
|
|
safeList(
|
|
() =>
|
|
hasFilter
|
|
? 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: [] }),
|
|
{ items: [] }
|
|
),
|
|
|
|
safeList(
|
|
() =>
|
|
hasFilter
|
|
? pb.collection("pos").getList<{ sku: string; stock_qty: number }>(1, 5, { filter: skuFilter })
|
|
: Promise.resolve({ items: [] }),
|
|
{ items: [] }
|
|
),
|
|
|
|
pb.collection("suppliers").getList<{ id: string; suppliername: string }>(1, 500, {
|
|
fields: "id,suppliername",
|
|
}),
|
|
]);
|
|
|
|
const supplierNameById = new Map(
|
|
suppliersRows.items.map((s) => [s.id, s.suppliername])
|
|
);
|
|
|
|
// ── last_quoted ─────────────────────────────────────────────────
|
|
let last_quoted: QuoteItemInsightsLastQuoted | undefined;
|
|
const q = otherQuoteRows.items.find(
|
|
(r) => (r.cost_price || 0) > 0 || (r.selling_price || 0) > 0
|
|
);
|
|
if (q) {
|
|
last_quoted = {
|
|
quote_item_id: q.id,
|
|
quote_id: q.quote,
|
|
quote_number: q.expand?.quote?.quote_number || "",
|
|
quote_date: q.created,
|
|
cost_price: q.cost_price || 0,
|
|
selling_price: q.selling_price || 0,
|
|
customer_id: "",
|
|
customer_name: "",
|
|
days_ago: daysAgo(q.created),
|
|
};
|
|
}
|
|
|
|
// ── on_price_list + price_list_entries ──────────────────────────
|
|
const allPriceRows = [
|
|
...priceListExactRows.items,
|
|
...priceListNormalisedRows.items,
|
|
];
|
|
let on_price_list: boolean | undefined;
|
|
let price_list_entries: QuoteItemInsightsPriceListEntry[] | undefined;
|
|
if (allPriceRows.length > 0) {
|
|
on_price_list = true;
|
|
// Dedup by (supplier_id, sku) so the same row doesn't appear twice
|
|
// when we hit it via exact + normalised.
|
|
const seen = new Set<string>();
|
|
price_list_entries = [];
|
|
for (const r of allPriceRows) {
|
|
const key = `${r.supplier}|${r.sku}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
price_list_entries.push({
|
|
price_list_id: r.id,
|
|
sku: r.sku,
|
|
description: r.description || "",
|
|
supplier_id: r.supplier,
|
|
supplier_name: supplierNameById.get(r.supplier) || r.supplier,
|
|
cost_price: r.cost_price || 0,
|
|
selling_price: r.selling_price || 0,
|
|
brand: r.brand || "",
|
|
category: r.category || "",
|
|
subcategory: r.subcategory || "",
|
|
updated: r.updated,
|
|
});
|
|
}
|
|
} else if (hasFilter) {
|
|
on_price_list = false;
|
|
}
|
|
|
|
// If we got exact + normalised matches, we matched by normalised
|
|
// SKU — surface that in `match.match_kind`.
|
|
if (
|
|
match.match_kind === "sku_exact" &&
|
|
priceListNormalisedRows.items.length > 0 &&
|
|
priceListExactRows.items.length === 0
|
|
) {
|
|
match.match_kind = "sku_normalised";
|
|
}
|
|
|
|
// ── last_order ──────────────────────────────────────────────────
|
|
let last_order: QuoteItemInsightsLastOrder | undefined;
|
|
const o = orderRows.items.find(
|
|
(r) => (r.cost_price || 0) > 0 || (r.selling_price || 0) > 0
|
|
);
|
|
if (o) {
|
|
last_order = {
|
|
order_item_id: o.id,
|
|
order_id: o.order,
|
|
order_number: o.expand?.order?.order_number || "",
|
|
order_date: o.updated,
|
|
delivered_at: o.delivered_at || null,
|
|
cost_price: o.cost_price || 0,
|
|
selling_price: o.selling_price || 0,
|
|
item_status: o.item_status,
|
|
supplier_id: o.supplier,
|
|
supplier_name: supplierNameById.get(o.supplier) || o.supplier,
|
|
days_ago: daysAgo(o.updated),
|
|
};
|
|
}
|
|
|
|
// ── last_invoiced ───────────────────────────────────────────────
|
|
let last_invoiced: QuoteItemInsightsLastInvoiced | undefined;
|
|
const i = invoicedRows.items[0];
|
|
if (i) {
|
|
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 || 0,
|
|
quantity: i.quantity || 0,
|
|
qty_invoiced: i.qty_invoiced || 0,
|
|
days_ago: daysAgo(i.invoiced_date),
|
|
};
|
|
}
|
|
|
|
// ── stock (prefer stock_items; fall back to pos for legacy) ─────
|
|
let stock: QuoteItemInsightsStock | undefined;
|
|
const s = stockRows.items[0];
|
|
if (s) {
|
|
let status: QuoteItemInsightsStock["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";
|
|
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,
|
|
};
|
|
} else {
|
|
const p = posRows.items[0];
|
|
if (p) {
|
|
stock = {
|
|
stock_item_id: "",
|
|
name: "",
|
|
sku: p.sku,
|
|
current_qty: p.stock_qty || 0,
|
|
min_qty: null,
|
|
selling_price: null,
|
|
markup_percent: null,
|
|
status:
|
|
(p.stock_qty || 0) <= 0
|
|
? "out"
|
|
: (p.stock_qty || 0) > 0
|
|
? "low"
|
|
: "out",
|
|
};
|
|
}
|
|
}
|
|
|
|
// ── suppliers (from all matching order_rows) ────────────────────
|
|
const suppliers = aggregateSuppliers(
|
|
orderRows.items.map((r) => ({
|
|
id: r.id,
|
|
supplier: r.supplier,
|
|
cost_price: r.cost_price || 0,
|
|
updated: r.updated,
|
|
})),
|
|
supplierNameById
|
|
).filter((s) => s.last_cost > 0);
|
|
const cheapest = pickCheapest(suppliers);
|
|
|
|
// Whether the join produced any useful data — independent of how we
|
|
// matched (SKU exact / normalised / description fuzzy). An item with
|
|
// `match_kind: "sku_exact"` but no historical rows still has
|
|
// `found_data: false` — useful for the batch rollup that distinguishes
|
|
// "populated" from "no history".
|
|
const found_data =
|
|
!!last_quoted ||
|
|
!!last_order ||
|
|
!!last_invoiced ||
|
|
on_price_list === true ||
|
|
!!stock ||
|
|
suppliers.length > 0;
|
|
|
|
const insights: QuoteItemInsights = {
|
|
id: itemId,
|
|
summary: composeSummary(match, {
|
|
last_quoted,
|
|
last_order,
|
|
last_invoiced,
|
|
on_price_list,
|
|
stock,
|
|
}),
|
|
match,
|
|
generated_at: generatedAt,
|
|
};
|
|
if (last_quoted) insights.last_quoted = last_quoted;
|
|
if (on_price_list !== undefined) insights.on_price_list = on_price_list;
|
|
if (price_list_entries) insights.price_list_entries = price_list_entries;
|
|
if (last_order) insights.last_order = last_order;
|
|
if (last_invoiced) insights.last_invoiced = last_invoiced;
|
|
if (stock) insights.stock = stock;
|
|
if (suppliers.length > 0) insights.suppliers = suppliers;
|
|
// Cheapest as a top-level convenience field so the renderer doesn't
|
|
// have to recompute it from the suppliers array.
|
|
if (cheapest) {
|
|
(insights as QuoteItemInsights & { cheapest?: QuoteItemInsightsSupplier }).cheapest = cheapest;
|
|
}
|
|
|
|
if (write) {
|
|
await pb.collection("quote_items").update(itemId, {
|
|
ai_item_insights: insights,
|
|
});
|
|
}
|
|
|
|
return {
|
|
item_id: itemId,
|
|
insights,
|
|
match_kind: match.match_kind,
|
|
found_data,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Populate `ai_item_insights` for every active line item on a quote.
|
|
*
|
|
* "Active" = `moved_to_quote = null || moved_to_quote = ""`. Items
|
|
* already moved to a continuation quote are skipped — they belong to
|
|
* a different quote's history now.
|
|
*
|
|
* Hermes's typical pattern:
|
|
*
|
|
* for each quote in quotes.list:
|
|
* POST /api/v1/quotes/{quote.id}/populate-insights
|
|
*
|
|
* The route is parallel-safe within a single quote (we Promise.all
|
|
* the items). Across quotes, Hermes runs sequentially or in small
|
|
* batches to avoid hammering PB.
|
|
*/
|
|
export async function populateQuoteInsightsForQuote(
|
|
quoteId: string,
|
|
options: { write?: boolean } = {}
|
|
): Promise<QuoteItemInsightsBatchResult> {
|
|
const pb = await getAdminPocketBase();
|
|
const items = await pb.collection("quote_items").getFullList<{ id: string }>({
|
|
filter: `quote = "${quoteId}" && (moved_to_quote = null || moved_to_quote = "")`,
|
|
fields: "id",
|
|
});
|
|
|
|
const results = await Promise.all(
|
|
items.map((it) => populateQuoteItemInsights(it.id, options))
|
|
);
|
|
|
|
return {
|
|
quote_id: quoteId,
|
|
total: items.length,
|
|
/** Items where the join produced at least one historical row. */
|
|
populated: results.filter((r) => r.found_data).length,
|
|
/** Items where we found a SKU / fuzzy match but no history. Plus items with no match at all. */
|
|
no_match: results.filter((r) => !r.found_data).length,
|
|
results,
|
|
};
|
|
}
|