Files
EM2_ERP/em2rp/lib/models/event_model.dart
ElPoyo b30ae0f10a feat: Sécurisation Firestore, gestion des prix HT/TTC et refactorisation majeure
Cette mise à jour verrouille l'accès direct à Firestore depuis le client pour renforcer la sécurité et introduit une gestion complète des prix HT/TTC dans toute l'application. Elle apporte également des améliorations significatives des permissions, des optimisations de performance et de nouvelles fonctionnalités.

### Sécurité et Backend
- **Firestore Rules :** Ajout de `firestore.rules` qui bloque par défaut tous les accès en lecture/écriture depuis le client. Toutes les opérations de données doivent maintenant passer par les Cloud Functions, renforçant considérablement la sécurité.
- **Index Firestore :** Création d'un fichier `firestore.indexes.json` pour optimiser les requêtes sur la collection `events`.
- **Cloud Functions :** Les fonctions de création/mise à jour d'événements ont été adaptées pour accepter des ID de documents (utilisateurs, type d'événement) et les convertir en `DocumentReference` côté serveur, simplifiant les appels depuis le client.

### Gestion des Prix HT/TTC
- **Calcul Automatisé :** Introduction d'un helper `PriceHelpers` et d'un widget `PriceHtTtcFields` pour calculer et synchroniser automatiquement les prix HT et TTC dans le formulaire d'événement.
- **Affichage Détaillé :**
    - Les détails des événements et des options affichent désormais les prix HT, la TVA et le TTC séparément pour plus de clarté.
    - Le prix de base (`basePrice`) est maintenant traité comme un prix TTC dans toute l'application.

### Permissions et Rôles
- **Centralisation (`AppPermission`) :** Création d'une énumération `AppPermission` pour centraliser toutes les permissions de l'application, avec descriptions et catégories.
- **Rôles Prédéfinis :** Définition de rôles standards (Admin, Manager, Technicien, User) avec des jeux de permissions prédéfinis.
- **Filtre par Utilisateur :** Ajout d'un filtre par utilisateur sur la page Calendrier, visible uniquement pour les utilisateurs ayant la permission `view_all_user_events`.

### Améliorations et Optimisations (Frontend)
- **`DebugLog` :** Ajout d'un utilitaire `DebugLog` pour gérer les logs, qui sont automatiquement désactivés en mode production.
- **Optimisation du Sélecteur d'Équipement :**
    - La boîte de dialogue de sélection d'équipement a été lourdement optimisée pour éviter les reconstructions complètes de la liste lors de la sélection/désélection d'items.
    - Utilisation de `ValueNotifier` et de caches locaux (`_cachedContainers`, `_cachedEquipment`) pour des mises à jour d'UI plus ciblées et fluides.
    - La position du scroll est désormais préservée.
- **Catégorie d'Équipement :** Ajout de la catégorie `Vehicle` (Véhicule) pour les équipements.
- **Formulaires :** Les formulaires de création/modification d'événements et d'équipements ont été nettoyés de leurs logs de débogage excessifs.
2026-01-14 17:32:58 +01:00

576 lines
18 KiB
Dart

