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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
c7f0db00c8
commit
fb3fe82a56
@@ -498,6 +498,8 @@ function SIEImportWizard() {
|
||||
closingBalances: [],
|
||||
resultBalances: [],
|
||||
vouchers: [],
|
||||
dimensions: [],
|
||||
dimensionValues: [],
|
||||
issues: data.parsed.issues,
|
||||
stats: data.parsed.stats,
|
||||
})
|
||||
|
||||
@@ -107,6 +107,33 @@ export default function ImportResultStep({ result, onNewImport, onUndo }: Import
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Dimensions detected (lossless SIE round-trip, dimensions plan PR5) */}
|
||||
{result.success && result.dimensionsImported && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Info className="h-5 w-5 text-muted-foreground" />
|
||||
Dimensioner följde med importen
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Filen innehöll kostnadsställen/projekt: {result.dimensionsImported.taggedLines}{' '}
|
||||
taggade rader importerades
|
||||
{result.dimensionsImported.values > 0 && (
|
||||
<> och {result.dimensionsImported.values} nya värden lades till i registret</>
|
||||
)}
|
||||
.{' '}
|
||||
{result.dimensionsImported.toggleEnabled && (
|
||||
<>Dimensioner aktiverades automatiskt för företaget — du hittar registret under{' '}
|
||||
<Link href="/dimensions" className="underline underline-offset-4">
|
||||
Kostnadsställen & projekt
|
||||
</Link>
|
||||
.</>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Statistics */}
|
||||
{result.success && (
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { parseSIEFile } from '../sie-parser'
|
||||
import { collectSIEDimensionUsage } from '../sie-dimensions'
|
||||
import { normalizeLineDimensions } from '@/lib/bookkeeping/dimension-resolver'
|
||||
import { generateSIEExport } from '@/lib/reports/sie-export'
|
||||
|
||||
// ============================================================
|
||||
// Dimensions plan PR5 — the lossless round-trip guarantee.
|
||||
//
|
||||
// parse(source) → what import writes (registry rows + line dimension maps)
|
||||
// → generateSIEExport over exactly that state → parse(exported) must carry
|
||||
// the same dimension surface: #DIM/#UNDERDIM declarations, #OBJEKT values,
|
||||
// and per-line object lists. The comparison is structural (both files run
|
||||
// through the same parser), so formatting/order differences don't matter.
|
||||
// ============================================================
|
||||
|
||||
// Sequential-queue supabase mock — same consumption order as
|
||||
// sie-export.test.ts documents:
|
||||
// 0 fiscal_periods.single, 1 prev period, 2 accounts, 3 journal_entries,
|
||||
// 4 journal_entry_lines, 5 dimensions, 6 dimension_values, 7 OB fallback
|
||||
let resultIdx: number
|
||||
let results: Array<{ data?: unknown; error?: unknown }>
|
||||
|
||||
function makeBuilder() {
|
||||
const b: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'in', 'order', 'range', 'lt', 'lte', 'gte', 'gt', 'limit', 'neq', 'eq']) {
|
||||
b[m] = vi.fn().mockReturnValue(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
|
||||
}
|
||||
|
||||
function makeClient() {
|
||||
return {
|
||||
from: vi.fn().mockImplementation(() => makeBuilder()),
|
||||
rpc: vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resultIdx = 0
|
||||
results = []
|
||||
})
|
||||
|
||||
const SOURCE_SIE = [
|
||||
'#FLAGGA 0',
|
||||
'#SIETYP 4',
|
||||
'#FNAMN "Roundtrip AB"',
|
||||
'#RAR 0 20260101 20261231',
|
||||
'#KONTO 5010 "Lokalhyra"',
|
||||
'#KONTO 1930 "Företagskonto"',
|
||||
'#DIM 1 "Kostnadsställe"',
|
||||
'#DIM 6 "Projekt"',
|
||||
'#UNDERDIM 2 "Kostnadsbärare" 1',
|
||||
'#OBJEKT 1 "KS01" "Butiken"',
|
||||
'#OBJEKT 2 "KB1" "Bärare ett"',
|
||||
'#OBJEKT 6 "P001" "Villa Almgren"',
|
||||
'#VER A 1 20260115 "Hyra januari"',
|
||||
'{',
|
||||
'#TRANS 5010 {1 "KS01" 2 "KB1" 6 "P001"} 15000.00',
|
||||
'#TRANS 1930 {} -15000.00',
|
||||
'}',
|
||||
'#VER A 2 20260116 "Odeklarerat projekt"',
|
||||
'{',
|
||||
// P002 is referenced but never declared via #OBJEKT — import synthesizes
|
||||
// it (name = code) and export must re-declare it.
|
||||
'#TRANS 5010 {6 "P002"} 500.00',
|
||||
'#TRANS 1930 {} -500.00',
|
||||
'}',
|
||||
].join('\n')
|
||||
|
||||
describe('SIE dimensions round-trip', () => {
|
||||
it('parse → import state → export → parse preserves the dimension surface', async () => {
|
||||
const parsedSource = parseSIEFile(SOURCE_SIE)
|
||||
expect(parsedSource.issues.filter((i) => i.severity === 'error')).toEqual([])
|
||||
|
||||
// ── What the importer writes ─────────────────────────────────
|
||||
const usage = collectSIEDimensionUsage(parsedSource)
|
||||
|
||||
// Registry rows exactly as importDimensionRegistry inserts them.
|
||||
const dimensionRows = [...usage.dims.entries()].map(([sieDimNo, info], i) => ({
|
||||
id: `dim-${sieDimNo}`,
|
||||
sie_dim_no: sieDimNo,
|
||||
parent_sie_dim_no: info.parent ?? null,
|
||||
name: info.name,
|
||||
sort_order: i,
|
||||
}))
|
||||
const valueRows = [...usage.values.values()].map((v) => ({
|
||||
dimension_id: `dim-${v.sieDimNo}`,
|
||||
code: v.code,
|
||||
name: v.name,
|
||||
}))
|
||||
|
||||
// Journal lines exactly as importVouchers inserts them (dimensions jsonb
|
||||
// via normalizeLineDimensions; SIE amount sign → debit/credit).
|
||||
const journalEntries = parsedSource.vouchers.map((v, i) => ({
|
||||
id: `e${i + 1}`,
|
||||
entry_date: '2026-01-15',
|
||||
voucher_number: v.number,
|
||||
voucher_series: v.series,
|
||||
description: v.description,
|
||||
status: 'posted',
|
||||
}))
|
||||
const journalLines = parsedSource.vouchers.flatMap((v, i) =>
|
||||
v.lines.map((line) => ({
|
||||
journal_entry_id: `e${i + 1}`,
|
||||
account_number: line.account,
|
||||
debit_amount: line.amount > 0 ? line.amount : 0,
|
||||
credit_amount: line.amount < 0 ? Math.abs(line.amount) : 0,
|
||||
line_description: line.description ?? null,
|
||||
dimensions: normalizeLineDimensions({ dimensions: line.dimensions ?? null }),
|
||||
}))
|
||||
)
|
||||
|
||||
// ── Export over exactly that state ───────────────────────────
|
||||
results = [
|
||||
{ data: { id: 'period-1', period_start: '2026-01-01', period_end: '2026-12-31' }, error: null },
|
||||
{ data: null, error: null }, // prev period
|
||||
{
|
||||
data: [
|
||||
{ account_number: '5010', account_name: 'Lokalhyra', sru_code: null, is_active: true },
|
||||
{ account_number: '1930', account_name: 'Företagskonto', sru_code: null, is_active: true },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: journalEntries, error: null },
|
||||
{ data: journalLines, error: null },
|
||||
{ data: dimensionRows, error: null },
|
||||
{ data: valueRows, error: null },
|
||||
{ data: [], error: null }, // OB fallback
|
||||
]
|
||||
|
||||
const exported = await generateSIEExport(makeClient(), 'company-1', {
|
||||
fiscal_period_id: 'period-1',
|
||||
company_name: 'Roundtrip AB',
|
||||
org_number: null,
|
||||
program_name: 'ERPBase',
|
||||
})
|
||||
|
||||
// ── Compare the two dimension surfaces structurally ──────────
|
||||
const parsedExport = parseSIEFile(exported)
|
||||
expect(parsedExport.issues.filter((i) => i.severity === 'error')).toEqual([])
|
||||
|
||||
const dimSet = (dims: typeof parsedSource.dimensions) =>
|
||||
new Set(dims.map((d) => `${d.sieDimNo}|${d.name}|${d.parentSieDimNo ?? ''}`))
|
||||
// Every declaration that carries data survives — including the
|
||||
// #UNDERDIM child with its parent link. (A declared dimension with no
|
||||
// values and no tagged lines is metadata without data; export
|
||||
// deliberately omits it, so the fixture gives every dim a value.)
|
||||
for (const entry of dimSet(parsedSource.dimensions)) {
|
||||
expect(dimSet(parsedExport.dimensions)).toContain(entry)
|
||||
}
|
||||
|
||||
const valueSet = (values: typeof parsedSource.dimensionValues) =>
|
||||
new Set(values.map((v) => `${v.sieDimNo}|${v.code}|${v.name}`))
|
||||
for (const entry of valueSet(parsedSource.dimensionValues)) {
|
||||
expect(valueSet(parsedExport.dimensionValues)).toContain(entry)
|
||||
}
|
||||
// The undeclared-but-referenced P002 got synthesized (name = code).
|
||||
expect(valueSet(parsedExport.dimensionValues)).toContain('6|P002|P002')
|
||||
|
||||
// Per-line object lists survive verbatim.
|
||||
const lineDims = (parsed: typeof parsedSource) =>
|
||||
parsed.vouchers.flatMap((v) => v.lines.map((l) => l.dimensions ?? null))
|
||||
expect(lineDims(parsedExport)).toEqual(lineDims(parsedSource))
|
||||
})
|
||||
|
||||
it('collectSIEDimensionUsage synthesizes reserved names and prefers declared ones', () => {
|
||||
const parsed = parseSIEFile(
|
||||
[
|
||||
'#FLAGGA 0',
|
||||
'#SIETYP 4',
|
||||
'#RAR 0 20260101 20261231',
|
||||
// Dim 7 (reserved: Anställd) referenced without declaration; dim 6
|
||||
// declared with a custom name that must win over "Projekt".
|
||||
'#DIM 6 "Mina projekt"',
|
||||
'#VER A 1 20260115 "Löner"',
|
||||
'{',
|
||||
'#TRANS 7010 {7 "ANNA" 6 "P001" 42 "X"} 1000.00',
|
||||
'#TRANS 1930 {} -1000.00',
|
||||
'}',
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
const usage = collectSIEDimensionUsage(parsed)
|
||||
expect(usage.dims.get(7)?.name).toBe('Anställd')
|
||||
expect(usage.dims.get(6)?.name).toBe('Mina projekt')
|
||||
// Unknown custom number falls back to a generic label.
|
||||
expect(usage.dims.get(42)?.name).toBe('Dimension 42')
|
||||
expect(usage.taggedLines).toBe(1)
|
||||
expect([...usage.values.keys()].sort()).toEqual(['42 X', '6 P001', '7 ANNA'])
|
||||
})
|
||||
|
||||
it('rejects registry codes that violate the DB CHECK but keeps them on lines', () => {
|
||||
const longCode = 'X'.repeat(41)
|
||||
const parsed = parseSIEFile(
|
||||
[
|
||||
'#FLAGGA 0',
|
||||
'#SIETYP 4',
|
||||
'#RAR 0 20260101 20261231',
|
||||
'#VER A 1 20260115 "För lång kod"',
|
||||
'{',
|
||||
`#TRANS 5010 {6 "${longCode}"} 100.00`,
|
||||
'#TRANS 1930 {} -100.00',
|
||||
'}',
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
// The line keeps the tag (legacy free-text survives on lines)…
|
||||
expect(parsed.vouchers[0].lines[0].dimensions).toEqual({ '6': longCode })
|
||||
// …but the registry collection skips it and reports why.
|
||||
const usage = collectSIEDimensionUsage(parsed)
|
||||
expect(usage.values.size).toBe(0)
|
||||
expect(usage.invalidCodes.size).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -42,6 +42,8 @@ function makeParsedFile(overrides?: Partial<ParsedSIEFile>): ParsedSIEFile {
|
||||
openingBalances: [{ yearIndex: 0, account: '1930', amount: 50000 }],
|
||||
closingBalances: [],
|
||||
resultBalances: [],
|
||||
dimensions: [],
|
||||
dimensionValues: [],
|
||||
vouchers: [
|
||||
{
|
||||
series: 'A',
|
||||
|
||||
@@ -99,6 +99,8 @@ function makeParsedFile(overrides?: Partial<ParsedSIEFile>): ParsedSIEFile {
|
||||
{ yearIndex: 0, account: '2010', amount: -160406.0 },
|
||||
],
|
||||
resultBalances: [],
|
||||
dimensions: [],
|
||||
dimensionValues: [],
|
||||
vouchers: [],
|
||||
issues: [],
|
||||
stats: {
|
||||
|
||||
@@ -46,6 +46,8 @@ function makeParsedFile(): ParsedSIEFile {
|
||||
openingBalances: [],
|
||||
closingBalances: [],
|
||||
resultBalances: [],
|
||||
dimensions: [],
|
||||
dimensionValues: [],
|
||||
vouchers: [
|
||||
{
|
||||
series: 'A',
|
||||
|
||||
@@ -43,6 +43,8 @@ function makeParsedFile(overrides?: Partial<ParsedSIEFile>): ParsedSIEFile {
|
||||
],
|
||||
closingBalances: [],
|
||||
resultBalances: [],
|
||||
dimensions: [],
|
||||
dimensionValues: [],
|
||||
vouchers: [
|
||||
{
|
||||
series: 'A',
|
||||
|
||||
@@ -275,14 +275,105 @@ describe('parseSIEFile', () => {
|
||||
expect(v1.lines[2]).toMatchObject({ account: '2611', amount: -2500 })
|
||||
})
|
||||
|
||||
it('handles object lists in braces', () => {
|
||||
it('handles object lists in braces and captures them as dimensions', () => {
|
||||
const result = parseSIEFile(SIE_WITH_OBJECT_LIST)
|
||||
expect(result.vouchers).toHaveLength(1)
|
||||
|
||||
const v = result.vouchers[0]
|
||||
expect(v.lines).toHaveLength(2)
|
||||
expect(v.lines[0]).toMatchObject({ account: '5010', amount: 15000 })
|
||||
// The object list is data, not noise — lossless import (PR5).
|
||||
expect(v.lines[0].dimensions).toEqual({ '1': 'Kontor' })
|
||||
expect(v.lines[1]).toMatchObject({ account: '1930', amount: -15000 })
|
||||
// Empty object list {} → no dimensions key at all.
|
||||
expect(v.lines[1].dimensions).toBeUndefined()
|
||||
})
|
||||
|
||||
it('parses multi-pair object lists with quoted codes and canonical keys', () => {
|
||||
const sie = [
|
||||
'#FLAGGA 0',
|
||||
'#SIETYP 4',
|
||||
'#RAR 0 20240101 20241231',
|
||||
'#VER A 1 20240115 "Projektköp"',
|
||||
'{',
|
||||
'#TRANS 5010 {"1" "KS 01" 06 "P001"} 15000.00',
|
||||
'#TRANS 1930 {} -15000.00',
|
||||
'}',
|
||||
].join('\n')
|
||||
|
||||
const result = parseSIEFile(sie)
|
||||
// '06' canonicalizes to '6' (matches normalizeLineDimensions); quoted
|
||||
// codes may contain spaces.
|
||||
expect(result.vouchers[0].lines[0].dimensions).toEqual({ '1': 'KS 01', '6': 'P001' })
|
||||
})
|
||||
|
||||
it('warns on a malformed (odd-field) object list but keeps the line', () => {
|
||||
const sie = [
|
||||
'#FLAGGA 0',
|
||||
'#SIETYP 4',
|
||||
'#RAR 0 20240101 20241231',
|
||||
'#VER A 1 20240115 "Trasig objektlista"',
|
||||
'{',
|
||||
'#TRANS 5010 {6} 100.00',
|
||||
'#TRANS 1930 {} -100.00',
|
||||
'}',
|
||||
].join('\n')
|
||||
|
||||
const result = parseSIEFile(sie)
|
||||
expect(result.vouchers[0].lines[0]).toMatchObject({ account: '5010', amount: 100 })
|
||||
expect(result.vouchers[0].lines[0].dimensions).toBeUndefined()
|
||||
expect(result.issues.some((i) => i.severity === 'warning' && i.message.toLowerCase().includes('objektlista'))).toBe(true)
|
||||
})
|
||||
|
||||
it('surfaces OIB/OUB drops and dimension presence as info issues', () => {
|
||||
const sie = [
|
||||
'#FLAGGA 0',
|
||||
'#SIETYP 4',
|
||||
'#RAR 0 20240101 20241231',
|
||||
'#DIM 6 "Projekt"',
|
||||
'#OIB 0 1930 {6 "P001"} 5000.00',
|
||||
'#OUB 0 1930 {6 "P001"} 7000.00',
|
||||
'#VER A 1 20240115 "Taggad"',
|
||||
'{',
|
||||
'#TRANS 5010 {6 "P001"} 100.00',
|
||||
'#TRANS 1930 {} -100.00',
|
||||
'}',
|
||||
].join('\n')
|
||||
|
||||
const result = parseSIEFile(sie)
|
||||
const infos = result.issues.filter((i) => i.severity === 'info').map((i) => i.message)
|
||||
expect(infos.some((m) => m.includes('2 objektbalansrader'))).toBe(true)
|
||||
expect(infos.some((m) => m.includes('dimensionsdata'))).toBe(true)
|
||||
// Silence preserved for files without any dimension data.
|
||||
const plain = parseSIEFile(['#FLAGGA 0', '#SIETYP 4', '#RAR 0 20240101 20241231'].join('\n'))
|
||||
expect(plain.issues.some((i) => i.tag === 'DIM' || i.tag === 'OIB')).toBe(false)
|
||||
})
|
||||
|
||||
it('parses #DIM, #UNDERDIM and #OBJEKT into the registry arrays', () => {
|
||||
const sie = [
|
||||
'#FLAGGA 0',
|
||||
'#SIETYP 4',
|
||||
'#RAR 0 20240101 20241231',
|
||||
'#DIM 1 "Kostnadsställe"',
|
||||
'#DIM 6 "Projekt"',
|
||||
'#UNDERDIM 2 "Kostnadsbärare" 1',
|
||||
'#OBJEKT 1 "KS01" "Butiken"',
|
||||
'#OBJEKT 6 "P001" "Villa Almgren"',
|
||||
'#OBJEKT 6 "P002" ""',
|
||||
].join('\n')
|
||||
|
||||
const result = parseSIEFile(sie)
|
||||
expect(result.dimensions).toEqual([
|
||||
{ sieDimNo: 1, name: 'Kostnadsställe' },
|
||||
{ sieDimNo: 6, name: 'Projekt' },
|
||||
{ sieDimNo: 2, name: 'Kostnadsbärare', parentSieDimNo: 1 },
|
||||
])
|
||||
expect(result.dimensionValues).toEqual([
|
||||
{ sieDimNo: 1, code: 'KS01', name: 'Butiken' },
|
||||
{ sieDimNo: 6, code: 'P001', name: 'Villa Almgren' },
|
||||
// Nameless objekt falls back to its code.
|
||||
{ sieDimNo: 6, code: 'P002', name: 'P002' },
|
||||
])
|
||||
})
|
||||
|
||||
it('parses quoted VER fields (series, number, date)', () => {
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getPool } from '@/tests/pg/setup'
|
||||
import { seedCompany, insertDraftJournalEntry } from '@/tests/pg/fixtures'
|
||||
|
||||
// Migration 20260702154500_dimension_import_provenance_undo_lockstep.sql:
|
||||
// SIE import creates dimensions/dimension_values rows carrying
|
||||
// created_by_import_id; undo_sie_import deletes the rows the undone import
|
||||
// introduced — but ONLY when no remaining posted/reversed line references
|
||||
// them, and NEVER user-created rows (created_by_import_id IS NULL).
|
||||
//
|
||||
// The registry guard triggers (enforce_dimension_registry_guards /
|
||||
// enforce_dimension_value_retention) fire on these deletes as a backstop, so
|
||||
// these tests also prove the lockstep deletes are trigger-compatible.
|
||||
|
||||
async function insertCompletedImport(params: {
|
||||
companyId: string
|
||||
userId: string
|
||||
fiscalPeriodId: string
|
||||
}): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.sie_imports
|
||||
(id, user_id, company_id, filename, file_hash, sie_type,
|
||||
fiscal_year_start, fiscal_year_end, accounts_count, transactions_count,
|
||||
status, fiscal_period_id, imported_at)
|
||||
VALUES ($1, $2, $3, 'undo-dims-test.se', $4, 4,
|
||||
'2026-01-01', '2026-12-31', 0, 1,
|
||||
'completed', $5, now())`,
|
||||
[id, params.userId, params.companyId, `hash-${id}`, params.fiscalPeriodId],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
/** Posted entry with dimension-tagged balanced lines. */
|
||||
async function insertPostedTaggedEntry(params: {
|
||||
companyId: string
|
||||
userId: string
|
||||
fiscalPeriodId: string
|
||||
sourceType: string
|
||||
voucherNumber: number
|
||||
dimensions: Record<string, string>
|
||||
}): Promise<string> {
|
||||
const jeId = await insertDraftJournalEntry({
|
||||
userId: params.userId,
|
||||
companyId: params.companyId,
|
||||
fiscalPeriodId: params.fiscalPeriodId,
|
||||
sourceType: params.sourceType,
|
||||
status: 'draft',
|
||||
voucherNumber: params.voucherNumber,
|
||||
})
|
||||
await getPool().query(
|
||||
`INSERT INTO public.journal_entry_lines
|
||||
(journal_entry_id, account_number, debit_amount, credit_amount, dimensions)
|
||||
VALUES ($1, '5010', 1000, 0, $2::jsonb),
|
||||
($1, '1930', 0, 1000, '{}'::jsonb)`,
|
||||
[jeId, JSON.stringify(params.dimensions)],
|
||||
)
|
||||
await getPool().query(
|
||||
`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`,
|
||||
[jeId],
|
||||
)
|
||||
return jeId
|
||||
}
|
||||
|
||||
async function insertDimension(params: {
|
||||
companyId: string
|
||||
sieDimNo: number
|
||||
name: string
|
||||
importId?: string | null
|
||||
isSystem?: boolean
|
||||
}): Promise<string> {
|
||||
const { rows } = await getPool().query<{ id: string }>(
|
||||
`INSERT INTO public.dimensions
|
||||
(company_id, sie_dim_no, name, resets_annually, is_system, created_by_import_id)
|
||||
VALUES ($1, $2, $3, true, $4, $5)
|
||||
RETURNING id`,
|
||||
[params.companyId, params.sieDimNo, params.name, params.isSystem ?? false, params.importId ?? null],
|
||||
)
|
||||
return rows[0].id
|
||||
}
|
||||
|
||||
async function insertDimensionValue(params: {
|
||||
companyId: string
|
||||
dimensionId: string
|
||||
code: string
|
||||
importId?: string | null
|
||||
}): Promise<string> {
|
||||
const { rows } = await getPool().query<{ id: string }>(
|
||||
`INSERT INTO public.dimension_values
|
||||
(company_id, dimension_id, code, name, created_by_import_id)
|
||||
VALUES ($1, $2, $3, $3, $4)
|
||||
RETURNING id`,
|
||||
[params.companyId, params.dimensionId, params.code, params.importId ?? null],
|
||||
)
|
||||
return rows[0].id
|
||||
}
|
||||
|
||||
async function callUndo(companyId: string, importId: string, actor: string) {
|
||||
return getPool().query<{ deleted: number }>(
|
||||
`SELECT public.undo_sie_import($1::uuid, $2::uuid, $3::uuid) AS deleted`,
|
||||
[companyId, importId, actor],
|
||||
)
|
||||
}
|
||||
|
||||
async function countRows(table: string, id: string): Promise<number> {
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT 1 FROM public.${table} WHERE id = $1`,
|
||||
[id],
|
||||
)
|
||||
return rows.length
|
||||
}
|
||||
|
||||
describe('undo_sie_import: dimension registry lockstep', () => {
|
||||
it('deletes import-created values whose references vanish with the import', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
const importId = await insertCompletedImport({ companyId, userId, fiscalPeriodId })
|
||||
|
||||
const dimId = await insertDimension({ companyId, sieDimNo: 6, name: 'Projekt' })
|
||||
const valueId = await insertDimensionValue({ companyId, dimensionId: dimId, code: 'P001', importId })
|
||||
await insertPostedTaggedEntry({
|
||||
companyId, userId, fiscalPeriodId,
|
||||
sourceType: 'import', voucherNumber: 1,
|
||||
dimensions: { '6': 'P001' },
|
||||
})
|
||||
|
||||
const res = await callUndo(companyId, importId, userId)
|
||||
expect(res.rows[0].deleted).toBe(1)
|
||||
|
||||
// The import's value is gone; the dimension itself (not import-created)
|
||||
// survives.
|
||||
expect(await countRows('dimension_values', valueId)).toBe(0)
|
||||
expect(await countRows('dimensions', dimId)).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps import-created values that other posted bookkeeping still references', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
const importId = await insertCompletedImport({ companyId, userId, fiscalPeriodId })
|
||||
|
||||
const dimId = await insertDimension({ companyId, sieDimNo: 6, name: 'Projekt' })
|
||||
const valueId = await insertDimensionValue({ companyId, dimensionId: dimId, code: 'P002', importId })
|
||||
await insertPostedTaggedEntry({
|
||||
companyId, userId, fiscalPeriodId,
|
||||
sourceType: 'import', voucherNumber: 1,
|
||||
dimensions: { '6': 'P002' },
|
||||
})
|
||||
// A MANUAL posted entry tagged with the same code — survives the undo and
|
||||
// must keep its registry row (retention trigger would block the delete;
|
||||
// the lockstep's own WHERE avoids even attempting it).
|
||||
await insertPostedTaggedEntry({
|
||||
companyId, userId, fiscalPeriodId,
|
||||
sourceType: 'manual', voucherNumber: 2,
|
||||
dimensions: { '6': 'P002' },
|
||||
})
|
||||
|
||||
const res = await callUndo(companyId, importId, userId)
|
||||
expect(res.rows[0].deleted).toBe(1) // only the import entry
|
||||
|
||||
expect(await countRows('dimension_values', valueId)).toBe(1)
|
||||
})
|
||||
|
||||
it('never touches user-created values (no provenance)', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
const importId = await insertCompletedImport({ companyId, userId, fiscalPeriodId })
|
||||
|
||||
const dimId = await insertDimension({ companyId, sieDimNo: 6, name: 'Projekt' })
|
||||
const userValueId = await insertDimensionValue({ companyId, dimensionId: dimId, code: 'EGEN', importId: null })
|
||||
await insertPostedTaggedEntry({
|
||||
companyId, userId, fiscalPeriodId,
|
||||
sourceType: 'import', voucherNumber: 1,
|
||||
dimensions: { '6': 'EGEN' },
|
||||
})
|
||||
|
||||
await callUndo(companyId, importId, userId)
|
||||
|
||||
// Unreferenced now, but user-created → stays.
|
||||
expect(await countRows('dimension_values', userValueId)).toBe(1)
|
||||
})
|
||||
|
||||
it('deletes an import-created custom dimension once empty and unreferenced', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
const importId = await insertCompletedImport({ companyId, userId, fiscalPeriodId })
|
||||
|
||||
const dimId = await insertDimension({ companyId, sieDimNo: 7, name: 'Anställd', importId })
|
||||
const valueId = await insertDimensionValue({ companyId, dimensionId: dimId, code: 'ANNA', importId })
|
||||
await insertPostedTaggedEntry({
|
||||
companyId, userId, fiscalPeriodId,
|
||||
sourceType: 'import', voucherNumber: 1,
|
||||
dimensions: { '7': 'ANNA' },
|
||||
})
|
||||
|
||||
await callUndo(companyId, importId, userId)
|
||||
|
||||
expect(await countRows('dimension_values', valueId)).toBe(0)
|
||||
expect(await countRows('dimensions', dimId)).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps an import-created dimension that still has user-created values', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
const importId = await insertCompletedImport({ companyId, userId, fiscalPeriodId })
|
||||
|
||||
const dimId = await insertDimension({ companyId, sieDimNo: 8, name: 'Kund', importId })
|
||||
const userValueId = await insertDimensionValue({ companyId, dimensionId: dimId, code: 'KUND1', importId: null })
|
||||
await insertPostedTaggedEntry({
|
||||
companyId, userId, fiscalPeriodId,
|
||||
sourceType: 'import', voucherNumber: 1,
|
||||
dimensions: { '8': 'KUND1' },
|
||||
})
|
||||
|
||||
await callUndo(companyId, importId, userId)
|
||||
|
||||
// The user's value anchors the dimension.
|
||||
expect(await countRows('dimension_values', userValueId)).toBe(1)
|
||||
expect(await countRows('dimensions', dimId)).toBe(1)
|
||||
})
|
||||
|
||||
it('deleting an old sie_imports row nulls provenance instead of cascading', async () => {
|
||||
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
||||
const importId = await insertCompletedImport({ companyId, userId, fiscalPeriodId })
|
||||
const dimId = await insertDimension({ companyId, sieDimNo: 6, name: 'Projekt' })
|
||||
const valueId = await insertDimensionValue({ companyId, dimensionId: dimId, code: 'P009', importId })
|
||||
|
||||
await getPool().query(`DELETE FROM public.sie_imports WHERE id = $1`, [importId])
|
||||
|
||||
const { rows } = await getPool().query<{ created_by_import_id: string | null }>(
|
||||
`SELECT created_by_import_id FROM public.dimension_values WHERE id = $1`,
|
||||
[valueId],
|
||||
)
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].created_by_import_id).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,216 @@
|
||||
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 }
|
||||
}
|
||||
@@ -7,6 +7,8 @@
|
||||
*/
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { normalizeLineDimensions, lineDimensionColumns } from '@/lib/bookkeeping/dimension-resolver'
|
||||
import { importDimensionRegistry } from './sie-dimensions'
|
||||
import { createJournalEntry, reverseEntry } from '@/lib/bookkeeping/engine'
|
||||
import type {
|
||||
ParsedSIEFile,
|
||||
@@ -1012,7 +1014,13 @@ export async function importVouchers(
|
||||
// 'import' for ordinary migrated vouchers; 'opening_balance' for a #VER that
|
||||
// is really the year's ingående balans (see isLikelyOpeningBalance below).
|
||||
sourceType: 'import' | 'opening_balance'
|
||||
lines: { account_number: string; debit_amount: number; credit_amount: number; line_description: string | null }[]
|
||||
lines: {
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
line_description: string | null
|
||||
dimensions?: Record<string, string>
|
||||
}[]
|
||||
}
|
||||
|
||||
const preparedVouchers: PreparedVoucher[] = []
|
||||
@@ -1060,6 +1068,7 @@ export async function importVouchers(
|
||||
debit_amount: Math.round(line.amount * 100) / 100,
|
||||
credit_amount: 0,
|
||||
line_description: line.description || null,
|
||||
...(line.dimensions ? { dimensions: line.dimensions } : {}),
|
||||
})
|
||||
} else if (line.amount < 0) {
|
||||
lines.push({
|
||||
@@ -1067,6 +1076,7 @@ export async function importVouchers(
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.round(Math.abs(line.amount) * 100) / 100,
|
||||
line_description: line.description || null,
|
||||
...(line.dimensions ? { dimensions: line.dimensions } : {}),
|
||||
})
|
||||
}
|
||||
// Note: lines with amount === 0 are silently dropped
|
||||
@@ -1334,6 +1344,9 @@ export async function importVouchers(
|
||||
currency: string
|
||||
line_description: string | null
|
||||
sort_order: number
|
||||
dimensions: Record<string, string>
|
||||
cost_center: string | null
|
||||
project: string | null
|
||||
}[] = []
|
||||
|
||||
for (let i = 0; i < batch.length; i++) {
|
||||
@@ -1343,6 +1356,12 @@ export async function importVouchers(
|
||||
const voucher = batch[i]
|
||||
const assignedNumber = currentVoucherNumber + batchStart + i
|
||||
voucher.lines.forEach((line, lineIndex) => {
|
||||
// dimensions jsonb is the source of truth; cost_center/project are
|
||||
// derived mirrors — the same dual-write every sanctioned writer uses
|
||||
// (see lib/bookkeeping/dimension-resolver.ts). SIE object-list codes
|
||||
// survive verbatim on lines (legacy free-text is a documented
|
||||
// exception to the registry format rules).
|
||||
const dims = normalizeLineDimensions({ dimensions: line.dimensions ?? null })
|
||||
allLines.push({
|
||||
journal_entry_id: entryId,
|
||||
account_number: line.account_number,
|
||||
@@ -1352,6 +1371,8 @@ export async function importVouchers(
|
||||
currency: 'SEK',
|
||||
line_description: line.line_description,
|
||||
sort_order: lineIndex,
|
||||
dimensions: dims,
|
||||
...lineDimensionColumns(dims),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2081,6 +2102,26 @@ export async function executeSIEImport(
|
||||
options.filename
|
||||
)
|
||||
|
||||
// Dimension registry (#DIM/#UNDERDIM/#OBJEKT + object-list references):
|
||||
// upsert missing rows and auto-enable the toggle with a notice. Runs
|
||||
// before vouchers so tagged lines land with their registry rows present.
|
||||
// Files without dimension data return null — nothing changes.
|
||||
const dimensionSummary = await importDimensionRegistry(
|
||||
supabase,
|
||||
companyId,
|
||||
parsed,
|
||||
result.importId
|
||||
)
|
||||
if (dimensionSummary) {
|
||||
result.dimensionsImported = {
|
||||
dimensions: dimensionSummary.dimensionsCreated,
|
||||
values: dimensionSummary.valuesCreated,
|
||||
taggedLines: dimensionSummary.taggedLines,
|
||||
toggleEnabled: dimensionSummary.toggleEnabled,
|
||||
}
|
||||
result.warnings.push(...dimensionSummary.warnings)
|
||||
}
|
||||
|
||||
// Build account mapping lookup
|
||||
const accountMap = mappingsToMap(mappings)
|
||||
|
||||
|
||||
+126
-4
@@ -18,6 +18,8 @@ import type {
|
||||
SIEBalance,
|
||||
SIEVoucher,
|
||||
SIETransactionLine,
|
||||
SIEDimension,
|
||||
SIEDimensionValue,
|
||||
ParsedSIEFile,
|
||||
ParseIssue,
|
||||
ParseIssueSeverity,
|
||||
@@ -343,6 +345,41 @@ function splitSIELine(line: string): string[] {
|
||||
return fields
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a #TRANS object list — `{1 "KS01" 6 "P001"}` — into an SIE dim
|
||||
* number → object code map. The list arrives as ONE field thanks to the
|
||||
* brace-aware splitter; the inner content is itself space-separated with
|
||||
* SIE quoting, so it re-runs through splitSIELine. Returns undefined for an
|
||||
* empty list ({}), malformed pairs are skipped with a warning issue.
|
||||
*/
|
||||
function parseObjectList(
|
||||
raw: string,
|
||||
issues: ParseIssue[],
|
||||
lineNum: number
|
||||
): Record<string, string> | undefined {
|
||||
const inner = raw.replace(/^\{/, '').replace(/\}$/, '').trim()
|
||||
if (!inner) return undefined
|
||||
|
||||
const parts = splitSIELine(inner)
|
||||
if (parts.length % 2 !== 0) {
|
||||
addIssue(issues, 'warning', lineNum, `Objektlista med udda antal fält ignoreras delvis: ${raw}`, 'TRANS')
|
||||
}
|
||||
|
||||
const dims: Record<string, string> = {}
|
||||
for (let i = 0; i + 1 < parts.length; i += 2) {
|
||||
const dimNoRaw = parseStringField(parts[i])
|
||||
const code = parseStringField(parts[i + 1]).trim()
|
||||
const dimNo = parseInt(dimNoRaw, 10)
|
||||
if (isNaN(dimNo) || dimNo < 1 || !code) {
|
||||
addIssue(issues, 'warning', lineNum, `Ogiltigt objektpar i objektlista: ${dimNoRaw} ${code}`, 'TRANS')
|
||||
continue
|
||||
}
|
||||
// Canonical numeric key ('01' → '1') — matches normalizeLineDimensions.
|
||||
dims[String(dimNo)] = code
|
||||
}
|
||||
return Object.keys(dims).length > 0 ? dims : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an issue to the issues list
|
||||
*/
|
||||
@@ -385,6 +422,9 @@ export function parseSIEFile(content: string): ParsedSIEFile {
|
||||
const closingBalances: SIEBalance[] = []
|
||||
const resultBalances: SIEBalance[] = []
|
||||
const vouchers: SIEVoucher[] = []
|
||||
const dimensions: SIEDimension[] = []
|
||||
const dimensionValues: SIEDimensionValue[] = []
|
||||
let objectBalanceCount = 0
|
||||
|
||||
// Track current voucher being parsed (inside #VER { ... })
|
||||
let currentVoucher: SIEVoucher | null = null
|
||||
@@ -644,12 +684,16 @@ export function parseSIEFile(content: string): ParsedSIEFile {
|
||||
break
|
||||
}
|
||||
|
||||
// Parse account and skip object list (in braces)
|
||||
// Parse account and capture the object list (in braces)
|
||||
let fieldIndex = 1
|
||||
const account = parseStringField(fields[fieldIndex++])
|
||||
|
||||
// Skip object list if present (now a single field thanks to brace-aware splitting)
|
||||
// Object list (single field thanks to brace-aware splitting) —
|
||||
// dimension tags like {1 "KS01" 6 "P001"}. Parsed onto the line so
|
||||
// import is lossless (dimensions plan PR5).
|
||||
let objectListRaw: string | null = null
|
||||
if (fields[fieldIndex]?.startsWith('{')) {
|
||||
objectListRaw = fields[fieldIndex]
|
||||
fieldIndex++
|
||||
}
|
||||
|
||||
@@ -666,6 +710,13 @@ export function parseSIEFile(content: string): ParsedSIEFile {
|
||||
amount,
|
||||
}
|
||||
|
||||
if (objectListRaw) {
|
||||
const dims = parseObjectList(objectListRaw, issues, lineNum)
|
||||
if (dims) {
|
||||
transLine.dimensions = dims
|
||||
}
|
||||
}
|
||||
|
||||
// Optional fields
|
||||
if (fields[fieldIndex]) {
|
||||
transLine.date = parseSIEDate(parseStringField(fields[fieldIndex++])) || undefined
|
||||
@@ -684,9 +735,53 @@ export function parseSIEFile(content: string): ParsedSIEFile {
|
||||
break
|
||||
}
|
||||
|
||||
case 'DIM': {
|
||||
// #DIM dimNo "name"
|
||||
const dimNo = parseInt(parseStringField(fields[1]), 10)
|
||||
const name = parseStringField(fields[2])
|
||||
if (!isNaN(dimNo) && dimNo >= 1) {
|
||||
dimensions.push({ sieDimNo: dimNo, name: name || '' })
|
||||
} else {
|
||||
addIssue(issues, 'warning', lineNum, 'Ogiltig dimensionsdefinition — numret kunde inte tolkas', tag)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'UNDERDIM': {
|
||||
// #UNDERDIM dimNo "name" parentDimNo
|
||||
const dimNo = parseInt(parseStringField(fields[1]), 10)
|
||||
const name = parseStringField(fields[2])
|
||||
const parent = parseInt(parseStringField(fields[3]), 10)
|
||||
if (!isNaN(dimNo) && dimNo >= 1 && !isNaN(parent) && parent >= 1) {
|
||||
dimensions.push({ sieDimNo: dimNo, name: name || '', parentSieDimNo: parent })
|
||||
} else {
|
||||
addIssue(issues, 'warning', lineNum, 'Ogiltig underdimension — nummer eller överdimension kunde inte tolkas', tag)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'OBJEKT': {
|
||||
// #OBJEKT dimNo "code" "name"
|
||||
const dimNo = parseInt(parseStringField(fields[1]), 10)
|
||||
const code = parseStringField(fields[2]).trim()
|
||||
const name = parseStringField(fields[3])
|
||||
if (!isNaN(dimNo) && dimNo >= 1 && code) {
|
||||
dimensionValues.push({ sieDimNo: dimNo, code, name: name || code })
|
||||
} else {
|
||||
addIssue(issues, 'warning', lineNum, 'Ogiltigt objekt — dimension eller kod kunde inte tolkas', tag)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
// Unknown tag - add info issue for notable ones
|
||||
if (!['KSUMMA', 'BKOD', 'TAXAR', 'OMFATTN', 'DIM', 'OBJEKT', 'OIB', 'OUB', 'PBUDGET', 'PSALDO'].includes(tag)) {
|
||||
// Unknown tag - add info issue for notable ones. OIB/OUB (per-object
|
||||
// opening/closing balances) are counted and surfaced as ONE info
|
||||
// issue below — dimension reporting is P&L-only in v1, so
|
||||
// object-level balance records have no consumer yet, but dropping
|
||||
// them must never be silent (#866 review).
|
||||
if (tag === 'OIB' || tag === 'OUB') {
|
||||
objectBalanceCount++
|
||||
} else if (!['KSUMMA', 'BKOD', 'TAXAR', 'OMFATTN', 'PBUDGET', 'PSALDO'].includes(tag)) {
|
||||
addIssue(issues, 'info', lineNum, `Okänd tagg: #${tag} — ignoreras`, tag)
|
||||
}
|
||||
}
|
||||
@@ -755,6 +850,31 @@ export function parseSIEFile(content: string): ParsedSIEFile {
|
||||
)
|
||||
}
|
||||
|
||||
// Dimension visibility: the preview step renders parse issues, so these
|
||||
// make dimension handling explicit BEFORE the user executes the import.
|
||||
if (objectBalanceCount > 0) {
|
||||
addIssue(
|
||||
issues,
|
||||
'info',
|
||||
0,
|
||||
`${objectBalanceCount} objektbalansrader (#OIB/#OUB) hoppades över — balanser per objekt stöds inte ännu`,
|
||||
'OIB'
|
||||
)
|
||||
}
|
||||
const taggedLineCount = vouchers.reduce(
|
||||
(sum, v) => sum + v.lines.filter((l) => l.dimensions).length,
|
||||
0
|
||||
)
|
||||
if (dimensions.length > 0 || dimensionValues.length > 0 || taggedLineCount > 0) {
|
||||
addIssue(
|
||||
issues,
|
||||
'info',
|
||||
0,
|
||||
`Filen innehåller dimensionsdata (kostnadsställen/projekt): ${taggedLineCount} taggade rader — dimensionerna följer med importen`,
|
||||
'DIM'
|
||||
)
|
||||
}
|
||||
|
||||
// Calculate statistics
|
||||
const currentFiscalYear = header.fiscalYears.find((fy) => fy.yearIndex === 0)
|
||||
const totalTransactionLines = vouchers.reduce((sum, v) => sum + v.lines.length, 0)
|
||||
@@ -766,6 +886,8 @@ export function parseSIEFile(content: string): ParsedSIEFile {
|
||||
closingBalances,
|
||||
resultBalances,
|
||||
vouchers,
|
||||
dimensions,
|
||||
dimensionValues,
|
||||
issues,
|
||||
stats: {
|
||||
totalAccounts: accounts.length,
|
||||
|
||||
@@ -84,6 +84,27 @@ export interface SIETransactionLine {
|
||||
quantity?: number
|
||||
signature?: string
|
||||
objectId?: string
|
||||
/** Object list ({dimNo "code" …}) as SIE dim number → object code. */
|
||||
dimensions?: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Dimension declaration from #DIM or #UNDERDIM
|
||||
*/
|
||||
export interface SIEDimension {
|
||||
sieDimNo: number
|
||||
name: string
|
||||
/** Set when declared via #UNDERDIM — the parent dimension number. */
|
||||
parentSieDimNo?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Dimension value from #OBJEKT
|
||||
*/
|
||||
export interface SIEDimensionValue {
|
||||
sieDimNo: number
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,6 +148,10 @@ export interface ParsedSIEFile {
|
||||
// Transactions (SIE4 only)
|
||||
vouchers: SIEVoucher[]
|
||||
|
||||
// Dimension registry records (#DIM / #UNDERDIM / #OBJEKT)
|
||||
dimensions: SIEDimension[]
|
||||
dimensionValues: SIEDimensionValue[]
|
||||
|
||||
// Parse issues
|
||||
issues: ParseIssue[]
|
||||
|
||||
@@ -319,6 +344,16 @@ export interface ImportResult {
|
||||
// If the next period's IB needed resync but we couldn't do it (locked,
|
||||
// closed, or no existing IB), the human-readable reason.
|
||||
nextPeriodIBResyncSkipped?: { reason: string; nextPeriodName: string } | null
|
||||
|
||||
// Populated when the file carried dimension data (#DIM/#OBJEKT/object
|
||||
// lists): what landed in the registry and whether the import flipped
|
||||
// company_settings.dimensions_enabled on (with a UI notice).
|
||||
dimensionsImported?: {
|
||||
dimensions: number
|
||||
values: number
|
||||
taggedLines: number
|
||||
toggleEnabled: boolean
|
||||
} | null
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -344,7 +344,7 @@ interface RegistryValue {
|
||||
* 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 }> = {
|
||||
export const SIE_RESERVED_DIMENSIONS: Record<number, { name: string; parent?: number }> = {
|
||||
1: { name: 'Kostnadsställe' },
|
||||
2: { name: 'Kostnadsbärare', parent: 1 },
|
||||
6: { name: 'Projekt' },
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
-- Dimensions plan PR5 (SIE round-trip): registry provenance + undo lockstep.
|
||||
--
|
||||
-- SIE import now creates `dimensions`/`dimension_values` rows (#DIM /
|
||||
-- #UNDERDIM / #OBJEKT / object-list references). undo_sie_import previously
|
||||
-- only deleted the import's journal entries — the registry rows it introduced
|
||||
-- would linger as orphans. Two changes:
|
||||
--
|
||||
-- 1. `created_by_import_id` provenance on both registry tables (NULL for
|
||||
-- user-created rows; ON DELETE SET NULL so deleting an old sie_imports
|
||||
-- row never cascades into the registry).
|
||||
-- 2. undo_sie_import deletes the values/dimensions the undone import
|
||||
-- created, but ONLY when no remaining posted/reversed line references
|
||||
-- them — user-created rows and rows referenced by other imports or
|
||||
-- manual bookkeeping are untouched. The registry guard triggers
|
||||
-- (enforce_dimension_registry_guards / enforce_dimension_value_retention)
|
||||
-- run on these deletes as a backstop: the WHERE clauses mirror their
|
||||
-- reference checks, so they only fire if a concurrent write tagged a
|
||||
-- line between check and delete — in which case aborting the undo is
|
||||
-- the correct outcome.
|
||||
--
|
||||
-- replace_sie_import intentionally does NOT get the lockstep: replace is
|
||||
-- undo + immediate re-import of the same fiscal year, and the re-import
|
||||
-- re-upserts the same codes — deleting them in between would only churn ids
|
||||
-- that MCP/agent flows may hold. Stale values from a replaced file remain
|
||||
-- inactivatable via the register UI.
|
||||
--
|
||||
-- pg-test: lib/import/__tests__/undo-sie-import-dimensions.pg.test.ts
|
||||
|
||||
ALTER TABLE public.dimensions
|
||||
ADD COLUMN created_by_import_id uuid REFERENCES public.sie_imports(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE public.dimension_values
|
||||
ADD COLUMN created_by_import_id uuid REFERENCES public.sie_imports(id) ON DELETE SET NULL;
|
||||
|
||||
COMMENT ON COLUMN public.dimensions.created_by_import_id IS
|
||||
'SIE import that introduced this dimension (NULL = user-created). Undo deletes import-created rows that ended up unreferenced.';
|
||||
COMMENT ON COLUMN public.dimension_values.created_by_import_id IS
|
||||
'SIE import that introduced this value (NULL = user-created). Undo deletes import-created rows that ended up unreferenced.';
|
||||
|
||||
-- Undo looks rows up by import id; partial indexes keep the common case
|
||||
-- (user-created rows, NULL) out of the index entirely.
|
||||
CREATE INDEX idx_dimensions_created_by_import
|
||||
ON public.dimensions (created_by_import_id)
|
||||
WHERE created_by_import_id IS NOT NULL;
|
||||
CREATE INDEX idx_dimension_values_created_by_import
|
||||
ON public.dimension_values (created_by_import_id)
|
||||
WHERE created_by_import_id IS NOT NULL;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.undo_sie_import(
|
||||
p_company_id uuid,
|
||||
p_import_id uuid,
|
||||
p_user_id uuid DEFAULT NULL
|
||||
)
|
||||
RETURNS integer
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
-- CREATE OR REPLACE resets proconfig, so the function-local timeout from
|
||||
-- 20260629160100 must be restated here or the service-client bulk delete
|
||||
-- regresses to the authenticator role's 8s limit (pinned by
|
||||
-- sie-import.replace.pg.test.ts).
|
||||
SET statement_timeout TO '290s'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_fiscal_period_id uuid;
|
||||
v_opening_balance_entry_id uuid;
|
||||
v_is_closed boolean;
|
||||
v_locked_at timestamptz;
|
||||
v_deleted integer := 0;
|
||||
v_caller_role text;
|
||||
v_actor uuid := COALESCE(p_user_id, auth.uid());
|
||||
BEGIN
|
||||
SELECT cm.role INTO v_caller_role
|
||||
FROM company_members cm
|
||||
WHERE cm.company_id = p_company_id
|
||||
AND cm.user_id = v_actor;
|
||||
|
||||
IF v_caller_role IS NULL OR v_caller_role NOT IN ('owner', 'admin') THEN
|
||||
RAISE EXCEPTION 'Only company owners and admins can undo SIE imports';
|
||||
END IF;
|
||||
|
||||
SELECT fiscal_period_id, opening_balance_entry_id
|
||||
INTO v_fiscal_period_id, v_opening_balance_entry_id
|
||||
FROM public.sie_imports
|
||||
WHERE id = p_import_id
|
||||
AND company_id = p_company_id
|
||||
AND status = 'completed';
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Import % not found or not in completed status', p_import_id;
|
||||
END IF;
|
||||
|
||||
IF v_fiscal_period_id IS NOT NULL THEN
|
||||
SELECT is_closed, locked_at
|
||||
INTO v_is_closed, v_locked_at
|
||||
FROM public.fiscal_periods
|
||||
WHERE id = v_fiscal_period_id;
|
||||
|
||||
IF v_is_closed OR v_locked_at IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Cannot undo SIE import in a locked or closed fiscal period';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
PERFORM set_config('gnubok.allow_delete', 'true', true);
|
||||
|
||||
-- Detach documents (entry- and line-level).
|
||||
UPDATE public.document_attachments
|
||||
SET journal_entry_id = NULL,
|
||||
journal_entry_line_id = NULL
|
||||
WHERE journal_entry_id IN (
|
||||
SELECT je.id
|
||||
FROM public.journal_entries je
|
||||
WHERE je.company_id = p_company_id
|
||||
AND je.fiscal_period_id = v_fiscal_period_id
|
||||
AND je.source_type IN ('import', 'opening_balance')
|
||||
AND je.status IN ('posted', 'cancelled')
|
||||
)
|
||||
OR journal_entry_line_id IN (
|
||||
SELECT jel.id
|
||||
FROM public.journal_entry_lines jel
|
||||
JOIN public.journal_entries je ON je.id = jel.journal_entry_id
|
||||
WHERE je.company_id = p_company_id
|
||||
AND je.fiscal_period_id = v_fiscal_period_id
|
||||
AND je.source_type IN ('import', 'opening_balance')
|
||||
AND je.status IN ('posted', 'cancelled')
|
||||
);
|
||||
|
||||
-- Clear the fiscal-period OB pointer (two-step around
|
||||
-- enforce_opening_balance_immutability).
|
||||
IF v_opening_balance_entry_id IS NOT NULL THEN
|
||||
UPDATE public.fiscal_periods
|
||||
SET opening_balances_set = false
|
||||
WHERE id = v_fiscal_period_id
|
||||
AND opening_balance_entry_id = v_opening_balance_entry_id;
|
||||
|
||||
UPDATE public.fiscal_periods
|
||||
SET opening_balance_entry_id = NULL
|
||||
WHERE id = v_fiscal_period_id
|
||||
AND opening_balance_entry_id = v_opening_balance_entry_id;
|
||||
END IF;
|
||||
|
||||
-- Drop the sie_imports -> opening_balance_entry FK before delete.
|
||||
UPDATE public.sie_imports
|
||||
SET opening_balance_entry_id = NULL
|
||||
WHERE id = p_import_id;
|
||||
|
||||
-- Hard-delete the import's journal entries (both transaction vouchers
|
||||
-- and the opening_balance entry).
|
||||
WITH deleted AS (
|
||||
DELETE FROM public.journal_entries
|
||||
WHERE company_id = p_company_id
|
||||
AND fiscal_period_id = v_fiscal_period_id
|
||||
AND source_type IN ('import', 'opening_balance')
|
||||
AND status IN ('posted', 'cancelled')
|
||||
RETURNING id
|
||||
)
|
||||
SELECT count(*) INTO v_deleted FROM deleted;
|
||||
|
||||
-- Registry lockstep (dimensions plan PR5): remove dimension VALUES this
|
||||
-- import introduced, unless a remaining posted/reversed line still
|
||||
-- references the code (other imports, manual bookkeeping). User-created
|
||||
-- rows have created_by_import_id NULL and are never touched.
|
||||
DELETE FROM public.dimension_values dv
|
||||
USING public.dimensions d
|
||||
WHERE dv.created_by_import_id = p_import_id
|
||||
AND dv.company_id = p_company_id
|
||||
AND d.id = dv.dimension_id
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.journal_entries je
|
||||
JOIN public.journal_entry_lines jel ON jel.journal_entry_id = je.id
|
||||
WHERE je.company_id = p_company_id
|
||||
AND je.status IN ('posted', 'reversed')
|
||||
AND jel.dimensions ->> d.sie_dim_no::text = dv.code
|
||||
);
|
||||
|
||||
-- ...and custom DIMENSIONS this import introduced that are now empty and
|
||||
-- unreferenced. System dims (1/6) are never import-created and are
|
||||
-- trigger-protected regardless.
|
||||
DELETE FROM public.dimensions d
|
||||
WHERE d.created_by_import_id = p_import_id
|
||||
AND d.company_id = p_company_id
|
||||
AND d.is_system = false
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.dimension_values dv WHERE dv.dimension_id = d.id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.journal_entries je
|
||||
JOIN public.journal_entry_lines jel ON jel.journal_entry_id = je.id
|
||||
WHERE je.company_id = p_company_id
|
||||
AND je.status IN ('posted', 'reversed')
|
||||
AND jel.dimensions ? d.sie_dim_no::text
|
||||
);
|
||||
|
||||
-- Reset voucher_sequences per series to the max remaining number.
|
||||
UPDATE public.voucher_sequences vs
|
||||
SET last_number = COALESCE((
|
||||
SELECT MAX(je.voucher_number)
|
||||
FROM public.journal_entries je
|
||||
WHERE je.company_id = vs.company_id
|
||||
AND je.fiscal_period_id = vs.fiscal_period_id
|
||||
AND je.voucher_series = vs.voucher_series
|
||||
AND je.voucher_number > 0
|
||||
), 0),
|
||||
updated_at = now()
|
||||
WHERE vs.company_id = p_company_id
|
||||
AND vs.fiscal_period_id = v_fiscal_period_id;
|
||||
|
||||
UPDATE public.sie_imports
|
||||
SET status = 'undone',
|
||||
replaced_at = now()
|
||||
WHERE id = p_import_id
|
||||
AND company_id = p_company_id;
|
||||
|
||||
RETURN v_deleted;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
Reference in New Issue
Block a user