diff --git a/src/lib/quote-assistant-review.test.ts b/src/lib/quote-assistant-review.test.ts new file mode 100644 index 0000000..ccd2184 --- /dev/null +++ b/src/lib/quote-assistant-review.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import type { QuoteItemAiFill } from "@/lib/types"; +import { + aiRowClass, + buildAiReviewPatch, + isAiFillPending, + nextPricingSourceOnSave, +} from "./quote-assistant-review"; + +const NOW = new Date("2026-10-16T12:00:00.000Z"); + +function fill(over: Partial = {}): QuoteItemAiFill { + return { + status: "filled", + stock_item_id: "s-1", + stock_item_name: "Bolt", + stock_item_sku: "B", + cost_price: 1.89, + selling_price: 3.4, + markup: 80, + supplier_id: "s1", + confidence: "high", + reason: "", + doubts: [], + candidates: [], + description_snapshot: "bolt", + model: "m", + attempted_at: NOW.toISOString(), + ...over, + }; +} + +const pending = { ai_fill: fill(), pricing_source: "ai_pos" }; + +describe("isAiFillPending", () => { + it("is true only for an unreviewed filled ai_pos line", () => { + expect(isAiFillPending(pending)).toBe(true); + }); + it("is false once reviewed", () => { + expect( + isAiFillPending({ + ai_fill: fill({ reviewed_at: NOW.toISOString() }), + pricing_source: "ai_pos", + }), + ).toBe(false); + }); + it("is false when a rep's save flipped pricing_source to manual", () => { + expect(isAiFillPending({ ai_fill: fill(), pricing_source: "manual" })).toBe(false); + }); + it("is false for non-filled statuses and missing ai_fill", () => { + expect(isAiFillPending({ ai_fill: fill({ status: "no_match" }), pricing_source: "ai_pos" })).toBe(false); + expect(isAiFillPending({ ai_fill: null, pricing_source: "ai_pos" })).toBe(false); + expect(isAiFillPending({ pricing_source: "ai_pos" })).toBe(false); + }); +}); + +describe("nextPricingSourceOnSave", () => { + it("keeps catalogue and ai_pos, otherwise manual", () => { + expect(nextPricingSourceOnSave("catalogue")).toBe("catalogue"); + expect(nextPricingSourceOnSave("ai_pos")).toBe("ai_pos"); + expect(nextPricingSourceOnSave("ai_email")).toBe("manual"); + expect(nextPricingSourceOnSave("")).toBe("manual"); + expect(nextPricingSourceOnSave(undefined)).toBe("manual"); + expect(nextPricingSourceOnSave("manual")).toBe("manual"); + }); +}); + +describe("buildAiReviewPatch", () => { + const reviewer = { id: "u1", name: "Sam" }; + + it("records an unchanged acceptance", () => { + const patch = buildAiReviewPatch( + pending, + { cost_price: 1.89, selling_price: 3.4, supplier: "s1" }, + reviewer, + NOW, + ); + expect(patch.ai_fill).toMatchObject({ + status: "filled", + accepted_unchanged: true, + supplier_changed: false, + reviewed_at: NOW.toISOString(), + reviewed_by: "Sam", + }); + }); + + it("flags a changed selling price", () => { + const patch = buildAiReviewPatch( + pending, + { cost_price: 1.89, selling_price: 4, supplier: "s1" }, + reviewer, + NOW, + ); + expect(patch.ai_fill?.accepted_unchanged).toBe(false); + }); + + it("flags a changed cost price", () => { + const patch = buildAiReviewPatch( + pending, + { cost_price: 2, selling_price: 3.4, supplier: "s1" }, + reviewer, + NOW, + ); + expect(patch.ai_fill?.accepted_unchanged).toBe(false); + }); + + it("flags a different or cleared supplier", () => { + const changed = buildAiReviewPatch( + pending, + { cost_price: 1.89, selling_price: 3.4, supplier: "s2" }, + reviewer, + NOW, + ); + expect(changed.ai_fill?.supplier_changed).toBe(true); + const cleared = buildAiReviewPatch( + pending, + { cost_price: 1.89, selling_price: 3.4, supplier: null }, + reviewer, + NOW, + ); + expect(cleared.ai_fill?.supplier_changed).toBe(true); + }); + + it("returns an empty patch for a non-pending line", () => { + const saved = { cost_price: 1, selling_price: 2, supplier: null }; + expect( + buildAiReviewPatch({ ai_fill: fill(), pricing_source: "manual" }, saved, reviewer, NOW), + ).toEqual({}); + expect( + buildAiReviewPatch({ ai_fill: null, pricing_source: "ai_pos" }, saved, reviewer, NOW), + ).toEqual({}); + }); +}); + +describe("aiRowClass", () => { + it("highlights pending lines only", () => { + expect(aiRowClass(pending)).toBe("bg-ai/10"); + expect(aiRowClass({ ai_fill: fill(), pricing_source: "manual" })).toBe(""); + expect(aiRowClass({ ai_fill: null, pricing_source: "" })).toBe(""); + }); +}); diff --git a/src/lib/quote-assistant-review.ts b/src/lib/quote-assistant-review.ts new file mode 100644 index 0000000..51516e1 --- /dev/null +++ b/src/lib/quote-assistant-review.ts @@ -0,0 +1,65 @@ +/** + * quote-assistant-review — client-safe helpers for AI-filled quote lines. + * + * Imports only types, so Process-page components can use it directly. + * + * A line is "pending review" only when the AI filled it, nobody has + * reviewed it, AND `pricing_source` is still "ai_pos". A rep who saves a + * line from a stale page flips `pricing_source` to "manual" + * (`nextPricingSourceOnSave`), so their values win and the line stops + * being highlighted. + */ +import type { QuoteItem, QuoteItemAiFill } from "@/lib/types"; + +type AiLine = Pick; + +export function isAiFillPending(item: AiLine): boolean { + const fill = item.ai_fill; + return ( + !!fill && + fill.status === "filled" && + !fill.reviewed_at && + item.pricing_source === "ai_pos" + ); +} + +/** What `pricing_source` a human save should write, given the loaded value. */ +export function nextPricingSourceOnSave( + current: string | undefined, +): "catalogue" | "ai_pos" | "manual" { + if (current === "catalogue" || current === "ai_pos") return current; + return "manual"; +} + +const sameMoney = (a: number, b: number) => + Math.round((Number(a) || 0) * 100) === Math.round((Number(b) || 0) * 100); + +/** + * The `ai_fill` update recording that a person reviewed a pending AI fill. + * Returns `{}` for a line that isn't pending (nothing to record). + */ +export function buildAiReviewPatch( + item: AiLine, + saved: { cost_price: number; selling_price: number; supplier: string | null }, + reviewer: { id?: string; name: string }, + now: Date, +): { ai_fill?: QuoteItemAiFill } { + if (!isAiFillPending(item) || !item.ai_fill) return {}; + const fill = item.ai_fill; + return { + ai_fill: { + ...fill, + reviewed_at: now.toISOString(), + reviewed_by: reviewer.name, + accepted_unchanged: + sameMoney(saved.cost_price, fill.cost_price) && + sameMoney(saved.selling_price, fill.selling_price), + supplier_changed: (saved.supplier ?? "") !== (fill.supplier_id ?? ""), + }, + }; +} + +/** Row highlight for lines awaiting human review of an AI fill. */ +export function aiRowClass(item: AiLine): string { + return isAiFillPending(item) ? "bg-ai/10" : ""; +} diff --git a/src/lib/server/quote-assistant-core.test.ts b/src/lib/server/quote-assistant-core.test.ts new file mode 100644 index 0000000..fcdf574 --- /dev/null +++ b/src/lib/server/quote-assistant-core.test.ts @@ -0,0 +1,380 @@ +import { describe, expect, it } from "vitest"; +import type { QuoteItem, QuoteItemAiFill } from "@/lib/types"; +import type { StockCandidate } from "./quote-assistant-shortlist"; +import { + buildLinePatch, + isLineEligible, + isLineStillWritable, + meetsConfidence, + parseDecisions, + type AiLineDecision, +} from "./quote-assistant-core"; + +const NOW = new Date("2026-10-16T12:00:00.000Z"); + +function aiFill(over: Partial = {}): QuoteItemAiFill { + return { + status: "no_match", + stock_item_id: null, + stock_item_name: "", + stock_item_sku: "", + cost_price: 0, + selling_price: 0, + markup: 0, + supplier_id: "", + confidence: null, + reason: "", + doubts: [], + candidates: [], + description_snapshot: "M10 bolt", + model: "m", + attempted_at: NOW.toISOString(), + ...over, + }; +} + +function line(over: Partial = {}): QuoteItem { + return { + id: "qi1", + quote: "q1", + description: "M10 bolt", + cost_price: 0, + selling_price: 0, + markup: 0, + supplier: "", + user_edited: false, + is_processed: false, + pricing_source: "ai_email", + pricing_notes: "", + moved_to_quote: "", + ai_fill: null, + ...over, + } as unknown as QuoteItem; +} + +function cand(over: Partial = {}): StockCandidate { + return { + id: "s-1", + name: "M10 Bolt Zinc", + sku: "BLT10", + description: "", + notes: "", + unit: "EA", + cost_price: 1.89, + selling_price: 3.4, + markup: 80, + supplier_id: "s1", + supplier_name: "Supp", + score: 0.9, + ...over, + }; +} + +function decision(over: Partial = {}): AiLineDecision { + return { + quote_item_id: "qi1", + stock_item_id: "s-1", + confidence: "high", + reason: "same bolt", + doubts: [], + ...over, + }; +} + +describe("isLineEligible", () => { + it("accepts a fresh unpriced AI-email line", () => { + expect(isLineEligible(line(), { now: NOW })).toBe(true); + }); + + it("treats null/undefined moved_to_quote as not moved", () => { + expect(isLineEligible(line({ moved_to_quote: null }), { now: NOW })).toBe(true); + expect(isLineEligible(line({ moved_to_quote: undefined }), { now: NOW })).toBe(true); + }); + + it.each([ + ["has a cost", { cost_price: 5 }], + ["has a selling price", { selling_price: 5 }], + ["user edited", { user_edited: true }], + ["processed", { is_processed: true }], + ["manual pricing source", { pricing_source: "manual" }], + ["empty description", { description: " " }], + ["moved to another quote", { moved_to_quote: "q2" }], + ] as [string, Partial][])("rejects when %s", (_n, over) => { + expect(isLineEligible(line(over), { now: NOW })).toBe(false); + }); + + it("skips no_match with an unchanged description", () => { + expect(isLineEligible(line({ ai_fill: aiFill() }), { now: NOW })).toBe(false); + }); + + it("retries no_match when the description changed", () => { + const item = line({ + description: "M10 bolt zinc 50mm", + ai_fill: aiFill({ description_snapshot: "M10 bolt" }), + }); + expect(isLineEligible(item, { now: NOW })).toBe(true); + }); + + it("retries no_match with the same snapshot when forced", () => { + expect(isLineEligible(line({ ai_fill: aiFill() }), { now: NOW, force: true })).toBe(true); + }); + + it("retries error lines only after an hour", () => { + const at = (mins: number) => new Date(NOW.getTime() - mins * 60_000).toISOString(); + const old = line({ ai_fill: aiFill({ status: "error", attempted_at: at(61) }) }); + const recent = line({ ai_fill: aiFill({ status: "error", attempted_at: at(59) }) }); + expect(isLineEligible(old, { now: NOW })).toBe(true); + expect(isLineEligible(recent, { now: NOW })).toBe(false); + expect(isLineEligible(recent, { now: NOW, force: true })).toBe(true); + }); + + it("never lets force override a person's work", () => { + expect(isLineEligible(line({ user_edited: true }), { now: NOW, force: true })).toBe(false); + expect(isLineEligible(line({ cost_price: 2 }), { now: NOW, force: true })).toBe(false); + }); +}); + +describe("isLineStillWritable", () => { + it("is true for an untouched unpriced line", () => { + expect(isLineStillWritable(line())).toBe(true); + }); + it.each([ + { cost_price: 1 }, + { selling_price: 1 }, + { user_edited: true }, + { is_processed: true }, + ] as Partial[])("is false after %o", (over) => { + expect(isLineStillWritable(line(over))).toBe(false); + }); +}); + +describe("parseDecisions", () => { + const ids = new Set(["a", "b"]); + + it("drops unknown ids and keeps the first of duplicates", () => { + const out = parseDecisions( + { + decisions: [ + { quote_item_id: "zzz", stock_item_id: "s", confidence: "high" }, + { quote_item_id: "a", stock_item_id: "first", confidence: "high" }, + { quote_item_id: "a", stock_item_id: "second", confidence: "low" }, + ], + }, + ids, + ); + expect([...out.keys()]).toEqual(["a"]); + expect(out.get("a")?.stock_item_id).toBe("first"); + }); + + it("accepts a bare array too", () => { + const out = parseDecisions([{ quote_item_id: "b", stock_item_id: null }], ids); + expect(out.get("b")?.stock_item_id).toBeNull(); + }); + + it("nulls invalid confidence and coerces bad doubts", () => { + const out = parseDecisions( + [{ quote_item_id: "a", stock_item_id: "s", confidence: "certain", doubts: "nope", reason: 5 }], + ids, + ); + const d = out.get("a")!; + expect(d.confidence).toBeNull(); + expect(d.doubts).toEqual([]); + expect(d.reason).toBe(""); + }); + + it("returns empty for garbage", () => { + expect(parseDecisions(null, ids).size).toBe(0); + expect(parseDecisions("x", ids).size).toBe(0); + expect(parseDecisions({ decisions: "x" }, ids).size).toBe(0); + }); +}); + +describe("meetsConfidence", () => { + it("compares against the minimum", () => { + expect(meetsConfidence("medium", "medium")).toBe(true); + expect(meetsConfidence("high", "medium")).toBe(true); + expect(meetsConfidence("low", "medium")).toBe(false); + expect(meetsConfidence("medium", "high")).toBe(false); + expect(meetsConfidence("high", "high")).toBe(true); + expect(meetsConfidence(null, "medium")).toBe(false); + expect(meetsConfidence(null, "high")).toBe(false); + }); +}); + +describe("buildLinePatch", () => { + const base = { minConfidence: "medium" as const, model: "MiniMax-M2", now: NOW }; + + it("fills from the chosen candidate", () => { + const patch = buildLinePatch({ + ...base, + item: line(), + decision: decision(), + candidates: [cand()], + }); + expect(patch).toMatchObject({ + cost_price: 1.89, + selling_price: 3.4, + markup: 80, + supplier: "s1", + pricing_source: "ai_pos", + }); + expect(patch.pricing_notes).toBe("AI: matched POS 'M10 Bolt Zinc' (high)"); + const fill = patch.ai_fill as QuoteItemAiFill; + expect(fill).toMatchObject({ + status: "filled", + stock_item_id: "s-1", + stock_item_name: "M10 Bolt Zinc", + stock_item_sku: "BLT10", + cost_price: 1.89, + selling_price: 3.4, + markup: 80, + supplier_id: "s1", + confidence: "high", + description_snapshot: "M10 bolt", + model: "MiniMax-M2", + attempted_at: NOW.toISOString(), + }); + expect(fill.price_from_default_markup).toBeFalsy(); + expect(patch).not.toHaveProperty("sku"); + expect(patch).not.toHaveProperty("is_processed"); + expect(patch).not.toHaveProperty("user_edited"); + }); + + it("uses the default markup when the POS item has no selling price", () => { + const patch = buildLinePatch({ + ...base, + item: line(), + decision: decision(), + candidates: [cand({ cost_price: 10, selling_price: 0, markup: 0 })], + }); + expect(patch.selling_price).toBe(18); + expect(patch.markup).toBe(80); + const fill = patch.ai_fill as QuoteItemAiFill; + expect(fill.price_from_default_markup).toBe(true); + expect(patch.pricing_notes).toBe( + "AI: matched POS 'M10 Bolt Zinc' (high). Selling price from default markup.", + ); + }); + + it("derives markup when the POS item has prices but no markup", () => { + const patch = buildLinePatch({ + ...base, + item: line(), + decision: decision(), + candidates: [cand({ cost_price: 10, selling_price: 15, markup: 0 })], + }); + expect(patch.selling_price).toBe(15); + expect(patch.markup).toBe(50); + }); + + it("is no_match with no price keys when the candidate has no cost", () => { + const patch = buildLinePatch({ + ...base, + item: line(), + decision: decision(), + candidates: [cand({ cost_price: 0 })], + }); + const fill = patch.ai_fill as QuoteItemAiFill; + expect(fill.status).toBe("no_match"); + for (const k of ["cost_price", "selling_price", "markup", "supplier", "pricing_source"]) { + expect(patch).not.toHaveProperty(k); + } + expect(fill).toMatchObject({ + stock_item_id: null, + cost_price: 0, + selling_price: 0, + markup: 0, + supplier_id: "", + }); + }); + + it("is no_match for low confidence and lists up to 5 candidates", () => { + const many = Array.from({ length: 7 }, (_, i) => cand({ id: `s-${i + 1}`, name: `n${i}` })); + const patch = buildLinePatch({ + ...base, + item: line(), + decision: decision({ confidence: "low" }), + candidates: many, + }); + const fill = patch.ai_fill as QuoteItemAiFill; + expect(fill.status).toBe("no_match"); + expect(fill.confidence).toBe("low"); + expect(fill.stock_item_id).toBeNull(); + expect(fill.candidates).toHaveLength(5); + expect(fill.candidates[0]).toEqual({ + id: "s-1", + name: "n0", + sku: "BLT10", + cost_price: 1.89, + selling_price: 3.4, + }); + expect(patch).not.toHaveProperty("cost_price"); + expect(patch).not.toHaveProperty("pricing_source"); + }); + + it("is no_match when the chosen id is not among the candidates", () => { + const patch = buildLinePatch({ + ...base, + item: line(), + decision: decision({ stock_item_id: "not-there" }), + candidates: [cand()], + }); + expect((patch.ai_fill as QuoteItemAiFill).status).toBe("no_match"); + expect(patch).not.toHaveProperty("cost_price"); + }); + + it("is no_match when there is no decision for the line", () => { + const patch = buildLinePatch({ ...base, item: line(), decision: null, candidates: [cand()] }); + expect((patch.ai_fill as QuoteItemAiFill).status).toBe("no_match"); + }); + + it("sets pricing_notes on no_match only when empty", () => { + const empty = buildLinePatch({ ...base, item: line(), decision: null, candidates: [cand()] }); + expect(empty.pricing_notes).toBe("AI: no confident POS match — see closest items."); + const kept = buildLinePatch({ + ...base, + item: line({ pricing_notes: "Rep note" }), + decision: null, + candidates: [cand()], + }); + expect(kept).not.toHaveProperty("pricing_notes"); + }); + + it("records no_candidates", () => { + const patch = buildLinePatch({ + ...base, + item: line(), + decision: null, + candidates: [], + status: "no_candidates", + }); + const fill = patch.ai_fill as QuoteItemAiFill; + expect(fill.status).toBe("no_candidates"); + expect(fill.candidates).toEqual([]); + expect(patch).not.toHaveProperty("cost_price"); + expect(patch.pricing_notes).toBe("AI: no confident POS match — see closest items."); + }); + + it("records error without touching prices or notes", () => { + const patch = buildLinePatch({ + ...base, + item: line(), + decision: null, + candidates: [cand()], + status: "error", + }); + const fill = patch.ai_fill as QuoteItemAiFill; + expect(fill.status).toBe("error"); + expect(fill.attempted_at).toBe(NOW.toISOString()); + for (const k of [ + "cost_price", + "selling_price", + "markup", + "supplier", + "pricing_source", + "pricing_notes", + ]) { + expect(patch).not.toHaveProperty(k); + } + }); +}); diff --git a/src/lib/server/quote-assistant-core.ts b/src/lib/server/quote-assistant-core.ts new file mode 100644 index 0000000..1a51294 --- /dev/null +++ b/src/lib/server/quote-assistant-core.ts @@ -0,0 +1,227 @@ +/** + * quote-assistant-core — pure decision logic for the AI quote assistant. + * + * No I/O here: these functions decide which quote lines the AI may touch, + * parse/validate what the AI returned, and build the PocketBase patch to + * write. The runner (later task) does the fetching, the AI call and the + * writing, and re-reads each line with `isLineStillWritable` right before + * writing. + * + * Guarantees encoded here: + * - the AI never writes a line a person has priced/edited/processed; + * - prices come only from the chosen POS candidate (never from the AI); + * - the POS SKU stays in `ai_fill`, never on the line's own `sku`; + * - `is_processed` / `user_edited` are never set. + */ +import type { + AiFillCandidate, + AiFillConfidence, + QuoteItem, + QuoteItemAiFill, +} from "@/lib/types"; +import type { StockCandidate } from "@/lib/server/quote-assistant-shortlist"; +import { + DEFAULT_MARKUP_MULTIPLIER, + DEFAULT_MARKUP_PERCENT, + round2, +} from "@/lib/server/stock-default-markup-service"; + +export const ELIGIBLE_QUOTE_STATUSES = ["new", "follow_up", "claimed"] as const; +export const ERROR_RETRY_MS = 60 * 60 * 1000; + +const MAX_LISTED_CANDIDATES = 5; +const NO_MATCH_NOTE = "AI: no confident POS match — see closest items."; + +export interface AiLineDecision { + quote_item_id: string; + stock_item_id: string | null; + confidence: AiFillConfidence | null; + reason: string; + doubts: string[]; +} + +/** True when the line carries any price (cost or selling). */ +function hasPrice(item: QuoteItem): boolean { + return (Number(item.cost_price) || 0) > 0 || (Number(item.selling_price) || 0) > 0; +} + +/** A person has not touched the line and no price is on it. */ +export function isLineStillWritable(item: QuoteItem): boolean { + return !hasPrice(item) && !item.user_edited && !item.is_processed; +} + +/** Should this line be sent to the AI on this run? */ +export function isLineEligible( + item: QuoteItem, + opts: { force?: boolean; now: Date }, +): boolean { + if (!isLineStillWritable(item)) return false; + if (item.pricing_source === "manual") return false; + const description = (item.description || "").trim(); + if (!description) return false; + if (item.moved_to_quote) return false; + + const prior = item.ai_fill; + if (!prior || opts.force) return true; + + if (prior.status === "error") { + const at = Date.parse(prior.attempted_at); + // Unparseable timestamp: don't wedge the line forever. + return Number.isNaN(at) || opts.now.getTime() - at > ERROR_RETRY_MS; + } + + // Tried before (filled-then-cleared, no_match, no_candidates): only retry + // when the description changed since the attempt. + return prior.description_snapshot !== item.description; +} + +const CONFIDENCE_RANK: Record = { low: 1, medium: 2, high: 3 }; + +export function meetsConfidence( + c: AiFillConfidence | null, + min: "high" | "medium", +): boolean { + if (!c) return false; + return CONFIDENCE_RANK[c] >= CONFIDENCE_RANK[min]; +} + +function asConfidence(v: unknown): AiFillConfidence | null { + return v === "high" || v === "medium" || v === "low" ? v : null; +} + +/** + * Validate the AI's JSON. Accepts `{ decisions: [...] }` or a bare array. + * Unknown line ids are dropped, the first decision per id wins, and + * anything malformed degrades to null/empty rather than throwing. + */ +export function parseDecisions( + raw: unknown, + requestedIds: ReadonlySet, +): Map { + const out = new Map(); + const list = Array.isArray(raw) + ? raw + : raw && typeof raw === "object" && Array.isArray((raw as { decisions?: unknown }).decisions) + ? (raw as { decisions: unknown[] }).decisions + : []; + + for (const entry of list) { + if (!entry || typeof entry !== "object") continue; + const e = entry as Record; + const id = typeof e.quote_item_id === "string" ? e.quote_item_id : ""; + if (!id || !requestedIds.has(id) || out.has(id)) continue; + out.set(id, { + quote_item_id: id, + stock_item_id: + typeof e.stock_item_id === "string" && e.stock_item_id ? e.stock_item_id : null, + confidence: asConfidence(e.confidence), + reason: typeof e.reason === "string" ? e.reason : "", + doubts: Array.isArray(e.doubts) + ? e.doubts.filter((d): d is string => typeof d === "string") + : [], + }); + } + return out; +} + +function listCandidates(candidates: StockCandidate[]): AiFillCandidate[] { + return candidates.slice(0, MAX_LISTED_CANDIDATES).map((c) => ({ + id: c.id, + name: c.name, + sku: c.sku, + cost_price: c.cost_price, + selling_price: c.selling_price, + })); +} + +/** + * Build the PB patch for one line. Fills only when the decision picked a + * real candidate with a cost at/above the minimum confidence; otherwise + * records `no_match` (or the forced `no_candidates` / `error` status) and + * leaves every price field alone. + * + * For non-fill statuses `ai_fill.stock_item_id` is always null and the + * numeric fields are 0 — the closest options live in `ai_fill.candidates`. + */ +export function buildLinePatch(args: { + item: QuoteItem; + decision: AiLineDecision | null; + candidates: StockCandidate[]; + minConfidence: "high" | "medium"; + model: string; + now: Date; + status?: "no_candidates" | "error"; +}): Record { + const { item, decision, candidates, minConfidence, model, now, status } = args; + + const common = { + confidence: decision?.confidence ?? null, + reason: decision?.reason ?? "", + doubts: decision?.doubts ?? [], + candidates: listCandidates(candidates), + description_snapshot: item.description, + model, + attempted_at: now.toISOString(), + }; + + const chosen = + !status && decision?.stock_item_id + ? candidates.find((c) => c.id === decision.stock_item_id) + : undefined; + const cost = chosen ? round2(Number(chosen.cost_price) || 0) : 0; + + if (chosen && cost > 0 && meetsConfidence(decision?.confidence ?? null, minConfidence)) { + const posSelling = round2(Number(chosen.selling_price) || 0); + const fromDefault = posSelling <= 0; + const selling = fromDefault ? round2(cost * DEFAULT_MARKUP_MULTIPLIER) : posSelling; + const posMarkup = Number(chosen.markup) || 0; + const markup = fromDefault + ? DEFAULT_MARKUP_PERCENT + : posMarkup > 0 + ? posMarkup + : round2((selling / cost - 1) * 100); + + const aiFill: QuoteItemAiFill = { + ...common, + status: "filled", + stock_item_id: chosen.id, + stock_item_name: chosen.name, + stock_item_sku: chosen.sku, + cost_price: cost, + selling_price: selling, + markup, + supplier_id: chosen.supplier_id, + ...(fromDefault ? { price_from_default_markup: true } : {}), + }; + const patch: Record = { + cost_price: cost, + selling_price: selling, + markup, + pricing_source: "ai_pos", + pricing_notes: + `AI: matched POS '${chosen.name}' (${decision?.confidence})` + + (fromDefault ? ". Selling price from default markup." : ""), + ai_fill: aiFill, + }; + if (chosen.supplier_id) patch.supplier = chosen.supplier_id; + return patch; + } + + const aiFill: QuoteItemAiFill = { + ...common, + status: status ?? "no_match", + stock_item_id: null, + stock_item_name: "", + stock_item_sku: "", + cost_price: 0, + selling_price: 0, + markup: 0, + supplier_id: "", + }; + const patch: Record = { ai_fill: aiFill }; + // Don't overwrite a rep's note, and an error says nothing about the match. + if (status !== "error" && !(item.pricing_notes || "").trim()) { + patch.pricing_notes = NO_MATCH_NOTE; + } + return patch; +}