diff --git a/src/app/api/dn-batches/[id]/notes/[noteId]/matching/route.test.ts b/src/app/api/dn-batches/[id]/notes/[noteId]/matching/route.test.ts index 9c97b9e..2949f2f 100644 --- a/src/app/api/dn-batches/[id]/notes/[noteId]/matching/route.test.ts +++ b/src/app/api/dn-batches/[id]/notes/[noteId]/matching/route.test.ts @@ -68,3 +68,52 @@ it('refreshes changed source evidence into an open line without posting or reusi expect(body.preview.allocations[0].operatorConfirmation).toBeUndefined(); expect(m.batch).toHaveBeenCalledTimes(1);expect(m.post).not.toHaveBeenCalled(); }); + +// ---------- post_ready: only the lines that are ready ---------- +const threeLines=()=>{ + const c=context(); + c.lines=[{...c.lines[0],unit:'m',unit_price:10,line_amount:110},c.lines[1],{sourceLineId:'mesh',description:'Mesh 450',qty:4,unit:'ea',unit_price:100,line_amount:400,product_code:null}]; + return c; +}; +const threeLinePreview=(c:ReturnType)=>buildPreview(c,{allocations:[ + {sourceLineIds:['pipe'],purchaseOrderItemId:'itemabcdefghijk',quantityBasis:'direct',factor:1,amountBasis:'po_rate',sourceAmountBasis:'unknown',ruleKeys:[],explanation:'Pipe',reviewQuestion:null}, + {sourceLineIds:['ring'],purchaseOrderItemId:null,quantityBasis:'none',factor:null,amountBasis:'unknown',sourceAmountBasis:'unknown',ruleKeys:[],explanation:'Rubber',reviewQuestion:'How should this ring line be booked?'}, + {sourceLineIds:['mesh'],purchaseOrderItemId:'itemabcdefghijk',quantityBasis:'direct',factor:1,amountBasis:'source',sourceAmountBasis:'unknown',ruleKeys:[],explanation:'Mesh',reviewQuestion:null}, +]}); +it('posts only the ready lines and leaves the rest open',async()=>{ + const ctx=threeLines();p=threeLinePreview(ctx);m.load.mockResolvedValue({context:ctx,stored:p}); + m.post.mockImplementation(async()=>({preview:{...p,allocations:p.allocations.map((a:unknown,index:number)=>index===0?{...(a as object),posted:true}:a)}})); + const r=await POST(req({action:'post_ready',snapshotHash:p.snapshotHash,decisionHash:decisionHash(p)}),params); + expect(r.status).toBe(200); + const body=await r.json(); + expect(body.posted).toBe(1); + expect(body.remaining).toBe(2); + expect(m.post).toHaveBeenCalledTimes(1); + expect(m.post.mock.calls[0][1].body.sourceLineIds).toEqual(['pipe']); +}); +it('refuses a second confirmation while the first holds the lease and never books a line twice',async()=>{ + const ctx=threeLines();p=threeLinePreview(ctx);m.load.mockResolvedValue({context:ctx,stored:p}); + const booked:string[]=[]; + m.post.mockImplementation(async(_pb:unknown,args:{body:{sourceLineIds:string[]}})=>{booked.push(...args.body.sourceLineIds); + return {preview:{...p,allocations:p.allocations.map((a:unknown,index:number)=>index===0?{...(a as object),posted:true}:a)}};}); + let leases=0; + m.lease.mockImplementation(async(_p:unknown,_b:unknown,_s:unknown,fn:(lease:unknown)=>Promise)=>{leases++;if(leases>1)return {acquired:false};return {acquired:true,value:await fn({batch:m.batch})};}); + const body={action:'post_ready',snapshotHash:p.snapshotHash,decisionHash:decisionHash(p)}; + expect((await POST(req(body),params)).status).toBe(200); + const second=await POST(req(body),params); + expect(second.status).toBe(409); + expect((await second.json()).error).toBe('locked'); + expect(booked).toEqual(['pipe']); + expect(new Set(booked).size).toBe(booked.length); +}); +it('refreshes a stale match and posts the ready lines against the refreshed preview',async()=>{ + const ctx=threeLines();p=threeLinePreview(ctx); + const saved=p; + ctx.priorItems=[{purchase_order_item:'itemabcdefghijk',qty_received:5,booking:{net:50}}]; + m.load.mockResolvedValue({context:ctx,stored:saved}); + m.post.mockImplementation(async()=>({preview:saved})); + const r=await POST(req({action:'post_ready',snapshotHash:saved.snapshotHash,decisionHash:decisionHash(saved)}),params); + expect(r.status).toBe(200); + expect(m.batch.mock.calls[0][0][0]).toMatchObject({collection:'dn_batch_notes',data:{reconciliation_preview:expect.objectContaining({snapshotHash:expect.not.stringMatching(saved.snapshotHash)})}}); + expect(m.post).toHaveBeenCalledTimes(1); +}); diff --git a/src/app/api/dn-batches/[id]/notes/[noteId]/matching/route.ts b/src/app/api/dn-batches/[id]/notes/[noteId]/matching/route.ts index 71fcdb8..7fae332 100644 --- a/src/app/api/dn-batches/[id]/notes/[noteId]/matching/route.ts +++ b/src/app/api/dn-batches/[id]/notes/[noteId]/matching/route.ts @@ -6,9 +6,9 @@ import {pbAdmin} from '@/lib/pb'; import {requireApiUser,requireSiteAccess} from '@/lib/auth/guard'; import {withBatchLease} from '@/lib/dn-pipeline/lock'; import {loadPreviewContext} from '@/lib/dn-pipeline/ai-preview-context'; -import {resolveMatch,prepareDelivery,refreshPreview} from '@/lib/dn-pipeline/matching'; +import {resolveMatch,prepareDelivery,prepareReadyLines,refreshPreview} from '@/lib/dn-pipeline/matching'; import {postLineWithinLease} from '@/lib/dn-pipeline/post-line'; -import {deliveryReady} from '@/lib/dn-pipeline/delivery-ready'; +import {readyIndexes} from '@/lib/inbox-three/readiness'; export const dynamic='force-dynamic';export const runtime='nodejs'; /** A refusal the clerk can act on, rather than the generic "could not finish". */ class ActionError extends Error{constructor(readonly error:string,readonly detail:string){super(detail);}} @@ -17,7 +17,7 @@ export async function POST(request:Request,{params}:{params:Promise<{id:string;n const {id,noteId}=await params; if(![id,noteId].every(v=>/^[a-z0-9]{15}$/i.test(v)))return NextResponse.json({error:'invalid_id'},{status:400}); const body=await request.json().catch(()=>null); - if(!body||!['save','refresh','post'].includes(body.action))return NextResponse.json({error:'invalid_action'},{status:400}); + if(!body||!['save','refresh','post','post_ready'].includes(body.action))return NextResponse.json({error:'invalid_action'},{status:400}); const pb=await pbAdmin(); const [batch,note]=await Promise.all([pb.collection('dn_batches').getOne(id).catch(()=>null),pb.collection('dn_batch_notes').getOne(noteId).catch(()=>null)]); if(!batch||!note||note.batch!==id)return NextResponse.json({error:'not_found'},{status:404}); @@ -38,7 +38,7 @@ export async function POST(request:Request,{params}:{params:Promise<{id:string;n catch(e){if(e instanceof Error&&/Slip lines changed/.test(e.message))throw new ActionError('lines_changed','The slip lines changed. Run matching again.');throw e;} // Not stale: nothing changed, so nothing is written. if(current===stored)return {preview:{...stored,decisionHash:decisionHash(stored)},refreshed:false}; - const ready=deliveryReady(current); + const ready=readyIndexes(context,current).length>0; await lease.batch([{collection:'dn_batch_notes',method:'update',id:noteId,data:{reconciliation_preview:current,status:ready?'candidate':'blocked',stage:ready?'ready_to_post':'needs_review'}}, {collection:'dn_review_events',method:'create',data:{batch:id,note:noteId,actor:user.id,action:'refresh_preview',reason:'Re-applied saved decisions after the PO changed; no receipt posted.'}}]); return {preview:{...current,decisionHash:decisionHash(current)},refreshed:true}; @@ -48,7 +48,9 @@ export async function POST(request:Request,{params}:{params:Promise<{id:string;n let preview=resolveMatch(context,stored,body); const guidance=body.rememberMatch===true?rememberConnection(context,preview,body.index,user.id):null; if(guidance)preview={...preview,snapshotHash:snapshotHash({...context,supplier:{...context.supplier,reconciliation_rules:guidance}})}; - const ready=deliveryReady(preview); + // A remembered connection changes the supplier guidance, so the preview + // is measured against the context the guidance produces. + const ready=readyIndexes(guidance?{...context,supplier:{...context.supplier,reconciliation_rules:guidance}}:context,preview).length>0; await lease.batch([...(guidance?[{collection:'suppliers',method:'update' as const,id:String(context.supplier.id),data:{reconciliation_rules:guidance}}]:[]),{collection:'dn_batch_notes',method:'update',id:noteId,data:{reconciliation_preview:preview,status:ready?'candidate':'blocked',stage:ready?'ready_to_post':'needs_review'}}, {collection:'dn_review_events',method:'create',data:{batch:id,note:noteId,actor:user.id,action:'resolve_item',reason:'Saved receiving connection; no receipt posted.',after:{sourceLineIds:body.sourceLineIds,confirmation:body.confirmation}}}]); return {preview:{...preview,decisionHash:decisionHash(preview)}}; @@ -57,7 +59,9 @@ export async function POST(request:Request,{params}:{params:Promise<{id:string;n // Keep the saved decisions instead of sending the user back to a re-match. let current=stored; if(snapshotHash(context)!==stored.snapshotHash){current=refreshPreview(context,stored);await lease.batch([{collection:'dn_batch_notes',method:'update',id:noteId,data:{reconciliation_preview:current}}]);} - const lines=prepareDelivery(context,current); + // post_ready confirms the lines that are ready and leaves the rest open; + // post still confirms the whole delivery. + const lines=body.action==='post_ready'?prepareReadyLines(context,current):prepareDelivery(context,current); let preview=current; for(const line of lines){ const index=preview.allocations.findIndex(a=>JSON.stringify([...a.sourceLineIds].sort())===JSON.stringify([...line.sourceLineIds].sort())); @@ -65,6 +69,7 @@ export async function POST(request:Request,{params}:{params:Promise<{id:string;n if(!posted.preview)throw Error('Refresh to check saved receiving progress'); preview=posted.preview; } + if(body.action==='post_ready')return {preview,posted:lines.length,remaining:preview.allocations.filter(a=>!a.posted).length}; return {preview,complete:true}; },{scopeKey:'supplier:'+note.supplier}); return result.acquired?NextResponse.json(result.value):NextResponse.json({error:'locked',detail:'This delivery or supplier is being updated. Try again shortly.'},{status:409}); diff --git a/src/app/dashboard/financial/delivery-notes/document/[id]/processing-preview.tsx b/src/app/dashboard/financial/delivery-notes/document/[id]/processing-preview.tsx index 58f300c..7c08bec 100644 --- a/src/app/dashboard/financial/delivery-notes/document/[id]/processing-preview.tsx +++ b/src/app/dashboard/financial/delivery-notes/document/[id]/processing-preview.tsx @@ -97,7 +97,7 @@ export function ProcessingPreviewPanel({ batchId, noteId, closed = false, defaul if(!preview||stale||posting)return; setPosting(true);setError(''); try { - const response=await fetch(`/api/dn-batches/${batchId}/notes/${noteId}/matching`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'post',snapshotHash:preview.snapshotHash,decisionHash:preview.decisionHash})}); + const response=await fetch(`/api/dn-batches/${batchId}/notes/${noteId}/matching`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'post_ready',snapshotHash:preview.snapshotHash,decisionHash:preview.decisionHash})}); const body=await response.json(); if(!response.ok)throw Error(body.detail||'Could not confirm this delivery.'); router.replace(inboxTwo ? '/dashboard/financial/delivery-notes/inbox-2' : '/dashboard/financial/delivery-notes/inbox'); diff --git a/src/lib/dn-pipeline/matching.test.ts b/src/lib/dn-pipeline/matching.test.ts index e0194e0..ed83bd6 100644 --- a/src/lib/dn-pipeline/matching.test.ts +++ b/src/lib/dn-pipeline/matching.test.ts @@ -140,3 +140,33 @@ it.each(['ea','length'])('preserves a confirmed %s unit override through save, r expect(refreshed.allocations[0]).toMatchObject({status:'proposed',unit,operatorConfirmation:confirmation}); expect(prepareDelivery(c,refreshed)[0].confirmation).toEqual(confirmation); }); + +// ---------- ready lines: prepareReadyLines ---------- +import {prepareReadyLines} from './matching'; +/** Three slip lines: one the AI is sure of, two that still need a decision. */ +const threeLines=()=>{ + const c=context(); + c.lines=[{...c.lines[0],unit:'m',unit_price:10,line_amount:110},c.lines[1],{sourceLineId:'mesh',description:'Mesh 450',qty:4,unit:'ea',unit_price:100,line_amount:400,product_code:null}]; + return c; +}; +const threeLinePreview=(c:ReturnType)=>buildPreview(c,{allocations:[ + {sourceLineIds:['pipe'],purchaseOrderItemId:'itemabcdefghijk',quantityBasis:'direct',factor:1,amountBasis:'po_rate',sourceAmountBasis:'unknown',ruleKeys:[],explanation:'Pipe',reviewQuestion:null}, + {sourceLineIds:['ring'],purchaseOrderItemId:null,quantityBasis:'none',factor:null,amountBasis:'unknown',sourceAmountBasis:'unknown',ruleKeys:[],explanation:'Rubber',reviewQuestion:'How should this ring line be booked?'}, + {sourceLineIds:['mesh'],purchaseOrderItemId:'itemabcdefghijk',quantityBasis:'direct',factor:1,amountBasis:'source',sourceAmountBasis:'unknown',ruleKeys:[],explanation:'Mesh',reviewQuestion:null}, +]}); +it('prepares only the ready lines and leaves the rest open',()=>{ + const c=threeLines();const p=threeLinePreview(c); + expect(p.allocations.map(a=>a.status)).toEqual(['proposed','review','review']); + const lines=prepareReadyLines(c,p); + expect(lines).toHaveLength(1); + expect(lines[0]).toMatchObject({sourceLineIds:['pipe']}); +}); +it('refuses to confirm a slip when no line is ready',()=>{ + const {context:c,preview}=input(); + expect(()=>prepareReadyLines(c,preview)).toThrow(/No lines are ready to confirm/); +}); +it('refuses to confirm a slip while the site question is open',()=>{ + const c=threeLines();c.note.checks=[{id:'site_decision',passed:false,reason:'Does the length include the socket?'}]; + const p=threeLinePreview(c); + expect(()=>prepareReadyLines(c,p)).toThrow(/Answer the outstanding site question/); +}); diff --git a/src/lib/dn-pipeline/matching.ts b/src/lib/dn-pipeline/matching.ts index ffad948..9b386cf 100644 --- a/src/lib/dn-pipeline/matching.ts +++ b/src/lib/dn-pipeline/matching.ts @@ -3,6 +3,7 @@ import {buildPreview,snapshotHash,type PreviewAllocation} from './ai-preview'; import {PostLineSchema} from './post-line'; import {prepareLineBooking,stableSourceKey,type LineBooking} from './line-booking'; import {deliveryReady} from './delivery-ready'; +import {readyIndexes} from '@/lib/inbox-three/readiness'; import {openSiteDecision} from './site-decision-model'; import type {LineConfirmation} from './line-booking'; @@ -24,10 +25,15 @@ export function resolveMatch(context:PreviewContext,preview:ProcessingPreview,in estimatedNetExcess:preview.priorNetEstimate!==null&&preview.orderedNet!==null?Math.max(0,Math.round((preview.priorNetEstimate+netTotal-preview.orderedNet)*100)/100):null}; } -/** Preflight every line and cumulative totals before the first receipt write. */ -export function prepareDelivery(context:PreviewContext,preview:ProcessingPreview){ +/** The guards every confirmation has to pass before a receipt is written. */ +function assertConfirmable(context:PreviewContext){ if(openSiteDecision(context.note.checks))throw Error('Answer the outstanding site question before confirming delivery'); if(Array.isArray(context.note.checks)&&context.note.checks.some(c=>c&&typeof c==='object'&&c.id==='not_duplicate'&&c.passed===false))throw Error('Resolve the possible duplicate delivery before confirming'); +} + +/** Preflight every line and cumulative totals before the first receipt write. */ +export function prepareDelivery(context:PreviewContext,preview:ProcessingPreview){ + assertConfirmable(context); if(!deliveryReady(preview))throw Error('Resolve the remaining delivery lines before confirming delivery'); if(snapshotHash(context)!==preview.snapshotHash)throw Error('Matching result is stale; process this delivery again'); const state=context.note.line_posting as {bookings?:LineBooking[]}|undefined; @@ -45,6 +51,31 @@ export function prepareDelivery(context:PreviewContext,preview:ProcessingPreview return result; } +/** + * Preflight only the lines that are ready to confirm. + * + * The same guards as the whole delivery, but the open lines do not have to + * be covered: a slip can be half done. The lines that are ready are + * preflighted against each other so the cumulative quantity still holds. + */ +export function prepareReadyLines(context:PreviewContext,preview:ProcessingPreview){ + assertConfirmable(context); + if(snapshotHash(context)!==preview.snapshotHash)throw Error('Matching result is stale; process this delivery again'); + const state=context.note.line_posting as {bookings?:LineBooking[]}|undefined; + const bookings=[...(state?.bookings??[])]; + const result:PlanEntry[]=[]; + for(const index of readyIndexes(context,preview)){ + const a=preview.allocations[index]; + if(a.posted)continue; + const confirmation=a.operatorConfirmation??(a.amountBasis==='not_delivered'?{confirmed:true as const,disposition:'not_delivered' as const,reason:'Confirmed no delivery'}:{confirmed:true as const}); + const simulated={...context,note:{...context.note,line_posting:{...state,bookings}}}; + const booking=prepareLineBooking({context:simulated,preview:{...preview,snapshotHash:snapshotHash(simulated)},index,posted:bookings.flatMap(b=>b.sourceKeys),confirmation}); + bookings.push(booking);result.push({sourceLineIds:a.sourceLineIds,confirmation}); + } + if(!result.length)throw Error('No lines are ready to confirm'); + return result; +} + const sameLines=(a:string[],b:string[])=>JSON.stringify([...a].sort())===JSON.stringify([...b].sort()); /** Re-apply every saved operator decision onto a rebuilt preview; a decision that no longer fits stays open. */ diff --git a/src/lib/dn-pipeline/post-line.ts b/src/lib/dn-pipeline/post-line.ts index cf4dcbe..6426bf8 100644 --- a/src/lib/dn-pipeline/post-line.ts +++ b/src/lib/dn-pipeline/post-line.ts @@ -1,4 +1,4 @@ -import {deliveryReady} from './delivery-ready'; +import {readyIndexes} from '@/lib/inbox-three/readiness'; import {buildReceiptProof} from './receipt-proof'; import {createHash} from 'node:crypto'; import type PocketBase from 'pocketbase'; @@ -61,7 +61,7 @@ export async function postLineWithinLease(pb:PocketBase,input:{batchId:string;no } nextPreview.netTotal=Math.round(nextPreview.allocations.reduce((s,a)=>s+(a.netAmount??0),0)*100)/100; nextPreview.complete=nextPreview.allocations.every(a=>a.status==='proposed'&&a.netAmount!==null); - if(!complete&&deliveryReady(nextPreview)){status='candidate';stage='ready_to_post';} + if(!complete&&readyIndexes(nextContext,nextPreview).length>0){status='candidate';stage='ready_to_post';} const mutations:Parameters[0][number][]=[]; if(!state){const proof=await buildReceiptProof(pb,context.pages);mutations.push({collection:'delivery_notes',method:'create',data:{ slip_image:new File([new Uint8Array(proof)], 'delivery-'+input.noteId+'.pdf', {type:'application/pdf'}),id:next.deliveryNoteId,batch:input.batchId,batch_note:input.noteId, diff --git a/src/lib/dn-pipeline/processing.ts b/src/lib/dn-pipeline/processing.ts index 7b3a40d..f4f857d 100644 --- a/src/lib/dn-pipeline/processing.ts +++ b/src/lib/dn-pipeline/processing.ts @@ -1,5 +1,5 @@ import {applyRememberedConnections} from './remembered-match'; -import {deliveryReady} from './delivery-ready'; +import {readyIndexes} from '@/lib/inbox-three/readiness'; import type PocketBase from 'pocketbase'; import {loadPreviewContext} from './ai-preview-context'; import {buildPreview,snapshotHash,type ProcessingPreview} from './ai-preview'; @@ -13,7 +13,7 @@ export async function processCandidateWithinLease(pb:PocketBase,batchId:string,n const checks=Array.isArray(context.note.checks)?context.note.checks.filter(check=>check.id!=='ai_processing'):undefined; const cleanContext=checks?{...context,note:{...context.note,checks}}:context; if(options?.reuseFresh&&stored?.snapshotHash===hash){ - const ready=deliveryReady(stored); + const ready=readyIndexes(context,stored).length>0; await lease.update('dn_batch_notes',noteId,{stage:ready?'ready_to_post':'needs_review',status:ready?'candidate':'blocked'}); return stored; } @@ -29,7 +29,7 @@ export async function processCandidateWithinLease(pb:PocketBase,batchId:string,n const preview=refreshPreview(checkedContext,stored); const fresh=await loadPreviewContext(pb,batchId,noteId); if(snapshotHash(fresh.context)!==hash)throw Error('preview_source_changed'); - const ready=deliveryReady(preview); + const ready=readyIndexes(checkedContext,preview).length>0; await lease.batch([{collection:'dn_batch_notes',method:'update',id:noteId,data:{reconciliation_preview:preview,checks:nextChecks,stage:ready?'ready_to_post':'needs_review',status:ready?'candidate':'blocked'}}, {collection:'dn_review_events',method:'create',data:{batch:batchId,note:noteId,action:'processing_preview',...(actor?{actor}:{}),reason:'Refreshed the saved receiving preview after source evidence changed; unchanged line decisions were retained and no financial posting occurred.',after:{snapshotHash:preview.snapshotHash,model:preview.model??null,rulesUsed:preview.rulesUsed,generatedAt:preview.generatedAt}}}]); return preview; @@ -47,7 +47,7 @@ export async function processCandidateWithinLease(pb:PocketBase,batchId:string,n if(stored)preview=reapplyUnchangedDecisions(checkedContext,preview,stored); const fresh=await loadPreviewContext(pb,batchId,noteId); if(snapshotHash(fresh.context)!==hash)throw Error('preview_source_changed'); - const ready=deliveryReady(preview); + const ready=readyIndexes(checkedContext,preview).length>0; await lease.batch([{collection:'dn_batch_notes',method:'update',id:noteId,data:{reconciliation_preview:preview,checks:nextChecks,stage:ready?'ready_to_post':'needs_review',status:ready?'candidate':'blocked'}}, {collection:'dn_review_events',method:'create',data:{batch:batchId,note:noteId,action:'processing_preview',...(actor?{actor}:{}),reason:'Processed saved receiving evidence against supplier guidance, PO and prior receipts; no financial posting.',after:{snapshotHash:hash,model,rulesUsed:preview.rulesUsed,generatedAt:preview.generatedAt}}}]); return preview; diff --git a/src/lib/inbox-two/review.ts b/src/lib/inbox-two/review.ts index dc9a7b2..70b4008 100644 --- a/src/lib/inbox-two/review.ts +++ b/src/lib/inbox-two/review.ts @@ -5,6 +5,7 @@ import { withBatchLease } from '@/lib/dn-pipeline/lock'; import { loadPreviewContext, terminalPreviewNote, PreviewContextError } from '@/lib/dn-pipeline/ai-preview-context'; import { buildPreview, snapshotHash, type PreviewContext, type ProcessingPreview } from '@/lib/dn-pipeline/ai-preview'; import { applyPlan, prepareDelivery } from '@/lib/dn-pipeline/matching'; +import { readyIndexes } from '@/lib/inbox-three/readiness'; import { openSiteDecision } from '@/lib/dn-pipeline/site-decision-model'; import { ReviewProposalSchema, ReviewRequestSchema, type ReviewReply, type ReviewRequest, type ReviewProposal } from './review-schema'; import { runInboxReviewModel, loadReviewImages } from './review-model'; @@ -51,7 +52,7 @@ function proposalPreview(context: PreviewContext, proposal: ReviewProposal): Pro ...(a.amountBasis === 'not_delivered' ? { disposition: 'not_delivered' as const } : {}), acceptExcess: false, reason: a.explanation, } })); const planned = applyPlan(context, preview, { entries, closeNil: true }); - if (!planned.ready || planned.missing.length) throw Error('Some delivery details still need a decision. Open the delivery controls to check them.'); + if (!readyIndexes(context, planned.preview).length || planned.missing.length) throw Error('Some delivery details still need a decision. Open the delivery controls to check them.'); prepareDelivery(context, planned.preview); // Includes cumulative quantities, duplicates and immutable receipts. return planned.preview; }