Skip to content
Zurück zur Projektübersicht
Live 2022 — heute · 10 Min. Lesezeit

Solario PV-Plattform

PV-Plattform für den klassischen Solario-Vertrieb — Builder, Configurator und Planner für den persönlich begleiteten Prozess vom Erstkontakt bis zur fertigen Anlage

Solarios klassische, persönlich begleitete Vertriebslinie: drei eigenständige Angular-Apps auf gemeinsamem Supabase-Backend. Builder für die erste Online- Anfrage des Kunden, Configurator für das ausgearbeitete Angebot — das der Berater im Wohnzimmertermin gemeinsam mit dem Kunden anpasst — und ein internes Planner-Dashboard, in dem die Solario-Berater den gesamten Weg vom Erstkontakt über Hausbesuch und Angebot bis zur Übergabe der installierten Anlage steuern. Typsichere GraphQL-Abfragen und Mandantentrennung über Row-Level Security.

01 Problem

Anfragen kamen über Formulare, wurden in Excel überführt, Angebote per Word erstellt — pro Auftrag mehrere Stunden manuelle Übertragung. Der Übergang von der Online-Anfrage zum Wohnzimmertermin war ein Bruch im Datenfluss: der Berater fuhr mit einem ausgedruckten Formular raus und schrieb beim Kunden handschriftlich mit. Drei Schmerzpunkte gleichzeitig: keine Datenqualität, keine Datenkontinuität zwischen Anfrage, Hausbesuch und Angebot, und der Online-Erstkontakt war für den potenziellen Kunden Reibung statt Hilfe. Dazu arbeitet Solario mit mehreren Vertriebspartnern — Mandantentrennung musste sauber ab Tag 1 stehen, weil ein Berater im Wohnzimmer keine Daten anderer Partner sehen darf.

02 Lösung

Drei Angular-Apps mit gemeinsamem Supabase-Backend, entlang des klassischen Vertriebsprozesses aufgebaut. Builder als kundenseitige Anfrage-App vor dem ersten Termin, Configurator als ausgearbeitetes Angebot, das der Berater im Wohnzimmer gemeinsam mit dem Kunden anpasst, Planner als internes Dashboard für Termine, Angebote und Kundenkontakt. GraphQL-Codegen für typsichere Abfragen, automatische Ertragsabschätzung pro Dachfläche samt Ausrichtung und Verschattung. Angebotsdaten werden aus den Builder-Antworten und den im Hausbesuch ergänzten Details generiert und im Planner nur noch geprüft und freigegeben — kein erneutes Abtippen. PV-Anlage als versioniertes JSON-Schema, Mandantentrennung als RLS-Policy, Angebote als HTML mit Browser-Print-API zu PDF.

03 Ergebnis

Durchlaufzeit von der Kundenanfrage bis zum versendeten Angebot deutlich verkürzt — und der Wohnzimmertermin startet mit sauberen, vollständigen Daten statt Papierformularen und handschriftlichen Notizen. Plattform läuft parallel zur DIY-Linie „Machs Dir Selbst Solar" weiter und wird kontinuierlich erweitert — neue Module, neue Wechselrichter-Modelle und gesetzliche Anpassungen (Einspeisevergütung, Steuerregeln) landen ohne Code-Änderungen direkt im Tool. Nach drei Jahren noch das Rückgrat von Solarios klassischer, persönlich begleiteter Vertriebslinie.

Drei Apps, ein Backend

Die Plattform besteht aus drei eigenständigen Angular-Apps mit gemeinsamer Supabase-Datenebene — entlang des klassischen Solario-Vertriebsprozesses aufgebaut: Online-Anfrage, Hausbesuch, ausgearbeitetes Angebot, Installation, Übergabe.

