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>
126 lines
4.9 KiB
TypeScript
126 lines
4.9 KiB
TypeScript
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'
|
|
import { contentDisposition } from '@/lib/api/content-disposition'
|
|
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
|
|
*
|
|
* Behandlingshistorik (BFL 5 kap. 11 §, BFNAR 2013:2 p. 9.16) for one fiscal
|
|
* period, optionally narrowed to a date sub-range inside it.
|
|
*
|
|
* Query: period_id (required), from_date / to_date (optional, inside the
|
|
* period), category (optional), format=json|csv|xlsx (default json).
|
|
*
|
|
* Read-only: the report is a view over journal_entries, the trigger-written
|
|
* audit_log, the rättelse log and the import tables. Actor e-mails are
|
|
* resolved through a service-role lookup on `profiles` (self-only RLS),
|
|
* restricted to the user ids that appear in the result.
|
|
*/
|
|
|
|
export const GET = withRouteContext('report.behandlingshistorik', async (request, ctx) => {
|
|
const { supabase, companyId, log, requestId } = ctx
|
|
|
|
const query = validateQuery(request, BehandlingshistorikQuerySchema, {
|
|
log,
|
|
operation: 'report.behandlingshistorik',
|
|
})
|
|
if (!query.success) return query.response
|
|
const { period_id: periodId, from_date, to_date, format, category } = query.data
|
|
|
|
// Validate the optional sub-range against the period bounds (same contract
|
|
// as the other fiscal-range reports). Unknown period: 404.
|
|
if (from_date || to_date) {
|
|
const { data: period } = await supabase
|
|
.from('fiscal_periods')
|
|
.select('period_start, period_end')
|
|
.eq('id', periodId)
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
if (!period) {
|
|
return errorResponseFromCode('FISCAL_PERIOD_NOT_FOUND', log, { requestId })
|
|
}
|
|
const { searchParams } = new URL(request.url)
|
|
const parsed = parseReportDateRange(searchParams, period as { period_start: string; period_end: string })
|
|
if (!parsed.ok) {
|
|
return NextResponse.json({ error: parsed.error }, { status: 400 })
|
|
}
|
|
}
|
|
|
|
try {
|
|
const serviceClient = createServiceClient()
|
|
const report = await generateBehandlingshistorik(
|
|
supabase,
|
|
companyId,
|
|
{
|
|
periodId,
|
|
fromDate: from_date,
|
|
toDate: to_date,
|
|
categories: category ? [category] : undefined,
|
|
},
|
|
{
|
|
resolveUserLabels: (ids) => resolveUserLabelsFromProfiles(serviceClient, ids),
|
|
appVersion: currentAppVersion(),
|
|
},
|
|
)
|
|
if (!report) {
|
|
return errorResponseFromCode('FISCAL_PERIOD_NOT_FOUND', log, { requestId })
|
|
}
|
|
|
|
if (format === 'json') {
|
|
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: {
|
|
'Content-Type': file.contentType,
|
|
'Content-Disposition': contentDisposition('attachment', file.filename),
|
|
'Cache-Control': 'private, no-store',
|
|
},
|
|
})
|
|
} catch (err) {
|
|
// Raw message stays server-side: it can carry table names / SQL.
|
|
log.error('behandlingshistorik generation failed', err as Error, { periodId })
|
|
return errorResponseFromCode('REPORT_GENERATION_FAILED', log, { requestId })
|
|
}
|
|
})
|