fix(pos): never sell or list an item at R0; customer lines go to the price list

Owner's rule: the POS counter only carries priced stock.

- POS catalogue lists only active stock items with selling_price > 0
  (POS_CATALOG_FILTER); checkout refuses any line priced at R0 or less.
- New stock order now gives a new stock item the PO line's cost and the
  default 80% markup. It used to drop the cost, which is how 157 stock
  items reached the till at R0.
- Collection Confirm "Add to POS" becomes "Add to price list": customer
  lines go to price_list at cost + 80% (needs SKU, cost and supplier),
  never into stock_items. Route renamed to add-to-price-list.
- scripts/2026-10-30-pos-zero-price-cleanup.ts: priced 79 items from their
  PO line cost, switched off 48 with no cost and no stock, left 30 stocked
  ones active for costing (already applied on live PB).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-31 09:56:03 +02:00
parent 45529731a7
commit afdfbce71d
18 changed files with 488 additions and 177 deletions

View file

@ -221,9 +221,9 @@ Settings → AI → Order assistant.
Runbook: `docs/collection-ai-checks.md`. Files: `src/lib/server/collection-ai-{read,pair,findings,service,prompt}.ts`
(vision read, pairing call, document-level findings, orchestration, default prompt) and `src/lib/collection-ai-ticks.ts`
(pure, client-safe tick logic). Routes: `POST /api/v1/collection-confirm/ai/tick` (n8n, agent auth),
`.../ai/check` and `.../add-to-pos` (browser session), MCP `collection_confirm.ai_check`. Settings live in Settings → AI → Collection checks.
`.../ai/check` and `.../add-to-price-list` (browser session), MCP `collection_confirm.ai_check`. Settings live in Settings → AI → Collection checks.
- **Manual first:** the Add to POS button and the Check with AI button are manual; the tick only reads and checks, nothing else.
- **Manual first:** the Add to price list button and the Check with AI button are manual; the tick only reads and checks, nothing else.
- The AI writes **only** `ai_check`, `collection_doc_reads` rows and an **empty** SKU when it is sure. It **never writes a price** (owner's rule, 2026-10-30: it misread a discount that wasn't on the invoice) — a differing cost is a red Price tick for a person. It never overwrites a person's SKU, confirms a collection, or changes status.
- **Auto-push** (`collection-auto-push-service.ts`, runbook section "Auto-push"): a purple card is confirmed by the tick itself and logged to `ai_settings.collection_auto_push_log`; off by default, needs `npm run pb:migrate:collection-auto-push`. Change what counts as pushable in `planAutoPush`, never by calling confirm elsewhere.
- The service refuses to run until `ai_check` exists (PB drops unknown fields silently); `ai_check` is in `IGNORED_FIELDS` of the audit hook.
@ -349,3 +349,14 @@ routes under `/api/v1/delivery-pool` (browser session), page `src/app/(app)/deli
a DN is closed only when none of its lines is still open.
- Manual live step: `npm run pb:migrate:delivery-pool` before deploying (the service 503s
until the fields exist). The old `/deliveries` page stays until the office switches over.
# The POS counter never sells at R0
The POS (`/api/v1/pos/catalog`) lists `stock_items` matching
`POS_CATALOG_FILTER` in `pos-service.ts`: active **and** `selling_price > 0`.
`checkoutSale` refuses any line with `unit_price <= 0`. Owner's rule
(2026-10-30): only stock (and over-collected surplus) goes on the counter.
Customer-order lines go to the `price_list` catalogue instead
(`addRowsToPriceList`), never straight into `stock_items`. Any path that creates
a stock item with a cost must price it with `applyDefaultMarkupToPatch`
(80%); without a cost it stays off the till until someone prices it.

View file

@ -18,10 +18,13 @@ per line (SKU, quantity, description, price), plus a POS column and an
A failed photo read is retried only by the **Check with AI** button, not
by the tick.
- Lines with no AI check look exactly as before, with no ticks.
- **Add to POS** is a manual feature (per line, or "Confirm and add to
POS" on the card). It creates a `stock_items` row with zero stock. A line
needs a SKU first (the button is disabled without one); the toast reports
lines added, failed and still needing a SKU.
- **Add to price list** (was "Add to POS", changed 2026-10-30) is a manual
feature (per line, or "Confirm and add to price list" on the card). A
customer line goes to the `price_list` catalogue at cost + 80%, never to
the POS: only stock goes on the counter, and nothing there may be R0. A
line needs a SKU, a cost and a supplier first; the toast reports lines
added, failed and still needing a SKU / cost / supplier. The buttons are
currently hidden on the card (see `collection-return-to-pool.md`).
What the AI writes: only `ai_check` on the order/stock line, the document
reads and an **empty SKU** when it is sure. It never writes a price. It never overwrites a SKU a person set, never confirms a collection,

View file

@ -17,7 +17,7 @@ booking queue until collection is confirmed again.
Items already invoiced, dispatched, delivered or booked into stock are
refused. Refused lines are reported separately if some lines can return.
The browser reloads the queue after the operation. Normal confirmation
stays available; Add to POS controls are hidden but their underlying
stays available; Add to price list (formerly Add to POS) controls are hidden but their underlying
handlers and endpoints remain.
## Deployment

View file

@ -0,0 +1,177 @@
/**
* Clear R0 items off the POS counter (owner's rule, 2026-10-30: nothing on
* the till may have a zero selling price).
*
* For every ACTIVE `stock_items` row whose `selling_price` is 0 or empty:
*
* 1. Find a cost: the row's own `cost_price`, else the newest
* `stock_purchase_items.cost_price` > 0 for that item. (New stock
* order used to create the stock item without the PO line's cost, so
* most of these rows have a cost on their PO line.)
* 2. Cost found → `selling_price = round(cost × 1.80, 2)`, `markup = 80`,
* and `cost_price = cost` when the row had none. Cashiers can change
* the price later.
* 3. No cost anywhere and nothing on hand → `is_active = false` (off the
* POS; reversible, the row is not deleted). With stock on hand the row
* stays active so it is still on the stock page to be costed; the POS
* filter keeps it off the till anyway (owner's call, 2026-10-31).
*
* The POS catalogue now also filters `selling_price > 0`, so this is the
* data side of the same rule. Rows with a price but no cost are reported,
* never changed.
*
* Run:
* npx tsx scripts/2026-10-30-pos-zero-price-cleanup.ts # dry-run
* npx tsx scripts/2026-10-30-pos-zero-price-cleanup.ts --apply # write
* ... --report=/path/report.csv # CSV of every row
*
* Requires .env.local with NEXT_PUBLIC_POCKETBASE_URL, PB_ADMIN_EMAIL,
* PB_ADMIN_PASSWORD.
*/
import "./_env"; // side-effect import: loads .env.local before anything else
import { writeFileSync } from "node:fs";
import { getAdminPocketBase } from "../src/lib/server/pocketbase-admin";
import {
DEFAULT_MARKUP_MULTIPLIER,
DEFAULT_MARKUP_PERCENT,
round2,
} from "../src/lib/server/stock-default-markup-service";
type StockRow = {
id: string;
sku: string;
name: string;
cost_price: number | null;
selling_price: number | null;
current_qty: number | null;
notes: string;
};
type Plan =
| { kind: "price"; row: StockRow; cost: number; costFrom: "item" | "po_line"; selling: number }
| { kind: "deactivate"; row: StockRow }
| { kind: "keep"; row: StockRow };
const apply = process.argv.includes("--apply");
const reportArg = process.argv.find((a) => a.startsWith("--report="));
const reportPath = reportArg ? reportArg.slice("--report=".length) : null;
async function main(): Promise<void> {
const pb = await getAdminPocketBase();
const zero = await pb.collection("stock_items").getFullList<StockRow>({
filter: "is_active = true && (selling_price = 0 || selling_price = null)",
fields: "id,sku,name,cost_price,selling_price,current_qty,notes",
sort: "-created",
});
const pricedNoCost = await pb.collection("stock_items").getList(1, 1, {
filter: "is_active = true && selling_price > 0 && (cost_price = 0 || cost_price = null)",
fields: "id",
});
// Newest positive PO-line cost per stock item, for rows with no cost.
const needCost = zero.filter((r) => !(Number(r.cost_price ?? 0) > 0)).map((r) => r.id);
const lineCost = new Map<string, number>();
for (let i = 0; i < needCost.length; i += 40) {
const chunk = needCost.slice(i, i + 40);
const lines = await pb
.collection("stock_purchase_items")
.getFullList<{ stock_item: string; cost_price: number | null }>({
filter: `cost_price > 0 && (${chunk.map((id) => `stock_item = "${id}"`).join(" || ")})`,
fields: "stock_item,cost_price",
sort: "-created",
});
for (const l of lines) {
if (!lineCost.has(l.stock_item)) lineCost.set(l.stock_item, Number(l.cost_price));
}
}
const plans: Plan[] = zero.map((row) => {
const own = Number(row.cost_price ?? 0);
if (own > 0) {
return { kind: "price", row, cost: own, costFrom: "item", selling: round2(own * DEFAULT_MARKUP_MULTIPLIER) };
}
const fromLine = lineCost.get(row.id);
if (fromLine && fromLine > 0) {
return {
kind: "price",
row,
cost: fromLine,
costFrom: "po_line",
selling: round2(fromLine * DEFAULT_MARKUP_MULTIPLIER),
};
}
return Number(row.current_qty ?? 0) !== 0 ? { kind: "keep", row } : { kind: "deactivate", row };
});
const priced = plans.filter((p): p is Extract<Plan, { kind: "price" }> => p.kind === "price");
const off = plans.filter((p): p is Extract<Plan, { kind: "deactivate" }> => p.kind === "deactivate");
const kept = plans.filter((p) => p.kind === "keep");
console.log(`Mode: ${apply ? "APPLY" : "dry-run (no writes)"}`);
console.log(`Active stock items with a zero selling price: ${zero.length}`);
console.log(` price at cost + ${DEFAULT_MARKUP_PERCENT}%: ${priced.length}`);
console.log(` cost already on the item: ${priced.filter((p) => p.costFrom === "item").length}`);
console.log(` cost taken from the stock PO line: ${priced.filter((p) => p.costFrom === "po_line").length}`);
console.log(` no cost, nothing on hand, switch off (is_active = false): ${off.length}`);
console.log(` no cost but stock on hand, left active (hidden from the till): ${kept.length}`);
console.log(`Info only, not changed: priced items with no cost: ${pricedNoCost.totalItems}`);
console.log("");
for (const p of priced.slice(0, 15)) {
console.log(` price ${p.row.sku || "(no sku)"} ${p.row.name} cost ${p.cost} (${p.costFrom}) -> sell ${p.selling}`);
}
for (const p of off.slice(0, 15)) {
console.log(` off ${p.row.sku || "(no sku)"} ${p.row.name} qty ${p.row.current_qty ?? 0}`);
}
if (reportPath) {
const esc = (v: unknown) => `"${String(v ?? "").replace(/"/g, '""')}"`;
const lines = ["action,id,sku,name,current_qty,cost,cost_from,new_selling_price"];
for (const p of plans) {
lines.push(
p.kind === "price"
? ["price", p.row.id, p.row.sku, p.row.name, p.row.current_qty ?? 0, p.cost, p.costFrom, p.selling].map(esc).join(",")
: [p.kind, p.row.id, p.row.sku, p.row.name, p.row.current_qty ?? 0, "", "", ""].map(esc).join(","),
);
}
writeFileSync(reportPath, lines.join("\n") + "\n");
console.log(`\nReport: ${reportPath}`);
}
if (!apply) return;
let ok = 0;
const failed: string[] = [];
const stamp = "2026-10-30";
for (const p of plans) {
if (p.kind === "keep") continue;
try {
if (p.kind === "price") {
const patch: Record<string, unknown> = {
selling_price: p.selling,
markup: DEFAULT_MARKUP_PERCENT,
};
if (p.costFrom === "po_line") patch.cost_price = p.cost;
await pb.collection("stock_items").update(p.row.id, patch);
} else {
const note = `${stamp}: taken off the POS, no cost or selling price.`;
await pb.collection("stock_items").update(p.row.id, {
is_active: false,
notes: p.row.notes ? `${p.row.notes}\n${note}` : note,
});
}
ok += 1;
} catch (err) {
failed.push(`${p.row.id} ${p.row.sku}: ${err instanceof Error ? err.message : String(err)}`);
}
}
console.log(`\nWrote ${ok} rows, ${failed.length} failed.`);
for (const f of failed) console.log(` FAILED ${f}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View file

@ -51,7 +51,7 @@ const rows: ConfirmRow[] = [
aiRow("row-c", { in_pos: null }),
];
let addResults: unknown[] = [{ row_id: "row-a", status: "created", stock_item_id: "s" }];
let addResults: unknown[] = [{ row_id: "row-a", status: "added", stock_item_id: null }];
function setupFetch(opts: { confirmOk: boolean }) {
return vi.spyOn(window, "fetch").mockImplementation(async (input) => {
@ -62,7 +62,7 @@ function setupFetch(opts: { confirmOk: boolean }) {
? jsonResponse({ updatedIds: [] })
: jsonResponse({ error: "boom" }, { ok: false, status: 500 });
}
if (url === "/api/v1/collection-confirm/add-to-pos") {
if (url === "/api/v1/collection-confirm/add-to-price-list") {
return jsonResponse({ results: addResults });
}
if (url === "/api/v1/collection-confirm/ai/check") {
@ -85,13 +85,13 @@ async function openConfirm() {
}
const addCalls = (spy: ReturnType<typeof setupFetch>) =>
spy.mock.calls.filter(([arg]) => fetchUrl(arg) === "/api/v1/collection-confirm/add-to-pos");
spy.mock.calls.filter(([arg]) => fetchUrl(arg) === "/api/v1/collection-confirm/add-to-price-list");
describe("CollectionConfirmPage AI handlers", () => {
beforeEach(() => {
vi.restoreAllMocks();
for (const fn of Object.values(toastMock)) fn.mockClear();
addResults = [{ row_id: "row-a", status: "created", stock_item_id: "s" }];
addResults = [{ row_id: "row-a", status: "added", stock_item_id: null }];
});
it("hides Add to POS and keeps normal confirmation", async () => {
@ -100,7 +100,7 @@ describe("CollectionConfirmPage AI handlers", () => {
render(<Page />);
await screen.findByTestId("po-card-PO-1");
await user.click(screen.getByTestId("po-card-toggle-PO-1"));
expect(screen.queryByRole("button", { name: /add to pos/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /add to (pos|price list)/i })).not.toBeInTheDocument();
expect(screen.getByTestId("po-card-confirm-PO-1")).toBeInTheDocument();
});

View file

@ -12,7 +12,7 @@ import { ConfirmStockDialog } from "@/components/collection-confirm/confirm-stoc
import { InvoiceViewer } from "@/components/collection-confirm/invoice-viewer";
import { PoCard } from "@/components/collection-confirm/po-card";
import type { Attachment } from "@/components/collection-confirm/photo-strip";
import { aiInvoiceDefaults, posResultToast } from "@/lib/collection-ai-card";
import { aiInvoiceDefaults, priceListResultToast } from "@/lib/collection-ai-card";
import { getClientIdentity } from "@/lib/dev-identity";
import { getPocketBase } from "@/lib/pocketbase";
import type { ConfirmRow } from "@/lib/server/collection-confirm-service";
@ -74,7 +74,7 @@ export default function CollectionConfirmPage() {
const [confirmState, setConfirmState] = useState<{
poNumber: string;
rowIds: string[];
/** Set by "Confirm and add to POS": customer rows to add once the confirm succeeds. */
/** Set by "Confirm and add to price list": customer rows to add once the confirm succeeds. */
addToPosRowIds?: string[];
/** Snapshotted when the dialog opens so a reload can't remount it and wipe edits. */
defaultValues?: { number: string; date?: string };
@ -380,12 +380,12 @@ export default function CollectionConfirmPage() {
};
}, []);
/** POST the rows to the add-to-POS route and toast the outcome (no reload). */
/** POST the rows to the add-to-price-list route and toast the outcome (no reload). */
const addRowsToPos = useCallback(
async (rowIds: string[]) => {
if (rowIds.length === 0) return;
try {
const response = await fetch("/api/v1/collection-confirm/add-to-pos", {
const response = await fetch("/api/v1/collection-confirm/add-to-price-list", {
method: "POST",
headers: authedJsonHeaders(),
body: JSON.stringify({ row_ids: rowIds }),
@ -395,9 +395,9 @@ export default function CollectionConfirmPage() {
results?: Array<{ status: string }>;
};
if (!response.ok) {
throw new Error(data.error || `Failed to add to the POS (HTTP ${response.status})`);
throw new Error(data.error || `Failed to add to the price list (HTTP ${response.status})`);
}
const summary = posResultToast(data.results ?? []);
const summary = priceListResultToast(data.results ?? []);
if (summary.level === "success") toast.success(summary.message);
else toast.warning(summary.message);
} catch (err) {
@ -565,7 +565,7 @@ export default function CollectionConfirmPage() {
const addToPosRowIds = confirmState.addToPosRowIds ?? [];
setConfirmState(null);
setCostOverrides({});
// Only after the confirm succeeded: add the card's not-in-POS lines.
// Only after the confirm succeeded: add the card's not-in-POS lines to the price list.
await addRowsToPos(addToPosRowIds);
await load();
} catch (err) {

View file

@ -30,6 +30,7 @@ import { toast } from "sonner";
import getPocketBase from "@/lib/pocketbase";
import { cn } from "@/lib/utils";
import type { StockItem, Supplier } from "@/lib/types";
import { applyDefaultMarkupToPatch } from "@/lib/server/stock-default-markup-service";
/**
* /stock-orders/new — create a draft stock_purchase.
@ -399,16 +400,23 @@ export default function NewStockOrderPage() {
for (const it of committed) {
if (it.creatingNewStockItem) {
try {
const newSi = await pb.collection("stock_items").create({
sku: it.stockItemSku.trim(),
name: it.stockItemName.trim(),
description: it.description.trim(),
current_qty: 0,
is_active: true,
// The picked supplier is the new stock_item's default
// supplier. /stock detail page can change it later.
supplier: supplierId,
});
// The line's cost is the item's cost; the default 80%
// markup prices it for the till. Without a cost it gets
// no selling price and the POS keeps it off the counter.
const cost = it.costPrice ?? 0;
const newSi = await pb.collection("stock_items").create(
applyDefaultMarkupToPatch({
sku: it.stockItemSku.trim(),
name: it.stockItemName.trim(),
description: it.description.trim(),
current_qty: 0,
is_active: true,
// The picked supplier is the new stock_item's default
// supplier. /stock detail page can change it later.
supplier: supplierId,
...(cost > 0 ? { cost_price: cost } : {}),
}),
);
resolvedStockItemIds.push(newSi.id);
newlyCreated.push(newSi as unknown as StockItem);
} catch (err: unknown) {

View file

@ -1,9 +1,9 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { NextRequest } from "next/server";
const addRowsToPos = vi.fn();
const addRowsToPriceList = vi.fn();
vi.mock("@/lib/server/collection-confirm-service", () => ({
addRowsToPos: (...a: unknown[]) => addRowsToPos(...a),
addRowsToPriceList: (...a: unknown[]) => addRowsToPriceList(...a),
}));
const getBrowserSessionUser = vi.fn();
@ -14,7 +14,7 @@ vi.mock("@/lib/server/browser-session", () => ({
import { POST } from "./route";
function makeReq(body?: unknown) {
return new NextRequest("http://localhost/api/v1/collection-confirm/add-to-pos", {
return new NextRequest("http://localhost/api/v1/collection-confirm/add-to-price-list", {
method: "POST",
headers: { authorization: "Bearer tok", "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
@ -26,14 +26,14 @@ beforeEach(() => {
getBrowserSessionUser.mockResolvedValue({ id: "u1", user_type: null, name: "Sam" });
});
describe("POST /api/v1/collection-confirm/add-to-pos", () => {
describe("POST /api/v1/collection-confirm/add-to-price-list", () => {
it("401s without a valid session", async () => {
getBrowserSessionUser.mockRejectedValue(
Object.assign(new Error("Not authenticated."), { status: 401 }),
);
const res = await POST(makeReq({ row_ids: ["a"] }));
expect(res.status).toBe(401);
expect(addRowsToPos).not.toHaveBeenCalled();
expect(addRowsToPriceList).not.toHaveBeenCalled();
});
it.each([
@ -44,14 +44,14 @@ describe("POST /api/v1/collection-confirm/add-to-pos", () => {
])("400s for %s", async (_label, body) => {
const res = await POST(makeReq(body));
expect(res.status).toBe(400);
expect(addRowsToPos).not.toHaveBeenCalled();
expect(addRowsToPriceList).not.toHaveBeenCalled();
});
it("returns the service results", async () => {
const results = [{ row_id: "a", status: "created", stock_item_id: "si-1" }];
addRowsToPos.mockResolvedValue(results);
const results = [{ row_id: "a", status: "added", stock_item_id: null }];
addRowsToPriceList.mockResolvedValue(results);
const res = await POST(makeReq({ row_ids: ["a"] }));
expect(addRowsToPos).toHaveBeenCalledWith(["a"]);
expect(addRowsToPriceList).toHaveBeenCalledWith(["a"]);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ results });
});
@ -61,14 +61,14 @@ describe("POST /api/v1/collection-confirm/add-to-pos", () => {
{ row_id: "a", status: "failed", stock_item_id: null, error: "boom" },
{ row_id: "b", status: "needs_sku", stock_item_id: null },
];
addRowsToPos.mockResolvedValue(results);
addRowsToPriceList.mockResolvedValue(results);
const res = await POST(makeReq({ row_ids: ["a", "b"] }));
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ results });
});
it("500s when the service throws", async () => {
addRowsToPos.mockRejectedValue(new Error("boom"));
addRowsToPriceList.mockRejectedValue(new Error("boom"));
const res = await POST(makeReq({ row_ids: ["a"] }));
expect(res.status).toBe(500);
});

View file

@ -1,18 +1,21 @@
import { NextRequest, NextResponse } from "next/server";
import { getBrowserSessionUser } from "@/lib/server/browser-session";
import { addRowsToPos } from "@/lib/server/collection-confirm-service";
import { addRowsToPriceList } from "@/lib/server/collection-confirm-service";
/**
* POST /api/v1/collection-confirm/add-to-pos
* POST /api/v1/collection-confirm/add-to-price-list
*
* "Add to POS" for Collection Confirm rows whose supplier code is not a
* stock item yet. Body: `{ row_ids: string[] }` (1-100 ids).
* "Add to price list" for Collection Confirm customer rows whose supplier
* code is not a stock item. They go to the `price_list` catalogue at cost
* + 80%, never straight to the POS (only stock goes on the counter).
* Body: `{ row_ids: string[] }` (1-100 ids).
*
* Auth: browser session (PB token forwarded in `Authorization`).
*
* Response (200 even when some rows fail): `{ results: [{ row_id, status,
* stock_item_id, error? }] }` with status `created` | `already_in_pos` |
* `not_found` | `needs_sku` (no SKU, nothing created) | `failed` (+ `error`).
* stock_item_id, error? }] }` with status `added` | `already_in_pos` |
* `not_found` | `needs_sku` | `needs_cost` | `needs_supplier` (nothing
* written) | `failed` (+ `error`).
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@ -35,10 +38,10 @@ export async function POST(req: NextRequest) {
{ status: 400 },
);
}
const results = await addRowsToPos(ids as string[]);
const results = await addRowsToPriceList(ids as string[]);
return NextResponse.json({ results });
} catch (err) {
const message = err instanceof Error ? err.message : "Add to POS failed";
const message = err instanceof Error ? err.message : "Add to price list failed";
const status = (err as Error & { status?: number }).status || 500;
return NextResponse.json({ error: message }, { status });
}

View file

@ -25,7 +25,7 @@ describe("ItemsTable AI layer", () => {
expect(headers).toEqual(["SKU", "Description", "Ord", "Coll", "Cost", "Surplus"]);
expect(screen.queryByLabelText(/^SKU: /)).not.toBeInTheDocument();
expect(screen.queryByTestId("ai-sku-robot-row-p")).not.toBeInTheDocument();
expect(screen.queryByText(/add to pos/i)).not.toBeInTheDocument();
expect(screen.queryByText(/add to price list/i)).not.toBeInTheDocument();
});
it("keeps the original six columns and shows POS status under the description when no row has ai_check", () => {
@ -39,7 +39,7 @@ describe("ItemsTable AI layer", () => {
expect(screen.getByText(/tap the pencil/i).closest("td")).toHaveAttribute("colspan", "6");
const extra = screen.getByTestId(`ai-pos-row-${plain.id}`);
expect(extra.closest("td")?.textContent).toContain(plain.description ?? "");
expect(extra.textContent).toContain("Add to POS");
expect(extra.textContent).toContain("Add to price list");
expect(container.querySelector('[data-testid^="ai-ticks-"]')).toBeNull();
});
@ -130,22 +130,32 @@ describe("ItemsTable AI layer", () => {
render(<ItemsTable rows={[row]} onCostChange={noop} onSkuChange={noop} onAddToPos={noop} />);
const link = screen.getByRole("link", { name: /in pos/i });
expect(link).toHaveAttribute("href", "/stock/stock-77");
expect(screen.queryByRole("button", { name: /add to pos/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /add to price list/i })).not.toBeInTheDocument();
});
it("calls onAddToPos with the row id when not in POS", async () => {
it("calls onAddToPos (add to price list) with the row id when not in POS", async () => {
const onAdd = vi.fn();
render(<ItemsTable rows={[makeAiRow()]} onCostChange={noop} onSkuChange={noop} onAddToPos={onAdd} />);
await userEvent.click(screen.getByRole("button", { name: /add to pos/i }));
await userEvent.click(screen.getByRole("button", { name: /add to price list/i }));
expect(onAdd).toHaveBeenCalledWith("row-1");
});
it("disables Add to POS on a row without a SKU (Set a SKU first)", async () => {
it("disables Add to price list on a row without a cost (Set a cost first)", async () => {
const onAdd = vi.fn();
render(
<ItemsTable rows={[makeAiRow({ cost_price: 0 })]} onCostChange={noop} onSkuChange={noop} onAddToPos={onAdd} />,
);
const button = screen.getByRole("button", { name: /add to price list/i });
expect(button).toBeDisabled();
expect(button).toHaveAttribute("title", "Set a cost first");
});
it("disables Add to price list on a row without a SKU (Set a SKU first)", async () => {
const onAdd = vi.fn();
render(
<ItemsTable rows={[makeAiRow({ sku: "" })]} onCostChange={noop} onSkuChange={noop} onAddToPos={onAdd} />,
);
const button = screen.getByRole("button", { name: /add to pos/i });
const button = screen.getByRole("button", { name: /add to price list/i });
expect(button).toBeDisabled();
expect(button).toHaveAttribute("title", "Set a SKU first");
await userEvent.click(button);

View file

@ -34,7 +34,7 @@ type Props = {
* pencil can hide it.
*/
onQtyChange?: (id: string, qty: number) => void;
/** "Add to POS" click for a line that is not in the POS yet. */
/** "Add to price list" click for a customer line that is not in the POS (only stock goes on the till). */
onAddToPos?: (rowId: string) => void;
onReturnToPool?: (row: ConfirmRow) => void;
/**
@ -121,7 +121,7 @@ function AiTicks({ row }: { row: ConfirmRow }) {
);
}
/** "In POS" (linked) or an "Add to POS" button; nothing when POS status is unknown. */
/** "In POS" (linked) or an "Add to price list" button; nothing when POS status is unknown. */
function PosStatus({ row, onAddToPos }: { row: ConfirmRow; onAddToPos?: (rowId: string) => void }) {
if (row.in_pos) {
return row.pos_stock_item_id ? (
@ -137,16 +137,18 @@ function PosStatus({ row, onAddToPos }: { row: ConfirmRow; onAddToPos?: (rowId:
}
if (row.in_pos === false && onAddToPos) {
const hasSku = !!effectiveSku(row).trim();
const hasCost = (row.cost_price ?? 0) > 0;
const blocker = !hasSku ? "Set a SKU first" : !hasCost ? "Set a cost first" : undefined;
return (
<button
type="button"
onClick={() => onAddToPos(row.id)}
disabled={!hasSku}
title={hasSku ? undefined : "Set a SKU first"}
disabled={!!blocker}
title={blocker}
className="rounded border px-1.5 py-0.5 text-xs font-medium hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
data-testid={`add-to-pos-${row.id}`}
>
Add to POS
Add to price list
</button>
);
}

View file

@ -31,7 +31,7 @@ describe("PoCard AI layer", () => {
expect(screen.queryByTestId("ai-doc-chip-PO-1")).not.toBeInTheDocument();
expect(screen.queryByTestId("ai-findings")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /check with ai/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /confirm and add to pos/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /confirm and add to (pos|price list)/i })).not.toBeInTheDocument();
});
it("renders the document chip and opens the extracted lines panel", async () => {
@ -219,7 +219,7 @@ describe("PoCard AI layer", () => {
const { rerender } = render(
<PoCard rows={[makeAiRow({ in_pos: false })]} {...props} onConfirmAndAddToPos={onGo} onAddToPos={onGo} />,
);
expect(screen.queryByRole("button", { name: /add to pos/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /add to (pos|price list)/i })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /^confirm for invoicing$/i })).toBeInTheDocument();
rerender(
@ -229,7 +229,7 @@ describe("PoCard AI layer", () => {
onConfirmAndAddToPos={onGo}
/>,
);
expect(screen.queryByRole("button", { name: /confirm and add to pos/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /confirm and add to (pos|price list)/i })).not.toBeInTheDocument();
});
it("returns one selected line only after confirming the reset", async () => {

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { makeAiRow, makeCheck } from "@/components/collection-confirm/ai-fixtures";
import { aiInvoiceDefaults, cardDocument, cardFindings, cardNotes, posResultToast } from "./collection-ai-card";
import { aiInvoiceDefaults, cardDocument, cardFindings, cardNotes, priceListResultToast } from "./collection-ai-card";
const doc = (read_id: string, findings: string[], doc_date = "2026-10-16") => ({
read_id,
@ -56,20 +56,25 @@ describe("collection-ai-card", () => {
]);
});
it("summarises Add to POS results for the toast", () => {
it("summarises Add to price list results for the toast", () => {
const r = (status: string) => ({ status });
expect(posResultToast([r("created"), r("created")])).toEqual({
expect(priceListResultToast([r("added"), r("added")])).toEqual({
level: "success",
message: "Added 2 items to the POS",
message: "Added 2 items to the price list",
});
expect(posResultToast([r("created")])).toEqual({ level: "success", message: "Added 1 item to the POS" });
expect(posResultToast([r("created"), r("failed"), r("failed"), r("needs_sku")])).toEqual({
level: "warning",
message: "1 added, 2 failed, 1 needs a SKU",
expect(priceListResultToast([r("added"), r("already_in_pos")])).toEqual({
level: "success",
message: "Added 1 item to the price list",
});
expect(posResultToast([r("needs_sku"), r("needs_sku")])).toEqual({
expect(
priceListResultToast([r("added"), r("failed"), r("failed"), r("needs_sku"), r("needs_cost")]),
).toEqual({
level: "warning",
message: "0 added, 2 need a SKU",
message: "1 added, 2 failed, 1 needs a SKU, 1 needs a cost",
});
expect(priceListResultToast([r("needs_cost"), r("needs_cost"), r("needs_supplier")])).toEqual({
level: "warning",
message: "0 added, 2 need a cost, 1 needs a supplier",
});
});

View file

@ -70,20 +70,28 @@ export function aiInvoiceDefaults(
return /^\d{4}-\d{2}-\d{2}$/.test(date) ? { number, date } : { number };
}
/** Toast for an Add to POS response: plain success, or the counts when any row didn't go in. */
export function posResultToast(
/** Toast for an Add to price list response: plain success, or the counts when any row didn't go in. */
export function priceListResultToast(
results: Array<{ status: string }>,
): { level: "success" | "warning"; message: string } {
const count = (status: string) => results.filter((r) => r.status === status).length;
const created = count("created");
const added = count("added");
const failed = count("failed");
const needsSku = count("needs_sku");
if (failed === 0 && needsSku === 0) {
return { level: "success", message: `Added ${created} item${created === 1 ? "" : "s"} to the POS` };
const needsCost = count("needs_cost");
const needsSupplier = count("needs_supplier");
if (failed === 0 && needsSku === 0 && needsCost === 0 && needsSupplier === 0) {
return {
level: "success",
message: `Added ${added} item${added === 1 ? "" : "s"} to the price list`,
};
}
const parts = [`${created} added`];
const need = (n: number, what: string) => `${n} need${n === 1 ? "s" : ""} ${what}`;
const parts = [`${added} added`];
if (failed > 0) parts.push(`${failed} failed`);
if (needsSku > 0) parts.push(`${needsSku} need${needsSku === 1 ? "s" : ""} a SKU`);
if (needsSku > 0) parts.push(need(needsSku, "a SKU"));
if (needsCost > 0) parts.push(need(needsCost, "a cost"));
if (needsSupplier > 0) parts.push(need(needsSupplier, "a supplier"));
return { level: "warning", message: parts.join(", ") };
}

View file

@ -8,11 +8,11 @@ vi.mock("@/lib/server/orders-service", () => ({
getOrderItemsCollectionName: vi.fn(() => "order_items_v2"),
}));
vi.mock("@/lib/server/stock-items-service", () => ({
createStockItem: vi.fn(),
vi.mock("@/lib/server/price-list-service", () => ({
addPriceListItems: vi.fn(),
}));
import { createStockItem } from "@/lib/server/stock-items-service";
import { addPriceListItems } from "@/lib/server/price-list-service";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { getOrderItemsCollectionName } from "@/lib/server/orders-service";
import {
@ -21,7 +21,7 @@ import {
updateOrderItemCost, confirmOrderItemsForInvoicing,
confirmOrderItemsWithSplits,
clearCollectedRows,
addRowsToPos,
addRowsToPriceList,
} from "./collection-confirm-service";
/**
@ -1609,14 +1609,16 @@ describe("listCollectedItems — AI check passthrough", () => {
});
});
describe("addRowsToPos", () => {
describe("addRowsToPriceList", () => {
const oi = (over: Record<string, unknown> = {}) => ({
id: "oi-1",
description: "M6 nylock nut",
po_description: "NUT NYLOCK M6 ZP",
sku: "SM2-NUT9B506",
cost_price: 0.2,
expand: { purchase_order: { id: "po-1", expand: { supplier: { id: "sup-1" } } } },
expand: {
purchase_order: { id: "po-1", expand: { supplier: { id: "sup-1", suppliername: "NSS Fasteners" } } },
},
...over,
});
const makeAdmin = (opts: {
@ -1631,36 +1633,40 @@ describe("addRowsToPos", () => {
throw new Error(`unexpected collection ${name}`);
}),
});
const ok = { ok: true, added: 1, updated: 0, ids: ["pl-1"], skipped: [] };
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(createStockItem).mockResolvedValue({ id: "si-new" } as never);
vi.mocked(addPriceListItems).mockResolvedValue(ok as never);
});
it("creates a POS item for a customer row from its line fields", async () => {
it("adds a customer row to the price list at cost + 80%, never the POS", async () => {
vi.mocked(getAdminPocketBase).mockResolvedValue(makeAdmin({ orderItems: [oi()] }) as never);
const results = await addRowsToPos(["oi-1"]);
const results = await addRowsToPriceList(["oi-1"]);
expect(results).toEqual([{ row_id: "oi-1", status: "created", stock_item_id: "si-new" }]);
expect(createStockItem).toHaveBeenCalledWith({
name: "M6 nylock nut",
description: "NUT NYLOCK M6 ZP",
sku: "SM2-NUT9B506",
supplier: "sup-1",
cost_price: 0.2,
expect(results).toEqual([{ row_id: "oi-1", status: "added", stock_item_id: null }]);
expect(addPriceListItems).toHaveBeenCalledWith({
items: [
{
supplier_name: "NSS Fasteners",
sku: "SM2-NUT9B506",
description: "M6 nylock nut",
cost_price: 0.2,
markup: 80,
},
],
source_label: "Collection Confirm",
});
});
it("takes the unit from the AI document line when present", async () => {
const row = oi({ po_description: "", ai_check: { doc_line: { unit: "EA" } } });
const row = oi({ ai_check: { doc_line: { unit: "EA" } } });
vi.mocked(getAdminPocketBase).mockResolvedValue(makeAdmin({ orderItems: [row] }) as never);
await addRowsToPos(["oi-1"]);
await addRowsToPriceList(["oi-1"]);
expect(createStockItem).toHaveBeenCalledWith(
expect.objectContaining({ unit: "EA", description: "M6 nylock nut" }),
);
expect(vi.mocked(addPriceListItems).mock.calls[0][0].items[0]).toMatchObject({ unit: "EA" });
});
it("returns already_in_pos when a normalised SKU match exists", async () => {
@ -1668,38 +1674,43 @@ describe("addRowsToPos", () => {
makeAdmin({ orderItems: [oi()], posItems: [{ id: "si-9", sku: "sm2 nut9b506" }] }) as never,
);
const results = await addRowsToPos(["oi-1"]);
const results = await addRowsToPriceList(["oi-1"]);
expect(results).toEqual([{ row_id: "oi-1", status: "already_in_pos", stock_item_id: "si-9" }]);
expect(createStockItem).not.toHaveBeenCalled();
expect(addPriceListItems).not.toHaveBeenCalled();
});
it("returns needs_sku for a customer row without a SKU and creates nothing", async () => {
vi.mocked(getAdminPocketBase).mockResolvedValue(
makeAdmin({ orderItems: [oi({ sku: " " })] }) as never,
);
it.each([
["needs_sku", { sku: " " }],
["needs_cost", { cost_price: 0 }],
["needs_cost", { cost_price: null }],
["needs_supplier", { expand: {} }],
])("returns %s and writes nothing", async (status, over) => {
vi.mocked(getAdminPocketBase).mockResolvedValue(makeAdmin({ orderItems: [oi(over)] }) as never);
const results = await addRowsToPos(["oi-1"]);
// A second click is still a no-op.
await addRowsToPos(["oi-1"]);
const results = await addRowsToPriceList(["oi-1"]);
expect(results).toEqual([{ row_id: "oi-1", status: "needs_sku", stock_item_id: null }]);
expect(createStockItem).not.toHaveBeenCalled();
expect(results).toEqual([{ row_id: "oi-1", status, stock_item_id: null }]);
expect(addPriceListItems).not.toHaveBeenCalled();
});
it("reports a failed create per row and carries on with the rest", async () => {
vi.mocked(createStockItem)
it("reports a failed write per row and carries on with the rest", async () => {
vi.mocked(addPriceListItems)
.mockRejectedValueOnce(new Error("PB said no"))
.mockResolvedValueOnce({ id: "si-2" } as never);
.mockResolvedValueOnce({ ok: true, added: 0, updated: 0, ids: [], skipped: [{ index: 0, sku: "X", reason: "invalid_cost_price" }] } as never)
.mockResolvedValueOnce(ok as never);
vi.mocked(getAdminPocketBase).mockResolvedValue(
makeAdmin({ orderItems: [oi(), oi({ id: "oi-2", sku: "OTHER-2" })] }) as never,
makeAdmin({
orderItems: [oi(), oi({ id: "oi-2", sku: "OTHER-2" }), oi({ id: "oi-3", sku: "OTHER-3" })],
}) as never,
);
const results = await addRowsToPos(["oi-1", "oi-2"]);
const results = await addRowsToPriceList(["oi-1", "oi-2", "oi-3"]);
expect(results).toEqual([
{ row_id: "oi-1", status: "failed", stock_item_id: null, error: "PB said no" },
{ row_id: "oi-2", status: "created", stock_item_id: "si-2" },
{ row_id: "oi-2", status: "failed", stock_item_id: null, error: "invalid_cost_price" },
{ row_id: "oi-3", status: "added", stock_item_id: null },
]);
});
@ -1708,23 +1719,12 @@ describe("addRowsToPos", () => {
makeAdmin({ stockPurchaseItems: [{ id: "spi-1" }] }) as never,
);
const results = await addRowsToPos(["spi-1", "nope"]);
const results = await addRowsToPriceList(["spi-1", "nope"]);
expect(results).toEqual([
{ row_id: "spi-1", status: "already_in_pos", stock_item_id: null },
{ row_id: "nope", status: "not_found", stock_item_id: null },
]);
expect(createStockItem).not.toHaveBeenCalled();
});
it("creates only once when two rows share a supplier code", async () => {
vi.mocked(getAdminPocketBase).mockResolvedValue(
makeAdmin({ orderItems: [oi(), oi({ id: "oi-2" })] }) as never,
);
const results = await addRowsToPos(["oi-1", "oi-2"]);
expect(results.map((r) => r.status)).toEqual(["created", "already_in_pos"]);
expect(createStockItem).toHaveBeenCalledTimes(1);
expect(addPriceListItems).not.toHaveBeenCalled();
});
});

View file

@ -1493,47 +1493,64 @@ export async function clearCollectedRows(
return result;
}
export type AddToPosResult = {
export type AddToPriceListResult = {
row_id: string;
status: "created" | "already_in_pos" | "not_found" | "needs_sku" | "failed";
status:
| "added"
| "already_in_pos"
| "not_found"
| "needs_sku"
| "needs_cost"
| "needs_supplier"
| "failed";
/** The stock item the row already matches (`already_in_pos` only). */
stock_item_id: string | null;
/** Only on `failed`: why the stock item couldn't be created. */
/** Only on `failed`: why the price list row couldn't be written. */
error?: string;
};
/**
* "Add to POS" for Collection Confirm rows. Customer rows (`order_items`)
* become a `stock_items` row keyed by the supplier's code (SKU). A row whose
* normalised SKU already matches a stock item is left alone
* (`already_in_pos`), as is any id that is a `stock_purchase_items` line
* (those are already POS items). Unknown ids come back `not_found`. A
* customer row without a SKU is `needs_sku` (nothing created, so repeated
* clicks never duplicate). A create that throws is `failed` with `error`;
* the other rows still run. Rows are handled one at a time so two rows
* sharing a code create only one item.
* "Add to price list" for Collection Confirm customer rows (`order_items`).
*
* Owner's rule (2026-10-30): the POS is the counter till, so only stock goes
* there. A line bought for a customer goes to the `price_list` catalogue
* instead (supplier, SKU, cost, selling = cost + 80%), and is moved to the
* POS from there if it turns out to sell over the counter. This function
* never creates a `stock_items` row.
*
* Per row: a normalised SKU that already matches a stock item is left alone
* (`already_in_pos`), as is a `stock_purchase_items` line (it is stock).
* Unknown ids are `not_found`. A row without a SKU, a cost or a supplier
* writes nothing (`needs_sku` / `needs_cost` / `needs_supplier`). The upsert
* is keyed on (SKU, supplier), so a second click updates the same row. A
* write that fails is `failed` with `error`; the other rows still run.
*/
export async function addRowsToPos(rowIds: string[]): Promise<AddToPosResult[]> {
const { createStockItem } = await import("@/lib/server/stock-items-service");
export async function addRowsToPriceList(rowIds: string[]): Promise<AddToPriceListResult[]> {
const { addPriceListItems } = await import("@/lib/server/price-list-service");
const { DEFAULT_MARKUP_PERCENT } = await import("@/lib/server/stock-default-markup-service");
const pb = await getAdminPocketBase();
const ids = [...new Set(rowIds.filter((id) => typeof id === "string" && id !== ""))];
const byId = (chunk: string[]) => chunk.map((id) => `id = "${pbQuote(id)}"`).join(" || ");
type PosSourceRow = {
type SupplierExpand = { id: string; suppliername?: string };
type PriceListSourceRow = {
id: string;
description?: string;
po_description?: string;
sku?: string;
supplier?: string;
cost_price?: number | null;
ai_check?: { doc_line?: { unit?: string } | null } | null;
expand?: { purchase_order?: { expand?: { supplier?: { id: string } } } };
expand?: {
supplier?: SupplierExpand;
purchase_order?: { expand?: { supplier?: SupplierExpand } };
};
};
const customerById = new Map<string, PosSourceRow>();
const customerById = new Map<string, PriceListSourceRow>();
for (const chunk of chunked(ids)) {
const found = await pb.collection(getOrderItemsCollectionName()).getFullList<PosSourceRow>({
const found = await pb.collection(getOrderItemsCollectionName()).getFullList<PriceListSourceRow>({
filter: byId(chunk),
expand: "purchase_order.supplier",
expand: "purchase_order.supplier,supplier",
});
for (const r of found) customerById.set(r.id, r);
}
@ -1547,8 +1564,7 @@ export async function addRowsToPos(rowIds: string[]): Promise<AddToPosResult[]>
for (const r of found) stockIds.add(r.id);
}
const results: AddToPosResult[] = [];
const createdByCode = new Map<string, string>();
const results: AddToPriceListResult[] = [];
for (const id of ids) {
const row = customerById.get(id);
if (!row) {
@ -1563,40 +1579,62 @@ export async function addRowsToPos(rowIds: string[]): Promise<AddToPosResult[]>
const sku = (row.sku ?? "").trim();
const code = normaliseCode(sku);
if (!code) {
// No supplier code: nothing to key the POS item on, and creating one
// per click would duplicate.
results.push({ row_id: id, status: "needs_sku", stock_item_id: null });
continue;
}
let existingId = createdByCode.get(code) ?? null;
if (!existingId) {
const found = await pb.collection("stock_items").getFullList<{ id: string; sku?: string }>({
filter: `sku ~ "${pbQuote(sku)}"`,
fields: "id,sku",
});
existingId = found.find((s) => normaliseCode(s.sku) === code)?.id ?? null;
const cost = Number(row.cost_price ?? 0);
if (!Number.isFinite(cost) || cost <= 0) {
results.push({ row_id: id, status: "needs_cost", stock_item_id: null });
continue;
}
const supplierName = (
row.expand?.purchase_order?.expand?.supplier?.suppliername ??
row.expand?.supplier?.suppliername ??
""
).trim();
if (!supplierName) {
results.push({ row_id: id, status: "needs_supplier", stock_item_id: null });
continue;
}
const found = await pb.collection("stock_items").getFullList<{ id: string; sku?: string }>({
filter: `sku ~ "${pbQuote(sku)}"`,
fields: "id,sku",
});
const existingId = found.find((s) => normaliseCode(s.sku) === code)?.id ?? null;
if (existingId) {
results.push({ row_id: id, status: "already_in_pos", stock_item_id: existingId });
continue;
}
const description = (row.description ?? "").trim();
const supplier = row.expand?.purchase_order?.expand?.supplier?.id ?? row.supplier ?? undefined;
const description = (row.description ?? "").trim() || (row.po_description ?? "").trim();
const unit = row.ai_check?.doc_line?.unit?.trim();
try {
const item = await createStockItem({
name: description,
description: (row.po_description ?? "").trim() || description,
sku,
...(supplier ? { supplier } : {}),
...(typeof row.cost_price === "number" && row.cost_price > 0
? { cost_price: row.cost_price }
: {}),
...(unit ? { unit } : {}),
const outcome = await addPriceListItems({
items: [
{
supplier_name: supplierName,
sku,
description: description || sku,
cost_price: cost,
markup: DEFAULT_MARKUP_PERCENT,
...(unit ? { unit } : {}),
},
],
source_label: "Collection Confirm",
});
createdByCode.set(code, item.id);
results.push({ row_id: id, status: "created", stock_item_id: item.id });
if (!outcome.ok) {
results.push({ row_id: id, status: "failed", stock_item_id: null, error: outcome.message });
} else if (outcome.skipped.length > 0) {
results.push({
row_id: id,
status: "failed",
stock_item_id: null,
error: outcome.skipped[0].reason,
});
} else {
results.push({ row_id: id, status: "added", stock_item_id: null });
}
} catch (err) {
results.push({
row_id: id,

View file

@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { checkoutSale, type PosPb } from "./pos-service";
import { checkoutSale, listActiveStockCatalog, POS_CATALOG_FILTER, type PosPb } from "./pos-service";
import type { AdHocDeliveryNoteInput } from "./delivery-notes-service";
/**
@ -679,3 +679,35 @@ describe("checkoutSale — custom (ad-hoc) lines", () => {
expect(result.stockErrors).toHaveLength(0);
});
});
describe("POS never sells at R0", () => {
it("lists only active, priced stock items on the counter", async () => {
const calls: Array<{ name: string; opts: Record<string, unknown> }> = [];
const stub = {
collection: (name: string) => ({
getFullList: async (opts: Record<string, unknown>) => {
calls.push({ name, opts });
return [];
},
}),
} as unknown as PosPb;
await listActiveStockCatalog(stub);
const stock = calls.find((c) => c.name === "stock_items");
expect(stock?.opts.filter).toBe(POS_CATALOG_FILTER);
expect(POS_CATALOG_FILTER).toContain("selling_price > 0");
expect(POS_CATALOG_FILTER).toContain("is_active = true");
});
it.each([
["catalogue", { stock_item_id: "si-1", qty: 1, unit_price: 0, description: "M8 set screw" }],
["custom", { qty: 1, unit_price: 0, description: "Bolt cutting", is_custom: true }],
["negative", { stock_item_id: "si-1", qty: 1, unit_price: -5 }],
])("refuses a %s line with no price and writes no sale", async (_label, line) => {
const result = await checkoutSale(pb, { items: [line], payment_method: "cash" });
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("invalid_line");
expect(result.message).toMatch(/no price/);
expect(pb._salesCalls).toHaveLength(0);
});
});

View file

@ -136,6 +136,9 @@ function matchItem(
* the supplier's `supply_tags` field; keyword and tag checks against
* the item's name/description/sku.
*/
/** Stock items the till may sell: active AND priced. */
export const POS_CATALOG_FILTER = "is_active = true && selling_price > 0";
export async function listActiveStockCatalog(pb: PosPb): Promise<PosCatalogItem[]> {
const catRows = await pb.collection("categories").getFullList<CategoryRow>({
filter: "is_active = true",
@ -160,7 +163,9 @@ export async function listActiveStockCatalog(pb: PosPb): Promise<PosCatalogItem[
supplier?: string;
expand?: { supplier?: { supply_tags?: string } };
}>({
filter: "is_active = true",
// The counter never sees an unpriced item: whichever path created
// the stock row, a zero selling price keeps it off the till.
filter: POS_CATALOG_FILTER,
sort: "-created",
expand: "supplier",
});
@ -519,6 +524,15 @@ export async function checkoutSale(
message: `each item needs a positive qty (got ${JSON.stringify(line)})`,
};
}
// Nothing leaves the counter at R0 — an unpriced item is priced
// first (stock page or price list), never sold for free.
if (!Number.isFinite(line.unit_price) || line.unit_price <= 0) {
return {
ok: false,
reason: "invalid_line",
message: `"${line.description || line.sku || line.stock_item_id || "item"}" has no price — set a selling price before selling it.`,
};
}
// A custom line is the cashier's own wording and price — no
// catalogue row, so no stock_item_id to check. It does need
// something to print on the slip.