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 36ec8aa..09cd321 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 @@ -36,3 +36,32 @@ it('keeps saved decisions when another slip on the PO was booked in between',asy expect(m.post).toHaveBeenCalledTimes(2); expect(m.post.mock.calls[0][1].body.confirmation).toMatchObject({quantity:27.5}); }); +it('refreshes a stale match and keeps the saved decisions without posting anything',async()=>{ + const {applyPlan}=await import('@/lib/dn-pipeline/matching'); + p=applyPlan(c,p,{entries:[{sourceLineIds:['pipe'],confirmation:{confirmed:true,poItemId:'itemabcdefghijk',quantity:27.5,unit:'m',usePoRate:true,reason:'11 x 2.5m'}},{sourceLineIds:['ring'],confirmation:{confirmed:true,disposition:'not_delivered',reason:'free rings'}}],closeNil:false}).preview; + const saved=p; + c.priorItems=[{purchase_order_item:'itemabcdefghijk',qty_received:5,booking:{net:50}}]; + m.load.mockResolvedValue({context:c,stored:saved}); + const r=await POST(req({action:'refresh'}),params); + expect(r.status).toBe(200); + expect(m.lease).toHaveBeenCalled(); + expect(m.batch).toHaveBeenCalledTimes(1); + expect(m.batch.mock.calls[0][0].map((v:{collection:string})=>v.collection)).toEqual(['dn_batch_notes','dn_review_events']); + 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).not.toHaveBeenCalled(); + // The saved decisions survive the rebuild. + const body=await r.json(); + expect(body.preview.allocations.find((a:{sourceLineIds:string[]})=>a.sourceLineIds[0]==='pipe')).toMatchObject({quantity:27.5,netAmount:275}); + expect(body.preview.decisionHash).toBe(decisionHash(body.preview)); +}); +it('refuses to refresh when the slip lines themselves changed',async()=>{ + const {applyPlan}=await import('@/lib/dn-pipeline/matching'); + p=applyPlan(c,p,{entries:[{sourceLineIds:['pipe'],confirmation:{confirmed:true,poItemId:'itemabcdefghijk',quantity:27.5,unit:'m',usePoRate:true,reason:'11 x 2.5m'}}],closeNil:false}).preview; + const saved=p; + c.lines=[{...c.lines[0],sourceLineId:'pipe-renumbered'}]; + m.load.mockResolvedValue({context:c,stored:saved}); + const r=await POST(req({action:'refresh'}),params); + expect(r.status).toBe(409); + expect((await r.json()).error).toBe('lines_changed'); + expect(m.batch).not.toHaveBeenCalled(); +}); 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 1eb7149..71fcdb8 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 @@ -10,12 +10,14 @@ import {resolveMatch,prepareDelivery,refreshPreview} from '@/lib/dn-pipeline/mat import {postLineWithinLease} from '@/lib/dn-pipeline/post-line'; import {deliveryReady} from '@/lib/dn-pipeline/delivery-ready'; 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);}} export async function POST(request:Request,{params}:{params:Promise<{id:string;noteId:string}>}){ const user=await requireApiUser('financial');if(user instanceof Response)return user; 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','post'].includes(body.action))return NextResponse.json({error:'invalid_action'},{status:400}); + if(!body||!['save','refresh','post'].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}); @@ -25,6 +27,22 @@ export async function POST(request:Request,{params}:{params:Promise<{id:string;n const result=await withBatchLease(pb,id,'delivery-matching',async lease=>{ const {context,stored}=await loadPreviewContext(pb,id,noteId); if(['posted','promoted','dismissed'].includes(String(context.note.status))||context.note.posted_at)throw Error('Delivery is already closed'); + // Another slip on this PO may have been booked since these lines were saved. + // Refreshing rebuilds the match and re-applies the decisions already saved, + // instead of throwing that work away for an AI re-match. No client hashes: + // the point of the refresh is that the stored match no longer matches. + if(body.action==='refresh'){ + if(!stored)throw new ActionError('preview_missing','There is no saved match to refresh. Run matching again.'); + let current:typeof stored; + try{current=refreshPreview(context,stored);} + 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); + 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}; + } if(!stored||body.snapshotHash!==stored.snapshotHash||body.decisionHash!==decisionHash(stored))throw Error('Matching result changed; refresh before continuing'); if(body.action==='save'){ let preview=resolveMatch(context,stored,body); @@ -50,5 +68,5 @@ export async function POST(request:Request,{params}:{params:Promise<{id:string;n 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}); - }catch(e){return NextResponse.json({error:'delivery_not_confirmed',detail:(e instanceof Error?e.message:'Unable to finish')+'. If confirmation was interrupted, refresh to see saved lines before retrying.'},{status:409});} + }catch(e){if(e instanceof ActionError)return NextResponse.json({error:e.error,detail:e.detail},{status:409});return NextResponse.json({error:'delivery_not_confirmed',detail:(e instanceof Error?e.message:'Unable to finish')+'. If confirmation was interrupted, refresh to see saved lines before retrying.'},{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 3b7f3f8..f61c7fc 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 @@ -26,6 +26,7 @@ export function ProcessingPreviewPanel({ batchId, noteId, closed = false, defaul const [view, setView] = React.useState(defaultView); const active = React.useRef(null); const url = `/api/dn-batches/${batchId}/notes/${noteId}/processing-preview`; + const matchUrl = `/api/dn-batches/${batchId}/notes/${noteId}/matching`; // Read the stored choice after mount so the server markup and the first // client render agree; the switch itself never flickers the whole card. // Applied off the effect body so it is an update, not a cascading render. @@ -45,11 +46,23 @@ export function ProcessingPreviewPanel({ batchId, noteId, closed = false, defaul const body = await response.json(); if (controller.signal.aborted) return; if (!response.ok) { setError(body.detail || "Could not load this preview."); return; } - setPoItems(body.poItems ?? []); setPreview(body.preview ?? null); setStale(Boolean(body.stale)); + setPoItems(body.poItems ?? []); + // A stale match is usually just another slip booked on the same PO. + // Refresh it so the saved decisions survive; only a changed slip itself + // still needs a full re-match. + if (body.stale) { + const refreshed = await fetch(matchUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "refresh" }), signal: controller.signal }) + .then(async r => r.ok ? await r.json() as { preview?: ProcessingPreview } : null) + .catch(() => null); + if (controller.signal.aborted) return; + if (refreshed?.preview) { setPreview(refreshed.preview); setStale(false); return; } + setPreview(body.preview ?? null); setStale(true); return; + } + setPreview(body.preview ?? null); setStale(Boolean(body.stale)); }).catch(() => { if (!controller.signal.aborted) setError("Could not load the saved preview. Check your connection."); }) .finally(() => { if (!controller.signal.aborted) setLoading(false); }); return () => active.current?.abort(); - }, [url]); + }, [url, matchUrl]); async function run() { if (running) return;