fix(po): stop PocketBase auto-cancellation from killing PO creation
The Purchase Order form never reached the server. Creating a PO with two or more "Manual Entry" rows fired N parallel POSTs to /api/collections/stock_items/records via Promise.all. The PocketBase JS SDK auto-cancels any in-flight request whose method + path match a newer one, so the first POST was aborted with: ClientResponseError 0: The request was autocancelled. Promise.all then rejected before the PO create ever ran, so the PO was never created - only an orphan stock item per attempt. Confirmed against the production PB logs: two stock_items POSTs today, zero purchase_orders POSTs, and two orphan stock_items rows with no PO. Fixes: - src/lib/pocketbase.ts: pb.autoCancellation(false) on the browser client. Every server-side client in this repo already did this (scripts/*.mjs, server/po-api.mjs) - the browser client was the one place it was missing. - PurchaseOrderForm: resolve rows sequentially instead of Promise.all, and build the snapshot in the same pass so manual rows carry the real stock item id instead of "". - PurchaseOrderForm: disable the submit button while submitting, so a double click cannot create two POs. - PurchaseOrderForm: compare SKUs trimmed, so a stray trailing space does not create a duplicate stock item. - po-photo-ingest.mjs, jei_skill.ts, setup-users.js: same autoCancellation(false) guard.
This commit is contained in:
parent
a30270e77f
commit
2b56053e1c
5 changed files with 97 additions and 44 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<string, any> = {
|
||||
|
|
@ -767,7 +788,13 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro
|
|||
onFilesChange={setAttachedFiles}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.98] transition-all">Create Purchase Order</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={form.formState.isSubmitting}
|
||||
className="w-full bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.98] transition-all disabled:opacity-60 disabled:pointer-events-none"
|
||||
>
|
||||
{form.formState.isSubmitting ? "Creating…" : "Create Purchase Order"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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/<c>/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;
|
||||
|
|
|
|||
|
|
@ -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 || ''
|
||||
|
|
|
|||
Loading…
Reference in a new issue