* 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>
217 lines
8.4 KiB
TypeScript
217 lines
8.4 KiB
TypeScript
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 }
|
||
}
|