feat(dimensions): PR2 registry — CRUD API, register UI, settings toggle, SIE export on the new registry (#858)

* feat(dimensions): PR2 registry — CRUD API, register UI, settings toggle, SIE export on the new registry

Phase 2 of dev_docs/dimensions_implementation_plan.md. Companies with
dimensions_enabled=false (default) see zero change.

API:
- Dashboard CRUD: GET /api/dimensions (lazy-seeds system dims 1/6 via the
  ensure_company_dimensions RPC), PATCH /api/dimensions/[id] (is_system
  rename blocked), POST/PATCH/DELETE values (code immutable after creation;
  strict Fortnox code format ^[A-Za-z0-9ÅÄÖåäö_+\-]{1,20}$ at the API layer;
  retention-trigger deletes surface the Swedish "arkivera istället" message
  as 409 DIMENSION_VALUE_REFERENCED).
- POST /api/dimensions/import-existing — scans journal_entry_lines.dimensions
  for unregistered codes and mints inactive placeholder registry rows.
- v1 public API: GET dimensions + POST values (Idempotency-Key, dry-run),
  registered in the OpenAPI spec (102→104 endpoints).
- dimensions_enabled boolean on company_settings (new migration,
  UI-visibility only, never correctness-bearing) exposed through the
  existing settings read/update path.

SIE export (lib/reports/sie-export.ts):
- Reads the new dimensions/dimension_values registry; legacy
  cost_centers/projects tables now have zero readers (drop migration next).
- Fixes the latent Visma-rejection bug: #OBJEKT now declared for INACTIVE
  values referenced by lines.
- Generic-N: #DIM/#UNDERDIM loop sorted by sie_dim_no; #TRANS object lists
  serialize from the line JSONB map (sorted, '01'→'1' collapse); orphan
  codes/dims synthesize declarations from the SIE reserved-number seed —
  every referenced (dim, code) pair is guaranteed declared.

UI:
- /dimensions register (Register-recipe): tabs per dimension, search,
  sortable table, value dialog (code immutable on edit, projekt dates on
  dim 6), archive-not-delete affordances.
- DimensionCombobox shipped (mounts in the tagging PR).
- Settings toggle "Aktivera kostnadsställen & projekt" — toggle-on runs the
  import-existing scan and links to the register.
- Nav row in redovisning, rendered only when dimensions_enabled (same
  mechanism as pays_salaries).
- dimensions.* i18n namespace (51 keys, sv/en parity).
- Sandbox seed: demo dims + values, revenue line tagged {"1":"BUTIK","6":"P001"}.

Verified: 6328/6328 unit tests, guard + coverage gate green, tsc parity with
main (210=210), production build passes.

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

* fix(dimensions): PR2 review round — atomic archived-create, UNDERDIM ordering, import robustness, date semantics

- POST values accepts is_active so "create as archived" is atomic; the UI's
  fragile create-then-PATCH fallback is deleted (PR Agent finding 1).
- DimensionCombobox blur revert reads the committed value/values through refs
  so a selection landing inside the 150ms window always wins (finding 2).
- import-existing sanitizes candidate codes like the PR1 backfill and upserts
  with ignoreDuplicates — one bad/duplicate code can no longer abort the
  batch; created counted from returned rows (finding 3).
- SIE export emits all root #DIM before any #UNDERDIM so a parent always
  precedes a lower-numbered child (SIE4 declaration order — Swedish review);
  synthesized placeholder declarations now log one structured warning
  (BFNAR 2013:2 behandlingshistorik) + defence-in-depth comment.
- Value dates rejected (400 DIMENSION_VALUE_DATES_NOT_ALLOWED) when the
  parent dimension is flow-period (resets_annually=true); explicit null
  still clears (Swedish review).
- Sandbox seed logs seeded dimension codes; GET /api/dimensions documents
  the deliberate absence of dimensions_enabled gating (UI-visibility flag,
  not a security boundary — compliance-swarm V8.2.1 rejected by design).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-02 12:26:42 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 8cc2efb083
commit 8bb49c07a2
35 changed files with 3981 additions and 100 deletions
+64
View File
@@ -713,6 +713,67 @@ export const CorrectJournalEntrySchema = z.object({
lines: z.array(CreateJournalEntryLineSchema).min(2, 'At least two lines are required for double-entry'),
})
// ============================================================
// Dimension registry schemas (kostnadsställe/projekt)
// ============================================================
// dev_docs/dimensions_implementation_plan.md §6. The registry tables
// (dimensions/dimension_values) shipped in 20260702084500_dimensions_substrate.
/**
* Object code for USER-CREATED dimension values: strict Fortnox format.
* Deliberately tighter than both the DB CHECK (1..40 chars, no `"{}`') and
* DimensionsBagSchema (line-level values) — legacy free-text codes from the
* backfill/SIE import must survive on lines, but new registry codes minted
* through the API stay portable to Fortnox/Visma.
*/
const dimensionValueCode = z
.string()
.regex(
/^[A-Za-z0-9ÅÄÖåäö_+\-]{1,20}$/,
'Koden får bara innehålla bokstäver (A–Ö), siffror, _, + och - (max 20 tecken)',
)
const dimensionValueDates = {
start_date: isoDate.nullable().optional(),
end_date: isoDate.nullable().optional(),
}
/** PATCH /api/dimensions/[id] — name is rejected route-side for is_system dims. */
export const UpdateDimensionSchema = z
.object({
name: z.string().min(1).max(80).optional(),
is_active: z.boolean().optional(),
sort_order: z.number().int().min(0).optional(),
})
.refine((body) => Object.values(body).some((v) => v !== undefined), {
message: 'Minst ett fält måste anges',
})
/** POST /api/dimensions/[id]/values — code is immutable after creation (v1: no rename). */
export const CreateDimensionValueSchema = z
.object({
code: dimensionValueCode,
name: z.string().min(1).max(120),
/** Omitted → true. Lets "create as archived" be a single atomic POST. */
is_active: z.boolean().optional(),
...dimensionValueDates,
})
.refine(
(body) => !body.start_date || !body.end_date || body.end_date >= body.start_date,
{ message: 'Slutdatum får inte vara före startdatum', path: ['end_date'] },
)
/** PATCH /api/dimensions/[id]/values/[valueId] — no `code` field by design. */
export const UpdateDimensionValueSchema = z
.object({
name: z.string().min(1).max(120).optional(),
is_active: z.boolean().optional(),
...dimensionValueDates,
})
.refine((body) => Object.values(body).some((v) => v !== undefined), {
message: 'Minst ett fält måste anges',
})
/**
* Move a posted verifikation to a different date (and thereby fiscal period)
* without changing its lines — fixes a booking entered with the wrong
@@ -1172,6 +1233,9 @@ export const UpdateSettingsSchema = z.object({
.optional(),
// AI agent flow
ai_flow_enabled: z.boolean().optional(),
// Dimensions (kostnadsställe/projekt) — UI-visibility toggle only, never
// load-bearing for correctness (dev_docs/dimensions_implementation_plan.md §2).
dimensions_enabled: z.boolean().optional(),
// Salary payment file
preferred_payment_format: z.enum(['bg_lb', 'pain001']).optional(),
}).refine(
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `102`;
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `104`;
exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-keys 1`] = `
[
@@ -14,6 +14,7 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
"GET /api/v1/companies/:companyId/compliance/check",
"GET /api/v1/companies/:companyId/customers",
"GET /api/v1/companies/:companyId/customers/:id",
"GET /api/v1/companies/:companyId/dimensions",
"GET /api/v1/companies/:companyId/documents/:id/download",
"GET /api/v1/companies/:companyId/employees",
"GET /api/v1/companies/:companyId/employees/:id",
@@ -60,6 +61,7 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
"PATCH /api/v1/companies/:companyId/webhooks/:id",
"POST /api/v1/companies/:companyId/customers",
"POST /api/v1/companies/:companyId/customers/bulk-create",
"POST /api/v1/companies/:companyId/dimensions/:id/values",
"POST /api/v1/companies/:companyId/documents",
"POST /api/v1/companies/:companyId/documents/:id/link",
"POST /api/v1/companies/:companyId/employees",
+4
View File
@@ -132,4 +132,8 @@ import '@/app/api/v1/companies/[companyId]/webhooks/[id]/rotate-secret/route'
// Inbox item stamp.
import '@/app/api/v1/companies/[companyId]/inbox-items/[id]/stamp/route'
// Dimensions PR2 — registry list + value creation (kostnadsställe/projekt).
import '@/app/api/v1/companies/[companyId]/dimensions/route'
import '@/app/api/v1/companies/[companyId]/dimensions/[id]/values/route'
export {}
+6
View File
@@ -181,6 +181,12 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
'POST /api/v1/companies/:companyId/salary-runs/:id/book': 'payroll:write',
'POST /api/v1/companies/:companyId/salary-runs/:id/generate-agi': 'payroll:write',
// Dimensions (kostnadsställe/projekt) — dimensions PR2. Reads ride
// reports:read (registry data feeds report filters/pickers); value creation
// is bookkeeping:write (it mints codes that journal lines reference).
'GET /api/v1/companies/:companyId/dimensions': 'reports:read',
'POST /api/v1/companies/:companyId/dimensions/:id/values': 'bookkeeping:write',
// Webhooks (Phase 6 PR-1)
'GET /api/v1/companies/:companyId/webhooks': 'webhooks:manage',
'POST /api/v1/companies/:companyId/webhooks': 'webhooks:manage',
+70
View File
@@ -2462,6 +2462,75 @@ const ASSETS: Record<string, StructuredErrorEntry> = {
},
}
// Dimensions registry (kostnadsställe/projekt) — dev_docs/dimensions_implementation_plan.md §6
const DIMENSION: Record<string, StructuredErrorEntry> = {
DIMENSION_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Dimensionen kunde inte hittas.',
message_en: 'Dimension not found.',
},
DIMENSION_SYSTEM_RENAME: {
httpStatus: 400,
message_sv: 'Systemdimensioner kan inte döpas om.',
message_en: 'System dimensions (kostnadsställe/projekt) cannot be renamed.',
},
DIMENSION_UPDATE_FAILED: {
httpStatus: 500,
message_sv: 'Dimensionen kunde inte uppdateras.',
message_en: 'Failed to update dimension.',
},
DIMENSION_VALUE_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Dimensionsvärdet kunde inte hittas.',
message_en: 'Dimension value not found.',
},
DIMENSION_VALUE_DUPLICATE_CODE: {
httpStatus: 409,
message_sv: 'Ett värde med samma kod finns redan i dimensionen.',
message_en: 'A value with that code already exists in the dimension.',
},
DIMENSION_VALUE_DATES_NOT_ALLOWED: {
httpStatus: 400,
message_sv: 'Datum kan bara sättas på ackumulerande dimensioner (t.ex. projekt).',
message_en:
'Start/end dates can only be set on accumulating dimensions (e.g. projects) — this dimension resets annually.',
},
DIMENSION_VALUE_CREATE_FAILED: {
httpStatus: 500,
message_sv: 'Dimensionsvärdet kunde inte skapas.',
message_en: 'Failed to create dimension value.',
},
DIMENSION_VALUE_UPDATE_FAILED: {
httpStatus: 500,
message_sv: 'Dimensionsvärdet kunde inte uppdateras.',
message_en: 'Failed to update dimension value.',
},
// The DB retention trigger (enforce_dimension_value_retention) raises when a
// code is referenced by posted/reversed lines. Routes surface the trigger's
// own Swedish message via `messageSv` so the code + kod appear in the toast.
DIMENSION_VALUE_REFERENCED: {
httpStatus: 409,
message_sv:
'Värdet används på bokförda verifikat och kan inte tas bort — arkivera det istället.',
message_en:
'The value is referenced by posted vouchers and cannot be deleted — archive (inactivate) it instead.',
remediation: {
description:
'Archive the value instead: PATCH the dimension value with { "is_active": false }. Codes referenced by posted lines are retained for the BFL 7-year period.',
},
},
DIMENSION_VALUE_DELETE_FAILED: {
httpStatus: 500,
message_sv: 'Dimensionsvärdet kunde inte tas bort.',
message_en: 'Failed to delete dimension value.',
},
DIMENSION_IMPORT_FAILED: {
httpStatus: 500,
message_sv: 'Import av befintliga dimensionskoder misslyckades.',
message_en: 'Failed to import existing dimension codes from journal lines.',
},
}
// ─────────────────────────────────────────────────────────────────
// Combined registry
// ─────────────────────────────────────────────────────────────────
@@ -2505,6 +2574,7 @@ const REGISTRY: Record<string, StructuredErrorEntry> = {
...SKATTEVERKET,
...BOLAGSVERKET,
...ASSETS,
...DIMENSION,
}
export function getErrorEntry(code: string): StructuredErrorEntry | undefined {
+292 -59
View File
@@ -6,12 +6,20 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
let resultIdx: number
let results: Array<{ data?: unknown; error?: unknown }>
// `.eq()` args recorded per table so tests can assert on query shape (e.g.
// that the dimension registry fetch does NOT filter is_active — the latent
// undeclared-#OBJEKT bug was exactly such a filter).
let eqCallsByTable: Record<string, Array<[string, unknown]>>
function makeBuilder() {
function makeBuilder(table: string) {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'order', 'range', 'lt', 'lte', 'gte', 'gt', 'limit', 'neq']) {
for (const m of ['select', 'in', 'order', 'range', 'lt', 'lte', 'gte', 'gt', 'limit', 'neq']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.eq = vi.fn().mockImplementation((column: string, value: unknown) => {
;(eqCallsByTable[table] ??= []).push([column, value])
return 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
@@ -19,7 +27,7 @@ function makeBuilder() {
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
from: vi.fn().mockImplementation((table: string) => makeBuilder(table)),
// `rpc` drains the same queue so tests can intersperse RPC + table fetches.
// SIE export calls `compute_prior_opening_balances` via getOpeningBalances
// whenever `opening_balance_entry_id` is null (the multi-year-import path).
@@ -36,6 +44,7 @@ beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
eqCallsByTable = {}
supabase = makeClient()
})
@@ -46,16 +55,20 @@ const baseOptions = {
program_name: 'ERPBase',
}
// Queue consumption order after the pagination fix:
// Queue consumption order (dimensions registry replaced cost_centers/projects):
// 0: fiscal_periods.single()
// 1: previous fiscal period .single() (#RAR -1)
// 2: chart_of_accounts (fetchAllRows)
// 3: journal_entries (fetchAllRows)
// 4: journal_entry_lines (fetchAllRows) ← split out from the entries query
// 5: cost_centers
// 6: projects
// 5: dimensions (registry #DIM/#UNDERDIM rows)
// 6: dimension_values (registry #OBJEKT rows)
// 7: opening balances (RPC fallback or journal_entry_lines page)
// Registry fixtures for the system dims (seeded by ensure_company_dimensions).
const dimKostnadsstalle = { id: 'dim-1', sie_dim_no: 1, parent_sie_dim_no: null, name: 'Kostnadsställe' }
const dimProjekt = { id: 'dim-6', sie_dim_no: 6, parent_sie_dim_no: null, name: 'Projekt' }
describe('generateSIEExport', () => {
it('throws when fiscal period not found', async () => {
results = [
@@ -74,8 +87,8 @@ describe('generateSIEExport', () => {
{ data: [], error: null }, // accounts
{ data: [], error: null }, // journal_entries
{ data: [], error: null }, // journal_entry_lines
{ data: [], error: null }, // cost_centers
{ data: [], error: null }, // projects
{ data: [], error: null }, // dimensions
{ data: [], error: null }, // dimension_values
{ data: [], error: null }, // opening balances RPC
]
@@ -99,8 +112,8 @@ describe('generateSIEExport', () => {
{ data: [], error: null }, // accounts
{ data: [], error: null }, // journal_entries
{ data: [], error: null }, // journal_entry_lines
{ data: [], error: null }, // cost_centers
{ data: [], error: null }, // projects
{ data: [], error: null }, // dimensions
{ data: [], error: null }, // dimension_values
{ data: [], error: null }, // RPC fallback
]
@@ -125,8 +138,8 @@ describe('generateSIEExport', () => {
},
{ data: [], error: null }, // journal_entries
{ data: [], error: null }, // journal_entry_lines
{ data: [], error: null }, // cost_centers
{ data: [], error: null }, // projects
{ data: [], error: null }, // dimensions
{ data: [], error: null }, // dimension_values
{ data: [], error: null }, // RPC fallback
]
@@ -154,14 +167,14 @@ describe('generateSIEExport', () => {
{
// journal_entry_lines — each carries journal_entry_id for grouping
data: [
{ journal_entry_id: 'e1', account_number: '1510', debit_amount: 1250, credit_amount: 0, line_description: null, cost_center: null, project: null },
{ journal_entry_id: 'e1', account_number: '3001', debit_amount: 0, credit_amount: 1000, line_description: 'Revenue', cost_center: null, project: null },
{ journal_entry_id: 'e1', account_number: '2611', debit_amount: 0, credit_amount: 250, line_description: null, cost_center: null, project: null },
{ journal_entry_id: 'e1', account_number: '1510', debit_amount: 1250, credit_amount: 0, line_description: null, dimensions: {} },
{ journal_entry_id: 'e1', account_number: '3001', debit_amount: 0, credit_amount: 1000, line_description: 'Revenue', dimensions: {} },
{ journal_entry_id: 'e1', account_number: '2611', debit_amount: 0, credit_amount: 250, line_description: null, dimensions: {} },
],
error: null,
},
{ data: [], error: null }, // cost_centers
{ data: [], error: null }, // projects
{ data: [], error: null }, // dimensions
{ data: [], error: null }, // dimension_values
{ data: [], error: null }, // RPC fallback
]
@@ -175,22 +188,18 @@ describe('generateSIEExport', () => {
expect(output).toContain('}')
})
it('generates #DIM and #OBJEKT for dimensions', async () => {
it('generates #DIM and #OBJEKT for registry dimensions', async () => {
results = [
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
{ data: null, error: null }, // prevPeriod
{ data: [], error: null }, // accounts
{ data: [], error: null }, // journal_entries
{ data: [], error: null }, // journal_entry_lines
{ data: [dimKostnadsstalle, dimProjekt], error: null }, // dimensions
{
data: [
{ code: 'CC1', name: 'Avdelning 1', is_active: true },
],
error: null,
},
{
data: [
{ code: 'P001', name: 'Projekt Alpha', is_active: true },
{ dimension_id: 'dim-1', code: 'CC1', name: 'Avdelning 1' },
{ dimension_id: 'dim-6', code: 'P001', name: 'Projekt Alpha' },
],
error: null,
},
@@ -205,7 +214,7 @@ describe('generateSIEExport', () => {
expect(output).toContain('#OBJEKT 6 "P001" "Projekt Alpha"')
})
it('includes dimension objects in #TRANS lines', async () => {
it('includes dimension objects in #TRANS lines from the jsonb map', async () => {
results = [
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
{ data: null, error: null }, // prevPeriod
@@ -218,13 +227,19 @@ describe('generateSIEExport', () => {
},
{
data: [
{ journal_entry_id: 'e1', account_number: '5010', debit_amount: 8000, credit_amount: 0, line_description: null, cost_center: 'CC1', project: 'P001' },
{ journal_entry_id: 'e1', account_number: '1930', debit_amount: 0, credit_amount: 8000, line_description: null, cost_center: null, project: null },
{ journal_entry_id: 'e1', account_number: '5010', debit_amount: 8000, credit_amount: 0, line_description: null, dimensions: { '1': 'CC1', '6': 'P001' } },
{ journal_entry_id: 'e1', account_number: '1930', debit_amount: 0, credit_amount: 8000, line_description: null, dimensions: {} },
],
error: null,
},
{ data: [dimKostnadsstalle, dimProjekt], error: null }, // dimensions
{
data: [
{ dimension_id: 'dim-1', code: 'CC1', name: 'Avdelning 1' },
{ dimension_id: 'dim-6', code: 'P001', name: 'Projekt Alpha' },
],
error: null,
},
{ data: [{ code: 'CC1', name: 'Avdelning 1', is_active: true }], error: null },
{ data: [{ code: 'P001', name: 'Projekt Alpha', is_active: true }], error: null },
{ data: [], error: null }, // RPC fallback
]
@@ -234,6 +249,203 @@ describe('generateSIEExport', () => {
expect(output).toContain('\t#TRANS 1930 {} -8000.00 20240315')
})
it('declares INACTIVE registry values as #OBJEKT (undeclared-object regression)', async () => {
// Latent bug in the legacy read path: the registry fetch filtered
// is_active=true, so a line referencing an archived code serialized into
// #TRANS while its #OBJEKT declaration was missing — Visma rejects such
// files. The registry fetch must NOT filter on is_active.
results = [
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
{ data: null, error: null }, // prevPeriod
{ data: [], error: null }, // accounts
{
data: [
{ id: 'e1', entry_date: '2024-05-02', voucher_number: 1, voucher_series: 'A', description: 'Archived code', status: 'posted' },
],
error: null,
},
{
data: [
{ journal_entry_id: 'e1', account_number: '5010', debit_amount: 500, credit_amount: 0, line_description: null, dimensions: { '1': 'CC9' } },
{ journal_entry_id: 'e1', account_number: '1930', debit_amount: 0, credit_amount: 500, line_description: null, dimensions: {} },
],
error: null,
},
{ data: [dimKostnadsstalle], error: null }, // dimensions
// The archived (is_active=false) value row IS returned by the query
// because the export must not filter it out.
{ data: [{ dimension_id: 'dim-1', code: 'CC9', name: 'Nedlagd avdelning', is_active: false }], error: null },
{ data: [], error: null }, // RPC fallback
]
const output = await generateSIEExport(supabase, 'company-1', baseOptions)
// Declared with its registry name (not synthesized code-as-name)
expect(output).toContain('#DIM 1 "Kostnadsställe"')
expect(output).toContain('#OBJEKT 1 "CC9" "Nedlagd avdelning"')
expect(output).toContain('\t#TRANS 5010 {1 "CC9"} 500.00 20240502')
// Query-shape guard: neither registry fetch may filter on is_active —
// that is the exact filter that caused the undeclared-#OBJEKT bug.
expect(eqCallsByTable['dimensions'] ?? []).not.toContainEqual(['is_active', true])
expect(eqCallsByTable['dimension_values'] ?? []).not.toContainEqual(['is_active', true])
})
it('synthesizes #DIM and #OBJEKT for orphan line codes with no registry rows', async () => {
// Free-text writers can still mint dimension numbers/codes until the
// write-path PR — every referenced (dimNo, code) pair must be declared,
// never silently dropped. Dim 6 resolves from the SIE reserved-number
// seed; dim 13 falls back to "Dimension n"; both codes get name = code.
results = [
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
{ data: null, error: null }, // prevPeriod
{ data: [], error: null }, // accounts
{
data: [
{ id: 'e1', entry_date: '2024-02-01', voucher_number: 1, voucher_series: 'A', description: 'Orphans', status: 'posted' },
],
error: null,
},
{
data: [
{ journal_entry_id: 'e1', account_number: '4010', debit_amount: 900, credit_amount: 0, line_description: null, dimensions: { '6': 'GHOST', '13': 'X1' } },
{ journal_entry_id: 'e1', account_number: '1930', debit_amount: 0, credit_amount: 900, line_description: null, dimensions: {} },
],
error: null,
},
{ data: [], error: null }, // dimensions — registry is empty
{ data: [], error: null }, // dimension_values
{ data: [], error: null }, // RPC fallback
]
const output = await generateSIEExport(supabase, 'company-1', baseOptions)
expect(output).toContain('#DIM 6 "Projekt"')
expect(output).toContain('#DIM 13 "Dimension 13"')
expect(output).toContain('#OBJEKT 6 "GHOST" "GHOST"')
expect(output).toContain('#OBJEKT 13 "X1" "X1"')
expect(output).toContain('\t#TRANS 4010 {6 "GHOST" 13 "X1"} 900.00 20240201')
})
it('serializes multi-dimension lines sorted by numeric dimension number', async () => {
results = [
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
{ data: null, error: null }, // prevPeriod
{ data: [], error: null }, // accounts
{
data: [
{ id: 'e1', entry_date: '2024-04-10', voucher_number: 1, voucher_series: 'A', description: 'Multi-dim', status: 'posted' },
],
error: null,
},
{
// Keys deliberately out of order — output must sort 1 < 6 < 7
data: [
{ journal_entry_id: 'e1', account_number: '7010', debit_amount: 30000, credit_amount: 0, line_description: null, dimensions: { '7': 'EMP1', '1': 'KS01', '6': 'P001' } },
{ journal_entry_id: 'e1', account_number: '1930', debit_amount: 0, credit_amount: 30000, line_description: null, dimensions: {} },
],
error: null,
},
{ data: [dimKostnadsstalle, dimProjekt], error: null }, // dimensions
{
data: [
{ dimension_id: 'dim-1', code: 'KS01', name: 'Kontoret' },
{ dimension_id: 'dim-6', code: 'P001', name: 'Projekt Alpha' },
],
error: null,
},
{ data: [], error: null }, // RPC fallback
]
const output = await generateSIEExport(supabase, 'company-1', baseOptions)
expect(output).toContain('\t#TRANS 7010 {1 "KS01" 6 "P001" 7 "EMP1"} 30000.00 20240410')
// Dim 7 has no registry row → synthesized from the SIE reserved seed,
// with the orphan employee code declared.
expect(output).toContain('#DIM 7 "Anställd"')
expect(output).toContain('#OBJEKT 7 "EMP1" "EMP1"')
})
it('emits #UNDERDIM for child dimensions and declares the parent', async () => {
results = [
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
{ data: null, error: null }, // prevPeriod
{ data: [], error: null }, // accounts
{ data: [], error: null }, // journal_entries
{ data: [], error: null }, // journal_entry_lines
{
// Kostnadsbärare (2) is a sub-dimension of Kostnadsställe (1); the
// parent has NO values of its own — it must still be declared because
// an #UNDERDIM referencing an undeclared parent is invalid.
data: [
dimKostnadsstalle,
{ id: 'dim-2', sie_dim_no: 2, parent_sie_dim_no: 1, name: 'Kostnadsbärare' },
],
error: null,
},
{ data: [{ dimension_id: 'dim-2', code: 'KB1', name: 'Bärare 1' }], error: null },
{ data: [], error: null }, // RPC fallback
]
const output = await generateSIEExport(supabase, 'company-1', baseOptions)
expect(output).toContain('#DIM 1 "Kostnadsställe"')
expect(output).toContain('#UNDERDIM 2 "Kostnadsbärare" 1')
expect(output).toContain('#OBJEKT 2 "KB1" "Bärare 1"')
// The parent was pulled in as a declaration only — no #UNDERDIM for it
expect(output).not.toContain('#UNDERDIM 1')
})
it('declares a parent #DIM before an #UNDERDIM child with a LOWER number', async () => {
// SIE4 requires the parent to be declared before any #UNDERDIM that
// references it. A registry can hold a child whose sie_dim_no is LOWER
// than its parent's (dim 3 under dim 7 here), so a single numeric sort
// would emit the #UNDERDIM first — the two-pass emit (#DIM roots first,
// then #UNDERDIM) must keep the parent ahead.
results = [
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
{ data: null, error: null }, // prevPeriod
{ data: [], error: null }, // accounts
{
data: [
{ id: 'e1', entry_date: '2024-03-01', voucher_number: 1, voucher_series: 'A', description: 'Child below parent', status: 'posted' },
],
error: null,
},
{
data: [
{ journal_entry_id: 'e1', account_number: '5010', debit_amount: 700, credit_amount: 0, line_description: null, dimensions: { '3': 'UND1', '7': 'EMP1' } },
{ journal_entry_id: 'e1', account_number: '1930', debit_amount: 0, credit_amount: 700, line_description: null, dimensions: {} },
],
error: null,
},
{
// dim 3 is a child of dim 7 — numerically BEFORE its parent.
data: [
{ id: 'dim-3', sie_dim_no: 3, parent_sie_dim_no: 7, name: 'Underavdelning' },
{ id: 'dim-7', sie_dim_no: 7, parent_sie_dim_no: null, name: 'Anställd' },
],
error: null,
},
{
data: [
{ dimension_id: 'dim-3', code: 'UND1', name: 'Under 1' },
{ dimension_id: 'dim-7', code: 'EMP1', name: 'Anna' },
],
error: null,
},
{ data: [], error: null }, // RPC fallback
]
const output = await generateSIEExport(supabase, 'company-1', baseOptions)
expect(output).toContain('#DIM 7 "Anställd"')
expect(output).toContain('#UNDERDIM 3 "Underavdelning" 7')
// The parent #DIM must precede the child #UNDERDIM in the file.
expect(output.indexOf('#DIM 7 "Anställd"')).toBeLessThan(
output.indexOf('#UNDERDIM 3 "Underavdelning" 7'),
)
})
it('generates #UB for class 1-2 and #RES for class 3-8', async () => {
results = [
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
@@ -247,14 +459,14 @@ describe('generateSIEExport', () => {
},
{
data: [
{ journal_entry_id: 'e1', account_number: '1510', debit_amount: 1250, credit_amount: 0, line_description: null, cost_center: null, project: null },
{ journal_entry_id: 'e1', account_number: '3001', debit_amount: 0, credit_amount: 1000, line_description: null, cost_center: null, project: null },
{ journal_entry_id: 'e1', account_number: '2611', debit_amount: 0, credit_amount: 250, line_description: null, cost_center: null, project: null },
{ journal_entry_id: 'e1', account_number: '1510', debit_amount: 1250, credit_amount: 0, line_description: null, dimensions: {} },
{ journal_entry_id: 'e1', account_number: '3001', debit_amount: 0, credit_amount: 1000, line_description: null, dimensions: {} },
{ journal_entry_id: 'e1', account_number: '2611', debit_amount: 0, credit_amount: 250, line_description: null, dimensions: {} },
],
error: null,
},
{ data: [], error: null }, // cost_centers
{ data: [], error: null }, // projects
{ data: [], error: null }, // dimensions
{ data: [], error: null }, // dimension_values
{ data: [], error: null }, // RPC fallback
]
@@ -281,13 +493,13 @@ describe('generateSIEExport', () => {
},
{
data: [
{ journal_entry_id: 'e1', account_number: '1930', debit_amount: 100, credit_amount: 0, line_description: null, cost_center: null, project: null },
{ journal_entry_id: 'e1', account_number: '3001', debit_amount: 0, credit_amount: 100, line_description: null, cost_center: null, project: null },
{ journal_entry_id: 'e1', account_number: '1930', debit_amount: 100, credit_amount: 0, line_description: null, dimensions: {} },
{ journal_entry_id: 'e1', account_number: '3001', debit_amount: 0, credit_amount: 100, line_description: null, dimensions: {} },
],
error: null,
},
{ data: [], error: null }, // cost_centers
{ data: [], error: null }, // projects
{ data: [], error: null }, // dimensions
{ data: [], error: null }, // dimension_values
{ data: [], error: null }, // RPC fallback
]
@@ -303,8 +515,8 @@ describe('generateSIEExport', () => {
{ data: [], error: null }, // accounts
{ data: [], error: null }, // journal_entries
{ data: [], error: null }, // journal_entry_lines
{ data: [], error: null }, // cost_centers
{ data: [], error: null }, // projects
{ data: [], error: null }, // dimensions
{ data: [], error: null }, // dimension_values
{ data: [], error: null }, // RPC fallback
]
@@ -328,8 +540,8 @@ describe('generateSIEExport', () => {
{ data: [], error: null }, // accounts
{ data: [], error: null }, // journal_entries
{ data: [], error: null }, // journal_entry_lines
{ data: [], error: null }, // cost_centers
{ data: [], error: null }, // projects
{ data: [], error: null }, // dimensions
{ data: [], error: null }, // dimension_values
{ data: [], error: null }, // RPC fallback
]
@@ -346,8 +558,29 @@ describe('generateSIEExport', () => {
{ data: [], error: null }, // accounts
{ data: [], error: null }, // journal_entries
{ data: [], error: null }, // journal_entry_lines
{ data: [], error: null }, // cost_centers
{ data: [], error: null }, // projects
{ data: [], error: null }, // dimensions
{ data: [], error: null }, // dimension_values
{ data: [], error: null }, // RPC fallback
]
const output = await generateSIEExport(supabase, 'company-1', baseOptions)
expect(output).not.toContain('#DIM')
expect(output).not.toContain('#OBJEKT')
})
it('keeps seeded-but-unused system dimensions silent (no #DIM without values or tagged lines)', async () => {
// ensure_company_dimensions lazily seeds dims 1/6 for any company that
// touches the dimensions UI — a company that merely visited the register
// page must still get a dimension-free file.
results = [
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
{ data: null, error: null }, // prevPeriod
{ data: [], error: null }, // accounts
{ data: [], error: null }, // journal_entries
{ data: [], error: null }, // journal_entry_lines
{ data: [dimKostnadsstalle, dimProjekt], error: null }, // dimensions — seeded, valueless
{ data: [], error: null }, // dimension_values
{ data: [], error: null }, // RPC fallback
]
@@ -375,8 +608,8 @@ describe('generateSIEExport', () => {
}))
const lines = entries.flatMap((e, i) => [
{ id: `l${i * 2 + 1}`, journal_entry_id: e.id, account_number: '1510', debit_amount: 100, credit_amount: 0, line_description: null, cost_center: null, project: null },
{ id: `l${i * 2 + 2}`, journal_entry_id: e.id, account_number: '3001', debit_amount: 0, credit_amount: 100, line_description: null, cost_center: null, project: null },
{ id: `l${i * 2 + 1}`, journal_entry_id: e.id, account_number: '1510', debit_amount: 100, credit_amount: 0, line_description: null, dimensions: {} },
{ id: `l${i * 2 + 2}`, journal_entry_id: e.id, account_number: '3001', debit_amount: 0, credit_amount: 100, line_description: null, dimensions: {} },
])
// fetchAllRows paginates at PAGE_SIZE = 1000; chunk the mock data so the
@@ -399,8 +632,8 @@ describe('generateSIEExport', () => {
{ data: [], error: null }, // accounts
...paginate(entries), // journal_entries — 3 pages (1000 + 1000 + 500)
...paginate(lines), // journal_entry_lines — 5000 rows → 5 pages
{ data: [], error: null }, // cost_centers
{ data: [], error: null }, // projects
{ data: [], error: null }, // dimensions
{ data: [], error: null }, // dimension_values
{ data: [], error: null }, // RPC fallback
]
@@ -433,8 +666,8 @@ describe('generateSIEExport', () => {
{ data: [], error: null }, // accounts
{ data: [], error: null }, // journal_entries (no movements this period)
{ data: [], error: null }, // journal_entry_lines
{ data: [], error: null }, // cost_centers
{ data: [], error: null }, // projects
{ data: [], error: null }, // dimensions
{ data: [], error: null }, // dimension_values
// RPC fallback returns prior IBs derived from historical journal lines
{
data: [
@@ -464,8 +697,8 @@ describe('generateSIEExport', () => {
{ data: [], error: null }, // accounts
{ data: [], error: null }, // journal_entries
{ data: [], error: null }, // journal_entry_lines
{ data: [], error: null }, // cost_centers
{ data: [], error: null }, // projects
{ data: [], error: null }, // dimensions
{ data: [], error: null }, // dimension_values
// fetchAllRows page 1 — explicit OB entry lines
{
data: [
@@ -526,15 +759,15 @@ describe('generateSIEExport', () => {
// movement via obEntryId) and the real transfer's lines both flow through here.
{
data: [
{ id: 'l1', journal_entry_id: 'ob-entry-1', account_number: '1933', debit_amount: 96466.59, credit_amount: 0, line_description: 'IB 1933', cost_center: null, project: null },
{ id: 'l2', journal_entry_id: 'ob-entry-1', account_number: '2019', debit_amount: 0, credit_amount: 96466.59, line_description: null, cost_center: null, project: null },
{ id: 'l3', journal_entry_id: 'e2', account_number: '1930', debit_amount: 96466.59, credit_amount: 0, line_description: null, cost_center: null, project: null },
{ id: 'l4', journal_entry_id: 'e2', account_number: '1933', debit_amount: 0, credit_amount: 96466.59, line_description: null, cost_center: null, project: null },
{ id: 'l1', journal_entry_id: 'ob-entry-1', account_number: '1933', debit_amount: 96466.59, credit_amount: 0, line_description: 'IB 1933', dimensions: {} },
{ id: 'l2', journal_entry_id: 'ob-entry-1', account_number: '2019', debit_amount: 0, credit_amount: 96466.59, line_description: null, dimensions: {} },
{ id: 'l3', journal_entry_id: 'e2', account_number: '1930', debit_amount: 96466.59, credit_amount: 0, line_description: null, dimensions: {} },
{ id: 'l4', journal_entry_id: 'e2', account_number: '1933', debit_amount: 0, credit_amount: 96466.59, line_description: null, dimensions: {} },
],
error: null,
},
{ data: [], error: null }, // cost_centers
{ data: [], error: null }, // projects
{ data: [], error: null }, // dimensions
{ data: [], error: null }, // dimension_values
// fetchAllRows for OB entry lines (opening_balance_entry_id is set)
{
data: [
+221 -37
View File
@@ -1,9 +1,12 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { getBranding } from '@/lib/branding/service'
import { createLogger } from '@/lib/logger'
import { getOpeningBalances } from './opening-balances'
import type { SIEExportOptions, JournalEntry, JournalEntryLine, BASAccount } from '@/types'
const log = createLogger('reports:sie-export')
function sanitizeProgramName(str: string): string {
return str.replace(/"/g, '').replace(/[\r\n]/g, ' ').substring(0, 60)
}
@@ -142,19 +145,21 @@ export async function generateSIEExport(
entry.lines = linesByEntryId.get(entry.id) || []
}
// Fetch cost centers and projects for dimension records
const { data: costCenters } = await supabase
.from('cost_centers')
.select('*')
// Fetch the dimension registry (#DIM/#UNDERDIM + #OBJEKT source).
// Deliberately NO is_active filter: lines referencing archived codes still
// serialize into #TRANS object lists, and Visma rejects files whose #TRANS
// references an undeclared #OBJEKT (plan §5 latent bug #2 — the legacy
// cost_centers/projects read filtered is_active=true and dropped them).
const { data: registryDimensions } = await supabase
.from('dimensions')
.select('id, sie_dim_no, parent_sie_dim_no, name')
.eq('company_id', companyId)
.eq('is_active', true)
.order('code')
.order('sie_dim_no')
const { data: projects } = await supabase
.from('projects')
.select('*')
const { data: registryValues } = await supabase
.from('dimension_values')
.select('dimension_id, code, name')
.eq('company_id', companyId)
.eq('is_active', true)
.order('code')
const lines: string[] = []
@@ -183,25 +188,10 @@ export async function generateSIEExport(
lines.push(`#RAR -1 ${dateStringToSIE(prevPeriod.period_start)} ${dateStringToSIE(prevPeriod.period_end)}`)
}
// === Dimension definitions ===
// SIE standard: dimension 1 = kostnadsställe, dimension 6 = projekt
const hasCostCenters = costCenters && costCenters.length > 0
const hasProjects = projects && projects.length > 0
if (hasCostCenters) {
lines.push('#DIM 1 "Kostnadsställe"')
}
if (hasProjects) {
lines.push('#DIM 6 "Projekt"')
}
// === Dimension objects (#OBJEKT) ===
for (const cc of costCenters || []) {
lines.push(`#OBJEKT 1 "${escapeQuotes(cc.code)}" "${escapeQuotes(cc.name)}"`)
}
for (const proj of projects || []) {
lines.push(`#OBJEKT 6 "${escapeQuotes(proj.code)}" "${escapeQuotes(proj.name)}"`)
}
// === Dimension definitions (#DIM / #UNDERDIM) + objects (#OBJEKT) ===
lines.push(
...buildDimensionSection(registryDimensions ?? [], registryValues ?? [], allLines)
)
// === Chart of accounts ===
for (const account of (accounts as BASAccount[]) || []) {
@@ -257,14 +247,12 @@ export async function generateSIEExport(
? ` "${escapeQuotes(line.line_description)}"`
: ''
// Build dimension object list for #TRANS line
const dimParts: string[] = []
if (line.cost_center) {
dimParts.push(`1 "${escapeQuotes(line.cost_center)}"`)
}
if (line.project) {
dimParts.push(`6 "${escapeQuotes(line.project)}"`)
}
// Build dimension object list for #TRANS from the jsonb map — the
// single source of truth. The legacy cost_center/project columns are
// derived mirrors of keys '1'/'6' and are no longer read here.
const dimParts = lineDimensionEntries(line.dimensions).map(
([dimNo, code]) => `${dimNo} "${escapeQuotes(code)}"`
)
const objList = dimParts.length > 0 ? `{${dimParts.join(' ')}}` : '{}'
lines.push(`\t#TRANS ${line.account_number} ${objList} ${formatAmount(amount)} ${entryDate}${lineDesc}`)
@@ -334,6 +322,202 @@ function escapeQuotes(str: string): string {
return str.replace(/"/g, '\\"')
}
// ── Dimensions (#DIM / #UNDERDIM / #OBJEKT) ─────────────────────────────────
interface RegistryDimension {
id: string
sie_dim_no: number
parent_sie_dim_no: number | null
name: string
}
interface RegistryValue {
dimension_id: string
code: string
name: string
}
/**
* SIE reserved dimension numbers. Used to synthesize a #DIM declaration for
* dimension numbers referenced by exported lines but absent from the registry
* — free-text writers can still mint arbitrary numbers until the write-path
* PR lands, and an undeclared dimension would make importers reject the file.
* Dim 2 (kostnadsbärare) is a reserved sub-dimension of 1 → #UNDERDIM.
*/
const SIE_RESERVED_DIMENSIONS: Record<number, { name: string; parent?: number }> = {
1: { name: 'Kostnadsställe' },
2: { name: 'Kostnadsbärare', parent: 1 },
6: { name: 'Projekt' },
7: { name: 'Anställd' },
8: { name: 'Kund' },
9: { name: 'Leverantör' },
10: { name: 'Faktura' },
}
/**
* Normalize a line's jsonb dimensions map ({"1":"KS01","6":"P001"}) into
* [dimNo, code] entries sorted by numeric dimension number. Defensive on
* shape: non-numeric keys and blank codes are skipped, and duplicate keys
* ('01' vs '1') collapse onto the canonical number with last-write-wins —
* mirroring normalizeLineDimensions in lib/bookkeeping/dimension-resolver.ts.
*/
function lineDimensionEntries(dimensions: unknown): Array<[number, string]> {
if (!dimensions || typeof dimensions !== 'object' || Array.isArray(dimensions)) {
return []
}
const byDimNo = new Map<number, string>()
for (const [key, value] of Object.entries(dimensions as Record<string, unknown>)) {
if (!/^\d+$/.test(key)) continue
const dimNo = Number(key)
if (dimNo < 1) continue
const code = typeof value === 'string' ? value.trim() : ''
if (!code) continue
byDimNo.set(dimNo, code)
}
return [...byDimNo.entries()].sort((a, b) => a[0] - b[0])
}
/**
* Build the #DIM/#UNDERDIM + #OBJEKT section from the dimension registry and
* the exported journal lines.
*
* Completeness guarantee: every (dimNo, code) pair referenced by any exported
* line gets an #OBJEKT record — registry rows contribute their name, orphan
* codes (no registry row) synthesize name = code, and dimension numbers with
* no registry row synthesize a #DIM from the SIE reserved-number seed. A file
* whose #TRANS references an undeclared object is rejected by Visma et al.
*
* Silence guarantee: registry dimensions with no values and no line
* references (e.g. lazily seeded system dims 1/6 that were never used) emit
* nothing, so companies that never touch dimensions keep dimension-free files.
*/
function buildDimensionSection(
registryDimensions: RegistryDimension[],
registryValues: RegistryValue[],
journalLines: JournalEntryLine[]
): string[] {
// Defence in depth: a value row whose dimension_id doesn't resolve in
// dimNoById is skipped by construction (the `continue` below). Both fetches
// are scoped to the same company_id (query filter + RLS), so every
// dimension_id in registryValues should resolve; a miss can only mean the
// dimension row vanished mid-export — skipping just omits its #OBJEKT,
// never leaks a foreign company's data into the file.
const dimsByNo = new Map<number, RegistryDimension>()
const dimNoById = new Map<string, number>()
for (const dim of registryDimensions) {
dimsByNo.set(dim.sie_dim_no, dim)
dimNoById.set(dim.id, dim.sie_dim_no)
}
// Registry values grouped by dimension number: dimNo → (code → name)
const valuesByDimNo = new Map<number, Map<string, string>>()
for (const value of registryValues) {
const dimNo = dimNoById.get(value.dimension_id)
if (dimNo === undefined) continue
let codeMap = valuesByDimNo.get(dimNo)
if (!codeMap) {
codeMap = new Map()
valuesByDimNo.set(dimNo, codeMap)
}
if (!codeMap.has(value.code)) codeMap.set(value.code, value.name)
}
// (dimNo, code) pairs referenced by exported lines
const referencedByDimNo = new Map<number, Set<string>>()
for (const line of journalLines) {
for (const [dimNo, code] of lineDimensionEntries(line.dimensions)) {
let codes = referencedByDimNo.get(dimNo)
if (!codes) {
codes = new Set()
referencedByDimNo.set(dimNo, codes)
}
codes.add(code)
}
}
const emitDimNos = new Set<number>([
...valuesByDimNo.keys(),
...referencedByDimNo.keys(),
])
const declarationFor = (dimNo: number): { name: string; parent: number | null } => {
const dim = dimsByNo.get(dimNo)
if (dim) return { name: dim.name, parent: dim.parent_sie_dim_no ?? null }
const reserved = SIE_RESERVED_DIMENSIONS[dimNo]
return { name: reserved?.name ?? `Dimension ${dimNo}`, parent: reserved?.parent ?? null }
}
// An #UNDERDIM must not reference an undeclared parent — pull parents into
// the emit set transitively (the has-guard also breaks registry cycles).
const pending = [...emitDimNos]
while (pending.length > 0) {
const { parent } = declarationFor(pending.pop()!)
if (parent !== null && !emitDimNos.has(parent)) {
emitDimNos.add(parent)
pending.push(parent)
}
}
const sortedDimNos = [...emitDimNos].sort((a, b) => a - b)
const out: string[] = []
// Synthesized placeholders collected for the operator warning below:
// #DIM "Dimension n" fallbacks and #OBJEKT rows with name = code.
const synthesizedDimNos: number[] = []
const orphanObjects: Array<{ dimNo: number; code: string }> = []
// Two passes: every root #DIM first (sorted by sie_dim_no), then every
// #UNDERDIM (sorted by sie_dim_no). SIE4 requires a parent to be declared
// before any #UNDERDIM referencing it, and a child may carry a LOWER
// number than its parent — so a single numeric sort is not enough.
for (const dimNo of sortedDimNos) {
const { name, parent } = declarationFor(dimNo)
if (parent !== null) continue
if (!dimsByNo.has(dimNo) && !SIE_RESERVED_DIMENSIONS[dimNo]) {
synthesizedDimNos.push(dimNo)
}
out.push(`#DIM ${dimNo} "${escapeQuotes(name)}"`)
}
// "Dimension n" fallbacks never carry a parent (declarationFor only
// assigns parents from the registry or the reserved seed), so #UNDERDIM
// lines are never synthesized placeholders.
for (const dimNo of sortedDimNos) {
const { name, parent } = declarationFor(dimNo)
if (parent === null) continue
out.push(`#UNDERDIM ${dimNo} "${escapeQuotes(name)}" ${parent}`)
}
for (const dimNo of sortedDimNos) {
const registry = valuesByDimNo.get(dimNo) ?? new Map<string, string>()
const codes = new Set<string>([
...registry.keys(),
...(referencedByDimNo.get(dimNo) ?? []),
])
for (const code of [...codes].sort((a, b) => a.localeCompare(b, 'sv'))) {
const registryName = registry.get(code)
if (registryName === undefined) {
orphanObjects.push({ dimNo, code })
}
out.push(
`#OBJEKT ${dimNo} "${escapeQuotes(code)}" "${escapeQuotes(registryName ?? code)}"`
)
}
}
// Operators must know the file contains synthesized placeholder names
// (BFNAR 2013:2 behandlingshistorik) — one structured warning listing the
// pairs; silent when the registry covered everything.
if (orphanObjects.length > 0 || synthesizedDimNos.length > 0) {
log.warn('SIE export synthesized placeholder dimension declarations', {
orphanObjects,
synthesizedDimNos,
})
}
return out
}
/**
* Calculate net balances per account from journal entries
*/