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: {
|
||||
|
||||
Reference in New Issue
Block a user