diff --git a/docs/stock-counts.md b/docs/stock-counts.md index 4c0cef9..c7ca7a6 100644 --- a/docs/stock-counts.md +++ b/docs/stock-counts.md @@ -2,7 +2,9 @@ Stock mode uses the ordinary Giellie text box, attachments and camera. It persists on the chat. Quantity changes require an explicit approval of a stored review even when Trust this chat is on. -Ask for a stock count sheet by supplier, brand text, category or selected items, optionally “split into three lists”. Every active matching item is included, even when unpriced or its tracked quantity is zero or negative. Brand selection is text-based until stock has a canonical brand field. Supplier means the item's default supplier. +Ask for a stock count sheet by supplier, brand text, category or selected items, optionally “split into three lists”. Selection uses the same active, priced stock_items catalogue as the POS, including zero and negative tracked quantities. It never selects from the supplier price_list. Brand selection is text-based until stock has a canonical brand field. Supplier means the item's default supplier. + +PDFs are rendered in memory for download and never uploaded to PocketBase or retained as document history. A temporary working manifest maps returned photos to the selected stock items. Once all rows have been applied, the working manifest and download parts are cleared; stock ledger and idempotency records remain as the inventory audit trail. PDFs show the sheet reference/revision, part, page, stable row reference, SKU, description, unit, current quantity at printing, and a blank physical-count box. Write zero explicitly; blank means not counted. A QR code opens the original chat/count context after sign-in. Upload completed JPEG/PNG/WebP photos through the chat camera or attachment control. Include the printed header, row references and SKUs. Photographs never change stock on their own. @@ -18,13 +20,13 @@ Developed in an isolated worktree; no live schema, hooks or stock were changed d 4. Deploy the matching app version. Do not leave the old app serving stock writes while new hooks reject its unversioned direct writes; use a coordinated maintenance cutover. 5. Verify a stock-mode save round-trips, a small sheet downloads after reload, and a representative handwritten photo yields the right proposed counts. Verify actual counts and unclear readings with the operator before approving production changes. -The new flow fails closed if required hooks/schema are absent. Protected count PDFs are served through owned session-authenticated routes. Bearer tokens never appear in QR codes or browser links. +The new flow fails closed if required hooks/schema are absent. Download-only PDFs are rendered through owned session-authenticated routes. Bearer tokens never appear in QR codes or browser links. ## Recovery A connection failure does not prove a stock write failed. Retry with the same movement operation identifier to retrieve its committed outcome. Do not create a new operation ID for an uncertain retry. Catalogue mirrors are secondary; their failure must not replay an inventory movement. -The immutable count manifest and stock review live in PB, not solely in truncated chat history. Retain them and ledger/operation records for investigation. A PDF generation interrupted in building state has no inventory effect; regenerate before attempting a read. +The active count manifest and review remain available across chat reloads while the count is in progress. PDFs are not retained. Stock ledger and operation records keep applied adjustments safe to retry; they are not a document archive. ## Verification record diff --git a/src/app/api/v1/stock-counts/[id]/pdf/[part]/route.ts b/src/app/api/v1/stock-counts/[id]/pdf/[part]/route.ts index 77e073e..3ed8da8 100644 --- a/src/app/api/v1/stock-counts/[id]/pdf/[part]/route.ts +++ b/src/app/api/v1/stock-counts/[id]/pdf/[part]/route.ts @@ -11,7 +11,7 @@ export async function GET( try { const user = await getBrowserSessionUser(req); const p = await params; - const file = await stockCountPdf(p.id, Number(p.part), user); + const file = await stockCountPdf(p.id, Number(p.part), user, req.nextUrl.origin); return new NextResponse(file.bytes, { headers: { "Content-Type": "application/pdf", diff --git a/src/lib/server/giellie-system-prompt.ts b/src/lib/server/giellie-system-prompt.ts index ddbe4a3..b8e24c2 100644 --- a/src/lib/server/giellie-system-prompt.ts +++ b/src/lib/server/giellie-system-prompt.ts @@ -42,6 +42,7 @@ When the user pastes stock lines (a SKU or description with a quantity): Quantities are not catalogue work: price_list.add_items writes only the supplier catalogue (price_list.stock_qty), and the till quantity lives on stock_items (stock_items.current_qty). Stock count sheets: +- Count sheets use only the active, priced POS stock_items catalogue, never the supplier price_list. PDFs are generated for download and not stored as history. A temporary working count links photos to items until the count is completed. - Use stock_counts.create to make a printable list filtered by supplier_id, brand, category, search or item_ids. Resolve any ambiguous supplier or item first. Give parts for a requested split; use stock_counts.split to repartition an existing unsubmitted count. A part may contain several pages. Show the returned downloadable PDFs; never fabricate a file link. - Each sheet shows the SKU, description, unit, printed stock and a blank new physical count. Blank is not counted; explicit 0 means none. These are absolute on-hand counts, so do not ask set-versus-add again for a completed count sheet. Bare quantity lists still need clarification unless the user already clearly stated their meaning. - For a photo, identify the count id and part from the printed reference or ask which sheet. Call stock_counts.read with that id, part and the exact attachment_refs registered in the user turn. Never copy the printed current column as the new count. Never invent a row reference or an unclear handwritten number. diff --git a/src/lib/server/stock-count-mcp.ts b/src/lib/server/stock-count-mcp.ts index 27f4f2b..0908da6 100644 --- a/src/lib/server/stock-count-mcp.ts +++ b/src/lib/server/stock-count-mcp.ts @@ -34,8 +34,8 @@ export const STOCK_COUNT_TOOLS = [ description: ( { create: - "Generate printable stock count PDFs, optionally filtered by supplier, brand text, category or explicit items and split into N balanced lists. Does not change inventory.", - get: "Get a saved stock count and PDF links.", + "Prepare a temporary count from the POS stock_items catalogue (never price_list). PDFs are generated only when downloaded and are not stored. Optionally filtered by supplier, brand text, category or explicit items and split into N balanced lists. Does not change inventory.", + get: "Get an active working count and on-demand PDF links.", split: "Split a saved unused count into N PDFs.", read: "Read registered chat photos of a stock count part. Returns readings for review, never writes inventory.", preview: diff --git a/src/lib/server/stock-count-public-url.test.ts b/src/lib/server/stock-count-public-url.test.ts new file mode 100644 index 0000000..9ccc45f --- /dev/null +++ b/src/lib/server/stock-count-public-url.test.ts @@ -0,0 +1,6 @@ +import {it,expect,afterEach,vi} from 'vitest'; +import {stockCountReturnUrl} from './stock-count-public-url'; +afterEach(()=>vi.unstubAllEnvs()); +it('uses the public WFDASH address for container origins',()=>{vi.stubEnv('NEXT_PUBLIC_BASE_URL','');vi.stubEnv('AGENT_API_BASE_URL','');expect(stockCountReturnUrl('abc123','https://0.0.0.0:80')).toBe('https://wfdash.apphub.co.za/giellie?stock_count=abc123');}); +it('uses a configured public origin without exposing credentials',()=>{vi.stubEnv('NEXT_PUBLIC_BASE_URL','https://public.example');expect(stockCountReturnUrl('abc123','http://localhost:3000')).toBe('https://public.example/giellie?stock_count=abc123');}); +it('keeps a public request origin when no public URL is configured',()=>{vi.stubEnv('NEXT_PUBLIC_BASE_URL','');vi.stubEnv('AGENT_API_BASE_URL','');expect(stockCountReturnUrl('abc123','https://wfdash.apphub.co.za')).toContain('https://wfdash.apphub.co.za/giellie?');}); diff --git a/src/lib/server/stock-count-public-url.ts b/src/lib/server/stock-count-public-url.ts new file mode 100644 index 0000000..2f28ba1 --- /dev/null +++ b/src/lib/server/stock-count-public-url.ts @@ -0,0 +1,8 @@ +/** QR links are for a phone, never the reverse proxy's container origin. */ +export function stockCountReturnUrl(id: string, requestOrigin: string): string { + for (const candidate of [process.env.NEXT_PUBLIC_BASE_URL, process.env.AGENT_API_BASE_URL, requestOrigin, 'https://wfdash.apphub.co.za']) { + if (!candidate) continue; + try { const url=new URL(candidate); if(url.protocol!=='https:'||url.username||url.password||['0.0.0.0','127.0.0.1','localhost','[::1]'].includes(url.hostname))continue; return new URL(`/giellie?stock_count=${encodeURIComponent(id)}`,url.origin).href; } catch { /* try the public fallback */ } + } + return `https://wfdash.apphub.co.za/giellie?stock_count=${encodeURIComponent(id)}`; +} diff --git a/src/lib/server/stock-count-service.test.ts b/src/lib/server/stock-count-service.test.ts index 5a9db37..219b68b 100644 --- a/src/lib/server/stock-count-service.test.ts +++ b/src/lib/server/stock-count-service.test.ts @@ -15,7 +15,7 @@ vi.mock("./giellie-chat-store", () => ({ getChat: (...args: unknown[]) => getCha vi.mock("./stock-count-read", () => ({ readStockCountPhoto: (...args: unknown[]) => reader(...args) })); vi.mock("@/lib/pdf/stock-count-document", () => ({ renderStockCountPdf: (...args: unknown[]) => renderPdf(...args) })); vi.mock("./stock-movement-service", () => ({ applyStockMovement: (...args: unknown[]) => movement(...args) })); -import { getStockCount, createStockCount, previewStockCount, readStockCount, stockCountSummary } from "./stock-count-service"; +import { getStockCount, createStockCount, previewStockCount, readStockCount, stockCountSummary, stockCountPdf, clearWorkingCountIfComplete } from "./stock-count-service"; const id = "abcde12345fghij"; const chatId = "abcdefghijklmno"; const actor = { id: "user00000000001", name: "Dave" }; @@ -149,3 +149,27 @@ describe("registered photo sources", () => { expect(result.readings).toEqual([reading()]); }); }); + +describe("download-only count PDFs",()=>{ + it("creates a temporary working count without rendering or uploading a PDF",async()=>{ + getFullList.mockResolvedValue([item({selling_price:100})]); + create.mockResolvedValue(count()); + update.mockImplementation(async(_name,_id,patch)=>({...count(),...patch})); + await createStockCount({parts:1},actor,'https://0.0.0.0:80'); + expect(renderPdf).not.toHaveBeenCalled(); + expect(update.mock.calls.every(([, ,body])=>!(body instanceof FormData))).toBe(true); + expect(getFullList.mock.calls[0][1].filter).toBe('is_active = true && selling_price > 0'); + }); + it("renders the requested part in memory at download time and never stores bytes",async()=>{ + renderPdf.mockResolvedValue(Buffer.from('%PDF-test')); + const result=await stockCountPdf(id,1,actor); + expect(Buffer.from(result.bytes).toString()).toBe('%PDF-test'); + expect(renderPdf).toHaveBeenCalledWith(expect.objectContaining({reference:'SC123-R1',part:1,rows:[row]})); + expect(create).not.toHaveBeenCalled();expect(update).not.toHaveBeenCalled(); + }); +}); + +describe("temporary count lifecycle",()=>{ + it("keeps the working count until every row has an applied observation",async()=>{getFullList.mockResolvedValue([]);await clearWorkingCountIfComplete(id,actor);expect(update).not.toHaveBeenCalled();}); + it("clears the working sheet after every row has been applied",async()=>{getFullList.mockResolvedValue([{observation_key:`${id}_${row.row_ref}`}]);await clearWorkingCountIfComplete(id,actor);expect(update).toHaveBeenCalledWith('stock_counts',id,{status:'completed',manifest:[],parts:[]});}); +}); diff --git a/src/lib/server/stock-count-service.ts b/src/lib/server/stock-count-service.ts index ec55a8a..04f1cab 100644 --- a/src/lib/server/stock-count-service.ts +++ b/src/lib/server/stock-count-service.ts @@ -20,6 +20,8 @@ import { type StockPlanRow, } from "./stock-count-quantity-plans"; import { applyStockMovement } from "./stock-movement-service"; +import { stockCountReturnUrl } from "./stock-count-public-url"; +import { POS_CATALOG_FILTER } from "./pos-service"; export interface CountActor { id: string; name: string; @@ -71,51 +73,11 @@ export function stockCountSummary(c: CountRecord) { })), }; } -async function publishParts(count: CountRecord, parts: number, origin: string) { - const pb = await getAdminPocketBase(); - const groups = splitStockCountRows(count.manifest, parts); - const manifest: CountPart[] = []; - const fd = new FormData(); - // Only superseded PDFs belonging to this same count are removed. - for (const filename of count.pdfs || []) fd.append("pdfs-", filename); - const qr = await QRCode.toDataURL( - new URL(`/giellie?stock_count=${count.id}`, origin).href, - { margin: 1, width: 180 }, - ); - for (let i = 0; i < groups.length; i++) { - const filename = `count_${count.reference}_r${count.revision}_part${i + 1}.pdf`; - const bytes = await renderStockCountPdf({ - reference: `${count.reference}-R${count.revision}`, - part: i + 1, - parts, - scope: count.scope, - generatedAtIso: count.created || new Date().toISOString(), - rows: groups[i], - qrDataUrl: qr, - }); - fd.append( - "pdfs+", - new Blob([new Uint8Array(bytes)], { type: "application/pdf" }), - filename, - ); - manifest.push({ - part: i + 1, - row_refs: groups[i].map((r) => r.row_ref), - filename, - }); - } - const updated = await pb - .collection("stock_counts") - .update(count.id, fd); - for (const part of manifest) { - const prefix = part.filename.slice(0, -4).toLowerCase(); - const stored = updated.pdfs.find((f) => f.toLowerCase().startsWith(prefix)); - if (!stored) fail("PDF storage could not be verified.", 503); - part.filename = stored!; - } - return pb - .collection("stock_counts") - .update(count.id, { parts: manifest, status: "ready" }); +async function publishParts(count: CountRecord, parts: number, _origin: string) { + const groups=splitStockCountRows(count.manifest,parts); + const manifest=groups.map((rows,i)=>({part:i+1,row_refs:rows.map(r=>r.row_ref),filename:`count_${count.reference}_r${count.revision}_part${i+1}.pdf`})); + const pb=await getAdminPocketBase(); + return pb.collection("stock_counts").update(count.id,{parts:manifest,status:"ready"}); } export async function createStockCount( selection: CountSelection & { parts?: number; chat_id?: string }, @@ -128,7 +90,7 @@ export async function createStockCount( await pb.send("/api/wfdash/stock-counts-capability", { method: "GET" }); const items = await pb .collection("stock_items") - .getFullList({ filter: "is_active = true", expand: "supplier" }); + .getFullList({ filter: POS_CATALOG_FILTER, expand: "supplier" }); if (selection.category) { const { buildCategoryMatcher, matchItem } = await import("./pos-service"); const categories = await pb.collection("categories").getFullList({ @@ -189,29 +151,14 @@ export async function splitStockCount( ); return stockCountSummary(await publishParts(reserved, parts, origin)); } -export async function stockCountPdf( - id: string, - part: number, - actor: CountActor, -) { - const c = await getStockCount(id, actor); - const p = c.parts.find((p) => p.part === part); - if (!p) fail("PDF part not found.", 404); - const pb = await getAdminPocketBase(); - const token = await pb.files.getToken(); - const url = pb.files.getURL( - { id: c.id, collectionName: "stock_counts" }, - p!.filename, - { token }, - ); - const res = await fetch(url, { - headers: { Authorization: pb.authStore.token }, - }); - if (!res.ok) fail("Stored PDF unavailable.", 502); - return { - bytes: new Uint8Array(await res.arrayBuffer()), - filename: p!.filename, - }; +/** Generate a download in memory. No PDF is uploaded or retained by WFDASH. */ +export async function stockCountPdf(id:string,part:number,actor:CountActor,origin='https://wfdash.apphub.co.za') { + const c=await getStockCount(id,actor); + if(c.status!=="ready")fail("This working count is closed or not ready.",410); + const p=c.parts.find(p=>p.part===part);if(!p)fail("PDF part not found.",404); + const qrDataUrl=await QRCode.toDataURL(stockCountReturnUrl(c.id,origin),{margin:1,width:180}); + const bytes=await renderStockCountPdf({reference:`${c.reference}-R${c.revision}`,part,parts:c.parts.length,scope:c.scope,generatedAtIso:c.created,rows:c.manifest.filter(r=>p!.row_refs.includes(r.row_ref)),qrDataUrl}); + return {bytes:new Uint8Array(bytes),filename:`count_${c.reference}_r${c.revision}_part${part}.pdf`}; } export async function readStockCount( id: string, @@ -448,6 +395,9 @@ export async function applyStockCountPlan(id: string, actor: CountActor) { }); } } + if(p.count && results.every(row=>row.status === "applied")){ + try { await clearWorkingCountIfComplete(p.count,actor); } catch { /* Inventory is already committed; a metadata cleanup retry must not replay it. */ } + } return { plan_id: id, results }; } @@ -464,3 +414,13 @@ export async function stockCountDetails(id: string, actor: CountActor) { reviews: plans.map((p) => ({ plan_id: p.id, status: p.status })), }; } + +/** Remove working-sheet content when all physical observations have been applied. */ +export async function clearWorkingCountIfComplete(id: string, actor: CountActor): Promise { + const count = await getStockCount(id, actor); + if(count.status !== 'ready' || !count.manifest.length) return; + const pb = await getAdminPocketBase(); + const operations = await pb.collection('stock_movement_operations').getFullList<{observation_key:string}>({filter:`observation_key ~ "${id}_"`,fields:'observation_key'}); + const applied = new Set(operations.map(row=>row.observation_key)); + if(count.manifest.every(row=>applied.has(`${id}_${row.row_ref}`))) await pb.collection('stock_counts').update(id,{status:'completed',manifest:[],parts:[]}); +}