SQL
REPORT
SQL · MySQL · Magento · Reporting · Shop-Daten
Reporting-Queries auf Shop-Daten: Umsatz, Warenkorb, Conversion sauber berechnen

Shop Reporting SQL ist mehr als SELECT SUM. Wer Umsatz, Warenkorbabbruchrate und Customer Lifetime Value korrekt berechnen will, muss Statusfilter, Stornierungen, Quote-Tabellen und CTEs verstehen.

Statusfilter korrekt setzen Warenkorbabbruchrate CLV und AOV-Trend CTEs für lesbare Reports

1. Warum Shop Reporting SQL so fehleranfällig ist

Shop Reporting SQL klingt einfach: Man addiert grand_total aus sales_order und hat seinen Umsatz. In der Praxis ist diese Annahme falsch — und zwar auf mehreren Ebenen gleichzeitig. Erstens enthält sales_order Bestellungen in allen möglichen Status, darunter stornierte, abgelehnte und rückerstattete. Zweitens reduzieren Gutschriften (Credit Memos) den tatsächlichen Nettoumsatz, tauchen aber in einer separaten Tabelle auf. Drittens unterscheidet Magento zwischen quote-Einträgen und echten Bestellungen, was für die Warenkorbabbruchrate entscheidend ist.

Hinzu kommt die Frage der Vollständigkeit: Ein Report, der nur status = 'complete' berücksichtigt, schneidet aktive Bestellungen mit Status processing ab. Ein Report, der beide Status einschließt, aber Stornierungen nicht herausfiltert, überschätzt den Umsatz. Die richtige Antwort liegt in einer durchdachten Statusfilterung in Kombination mit einem JOIN auf die Creditmemo-Tabelle. Dieser Artikel zeigt Schritt für Schritt, wie korrektes Shop Reporting SQL aussieht.

2. Tages- und Monatsumsatz korrekt berechnen

Der wichtigste Grundsatz beim Umsatz-Reporting: Nur Bestellungen mit Status complete oder processing zählen als Umsatz. Stornierungen (canceled), Fehlschläge (failed) und offene Zahlungen (pending) dürfen nicht eingehen. Zusätzlich müssen Credit Memos (Rückerstattungen) vom Bruttobetrag abgezogen werden.

Tagesumsatz mit korrektem Statusfilter und Creditmemo-Abzug:

-- Daily revenue: only complete/processing orders minus credit memos
SELECT
    DATE(o.created_at)         AS day,
    COUNT(DISTINCT o.entity_id) AS order_count,
    SUM(o.grand_total)          AS gross_revenue,
    COALESCE(SUM(cm.creditmemo_total), 0) AS refunded,
    SUM(o.grand_total) - COALESCE(SUM(cm.creditmemo_total), 0) AS net_revenue
FROM sales_order o
LEFT JOIN (
    SELECT order_id, SUM(grand_total) AS creditmemo_total
    FROM sales_creditmemo
    WHERE state = 2  -- 2 = refunded
    GROUP BY order_id
) cm ON cm.order_id = o.entity_id
WHERE o.status IN ('complete', 'processing')
  AND o.created_at >= '2025-01-01'
  AND o.created_at <  '2025-02-01'
GROUP BY DATE(o.created_at)
ORDER BY day;

Für den Monatsumsatz reicht es, die GROUP BY-Klausel anzupassen. Statt DATE() wird DATE_FORMAT(o.created_at, '%Y-%m') verwendet — oder besser als SARGable-Variante ein vorberechnetes Feld via CTE (dazu später mehr).

-- Monthly revenue with average order value
SELECT
    DATE_FORMAT(o.created_at, '%Y-%m') AS month,
    COUNT(DISTINCT o.entity_id)         AS orders,
    ROUND(SUM(o.grand_total), 2)        AS gross_revenue,
    ROUND(AVG(o.grand_total), 2)        AS avg_order_value
FROM sales_order o
WHERE o.status IN ('complete', 'processing')
  AND o.created_at >= '2024-01-01'
GROUP BY DATE_FORMAT(o.created_at, '%Y-%m')
ORDER BY month DESC;

3. Top-10-Produkte nach Umsatz

Um die umsatzstärksten Produkte zu ermitteln, muss man sales_order_item mit dem Produktnamen verknüpfen und nach SKU gruppieren. Wichtig: Refundierte Bestellungen sollten hier ebenfalls herausgefiltert werden, indem nur Items aus Bestellungen mit gültigem Status berücksichtigt werden.

-- Top 10 products by revenue (joined with order status filter)
SELECT
    oi.sku,
    oi.name                              AS product_name,
    SUM(oi.qty_ordered)                  AS units_sold,
    ROUND(SUM(oi.row_total), 2)          AS total_revenue,
    ROUND(SUM(oi.row_total) / SUM(oi.qty_ordered), 2) AS avg_unit_price
