feat: Mise à jour à la version 1.1.20 et amélioration de la recherche d'équipements
- Mise à jour de la version de l'application à `1.1.20` dans `app_version.dart`, `version.json` et `CHANGELOG.md`.
- Optimisation de la fonction Cloud `getEquipmentsPaginated` pour supporter la recherche par ID exact (document ID ou ID legacy) et améliorer la recherche textuelle avec filtrage par lots.
- Amélioration de la gestion des alertes dans `processEquipmentValidation.js` :
- Ajout d'un statut `NOT_TAKEN` pour éviter les fausses alertes d'équipements perdus s'ils n'ont jamais été emportés.
- Refonte complète du parsing des dates Firestore pour une meilleure robustesse dans les alertes.
- Correction de la validation des quantités (vérification du type `number`).
- Ajout de méthodes statiques dans `EventPreparationService` (`shouldMarkEquipmentAsLost`, `isEquipmentNotTakenToEvent`) pour centraliser la logique de détermination du statut des équipements au retour.
- Mise à jour de `EventPreparationPage` pour intégrer le nouveau statut `NOT_TAKEN` et utiliser la logique centralisée du service de préparation.
- Mise à jour des fichiers de cache Firebase Hosting.
This commit is contained in:
+187
-38
@@ -3825,18 +3825,97 @@ exports.getEquipmentsPaginated = onRequest(httpOptions, withCors(async (req, res
|
||||
// Convertir en majuscules pour correspondre au format Firestore
|
||||
const category = params.category ? params.category.toUpperCase() : null;
|
||||
const status = params.status ? params.status.toUpperCase() : null;
|
||||
const searchQuery = params.searchQuery?.toLowerCase() || null;
|
||||
const rawSearchQuery = typeof params.searchQuery === 'string' ? params.searchQuery.trim() : '';
|
||||
const searchQuery = rawSearchQuery ? rawSearchQuery.toLowerCase() : null;
|
||||
const compactSearchQuery = searchQuery ? searchQuery.replace(/[\s_-]+/g, '') : null;
|
||||
const sortBy = params.sortBy || 'id';
|
||||
const sortOrder = params.sortOrder === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
logger.info(`[getEquipmentsPaginated] Params: limit=${limit}, startAfter=${startAfterId}, category=${category}, status=${status}, search=${searchQuery}`);
|
||||
|
||||
// Fast-path pour une recherche d'ID exact: évite le cap queryLimit lors d'une recherche précise.
|
||||
if (searchQuery && !startAfterId) {
|
||||
const exactIdCandidates = Array.from(new Set([
|
||||
rawSearchQuery,
|
||||
rawSearchQuery.toUpperCase(),
|
||||
rawSearchQuery.toLowerCase()
|
||||
].filter(Boolean)));
|
||||
|
||||
for (const candidateId of exactIdCandidates) {
|
||||
const exactDoc = await db.collection('equipments').doc(candidateId).get();
|
||||
if (!exactDoc.exists) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const exactData = exactDoc.data() || {};
|
||||
const matchesCategory = !category || exactData.category === category;
|
||||
const matchesStatus = !status || exactData.status === status;
|
||||
if (!matchesCategory || !matchesStatus) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!canManage) {
|
||||
delete exactData.purchasePrice;
|
||||
delete exactData.rentalPrice;
|
||||
}
|
||||
|
||||
const exactEquipment = {
|
||||
...helpers.serializeTimestamps(exactData, ['purchaseDate', 'nextMaintenanceDate', 'lastMaintenanceDate', 'createdAt', 'updatedAt']),
|
||||
id: exactDoc.id
|
||||
};
|
||||
|
||||
logger.info(`[getEquipmentsPaginated] Exact ID hit for ${exactDoc.id}`);
|
||||
res.status(200).json({
|
||||
equipments: [exactEquipment],
|
||||
hasMore: false,
|
||||
lastVisible: exactDoc.id,
|
||||
total: 1
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Compatibilité legacy: certains documents peuvent stocker un ancien champ `id` différent du document ID.
|
||||
for (const legacyId of exactIdCandidates) {
|
||||
let legacyIdQuery = db.collection('equipments').where('id', '==', legacyId);
|
||||
if (category) {
|
||||
legacyIdQuery = legacyIdQuery.where('category', '==', category);
|
||||
}
|
||||
if (status) {
|
||||
legacyIdQuery = legacyIdQuery.where('status', '==', status);
|
||||
}
|
||||
|
||||
const legacySnapshot = await legacyIdQuery.limit(1).get();
|
||||
if (legacySnapshot.empty) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const exactDoc = legacySnapshot.docs[0];
|
||||
const exactData = exactDoc.data() || {};
|
||||
|
||||
if (!canManage) {
|
||||
delete exactData.purchasePrice;
|
||||
delete exactData.rentalPrice;
|
||||
}
|
||||
|
||||
const exactEquipment = {
|
||||
...helpers.serializeTimestamps(exactData, ['purchaseDate', 'nextMaintenanceDate', 'lastMaintenanceDate', 'createdAt', 'updatedAt']),
|
||||
id: exactDoc.id
|
||||
};
|
||||
|
||||
logger.info(`[getEquipmentsPaginated] Exact legacy ID hit for ${legacyId} -> ${exactDoc.id}`);
|
||||
res.status(200).json({
|
||||
equipments: [exactEquipment],
|
||||
hasMore: false,
|
||||
lastVisible: exactDoc.id,
|
||||
total: 1
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Construire la requête Firestore
|
||||
let query = db.collection('equipments');
|
||||
|
||||
// Si recherche textuelle, on augmente la limite pour filtrer ensuite
|
||||
const queryLimit = searchQuery ? Math.min(limit * 10, 200) : limit;
|
||||
|
||||
// Appliquer les filtres
|
||||
if (category) {
|
||||
query = query.where('category', '==', category);
|
||||
@@ -3861,20 +3940,10 @@ exports.getEquipmentsPaginated = onRequest(httpOptions, withCors(async (req, res
|
||||
}
|
||||
}
|
||||
|
||||
// Limiter les résultats
|
||||
query = query.limit(queryLimit + 1);
|
||||
const timestampFields = ['purchaseDate', 'nextMaintenanceDate', 'lastMaintenanceDate', 'createdAt', 'updatedAt'];
|
||||
|
||||
const snapshot = await query.get();
|
||||
|
||||
// Déterminer hasMore basé sur le nombre de documents Firestore
|
||||
const rawDocCount = snapshot.docs.length;
|
||||
const hasMoreDocs = rawDocCount > queryLimit;
|
||||
const docsToProcess = hasMoreDocs ? snapshot.docs.slice(0, queryLimit) : snapshot.docs;
|
||||
|
||||
logger.info(`[getEquipmentsPaginated] Firestore returned ${rawDocCount} docs, hasMore=${hasMoreDocs}`);
|
||||
|
||||
let equipments = docsToProcess.map(doc => {
|
||||
const data = doc.data();
|
||||
const mapEquipmentDoc = (doc) => {
|
||||
const data = {...(doc.data() || {})};
|
||||
|
||||
// Masquer les prix si l'utilisateur n'a pas manage_equipment
|
||||
if (!canManage) {
|
||||
@@ -3882,36 +3951,116 @@ exports.getEquipmentsPaginated = onRequest(httpOptions, withCors(async (req, res
|
||||
delete data.rentalPrice;
|
||||
}
|
||||
|
||||
return {
|
||||
id: doc.id,
|
||||
...helpers.serializeTimestamps(data, ['purchaseDate', 'nextMaintenanceDate', 'lastMaintenanceDate', 'createdAt', 'updatedAt'])
|
||||
};
|
||||
});
|
||||
const legacyId = typeof data.id === 'string' ? data.id : '';
|
||||
|
||||
// Filtrage textuel côté serveur
|
||||
if (searchQuery) {
|
||||
equipments = equipments.filter(eq => {
|
||||
const searchableText = [
|
||||
eq.name || '',
|
||||
eq.id || '',
|
||||
eq.model || '',
|
||||
eq.brand || '',
|
||||
eq.subCategory || ''
|
||||
].join(' ').toLowerCase();
|
||||
return searchableText.includes(searchQuery);
|
||||
return {
|
||||
...helpers.serializeTimestamps(data, timestampFields),
|
||||
id: doc.id,
|
||||
_legacyId: legacyId
|
||||
};
|
||||
};
|
||||
|
||||
const matchesSearchQuery = (equipment) => {
|
||||
const searchableText = [
|
||||
equipment.name || '',
|
||||
equipment.id || '',
|
||||
equipment._legacyId || '',
|
||||
equipment.model || '',
|
||||
equipment.brand || '',
|
||||
equipment.subCategory || ''
|
||||
].join(' ').toLowerCase();
|
||||
|
||||
if (searchableText.includes(searchQuery)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!compactSearchQuery) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const compactSearchableText = searchableText.replace(/[\s_-]+/g, '');
|
||||
return compactSearchableText.includes(compactSearchQuery);
|
||||
};
|
||||
|
||||
if (!searchQuery) {
|
||||
const snapshot = await query.limit(limit + 1).get();
|
||||
const rawDocCount = snapshot.docs.length;
|
||||
const hasMoreDocs = rawDocCount > limit;
|
||||
const docsToProcess = hasMoreDocs ? snapshot.docs.slice(0, limit) : snapshot.docs;
|
||||
|
||||
const limitedEquipments = docsToProcess
|
||||
.map(mapEquipmentDoc)
|
||||
.map(({_legacyId, ...equipment}) => equipment);
|
||||
const lastVisible = limitedEquipments.length > 0 ? limitedEquipments[limitedEquipments.length - 1].id : null;
|
||||
|
||||
logger.info(`[getEquipmentsPaginated] Firestore returned ${rawDocCount} docs, hasMore=${hasMoreDocs}`);
|
||||
logger.info(`[getEquipmentsPaginated] Returning ${limitedEquipments.length} equipments, hasMore=${hasMoreDocs}`);
|
||||
|
||||
res.status(200).json({
|
||||
equipments: limitedEquipments,
|
||||
hasMore: hasMoreDocs,
|
||||
lastVisible,
|
||||
total: limitedEquipments.length
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Pour la limite finale après filtrage textuel
|
||||
const limitedEquipments = equipments.slice(0, limit);
|
||||
// En mode recherche, scanner la collection par lots jusqu'à obtenir `limit + 1` matchs
|
||||
// afin de garantir des résultats même si les documents pertinents sont loin dans l'ordre de tri.
|
||||
const searchBatchSize = Math.min(Math.max(limit * 10, limit), 200);
|
||||
const matchedEquipments = [];
|
||||
let scannedDocuments = 0;
|
||||
let searchQueryRef = query;
|
||||
let hasMoreMatches = false;
|
||||
let hasMoreDocsToScan = true;
|
||||
|
||||
while (hasMoreDocsToScan && !hasMoreMatches) {
|
||||
const snapshot = await searchQueryRef.limit(searchBatchSize).get();
|
||||
|
||||
if (snapshot.empty) {
|
||||
hasMoreDocsToScan = false;
|
||||
break;
|
||||
}
|
||||
|
||||
scannedDocuments += snapshot.docs.length;
|
||||
|
||||
for (const doc of snapshot.docs) {
|
||||
const equipment = mapEquipmentDoc(doc);
|
||||
if (!matchesSearchQuery(equipment)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
matchedEquipments.push(equipment);
|
||||
if (matchedEquipments.length > limit) {
|
||||
hasMoreMatches = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasMoreMatches) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (snapshot.docs.length < searchBatchSize) {
|
||||
hasMoreDocsToScan = false;
|
||||
break;
|
||||
}
|
||||
|
||||
const lastDocInBatch = snapshot.docs[snapshot.docs.length - 1];
|
||||
searchQueryRef = query.startAfter(lastDocInBatch);
|
||||
}
|
||||
|
||||
const limitedEquipments = matchedEquipments
|
||||
.slice(0, limit)
|
||||
.map(({_legacyId, ...equipment}) => equipment);
|
||||
const lastVisible = limitedEquipments.length > 0 ? limitedEquipments[limitedEquipments.length - 1].id : null;
|
||||
|
||||
// hasMore reste basé sur le nombre de docs Firestore, pas sur le filtrage textuel
|
||||
logger.info(`[getEquipmentsPaginated] Returning ${limitedEquipments.length} equipments (filtered from ${equipments.length}), hasMore=${hasMoreDocs}`);
|
||||
logger.info(`[getEquipmentsPaginated] Search scan read ${scannedDocuments} docs and found ${matchedEquipments.length} matches`);
|
||||
logger.info(`[getEquipmentsPaginated] Returning ${limitedEquipments.length} equipments, hasMore=${hasMoreMatches}`);
|
||||
|
||||
res.status(200).json({
|
||||
equipments: limitedEquipments,
|
||||
hasMore: hasMoreDocs,
|
||||
hasMore: hasMoreMatches,
|
||||
lastVisible,
|
||||
total: limitedEquipments.length
|
||||
});
|
||||
|
||||
@@ -50,6 +50,11 @@ exports.processEquipmentValidation = onCall({
|
||||
for (const equipment of equipmentList) {
|
||||
const {equipmentId, status, quantity, expectedQuantity} = equipment;
|
||||
|
||||
// Équipement non emporté: pas d'alerte de perte/manquant au retour.
|
||||
if (status === 'NOT_TAKEN') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Cas 1: Équipement PERDU
|
||||
if (status === 'LOST') {
|
||||
const alertData = await createAlertInFirestore({
|
||||
@@ -91,7 +96,9 @@ exports.processEquipmentValidation = onCall({
|
||||
}
|
||||
|
||||
// Cas 3: Quantité incorrecte
|
||||
if (expectedQuantity && quantity !== expectedQuantity) {
|
||||
const hasExpectedQuantity = typeof expectedQuantity === 'number';
|
||||
const hasActualQuantity = typeof quantity === 'number';
|
||||
if (hasExpectedQuantity && hasActualQuantity && quantity !== expectedQuantity) {
|
||||
const alertData = await createAlertInFirestore({
|
||||
type: 'QUANTITY_MISMATCH',
|
||||
severity: 'INFO',
|
||||
@@ -409,10 +416,48 @@ async function sendAlertEmails(alert, userIds) {
|
||||
* Formate la date d'un événement
|
||||
*/
|
||||
function formatEventDate(event) {
|
||||
if (event.startDate) {
|
||||
const date = event.startDate.toDate ? event.startDate.toDate() : new Date(event.startDate);
|
||||
return date.toLocaleDateString('fr-FR', {day: 'numeric', month: 'numeric', year: 'numeric'});
|
||||
}
|
||||
return 'Date inconnue';
|
||||
const rawDate =
|
||||
event?.StartDateTime ||
|
||||
event?.startDateTime ||
|
||||
event?.startDate ||
|
||||
event?.eventDate;
|
||||
|
||||
const parsedDate = parseFirestoreDate(rawDate);
|
||||
const safeDate = parsedDate || new Date();
|
||||
|
||||
return safeDate.toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function parseFirestoreDate(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value.toDate === 'function') {
|
||||
return value.toDate();
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && typeof value.seconds === 'number') {
|
||||
return new Date(value.seconds * 1000);
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && typeof value._seconds === 'number') {
|
||||
return new Date(value._seconds * 1000);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user