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
@@ -763,6 +763,23 @@ export const CreateDimensionValueSchema = z
|
||||
{ message: 'Slutdatum får inte vara före startdatum', path: ['end_date'] },
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /api/bookkeeping/journal-entry-lines/[lineId]/retag — Tier-2 retro-
|
||||
* tagging (dimensions plan PR6). The RPC enforces every rule (posted only,
|
||||
* open period, lock date, active registry values); this schema only shapes
|
||||
* the request. An empty bag {} untags the line.
|
||||
*/
|
||||
export const RetagLineDimensionsSchema = z.object({
|
||||
// {} passes (no entries to validate) = UNTAG. Intentional divergence from
|
||||
// the MCP staged path (RetagLineDimensionsParamsSchema), which rejects an
|
||||
// empty bag: a human clearing phantom tags via the dialog/workbench is a
|
||||
// deliberate act with a logged reason; an agent bulk-clearing history is
|
||||
// not something we allow to be staged. The retag log records {} as the
|
||||
// new value either way (#867 review).
|
||||
dimensions: DimensionsBagSchema,
|
||||
reason: z.string().min(3).max(500),
|
||||
})
|
||||
|
||||
/** PATCH /api/dimensions/[id]/values/[valueId] — no `code` field by design. */
|
||||
export const UpdateDimensionValueSchema = z
|
||||
.object({
|
||||
@@ -2138,3 +2155,41 @@ export const SalaryEmployeeOverrideSchema = z
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
// ============================================================
|
||||
// Dimensions PR6 — bulk retro-tagging workbench (appended at end
|
||||
// of file by PR6 to avoid conflicts; keep new schemas below).
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Query filters for GET /api/dimensions/tagging/lines (the BulkTagWorkbench
|
||||
* line browser). All filters optional; `limit` is a hard cap (default 200,
|
||||
* max 500) — the route fetches limit+1 and reports `total_capped` instead of
|
||||
* paginating (dimensions plan §3, v1 scope).
|
||||
*/
|
||||
export const DimensionTaggingLinesQuerySchema = z.object({
|
||||
period_id: uuid.optional(),
|
||||
date_from: saneIsoDate.optional(),
|
||||
date_to: saneIsoDate.optional(),
|
||||
account_from: accountNumber.optional(),
|
||||
account_to: accountNumber.optional(),
|
||||
/** Free-text ilike filter on journal_entries.description. */
|
||||
text: z.string().trim().max(200).optional(),
|
||||
/** '1' → only lines whose dimensions map is empty ({}). */
|
||||
only_untagged: z.enum(['0', '1']).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(500).default(200),
|
||||
})
|
||||
|
||||
/**
|
||||
* Body for POST /api/dimensions/tagging/apply. One dimensions object applied
|
||||
* to every listed line via the retag_line_dimensions RPC (the UI groups
|
||||
* selected lines by their computed resulting map and issues one POST per
|
||||
* distinct map). `dimensions` reuses THE bag schema so validation cannot
|
||||
* drift from the engine/API layers; an empty bag is allowed — replace mode
|
||||
* uses it to clear phantom tags. `reason` mirrors the RPC's >= 3 chars CHECK.
|
||||
*/
|
||||
export const DimensionTaggingApplySchema = z.object({
|
||||
line_ids: z.array(uuid).min(1).max(500),
|
||||
dimensions: DimensionsBagSchema,
|
||||
reason: z.string().trim().min(3).max(500),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user