Files
accounted/lib/import/__tests__/sie-dimensions-roundtrip.test.ts
T
Jakob WennbergandClaude Fable 5 fb3fe82a56 feat(dimensions): PR5 SIE round-trip — lossless dimension import + undo lockstep (#866)
* feat(dimensions): PR5 SIE round-trip — lossless dimension import, registry upsert, undo lockstep

SIE import previously parsed and silently DISCARDED all dimension data
(object lists at sie-parser.ts:651-654, #DIM/#OBJEKT in the ignore list at
:689). Import is now lossless — the dimensions plan PR5 milestone.

Parser: #TRANS object lists ({1 "KS01" 6 "P001"}) land on the line as an
SIE-dim-no → code map (canonical numeric keys, quoted codes, malformed
pairs warn); #DIM/#UNDERDIM/#OBJEKT parse into registry records. OIB/OUB
stay ignored (dimension reporting is P&L-only in v1).

Importer (lib/import/sie-dimensions.ts): upserts missing dimensions/
dimension_values rows — never renames existing ones (ON CONFLICT DO
NOTHING); undeclared reserved numbers synthesize their SIE-standard names
(mirroring the export's orphan synthesis); codes violating the registry
CHECK are skipped with a warning but survive verbatim on lines (documented
legacy-free-text exception). Bulk voucher insert now writes the dimensions
jsonb + cost_center/project mirrors via the sanctioned dual-write helpers
(no trigger suppression needed — the immutability trigger guards
UPDATE/DELETE, not INSERT). Import auto-enables dimensions_enabled with a
result-card notice (pre-authorized by the column comment). arcim-migration
provider syncs inherit all of it via the shared parser/importer.

Undo lockstep (migration 20260702154500): created_by_import_id provenance
on both registry tables (ON DELETE SET NULL); undo_sie_import deletes the
values/dimensions the undone import introduced when no remaining
posted/reversed line references them — user-created rows and rows other
bookkeeping references are untouched. The registry guard triggers act as
backstop. replace_sie_import deliberately skips the lockstep (re-import
re-upserts the same codes). Six pg-real tests cover the lockstep.

Round-trip pinned by test: parse → import state → export → parse preserves
declarations (#UNDERDIM parent links included), values, and per-line object
lists — including synthesis of referenced-but-undeclared values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dimensions): restate function-local statement_timeout on undo_sie_import

CREATE OR REPLACE resets proconfig, so the 290s timeout from 20260629160100
was silently dropped — regressing service-client bulk deletes to the
authenticator role's 8s limit. Caught by sie-import.replace.pg.test.ts in CI.
Full pg-real suite green (483/483, TZ=UTC).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dimensions): surface OIB/OUB drops and dimension presence as parse-level info (#866 review)

Dropping object-balance records must never be silent — one info issue counts
the skipped #OIB/#OUB rows (object-level balances are P&L-out-of-scope in
v1), and a second announces dimension data before the user executes the
import (the preview step renders parse issues), so the auto-enable notice is
no longer purely post-hoc.

Triage notes for the remaining findings: the RPC's opening SELECT is the
company-ownership check the swarm asked for; registry writes are RLS-bound;
line-verbatim codes are the documented legacy-free-text exception; export
emits no #KSUMMA so there is nothing to recompute; SIE dims 3–5 are
"reserved for future use" with no standard names, so generic synthesis is
spec-correct; ON DELETE SET NULL is deliberate — provenance is operational
metadata for undo, not räkenskapsinformation (the guarded journal lines
are), and RESTRICT would block legitimate post-retention housekeeping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:05:13 +02:00

219 lines
8.6 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { parseSIEFile } from '../sie-parser'
import { collectSIEDimensionUsage } from '../sie-dimensions'
import { normalizeLineDimensions } from '@/lib/bookkeeping/dimension-resolver'
import { generateSIEExport } from '@/lib/reports/sie-export'
// ============================================================
// Dimensions plan PR5 — the lossless round-trip guarantee.
//
// parse(source) → what import writes (registry rows + line dimension maps)
// → generateSIEExport over exactly that state → parse(exported) must carry
// the same dimension surface: #DIM/#UNDERDIM declarations, #OBJEKT values,
// and per-line object lists. The comparison is structural (both files run
// through the same parser), so formatting/order differences don't matter.
// ============================================================
// Sequential-queue supabase mock — same consumption order as
// sie-export.test.ts documents:
// 0 fiscal_periods.single, 1 prev period, 2 accounts, 3 journal_entries,
// 4 journal_entry_lines, 5 dimensions, 6 dimension_values, 7 OB fallback
let resultIdx: number
let results: Array<{ data?: unknown; error?: unknown }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'in', 'order', 'range', 'lt', 'lte', 'gte', 'gt', 'limit', 'neq', 'eq']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
return b
}
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
rpc: vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
}
beforeEach(() => {
resultIdx = 0
results = []
})
const SOURCE_SIE = [
'#FLAGGA 0',
'#SIETYP 4',
'#FNAMN "Roundtrip AB"',
'#RAR 0 20260101 20261231',
'#KONTO 5010 "Lokalhyra"',
'#KONTO 1930 "Företagskonto"',
'#DIM 1 "Kostnadsställe"',
'#DIM 6 "Projekt"',
'#UNDERDIM 2 "Kostnadsbärare" 1',
'#OBJEKT 1 "KS01" "Butiken"',
'#OBJEKT 2 "KB1" "Bärare ett"',
'#OBJEKT 6 "P001" "Villa Almgren"',
'#VER A 1 20260115 "Hyra januari"',
'{',
'#TRANS 5010 {1 "KS01" 2 "KB1" 6 "P001"} 15000.00',
'#TRANS 1930 {} -15000.00',
'}',
'#VER A 2 20260116 "Odeklarerat projekt"',
'{',
// P002 is referenced but never declared via #OBJEKT — import synthesizes
// it (name = code) and export must re-declare it.
'#TRANS 5010 {6 "P002"} 500.00',
'#TRANS 1930 {} -500.00',
'}',
].join('\n')
describe('SIE dimensions round-trip', () => {
it('parse → import state → export → parse preserves the dimension surface', async () => {
const parsedSource = parseSIEFile(SOURCE_SIE)
expect(parsedSource.issues.filter((i) => i.severity === 'error')).toEqual([])
// ── What the importer writes ─────────────────────────────────
const usage = collectSIEDimensionUsage(parsedSource)
// Registry rows exactly as importDimensionRegistry inserts them.
const dimensionRows = [...usage.dims.entries()].map(([sieDimNo, info], i) => ({
id: `dim-${sieDimNo}`,
sie_dim_no: sieDimNo,
parent_sie_dim_no: info.parent ?? null,
name: info.name,
sort_order: i,
}))
const valueRows = [...usage.values.values()].map((v) => ({
dimension_id: `dim-${v.sieDimNo}`,
code: v.code,
name: v.name,
}))
// Journal lines exactly as importVouchers inserts them (dimensions jsonb
// via normalizeLineDimensions; SIE amount sign → debit/credit).
const journalEntries = parsedSource.vouchers.map((v, i) => ({
id: `e${i + 1}`,
entry_date: '2026-01-15',
voucher_number: v.number,
voucher_series: v.series,
description: v.description,
status: 'posted',
}))
const journalLines = parsedSource.vouchers.flatMap((v, i) =>
v.lines.map((line) => ({
journal_entry_id: `e${i + 1}`,
account_number: line.account,
debit_amount: line.amount > 0 ? line.amount : 0,
credit_amount: line.amount < 0 ? Math.abs(line.amount) : 0,
line_description: line.description ?? null,
dimensions: normalizeLineDimensions({ dimensions: line.dimensions ?? null }),
}))
)
// ── Export over exactly that state ───────────────────────────
results = [
{ data: { id: 'period-1', period_start: '2026-01-01', period_end: '2026-12-31' }, error: null },
{ data: null, error: null }, // prev period
{
data: [
{ account_number: '5010', account_name: 'Lokalhyra', sru_code: null, is_active: true },
{ account_number: '1930', account_name: 'Företagskonto', sru_code: null, is_active: true },
],
error: null,
},
{ data: journalEntries, error: null },
{ data: journalLines, error: null },
{ data: dimensionRows, error: null },
{ data: valueRows, error: null },
{ data: [], error: null }, // OB fallback
]
const exported = await generateSIEExport(makeClient(), 'company-1', {
fiscal_period_id: 'period-1',
company_name: 'Roundtrip AB',
org_number: null,
program_name: 'ERPBase',
})
// ── Compare the two dimension surfaces structurally ──────────
const parsedExport = parseSIEFile(exported)
expect(parsedExport.issues.filter((i) => i.severity === 'error')).toEqual([])
const dimSet = (dims: typeof parsedSource.dimensions) =>
new Set(dims.map((d) => `${d.sieDimNo}|${d.name}|${d.parentSieDimNo ?? ''}`))
// Every declaration that carries data survives — including the
// #UNDERDIM child with its parent link. (A declared dimension with no
// values and no tagged lines is metadata without data; export
// deliberately omits it, so the fixture gives every dim a value.)
for (const entry of dimSet(parsedSource.dimensions)) {
expect(dimSet(parsedExport.dimensions)).toContain(entry)
}
const valueSet = (values: typeof parsedSource.dimensionValues) =>
new Set(values.map((v) => `${v.sieDimNo}|${v.code}|${v.name}`))
for (const entry of valueSet(parsedSource.dimensionValues)) {
expect(valueSet(parsedExport.dimensionValues)).toContain(entry)
}
// The undeclared-but-referenced P002 got synthesized (name = code).
expect(valueSet(parsedExport.dimensionValues)).toContain('6|P002|P002')
// Per-line object lists survive verbatim.
const lineDims = (parsed: typeof parsedSource) =>
parsed.vouchers.flatMap((v) => v.lines.map((l) => l.dimensions ?? null))
expect(lineDims(parsedExport)).toEqual(lineDims(parsedSource))
})
it('collectSIEDimensionUsage synthesizes reserved names and prefers declared ones', () => {
const parsed = parseSIEFile(
[
'#FLAGGA 0',
'#SIETYP 4',
'#RAR 0 20260101 20261231',
// Dim 7 (reserved: Anställd) referenced without declaration; dim 6
// declared with a custom name that must win over "Projekt".
'#DIM 6 "Mina projekt"',
'#VER A 1 20260115 "Löner"',
'{',
'#TRANS 7010 {7 "ANNA" 6 "P001" 42 "X"} 1000.00',
'#TRANS 1930 {} -1000.00',
'}',
].join('\n')
)
const usage = collectSIEDimensionUsage(parsed)
expect(usage.dims.get(7)?.name).toBe('Anställd')
expect(usage.dims.get(6)?.name).toBe('Mina projekt')
// Unknown custom number falls back to a generic label.
expect(usage.dims.get(42)?.name).toBe('Dimension 42')
expect(usage.taggedLines).toBe(1)
expect([...usage.values.keys()].sort()).toEqual(['42 X', '6 P001', '7 ANNA'])
})
it('rejects registry codes that violate the DB CHECK but keeps them on lines', () => {
const longCode = 'X'.repeat(41)
const parsed = parseSIEFile(
[
'#FLAGGA 0',
'#SIETYP 4',
'#RAR 0 20260101 20261231',
'#VER A 1 20260115 "För lång kod"',
'{',
`#TRANS 5010 {6 "${longCode}"} 100.00`,
'#TRANS 1930 {} -100.00',
'}',
].join('\n')
)
// The line keeps the tag (legacy free-text survives on lines)…
expect(parsed.vouchers[0].lines[0].dimensions).toEqual({ '6': longCode })
// …but the registry collection skips it and reports why.
const usage = collectSIEDimensionUsage(parsed)
expect(usage.values.size).toBe(0)
expect(usage.invalidCodes.size).toBe(1)
})
})