Spaces:
Running
Running
| // --- Sentinel Logic Engine --- | |
| // State: Articles tracked by the system | |
| // Structure: { id, currentZone, paid, state (ok, latent, confirmed), lastSeen } | |
| let articles = {}; | |
| // State: Douts Latents (Set of Article IDs) | |
| let latentDoubts = new Set(); | |
| // State: Confirmed Alerts (Array of objects) | |
| let confirmedAlerts = []; | |
| // State: Statistics | |
| let resolvedDoubts = 0; | |
| let sessionStartTime = Date.now(); | |
| // DOM Elements - safely accessed | |
| function getElement(id) { | |
| return document.getElementById(id); | |
| } | |
| let eventLog, latentList, alertList, latentCount, alertCount, statTracked, statRate, statResolved, statTime; | |
| function initDOMElements() { | |
| eventLog = getElement('event-log'); | |
| latentList = getElement('latent-list'); | |
| alertList = getElement('alert-list'); | |
| latentCount = getElement('latent-count'); | |
| alertCount = getElement('alert-count'); | |
| statTracked = getElement('stat-tracked'); | |
| statRate = getElement('stat-rate'); | |
| statResolved = getElement('stat-resolved'); | |
| statTime = getElement('stat-time'); | |
| } | |
| // --- Helper Functions --- | |
| function getTimestamp() { | |
| return new Date().toISOString(); | |
| } | |
| function logEvent(message, type = 'info') { | |
| if (!eventLog) return; | |
| // Remove placeholder if exists | |
| const placeholder = document.getElementById('event-placeholder'); | |
| if (placeholder) placeholder.remove(); | |
| const entry = document.createElement('div'); | |
| let borderColor = 'border-slate-700'; | |
| let textColor = 'text-slate-300'; | |
| let icon = 'arrow-right'; | |
| let bgColor = 'bg-slate-800/50'; | |
| switch(type) { | |
| case 'doute': | |
| borderColor = 'border-amber-500'; | |
| textColor = 'text-amber-400'; | |
| icon = 'alert-triangle'; | |
| bgColor = 'bg-amber-900/20'; | |
| break; | |
| case 'alerte': | |
| borderColor = 'border-red-500'; | |
| textColor = 'text-red-400'; | |
| icon = 'alert-octagon'; | |
| bgColor = 'bg-red-900/20'; | |
| break; | |
| case 'validation': | |
| borderColor = 'border-green-500'; | |
| textColor = 'text-green-400'; | |
| icon = 'check-circle'; | |
| bgColor = 'bg-green-900/20'; | |
| break; | |
| case 'system': | |
| borderColor = 'border-cyan-500'; | |
| textColor = 'text-cyan-400'; | |
| icon = 'cpu'; | |
| bgColor = 'bg-cyan-900/20'; | |
| break; | |
| } | |
| entry.className = `p-2 ${bgColor} border-l-2 ${borderColor} ${textColor} text-[10px] animate-fade-in font-mono break-all mb-2 rounded-r`; | |
| entry.innerHTML = `<span class="opacity-50 mr-2">[${getTimestamp().split('T')[1].split('.')[0]}]</span> <i data-feather="${icon}" class="w-3 h-3 inline mr-1"></i> ${message}`; | |
| eventLog.prepend(entry); | |
| // Keep only last 100 entries | |
| while (eventLog.children.length > 100) { | |
| if (eventLog.lastChild) eventLog.lastChild.remove(); | |
| } | |
| if (typeof feather !== 'undefined' && typeof feather.replace === 'function') { | |
| feather.replace(); | |
| } | |
| updateStats(); | |
| } | |
| // --- Core Logic Implementation --- | |
| function processEvent(eventType, articleId) { | |
| // Sécurité cognitive : validation d'entrée | |
| if (!eventType || typeof eventType !== 'string') { | |
| console.error('[Sentinel Core] Invalid eventType:', eventType); | |
| return { error: 'Invalid eventType', etat: 'erreur' }; | |
| } | |
| if (!articleId || typeof articleId !== 'string') { | |
| console.error('[Sentinel Core] Invalid articleId:', articleId); | |
| return { error: 'Invalid articleId', etat: 'erreur' }; | |
| } | |
| const now = getTimestamp(); | |
| let output = { | |
| doute: null, | |
| raison: null, | |
| article: String(articleId), | |
| zone: null, | |
| timestamp: now, | |
| etat: "aucun" | |
| }; | |
| // 1. Initialization if new | |
| if (!articles[articleId]) { | |
| articles[articleId] = { | |
| id: String(articleId), | |
| currentZone: 'UNKNOWN', | |
| paid: false, | |
| state: 'ok', | |
| lastSeen: now | |
| }; | |
| } | |
| const article = articles[articleId]; | |
| // 2. Event Handling & Rules | |
| // CASE: Detection in Zone (Rayon, Réserve, etc.) | |
| if (eventType === 'RAYON') { | |
| output.zone = 'RAYON'; | |
| article.currentZone = 'RAYON'; | |
| article.lastSeen = now; | |
| // Reset latent if it was latent and comes back to authorized zone? | |
| // Usually stays latent until payment or exit, but let's assume tracking resumes normally. | |
| logEvent(`Détection article ${articleId} en zone RAYON`, 'info'); | |
| } else if (eventType === 'MOVE_RESERVE') { | |
| output.zone = 'RESERVE'; | |
| const previousZone = article.currentZone; | |
| article.currentZone = 'RESERVE'; | |
| article.lastSeen = now; | |
| // RULE: Déplacement non autorisé | |
| // Si l'article était en RAYON et passe en RESERVE (sans passer caisse logiquement dans ce scénario simplifié) | |
| if (previousZone === 'RAYON' && !article.paid) { | |
| const doubtType = "article_deplace"; | |
| createLatentDoubt(articleId, doubtType, `Article déplacé de ${previousZone} vers RESERVE sans validation.`); | |
| output.doute = doubtType; | |
| output.raison = "Déplacement suspect"; | |
| output.etat = "latent"; | |
| } else { | |
| logEvent(`Article ${articleId} déplacé vers RÉSERVE`, 'info'); | |
| } | |
| } else if (eventType === 'SCAN') { | |
| output.zone = 'CAISSE'; | |
| article.paid = true; | |
| article.currentZone = 'CAISSE'; // Technically leaving store logic-wise | |
| article.lastSeen = now; | |
| // Clear any latent doubts if paid | |
| if (latentDoubts.has(articleId)) { | |
| clearLatentDoubt(articleId); | |
| logEvent(`Validation article ${articleId} : Paiement confirmé`, 'validation'); | |
| } else { | |
| logEvent(`Scan article ${articleId} : OK`, 'validation'); | |
| } | |
| } else if (eventType === 'SORTIE') { | |
| output.zone = 'SORTIE'; | |
| article.currentZone = 'SORTIE'; | |
| article.lastSeen = now; | |
| // RULE: Sortie Client | |
| // Si article détecté en sortie ET article ∈ doutes_latents ET non payé | |
| if (latentDoubts.has(articleId) && !article.paid) { | |
| const doubtType = "article_non_declare_sortie_client"; | |
| confirmDoubt(articleId, doubtType, `Sortie zone non autorisée pour article en litige.`); | |
| output.doute = doubtType; | |
| output.raison = "Sortie non déclarée"; | |
| output.etat = "confirme"; | |
| } | |
| else if (!article.paid && article.state === 'ok' && article.currentZone === 'RAYON') { | |
| // Scénario: Article disparaît du rayon (non détecté déplacement) et sort direct. | |
| // On crée un doute immédiat à la sortie car l'état était ok mais non payé. | |
| const doubtType = "article_non_declare_sortie_client"; | |
| confirmDoubt(articleId, doubtType, `Sortie sans passage caisse détectée.`); | |
| output.doute = doubtType; | |
| output.raison = "Anomalie sortie"; | |
| output.etat = "confirme"; | |
| } | |
| else if (article.paid) { | |
| logEvent(`Sortie autorisée pour article ${articleId}`, 'validation'); | |
| } | |
| } | |
| updateMapVisualization(articleId, article); | |
| return output; | |
| } | |
| // --- Doubt Management --- | |
| function createLatentDoubt(articleId, type, reason) { | |
| // Validation défensive | |
| const safeArticleId = String(articleId || 'INCONNU'); | |
| const safeType = String(type || 'Type inconnu'); | |
| const safeReason = String(reason || 'Raison non spécifiée'); | |
| if (!latentDoubts.has(safeArticleId)) { | |
| latentDoubts.add(safeArticleId); | |
| if (articles[safeArticleId]) { | |
| articles[safeArticleId].state = 'latent'; | |
| } | |
| // UI Update avec sanitization | |
| const card = document.createElement('div'); | |
| card.id = `latent-${safeArticleId.replace(/[^a-zA-Z0-9-]/g, '')}`; | |
| card.className = "bg-amber-900/20 border border-amber-700/50 p-2 rounded text-xs text-amber-200 animate-fade-in"; | |
| card.innerHTML = ` | |
| <div class="flex justify-between items-center mb-1"> | |
| <strong class="text-amber-400">${safeArticleId}</strong> | |
| <i data-feather="help-circle" class="w-3 h-3 text-amber-500"></i> | |
| </div> | |
| <p class="opacity-80">${safeType}</p> | |
| <p class="opacity-50 text-[10px] mt-1 italic">${safeReason}</p> | |
| `; | |
| if (latentList) latentList.appendChild(card); | |
| updateCounts(); | |
| logEvent(`DOUTE LATENT généré pour ${safeArticleId} : ${safeType}`, 'doute'); | |
| } | |
| } | |
| function clearLatentDoubt(articleId) { | |
| if (latentDoubts.has(articleId)) { | |
| latentDoubts.delete(articleId); | |
| articles[articleId].state = 'ok'; | |
| resolvedDoubts++; | |
| const el = document.getElementById(`latent-${articleId}`); | |
| if (el) { | |
| el.style.animation = 'fadeOut 0.3s ease-out forwards'; | |
| setTimeout(() => el.remove(), 300); | |
| } | |
| updateCounts(); | |
| updateStats(); | |
| } | |
| } | |
| function confirmDoubt(articleId, type, reason) { | |
| // Validation défensive et coercition de type | |
| const safeArticleId = String(articleId || 'INCONNU'); | |
| const safeType = String(type || 'Type inconnu'); | |
| const safeReason = String(reason || 'Raison non spécifiée'); | |
| // Remove from latent if exists | |
| clearLatentDoubt(safeArticleId); | |
| // Mark article (vérification d'existence) | |
| if (articles[safeArticleId]) { | |
| articles[safeArticleId].state = 'confirmed'; | |
| } | |
| // Add to confirmed | |
| confirmedAlerts.push({ | |
| article: safeArticleId, | |
| type: safeType, | |
| reason: safeReason, | |
| time: getTimestamp() | |
| }); | |
| // UI Update | |
| const card = document.createElement('div'); | |
| card.className = "bg-red-900/30 border border-red-600 p-2 rounded text-xs text-red-100 animate-fade-in alert-pulse relative overflow-hidden"; | |
| card.innerHTML = ` | |
| <div class="absolute top-0 right-0 p-1"><i data-feather="x" class="w-3 h-3"></i></div> | |
| <div class="flex justify-between items-center mb-1"> | |
| <strong class="text-red-400 font-bold tracking-wide">ALERTe</strong> | |
| <span class="bg-red-600 text-white px-1 rounded text-[9px]">${safeType}</span> | |
| </div> | |
| <div class="mb-1 text-red-200 font-mono">${safeArticleId}</div> | |
| <p class="opacity-80 border-t border-red-800/50 pt-1 mt-1">${safeReason}</p> | |
| <p class="opacity-40 text-[9px] mt-2 font-mono">${getTimestamp()}</p> | |
| `; | |
| if (alertList) alertList.prepend(card); | |
| updateCounts(); | |
| logEvent(`ALERTE CONFIRMÉE pour ${safeArticleId} : ${safeType}`, 'alerte'); | |
| } | |
| function updateCounts() { | |
| if (latentCount) latentCount.innerText = latentDoubts.size; | |
| if (alertCount) alertCount.innerText = confirmedAlerts.length; | |
| } | |
| // --- Visualizer (2D Map) --- | |
| function updateMapVisualization(articleId, article) { | |
| if (!article) return; | |
| // Remove this specific dot from all zones first to avoid duplicates | |
| const existingDot = document.getElementById(`dot-visual-${articleId}`); | |
| if (existingDot) { | |
| existingDot.remove(); | |
| } | |
| // Create new dot with proper state class | |
| const dot = document.createElement('div'); | |
| dot.id = `dot-visual-${articleId}`; | |
| // Determine state class | |
| let stateClass = 'ok'; | |
| if (article.state === 'latent') stateClass = 'latent'; | |
| if (article.state === 'confirmed') stateClass = 'confirmed'; | |
| dot.className = `map-dot active ${stateClass}`; | |
| dot.style.width = '24px'; | |
| dot.style.height = '24px'; | |
| dot.style.display = 'flex'; | |
| dot.style.alignItems = 'center'; | |
| dot.style.justifyContent = 'center'; | |
| dot.style.fontSize = '10px'; | |
| dot.style.fontWeight = 'bold'; | |
| dot.style.color = 'white'; | |
| dot.innerText = articleId.split('-')[1] || '?'; | |
| // Determine target zone | |
| let targetZoneId = ''; | |
| switch(article.currentZone) { | |
| case 'RAYON': targetZoneId = 'zone-rayon-a'; break; | |
| case 'RESERVE': targetZoneId = 'zone-reserve'; break; | |
| case 'CAISSE': targetZoneId = 'zone-rayon-b'; break; | |
| case 'SORTIE': targetZoneId = 'zone-sortie'; break; | |
| default: targetZoneId = 'zone-entree'; | |
| } | |
| const targetZone = document.getElementById(targetZoneId); | |
| if (targetZone) { | |
| const dotContainer = targetZone.querySelector('[id^="dot-"]') || targetZone; | |
| if (dotContainer) { | |
| dotContainer.appendChild(dot); | |
| // Add entry animation | |
| dot.style.transform = 'scale(0)'; | |
| setTimeout(() => { | |
| dot.style.transform = 'scale(1)'; | |
| }, 50); | |
| } | |
| } | |
| } | |
| // Clean up dots for articles that no longer exist | |
| function cleanupMapDots() { | |
| const allDots = document.querySelectorAll('[id^="dot-visual-"]'); | |
| allDots.forEach(dot => { | |
| const articleId = dot.id.replace('dot-visual-', ''); | |
| if (!articles[articleId]) { | |
| dot.remove(); | |
| } | |
| }); | |
| } | |
| // --- Simulation Controller --- | |
| window.simulateEvent = function(type, id) { | |
| // Validation préventive pour éviter undefinednull | |
| if (!type || !id) { | |
| console.error('[Sentinel Core] Paramètres manquants:', { type, id }); | |
| logEvent(`Erreur: paramètres invalides (${type || 'undefined'}, ${id || 'null'})`, 'system'); | |
| return; | |
| } | |
| try { | |
| const result = processEvent(type, id); | |
| console.log("Agent Output:", result); | |
| // Flash effect on buttons could be added here | |
| } catch (err) { | |
| console.error('[Sentinel Core] Simulation error:', err); | |
| logEvent(`Erreur système interne: ${err.message}`, 'system'); | |
| } | |
| }; | |
| window.resetSystem = function() { | |
| articles = {}; | |
| latentDoubts.clear(); | |
| confirmedAlerts = []; | |
| resolvedDoubts = 0; | |
| sessionStartTime = Date.now(); | |
| if (eventLog) eventLog.innerHTML = '<div class="text-slate-500 italic">Système réinitialisé.</div>'; | |
| if (latentList) latentList.innerHTML = ''; | |
| if (alertList) alertList.innerHTML = ''; | |
| document.querySelectorAll('.map-dot').forEach(el => el.remove()); | |
| updateCounts(); | |
| updateStats(); | |
| logEvent("Redémarrage du noyau Sentinel v1.0", 'system'); | |
| }; | |
| window.exportAlerts = function() { | |
| const data = { | |
| exportDate: getTimestamp(), | |
| totalAlerts: confirmedAlerts.length, | |
| resolvedDoubts: resolvedDoubts, | |
| alerts: confirmedAlerts | |
| }; | |
| const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `sentinel-alerts-${Date.now()}.json`; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| logEvent("Export des alertes généré avec succès", 'system'); | |
| }; | |
| // Stats Update | |
| function updateStats() { | |
| if (!statTracked || !statRate || !statResolved || !statTime) return; | |
| const trackedCount = Object.keys(articles).length; | |
| statTracked.innerText = trackedCount; | |
| const totalDoubts = resolvedDoubts + latentDoubts.size + confirmedAlerts.length; | |
| const rate = totalDoubts > 0 ? Math.round((resolvedDoubts / totalDoubts) * 100) : 100; | |
| statRate.innerText = `${rate}%`; | |
| statResolved.innerText = resolvedDoubts; | |
| const elapsed = Math.floor((Date.now() - sessionStartTime) / 1000); | |
| const minutes = Math.floor(elapsed / 60).toString().padStart(2, '0'); | |
| const seconds = (elapsed % 60).toString().padStart(2, '0'); | |
| statTime.innerText = `${minutes}:${seconds}`; | |
| } | |
| // Update timer every second | |
| setInterval(updateStats, 1000); | |
| // Cleanup map dots periodically | |
| setInterval(cleanupMapDots, 5000); | |
| // Init | |
| document.addEventListener('DOMContentLoaded', () => { | |
| initDOMElements(); | |
| logEvent("Initialisation du module Démarque Inconnue...", 'system'); | |
| logEvent("Connexion aux flux capteurs : OK", 'system'); | |
| logEvent("Moteur de détection prêt", 'system'); | |
| updateStats(); | |
| }); | |
| // Ensure all global functions are properly exposed | |
| window.simulateEvent = simulateEvent; | |
| window.resetSystem = resetSystem; | |
| window.exportAlerts = exportAlerts; | |