diff --git a/scripts/po-photo-ingest.mjs b/scripts/po-photo-ingest.mjs index 15c1450..df06c1e 100644 --- a/scripts/po-photo-ingest.mjs +++ b/scripts/po-photo-ingest.mjs @@ -44,6 +44,9 @@ async function main() { } const pb = new PocketBase(PB_URL); + // Never let the SDK auto-cancel a request: parallel stock-item lookups in + // photoIngest share one method+path and would otherwise cancel each other. + pb.autoCancellation(false); await pb.admins.authWithPassword(PB_EMAIL, PB_PASSWORD); const photoUrl = pathToFileURL(path.join(ROOT, 'src/lib/api/photoIngest.ts')).href; diff --git a/setup-users.js b/setup-users.js index a0ec46b..300b22c 100644 --- a/setup-users.js +++ b/setup-users.js @@ -1,6 +1,8 @@ import PocketBase from 'pocketbase'; const pb = new PocketBase(process.env.VITE_PB_URL || 'http://127.0.0.1:8090'); +// Never let the SDK auto-cancel a request (see src/lib/pocketbase.ts). +pb.autoCancellation(false); async function ensureUsersCollection() { try { diff --git a/src/components/purchase-orders/PurchaseOrderForm.tsx b/src/components/purchase-orders/PurchaseOrderForm.tsx index e9a5b16..acd23cc 100644 --- a/src/components/purchase-orders/PurchaseOrderForm.tsx +++ b/src/components/purchase-orders/PurchaseOrderForm.tsx @@ -285,62 +285,83 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro manualUnitPrice: Number(item.manualUnitPrice) || 0, })); - // Resolve items — for manual entries, create stock items on-the-fly - const resolvedItems = await Promise.all( - normalizedItems.map(async (item) => { - if (item.entryMode === 'manual') { - // Look up by manual SKU first - const existing = stockItems.find(si => si.sku === item.manualSku && item.manualSku); - if (existing) return existing.id; + // Resolve items — for manual entries, create stock items on-the-fly. + // + // SEQUENTIAL ON PURPOSE. The PocketBase JS SDK cancels any in-flight + // request that shares a method + path with a newer one, so two parallel + // POSTs to /api/collections/stock_items/records cancel each other: the + // first is aborted with "The request was autocancelled", the rejection + // propagates, and the purchase order is never created. Resolving one row + // at a time also keeps the SKU de-dup check honest — in parallel, two + // rows with the same new SKU both miss the cache and both create it. + // + // One pass produces both the PB relation ids and the snapshot rows, so + // the snapshot carries the real stock item id instead of "". + const resolvedItems: string[] = []; + const snapshotItems: Array<{ + stockItemId: string; + itemName: string; + sku: string; + quantity: number; + costPrice: number; + costPriceGBP: number; + entryMode: 'stock' | 'manual'; + }> = []; + + // Compare SKUs with surrounding whitespace trimmed so a stray trailing + // space ("JEIPILOT2 ") does not create a duplicate stock item. + const sameSku = (a: string, b: string) => + !!a && !!b && a.trim().toLowerCase() === b.trim().toLowerCase(); + + for (const item of normalizedItems) { + if (item.entryMode === 'manual') { + const sku = item.manualSku || ''; + let stockItem = stockItems.find(si => sameSku(si.sku, sku)); + + if (!stockItem) { // Create new stock item const newStockItem = await pb.collection('stock_items').create({ - name: item.manualDescription || item.manualSku || 'Unnamed Item', - sku: item.manualSku || '', + name: item.manualDescription || sku || 'Unnamed Item', + sku, cost_price_gbp: item.manualUnitPrice || 0, quantity: 0, }); - // Update local stock items cache - setStockItems(prev => [...prev, { + stockItem = { id: newStockItem.id, name: newStockItem.name, sku: newStockItem.sku, costPrice: newStockItem.cost_price || 0, costPriceGBP: newStockItem.cost_price_gbp || 0, - }]); - return newStockItem.id; - } - return item.stockItemId; - }) - ); - - // Build snapshot items — store full details so PO survives stock item deletion - const snapshotItems = await Promise.all( - normalizedItems.map(async (item) => { - if (item.entryMode === 'manual') { - const existing = stockItems.find(si => si.sku === item.manualSku && item.manualSku); - const stockItemId = existing ? existing.id : ''; - return { - stockItemId, - itemName: item.manualDescription || item.manualSku || 'Unnamed Item', - sku: item.manualSku || '', - quantity: item.quantity, - costPrice: 0, - costPriceGBP: item.manualUnitPrice || 0, - entryMode: 'manual' as const, }; + // Update local stock items cache + setStockItems(prev => [...prev, stockItem!]); } - const stockItem = stockItems.find(si => si.id === item.stockItemId); - return { - stockItemId: item.stockItemId, - itemName: item.itemName, - sku: item.sku, + + resolvedItems.push(stockItem.id); + snapshotItems.push({ + stockItemId: stockItem.id, + itemName: item.manualDescription || sku || 'Unnamed Item', + sku, quantity: item.quantity, - costPrice: Number(item.costPrice || 0), - costPriceGBP: Number(stockItem?.costPriceGBP || 0), - entryMode: 'stock' as const, - }; - }) - ); + costPrice: 0, + costPriceGBP: item.manualUnitPrice || 0, + entryMode: 'manual', + }); + continue; + } + + const stockItem = stockItems.find(si => si.id === item.stockItemId); + resolvedItems.push(item.stockItemId); + snapshotItems.push({ + stockItemId: item.stockItemId, + itemName: item.itemName, + sku: item.sku, + quantity: item.quantity, + costPrice: Number(item.costPrice || 0), + costPriceGBP: Number(stockItem?.costPriceGBP || 0), + entryMode: 'stock', + }); + } // Build the PO data const purchaseOrderData: Record = { @@ -767,7 +788,13 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro onFilesChange={setAttachedFiles} /> - + diff --git a/src/lib/pocketbase.ts b/src/lib/pocketbase.ts index 1a4ac9b..85def2d 100644 --- a/src/lib/pocketbase.ts +++ b/src/lib/pocketbase.ts @@ -20,6 +20,24 @@ function buildPbUrl(raw?: string): string { const PB_URL = buildPbUrl(import.meta.env.VITE_PB_URL); const pb = new PocketBase(PB_URL); +// Disable the SDK's request auto-cancellation. +// +// The PocketBase JS SDK aborts any in-flight request whose method + path +// match a NEWER request. That is fine for "latest search wins" list reads, +// but it silently kills concurrent WRITES to the same collection: the first +// POST /api/collections//records is aborted with +// "ClientResponseError 0: The request was autocancelled", the surrounding +// Promise.all rejects, and the write never lands. +// +// That is exactly how the Purchase Order form failed: two manual rows each +// created a stock item in parallel, the first was cancelled, Promise.all +// rejected, and the PO itself was never created. +// +// Every server-side client in this repo already calls +// `pb.autoCancellation(false)` (scripts/*.mjs, server/po-api.mjs). The +// browser client was the one place it was missing. +pb.autoCancellation(false); + // Optional: expose resolved URL for diagnostics // @ts-ignore pb.__resolvedUrl = PB_URL; diff --git a/src/skills/jei_skill.ts b/src/skills/jei_skill.ts index d0562d7..f0cd8ea 100644 --- a/src/skills/jei_skill.ts +++ b/src/skills/jei_skill.ts @@ -3,6 +3,9 @@ import PocketBase from 'pocketbase' // PocketBase URL - change this to match your environment const PB_URL = 'https://pb.jeistk.apphub.co.za' const pb = new PocketBase(PB_URL) +// Never let the SDK auto-cancel a request: concurrent writes to the same +// collection share one method+path and would otherwise cancel each other. +pb.autoCancellation(false) // Authenticate as admin using API token (set in env or replace here) const API_TOKEN = import.meta.env.VITE_PB_ADMIN_TOKEN || ''