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:
co-authored by
Claude Fable 5
parent
222e581476
commit
80a14ddfd2
@@ -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())
|
||||
|
||||
@@ -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] },
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user