feat(mcp): merge_suppliers repoints a duplicate supplier and flags it for deletion

Co-Authored-By: MiniMax Code <noreply@minimax.io>
This commit is contained in:
DEls 2026-10-09 13:23:02 +02:00
parent 9ac2aa5428
commit 083cf849c8
4 changed files with 139 additions and 1 deletions

View file

@ -1,7 +1,7 @@
import {z} from 'zod';
import type PocketBase from 'pocketbase';
import type {PurchasingContext,OperationResult} from './types';
import {MutationSchema,runOperation} from './operations';
import {MutationSchema,runOperation,operationRecordId,operationPayloadHash} from './operations';
import {RecordId} from './documents';
import {PurchasingError} from './errors';
@ -31,6 +31,17 @@ export async function requestDeletion(ctx:PurchasingContext,input:Record<string,
});
}
/**
* The same journal row, written inside another operation's own lease.
*
* `mcp_operations` only ever holds committed operations, so an operation that
* cleans up after itself (a supplier merge, say) records its leftover exactly
* the way a standalone flag would, and it lands on the same list.
*/
export function deletionRequestRow(credential:string,operationId:string,request:{collection:string;recordId:string;reason:string;summary:string}):Record<string,unknown>{
return {id:operationRecordId(credential,'request_deletion',`${operationId}:flag`),credential,tool:'request_deletion',operation_id:operationId,payload_hash:operationPayloadHash('request_deletion',request),status:'committed',result:request};
}
/**
* The open flags, one per record however many times it was flagged.
*

View file

@ -0,0 +1,72 @@
import {expect,it} from 'vitest';
import {memoryDatabase} from './test-support';
import {mergeSuppliers} from './supplier-merge';
import {listDeletionRequests} from './deletion-requests';
const SITE='siteaaaaaaaaaaa',KEEP='supplierkeepaaa',DUPLICATE='supplierdupbbbb',PO='purchaseorderaa',NOTE='notemergeddddd',ALIAS='aliasduplicatedd';
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,name:'Alpha Yard'});
await pb.collection('suppliers').create({id:KEEP,name:'Vula Steel'});
await pb.collection('suppliers').create({id:DUPLICATE,name:'Vula Steel '});
await pb.collection('purchase_orders').create({id:PO,site:SITE,purchase_order_number:'PO-9330',supplier_ref:DUPLICATE,supplier:'Vula Steel '});
await pb.collection('dn_batch_notes').create({id:NOTE,batch:'batchmergedddd',supplier:DUPLICATE,doc_number:'DN-1'});
await pb.collection('supplier_document_aliases').create({id:ALIAS,supplier:DUPLICATE,raw_value:'vula steel cc',normalised_key:'vula steel cc',source:'learned'});
}
const op=(n:number)=>`1b2c3d4e-5f60-4a7b-8c9d-0e1f2a3b4c5${'abcdef0123456789'[n]}`;
const aliasRows=(store:ReturnType<typeof memoryDatabase>['store'])=>[...store('supplier_document_aliases').values()].sort((a,b)=>String(a.id).localeCompare(String(b.id)));
it('repoints every reference and flags the duplicate for deletion',async()=>{
const {pb,store,ctx:c}=fixture();await seed(pb);
const result=await mergeSuppliers(c,{operationId:op(0),keepId:KEEP,duplicateId:DUPLICATE,reason:'Two supplier records for one company'});
expect(result.result).toEqual({keep:{id:KEEP,name:'Vula Steel'},duplicate:{id:DUPLICATE,name:'Vula Steel (merged into Vula Steel)'},moved:{purchase_orders:1,dn_batch_notes:1,supplier_document_aliases:1}});
// The order keeps its text supplier in step with the relation.
expect(store('purchase_orders').get(PO)).toMatchObject({supplier_ref:KEEP,supplier:'Vula Steel'});
expect(store('dn_batch_notes').get(NOTE)).toMatchObject({supplier:KEEP});
expect(aliasRows(store).map(row=>row.supplier)).toEqual([KEEP,KEEP]);
// The duplicate is renamed, not deleted, and nothing points at it any more.
expect(store('suppliers').get(DUPLICATE)).toMatchObject({name:'Vula Steel (merged into Vula Steel)'});
expect(store('suppliers').size).toBe(2);
expect(await listDeletionRequests(pb)).toEqual([{operationId:op(0),collection:'suppliers',recordId:DUPLICATE,reason:'Merged into Vula Steel; no records point to it any more.',reasons:['Merged into Vula Steel; no records point to it any more.'],summary:'Vula Steel (merged into Vula Steel)',requestedBy:'agent'}]);
});
it('adds an alias from the duplicate name to the kept supplier',async()=>{
const {pb,store,ctx:c}=fixture();await seed(pb);
await mergeSuppliers(c,{operationId:op(1),keepId:KEEP,duplicateId:DUPLICATE,reason:'Two supplier records for one company'});
const added=aliasRows(store).find(row=>row.source==='supplier_merge')!;
expect(added).toMatchObject({supplier:KEEP,raw_value:'Vula Steel ',normalised_key:'VULA STEEL',approved_by:'agent'});
expect(String(added.id)).toHaveLength(15);
});
it('keeps an alias that already resolves the duplicate name',async()=>{
const {pb,store,ctx:c}=fixture();await seed(pb);
await pb.collection('suppliers').create({id:'supplierkeepbb0',name:'Vula Steel'});
await pb.collection('suppliers').create({id:'supplierdupeee0',name:'Vula Steel '});
await mergeSuppliers(c,{operationId:op(2),keepId:KEEP,duplicateId:DUPLICATE,reason:'Two supplier records for one company'});
await mergeSuppliers(c,{operationId:op(3),keepId:'supplierkeepbb0',duplicateId:'supplierdupeee0',reason:'Another duplicate company record'});
expect(aliasRows(store).filter(row=>row.source==='supplier_merge')).toHaveLength(1);
// The second copy had no references, so the first merge's work stands.
expect(store('dn_batch_notes').get(NOTE)).toMatchObject({supplier:KEEP});
expect(store('purchase_orders').get(PO)).toMatchObject({supplier_ref:KEEP});
});
it('refuses to merge a supplier into itself',async()=>{
const {pb,store,ctx:c}=fixture();await seed(pb);
await expect(mergeSuppliers(c,{operationId:op(4),keepId:KEEP,duplicateId:KEEP,reason:'Two supplier records for one company'})).rejects.toThrow('same_supplier');
expect(store('suppliers').get(KEEP)).toMatchObject({name:'Vula Steel'});
});
it('refuses a merge that would not fit one transaction',async()=>{
const {pb,ctx:c}=fixture();await seed(pb);
for(let n=0;n<60;n++)await pb.collection('purchase_orders').create({id:`pobulk${String(n).padStart(10,'0')}`,site:SITE,purchase_order_number:`PO-${n}`,supplier_ref:DUPLICATE,supplier:'Vula Steel '});
await expect(mergeSuppliers(c,{operationId:op(5),keepId:KEEP,duplicateId:DUPLICATE,reason:'Two supplier records for one company'})).rejects.toThrow('merge_too_large');
});
it('returns the stored result on a retry with the same operationId',async()=>{
const {pb,store,ctx:c}=fixture();await seed(pb);
const first=await mergeSuppliers(c,{operationId:op(6),keepId:KEEP,duplicateId:DUPLICATE,reason:'Two supplier records for one company'});
const again=await mergeSuppliers(c,{operationId:op(6),keepId:KEEP,duplicateId:DUPLICATE,reason:'Two supplier records for one company'});
expect(again).toEqual(first);
expect(aliasRows(store).filter(row=>row.source==='supplier_merge')).toHaveLength(1);
expect(store('purchase_orders').get(PO)).toMatchObject({supplier_ref:KEEP});
});

View file

@ -0,0 +1,53 @@
import {z} from 'zod';
import {hash} from 'node:crypto';
import type {PurchasingContext,OperationResult} from './types';
import {MutationSchema,runOperation} from './operations';
import {RecordId} from './documents';
import {PurchasingError} from './errors';
import {normaliseSupplierKey} from '@/lib/dn-pipeline/resolve-entities';
import {deletionRequestRow} from './deletion-requests';
export const MergeSuppliersSchema=MutationSchema.extend({keepId:RecordId,duplicateId:RecordId,reason:z.string().trim().min(8).max(2000)}).strict();
/** runOperation refuses a transaction over 48 writes, so a merge is checked before it starts. */
const MAX_WRITES=48;
/**
* Folds a duplicate supplier record into the one to keep.
*
* Two records for one company are not a cosmetic problem: every reconciliation
* that resolved against the copy silently stopped matching the orders that
* hang off it. The copy is renamed rather than deleted so a later read of an
* old document can still be traced, an alias keeps the copy's name resolving
* to the supplier that survives, and the record itself is flagged for a person
* to delete once nothing points at it.
*
* Refuses a merge too large for one transaction instead of writing half of it.
*/
export async function mergeSuppliers(ctx:PurchasingContext,input:Record<string,unknown>):Promise<OperationResult>{
const args=MergeSuppliersSchema.parse(input);
if(args.keepId===args.duplicateId)throw new PurchasingError('same_supplier');
return runOperation(ctx,'merge_suppliers',args,async lease=>{
const [keep,duplicate]=await Promise.all([ctx.pb.collection('suppliers').getOne(args.keepId,{fields:'id,name'}),ctx.pb.collection('suppliers').getOne(args.duplicateId,{fields:'id,name'}).catch(()=>null)]);
if(!duplicate)throw new PurchasingError('record_not_found',404);
const [orders,notes,aliases,known]=await Promise.all([
ctx.pb.collection('purchase_orders').getFullList({filter:ctx.pb.filter('supplier_ref={:id}',{id:args.duplicateId}),fields:'id,supplier_ref,supplier'}),
ctx.pb.collection('dn_batch_notes').getFullList({filter:ctx.pb.filter('supplier={:id}',{id:args.duplicateId}),fields:'id,supplier'}),
ctx.pb.collection('supplier_document_aliases').getFullList({filter:ctx.pb.filter('supplier={:id}',{id:args.duplicateId}),fields:'id,supplier,normalised_key'}),
ctx.pb.collection('supplier_document_aliases').getFullList({fields:'id,supplier,normalised_key'}),
]);
const key=normaliseSupplierKey(duplicate.name),needsAlias=Boolean(key)&&!known.some(alias=>normaliseSupplierKey(alias.normalised_key)===key);
const total=orders.length+notes.length+aliases.length+(needsAlias?1:0)+2;
if(total>MAX_WRITES)throw new PurchasingError('merge_too_large',409);
const keptName=String(keep.name??'');
// The order's own supplier text is what people read, so it follows the relation.
for(const order of orders.filter(row=>String(row.supplier_ref)===args.duplicateId))await lease.update('purchase_orders',String(order.id),{supplier_ref:args.keepId,supplier:keptName});
for(const note of notes.filter(row=>String(row.supplier)===args.duplicateId))await lease.update('dn_batch_notes',String(note.id),{supplier:args.keepId});
for(const alias of aliases.filter(row=>String(row.supplier)===args.duplicateId))await lease.update('supplier_document_aliases',String(alias.id),{supplier:args.keepId});
if(needsAlias)await lease.create('supplier_document_aliases',{id:hash('sha256',`supplier_merge:${args.keepId}:${key}`).slice(0,15),supplier:args.keepId,raw_value:String(duplicate.name??''),normalised_key:key,source:'supplier_merge',approved_by:ctx.principal.id});
const name=`${String(duplicate.name??'').trim()} (merged into ${keptName})`;
await lease.update('suppliers',args.duplicateId,{name});
const reason=`Merged into ${keptName}; no records point to it any more.`;
await lease.create('mcp_operations',deletionRequestRow(ctx.principal.id,args.operationId,{collection:'suppliers',recordId:args.duplicateId,reason,summary:name}));
return {keep:{id:args.keepId,name:keptName},duplicate:{id:args.duplicateId,name},moved:{purchase_orders:orders.length,dn_batch_notes:notes.length,supplier_document_aliases:aliases.length}};
},undefined,{scopeKey:`supplier:${args.duplicateId}`,resourceKey:`supplier-merge:${args.keepId}:${args.duplicateId}`});
}

