diff --git a/src/lib/purchasing/inbox-board.test.ts b/src/lib/purchasing/inbox-board.test.ts new file mode 100644 index 0000000..c541b60 --- /dev/null +++ b/src/lib/purchasing/inbox-board.test.ts @@ -0,0 +1,57 @@ +import {expect,it} from 'vitest'; +import {memoryDatabase} from './test-support'; +import {inboxBoard} from './inbox-board'; + +const SITE_A='siteaaaaaaaaaaa',SITE_B='sitebbbbbbbbbbb',BATCH_A='batchaaaaaaaaaa',BATCH_B='batchbbbbbbbbbb',SUPPLIER='supplierabcdefg',PO='poaaaaaaaaaaaaa'; +const NOTE_A='noteaaaaaaaaaaa',NOTE_B='notebbbbbbbbbbb',NOTE_C='noteccccccccccc',NOTE_D='noteddddddddddd',NOTE_E='noteeeeeeeeeeee'; +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('sites').create({id:SITE_A,name:'Alpha Yard'}); + await pb.collection('sites').create({id:SITE_B,name:'Beta Yard'}); + await pb.collection('suppliers').create({id:SUPPLIER,name:'Avidan Steel'}); + await pb.collection('purchase_orders').create({id:PO,purchase_order_number:'PO-501'}); + await pb.collection('dn_batches').create({id:BATCH_A,site:SITE_A,stage:'complete',page_count:1}); + await pb.collection('dn_batches').create({id:BATCH_B,site:SITE_B,stage:'split_review',page_count:3}); + // A: blocked with nothing linked yet. + await pb.collection('dn_batch_notes').create({id:NOTE_A,batch:BATCH_A,status:'blocked',stage:'needs_review',items:[],checks:[]}); + // B: ready to post. + await pb.collection('dn_batch_notes').create({id:NOTE_B,batch:BATCH_A,status:'candidate',stage:'ready_to_post',supplier:SUPPLIER,purchase_order:PO,delivery_note_number:'DN-2',doc_number:'DN-2',doc_type:'delivery_note',items:[{qty:1}],checks:[],reconciliation_preview:{complete:true,allocations:[]}}); + // C: known blocker with an over-long reason. + await pb.collection('dn_batch_notes').create({id:NOTE_C,batch:BATCH_A,status:'blocked',stage:'needs_review',supplier:SUPPLIER,purchase_order:PO,doc_number:'DN-3',doc_type:'unknown',items:[{qty:1},{qty:2}],checks:[{id:'po_resolved',passed:false,reason:'x'.repeat(200)},{id:'sources_current',passed:true,reason:'ok'}]}); + // D: already on the ledger, never shown. + await pb.collection('dn_batch_notes').create({id:NOTE_D,batch:BATCH_B,status:'promoted',stage:'posted',checks:[]}); + // E: open, but on the other site. + await pb.collection('dn_batch_notes').create({id:NOTE_E,batch:BATCH_B,status:'blocked',stage:'needs_review',checks:[]}); +} + +it('lists every open document with its blocking reasons and next action',async()=>{ + const {pb,store,ctx:c}=fixture();await seed(pb); + const before=store('dn_batch_notes').size; + const board=await inboxBoard(c,{}); + expect(board.counts).toEqual({open:4,grouping:1}); + expect(board.documents.map(d=>d.id)).not.toContain(NOTE_D); + const byId=new Map(board.documents.map(d=>[d.id,d])); + expect(byId.get(NOTE_A)?.next).toBe('link supplier and PO (resolve_delivery_entities)'); + expect(byId.get(NOTE_B)).toMatchObject({next:'post_delivery',purchaseOrderNumber:'PO-501',supplierName:'Avidan Steel',siteName:'Alpha Yard',lines:1,documentNumber:'DN-2'}); + expect(byId.get(NOTE_C)?.next).toBe('confirm document type or set aside (edit_document / set_aside_document)'); + expect(byId.get(NOTE_C)?.lines).toBe(2); + expect(byId.get(NOTE_C)?.blocking).toHaveLength(1); + expect(byId.get(NOTE_C)!.blocking[0]!.length).toBeLessThanOrEqual(120); + expect(byId.get(NOTE_C)!.blocking[0]).toBe('po_resolved: '+'x'.repeat(200).slice(0,120-(('po_resolved: ').length))); + expect(byId.get(NOTE_B)?.blocking).toEqual([]); + expect(board.groupingBatches).toEqual([{batchId:BATCH_B,site:SITE_B,siteName:'Beta Yard',pages:3,stage:'split_review'}]); + // Sorted by siteName, then supplierName, then documentNumber. + expect(board.documents.map(d=>d.siteName)).toEqual(['Alpha Yard','Alpha Yard','Alpha Yard','Beta Yard']); + // Read-only: nothing was written. + expect(store('dn_batch_notes').size).toBe(before); + expect(store('dn_batches').size).toBe(2); +}); + +it('filters to one site without touching the other',async()=>{ + const {pb,ctx:c}=fixture();await seed(pb); + const board=await inboxBoard(c,{site:SITE_A}); + expect(board.counts).toEqual({open:3,grouping:0}); + expect(board.documents.every(d=>d.site===SITE_A)).toBe(true); + expect(board.documents.map(d=>d.id)).not.toContain(NOTE_E); + expect(board.groupingBatches).toEqual([]); +}); \ No newline at end of file diff --git a/src/lib/purchasing/inbox-board.ts b/src/lib/purchasing/inbox-board.ts new file mode 100644 index 0000000..995756f --- /dev/null +++ b/src/lib/purchasing/inbox-board.ts @@ -0,0 +1,52 @@ +import {z} from 'zod'; +import type {PurchasingContext} from './types'; +import {RecordId} from './documents'; + +export const InboxBoardSchema=z.object({site:RecordId.optional()}).strict(); +export type InboxDocument={batchId:string;id:string;site:string;siteName:string;supplier:string;supplierName:string;documentNumber:string;docType:string;purchaseOrder:string;purchaseOrderNumber:string;status:string;stage:string;lines:number;blocking:string[];next:string}; +export type InboxBoard={documents:InboxDocument[];groupingBatches:Array<{batchId:string;site:string;siteName:string;pages:number;stage:string}>;counts:{open:number;grouping:number}}; +type Check={id?:string;passed?:boolean;reason?:string}; +const REASON_LIMIT=120; +/** A failing check is a sentence an agent can act on; a 200-character OCR dump is not. */ +const blockingOf=(note:Record):string[]=>{ + const checks=Array.isArray(note.checks)?(note.checks as Check[]):[]; + return checks.filter(check=>check.passed===false).map(check=>`${String(check.id??'')}: ${String(check.reason??'')}`.slice(0,REASON_LIMIT)); +}; +/** The first thing that would unblock this document, in the order a person would do it. */ +const nextAction=(note:Record):string=>{ + if(!note.supplier||!note.purchase_order)return 'link supplier and PO (resolve_delivery_entities)'; + if(note.doc_type==='unknown')return 'confirm document type or set aside (edit_document / set_aside_document)'; + if(!note.reconciliation_preview)return 'process_delivery_candidate'; + const preview=note.reconciliation_preview as {complete?:boolean}|null; + return preview?.complete===true?'post_delivery':'fix quantities/links then post_delivery'; +}; +/** + * One compact read of the whole receiving inbox: what is open, what is + * blocking it, and the single next action for each. + * + * An agent otherwise has to page through candidates one at a time to learn + * that most of them are waiting on the same two things. This is read-only — it + * never writes, so it is safe to poll while a batch is being worked. + */ +export async function inboxBoard(ctx:PurchasingContext,input:Record):Promise{ + const args=InboxBoardSchema.parse(input),pb=ctx.pb; + const [notes,batches,sites,suppliers,orders]=await Promise.all([ + pb.collection('dn_batch_notes').getFullList({fields:'id,batch,status,stage,supplier,purchase_order,delivery_note_number,doc_number,doc_type,items,checks,reconciliation_preview'}), + pb.collection('dn_batches').getFullList({fields:'id,site,stage,page_count'}), + pb.collection('sites').getFullList({fields:'id,name'}), + pb.collection('suppliers').getFullList({fields:'id,name'}), + pb.collection('purchase_orders').getFullList({fields:'id,purchase_order_number'}), + ]); + const siteName=new Map(sites.map(s=>[String(s.id),String(s.name??'')])),supplierName=new Map(suppliers.map(s=>[String(s.id),String(s.name??'')])),orderNumber=new Map(orders.map(o=>[String(o.id),String(o.purchase_order_number??'')])); + const batchSite=new Map(batches.map(b=>[String(b.id),String(b.site??'')])); + const wanted=args.site; + const inScope=(batchId:string)=>!wanted||batchSite.get(batchId)===wanted; + const documents:InboxDocument[]=notes.filter(n=>['blocked','candidate'].includes(String(n.status))&&inScope(String(n.batch??''))).map(note=>{ + const id=String(note.id),batchId=String(note.batch??''),site=batchSite.get(batchId)??'',purchaseOrder=String(note.purchase_order??''); + return {batchId,id,site,siteName:siteName.get(site)??'',supplier:String(note.supplier??''),supplierName:supplierName.get(String(note.supplier??''))??'',documentNumber:String(note.doc_number||note.delivery_note_number||''),docType:String(note.doc_type??''),purchaseOrder,purchaseOrderNumber:purchaseOrder?orderNumber.get(purchaseOrder)??'':'',status:String(note.status??''),stage:String(note.stage??''),lines:Array.isArray(note.items)?note.items.length:0,blocking:blockingOf(note),next:nextAction(note)}; + }); + documents.sort((a,b)=>a.siteName.localeCompare(b.siteName)||a.supplierName.localeCompare(b.supplierName)||a.documentNumber.localeCompare(b.documentNumber)); + const groupingBatches=batches.filter(batch=>batch.stage==='split_review'&&inScope(String(batch.id))).map(batch=>({batchId:String(batch.id),site:String(batch.site??''),siteName:siteName.get(String(batch.site??''))??'',pages:Number(batch.page_count??0),stage:String(batch.stage??'')})); + groupingBatches.sort((a,b)=>a.siteName.localeCompare(b.siteName)||a.batchId.localeCompare(b.batchId)); + return {documents,groupingBatches,counts:{open:documents.length,grouping:groupingBatches.length}}; +} \ No newline at end of file diff --git a/src/mcp/purchasing-read-tools.ts b/src/mcp/purchasing-read-tools.ts index 2ab708f..cff4cf6 100644 --- a/src/mcp/purchasing-read-tools.ts +++ b/src/mcp/purchasing-read-tools.ts @@ -6,6 +6,7 @@ import {ListPurchasingSchema,GetPurchasingSchema,GetPoSchema,GetCandidateSchema, import {DocumentReferenceSchema,loadDocument,documentDownloadUrl} from '@/lib/purchasing/documents'; import {PurchasingError} from '@/lib/purchasing/errors'; import {getOperation} from '@/lib/purchasing/operations'; +import {InboxBoardSchema,inboxBoard} from '@/lib/purchasing/inbox-board'; export function registerPurchasingReads(server:McpServer,principal:Principal):void{ const detail=[ ['list_purchasing_records','List purchasing records with bounded pagination.',ListPurchasingSchema], @@ -15,6 +16,7 @@ export function registerPurchasingReads(server:McpServer,principal:Principal):vo ['get_delivery_reconciliation','Compare an order, posted receipts and unposted candidates.',GetPoSchema], ] as const; for(const [name,description,schema] of detail)registerPurchasingTool(server,principal,{name,description,schema,permission:'read',handler:(ctx,input)=>readPurchasing(ctx,name,input)}); + registerPurchasingTool(server,principal,{name:'inbox_board',description:'Every open delivery document with its blocking reasons and the next action, plus batches waiting for page grouping.',permission:'read',schema:InboxBoardSchema,handler:inboxBoard}); registerPurchasingTool(server,principal,{name:'get_document_download',description:'Get document metadata and a same-app download URL requiring the MCP bearer header.',permission:'read',schema:DocumentReferenceSchema,handler:async(ctx,input)=>{ const ref=DocumentReferenceSchema.parse(input),doc=await loadDocument(ctx,ref);return {filename:doc.filename,mime:doc.mime,size:doc.bytes.length,url:documentDownloadUrl(ref),authentication:'Authorization: Bearer '}; }});