feat(categorize): dimension bags end-to-end + runtime template learning (#1273)
* feat(categorize): dimension bags end-to-end + runtime template learning The categorize path could not tag: categorize-core accepted a dimensions bag but no route or UI ever passed one, and runtime template learning dropped the bag entirely (only SIE import produced dimension-carrying patterns). - CategorizeTransactionSchema gains dimensions; the dashboard route, v1 single and v1 batch-categorize apply it to the mapping result's business lines (explicit bag wins over a learned counterparty bag). - categorization_templates.default_dimensions (migration 20260728091000) records the bag of the latest tagged booking; latest-explicit-wins, an untagged booking never erases it. Applied on the legacy single-line template path and the mirrored-refund path; multi-line SIE patterns keep their authoritative per-entry bags. - QuickReviewDialog gets a LineDimensionFields picker (dimensions_enabled gate, same as BulkBookDialog), prefilled from the counterparty suggestion's learned bag; hidden for multi-line patterns whose per-line bags would ignore an edit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Renumber migration above 20260728120000 (out-of-order vs prod after #1271) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
951b33363b
commit
222e581476
@@ -282,6 +282,61 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('passes body.dimensions onto the mapping result the engine books', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
amount: -500,
|
||||
merchant_name: 'GitHub',
|
||||
journal_entry_id: null,
|
||||
})
|
||||
// Fresh copy: the route mutates the mapping result in place, and the
|
||||
// shared defaultMappingResult object would leak dimensions across tests.
|
||||
mockBuildMappingResultFromCategory.mockReturnValue({ ...defaultMappingResult })
|
||||
|
||||
enqueue({ data: tx, error: null }) // fetch transaction
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) // settings
|
||||
enqueue({ data: [{ id: 'period-1' }], error: null }) // fiscal period check
|
||||
mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
enqueue({ data: [{ id: 'tx-1' }], error: null }) // tx update (CAS matched)
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/categorize', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
is_business: true,
|
||||
category: 'expense_software',
|
||||
dimensions: { '1': 'KS1', '6': 'P001' },
|
||||
},
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(mockCreateTransactionJournalEntry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'user-1',
|
||||
expect.objectContaining({ id: 'tx-1' }),
|
||||
expect.objectContaining({ dimensions: { '1': 'KS1', '6': 'P001' } }),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a malformed dimensions bag with 400', async () => {
|
||||
const request = createMockRequest('/api/transactions/tx-1/categorize', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
is_business: true,
|
||||
category: 'expense_software',
|
||||
// Key must be a SIE dim number: 'projekt' is not.
|
||||
dimensions: { projekt: 'P001' },
|
||||
},
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(mockCreateTransactionJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('flags an inbox underlag matched to the transaction as booked', async () => {
|
||||
// A document was attached to this transaction in the inbox
|
||||
// (matched_transaction_id) but not booked from there. Booking the
|
||||
|
||||
@@ -382,6 +382,14 @@ export const POST = withRouteContext(
|
||||
}
|
||||
}
|
||||
|
||||
// Dimensions: an explicitly picked bag tags the business lines of the
|
||||
// generated verifikat (bank/VAT legs stay untagged, see
|
||||
// buildTransactionEntryLines). It wins over a learned counterparty-
|
||||
// template bag; omitted = the learned bag (if any) applies unchanged.
|
||||
if (body.dimensions && Object.keys(body.dimensions).length > 0) {
|
||||
mappingResult.dimensions = body.dimensions
|
||||
}
|
||||
|
||||
if (!mappingResult.debit_account || !mappingResult.credit_account) {
|
||||
return errorResponseFromCode('TX_CATEGORIZE_INVALID_MAPPING', txLog, {
|
||||
requestId,
|
||||
|
||||
@@ -100,6 +100,10 @@ export const POST = withRouteContext(
|
||||
risk_level: 'NONE',
|
||||
requires_review: false,
|
||||
line_pattern: tmpl.line_pattern ?? null,
|
||||
default_dimensions:
|
||||
tmpl.default_dimensions && Object.keys(tmpl.default_dimensions).length > 0
|
||||
? tmpl.default_dimensions
|
||||
: null,
|
||||
}
|
||||
|
||||
const existing = template_suggestions[tx.id] || []
|
||||
|
||||
@@ -295,6 +295,13 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
}
|
||||
}
|
||||
|
||||
// Dimensions: an explicitly supplied bag tags the business lines of the
|
||||
// generated verifikat (bank/VAT legs stay untagged). Wins over a learned
|
||||
// counterparty-template bag; omitted = the learned bag (if any) applies.
|
||||
if (body.dimensions && Object.keys(body.dimensions).length > 0) {
|
||||
mappingResult.dimensions = body.dimensions
|
||||
}
|
||||
|
||||
if (!mappingResult.debit_account || !mappingResult.credit_account) {
|
||||
return v1ErrorResponseFromCode('TX_CATEGORIZE_INVALID_MAPPING', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
|
||||
@@ -187,6 +187,11 @@ async function categorizeOne(
|
||||
input.vat_treatment,
|
||||
)
|
||||
}
|
||||
// Dimensions: an explicitly supplied bag tags the business lines of the
|
||||
// generated verifikat (bank/VAT legs stay untagged).
|
||||
if (input.dimensions && Object.keys(input.dimensions).length > 0) {
|
||||
mappingResult.dimensions = input.dimensions
|
||||
}
|
||||
if (!mappingResult.debit_account || !mappingResult.credit_account) {
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
Reference in New Issue
Block a user