feat(mcp): set delivery documents aside and restore them

Co-Authored-By: MiniMax Code <noreply@minimax.io>
This commit is contained in:
DEls 2026-10-09 02:28:05 +02:00
parent e3b285cd17
commit 6e5aa8cf10
3 changed files with 109 additions and 0 deletions

View file

@ -0,0 +1,40 @@
import {expect,it} from 'vitest';
import {memoryDatabase} from './test-support';
import {setAsideDocument,restoreDocument} from './document-disposition';
const fixture=()=>{const db=memoryDatabase();return {...db,ctx:{pb:db.pb,principal:{id:'agent',permissions:new Set<'read'|'write'>(['read','write'])}}};};
async function seed(pb:ReturnType<typeof memoryDatabase>['pb']){
await pb.collection('dn_batches').create({id:'batchabcdefghij',status:'needs_attention',stage:'complete'});
await pb.collection('dn_batch_notes').create({id:'noteabcdefghijk',batch:'batchabcdefghij',status:'blocked',stage:'needs_review',checks:[]});
await pb.collection('dn_batch_notes').create({id:'notebbbbbbbbbbb',batch:'batchabcdefghij',status:'promoted',stage:'posted',checks:[]});
}
it('sets a quotation aside, closes the batch, and restores it',async()=>{
const {pb,store,ctx:c}=fixture();await seed(pb);
const result=await setAsideDocument(c,{operationId:'1b2c3d4e-5f60-4a7b-8c9d-0e1f2a3b4c5d',batchId:'batchabcdefghij',id:'noteabcdefghijk',kind:'quotation',reason:'Avidan quote QU009224, not a delivery'});
expect(result.result).toMatchObject({id:'noteabcdefghijk',status:'dismissed',stage:'dismissed'});
expect(store('dn_batch_notes').get('noteabcdefghijk')).toMatchObject({status:'dismissed',stage:'dismissed'});
expect(store('dn_batches').get('batchabcdefghij')).toMatchObject({status:'processed',stage:'complete'});
expect(store('dn_review_events').size).toBe(1);
expect([...store('dn_review_events').values()][0]).toMatchObject({action:'set_aside',note:'noteabcdefghijk',actor:'agent'});
await restoreDocument(c,{operationId:'2b2c3d4e-5f60-4a7b-8c9d-0e1f2a3b4c5d',batchId:'batchabcdefghij',id:'noteabcdefghijk',reason:'set aside by mistake'});
expect(store('dn_batch_notes').get('noteabcdefghijk')).toMatchObject({status:'blocked',stage:'needs_review',checks:[]});
expect(store('dn_batches').get('batchabcdefghij')).toMatchObject({status:'needs_attention'});
expect(store('dn_review_events').size).toBe(2);
expect([...store('dn_review_events').values()][1]).toMatchObject({action:'restore',before:{status:'dismissed'},after:{status:'blocked'}});
});
it('refuses to set aside a posted document',async()=>{
const {pb,ctx:c}=fixture();await seed(pb);
await expect(setAsideDocument(c,{operationId:'3b2c3d4e-5f60-4a7b-8c9d-0e1f2a3b4c5d',batchId:'batchabcdefghij',id:'notebbbbbbbbbbb',kind:'duplicate',reason:'x'})).rejects.toThrow('already_posted');
await expect(restoreDocument(c,{operationId:'4b2c3d4e-5f60-4a7b-8c9d-0e1f2a3b4c5d',batchId:'batchabcdefghij',id:'noteabcdefghijk',reason:'x'})).rejects.toThrow('not_set_aside');
});
it('refuses a note that is not in the named batch or has started receiving',async()=>{
const {pb,ctx:c}=fixture();await seed(pb);
await pb.collection('dn_batch_notes').create({id:'noteccccccccccc',batch:'batchabcdefghij',status:'blocked',stage:'needs_review',checks:[],line_posting:{lines:[{index:0}]}});
await expect(setAsideDocument(c,{operationId:'5b2c3d4e-5f60-4a7b-8c9d-0e1f2a3b4c5d',batchId:'batchzzzzzzzzzz',id:'noteabcdefghijk',kind:'other',reason:'x'})).rejects.toThrow('candidate_batch_mismatch');
await expect(setAsideDocument(c,{operationId:'6b2c3d4e-5f60-4a7b-8c9d-0e1f2a3b4c5d',batchId:'batchabcdefghij',id:'noteccccccccccc',kind:'other',reason:'x'})).rejects.toThrow('receipt_started');
});
it('leaves the batch open while another note still needs attention',async()=>{
const {pb,store,ctx:c}=fixture();await seed(pb);
await pb.collection('dn_batch_notes').create({id:'noteddddddddddd',batch:'batchabcdefghij',status:'blocked',stage:'needs_review',checks:[]});
await setAsideDocument(c,{operationId:'7b2c3d4e-5f60-4a7b-8c9d-0e1f2a3b4c5d',batchId:'batchabcdefghij',id:'noteabcdefghijk',kind:'not_a_delivery',reason:'x'});
expect(store('dn_batches').get('batchabcdefghij')).toMatchObject({status:'needs_attention'});
});

