commit 799e00552c1382eec44f633b4a944844fe87ab29 parent 53557ac3c2c93f2b5ea4e7979460c63454dcc266 Author: Florian Dold <dold@taler.net> Date: Wed, 4 Nov 2026 00:17:12 +0100 merchant web UI: add stock loss recording Diffstat:
11 files changed, 821 insertions(+), 13 deletions(-)
diff --git a/packages/taler-merchant-webui/contrib/qa/product-restock-check.mjs b/packages/taler-merchant-webui/contrib/qa/product-restock-check.mjs @@ -28,6 +28,7 @@ const bundle = await build({ contents: ` import { render } from 'preact'; import { TalerMerchantInstanceHttpClient } from '@gnu-taler/taler-util'; + import { useInventory } from './src/api/hooks/useInventory.ts'; import { CreateProductRoute } from './src/routes/CreateProductRoute.tsx'; import { useHttpLibForTesting } from './src/api/client.ts'; import { FakeHttpLib, ok, noContent } from './src/testing/fake-http.ts'; @@ -35,10 +36,11 @@ const bundle = await build({ window.saved = { product_name: 'Tea', description: 'Tea', description_i18n: {}, unit: 'Piece', price: 'EUR:2.5', image: '', categories: [], - total_stock: 10, total_sold: 0, total_lost: 0, - next_restock: { t_s: Date.parse('2026-11-10T00:00:00Z') / 1000 }, + total_stock: 10, total_sold: 2, total_lost: 1, + next_restock: { t_s: Date.parse('2026-11-10T14:23:45Z') / 1000 }, }; window.writes = []; + window.pages = []; const http = new FakeHttpLib().handle((request) => { const path = new URL(request.url).pathname; if (path.endsWith('/private/products/tea')) { @@ -54,16 +56,28 @@ const bundle = await build({ exchanges: [], default_persona: 'expert', report_generators: [], have_donau: false, mandatory_tan_channels: [], payment_target_types: '*', }); - if (path.endsWith('/private/products')) return ok({ products: [{ product_id: 'tea' }] }); + if (path.endsWith('/private/products')) { + const url = new URL(request.url); + if (url.searchParams.get('limit') !== '100') throw new Error('Expected ascending pagination'); + const offset = Number(url.searchParams.get('offset')); + window.pages.push(offset); + return ok({ products: Array.from({length: Math.min(100, 121 - offset)}, (_, i) => ({ + product_id: offset + i === 0 ? 'tea' : String(offset + i + 1), product_serial: offset + i + 1, + })) }); + } + if (path.endsWith('/private/products/4')) throw new Error('Detail unavailable'); + if (path.endsWith('/private/products/5')) return ok({ ...window.saved, total_stock: -1, unit_total_stock: '9223372036854775807' }); + if (path.includes('/private/products/')) return ok(window.saved); if (path.endsWith('/private/categories')) return ok({ categories: [] }); if (path.endsWith('/private/accounts')) return ok({ accounts: [] }); return ok({}); }); useHttpLibForTesting(http); signIn('shop', 'secret-token:test', 'https://merchant.example.test/'); + function Probe() { window.inventory = useInventory(); return null; } window.mount = () => { render(null, document.getElementById('app')); - render(<CreateProductRoute editId="tea" />, document.getElementById('app')); + render(<><CreateProductRoute editId="tea" /><Probe /></>, document.getElementById('app')); }; window.mount(); `, @@ -104,6 +118,49 @@ try { await page.$eval("#prod_restock_date_input", (input) => input.value), "2026-11-10", ); + await page.waitForFunction(() => window.inventory.products?.length === 121); + assert.deepEqual( + await page.evaluate(() => window.pages.slice(0, 3)), + [0, 100, 121], + ); + assert.equal( + await page.evaluate(() => window.inventory.products[0].remainingStock), + 7, + ); + assert.equal( + await page.evaluate(() => window.inventory.products[3].detailError), + true, + ); + assert.equal( + await page.evaluate(() => Boolean(window.inventory.enrichmentError)), + true, + ); + assert.deepEqual( + await page.evaluate(() => { + const product = window.inventory.products[4]; + return { + tracked: product.stockTracked, + stock: product.stock, + balance: product.remainingStock ?? null, + }; + }), + { tracked: false, stock: "Unlimited", balance: null }, + ); + await page.type("#prod_loss_input", "2"); + await page.click("#prod_record_loss_btn"); + await page.waitForFunction( + () => document.querySelector("[data-total-lost]")?.textContent === "3", + ); + assert.equal(await page.evaluate(() => window.saved.total_lost), 3); + assert.equal(await page.evaluate(() => window.saved.total_stock), 10); + assert.equal(await page.evaluate(() => window.saved.total_sold), 2); + assert.deepEqual(await page.evaluate(() => window.saved.next_restock), { + t_s: Date.parse("2026-11-10T14:23:45Z") / 1000, + }); + assert.equal( + await page.evaluate(() => "total_sold" in window.writes.at(-1)), + false, + ); for (const date of ["2026-12-15", ""]) { await page.$eval( "#prod_restock_date_input", @@ -145,7 +202,9 @@ try { ); } assert.deepEqual(errors, []); - console.log("Product delivery date: saving, clearing, and reopening passed."); + console.log( + "Product losses and delivery dates: recording, saving, clearing, and reopening passed.", + ); } finally { await browser?.close(); await new Promise((resolve) => server.close(resolve)); diff --git a/packages/taler-merchant-webui/src/api/hooks/useInventory.ts b/packages/taler-merchant-webui/src/api/hooks/useInventory.ts @@ -14,10 +14,11 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { mutate as mutateCache } from "swr"; import { useMerchantSWR as useSWR } from "../swr.js"; import type { AccessToken, TalerMerchantApi } from "@gnu-taler/taler-util"; import { merchantClient } from "../client.js"; -import { unwrap, unwrapEmpty } from "../failure.js"; +import { ApiFailure, unwrap, unwrapEmpty } from "../failure.js"; import { session } from "../../stores/session.js"; import type { CategoryItem, ProductItem } from "../../types/domain.js"; import { invalidateMerchantCache } from "../cache.js"; @@ -30,6 +31,8 @@ import { loadInventoryEntries, productItem, mergeProductUpdate, + lossState, + type RecordLoss, } from "../../utils/inventory.js"; export type { CategoryItem }; @@ -157,6 +160,62 @@ export function useInventory({ enabled = true }: { enabled?: boolean } = {}) { await mutate(); }; + const recordLoss: RecordLoss = async (productId, request) => { + if (!token) throw new Error(t`Please sign in again.`); + const client = merchantClient(config); + let current: TalerMerchantApi.ProductDetailResponse; + try { + current = unwrap(await client.getProductDetails(token, productId)); + } catch (error) { + // A failed preflight read cannot make a first submission an ambiguous + // write. On retry, however, the previous write is still unconfirmed. + throw new ApiFailure( + { type: "fail", case: "read", detail: error }, + { + outcome: request.retry ? "indeterminate" : "rejected", + message: error instanceof Error ? error.message : undefined, + }, + ); + } + const state = lossState(current, request); + if (state === "changed") + return { status: "changed", product: productItem(productId, current, t) }; + if (state === "write") { + unwrapEmpty( + await client.updateProduct( + token, + productId, + mergeProductUpdate(current, { total_lost: request.target }), + ), + ); + } + let saved: TalerMerchantApi.ProductDetailResponse; + try { + saved = unwrap(await client.getProductDetails(token, productId)); + } catch (error) { + throw new ApiFailure( + { type: "fail", case: "confirmation", detail: error }, + { + outcome: "indeterminate", + message: t`The loss was submitted but could not be confirmed. Retry to check the same loss.`, + }, + ); + } + await mutateCache( + [ + "getProductDetails", + config.rootUrl.href, + config.account, + token, + productId, + ], + productItem(productId, saved, t), + { revalidate: false }, + ); + await mutate(); + return { status: "recorded", product: productItem(productId, saved, t) }; + }; + const deleteProduct = async (productId: string, force?: boolean) => { if (!token) return; const client = merchantClient(config); @@ -187,6 +246,7 @@ export function useInventory({ enabled = true }: { enabled?: boolean } = {}) { mutate, resource: remoteResource(swr), createProduct, + recordLoss, updateProduct, deleteProduct, }; diff --git a/packages/taler-merchant-webui/src/api/inventory.test.tsx b/packages/taler-merchant-webui/src/api/inventory.test.tsx @@ -0,0 +1,171 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { render } from "preact"; +import { act } from "preact/test-utils"; +import { useInventory } from "./hooks/useInventory.js"; +import { useHttpLibForTesting } from "./client.js"; +import { FakeHttpLib, ok, noContent } from "../testing/fake-http.js"; +import { signIn, signOut } from "../stores/session.js"; +const detail = { + product_name: "Tea", + description: "Tea", + description_i18n: { de: "Tee" }, + unit: "Piece", + price: "EUR:2", + image: "", + categories: [], + total_stock: 100, + total_sold: 30, + total_lost: 5, + next_restock: { t_s: 1794301234 }, + unit_price: ["EUR:2", "CHF:3"], + unit_total_stock: "100", + unit_allow_fraction: false, + unit_precision_level: 0, + money_pot_id: 7, +}; + +test("loss hook preserves full records and retries an ambiguous write without double counting", async () => { + let saved = { ...detail }; + let writes = 0; + let loseResponse = true; + const http = new FakeHttpLib().handle((request) => { + if (request.method === "PATCH") { + writes++; + const body = request.body as Record<string, unknown>; + assert.equal(body.description, "Tea"); + assert.equal(body.money_pot_id, 7); + assert.deepEqual(body.unit_price, ["EUR:2", "CHF:3"]); + assert.deepEqual(body.next_restock, detail.next_restock); + assert.equal(body.total_sold, undefined); + saved = { ...saved, ...body }; + if (loseResponse) { + loseResponse = false; + throw new Error("Lost response"); + } + return noContent(); + } + return ok(saved); + }); + const restore = useHttpLibForTesting(http); + const container = document.body.appendChild(document.createElement("div")); + let inventory!: ReturnType<typeof useInventory>; + function Probe() { + inventory = useInventory({ enabled: false }); + return null; + } + signIn("shop", "secret-token:test", "https://inventory-loss.test/"); + try { + await act(async () => render(<Probe />, container)); + const request = { + totalStock: 100, + soldCount: 30, + lostCount: 5, + unitTotalStock: "100", + target: 8, + }; + await assert.rejects(inventory.recordLoss("tea", request), /Lost response/); + const result = await inventory.recordLoss("tea", { + ...request, + retry: true, + }); + assert.equal(result.status, "recorded"); + assert.equal(result.product.lostCount, 8); + assert.equal(result.product.remainingStock, 62); + assert.equal(writes, 1); + const changed = await inventory.recordLoss("tea", { + ...request, + target: 9, + }); + assert.equal(changed.status, "changed"); + assert.equal(writes, 1); + } finally { + act(() => render(null, container)); + container.remove(); + restore(); + signOut(); + } +}); + +for (const failRead of [false, true]) { + test(`a concurrent loss requires review on first submission (preflight failure: ${failRead})`, async () => { + let saved = { ...detail, total_lost: 8 }; + let readsFail = failRead; + let writes = 0; + const http = new FakeHttpLib().handle((request) => { + if (request.method === "PATCH") { + writes++; + saved = { ...saved, ...(request.body as object) }; + return noContent(); + } + if (readsFail) throw new Error("Read failed"); + return ok(saved); + }); + const restore = useHttpLibForTesting(http); + const container = document.body.appendChild(document.createElement("div")); + let inventory!: ReturnType<typeof useInventory>; + function Probe() { + inventory = useInventory({ enabled: false }); + return null; + } + signIn( + "shop", + "secret-token:test", + `https://loss-concurrent-${failRead}.test/`, + ); + try { + await act(async () => render(<Probe />, container)); + const request = { + totalStock: 100, + soldCount: 30, + lostCount: 5, + unitTotalStock: "100", + target: 8, + }; + if (failRead) { + await assert.rejects( + inventory.recordLoss("tea", request), + (error: any) => error.outcome === "rejected", + ); + await assert.rejects( + inventory.recordLoss("tea", { ...request, retry: true }), + (error: any) => error.outcome === "indeterminate", + ); + readsFail = false; + } + const result = await inventory.recordLoss("tea", request); + assert.equal(result.status, "changed"); + assert.equal(result.product.lostCount, 8); + assert.equal(writes, 0); + const confirmed = await inventory.recordLoss("tea", { + ...request, + lostCount: 8, + target: 11, + }); + assert.equal(confirmed.status, "recorded"); + assert.equal(saved.total_lost, 11); + assert.equal(writes, 1); + } finally { + act(() => render(null, container)); + container.remove(); + restore(); + signOut(); + } + }); +} diff --git a/packages/taler-merchant-webui/src/routes/CreateProductRoute.tsx b/packages/taler-merchant-webui/src/routes/CreateProductRoute.tsx @@ -25,7 +25,7 @@ import { import { CreateProductScreen } from "../screens/CreateProductScreen.js"; export function CreateProductRoute({ editId }: { editId?: string }): VNode { - const { createProduct, updateProduct } = useInventory(); + const { createProduct, updateProduct, recordLoss } = useInventory(); const { product, resource: productResource } = useProductDetails(editId); const { categories, @@ -56,6 +56,7 @@ export function CreateProductRoute({ editId }: { editId?: string }): VNode { payoutCurrencies={payoutCurrencies} onCreateProduct={createProduct} onUpdateProduct={updateProduct} + onRecordLoss={recordLoss} onCreateCategory={createCategory} /> ); diff --git a/packages/taler-merchant-webui/src/screens/CreateProductScreen.tsx b/packages/taler-merchant-webui/src/screens/CreateProductScreen.tsx @@ -14,6 +14,8 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { ProductLossFields } from "./ProductLossFields.js"; +import type { RecordLoss } from "../utils/inventory.js"; import type { VNode } from "preact"; import { session } from "../stores/session.js"; import { Amounts } from "@gnu-taler/taler-util"; @@ -61,6 +63,7 @@ export interface CreateProductScreenProps { id: string, product: TalerMerchantApi.ProductPatchDetailRequest, ) => Promise<unknown>; + onRecordLoss?: RecordLoss; onCreateCategory?: (name: string) => Promise<unknown>; } @@ -243,6 +246,7 @@ function CreateProductForm({ initialProduct, onSave, onCreateCategory, + onRecordLoss, }: CreateProductScreenProps): VNode { const { t } = useTranslation(); const [, setLocation] = useLocation(); @@ -753,6 +757,12 @@ function CreateProductForm({ </div> </div> + {existingStockTracked && existing && onRecordLoss && ( + <div class="bg-white p-6 rounded-xl border border-gray-200"> + <ProductLossFields product={existing} onRecordLoss={onRecordLoss} /> + </div> + )} + {/* Section 4: Product Categories (Standalone Multi-Select) */} <div class="bg-white p-6 rounded-xl border border-gray-200 shadow-xs space-y-4"> <div class="flex items-center justify-between"> diff --git a/packages/taler-merchant-webui/src/screens/InventoryScreen.tsx b/packages/taler-merchant-webui/src/screens/InventoryScreen.tsx @@ -434,9 +434,10 @@ export function InventoryScreen({ : prod.soldCount === 1 ? t`1 unit` : t`${prod.soldCount} units`} - {prod.lostCount !== undefined && ( - <span class="block text-xs text-gray-500">{t`${prod.lostCount} lost`}</span> - )} + {prod.stockTracked && + prod.lostCount !== undefined && ( + <span class="block text-xs text-gray-500">{t`${prod.lostCount} lost`}</span> + )} </dd> </div> </dl> @@ -572,9 +573,10 @@ export function InventoryScreen({ : prod.soldCount === 1 ? t`1 unit` : t`${prod.soldCount} units`} - {prod.lostCount !== undefined && ( - <span class="block text-xs text-gray-500">{t`${prod.lostCount} lost`}</span> - )} + {prod.stockTracked && + prod.lostCount !== undefined && ( + <span class="block text-xs text-gray-500">{t`${prod.lostCount} lost`}</span> + )} </div> </td> <td class="px-3 py-3 text-right whitespace-nowrap"> diff --git a/packages/taler-merchant-webui/src/screens/ProductLossFields.test.tsx b/packages/taler-merchant-webui/src/screens/ProductLossFields.test.tsx @@ -0,0 +1,249 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { render } from "preact"; +import { act } from "preact/test-utils"; +import { ProductLossFields } from "./ProductLossFields.js"; +import { ApiFailure } from "../api/failure.js"; +import type { ProductItem } from "../types/domain.js"; +import type { LossRequest, LossResult } from "../utils/inventory.js"; +const product: ProductItem = { + id: "tea", + name: "Tea", + price: "EUR:2", + category: "", + stockTracked: true, + totalStock: 100, + soldCount: 30, + lostCount: 5, + remainingStock: 65, + stock: "65 stock balance", +}; +function input(container: HTMLElement, value: string) { + act(() => { + const el = container.querySelector<HTMLInputElement>("input")!; + el.value = value; + el.dispatchEvent(new Event("input", { bubbles: true })); + }); +} +async function click(container: HTMLElement) { + await act(async () => { + container.querySelector<HTMLButtonElement>("button")!.click(); + }); +} + +test("loss validation and changed counters require review before recording", async () => { + const container = document.body.appendChild(document.createElement("div")); + const calls: LossRequest[] = []; + try { + await act(() => + render( + <ProductLossFields + product={product} + onRecordLoss={async (_, req) => { + calls.push(req); + return calls.length === 1 + ? { + status: "changed", + product: { ...product, soldCount: 31, remainingStock: 64 }, + } + : { + status: "recorded", + product: { + ...product, + soldCount: 31, + lostCount: 8, + remainingStock: 61, + stock: "61 stock balance", + }, + }; + }} + />, + container, + ), + ); + for (const quantity of ["", "0", "-1", "1.5", "66", "9007199254740992"]) { + input(container, quantity); + await click(container); + assert.equal(calls.length, 0); + } + input(container, "3"); + await click(container); + assert.match(container.textContent!, /Stock changed/); + assert.equal(calls.length, 1); + await click(container); + assert.equal(calls.length, 2); + assert.equal(calls[1]!.soldCount, 31); + assert.equal(calls[1]!.target, 8); + assert.equal( + container.querySelector("[data-total-lost]")?.textContent, + "8", + ); + assert.match(container.textContent!, /Loss recorded/); + assert.equal(container.querySelector<HTMLInputElement>("input")!.value, ""); + } finally { + act(() => render(null, container)); + container.remove(); + } +}); + +test("loss retry retains its target and prevents duplicate submissions", async () => { + const container = document.body.appendChild(document.createElement("div")); + const calls: LossRequest[] = []; + let resolve!: (result: LossResult) => void; + try { + await act(() => + render( + <ProductLossFields + product={product} + onRecordLoss={async (_, req) => { + calls.push(req); + if (calls.length === 1) throw new Error("Connection lost"); + return new Promise((r) => { + resolve = r; + }); + }} + />, + container, + ), + ); + input(container, "3"); + await click(container); + assert.equal( + container.querySelector<HTMLInputElement>("input")!.disabled, + true, + ); + await click(container); + assert.equal( + container.querySelector<HTMLButtonElement>("button")!.disabled, + true, + ); + await click(container); + assert.equal(calls.length, 2); + assert.equal(calls[0]!.retry, undefined); + assert.deepEqual(calls[1], { ...calls[0], retry: true }); + await act(async () => + resolve({ status: "recorded", product: { ...product, lostCount: 8 } }), + ); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + assert.match(container.textContent!, /Loss recorded/); + } finally { + act(() => render(null, container)); + container.remove(); + } +}); + +test("a rejected loss keeps its input editable", async () => { + const container = document.body.appendChild(document.createElement("div")); + try { + await act(() => + render( + <ProductLossFields + product={product} + onRecordLoss={async () => { + throw new ApiFailure( + { type: "fail", case: 400 }, + { message: "Loss rejected", outcome: "rejected" }, + ); + }} + />, + container, + ), + ); + input(container, "3"); + await click(container); + assert.match(container.textContent!, /Loss rejected/); + assert.equal( + container.querySelector<HTMLInputElement>("input")!.disabled, + false, + ); + assert.equal( + container.querySelector<HTMLInputElement>("input")!.value, + "3", + ); + } finally { + act(() => render(null, container)); + container.remove(); + } +}); + +for (const ambiguous of [false, true]) { + test(`refreshed stock updates the loss panel unless an ambiguous attempt is pending: ${ambiguous}`, async () => { + const container = document.body.appendChild(document.createElement("div")); + const calls: LossRequest[] = []; + const initial = { + ...product, + totalStock: 35, + remainingStock: 0, + stock: "0 stock balance", + }; + const onRecordLoss = async ( + _: string, + request: LossRequest, + ): Promise<LossResult> => { + calls.push(request); + if (ambiguous && calls.length === 1) throw new Error("Response lost"); + return { + status: "recorded", + product: { ...product, lostCount: request.target }, + }; + }; + try { + await act(() => + render( + <ProductLossFields + product={ambiguous ? product : initial} + onRecordLoss={onRecordLoss} + />, + container, + ), + ); + input(container, "3"); + await click(container); + assert.equal(calls.length, ambiguous ? 1 : 0); + const refreshed = { + ...product, + totalStock: 110, + lostCount: 8, + remainingStock: 72, + stock: "72 stock balance", + }; + await act(() => + render( + <ProductLossFields product={refreshed} onRecordLoss={onRecordLoss} />, + container, + ), + ); + assert.equal( + container.querySelector("[data-total-lost]")!.textContent, + ambiguous ? "5" : "8", + ); + await click(container); + const request = calls.at(-1)!; + assert.equal(request.totalStock, ambiguous ? 100 : 110); + assert.equal(request.target, ambiguous ? 8 : 11); + assert.equal(request.retry, ambiguous ? true : undefined); + assert.match(container.textContent!, /Loss recorded/); + } finally { + act(() => render(null, container)); + container.remove(); + } + }); +} diff --git a/packages/taler-merchant-webui/src/screens/ProductLossFields.tsx b/packages/taler-merchant-webui/src/screens/ProductLossFields.tsx @@ -0,0 +1,150 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { useLayoutEffect, useState } from "preact/hooks"; +import { useSignal } from "@preact/signals"; +import type { ProductItem } from "../types/domain.js"; +import type { LossRequest, RecordLoss } from "../utils/inventory.js"; +import { useTranslation } from "../context/translation.js"; +import { normalizeUiFailure, formatErrorMessage } from "../utils/errors.js"; + +/** Losses are saved separately from the product draft. */ +export function ProductLossFields({ + product, + onRecordLoss, +}: { + product: ProductItem; + onRecordLoss: RecordLoss; +}) { + const { t } = useTranslation(); + const [snapshot, setSnapshot] = useState(product); + const [quantity, setQuantity] = useState(""); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + const pending = useSignal<LossRequest | undefined>(undefined); + const busy = useSignal(false); + useLayoutEffect(() => { + // Do not replace the baseline or target of an in-flight/ambiguous write. + if (!pending.value) setSnapshot(product); + }, [product]); + const record = async () => { + if (busy.value) return; + setError(""); + setMessage(""); + if (!pending.value) { + const additional = Number(quantity); + const lost = snapshot.lostCount; + if ( + !/^\d+$/.test(quantity) || + !Number.isSafeInteger(additional) || + additional <= 0 || + lost === undefined || + !Number.isSafeInteger(lost + additional) + ) { + setError(t`Enter a positive whole number of lost units.`); + return; + } + if ( + snapshot.remainingStock !== undefined && + additional > snapshot.remainingStock + ) { + setError(t`The loss exceeds the stock balance.`); + return; + } + pending.value = { + totalStock: snapshot.totalStock!, + soldCount: snapshot.soldCount!, + lostCount: lost, + unitTotalStock: snapshot.unitTotalStock, + target: lost + additional, + }; + } + busy.value = true; + try { + const result = await onRecordLoss(product.id, pending.value); + setSnapshot(result.product); + pending.value = undefined; + if (result.status === "changed") { + setError( + t`Stock changed. Review the updated totals and submit the loss again.`, + ); + } else { + setQuantity(""); + setMessage(t`Loss recorded.`); + } + } catch (err) { + const failure = normalizeUiFailure(err); + if (failure.outcome === "rejected") pending.value = undefined; + else if (pending.value) pending.value = { ...pending.value, retry: true }; + setError( + formatErrorMessage( + err, + t`The loss could not be confirmed. Retry to check the same loss.`, + t, + ), + ); + } finally { + busy.value = false; + } + }; + return ( + <div class="space-y-3 border-t border-gray-200 pt-4"> + <p> + {t`Total units lost`}:{" "} + <strong data-total-lost>{snapshot.lostCount}</strong> + </p> + <p>{snapshot.stock}</p> + <label + class="block text-sm font-semibold" + htmlFor="prod_loss_input" + >{t`Additional units lost`}</label> + <input + id="prod_loss_input" + type="text" + inputMode="numeric" + value={quantity} + disabled={busy.value || pending.value !== undefined} + aria-invalid={error ? "true" : undefined} + aria-describedby="prod_loss_help" + onInput={(e) => setQuantity(e.currentTarget.value)} + class="w-full rounded-lg border border-gray-300 px-3 py-2" + /> + <p + id="prod_loss_help" + class="text-xs text-gray-500" + >{t`Record whole units lost, spoiled, or stolen. This saves separately from other product edits.`}</p> + {error && ( + <p role="alert" class="text-sm text-red-700"> + {error} + </p> + )} + {message && ( + <p role="status" class="text-sm text-green-700"> + {message} + </p> + )} + <button + id="prod_record_loss_btn" + type="button" + disabled={busy.value} + onClick={record} + class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50" + > + {pending.value ? t`Retry loss` : t`Record loss`} + </button> + </div> + ); +} diff --git a/packages/taler-merchant-webui/src/screens/screens.test.tsx b/packages/taler-merchant-webui/src/screens/screens.test.tsx @@ -2059,6 +2059,7 @@ test("InventoryScreen renders products and categories tabs", () => { stock: "8 in stock", stockTracked: true, soldCount: 3, + lostCount: 2, category: "General", }, { @@ -2068,6 +2069,7 @@ test("InventoryScreen renders products and categories tabs", () => { stock: "", stockTracked: false, soldCount: 1, + lostCount: 7, category: "General", }, ]} @@ -2080,6 +2082,20 @@ test("InventoryScreen renders products and categories tabs", () => { assert.match(container.innerHTML, /Coffee beans 500 g/); assert.match(container.innerHTML, /Unlimited/); assert.match(container.innerHTML, /Categories/); + // Both the mobile cards and desktop table show losses only for tracked stock. + for (const selector of ["article", "tbody tr"]) { + const rows = Array.from(container.querySelectorAll(selector)); + const tracked = rows.find((row) => + row.textContent?.includes("Coffee beans"), + ); + const unlimited = rows.find((row) => + row.textContent?.includes("Coffee tasting"), + ); + assert.ok(tracked); + assert.ok(unlimited); + assert.match(tracked.textContent ?? "", /2 lost/); + assert.doesNotMatch(unlimited.textContent ?? "", /lost/); + } render(null, container); // unmount so effect cleanups run document.body.removeChild(container); diff --git a/packages/taler-merchant-webui/src/utils/inventory.test.ts b/packages/taler-merchant-webui/src/utils/inventory.test.ts @@ -21,6 +21,7 @@ import { loadInventoryEntries, productItem, mergeProductUpdate, + lossState, } from "./inventory.js"; const t = ((strings: TemplateStringsArray, ...values: unknown[]) => strings.reduce((a, s, i) => a + s + (values[i] ?? ""), "")) as TranslateFn; @@ -174,6 +175,39 @@ test("full updates preserve hidden fields, precision, timestamp and additional p ); }); +test("loss targets are idempotent and changed counters require review", () => { + const request = { + totalStock: 100, + soldCount: 30, + lostCount: 5, + unitTotalStock: "100", + target: 8, + }; + assert.equal(lossState(detail, request), "write"); + assert.equal(lossState({ ...detail, total_lost: 8 }, request), "changed"); + assert.equal( + lossState({ ...detail, total_lost: 8 }, { ...request, retry: true }), + "recorded", + ); + assert.equal(lossState({ ...detail, total_lost: 6 }, request), "changed"); + assert.equal(lossState({ ...detail, total_lost: 9 }, request), "changed"); + assert.equal( + lossState({ ...detail, total_lost: 9 }, { ...request, retry: true }), + "recorded", + ); + assert.equal(lossState({ ...detail, total_sold: 31 }, request), "changed"); + assert.equal( + lossState({ ...detail, unit_total_stock: "101" }, request), + "changed", + ); + assert.throws(() => lossState(detail, { ...request, target: 71 }), /exceeds/); + for (const target of [5, -1, 5.5, Number.MAX_SAFE_INTEGER + 1]) + assert.throws( + () => lossState(detail, { ...request, target }), + /positive whole/, + ); +}); + for (const counters of [ { total_stock: -1, unit_total_stock: undefined }, { total_stock: -1, unit_total_stock: "-1" }, @@ -198,4 +232,15 @@ for (const counters of [ assert.equal(patch.unit_total_stock, "-1"); assert.equal(patch.total_stock, undefined); } + assert.throws( + () => + lossState(current, { + totalStock: counters.total_stock, + unitTotalStock: counters.unit_total_stock, + soldCount: 30, + lostCount: 5, + target: 8, + }), + /Unlimited stock/, + ); }); diff --git a/packages/taler-merchant-webui/src/utils/inventory.ts b/packages/taler-merchant-webui/src/utils/inventory.ts @@ -161,3 +161,48 @@ export function mergeProductUpdate(current: Detail, changes: Patch): Patch { return body; } +export interface LossRequest { + totalStock: number; + soldCount: number; + lostCount: number; + unitTotalStock?: string; + target: number; + /** A previous write may have succeeded despite an unconfirmed response. */ + retry?: boolean; +} +export interface LossResult { + status: "changed" | "recorded"; + product: ProductItem; +} +export type RecordLoss = ( + id: string, + request: LossRequest, +) => Promise<LossResult>; + +export function lossState( + current: Detail, + request: LossRequest, +): "changed" | "recorded" | "write" { + if ( + !Number.isSafeInteger(request.target) || + request.target <= request.lostCount + ) + throw new Error("Enter a positive whole number of lost units."); + // An ambiguous retry must never increment the target a second time. + if (request.retry && current.total_lost >= request.target) return "recorded"; + if ( + current.total_stock !== request.totalStock || + current.total_sold !== request.soldCount || + current.total_lost !== request.lostCount || + current.unit_total_stock !== request.unitTotalStock + ) + return "changed"; + if (stockIsUnlimited(current)) + throw new Error("Unlimited stock cannot record a loss."); + if ( + !current.unit_allow_fraction && + request.target > current.total_stock - current.total_sold + ) + throw new Error("The loss exceeds the stock balance."); + return "write"; +}