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
+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