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 7309676..36ec8aa 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 @@ -21,3 +21,18 @@ it('posts every resolved line under the supplier lease with one confirmation',as m.load.mockResolvedValue({context:c,stored:p});m.post.mockResolvedValue({preview:p}); const r=await POST(req({action:'post',snapshotHash:p.snapshotHash,decisionHash:decisionHash(p)}),params);expect(r.status).toBe(200);expect(m.post).toHaveBeenCalledTimes(2);expect(m.lease).toHaveBeenCalledWith(expect.anything(),batch,'delivery-matching',expect.any(Function),{scopeKey:'supplier:supplierabcdefg'}); }); +it('keeps saved decisions when another slip on the PO was booked in between',async()=>{ + const {applyPlan}=await import('@/lib/dn-pipeline/matching'); + // Prepared earlier (by a person or an agent): pipe 27.5 m, rings free. + 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; + // Then a sibling slip on the same PO was booked: the stored hash is now stale. + c.priorItems=[{purchase_order_item:'itemabcdefghijk',qty_received:5,booking:{net:50}}]; + m.load.mockResolvedValue({context:c,stored:saved});m.post.mockImplementation(async()=>({preview:saved})); + const r=await POST(req({action:'post',snapshotHash:saved.snapshotHash,decisionHash:decisionHash(saved)}),params); + expect(r.status).toBe(200); + // The refreshed preview is stored before posting, and both saved lines are posted with their confirmations. + 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(2); + expect(m.post.mock.calls[0][1].body.confirmation).toMatchObject({quantity:27.5}); +}); 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 572fc50..1eb7149 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 @@ -6,7 +6,7 @@ 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} from '@/lib/dn-pipeline/matching'; +import {resolveMatch,prepareDelivery,refreshPreview} from '@/lib/dn-pipeline/matching'; 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'; @@ -35,8 +35,12 @@ export async function POST(request:Request,{params}:{params:Promise<{id:string;n {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)}}; } - const lines=prepareDelivery(context,stored); - let preview=stored; + // Another slip on this PO may have been booked since these lines were saved. + // 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); + let preview=current; for(const line of lines){ const index=preview.allocations.findIndex(a=>JSON.stringify([...a.sourceLineIds].sort())===JSON.stringify([...line.sourceLineIds].sort())); const posted=await postLineWithinLease(pb,{batchId:id,noteId,poId:String(context.po.id),actor:user.id,body:{index,sourceLineIds:line.sourceLineIds,snapshotHash:preview.snapshotHash,confirmation:line.confirmation}},lease); diff --git a/src/lib/dn-pipeline/matching.test.ts b/src/lib/dn-pipeline/matching.test.ts index fa8b8e8..c14505c 100644 --- a/src/lib/dn-pipeline/matching.test.ts +++ b/src/lib/dn-pipeline/matching.test.ts @@ -41,3 +41,46 @@ it('prevents a saved duplicate-check failure from reaching final confirmation',( const c=context();c.note.checks=[{id:'not_duplicate',passed:false,reason:'Copy already received'}]; const p=buildPreview(c,{allocations:[]});expect(p.identityReviewReasons).toEqual(['Copy already received']);expect(()=>prepareDelivery(c,p)).toThrow(/duplicate/); }); + +// ---------- agent staging: applyPlan / refreshPreview ---------- +import {applyPlan,refreshPreview} from './matching'; +const pipe={confirmed:true as const,poItemId:'itemabcdefghijk',quantity:27.5,unit:'m',usePoRate:true,reason:'11 pipes x 2.5m'}; +it('stages a plan: saves every decision and reaches Ready to post without posting',()=>{ + const {context:c,preview}=input(); + const r=applyPlan(c,preview,{entries:[{sourceLineIds:['pipe'],confirmation:pipe},{sourceLineIds:['ring'],confirmation:{confirmed:true,disposition:'not_delivered',reason:'rings are free with the pipes'}}],closeNil:true}); + expect(r.missing).toEqual([]);expect(r.ready).toBe(true); + expect(r.preview.allocations.find(a=>a.sourceLineIds[0]==='pipe')).toMatchObject({status:'proposed',quantity:27.5,netAmount:275,operatorConfirmation:pipe}); + expect(r.preview.netTotal).toBe(275); + expect(c.note.line_posting).toBeUndefined(); + // What the site manager's Confirm would post, unchanged. + expect(prepareDelivery(c,r.preview)).toHaveLength(2); +}); +it('groups several slip lines into one kit allocation',()=>{ + const {context:c,preview}=input(); + const r=applyPlan(c,preview,{entries:[{sourceLineIds:['pipe','ring'],confirmation:pipe}],closeNil:true}); + expect(r.preview.allocations.filter(a=>!a.posted)).toHaveLength(1); + expect(r.preview.allocations[0]).toMatchObject({sourceLineIds:['pipe','ring'],quantityBasis:'kit',status:'proposed',netAmount:275}); + expect(r.ready).toBe(true); +}); +it('closes a NIL line by itself and reports unplanned delivered lines',()=>{ + const {context:c}=input();c.lines[1].qty=0; + const p=buildPreview(c,{allocations:[]}); + const r=applyPlan(c,p,{entries:[],closeNil:true}); + expect(r.missing).toEqual([['pipe']]); + expect(r.preview.allocations.find(a=>a.sourceLineIds[0]==='ring')).toMatchObject({status:'proposed',netAmount:0}); +}); +it('keeps earlier saved decisions when the preview has gone stale',()=>{ + const {context:c,preview}=input(); + const staged=applyPlan(c,preview,{entries:[{sourceLineIds:['pipe'],confirmation:pipe}],closeNil:false}).preview; + // Another slip on the same PO was booked: the context changes, the stored hash no longer matches. + c.priorItems=[{purchase_order_item:'itemabcdefghijk',qty_received:5,booking:{net:50}}]; + expect(snapshotHash(c)).not.toBe(staged.snapshotHash); + const fresh=refreshPreview(c,staged); + expect(fresh.snapshotHash).toBe(snapshotHash(c)); + expect(fresh.allocations.find(a=>a.sourceLineIds[0]==='pipe')).toMatchObject({status:'proposed',quantity:27.5,operatorConfirmation:pipe}); +}); +it('refuses to refresh over slip lines that have since changed',()=>{ + const {context:c,preview}=input(); + c.lines[0]={...c.lines[0],sourceLineId:'pipe-edited'}; + expect(()=>refreshPreview(c,preview)).toThrow(/Slip lines changed/); +}); diff --git a/src/lib/dn-pipeline/matching.ts b/src/lib/dn-pipeline/matching.ts index cc5483f..7dd9102 100644 --- a/src/lib/dn-pipeline/matching.ts +++ b/src/lib/dn-pipeline/matching.ts @@ -1,8 +1,9 @@ import type {PreviewContext,ProcessingPreview} from './ai-preview'; -import {snapshotHash} from './ai-preview'; +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 type {LineConfirmation} from './line-booking'; export function resolveMatch(context:PreviewContext,preview:ProcessingPreview,input:unknown):ProcessingPreview{ const body=PostLineSchema.parse(input); @@ -41,3 +42,67 @@ export function prepareDelivery(context:PreviewContext,preview:ProcessingPreview if(context.lines.some(l=>!covered.has(stableSourceKey(l.sourceLineId))))throw Error('Resolve every source line before confirming delivery'); 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. */ +function reapply(context:PreviewContext,preview:ProcessingPreview,saved:PreviewAllocation[]):ProcessingPreview{ + for(const old of saved){ + if(old.posted||!old.operatorConfirmation)continue; + const index=preview.allocations.findIndex(a=>!a.posted&&sameLines(a.sourceLineIds,old.sourceLineIds)); + if(index<0)continue; + try{preview=resolveMatch(context,preview,{index,sourceLineIds:preview.allocations[index].sourceLineIds,snapshotHash:preview.snapshotHash,confirmation:old.operatorConfirmation});} + catch{/* e.g. now over the PO: leave it for a person */} + } + return preview; +} + +/** + * Bring a stale preview up to date without an AI re-match. + * + * Booking any slip on a PO changes the prior receipts every other slip on it + * is checked against, so their stored hash goes stale. The decisions saved on + * those slips (by a person or an agent) are still right; rebuild over the same + * allocations and re-apply them, instead of throwing that work away. + */ +export function refreshPreview(context:PreviewContext,stored:ProcessingPreview):ProcessingPreview{ + if(snapshotHash(context)===stored.snapshotHash)return stored; + const present=new Set(context.lines.map(l=>String(l.sourceLineId))); + if(stored.allocations.some(a=>a.sourceLineIds.some(id=>!present.has(id))))throw Error('Slip lines changed; process this delivery again'); + return reapply(context,buildPreview(context,{allocations:stored.allocations}),stored.allocations); +} + +export type PlanEntry={sourceLineIds:string[];confirmation:LineConfirmation}; + +/** + * Save an agent's receiving plan onto the preview, booking nothing. + * + * Entries naming several slip lines become one kit allocation. Lines whose + * delivered quantity is 0 close as not delivered when `closeNil` is set. The + * result is what a person's "save" on each line would have produced, so the + * site manager's Confirm delivery posts exactly this. + */ +export function applyPlan(context:PreviewContext,stored:ProcessingPreview,plan:{entries:PlanEntry[];closeNil:boolean}):{preview:ProcessingPreview;ready:boolean;missing:string[][]}{ + let preview=refreshPreview(context,stored); + const regroup=plan.entries.filter(e=>!preview.allocations.some(a=>sameLines(a.sourceLineIds,e.sourceLineIds))); + if(regroup.length){ + const claimed=new Set(regroup.flatMap(e=>e.sourceLineIds)); + if(preview.allocations.some(a=>a.posted&&a.sourceLineIds.some(id=>claimed.has(id))))throw Error('A slip line in this plan is already posted'); + const kept=preview.allocations.filter(a=>a.posted||!a.sourceLineIds.some(id=>claimed.has(id))); + const added=regroup.map(e=>({sourceLineIds:e.sourceLineIds,purchaseOrderItemId:e.confirmation.poItemId??null,quantityBasis:e.sourceLineIds.length>1?'kit' as const:'direct' as const,factor:1, + amountBasis:'po_rate' as const,sourceAmountBasis:'unknown' as const,ruleKeys:[],explanation:e.confirmation.reason??'Agent grouping',reviewQuestion:null})); + preview=reapply(context,buildPreview(context,{allocations:[...kept,...added]}),kept); + } + const qtyOf=(id:string)=>Number(context.lines.find(l=>l.sourceLineId===id)?.qty??NaN); + const missing:string[][]=[]; + for(const allocation of [...preview.allocations]){ + if(allocation.posted)continue; + const entry=plan.entries.find(e=>sameLines(e.sourceLineIds,allocation.sourceLineIds)); + const nil=plan.closeNil&&allocation.sourceLineIds.every(id=>qtyOf(id)===0); + const confirmation=entry?.confirmation??(nil?{confirmed:true as const,disposition:'not_delivered' as const,reason:'NIL on slip'}:undefined); + if(!confirmation){if(!allocation.operatorConfirmation)missing.push(allocation.sourceLineIds);continue;} + const index=preview.allocations.findIndex(a=>!a.posted&&sameLines(a.sourceLineIds,allocation.sourceLineIds)); + preview=resolveMatch(context,preview,{index,sourceLineIds:preview.allocations[index].sourceLineIds,snapshotHash:preview.snapshotHash,confirmation}); + } + return {preview,ready:deliveryReady(preview),missing}; +} diff --git a/src/lib/purchasing/delivery-posting.ts b/src/lib/purchasing/delivery-posting.ts index f460f6c..d38f836 100644 --- a/src/lib/purchasing/delivery-posting.ts +++ b/src/lib/purchasing/delivery-posting.ts @@ -16,7 +16,7 @@ */ import {z} from 'zod'; import type {PurchasingContext,OperationResult} from './types'; -import {MutationSchema,operationPayloadHash,operationRecordId} from './operations'; +import {MutationSchema,operationPayloadHash,operationRecordId,runOperation} from './operations'; import {RecordId} from './documents'; import {PurchasingError,describePurchasingError} from './errors'; import {withResourceLease} from './resource-lease'; @@ -25,6 +25,9 @@ import {loadPreviewContext} from '@/lib/dn-pipeline/ai-preview-context'; import {buildPreview,snapshotHash,type PreviewAllocation} from '@/lib/dn-pipeline/ai-preview'; import {postLineWithinLease} from '@/lib/dn-pipeline/post-line'; import {LineConfirmationSchema,type LineConfirmation} from '@/lib/dn-pipeline/line-booking'; +import {applyPlan} from '@/lib/dn-pipeline/matching'; +import {allocationNeedsHelp} from '@/lib/dn-pipeline/delivery-ready'; +import {openSiteDecision} from '@/lib/dn-pipeline/site-decision-model'; const PlanLineSchema=z.object({lines:z.array(z.number().int().min(0).max(199)).min(1).max(50),confirmation:LineConfirmationSchema.strict()}).strict(); export const PostDeliverySchema=MutationSchema.extend({batchId:RecordId,id:RecordId,closeNil:z.boolean().default(true),lines:z.array(PlanLineSchema).min(1).max(200)}).strict(); @@ -119,3 +122,44 @@ export async function postDelivery(ctx:PurchasingContext,input:Record):Promise{ + const args=PrepareDeliverySchema.parse(input); + return runOperation(ctx,'prepare_delivery',args,async lease=>{ + const note=await ctx.pb.collection('dn_batch_notes').getOne(args.id,{fields:'id,batch,status,stage,posted_at,supplier,purchase_order,checks'}); + if(note.batch!==args.batchId)throw new PurchasingError('candidate_batch_mismatch'); + if(['promoted','dismissed'].includes(String(note.status))||note.stage==='posted'||note.posted_at)throw new PurchasingError('candidate_closed',409); + if(!note.purchase_order||!note.supplier)throw new PurchasingError('candidate_entities_unresolved',409); + if(openSiteDecision(note.checks))throw new PurchasingError('waiting_for_site',409); + const {context,stored}=await loadPreviewContext(ctx.pb,args.batchId,args.id); + if(!stored)throw new PurchasingError('preview_missing',409); + const idOf=(n:number)=>context.lines.find(l=>lineOf(String(l.sourceLineId))===n)?.sourceLineId; + const entries=args.lines.map(entry=>({sourceLineIds:entry.lines.map(idOf).filter((v):v is string=>typeof v==='string'),confirmation:entry.confirmation})); + if(entries.some((e,i)=>e.sourceLineIds.length!==args.lines[i].lines.length))throw new PurchasingError('source_line_unknown',400); + let planned; + try{planned=applyPlan(context,stored,{entries,closeNil:args.closeNil});} + catch(error){const message=error instanceof Error?error.message:String(error); + if(/Slip lines changed/.test(message))throw Object.assign(new PurchasingError('preview_stale',409),{message:'preview_stale: slip lines changed; run process_delivery_candidate first'}); + if(/already posted/.test(message))throw new PurchasingError('line_already_posted',409); + if(/cumulative excess/i.test(message))throw Object.assign(new PurchasingError('over_po_needs_acceptance',409),{message:'over_po_needs_acceptance: a planned quantity takes this PO line over its ordered quantity'}); + throw error;} + if(planned.missing.length)throw Object.assign(new PurchasingError('lines_not_planned',409),{message:`lines_not_planned: slip line(s) ${planned.missing.flat().map(id=>lineOf(id)).join(', ')} have a delivered quantity but no plan entry`}); + const {preview,ready}=planned; + const lines=preview.allocations.filter(a=>!a.posted).map(a=>({lines:linesOf(a),poItemId:a.purchaseOrderItemId,quantity:a.quantity,unit:a.unit,net:a.netAmount,disposition:a.operatorConfirmation?.disposition??'received'})); + // Why a slip is not ready yet, in the words the board shows (usually: over the PO without acceptExcess). + const stillNeeds=ready?[]:preview.allocations.filter(a=>allocationNeedsHelp(a,preview)).map(a=>({lines:linesOf(a),reasons:(a.quantityExcess??0)>0&&!a.operatorConfirmation?.acceptExcess?[`over the PO by ${a.quantityExcess} ${a.unit??''}`.trim(),...a.reviewReasons]:a.reviewReasons})); + await lease.update('dn_batch_notes',args.id,{reconciliation_preview:preview,status:ready?'candidate':'blocked',stage:ready?'ready_to_post':'needs_review'}); + await lease.create('dn_review_events',{batch:args.batchId,note:args.id,actor:ctx.principal.id,action:'agent_prepare_delivery',reason:args.reason??'',after:{ready,netTotal:preview.netTotal,lines}}); + return {ready,netTotal:preview.netTotal,lines,stillNeeds}; + },`batch:${args.batchId}`); +} diff --git a/src/lib/purchasing/delivery-prepare.test.ts b/src/lib/purchasing/delivery-prepare.test.ts new file mode 100644 index 0000000..0ad523d --- /dev/null +++ b/src/lib/purchasing/delivery-prepare.test.ts @@ -0,0 +1,62 @@ +import {beforeEach,expect,it,vi} from 'vitest'; +import type {PreviewContext,ProcessingPreview} from '@/lib/dn-pipeline/ai-preview'; +// Only the PocketBase-backed loader is mocked; buildPreview, matching and readiness are the real code. +const load=vi.hoisted(()=>vi.fn()); +vi.mock('@/lib/dn-pipeline/ai-preview-context',()=>({loadPreviewContext:load})); +import {buildPreview} from '@/lib/dn-pipeline/ai-preview'; +import {prepareDelivery as confirmPlan} from '@/lib/dn-pipeline/matching'; +import {context as fixture} from '@/lib/dn-pipeline/preview-fixture'; +import {prepareDeliveryForSite} from './delivery-posting'; +import {memoryDatabase} from './test-support'; + +const NOTE='noteabcdefghijk',BATCH='batchabcdefghij'; +const sid=(n:number)=>`${NOTE}:${n}:aaaaaaaaaaa${n}`; +const op=(n:number)=>`7c1d2e3f-4a5b-4c6d-8e7f-0a1b2c3d4e5${n}`; +let ctxData:PreviewContext;let stored:ProcessingPreview; +async function setup(qty=[11,36],checks:unknown[]=[]){ + ctxData=fixture();ctxData.lines=ctxData.lines.map((l,n)=>({...l,sourceLineId:sid(n),qty:qty[n]})); + stored=buildPreview(ctxData,{allocations:[]}); + load.mockImplementation(async()=>({context:ctxData,stored})); + const {pb,store}=memoryDatabase(); + await pb.collection('dn_batch_notes').create({id:NOTE,batch:BATCH,status:'blocked',stage:'needs_review',supplier:'supplierabcdefg',purchase_order:'poabcdefghijklm',checks}); + return {store,ctx:{pb,principal:{id:'foreman',permissions:new Set<'read'|'write'>(['read','write'])}}}; +} +const pipe={confirmed:true as const,poItemId:'itemabcdefghijk',quantity:27.5,unit:'m',usePoRate:true,reason:'11 pipes x 2.5 m'}; +beforeEach(()=>vi.clearAllMocks()); + +it('saves the plan and moves the slip to Ready to post without booking anything',async()=>{ + const {ctx,store}=await setup(); + const r=await prepareDeliveryForSite(ctx,{operationId:op(1),batchId:BATCH,id:NOTE,lines:[{lines:[0,1],confirmation:pipe}],reason:'kit: pipes with their rings'}); + expect(r.result).toMatchObject({ready:true,netTotal:275,stillNeeds:[]}); + const note=store('dn_batch_notes').get(NOTE)!; + expect(note).toMatchObject({status:'candidate',stage:'ready_to_post'}); + expect(note.line_posting).toBeUndefined();expect(note.posted_at).toBeUndefined(); + expect(store('delivery_notes').size).toBe(0); + // The site manager's Confirm posts exactly what was saved. + const preview=note.reconciliation_preview as ProcessingPreview; + expect(confirmPlan(ctxData,preview)).toMatchObject([{sourceLineIds:[sid(0),sid(1)],confirmation:{...pipe,disposition:'received'}}]); + expect([...store('dn_review_events').values()].some(e=>e.action==='agent_prepare_delivery')).toBe(true); +}); + +it('refuses an over-PO quantity unless the excess is accepted, then stays in Needs review',async()=>{ + const {ctx,store}=await setup(); + await expect(prepareDeliveryForSite(ctx,{operationId:op(2),batchId:BATCH,id:NOTE,lines:[{lines:[0,1],confirmation:{...pipe,quantity:80}}]})).rejects.toThrow('over_po_needs_acceptance'); + expect(store('dn_batch_notes').get(NOTE)).toMatchObject({stage:'needs_review'}); + // With the site's approval recorded, the excess line is saved and the slip can go to Ready to post. + const r=await prepareDeliveryForSite(ctx,{operationId:op(6),batchId:BATCH,id:NOTE,lines:[{lines:[0,1],confirmation:{...pipe,quantity:80,acceptExcess:true,reason:'Site approved 10 m extra (site decision answered 9 Oct)'}}]}); + expect(r.result).toMatchObject({ready:true}); +}); + +it('refuses a slip with an open site question, and unplanned delivered lines',async()=>{ + const asked=await setup([11,36],[{id:'site_decision',passed:false,reason:'How long is each pipe?'}]); + await expect(prepareDeliveryForSite(asked.ctx,{operationId:op(3),batchId:BATCH,id:NOTE,lines:[{lines:[0,1],confirmation:pipe}]})).rejects.toThrow('waiting_for_site'); + const plain=await setup(); + await expect(prepareDeliveryForSite(plain.ctx,{operationId:op(4),batchId:BATCH,id:NOTE,lines:[{lines:[0],confirmation:pipe}]})).rejects.toThrow(/slip line\(s\) 1 have a delivered quantity/); +}); + +it('closes NIL lines by itself',async()=>{ + const {ctx}=await setup([11,0]); + const r=await prepareDeliveryForSite(ctx,{operationId:op(5),batchId:BATCH,id:NOTE,lines:[{lines:[0],confirmation:pipe}]}); + expect(r.result).toMatchObject({ready:true}); + expect((r.result as {lines:Array<{lines:number[];disposition:string}>}).lines).toContainEqual(expect.objectContaining({lines:[1],disposition:'not_delivered'})); +}); diff --git a/src/lib/purchasing/errors.ts b/src/lib/purchasing/errors.ts index 9832a58..47ae29e 100644 --- a/src/lib/purchasing/errors.ts +++ b/src/lib/purchasing/errors.ts @@ -13,6 +13,8 @@ const HINTS:Array<[RegExp,string]>=[ [/^preview_stale$|slip lines changed/i,'Run process_delivery_candidate, then post again.'], [/line_receipt_value_basis/i,'This receipt line was valued from the slip amount, not the PO rate. A person must correct its value; only move it without changing the quantity.'], [/line_receipt_no_rate/i,'The target PO line has no usable rate, so the booking cannot be revalued. Set the rate on the PO line first (update_purchase_order_item), then retry.'], + [/over_po_needs_acceptance/i,'A line is over the PO. Only set acceptExcess:true with a reason when the site has approved the extra quantity; otherwise ask the site with request_site_decision.'], + [/waiting_for_site/i,'The site has an open question on this document. Wait for their answer (answer_site_decision records it) before preparing or posting.'], [/stale|context changed/i,'Run process_delivery_candidate again, then post with the new snapshotHash.'], [/already has a receipt/i,'A posted receipt shares a document number with this slip. Compare both documents; if they are different deliveries, correct the reference numbers.'], [/identity or PO first|candidate_entities_unresolved/i,'Link the supplier and purchase order with resolve_delivery_entities first.'], diff --git a/src/mcp/purchasing-write-tools.ts b/src/mcp/purchasing-write-tools.ts index 8846587..aa05b8e 100644 --- a/src/mcp/purchasing-write-tools.ts +++ b/src/mcp/purchasing-write-tools.ts @@ -7,7 +7,7 @@ import {CandidateOperationSchema,EntityResolutionSchema,ItemResolutionSchema,res import {McpPostDeliveryLineSchema,ReceiptCorrectionSchema,postDeliveryLine,correctDeliveryReceipt} from '@/lib/purchasing/delivery-receipts'; import {ProcessBatchSchema,enqueueDeliveryJob} from '@/lib/purchasing/jobs'; import {setDeliveryVerification} from '@/lib/purchasing/verification'; -import {PostDeliverySchema,postDelivery} from '@/lib/purchasing/delivery-posting'; +import {PostDeliverySchema,postDelivery,PrepareDeliverySchema,prepareDeliveryForSite} from '@/lib/purchasing/delivery-posting'; import {UploadPurchasingSchema,uploadPurchasingDocument} from '@/lib/purchasing/uploads'; import {CreateDeliveryCandidateSchema,UpdateDeliveryCandidateSchema,DeliveryGroupingSchema,mutateDeliveryCandidate} from '@/lib/purchasing/delivery-candidates'; import {SetAsideSchema,RestoreSchema,setAsideDocument,restoreDocument} from '@/lib/purchasing/document-disposition'; @@ -27,6 +27,7 @@ export function registerPurchasingWrites(server:McpServer,principal:Principal):v for(const [name,description,schema,handler] of [['request_site_decision','Ask the site a plain-language question about a delivery document you cannot decide (for example an unclear quantity or a quantity beyond the PO). The document waits in Needs attention with your question until someone answers it.',RequestSiteDecisionSchema,requestSiteDecision],['answer_site_decision',"Record the site's answer to an open site-decision question.",AnswerSiteDecisionSchema,answerSiteDecision]] as const)registerPurchasingTool(server,principal,{name,description,schema,permission:'write',handler}); registerPurchasingTool(server,principal,{name:'move_to_purchase_orders',description:'Move a purchase-order photo that was uploaded as a delivery document into purchase orders, optionally with its lines, and set the delivery document aside.',schema:MoveToPurchaseOrdersSchema,permission:'write',handler:moveToPurchaseOrders}); for(const [name,description,schema,handler] of [['process_delivery_candidate','Generate a fresh matching preview without posting.',CandidateOperationSchema,processDeliveryCandidate],['post_delivery_line','Post one allocation with explicit confirmation and a current preview hash.',McpPostDeliveryLineSchema,postDeliveryLine],['correct_delivery_receipt','Correct an eligible legacy receipt with expected quantities and audit history.',ReceiptCorrectionSchema,correctDeliveryReceipt]] as const)registerPurchasingTool(server,principal,{name,description,schema,permission:'write',handler}); + registerPurchasingTool(server,principal,{name:'prepare_delivery',description:'Prepare a whole delivery slip for the site manager without booking it: same plan as post_delivery (slip lines by number, kits, NIL auto-close). Every line is saved and, when nothing is left open, the slip moves to Ready to post; the site manager checks the totals and presses Confirm delivery. Returns ready, netTotal and what still needs help (e.g. quantity over the PO).',schema:PrepareDeliverySchema,permission:'write',handler:prepareDeliveryForSite}); registerPurchasingTool(server,principal,{name:'post_delivery',description:'Post a whole delivery slip in one call. Plan every delivered slip line by its line number (group several slip lines for one PO line as a kit); NIL lines close automatically. Commits line by line and is safe to retry with the same operationId.',schema:PostDeliverySchema,permission:'write',handler:postDelivery}); registerPurchasingTool(server,principal,{name:'process_delivery_batch',description:'Queue durable source reading and matching; no automatic receipt posting. Poll get_mcp_operation.',schema:ProcessBatchSchema,permission:'write',handler:enqueueDeliveryJob}); const orderTools=[