Builder — die kundenseitige Erstanfrage-App, öffentlich erreichbar, ohne Login, konversionskritisch. Ein potenzieller Kunde gibt seine Adresse ein, skizziert seine Dachflächen grob und beschreibt, was er sich vorstellt — keine Materialwahl auf Komponentenebene, keine technischen Detail-Entscheidungen. Heraus kommt eine erste, in sich konsistente Anfrage, die der Solario-Berater vor dem Vor-Ort-Termin sichten kann. Die App reserviert beim ersten Klick eine configuration_id und nutzt sie als anonymes Pseudo-Auth — die Anfrage ist über einen Link teilbar, die Daten existieren in der DB von Sekunde 1 an.

Configurator — das ausgearbeitete Angebot, in dem der Kunde nach dem Hausbesuch landet. Hier hat der Berater die technischen Details aus dem Vor-Ort-Termin (Dachzugang, Verkabelung, Verschattung, Zählerschrank) eingepflegt; der Kunde sieht konkrete Modulreihen, konkreten Wechselrichter, optionale Komponenten mit Aufpreis und eine detaillierte Wirtschaftlichkeitsrechnung. Material lässt sich live tauschen (z. B. Premium-Modul statt Basis), Auswirkungen auf Preis, Ertrag und Payback sind sofort sichtbar — oft sitzt der Berater gemeinsam mit dem Kunden vor dem Bildschirm und passt es interaktiv an.

Planner — die interne App mit Supabase Auth und Row-Level Security pro Mandant. Berater sehen alle Anfragen ihres Mandanten, planen die Vor-Ort-Termine, ergänzen technische Details nach dem Hausbesuch (welche Modulreihe, welche Verkabelung, welcher Wechselrichter zum String-Layout), erstellen Angebote und begleiten den Kunden über Installation und Inbetriebnahme bis zur Übergabe der fertigen Anlage. Stammdaten (Hersteller, Module, Wechselrichter, Zuschüsse, Strompreis-Annahmen) werden hier redaktionell gepflegt.

Alle drei ziehen ihre TypeScript-Typen direkt aus dem Supabase-Schema über GraphQL Codegen — eine Schemaänderung landet binnen Minuten typsicher in allen drei Frontends. Das hat sich besonders in der frühen Phase bewährt, in der Datenmodell und UI-Anforderungen sich noch wechselseitig beeinflussten: alle paar Tage änderte sich etwas im Datenmodell, und in einem von Hand gepflegten Typen-Layer hätten wir uns daran zermürbt.

Vorgehen

Die zentrale Designentscheidung war, das PV-System nicht in zehn normalisierte Tabellen zu zerschlagen, sondern als ein versioniertes JSON in einer einzigen Spalte zu halten. Eine Anlage hat irreduzible Komplexität — Module, Wechselrichter, Speicher, Dachflächen mit Verschattungssegmenten, optionale Notstromversorgung, jede Komponente mit Eigenschaften, die sich pro Hersteller unterscheiden. Würde man jede Beziehung in eine Tabelle gießen, wäre jeder neue Komponententyp eine Migration. So aber:

  • Das Schema lebt als Zod-Definition in TypeScript und wird auf der App-Seite validiert.
  • Neue Felder werden additiv eingeführt — schema_version zählt hoch, alte Versionen werden beim Lesen migriert.
  • Postgres-Indizes auf JSON-Pfaden (->> und jsonb_path_ops) decken die wenigen Abfragen ab, die wir tatsächlich quer über Anlagen fahren (z. B. „alle Anlagen mit Hersteller X”).

Das gibt uns Refactoring-Freiheit, ohne Datenintegrität zu verlieren: das Schema ist versioniert, jede Lesung läuft durch Validierung, und beim Schreiben gehen nur strukturell korrekte Objekte rein.

src/lib/system/schema.ts ts
import { z } from 'zod';

export const ModuleEntry = z.object({
  model: z.string(),      // z.B. "JA Solar JAM72S30-545"
  count: z.number().int().positive(),
  power_wp: z.number().positive(),
});

export const SystemV2 = z.object({
  schema_version: z.literal(2),
  modules: z.array(ModuleEntry).min(1),
  inverter: Inverter,
  storage: Storage.optional(),
  roofs: z.array(RoofSegment).min(1),
});

// Beim Lesen: alte Versionen werden hier migriert.
export function readSystem(raw: unknown): z.infer<typeof SystemV2> {
  const v = (raw as any)?.schema_version ?? 1;
  return v === 2 ? SystemV2.parse(raw) : migrateV1toV2(raw);
}

Tiefe Einblicke

Typsichere Abfragen end-to-end

Eine .graphql-Datei beschreibt die Query, der Codegen generiert TypeScript-Types und einen typsicheren Apollo-Hook. Wenn das Schema sich ändert, schlägt der Build fehl — bevor der Code in Produktion landet. Wir lassen den Codegen sowohl als Pre-Commit-Hook als auch als CI-Check laufen: ein PR mit veralteten Generated-Files wird im Review sofort als rot markiert.

graphql/queries/getOffer.graphql graphql
query GetOffer($id: UUID!) {
  offer(id: $id) {
    id
    status
    customer { name email }
    system {
      modules { model count }
      inverter { model power_kw }
      storage  { model capacity_kwh }
      roofs    { area_m2 azimuth tilt shading }
    }
    pricing { total vat installation_eur }
  }
}

# → generates GetOfferQuery, GetOfferQueryVariables,
#   useGetOfferQuery(), watchGetOffer() automatically.

PV-Anlage als versioniertes JSON-Schema

Die Anlage selbst lebt als JSON-Spalte in Postgres. Das Schema wird in TypeScript geführt und in der App validiert (Zod). Neue Felder werden additiv eingeführt; ältere Anlagen werden beim Lesen migriert. Drei Jahre und sechs Schema-Versionen später haben wir keine einzige tatsächliche DB-Migration auf der system-Spalte gebraucht — alle Form-Änderungen leben in der App-Schicht.

src/lib/system/migrations.ts ts
// V1 hatte `roof_area_m2` als Skalar. V2 hat ein Array von RoofSegments
// mit eigener Ausrichtung pro Segment — Dächer mit Süd- + Westanteil
// brauchen das für eine ehrliche Ertragsabschätzung.
export function migrateV1toV2(raw: unknown): z.infer<typeof SystemV2> {
  const v1 = SystemV1.parse(raw);
  return SystemV2.parse({
    schema_version: 2,
    modules: v1.modules,
    inverter: v1.inverter,
    storage: v1.storage,
    roofs: [{
      area_m2: v1.roof_area_m2,
      azimuth: v1.roof_azimuth ?? 180,
      tilt: v1.roof_tilt ?? 35,
      shading: 0,
    }],
  });
}

Mandantentrennung als Datenbank-Policy

Jede Anfrage gehört zu einem tenant_id. Eine einzige RLS-Policy regelt, wer was sehen darf — die Apps müssen darüber nichts wissen. Auch Wartungs-Skripte greifen über die gleiche Schicht zu. Das schließt eine ganze Klasse von Bugs aus: wer in einer SQL-Abfrage versehentlich den WHERE tenant_id = ?-Filter vergisst, kriegt trotzdem nur die eigenen Rows zurück.

supabase/migrations/0042_rls_offers.sql sql
alter table public.offers enable row level security;

-- Planner dürfen nur die Angebote ihres Mandanten lesen
create policy "offers_select_own_tenant"
  on public.offers for select
  using (tenant_id = auth.jwt()->>'tenant_id');

-- Konfigurator (anonym) darf nur seine eigene Offer-ID schreiben
create policy "offers_insert_own_session"
  on public.offers for insert
  with check (session_id = current_setting('request.session_id', true));

-- Konfigurator darf seine eigene Offer-ID auch wieder lesen — solange er
-- die Session-ID kennt. Damit funktioniert "Konfiguration per Link teilen".
create policy "offers_select_own_session"
  on public.offers for select
  using (session_id = current_setting('request.session_id', true));

Ertragsabschätzung pro Dachsegment

Eine ehrliche Ertragsabschätzung kommt nicht von „PV-Größe × Standard-Faktor”, sondern von einer separaten Rechnung pro Dachsegment: Ausrichtung, Neigung, Verschattung, Modulgröße. Die libs/economics-Bibliothek macht den Rest — Strompreis-Eskalation über 25 Jahre, Eigenverbrauch versus Einspeisung, Annuität bei Finanzierung, Payback-Periode. Sie ist als reine Funktionen geschrieben, damit sie testbar ist und sowohl im Configurator (Live-Update beim Schieber-Drag) als auch im Planner (finale Angebots-Berechnung) läuft.

libs/economics/src/lib/economics.ts ts
export class EconomicsUtils {
  static DEFAULT_YEARS_FOR_CALCULATION = 25;
  static ELECTRICITY_PRICE_YEARLY_INCREASE_PERCENTAGE = 0.04;

  static calculate_electricity_price_list(
    starting_price_cent: number,
    yearly_factor = 1 + EconomicsUtils.ELECTRICITY_PRICE_YEARLY_INCREASE_PERCENTAGE,
    max_years = EconomicsUtils.DEFAULT_YEARS_FOR_CALCULATION,
  ): number[] {
    const list = [starting_price_cent];
    for (let y = 1; y < max_years; y++) list.push(list[y - 1] * yearly_factor);
    return list;
  }

  static calculate_payback_period(profit_list: number[], total_cost: number): number {
    let years = 0, cumulative = 0;
    while (total_cost > cumulative && years < profit_list.length) {
      cumulative += profit_list[years++];
    }
    const overshoot = cumulative - total_cost;
    return overshoot > 0 && years > 0 ? years - overshoot / profit_list[years - 1] : years;
  }
}

Angebote als HTML mit Browser-Print

Statt einen PDF-Generator zu pflegen (PDFKit, Puppeteer, eigene Schriftarten-Pipeline, manuelles Seitenumbruch-Management) wird das Angebot als HTML-Komponente gerendert — mit eigenem Print-Stylesheet, das @page-Regeln, break-inside: avoid auf Komponentengruppen und seitenfüllende Diagramme nutzt. Der Browser macht den Rest. Bei Layout-Änderungen ist das Cycletime: einen Wert in der SCSS-Datei ändern, in der App auf „Drucken” klicken, fertig.

src/app/offer/offer-pdf.component.scss scss
@page {
  size: A4;
  margin: 1.5cm 2cm;
}
@media print {
  .offer-page { break-after: page; }
  .offer-component { break-inside: avoid; }
  .offer-cover { background: var(--brand-bg) !important;
                 -webkit-print-color-adjust: exact; }
  .no-print { display: none !important; }
}

Was sich in drei Jahren bewährt hat

Drei Punkte, die sich nach längerem Betrieb als wichtiger erwiesen haben als wir am Anfang dachten:

Stammdaten gehören redaktionell pflegbar in die DB. Jeder neue Modultyp, jede Anpassung der Einspeisevergütung, jeder neue Wechselrichter ist eine Zeile im Backoffice — kein Deploy. In drei Jahren Betrieb gab es zwei oder drei gesetzliche Änderungen (Mehrwertsteuer auf PV-Anlagen, Anhebung der Einspeisevergütung), die sonst jedes Mal eine Code-Änderung gebraucht hätten.

JSON-Schema-Versionierung trägt weiter als gedacht. Wir sind inzwischen bei schema_version: 5 für das System-JSON. Jede Version wird beim Lesen sauber migriert, sodass die Daten im Frontend einheitlich aussehen, ohne dass wir eine Postgres-Migration auf 50.000 Anlagen jagen mussten. Wenn etwas wirklich nicht mehr passt (extrem alte Schemata), kann ein nächtlicher Cron die DB-Spalte rewriten — bisher nicht nötig gewesen.

RLS rettet bei jedem Refactor. Wir haben drei größere Frontend-Refactors hinter uns (RxJS-Subjects → Signals; lazy-loaded Module → Standalone-Components; Tailwind v3 → v4). Bei jedem haben wir versehentlich irgendwo einen Tenant-Filter rausgelöscht — und nichts ist passiert, weil RLS in der DB ihn nochmal durchsetzt. Das ist genau die Defense-in-Depth, die Mandantentrennung verdient.

05 Entscheidungen

Schlüssel-Entscheidungen

01

Configurator ohne Login

Anonyme Sessions mit serverseitiger Reservierung der Konfigurations-ID. Kunden können die Konfiguration teilen, Solario sieht sie in Echtzeit im Planner. Erst beim Angebotsversand wird Kontaktdaten verlangt — das senkt die Abbruchquote erheblich gegenüber einem Wall mit „bitte registrieren Sie sich erst".

02

PV-Systeme als typisiertes JSON, nicht als 12 normalisierte Tabellen

Eine Anlage besteht aus Modulen, Wechselrichtern, Speichern, Dachflächen, Verkabelung — Beziehungen, die sich oft ändern. Statt sie in ein starres Schema zu pressen, leben sie als versioniertes JSON in Postgres mit Validierung in der App-Schicht. Refactors brauchen keine Migrationen, neue Komponentenarten lassen sich additiv einführen.

03

GraphQL Codegen statt manuell gepflegter Types

Die TypeScript-Typen aller Queries und Mutations werden aus dem Supabase-GraphQL-Schema generiert. Eine Schemaänderung im Backend wird zum Compile-Fehler im Frontend — kein „silent drift" zwischen DB und UI. Die Pipeline läuft als Pre-Commit-Hook und als CI-Check, damit niemand mit veralteten Typen Code reviewt.

04

Angebote als HTML-Template mit Print-Stylesheet

Statt einen PDF-Generator zu pflegen (eigene Schriftarten, Seitenumbrüche, Tabellenrendering) wird das Angebot als HTML gerendert und per Browser-Print-API zu PDF gemacht. Anpassungen am Layout brauchen keine Spezial-Toolchain — wer HTML/CSS kann, kann das Angebot anpassen.

05

Row-Level Security in Postgres, nicht in der App

Mandantentrennung (welche Solario-Planer sehen welche Anfragen) ist als RLS-Policy in Postgres umgesetzt. Eine vergessene WHERE-Klausel im Frontend kann keinen Datenleak mehr erzeugen — die Datenbank weiß, wer was sehen darf, und der anonyme Configurator hat eigene Policies pro Session-ID.

06

Stammdaten in der Datenbank, nicht im Code

Module, Wechselrichter, Speicher, Zuschüsse, Strompreis-Annahmen, Einspeisevergütung — alles redaktionell pflegbar im Planner-Backoffice. Eine gesetzliche Änderung am Strompreis braucht keinen Deploy mehr, nur einen Update auf einer Stammdatenzeile.

07 Was hängenbleibt

  • 01 JSON-Felder für komplexe Domänen-Objekte sind ein guter Mittelweg zwischen Flexibilität und Typsicherheit — vorausgesetzt, die App-Schicht validiert konsequent und das Schema selbst ist versioniert.
  • 02 GraphQL Codegen ist den Setup-Aufwand vom ersten Tag wert. Die einmaligen 90 Minuten zahlen sich nach einer Woche aus, und in Reviews fängt der Compiler Bugs, die sonst erst in der Live-Umgebung auftauchen würden.
  • 03 Row-Level Security in der Datenbank ist robuster als „wir denken im Frontend dran". Ein vergessener WHERE-Filter führt nicht mehr zum Datenleak, weil die Datenbank schon nichts mehr ausliefert.
  • 04 Anonyme Konfigurator-Sessions konvertieren deutlich besser als ein Login-Wall am Anfang — solange die Daten serverseitig sauber an eine spätere E-Mail gebunden werden können.
  • 05 Browser-Print-PDFs reichen für 95 % aller Angebotslayouts. Den 5 % mit exotischen Wünschen (eigene Wasserzeichen, mehrseitige Komponenten- Pläne) gibt man ein zweites Tool — nicht den ganzen PDF-Stack umbauen.
Kontakt

Klingt vertraut?

Schreiben Sie mir kurz, worum es geht. Ich melde mich innerhalb von 24 Stunden mit einer ehrlichen Einschätzung — auch wenn ich nicht der richtige Partner bin.