feat(quote-assistant): eligibility, patch building, review helpers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-16 19:17:47 +02:00
parent 0adf23ef4e
commit 29144943d0
4 changed files with 813 additions and 0 deletions

View file

@ -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> = {}): 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("");
});
});

View file

@ -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<QuoteItem, "ai_fill" | "pricing_source">;
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" : "";
}

View file

@ -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> = {}): 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> = {}): 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> = {}): 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> = {}): 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<QuoteItem>][])("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<QuoteItem>[])("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);
}
});
});

View file

@ -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<AiFillConfidence, number> = { 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<string>,
): Map<string, AiLineDecision> {
const out = new Map<string, AiLineDecision>();
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<string, unknown>;
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<string, unknown> {
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<string, unknown> = {
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<string, unknown> = { 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;
}