diff --git a/src/app/api/dn-batches/[id]/notes/[noteId]/resolve-entities/route.ts b/src/app/api/dn-batches/[id]/notes/[noteId]/resolve-entities/route.ts index 604d302..bfa0299 100644 --- a/src/app/api/dn-batches/[id]/notes/[noteId]/resolve-entities/route.ts +++ b/src/app/api/dn-batches/[id]/notes/[noteId]/resolve-entities/route.ts @@ -1,11 +1,10 @@ +import {resolveEntitiesWithinLease} from "@/lib/purchasing/delivery-resolution"; +import {PurchasingError} from "@/lib/purchasing/errors"; import { authorizeOperation } from "@/lib/auth/operation"; import { NextResponse } from "next/server"; import { pbAdmin } from "@/lib/pb"; import { processCandidateWithinLease } from "@/lib/dn-pipeline/processing"; import { withBatchLease } from "@/lib/dn-pipeline/lock"; -import type { BatchMutation } from "@/lib/dn-pipeline/batch-mutations"; -import { normaliseSupplierKey } from "@/lib/dn-pipeline/resolve-entities"; -import { evaluateCandidateReadiness } from "@/lib/dn-pipeline/readiness"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -13,8 +12,6 @@ export const maxDuration = 240; const idPattern = /^[a-z0-9]{15}$/i; const isObject = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); -const relationId = (value: unknown): string => typeof value === "string" ? value : isObject(value) && typeof value.id === "string" ? value.id : ""; -const terminal = (row: Record) => row.status === "posted" || row.status === "promoted" || row.status === "dismissed" || row.stage === "posted" || row.stage === "dismissed" || Boolean(row.posted_at) || Boolean(row.delivery_note); type Body = { supplierId: string; purchaseOrderId: string; reason: string; rememberSupplierAlias: boolean }; @@ -34,51 +31,10 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: const pb = await pbAdmin(); const leased = await withBatchLease(pb, id, "resolve-entities", async (lease) => { - const [batch, note] = await Promise.all([ - pb.collection("dn_batches").getOne(id, { fields: "id,site,status,page_count,assembly_revision,revision" }).catch(() => null), - pb.collection("dn_batch_notes").getOne(noteId, { fields: "id,batch,status,stage,posted_at,delivery_note,supplier,supplier_name_raw,purchase_order,checks,items,pages,doc_number,delivery_note_number,assembly_revision,candidate_revision" }).catch(() => null), - ]); - if (!batch) return NextResponse.json({ error: "batch_not_found" }, { status: 404 }); - if (!note) return NextResponse.json({ error: "note_not_found" }, { status: 404 }); - if (note.batch !== id) return NextResponse.json({ error: "note_not_in_batch" }, { status: 400 }); - if (terminal(note)) return NextResponse.json({ error: note.status === "dismissed" || note.stage === "dismissed" ? "dismissed_candidate" : "already_posted" }, { status: 409 }); + let resolution:Record; + try {resolution=await resolveEntitiesWithinLease(pb,id,noteId,{supplierId,purchaseOrderId,reason,rememberSupplierAlias:Boolean(body.rememberSupplierAlias)},lease,"web");} + catch(error){if(error instanceof PurchasingError)return NextResponse.json({error:error.code},{status:error.status});throw error;} - const siteId = typeof batch.site === "string" ? batch.site : relationId(batch.site); - if (!siteId) return NextResponse.json({ error: "batch_site_missing" }, { status: 400 }); - const [site, supplier, purchaseOrder] = await Promise.all([ - pb.collection("sites").getOne(siteId, { fields: "id" }).catch(() => null), - pb.collection("suppliers").getOne(supplierId, { fields: "id,name" }).catch(() => null), - pb.collection("purchase_orders").getOne(purchaseOrderId, { fields: "id,site,supplier,supplier_ref,purchase_order_number" }).catch(() => null), - ]); - if (!site) return NextResponse.json({ error: "batch_site_not_found" }, { status: 400 }); - if (!supplier) return NextResponse.json({ error: "supplier_not_found" }, { status: 400 }); - if (!purchaseOrder) return NextResponse.json({ error: "purchase_order_not_found" }, { status: 400 }); - const poSite = relationId(purchaseOrder.site); - if (!poSite || poSite !== siteId) return NextResponse.json({ error: "purchase_order_site_mismatch" }, { status: 400 }); - const rawPoSupplier = relationId(purchaseOrder.supplier); - const poSupplier = relationId(purchaseOrder.supplier_ref) || (idPattern.test(rawPoSupplier) ? rawPoSupplier : ""); - if (poSupplier && poSupplier !== supplierId) return NextResponse.json({ error: "purchase_order_supplier_mismatch" }, { status: 400 }); - if (!poSupplier && (typeof purchaseOrder.supplier !== "string" || normaliseSupplierKey(purchaseOrder.supplier) !== normaliseSupplierKey(supplier.name))) return NextResponse.json({ error: "purchase_order_supplier_mismatch" }, { status: 400 }); - - const poItems = await pb.collection("purchase_order_items").getFullList({ filter: `purchase_order = ${JSON.stringify(purchaseOrderId)}`, fields: "id,purchase_order,description,unit,material_code,qty,rate" }).catch(() => []); - const before = { supplier: note.supplier ?? "", purchase_order: note.purchase_order ?? "", stage: note.stage ?? "needs_review", status: note.status ?? "blocked" }; - const checks = Array.isArray(note.checks) ? note.checks.map((check: unknown) => isObject(check) && check.id === "po_resolved" ? { ...check, passed: true, reason: "purchase order selected by operator" } : check) : []; - const readiness = evaluateCandidateReadiness({ batch, note: { ...note, supplier: supplierId, purchase_order: purchaseOrderId }, supplierId, purchaseOrderId, purchaseOrderSiteId: poSite, purchaseOrderSupplierId: poSupplier || supplierId, poItems: (Array.isArray(poItems) ? poItems : []) as Record[], checks: checks as Record[] }); - const ready = readiness.ready; - const after = { supplier: supplierId, purchase_order: purchaseOrderId, stage: ready ? "ready_to_post" : "needs_review", status: ready ? "candidate" : "blocked", reasons: readiness.reasons }; - const mutations: BatchMutation[] = [ - { collection: "dn_batch_notes", method: "update", id: noteId, data: { supplier: supplierId, purchase_order: purchaseOrderId, checks, stage: after.stage, status: after.status } }, - { collection: "dn_review_events", method: "create", data: { batch: id, note: noteId, action: "resolve_entities", before, after, reason, actor: "web" } }, - ]; - if (body.rememberSupplierAlias && typeof note.supplier_name_raw === "string" && note.supplier_name_raw.trim()) { - const rawValue = note.supplier_name_raw.trim(); - const normalisedKey = normaliseSupplierKey(rawValue); - const existing = await pb.collection("supplier_document_aliases").getFullList({ filter: `normalised_key = ${JSON.stringify(normalisedKey)}`, fields: "id,supplier,raw_value,normalised_key,source,approved_by" }).catch(() => []); - const data = { supplier: supplierId, raw_value: rawValue, normalised_key: normalisedKey, source: "manual_correction", approved_by: "web" }; - if (existing[0]?.id) mutations.push({ collection: "supplier_document_aliases", method: "update", id: existing[0].id, data }); - else mutations.push({ collection: "supplier_document_aliases", method: "create", data }); - } - await lease.batch(mutations); if (process.env.MINIMAX_API_KEY && process.env.DN_PROCESSING_ENABLED !== "false") { try { const preview = await processCandidateWithinLease(pb, id, noteId, lease, "web"); @@ -88,7 +44,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: return NextResponse.json({ ok: true, readyToPost: false, stage: "needs_review", status: "blocked", processingError: error instanceof Error ? error.message : "Processing failed; try Process again." }); } } - return NextResponse.json({ ok: true, stage: after.stage, status: after.status, readyToPost: ready, reasons: readiness.reasons }); + return NextResponse.json(resolution); }); if (!leased.acquired) return NextResponse.json({ error: "batch_locked" }, { status: 409 }); return leased.value; diff --git a/src/app/api/dn-batches/[id]/notes/[noteId]/resolve-item/route.ts b/src/app/api/dn-batches/[id]/notes/[noteId]/resolve-item/route.ts index d5dd445..9bee9e2 100644 --- a/src/app/api/dn-batches/[id]/notes/[noteId]/resolve-item/route.ts +++ b/src/app/api/dn-batches/[id]/notes/[noteId]/resolve-item/route.ts @@ -1,20 +1,16 @@ +import {resolveItemWithinLease} from "@/lib/purchasing/delivery-resolution"; +import {PurchasingError} from "@/lib/purchasing/errors"; import { authorizeOperation } from "@/lib/auth/operation"; import { NextResponse } from "next/server"; import { pbAdmin } from "@/lib/pb"; import { processCandidateWithinLease } from "@/lib/dn-pipeline/processing"; import { withBatchLease } from "@/lib/dn-pipeline/lock"; -import type { BatchMutation } from "@/lib/dn-pipeline/batch-mutations"; -import { aliasKey } from "@/lib/dn-pipeline/aliases"; -import { norm } from "@/lib/dn-pipeline/match-po-item"; -import { evaluateCandidateReadiness } from "@/lib/dn-pipeline/readiness"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; export const maxDuration = 240; const idPattern = /^[a-z0-9]{15}$/i; const object = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); -const relationId = (value: unknown): string => typeof value === "string" ? value : object(value) && typeof value.id === "string" ? value.id : ""; -const terminal = (row: Record) => row.status === "posted" || row.status === "promoted" || row.status === "dismissed" || row.stage === "posted" || row.stage === "dismissed" || Boolean(row.posted_at) || Boolean(row.delivery_note); export async function POST(request: Request, { params }: { params: Promise<{ id: string; noteId: string }> }) { const operationDenied = await authorizeOperation(request); @@ -29,70 +25,10 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: if (!Number.isInteger(lineIndex) || (lineIndex as number) < 0 || !idPattern.test(String(purchaseOrderItemId ?? "")) || typeof body.reason !== "string" || !String(body.reason).trim() || typeof body.rememberAlias !== "boolean") return NextResponse.json({ error: "invalid_item_resolution" }, { status: 400 }); const pb = await pbAdmin(); const leased = await withBatchLease(pb, id, "resolve-item", async (lease) => { - const [batch, note] = await Promise.all([ - pb.collection("dn_batches").getOne(id, { fields: "id,site,page_count,assembly_revision,revision" }).catch(() => null), - pb.collection("dn_batch_notes").getOne(noteId, { fields: "id,batch,status,stage,posted_at,delivery_note,purchase_order,supplier,items,checks,pages,doc_number,delivery_note_number,assembly_revision,candidate_revision,manual_overrides" }).catch(() => null), - ]); - if (!batch) return NextResponse.json({ error: "batch_not_found" }, { status: 404 }); - if (!note) return NextResponse.json({ error: "note_not_found" }, { status: 404 }); - if (note.batch !== id) return NextResponse.json({ error: "note_not_in_batch" }, { status: 400 }); - if (terminal(note)) return NextResponse.json({ error: note.status === "dismissed" || note.stage === "dismissed" ? "dismissed_candidate" : "already_posted" }, { status: 409 }); - const items = Array.isArray(note.items) ? note.items.map((item: unknown) => object(item) ? { ...item } : item) : []; - if ((lineIndex as number) >= items.length || !object(items[lineIndex as number])) return NextResponse.json({ error: "line_index_out_of_range" }, { status: 400 }); - const candidatePoId = typeof note.purchase_order === "string" ? note.purchase_order : relationId(note.purchase_order); - if (!candidatePoId) return NextResponse.json({ error: "candidate_po_missing" }, { status: 400 }); - const [supplier, purchaseOrder, poItem, allPoItems] = await Promise.all([ - note.supplier ? pb.collection("suppliers").getOne(String(note.supplier), { fields: "id,name" }).catch(() => null) : Promise.resolve(null), - pb.collection("purchase_orders").getOne(candidatePoId, { fields: "id,site,supplier,supplier_ref" }).catch(() => null), - pb.collection("purchase_order_items").getOne(String(purchaseOrderItemId), { fields: "id,purchase_order,description,material_code,unit,qty,rate" }).catch(() => null), - Promise.resolve(pb.collection("purchase_order_items").getFullList({ filter: `purchase_order = ${JSON.stringify(candidatePoId)}`, fields: "id,purchase_order,description,unit,material_code,qty,rate" })).catch(() => []), - ]); - if (note.supplier && !supplier) return NextResponse.json({ error: "supplier_not_found" }, { status: 400 }); - if (!purchaseOrder) return NextResponse.json({ error: "purchase_order_not_found" }, { status: 404 }); - const siteId = relationId(batch.site); - const poSite = relationId(purchaseOrder.site); - if (!siteId || !poSite || poSite !== siteId) return NextResponse.json({ error: "purchase_order_site_mismatch" }, { status: 400 }); - const poSupplier = relationId(purchaseOrder.supplier_ref) || (idPattern.test(relationId(purchaseOrder.supplier)) ? relationId(purchaseOrder.supplier) : ""); - if (!note.supplier || !supplier || !poSupplier || poSupplier !== note.supplier) return NextResponse.json({ error: "purchase_order_supplier_mismatch" }, { status: 400 }); - if (!poItem) return NextResponse.json({ error: "po_item_not_found" }, { status: 404 }); - if (relationId(poItem.purchase_order) !== candidatePoId) return NextResponse.json({ error: "po_item_not_in_candidate_po" }, { status: 400 }); - const before = { lineIndex, purchase_order_item: (items[lineIndex as number] as Record).purchase_order_item ?? null }; - const replacement = { ...(items[lineIndex as number] as Record), purchase_order_item: String(purchaseOrderItemId) }; - items[lineIndex as number] = replacement; - const checks = Array.isArray(note.checks) ? note.checks : []; - const readiness = evaluateCandidateReadiness({ batch, note: { ...note, items }, supplierId: String(note.supplier), purchaseOrderId: candidatePoId, purchaseOrderSiteId: poSite, purchaseOrderSupplierId: poSupplier, poItems: (Array.isArray(allPoItems) && allPoItems.length > 0 ? allPoItems : [poItem]) as Record[], checks: checks as Record[] }); - const ready = readiness.ready; - const after = { lineIndex, purchase_order_item: String(purchaseOrderItemId), stage: ready ? "ready_to_post" : "needs_review", status: ready ? "candidate" : "blocked", reasons: readiness.reasons }; - const reason = String(body.reason).trim(); - // Record the link as a decision, not just a value. Every run rebuilds - // `items` from the page reads, and only an aliased choice can be - // re-derived from the descriptions — so without this, a link made - // without ticking "remember this alias" is gone on the next run. - const priorOverrides = object(note.manual_overrides) ? note.manual_overrides as Record : {}; - const priorItems = object(priorOverrides.items) ? priorOverrides.items as Record : {}; - const manual_overrides = { - ...priorOverrides, - items: { ...priorItems, [String(lineIndex)]: String(purchaseOrderItemId) }, - }; + let resolution:Record; + try {resolution=await resolveItemWithinLease(pb,id,noteId,{lineIndex:lineIndex as number,purchaseOrderItemId:String(purchaseOrderItemId),reason:String(body.reason),rememberAlias:Boolean(body.rememberAlias)},lease,"web");} + catch(error){if(error instanceof PurchasingError)return NextResponse.json({error:error.code},{status:error.status});throw error;} - const mutations: BatchMutation[] = [ - { collection: "dn_batch_notes", method: "update", id: noteId, data: { items, stage: after.stage, status: after.status, manual_overrides } }, - { collection: "dn_review_events", method: "create", data: { batch: id, note: noteId, action: "resolve_item", before, after, reason, actor: "web" } }, - ]; - if (body.rememberAlias) { - const line = items[lineIndex as number] as Record; - const description = typeof line.description === "string" ? line.description.trim() : ""; - if (!supplier || !String(note.supplier).trim()) return NextResponse.json({ error: "supplier_unresolved" }, { status: 400 }); - if (!description) return NextResponse.json({ error: "item_description_missing" }, { status: 400 }); - const supplierKey = norm(String(supplier?.name ?? note.supplier ?? "")); - const key = aliasKey(description); - if (!key) return NextResponse.json({ error: "item_alias_key_missing" }, { status: 400 }); - const existing = await pb.collection("dn_item_aliases").getFullList({ filter: `slip_description_key=${JSON.stringify(key)} && supplier_key=${JSON.stringify(supplierKey)}`, fields: "id,times_used" }).catch(() => []); - const data = { supplier_key: supplierKey, slip_description_raw: description, slip_description_key: key, material_code: String(poItem.material_code ?? ""), po_description: String(poItem.description ?? ""), po_description_key: aliasKey(String(poItem.description ?? "")), created_by: "web", last_used: new Date().toISOString() }; - if (existing[0]?.id) mutations.push({ collection: "dn_item_aliases", method: "update", id: existing[0].id, data: { ...data, times_used: Number(existing[0].times_used ?? 0) + 1 } }); - else mutations.push({ collection: "dn_item_aliases", method: "create", data: { ...data, times_used: 1 } }); - } - await lease.batch(mutations); if (process.env.MINIMAX_API_KEY && process.env.DN_PROCESSING_ENABLED !== "false") { try { const preview = await processCandidateWithinLease(pb, id, noteId, lease, "web"); @@ -102,7 +38,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: return NextResponse.json({ ok: true, readyToPost: false, stage: "needs_review", status: "blocked", processingError: error instanceof Error ? error.message : "Processing failed; try Process again." }); } } - return NextResponse.json({ ok: true, stage: after.stage, status: after.status, readyToPost: ready, reasons: readiness.reasons, lineIndex, purchaseOrderItemId: String(purchaseOrderItemId) }); + return NextResponse.json(resolution); }); if (!leased.acquired) return NextResponse.json({ error: "batch_locked" }, { status: 409 }); return leased.value; diff --git a/src/lib/purchasing/delivery-matching.test.ts b/src/lib/purchasing/delivery-matching.test.ts new file mode 100644 index 0000000..0e62480 --- /dev/null +++ b/src/lib/purchasing/delivery-matching.test.ts @@ -0,0 +1,14 @@ +import {expect,it} from 'vitest'; +import {memoryDatabase} from './test-support'; +import {resolveDelivery} from './delivery-matching'; +it('rejects cross-site entity resolution and leaves receipts untouched',async()=>{ + const {pb,store}=memoryDatabase(); + await pb.collection('sites').create({id:'abcdefghijklmn1'}); + await pb.collection('dn_batches').create({id:'abcdefghijklmn2',site:'abcdefghijklmn1'}); + await pb.collection('dn_batch_notes').create({id:'abcdefghijklmn3',batch:'abcdefghijklmn2',status:'candidate'}); + await pb.collection('suppliers').create({id:'abcdefghijklmn4',name:'Supplier'}); + await pb.collection('purchase_orders').create({id:'abcdefghijklmn5',site:'other',supplier_ref:'abcdefghijklmn4'}); + const ctx={pb,principal:{id:'agent',permissions:new Set<'read'|'write'>(['read','write'])}}; + await expect(resolveDelivery(ctx,'resolve_delivery_entities',{operationId:'83f33948-63c7-4a02-a45a-9ab18d3bdc02',batchId:'abcdefghijklmn2',id:'abcdefghijklmn3',supplierId:'abcdefghijklmn4',purchaseOrderId:'abcdefghijklmn5',reason:'operator verified',rememberSupplierAlias:false})).rejects.toThrow('purchase_order_site_mismatch'); + expect(store('delivery_notes').size).toBe(0); +}); diff --git a/src/lib/purchasing/delivery-matching.ts b/src/lib/purchasing/delivery-matching.ts new file mode 100644 index 0000000..4eebfcb --- /dev/null +++ b/src/lib/purchasing/delivery-matching.ts @@ -0,0 +1,22 @@ +import {z} from 'zod'; +import type {PurchasingContext,OperationResult} from './types'; +import {MutationSchema,runOperation} from './operations'; +import {RecordId} from './documents'; +import {resolveEntitiesWithinLease,resolveItemWithinLease} from './delivery-resolution'; +import {processCandidateWithinLease} from '@/lib/dn-pipeline/processing'; +import {PurchasingError} from './errors'; +export const CandidateOperationSchema=MutationSchema.extend({batchId:RecordId,id:RecordId}).strict(); +export const EntityResolutionSchema=CandidateOperationSchema.extend({supplierId:RecordId,purchaseOrderId:RecordId,reason:z.string().trim().min(1).max(2000),rememberSupplierAlias:z.boolean().default(false)}).strict(); +export const ItemResolutionSchema=CandidateOperationSchema.extend({lineIndex:z.number().int().min(0).max(199),purchaseOrderItemId:RecordId,reason:z.string().trim().min(1).max(2000),rememberAlias:z.boolean().default(false)}).strict(); +export async function resolveDelivery(ctx:PurchasingContext,action:'resolve_delivery_entities'|'resolve_delivery_item',input:Record):Promise{ + const args=(action==='resolve_delivery_entities'?EntityResolutionSchema:ItemResolutionSchema).parse(input); + return runOperation(ctx,action,args,lease=>action==='resolve_delivery_entities'?resolveEntitiesWithinLease(ctx.pb,args.batchId,args.id,EntityResolutionSchema.parse(args),lease,ctx.principal.id):resolveItemWithinLease(ctx.pb,args.batchId,args.id,ItemResolutionSchema.parse(args),lease,ctx.principal.id),`batch:${args.batchId}`); +} +export async function processDeliveryCandidate(ctx:PurchasingContext,input:Record):Promise{ + const args=CandidateOperationSchema.parse(input); + if(!process.env.MINIMAX_API_KEY||process.env.DN_PROCESSING_ENABLED==='false')throw new PurchasingError('processing_setup_required',503); + return runOperation(ctx,'process_delivery_candidate',args,async lease=>{ + const preview=await processCandidateWithinLease(ctx.pb,args.batchId,args.id,lease,ctx.principal.id); + return {id:args.id,preview}; + },`batch:${args.batchId}`); +} diff --git a/src/lib/purchasing/delivery-receipts.test.ts b/src/lib/purchasing/delivery-receipts.test.ts new file mode 100644 index 0000000..8d3c0d0 --- /dev/null +++ b/src/lib/purchasing/delivery-receipts.test.ts @@ -0,0 +1,11 @@ +import {expect,it} from 'vitest'; +import {McpPostDeliveryLineSchema,correctDeliveryReceipt} from './delivery-receipts'; +import {memoryDatabase} from './test-support'; +it('requires explicit posting confirmation and denies actor impersonation',()=>{ + expect(()=>McpPostDeliveryLineSchema.parse({operationId:'83f33948-63c7-4a02-a45a-9ab18d3bdc02',batchId:'abcdefghijklmn1',id:'abcdefghijklmn2',index:0,sourceLineIds:['source'],snapshotHash:'hash'})).toThrow(); +}); +it('refuses corrections to immutable per-line receipts',async()=>{ + const {pb}=memoryDatabase();await pb.collection('dn_batches').create({id:'abcdefghijklmn1'});await pb.collection('dn_batch_notes').create({id:'abcdefghijklmn2',batch:'abcdefghijklmn1',status:'promoted',delivery_note:'abcdefghijklmn3',line_posting:{version:1}}); + const ctx={pb,principal:{id:'agent',permissions:new Set<'read'|'write'>(['read','write'])}}; + await expect(correctDeliveryReceipt(ctx,{operationId:'83f33948-63c7-4a02-a45a-9ab18d3bdc02',batchId:'abcdefghijklmn1',id:'abcdefghijklmn2',reason:'correct saved quantity',lines:[{id:'abcdefghijklmn4',expectedQtyReceived:1,qtyReceived:2}]})).rejects.toThrow('immutable_line_receipt'); +}); diff --git a/src/lib/purchasing/delivery-receipts.ts b/src/lib/purchasing/delivery-receipts.ts new file mode 100644 index 0000000..98ec53a --- /dev/null +++ b/src/lib/purchasing/delivery-receipts.ts @@ -0,0 +1,25 @@ +import {z} from 'zod'; +import type {PurchasingContext,OperationResult} from './types'; +import {runOperation} from './operations'; +import {RecordId} from './documents'; +import {CandidateOperationSchema} from './delivery-matching'; +import {LineConfirmationSchema} from '@/lib/dn-pipeline/line-booking'; +import {postLineWithinLease} from '@/lib/dn-pipeline/post-line'; +import {correctReceiptWithinLease} from './receipt-correction'; +import {PurchasingError} from './errors'; +export const McpPostDeliveryLineSchema=CandidateOperationSchema.extend({index:z.number().int().min(0).max(199),sourceLineIds:z.array(z.string().min(1).max(200)).min(1).max(200),snapshotHash:z.string().min(1).max(200),confirmation:LineConfirmationSchema.strict()}).strict(); +export const ReceiptCorrectionSchema=CandidateOperationSchema.extend({reason:z.string().trim().min(8).max(500),lines:z.array(z.object({id:RecordId,expectedQtyReceived:z.number().finite().min(0),qtyReceived:z.number().finite().min(0).max(1e9)}).strict()).min(1).max(45)}).strict(); +export async function postDeliveryLine(ctx:PurchasingContext,input:Record):Promise{ + const args=McpPostDeliveryLineSchema.parse(input); + const note=await ctx.pb.collection('dn_batch_notes').getOne(args.id,{fields:'id,batch,purchase_order,supplier'}); + if(note.batch!==args.batchId)throw new PurchasingError('candidate_batch_mismatch'); + if(!note.purchase_order||!note.supplier)throw new PurchasingError('candidate_entities_unresolved'); + return runOperation(ctx,'post_delivery_line',args,async lease=>{ + return {...await postLineWithinLease(ctx.pb,{batchId:args.batchId,noteId:args.id,poId:note.purchase_order,actor:ctx.principal.id,body:args},lease)}; + },`batch:${args.batchId}`,{scopeKey:`supplier:${note.supplier}`}); +} +export async function correctDeliveryReceipt(ctx:PurchasingContext,input:Record):Promise{ + const args=ReceiptCorrectionSchema.parse(input); + if(new Set(args.lines.map(l=>l.id)).size!==args.lines.length)throw new PurchasingError('duplicate_correction_line'); + return runOperation(ctx,'correct_delivery_receipt',args,lease=>correctReceiptWithinLease(ctx.pb,args.batchId,args.id,{...args,actor:ctx.principal.id},lease),`batch:${args.batchId}`); +} diff --git a/src/lib/purchasing/delivery-resolution.ts b/src/lib/purchasing/delivery-resolution.ts new file mode 100644 index 0000000..67db341 --- /dev/null +++ b/src/lib/purchasing/delivery-resolution.ts @@ -0,0 +1,134 @@ +import type PocketBase from 'pocketbase'; +import type {BatchLeaseContext} from '@/lib/dn-pipeline/lock'; +import type {BatchMutation} from '@/lib/dn-pipeline/batch-mutations'; +import {normaliseSupplierKey} from '@/lib/dn-pipeline/resolve-entities'; +import {evaluateCandidateReadiness} from '@/lib/dn-pipeline/readiness'; +import {aliasKey} from '@/lib/dn-pipeline/aliases'; +import {norm} from '@/lib/dn-pipeline/match-po-item'; +import {PurchasingError} from './errors'; +const idPattern=/^[a-z0-9]{15}$/i; +const object=(value:unknown):value is Record=>typeof value==='object'&&value!==null&&!Array.isArray(value); +const isObject=object; +const relationId=(value:unknown):string=>typeof value==='string'?value:object(value)&&typeof value.id==='string'?value.id:''; +const terminal=(row:Record)=>['posted','promoted','dismissed'].includes(String(row.status))||['posted','dismissed'].includes(String(row.stage))||Boolean(row.posted_at)||Boolean(row.delivery_note); +export type EntityResolutionBody={supplierId:string;purchaseOrderId:string;reason:string;rememberSupplierAlias:boolean}; +export type ItemResolutionBody={lineIndex:number;purchaseOrderItemId:string;reason:string;rememberAlias:boolean}; + +export async function resolveEntitiesWithinLease(pb:PocketBase,id:string,noteId:string,body:EntityResolutionBody,lease:BatchLeaseContext,actor:string):Promise>{ + const supplierId=body.supplierId,purchaseOrderId=body.purchaseOrderId,reason=body.reason.trim(); + const [batch, note] = await Promise.all([ + pb.collection("dn_batches").getOne(id, { fields: "id,site,status,page_count,assembly_revision,revision" }).catch(() => null), + pb.collection("dn_batch_notes").getOne(noteId, { fields: "id,batch,status,stage,posted_at,delivery_note,supplier,supplier_name_raw,purchase_order,checks,items,pages,doc_number,delivery_note_number,assembly_revision,candidate_revision" }).catch(() => null), + ]); + if (!batch) throw new PurchasingError("batch_not_found",404); + if (!note) throw new PurchasingError("note_not_found",404); + if (note.batch !== id) throw new PurchasingError("note_not_in_batch",400); + if (terminal(note)) throw new PurchasingError(note.status === "dismissed" || note.stage === "dismissed" ? "dismissed_candidate" : "already_posted",409); + + const siteId = typeof batch.site === "string" ? batch.site : relationId(batch.site); + if (!siteId) throw new PurchasingError("batch_site_missing",400); + const [site, supplier, purchaseOrder] = await Promise.all([ + pb.collection("sites").getOne(siteId, { fields: "id" }).catch(() => null), + pb.collection("suppliers").getOne(supplierId, { fields: "id,name" }).catch(() => null), + pb.collection("purchase_orders").getOne(purchaseOrderId, { fields: "id,site,supplier,supplier_ref,purchase_order_number" }).catch(() => null), + ]); + if (!site) throw new PurchasingError("batch_site_not_found",400); + if (!supplier) throw new PurchasingError("supplier_not_found",400); + if (!purchaseOrder) throw new PurchasingError("purchase_order_not_found",400); + const poSite = relationId(purchaseOrder.site); + if (!poSite || poSite !== siteId) throw new PurchasingError("purchase_order_site_mismatch",400); + const rawPoSupplier = relationId(purchaseOrder.supplier); + const poSupplier = relationId(purchaseOrder.supplier_ref) || (idPattern.test(rawPoSupplier) ? rawPoSupplier : ""); + if (poSupplier && poSupplier !== supplierId) throw new PurchasingError("purchase_order_supplier_mismatch",400); + if (!poSupplier && (typeof purchaseOrder.supplier !== "string" || normaliseSupplierKey(purchaseOrder.supplier) !== normaliseSupplierKey(supplier.name))) throw new PurchasingError("purchase_order_supplier_mismatch",400); + + const poItems = await pb.collection("purchase_order_items").getFullList({ filter: `purchase_order = ${JSON.stringify(purchaseOrderId)}`, fields: "id,purchase_order,description,unit,material_code,qty,rate" }).catch(() => []); + const before = { supplier: note.supplier ?? "", purchase_order: note.purchase_order ?? "", stage: note.stage ?? "needs_review", status: note.status ?? "blocked" }; + const checks = Array.isArray(note.checks) ? note.checks.map((check: unknown) => isObject(check) && check.id === "po_resolved" ? { ...check, passed: true, reason: "purchase order selected by operator" } : check) : []; + const readiness = evaluateCandidateReadiness({ batch, note: { ...note, supplier: supplierId, purchase_order: purchaseOrderId }, supplierId, purchaseOrderId, purchaseOrderSiteId: poSite, purchaseOrderSupplierId: poSupplier || supplierId, poItems: (Array.isArray(poItems) ? poItems : []) as Record[], checks: checks as Record[] }); + const ready = readiness.ready; + const after = { supplier: supplierId, purchase_order: purchaseOrderId, stage: ready ? "ready_to_post" : "needs_review", status: ready ? "candidate" : "blocked", reasons: readiness.reasons }; + const mutations: BatchMutation[] = [ + { collection: "dn_batch_notes", method: "update", id: noteId, data: { supplier: supplierId, purchase_order: purchaseOrderId, checks, reconciliation_preview:null, stage: after.stage, status: after.status } }, + { collection: "dn_review_events", method: "create", data: { batch: id, note: noteId, action: "resolve_entities", before, after, reason, actor } }, + ]; + if (body.rememberSupplierAlias && typeof note.supplier_name_raw === "string" && note.supplier_name_raw.trim()) { + const rawValue = note.supplier_name_raw.trim(); + const normalisedKey = normaliseSupplierKey(rawValue); + const existing = await pb.collection("supplier_document_aliases").getFullList({ filter: `normalised_key = ${JSON.stringify(normalisedKey)}`, fields: "id,supplier,raw_value,normalised_key,source,approved_by" }).catch(() => []); + const data = { supplier: supplierId, raw_value: rawValue, normalised_key: normalisedKey, source: "manual_correction", approved_by:actor }; + if (existing[0]?.id) mutations.push({ collection: "supplier_document_aliases", method: "update", id: existing[0].id, data }); + else mutations.push({ collection: "supplier_document_aliases", method: "create", data }); + } + await lease.batch(mutations); + return {ok:true,stage:after.stage,status:after.status,readyToPost:ready,reasons:readiness.reasons}; +} + +export async function resolveItemWithinLease(pb:PocketBase,id:string,noteId:string,body:ItemResolutionBody,lease:BatchLeaseContext,actor:string):Promise>{ + const lineIndex=body.lineIndex,purchaseOrderItemId=body.purchaseOrderItemId; + const [batch, note] = await Promise.all([ + pb.collection("dn_batches").getOne(id, { fields: "id,site,page_count,assembly_revision,revision" }).catch(() => null), + pb.collection("dn_batch_notes").getOne(noteId, { fields: "id,batch,status,stage,posted_at,delivery_note,purchase_order,supplier,items,checks,pages,doc_number,delivery_note_number,assembly_revision,candidate_revision,manual_overrides" }).catch(() => null), + ]); + if (!batch) throw new PurchasingError("batch_not_found",404); + if (!note) throw new PurchasingError("note_not_found",404); + if (note.batch !== id) throw new PurchasingError("note_not_in_batch",400); + if (terminal(note)) throw new PurchasingError(note.status === "dismissed" || note.stage === "dismissed" ? "dismissed_candidate" : "already_posted",409); + const items = Array.isArray(note.items) ? note.items.map((item: unknown) => object(item) ? { ...item } : item) : []; + if ((lineIndex as number) >= items.length || !object(items[lineIndex as number])) throw new PurchasingError("line_index_out_of_range",400); + const candidatePoId = typeof note.purchase_order === "string" ? note.purchase_order : relationId(note.purchase_order); + if (!candidatePoId) throw new PurchasingError("candidate_po_missing",400); + const [supplier, purchaseOrder, poItem, allPoItems] = await Promise.all([ + note.supplier ? pb.collection("suppliers").getOne(String(note.supplier), { fields: "id,name" }).catch(() => null) : Promise.resolve(null), + pb.collection("purchase_orders").getOne(candidatePoId, { fields: "id,site,supplier,supplier_ref" }).catch(() => null), + pb.collection("purchase_order_items").getOne(String(purchaseOrderItemId), { fields: "id,purchase_order,description,material_code,unit,qty,rate" }).catch(() => null), + Promise.resolve(pb.collection("purchase_order_items").getFullList({ filter: `purchase_order = ${JSON.stringify(candidatePoId)}`, fields: "id,purchase_order,description,unit,material_code,qty,rate" })).catch(() => []), + ]); + if (note.supplier && !supplier) throw new PurchasingError("supplier_not_found",400); + if (!purchaseOrder) throw new PurchasingError("purchase_order_not_found",404); + const siteId = relationId(batch.site); + const poSite = relationId(purchaseOrder.site); + if (!siteId || !poSite || poSite !== siteId) throw new PurchasingError("purchase_order_site_mismatch",400); + const poSupplier = relationId(purchaseOrder.supplier_ref) || (idPattern.test(relationId(purchaseOrder.supplier)) ? relationId(purchaseOrder.supplier) : ""); + if (!note.supplier || !supplier || !poSupplier || poSupplier !== note.supplier) throw new PurchasingError("purchase_order_supplier_mismatch",400); + if (!poItem) throw new PurchasingError("po_item_not_found",404); + if (relationId(poItem.purchase_order) !== candidatePoId) throw new PurchasingError("po_item_not_in_candidate_po",400); + const before = { lineIndex, purchase_order_item: (items[lineIndex as number] as Record).purchase_order_item ?? null }; + const replacement = { ...(items[lineIndex as number] as Record), purchase_order_item: String(purchaseOrderItemId) }; + items[lineIndex as number] = replacement; + const checks = Array.isArray(note.checks) ? note.checks : []; + const readiness = evaluateCandidateReadiness({ batch, note: { ...note, items }, supplierId: String(note.supplier), purchaseOrderId: candidatePoId, purchaseOrderSiteId: poSite, purchaseOrderSupplierId: poSupplier, poItems: (Array.isArray(allPoItems) && allPoItems.length > 0 ? allPoItems : [poItem]) as Record[], checks: checks as Record[] }); + const ready = readiness.ready; + const after = { lineIndex, purchase_order_item: String(purchaseOrderItemId), stage: ready ? "ready_to_post" : "needs_review", status: ready ? "candidate" : "blocked", reasons: readiness.reasons }; + const reason = String(body.reason).trim(); + // Record the link as a decision, not just a value. Every run rebuilds + // `items` from the page reads, and only an aliased choice can be + // re-derived from the descriptions — so without this, a link made + // without ticking "remember this alias" is gone on the next run. + const priorOverrides = object(note.manual_overrides) ? note.manual_overrides as Record : {}; + const priorItems = object(priorOverrides.items) ? priorOverrides.items as Record : {}; + const manual_overrides = { + ...priorOverrides, + items: { ...priorItems, [String(lineIndex)]: String(purchaseOrderItemId) }, + }; + + const mutations: BatchMutation[] = [ + { collection: "dn_batch_notes", method: "update", id: noteId, data: { items, reconciliation_preview:null, stage: after.stage, status: after.status, manual_overrides } }, + { collection: "dn_review_events", method: "create", data: { batch: id, note: noteId, action: "resolve_item", before, after, reason, actor } }, + ]; + if (body.rememberAlias) { + const line = items[lineIndex as number] as Record; + const description = typeof line.description === "string" ? line.description.trim() : ""; + if (!supplier || !String(note.supplier).trim()) throw new PurchasingError("supplier_unresolved",400); + if (!description) throw new PurchasingError("item_description_missing",400); + const supplierKey = norm(String(supplier?.name ?? note.supplier ?? "")); + const key = aliasKey(description); + if (!key) throw new PurchasingError("item_alias_key_missing",400); + const existing = await pb.collection("dn_item_aliases").getFullList({ filter: `slip_description_key=${JSON.stringify(key)} && supplier_key=${JSON.stringify(supplierKey)}`, fields: "id,times_used" }).catch(() => []); + const data = { supplier_key: supplierKey, slip_description_raw: description, slip_description_key: key, material_code: String(poItem.material_code ?? ""), po_description: String(poItem.description ?? ""), po_description_key: aliasKey(String(poItem.description ?? "")), created_by:actor, last_used: new Date().toISOString() }; + if (existing[0]?.id) mutations.push({ collection: "dn_item_aliases", method: "update", id: existing[0].id, data: { ...data, times_used: Number(existing[0].times_used ?? 0) + 1 } }); + else mutations.push({ collection: "dn_item_aliases", method: "create", data: { ...data, times_used: 1 } }); + } + await lease.batch(mutations); + return {ok:true,stage:after.stage,status:after.status,readyToPost:ready,reasons:readiness.reasons,lineIndex,purchaseOrderItemId:String(purchaseOrderItemId)}; +} diff --git a/src/lib/purchasing/operations.ts b/src/lib/purchasing/operations.ts index 49ccdd7..60f5d4e 100644 --- a/src/lib/purchasing/operations.ts +++ b/src/lib/purchasing/operations.ts @@ -16,7 +16,7 @@ export async function getOperation(ctx:PurchasingContext,operationId:string):Pro if(rows.length!==1)throw new PurchasingError(rows.length?'operation_id_ambiguous':'operation_not_found',404); return {operationId,status:rows[0].status,result:rows[0].result??null}; } -export async function runOperation(ctx:PurchasingContext,tool:string,input:MutationInput & Record,commit:(lease:BatchLeaseContext)=>Promise>,resourceKey?:string):Promise{ +export async function runOperation(ctx:PurchasingContext,tool:string,input:MutationInput & Record,commit:(lease:BatchLeaseContext)=>Promise>,resourceKey?:string,options?:{scopeKey?:string}):Promise{ MutationSchema.parse(input); const id=operationRecordId(ctx.principal.id,tool,input.operationId),payloadHash=operationPayloadHash(tool,input); const acquire=resourceKey?.startsWith('batch:')?withBatchLease:withResourceLease; @@ -54,7 +54,7 @@ export async function runOperation(ctx:PurchasingContext,tool:string,input:Mutat await lease.update('mcp_operations',id,{status:pending.length?'uncertain':'failed',error_code:error instanceof PurchasingError?error.code:'operation_failed'}).catch(()=>undefined); throw error; } - }); + },options); if(!result.acquired)throw new PurchasingError('resource_locked',409); return result.value; } diff --git a/src/lib/purchasing/receipt-correction.ts b/src/lib/purchasing/receipt-correction.ts new file mode 100644 index 0000000..ca72895 --- /dev/null +++ b/src/lib/purchasing/receipt-correction.ts @@ -0,0 +1,69 @@ +import type PocketBase from 'pocketbase'; +import type {BatchLeaseContext} from '@/lib/dn-pipeline/lock'; +import type {BatchMutation} from '@/lib/dn-pipeline/batch-mutations'; +import {PurchasingError} from './errors'; +const ID=/^[a-z0-9]{15}$/i; +const isObject=(value:unknown):value is Record=>typeof value==='object'&&value!==null&&!Array.isArray(value); +export type CorrectionBody={actor:string;reason:string;operationId:string;lines:Array<{id:string;expectedQtyReceived:number;qtyReceived:number}>}; +function sameRetry(event: Record, body:CorrectionBody): boolean { + if (event.actor !== body.actor || event.reason !== body.reason || !isObject(event.before) || !isObject(event.after)) return false; + const before = event.before.lines; + const after = event.after.lines; + if (!Array.isArray(before) || !Array.isArray(after) || before.length !== body.lines.length || after.length !== body.lines.length) return false; + return body.lines.every((line, index) => isObject(before[index]) && isObject(after[index]) && + before[index].id === line.id && after[index].id === line.id && + Number(before[index].qty_received) === line.expectedQtyReceived && Number(after[index].qty_received) === line.qtyReceived); +} + + +export async function correctReceiptWithinLease(pb:PocketBase,batchId:string,noteId:string,body:CorrectionBody,lease:BatchLeaseContext):Promise>{ + const eventKey = `receipt-correction:${body.actor}:${body.operationId}`; + const earlier = await pb.collection("dn_review_events").getFullList({ filter: pb.filter("event_key = {:key}", { key: eventKey }), fields: "id,note,action,actor,reason,before,after" }); + if (earlier.length) { + if (earlier[0].note !== noteId || earlier[0].action !== "correct_receipt" || !sameRetry(earlier[0], body)) throw new PurchasingError("operation_id_reused",409); + return {ok:true,alreadyApplied:true,after:earlier[0].after}; + } + + const [batch, candidate] = await Promise.all([ + pb.collection("dn_batches").getOne(batchId).catch(() => null), + pb.collection("dn_batch_notes").getOne(noteId).catch(() => null), + ]); + if (!batch || !candidate) throw new PurchasingError("not_found",404); + if (candidate.batch !== batchId) throw new PurchasingError("note_not_in_batch",400); + if ((candidate.status !== "promoted" && candidate.stage !== "posted") || !ID.test(String(candidate.delivery_note ?? ""))) throw new PurchasingError("note_not_posted",409); + if (candidate.line_posting) throw new PurchasingError("immutable_line_receipt",409); + const deliveryId = String(candidate.delivery_note); + const delivery = await pb.collection("delivery_notes").getOne(deliveryId).catch(() => null); + if (!delivery || delivery.batch_note !== noteId || delivery.batch !== batchId || delivery.purchase_order !== candidate.purchase_order || delivery.site !== batch.site) throw new PurchasingError("delivery_link_mismatch",409); + const po = await pb.collection("purchase_orders").getOne(String(delivery.purchase_order)).catch(() => null); + if (!po || po.site !== batch.site) throw new PurchasingError("purchase_order_site_mismatch",409); + + const rows = await pb.collection("delivery_note_items").getFullList({ filter: pb.filter("delivery_note = {:delivery}", { delivery: deliveryId }), fields: "id,delivery_note,purchase_order_item,qty_raw,unit_raw,qty_received,unit_converted,condition_notes" }); + const rowById = new Map(rows.map((row) => [row.id, row])); + const beforeLines: Record[] = []; + const afterLines: Record[] = []; + const mutations: BatchMutation[] = []; + + for (const edit of body.lines) { + const row = rowById.get(edit.id); + if (!row) throw new PurchasingError("line_not_on_delivery",400); + if (!ID.test(String(row.purchase_order_item ?? ""))) throw new PurchasingError("line_not_matched",409); + const poItem = await pb.collection("purchase_order_items").getOne(String(row.purchase_order_item)).catch(() => null); + if (!poItem || poItem.purchase_order !== delivery.purchase_order) throw new PurchasingError("po_line_not_on_order",400); + const oldReceived = Number(row.qty_received); + const rawQty = Number(row.qty_raw); + if (!Number.isFinite(oldReceived) || !Number.isFinite(rawQty) || Math.abs(oldReceived - edit.expectedQtyReceived) > 0.0001) throw new PurchasingError("receipt_changed_reload",409); + const corrected = edit.qtyReceived; + const before = { id: row.id, purchase_order_item: row.purchase_order_item, qty_raw: rawQty, unit_raw: row.unit_raw ?? "", qty_received: oldReceived, condition_notes: row.condition_notes ?? "" }; + const after = { ...before, qty_received: corrected }; + beforeLines.push(before); + afterLines.push(after); + mutations.push({ collection: "delivery_note_items", method: "update", id: row.id, data: { qty_received: corrected } }); + } + if (afterLines.every((line, index) => line.qty_received === beforeLines[index].qty_received)) throw new PurchasingError("nothing_changed",400); + const before = { delivery_note: deliveryId, purchase_order: delivery.purchase_order, lines: beforeLines }; + const after = { delivery_note: deliveryId, purchase_order: delivery.purchase_order, lines: afterLines }; + mutations.push({ collection: "dn_review_events", method: "create", data: { event_key: eventKey, batch: batchId, note: noteId, action: "correct_receipt", before, after, actor: body.actor, reason: body.reason } }); + await lease.batch(mutations); + return {ok:true,after}; +} diff --git a/src/mcp/purchasing-write-tools.ts b/src/mcp/purchasing-write-tools.ts index 46bd0d7..8f95d29 100644 --- a/src/mcp/purchasing-write-tools.ts +++ b/src/mcp/purchasing-write-tools.ts @@ -3,12 +3,16 @@ import type {Principal} from '@/lib/purchasing/types'; import {registerPurchasingTool} from './tool-contract'; import {CreatePurchaseOrderSchema,UpdatePurchaseOrderSchema,AddPurchaseOrderItemSchema,UpdatePurchaseOrderItemSchema,VerifyPurchaseOrderSchema,mutatePurchaseOrder} from '@/lib/purchasing/purchase-orders'; import {SupplierGuidanceSchema,mutateSupplierGuidance} from '@/lib/purchasing/supplier-guidance'; +import {CandidateOperationSchema,EntityResolutionSchema,ItemResolutionSchema,resolveDelivery,processDeliveryCandidate} from '@/lib/purchasing/delivery-matching'; +import {McpPostDeliveryLineSchema,ReceiptCorrectionSchema,postDeliveryLine,correctDeliveryReceipt} from '@/lib/purchasing/delivery-receipts'; import {setDeliveryVerification} from '@/lib/purchasing/verification'; import {UploadPurchasingSchema,uploadPurchasingDocument} from '@/lib/purchasing/uploads'; import {CreateDeliveryCandidateSchema,UpdateDeliveryCandidateSchema,DeliveryGroupingSchema,mutateDeliveryCandidate} from '@/lib/purchasing/delivery-candidates'; 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 [['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}); const orderTools=[ ['create_purchase_order','Create a manual order and validated lines atomically.',CreatePurchaseOrderSchema], ['update_purchase_order','Update permitted header fields of an unverified order.',UpdatePurchaseOrderSchema],