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
@@ -0,0 +1,151 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
|
||||
export interface RetagLine {
|
||||
id: string
|
||||
account_number: string
|
||||
line_description: string | null
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
dimensions?: Record<string, string> | null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
line: RetagLine | null
|
||||
/** Fired after a successful retag so the host refetches the entry. */
|
||||
onRetagged: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Tier-2 retro-tagging on a posted voucher line (dimensions plan PR6).
|
||||
* Edits ONLY the dimension tags via the audited retag RPC; the verifikat
|
||||
* itself is untouchable. Dims other than 1/6 pass through unedited (same
|
||||
* merge semantics as the voucher editor). Hardcoded Swedish — voucher
|
||||
* detail is a stays-Swedish surface.
|
||||
*/
|
||||
export default function RetagLineDialog({ open, onOpenChange, line, onRetagged }: Props) {
|
||||
const { toast } = useToast()
|
||||
const [dims, setDims] = useState<Record<string, string>>({})
|
||||
const [reason, setReason] = useState('')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open && line) {
|
||||
setDims({ ...(line.dimensions ?? {}) })
|
||||
setReason('')
|
||||
setError(null)
|
||||
}
|
||||
}, [open, line])
|
||||
|
||||
if (!line) return null
|
||||
|
||||
const amount = Number(line.debit_amount) > 0 ? Number(line.debit_amount) : Number(line.credit_amount)
|
||||
const side = Number(line.debit_amount) > 0 ? 'Debet' : 'Kredit'
|
||||
|
||||
const handleChange = (dimNo: string, code: string | null) => {
|
||||
setDims((prev) => {
|
||||
const next = { ...prev }
|
||||
if (code) next[dimNo] = code
|
||||
else delete next[dimNo]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/bookkeeping/journal-entry-lines/${line.id}/retag`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dimensions: dims, reason }),
|
||||
})
|
||||
const payload = await res.json()
|
||||
if (!res.ok) {
|
||||
setError(typeof payload.error === 'string' ? payload.error : 'Kunde inte ändra dimensioner')
|
||||
return
|
||||
}
|
||||
if (payload.data?.changed === false) {
|
||||
toast({ title: 'Inga ändringar', description: 'Dimensionerna var redan de valda.' })
|
||||
} else {
|
||||
toast({ title: 'Dimensioner ändrade', description: 'Ändringen är loggad i ändringshistoriken.' })
|
||||
}
|
||||
onOpenChange(false)
|
||||
onRetagged()
|
||||
} catch {
|
||||
setError('Kunde inte ändra dimensioner')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const reasonValid = reason.trim().length >= 3
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ändra dimensioner</DialogTitle>
|
||||
<DialogDescription>
|
||||
Påverkar endast internredovisningen, inte verifikatet. Ändringen
|
||||
loggas med före/efter och anledning.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="rounded-lg border border-border p-3 text-sm">
|
||||
<AccountNumber number={line.account_number} showName />
|
||||
<div className="mt-1 flex items-center justify-between text-muted-foreground">
|
||||
<span className="truncate">{line.line_description || '—'}</span>
|
||||
<span className="tabular-nums shrink-0 ml-3">
|
||||
{amount.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} kr {side}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LineDimensionFields dimensions={dims} onChange={handleChange} />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="retag-reason">Anledning</Label>
|
||||
<Input
|
||||
id="retag-reason"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="t.ex. Raden hörde till projekt P002"
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving || !reasonValid}>
|
||||
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Spara ändring
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user