feat(dimensions): PR6 retro-tagging — audited retag carve-out, BulkTagWorkbench, staged MCP tool (#867)
* feat(dimensions): PR6 retro-tagging — audited retag carve-out, workbench, staged MCP tool
Tier-2 retro-tagging (founder decision №1, approved 2026-07-02): posted
entries in OPEN periods can have their dimension tags changed through ONE
audited path — everything about the verifikat itself stays immutable.
Carve-out (migration 20260702170000): the line-immutability trigger gains a
single narrow branch — while the transaction-local GUC set by the RPC is
active, an UPDATE of a posted line is admitted iff every non-dimension
column is unchanged, enforced by a whole-row to_jsonb diff (any future
column is protected by construction; mirrors cost_center/project are in the
changeable set because they are derived views of dimensions['1']/['6']).
Precedent: mark_entry_as_opening_balance (20260613120000).
retag_line_dimensions RPC: tenant guard (20260619130100 pattern), writer
gate (viewers rejected), posted-only, open period + company lock date
enforced, every code validated against the ACTIVE registry, immutable
dimension_retag_log row (before/after/actor/reason, INSERT-only via its own
trigger, no FKs so the trail survives hard-deletes) written BEFORE the
carve-out UPDATE. Idempotent no-op without a log row. Untag ({}) supported.
Legal position per the plan: dimensions are internredovisning metadata, not
BFL 5 kap 7§ verifikat content — this is strictly more conservative than
Fortnox/Visma (dimension-only diffs, open periods only, immutable log,
storno past locks — Tier 3 has no exceptions).
Mandatory pg suite (11 tests): GUC-less updates still blocked; amounts/
description can never change even under the GUC (transaction-local);
closed/locked/lock-date, role, registry, draft and cross-tenant rejections;
log immutability; gnubok.allow_delete bulk path unaffected.
UX (all writes through the ONE RPC): pencil on posted-voucher lines in
bookkeeping/[id] ("Påverkar endast internredovisningen, inte verifikatet")
+ retag-history card; BulkTagWorkbench at /dimensions/tagging (filters,
shift-select, merge vs "Ersätt tagg" replace mode, reversal-pair warning
with "Inkludera motverifikat" auto-selection, per-line failure display).
MCP: gnubok_tag_journal_lines (bookkeeping:write) — filter block resolved
via resolve-don't-select, ≤500 lines, staged via pending_operations (new
op type migration 20260702171000, medium risk tier, shared Zod validation
boundary between staging and commit; executor loops the RPC per line with
partial-success aggregation).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dimensions): address #867 review — SQLSTATE classification, blocking storno confirm, documented divergence
- Retag route classifies RPC errors by SQLSTATE instead of message-regex:
P0001 (every rule violation in the RPC) → 409 verbatim, 42501 (tenant
guard) → 403, anything else → logged 500 with a generic message. No more
substring sniffing.
- The workbench's storno-pair warning escalates to a BLOCKING confirmation
naming the unselected counter-vouchers before apply (Srf U 14 gross
reporting — one-legged retags silently skew project P&L; the banner alone
was advisory).
- The empty-bag divergence is now documented on both schemas as intentional:
the direct dialog/workbench path allows {} (human untags phantom codes,
logged with reason), the MCP staged path rejects it (agents never
bulk-clear history).
Triage notes: the log's missing FKs are the point (behandlingshistorik must
survive undo_sie_import hard-deletes — a cascade would erase the trail);
SIE exports are generated fresh on demand, never cached, so post-retag
exports carry the new object lists automatically; date-scoped registry
values are deliberately not enforced at retag because entry creation does
not enforce them either — enforcing in one path only would be incoherent
(both belong to the PR10 rules engine).
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
fb3fe82a56
commit
816b1769c8
@@ -75,6 +75,7 @@ import { appendProcessingHistory } from '@/lib/processing-history/append'
|
||||
import { CreateSupplierParamsSchema } from '@/lib/pending-operations/schemas/create-supplier'
|
||||
import { CreateArticleParamsSchema, UpdateArticleParamsSchema } from '@/lib/pending-operations/schemas/article'
|
||||
import { CreateDimensionValueParamsSchema } from '@/lib/pending-operations/schemas/dimension-value'
|
||||
import { RetagLineDimensionsParamsSchema } from '@/lib/pending-operations/schemas/retag-line-dimensions'
|
||||
import { BulkBookInboxSchema } from '@/lib/api/schemas'
|
||||
import { ensureArticleNumber } from '@/lib/articles/ensure-article-number'
|
||||
import { isValidRevenueAccount } from '@/lib/articles/validate-revenue-account'
|
||||
@@ -557,6 +558,83 @@ async function commitCreateDimensionValue(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executor for the staged retag_line_dimensions operation
|
||||
* (gnubok_tag_journal_lines — dimensions PR6). Loops the staged line_ids
|
||||
* through the retag_line_dimensions RPC — the ONE audited write path for
|
||||
* changing dimension tags on posted lines. The RPC enforces everything per
|
||||
* line at commit time (open period, company lock date, active registry
|
||||
* values, writer role, posted status) and writes an immutable
|
||||
* dimension_retag_log row before touching the line.
|
||||
*
|
||||
* Partial-success semantics: one line failing (e.g. its period was locked
|
||||
* between staging and approval) must not roll back the lines already
|
||||
* retagged — each RPC call is its own transaction. Failures are collected
|
||||
* and echoed (capped at 20) so the caller can re-stage just the failed set.
|
||||
* Only when EVERY line fails does the operation as a whole fail.
|
||||
*/
|
||||
async function commitRetagLineDimensions(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<ExecutorResult> {
|
||||
// Defense in depth: re-validate the staged params at the commit boundary so
|
||||
// a tampered pending_operations row cannot inject arbitrary ids or a
|
||||
// malformed bag (ASVS V4.5) — mirrors commitCreateDimensionValue.
|
||||
let validated
|
||||
try {
|
||||
validated = RetagLineDimensionsParamsSchema.parse(params)
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
const issue = err.issues[0]
|
||||
const path = issue?.path?.join('.') ?? 'params'
|
||||
return { error: `Invalid ${path}: ${issue?.message ?? 'validation failed'}`, status: 400 }
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
let retagged = 0
|
||||
let unchanged = 0
|
||||
const failed: Array<{ line_id: string; error: string }> = []
|
||||
|
||||
for (const lineId of validated.line_ids) {
|
||||
const { data, error } = await supabase.rpc('retag_line_dimensions', {
|
||||
p_company_id: companyId,
|
||||
p_line_id: lineId,
|
||||
p_dimensions: validated.dimensions,
|
||||
p_reason: validated.reason,
|
||||
p_user_id: userId,
|
||||
})
|
||||
if (error) {
|
||||
failed.push({ line_id: lineId, error: error.message })
|
||||
continue
|
||||
}
|
||||
if ((data as { changed?: boolean } | null)?.changed) retagged++
|
||||
else unchanged++
|
||||
}
|
||||
|
||||
if (failed.length > 0 && retagged === 0 && unchanged === 0) {
|
||||
return {
|
||||
error: `Ingen rad kunde taggas om (${failed.length} rader misslyckades). Första felet: ${failed[0].error}`,
|
||||
status: 400,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
retagged,
|
||||
unchanged,
|
||||
failed_count: failed.length,
|
||||
// Echo at most 20 failures — enough to act on without bloating
|
||||
// result_data on a pathological 500-line all-but-one failure.
|
||||
failed: failed.slice(0, 20),
|
||||
dimensions: validated.dimensions,
|
||||
...(validated.filter_summary ? { filter_summary: validated.filter_summary } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function commitCreateTransaction(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
@@ -3654,6 +3732,9 @@ async function commitPendingOperationInner(
|
||||
case 'create_dimension_value':
|
||||
result = await commitCreateDimensionValue(supabase, userId, companyId, pendingOp.params)
|
||||
break
|
||||
case 'retag_line_dimensions':
|
||||
result = await commitRetagLineDimensions(supabase, userId, companyId, pendingOp.params)
|
||||
break
|
||||
case 'create_invoice':
|
||||
result = await commitCreateInvoice(supabase, userId, companyId, pendingOp.params)
|
||||
break
|
||||
|
||||
Reference in New Issue
Block a user