feat(mcp): bulk-link documents to vouchers in one staged approval (#1411)

gnubok_link_documents_to_vouchers stages up to 300 document-to-verifikat
links as a single pending operation, addressed by voucher_series /
voucher_number / fiscal_year instead of journal_entry_id UUIDs, for bulk
receipt-migration jobs where N separate tools mean N separate approvals.

Staging resolves every row server-side and returns a per-row hit or miss,
so a systematic offset such as a wrong fiscal_year is visible before
anything is approved rather than after N approvals. Only resolved rows
enter the staged operation.

The WORM precondition and the document lookup are shared with the
single-document executor through precheckDocumentLink: a bulk call must
enforce exactly the invariants N single calls would, and a second copy of
a BFL 5 kap 6 § guard is a copy that keeps the old behaviour when the
first is hardened.

A batch that links nothing returns 409 instead of a committed no-op.
Partial skips stay committed, but an approval-gated operation on
räkenskapsinformation must not leave an audit record asserting a run that
changed nothing.

The tool is search-only: a one-off migration tool does not belong in the
default catalog every session pays for in context, and keeping it there
pushed the tools/list projection past the 58.5K token ceiling that
payload-size.bench.test.ts guards.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
bjornbergenheim
2026-08-17 14:52:58 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 4300cb711b
commit 3841ab9f54
12 changed files with 682 additions and 32 deletions
+267
View File
@@ -10742,6 +10742,273 @@ export const tools: McpTool[] = [
)
},
},
{
name: 'gnubok_link_documents_to_vouchers',
title: 'Bulk-Link Documents to Vouchers',
description: 'Bulk receipt migration: stage up to 300 document-to-verifikat links as ONE approval, addressed by voucher_series/voucher_number/fiscal_year (server resolves the UUID). Returns per-row hit/miss, so a wrong fiscal_year shows before approval. Only resolved rows are staged.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
links: {
type: 'array',
minItems: 1,
maxItems: 300,
items: {
type: 'object',
additionalProperties: false,
properties: {
document_id: { type: 'string', description: 'UUID of the document_attachments row' },
voucher_series: { type: 'string', minLength: 1, maxLength: 10, description: 'Voucher series letter, e.g. "A"' },
voucher_number: { type: 'integer', minimum: 1, description: 'Voucher number within the series and fiscal year' },
fiscal_year: { type: 'integer', minimum: 2000, maximum: 2100, description: 'Calendar year of the fiscal period (matched against fiscal_periods.period_start), e.g. 2025' },
journal_entry_line_id: { type: 'string', description: 'Optional UUID to pin the doc to a specific debit/credit line' },
},
required: ['document_id', 'voucher_series', 'voucher_number', 'fiscal_year'],
},
},
idempotency_key: { type: 'string', description: 'Optional UUID to dedupe retries' },
dry_run: { type: 'boolean', description: 'Preview without staging' },
},
required: ['links'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
// Search-only: a one-off bulk migration tool does not belong in the default
// catalog, which every session pays for in context. The everyday
// gnubok_link_document_to_voucher stays default; this one is discovered via
// gnubok_search_tools when a migration job actually needs it. Keeping it
// default pushed the tools/list projection past the 58.5K token ceiling
// (payload-size.bench.test.ts), whose own guidance is to prefer opt-in
// search over raising the budget.
catalogVisibility: 'search',
async execute(args, companyId, userId, supabase, actor) {
const rawLinks = args.links as Array<{
document_id: string
voucher_series: string
voucher_number: number
fiscal_year: number
journal_entry_line_id?: string
}>
if (!Array.isArray(rawLinks) || rawLinks.length === 0) throw new Error('links is required (non-empty)')
if (rawLinks.length > 300) throw new Error('links: max 300 per call')
for (const [i, l] of rawLinks.entries()) {
if (!l.document_id) throw new Error(`links[${i}]: document_id is required`)
if (!l.voucher_series) throw new Error(`links[${i}]: voucher_series is required`)
if (!Number.isInteger(l.voucher_number) || l.voucher_number < 1) {
throw new Error(`links[${i}]: voucher_number must be a positive integer`)
}
if (!Number.isInteger(l.fiscal_year)) throw new Error(`links[${i}]: fiscal_year must be an integer`)
}
// ── Resolve fiscal_year → fiscal_period_id. A "fiscal_year" here means
// the calendar year the period STARTS in; broken (non-calendar)
// fiscal years or a company with >1 period starting the same year
// are surfaced as a per-row miss rather than guessed at.
const { data: periods, error: periodsError } = await supabase
.from('fiscal_periods')
.select('id, period_start, period_end')
.eq('company_id', companyId)
if (periodsError) throw new Error(`Database error: ${periodsError.message}`)
const periodsByYear = new Map<number, Array<{ id: string; period_start: string; period_end: string }>>()
for (const p of periods ?? []) {
const year = new Date(p.period_start as string).getUTCFullYear()
const bucket = periodsByYear.get(year) ?? []
bucket.push(p as { id: string; period_start: string; period_end: string })
periodsByYear.set(year, bucket)
}
// ── Batch-fetch documents.
const documentIds = [...new Set(rawLinks.map((l) => l.document_id))]
const { data: docs, error: docsError } = await supabase
.from('document_attachments')
.select('id, file_name, mime_type, journal_entry_id')
.in('id', documentIds)
.eq('company_id', companyId)
if (docsError) throw new Error(`Database error: ${docsError.message}`)
const docsById = new Map((docs ?? []).map((d) => [d.id as string, d as {
id: string; file_name: string; mime_type: string; journal_entry_id: string | null
}]))
// ── Batch-fetch candidate journal entries: every resolved period id ×
// every series named in the payload, then match tuples in JS (the
// unique key is (company_id, fiscal_period_id, voucher_series,
// voucher_number); see migration 20260402100000).
const resolvedPeriodIds = [...new Set(
rawLinks.flatMap((l) => (periodsByYear.get(l.fiscal_year) ?? []).map((p) => p.id))
)]
const seriesList = [...new Set(rawLinks.map((l) => l.voucher_series))]
const voucherNumbers = [...new Set(rawLinks.map((l) => l.voucher_number))]
type CandidateJe = {
id: string; entry_date: string; description: string
voucher_series: string | null; voucher_number: number | null; status: string; fiscal_period_id: string
}
let jesByKey = new Map<string, CandidateJe>()
if (resolvedPeriodIds.length > 0) {
// Bounded by voucher_number (at most 300 distinct per call) and paged
// through fetchAllRows. Without both, a migration into a fiscal year
// that already holds more than 1000 vouchers in the series would hit
// PostgREST's silent 1000-row cap: the missing entries resolve as
// voucher_not_found and vanish from the staged batch, so the caller
// approves fewer links than requested with nothing explaining why.
const jes = await fetchAllRows<CandidateJe>(
({ from, to }) =>
supabase
.from('journal_entries')
.select('id, entry_date, description, voucher_series, voucher_number, status, fiscal_period_id')
.eq('company_id', companyId)
.in('fiscal_period_id', resolvedPeriodIds)
.in('voucher_series', seriesList)
.in('voucher_number', voucherNumbers)
.order('id', { ascending: true })
.range(from, to),
)
jesByKey = new Map(jes.map((je) => [
`${je.fiscal_period_id}::${je.voucher_series}::${je.voucher_number}`,
je,
]))
}
// ── Resolve each row to a hit or a miss. Only hits are carried into the
// staged params; misses are returned in the preview so the caller
// sees them without having to approve anything first.
type RowResult = {
document_id: string
voucher_series: string
voucher_number: number
fiscal_year: number
status: 'matched' | 'ambiguous_fiscal_year' | 'unknown_fiscal_year' | 'document_not_found'
| 'voucher_not_found' | 'already_linked' | 'duplicate_in_batch'
document_file_name?: string
journal_entry_id?: string
voucher_label?: string
voucher_date?: string
}
// Identifying fields only: spreading the raw row would leak
// journal_entry_line_id into a response shape that does not declare it.
const rowKey = (l: typeof rawLinks[number]) => ({
document_id: l.document_id,
voucher_series: l.voucher_series,
voucher_number: l.voucher_number,
fiscal_year: l.fiscal_year,
})
// ── Statuses of the verifikat these documents are ALREADY attached to.
// The WORM guard in the executor refuses to move a document off a
// POSTED verifikat, and without this lookup such a row previews as
// `matched` and is silently skipped after approval: the user approves
// 42 and gets 38. Those entries can sit outside the periods fetched
// above, so they need their own lookup.
const existingJeIds = [...new Set(
(docs ?? [])
.map((d) => d.journal_entry_id as string | null)
.filter((id): id is string => !!id)
)]
const postedExistingJeIds = new Set<string>()
if (existingJeIds.length > 0) {
const { data: existingJes, error: existingErr } = await supabase
.from('journal_entries')
.select('id, status')
.eq('company_id', companyId)
.in('id', existingJeIds)
if (existingErr) throw new Error(`Database error: ${existingErr.message}`)
for (const je of existingJes ?? []) {
if ((je as { status: string }).status === 'posted') postedExistingJeIds.add(je.id as string)
}
}
const results: RowResult[] = []
const matchedLinks: Array<{
document_id: string; journal_entry_id: string; journal_entry_line_id: string | null
}> = []
// Same document twice in one payload: the executor applies rows in order,
// so against a DRAFT verifikat the second row would silently overwrite
// the first (the WORM guard only blocks posted targets). Reject the
// repeat here instead of staging a batch whose outcome depends on order.
const seenDocumentIds = new Set<string>()
for (const l of rawLinks) {
if (seenDocumentIds.has(l.document_id)) {
results.push({ ...rowKey(l), status: 'duplicate_in_batch' })
continue
}
seenDocumentIds.add(l.document_id)
const yearPeriods = periodsByYear.get(l.fiscal_year) ?? []
if (yearPeriods.length === 0) {
results.push({ ...rowKey(l), status: 'unknown_fiscal_year' })
continue
}
if (yearPeriods.length > 1) {
results.push({ ...rowKey(l), status: 'ambiguous_fiscal_year' })
continue
}
const doc = docsById.get(l.document_id)
if (!doc) {
results.push({ ...rowKey(l), status: 'document_not_found' })
continue
}
const period = yearPeriods[0]!
const je = jesByKey.get(`${period.id}::${l.voucher_series}::${l.voucher_number}`)
if (!je) {
results.push({ ...rowKey(l), status: 'voucher_not_found', document_file_name: doc.file_name })
continue
}
const existingJeId = doc.journal_entry_id
if (existingJeId && existingJeId !== je.id && postedExistingJeIds.has(existingJeId)) {
results.push({ ...rowKey(l), status: 'already_linked', document_file_name: doc.file_name })
continue
}
results.push({
...rowKey(l),
status: 'matched',
document_file_name: doc.file_name,
journal_entry_id: je.id,
voucher_label: `${je.voucher_series ?? l.voucher_series}${je.voucher_number ?? l.voucher_number}`,
voucher_date: je.entry_date,
})
matchedLinks.push({
document_id: l.document_id,
journal_entry_id: je.id,
journal_entry_line_id: l.journal_entry_line_id ?? null,
})
}
const matchedCount = matchedLinks.length
const missedCount = results.length - matchedCount
if (matchedCount === 0) {
throw new Error(
`No links resolved: 0/${rawLinks.length} matched a real document + voucher. ` +
`First miss: ${JSON.stringify(results[0])}`
)
}
return stagePendingOperation(
supabase, companyId, userId, 'link_documents_to_vouchers',
`Koppla ${matchedCount} bilagor till verifikat${missedCount > 0 ? ` (${missedCount} utan träff)` : ''}`,
{ links: matchedLinks },
{
total: rawLinks.length,
matched_count: matchedCount,
missed_count: missedCount,
results,
},
actor,
undefined,
{
idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined,
dryRun: args.dry_run === true,
}
)
},
},
// ── Payroll (Lönehantering) ──────────────────────────────────
{
name: 'gnubok_list_mileage_trips',