M-Bus Gateway
← Tilbage til blog
· portefølje· ejendomsadministration· varmeregnskab· batch· KPI· OIOUBL· revision· professionel· storskala

Varmeregnskab i storskala ejendomsportefølje — professionel administration

Professionel varmeafregning for store ejendomsporteføljer: batch-generering, portfolio KPI, automatisk á conto-justering, OIOUBL bulk-eksport, revision og SLA-overvågning.

Af M-Bus Gateway

Store ejendomsporteføljer med 50-500+ ejendomme kræver automatisering, batch-processer og konsoliderede KPI'er. Her er arkitekturen bag professionel storskala-administration.


Skala og kompleksitet

Porteføljestørrelser og krav:

Lille (1-10 ejendomme):
  → Manuel administration mulig
  → Én landlord-bruger
  → Afregning én gang pr. år

Mellemstor (10-50 ejendomme):
  → Behov for batch-generering
  → 2-5 administratorer (landlord-roller)
  → Portefølje-KPI ønskes

Stor (50-500 ejendomme):
  → Fuld automatisering nødvendig
  → Ekstern admin + landlord pr. ejendomsklynge
  → Batch-afregning + bulk-OIOUBL
  → Revisoradgang + separat tenant pr. CVR

Enterprise (500+ ejendomme):
  → Multi-tenant holding-struktur
  → API-integration til ERP (e-conomic, Business Central)
  → Webhook-baseret regnskabssystem-opdatering
  → SLA-krav på gateway-leveringsrate

Batch-generering af årsafregninger

# server/src/settlement/batch_generator.py
import asyncio
from datetime import date
from typing import AsyncIterator
from sqlmodel.ext.asyncio.session import AsyncSession
from server.src.models import Property, AnnualInput


async def generate_settlements_for_portfolio(
    tenant_id: str,
    period_end: date,
    db: AsyncSession,
    concurrency: int = 5,
) -> dict[str, str]:
    """
    Generer årsafregninger for hele porteføljen.
    concurrency=5: Maks 5 properties parallelt (undgå DB overload).
    Returnerer: {property_id: status}
    """
    # Find alle ejendomme klar til afregning
    properties = await get_properties_ready_for_settlement(
        tenant_id, period_end, db
    )

    results: dict[str, str] = {}
    semaphore = asyncio.Semaphore(concurrency)

    async def generate_one(property_id: str) -> None:
        async with semaphore:
            try:
                await generate_settlement(property_id, period_end, db)
                results[property_id] = "success"
            except Exception as e:
                results[property_id] = f"error: {e}"

    await asyncio.gather(*[generate_one(p.id) for p in properties])
    return results


async def get_properties_ready_for_settlement(
    tenant_id: str,
    period_end: date,
    db: AsyncSession,
) -> list[Property]:
    """
    Returnerer ejendomme der opfylder:
    1. Har AnnualInput for den ønskede periode
    2. Har aktive lejemål
    3. Har aflæsninger inden for perioden
    4. Har ikke allerede en settlement for perioden
    """
    from sqlmodel import select, and_, not_, exists
    from server.src.models import Settlement

    stmt = (
        select(Property)
        .where(
            and_(
                Property.tenant_id == tenant_id,
                Property.deleted_at.is_(None),
                exists(
                    select(AnnualInput.id).where(
                        and_(
                            AnnualInput.property_id == Property.id,
                            AnnualInput.period_end == period_end,
                            AnnualInput.deleted_at.is_(None),
                        )
                    )
                ),
                not_(
                    exists(
                        select(Settlement.id).where(
                            and_(
                                Settlement.property_id == Property.id,
                                Settlement.period_end == period_end,
                                Settlement.deleted_at.is_(None),
                            )
                        )
                    )
                ),
            )
        )
    )
    result = await db.exec(stmt)
    return list(result.all())

Portfolio KPI — executive overblik

Konsoliderede KPI'er for storportefølje:

Afregnings-KPI (pr. sæson):
  → Total genererede afregninger: 412/430 ejendomme (95,8%)
  → Udestående (>4 måneder): 18 ejendomme (KRITISK)
  → Sendte afregninger: 398 (96,6%)
  → Åbningsrate: 78% inden for 7 dage
  → Indsigelses-rate: 3,2% (branche-benchmark: 4-6%)

Gateway-KPI:
  → Portefølje leverings-SLA (24t): 94,2%
  → Gateways offline > 7 dage: 3 stk (ALARM)
  → Stille målere (>72t): 47 stk
  → AES-dækning: 89% (mangler nøgler: 23 Engelmann HCA)

Energi-KPI:
  → Portefølje-gns: 124 kWh/m²/år
  → Outliers > 2σ: 12 ejendomme (ANALYSE ANBEFALET)
  → Graddage-normaliseret ÅTÅ ændring: -3,2% (forbedring)

Platform endpoints:
  GET /api/v1/portfolio/kpi          → Executive snapshot
  GET /api/v1/portfolio/kpi/export   → CSV til ledelsesrapport
  GET /api/v1/portfolio/alerts       → Handlingskrævende items

Automatisk á conto-justering

# server/src/aconto/auto_adjuster.py
# Kør efter afregningssæson: Justér á conto for kommende år

from decimal import Decimal, ROUND_HALF_UP
from dataclasses import dataclass


@dataclass
class AContoAdjustment:
    unit_id: str
    current_monthly: Decimal
    actual_annual_cost: Decimal
    recommended_monthly: Decimal
    change_pct: Decimal
    action: str    # "increase", "decrease", "ok"


def calculate_aconto_adjustment(
    unit_id: str,
    current_monthly_aconto: Decimal,
    actual_annual_cost: Decimal,
    buffer_pct: Decimal = Decimal("0.10"),
) -> AContoAdjustment:
    """
    Beregn anbefalet á conto for næste år baseret på faktisk forbrug.
    Buffer: 10% over faktisk forbrug (undgå efterbetaling).
    """
    # Anbefalet annual = faktisk + buffer
    recommended_annual = actual_annual_cost * (Decimal("1") + buffer_pct)
    recommended_monthly = (recommended_annual / Decimal("12")).quantize(
        Decimal("0.01"), rounding=ROUND_HALF_UP
    )

    current_annual = current_monthly_aconto * Decimal("12")
    change_pct = (
        (recommended_annual - current_annual) / current_annual * 100
    ).quantize(Decimal("0.1"), rounding=ROUND_HALF_UP)

    if change_pct > Decimal("5"):
        action = "increase"
    elif change_pct < Decimal("-5"):
        action = "decrease"
    else:
        action = "ok"

    return AContoAdjustment(
        unit_id=unit_id,
        current_monthly=current_monthly_aconto,
        actual_annual_cost=actual_annual_cost,
        recommended_monthly=recommended_monthly,
        change_pct=change_pct,
        action=action,
    )

OIOUBL bulk-eksport

OIOUBL batch-eksport til erhvervslejere:

Trigger:
  → Manuelt fra portal: "Eksportér alle OIOUBL"
  → API: POST /api/v1/settlements/oioubl/batch
  → Celery task: Asynkron generering

Output:
  → ZIP-fil med én .xml pr. settlement med EAN-nummer
  → Navngivning: OIOUBL_{settlement_id}_{date}.xml
  → Download-link via Hetzner Object Storage (pre-signed URL, 24t gyldighed)

Valideringsrapport (vedlagt i ZIP):
  → validation_report.csv
  → Kolonner: settlement_id, ean, status, error_message
  → Status: valid / invalid_ean / missing_cvr / schema_error

Automatisk NemHandel-upload (fremtid):
  → Peppol Access Point integration
  → AS4 transport protokol
  → Automatisk kvittering fra modtager

Tidsforbrug (benchmark):
  → 1 OIOUBL XML: ~50ms (lxml generering)
  → 100 OIOUBL XML: ~5 sekunder (Celery single worker)
  → 500 OIOUBL XML: ~25 sekunder (Celery 4 workers)

Revisionseksport og dokumentation

Revisoradgang til storportefølje:

Hvad revisoren kræver (BEK 563 + bogføringsloven):
  ✅ Årsafregninger som PDF (5-7 år)
  ✅ Fjernvarmefakturaer (AnnualInput PDF-bilag)
  ✅ HCA-aflæsningslister med datoer
  ✅ Pro-rata beregninger ved ind-/fraflytning
  ✅ Audit-log for godkendelser og ændringer
  ✅ OIOUBL XML ved erhvervslejemål
  ✅ MID-certifikat-dokumentation

Platform-revisionseksport:
  GET /api/v1/export/audit          → Audit-log CSV (filter pr. periode)
  GET /api/v1/export/settlements    → Alle afregninger pr. tenant
  GET /api/v1/export/annual-inputs  → Alle AnnualInput med bilag-links
  GET /api/v1/export/readings       → Råaflæsninger pr. periode

Revisor-brugerrolle:
  → Oprettes som landlord med read-only flag
  → Kan eksportere men ikke ændre
  → Audit-log: Revisors adgange logges (GDPR-compliance)

SLA-dokumentation til bestyrelsesrapport:
  → Gateway leverings-SLA pr. ejendom
  → Indsigels-rate og -behandlingstid
  → Energiforbrug kWh/m² vs. benchmark

Konklusion

Storskala-porteføljeadministration kræver batch-generering med concurrency-styring, konsoliderede portfolio-KPI'er, automatisk á conto-justering baseret på faktisk forbrug og bulk-OIOUBL-eksport til erhvervslejere. Platformen er bygget til dette fra dag 1: multi-tenant med external_admin, separate tenant pr. CVR, portfolio-endpoints og Celery-baseret async batch-processing. Revisoreksport dækker alle BEK 563- og bogføringskrav.

Se ekstern admin guide eller portfolio-analyse guide.