Controlling-Systeme entwickeln: Von Excel zu Echtzeit-Dashboard
“Können Sie aus unseren 47 Excel-Dateien ein echtes Controlling-System bauen?” – Diese Frage höre ich regelmäßig. Die Antwort: Ja, aber nicht eins zu eins. Excel ist ein Symptom, kein Konzept. Dahinter stecken echte Business-Prozesse, die digitalisiert werden wollen.
In diesem Artikel zeige ich anhand eines realen Projekts, wie wir ein Controlling-System für 15.000 Mitarbeiter entwickelt haben – von der Anforderungsanalyse bis zum produktiven Echtzeit-Dashboard.
TL;DR – Die Kernpunkte
- Vom Excel-Chaos zur strukturierten Datenbank: ETL-Pipelines statt manueller Copy-Paste-Orgien
- Echtzeit-KPIs statt Monatsreports: PostgreSQL Materialized Views + REFRESH CONCURRENTLY
- Self-Service statt IT-Ticket-Flut: Filterable Dashboards mit gespeicherten Ansichten pro Rolle
Das Ausgangsproblem: Excel-Hölle
Status Quo beim Kunden (Mittelständisches Unternehmen, 15.000 MA)
47 Excel-Dateien über 12 Abteilungen verteilt:
Controlling_2024_v3_final_WIRKLICH_final.xlsxUmsatz_Q4_korrigiert_neue_Zahlen.xlsxPersonal_Kosten_Jan_Feb_Mär.xlsx- … you get the idea
Typischer Monatsabschluss:
- Tag 1-3: Controlling-Abteilung verschickt E-Mails: “Bitte Zahlen eintragen”
- Tag 4-8: Erinnerungs-E-Mails an säumige Abteilungen
- Tag 9-12: Zusammenführen der Dateien, manuelles Copy-Paste
- Tag 13-15: Fehlersuche (“Warum stimmen die Summen nicht?”)
- Tag 16-18: Berichte erstellen, Präsentationen vorbereiten
- Tag 19: Präsentation im Management-Meeting
Ergebnis: Zahlen sind 3 Wochen alt, wenn das Management sie sieht. Entscheidungen basieren auf veralteten Daten.
Die Schmerzpunkte
- Keine Historisierung: “Wie war der Wert letzten Monat?” → Alte Datei suchen, hoffen dass niemand sie überschrieben hat
- Keine Validierung: Tippfehler, Copy-Paste-Fehler, falsche Formeln
- Keine Rechte-Verwaltung: Jeder kann alles sehen und ändern
- Keine Automatisierung: Alles manuell, fehleranfällig, zeitintensiv
- Keine Auswertungen: “Welche Abteilung hatte die höchsten Überstunden?” → 2h Excel-Fummelei
- Keine Datengüte & Governance: “Welche Zahl ist die richtige?” – Endlose Diskussionen, weil keine Single Source of Truth & kein Datenkatalog (Definitionen der KPIs)
Die Anforderungsanalyse: Was wollen wir wirklich?
Workshop-Tag 1: Prozess-Mapping
Wir haben nicht gefragt: “Was steht in euren Excel-Dateien?”
Wir haben gefragt: “Welche Entscheidungen trefft ihr basierend auf den Zahlen?”
Erkenntnisse:
Management-Ebene braucht:
- KPIs auf einen Blick (Umsatz, Kosten, Marge, Mitarbeiter)
- Drill-Down nach Abteilung/Region/Produkt
- Trendverläufe (YoY, MoM)
- Export für Board-Präsentationen
Abteilungsleiter brauchen:
- Detailansicht ihrer Abteilung
- Vergleich mit Plan-Zahlen
- Historische Verläufe
- “Was-wäre-wenn”-Simulationen
Controlling-Team braucht:
- Daten-Import aus Vorsystemen (SAP, HR-System, CRM)
- Validierungsregeln
- Freigabe-Workflows
- Audit-Trail (Wer hat wann was geändert?)
Geschäftsführung braucht:
- Ein Dashboard. Drei Zahlen. Grün/Gelb/Rot.
- Fertig.
Workshop-Tag 2: Datenmodell
Wir haben die Excel-Struktur ignoriert und von den Business-Objekten her gedacht:
Core-Entities:
- Organisationsstruktur: Unternehmen → Bereiche → Abteilungen → Teams
- Kostenstellen: Hierarchisch, mit Budget-Zuordnung
- Mitarbeiter: Mit Zuordnung zu Kostenstellen, Verträgen, Gehaltsbändern
- Umsatz: Nach Kunde, Produkt, Region, Periode
- Kosten: Nach Kostenart, Kostenstelle, Periode
- KPIs: Berechnete Kennzahlen (Marge, Produktivität, Fluktuation, etc.)
Zeitliche Dimension:
- Plan-Daten: Budget, Forecast
- Ist-Daten: Actual, monatlich/wöchentlich/täglich je nach KPI
- Historisierung: Alle Änderungen mit Timestamp + User
Die Architektur: Layers statt Spaghetti
Layer 1: Daten-Import (ETL)
Quellen:
- SAP: Finanzdaten via SAP RFC (BC_ABAP_CALL_RFC)
- HR-System: Mitarbeiterdaten via REST-API
- CRM: Umsatzdaten via CSV-Export (täglich um 02:00 Uhr)
- Zeiterfassung: Arbeitsstunden via DB-Replikation (PostgreSQL Foreign Data Wrapper)
Import-Prozess:
// Beispiel: CRM-CSV-Import
class CrmUmsatzImporter {
private PDO $pdo;
private ValidationService $validator;
private AuditLogger $audit;
public function importDaily(string $csvPath): ImportResult {
$this->audit->log("Import gestartet: $csvPath");
// 1. CSV parsen
$rows = $this->parseCsv($csvPath);
// 2. Validierung
$validated = [];
$errors = [];
foreach ($rows as $idx => $row) {
try {
$this->validator->validate($row, [
'kunde_id' => 'required|exists:kunden,id',
'umsatz' => 'required|decimal|min:0',
'datum' => 'required|date',
'produkt_id' => 'required|exists:produkte,id'
]);
$validated[] = $row;
} catch (ValidationException $e) {
$errors[] = "Zeile $idx: {$e->getMessage()}";
}
}
if (!empty($errors)) {
$this->audit->log("Import fehlgeschlagen", $errors);
throw new ImportException("Validierung fehlgeschlagen", $errors);
}
// 3. Transaktion: Alles oder nichts
$this->pdo->beginTransaction();
try {
// Staging-Tabelle leeren
$this->pdo->exec("TRUNCATE TABLE umsatz_staging");
// Daten in Staging-Tabelle
$stmt = $this->pdo->prepare("
INSERT INTO umsatz_staging
(kunde_id, umsatz, datum, produkt_id, import_timestamp)
VALUES (:kunde_id, :umsatz, :datum, :produkt_id, NOW())
");
foreach ($validated as $row) {
$stmt->execute($row);
}
// Duplikate erkennen (Geschäftslogik: Datum + Kunde + Produkt = unique)
$dupes = $this->pdo->query("
SELECT COUNT(*)
FROM umsatz_staging s
JOIN umsatz u USING (kunde_id, produkt_id, datum)
")->fetchColumn();
if ($dupes > 0) {
throw new ImportException("$dupes Duplikate gefunden");
}
// Staging → Production
$this->pdo->exec("
INSERT INTO umsatz SELECT * FROM umsatz_staging
");
$this->pdo->commit();
$this->audit->log("Import erfolgreich", ['rows' => count($validated)]);
return new ImportResult(count($validated), 0);
} catch (Exception $e) {
$this->pdo->rollBack();
throw $e;
}
}
}
Guardrails:
- Staging-Tabellen: Import läuft nie direkt in Production-Tabellen
- Validierung vor Insert: PDO Prepared Statements + Application-Layer-Validierung
- Atomare Transaktionen: Alles oder nichts
- Audit-Log: Jeder Import wird geloggt (Timestamp, User, Datei, Result)
- Retry-Mechanik: Bei Fehlern E-Mail an Controlling + automatischer Retry nach 1h
- Schema-Drift-Detection: Contract Tests gegen CSV/API + Quarantäne für fehlerhafte Records
- PII-Minimierung: Hashing/Pseudonymisierung in Staging; Zugriff per Row-Level Security (RLS) pro Abteilung
Layer 2: Datenbank-Modell (PostgreSQL)
Warum PostgreSQL statt MySQL?
- Bessere Aggregations-Performance: Window Functions, CTEs
- Materialized Views: Pre-computed KPIs mit REFRESH CONCURRENTLY
- Array-Types: Speichern von Historien direkt in einer Spalte
- JSONB: Flexible Metadaten (unterschiedliche KPIs pro Abteilung)
- Partitioning: Zeitbasierte Partitionierung für große Tabellen
Beispiel: Umsatz-Tabelle mit Partitionierung
-- Parent-Tabelle
CREATE TABLE umsatz (
id BIGSERIAL,
kunde_id INTEGER NOT NULL,
produkt_id INTEGER NOT NULL,
umsatz NUMERIC(12,2) NOT NULL,
datum DATE NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
created_by INTEGER
) PARTITION BY RANGE (datum);
-- Partitionen pro Monat (automatisch via Cron-Job)
CREATE TABLE umsatz_2025_01 PARTITION OF umsatz
FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
CREATE TABLE umsatz_2025_02 PARTITION OF umsatz
FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');
-- Index nur auf aktuelle Partition → schnellere Inserts
CREATE INDEX idx_umsatz_2025_01_kunde ON umsatz_2025_01(kunde_id);
CREATE INDEX idx_umsatz_2025_01_datum ON umsatz_2025_01(datum);
Materialized Views für KPIs
-- Monatliche Umsätze pro Abteilung
CREATE MATERIALIZED VIEW kpi_umsatz_monat AS
SELECT
DATE_TRUNC('month', u.datum) AS monat,
a.id AS abteilung_id,
a.name AS abteilung,
SUM(u.umsatz) AS umsatz_gesamt,
COUNT(DISTINCT u.kunde_id) AS anzahl_kunden,
AVG(u.umsatz) AS avg_umsatz_pro_transaktion,
-- Vergleich zum Vormonat
LAG(SUM(u.umsatz)) OVER (
PARTITION BY a.id ORDER BY DATE_TRUNC('month', u.datum)
) AS umsatz_vormonat,
-- Wachstum %
ROUND(
(SUM(u.umsatz) - LAG(SUM(u.umsatz)) OVER (
PARTITION BY a.id ORDER BY DATE_TRUNC('month', u.datum)
)) / NULLIF(LAG(SUM(u.umsatz)) OVER (
PARTITION BY a.id ORDER BY DATE_TRUNC('month', u.datum)
), 0) * 100,
2
) AS wachstum_prozent
FROM umsatz u
JOIN kunden k ON u.kunde_id = k.id
JOIN abteilungen a ON k.abteilung_id = a.id
GROUP BY DATE_TRUNC('month', u.datum), a.id, a.name;
-- Index für schnellen Zugriff
CREATE INDEX idx_kpi_umsatz_monat_abteilung ON kpi_umsatz_monat(abteilung_id, monat);
-- Refresh täglich um 03:00 Uhr (nach allen Imports)
-- CONCURRENTLY = ohne Lock, läuft parallel zu Queries
REFRESH MATERIALIZED VIEW CONCURRENTLY kpi_umsatz_monat;
Performance:
- Ohne Materialized View: Query 2.800ms (Live-Aggregation über 12M Zeilen)
- Mit Materialized View: Query 8ms (Read from Index)
- Refresh-Dauer: 45 Sekunden (nachts, wenn niemand da ist)
Refresh-Strategie:
Inkrementelle Refresh-Strategie (monatliche Partitionen) reduziert Nightly-Window weiter: REFRESH MATERIALIZED VIEW CONCURRENTLY kpi_umsatz_monat WHERE monat >= CURRENT_DATE - INTERVAL '1 month' – nur aktuelle + Vormonat, statt volle 36 Monate.
Layer 3: Business-Logik (PHP)
Domain-Driven Design:
src/
Domain/
Controlling/
Entity/
Abteilung.php
KPI.php
Periode.php
Service/
KPIBerechnung.php
TrendAnalyse.php
BudgetVergleich.php
Repository/
KPIRepository.php
ValueObject/
Zeitraum.php
Budget.php
Infrastructure/
Persistence/
PostgresKPIRepository.php
Import/
CrmImporter.php
SapImporter.php
Beispiel: KPI-Berechnung
class KPIBerechnung {
private KPIRepository $repo;
public function berechneMarge(Abteilung $abt, Zeitraum $zeitraum): KPI {
$umsatz = $this->repo->getUmsatz($abt, $zeitraum);
$kosten = $this->repo->getKosten($abt, $zeitraum);
$margeAbsolut = $umsatz - $kosten;
$margeProzent = ($umsatz > 0) ? ($margeAbsolut / $umsatz * 100) : 0;
// Ampel-Logik
$budgetMarge = $abt->getBudgetMarge($zeitraum);
$status = match(true) {
$margeProzent >= $budgetMarge => Status::GRUEN,
$margeProzent >= $budgetMarge * 0.9 => Status::GELB,
default => Status::ROT
};
return new KPI(
name: 'Marge',
wert: $margeProzent,
einheit: '%',
status: $status,
vergleichswert: $budgetMarge,
zeitraum: $zeitraum
);
}
}
Layer 4: Frontend (Dashboard)
Tech-Stack:
- Backend: PHP 8.2 + REST-API
- Frontend: Vue.js 3 (leichtgewichtig, kein Overkill)
- Charts: Chart.js (simpel, funktioniert)
- State: Pinia (statt Vuex)
- Build: Vite (schnell)
Dashboard-Features:
- Echtzeit-Updates: WebSocket-Push bei Daten-Refresh (via Soketi/Laravel Echo Server)
- Drill-Down: Klick auf KPI → Detail-Ansicht
- Zeitraum-Auswahl: Tag/Woche/Monat/Quartal/Jahr + Custom-Range
- Vergleichsperioden: vs. Vormonat, vs. Vorjahr, vs. Budget
- Export: PDF (Management-Report), Excel (Detail-Daten), CSV (Rohdaten)
- Gespeicherte Ansichten: Pro User/Rolle eigene Dashboard-Konfiguration
Performance-Optimierung:
// Lazy Loading: Charts werden erst geladen, wenn sichtbar
<script setup>
import { defineAsyncComponent } from 'vue'
const UmsatzChart = defineAsyncComponent(() =>
import('./components/UmsatzChart.vue')
)
</script>
// Virtuelles Scrolling für lange Listen (10.000+ Abteilungen)
<template>
<RecycleScroller
:items="abteilungen"
:item-size="60"
key-field="id"
>
<template #default="{ item }">
<AbteilungRow :abteilung="item" />
</template>
</RecycleScroller>
</template>
// Debouncing bei Filter-Inputs
watch(filterText, debounce(() => {
loadAbteilungen(filterText.value)
}, 300))
Der Rollout: Big Bang vs. Inkrementell
Entscheidung: Inkrementell
Phase 1 (Monat 1-2): Management-Dashboard
- Nur die Top-10-KPIs
- Nur Monats-Ansicht
- Nur Geschäftsführung + Controlling-Leiter
- Ziel: Quick Win, Feedback sammeln
Phase 2 (Monat 3-4): Abteilungsleiter-Dashboards
- Drill-Down pro Abteilung
- Wochen-Ansicht
- Plan-vs-Ist-Vergleich
- Ziel: Buy-in der mittleren Führungsebene
Phase 3 (Monat 5-6): Controlling-Team-Features
- Daten-Import-UI
- Validierungsregeln selbst pflegen
- Freigabe-Workflows
- Ziel: Excel-Ablösung
Phase 4 (Monat 7-8): Self-Service
- Filter, Gruppierungen, Sortierungen
- Gespeicherte Ansichten
- Ad-hoc-Exporte
- Ziel: IT-Ticket-Reduktion
Phase 5 (Monat 9-10): Advanced Analytics
- Trend-Prognosen (Machine Learning)
- Was-wäre-wenn-Simulationen
- Automatische Anomalie-Erkennung
- Ziel: Proaktives Controlling
Die Ergebnisse: Harte Zahlen
Vor dem System (Excel-Prozess)
- Monatsabschluss-Dauer: 19 Tage
- FTE im Controlling: 8 Personen
- Fehlerquote: ~15% (manuelle Korrekturen)
- Report-Aktualität: 3 Wochen alt
- Management-Zufriedenheit: 4/10
Nach dem System (12 Monate produktiv)
- Monatsabschluss-Dauer: 2 Tage (nur noch Freigabe-Workflow)
- FTE im Controlling: 5 Personen (3 umgeschult zu Business Analysts)
- Fehlerquote: unter 1% (automatische Validierung)
- Report-Aktualität: Live (täglich aktualisiert)
- Management-Zufriedenheit: 9/10
- ROI: Break-Even nach 14 Monaten
Zusätzliche Benefits:
- Schnellere Entscheidungen: “Wir können auf Marktveränderungen jetzt innerhalb von Tagen reagieren statt Wochen”
- Bessere Planung: “Forecasts sind 40% genauer geworden”
- Transparenz: “Jede Abteilung sieht ihre eigenen Zahlen in Echtzeit”
- Compliance: “Audit-Trail für jeden Datenpunkt – DSGVO & SOX-ready”
Lessons Learned: Was würden wir anders machen?
✅ Gut gelaufen
- Inkrementeller Rollout: Statt Big Bang → weniger Risiko, frühe Erfolge
- User-Workshops: Wir haben die Excel-Nutzer gefragt, nicht nur das Management
- Materialized Views: Performance-Game-Changer
- Staging-Tabellen: Fehlerhafte Imports crashen nicht das System
- Audit-Trail: Hat uns zweimal bei Diskussionen gerettet
❌ Hätten wir besser machen können
- Excel-Export zu früh: User wollten sofort Excel-Export → haben dann wieder in Excel gearbeitet statt im System. Hätten wir 3 Monate später einbauen sollen. Governance-Regel heute: Export nur für Board-Decks; operative Arbeit im System (Audit-Trail).
- Zu viele Filter: “Wir brauchen Filter nach allem!” → UI-Overload. Hätten wir auf Top-10-Filter beschränken sollen.
- Change Management unterschätzt: 20% der User haben sich 6 Monate lang gegen das System gewehrt. Hätten wir mehr Schulungen machen sollen. Adoptions-KPI heute: Weekly Active Analysts (WAA) + Export-Quote (Ziel: unter 10% nach Monat 3).
- Kein Dark Mode: Geschäftsführer arbeitet abends am Dashboard → Augen tun weh. War ein 2-Zeilen-CSS-Fix, aber erst nach 3 Monaten Gemecker.
Kostenrahmen: Was kostet so ein System?
Unser Projekt (15.000 MA, 12 Abteilungen):
- Discovery & Konzept: 40h @ 120€ = 4.800€
- Entwicklung: 800h @ 95€ = 76.000€
- Design & Frontend: 120h @ 95€ = 11.400€
- Testing & QA: 80h @ 95€ = 7.600€
- Schulungen: 40h @ 120€ = 4.800€
- Projekt-Management: 100h @ 120€ = 12.000€
Gesamt: ~116.600€
Laufende Kosten (pro Jahr):
- Hosting: 3.600€ (dedicated Server + Backup; SLO: 99,5% Uptime, p95 KPI-Query unter 150ms, p95 Refresh unter 60s)
- Wartung & Support: 12.000€ (SLA: 8h Response-Time)
- Feature-Entwicklung: 24.000€ (ca. 2-3 neue Features pro Jahr)
Gesamt p.a.: ~39.600€
Kleinere Systeme (unter 500 MA):
- Entwicklung: 200-400h = 19.000-38.000€
- Laufend: ~12.000€/Jahr
Technische Highlights: Code-Snippets
PostgreSQL: Recursive CTE für Org-Hierarchie
-- Alle Unter-Abteilungen einer Abteilung finden
WITH RECURSIVE abteilung_tree AS (
-- Start: Gewählte Abteilung
SELECT id, name, parent_id, 0 AS ebene
FROM abteilungen
WHERE id = :abteilung_id
UNION ALL
-- Rekursion: Alle Kinder
SELECT a.id, a.name, a.parent_id, t.ebene + 1
FROM abteilungen a
JOIN abteilung_tree t ON a.parent_id = t.id
)
SELECT * FROM abteilung_tree ORDER BY ebene, name;
PostgreSQL: Percentile-Aggregation
-- Welche Abteilungen liegen über dem 75%-Perzentil bei Umsatz?
SELECT
abteilung,
umsatz,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY umsatz)
OVER () AS p75,
CASE
WHEN umsatz > PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY umsatz) OVER ()
THEN 'Top 25%'
ELSE 'Bottom 75%'
END AS kategorie
FROM kpi_umsatz_monat
WHERE monat = DATE_TRUNC('month', CURRENT_DATE);
PHP: Data Transfer Objects (DTOs) für API
readonly class KPIDashboardDTO {
public function __construct(
public string $abteilung,
public Zeitraum $zeitraum,
public array $kpis, // KPI[]
public ?TrendDTO $trend = null
) {}
public static function fromEntity(Abteilung $abt, Zeitraum $zeitraum, array $kpis): self {
return new self(
abteilung: $abt->name,
zeitraum: $zeitraum,
kpis: array_map(fn(KPI $k) => KPIDashboardKPI::from($k), $kpis),
trend: TrendDTO::calculate($kpis, $zeitraum)
);
}
public function toArray(): array {
return [
'abteilung' => $this->abteilung,
'zeitraum' => [
'von' => $this->zeitraum->von->format('Y-m-d'),
'bis' => $this->zeitraum->bis->format('Y-m-d')
],
'kpis' => array_map(fn($k) => $k->toArray(), $this->kpis),
'trend' => $this->trend?->toArray()
];
}
}
Fazit: Excel ist tot, lang lebe das Controlling-System
Wann lohnt sich ein Custom-System?
✅ Ja, wenn:
- Mehr als 50 Mitarbeiter im Controlling/Management
- Viele manuelle Prozesse (Copy-Paste, Konsolidierung)
- Mehrere Datenquellen (SAP, CRM, HR-System, etc.)
- Compliance-Anforderungen (Audit-Trail, DSGVO)
- Schnelle Entscheidungen nötig (Real-time statt Monatsabschluss)
❌ Nein, wenn:
- Weniger als 10 Mitarbeiter → Excel reicht, Power BI reicht
- Keine IT-Ressourcen für Wartung
- Standardprozesse → lieber Standard-Software kaufen
- Budget unter 30.000€ → zu klein für Custom-Entwicklung
Mein Angebot:
Ich entwickle seit 1998 Controlling-Systeme für mittelständische Unternehmen:
- Discovery-Workshop (2 Tage) → Kostenlose Erstanalyse
- Proof-of-Concept (4 Wochen) → 1-2 KPIs live testen
- MVP (3-4 Monate) → Core-Features produktiv
- Rollout & Schulung → Sukzessive alle User
Kontakt:
📧 die@entwicklerin.net 🌐 www.entwicklerin.net 📍 Lüneburg & Remote
Über die Autorin: Carola Schulte ist Software-Architektin und entwickelt seit 1998 Business-Apps. Schwerpunkte: PHP/PostgreSQL, Security-by-Design, DSGVO-konforme Systeme. 1,8M+ Lines of Code in Produktion.
Hat Ihnen dieser Artikel geholfen? Teilen Sie ihn mit Kollegen im Controlling, die noch in der Excel-Hölle feststecken.
Weitere Artikel in dieser Serie:
- PostgreSQL Performance-Tuning für Business-Apps (erscheint 15.02.2025)
- Business-Portale entwickeln: Kunden-, Partner- & Mitarbeiter-Portale (erscheint 01.03.2025)
- DSGVO für Webapplikationen: Was wirklich nötig ist (erscheint 01.04.2025)
Über Carola Schulte
Software-Architektin mit 25+ Jahren Erfahrung. Spezialisiert auf robuste Business-Apps mit PHP/PostgreSQL, Security-by-Design und DSGVO-konforme Systeme. 1,8M+ Lines of Code in Produktion.
Projekt im Kopf?
Lassen Sie uns besprechen, wie ich Ihre Anforderungen umsetzen kann – kostenlos und unverbindlich.
Kostenloses Erstgespräch