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:
parent
45529731a7
commit
afdfbce71d
18 changed files with 488 additions and 177 deletions
15
AGENTS.md
15
AGENTS.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
177
scripts/2026-10-30-pos-zero-price-cleanup.ts
Normal file
177
scripts/2026-10-30-pos-zero-price-cleanup.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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(", ") };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue