diff --git a/src/lib/purchasing/document-disposition.test.ts b/src/lib/purchasing/document-disposition.test.ts new file mode 100644 index 0000000..7f5aab2 --- /dev/null +++ b/src/lib/purchasing/document-disposition.test.ts @@ -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['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'}); +}); \ No newline at end of file diff --git a/src/lib/purchasing/document-disposition.ts b/src/lib/purchasing/document-disposition.ts new file mode 100644 index 0000000..36b092a --- /dev/null +++ b/src/lib/purchasing/document-disposition.ts @@ -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&{id?:string;detail?:{previous?:{status?:string;stage?:string}}}; +const checksOf=(note:Record):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>,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)=>hash('sha256',parts.join('|')).slice(0,15); +async function audit(lease:BatchLeaseContext,row:Record):Promise{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,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):Promise{ + 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):Promise{ + 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}`); +} \ No newline at end of file diff --git a/src/mcp/purchasing-write-tools.ts b/src/mcp/purchasing-write-tools.ts index 712f8ec..f972c86 100644 --- a/src/mcp/purchasing-write-tools.ts +++ b/src/mcp/purchasing-write-tools.ts @@ -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});