import 'package:cloud_firestore/cloud_firestore.dart';
enum EventStatus {
confirmed,
canceled,
waitingForApproval,
}
String eventStatusToString(EventStatus status) {
switch (status) {
case EventStatus.confirmed:
return 'CONFIRMED';
case EventStatus.canceled:
return 'CANCELED';
case EventStatus.waitingForApproval:
return 'WAITING_FOR_APPROVAL';
}
}
EventStatus eventStatusFromString(String? status) {
switch (status) {
case 'CONFIRMED':
return EventStatus.confirmed;
case 'CANCELED':
return EventStatus.canceled;
case 'WAITING_FOR_APPROVAL':
default:
return EventStatus.waitingForApproval;
}
}
enum PreparationStatus {
notStarted,
inProgress,
completed,
completedWithMissing
}
String preparationStatusToString(PreparationStatus status) {
switch (status) {
case PreparationStatus.notStarted:
return 'NOT_STARTED';
case PreparationStatus.inProgress:
return 'IN_PROGRESS';
case PreparationStatus.completed:
return 'COMPLETED';
case PreparationStatus.completedWithMissing:
return 'COMPLETED_WITH_MISSING';
}
}
PreparationStatus preparationStatusFromString(String? status) {
switch (status) {
case 'NOT_STARTED':
return PreparationStatus.notStarted;
case 'IN_PROGRESS':
return PreparationStatus.inProgress;
case 'COMPLETED':
return PreparationStatus.completed;
case 'COMPLETED_WITH_MISSING':
return PreparationStatus.completedWithMissing;
default:
return PreparationStatus.notStarted;
}
}
// Statut de chargement (loading)
enum LoadingStatus {
notStarted,
inProgress,
completed,
completedWithMissing
}
String loadingStatusToString(LoadingStatus status) {
switch (status) {
case LoadingStatus.notStarted:
return 'NOT_STARTED';
case LoadingStatus.inProgress:
return 'IN_PROGRESS';
case LoadingStatus.completed:
return 'COMPLETED';
case LoadingStatus.completedWithMissing:
return 'COMPLETED_WITH_MISSING';
}
}
LoadingStatus loadingStatusFromString(String? status) {
switch (status) {
case 'NOT_STARTED':
return LoadingStatus.notStarted;
case 'IN_PROGRESS':
return LoadingStatus.inProgress;
case 'COMPLETED':
return LoadingStatus.completed;
case 'COMPLETED_WITH_MISSING':
return LoadingStatus.completedWithMissing;
default:
return LoadingStatus.notStarted;
}
}
// Statut de déchargement (unloading)
enum UnloadingStatus {
notStarted,
inProgress,
completed,
completedWithMissing
}
String unloadingStatusToString(UnloadingStatus status) {
switch (status) {
case UnloadingStatus.notStarted:
return 'NOT_STARTED';
case UnloadingStatus.inProgress:
return 'IN_PROGRESS';
case UnloadingStatus.completed:
return 'COMPLETED';
case UnloadingStatus.completedWithMissing:
return 'COMPLETED_WITH_MISSING';
}
}
UnloadingStatus unloadingStatusFromString(String? status) {
switch (status) {
case 'NOT_STARTED':
return UnloadingStatus.notStarted;
case 'IN_PROGRESS':
return UnloadingStatus.inProgress;
case 'COMPLETED':
return UnloadingStatus.completed;
case 'COMPLETED_WITH_MISSING':
return UnloadingStatus.completedWithMissing;
default:
return UnloadingStatus.notStarted;
}
}
enum ReturnStatus {
notStarted,
inProgress,
completed,
completedWithMissing
}
String returnStatusToString(ReturnStatus status) {
switch (status) {
case ReturnStatus.notStarted:
return 'NOT_STARTED';
case ReturnStatus.inProgress:
return 'IN_PROGRESS';
case ReturnStatus.completed:
return 'COMPLETED';
case ReturnStatus.completedWithMissing:
return 'COMPLETED_WITH_MISSING';
}
}
ReturnStatus returnStatusFromString(String? status) {
switch (status) {
case 'NOT_STARTED':
return ReturnStatus.notStarted;
case 'IN_PROGRESS':
return ReturnStatus.inProgress;
case 'COMPLETED':
return ReturnStatus.completed;
case 'COMPLETED_WITH_MISSING':
return ReturnStatus.completedWithMissing;
default:
return ReturnStatus.notStarted;
}
}
class EventEquipment {
final String equipmentId; // ID de l'équipement
final int quantity; // Quantité (pour consommables)
final bool isPrepared; // Validé en préparation
final bool isLoaded; // Validé au chargement
final bool isUnloaded; // Validé au déchargement
final bool isReturned; // Validé au retour
final int? returnedQuantity; // Quantité retournée (pour consommables)
EventEquipment({
required this.equipmentId,
this.quantity = 1,
this.isPrepared = false,
this.isLoaded = false,
this.isUnloaded = false,
this.isReturned = false,
this.returnedQuantity,
});
factory EventEquipment.fromMap(Map<String, dynamic> map) {
return EventEquipment(
equipmentId: map['equipmentId'] ?? '',
quantity: map['quantity'] ?? 1,
isPrepared: map['isPrepared'] ?? false,
isLoaded: map['isLoaded'] ?? false,
isUnloaded: map['isUnloaded'] ?? false,
isReturned: map['isReturned'] ?? false,
returnedQuantity: map['returnedQuantity'],
);
}
Map<String, dynamic> toMap() {
return {
'equipmentId': equipmentId,
'quantity': quantity,
'isPrepared': isPrepared,
'isLoaded': isLoaded,
'isUnloaded': isUnloaded,
'isReturned': isReturned,
'returnedQuantity': returnedQuantity,
};
}
EventEquipment copyWith({
String? equipmentId,
int? quantity,
bool? isPrepared,
bool? isLoaded,
bool? isUnloaded,
bool? isReturned,
int? returnedQuantity,
}) {
return EventEquipment(
equipmentId: equipmentId ?? this.equipmentId,
quantity: quantity ?? this.quantity,
isPrepared: isPrepared ?? this.isPrepared,
isLoaded: isLoaded ?? this.isLoaded,
isUnloaded: isUnloaded ?? this.isUnloaded,
isReturned: isReturned ?? this.isReturned,
returnedQuantity: returnedQuantity ?? this.returnedQuantity,
);
}
}
class EventModel {
final String id;
final String name;
final String description;
final DateTime startDateTime;
final DateTime endDateTime;
final double basePrice;
final int installationTime;
final int disassemblyTime;
final String eventTypeId;
final DocumentReference? eventTypeRef;
final String customerId;
final String address;
final double latitude;
final double longitude;
final List<dynamic> workforce; // Peut contenir DocumentReference OU String (UIDs)
final List<Map<String, String>> documents;
final List<Map<String, dynamic>> options;
final EventStatus status;
// Champs de contact
final int? jauge;
final String? contactEmail;
final String? contactPhone;
// Nouveaux champs pour la gestion du matériel
final List<EventEquipment> assignedEquipment;
final List<String> assignedContainers; // IDs des conteneurs assignés
final PreparationStatus? preparationStatus;
final LoadingStatus? loadingStatus;
final UnloadingStatus? unloadingStatus;
final ReturnStatus? returnStatus;
EventModel({
required this.id,
required this.name,
required this.description,
required this.startDateTime,
required this.endDateTime,
required this.basePrice,
required this.installationTime,
required this.disassemblyTime,
required this.eventTypeId,
this.eventTypeRef,
required this.customerId,
required this.address,
required this.latitude,
required this.longitude,
required this.workforce,
required this.documents,
this.options = const [],
this.status = EventStatus.waitingForApproval,
this.jauge,
this.contactEmail,
this.contactPhone,
this.assignedEquipment = const [],
this.assignedContainers = const [],
this.preparationStatus,
this.loadingStatus,
this.unloadingStatus,
this.returnStatus,
});
factory EventModel.fromMap(Map<String, dynamic> map, String id) {
try {
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
DateTime _parseDate(dynamic value, DateTime defaultValue) {
if (value == null) return defaultValue;
if (value is Timestamp) return value.toDate();
if (value is String) return DateTime.tryParse(value) ?? defaultValue;
return defaultValue;
}
// Gestion sécurisée des références workforce
final List<dynamic> workforceRefs = map['workforce'] ?? [];
final List<dynamic> safeWorkforce = [];
for (var ref in workforceRefs) {
if (ref is DocumentReference) {
safeWorkforce.add(ref);
} else if (ref is String) {
// Accepter directement les UIDs (envoyés par le backend)
safeWorkforce.add(ref);
} else {
print('Warning: Invalid workforce reference in event $id: $ref');
}
}
// Gestion sécurisée des timestamps avec support ISO string
final DateTime startDate = _parseDate(map['StartDateTime'], DateTime.now());
final DateTime endDate = _parseDate(map['EndDateTime'], startDate.add(const Duration(hours: 1)));
// Gestion sécurisée des documents
final docsRaw = map['documents'] ?? [];
final List<Map<String, String>> docs = [];
if (docsRaw is List) {
for (var e in docsRaw) {
try {
if (e is Map) {
docs.add(Map<String, String>.from(e));
} else if (e is String) {
final fileName = Uri.decodeComponent(
e.split('/').last.split('?').first,
);
docs.add({'name': fileName, 'url': e});
}
} catch (docError) {
print('Warning: Failed to parse document in event $id: $docError');
}
}
}
// Gestion sécurisée des options
final optionsRaw = map['options'] ?? [];
final List<Map<String, dynamic>> options = [];
if (optionsRaw is List) {
for (var e in optionsRaw) {
try {
if (e is Map) {
options.add(Map<String, dynamic>.from(e));
}
} catch (optionError) {
print('Warning: Failed to parse option in event $id: $optionError');
}
}
}
// Gestion sécurisée de l'EventType
String eventTypeId = '';
DocumentReference? eventTypeRef;
if (map['EventType'] is DocumentReference) {
eventTypeRef = map['EventType'] as DocumentReference;
eventTypeId = eventTypeRef.id;
} else if (map['EventType'] is String) {
final eventTypeString = map['EventType'] as String;
// Si c'est un path (ex: "eventTypes/Mariage"), extraire juste l'ID
if (eventTypeString.contains('/')) {
eventTypeId = eventTypeString.split('/').last;
} else {
eventTypeId = eventTypeString;
}
}
// Gestion sécurisée du customer
String customerId = '';
if (map['customer'] is DocumentReference) {
customerId = (map['customer'] as DocumentReference).id;
} else if (map['customer'] is String) {
final customerString = map['customer'] as String;
// Si c'est un path (ex: "clients/abc123"), extraire juste l'ID
if (customerString.contains('/')) {
customerId = customerString.split('/').last;
} else {
customerId = customerString;
}
}
// Gestion des équipements assignés
final assignedEquipmentRaw = map['assignedEquipment'] ?? [];
final List<EventEquipment> assignedEquipment = [];
if (assignedEquipmentRaw is List) {
for (var e in assignedEquipmentRaw) {
try {
if (e is Map) {
assignedEquipment.add(EventEquipment.fromMap(Map<String, dynamic>.from(e)));
}
} catch (equipmentError) {
print('Warning: Failed to parse equipment in event $id: $equipmentError');
}
}
}
// Gestion des conteneurs assignés
final assignedContainersRaw = map['assignedContainers'] ?? [];
final List<String> assignedContainers = [];
if (assignedContainersRaw is List) {
for (var e in assignedContainersRaw) {
if (e is String) {
assignedContainers.add(e);
}
}
}
return EventModel(
id: id,
name: (map['Name'] ?? '').toString().trim(),
description: (map['Description'] ?? '').toString(),
startDateTime: startDate,
endDateTime: endDate,
basePrice: _parseDouble(map['BasePrice'] ?? map['Price'] ?? 0.0),
installationTime: _parseInt(map['InstallationTime'] ?? 0),
assignedContainers: assignedContainers,
disassemblyTime: _parseInt(map['DisassemblyTime'] ?? 0),
eventTypeId: eventTypeId,
eventTypeRef: eventTypeRef,
customerId: customerId,
address: (map['Address'] ?? '').toString(),
latitude: _parseDouble(map['Latitude'] ?? 0.0),
longitude: _parseDouble(map['Longitude'] ?? 0.0),
workforce: safeWorkforce,
documents: docs,
options: options,
status: eventStatusFromString(map['status'] as String?),
jauge: map['jauge'] != null ? _parseInt(map['jauge']) : null,
contactEmail: map['contactEmail']?.toString(),
contactPhone: map['contactPhone']?.toString(),
assignedEquipment: assignedEquipment,
preparationStatus: preparationStatusFromString(map['preparationStatus'] as String?),
loadingStatus: loadingStatusFromString(map['loadingStatus'] as String?),
unloadingStatus: unloadingStatusFromString(map['unloadingStatus'] as String?),
returnStatus: returnStatusFromString(map['returnStatus'] as String?),
);
} catch (e) {
print('Error parsing event $id: $e');
print('Event data: $map');
rethrow;
}
}
// Méthodes utilitaires pour le parsing sécurisé
static double _parseDouble(dynamic value) {
if (value is double) return value;
if (value is int) return value.toDouble();
if (value is String) {
final parsed = double.tryParse(value);
if (parsed != null) return parsed;
}
return 0.0;
}
static int _parseInt(dynamic value) {
if (value is int) return value;
if (value is double) return value.toInt();
if (value is String) {
final parsed = int.tryParse(value);
if (parsed != null) return parsed;
}
return 0;
}
Map<String, dynamic> toMap() {
return {
'Name': name,
'Description': description,
'StartDateTime': Timestamp.fromDate(startDateTime),
'EndDateTime': Timestamp.fromDate(endDateTime),
'BasePrice': basePrice,
'InstallationTime': installationTime,
'DisassemblyTime': disassemblyTime,
// Envoyer l'ID au lieu de DocumentReference pour compatibilité Cloud Functions
'EventType': eventTypeId.isNotEmpty ? eventTypeId : null,
// Envoyer l'ID au lieu de DocumentReference pour compatibilité Cloud Functions
'customer': customerId.isNotEmpty ? customerId : null,
'Address': address,
'Position': GeoPoint(latitude, longitude),
'Latitude': latitude,
'Longitude': longitude,
'workforce': workforce,
'documents': documents,
'options': options,
'status': eventStatusToString(status),
'jauge': jauge,
'contactEmail': contactEmail,
'contactPhone': contactPhone,
'assignedEquipment': assignedEquipment.map((e) => e.toMap()).toList(),
'assignedContainers': assignedContainers,
'preparationStatus': preparationStatus != null ? preparationStatusToString(preparationStatus!) : null,
'loadingStatus': loadingStatus != null ? loadingStatusToString(loadingStatus!) : null,
'unloadingStatus': unloadingStatus != null ? unloadingStatusToString(unloadingStatus!) : null,
'returnStatus': returnStatus != null ? returnStatusToString(returnStatus!) : null,
};
}
EventModel copyWith({
String? id,
String? name,
String? description,
DateTime? startDateTime,
DateTime? endDateTime,
double? basePrice,
int? installationTime,
int? disassemblyTime,
String? eventTypeId,
DocumentReference? eventTypeRef,
String? customerId,
String? address,
double? latitude,
double? longitude,
List<dynamic>? workforce,
List<Map<String, String>>? documents,
List<Map<String, dynamic>>? options,
EventStatus? status,
int? jauge,
String? contactEmail,
String? contactPhone,
List<EventEquipment>? assignedEquipment,
List<String>? assignedContainers,
PreparationStatus? preparationStatus,
LoadingStatus? loadingStatus,
UnloadingStatus? unloadingStatus,
ReturnStatus? returnStatus,
}) {
return EventModel(
id: id ?? this.id,
name: name ?? this.name,
description: description ?? this.description,
startDateTime: startDateTime ?? this.startDateTime,
endDateTime: endDateTime ?? this.endDateTime,
basePrice: basePrice ?? this.basePrice,
installationTime: installationTime ?? this.installationTime,
disassemblyTime: disassemblyTime ?? this.disassemblyTime,
eventTypeId: eventTypeId ?? this.eventTypeId,
eventTypeRef: eventTypeRef ?? this.eventTypeRef,
customerId: customerId ?? this.customerId,
address: address ?? this.address,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
workforce: workforce ?? this.workforce,
documents: documents ?? this.documents,
options: options ?? this.options,
status: status ?? this.status,
jauge: jauge ?? this.jauge,
contactEmail: contactEmail ?? this.contactEmail,
contactPhone: contactPhone ?? this.contactPhone,
assignedEquipment: assignedEquipment ?? this.assignedEquipment,
assignedContainers: assignedContainers ?? this.assignedContainers,
preparationStatus: preparationStatus ?? this.preparationStatus,
loadingStatus: loadingStatus ?? this.loadingStatus,
unloadingStatus: unloadingStatus ?? this.unloadingStatus,
returnStatus: returnStatus ?? this.returnStatus,
);
}
}