feat(dimensions): PR6 retro-tagging — audited retag carve-out, BulkTagWorkbench, staged MCP tool (#867)
* feat(dimensions): PR6 retro-tagging — audited retag carve-out, workbench, staged MCP tool
Tier-2 retro-tagging (founder decision №1, approved 2026-07-02): posted
entries in OPEN periods can have their dimension tags changed through ONE
audited path — everything about the verifikat itself stays immutable.
Carve-out (migration 20260702170000): the line-immutability trigger gains a
single narrow branch — while the transaction-local GUC set by the RPC is
active, an UPDATE of a posted line is admitted iff every non-dimension
column is unchanged, enforced by a whole-row to_jsonb diff (any future
column is protected by construction; mirrors cost_center/project are in the
changeable set because they are derived views of dimensions['1']/['6']).
Precedent: mark_entry_as_opening_balance (20260613120000).
retag_line_dimensions RPC: tenant guard (20260619130100 pattern), writer
gate (viewers rejected), posted-only, open period + company lock date
enforced, every code validated against the ACTIVE registry, immutable
dimension_retag_log row (before/after/actor/reason, INSERT-only via its own
trigger, no FKs so the trail survives hard-deletes) written BEFORE the
carve-out UPDATE. Idempotent no-op without a log row. Untag ({}) supported.
Legal position per the plan: dimensions are internredovisning metadata, not
BFL 5 kap 7§ verifikat content — this is strictly more conservative than
Fortnox/Visma (dimension-only diffs, open periods only, immutable log,
storno past locks — Tier 3 has no exceptions).
Mandatory pg suite (11 tests): GUC-less updates still blocked; amounts/
description can never change even under the GUC (transaction-local);
closed/locked/lock-date, role, registry, draft and cross-tenant rejections;
log immutability; gnubok.allow_delete bulk path unaffected.
UX (all writes through the ONE RPC): pencil on posted-voucher lines in
bookkeeping/[id] ("Påverkar endast internredovisningen, inte verifikatet")
+ retag-history card; BulkTagWorkbench at /dimensions/tagging (filters,
shift-select, merge vs "Ersätt tagg" replace mode, reversal-pair warning
with "Inkludera motverifikat" auto-selection, per-line failure display).
MCP: gnubok_tag_journal_lines (bookkeeping:write) — filter block resolved
via resolve-don't-select, ≤500 lines, staged via pending_operations (new
op type migration 20260702171000, medium risk tier, shared Zod validation
boundary between staging and commit; executor loops the RPC per line with
partial-success aggregation).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dimensions): address #867 review — SQLSTATE classification, blocking storno confirm, documented divergence
- Retag route classifies RPC errors by SQLSTATE instead of message-regex:
P0001 (every rule violation in the RPC) → 409 verbatim, 42501 (tenant
guard) → 403, anything else → logged 500 with a generic message. No more
substring sniffing.
- The workbench's storno-pair warning escalates to a BLOCKING confirmation
naming the unselected counter-vouchers before apply (Srf U 14 gross
reporting — one-legged retags silently skew project P&L; the banner alone
was advisory).
- The empty-bag divergence is now documented on both schemas as intentional:
the direct dialog/workbench path allows {} (human untags phantom codes,
logged with reason), the MCP staged path rejects it (agents never
bulk-clear history).
Triage notes: the log's missing FKs are the point (behandlingshistorik must
survive undo_sie_import hard-deletes — a cascade would erase the trail);
SIE exports are generated fresh on demand, never cached, so post-retag
exports carry the new object lists automatically; date-scoped registry
values are deliberately not enforced at retag because entry creation does
not enforce them either — enforcing in one path only would be incoherent
(both belong to the PR10 rules engine).
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
fb3fe82a56
commit
816b1769c8
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Dimensions PR6 — gnubok_tag_journal_lines (bulk retag staging) tests.
|
||||
*
|
||||
* Covers registration (scope map, strict schema, staged-operation output
|
||||
* contract via deriveToolMeta), the filter gates (no filters / 0 matches /
|
||||
* >500 matches), and the staging happy paths (free-text passthrough +
|
||||
* registry name resolution). Executor-side coverage
|
||||
* (commitRetagLineDimensions incl. partial failure) lives in
|
||||
* lib/pending-operations/__tests__/retag-line-dimensions-executor.test.ts.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
|
||||
import { tools, deriveToolMeta } from '../server'
|
||||
|
||||
const tagJournalLines = tools.find((t) => t.name === 'gnubok_tag_journal_lines')!
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
function makeLineRow(i: number, overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
// Real UUID shape — the staged params are re-validated against
|
||||
// RetagLineDimensionsParamsSchema (line_ids must be UUIDs) before insert.
|
||||
id: `00000000-0000-4000-8000-${String(i).padStart(12, '0')}`,
|
||||
account_number: '4010',
|
||||
debit_amount: 250,
|
||||
credit_amount: 0,
|
||||
sort_order: 1,
|
||||
journal_entries: {
|
||||
id: `je-${i}`,
|
||||
entry_date: '2024-03-01',
|
||||
voucher_number: i,
|
||||
voucher_series: 'A',
|
||||
status: 'posted',
|
||||
company_id: 'company-1',
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Registration + contracts ─────────────────────────────────────────────────
|
||||
|
||||
describe('gnubok_tag_journal_lines registration', () => {
|
||||
it('exists, is scoped bookkeeping:write, and keeps a strict input schema', () => {
|
||||
expect(tagJournalLines).toBeDefined()
|
||||
expect(TOOL_SCOPE_MAP.gnubok_tag_journal_lines).toBe('bookkeeping:write')
|
||||
expect((tagJournalLines.inputSchema as { additionalProperties?: boolean }).additionalProperties).toBe(false)
|
||||
expect(tagJournalLines.description.length).toBeLessThanOrEqual(280)
|
||||
expect(tagJournalLines.description).toMatch(/stag(e|ing)/i)
|
||||
})
|
||||
|
||||
it('uses the staged-operation output schema so the _meta staging contract derives', () => {
|
||||
// deriveToolMeta keys off reference identity with STAGED_OPERATION_SCHEMA —
|
||||
// a defined meta proves the tool shares THE schema, not a lookalike copy.
|
||||
const meta = deriveToolMeta(tagJournalLines)
|
||||
expect(meta).toBeDefined()
|
||||
expect(meta?.requires_approval).toBe(true)
|
||||
expect(meta?.approve_tool).toBe('gnubok_approve_pending_operation')
|
||||
const schema = tagJournalLines.outputSchema as { required?: string[] }
|
||||
expect(schema?.required).toContain('staged')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Filter gates ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('gnubok_tag_journal_lines — filter gates', () => {
|
||||
it('rejects an empty filter block before any DB work', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
await expect(
|
||||
tagJournalLines.execute(
|
||||
{ dimensions: { '6': 'P01' }, reason: 'Retro-taggning', filters: {} },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/minst ett filter/)
|
||||
expect(supabase.from).not.toHaveBeenCalled()
|
||||
expect(supabase.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an invalid dimensions bag before any DB work', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
await expect(
|
||||
tagJournalLines.execute(
|
||||
{ dimensions: { '0': 'X' }, reason: 'Retro-taggning', filters: { accounts: ['4010'] } },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/Invalid dimensions/)
|
||||
expect(supabase.from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws a helpful error when no posted lines match', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { dimensions_enabled: false }, error: null }) // resolveDimensionBags passthrough
|
||||
enqueue({ data: [], error: null }) // line match query → empty
|
||||
|
||||
await expect(
|
||||
tagJournalLines.execute(
|
||||
{ dimensions: { '6': 'P01' }, reason: 'Retro-taggning', filters: { accounts: ['4010'] } },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/Inga bokförda rader matchade filtret[\s\S]*gnubok_query_journal/)
|
||||
})
|
||||
|
||||
it('throws asking to narrow the filter when more than 500 lines match', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { dimensions_enabled: false }, error: null })
|
||||
enqueue({ data: Array.from({ length: 501 }, (_, i) => makeLineRow(i)), error: null })
|
||||
|
||||
await expect(
|
||||
tagJournalLines.execute(
|
||||
{ dimensions: { '6': 'P01' }, reason: 'Retro-taggning', filters: { account_from: '4000', account_to: '4999' } },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/fler än 500 rader/)
|
||||
|
||||
const insertCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls
|
||||
expect(insertCalls.some((args) => args[0] === 'pending_operations')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Staging ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('gnubok_tag_journal_lines — staging', () => {
|
||||
it('stages a retag_line_dimensions op with matched line_ids + preview sample', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { dimensions_enabled: false }, error: null }) // passthrough (free-text era)
|
||||
enqueue({ data: [makeLineRow(1), makeLineRow(2)], error: null }) // line match query
|
||||
enqueue({ data: { id: 'op-retag-1' }, error: null }) // pending_operations insert
|
||||
|
||||
const result = (await tagJournalLines.execute(
|
||||
{
|
||||
dimensions: { '6': 'P01' },
|
||||
reason: 'Retro-taggning av Bygg AB-projektet',
|
||||
filters: { accounts: ['4010'], date_from: '2024-01-01', date_to: '2024-12-31', text: 'Bygg AB' },
|
||||
},
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
{ type: 'api_key' },
|
||||
)) as {
|
||||
staged: boolean
|
||||
operation_id?: string
|
||||
risk_level: string
|
||||
preview: {
|
||||
matched_lines: number
|
||||
dimensions: Record<string, string>
|
||||
filter_summary: string
|
||||
sample: Array<{ account: string; date: string; debit: number; credit: number }>
|
||||
}
|
||||
}
|
||||
|
||||
expect(result.staged).toBe(true)
|
||||
expect(result.operation_id).toBe('op-retag-1')
|
||||
expect(result.risk_level).toBe('medium')
|
||||
expect(result.preview.matched_lines).toBe(2)
|
||||
expect(result.preview.dimensions).toEqual({ '6': 'P01' })
|
||||
expect(result.preview.filter_summary).toMatch(/konto 4010/)
|
||||
expect(result.preview.filter_summary).toMatch(/datum 2024-01-01–2024-12-31/)
|
||||
expect(result.preview.filter_summary).toMatch(/text "Bygg AB"/)
|
||||
expect(result.preview.sample).toEqual([
|
||||
{ account: '4010', date: '2024-03-01', debit: 250, credit: 0 },
|
||||
{ account: '4010', date: '2024-03-01', debit: 250, credit: 0 },
|
||||
])
|
||||
|
||||
const insertCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls
|
||||
expect(insertCalls.some((args) => args[0] === 'pending_operations')).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves dimension names to registry codes and echoes the resolution', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
// resolveDimensionBags (enabled): settings → ensure rpc → dimensions → values
|
||||
enqueue({ data: { dimensions_enabled: true }, error: null })
|
||||
enqueue({ data: null, error: null }) // ensure_company_dimensions rpc
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false, is_system: true, is_active: true, sort_order: 20 },
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'v1', dimension_id: 'dim-6', code: 'P001', name: 'Villa Almgren takrenovering', is_active: true, start_date: null, end_date: null },
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: [makeLineRow(1)], error: null }) // line match query
|
||||
enqueue({ data: { id: 'op-retag-2' }, error: null }) // pending_operations insert
|
||||
|
||||
const result = (await tagJournalLines.execute(
|
||||
{
|
||||
dimensions: { '6': 'villa almgren tak' },
|
||||
reason: 'Retro-taggning',
|
||||
filters: { accounts: ['4010'], only_untagged: true },
|
||||
},
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
)) as {
|
||||
staged: boolean
|
||||
preview: {
|
||||
dimensions: Record<string, string>
|
||||
filter_summary: string
|
||||
dimension_resolutions?: Array<{ dimension: number; input: string; resolved_code: string }>
|
||||
}
|
||||
}
|
||||
|
||||
expect(result.staged).toBe(true)
|
||||
// The staged bag carries the resolved registry CODE, never the raw name.
|
||||
expect(result.preview.dimensions).toEqual({ '6': 'P001' })
|
||||
expect(result.preview.filter_summary).toMatch(/endast otaggade rader/)
|
||||
expect(result.preview.dimension_resolutions).toHaveLength(1)
|
||||
expect(result.preview.dimension_resolutions![0]).toMatchObject({
|
||||
dimension: 6,
|
||||
input: 'villa almgren tak',
|
||||
resolved_code: 'P001',
|
||||
})
|
||||
expect((supabase.rpc as ReturnType<typeof vi.fn>).mock.calls[0][0]).toBe('ensure_company_dimensions')
|
||||
})
|
||||
|
||||
it('rejects before staging when a name has no registry match (no auto-create)', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { dimensions_enabled: true }, error: null })
|
||||
enqueue({ data: null, error: null }) // ensure rpc
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false, is_system: true, is_active: true, sort_order: 20 },
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'v1', dimension_id: 'dim-6', code: 'P001', name: 'Villa Almgren', is_active: true, start_date: null, end_date: null },
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
await expect(
|
||||
tagJournalLines.execute(
|
||||
{ dimensions: { '6': 'Bryggeriet ombyggnad' }, reason: 'Retro-taggning', filters: { accounts: ['4010'] } },
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
),
|
||||
).rejects.toThrow(/Okänt projekt[\s\S]*gnubok_create_dimension_value/)
|
||||
|
||||
const insertCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls
|
||||
expect(insertCalls.some((args) => args[0] === 'pending_operations')).toBe(false)
|
||||
})
|
||||
|
||||
it('dry_run previews the match without inserting a pending operation', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { dimensions_enabled: false }, error: null })
|
||||
enqueue({ data: [makeLineRow(1)], error: null }) // line match query
|
||||
|
||||
const result = (await tagJournalLines.execute(
|
||||
{
|
||||
dimensions: { '6': 'P01' },
|
||||
reason: 'Retro-taggning',
|
||||
filters: { accounts: ['4010'] },
|
||||
dry_run: true,
|
||||
},
|
||||
'company-1',
|
||||
'user-1',
|
||||
supabase as never,
|
||||
)) as { staged: boolean; dry_run?: boolean; preview: { matched_lines: number } }
|
||||
|
||||
expect(result.staged).toBe(false)
|
||||
expect(result.dry_run).toBe(true)
|
||||
expect(result.preview.matched_lines).toBe(1)
|
||||
const insertCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls
|
||||
expect(insertCalls.some((args) => args[0] === 'pending_operations')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -37,6 +37,7 @@ import type { SkillTier } from './skills'
|
||||
import { getRiskLevel } from '@/lib/pending-operations/risk-tiers'
|
||||
import { CreateSupplierParamsSchema } from '@/lib/pending-operations/schemas/create-supplier'
|
||||
import { CreateDimensionValueParamsSchema } from '@/lib/pending-operations/schemas/dimension-value'
|
||||
import { RetagLineDimensionsParamsSchema, RETAG_MAX_LINES } from '@/lib/pending-operations/schemas/retag-line-dimensions'
|
||||
import {
|
||||
ensureCompanyDimensions,
|
||||
fetchDimensionRegistry,
|
||||
@@ -4831,6 +4832,218 @@ export const tools: McpTool[] = [
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_tag_journal_lines',
|
||||
title: 'Tag Journal Lines (Bulk Retag)',
|
||||
description: "Bulk-tag POSTED journal lines with dimensions (kostnadsställe/projekt) selected by a filter block — e.g. all 4010 lines with 'Bygg AB' in 2024 → P01. Stages for approval; max 500 lines. Retags internal reporting only — the verifikat stays immutable, every change logged.",
|
||||
outputSchema: STAGED_OPERATION_SCHEMA,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
dimensions: {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'string' },
|
||||
description: 'Dimensions bag applied to every matched line, REPLACING its current bag: {"<sie_dim_no>":"<kod eller namn>"}, e.g. {"6":"P01"}. Values may be registry codes or names — resolved server-side (resolve-don\'t-select).',
|
||||
},
|
||||
reason: {
|
||||
type: 'string',
|
||||
minLength: 3,
|
||||
maxLength: 500,
|
||||
description: 'Why the lines are retagged — stored per line in the immutable dimension_retag_log.',
|
||||
},
|
||||
filters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
description: 'Line selection — at least one filter required. Preview the match set with gnubok_query_journal (same filter fields) first.',
|
||||
properties: {
|
||||
account_from: { type: 'string', description: 'Lowest account number (inclusive), e.g. "4010".' },
|
||||
account_to: { type: 'string', description: 'Highest account number (inclusive).' },
|
||||
accounts: { type: 'array', items: { type: 'string' }, description: 'Specific account numbers (overrides account_from/account_to). Up to 50.' },
|
||||
date_from: { type: 'string', description: 'Earliest entry date (YYYY-MM-DD, inclusive).' },
|
||||
date_to: { type: 'string', description: 'Latest entry date (YYYY-MM-DD, inclusive).' },
|
||||
text: { type: 'string', maxLength: 200, description: 'Case-insensitive substring match on the ENTRY description (verifikattext) — line descriptions are not searched.' },
|
||||
only_untagged: { type: 'boolean', description: 'Only lines whose dimensions bag is exactly empty ({}). Lines already carrying ANY dimension are excluded — partially tagged lines do not match.' },
|
||||
},
|
||||
},
|
||||
dry_run: {
|
||||
type: 'boolean',
|
||||
description: 'If true, validate inputs and return the would-be preview without staging. No DB writes, no side-effects.',
|
||||
},
|
||||
idempotency_key: {
|
||||
type: 'string',
|
||||
description: 'Random per-operation UUID. Repeat calls with the same key + same payload return the original response (24h TTL). Different payload → IDEMPOTENCY_KEY_REUSE error.',
|
||||
},
|
||||
},
|
||||
required: ['dimensions', 'reason', 'filters'],
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, companyId, userId, supabase, actor) {
|
||||
const { dry_run, idempotency_key } = args
|
||||
|
||||
const reason = typeof args.reason === 'string' ? args.reason.trim() : ''
|
||||
if (reason.length < 3 || reason.length > 500) {
|
||||
throw new Error('reason must be 3–500 characters — it is stored in the immutable dimension_retag_log.')
|
||||
}
|
||||
|
||||
const inputBag = parseDimensionsArg(args.dimensions, 'dimensions')
|
||||
if (!inputBag) {
|
||||
throw new Error('dimensions must contain at least one {"<sie_dim_no>":"<kod eller namn>"} pair, e.g. {"6":"P01"}.')
|
||||
}
|
||||
|
||||
// ── Filters — validated before any DB work so bad input fails fast.
|
||||
const filters = (args.filters && typeof args.filters === 'object' ? args.filters : {}) as Record<string, unknown>
|
||||
const accounts = Array.isArray(filters.accounts) ? (filters.accounts as string[]) : undefined
|
||||
if (accounts && accounts.length > 50) {
|
||||
throw new Error('filters.accounts is capped at 50 — use account_from/account_to for ranges')
|
||||
}
|
||||
const accountFrom = typeof filters.account_from === 'string' ? filters.account_from : undefined
|
||||
const accountTo = typeof filters.account_to === 'string' ? filters.account_to : undefined
|
||||
const dateFrom = typeof filters.date_from === 'string' ? filters.date_from : undefined
|
||||
const dateTo = typeof filters.date_to === 'string' ? filters.date_to : undefined
|
||||
const text = typeof filters.text === 'string' ? filters.text.trim() : ''
|
||||
if (text.length > 200) {
|
||||
throw new Error('filters.text must be 200 characters or shorter')
|
||||
}
|
||||
const onlyUntagged = filters.only_untagged === true
|
||||
|
||||
const hasFilter = Boolean(
|
||||
(accounts && accounts.length > 0) || accountFrom || accountTo || dateFrom || dateTo || text || onlyUntagged,
|
||||
)
|
||||
if (!hasFilter) {
|
||||
throw new Error(
|
||||
'Ange minst ett filter (konto, datum, text eller only_untagged) — en företagsbred omtaggning måste avgränsas. ' +
|
||||
'Förhandsgranska träffmängden med gnubok_query_journal.',
|
||||
)
|
||||
}
|
||||
|
||||
// ── Resolve the bag (names → registry codes; resolve-don't-select).
|
||||
// DimensionResolutionError propagates with candidates/create-first
|
||||
// guidance — nothing unresolved is ever staged.
|
||||
const { bags, resolutions } = await resolveDimensionBags(supabase, companyId, [inputBag])
|
||||
const resolvedBag = bags[0] as Record<string, string>
|
||||
|
||||
// ── Match the lines. POSTED entries only — drafts are edited directly
|
||||
// (the retag RPC rejects them too). Fetch cap+1 to detect overflow.
|
||||
let q = supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('id, account_number, debit_amount, credit_amount, sort_order, journal_entries!inner(id, entry_date, voucher_number, voucher_series, status, company_id)')
|
||||
.eq('journal_entries.company_id', companyId)
|
||||
.eq('journal_entries.status', 'posted')
|
||||
|
||||
if (accounts && accounts.length > 0) {
|
||||
q = q.in('account_number', accounts)
|
||||
} else {
|
||||
if (accountFrom) q = q.gte('account_number', accountFrom)
|
||||
if (accountTo) q = q.lte('account_number', accountTo)
|
||||
}
|
||||
if (dateFrom) q = q.gte('journal_entries.entry_date', dateFrom)
|
||||
if (dateTo) q = q.lte('journal_entries.entry_date', dateTo)
|
||||
if (text) {
|
||||
// LIKE wildcards escaped so the filter matches literal % / _ — same
|
||||
// treatment as gnubok_query_journal's text legs. v1 searches the
|
||||
// ENTRY description only (documented in the schema); the two-leg
|
||||
// line+entry union query_journal runs is overkill for a write filter.
|
||||
const escaped = text.replace(/[%]/g, '\\%').replace(/_/g, '\\_')
|
||||
q = q.ilike('journal_entries.description', `%${escaped}%`)
|
||||
}
|
||||
// Pragmatic v1 (documented in the schema): only-untagged means the bag
|
||||
// is EXACTLY '{}' (column is NOT NULL DEFAULT '{}'). Partially tagged
|
||||
// lines (e.g. only dim 1 set) do not match.
|
||||
if (onlyUntagged) q = q.filter('dimensions', 'eq', '{}')
|
||||
|
||||
const res = await q
|
||||
.order('entry_date', { foreignTable: 'journal_entries', ascending: false })
|
||||
.order('voucher_number', { foreignTable: 'journal_entries', ascending: false })
|
||||
.order('sort_order', { ascending: true })
|
||||
.limit(RETAG_MAX_LINES + 1)
|
||||
|
||||
if (res.error) {
|
||||
log.warn('tag_journal_lines match query failed', { companyId, userId, error: res.error.message })
|
||||
throw new Error('Database error while matching journal lines')
|
||||
}
|
||||
|
||||
type MatchedRow = {
|
||||
id: string
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
sort_order: number
|
||||
journal_entries: { id: string; entry_date: string; voucher_number: number; voucher_series: string }
|
||||
}
|
||||
const rows = (res.data ?? []) as unknown as MatchedRow[]
|
||||
|
||||
if (rows.length === 0) {
|
||||
throw new Error(
|
||||
'Inga bokförda rader matchade filtret. Kontrollera konto/datum/text — förhandsgranska med gnubok_query_journal (samma filterfält).',
|
||||
)
|
||||
}
|
||||
if (rows.length > RETAG_MAX_LINES) {
|
||||
throw new Error(
|
||||
`Filtret matchar fler än ${RETAG_MAX_LINES} rader — snäva av det (kortare datumintervall, färre konton) och kör i omgångar om högst ${RETAG_MAX_LINES}.`,
|
||||
)
|
||||
}
|
||||
|
||||
// Human description of the selection, carried on the op for the
|
||||
// approval preview (the executor acts on line_ids verbatim).
|
||||
const summaryParts: string[] = []
|
||||
if (accounts && accounts.length > 0) summaryParts.push(`konto ${accounts.join(', ')}`)
|
||||
else if (accountFrom || accountTo) summaryParts.push(`konto ${accountFrom ?? '…'}–${accountTo ?? '…'}`)
|
||||
if (dateFrom || dateTo) summaryParts.push(`datum ${dateFrom ?? '…'}–${dateTo ?? '…'}`)
|
||||
if (text) summaryParts.push(`text "${text}"`)
|
||||
if (onlyUntagged) summaryParts.push('endast otaggade rader')
|
||||
const filterSummary = summaryParts.join(', ').slice(0, 500)
|
||||
|
||||
const bagLabel = Object.entries(resolvedBag)
|
||||
.map(([dim, code]) => `${dim}=${code}`)
|
||||
.join(', ')
|
||||
|
||||
// Same Zod schema the commit executor re-validates with — the staged
|
||||
// params can never drift from what commitRetagLineDimensions accepts.
|
||||
const params = RetagLineDimensionsParamsSchema.parse({
|
||||
line_ids: rows.map((r) => r.id),
|
||||
dimensions: resolvedBag,
|
||||
reason,
|
||||
filter_summary: filterSummary,
|
||||
})
|
||||
|
||||
// No dateForPeriodCheck: the matched lines span dates; the retag RPC
|
||||
// enforces open-period + lock-date per line at commit time.
|
||||
return stagePendingOperation(supabase, companyId, userId, 'retag_line_dimensions',
|
||||
`Tagga om ${rows.length} verifikationsrader: ${bagLabel}`,
|
||||
params as unknown as Record<string, unknown>,
|
||||
{
|
||||
matched_lines: rows.length,
|
||||
dimensions: resolvedBag,
|
||||
filter_summary: filterSummary,
|
||||
sample: rows.slice(0, 10).map((r) => ({
|
||||
account: r.account_number,
|
||||
date: r.journal_entries.entry_date,
|
||||
debit: r.debit_amount,
|
||||
credit: r.credit_amount,
|
||||
})),
|
||||
...(resolutions.length > 0 ? { dimension_resolutions: resolutions } : {}),
|
||||
will: 'replace the dimensions bag on every matched POSTED line via the audited retag RPC — internal reporting only, the verifikat itself is untouched',
|
||||
},
|
||||
actor,
|
||||
{
|
||||
description: 'After approval, verify the retag with gnubok_query_journal (group_by_dimension) or gnubok_get_dimension_pnl.',
|
||||
tool: 'gnubok_query_journal',
|
||||
args: { group_by_dimension: Object.keys(resolvedBag)[0] },
|
||||
},
|
||||
{
|
||||
dryRun: Boolean(dry_run),
|
||||
idempotencyKey: typeof idempotency_key === 'string' ? idempotency_key : undefined,
|
||||
}
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_get_dimension_pnl',
|
||||
title: 'P&L per Dimension (Resultat per projekt)',
|
||||
|
||||
Reference in New Issue
Block a user