* 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>
221 lines
8.7 KiB
PL/PgSQL
221 lines
8.7 KiB
PL/PgSQL
-- 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';
|