diff --git a/src/lib/purchasing/deletion-requests.ts b/src/lib/purchasing/deletion-requests.ts index 4b35382..719188c 100644 --- a/src/lib/purchasing/deletion-requests.ts +++ b/src/lib/purchasing/deletion-requests.ts @@ -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{ + 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. * diff --git a/src/lib/purchasing/supplier-merge.test.ts b/src/lib/purchasing/supplier-merge.test.ts new file mode 100644 index 0000000..e771842 --- /dev/null +++ b/src/lib/purchasing/supplier-merge.test.ts @@ -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['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['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}); +}); \ No newline at end of file diff --git a/src/lib/purchasing/supplier-merge.ts b/src/lib/purchasing/supplier-merge.ts new file mode 100644 index 0000000..a5f74a8 --- /dev/null +++ b/src/lib/purchasing/supplier-merge.ts @@ -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):Promise{ + 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}`}); +} \ No newline at end of file diff --git a/src/mcp/purchasing-write-tools.ts b/src/mcp/purchasing-write-tools.ts index 833669b..8c3335f 100644 --- a/src/mcp/purchasing-write-tools.ts +++ b/src/mcp/purchasing-write-tools.ts @@ -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});