fix(dn): an out-of-date match refreshes and keeps saved decisions instead of asking for a re-match

Co-Authored-By: MiniMax Code <noreply@minimax.io>
This commit is contained in:
DEls 2026-10-09 20:13:31 +02:00
parent 91ac705536
commit e6e41d3c22
3 changed files with 64 additions and 4 deletions

View file

@ -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();
});

View file

@ -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});}
}

View file

@ -26,6 +26,7 @@ export function ProcessingPreviewPanel({ batchId, noteId, closed = false, defaul
const [view, setView] = React.useState<ReviewView>(defaultView);
const active = React.useRef<AbortController | null>(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;