M-Bus Gateway
← Tilbage til blog
· WeasyPrint· PDF· afregning· BEK 563· skabelon· Python· FastAPI· Jinja2

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?

BibliotekFordeleUlemper
WeasyPrintHTML/CSS input, åbent kildekode, EU-hostetLangsomt ved komplekse layouts
ReportLabMeget hurtigt, fleksibeltPython-kun PDF API (ikke HTML)
Puppeteer/ChromePerfekt CSS-renderingKræver Node.js/Chrome
PDFKitNode.js + wkhtmltopdfVedligeholdelsesmæ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.

Se BEK 563 paragraf-guide eller prøv platformen gratis.