Zum Inhalt

Konzept: Statistiken-Tab im Artikel-Detail

1. Übersicht

Neuer Tab "Statistiken" in der Artikel-Detailseite, analog zum bestehenden Statistiken-Tab beim Kunden. Zeigt artikelspezifische Verkaufsstatistiken mit dem Fokus auf Top Artikelvarianten statt "Top Artikel".


2. Ist-Zustand (Kunden-Statistiken als Referenz)

Kunden-Statistiken zeigen:

Bereich Inhalt
Info-Bar (4 Cards) Anzahl Bestellungen, Gesamt-Umsatz, Ø Bestellwert, Artikel-Vielfalt
Umsatz-Chart Zeitverlauf (stündlich/täglich/monatlich je nach Zeitraum)
Top Artikel Top 10 Artikel mit Platzierung, Name, Nummer, Menge, Umsatz
Zeitraum-Filter today, yesterday, thisWeek, last30Days, last60Days, thisMonth, lastMonth, thisYear, lastYear

Datenfluss Kunden-Statistiken:

job_calculateStatistics.js
  → writeCustomerSpecificStatistics()
    → calculateCustomerSpecificStats() (shared_statistic_helpers.js)
      → Firestore: statisticsCustomerSpecific/{customerId}_{periodKey}

Flutter: StatisticFirebaseService.loadCustomerSpecificStatistics()
  → CustomerSpecificStatistic Model
    → CustomerStatisticsBodyWidget
      → CustomerSalesVolumeChart + CustomerTopArticlesCard

3. Soll-Zustand (Artikel-Statistiken)

Artikel-Statistiken zeigen:

Bereich Inhalt
Info-Bar (4 Cards) Anzahl Bestellungen, Gesamt-Umsatz, Ø Bestellwert, Kunden-Vielfalt (wie viele verschiedene Kunden)
Umsatz-Chart Zeitverlauf für diesen Artikel (stündlich/täglich/monatlich)
Top Artikelvarianten Top 10 Varianten mit Platzierung, Varianten-Name, Varianten-Nummer, Menge, Umsatz
Zeitraum-Filter Identisch zum Kunden (alle TimePeriod-Werte)

4. Neues Datenmodell

4.1 Firestore Collection: statisticsArticleSpecific

Document-ID: {articleId}_{periodKey} (z.B. abc123_today)

{
  "articleId": "abc123",
  "timePeriod": "today",
  "orderCount": 15,
  "totalSalesVolume": 1250.50,
  "averageOrderValue": 83.37,
  "customerCount": 8,
  "topVariants": [
    {
      "variantId": "variant-uuid-1",
      "variantName": "500ml",
      "variantNumber": "ART-001-500",
      "quantity": 120,
      "salesVolume": 780.00
    },
    {
      "variantId": "variant-uuid-2",
      "variantName": "250ml",
      "variantNumber": "ART-001-250",
      "quantity": 85,
      "salesVolume": 340.00
    }
  ],
  "dailySales": [
    { "date": "2026-03-23T08:00:00", "salesVolume": 250.00 },
    { "date": "2026-03-23T09:00:00", "salesVolume": 180.50 }
  ],
  "lastUpdated": "<Timestamp>"
}

Wichtig: Die topVariants enthalten auch Bestellpositionen ohne Variante – dort wird variantId: null, variantName: "Ohne Variante" gesetzt, damit auch Einzelartikel-Bestellungen sichtbar sind.

4.2 Flutter Datenmodell: ArticleSpecificStatistic

Pfad: core/apps/erp_system/lib/models/statistic/article_specific_statistic.dart

class ArticleSpecificStatistic {
  final String articleId;
  final TimePeriod timePeriod;
  final int orderCount;
  final double totalSalesVolume;
  final double averageOrderValue;
  final int customerCount;
  final List<VariantData> topVariants;
  final List<DailySalesData> dailySales;
  final DateTime? lastUpdated;
}

class VariantData {
  final String? variantId;
  final String variantName;
  final String variantNumber;
  final double quantity;
  final double salesVolume;
}

DailySalesData wird aus dem bestehenden customer_specific_statistic.dart wiederverwendet (ggf. in eigene Datei extrahieren oder importieren).


5. Backend: Erweiterung job_calculateStatistics.js

5.1 Neue Funktion: writeArticleSpecificStatistics()

Wird analog zu writeCustomerSpecificStatistics() aufgerufen – pro Zeitraum einmal.

async function writeArticleSpecificStatistics(db, filteredOrders, timePeriodKey, start, end) {
  // 1. Gruppiere Orders nach articleId (aus orderPositions)
  const articleOrders = new Map(); // articleId → [positions mit order-context]

  for (const order of filteredOrders) {
    const positions = order.orderPositions || [];
    for (const position of positions) {
      const articleId = position.articleId;
      if (!articleId) continue;

      if (!articleOrders.has(articleId)) {
        articleOrders.set(articleId, []);
      }
      articleOrders.get(articleId).push({
        ...position,
        orderId: order.id,
        customerId: order.customerId,
        orderDate: order.orderDate,
      });
    }
  }

  // 2. Pro Artikel: Statistiken berechnen
  for (const [articleId, positions] of articleOrders.entries()) {
    // Varianten-Statistiken
    const variantMap = new Map();
    let totalSalesVolume = 0;
    const customerSet = new Set();
    const orderSet = new Set();
    const dailySales = new Map();

    for (const pos of positions) {
      const revenue = (pos.quantity || 0) * (pos.price || 0);
      totalSalesVolume += revenue;
      customerSet.add(pos.customerId);
      orderSet.add(pos.orderId);

      // Variante tracken
      const variantKey = pos.articleVariantId || '__no_variant__';
      if (!variantMap.has(variantKey)) {
        variantMap.set(variantKey, {
          variantId: pos.articleVariantId || null,
          variantName: pos.articleVariantName || 'Ohne Variante',
          variantNumber: pos.articleVariantId 
            ? (pos.articleVariantNumber || pos.articleNumber || '')
            : pos.articleNumber || '',
          quantity: 0,
          salesVolume: 0,
        });
      }
      const entry = variantMap.get(variantKey);
      entry.quantity += pos.quantity || 0;
      entry.salesVolume += revenue;

      // Daily Sales
      const orderDate = pos.orderDate?.toDate?.() || new Date(pos.orderDate);
      const useHourly = timePeriodKey === 'today' || timePeriodKey === 'yesterday';
      let dateKey;
      if (useHourly) {
        const isoString = orderDate.toISOString();
        const datePart = isoString.split('T')[0];
        const hour = orderDate.getHours().toString().padStart(2, '0');
        dateKey = `${datePart}T${hour}:00:00`;
      } else {
        dateKey = orderDate.toISOString().split('T')[0];
      }
      dailySales.set(dateKey, (dailySales.get(dateKey) || 0) + revenue);
    }

    const topVariants = Array.from(variantMap.values())
      .sort((a, b) => b.salesVolume - a.salesVolume)
      .slice(0, 20);

    const dailySalesArray = Array.from(dailySales.entries())
      .map(([date, volume]) => ({ date, salesVolume: volume }))
      .sort((a, b) => a.date.localeCompare(b.date));

    const orderCount = orderSet.size;

    // 3. In Firestore schreiben
    const docId = `${articleId}_${timePeriodKey}`;
    await db.collection("statisticsArticleSpecific").doc(docId).set({
      articleId,
      timePeriod: timePeriodKey,
      orderCount,
      totalSalesVolume,
      averageOrderValue: orderCount > 0 ? totalSalesVolume / orderCount : 0,
      customerCount: customerSet.size,
      topVariants,
      dailySales: dailySalesArray,
      lastUpdated: new Date(),
    });
  }
}

5.2 Integration in bestehenden Job

In der execute() Funktion von job_calculateStatistics.js wird writeArticleSpecificStatistics() als zusätzlicher Schritt eingehängt – sowohl im Quick-Modus (Promise.all) als auch im Normal-Modus:

// Quick-Modus (today/thisWeek): Zusätzlich in Promise.all einfügen
await Promise.all([
  // ... bestehende 1-5 ...
  // 6. Artikelspezifische Statistiken
  writeArticleSpecificStatistics(db, filteredOrders, key, start, end),
]);

// Normal-Modus: Nach den Customer-Specific Stats
await writeArticleSpecificStatistics(db, filteredOrders, key, start, end);

5.3 Cleanup alter Daten

Analog zu statisticsCustomerSpecific müssen alte Dokumente gelöscht werden:

// Vor dem Schreiben: Alte Artikel-Statistiken für diesen Zeitraum löschen
const oldArticleStats = await db.collection("statisticsArticleSpecific")
  .where("timePeriod", "==", timePeriodKey)
  .get();

// Batch-Delete in 500er-Chunks

6. Frontend: Flutter Änderungen

6.1 Neue Dateien

Datei Beschreibung
lib/models/statistic/article_specific_statistic.dart Datenmodell (analog zu CustomerSpecificStatistic)
lib/pages/articles/detail_page/widgets/article_statistics_body_widget.dart Haupt-Widget (analog zu CustomerStatisticsBodyWidget)
lib/pages/articles/detail_page/widgets/statistics/article_top_variants_card.dart Top-Varianten-Liste (analog zu CustomerTopArticlesCard)
lib/pages/articles/detail_page/widgets/statistics/article_sales_volume_chart.dart Umsatz-Chart (kann CustomerSalesVolumeChart wiederverwenden oder kopieren)

6.2 Vorhandene Dateien anpassen

Datei Änderung
lib/tabs/article_tab_registry.dart Neuen statistiken() Tab registrieren
lib/services/firebase_services/statistic_firebase_service.dart Neue Methode loadArticleSpecificStatistics()
lib/l10n/app_de.arb + app_en.arb Neue Lokalisierungs-Strings

6.3 Tab-Registrierung in ArticleTabRegistry

static TabDefinition<Article> statistiken() => TabDefinition<Article>(
  key: 'statistiken',
  label: 'Statistiken',
  labelBuilder: (ctx) => S.of(ctx)!.articleTab_statistiken,
  icon: CupertinoIcons.chart_bar,
  contentBuilder: (ctx, article) =>
      ArticleStatisticsBodyWidget(article: article),
);

static List<TabDefinition<Article>> defaults() => [
  stammdaten(),
  beschreibungen(),
  varianten(),
  zuweisung(),
  bilder(),
  dokumente(),
  statistiken(),  // ← NEU
];

6.4 Firebase Service Erweiterung

/// Lädt artikelspezifische Statistiken
Future<ArticleSpecificStatistic?> loadArticleSpecificStatistics(
    String articleId, TimePeriod period) async {
  final periodKey = _getPeriodKey(period);
  final docId = '${articleId}_$periodKey';

  try {
    final doc = await _firestore
        .collection('statisticsArticleSpecific')
        .doc(docId)
        .getWithTimeout();

    if (!doc.exists) return null;
    return ArticleSpecificStatistic.fromMap(doc.data()!);
  } catch (e) {
    return null;
  }
}

6.5 Widget-Struktur ArticleStatisticsBodyWidget

ArticleStatisticsBodyWidget
├── Header + TimePeriod-Filter (EsFilterButton)
├── Info-Bar (4 Cards)
│   ├── Anzahl Bestellungen (Shopping Cart Icon, orange)
│   ├── Gesamt-Umsatz (Euro Icon, purple)
│   ├── Ø Bestellwert (Chart Icon, green)
│   └── Kunden-Vielfalt (Person Icon, blue)
├── Row
│   ├── ArticleSalesVolumeChart (Expanded)
│   └── ArticleTopVariantsCard (width: 550)

6.6 ArticleTopVariantsCard Widget

Identisch aufgebaut wie CustomerTopArticlesCard: - Titel: "Top Artikelvarianten" - Liste mit max. 10 Einträgen - Platzierung 1-3 mit Gold/Silber/Bronze-Farben - Ab Platz 4: Theme-Farbe - Anzeige: Variantenname, Variantennummer, Menge, Umsatz

Unterschied zu CustomerTopArticlesCard: - Statt articleName / articleNumbervariantName / variantNumber - Untertitel: {variantNumber} • {quantity} Stk.


7. Datenquelle: OrderPosition-Felder

Die benötigten Daten sind bereits in den orderPositions vorhanden:

// Aus order_position.dart / Firestore:
{
  articleId: "...",
  articleNumber: "ART-001",
  articleName: "Shampoo Premium",
  articleVariantId: "uuid-variant-1",      // ← Varianten-ID
  articleVariantName: "500ml Flasche",     // ← Varianten-Name
  quantity: 5,
  price: 12.50,
  amount: 62.50,
}

Es müssen keine neuen Felder zu bestehenden Modellen hinzugefügt werden.


8. Performance-Überlegungen

Aspekt Lösung
Zusätzliche Writes Ca. N Artikel × 9 Zeiträume Dokumente. Bei 100 Artikeln = ~900 Docs pro Full-Run. Vertretbar.
Smart Caching Abgeschlossene Zeiträume werden übersprungen (bestehende Logik greift).
Incremental Update Bei today/thisWeek: Alte Artikel-Docs löschen + neue schreiben (analog Kunden).
Batch Writes 500er-Chunks für Firestore Limits (bestehende Patterns).
Frontend-Caching Kein extra BLoC nötig – direkte Firestore-Abfrage pro Tab-Anzeige (wie bei Kunden).

9. Zusammenfassung der Änderungen

Backend (Cloud Functions)

  1. job_calculateStatistics.js – Neue Funktion writeArticleSpecificStatistics() + Aufruf in beiden Modi
  2. shared_statistic_helpers.js – Optional: neue Helper-Funktion calculateArticleSpecificStats() extrahieren

Flutter (Frontend)

  1. Neues Datenmodell: article_specific_statistic.dart (mit VariantData)
  2. Neues Widget: article_statistics_body_widget.dart (Hauptcontainer)
  3. Neues Widget: article_top_variants_card.dart (Top-Varianten-Liste)
  4. Neues Widget: article_sales_volume_chart.dart (Umsatz-Chart, ggf. generisch)
  5. Erweitert: article_tab_registry.dart (neuer Tab statistiken)
  6. Erweitert: statistic_firebase_service.dart (neue Lade-Methode)
  7. Erweitert: Lokalisierungsdateien (neue Strings)

Firestore

  1. Neue Collection: statisticsArticleSpecific mit Dokumenten {articleId}_{periodKey}

10. Offene Fragen / Entscheidungen

  1. Varianten ohne ID: Sollen Bestellpositionen ohne articleVariantId unter "Ohne Variante" zusammengefasst oder ignoriert werden?
    Empfehlung: Zusammenfassen unter "Ohne Variante" – so gehen keine Umsätze verloren.

  2. Chart wiederverwenden: CustomerSalesVolumeChart ist generisch genug, um wiederverwendet zu werden (erwartet statistics Map + timePeriod). Alternativ ein generisches SalesVolumeChart-Widget erstellen.
    Empfehlung: Direkt wiederverwenden, da identische Datenstruktur.

  3. Firestore Security Rules: Für statisticsArticleSpecific müssen Leserechte analog zu statisticsCustomerSpecific eingerichtet werden.