diff --git a/em2rp/.gitignore b/em2rp/.gitignore index cd098bd..0e816a0 100644 --- a/em2rp/.gitignore +++ b/em2rp/.gitignore @@ -45,3 +45,4 @@ app.*.map.json # Environment configuration with credentials lib/config/env.dev.dart functions/.env +functions/.env.local \ No newline at end of file diff --git a/em2rp/functions/aiEquipmentProposal.js b/em2rp/functions/aiEquipmentProposal.js index aae337f..b6c068c 100644 --- a/em2rp/functions/aiEquipmentProposal.js +++ b/em2rp/functions/aiEquipmentProposal.js @@ -9,8 +9,16 @@ const { GoogleGenerativeAI } = require('@google/generative-ai'); const admin = require('firebase-admin'); const logger = require('firebase-functions/logger'); -const GEMINI_MODEL = 'gemini-3.1-flash-lite'; //Ne pas changer de versio, celle ci existe -const GEMINI_API_KEY = 'AIzaSyB0hOvBjWeWjdrxVARzfErZ_uGuArlvmQc'; +const GEMINI_MODEL = 'gemini-3.1-flash-lite'; //Ne pas changer de version, celle ci existe +const GEMINI_API_KEY = process.env.GEMINI_API_KEY || process.env.GOOGLE_AI_API_KEY || ''; + +// Log de démarrage pour debug +if (GEMINI_API_KEY) { + const maskedKey = GEMINI_API_KEY.substring(0, 8) + '...' + GEMINI_API_KEY.substring(GEMINI_API_KEY.length - 5); + logger.info(`[AI] GEMINI_API_KEY chargée (masquée: ${maskedKey})`); +} else { + logger.warn('[AI] ⚠️ GEMINI_API_KEY non trouvée ! Vérifiez .env.local ou les variables d\'environnement Firebase'); +} const MAX_TOOL_ITERATIONS = 20; const PAST_EVENTS_LIMIT = 5; const SEARCH_RESULTS_LIMIT = 50; // Augmenté pour permettre de trouver plusieurs unités du même modèle @@ -140,6 +148,32 @@ const AI_TOOLS = [ required: ['equipmentIds'], }, }, + { + name: 'check_container_availability', + description: 'Vérifie si un container (flight case) et son contenu sont disponibles pour une période donnée.', + parameters: { + type: 'object', + properties: { + containerId: { type: 'string', description: 'ID exact du container à vérifier.' }, + startDate: { type: 'string', description: 'Date de début ISO-8601.' }, + endDate: { type: 'string', description: 'Date de fin ISO-8601.' }, + }, + required: ['containerId', 'startDate', 'endDate'], + }, + }, + { + name: 'check_container_availability_batch', + description: 'Vérifie la disponibilité d une liste de containers pour la même période en un seul appel.', + parameters: { + type: 'object', + properties: { + containerIds: { type: 'array', items: { type: 'string' }, description: 'Liste des IDs de containers (max 50).' }, + startDate: { type: 'string', description: 'Date de début ISO-8601.' }, + endDate: { type: 'string', description: 'Date de fin ISO-8601.' }, + }, + required: ['containerIds', 'startDate', 'endDate'], + }, + }, { name: 'list_equipment_by_category', description: 'Liste le materiel d\'une categorie ou sous-categorie specifique. Utile si search_equipment ne donne rien a cause d\'une faute de frappe ou pour explorer les alternatives.', @@ -226,50 +260,96 @@ async function toolSearchEquipment(query) { return { query, count: 0, results: [] }; } - // Normalisation agressive pour la recherche (sans accents, sans ponctuation) + // Normalisation pour comparer proprement const normalize = (str) => (str || '') + .toString() .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') - .replace(/[^a-z0-9]/g, ''); + .replace(/[^a-z0-9\s-]/g, '') + .trim(); const normalizedQuery = normalize(query); + const tokens = extractQueryTokens(query); + // Charger un snapshot raisonnable une seule fois const snapshot = await getDb().collection('equipments').limit(500).get(); + + const docs = snapshot.docs.map((doc) => { + const data = doc.data(); + return { + id: doc.id, + name: String(data.name || doc.id), + category: data.category || '', + subCategory: data.subCategory || '', + brand: data.brand || null, + model: data.model || null, + status: data.status || '', + availableQuantity: data.availableQuantity ?? null, + totalQuantity: data.totalQuantity ?? null, + }; + }); + const results = []; - snapshot.docs.forEach((doc) => { - const data = doc.data(); - const searchableFields = [ - data.name, - doc.id, - data.model, - data.brand, - data.category, - data.subCategory, - ]; - - const isMatch = searchableFields.some(field => { - if (!field) return false; - const normalizedField = normalize(String(field)); - return normalizedField.includes(normalizedQuery) || normalizedQuery.includes(normalizedField); - }); - - if (isMatch) { - results.push({ - id: doc.id, - name: data.name || doc.id, - category: data.category || '', - subCategory: data.subCategory || '', - brand: data.brand || null, - model: data.model || null, - status: data.status || '', - availableQuantity: data.availableQuantity ?? null, - totalQuantity: data.totalQuantity ?? null, - }); + // PASS 1 : recherche stricte / exacte (id, nom complet, modele) + for (const item of docs) { + const candText = normalize([item.id, item.name, item.brand, item.model].join(' ')); + if ( + candText === normalizedQuery + || candText.includes(normalizedQuery) + || normalizedQuery.includes(candText) + ) { + results.push(item); } - }); + } + + // PASS 2 : recherche par tokens (mots-clés, marque, modèle partiel) + if (results.length === 0) { + for (const item of docs) { + const candText = normalize([item.id, item.name, item.brand, item.model].join(' ')); + const tokenMatch = tokens.some((t) => candText.includes(t)); + if (tokenMatch) results.push(item); + } + } + + // PASS 3 : fallback catégoriel si aucune correspondance directe + if (results.length === 0) { + // Essayer d'identifier une marque ou categorie depuis les tokens + const possibleBrand = tokens.length > 0 ? tokens[0] : null; + // Rechercher d'abord par brand + if (possibleBrand) { + for (const item of docs) { + if (item.brand && normalize(String(item.brand)).includes(possibleBrand)) { + results.push(item); + } + } + } + + // Si toujours rien, appeler list_equipment_by_category pour élargir la recherche + if (results.length === 0) { + // tenter de deviner une catégorie depuis la query (prend premier token long) + const guessedCategory = tokens.find((t) => t.length >= 3) || null; + if (guessedCategory) { + try { + const catRes = await toolListEquipmentByCategory(guessedCategory, null); + if (catRes && Array.isArray(catRes.results) && catRes.results.length > 0) { + // essayer un fuzzy match dans la categorie + const normQuery = normalizedQuery; + for (const itm of catRes.results) { + const cand = normalize([itm.id, itm.name, itm.brand, itm.model].join(' ')); + if (cand.includes(normQuery) || tokens.some((t) => cand.includes(t))) { + results.push(itm); + } + } + } + } catch (e) { + // ignore fallback errors + } + } + } + } const limited = results.slice(0, SEARCH_RESULTS_LIMIT); @@ -411,6 +491,125 @@ function buildAvailabilityResultForEquipment({ }; } +function buildAvailabilityResultForContainer({ + containerDoc, + containerId, + start, + end, + candidateDocs, + excludeEventId, +}) { + const conflicts = []; + + for (const eventDoc of candidateDocs) { + if (excludeEventId && eventDoc.id === excludeEventId) continue; + + const event = eventDoc.data(); + if (isCancelledStatus(event.status)) continue; + + const eventStart = toDateSafe(event.StartDateTime); + const eventEnd = toDateSafe(event.EndDateTime); + if (!eventStart || !eventEnd) continue; + + // Vérifier si le container est assigné à cet événement + const assignedContainers = Array.isArray(event.assignedContainers) ? event.assignedContainers : []; + const isAssigned = assignedContainers.some((c) => String(c) === String(containerId)); + + // Vérifier chevauchement de dates + const hasOverlap = start < eventEnd && end > eventStart; + if (!hasOverlap) continue; + + if (isAssigned) { + conflicts.push({ + eventId: eventDoc.id, + eventName: event.name || event.Name || 'Événement sans nom', + }); + } + } + + return { + containerId: containerId, + available: conflicts.length === 0, + conflictCount: conflicts.length, + conflicts, + }; +} + +/** + * Vérifie la disponibilité d'un container et son contenu sur une période donnée. + */ +async function toolCheckContainerAvailability(containerId, startDate, endDate, excludeEventId, sharedContext) { + if (!containerId || !startDate || !endDate) { + return { error: 'containerId, startDate et endDate sont requis.' }; + } + + const start = new Date(startDate); + const end = new Date(endDate); + if (isNaN(start.getTime()) || isNaN(end.getTime())) return { error: 'Dates invalides.' }; + + // Charger le container + const containerDocRef = await getDb().collection('containers').doc(containerId).get(); + if (!containerDocRef.exists) { + return { containerId, error: 'Container introuvable.' }; + } + const containerData = containerDocRef.data() || {}; + const equipmentIds = Array.isArray(containerData.equipmentIds) ? containerData.equipmentIds.map((id) => String(id)) : []; + + const candidateDocs = await loadAvailabilityCandidates(start, sharedContext); + + // Vérifier si le container lui-même est assigné + const containerAvailability = buildAvailabilityResultForContainer({ + containerDoc: containerData, + containerId, + start, + end, + candidateDocs, + excludeEventId, + }); + + // Vérifier aussi la disponibilité des équipements internes + let membersAvailability = null; + if (equipmentIds.length > 0) { + const batchRes = await toolCheckAvailabilityBatch(equipmentIds, startDate, endDate, excludeEventId, sharedContext); + membersAvailability = Array.isArray(batchRes.results) ? batchRes.results : []; + } + + // Si un membre est indisponible ou le container est assigné -> indisponible + const anyMemberConflict = Array.isArray(membersAvailability) && membersAvailability.some((m) => m && m.available === false); + const finalAvailable = containerAvailability.available && !anyMemberConflict; + + return { + containerId, + available: finalAvailable, + containerConflicts: containerAvailability.conflicts || [], + memberAvailability: membersAvailability || [], + }; +} + +/** + * Vérifie en batch la disponibilité de plusieurs containers. + */ +async function toolCheckContainerAvailabilityBatch(containerIds, startDate, endDate, excludeEventId, sharedContext) { + if (!Array.isArray(containerIds) || containerIds.length === 0 || !startDate || !endDate) { + return { error: 'containerIds (array), startDate et endDate sont requis.' }; + } + + const normalized = Array.from(new Set(containerIds.map((c) => String(c || '').trim()).filter(Boolean))).slice(0, MAX_BATCH_AVAILABILITY_ITEMS); + if (normalized.length === 0) return { error: 'Aucun containerId valide.' }; + + const results = []; + for (const cid of normalized) { + try { + const r = await toolCheckContainerAvailability(cid, startDate, endDate, excludeEventId, sharedContext); + results.push(r); + } catch (e) { + results.push({ containerId: cid, error: e?.message || 'Erreur interne' }); + } + } + + return { startDate, endDate, count: results.length, results }; +} + /** * Vérifie la disponibilité d'une liste d'équipements pour la même période. */ @@ -530,6 +729,20 @@ function normalizeSearchText(value) { .trim(); } +/** + * Retourne true si le message utilisateur autorise l'utilisation de containers. + * Détecte quelques formulations négatives courantes en français/anglais. + */ +function userAllowsContainers(userMessage) { + if (!userMessage || userMessage.toString().trim().length === 0) return true; + const txt = userMessage.toString().toLowerCase(); + const negativePatterns = [ + 'pas de container', 'sans container', 'pas de flight', 'sans flight', 'pas de flight case', 'sans flight case', + 'ne pas utiliser', 'ne pas prendre', 'do not use', 'no container', 'no flight', 'no flight case' + ]; + return !negativePatterns.some((pat) => txt.includes(pat)); +} + function parseDateHintFlexible(value) { const directDate = toDateSafe(value); if (directDate) { @@ -949,6 +1162,24 @@ async function executeTool(toolCall, excludeEventId, sharedContext) { case 'search_containers': return await toolSearchContainers(args.equipmentIds, args.query || null); + case 'check_container_availability': + return await toolCheckContainerAvailability( + args.containerId, + args.startDate, + args.endDate, + excludeEventId, + sharedContext, + ); + + case 'check_container_availability_batch': + return await toolCheckContainerAvailabilityBatch( + args.containerIds, + args.startDate, + args.endDate, + excludeEventId, + sharedContext, + ); + case 'list_equipment_by_category': return await toolListEquipmentByCategory(args.category || null, args.subCategory || null); @@ -1132,10 +1363,13 @@ function parseAiResponse(rawText, logDebug, logError) { } /** - * Envoie un message à Gemini avec une temporisation en cas de Rate Limit (429). + * Envoie un message à Gemini avec une stratégie de retry robuste. + * Exponential backoff : délais augmentent avec chaque tentative. + * 503 (surcharge) = plus d'attente que 429 (quota). */ async function sendMessageWithRetry(chat, messagePayload, logDebug, logError) { - const maxRetries = 5; + const maxRetries = 8; + const baseDelayMs = 5000; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { @@ -1147,15 +1381,26 @@ async function sendMessageWithRetry(chat, messagePayload, logDebug, logError) { const isOverloaded = status === 503 || errorMessage.includes('503') || errorMessage.includes('overloaded') || errorMessage.includes('high demand'); if ((isRateLimit || isOverloaded) && attempt < maxRetries) { - let delayMs = isOverloaded ? 5000 : 30000; // Plus court pour 503, long pour 429 + // Exponential backoff : 5s * (2 ^ (attempt - 1)) + // Surcharge (503) : attente plus longue car le modèle est saturé + // Quota (429) : attente plus courte car c'est juste une limite de débit + const exponentialMultiplier = Math.pow(2, Math.max(0, attempt - 1)); + let delayMs = isOverloaded + ? baseDelayMs * exponentialMultiplier * 3 // 15s, 30s, 60s, 120s, etc. + : baseDelayMs * exponentialMultiplier; // 5s, 10s, 20s, 40s, etc. + // Si le serveur indique un délai spécifique, le respecter const retryMatch = errorMessage.match(/retry in\s+([\d.]+)\s*s/i); if (retryMatch && !isNaN(parseFloat(retryMatch[1]))) { delayMs = Math.ceil(parseFloat(retryMatch[1]) * 1000) + 2000; } - const reason = isOverloaded ? 'Modèle surchargé (503)' : 'Quota atteint (429)'; - logDebug(`[AI] ${reason}. Temporisation de ${delayMs / 1000}s avant la tentative ${attempt + 1}/${maxRetries}...`); + const reason = isOverloaded ? 'Modèle surchargé (503 - haute demande)' : 'Quota atteint (429)'; + logDebug( + `[AI] ${reason}. Exponential backoff : tentative ${attempt}/${maxRetries}, ` + + `attente de ${Math.round(delayMs / 1000)}s avant nouvelle tentative...`, + { status, attempt, maxRetries } + ); await new Promise((resolve) => setTimeout(resolve, delayMs)); } else { throw error; @@ -1180,6 +1425,28 @@ async function handleAiEquipmentProposal(req, res) { logger.error(msg, data); }; + // Récupérer la valeur de firebase functions:config si disponible + let firebaseConfigKey = null; + try { + const config = functions.config(); + firebaseConfigKey = config.gemini?.api_key || config.gemini?.apikey; + } catch (e) { + // Ignoré + } + + // Évaluer la clé dynamiquement au moment de l'appel + const dynamicApiKey = process.env.GEMINI_API_KEY || firebaseConfigKey || process.env.GOOGLE_AI_API_KEY; + + if (!dynamicApiKey) { + logError('[AI] Configuration manquante : GEMINI_API_KEY / GOOGLE_AI_API_KEY absent'); + res.status(500).json({ + assistantMessage: 'La configuration de l\'IA est incomplète côté serveur. Veuillez configurer la clé Gemini avant de réessayer.', + proposal: null, + debugLogs, + }); + return; + } + const { eventName, eventTypeId, @@ -1201,7 +1468,7 @@ async function handleAiEquipmentProposal(req, res) { return; } - const genAI = new GoogleGenerativeAI(GEMINI_API_KEY); + const genAI = new GoogleGenerativeAI(dynamicApiKey); const model = genAI.getGenerativeModel({ model: GEMINI_MODEL, systemInstruction: SYSTEM_PROMPT, @@ -1464,6 +1731,87 @@ async function handleAiEquipmentProposal(req, res) { } } + // Post-traitement : privilégier les containers si ceux-ci contiennent uniquement des équipements + // demandés par l'événement (i.e. tous les équipements du container sont nécessaires). + try { + if (proposal && Array.isArray(proposal.items) && proposal.items.length > 0) { + const allowContainers = userAllowsContainers(userMessage); + logDebug('[AI] Vérification auto des containers possible (autorisé par l utilisateur) :', { allowContainers }); + + if (allowContainers) { + const itemIds = proposal.items.map((it) => String(it.equipmentId)); + if (itemIds.length > 0) { + const searchArgs = { equipmentIds: itemIds }; + const cacheKey = buildToolCacheKey('search_containers', searchArgs); + let containersResult = toolResultCache.get(cacheKey); + if (!containersResult) { + containersResult = await executeTool({ name: 'search_containers', args: searchArgs }, excludeEventId, sharedToolContext); + toolResultCache.set(cacheKey, containersResult); + } + + if (containersResult && Array.isArray(containersResult.containers) && containersResult.containers.length > 0) { + const itemsSet = new Set(itemIds); + proposal.containers = proposal.containers || []; + + for (const c of containersResult.containers) { + const cEquip = Array.isArray(c.equipmentIds) ? c.equipmentIds.map((id) => String(id)) : []; + if (cEquip.length === 0) continue; + + const matching = Array.isArray(c.matchingEquipmentIds) ? c.matchingEquipmentIds.map((id) => String(id)) : cEquip.filter((id) => itemsSet.has(String(id))); + const missing = cEquip.filter((id) => !itemsSet.has(String(id))); + const isFull = matching.length > 0 && missing.length === 0; + + // Vérifier la disponibilité du container (et de ses membres) pour la période + const checkArgs = { containerId: c.id, startDate: startDate, endDate: endDate }; + const checkKey = buildToolCacheKey('check_container_availability', checkArgs); + let containerAvail = toolResultCache.get(checkKey); + if (!containerAvail) { + try { + containerAvail = await executeTool({ name: 'check_container_availability', args: checkArgs }, excludeEventId, sharedToolContext); + } catch (err) { + containerAvail = { containerId: c.id, available: true, error: err?.message || 'Erreur check' }; + } + toolResultCache.set(checkKey, containerAvail); + } + + const containerEntry = { + containerId: String(c.id), + rationale: `Conteneur ${c.name || c.id} (matching: ${matching.length}/${cEquip.length})`, + equipmentIds: cEquip, + matchingEquipmentIds: matching, + missingEquipmentIds: missing, + matchingCount: matching.length, + totalItemCount: cEquip.length, + coverageRatio: c.coverageRatio || (matching.length / Math.max(1, itemsSet.size)), + partial: !isFull, + available: containerAvail && typeof containerAvail.available === 'boolean' ? containerAvail.available : true, + availabilityDetail: containerAvail || null, + }; + + // Si le container couvre totalement ses éléments -> favoriser et retirer les items individuels + if (isFull) { + const already = proposal.containers.find((pc) => pc.containerId === c.id); + if (!already) { + proposal.containers.push(containerEntry); + logDebug('[AI] Ajout automatique du container car tous ses éléments sont demandés :', { containerId: c.id }); + } + + const covered = new Set(cEquip.map((id) => String(id))); + proposal.items = proposal.items.filter((it) => !covered.has(String(it.equipmentId))); + } else if (matching.length > 0) { + // Container partiel : proposer à l'utilisateur d'ajouter le container complète + proposal.containers.push(containerEntry); + logDebug('[AI] Container partiel proposé (non retiré des items) :', { containerId: c.id, matchingCount: matching.length, missingCount: missing.length }); + } + } + } + } + } + } + } catch (e) { + logError('[AI] Erreur pendant l auto-selection des containers :', { message: e?.message || e }); + } + res.status(200).json({ assistantMessage, proposal, debugLogs }); } diff --git a/em2rp/functions/index.js b/em2rp/functions/index.js index f4bb1fa..eb77bda 100644 --- a/em2rp/functions/index.js +++ b/em2rp/functions/index.js @@ -3,8 +3,11 @@ * Architecture backend sécurisée avec authentification et permissions */ -// Charger les variables d'environnement depuis .env -require('dotenv').config(); +// Charger les variables d'environnement depuis .env.local (développement) +// ou .env (production Firebase) +const path = require('path'); +require('dotenv').config({ path: path.join(__dirname, '.env.local') }); +require('dotenv').config({ path: path.join(__dirname, '.env') }); const { onRequest, onCall } = require("firebase-functions/v2/https"); const { onSchedule } = require("firebase-functions/v2/scheduler"); diff --git a/em2rp/functions/package-lock.json b/em2rp/functions/package-lock.json index 2f39817..fdbcf48 100644 --- a/em2rp/functions/package-lock.json +++ b/em2rp/functions/package-lock.json @@ -13,7 +13,7 @@ "dotenv": "^17.2.3", "envdot": "^0.0.3", "firebase-admin": "^12.6.0", - "firebase-functions": "^7.0.3", + "firebase-functions": "^7.2.5", "handlebars": "^4.7.8", "nodemailer": "^6.10.1" }, @@ -3364,9 +3364,9 @@ } }, "node_modules/firebase-functions": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-7.0.3.tgz", - "integrity": "sha512-DiIjIUv0OS4KEAA3jqyIc7ymZKdcmMcaXy7FCCkrDQo/1CVMbDDWMdZIslmsgSjldA2nhau1dTE/6JQI8Urjjw==", + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-7.2.5.tgz", + "integrity": "sha512-K+pP0AknluAguLRbD96hibyXbnOgwnvd4hkExWdGrxnNCLoj8LBFj08uvJYxyvhsCgYzQumrUaHBW4lsXKSiRg==", "license": "MIT", "peer": true, "dependencies": { @@ -3385,7 +3385,8 @@ "peerDependencies": { "@apollo/server": "^5.2.0", "@as-integrations/express4": "^1.1.2", - "firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0" + "firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0", + "graphql": "^16.12.0" }, "peerDependenciesMeta": { "@apollo/server": { @@ -3393,6 +3394,9 @@ }, "@as-integrations/express4": { "optional": true + }, + "graphql": { + "optional": true } } }, diff --git a/em2rp/functions/package.json b/em2rp/functions/package.json index b0740b0..f4bbf4e 100644 --- a/em2rp/functions/package.json +++ b/em2rp/functions/package.json @@ -21,7 +21,7 @@ "dotenv": "^17.2.3", "envdot": "^0.0.3", "firebase-admin": "^12.6.0", - "firebase-functions": "^7.0.3", + "firebase-functions": "^7.2.5", "handlebars": "^4.7.8", "nodemailer": "^6.10.1" }, diff --git a/em2rp/lib/services/ai_equipment_assistant_service.dart b/em2rp/lib/services/ai_equipment_assistant_service.dart index 345ccbe..0dc3f00 100644 --- a/em2rp/lib/services/ai_equipment_assistant_service.dart +++ b/em2rp/lib/services/ai_equipment_assistant_service.dart @@ -38,6 +38,29 @@ class AiEquipmentProposalItem { }); } +/// Métadonnées pour un container proposé par l'IA. +class AiEquipmentProposalContainer { + final String containerId; + final String rationale; + final List equipmentIds; + final List matchingEquipmentIds; + final List missingEquipmentIds; + final bool partial; + final bool? available; + final dynamic availabilityDetail; + + const AiEquipmentProposalContainer({ + required this.containerId, + required this.rationale, + this.equipmentIds = const [], + this.matchingEquipmentIds = const [], + this.missingEquipmentIds = const [], + this.partial = false, + this.available, + this.availabilityDetail, + }); +} + /// Proposition complète retournée par l'IA. class AiEquipmentProposal { final String summary; @@ -46,14 +69,16 @@ class AiEquipmentProposal { /// Équipements individuels prêts à être injectés dans l'état local de l'événement. final List asEventEquipment; - /// IDs des containers (flight cases) proposés par l'IA. - final List containerIds; + /// Containers (métadonnées) proposés par l'IA. + final List containers; + + List get containerIds => containers.map((c) => c.containerId).toList(); const AiEquipmentProposal({ required this.summary, required this.items, required this.asEventEquipment, - required this.containerIds, + required this.containers, }); } @@ -156,7 +181,7 @@ class AiEquipmentAssistantService { final proposalItems = []; final eventEquipmentList = []; - final containerIds = []; + // legacy containerIds variable removed (we now use containersMeta) final rawItems = rawProposal['items']; if (rawItems is List) { @@ -184,19 +209,64 @@ class AiEquipmentAssistantService { } } + final containersMeta = []; final rawContainers = rawProposal['containers']; if (rawContainers is List) { for (final rawContainer in rawContainers) { + if (rawContainer is String) { + final cid = rawContainer.toString().trim(); + if (cid.isNotEmpty) { + containersMeta.add(AiEquipmentProposalContainer(containerId: cid, rationale: 'Proposition IA')); + } + continue; + } if (rawContainer is! Map) continue; final container = Map.from(rawContainer); final containerId = container['containerId']?.toString().trim() ?? ''; - if (containerId.isNotEmpty) { - containerIds.add(containerId); + if (containerId.isEmpty) continue; + + final rationale = container['rationale']?.toString().trim() ?? 'Proposition IA'; + final equipmentIds = []; + final matching = []; + final missing = []; + + if (container['equipmentIds'] is List) { + for (final v in container['equipmentIds']) { + final s = v == null ? null : v.toString().trim(); + if (s != null && s.isNotEmpty) equipmentIds.add(s); + } } + if (container['matchingEquipmentIds'] is List) { + for (final v in container['matchingEquipmentIds']) { + final s = v == null ? null : v.toString().trim(); + if (s != null && s.isNotEmpty) matching.add(s); + } + } + if (container['missingEquipmentIds'] is List) { + for (final v in container['missingEquipmentIds']) { + final s = v == null ? null : v.toString().trim(); + if (s != null && s.isNotEmpty) missing.add(s); + } + } + + final partial = container['partial'] is bool ? container['partial'] as bool : (missing.isNotEmpty); + final available = container.containsKey('available') ? (container['available'] is bool ? container['available'] as bool : null) : null; + final availabilityDetail = container.containsKey('availabilityDetail') ? container['availabilityDetail'] : null; + + containersMeta.add(AiEquipmentProposalContainer( + containerId: containerId, + rationale: rationale, + equipmentIds: equipmentIds, + matchingEquipmentIds: matching, + missingEquipmentIds: missing, + partial: partial, + available: available, + availabilityDetail: availabilityDetail, + )); } } - if (proposalItems.isEmpty && containerIds.isEmpty) return null; + if (proposalItems.isEmpty && containersMeta.isEmpty) return null; return AiEquipmentProposal( summary: rawProposal['summary']?.toString().trim().isNotEmpty == true @@ -204,7 +274,7 @@ class AiEquipmentAssistantService { : 'Proposition matériel générée automatiquement.', items: proposalItems, asEventEquipment: eventEquipmentList, - containerIds: containerIds, + containers: containersMeta, ); } } diff --git a/em2rp/lib/views/widgets/event_form/ai_equipment_assistant_dialog.dart b/em2rp/lib/views/widgets/event_form/ai_equipment_assistant_dialog.dart index a5d6f21..6cd77cb 100644 --- a/em2rp/lib/views/widgets/event_form/ai_equipment_assistant_dialog.dart +++ b/em2rp/lib/views/widgets/event_form/ai_equipment_assistant_dialog.dart @@ -52,6 +52,7 @@ class _AiEquipmentAssistantDialogState late List _workingEquipment; AiEquipmentDocument? _selectedDocument; List _sessionLogs = []; + Set _selectedContainerIds = {}; @override void initState() { @@ -134,6 +135,11 @@ class _AiEquipmentAssistantDialogState _workingEquipment = List.from( response.proposal!.asEventEquipment, ); + // Préselectionner les containers non partiels + _selectedContainerIds = { + for (final c in response.proposal!.containers) + if (!c.partial) c.containerId + }; } _sessionLogs.addAll(response.debugLogs); _isLoading = false; @@ -267,7 +273,7 @@ class _AiEquipmentAssistantDialogState children: [ AppBar( automaticallyImplyLeading: false, - title: const Text('Assistant IA Logisticien'), + title: const Text('(BETA) Assistant IA Logisticien'), actions: [ if (_sessionLogs.isNotEmpty) IconButton( @@ -422,13 +428,13 @@ class _AiEquipmentAssistantDialogState bottomLeft: Radius.circular(message.isUser ? 16 : 4), bottomRight: Radius.circular(message.isUser ? 4 : 16), ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], border: message.isUser ? null : Border.all(color: Colors.grey.shade200), ), @@ -488,11 +494,11 @@ class _AiEquipmentAssistantDialogState width: double.infinity, margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: bgColor, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: color.withValues(alpha: 0.3)), - ), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withValues(alpha: 0.3)), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -500,11 +506,13 @@ class _AiEquipmentAssistantDialogState children: [ Icon(icon, size: 18, color: color), const SizedBox(width: 8), - Text( - title.replaceAll(':', '').trim(), - style: TextStyle( - fontWeight: FontWeight.bold, - color: color, + Expanded( + child: Text( + title.replaceAll(':', '').trim(), + style: TextStyle( + fontWeight: FontWeight.bold, + color: color, + ), ), ), ], @@ -525,7 +533,10 @@ class _AiEquipmentAssistantDialogState if (_latestProposal == null) return; List equipment = List.from(_latestProposal!.asEventEquipment); - List containerIds = List.from(_latestProposal!.containerIds); + // Ne renvoyer que les containerIds sélectionnés (par défaut les containers complets) + final List containerIds = _selectedContainerIds.isNotEmpty + ? _selectedContainerIds.toList() + : List.from(_latestProposal!.containerIds); if (excludeAlternatives) { // On utilise la liste des items d'origine pour savoir lesquels exclure @@ -558,9 +569,9 @@ class _AiEquipmentAssistantDialogState padding: const EdgeInsets.all(16), constraints: const BoxConstraints(maxHeight: 280), decoration: BoxDecoration( - color: Colors.indigo.shade50, + color: Colors.white, borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.indigo.shade200), + border: Border.all(color: Colors.grey.shade300), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.05), @@ -576,12 +587,14 @@ class _AiEquipmentAssistantDialogState children: [ const Icon(Icons.assignment_turned_in, color: Colors.indigo), const SizedBox(width: 12), - const Text( - 'Récapitulatif de la proposition IA', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - color: Colors.indigo, + const Expanded( + child: Text( + 'Récapitulatif de la proposition IA', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: Colors.indigo, + ), ), ), ], @@ -633,21 +646,58 @@ class _AiEquipmentAssistantDialogState ); }), ], - if (proposal.containerIds.isNotEmpty) ...[ + if (proposal.containers.isNotEmpty) ...[ const SizedBox(height: 12), const Text( 'Fly-cases & Boîtes :', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13), ), const SizedBox(height: 4), - ...proposal.containerIds.map((id) { + ...proposal.containers.map((c) { + final isPartial = c.partial; + final isSelected = _selectedContainerIds.contains(c.containerId); return Padding( padding: const EdgeInsets.only(bottom: 6, left: 4), child: Row( children: [ - const Icon(Icons.inventory_2_outlined, size: 14, color: Colors.indigo), + Icon( + Icons.inventory_2_outlined, + size: 14, + color: c.available == false ? Colors.red : Colors.indigo, + ), const SizedBox(width: 8), - Text(id, style: const TextStyle(fontWeight: FontWeight.w500)), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded(child: Text('${c.containerId} ${c.rationale.isNotEmpty ? "- ${c.rationale}" : ""}', style: const TextStyle(fontWeight: FontWeight.w500))), + if (c.available == false) + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Icon(Icons.block, color: Colors.red.shade700, size: 14), + ), + ], + ), + if (isPartial) ...[ + const SizedBox(height: 4), + Text('Contenu partiel : ${c.matchingEquipmentIds.length}/${c.equipmentIds.length} items utilisés.', style: TextStyle(fontSize: 12, color: Colors.grey.shade700)), + ], + ], + ), + ), + const SizedBox(width: 8), + if (isPartial) + Checkbox( + value: isSelected, + onChanged: (v) { + setState(() { + if (v == true) _selectedContainerIds.add(c.containerId); + else _selectedContainerIds.remove(c.containerId); + }); + }, + ), ], ), );