ec27228a8e
Em dashes (—) and en dashes (–) had spread across comments, docs, tests, and a few UI strings, reading as AI-generated boilerplate rather than house style. Replaced each with punctuation matching its context: colon for explanatory clauses, comma for asides, plain hyphen for numeric/legal ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for paired-dash asides. messages/en.json and messages/sv.json were fixed by hand together to keep sv/en in sync. Left untouched where the dash is the functional subject rather than decorative punctuation: date-range-parser.ts's separator regex, charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the agent system-prompt files that already instruct against em dashes, and a golden iXBRL test fixture compared byte-for-byte. Also fixes two bugs surfaced along the way: an off-by-one in ApiKeysPanel's scope-label split (a leftover from an earlier partial pass), and a charset-repair test that had lost the literal en-dash it exists to verify. Regenerated the agent atom seed migration (skills:generate) since 27 SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes, with an explicit carve-out for the functional-dash cases above. Co-authored-by: Claude Sonnet 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 }
|
|
}
|