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
+1
View File
@@ -241,6 +241,7 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
gnubok_get_document_content: 'transactions:read',
gnubok_attach_document_to_transaction: 'transactions:write',
gnubok_link_document_to_voucher: 'bookkeeping:write',
gnubok_link_documents_to_vouchers: 'bookkeeping:write',
// Körjournal (mileage): trip log reads/writes are payroll surface
// (milersättning, 7331); booking the verifikat is a journal write.
gnubok_list_mileage_trips: 'payroll:read',
@@ -1285,6 +1285,148 @@ describe('commitPendingOperation: link_document_to_voucher', () => {
})
})
// ─── link_documents_to_vouchers (bulk) ─────────────────────────────
describe('commitPendingOperation: link_documents_to_vouchers', () => {
const baseOp: Partial<PendingOperation> = {
operation_type: 'link_documents_to_vouchers',
params: {
links: [
{ document_id: 'doc-1', journal_entry_id: 'je-1', journal_entry_line_id: null },
{ document_id: 'doc-2', journal_entry_id: 'je-2', journal_entry_line_id: null },
],
},
}
it('happy path: links every row and reports zero skipped', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
// row 1: doc-1 -> je-1
enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch
enqueue({ data: { id: 'je-1' }, error: null }) // linkToJournalEntry: JE ownership
enqueue({
data: { id: 'doc-1', file_name: 'kvitto1.pdf', journal_entry_id: 'je-1', journal_entry_line_id: null },
error: null,
}) // linkToJournalEntry: doc update
// row 2: doc-2 -> je-2
enqueue({ data: { id: 'doc-2', journal_entry_id: null }, error: null }) // doc fetch
enqueue({ data: { id: 'je-2' }, error: null }) // linkToJournalEntry: JE ownership
enqueue({
data: { id: 'doc-2', file_name: 'kvitto2.pdf', journal_entry_id: 'je-2', journal_entry_line_id: null },
error: null,
}) // linkToJournalEntry: doc update
enqueue({ data: null, error: null }) // dispatcher commit update
const result = await commitPendingOperation(
supabase as never, 'user-1', 'company-1', makePendingOp(baseOp),
)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({
linked_count: 2,
skipped_count: 0,
})
})
it('mixed batch: a missing document is skipped without blocking the other rows', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
// row 1: doc-1 not found in this company
enqueue({ data: null, error: null }) // doc fetch: not found
// row 2: doc-2 -> je-2 succeeds
enqueue({ data: { id: 'doc-2', journal_entry_id: null }, error: null }) // doc fetch
enqueue({ data: { id: 'je-2' }, error: null }) // linkToJournalEntry: JE ownership
enqueue({
data: { id: 'doc-2', file_name: 'kvitto2.pdf', journal_entry_id: 'je-2', journal_entry_line_id: null },
error: null,
}) // linkToJournalEntry: doc update
enqueue({ data: null, error: null }) // dispatcher commit update
const result = await commitPendingOperation(
supabase as never, 'user-1', 'company-1', makePendingOp(baseOp),
)
// Non-transactional bulk op: overall status is 'committed' even with
// partial skips (mirrors bulk_book_inbox_items's booked/skipped split),
// since no irreversible side-effect was posted for the skipped row.
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({ linked_count: 1, skipped_count: 1 })
expect((result.data as { skipped: Array<{ document_id: string; reason: string }> }).skipped[0]).toMatchObject({
document_id: 'doc-1',
reason: 'Bilagan hittades inte.',
})
})
it('WORM guard skips a row whose doc is already linked to a different posted JE', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
// row 1: doc-1 already linked to je-OTHER (posted) -> skipped
enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-OTHER' }, error: null }) // doc fetch
enqueue({ data: { status: 'posted' }, error: null }) // WORM: existing JE status
// row 2: doc-2 -> je-2 succeeds
enqueue({ data: { id: 'doc-2', journal_entry_id: null }, error: null }) // doc fetch
enqueue({ data: { id: 'je-2' }, error: null }) // linkToJournalEntry: JE ownership
enqueue({
data: { id: 'doc-2', file_name: 'kvitto2.pdf', journal_entry_id: 'je-2', journal_entry_line_id: null },
error: null,
}) // linkToJournalEntry: doc update
enqueue({ data: null, error: null }) // dispatcher commit update
const result = await commitPendingOperation(
supabase as never, 'user-1', 'company-1', makePendingOp(baseOp),
)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({ linked_count: 1, skipped_count: 1 })
})
it('auto-rejects 409 when every row is skipped instead of recording a committed no-op', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
// row 1: doc-1 already linked to a posted JE -> WORM skip
enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-OTHER' }, error: null }) // doc fetch
enqueue({ data: { status: 'posted' }, error: null }) // WORM: existing JE status
// row 2: doc-2 not found -> skip
enqueue({ data: null, error: null }) // doc fetch: not found
enqueue({ data: null, error: null }) // dispatcher reject update
const result = await commitPendingOperation(
supabase as never, 'user-1', 'company-1', makePendingOp(baseOp),
)
// Partial skips stay 'committed' (see the mixed-batch case), but a batch
// that linked nothing must not leave an audit record asserting a run that
// changed nothing: the single-document executor returns 409 for the same
// conditions, and the bulk path must not be the weaker one.
expect(result.status).toBe('rejected')
expect(result.http_status).toBe(409)
})
it('fails 400 when the staged params carry an empty links array', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher failure update
const result = await commitPendingOperation(
supabase as never, 'user-1', 'company-1',
makePendingOp({ ...baseOp, params: { links: [] } }),
)
// 400 is not auto-reject territory (only 404 and 409 are), so the operation
// is recorded as failed rather than rejected.
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
})
it('fails 400 when links is missing entirely', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher failure update
const result = await commitPendingOperation(
supabase as never, 'user-1', 'company-1',
makePendingOp({ ...baseOp, params: {} }),
)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
})
})
// ─── categorize_transaction: dimensions propagation (PR7) ──────────
describe('commitPendingOperation: categorize_transaction: dimensions propagation (PR7)', () => {
+157 -32
View File
@@ -23,6 +23,7 @@ import {
supplierInvoiceSekAmounts,
} from '@/lib/currency/supplier-invoice-rate'
import { roundOre } from '@/lib/money'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { validateVatNumber } from '@/lib/vat/vies-client'
import {
normalizeVatRateToDecimal,
@@ -3181,6 +3182,74 @@ async function commitAttachDocumentToTransaction(
}
}
/**
* Shared precondition for linking a document to a verifikat, used by BOTH the
* single and the bulk executor. It must live in one place: a bulk call has to
* be exactly N single calls, and a second copy of a legally motivated guard is
* a copy that silently keeps the old behaviour when the first one is hardened.
*
* Two checks: the document exists in THIS company, and the WORM rule that a
* document already belonging to a POSTED verifikat cannot be moved to another
* one (it is räkenskapsinformation under BFL 5 kap 6 § once the verifikat is
* posted). Re-linking to the SAME verifikat is allowed: that is a no-op or a
* line-level refinement, not a move.
*/
type DocumentLinkPrecheck =
| { ok: true }
| { ok: false; reason: string; status: number }
async function precheckDocumentLink(
supabase: SupabaseClient,
companyId: string,
documentId: string,
journalEntryId: string
): Promise<DocumentLinkPrecheck> {
const { data: doc, error: docError } = await supabase
.from('document_attachments')
.select('id, file_name, journal_entry_id')
.eq('id', documentId)
.eq('company_id', companyId)
.maybeSingle()
if (docError || !doc) return { ok: false, reason: 'Bilagan hittades inte.', status: 404 }
const existingJeId = (doc.journal_entry_id as string | null) ?? null
if (existingJeId && existingJeId !== journalEntryId) {
const { data: existingJe } = await supabase
.from('journal_entries')
.select('status')
.eq('id', existingJeId)
.eq('company_id', companyId)
.maybeSingle()
if (existingJe && (existingJe as { status: string }).status === 'posted') {
return {
ok: false,
reason:
'Bilagan är kopplad till en bokförd verifikation och kan inte länkas om. Ladda upp ett nytt dokument.',
status: 409,
}
}
}
return { ok: true }
}
/**
* Shared failure mapping for both link executors. A locked period is the one
* case worth its own sentence: it is recoverable by unlocking, unlike the rest,
* and the raw trigger text is English database prose. Everything else goes
* through getErrorMessage so nothing reaches the Granskning panel untranslated.
*/
function documentLinkFailureMessage(err: unknown): string {
const msg = err instanceof Error ? err.message : String(err ?? '')
if (/locked\/closed fiscal period|Bokföringen är låst/i.test(msg)) {
return 'Verifikationens period är låst: bilagan kan inte länkas.'
}
// No ErrorContext value covers document attachments, and journal_entry would
// phrase the fallback as a verifikat problem when the failure is the link.
// Omitting it keeps the generic Swedish fallback, which is accurate here.
return getErrorMessage(err)
}
async function commitLinkDocumentToVoucher(
supabase: SupabaseClient,
_userId: string,
@@ -3194,31 +3263,8 @@ async function commitLinkDocumentToVoucher(
return { error: 'document_id and journal_entry_id are required', status: 400 }
}
const { data: doc, error: docError } = await supabase
.from('document_attachments')
.select('id, file_name, journal_entry_id')
.eq('id', documentId)
.eq('company_id', companyId)
.maybeSingle()
if (docError || !doc) return { error: 'Document not found', status: 404 }
// WORM guard: refuse to re-link a doc already linked to a DIFFERENT posted JE.
const existingJeId = (doc.journal_entry_id as string | null) ?? null
if (existingJeId && existingJeId !== journalEntryId) {
const { data: existingJe } = await supabase
.from('journal_entries')
.select('status')
.eq('id', existingJeId)
.eq('company_id', companyId)
.maybeSingle()
if (existingJe && (existingJe as { status: string }).status === 'posted') {
return {
error:
'Bilagan är kopplad till en bokförd verifikation och kan inte länkas om. Ladda upp ett nytt dokument.',
status: 409,
}
}
}
const precheck = await precheckDocumentLink(supabase, companyId, documentId, journalEntryId)
if (!precheck.ok) return { error: precheck.reason, status: precheck.status }
try {
const updated = await linkToJournalEntry(
@@ -3237,14 +3283,90 @@ async function commitLinkDocumentToVoucher(
},
}
} catch (err) {
const msg = (err as Error).message ?? ''
if (/locked\/closed fiscal period|Bokföringen är låst/i.test(msg)) {
return {
error: 'Verifikationens period är låst: bilagan kan inte länkas.',
status: 409,
}
const msg = err instanceof Error ? err.message : String(err ?? '')
const isLocked = /locked\/closed fiscal period|Bokföringen är låst/i.test(msg)
return { error: documentLinkFailureMessage(err), status: isLocked ? 409 : 500 }
}
}
async function commitLinkDocumentsToVouchers(
supabase: SupabaseClient,
_userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
const links = params.links as Array<{
document_id: string; journal_entry_id: string; journal_entry_line_id: string | null
}> | undefined
if (!Array.isArray(links) || links.length === 0) {
return { error: 'links is required (non-empty)', status: 400 }
}
const linked: Array<{ document_id: string; journal_entry_id: string }> = []
const skipped: Array<{ document_id: string; journal_entry_id: string; reason: string }> = []
// Sequential, not Promise.all: each row re-validates against the current DB
// state (a prior row in the SAME batch can change a doc's journal_entry_id,
// and the WORM guard must see that), and goes through the SAME
// precheckDocumentLink + linkToJournalEntry pair as the single-document
// executor, so a bulk call enforces identical invariants to N individual
// calls rather than a parallel reimplementation of them.
for (const link of links) {
const documentId = link.document_id
const journalEntryId = link.journal_entry_id
if (!documentId || !journalEntryId) {
skipped.push({ document_id: documentId ?? '(saknas)', journal_entry_id: journalEntryId ?? '(saknas)', reason: 'Raden saknar dokument-id eller verifikations-id.' })
continue
}
return { error: `Failed to link document: ${msg}`, status: 500 }
const precheck = await precheckDocumentLink(supabase, companyId, documentId, journalEntryId)
if (!precheck.ok) {
skipped.push({ document_id: documentId, journal_entry_id: journalEntryId, reason: precheck.reason })
continue
}
try {
const updated = await linkToJournalEntry(
supabase, companyId, documentId, journalEntryId, link.journal_entry_line_id ?? undefined,
)
linked.push({ document_id: updated.id, journal_entry_id: updated.journal_entry_id as string })
} catch (err) {
// Same mapping as the single executor, including its locked-period
// sentence: skipped reasons land in result_data and are rendered in the
// Granskning panel, so none of them may reach the user as raw English
// database prose.
skipped.push({ document_id: documentId, journal_entry_id: journalEntryId, reason: documentLinkFailureMessage(err) })
}
}
// Nothing linked is a failure, not a partial success. Recording it as
// `committed` would leave an approval-gated operation on
// räkenskapsinformation sitting in the audit trail asserting a run that
// changed nothing, and the single-document executor returns 409 for exactly
// these conditions (locked period, WORM guard, missing document). A batch
// must not be the weaker path.
if (linked.length === 0) {
return {
error: `Inga bilagor kunde länkas (${skipped.length} hoppades över). Första orsak: ${skipped[0]?.reason ?? 'okänd'}`,
status: 409,
}
}
log.info('link_documents_to_vouchers committed', {
companyId,
operationType: 'link_documents_to_vouchers',
requested: links.length,
linkedCount: linked.length,
skippedCount: skipped.length,
})
return {
data: {
linked_count: linked.length,
skipped_count: skipped.length,
linked,
skipped,
},
}
}
@@ -5896,6 +6018,9 @@ async function commitPendingOperationInner(
case 'link_document_to_voucher':
result = await commitLinkDocumentToVoucher(supabase, userId, companyId, pendingOp.params)
break
case 'link_documents_to_vouchers':
result = await commitLinkDocumentsToVouchers(supabase, userId, companyId, pendingOp.params)
break
case 'run_year_end':
result = await commitRunYearEnd(supabase, userId, companyId, pendingOp.params)
break
+2
View File
@@ -94,6 +94,8 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
// (BFL 5 kap 6 §) and becomes immutable once the JE is posted. Medium so a
// human confirms the doc-to-verifikat pairing before it locks.
link_document_to_voucher: 'medium',
// Same rationale as link_document_to_voucher, N rows in one staged op.
link_documents_to_vouchers: 'medium',
// Dimension-only diff on posted lines (verifikat stays immutable), fully
// audited via dimension_retag_log, but it rewrites reporting history, so
// it crosses a human at medium.