FROM sales_order_item oi
INNER JOIN sales_order o ON o.entity_id = oi.order_id
WHERE o.status IN ('complete', 'processing')
  AND oi.parent_item_id IS NULL  -- exclude configurable sub-items
  AND o.created_at >= '2024-01-01'
GROUP BY oi.sku, oi.name
ORDER BY total_revenue DESC
LIMIT 10;

Der Filter oi.parent_item_id IS NULL ist bei Magento-konfigurierbaren Produkten entscheidend: Jede konfigurierbare Bestellung erzeugt zwei Items — das Parent (einfache SKU) und das Kind. Ohne diesen Filter werden Mengen und Umsatz doppelt gezählt.

4. Warenkorbabbruchrate ermitteln

Die Warenkorbabbruchrate ist eines der am häufigsten falsch berechneten Metriken im Shop Reporting SQL. Die richtige Formel lautet: (Quotes ohne zugehörige Bestellung) ÷ (alle aktiven Quotes) × 100. Magento legt für jede Sitzung, in der ein Produkt in den Warenkorb gelegt wird, einen Eintrag in quote an. Wird die Bestellung abgeschlossen, erhält die Quote eine is_active = 0 und ist über reserved_order_id mit sales_order.quote_id verbunden.

-- Cart abandonment rate for a given period
WITH period_quotes AS (
    SELECT
        COUNT(*) AS total_quotes,
        SUM(CASE WHEN is_active = 0 AND reserved_order_id IS NOT NULL THEN 1 ELSE 0 END) AS converted
    FROM quote
    WHERE items_count > 0
      AND created_at >= '2025-01-01'
      AND created_at <  '2025-02-01'
),
converted_orders AS (
    SELECT COUNT(DISTINCT quote_id) AS order_count
    FROM sales_order
    WHERE status IN ('complete', 'processing', 'pending')
      AND created_at >= '2025-01-01'
      AND created_at <  '2025-02-01'
)
SELECT
    pq.total_quotes,
    co.order_count                                         AS converted_quotes,
    pq.total_quotes - co.order_count                      AS abandoned_quotes,
    ROUND(
        (pq.total_quotes - co.order_count) / pq.total_quotes * 100, 2
    )                                                      AS abandonment_rate_pct
FROM period_quotes pq, converted_orders co;

5. Customer Lifetime Value und AOV-Trend

Der Customer Lifetime Value (CLV) gibt an, wie viel Umsatz ein Kunde über alle seine Bestellungen hinweg generiert hat. Im einfachsten Fall ist das die Summe aller grand_total-Werte pro Kunden-ID. Wichtig ist auch hier der Statusfilter, da sonst stornierte Bestellungen den CLV verfälschen.

-- Customer lifetime value: top 20 customers by total spend
SELECT
    o.customer_id,
    o.customer_email,
    COUNT(DISTINCT o.entity_id)   AS total_orders,
    ROUND(SUM(o.grand_total), 2)  AS lifetime_value,
    ROUND(AVG(o.grand_total), 2)  AS avg_order_value,
    MIN(o.created_at)             AS first_order,
    MAX(o.created_at)             AS last_order
FROM sales_order o
WHERE o.status IN ('complete', 'processing')
  AND o.customer_id IS NOT NULL  -- exclude guest orders
GROUP BY o.customer_id, o.customer_email
ORDER BY lifetime_value DESC
LIMIT 20;

Der Average Order Value (AOV) im Zeitverlauf zeigt, ob Kampagnen oder Sortimentsänderungen die Warenkorbgröße beeinflussen:

-- Monthly AOV trend
SELECT
    DATE_FORMAT(created_at, '%Y-%m') AS month,
    COUNT(*)                          AS orders,
    ROUND(AVG(grand_total), 2)        AS aov,
    ROUND(MIN(grand_total), 2)        AS min_order,
    ROUND(MAX(grand_total), 2)        AS max_order
FROM sales_order
WHERE status IN ('complete', 'processing')
GROUP BY DATE_FORMAT(created_at, '%Y-%m')
ORDER BY month;

6. Neue vs. wiederkehrende Kunden

Für die Segmentierung in neue und wiederkehrende Kunden wird das Datum der ersten Bestellung pro Kunden-ID benötigt. Eine Bestellung gilt als "neu", wenn sie die erste Bestellung dieses Kunden ist — das heißt, wenn das Erstelldatum der Bestellung mit dem Erstbestelldatum übereinstimmt.

-- New vs returning customers per month
WITH first_orders AS (
    SELECT
        customer_id,
        MIN(created_at) AS first_order_date
    FROM sales_order
    WHERE status IN ('complete', 'processing')
      AND customer_id IS NOT NULL
    GROUP BY customer_id
)
SELECT
    DATE_FORMAT(o.created_at, '%Y-%m') AS month,
    COUNT(DISTINCT o.entity_id)         AS total_orders,
    COUNT(DISTINCT CASE
        WHEN DATE(o.created_at) = DATE(fo.first_order_date) THEN o.entity_id
    END)                                AS new_customer_orders,
    COUNT(DISTINCT CASE
        WHEN DATE(o.created_at) > DATE(fo.first_order_date) THEN o.entity_id
    END)                                AS returning_customer_orders
FROM sales_order o
INNER JOIN first_orders fo ON fo.customer_id = o.customer_id
WHERE o.status IN ('complete', 'processing')
GROUP BY DATE_FORMAT(o.created_at, '%Y-%m')
ORDER BY month;

7. CTEs für lesbare Reports

Komplexe Reporting-Queries werden schnell unübersichtlich, wenn alle JOINs, Subqueries und Berechnungen in einer einzigen Abfrage verschachtelt sind. Common Table Expressions (CTEs) mit dem WITH-Schlüsselwort lösen dieses Problem elegant: Jeder Rechenschritt wird als benannter CTE definiert und kann danach wie eine temporäre Tabelle referenziert werden.

Ein vollständiger Report, der Umsatz, Abbruchrate und CLV-Segmentierung in einem Durchgang verbindet:

-- Comprehensive monthly report using CTEs
WITH order_base AS (
    -- Base: only relevant statuses
    SELECT entity_id, customer_id, customer_email,
           grand_total, created_at
    FROM sales_order
    WHERE status IN ('complete', 'processing')
),
monthly_revenue AS (
    SELECT
        DATE_FORMAT(created_at, '%Y-%m') AS month,
        COUNT(*)                          AS orders,
        ROUND(SUM(grand_total), 2)        AS revenue,
        ROUND(AVG(grand_total), 2)        AS aov
    FROM order_base
    GROUP BY DATE_FORMAT(created_at, '%Y-%m')
),
refunds AS (
    SELECT
        DATE_FORMAT(o.created_at, '%Y-%m') AS month,
        ROUND(SUM(cm.grand_total), 2)       AS refund_total
    FROM sales_creditmemo cm
    INNER JOIN sales_order o ON o.entity_id = cm.order_id
    WHERE cm.state = 2
    GROUP BY DATE_FORMAT(o.created_at, '%Y-%m')
)
SELECT
    mr.month,
    mr.orders,
    mr.revenue                                      AS gross_revenue,
    COALESCE(r.refund_total, 0)                     AS refunds,
    mr.revenue - COALESCE(r.refund_total, 0)        AS net_revenue,
    mr.aov
FROM monthly_revenue mr
LEFT JOIN refunds r ON r.month = mr.month
ORDER BY mr.month DESC;

8. Magento-Aggregationstabellen als Alternative

Magento pflegt mehrere vorgefertigte Aggregationstabellen, die für Reporting-Queries erheblich schneller sind als direkte Abfragen auf sales_order. Diese werden vom Cron-Job befüllt und halten täglich und monatlich vorberechnete Werte bereit:

  • sales_order_aggregated_created: Aggregierte Bestelldaten nach Erstelldatum, aufgeteilt nach Store-View.
  • sales_order_aggregated_updated: Dieselben Daten, aber nach dem letzten Update-Datum — relevant, wenn Bestellstatus sich ändert.
  • report_viewed_product_aggregated_daily/monthly: Produktaufrufe für Reichweitenanalysen.
-- Using Magento aggregation table for fast monthly revenue
SELECT
    period,
    store_id,
    orders_count,
    ROUND(total_revenue, 2)  AS revenue,
    ROUND(total_refunded, 2) AS refunded,
    ROUND(total_revenue - total_refunded, 2) AS net_revenue
FROM sales_order_aggregated_created
WHERE period >= '2024-01-01'
  AND store_id = 1
ORDER BY period DESC;

Der Nachteil dieser Tabellen: Sie werden durch den Aggregations-Cron befüllt und können je nach Cron-Konfiguration einige Stunden hinter dem tatsächlichen Stand zurückliegen. Für Echtzeit-Dashboards sind direkte Queries auf sales_order notwendig. Für historische Berichte oder BI-Exporte sind die Aggregationstabellen jedoch klar vorzuziehen, da sie deutlich weniger I/O erzeugen.

Mironsoft

Shop Reporting SQL professionell aufsetzen und Magento-Daten korrekt auswerten

Wir helfen dabei, Umsatz-Reports, Conversion-Analysen und CLV-Berechnungen so aufzubauen, dass die Zahlen stimmen — mit korrektem Statusfilter, Creditmemo-Abzug und skalierbaren CTEs.

Report-Audit

Bestehende Reporting-Queries auf Korrektheit, Statusfilter und Creditmemo-Logik prüfen

Conversion-Analyse

Warenkorbabbruchrate, Funnel-Analyse und Quote-to-Order-Rate korrekt messen

BI-Integration

Magento-Daten sauber in BI-Tools oder Data Warehouses exportieren

Shop Reporting SQL — Das Wichtigste auf einen Blick

Statusfilter

Nur complete und processing für Umsatz zählen; canceled und pending_payment ausschließen.

Creditmemos

Rückerstattungen aus sales_creditmemo immer vom Bruttoumsatz abziehen für Nettoumsatz.

Warenkorbabbruch

Quote-Tabelle vs. sales_order.quote_id; nur Quotes mit items_count > 0 vergleichen.

Performance

Für historische Berichte Aggregationstabellen nutzen; für Echtzeit direkte Queries mit Indexunterstützung.

10. Zusammenfassung

Shop Reporting SQL ist in Magento deutlich komplexer als ein einfaches SUM(grand_total). Die häufigsten Fehler entstehen durch fehlende Statusfilter, ignorierte Rückerstattungen, falsch verstandene Quote-vs-Order-Logik und unnötig teure direkte Queries auf große Tabellen. Mit den in diesem Artikel gezeigten CTEs, korrekten Statusfiltern und der Kenntnis der Magento-Aggregationstabellen lassen sich Reporting-Queries erstellen, die sowohl korrekte Zahlen liefern als auch performant bleiben.

Die wichtigste Praxisregel: Immer erst den Statusfilter definieren, dann die Kreditmemo-Abzüge modellieren, und für komplexe Reports auf CTEs setzen — das macht Queries wartbar und für Kollegen nachvollziehbar.

11. FAQ: Shop Reporting SQL in Magento

1Welche Order-Status soll ich für den Umsatz-Report filtern?
Für den Umsatz zählen üblicherweise complete und processing. Status wie canceled, pending, pending_payment und holded dürfen nicht in die Umsatzsumme einfließen.
2Warum muss ich Credit Memos separat abziehen?
Magento legt Rückerstattungen in einer eigenen Tabelle (sales_creditmemo) ab und ändert nicht den grand_total der Ursprungsbestellung. Ohne expliziten LEFT JOIN auf die Creditmemo-Tabelle überschätzt man den Nettoumsatz.
3Wie berechne ich die Warenkorbabbruchrate korrekt?
Man vergleicht die Anzahl aller Quotes mit items_count > 0 im Zeitraum mit der Anzahl der tatsächlich entstandenen Bestellungen über sales_order.quote_id. Die Differenz geteilt durch die Quote-Gesamtzahl ergibt die Abbruchrate.
4Was ist der Unterschied zwischen Aggregationstabellen und direkten Queries?
Die Aggregationstabellen sind vorberechnete Zusammenfassungen, die der Cron befüllt. Sie sind deutlich schneller für historische Berichte, aber bis zu einige Stunden nicht aktuell. Für Echtzeit-Daten muss man direkt auf sales_order abfragen.
5Warum filtere ich bei Top-Produkten auf parent_item_id IS NULL?
Konfigurierbare Produkte in Magento erzeugen zwei Order-Item-Einträge. Ohne diesen Filter werden Mengen und Umsatz doppelt gezählt.
6Wie unterscheide ich neue von wiederkehrenden Kunden in SQL?
Mit einem CTE, der das MIN(created_at) pro customer_id ermittelt. Stimmt das Datum einer Bestellung mit dem ersten Bestelldatum überein, handelt es sich um einen Neukunden.
7Können CTEs die Query-Performance beeinträchtigen?
In MySQL 8 werden CTEs standardmäßig materialisiert, was bei großen Datenmengen zu temporären Tabellen auf Disk führen kann. Mit EXPLAIN ANALYZE prüfen und ggf. auf direkte Subqueries oder Staging-Tabellen ausweichen.
8Wie berechne ich den AOV-Trend über mehrere Monate?
Mit GROUP BY DATE_FORMAT(created_at, '%Y-%m') und AVG(grand_total). Nur Bestellungen mit Status complete oder processing berücksichtigen, damit Stornierungen den Durchschnitt nicht verfälschen.
9Sollte man Guest-Orders bei CLV-Berechnungen berücksichtigen?
Für echtes CLV nach customer_id sollte man customer_id IS NOT NULL filtern. Guest-Orders können separat nach customer_email aggregiert werden, sind aber weniger verlässlich.
10Wann lohnt sich eine eigene Reporting-Datenbank statt direkter Queries auf Magento?
Ab mehreren Millionen Bestellzeilen lohnt sich eine Read-Replica oder ein separates Data Warehouse, um die Produktionsdatenbank nicht durch komplexe Reporting-Queries zu belasten.