feat(reports): behandlingshistorik as PDF + systemdokumentation pointer and version (#1790)
PR 2 of the behandlingshistorik plan (stacked on #1787). - lib/reports/behandlingshistorik-pdf-template.tsx: landscape A4 react-pdf document. Fixed header (räkenskapsår, urval, legal reference, company) and footer (page x of y, generated in Europe/Stockholm), repeated table header, wrap={false} rows, no `break` props. Two sections in the order the reader needs them: "Ändringar i bokföringssystemet" (p. 9.16 second paragraph) then "Bokföringsposter i registreringsordning" (first paragraph). Meta row: generated, programversion, antal händelser, källor. Details as one wrapped paragraph per row (real-data render 371 events: 1.5 s, 23 pages). Glyphs the bundled Helvetica lacks (arrow, true minus) are mapped to ASCII. - GET /api/reports/behandlingshistorik?format=pdf with a 4 000-event guard (413 REPORT_PDF_TOO_LARGE, CSV/XLSX remain complete); PDF first in the export menu; catalog exports pdf+xlsx. - lib/reports/app-version.ts shared by the route and the archive: revision/systemdokumentation.json now carries system.version and a behandlingshistorik block (where and how it is produced, p. 9.15); the shipped systemdokumentation template §9.3 points at Rapporter > Behandlingshistorik (PDF/CSV/Excel) as well as the backup ZIP. - Settings values that are objects render as "key: value" pairs in every format; report carries category_filter so the document states its urval. - Tests: 4 PDF template tests (valid PDF, empty report, filtered range, 220-row pagination), route pdf 200 + 413, route "unknown format" moved off pdf. Prod read-only render verified visually (header, sections, paging). Claude-Session: https://claude.ai/code/session_01Kw2CFCEt8MxzbJiXMAgMVi Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
4be51aae67
commit
99a872987e
@@ -29,16 +29,29 @@ vi.mock('@/lib/reports/behandlingshistorik', () => ({
|
||||
resolveUserLabelsFromProfiles: vi.fn().mockResolvedValue(new Map()),
|
||||
}))
|
||||
|
||||
// The PDF layout itself is covered by the template test; here the renderer is
|
||||
// a stub so the route test stays fast and asserts only the HTTP contract.
|
||||
vi.mock('@react-pdf/renderer', () => ({
|
||||
renderToBuffer: vi.fn().mockResolvedValue(Buffer.from('%PDF-1.4 stub', 'utf-8')),
|
||||
}))
|
||||
vi.mock('@/lib/reports/behandlingshistorik-pdf-template', () => ({
|
||||
BehandlingshistorikPDF: vi.fn().mockReturnValue({ type: 'Document' }),
|
||||
}))
|
||||
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import {
|
||||
generateBehandlingshistorik,
|
||||
buildBehandlingshistorikExport,
|
||||
resolveUserLabelsFromProfiles,
|
||||
} from '@/lib/reports/behandlingshistorik'
|
||||
import { GET } from '../route'
|
||||
import { BehandlingshistorikPDF } from '@/lib/reports/behandlingshistorik-pdf-template'
|
||||
import { GET, PDF_EVENT_LIMIT } from '../route'
|
||||
|
||||
const mockGenerate = vi.mocked(generateBehandlingshistorik)
|
||||
const mockExport = vi.mocked(buildBehandlingshistorikExport)
|
||||
const mockResolve = vi.mocked(resolveUserLabelsFromProfiles)
|
||||
const mockRender = vi.mocked(renderToBuffer)
|
||||
const mockPdfTemplate = vi.mocked(BehandlingshistorikPDF)
|
||||
|
||||
function authed() {
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
|
||||
@@ -98,7 +111,7 @@ describe('GET /api/reports/behandlingshistorik', () => {
|
||||
})
|
||||
|
||||
it('returns 400 on an unknown format', async () => {
|
||||
const res = await call('/api/reports/behandlingshistorik?period_id=period-1&format=pdf')
|
||||
const res = await call('/api/reports/behandlingshistorik?period_id=period-1&format=docx')
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
@@ -181,4 +194,27 @@ describe('GET /api/reports/behandlingshistorik', () => {
|
||||
expect(body.error.code).toBe('REPORT_GENERATION_FAILED')
|
||||
expect(JSON.stringify(body)).not.toContain('audit_log')
|
||||
})
|
||||
|
||||
it('streams the PDF with an attachment filename and renders the template with the report', async () => {
|
||||
mockGenerate.mockResolvedValue(sampleReport)
|
||||
const res = await call('/api/reports/behandlingshistorik?period_id=period-1&format=pdf')
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('Content-Type')).toBe('application/pdf')
|
||||
expect(res.headers.get('Content-Disposition')).toContain('attachment')
|
||||
expect(res.headers.get('Content-Disposition')).toContain('behandlingshistorik-testbolaget-ab-20261231.pdf')
|
||||
expect(res.headers.get('Cache-Control')).toBe('private, no-store')
|
||||
expect(mockPdfTemplate).toHaveBeenCalledWith({ report: sampleReport })
|
||||
expect(mockRender).toHaveBeenCalledTimes(1)
|
||||
const text = await res.text()
|
||||
expect(text.startsWith('%PDF-')).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses the PDF with 413 when the report exceeds the render limit, without rendering', async () => {
|
||||
mockGenerate.mockResolvedValue({ ...sampleReport, total_events: PDF_EVENT_LIMIT + 1 })
|
||||
const res = await call('/api/reports/behandlingshistorik?period_id=period-1&format=pdf')
|
||||
expect(res.status).toBe(413)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('REPORT_PDF_TOO_LARGE')
|
||||
expect(mockRender).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateQuery } from '@/lib/api/validate'
|
||||
import { BehandlingshistorikQuerySchema } from '@/lib/api/schemas'
|
||||
@@ -7,11 +8,22 @@ import { privateNoStore } from '@/lib/api/private-no-store'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { parseReportDateRange } from '@/lib/reports/date-range'
|
||||
import { currentAppVersion } from '@/lib/reports/app-version'
|
||||
import { slugifyCompanyName } from '@/lib/reports/xlsx-export'
|
||||
import {
|
||||
buildBehandlingshistorikExport,
|
||||
generateBehandlingshistorik,
|
||||
resolveUserLabelsFromProfiles,
|
||||
} from '@/lib/reports/behandlingshistorik'
|
||||
import { BehandlingshistorikPDF } from '@/lib/reports/behandlingshistorik-pdf-template'
|
||||
|
||||
/**
|
||||
* @react-pdf/renderer lays out every row on the CPU (measured ~25 ms per event
|
||||
* on a 371-event year); beyond this many events the render approaches the
|
||||
* function timeout. Larger years are served as CSV/XLSX (complete, instant)
|
||||
* and the PDF is refused with 413.
|
||||
*/
|
||||
export const PDF_EVENT_LIMIT = 4000
|
||||
|
||||
/**
|
||||
* GET /api/reports/behandlingshistorik
|
||||
@@ -28,12 +40,6 @@ import {
|
||||
* restricted to the user ids that appear in the result.
|
||||
*/
|
||||
|
||||
/** Running build identifier, stamped on the report (p. 9.16: program version). */
|
||||
function currentAppVersion(): string | null {
|
||||
const sha = process.env.VERCEL_GIT_COMMIT_SHA || process.env.NEXT_PUBLIC_BUILD_ID || ''
|
||||
return sha ? sha.slice(0, 12) : null
|
||||
}
|
||||
|
||||
export const GET = withRouteContext('report.behandlingshistorik', async (request, ctx) => {
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
|
||||
@@ -87,6 +93,22 @@ export const GET = withRouteContext('report.behandlingshistorik', async (request
|
||||
return privateNoStore(NextResponse.json({ data: report }))
|
||||
}
|
||||
|
||||
if (format === 'pdf') {
|
||||
if (report.total_events > PDF_EVENT_LIMIT) {
|
||||
return errorResponseFromCode('REPORT_PDF_TOO_LARGE', log, { requestId })
|
||||
}
|
||||
const pdf = await renderToBuffer(BehandlingshistorikPDF({ report }))
|
||||
const date = report.mode === 'fiscal_year' ? report.period.end : report.range.to
|
||||
const filename = `behandlingshistorik-${slugifyCompanyName(report.company.name)}-${date.replace(/-/g, '')}.pdf`
|
||||
return new NextResponse(new Uint8Array(pdf), {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': contentDisposition('attachment', filename),
|
||||
'Cache-Control': 'private, no-store',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const file = buildBehandlingshistorikExport(report, format)
|
||||
return new NextResponse(new Uint8Array(file.buffer), {
|
||||
headers: {
|
||||
|
||||
@@ -130,6 +130,7 @@ export function BehandlingshistorikView({
|
||||
</div>
|
||||
<ReportExportMenu
|
||||
items={[
|
||||
{ format: 'pdf', href: `/api/reports/behandlingshistorik?${query}&format=pdf` },
|
||||
{ format: 'xlsx', href: `/api/reports/behandlingshistorik?${query}&format=xlsx` },
|
||||
{ format: 'csv', href: `/api/reports/behandlingshistorik?${query}&format=csv` },
|
||||
]}
|
||||
|
||||
+1
-1
@@ -2507,7 +2507,7 @@ export const BehandlingshistorikQuerySchema = z.object({
|
||||
category: z
|
||||
.enum(['verifikation', 'kontoplan', 'installningar', 'period', 'import', 'atkomst', 'ovrigt'])
|
||||
.optional(),
|
||||
format: z.enum(['json', 'csv', 'xlsx']).default('json'),
|
||||
format: z.enum(['json', 'csv', 'xlsx', 'pdf']).default('json'),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -1544,6 +1544,11 @@ const REPORT: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Rapporten kunde inte genereras.',
|
||||
message_en: 'Failed to generate the report.',
|
||||
},
|
||||
REPORT_PDF_TOO_LARGE: {
|
||||
httpStatus: 413,
|
||||
message_sv: 'Rapporten är för stor för PDF. Ladda ner den som CSV eller Excel i stället.',
|
||||
message_en: 'The report is too large for PDF. Download it as CSV or Excel instead.',
|
||||
},
|
||||
}
|
||||
|
||||
const VAT_REPORT: Record<string, StructuredErrorEntry> = {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { BehandlingshistorikPDF, pdfText } from '../behandlingshistorik-pdf-template'
|
||||
import type { BehandlingshistorikEvent, BehandlingshistorikReport } from '../behandlingshistorik-types'
|
||||
|
||||
// Real @react-pdf/renderer layout is CPU-heavy; give it room on a saturated runner.
|
||||
const RENDER_TIMEOUT = 30_000
|
||||
|
||||
function event(overrides: Partial<BehandlingshistorikEvent>): BehandlingshistorikEvent {
|
||||
return {
|
||||
id: `ev-${Math.random().toString(36).slice(2, 8)}`,
|
||||
occurred_at: '2026-03-10T09:30:00.000Z',
|
||||
category: 'verifikation',
|
||||
code: 'journal_entry.committed',
|
||||
event: 'Verifikation bokförd',
|
||||
object: 'A12',
|
||||
actor: { type: 'user', user_id: 'user-1', label: 'anna@example.se' },
|
||||
details: ['Datum: 2026-03-09', 'Text: Hyra mars', 'Källa: Manuell'],
|
||||
source: 'journal_entries',
|
||||
count: 1,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function report(overrides: Partial<BehandlingshistorikReport> = {}): BehandlingshistorikReport {
|
||||
const events = overrides.events ?? [
|
||||
event({ id: 'a1', category: 'kontoplan', code: 'account.created.bulk', event: 'Kontoplan upplagd', object: '41 konton', details: ['Konton 1510 till 8410'], source: 'audit_log', count: 41, occurred_at: '2026-01-05T08:00:00.000Z' }),
|
||||
event({ id: 'a2', category: 'installningar', code: 'settings.updated', event: 'Företagsinställningar ändrade', object: null, details: ['Momsperiod: Kvartal → Helår'], source: 'audit_log', occurred_at: '2026-02-01T08:00:00.000Z' }),
|
||||
event({ id: 'e1' }),
|
||||
event({ id: 'e2', object: 'A13', code: 'journal_entry.reversed', event: 'Verifikation makulerad (storno)', actor: { type: 'api_key', user_id: null, label: 'API-nyckel: Revisorn' }, occurred_at: '2026-04-02T10:00:00.000Z' }),
|
||||
]
|
||||
const by_category = { verifikation: 0, kontoplan: 0, installningar: 0, period: 0, import: 0, atkomst: 0, ovrigt: 0 }
|
||||
for (const e of events) by_category[e.category] += 1
|
||||
return {
|
||||
company: { name: 'Testbolaget AB', org_number: '5566778899' },
|
||||
period: { id: 'p', name: 'Räkenskapsår 2026', start: '2026-01-01', end: '2026-12-31' },
|
||||
range: { from: '2026-01-01', to: '2026-12-31' },
|
||||
mode: 'fiscal_year',
|
||||
generated_at: '2026-08-21T12:00:00.000Z',
|
||||
app_version: 'abc123def456',
|
||||
total_events: events.length,
|
||||
by_category,
|
||||
events,
|
||||
category_filter: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('pdfText', () => {
|
||||
it('maps glyphs the bundled Helvetica lacks to ASCII', () => {
|
||||
expect(pdfText('Momsperiod: Kvartal → Helår')).toBe('Momsperiod: Kvartal -> Helår')
|
||||
expect(pdfText('−1 200,00')).toBe('-1 200,00')
|
||||
expect(pdfText('åäö ÅÄÖ … ·')).toBe('åäö ÅÄÖ … ·')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BehandlingshistorikPDF', () => {
|
||||
it(
|
||||
'renders a valid PDF with both sections',
|
||||
async () => {
|
||||
const buffer = await renderToBuffer(BehandlingshistorikPDF({ report: report() }))
|
||||
expect(buffer).toBeInstanceOf(Buffer)
|
||||
expect(buffer.slice(0, 5).toString()).toBe('%PDF-')
|
||||
expect(buffer.length).toBeGreaterThan(1000)
|
||||
},
|
||||
RENDER_TIMEOUT,
|
||||
)
|
||||
|
||||
it(
|
||||
'renders an empty report (both sections show the empty line) and a category-filtered date range',
|
||||
async () => {
|
||||
const empty = await renderToBuffer(
|
||||
BehandlingshistorikPDF({
|
||||
report: report({ events: [], total_events: 0, by_category: { verifikation: 0, kontoplan: 0, installningar: 0, period: 0, import: 0, atkomst: 0, ovrigt: 0 } }),
|
||||
}),
|
||||
)
|
||||
expect(empty.slice(0, 5).toString()).toBe('%PDF-')
|
||||
|
||||
const filtered = await renderToBuffer(
|
||||
BehandlingshistorikPDF({
|
||||
report: report({ mode: 'date_range', range: { from: '2026-03-01', to: '2026-03-31' }, category_filter: ['verifikation'], app_version: null }),
|
||||
}),
|
||||
)
|
||||
expect(filtered.slice(0, 5).toString()).toBe('%PDF-')
|
||||
},
|
||||
RENDER_TIMEOUT,
|
||||
)
|
||||
|
||||
it(
|
||||
'paginates a long report without splitting rows (no break props: must not hang)',
|
||||
async () => {
|
||||
const events = Array.from({ length: 220 }, (_, i) =>
|
||||
event({
|
||||
id: `e${i}`,
|
||||
object: `A${i + 1}`,
|
||||
occurred_at: new Date(Date.parse('2026-01-01T08:00:00.000Z') + i * 3_600_000).toISOString(),
|
||||
details: ['Datum: 2026-01-01', `Text: Rad ${i} med en ganska lång beskrivning som ska radbrytas i detaljkolumnen`, 'Källa: Banktransaktion'],
|
||||
}),
|
||||
)
|
||||
const buffer = await renderToBuffer(BehandlingshistorikPDF({ report: report({ events, total_events: events.length }) }))
|
||||
expect(buffer.slice(0, 5).toString()).toBe('%PDF-')
|
||||
// A 220-row landscape table is several pages; /Type /Page objects prove pagination happened.
|
||||
const pages = (buffer.toString('latin1').match(/\/Type\s*\/Page[^s]/g) ?? []).length
|
||||
expect(pages).toBeGreaterThan(3)
|
||||
},
|
||||
RENDER_TIMEOUT,
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Running software version, stamped on räkenskapsinformation that BFNAR 2013:2
|
||||
* p. 9.16 second paragraph wants dated: the behandlingshistorik report and the
|
||||
* systemdokumentation in the archive. Vercel inlines the commit SHA at build;
|
||||
* self-hosted builds without it report null rather than a made-up value.
|
||||
*/
|
||||
export function currentAppVersion(): string | null {
|
||||
const sha = process.env.VERCEL_GIT_COMMIT_SHA || process.env.NEXT_PUBLIC_BUILD_ID || ''
|
||||
return sha ? sha.slice(0, 12) : null
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { Document, Page, StyleSheet, Text, View } from '@react-pdf/renderer'
|
||||
import {
|
||||
BEHANDLINGSHISTORIK_CATEGORIES,
|
||||
BEHANDLINGSHISTORIK_CATEGORY_LABELS,
|
||||
type BehandlingshistorikEvent,
|
||||
type BehandlingshistorikReport,
|
||||
} from '@/lib/reports/behandlingshistorik-types'
|
||||
import { formatStockholmTimestamp } from '@/lib/reports/behandlingshistorik'
|
||||
|
||||
/**
|
||||
* Behandlingshistorik as a printable document (BFL 5 kap. 11 §, BFNAR 2013:2
|
||||
* punkt 9.16). Two sections in the order the law lists them in reverse of how
|
||||
* a reader scans: the system changes first (short, the context), then the
|
||||
* bokföringsposter in registreringsordning (long, the body).
|
||||
*
|
||||
* Layout rules shared with the other report PDFs: Helvetica/Courier (bundled
|
||||
* standard fonts, so no Font.register), header + footer + table header
|
||||
* `fixed` so they repeat on every page, every row `wrap={false}` so a row is
|
||||
* never split across pages, and no `break` props (they deadlock multi-page
|
||||
* renders in @react-pdf/renderer 4). Landscape: the details column needs the
|
||||
* width.
|
||||
*/
|
||||
|
||||
const INK = '#1a1a1a'
|
||||
const MUTED = '#666'
|
||||
const HAIRLINE = '#d4d4d4'
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
paddingTop: 36,
|
||||
paddingHorizontal: 36,
|
||||
paddingBottom: 54,
|
||||
fontSize: 8.5,
|
||||
fontFamily: 'Helvetica',
|
||||
color: INK,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 10,
|
||||
paddingBottom: 10,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: HAIRLINE,
|
||||
},
|
||||
titleBlock: { flex: 1 },
|
||||
title: { fontSize: 18, fontWeight: 'bold', marginBottom: 3 },
|
||||
subtitle: { fontSize: 9.5, color: '#333', marginBottom: 2 },
|
||||
legal: { fontSize: 8, color: MUTED },
|
||||
companyInfo: { textAlign: 'right' },
|
||||
companyName: { fontSize: 10, fontWeight: 'bold', marginBottom: 2 },
|
||||
companyMeta: { fontSize: 8.5, color: MUTED },
|
||||
meta: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
marginBottom: 10,
|
||||
},
|
||||
metaItem: { width: '25%', paddingRight: 10, marginBottom: 4 },
|
||||
metaLabel: { fontSize: 7, color: MUTED, textTransform: 'uppercase', letterSpacing: 0.4 },
|
||||
metaValue: { fontSize: 9 },
|
||||
summary: { fontSize: 8.5, color: '#333', marginBottom: 10 },
|
||||
tableHeader: {
|
||||
flexDirection: 'row',
|
||||
paddingVertical: 4,
|
||||
borderBottomWidth: 0.5,
|
||||
borderBottomColor: INK,
|
||||
marginBottom: 2,
|
||||
},
|
||||
tableHeaderText: {
|
||||
fontSize: 7.5,
|
||||
fontWeight: 'bold',
|
||||
color: '#444',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
sectionHeading: {
|
||||
fontSize: 10.5,
|
||||
fontWeight: 'bold',
|
||||
marginTop: 12,
|
||||
marginBottom: 4,
|
||||
paddingBottom: 3,
|
||||
borderBottomWidth: 0.5,
|
||||
borderBottomColor: '#888',
|
||||
},
|
||||
sectionNote: { fontSize: 8, color: MUTED, marginBottom: 4 },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
paddingVertical: 2.5,
|
||||
borderBottomWidth: 0.5,
|
||||
borderBottomColor: '#ececec',
|
||||
},
|
||||
// Column widths live in one place so the header and the rows line up; the
|
||||
// time column gets Courier only in the body (the header stays Helvetica).
|
||||
hdrTime: { width: 100, paddingRight: 6 },
|
||||
hdrEvent: { width: 190, paddingRight: 8 },
|
||||
hdrActor: { width: 140, paddingRight: 8 },
|
||||
hdrDetails: { flex: 1 },
|
||||
colTime: { width: 100, fontFamily: 'Courier', fontSize: 7.5, color: MUTED, paddingRight: 6 },
|
||||
colEvent: { width: 190, paddingRight: 8 },
|
||||
colActor: { width: 140, paddingRight: 8, color: '#333' },
|
||||
colDetails: { flex: 1 },
|
||||
eventLabel: { fontSize: 8.5 },
|
||||
objectLabel: { fontFamily: 'Courier', fontSize: 7.5, color: MUTED, marginTop: 1 },
|
||||
detail: { fontSize: 7.5, color: '#444', marginBottom: 1 },
|
||||
empty: { fontSize: 8.5, color: MUTED, fontStyle: 'italic', paddingVertical: 6 },
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 22,
|
||||
left: 36,
|
||||
right: 36,
|
||||
borderTopWidth: 0.5,
|
||||
borderTopColor: HAIRLINE,
|
||||
paddingTop: 5,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
footerText: { fontSize: 7.5, color: '#888' },
|
||||
})
|
||||
|
||||
/**
|
||||
* The bundled Helvetica/Courier AFM fonts only carry WinAnsi glyphs: an arrow
|
||||
* or a true minus would be dropped silently. Map them to ASCII for print.
|
||||
*/
|
||||
export function pdfText(value: string): string {
|
||||
return value.replace(/→/g, '->').replace(/−/g, '-')
|
||||
}
|
||||
|
||||
function formatOrgNumber(orgNumber: string): string {
|
||||
const cleaned = orgNumber.replace(/\D/g, '')
|
||||
return cleaned.length === 10 ? `${cleaned.slice(0, 6)}-${cleaned.slice(6)}` : orgNumber
|
||||
}
|
||||
|
||||
function EventRow({ event }: { event: BehandlingshistorikEvent }) {
|
||||
return (
|
||||
<View style={styles.row} wrap={false}>
|
||||
<Text style={styles.colTime}>{formatStockholmTimestamp(event.occurred_at)}</Text>
|
||||
<View style={styles.colEvent}>
|
||||
<Text style={styles.eventLabel}>{pdfText(event.event)}</Text>
|
||||
{event.object ? <Text style={styles.objectLabel}>{pdfText(event.object)}</Text> : null}
|
||||
</View>
|
||||
<Text style={styles.colActor}>{pdfText(event.actor.label)}</Text>
|
||||
<View style={styles.colDetails}>
|
||||
{event.details.length > 0 ? (
|
||||
// One wrapped paragraph rather than one Text per line: half the
|
||||
// layout nodes (render time) and roughly twice the rows per page.
|
||||
<Text style={styles.detail}>{pdfText(event.details.join(' · '))}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ title, note, events }: { title: string; note: string; events: BehandlingshistorikEvent[] }) {
|
||||
return (
|
||||
<View>
|
||||
<Text style={styles.sectionHeading}>{title}</Text>
|
||||
<Text style={styles.sectionNote}>{note}</Text>
|
||||
{events.length === 0 ? (
|
||||
<Text style={styles.empty}>Inga händelser i urvalet.</Text>
|
||||
) : (
|
||||
events.map((event) => <EventRow key={event.id} event={event} />)
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export interface BehandlingshistorikPDFProps {
|
||||
report: BehandlingshistorikReport
|
||||
}
|
||||
|
||||
export function BehandlingshistorikPDF({ report }: BehandlingshistorikPDFProps) {
|
||||
const systemEvents = report.events.filter((e) => e.category !== 'verifikation')
|
||||
const voucherEvents = report.events.filter((e) => e.category === 'verifikation')
|
||||
const scope =
|
||||
report.mode === 'fiscal_year'
|
||||
? 'Hela räkenskapsåret'
|
||||
: `${report.range.from} till ${report.range.to}`
|
||||
const categoryFilter =
|
||||
report.category_filter && report.category_filter.length > 0
|
||||
? report.category_filter.map((c) => BEHANDLINGSHISTORIK_CATEGORY_LABELS[c]).join(', ')
|
||||
: null
|
||||
const summary = BEHANDLINGSHISTORIK_CATEGORIES.filter((c) => report.by_category[c] > 0)
|
||||
.map((c) => `${BEHANDLINGSHISTORIK_CATEGORY_LABELS[c]} ${report.by_category[c]}`)
|
||||
.join(' · ')
|
||||
const generated = formatStockholmTimestamp(report.generated_at)
|
||||
|
||||
return (
|
||||
<Document
|
||||
title={`Behandlingshistorik ${report.period.name}`}
|
||||
author={report.company.name}
|
||||
subject="Behandlingshistorik enligt BFL 5 kap. 11 §"
|
||||
>
|
||||
<Page size="A4" orientation="landscape" style={styles.page}>
|
||||
<View style={styles.header} fixed>
|
||||
<View style={styles.titleBlock}>
|
||||
<Text style={styles.title}>Behandlingshistorik</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
{report.period.name} ({report.period.start} till {report.period.end}) · Urval: {scope}
|
||||
{categoryFilter ? ` · Kategori: ${categoryFilter}` : ''}
|
||||
</Text>
|
||||
<Text style={styles.legal}>BFL 5 kap. 11 § · BFNAR 2013:2 punkt 9.16 · Tider i Europe/Stockholm</Text>
|
||||
</View>
|
||||
<View style={styles.companyInfo}>
|
||||
{report.company.name ? <Text style={styles.companyName}>{report.company.name}</Text> : null}
|
||||
{report.company.org_number ? (
|
||||
<Text style={styles.companyMeta}>Org.nr: {formatOrgNumber(report.company.org_number)}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.meta}>
|
||||
<View style={styles.metaItem}>
|
||||
<Text style={styles.metaLabel}>Genererad</Text>
|
||||
<Text style={styles.metaValue}>{generated}</Text>
|
||||
</View>
|
||||
<View style={styles.metaItem}>
|
||||
<Text style={styles.metaLabel}>Programversion</Text>
|
||||
<Text style={styles.metaValue}>{report.app_version ?? 'okänd'}</Text>
|
||||
</View>
|
||||
<View style={styles.metaItem}>
|
||||
<Text style={styles.metaLabel}>Antal händelser</Text>
|
||||
<Text style={styles.metaValue}>{String(report.total_events)}</Text>
|
||||
</View>
|
||||
<View style={styles.metaItem}>
|
||||
<Text style={styles.metaLabel}>Källor</Text>
|
||||
<Text style={styles.metaValue}>Verifikationer, oföränderlig ändringslogg, rättelselogg, importer</Text>
|
||||
</View>
|
||||
</View>
|
||||
{summary ? <Text style={styles.summary}>{summary}</Text> : null}
|
||||
|
||||
<View style={styles.tableHeader} fixed>
|
||||
<Text style={[styles.tableHeaderText, styles.hdrTime]}>Tidpunkt</Text>
|
||||
<Text style={[styles.tableHeaderText, styles.hdrEvent]}>Händelse</Text>
|
||||
<Text style={[styles.tableHeaderText, styles.hdrActor]}>Utförd av</Text>
|
||||
<Text style={[styles.tableHeaderText, styles.hdrDetails]}>Detaljer</Text>
|
||||
</View>
|
||||
|
||||
<Section
|
||||
title="Ändringar i bokföringssystemet"
|
||||
note="Kontoplan, inställningar som styr bokföringen, räkenskapsår, importer och åtkomst, med tidpunkt och utförare (BFNAR 2013:2 punkt 9.16 andra stycket)."
|
||||
events={systemEvents}
|
||||
/>
|
||||
<Section
|
||||
title="Bokföringsposter i registreringsordning"
|
||||
note="Varje bokförd verifikation med registreringstidpunkt och utförare, samt makuleringar, rättelser och raderingar (BFNAR 2013:2 punkt 9.16 första stycket)."
|
||||
events={voucherEvents}
|
||||
/>
|
||||
|
||||
<View style={styles.footer} fixed>
|
||||
<Text style={styles.footerText}>
|
||||
{report.company.name}
|
||||
{report.company.org_number ? ` · ${formatOrgNumber(report.company.org_number)}` : ''}
|
||||
{' · Behandlingshistorik'}
|
||||
</Text>
|
||||
<Text
|
||||
style={styles.footerText}
|
||||
render={({ pageNumber, totalPages }) => `Genererad ${generated} · Sida ${pageNumber} av ${totalPages}`}
|
||||
/>
|
||||
</View>
|
||||
</Page>
|
||||
</Document>
|
||||
)
|
||||
}
|
||||
@@ -72,6 +72,8 @@ export interface BehandlingshistorikReport {
|
||||
total_events: number
|
||||
by_category: Record<BehandlingshistorikCategory, number>
|
||||
events: BehandlingshistorikEvent[]
|
||||
/** Category filter the report was generated with, if any (shown as "Urval" on the document). */
|
||||
category_filter?: BehandlingshistorikCategory[] | null
|
||||
}
|
||||
|
||||
/** Swedish category labels for exports and the statutory document. */
|
||||
|
||||
@@ -434,6 +434,15 @@ function fmtValue(value: unknown, key?: string): string {
|
||||
if (value.length === 0) return '(tomt)'
|
||||
return truncate(value.map((v) => (typeof v === 'string' ? v : JSON.stringify(v))).join(', '))
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
// Settings maps such as default_voucher_series_per_source_type read better
|
||||
// as "import: A, manual: A" than as raw JSON.
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
if (entries.length === 0) return '(tomt)'
|
||||
return truncate(
|
||||
entries.map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`).join(', '),
|
||||
)
|
||||
}
|
||||
return truncate(JSON.stringify(value))
|
||||
}
|
||||
|
||||
@@ -1513,6 +1522,7 @@ export async function generateBehandlingshistorik(
|
||||
total_events: finalEvents.length,
|
||||
by_category: byCategory,
|
||||
events: finalEvents,
|
||||
category_filter: params.categories && params.categories.length > 0 ? [...params.categories] : null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -327,7 +327,7 @@ export const REPORT_CATALOG: ReportDescriptor[] = [
|
||||
descKey: 'desc_behandlingshistorik',
|
||||
category: 'export',
|
||||
params: 'fiscal-range',
|
||||
exports: ['xlsx'],
|
||||
exports: ['pdf', 'xlsx'],
|
||||
libraryOnly: true,
|
||||
searchTerms:
|
||||
'behandlingshistorik audit trail audit log händelselogg ändringslogg logg historik vem gjorde vad processing history revision systemdokumentation',
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type TrialBalanceLike,
|
||||
} from './archive-csv'
|
||||
import { buildArchiveReadme, buildDriveFolderReadme } from './archive-readme'
|
||||
import { currentAppVersion } from './app-version'
|
||||
import type { GeneralLedgerReport } from './general-ledger'
|
||||
import type {
|
||||
AuditLogEntry,
|
||||
@@ -1373,6 +1374,9 @@ async function buildSystemDoc(
|
||||
name: branding.appName.toLowerCase(),
|
||||
description: 'Bokforingssystem for enskild firma och aktiebolag',
|
||||
url: branding.appUrl,
|
||||
// BFNAR 2013:2 p. 9.16 second paragraph: program versions are system
|
||||
// changes that affect processing; the archive names the running build.
|
||||
version: currentAppVersion(),
|
||||
},
|
||||
kontoplan: {
|
||||
standard: 'BAS 2026',
|
||||
@@ -1408,6 +1412,15 @@ async function buildSystemDoc(
|
||||
email: 'Resend',
|
||||
export_format: 'SIE4',
|
||||
},
|
||||
// BFNAR 2013:2 p. 9.15: where and how the behandlingshistorik is produced.
|
||||
behandlingshistorik: {
|
||||
beskrivning:
|
||||
'Skapas automatiskt (BFL 5 kap. 11 §, BFNAR 2013:2 punkt 9.16): registreringstidpunkt och utförare för varje bokföringspost (journal_entries), förändringar via databasens oföränderliga ändringslogg audit_log (kontoplan, inställningar som styr bokföringen, räkenskapsår, API-nycklar, makuleringar, raderingar), rättelser i samma verifikat (journal_entry_rattelse_log) samt SIE-, bankfils- och migreringsloggar.',
|
||||
rapport:
|
||||
'Rapporter > Export & arkiv > Behandlingshistorik: per räkenskapsår eller datumintervall, som PDF, CSV eller Excel',
|
||||
arkivfil: 'revision/behandlingshistorik.json i denna säkerhetsbackup (råa loggrader)',
|
||||
tidszon: 'Europe/Stockholm i rapporten, UTC i JSON-filen',
|
||||
},
|
||||
generated_at: new Date().toISOString(),
|
||||
fiscal_periods: periods.map((p) => ({
|
||||
id: p.id,
|
||||
|
||||
@@ -227,7 +227,7 @@ Momsperiod: [ ] Månad [ ] Kvartal [ ] Helår
|
||||
|
||||
9.2. Behandlingshistoriken genereras automatiskt av systemet och kan inte ändras av användaren.
|
||||
|
||||
9.3. Behandlingshistoriken exporteras under **Importera/Exportera > Exportera > Säkerhetsbackup**. Exporten är en ZIP-fil som innehåller `revision/behandlingshistorik.json` (alla ändringar) och `revision/systemdokumentation.json` (kontoplan, verifikationsserier, arkiveringsprinciper), utöver SIE-filer, rapporter och underlag.
|
||||
9.3. Behandlingshistoriken tas fram under **Rapporter > Behandlingshistorik** per räkenskapsår eller datumintervall och kan laddas ner som PDF, CSV eller Excel. Rapporten visar registreringstidpunkt, utförare och detaljer för varje bokföringspost samt ändringar i bokföringssystemet (kontoplan, inställningar, räkenskapsår, importer, åtkomst) och anger programversionen. Behandlingshistoriken ingår även i säkerhetsbackupen under **Importera/Exportera > Exportera > Säkerhetsbackup**: ZIP-filen innehåller `revision/behandlingshistorik.json` (alla ändringar) och `revision/systemdokumentation.json` (kontoplan, verifikationsserier, arkiveringsprinciper, programversion), utöver SIE-filer, rapporter och underlag.
|
||||
|
||||
## 10. Import och export
|
||||
|
||||
|
||||
Reference in New Issue
Block a user