feat(dimensions): PR3 tagging — voucher-form pickers, MCP dimension tools with resolve-don't-select, engine soft validation (#859)

Phase 3 of dev_docs/dimensions_implementation_plan.md. Companies with
dimensions_enabled=false see zero change; existing free-text API writers keep
working (validation is toggle-governed).

Engine (soft validation):
- validateEntryDimensions() in dimension-resolver: zero queries for untagged
  entries; toggle off → passthrough; toggle on → one settings fetch + two
  registry queries, rejects unknown dims/codes and archived values with
  Swedish per-code messages (DimensionValidationError, 400, details.issues).
  Wired into createDraftEntry + updateDraftEntry before any insert; reversal/
  storno paths untouched (verbatim copies). Fails open on transient registry
  errors — soft validation must never block bookkeeping.

MCP (agent write path):
- New tools: gnubok_list_dimensions, gnubok_list_dimension_values (fuse.js
  fuzzy), gnubok_create_dimension_value (STAGED via pending_operations —
  agents never silently mint reporting values; new op type + CHECK migration
  + executor with duplicate-idempotency).
- create_voucher/correct_entry: per-line dimensions bag + default_dimensions,
  resolve-don't-select server-side (code OR natural-language name; exact →
  fuzzy ≤0.30 with ≥0.15 runner-up margin; non-exact resolutions echoed with
  confidence; ambiguous → ranked candidates, no auto-create).
- gnubok_get_agent_briefing gains a dimensions block (enabled, dims, top
  values) — omitted when registry empty.
- TOOL_SCOPE_MAP entries; risk tier low for staged value creation.

UI:
- JournalEntryForm (manual voucher + TransactionBookingDialog embed): header
  "+ Kostnadsställe/Projekt" progressive disclosure (gäller alla rader with
  documented inheritance rule) + per-row tag popover + compact KS·PR badges;
  gated on dimensions_enabled.
- Voucher detail: display-only dimension badges with registry-name resolution.
- EditDraftEntryDialog carries line dimensions so editing a draft no longer
  strips tags.

categorize/bulk_book dims deferred to PR7 (needs the bulk_book RPC migration).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-02 13:36:45 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 6be6510d73
commit 11126d6d56
28 changed files with 3152 additions and 43 deletions
+127
View File
@@ -74,6 +74,7 @@ import { createLogger } from '@/lib/logger'
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 { BulkBookInboxSchema } from '@/lib/api/schemas'
import { ensureArticleNumber } from '@/lib/articles/ensure-article-number'
import { isValidRevenueAccount } from '@/lib/articles/validate-revenue-account'
@@ -433,6 +434,129 @@ async function commitCreateSupplier(
return { data: { supplier_id: data.id } }
}
/**
* Executor for the staged create_dimension_value operation
* (gnubok_create_dimension_value — dimensions PR3). Inserts a dimension value
* (SIE #OBJEKT) into the registry. Agents never silently mint reporting
* values: this always arrives via a human-approved pending_operation.
*
* Idempotent on duplicate code: a 23505 on (company_id, dimension_id, code)
* re-reads the existing row and reports success with already_existed=true, so
* a raced or re-committed approval never fails on "already there".
*/
async function commitCreateDimensionValue(
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 a non-portable code or
// malformed dates into the registry (ASVS V4.5) — mirrors commitCreateSupplier.
let validated
try {
validated = CreateDimensionValueParamsSchema.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
}
// Get-or-create the system dims (1 = kostnadsställe, 6 = projekt) —
// idempotent lazy seeding. Custom dims must already exist in the registry:
// agents may stage new VALUES, never new dimensions.
if (validated.sie_dim_no === 1 || validated.sie_dim_no === 6) {
const { error: ensureError } = await supabase.rpc('ensure_company_dimensions', {
p_company_id: companyId,
})
if (ensureError) {
return { error: `Kunde inte skapa systemdimensionerna: ${ensureError.message}`, status: 500 }
}
}
const { data: dimension, error: dimError } = await supabase
.from('dimensions')
.select('id, sie_dim_no, name, resets_annually')
.eq('company_id', companyId)
.eq('sie_dim_no', validated.sie_dim_no)
.maybeSingle()
if (dimError) return { error: dimError.message, status: 500 }
if (!dimension) {
return {
error:
`Okänd dimension ${validated.sie_dim_no}. Endast registrerade dimensioner kan få nya värden ` +
'(1 = kostnadsställe och 6 = projekt skapas automatiskt; övriga skapas i registret).',
status: 400,
}
}
// Value dates only make sense on accumulating dimensions (projekt-style
// ranges) — mirrors POST /api/dimensions/[id]/values.
if (dimension.resets_annually && (validated.start_date || validated.end_date)) {
return {
error: `Start-/slutdatum är inte tillåtna på dimensionen "${dimension.name}" (nollställs årligen).`,
status: 400,
}
}
const { data: created, error: insertError } = await supabase
.from('dimension_values')
.insert({
company_id: companyId,
dimension_id: dimension.id,
code: validated.code,
name: validated.name,
start_date: validated.start_date ?? null,
end_date: validated.end_date ?? null,
})
.select('id, code, name, is_active')
.single()
if (insertError) {
if (insertError.code === '23505') {
// Duplicate code — treat the existing value as success (idempotency).
const { data: existing, error: existingError } = await supabase
.from('dimension_values')
.select('id, code, name, is_active')
.eq('company_id', companyId)
.eq('dimension_id', dimension.id)
.eq('code', validated.code)
.maybeSingle()
if (existingError || !existing) {
return { error: insertError.message, status: 500 }
}
return {
data: {
dimension_value_id: existing.id,
sie_dim_no: dimension.sie_dim_no,
dimension_name: dimension.name,
code: existing.code,
name: existing.name,
is_active: existing.is_active,
already_existed: true,
},
}
}
return { error: insertError.message, status: 500 }
}
return {
data: {
dimension_value_id: created.id,
sie_dim_no: dimension.sie_dim_no,
dimension_name: dimension.name,
code: created.code,
name: created.name,
is_active: created.is_active,
already_existed: false,
},
}
}
async function commitCreateTransaction(
supabase: SupabaseClient,
userId: string,
@@ -3527,6 +3651,9 @@ async function commitPendingOperationInner(
case 'create_supplier':
result = await commitCreateSupplier(supabase, userId, companyId, pendingOp.params)
break
case 'create_dimension_value':
result = await commitCreateDimensionValue(supabase, userId, companyId, pendingOp.params)
break
case 'create_invoice':
result = await commitCreateInvoice(supabase, userId, companyId, pendingOp.params)
break