PDF-generering med WeasyPrint — BEK 563-compliant afregning
Sådan genererer du juridisk korrekte varmeafregninger som PDF med WeasyPrint: skabeloner, CSS-print-layout, fonts, BEK 563-elementer og integration med FastAPI/Celery.
Af M-Bus Gateway
WeasyPrint konverterer HTML + CSS til PDF — den perfekte kombination til dynamiske afregningsdokumenter. Her er arkitekturen bag BEK 563-compliant PDF-generering.
Hvorfor WeasyPrint?
| Bibliotek | Fordele | Ulemper |
|---|---|---|
| WeasyPrint | HTML/CSS input, åbent kildekode, EU-hostet | Langsomt ved komplekse layouts |
| ReportLab | Meget hurtigt, fleksibelt | Python-kun PDF API (ikke HTML) |
| Puppeteer/Chrome | Perfekt CSS-rendering | Kræver Node.js/Chrome |
| PDFKit | Node.js + wkhtmltopdf | Vedligeholdelsesmæssig risiko |
WeasyPrint vælges fordi:
- HTML/CSS-input = samme skabeloner som web-visning
- Kører fully serverside (ingen browser-instans)
- EU-kompatibelt (GDPR) — ingen cloud-API-kald
- Understøtter CSS Print media queries
Skabelon-arkitektur (Jinja2)
server/
templates/
settlement/
base.html ← Layout, header, footer
settlement_dk.html ← Dansk afregning
settlement_en.html ← Engelsk afregning
components/
address_block.html
period_table.html
allocation_table.html
balance_section.html
css/
settlement_print.css ← CSS @page, fonts, print
HTML-skabelon (settlement_dk.html)
{% extends "settlement/base.html" %}
{% block content %}
<div class="settlement-header">
<div class="logo-block">
<img src="{{ logo_url }}" alt="{{ company_name }}">
<h1>VARMEAFREGNING</h1>
</div>
<div class="ref-block">
<p>Afregning nr.: <strong>{{ settlement_id }}</strong></p>
<p>Dato: <strong>{{ issue_date | datoformat }}</strong></p>
</div>
</div>
{% include "settlement/components/address_block.html" %}
<section class="period-section">
<h2>Afregningsperiode</h2>
<table>
<tr><td>Fra:</td><td>{{ period_start | datoformat }}</td></tr>
<tr><td>Til:</td><td>{{ period_end | datoformat }}</td></tr>
<tr><td>Lejlighedens areal:</td><td>{{ unit_area_m2 }} m²</td></tr>
<tr><td>Ejendommens samlede areal:</td><td>{{ property_area_m2 }} m²</td></tr>
</table>
</section>
{% include "settlement/components/allocation_table.html" %}
<section class="balance-section">
<table class="balance-table">
<tr class="total-row">
<td>Din samlede andel:</td>
<td class="amount">{{ total_share_kr | krformat }} kr.</td>
</tr>
<tr>
<td>Indbetalt á conto ({{ a_conto_months }} måneder × {{ a_conto_kr | krformat }} kr.):</td>
<td class="amount">- {{ total_a_conto_kr | krformat }} kr.</td>
</tr>
<tr class="result-row {% if balance_kr > 0 %}due{% else %}refund{% endif %}">
<td>{% if balance_kr > 0 %}EFTERBETALING{% else %}TILBAGEBETALING{% endif %}:</td>
<td class="amount">{{ balance_kr | abs | krformat }} kr.</td>
</tr>
</table>
{% if balance_kr > 0 %}
<p class="payment-note">Beløbet bedes indbetalt senest {{ due_date | datoformat }}.</p>
{% else %}
<p class="payment-note">Tilbagebetalingen sker inden {{ refund_date | datoformat }}.</p>
{% endif %}
</section>
<section class="dispute-section">
<h3>Indsigelsesret</h3>
<p>Du har ret til at gøre indsigelse mod denne afregning senest
<strong>{{ dispute_deadline | datoformat }}</strong> (6 uger fra afregningsdatoen).</p>
<p>Indsigelse indgives via: <a href="{{ dispute_url }}">{{ dispute_url }}</a></p>
</section>
{% endblock %}
CSS Print-layout
/* settlement_print.css */
@page {
size: A4 portrait;
margin: 20mm 15mm 25mm 15mm;
@top-center {
content: "VARMEAFREGNING — " string(settlement-id);
font-size: 9pt;
color: #666;
}
@bottom-center {
content: "Side " counter(page) " af " counter(pages);
font-size: 9pt;
color: #666;
}
}
/* Undgå sideskift inden for tabeller */
tr { page-break-inside: avoid; }
section { page-break-inside: avoid; }
/* Balance-sektion altid på samme side */
.balance-section { page-break-before: avoid; }
body {
font-family: "Noto Sans", Arial, sans-serif;
font-size: 10pt;
color: #1a1a1a;
}
.result-row.due { background: #fff3cd; font-weight: bold; }
.result-row.refund { background: #d4edda; font-weight: bold; }
.amount { text-align: right; font-family: monospace; }
FastAPI-endpoint + Celery-integration
# server/src/settlements/router.py
from fastapi import APIRouter
from fastapi.responses import StreamingResponse
import io
router = APIRouter()
@router.post("/{settlement_id}/generate-pdf")
async def generate_pdf(
settlement_id: str,
background_tasks: BackgroundTasks,
session: AsyncSession = Depends(get_session),
user = Depends(require_role("landlord", "super_admin")),
):
"""Kø PDF-generering som Celery-task."""
from server.src.workers.tasks.pdf import generate_settlement_pdf
task = generate_settlement_pdf.delay(settlement_id)
return {"task_id": task.id, "status": "queued"}
@router.get("/{settlement_id}/pdf")
async def get_pdf(
settlement_id: str,
session: AsyncSession = Depends(get_session),
user = Depends(require_role("landlord", "super_admin", "tenant")),
):
"""Stream PDF direkte til klient fra Object Storage."""
settlement = await get_settlement_or_404(settlement_id, session, user)
if not settlement.pdf_url:
raise HTTPException(404, "PDF ikke genereret endnu")
pdf_bytes = download_from_storage(settlement.pdf_url)
filename = f"varmeafregning-{settlement_id}.pdf"
return StreamingResponse(
io.BytesIO(pdf_bytes),
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
Jinja2 custom filters
# server/src/utils/template_filters.py
from babel.numbers import format_currency
from datetime import date
def krformat(value: float) -> str:
"""Format tal som dansk kroner: 1.234,56"""
return f"{value:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
def datoformat(value: date) -> str:
"""Format dato som dansk: 1. januar 2025"""
months = ["januar","februar","marts","april","maj","juni",
"juli","august","september","oktober","november","december"]
return f"{value.day}. {months[value.month-1]} {value.year}"
# Registrer i Jinja2-miljø:
env.filters["krformat"] = krformat
env.filters["datoformat"] = datoformat
BEK 563 § 10 compliance-check
Inden PDF genereres, verificerer platformen at alle 12 obligatoriske poster er til stede:
def verify_bek563_compliance(settlement_data: dict) -> list[str]:
"""Returnér liste over manglende obligatoriske poster."""
required = [
("property_address", "Ejendommens adresse"),
("unit_id", "Lejlighedens identifikation"),
("period_start", "Afregningsperiode start"),
("period_end", "Afregningsperiode slut"),
("unit_area_m2", "Lejlighedens areal"),
("property_total_kr", "Ejendommens samlede varmeomkostning"),
("allocation_m2_pct", "m²-andel (fast 30%-del)"),
("allocation_hca_units", "Individuelle HCA-enheder"),
("total_share_kr", "Lejligens samlede andel"),
("total_a_conto_kr", "Indbetalt á conto"),
("balance_kr", "Resultat (efterbetaling/tilbagebetaling)"),
("issue_date", "Dato for udsendelse"),
]
missing = []
for field, label in required:
if settlement_data.get(field) is None:
missing.append(label)
return missing
Konklusion
WeasyPrint + Jinja2 giver et komplet PDF-pipeline: HTML-skabeloner der bruges til både web-preview og PDF-output, CSS @page til korrekt A4-layout, og en compliance-check der verificerer alle 12 BEK 563 § 10-poster inden generering. Celery håndterer asynkron generering så API'et ikke blokeres.