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);
|
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);
|
await pb.admins.authWithPassword(PB_EMAIL, PB_PASSWORD);
|
||||||
|
|
||||||
const photoUrl = pathToFileURL(path.join(ROOT, 'src/lib/api/photoIngest.ts')).href;
|
const photoUrl = pathToFileURL(path.join(ROOT, 'src/lib/api/photoIngest.ts')).href;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import PocketBase from 'pocketbase';
|
import PocketBase from 'pocketbase';
|
||||||
|
|
||||||
const pb = new PocketBase(process.env.VITE_PB_URL || 'http://127.0.0.1:8090');
|
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() {
|
async function ensureUsersCollection() {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -285,62 +285,83 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro
|
||||||
manualUnitPrice: Number(item.manualUnitPrice) || 0,
|
manualUnitPrice: Number(item.manualUnitPrice) || 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Resolve items — for manual entries, create stock items on-the-fly
|
// Resolve items — for manual entries, create stock items on-the-fly.
|
||||||
const resolvedItems = await Promise.all(
|
//
|
||||||
normalizedItems.map(async (item) => {
|
// SEQUENTIAL ON PURPOSE. The PocketBase JS SDK cancels any in-flight
|
||||||
if (item.entryMode === 'manual') {
|
// request that shares a method + path with a newer one, so two parallel
|
||||||
// Look up by manual SKU first
|
// POSTs to /api/collections/stock_items/records cancel each other: the
|
||||||
const existing = stockItems.find(si => si.sku === item.manualSku && item.manualSku);
|
// first is aborted with "The request was autocancelled", the rejection
|
||||||
if (existing) return existing.id;
|
// 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
|
// Create new stock item
|
||||||
const newStockItem = await pb.collection('stock_items').create({
|
const newStockItem = await pb.collection('stock_items').create({
|
||||||
name: item.manualDescription || item.manualSku || 'Unnamed Item',
|
name: item.manualDescription || sku || 'Unnamed Item',
|
||||||
sku: item.manualSku || '',
|
sku,
|
||||||
cost_price_gbp: item.manualUnitPrice || 0,
|
cost_price_gbp: item.manualUnitPrice || 0,
|
||||||
quantity: 0,
|
quantity: 0,
|
||||||
});
|
});
|
||||||
// Update local stock items cache
|
stockItem = {
|
||||||
setStockItems(prev => [...prev, {
|
|
||||||
id: newStockItem.id,
|
id: newStockItem.id,
|
||||||
name: newStockItem.name,
|
name: newStockItem.name,
|
||||||
sku: newStockItem.sku,
|
sku: newStockItem.sku,
|
||||||
costPrice: newStockItem.cost_price || 0,
|
costPrice: newStockItem.cost_price || 0,
|
||||||
costPriceGBP: newStockItem.cost_price_gbp || 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 {
|
resolvedItems.push(stockItem.id);
|
||||||
stockItemId: item.stockItemId,
|
snapshotItems.push({
|
||||||
itemName: item.itemName,
|
stockItemId: stockItem.id,
|
||||||
sku: item.sku,
|
itemName: item.manualDescription || sku || 'Unnamed Item',
|
||||||
|
sku,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
costPrice: Number(item.costPrice || 0),
|
costPrice: 0,
|
||||||
costPriceGBP: Number(stockItem?.costPriceGBP || 0),
|
costPriceGBP: item.manualUnitPrice || 0,
|
||||||
entryMode: 'stock' as const,
|
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
|
// Build the PO data
|
||||||
const purchaseOrderData: Record<string, any> = {
|
const purchaseOrderData: Record<string, any> = {
|
||||||
|
|
@ -767,7 +788,13 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro
|
||||||
onFilesChange={setAttachedFiles}
|
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>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,24 @@ function buildPbUrl(raw?: string): string {
|
||||||
const PB_URL = buildPbUrl(import.meta.env.VITE_PB_URL);
|
const PB_URL = buildPbUrl(import.meta.env.VITE_PB_URL);
|
||||||
const pb = new PocketBase(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
|
// Optional: expose resolved URL for diagnostics
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
pb.__resolvedUrl = PB_URL;
|
pb.__resolvedUrl = PB_URL;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ import PocketBase from 'pocketbase'
|
||||||
// PocketBase URL - change this to match your environment
|
// PocketBase URL - change this to match your environment
|
||||||
const PB_URL = 'https://pb.jeistk.apphub.co.za'
|
const PB_URL = 'https://pb.jeistk.apphub.co.za'
|
||||||
const pb = new PocketBase(PB_URL)
|
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)
|
// Authenticate as admin using API token (set in env or replace here)
|
||||||
const API_TOKEN = import.meta.env.VITE_PB_ADMIN_TOKEN || ''
|
const API_TOKEN = import.meta.env.VITE_PB_ADMIN_TOKEN || ''
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue