- 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.
281 lines
8.0 KiB
Dart
281 lines
8.0 KiB
Dart
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,
|
|
});
|
|
}
|