feat: mise à jour v1.1.17 et ajout du tableau de bord des statistiques d'événements
- Mise à jour de la version de l'application à `1.1.17` dans `app_version.dart` et `version.json`. - Création d'un module complet de statistiques (`EventStatisticsPage`, `EventStatisticsService`, `EventStatisticsTab`) permettant de filtrer et visualiser les KPI d'événements (montants HT/TTC, panier moyen, répartition par type, top options). - Ajout d'une entrée "Statistiques événements" dans le menu latéral (`MainDrawer`) protégée par la permission `generate_reports`. - Migration exclusive vers Google Cloud TTS dans `SmartTextToSpeechService` et suppression de `TextToSpeechService` (Web Speech API native) pour garantir une compatibilité maximale sur tous les navigateurs. - Mise à jour des dépendances dans `pubspec.yaml` (`google_fonts`, `flutter_secure_storage`, `mobile_scanner`, `flutter_local_notifications`). - Migration du code d'export ICS vers `package:web` pour remplacer l'utilisation de `dart:html` obsolète. - Mise à jour du `CHANGELOG.md` documentant les statistiques et l'évolution du service de synthèse vocale.
This commit is contained in:
280
em2rp/lib/services/event_statistics_service.dart
Normal file
280
em2rp/lib/services/event_statistics_service.dart
Normal file
@@ -0,0 +1,280 @@
|
||||
import 'package:em2rp/models/event_model.dart';
|
||||
import 'package:em2rp/models/event_statistics_models.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class EventStatisticsService {
|
||||
const EventStatisticsService();
|
||||
|
||||
static const double _taxRatio = 1.2;
|
||||
|
||||
EventStatisticsSummary buildSummary({
|
||||
required List<EventModel> events,
|
||||
required EventStatisticsFilter filter,
|
||||
required Map<String, String> eventTypeNames,
|
||||
}) {
|
||||
final filteredEvents =
|
||||
events.where((event) => _matchesFilter(event, filter)).toList();
|
||||
|
||||
if (filteredEvents.isEmpty) {
|
||||
return EventStatisticsSummary.empty;
|
||||
}
|
||||
|
||||
var validatedEvents = 0;
|
||||
var pendingEvents = 0;
|
||||
var canceledEvents = 0;
|
||||
|
||||
var validatedAmount = 0.0;
|
||||
var pendingAmount = 0.0;
|
||||
var canceledAmount = 0.0;
|
||||
|
||||
var baseAmount = 0.0;
|
||||
var optionsAmount = 0.0;
|
||||
|
||||
final eventAmounts = <double>[];
|
||||
final byType = <String, _EventTypeAccumulator>{};
|
||||
final optionStats = <String, _OptionAccumulator>{};
|
||||
|
||||
for (final event in filteredEvents) {
|
||||
final base = _toHtAmount(event.basePrice);
|
||||
final optionTotal = _computeOptionsTotal(event);
|
||||
final amount = base + optionTotal;
|
||||
final isValidated = event.status == EventStatus.confirmed;
|
||||
|
||||
eventAmounts.add(amount);
|
||||
baseAmount += base;
|
||||
optionsAmount += optionTotal;
|
||||
|
||||
switch (event.status) {
|
||||
case EventStatus.confirmed:
|
||||
validatedEvents += 1;
|
||||
validatedAmount += amount;
|
||||
break;
|
||||
case EventStatus.waitingForApproval:
|
||||
pendingEvents += 1;
|
||||
pendingAmount += amount;
|
||||
break;
|
||||
case EventStatus.canceled:
|
||||
canceledEvents += 1;
|
||||
canceledAmount += amount;
|
||||
break;
|
||||
}
|
||||
|
||||
final eventTypeId = event.eventTypeId;
|
||||
final eventTypeName = eventTypeNames[eventTypeId] ?? 'Type inconnu';
|
||||
final typeAccumulator = byType.putIfAbsent(
|
||||
eventTypeId,
|
||||
() => _EventTypeAccumulator(
|
||||
eventTypeId: eventTypeId, eventTypeName: eventTypeName),
|
||||
);
|
||||
typeAccumulator.totalEvents += 1;
|
||||
typeAccumulator.totalAmount += amount;
|
||||
switch (event.status) {
|
||||
case EventStatus.confirmed:
|
||||
typeAccumulator.validatedAmount += amount;
|
||||
break;
|
||||
case EventStatus.waitingForApproval:
|
||||
typeAccumulator.pendingAmount += amount;
|
||||
break;
|
||||
case EventStatus.canceled:
|
||||
typeAccumulator.canceledAmount += amount;
|
||||
break;
|
||||
}
|
||||
|
||||
for (final rawOption in event.options) {
|
||||
final optionPrice = _toHtAmount(_toDouble(rawOption['price']));
|
||||
final optionQuantity = _toInt(rawOption['quantity'], fallback: 1);
|
||||
if (optionPrice == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final optionId = (rawOption['id'] ??
|
||||
rawOption['code'] ??
|
||||
rawOption['name'] ??
|
||||
'option')
|
||||
.toString();
|
||||
final optionLabel = _buildOptionLabel(rawOption, optionId);
|
||||
final optionAmount = optionPrice * optionQuantity;
|
||||
|
||||
final optionAccumulator = optionStats.putIfAbsent(
|
||||
optionId,
|
||||
() =>
|
||||
_OptionAccumulator(optionKey: optionId, optionLabel: optionLabel),
|
||||
);
|
||||
optionAccumulator.usageCount += 1;
|
||||
if (isValidated) {
|
||||
optionAccumulator.validatedUsageCount += 1;
|
||||
}
|
||||
optionAccumulator.quantity += optionQuantity;
|
||||
optionAccumulator.totalAmount += optionAmount;
|
||||
}
|
||||
}
|
||||
|
||||
final byEventType = byType.values
|
||||
.map((accumulator) => EventTypeStatistics(
|
||||
eventTypeId: accumulator.eventTypeId,
|
||||
eventTypeName: accumulator.eventTypeName,
|
||||
totalEvents: accumulator.totalEvents,
|
||||
totalAmount: accumulator.totalAmount,
|
||||
validatedAmount: accumulator.validatedAmount,
|
||||
pendingAmount: accumulator.pendingAmount,
|
||||
canceledAmount: accumulator.canceledAmount,
|
||||
))
|
||||
.toList()
|
||||
..sort((a, b) => b.totalAmount.compareTo(a.totalAmount));
|
||||
|
||||
final topOptions = optionStats.values
|
||||
.map((accumulator) => OptionStatistics(
|
||||
optionKey: accumulator.optionKey,
|
||||
optionLabel: accumulator.optionLabel,
|
||||
usageCount: accumulator.usageCount,
|
||||
validatedUsageCount: accumulator.validatedUsageCount,
|
||||
quantity: accumulator.quantity,
|
||||
totalAmount: accumulator.totalAmount,
|
||||
))
|
||||
.toList()
|
||||
..sort((a, b) {
|
||||
final validatedComparison =
|
||||
b.validatedUsageCount.compareTo(a.validatedUsageCount);
|
||||
if (validatedComparison != 0) {
|
||||
return validatedComparison;
|
||||
}
|
||||
return b.totalAmount.compareTo(a.totalAmount);
|
||||
});
|
||||
|
||||
return EventStatisticsSummary(
|
||||
totalEvents: filteredEvents.length,
|
||||
validatedEvents: validatedEvents,
|
||||
pendingEvents: pendingEvents,
|
||||
canceledEvents: canceledEvents,
|
||||
totalAmount: validatedAmount + pendingAmount + canceledAmount,
|
||||
validatedAmount: validatedAmount,
|
||||
pendingAmount: pendingAmount,
|
||||
canceledAmount: canceledAmount,
|
||||
baseAmount: baseAmount,
|
||||
optionsAmount: optionsAmount,
|
||||
medianAmount: _computeMedian(eventAmounts),
|
||||
byEventType: byEventType,
|
||||
topOptions: topOptions.take(8).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
bool _matchesFilter(EventModel event, EventStatisticsFilter filter) {
|
||||
if (!_overlapsRange(event, filter.period)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!filter.selectedStatuses.contains(event.status)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filter.eventTypeIds.isNotEmpty &&
|
||||
!filter.eventTypeIds.contains(event.eventTypeId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _overlapsRange(EventModel event, DateTimeRange range) {
|
||||
return !event.endDateTime.isBefore(range.start) &&
|
||||
!event.startDateTime.isAfter(range.end);
|
||||
}
|
||||
|
||||
double _computeOptionsTotal(EventModel event) {
|
||||
return event.options.fold<double>(0.0, (sum, option) {
|
||||
final optionPrice = _toHtAmount(_toDouble(option['price']));
|
||||
final optionQuantity = _toInt(option['quantity'], fallback: 1);
|
||||
return sum + (optionPrice * optionQuantity);
|
||||
});
|
||||
}
|
||||
|
||||
double _toHtAmount(double storedAmount) {
|
||||
return storedAmount / _taxRatio;
|
||||
}
|
||||
|
||||
double _toDouble(dynamic value) {
|
||||
if (value == null) {
|
||||
return 0.0;
|
||||
}
|
||||
if (value is num) {
|
||||
return value.toDouble();
|
||||
}
|
||||
return double.tryParse(value.toString()) ?? 0.0;
|
||||
}
|
||||
|
||||
int _toInt(dynamic value, {int fallback = 0}) {
|
||||
if (value == null) {
|
||||
return fallback;
|
||||
}
|
||||
if (value is int) {
|
||||
return value;
|
||||
}
|
||||
if (value is num) {
|
||||
return value.toInt();
|
||||
}
|
||||
return int.tryParse(value.toString()) ?? fallback;
|
||||
}
|
||||
|
||||
String _buildOptionLabel(Map<String, dynamic> option, String fallback) {
|
||||
final code = (option['code'] ?? '').toString().trim();
|
||||
final name = (option['name'] ?? '').toString().trim();
|
||||
|
||||
if (code.isNotEmpty && name.isNotEmpty) {
|
||||
return '$code - $name';
|
||||
}
|
||||
|
||||
if (name.isNotEmpty) {
|
||||
return name;
|
||||
}
|
||||
|
||||
if (code.isNotEmpty) {
|
||||
return code;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
double _computeMedian(List<double> values) {
|
||||
if (values.isEmpty) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
final sorted = [...values]..sort();
|
||||
final middleIndex = sorted.length ~/ 2;
|
||||
|
||||
if (sorted.length.isOdd) {
|
||||
return sorted[middleIndex];
|
||||
}
|
||||
|
||||
return (sorted[middleIndex - 1] + sorted[middleIndex]) / 2;
|
||||
}
|
||||
}
|
||||
|
||||
class _EventTypeAccumulator {
|
||||
final String eventTypeId;
|
||||
final String eventTypeName;
|
||||
int totalEvents = 0;
|
||||
double totalAmount = 0.0;
|
||||
double validatedAmount = 0.0;
|
||||
double pendingAmount = 0.0;
|
||||
double canceledAmount = 0.0;
|
||||
|
||||
_EventTypeAccumulator({
|
||||
required this.eventTypeId,
|
||||
required this.eventTypeName,
|
||||
});
|
||||
}
|
||||
|
||||
class _OptionAccumulator {
|
||||
final String optionKey;
|
||||
final String optionLabel;
|
||||
int usageCount = 0;
|
||||
int validatedUsageCount = 0;
|
||||
int quantity = 0;
|
||||
double totalAmount = 0.0;
|
||||
|
||||
_OptionAccumulator({
|
||||
required this.optionKey,
|
||||
required this.optionLabel,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user