* 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>
179 lines
6.9 KiB
TypeScript
179 lines
6.9 KiB
TypeScript
/**
|
|
* POST /api/dimensions/import-existing — one-shot scan that backfills the
|
|
* dimension_values registry from codes already present on journal lines.
|
|
*
|
|
* The settings toggle-on flow runs this ("Importera befintliga koder",
|
|
* dev_docs/dimensions_implementation_plan.md §4): every {sie_dim_no: code}
|
|
* entry found in journal_entry_lines.dimensions that lacks a registry row gets
|
|
* one created with is_active = false and name = code — referential validity
|
|
* holds retroactively without polluting pickers. Dimensions missing from the
|
|
* registry entirely (e.g. a custom dim number written via the v1 API) are
|
|
* created too, so no line code is skipped. Idempotent: re-running creates
|
|
* nothing new.
|
|
*
|
|
* Response: 200 { created: number } (count of dimension_values created).
|
|
*/
|
|
import { NextResponse } from 'next/server'
|
|
import { ensureInitialized } from '@/lib/init'
|
|
import { withRouteContext } from '@/lib/api/with-route-context'
|
|
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
|
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
|
|
|
ensureInitialized()
|
|
|
|
interface LineRow {
|
|
id: string
|
|
dimensions: Record<string, string> | null
|
|
}
|
|
|
|
export const POST = withRouteContext(
|
|
'dimension.import_existing',
|
|
async (_request, ctx) => {
|
|
const { supabase, companyId, log, requestId } = ctx
|
|
|
|
const { error: ensureError } = await supabase.rpc('ensure_company_dimensions', {
|
|
p_company_id: companyId,
|
|
})
|
|
if (ensureError) {
|
|
log.error('ensure_company_dimensions failed', ensureError)
|
|
return errorResponse(ensureError, log, { requestId })
|
|
}
|
|
|
|
try {
|
|
// 1. Collect every {dim_no: code} pair used on this company's lines.
|
|
// Inner-join scoping — journal_entry_lines has no company_id column.
|
|
const lines = await fetchAllRows<LineRow>(({ from, to }) =>
|
|
supabase
|
|
.from('journal_entry_lines')
|
|
.select('id, dimensions, journal_entries!inner(company_id)')
|
|
.eq('journal_entries.company_id', companyId)
|
|
.neq('dimensions', '{}')
|
|
.order('id', { ascending: true })
|
|
.range(from, to),
|
|
)
|
|
|
|
const codesByDimNo = new Map<number, Set<string>>()
|
|
for (const line of lines) {
|
|
if (!line.dimensions) continue
|
|
for (const [key, code] of Object.entries(line.dimensions)) {
|
|
const dimNo = Number(key)
|
|
if (!Number.isInteger(dimNo) || dimNo < 1) continue
|
|
if (typeof code !== 'string' || code.length === 0) continue
|
|
// Sanitize exactly like the PR1 substrate backfill
|
|
// (left(regexp_replace(code, '["{}]', '', 'g'), 40)) so candidates
|
|
// satisfy the dimension_values code CHECK; codes that sanitize to
|
|
// empty are dropped, and the Set de-duplicates post-sanitization.
|
|
const sanitized = code.replace(/["{}]/g, '').slice(0, 40)
|
|
if (sanitized.length === 0) continue
|
|
const set = codesByDimNo.get(dimNo) ?? new Set<string>()
|
|
set.add(sanitized)
|
|
codesByDimNo.set(dimNo, set)
|
|
}
|
|
}
|
|
|
|
if (codesByDimNo.size === 0) {
|
|
return NextResponse.json({ created: 0 })
|
|
}
|
|
|
|
// 2. Registry state: dim_no → dimension_id, plus existing value codes.
|
|
const { data: dims, error: dimsError } = await supabase
|
|
.from('dimensions')
|
|
.select('id, sie_dim_no')
|
|
.eq('company_id', companyId)
|
|
if (dimsError) throw dimsError
|
|
|
|
const dimIdByNo = new Map<number, string>(
|
|
((dims ?? []) as { id: string; sie_dim_no: number }[]).map((d) => [d.sie_dim_no, d.id]),
|
|
)
|
|
|
|
// 3. Create registry rows for dim numbers seen on lines but missing from
|
|
// the registry (written via raw v1 JSON / MCP before the register
|
|
// existed). is_system=false; the user can rename them afterwards.
|
|
const missingDimNos = [...codesByDimNo.keys()].filter((n) => !dimIdByNo.has(n))
|
|
if (missingDimNos.length > 0) {
|
|
// ignoreDuplicates: a concurrent creator (double-click, MCP) must not
|
|
// abort the whole batch with a 23505 — the survivor's row wins.
|
|
const { data: createdDims, error: createDimsError } = await supabase
|
|
.from('dimensions')
|
|
.upsert(
|
|
missingDimNos.map((n) => ({
|
|
company_id: companyId,
|
|
sie_dim_no: n,
|
|
name: `Dimension ${n}`,
|
|
is_system: false,
|
|
})),
|
|
{ onConflict: 'company_id,sie_dim_no', ignoreDuplicates: true },
|
|
)
|
|
.select('id, sie_dim_no')
|
|
if (createDimsError) throw createDimsError
|
|
for (const d of (createdDims ?? []) as { id: string; sie_dim_no: number }[]) {
|
|
dimIdByNo.set(d.sie_dim_no, d.id)
|
|
}
|
|
}
|
|
|
|
const { data: existingValues, error: valuesError } = await supabase
|
|
.from('dimension_values')
|
|
.select('dimension_id, code')
|
|
.eq('company_id', companyId)
|
|
if (valuesError) throw valuesError
|
|
|
|
const existing = new Set(
|
|
((existingValues ?? []) as { dimension_id: string; code: string }[]).map(
|
|
(v) => `${v.dimension_id} |