View file

@ -0,0 +1,67 @@
import {z} from 'zod';
import {hash} from 'node:crypto';
import type PocketBase from 'pocketbase';
import type {BatchLeaseContext} from '@/lib/dn-pipeline/lock';
import type {PurchasingContext,OperationResult} from './types';
import {MutationSchema,runOperation} from './operations';
import {RecordId} from './documents';
import {PurchasingError} from './errors';
export type SetAsideKind='quotation'|'duplicate'|'purchase_order_document'|'not_a_delivery'|'wrong_site'|'other';
export const SetAsideSchema=MutationSchema.extend({batchId:RecordId,id:RecordId,kind:z.enum(['quotation','duplicate','purchase_order_document','not_a_delivery','wrong_site','other']),reason:z.string().trim().min(1).max(2000)}).strict();
export const RestoreSchema=MutationSchema.extend({batchId:RecordId,id:RecordId,reason:z.string().trim().min(1).max(2000)}).strict();
type Check=Record<string,unknown>&{id?:string;detail?:{previous?:{status?:string;stage?:string}}};
const checksOf=(note:Record<string,unknown>):Check[]=>Array.isArray(note.checks)?[...(note.checks as Check[])]:[];
/** A batch whose every note is now promoted or dismissed has nothing left to answer for. */
const settled=(notes:Array<Record<string,unknown>>,id:string):boolean=>notes.filter(n=>String(n.id)!==id).every(n=>n.status==='promoted'||n.status==='dismissed');
/** Buffered commits run once, so a clock-seeded id cannot collide on a legitimate repeat. */
const auditId=(...parts:Array<string|number>)=>hash('sha256',parts.join('|')).slice(0,15);
async function audit(lease:BatchLeaseContext,row:Record<string,unknown>):Promise<void>{await lease.create('dn_review_events',row);}
/**
* Stands one candidate note down without promoting it: it writes the note,
* closes the batch if nothing else is open, and journals both. The caller has
* already refused a note whose receipt started or already reached the ledger.
*
* Duplication happens per note, not per batch — the same stack of invoices can
* be scanned into two batches at once — so dismissing the batch would either
* discard unique invoices or zero a counter for notes already on the ledger.
*
* Mirrors src/app/api/dn-batches/[id]/notes/[noteId]/dismiss/route.ts, but
* typed, reversible and reusable inside another operation's own lease.
*/
export async function setAsideWithinLease(lease:BatchLeaseContext,pb:PocketBase,note:Record<string,unknown>,batchId:string,kind:SetAsideKind,reason:string,actor:string):Promise<{id:string;status:string;stage:string}>{
const id=String(note.id),before={status:String(note.status??''),stage:String(note.stage??'')},after={status:'dismissed',stage:'dismissed'};
await lease.update('dn_batch_notes',id,{...after,checks:[...checksOf(note),{id:'dismissed',passed:true,reason:`${kind}: ${reason}`,detail:{kind,actor,previous:before}}]});
const siblings=await pb.collection('dn_batch_notes').getFullList({filter:pb.filter('batch={:batch}',{batch:batchId}),fields:'id,batch,status'});
// The lease buffers writes, so the note just dismissed still reads as open: exclude it.
if(settled(siblings.filter(n=>String(n.batch)===batchId),id))await lease.update('dn_batches',batchId,{status:'processed',stage:'complete'});
await audit(lease,{id:auditId('set_aside',actor,batchId,id,kind,reason,Date.now()),batch:batchId,note:id,action:'set_aside',actor,reason,before,after});
return {id,...after};
}
export async function setAsideDocument(ctx:PurchasingContext,input:Record<string,unknown>):Promise<OperationResult>{
const args=SetAsideSchema.parse(input),batchId=args.batchId;
return runOperation(ctx,'set_aside_document',args,async lease=>{
const note=await ctx.pb.collection('dn_batch_notes').getOne(args.id);
if(note.batch!==batchId)throw new PurchasingError('candidate_batch_mismatch');
if(note.line_posting)throw new PurchasingError('receipt_started',409);
if(note.status==='promoted'||note.delivery_note)throw new PurchasingError('already_posted',409);
return setAsideWithinLease(lease,ctx.pb,note,args.batchId,args.kind,args.reason,ctx.principal.id);
},`batch:${batchId}`);
}
export async function restoreDocument(ctx:PurchasingContext,input:Record<string,unknown>):Promise<OperationResult>{
const args=RestoreSchema.parse(input),batchId=args.batchId;
return runOperation(ctx,'restore_document',args,async lease=>{
const note=await ctx.pb.collection('dn_batch_notes').getOne(args.id);
if(note.batch!==batchId)throw new PurchasingError('candidate_batch_mismatch');
if(note.status!=='dismissed')throw new PurchasingError('not_set_aside',409);
// The dismissing check carries the state we displaced, so restoring is not a guess.
const checks=checksOf(note),at=checks.map(c=>c.id).lastIndexOf('dismissed'),previous=at>=0?checks[at]?.detail?.previous:undefined;
const status=previous?.status||'blocked',stage=previous?.stage||'needs_review',before={status:String(note.status??''),stage:String(note.stage??'')},after={status,stage};
await lease.update('dn_batch_notes',args.id,{...after,checks:at>=0?checks.filter((_,i)=>i!==at):checks});
const batch=await ctx.pb.collection('dn_batches').getOne(batchId,{fields:'id,status'}).catch(()=>null);
if(batch?.status==='processed')await lease.update('dn_batches',batchId,{status:'needs_attention'});
await audit(lease,{id:auditId('restore',ctx.principal.id,batchId,args.id,args.reason,Date.now()),batch:batchId,note:args.id,action:'restore',actor:ctx.principal.id,reason:args.reason,before,after});
return {id:args.id,status,stage};
},`batch:${batchId}`);
}

