feat(quote-assistant): eligibility, patch building, review helpers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
0adf23ef4e
commit
29144943d0
4 changed files with 813 additions and 0 deletions
141
src/lib/quote-assistant-review.test.ts
Normal file
141
src/lib/quote-assistant-review.test.ts
Normal 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("");
|
||||
});
|
||||
});
|
||||
65
src/lib/quote-assistant-review.ts
Normal file
65
src/lib/quote-assistant-review.ts
Normal 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" : "";
|
||||
}
|
||||
380
src/lib/server/quote-assistant-core.test.ts
Normal file
380
src/lib/server/quote-assistant-core.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
227
src/lib/server/quote-assistant-core.ts
Normal file
227
src/lib/server/quote-assistant-core.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue