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:
co-authored by
Claude Fable 5
parent
8cc2efb083
commit
8bb49c07a2
@@ -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",
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user