feat(dn): confirm posts the ready lines and leaves the rest open

Co-Authored-By: MiniMax (Pinchy)
This commit is contained in:
DEls 2026-10-26 15:46:52 +02:00
parent 2ccb6efadc
commit ddccdd8cfd
8 changed files with 132 additions and 16 deletions

View file

@ -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<typeof context>)=>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<unknown>)=>{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);
});

View file

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

View file

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

View file

@ -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<typeof context>)=>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/);
});

View file

@ -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. */

View file

@ -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<BatchLeaseContext['batch']>[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,

View file

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

View file

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