feat(mcp): prepare_delivery stages a slip for the site manager without booking it

Agents can now save a whole receiving plan (lines by number, kits, NIL
auto-close) and leave the slip in Ready to post; the site manager checks
the totals and presses Confirm delivery, which posts exactly what was
saved. Quantities over the PO need acceptExcess (site-approved) and a
slip with an open site question is refused.

Confirm delivery on the web now refreshes a slip whose match went stale
because another slip on the same PO was booked, re-applying the saved
decisions instead of sending the user back to an AI re-match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DEls 2026-10-09 17:47:35 +02:00
parent 2014a02db5
commit 0307d2cb35
8 changed files with 242 additions and 6 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string,unk
throw error;
}
}
export const PrepareDeliverySchema=PostDeliverySchema;
/**
* Prepare a whole slip for the site manager: save the agent's plan on every
* line and leave the slip in Ready to post. Nothing is booked.
*
* Same plan as `post_delivery` (lines by number, kits, NIL auto-close). The
* site manager's "Confirm delivery" then posts exactly what was saved here, so
* the person who pulls the trigger sees the agent's quantities and totals first.
*/
export async function prepareDeliveryForSite(ctx:PurchasingContext,input:Record<string,unknown>):Promise<OperationResult>{
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}`);
}

View file

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

View file

@ -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.'],

View file

@ -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=[