View file

@ -16,6 +16,7 @@ import {RequestSiteDecisionSchema,AnswerSiteDecisionSchema,requestSiteDecision,a
import {MoveToPurchaseOrdersSchema,moveToPurchaseOrders} from '@/lib/purchasing/move-to-purchase-orders';
import {RequestDeletionSchema,requestDeletion} from '@/lib/purchasing/deletion-requests';
import {ReassignReceiptSchema,reassignReceipt} from '@/lib/purchasing/receipt-reassign';
import {MergeSuppliersSchema,mergeSuppliers} from '@/lib/purchasing/supplier-merge';
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)});
@ -36,6 +37,7 @@ export function registerPurchasingWrites(server:McpServer,principal:Principal):v
] as const;
for(const [name,description,schema] of orderTools)registerPurchasingTool(server,principal,{name,description,schema,permission:'write',handler:(ctx,input)=>mutatePurchaseOrder(ctx,name,input)});
registerPurchasingTool(server,principal,{name:'set_delivery_verification',description:'Explicitly verify or unverify a delivery receipt.',schema:VerifyPurchaseOrderSchema,permission:'write',handler:setDeliveryVerification});
registerPurchasingTool(server,principal,{name:'merge_suppliers',description:'Merge a duplicate supplier into the one to keep: repoints its orders, documents and aliases, renames it, and flags it for deletion.',schema:MergeSuppliersSchema,permission:'write',handler:mergeSuppliers});
registerPurchasingTool(server,principal,{name:'reassign_receipt',description:"Fix a wrong booking: move a posted receipt's lines to the right PO line, or the whole receipt to the right PO (same site and supplier), and/or correct the received quantity. Give the quantity you expect is booked now so a concurrent change is caught.",schema:ReassignReceiptSchema,permission:'write',handler:reassignReceipt});
registerPurchasingTool(server,principal,{name:'request_deletion',description:'Flag a record that should be deleted, with the reason. Agents never delete; a person reviews the flag and deletes it.',schema:RequestDeletionSchema,permission:'write',handler:requestDeletion});
registerPurchasingTool(server,principal,{name:'update_supplier_guidance',description:'Create or update supplier reconciliation guidance, preserving other rules.',schema:SupplierGuidanceSchema,permission:'write',handler:mutateSupplierGuidance});