feat(mcp): compact delivery inbox board for agents

Co-Authored-By: MiniMax Code <noreply@minimax.io>
This commit is contained in:
DEls 2026-10-09 02:39:38 +02:00
parent 1da203553c
commit b0ecfbca10
3 changed files with 111 additions and 0 deletions

View file

@ -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<typeof memoryDatabase>['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([]);
});

View file

@ -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,unknown>):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,unknown>):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<string,unknown>):Promise<InboxBoard>{
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}};
}

View file

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