Cette mise à jour introduit la gestion complète des utilisateurs (création, mise à jour, suppression) via des Cloud Functions et optimise de manière significative le chargement des données dans toute l'application.
**Features :**
- **Gestion des utilisateurs (Backend & Frontend) :**
- Ajout des Cloud Functions `getUser`, `updateUser` et `deleteUser` pour gérer les utilisateurs de manière sécurisée, en respectant les permissions des rôles.
- L'authentification passe désormais par `onCall` pour plus de sécurité.
- **Optimisation du chargement des données :**
- Introduction de nouvelles Cloud Functions `getEquipmentsByIds` et `getContainersByIds` pour récupérer uniquement les documents nécessaires, réduisant ainsi la charge sur le client et Firestore.
- Les fournisseurs (`EquipmentProvider`, `ContainerProvider`) ont été refactorisés pour utiliser un chargement à la demande (`ensureLoaded`) et mettre en cache les données récupérées.
- Les écrans de détails et de préparation d'événements n'utilisent plus de `Stream` globaux, mais chargent les équipements et boites spécifiques via ces nouvelles fonctions, améliorant considérablement les performances.
**Refactorisation et Améliorations :**
- **Backend (Cloud Functions) :**
- Le service de vérification de disponibilité (`checkEquipmentAvailability`) est désormais une Cloud Function, déplaçant la logique métier côté serveur.
- La récupération des données (utilisateurs, événements, alertes) a été centralisée derrière des Cloud Functions, remplaçant les appels directs à Firestore depuis le client.
- Amélioration de la sérialisation des données (timestamps, références) dans les réponses des fonctions.
- **Frontend (Flutter) :**
- `LocalUserProvider` charge désormais les informations de l'utilisateur connecté via la fonction `getCurrentUser`, incluant son rôle et ses permissions en un seul appel.
- `AlertProvider` utilise des fonctions pour charger et manipuler les alertes, abandonnant le `Stream` Firestore.
- `EventAvailabilityService` utilise maintenant la Cloud Function `checkEquipmentAvailability` au lieu d'une logique client complexe.
- Correction de la gestion des références de rôles (`roles/ADMIN`) et des `DocumentReference` pour les utilisateurs dans l'ensemble de l'application.
- Le service d'export ICS (`IcsExportService`) a été simplifié, partant du principe que les données nécessaires (utilisateurs, options) sont déjà chargées dans l'application.
420 lines
14 KiB
Dart
420 lines
14 KiB
Dart
import 'package:em2rp/models/event_model.dart';
|
|
import 'package:em2rp/models/equipment_model.dart';
|
|
import 'package:em2rp/models/container_model.dart';
|
|
import 'package:em2rp/services/api_service.dart';
|
|
import 'package:em2rp/services/data_service.dart';
|
|
|
|
/// Type de conflit
|
|
enum ConflictType {
|
|
equipmentUnavailable, // Équipement non quantifiable utilisé
|
|
insufficientQuantity, // Quantité insuffisante pour consommable/câble
|
|
containerFullyUsed, // Boîte complète utilisée
|
|
containerPartiallyUsed, // Certains équipements de la boîte utilisés
|
|
}
|
|
|
|
/// Informations sur un conflit de disponibilité
|
|
class AvailabilityConflict {
|
|
final String equipmentId;
|
|
final String equipmentName;
|
|
final EventModel conflictingEvent;
|
|
final int overlapDays;
|
|
final ConflictType type;
|
|
|
|
// Pour les quantités (consommables/câbles)
|
|
final int? totalQuantity;
|
|
final int? availableQuantity;
|
|
final int? requestedQuantity;
|
|
final int? reservedQuantity;
|
|
|
|
// Pour les boîtes
|
|
final String? containerId;
|
|
final String? containerName;
|
|
final List<String>? conflictingChildrenIds;
|
|
|
|
AvailabilityConflict({
|
|
required this.equipmentId,
|
|
required this.equipmentName,
|
|
required this.conflictingEvent,
|
|
required this.overlapDays,
|
|
this.type = ConflictType.equipmentUnavailable,
|
|
this.totalQuantity,
|
|
this.availableQuantity,
|
|
this.requestedQuantity,
|
|
this.reservedQuantity,
|
|
this.containerId,
|
|
this.containerName,
|
|
this.conflictingChildrenIds,
|
|
});
|
|
|
|
/// Message descriptif du conflit
|
|
String get conflictMessage {
|
|
switch (type) {
|
|
case ConflictType.equipmentUnavailable:
|
|
return 'Équipement déjà utilisé';
|
|
case ConflictType.insufficientQuantity:
|
|
return 'Stock insuffisant : $availableQuantity/$totalQuantity disponible (demandé: $requestedQuantity)';
|
|
case ConflictType.containerFullyUsed:
|
|
return 'Boîte complète déjà utilisée';
|
|
case ConflictType.containerPartiallyUsed:
|
|
final count = conflictingChildrenIds?.length ?? 0;
|
|
return '$count équipement(s) de la boîte déjà utilisé(s)';
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Service pour vérifier la disponibilité du matériel
|
|
class EventAvailabilityService {
|
|
final DataService _dataService = DataService(apiService);
|
|
|
|
/// Helper pour récupérer uniquement la liste d'événements
|
|
Future<List<Map<String, dynamic>>> _getEventsList() async {
|
|
final result = await _dataService.getEvents();
|
|
final events = result['events'] as List<dynamic>? ?? [];
|
|
return events.map((e) => e as Map<String, dynamic>).toList();
|
|
}
|
|
|
|
/// Vérifie si un équipement est disponible pour une plage de dates via Cloud Function
|
|
Future<List<AvailabilityConflict>> checkEquipmentAvailability({
|
|
required String equipmentId,
|
|
required String equipmentName,
|
|
required DateTime startDate,
|
|
required DateTime endDate,
|
|
String? excludeEventId, // Pour exclure l'événement en cours d'édition
|
|
}) async {
|
|
final conflicts = <AvailabilityConflict>[];
|
|
|
|
try {
|
|
// Utiliser la Cloud Function pour vérifier la disponibilité
|
|
final result = await _dataService.checkEquipmentAvailability(
|
|
equipmentId: equipmentId,
|
|
startDate: startDate,
|
|
endDate: endDate,
|
|
excludeEventId: excludeEventId,
|
|
);
|
|
|
|
final available = result['available'] as bool? ?? true;
|
|
if (!available) {
|
|
final conflictsData = result['conflicts'] as List<dynamic>? ?? [];
|
|
|
|
// Récupérer les détails des événements en conflit
|
|
final eventsData = await _getEventsList();
|
|
|
|
for (final conflictData in conflictsData) {
|
|
final conflict = conflictData as Map<String, dynamic>;
|
|
final eventId = conflict['eventId'] as String;
|
|
|
|
// Trouver l'événement correspondant
|
|
final eventData = eventsData.firstWhere(
|
|
(e) => e['id'] == eventId,
|
|
orElse: () => <String, dynamic>{},
|
|
);
|
|
|
|
if (eventData.isNotEmpty) {
|
|
try {
|
|
final event = EventModel.fromMap(eventData, eventId);
|
|
conflicts.add(AvailabilityConflict(
|
|
equipmentId: equipmentId,
|
|
equipmentName: equipmentName,
|
|
conflictingEvent: event,
|
|
overlapDays: conflict['overlapDays'] as int? ?? 0,
|
|
));
|
|
} catch (e) {
|
|
print('[EventAvailabilityService] Error creating EventModel: $e');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
print('[EventAvailabilityService] Error checking availability: $e');
|
|
}
|
|
|
|
return conflicts;
|
|
}
|
|
|
|
/// Vérifie la disponibilité pour une liste d'équipements
|
|
Future<Map<String, List<AvailabilityConflict>>> checkMultipleEquipmentAvailability({
|
|
required List<String> equipmentIds,
|
|
required Map<String, String> equipmentNames,
|
|
required DateTime startDate,
|
|
required DateTime endDate,
|
|
String? excludeEventId,
|
|
}) async {
|
|
final allConflicts = <String, List<AvailabilityConflict>>{};
|
|
|
|
for (var equipmentId in equipmentIds) {
|
|
final conflicts = await checkEquipmentAvailability(
|
|
equipmentId: equipmentId,
|
|
equipmentName: equipmentNames[equipmentId] ?? equipmentId,
|
|
startDate: startDate,
|
|
endDate: endDate,
|
|
excludeEventId: excludeEventId,
|
|
);
|
|
|
|
if (conflicts.isNotEmpty) {
|
|
allConflicts[equipmentId] = conflicts;
|
|
}
|
|
}
|
|
|
|
|
|
return allConflicts;
|
|
}
|
|
|
|
/// Vérifie si deux plages de dates se chevauchent
|
|
bool _datesOverlap(DateTime start1, DateTime end1, DateTime start2, DateTime end2) {
|
|
// Deux plages se chevauchent si elles ne sont PAS complètement séparées
|
|
// Elles sont séparées si : end1 < start2 OU end2 < start1
|
|
// Donc elles se chevauchent si : NOT (end1 < start2 OU end2 < start1)
|
|
// Équivalent à : end1 >= start2 ET end2 >= start1
|
|
return !end1.isBefore(start2) && !end2.isBefore(start1);
|
|
}
|
|
|
|
/// Calcule le nombre de jours de chevauchement
|
|
int _calculateOverlapDays(DateTime start1, DateTime end1, DateTime start2, DateTime end2) {
|
|
final overlapStart = start1.isAfter(start2) ? start1 : start2;
|
|
final overlapEnd = end1.isBefore(end2) ? end1 : end2;
|
|
|
|
return overlapEnd.difference(overlapStart).inDays + 1;
|
|
}
|
|
|
|
/// Récupère la quantité disponible pour un consommable/câble
|
|
Future<int> getAvailableQuantity({
|
|
required EquipmentModel equipment,
|
|
required DateTime startDate,
|
|
required DateTime endDate,
|
|
String? excludeEventId,
|
|
}) async {
|
|
if (!equipment.hasQuantity) {
|
|
return 1; // Équipement non consommable
|
|
}
|
|
|
|
final totalQuantity = equipment.totalQuantity ?? 0;
|
|
int reservedQuantity = 0;
|
|
|
|
try {
|
|
// Récupérer tous les événements via Cloud Function
|
|
final eventsData = await _getEventsList();
|
|
|
|
for (var eventData in eventsData) {
|
|
final eventId = eventData['id'] as String;
|
|
if (excludeEventId != null && eventId == excludeEventId) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
final event = EventModel.fromMap(eventData, eventId);
|
|
|
|
// Ignorer les événements annulés
|
|
if (event.status == EventStatus.canceled) {
|
|
continue;
|
|
}
|
|
|
|
// Calculer les dates réelles avec temps d'installation et démontage
|
|
final eventRealStartDate = event.startDateTime.subtract(
|
|
Duration(hours: event.installationTime),
|
|
);
|
|
final eventRealEndDate = event.endDateTime.add(
|
|
Duration(hours: event.disassemblyTime),
|
|
);
|
|
|
|
// Vérifier le chevauchement des dates
|
|
if (_datesOverlap(startDate, endDate, eventRealStartDate, eventRealEndDate)) {
|
|
final assignedEquipment = event.assignedEquipment.firstWhere(
|
|
(eq) => eq.equipmentId == equipment.id,
|
|
orElse: () => EventEquipment(equipmentId: ''),
|
|
);
|
|
|
|
// Si l'équipement est assigné, réserver la quantité
|
|
// (peu importe le statut de préparation/retour)
|
|
if (assignedEquipment.equipmentId.isNotEmpty) {
|
|
reservedQuantity += assignedEquipment.quantity;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
print('[EventAvailabilityService] Error processing event $eventId for quantity: $e');
|
|
}
|
|
}
|
|
} catch (e) {
|
|
print('[EventAvailabilityService] Error getting available quantity: $e');
|
|
}
|
|
|
|
return totalQuantity - reservedQuantity;
|
|
}
|
|
|
|
/// Vérifie la disponibilité d'un équipement avec gestion des quantités
|
|
Future<List<AvailabilityConflict>> checkEquipmentAvailabilityWithQuantity({
|
|
required EquipmentModel equipment,
|
|
required int requestedQuantity,
|
|
required DateTime startDate,
|
|
required DateTime endDate,
|
|
String? excludeEventId,
|
|
}) async {
|
|
final conflicts = <AvailabilityConflict>[];
|
|
|
|
// Si équipement quantifiable (consommable/câble)
|
|
if (equipment.hasQuantity) {
|
|
final totalQuantity = equipment.totalQuantity ?? 0;
|
|
final availableQty = await getAvailableQuantity(
|
|
equipment: equipment,
|
|
startDate: startDate,
|
|
endDate: endDate,
|
|
excludeEventId: excludeEventId,
|
|
);
|
|
final reservedQty = totalQuantity - availableQty;
|
|
|
|
// ✅ Ne créer un conflit que si la quantité est VRAIMENT insuffisante
|
|
if (availableQty < requestedQuantity) {
|
|
// Trouver les événements qui réservent cette quantité
|
|
final eventsData = await _getEventsList();
|
|
|
|
for (var eventData in eventsData) {
|
|
final eventId = eventData['id'] as String;
|
|
if (excludeEventId != null && eventId == excludeEventId) continue;
|
|
|
|
try {
|
|
final event = EventModel.fromMap(eventData, eventId);
|
|
|
|
if (_datesOverlap(startDate, endDate, event.startDateTime, event.endDateTime)) {
|
|
final assignedEquipment = event.assignedEquipment.firstWhere(
|
|
(eq) => eq.equipmentId == equipment.id,
|
|
orElse: () => EventEquipment(equipmentId: ''),
|
|
);
|
|
|
|
if (assignedEquipment.equipmentId.isNotEmpty && !assignedEquipment.isReturned) {
|
|
conflicts.add(AvailabilityConflict(
|
|
equipmentId: equipment.id,
|
|
equipmentName: equipment.name,
|
|
conflictingEvent: event,
|
|
overlapDays: _calculateOverlapDays(startDate, endDate, event.startDateTime, event.endDateTime),
|
|
type: ConflictType.insufficientQuantity,
|
|
totalQuantity: totalQuantity,
|
|
availableQuantity: availableQty,
|
|
requestedQuantity: requestedQuantity,
|
|
reservedQuantity: reservedQty,
|
|
));
|
|
}
|
|
}
|
|
} catch (e) {
|
|
print('[EventAvailabilityService] Error processing event $eventId: $e');
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Équipement non quantifiable : vérification classique
|
|
return await checkEquipmentAvailability(
|
|
equipmentId: equipment.id,
|
|
equipmentName: equipment.name,
|
|
startDate: startDate,
|
|
endDate: endDate,
|
|
excludeEventId: excludeEventId,
|
|
);
|
|
}
|
|
|
|
return conflicts;
|
|
}
|
|
|
|
/// Vérifie la disponibilité d'une boîte et de son contenu
|
|
Future<List<AvailabilityConflict>> checkContainerAvailability({
|
|
required ContainerModel container,
|
|
required List<EquipmentModel> containerEquipment,
|
|
required DateTime startDate,
|
|
required DateTime endDate,
|
|
String? excludeEventId,
|
|
}) async {
|
|
final conflicts = <AvailabilityConflict>[];
|
|
final conflictingChildrenIds = <String>[];
|
|
|
|
// Vérifier d'abord si la boîte complète est utilisée
|
|
final eventsData = await _getEventsList();
|
|
bool isContainerFullyUsed = false;
|
|
EventModel? containerConflictingEvent;
|
|
|
|
for (var eventData in eventsData) {
|
|
final eventId = eventData['id'] as String;
|
|
if (excludeEventId != null && eventId == excludeEventId) continue;
|
|
|
|
try {
|
|
final event = EventModel.fromMap(eventData, eventId);
|
|
|
|
// Ignorer les événements annulés
|
|
if (event.status == EventStatus.canceled) {
|
|
continue;
|
|
}
|
|
|
|
// Calculer les dates réelles avec temps d'installation et démontage
|
|
final eventRealStartDate = event.startDateTime.subtract(
|
|
Duration(hours: event.installationTime),
|
|
);
|
|
final eventRealEndDate = event.endDateTime.add(
|
|
Duration(hours: event.disassemblyTime),
|
|
);
|
|
|
|
// Vérifier si cette boîte est assignée
|
|
if (event.assignedContainers.contains(container.id)) {
|
|
if (_datesOverlap(startDate, endDate, eventRealStartDate, eventRealEndDate)) {
|
|
isContainerFullyUsed = true;
|
|
containerConflictingEvent = event;
|
|
break;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
print('[EventAvailabilityService] Error processing event $eventId: $e');
|
|
}
|
|
}
|
|
|
|
if (isContainerFullyUsed && containerConflictingEvent != null) {
|
|
// Boîte complète utilisée
|
|
conflicts.add(AvailabilityConflict(
|
|
equipmentId: container.id,
|
|
equipmentName: container.name,
|
|
conflictingEvent: containerConflictingEvent,
|
|
overlapDays: _calculateOverlapDays(
|
|
startDate,
|
|
endDate,
|
|
containerConflictingEvent.startDateTime,
|
|
containerConflictingEvent.endDateTime,
|
|
),
|
|
type: ConflictType.containerFullyUsed,
|
|
containerId: container.id,
|
|
containerName: container.name,
|
|
));
|
|
} else {
|
|
// Vérifier chaque équipement enfant individuellement
|
|
for (var equipment in containerEquipment) {
|
|
final equipmentConflicts = await checkEquipmentAvailability(
|
|
equipmentId: equipment.id,
|
|
equipmentName: equipment.name,
|
|
startDate: startDate,
|
|
endDate: endDate,
|
|
excludeEventId: excludeEventId,
|
|
);
|
|
|
|
if (equipmentConflicts.isNotEmpty) {
|
|
conflictingChildrenIds.add(equipment.id);
|
|
conflicts.addAll(equipmentConflicts);
|
|
}
|
|
}
|
|
|
|
// Si au moins un enfant en conflit, ajouter un conflit pour la boîte
|
|
if (conflictingChildrenIds.isNotEmpty && conflicts.isNotEmpty) {
|
|
conflicts.insert(
|
|
0,
|
|
AvailabilityConflict(
|
|
equipmentId: container.id,
|
|
equipmentName: container.name,
|
|
conflictingEvent: conflicts.first.conflictingEvent,
|
|
overlapDays: conflicts.first.overlapDays,
|
|
type: ConflictType.containerPartiallyUsed,
|
|
containerId: container.id,
|
|
containerName: container.name,
|
|
conflictingChildrenIds: conflictingChildrenIds,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
return conflicts;
|
|
}
|
|
}
|
|
|
|
|