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:
parent
91ac705536
commit
e6e41d3c22
3 changed files with 64 additions and 4 deletions
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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});}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue