feat: expose matching and fenced receipt posting through MCP

This commit is contained in:
DEls 2026-10-05 02:00:37 +02:00
parent f9a5ca80a7
commit 4be0b2fa4e
10 changed files with 293 additions and 122 deletions

View file

@ -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<string, unknown> => 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<string, unknown>) => 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<string,unknown>;
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<string, unknown>[], checks: checks as Record<string, unknown>[] });
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;

View file

@ -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<string, unknown> => 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<string, unknown>) => 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<string, unknown>).purchase_order_item ?? null };
const replacement = { ...(items[lineIndex as number] as Record<string, unknown>), 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<string, unknown>[], checks: checks as Record<string, unknown>[] });
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<string, unknown> : {};
const priorItems = object(priorOverrides.items) ? priorOverrides.items as Record<string, unknown> : {};
const manual_overrides = {
...priorOverrides,
items: { ...priorItems, [String(lineIndex)]: String(purchaseOrderItemId) },
};
let resolution:Record<string,unknown>;
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<string, unknown>;
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;

View file

@ -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);
});

View file

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

View file

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

View file

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

View file

@ -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<string,unknown>=>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<string,unknown>)=>['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<Record<string,unknown>>{
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<string, unknown>[], checks: checks as Record<string, unknown>[] });
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<Record<string,unknown>>{
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<string, unknown>).purchase_order_item ?? null };
const replacement = { ...(items[lineIndex as number] as Record<string, unknown>), 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<string, unknown>[], checks: checks as Record<string, unknown>[] });
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<string, unknown> : {};
const priorItems = object(priorOverrides.items) ? priorOverrides.items as Record<string, unknown> : {};
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<string, unknown>;
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)};
}

View file

@ -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<string,unknown>,commit:(lease:BatchLeaseContext)=>Promise<Record<string,unknown>>,resourceKey?:string):Promise<OperationResult>{
export async function runOperation(ctx:PurchasingContext,tool:string,input:MutationInput & Record<string,unknown>,commit:(lease:BatchLeaseContext)=>Promise<Record<string,unknown>>,resourceKey?:string,options?:{scopeKey?:string}):Promise<OperationResult>{
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;
}

View file

@ -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<string,unknown>=>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<string, unknown>, 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<Record<string,unknown>>{
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<string, unknown>[] = [];
const afterLines: Record<string, unknown>[] = [];
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};
}

View file

@ -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],