feat(dimensions): PR7 producers — auto-tagged documents (invoices, supplier invoices, bulk-book, templates, MCP) (#868)
* feat(dimensions): PR7 producers — invoices/supplier invoices carry dims, generators propagate, BulkBook + templates + MCP bags
Source documents now carry dimension tags and every entry generator
propagates them onto journal lines (dev_docs/dimensions_implementation_plan.md PR7):
- invoices/supplier_invoices.default_dimensions + per-item dimensions
(migration 20260702200000; jsonb DEFAULT '{}' + object CHECK)
- invoice-entries: issuance/payment/cash/credit propagate — item bags merge
over the invoice default per revenue line (account+bag aggregation
identity), payment vouchers re-propagate the linked invoice's bag onto
every leg incl. FX result lines; ROT/RUT 1513 carries the item bag
- supplier-invoice-entries: registration/payment/cash/privately-paid/credit
propagate with the same merge rules (expense buckets keyed account+bag)
- bulk_book_transactions RPC persists per-line bags + derives
cost_center/project mirrors in SQL (migration 20260702201000; malformed
bags rejected with BULK_BOOK_INVALID_DIMENSIONS); route merges the header
default into template/manual lines
- counterparty templates: LinePatternEntry.dimensions learned from SIE
voucher history (kept only when every occurrence agrees), applied to
business lines on booking; QuickReviewDialog shows a dims badge
- categorize: staged dimensions bag tags business lines only (bank/VAT
untagged); credit/convert/inbox copy paths carry bags forward
- propose-payment/send-lines stamp the invoice default so the editable
payment grid books what the preview shows; mark-paid override lines
accept dimensions
- UI: InvoiceEditor + NewSupplierInvoiceForm header KS/Projekt pair with
per-row override; BulkBookDialog header default pair (both tabs)
- MCP: default_dimensions/items[].dimensions on create_invoice +
create_supplier_invoice_from_inbox, dimensions on categorize_transaction,
per-line bags on bulk_book_transactions — resolve-don't-select via the
shared registry helpers, resolutions echoed
32 new propagation unit tests + 4 pg-real tests for the RPC migration.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: use roundOre in new dims rounding assertions (ratchet)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: copy dimension bag per payment line, document dimensionsBagKey normalization contract (review)
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
816b1769c8
commit
755e0f7e47
@@ -90,6 +90,8 @@ export async function POST(
|
||||
notes: proforma.notes,
|
||||
document_type: 'invoice',
|
||||
converted_from_id: id,
|
||||
// Dimensions PR7: the converted invoice books with the proforma's bag.
|
||||
default_dimensions: proforma.default_dimensions ?? {},
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
@@ -98,7 +100,7 @@ export async function POST(
|
||||
return NextResponse.json({ error: invoiceError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const items = (proforma.items || []).map((item: { sort_order: number; line_type?: 'product' | 'text'; description: string; quantity: number; unit: string; unit_price: number; line_total: number }) => ({
|
||||
const items = (proforma.items || []).map((item: { sort_order: number; line_type?: 'product' | 'text'; description: string; quantity: number; unit: string; unit_price: number; line_total: number; dimensions?: Record<string, string> }) => ({
|
||||
invoice_id: invoice.id,
|
||||
sort_order: item.sort_order,
|
||||
line_type: item.line_type ?? 'product',
|
||||
@@ -107,6 +109,7 @@ export async function POST(
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
line_total: item.line_total,
|
||||
dimensions: item.dimensions ?? {},
|
||||
}))
|
||||
|
||||
if (items.length > 0) {
|
||||
|
||||
@@ -315,6 +315,9 @@ async function createCreditNote(
|
||||
our_reference: originalInvoice.our_reference,
|
||||
notes: input.reason || `Krediterar faktura ${originalInvoice.invoice_number}`,
|
||||
credited_invoice_id: input.credited_invoice_id,
|
||||
// Copy the original's dimension bag so the credit-note verifikat nets
|
||||
// against the same dimension cells in reports (dimensions PR7).
|
||||
default_dimensions: originalInvoice.default_dimensions ?? {},
|
||||
status: 'sent',
|
||||
})
|
||||
.select()
|
||||
@@ -328,7 +331,7 @@ async function createCreditNote(
|
||||
})
|
||||
}
|
||||
|
||||
const creditNoteItems = (originalInvoice.items || []).map((item: { sort_order: number; line_type?: 'product' | 'text'; description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate?: number; vat_amount?: number; revenue_account?: string | null; article_id?: string | null; accrual_period_start?: string | null; accrual_period_end?: string | null; accrual_balance_account?: string | null }) => ({
|
||||
const creditNoteItems = (originalInvoice.items || []).map((item: { sort_order: number; line_type?: 'product' | 'text'; description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate?: number; vat_amount?: number; revenue_account?: string | null; article_id?: string | null; accrual_period_start?: string | null; accrual_period_end?: string | null; accrual_balance_account?: string | null; dimensions?: Record<string, string> }) => ({
|
||||
invoice_id: creditNote.id,
|
||||
sort_order: item.sort_order,
|
||||
line_type: item.line_type ?? 'product',
|
||||
@@ -353,6 +356,9 @@ async function createCreditNote(
|
||||
accrual_period_start: item.accrual_period_start ?? null,
|
||||
accrual_period_end: item.accrual_period_end ?? null,
|
||||
accrual_balance_account: item.accrual_balance_account ?? null,
|
||||
// Same reasoning as revenue_account: the reversal must carry the exact
|
||||
// per-item bag the original booked with (dimensions PR7).
|
||||
dimensions: item.dimensions ?? {},
|
||||
}))
|
||||
|
||||
const { error: itemsError } = await supabase.from('invoice_items').insert(creditNoteItems)
|
||||
|
||||
@@ -59,6 +59,9 @@ export const POST = withRouteContext(
|
||||
remaining_amount: 0,
|
||||
is_credit_note: true,
|
||||
credited_invoice_id: id,
|
||||
// Copy the original's dimension bag so the reversal nets against the
|
||||
// same dimension cells in reports (dimensions PR7).
|
||||
default_dimensions: original.default_dimensions ?? {},
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
@@ -86,6 +89,9 @@ export const POST = withRouteContext(
|
||||
// Preserve the self-assessed RC rate so the credit-note verifikat
|
||||
// reverses fiktiv moms at the same rate the original was booked at.
|
||||
reverse_charge_rate: item.reverse_charge_rate,
|
||||
// Dims copied for display parity; the journal reversal reads the
|
||||
// ORIGINAL items below (dimensions PR7).
|
||||
dimensions: item.dimensions ?? {},
|
||||
}))
|
||||
|
||||
await supabase.from('supplier_invoice_items').insert(creditItems)
|
||||
|
||||
@@ -182,6 +182,9 @@ export const POST = withRouteContext(
|
||||
? (item.accrual_balance_account ??
|
||||
suggestBalanceAccount('expense', item.account_number))
|
||||
: null,
|
||||
// Dimensions PR7: per-item bag, merged over default_dimensions on the
|
||||
// expense line at booking (supplier-invoice-entries.ts).
|
||||
dimensions: item.dimensions ?? {},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -250,6 +253,8 @@ export const POST = withRouteContext(
|
||||
notes: body.notes || null,
|
||||
// Display-only öresavrundning override; null = off (no retroactive rounding).
|
||||
ore_rounding: body.ore_rounding ?? null,
|
||||
// Dimensions PR7: invoice-level bag; generators apply it to every line.
|
||||
default_dimensions: body.default_dimensions ?? {},
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
@@ -4,6 +4,7 @@ import { validateBody } from '@/lib/api/validate'
|
||||
import { BulkBookSchema } from '@/lib/api/schemas'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { applyTemplate } from '@/lib/bookkeeping/template-library'
|
||||
import { mergeDimensionBags } from '@/lib/bookkeeping/dimension-resolver'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { BookingTemplateLibraryLine, Transaction } from '@/types'
|
||||
@@ -34,6 +35,9 @@ interface ComputedLine {
|
||||
currency: string
|
||||
line_description?: string
|
||||
sort_order?: number
|
||||
// Dimensions PR7: bag persisted by the RPC onto journal_entry_lines
|
||||
// (with cost_center/project mirrors derived server-side).
|
||||
dimensions?: Record<string, string>
|
||||
}
|
||||
|
||||
function round2(n: number): number {
|
||||
@@ -120,6 +124,8 @@ export const POST = withRouteContext(
|
||||
currency: l.currency,
|
||||
line_description: l.line_description,
|
||||
sort_order: i,
|
||||
// Dimensions PR7: per-line bag wins over the header default.
|
||||
dimensions: mergeDimensionBags(body.default_dimensions, l.dimensions),
|
||||
})),
|
||||
}
|
||||
} else if (body.template_id && body.mode && body.entry_description) {
|
||||
@@ -199,6 +205,8 @@ export const POST = withRouteContext(
|
||||
currency,
|
||||
line_description: formLine.line_description || undefined,
|
||||
sort_order: sortOrder++,
|
||||
// Dimensions PR7: header default applies to all template lines.
|
||||
dimensions: body.default_dimensions,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
@@ -221,6 +229,8 @@ export const POST = withRouteContext(
|
||||
? `${formLine.line_description ?? ''} – ${txTag}`.trim()
|
||||
: formLine.line_description || undefined,
|
||||
sort_order: sortOrder++,
|
||||
// Dimensions PR7: header default applies to all template lines.
|
||||
dimensions: body.default_dimensions,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user