View file

@ -10,10 +10,12 @@ import {setDeliveryVerification} from '@/lib/purchasing/verification';
import {PostDeliverySchema,postDelivery} 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';
export function registerPurchasingWrites(server:McpServer,principal:Principal):void {
for(const name of ['upload_purchase_order','upload_delivery_documents'] as const)registerPurchasingTool(server,principal,{name,description:'Upload one immutable PDF or image source; retry using the same operationId.',schema:UploadPurchasingSchema,permission:'write',handler:(ctx,input)=>uploadPurchasingDocument(ctx,name,input)});
for(const [name,schema] of [['create_delivery_candidate',CreateDeliveryCandidateSchema],['update_delivery_candidate',UpdateDeliveryCandidateSchema],['set_delivery_grouping',DeliveryGroupingSchema]] as const)registerPurchasingTool(server,principal,{name,description:'Edit receiving candidates or grouping under the batch lease, preserving posted evidence.',schema,permission:'write',handler:(ctx,input)=>mutateDeliveryCandidate(ctx,name,input)});
for(const [name,schema] of [['resolve_delivery_entities',EntityResolutionSchema],['resolve_delivery_item',ItemResolutionSchema]] as const)registerPurchasingTool(server,principal,{name,description:'Resolve receiving entities or PO lines with ownership checks; does not book a receipt.',schema,permission:'write',handler:(ctx,input)=>resolveDelivery(ctx,name,input)});
for(const [name,description,schema,handler] of [['set_aside_document','Set a delivery document aside with a typed reason (quotation, duplicate, purchase_order_document, not_a_delivery, wrong_site, other). Reversible with restore_document.',SetAsideSchema,setAsideDocument],['restore_document','Restore a document that was set aside.',RestoreSchema,restoreDocument]] as const)registerPurchasingTool(server,principal,{name,description,schema,permission:'write',handler});
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:'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});