feat(dn): confirm posts the ready lines and leaves the rest open
Co-Authored-By: MiniMax (Pinchy)
This commit is contained in:
parent
2ccb6efadc
commit
ddccdd8cfd
8 changed files with 132 additions and 16 deletions
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue