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
topVariantsenthalten auch Bestellpositionen ohne Variante – dort wirdvariantId: 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;
}
DailySalesDatawird aus dem bestehendencustomer_specific_statistic.dartwiederverwendet (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 / articleNumber → variantName / 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)¶
job_calculateStatistics.js– Neue FunktionwriteArticleSpecificStatistics()+ Aufruf in beiden Modishared_statistic_helpers.js– Optional: neue Helper-FunktioncalculateArticleSpecificStats()extrahieren
Flutter (Frontend)¶
- Neues Datenmodell:
article_specific_statistic.dart(mitVariantData) - Neues Widget:
article_statistics_body_widget.dart(Hauptcontainer) - Neues Widget:
article_top_variants_card.dart(Top-Varianten-Liste) - Neues Widget:
article_sales_volume_chart.dart(Umsatz-Chart, ggf. generisch) - Erweitert:
article_tab_registry.dart(neuer Tabstatistiken) - Erweitert:
statistic_firebase_service.dart(neue Lade-Methode) - Erweitert: Lokalisierungsdateien (neue Strings)
Firestore¶
- Neue Collection:
statisticsArticleSpecificmit Dokumenten{articleId}_{periodKey}
10. Offene Fragen / Entscheidungen¶
-
Varianten ohne ID: Sollen Bestellpositionen ohne
articleVariantIdunter "Ohne Variante" zusammengefasst oder ignoriert werden?
Empfehlung: Zusammenfassen unter "Ohne Variante" – so gehen keine Umsätze verloren. -
Chart wiederverwenden:
CustomerSalesVolumeChartist generisch genug, um wiederverwendet zu werden (erwartetstatisticsMap +timePeriod). Alternativ ein generischesSalesVolumeChart-Widget erstellen.
Empfehlung: Direkt wiederverwenden, da identische Datenstruktur. -
Firestore Security Rules: Für
statisticsArticleSpecificmüssen Leserechte analog zustatisticsCustomerSpecificeingerichtet werden.