feat(mcp): dimension parity for the write/read tool edges (#1274)

Closes the MCP dimension gaps found in the 2026-07-28 audit:

- gnubok_bulk_book_inbox_items accepts a shared dimensions bag through
  all three layers (tool schema + BulkBookInboxSchema + categorize-core
  BulkBookInboxInput), resolve-don't-select with echoed resolutions; the
  web inbox bulk-book route and the pending-op executor inherit it via
  the shared schema.
- gnubok_create_employee / gnubok_update_employee accept
  default_dimensions (names resolve to codes; {} clears on update).
  The command layer already persisted the field: only the MCP boundary
  blocked it, leaving payroll tagging dashboard-only.
- gnubok_query_journal: dimensions bag filter (jsonb containment via
  the GIN index, covers custom dims the legacy project/cost_center
  filters cannot) + include_dimensions to return each line's bag.
  The wide full-match fetch stays dims-free unless something needs it.
- gnubok_list_invoices / gnubok_list_supplier_invoices return
  default_dimensions (agents could set invoice bags but never read
  them back).
- Discoverability: create_voucher, categorize_transaction,
  correct_entry, update_invoice descriptions now name dimensions;
  categorize_month and invoice_run loadouts include
  gnubok_list_dimensions. Trimmed new schema prose to stay under the
  tools/list payload budget.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-29 09:39:53 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 222e581476
commit 80a14ddfd2
8 changed files with 418 additions and 13 deletions
+4
View File
@@ -1376,6 +1376,10 @@ export const BulkBookInboxSchema = z.object({
vat_amount: z.number().positive().nullish().transform((v) => v ?? undefined),
notes: z.string().max(2000).nullish().transform((v) => v ?? undefined),
allow_duplicate: z.boolean().nullish().transform((v) => v ?? undefined),
// Shared dimensions bag applied to the business lines of every generated
// verifikat (same semantics as single categorize). nullish for the same
// staged-params reason as the fields above.
dimensions: DimensionsBagSchema.nullish().transform((v) => v ?? undefined),
})
export type BulkBookInboxInput = z.infer<typeof BulkBookInboxSchema>
@@ -101,6 +101,7 @@ describe('BulkBookInboxSchema', () => {
vat_amount: null,
notes: null,
allow_duplicate: false,
dimensions: null,
})
expect(r.success).toBe(true)
if (r.success) {
@@ -108,9 +109,28 @@ describe('BulkBookInboxSchema', () => {
expect(r.data.vat_treatment).toBeUndefined()
expect(r.data.vat_amount).toBeUndefined()
expect(r.data.notes).toBeUndefined()
expect(r.data.dimensions).toBeUndefined()
}
})
it('accepts a shared dimensions bag and rejects a malformed one', () => {
const ok = BulkBookInboxSchema.safeParse({
item_ids: ['11111111-1111-4111-8111-111111111111'],
category: 'expense_software',
dimensions: { '6': 'P001' },
})
expect(ok.success).toBe(true)
if (ok.success) expect(ok.data.dimensions).toEqual({ '6': 'P001' })
const bad = BulkBookInboxSchema.safeParse({
item_ids: ['11111111-1111-4111-8111-111111111111'],
category: 'expense_software',
// Key must be a SIE dim number: 'projekt' is not.
dimensions: { projekt: 'P001' },
})
expect(bad.success).toBe(false)
})
it('rejects an empty item_ids array', () => {
const r = BulkBookInboxSchema.safeParse({ item_ids: [], category: 'expense_software' })
expect(r.success).toBe(false)
@@ -227,6 +247,47 @@ describe('bulkBookMatchedInboxItems: booking', () => {
)
})
it('forwards the shared dimensions bag onto every booked mapping result', async () => {
// Fresh mapping object per call: the core mutates it in place, and a
// shared fixture would leak dimensions across tests.
mockMapping.mockImplementation(() => ({
rule: null,
debit_account: '5420',
credit_account: '1930',
risk_level: 'LOW',
confidence: 1,
requires_review: false,
default_private: false,
vat_lines: [],
description: 'Programvara',
}))
const supabase = queuedSupabase([
{ data: { id: 'i1', matched_transaction_id: 'tx-1', created_journal_entry_id: null, created_supplier_invoice_id: null } },
{ data: { id: 'tx-1', date: '2026-06-01', amount: -700, currency: 'SEK', cash_account_id: null, journal_entry_id: null } },
{ data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } },
{ data: [{ id: 'fp-1' }] },
{ error: null },
{ data: [] },
])
const { booked, skipped } = await bulkBookMatchedInboxItems(supabase, 'u1', 'c1', {
item_ids: ['i1'],
category: 'expense_software',
dimensions: { '6': 'P001' },
})
expect(skipped).toEqual([])
expect(booked).toHaveLength(1)
expect(mockCreateJE).toHaveBeenCalledWith(
expect.anything(),
'c1',
'u1',
expect.objectContaining({ id: 'tx-1' }),
expect.objectContaining({ dimensions: { '6': 'P001' } }),
undefined,
)
})
it('books the matched item and skips the unmatched one in a mixed batch', async () => {
const supabase = queuedSupabase([
// item i1 → not matched (1 from())
+7 -2
View File
@@ -447,6 +447,11 @@ export interface BulkBookInboxInput {
vat_amount?: number
notes?: string
allow_duplicate?: boolean
/**
* Shared dimensions bag applied to the business lines of every generated
* verifikat in the batch (same semantics as single categorize).
*/
dimensions?: Record<string, string>
}
export interface BulkBookInboxResult {
@@ -471,7 +476,7 @@ export async function bulkBookMatchedInboxItems(
companyId: string,
input: BulkBookInboxInput,
): Promise<BulkBookInboxResult> {
const { item_ids, category, vat_treatment, vat_amount, notes, allow_duplicate } = input
const { item_ids, category, vat_treatment, vat_amount, notes, allow_duplicate, dimensions } = input
const booked: BulkBookInboxResult['booked'] = []
const skipped: BulkBookInboxResult['skipped'] = []
@@ -516,7 +521,7 @@ export async function bulkBookMatchedInboxItems(
userId,
companyId,
item.matched_transaction_id as string,
{ category, vatTreatment: vat_treatment, vatAmount: vat_amount, notes, allowDuplicate: allow_duplicate },
{ category, vatTreatment: vat_treatment, vatAmount: vat_amount, notes, allowDuplicate: allow_duplicate, dimensions },
// Snapshot copies so the guard sees only the prior bookings of this batch.
{ excludeTransactionIds: [...bookedTransactionIds], excludeJournalEntryIds: [...bookedJournalEntryIds] },
)