diff --git a/src/Controller/AdminController.php b/src/Controller/AdminController.php index 29462144..3747c230 100644 --- a/src/Controller/AdminController.php +++ b/src/Controller/AdminController.php @@ -446,6 +446,13 @@ final class AdminController extends AbstractController if ($count) $latestFollowups['places']['count'] = $count; if ($completion) $latestFollowups['places']['completion'] = $completion; + // Calculer la progression sur 7 jours pour chaque type + $progression7Days = []; + foreach ($types as $type) { + $progression7Days[$type] = \App\Service\FollowUpService::calculate7DayProgression($stats, $type); + } + $progression7Days['places'] = \App\Service\FollowUpService::calculate7DayProgression($stats, 'places'); + return $this->render('admin/stats.html.twig', [ 'stats' => $stats, 'commerces' => $commerces, @@ -460,6 +467,7 @@ final class AdminController extends AbstractController 'latestFollowups' => $latestFollowups, 'followup_labels' => \App\Service\FollowUpService::getFollowUpThemes(), 'followup_icons' => \App\Service\FollowUpService::getFollowUpIcons(), + 'progression7Days' => $progression7Days, ]); } @@ -530,6 +538,7 @@ final class AdminController extends AbstractController public function labourer(Request $request, string $insee_code, bool $updateExisting = true): Response { $deleteMissing = $request->query->getBoolean('deleteMissing', true); + $disableFollowUpCleanup = $request->query->getBoolean('disableFollowUpCleanup', false); $this->actionLogger->log('labourer', ['insee_code' => $insee_code]); @@ -890,7 +899,7 @@ final class AdminController extends AbstractController $this->entityManager->flush(); // Générer les suivis (followups) après la mise à jour des Places - $this->followUpService->generateCityFollowUps($stats, $this->motocultrice, $this->entityManager); + $this->followUpService->generateCityFollowUps($stats, $this->motocultrice, $this->entityManager, $disableFollowUpCleanup); $message = 'Labourage terminé avec succès. ' . $processedCount . ' nouveaux lieux traités.'; if ($updateExisting) { diff --git a/src/Service/FollowUpService.php b/src/Service/FollowUpService.php index f7a6c1b7..44564b79 100644 --- a/src/Service/FollowUpService.php +++ b/src/Service/FollowUpService.php @@ -8,7 +8,7 @@ use Doctrine\ORM\EntityManagerInterface; class FollowUpService { - public function generateCityFollowUps(Stats $stats, Motocultrice $motocultrice, EntityManagerInterface $em): void + public function generateCityFollowUps(Stats $stats, Motocultrice $motocultrice, EntityManagerInterface $em, bool $disableCleanup = false): void { $insee_code = $stats->getZone(); $elements = $motocultrice->followUpCity($insee_code) ?? []; @@ -71,13 +71,13 @@ class FollowUpService } else { $objects = []; } - $types[$type] = [ - 'label' => $label, - 'objects' => $objects - ]; + $types[$type] = ['objects' => $objects, 'label' => $label]; } + $types['places'] = ['objects' => [], 'label' => 'Lieux']; + $now = new \DateTime(); $persisted = 0; + foreach ($types as $type => $data) { // Suivi du nombre $followupCount = new CityFollowUp(); @@ -153,30 +153,27 @@ class FollowUpService }); } elseif ($type === 'advertising_board') { $completed = array_filter($data['objects'], function($el) { - // On considère complet si le tag "operator" ou "ref" est présent - return !empty($el['tags']['operator'] ?? null) || !empty($el['tags']['ref'] ?? null); + return !empty($el['tags']['operator'] ?? null) || !empty($el['tags']['contact:phone'] ?? null); }); } elseif ($type === 'building') { $completed = array_filter($data['objects'], function($el) { - // Complet si ref:FR:RNB est rempli - return !empty($el['tags']['ref:FR:RNB'] ?? null); + return !empty($el['tags']['name'] ?? null) || !empty($el['tags']['ref'] ?? null); }); } elseif ($type === 'email') { - $completed = $data['objects']; // Possède déjà un email ou contact:email + $completed = array_filter($data['objects'], function($el) { + return !empty($el['tags']['name'] ?? null) || !empty($el['tags']['phone'] ?? null); + }); } elseif ($type === 'bench') { $completed = array_filter($data['objects'], function($el) { - // Complet si le tag "material" ou "backrest" est présent return !empty($el['tags']['material'] ?? null) || !empty($el['tags']['backrest'] ?? null); }); } elseif ($type === 'waste_basket') { $completed = array_filter($data['objects'], function($el) { - // Complet si le tag "material" ou "ref" est présent - return !empty($el['tags']['material'] ?? null) || !empty($el['tags']['ref'] ?? null); + return !empty($el['tags']['waste'] ?? null) || !empty($el['tags']['recycling_type'] ?? null); }); } elseif ($type === 'street_lamp') { $completed = array_filter($data['objects'], function($el) { - // Complet si le tag "lamp_type" ou "ref" est présent - return !empty($el['tags']['lamp_type'] ?? null) || !empty($el['tags']['ref'] ?? null); + return !empty($el['tags']['lamp_type'] ?? null) || !empty($el['tags']['height'] ?? null); }); } elseif ($type === 'drinking_water') { $completed = array_filter($data['objects'], function($el) { @@ -226,38 +223,40 @@ class FollowUpService $em->flush(); $em->clear(); - // Suppression des mesures redondantes (même valeur consécutive, sauf la dernière) - $repo = $em->getRepository(CityFollowUp::class); - foreach (array_keys(self::getFollowUpThemes()) as $type) { - foreach (['_count', '_completion'] as $suffix) { - $name = $type . $suffix; - $followups = $repo->createQueryBuilder('f') - ->where('f.stats = :stats') - ->andWhere('f.name = :name') - ->setParameter('stats', $stats) - ->setParameter('name', $name) - ->orderBy('f.date', 'ASC') - ->getQuery()->getResult(); - $toDelete = []; - $prev = null; - $n = count($followups); - foreach ($followups as $i => $fu) { - // Si seulement 2 mesures, ne rien supprimer - if ($n == 2) { - break; + // Suppression des mesures redondantes (même valeur consécutive, sauf la dernière) - désactivée si $disableCleanup est true + if (!$disableCleanup) { + $repo = $em->getRepository(CityFollowUp::class); + foreach (array_keys(self::getFollowUpThemes()) as $type) { + foreach (['_count', '_completion'] as $suffix) { + $name = $type . $suffix; + $followups = $repo->createQueryBuilder('f') + ->where('f.stats = :stats') + ->andWhere('f.name = :name') + ->setParameter('stats', $stats) + ->setParameter('name', $name) + ->orderBy('f.date', 'ASC') + ->getQuery()->getResult(); + $toDelete = []; + $prev = null; + $n = count($followups); + foreach ($followups as $i => $fu) { + // Si seulement 2 mesures, ne rien supprimer + if ($n == 2) { + break; + } + if ($prev && $fu->getMeasure() === $prev->getMeasure() && $i < $n - 1) { + $toDelete[] = $prev; + } + $prev = $fu; } - if ($prev && $fu->getMeasure() === $prev->getMeasure() && $i < $n - 1) { - $toDelete[] = $prev; + foreach ($toDelete as $del) { + $em->remove($del); } - $prev = $fu; - } - foreach ($toDelete as $del) { - $em->remove($del); } } + $em->flush(); + $em->clear(); } - $em->flush(); - $em->clear(); } public function generateGlobalFollowUps(EntityManagerInterface $em): void @@ -428,7 +427,121 @@ class FollowUpService 'street_lamp' => 'nwr["highway"="street_lamp"](area.searchArea);', 'drinking_water' => 'nwr["amenity"="drinking_water"](area.searchArea);', 'tree' => 'nwr["natural"="tree"](area.searchArea);', - 'places' => '' + 'power_pole' => 'nwr["power"="pole"](area.searchArea);', ]; } + + /** + * Calcule la progression sur 7 jours pour un type donné + */ + public static function calculate7DayProgression(Stats $stats, string $type): array + { + $followups = $stats->getCityFollowUps(); + $now = new \DateTime(); + $refDate = clone $now; + $refDate->modify('-7 days'); + + // Récupérer toutes les mesures pour ce type + $countData = []; + $completionData = []; + + foreach ($followups as $fu) { + if ($fu->getName() === $type . '_count') { + $countData[] = [ + 'date' => $fu->getDate(), + 'value' => $fu->getMeasure() + ]; + } + if ($fu->getName() === $type . '_completion') { + $completionData[] = [ + 'date' => $fu->getDate(), + 'value' => $fu->getMeasure() + ]; + } + } + + // Trier par date + usort($countData, fn($a, $b) => $a['date'] <=> $b['date']); + usort($completionData, fn($a, $b) => $a['date'] <=> $b['date']); + + $countDelta = self::calculateDelta($countData, $refDate); + $completionDelta = self::calculateDelta($completionData, $refDate); + + return [ + 'count' => $countDelta, + 'completion' => $completionDelta + ]; + } + + /** + * Calcule le delta pour une série de données + */ + private static function calculateDelta(array $data, \DateTime $refDate): ?float + { + if (empty($data)) { + return null; + } + + $last = end($data)['value']; + + // Chercher la mesure exacte à la date de référence + $exactRef = null; + foreach (array_reverse($data) as $point) { + if ($point['date'] <= $refDate) { + $exactRef = $point['value']; + break; + } + } + + // Si on a trouvé une mesure exacte, l'utiliser + if ($exactRef !== null) { + return $last - $exactRef; + } + + // Sinon, chercher les deux mesures les plus proches pour faire une interpolation + $beforeRef = null; + $afterRef = null; + $beforeDate = null; + $afterDate = null; + + // Chercher la mesure juste avant la date de référence + foreach (array_reverse($data) as $point) { + if ($point['date'] < $refDate) { + $beforeRef = $point['value']; + $beforeDate = $point['date']; + break; + } + } + + // Chercher la mesure juste après la date de référence + foreach ($data as $point) { + if ($point['date'] > $refDate) { + $afterRef = $point['value']; + $afterDate = $point['date']; + break; + } + } + + // Si on a les deux mesures, faire une interpolation linéaire + if ($beforeRef !== null && $afterRef !== null && $beforeDate !== null && $afterDate !== null) { + $timeDiff = $afterDate->getTimestamp() - $beforeDate->getTimestamp(); + $refTimeDiff = $refDate->getTimestamp() - $beforeDate->getTimestamp(); + $ratio = $refTimeDiff / $timeDiff; + $interpolatedRef = $beforeRef + ($afterRef - $beforeRef) * $ratio; + return $last - $interpolatedRef; + } + + // Si on n'a qu'une mesure avant, l'utiliser + if ($beforeRef !== null) { + return $last - $beforeRef; + } + + // Si on n'a qu'une mesure après, l'utiliser + if ($afterRef !== null) { + return $last - $afterRef; + } + + // Si aucune mesure n'est disponible, retourner null + return null; + } } \ No newline at end of file diff --git a/src/Service/Motocultrice.php b/src/Service/Motocultrice.php index 909e0d67..68e3bc58 100644 --- a/src/Service/Motocultrice.php +++ b/src/Service/Motocultrice.php @@ -11,7 +11,7 @@ class Motocultrice public $overpass_base_places = ' ( - nw["amenity"~"^(restaurant|fast_food|cafe|fuel|pharmacy|bank|bar|hospital|post_office|clinic|pub|car_wash|ice_cream|driving_school|cinema|car_rental|nightclub|bureau_de_change|studio|internet_cafe|money_transfer|casino|vehicle_inspection|frozen_food|boat_rental|coworking_space|workshop|personal_service|camping|dancing_school|training|ski_school|ski_rental|dive_centre|driver_training|nursing_home|funeral_hall|doctors|dentist|theatre|kindergarten|language_school|stripclub|veterinary|convenience|supermarket|clothes|hairdresser|car_repair|bakery|beauty|car|hardware|mobile_phone|butcher|furniture|car_parts|alcohol|florist|scooter|variety_store|electronics|shoes|optician|jewelry|mall|gift|doityourself|greengrocer|books|bicycle|chemist|department_store|laundry|travel_agency|stationery|pet|sports|confectionery|tyres|cosmetics|computer|tailor|tobacco|storage_rental|dry_cleaning|trade|copyshop|motorcycle|funeral_directors|beverages|newsagent|garden_centre|massage|pastry|interior_decoration|general|deli|toys|houseware|wine|seafood|pawnbroker|tattoo|paint|wholesale|photo|second_hand|bed|kitchen|outdoor|fabric|antiques|coffee|gas|e-cigarette|perfumery|craft|hearing_aids|money_lender|appliance|electrical|tea|motorcycle_repair|boutique|baby_goods|bag|musical_instrument|dairy|pet_grooming|music|carpet|rental|fashion_accessories|cheese|chocolate|medical_supply|leather|sewing|cannabis|locksmith|games|video_games|hifi|window_blind|caravan|tool_hire|household_linen|bathroom_furnishing|shoe_repair|watches|nutrition_supplements|fishing|erotic|frame|grocery|boat|repair|weapons|gold_buyer|lighting|pottery|security|groundskeeping|herbalist|curtain|health_food|flooring|printer_ink|anime|camera|scuba_diving|candles|printing|garden_furniture|food|estate_agent|insurance|it|accountant|employment_agency|tax_advisor|financial|advertising_agency|logistics|newspaper|financial_advisor|consulting|travel_agent|coworking|moving_company|lawyer|architect|construction_company|credit_broker|graphic_design|property_management|cleaning)$"](area.searchArea); + nw["amenity"~"^(library|townhall|restaurant|fast_food|cafe|fuel|pharmacy|bank|bar|hospital|post_office|clinic|pub|car_wash|ice_cream|driving_school|cinema|car_rental|nightclub|bureau_de_change|studio|internet_cafe|money_transfer|casino|vehicle_inspection|frozen_food|boat_rental|coworking_space|workshop|personal_service|camping|dancing_school|training|ski_school|ski_rental|dive_centre|driver_training|nursing_home|funeral_hall|doctors|dentist|theatre|kindergarten|language_school|stripclub|veterinary|convenience|supermarket|clothes|hairdresser|car_repair|bakery|beauty|car|hardware|mobile_phone|butcher|furniture|car_parts|alcohol|florist|scooter|variety_store|electronics|shoes|optician|jewelry|mall|gift|doityourself|greengrocer|books|bicycle|chemist|department_store|laundry|travel_agency|stationery|pet|sports|confectionery|tyres|cosmetics|computer|tailor|tobacco|storage_rental|dry_cleaning|trade|copyshop|motorcycle|funeral_directors|beverages|newsagent|garden_centre|massage|pastry|interior_decoration|general|deli|toys|houseware|wine|seafood|pawnbroker|tattoo|paint|wholesale|photo|second_hand|bed|kitchen|outdoor|fabric|antiques|coffee|gas|e-cigarette|perfumery|craft|hearing_aids|money_lender|appliance|electrical|tea|motorcycle_repair|boutique|baby_goods|bag|musical_instrument|dairy|pet_grooming|music|carpet|rental|fashion_accessories|cheese|chocolate|medical_supply|leather|sewing|cannabis|locksmith|games|video_games|hifi|window_blind|caravan|tool_hire|household_linen|bathroom_furnishing|shoe_repair|watches|nutrition_supplements|fishing|erotic|frame|grocery|boat|repair|weapons|gold_buyer|lighting|pottery|security|groundskeeping|herbalist|curtain|health_food|flooring|printer_ink|anime|camera|scuba_diving|candles|printing|garden_furniture|food|estate_agent|insurance|it|accountant|employment_agency|tax_advisor|financial|advertising_agency|logistics|newspaper|financial_advisor|consulting|travel_agent|coworking|moving_company|lawyer|architect|construction_company|credit_broker|graphic_design|property_management|cleaning)$"](area.searchArea); nw["shop"]["shop"!~"vacant"](area.searchArea); nw["tourism"~"^(hotel|hostel|motel|wilderness_hut|yes|chalet|gallery|guest_house|museum|zoo|theme_park|aquarium|alpine_hut|apartment)$"](area.searchArea); nw["healthcare"](area.searchArea); diff --git a/templates/admin/followup_embed_graph.html.twig b/templates/admin/followup_embed_graph.html.twig index c20e6ce5..fb22b5ce 100644 --- a/templates/admin/followup_embed_graph.html.twig +++ b/templates/admin/followup_embed_graph.html.twig @@ -101,28 +101,99 @@ if (canvas) { // Affichage de la progression sur une semaine function getDelta(data, days) { if (!data.length) return null; + const now = new Date(data[data.length - 1].x); const refDate = new Date(now.getTime() - days * 24 * 60 * 60 * 1000); - let ref = null; + const last = data[data.length - 1].y; + + // Chercher la mesure exacte à la date de référence + let exactRef = null; for (let i = data.length - 1; i >= 0; i--) { const d = new Date(data[i].x); if (d <= refDate) { - ref = data[i].y; + exactRef = data[i].y; break; } } - const last = data[data.length - 1].y; - return ref !== null ? last - ref : null; + + // Si on a trouvé une mesure exacte, l'utiliser + if (exactRef !== null) { + return last - exactRef; + } + + // Sinon, chercher les deux mesures les plus proches pour faire une interpolation + let beforeRef = null; + let afterRef = null; + let beforeDate = null; + let afterDate = null; + + // Chercher la mesure juste avant la date de référence + for (let i = data.length - 1; i >= 0; i--) { + const d = new Date(data[i].x); + if (d < refDate) { + beforeRef = data[i].y; + beforeDate = d; + break; + } + } + + // Chercher la mesure juste après la date de référence + for (let i = 0; i < data.length; i++) { + const d = new Date(data[i].x); + if (d > refDate) { + afterRef = data[i].y; + afterDate = d; + break; + } + } + + // Si on a les deux mesures, faire une interpolation linéaire + if (beforeRef !== null && afterRef !== null && beforeDate !== null && afterDate !== null) { + const timeDiff = afterDate.getTime() - beforeDate.getTime(); + const refTimeDiff = refDate.getTime() - beforeDate.getTime(); + const ratio = refTimeDiff / timeDiff; + const interpolatedRef = beforeRef + (afterRef - beforeRef) * ratio; + return last - interpolatedRef; + } + + // Si on n'a qu'une mesure avant, l'utiliser + if (beforeRef !== null) { + return last - beforeRef; + } + + // Si on n'a qu'une mesure après, l'utiliser + if (afterRef !== null) { + return last - afterRef; + } + + // Si aucune mesure n'est disponible, retourner null + return null; } + function formatDelta(val) { if (val === null) return 'Pas de données'; if (val === 0) return '0'; return (val > 0 ? '+' : '') + val; } + const delta7dCount = getDelta(countData, 7); +const delta7dCompletion = getDelta(completionData, 7); + const infoDiv = document.createElement('div'); infoDiv.className = 'mt-3 alert ' + (delta7dCount === null ? 'alert-secondary' : 'alert-info'); -infoDiv.innerHTML = `Progression sur 7 jours : ${delta7dCount === null ? 'Aucune donnée' : (delta7dCount > 0 ? '+' + delta7dCount : delta7dCount === 0 ? '0' : delta7dCount)}`; + +let progressionText = ''; +if (delta7dCount === null) { + progressionText = 'Aucune donnée'; +} else { + const countText = delta7dCount > 0 ? '+' + delta7dCount : delta7dCount === 0 ? '0' : delta7dCount; + const completionText = delta7dCompletion !== null ? + (delta7dCompletion > 0 ? '+' + delta7dCompletion.toFixed(1) : delta7dCompletion === 0 ? '0' : delta7dCompletion.toFixed(1)) + '%' : + 'N/A'; + progressionText = `${countText} objets, ${completionText} complétion`; +} + +infoDiv.innerHTML = `Progression sur 7 jours : ${progressionText}`; canvas.parentNode.appendChild(infoDiv); {% endblock %} \ No newline at end of file diff --git a/templates/admin/followup_graph.html.twig b/templates/admin/followup_graph.html.twig index 7a2fb63e..eabbe92e 100644 --- a/templates/admin/followup_graph.html.twig +++ b/templates/admin/followup_graph.html.twig @@ -12,6 +12,9 @@ Labourer la zone + + Labourer (sans nettoyage) + Voir les stats diff --git a/templates/admin/labourage_results.html.twig b/templates/admin/labourage_results.html.twig index c8e7c5e9..41921a02 100644 --- a/templates/admin/labourage_results.html.twig +++ b/templates/admin/labourage_results.html.twig @@ -11,6 +11,7 @@
diff --git a/templates/admin/stats.html.twig b/templates/admin/stats.html.twig index 84bc6071..43a63f8c 100644 --- a/templates/admin/stats.html.twig +++ b/templates/admin/stats.html.twig @@ -76,6 +76,9 @@