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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

217 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { SupabaseClient } from '@supabase/supabase-js'
import { SIE_RESERVED_DIMENSIONS } from '@/lib/reports/sie-export'
import type { ParsedSIEFile } from './types'
/**
* Registry-side of lossless SIE dimension import (dimensions plan PR5).
*
* Collects every dimension the file mentions — declared (#DIM/#UNDERDIM),
* valued (#OBJEKT), or merely referenced by a #TRANS object list — and
* inserts the missing `dimensions`/`dimension_values` rows. Existing rows
* are NEVER touched (ON CONFLICT DO NOTHING): an import must not rename a
* user's dimensions or values. Undeclared reserved numbers synthesize their
* SIE-standard names (1 Kostnadsställe, 2→1 Kostnadsbärare, 6 Projekt, 7–10);
* unknown customs fall back to "Dimension N", exactly mirroring the export's
* orphan synthesis so a parse→import→re-export round-trip is lossless.
*
* Rows created here carry `created_by_import_id` so `undo_sie_import` can
* remove registry values that the undone import introduced (and that nothing
* else references) — the lockstep the plan requires.
*/
export interface DimensionImportSummary {
/** dimensions rows actually inserted (not pre-existing). */
dimensionsCreated: number
/** dimension_values rows actually inserted (not pre-existing). */
valuesCreated: number
/** #TRANS lines in the file carrying an object list. */
taggedLines: number
/** True when this import flipped company_settings.dimensions_enabled on. */
toggleEnabled: boolean
warnings: string[]
}
/** dimension_values.code DB CHECK: 1–40 chars, none of `"{}`. */
function isValidRegistryCode(code: string): boolean {
return code.length >= 1 && code.length <= 40 && !/["{}]/.test(code)
}
export function collectSIEDimensionUsage(parsed: ParsedSIEFile): {
dims: Map<number, { name: string; parent?: number }>
values: Map<string, { sieDimNo: number; code: string; name: string }>
taggedLines: number
invalidCodes: Set<string>
} {
const dims = new Map<number, { name: string; parent?: number }>()
const values = new Map<string, { sieDimNo: number; code: string; name: string }>()
const invalidCodes = new Set<string>()
let taggedLines = 0
const ensureDim = (dimNo: number, name?: string, parent?: number) => {
const existing = dims.get(dimNo)
if (existing) {
// A declared name/parent wins over a synthesized placeholder.
if (name) existing.name = name
if (parent !== undefined) existing.parent = parent
return
}
const reserved = SIE_RESERVED_DIMENSIONS[dimNo]
dims.set(dimNo, {
name: name || reserved?.name || `Dimension ${dimNo}`,
parent: parent ?? reserved?.parent,
})
}
const ensureValue = (dimNo: number, code: string, name?: string) => {
if (!isValidRegistryCode(code)) {
invalidCodes.add(`${dimNo}:${code}`)
return
}
ensureDim(dimNo)
const key = `${dimNo} ${code}`
const existing = values.get(key)
if (existing) {
if (name && existing.name === existing.code) existing.name = name
return
}
values.set(key, { sieDimNo: dimNo, code, name: name || code })
}
// Nullish guards: ParsedSIEFile-shaped objects predating PR5 (serialized
// previews, hand-built test fixtures) may lack the dimension arrays.
for (const dim of parsed.dimensions ?? []) {
ensureDim(dim.sieDimNo, dim.name || undefined, dim.parentSieDimNo)
}
for (const value of parsed.dimensionValues ?? []) {
ensureValue(value.sieDimNo, value.code, value.name)
}
for (const voucher of parsed.vouchers ?? []) {
for (const line of voucher.lines) {
if (!line.dimensions) continue
taggedLines++
for (const [dimNoRaw, code] of Object.entries(line.dimensions)) {
const dimNo = Number(dimNoRaw)
if (!Number.isInteger(dimNo) || dimNo < 1) continue
ensureValue(dimNo, code)
}
}
}
return { dims, values, taggedLines, invalidCodes }
}
/**
* Upsert the registry rows the file needs and flip dimensions_enabled on.
* Returns null when the file carries no dimension data at all — companies
* without dimensions see literally nothing changed.
*/
export async function importDimensionRegistry(
supabase: SupabaseClient,
companyId: string,
parsed: ParsedSIEFile,
importId: string | null
): Promise<DimensionImportSummary | null> {
const { dims, values, taggedLines, invalidCodes } = collectSIEDimensionUsage(parsed)
if (dims.size === 0 && values.size === 0 && taggedLines === 0) {
return null
}
const warnings: string[] = []
if (invalidCodes.size > 0) {
warnings.push(
`${invalidCodes.size} dimensionskoder kunde inte registreras (ogiltig längd eller tecken): ` +
[...invalidCodes].slice(0, 5).join(', ') +
(invalidCodes.size > 5 ? '…' : '')
)
}
// Seed system dims 1/6 (idempotent) before touching the registry.
await supabase.rpc('ensure_company_dimensions', { p_company_id: companyId })
// ── dimensions rows — insert missing, never rename existing ────
let dimensionsCreated = 0
if (dims.size > 0) {
const dimInserts = [...dims.entries()].map(([sieDimNo, info]) => ({
company_id: companyId,
sie_dim_no: sieDimNo,
parent_sie_dim_no: info.parent ?? null,
name: info.name,
// Dim 1 resets annually per SIE convention; projekt (6) accumulates.
// ensure_company_dimensions already seeded 1/6 with the right flags,
// so this only matters for custom dims — default to resetting.
resets_annually: sieDimNo !== 6,
is_system: false,
created_by_import_id: importId,
}))
const { data: insertedDims, error: dimError } = await supabase
.from('dimensions')
.upsert(dimInserts, { onConflict: 'company_id,sie_dim_no', ignoreDuplicates: true })
.select('id')
if (dimError) {
warnings.push(`Dimensionsregistret kunde inte uppdateras: ${dimError.message}`)
return { dimensionsCreated: 0, valuesCreated: 0, taggedLines, toggleEnabled: false, warnings }
}
dimensionsCreated = insertedDims?.length ?? 0
}
// ── dimension_values rows ───────────────────────────────────────
let valuesCreated = 0
if (values.size > 0) {
const { data: dimRows, error: readError } = await supabase
.from('dimensions')
.select('id, sie_dim_no')
.eq('company_id', companyId)
.in('sie_dim_no', [...new Set([...values.values()].map((v) => v.sieDimNo))])
if (readError || !dimRows) {
warnings.push(`Dimensionsvärden kunde inte registreras: ${readError?.message ?? 'okänt fel'}`)
return { dimensionsCreated, valuesCreated: 0, taggedLines, toggleEnabled: false, warnings }
}
const dimIdByNo = new Map(dimRows.map((d) => [Number(d.sie_dim_no), d.id as string]))
const valueInserts = [...values.values()]
.filter((v) => dimIdByNo.has(v.sieDimNo))
.map((v) => ({
company_id: companyId,
dimension_id: dimIdByNo.get(v.sieDimNo)!,
code: v.code,
name: v.name,
created_by_import_id: importId,
}))
if (valueInserts.length > 0) {
const { data: insertedValues, error: valueError } = await supabase
.from('dimension_values')
.upsert(valueInserts, {
onConflict: 'company_id,dimension_id,code',
ignoreDuplicates: true,
})
.select('id')
if (valueError) {
warnings.push(`Dimensionsvärden kunde inte registreras: ${valueError.message}`)
return { dimensionsCreated, valuesCreated: 0, taggedLines, toggleEnabled: false, warnings }
}
valuesCreated = insertedValues?.length ?? 0
}
}
// ── Auto-enable the toggle with a notice ────────────────────────
// The column comment pre-authorizes this: "SIE import that finds dimensions
// may flip this on with a notice." Idempotent; only reported as flipped
// when it actually changed.
let toggleEnabled = false
const { data: settings } = await supabase
.from('company_settings')
.select('dimensions_enabled')
.eq('company_id', companyId)
.maybeSingle()
if (settings && settings.dimensions_enabled !== true) {
const { error: toggleError } = await supabase
.from('company_settings')
.update({ dimensions_enabled: true })
.eq('company_id', companyId)
if (!toggleError) toggleEnabled = true
}
return { dimensionsCreated, valuesCreated, taggedLines, toggleEnabled, warnings }
}