osm-labo/src/Controller/AdminController.php

2072 lines
90 KiB
PHP
Raw Normal View History

2025-05-26 11:55:44 +02:00
<?php
2025-06-27 00:35:11 +02:00
2025-05-26 11:55:44 +02:00
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use App\Entity\Place;
use App\Entity\Stats;
2025-06-17 19:38:44 +02:00
use App\Entity\StatsHistory;
2025-06-27 00:35:11 +02:00
use App\Service\Motocultrice;
use App\Service\BudgetService;
use Doctrine\ORM\EntityManagerInterface;
2025-06-21 11:28:31 +02:00
use Symfony\Component\HttpFoundation\Request;
use function uuid_create;
2025-06-24 00:29:15 +02:00
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpFoundation\JsonResponse;
use Twig\Environment;
2025-06-26 23:14:22 +02:00
use App\Service\ActionLogger;
2025-06-26 23:40:37 +02:00
use DateTime;
2025-06-29 19:24:00 +02:00
use App\Service\FollowUpService;
2025-07-15 21:22:02 +02:00
use phpDocumentor\Reflection\DocBlock\Tags\Var_;
2025-05-26 11:55:44 +02:00
final class AdminController extends AbstractController
{
2025-06-27 00:35:11 +02:00
2025-06-29 19:24:00 +02:00
private FollowUpService $followUpService;
2025-07-07 23:30:09 +02:00
// Flag pour activer/désactiver la suppression de ville
private $allowDeleteCity = false;
2025-06-29 19:24:00 +02:00
public function __construct(
private EntityManagerInterface $entityManager,
private Motocultrice $motocultrice,
private BudgetService $budgetService,
2025-06-26 23:14:22 +02:00
private Environment $twig,
2025-06-29 19:24:00 +02:00
private ActionLogger $actionLogger,
FollowUpService $followUpService
) {
$this->followUpService = $followUpService;
}
#[Route('/admin/labourer-toutes-les-zones', name: 'app_admin_labourer_tout')]
public function labourer_tout(): Response
{
2025-06-26 23:40:37 +02:00
2025-06-27 00:35:11 +02:00
2025-06-26 23:40:37 +02:00
$this->actionLogger->log('labourer_toutes_les_zones', []);
2025-06-27 00:35:11 +02:00
$updateExisting = true;
2025-06-27 00:35:11 +02:00
$stats_all = $this->entityManager->getRepository(Stats::class)->findAll();
echo 'on a trouvé ' . count($stats_all) . ' zones à labourer<br>';
2025-06-27 00:35:11 +02:00
foreach ($stats_all as $stats) {
echo '<br> on laboure la zone ' . $stats->getZone() . ' ';
$processedCount = 0;
$updatedCount = 0;
$insee_code = $stats->getZone();
// Vérifier si le code INSEE est un nombre valide
2025-06-27 00:35:11 +02:00
// Vérifier si les stats ont été modifiées il y a moins de 24h
if ($stats->getDateModified() !== null) {
$now = new \DateTime();
$diff = $now->diff($stats->getDateModified());
$hours = $diff->h + ($diff->days * 24);
if ($hours < 24) {
echo 'Stats modifiées il y a moins de 24h - on passe au suivant<br>';
continue;
}
2025-06-27 00:35:11 +02:00
}
if (!is_numeric($insee_code) || $insee_code == 'undefined' || $insee_code == '') {
echo 'Code INSEE invalide : ' . $insee_code . ' - on passe au suivant<br>';
continue;
}
$places_overpass = $this->motocultrice->labourer($stats->getZone());
$places = $places_overpass;
foreach ($places as $placeData) {
2025-06-27 00:35:11 +02:00
// Vérifier si le lieu existe déjà
$existingPlace = $this->entityManager->getRepository(Place::class)
->findOneBy(['osmId' => $placeData['id']]);
2025-06-27 00:35:11 +02:00
if (!$existingPlace) {
$place = new Place();
$place->setOsmId($placeData['id'])
2025-06-27 00:35:11 +02:00
->setOsmKind($placeData['type'])
->setZipCode($insee_code)
->setUuidForUrl($this->motocultrice->uuid_create())
->setModifiedDate(new \DateTime())
->setStats($stats)
->setDead(false)
->setOptedOut(false)
->setMainTag($this->motocultrice->find_main_tag($placeData['tags']) ?? '')
->setStreet($this->motocultrice->find_street($placeData['tags']) ?? '')
->setHousenumber($this->motocultrice->find_housenumber($placeData['tags']) ?? '')
->setSiret($this->motocultrice->find_siret($placeData['tags']) ?? '')
->setAskedHumainsSupport(false)
->setLastContactAttemptDate(null)
->setPlaceCount(0)
// ->setOsmData($placeData['modified'] ?? null)
2025-06-27 00:35:11 +02:00
;
// Mettre à jour les données depuis Overpass
$place->update_place_from_overpass_data($placeData);
2025-06-27 00:35:11 +02:00
$this->entityManager->persist($place);
$stats->addPlace($place);
$processedCount++;
// Générer le contenu de l'email avec le template
$emailContent = $this->twig->render('admin/email_content.html.twig', ['place' => $place]);
$place->setEmailContent($emailContent);
} elseif ($updateExisting) {
// Mettre à jour les données depuis Overpass uniquement si updateExisting est true
$existingPlace->update_place_from_overpass_data($placeData);
2025-06-21 10:26:55 +02:00
$stats->addPlace($existingPlace);
$this->entityManager->persist($existingPlace);
$updatedCount++;
}
2025-06-27 00:35:11 +02:00
}
// mettre à jour les stats
// Récupérer tous les commerces de la zone
$commerces = $this->entityManager->getRepository(Place::class)->findBy(['zip_code' => $insee_code]);
// Récupérer les stats existantes pour la zone
2025-07-15 21:22:02 +02:00
$stats_exist = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]);
if ($stats_exist) {
$stats = $stats_exist;
} else {
$stats = new Stats();
2025-07-15 21:22:02 +02:00
dump('nouvelle stat', $insee_code);
die();
$stats->setZone($insee_code);
}
2025-06-27 00:35:11 +02:00
$urls = $stats->getAllCTCUrlsMap();
$statsHistory = $this->entityManager->getRepository(StatsHistory::class)
->createQueryBuilder('sh')
->where('sh.stats = :stats')
->setParameter('stats', $stats)
->orderBy('sh.id', 'DESC')
->setMaxResults(365)
->getQuery()
->getResult();
2025-06-27 00:35:11 +02:00
// Calculer les statistiques
$calculatedStats = $this->motocultrice->calculateStats($commerces);
// Mettre à jour les stats pour la zone donnée
$stats->setPlacesCount($calculatedStats['places_count']);
$stats->setAvecHoraires($calculatedStats['counters']['avec_horaires']);
$stats->setAvecAdresse($calculatedStats['counters']['avec_adresse']);
$stats->setAvecSite($calculatedStats['counters']['avec_site']);
$stats->setAvecAccessibilite($calculatedStats['counters']['avec_accessibilite']);
$stats->setAvecNote($calculatedStats['counters']['avec_note']);
2025-06-27 00:35:11 +02:00
$stats->setCompletionPercent($calculatedStats['completion_percent']);
// Associer les stats à chaque commerce
foreach ($commerces as $commerce) {
$commerce->setStats($stats);
2025-07-05 16:53:12 +02:00
// Injection de l'emoji pour le template
$mainTag = $commerce->getMainTag();
$emoji = self::getTagEmoji($mainTag);
$commerce->tagEmoji = $emoji;
$this->entityManager->persist($commerce);
}
$stats->computeCompletionPercent();
2025-06-19 12:49:30 +02:00
// Calculer les statistiques de fraîcheur des données OSM
$timestamps = [];
foreach ($stats->getPlaces() as $place) {
if ($place->getOsmDataDate()) {
$timestamps[] = $place->getOsmDataDate()->getTimestamp();
}
}
2025-06-27 00:35:11 +02:00
2025-06-19 12:49:30 +02:00
if (!empty($timestamps)) {
// Date la plus ancienne (min)
$minTimestamp = min($timestamps);
$stats->setOsmDataDateMin(new \DateTime('@' . $minTimestamp));
2025-06-27 00:35:11 +02:00
2025-06-19 12:49:30 +02:00
// Date la plus récente (max)
$maxTimestamp = max($timestamps);
$stats->setOsmDataDateMax(new \DateTime('@' . $maxTimestamp));
2025-06-27 00:35:11 +02:00
2025-06-19 12:49:30 +02:00
// Date moyenne
$avgTimestamp = array_sum($timestamps) / count($timestamps);
$stats->setOsmDataDateAvg(new \DateTime('@' . (int)$avgTimestamp));
}
2025-06-27 00:35:11 +02:00
if ($stats->getDateCreated() == null) {
2025-06-19 12:49:30 +02:00
$stats->setDateCreated(new \DateTime());
}
$stats->setDateModified(new \DateTime());
// Créer un historique des statistiques
$statsHistory = new StatsHistory();
$statsHistory->setDate(new \DateTime())
2025-06-27 00:35:11 +02:00
->setStats($stats);
2025-06-19 12:49:30 +02:00
// Compter les Places avec email et SIRET
$placesWithEmail = 0;
$placesWithSiret = 0;
foreach ($stats->getPlaces() as $place) {
if ($place->getEmail() && $place->getEmail() !== '') {
$placesWithEmail++;
}
if ($place->getSiret() && $place->getSiret() !== '') {
$placesWithSiret++;
}
}
$statsHistory->setPlacesCount($stats->getPlaces()->count())
2025-06-27 00:35:11 +02:00
->setOpeningHoursCount($stats->getAvecHoraires())
->setAddressCount($stats->getAvecAdresse())
->setWebsiteCount($stats->getAvecSite())
->setSiretCount($placesWithSiret)
->setEmailsCount($placesWithEmail)
->setCompletionPercent($stats->getCompletionPercent())
->setStats($stats);
2025-06-19 12:49:30 +02:00
$this->entityManager->persist($statsHistory);
2025-06-27 00:35:11 +02:00
$this->entityManager->persist($stats);
2025-06-19 12:49:30 +02:00
$this->entityManager->flush();
2025-06-27 00:35:11 +02:00
2025-06-29 10:09:47 +02:00
// Générer les contenus d'email après le flush pour éviter les problèmes de mémoire
$placesToUpdate = $this->entityManager->getRepository(Place::class)->findBy(['zip_code' => $insee_code]);
foreach ($placesToUpdate as $place) {
if (!$place->getEmailContent()) {
$emailContent = $this->twig->render('admin/email_content.html.twig', ['place' => $place]);
$place->setEmailContent($emailContent);
$this->entityManager->persist($place);
}
}
$this->entityManager->flush();
2025-06-29 19:24:00 +02:00
// Générer les suivis (followups) après la mise à jour des Places
$themes = \App\Service\FollowUpService::getFollowUpThemes();
foreach (array_keys($themes) as $theme) {
$this->followUpService->generateCityFollowUps($stats, $this->motocultrice, $this->entityManager, true, $theme);
2025-06-19 12:49:30 +02:00
}
$this->entityManager->flush();
2025-07-15 23:23:32 +02:00
// Après le flush, vérifier s'il n'y a aucun commerce compté
if ($stats->getPlacesCount() < 2) {
// Récupérer les lieux via Motocultrice
try {
$placesData = $this->motocultrice->labourer($insee_code);
$places = $stats->getPlaces();
foreach ($places as $place) {
// Chercher les données correspondantes par OSM ID
foreach ($placesData as $placeData) {
if ($place->getOsmId() == $placeData['id']) {
// Mettre à jour les tags et coordonnées
$place->update_place_from_overpass_data($placeData);
$place->setStat($stats);
$stats->addPlace($place);
$this->entityManager->persist($place);
break;
}
}
}
$this->entityManager->flush();
} catch (\Exception $e) {
// Ignorer les erreurs silencieusement
}
}
return $this->redirectToRoute('app_admin_stats', ['insee_code' => $insee_code]);
}
2025-06-27 00:35:11 +02:00
$this->entityManager->flush();
2025-06-27 00:35:11 +02:00
$this->addFlash('success', 'Labourage des ' . count($stats_all) . ' zones terminé avec succès.');
return $this->redirectToRoute('app_public_dashboard');
}
2025-05-26 11:55:44 +02:00
#[Route('/admin', name: 'app_admin')]
public function index(): Response
{
return $this->render('admin/index.html.twig', [
'controller_name' => 'AdminController',
]);
}
2025-06-30 15:03:37 +02:00
#[Route('/admin/stats/{insee_code}', name: 'app_admin_stats', requirements: ['insee_code' => '\\d+'])]
public function stats(string $insee_code): Response
{
2025-06-17 18:27:19 +02:00
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]);
2025-06-26 18:20:43 +02:00
if (!$stats) {
2025-07-15 21:22:02 +02:00
$this->addFlash('error', '1 Aucune stats trouvée pour ce code INSEE. Veuillez d\'abord ajouter la ville.');
return $this->redirectToRoute('app_admin_import_stats');
2025-06-26 18:20:43 +02:00
}
2025-06-30 15:03:37 +02:00
$followups = $stats->getCityFollowUps();
$refresh = false;
if (!$followups->isEmpty()) {
$latest = null;
foreach ($followups as $fu) {
if ($latest === null || $fu->getDate() > $latest->getDate()) {
$latest = $fu;
}
}
if ($latest && $latest->getDate() < (new \DateTime('-1 day'))) {
$refresh = true;
}
} else {
$refresh = true;
}
if ($refresh) {
2025-06-29 19:24:00 +02:00
$this->followUpService->generateCityFollowUps($stats, $this->motocultrice, $this->entityManager);
2025-06-30 15:03:37 +02:00
$followups = $stats->getCityFollowUps();
2025-06-29 19:24:00 +02:00
}
$commerces = $stats->getPlaces();
2025-06-26 23:40:37 +02:00
$this->actionLogger->log('stats_de_ville', ['insee_code' => $insee_code, 'nom' => $stats->getZone()]);
// Récupérer tous les commerces de la zone
// $commerces = $this->entityManager->getRepository(Place::class)->findBy(['zip_code' => $insee_code, 'dead' => false]);
2025-06-27 00:35:11 +02:00
if (!$stats) {
2025-06-21 11:28:31 +02:00
// Si aucune stat n'existe, on en crée une vide pour éviter les erreurs, mais sans la sauvegarder
$stats = new Stats();
$stats->setZone($insee_code);
2025-06-21 11:28:31 +02:00
$stats->setName('Nouvelle zone non labourée');
}
2025-06-21 11:28:31 +02:00
$urls = $stats->getAllCTCUrlsMap();
$statsHistory = $this->entityManager->getRepository(StatsHistory::class)
->createQueryBuilder('sh')
->where('sh.stats = :stats')
->setParameter('stats', $stats)
->orderBy('sh.id', 'DESC')
2025-06-26 23:40:37 +02:00
->setMaxResults(100)
2025-06-21 11:28:31 +02:00
->getQuery()
2025-06-27 00:35:11 +02:00
->getResult();
2025-06-21 11:28:31 +02:00
// Données pour le graphique des modifications par trimestre
$modificationsByQuarter = [];
foreach ($commerces as $commerce) {
2025-06-21 11:28:31 +02:00
if ($commerce->getOsmDataDate()) {
$date = $commerce->getOsmDataDate();
$year = $date->format('Y');
$quarter = ceil($date->format('n') / 3);
$key = $year . '-Q' . $quarter;
if (!isset($modificationsByQuarter[$key])) {
$modificationsByQuarter[$key] = 0;
}
$modificationsByQuarter[$key]++;
2025-06-19 12:49:30 +02:00
}
}
2025-06-21 11:28:31 +02:00
ksort($modificationsByQuarter); // Trier par clé (année-trimestre)
2025-06-19 12:49:30 +02:00
$geojson = [
'type' => 'FeatureCollection',
'features' => []
];
foreach ($commerces as $commerce) {
if ($commerce->getLat() && $commerce->getLon()) {
$geojson['features'][] = [
'type' => 'Feature',
'geometry' => [
'type' => 'Point',
'coordinates' => [$commerce->getLon(), $commerce->getLat()]
],
'properties' => [
'id' => $commerce->getOsmId(),
'name' => $commerce->getName(),
'main_tag' => $commerce->getMainTag(),
'address' => $commerce->getStreet() . ' ' . $commerce->getHousenumber(),
'note' => $commerce->getNoteContent(),
'osm_url' => 'https://www.openstreetmap.org/' . $commerce->getOsmKind() . '/' . $commerce->getOsmId()
]
];
}
}
2025-05-27 12:17:46 +02:00
2025-06-27 11:14:27 +02:00
// Générer le podium local des contributeurs OSM pour cette ville
$placeRepo = $this->entityManager->getRepository(\App\Entity\Place::class);
$qb = $placeRepo->createQueryBuilder('p')
->select(
'p.osm_user',
'COUNT(p.id) as nb',
'AVG((CASE WHEN p.has_opening_hours = true THEN 1 ELSE 0 END) +'
. ' (CASE WHEN p.has_address = true THEN 1 ELSE 0 END) +'
. ' (CASE WHEN p.has_website = true THEN 1 ELSE 0 END) +'
. ' (CASE WHEN p.has_wheelchair = true THEN 1 ELSE 0 END) +'
. ' (CASE WHEN p.has_note = true THEN 1 ELSE 0 END)) / 5 * 100 as completion_moyen'
)
->where('p.osm_user IS NOT NULL')
->andWhere("p.osm_user != ''")
->andWhere('p.stats = :stats')
->setParameter('stats', $stats)
->groupBy('p.osm_user')
->orderBy('nb', 'DESC')
->setMaxResults(100);
$podium_local = $qb->getQuery()->getResult();
// Calcul du score pondéré et normalisation locale
$maxPondere = 0;
foreach ($podium_local as &$row) {
$row['completion_moyen'] = $row['completion_moyen'] !== null ? round($row['completion_moyen'], 1) : null;
$row['completion_pondere'] = ($row['completion_moyen'] !== null && $row['nb'] > 0)
? round($row['completion_moyen'] * $row['nb'], 1)
: null;
if ($row['completion_pondere'] !== null && $row['completion_pondere'] > $maxPondere) {
$maxPondere = $row['completion_pondere'];
}
}
unset($row);
2025-07-05 16:15:56 +02:00
2025-06-29 11:29:48 +02:00
// Normalisation des scores pondérés entre 0 et 100
foreach ($podium_local as &$row) {
if ($maxPondere > 0 && $row['completion_pondere'] !== null) {
$row['completion_pondere_normalisee'] = round($row['completion_pondere'] / $maxPondere * 100, 1);
} else {
$row['completion_pondere_normalisee'] = null;
2025-06-27 11:14:27 +02:00
}
}
2025-06-29 11:29:48 +02:00
unset($row);
2025-07-05 16:15:56 +02:00
2025-06-29 11:29:48 +02:00
// Tri décroissant sur le score normalisé
2025-06-27 11:14:27 +02:00
usort($podium_local, function ($a, $b) {
return ($b['completion_pondere_normalisee'] ?? 0) <=> ($a['completion_pondere_normalisee'] ?? 0);
});
// Récupérer les derniers followups pour chaque type
$latestFollowups = [];
2025-06-29 20:02:51 +02:00
$types = array_keys(\App\Service\FollowUpService::getFollowUpThemes());
foreach ($types as $type) {
$count = null;
$completion = null;
foreach ($stats->getCityFollowUps() as $fu) {
if ($fu->getName() === $type . '_count') {
2025-06-29 20:02:51 +02:00
if ($count === null) {
$count = $fu;
} else if ($fu->getDate() > $count->getDate()) {
$count = $fu;
}
}
if ($fu->getName() === $type . '_completion') {
2025-06-29 20:02:51 +02:00
if ($completion === null) {
$completion = $fu;
} else if ($fu->getDate() > $completion->getDate()) {
$completion = $fu;
}
}
}
$latestFollowups[$type] = [];
if ($count) $latestFollowups[$type]['count'] = $count;
if ($completion) $latestFollowups[$type]['completion'] = $completion;
}
// Pour les lieux (places_count et places_completion)
$count = null;
$completion = null;
foreach ($stats->getCityFollowUps() as $fu) {
if ($fu->getName() === 'places_count') {
2025-06-29 20:02:51 +02:00
if ($count === null) {
$count = $fu;
} else if ($fu->getDate() > $count->getDate()) {
$count = $fu;
}
}
if ($fu->getName() === 'places_completion') {
2025-06-29 20:02:51 +02:00
if ($completion === null) {
$completion = $fu;
} else if ($fu->getDate() > $completion->getDate()) {
$completion = $fu;
}
}
}
$latestFollowups['places'] = [];
if ($count) $latestFollowups['places']['count'] = $count;
if ($completion) $latestFollowups['places']['completion'] = $completion;
2025-07-05 10:29:53 +02:00
// 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');
2025-07-16 17:00:09 +02:00
2025-07-14 18:17:41 +02:00
// --- Ajout : mesures CTC CityFollowUp pour le graphique d'évolution ---
$ctc_completion_series = [];
foreach ($stats->getCityFollowUps() as $fu) {
// On ne prend que les types *_count importés CTC (name_count, hours_count, etc.)
if (preg_match('/^(name|hours|website|address|siret)_count$/', $fu->getName())) {
$ctc_completion_series[$fu->getName()][] = [
'date' => $fu->getDate()->format('Y-m-d'),
'value' => $fu->getMeasure(),
];
}
}
// Tri par date dans chaque série
foreach ($ctc_completion_series as &$points) {
usort($points, function($a, $b) {
return strcmp($a['date'], $b['date']);
});
}
unset($points);
2025-07-05 10:29:53 +02:00
return $this->render('admin/stats.html.twig', [
'stats' => $stats,
2025-06-21 11:28:31 +02:00
'commerces' => $commerces,
'urls' => $urls,
'geojson' => json_encode($geojson),
2025-06-21 11:28:31 +02:00
'modificationsByQuarter' => json_encode($modificationsByQuarter),
'maptiler_token' => $_ENV['MAPTILER_TOKEN'],
2025-06-17 18:27:19 +02:00
'statsHistory' => $statsHistory,
2025-06-18 00:41:24 +02:00
'CTC_urls' => $urls,
2025-06-27 11:14:27 +02:00
'overpass' => '',
'podium_local' => $podium_local,
2025-06-29 19:24:00 +02:00
'latestFollowups' => $latestFollowups,
'followup_labels' => \App\Service\FollowUpService::getFollowUpThemes(),
'followup_icons' => \App\Service\FollowUpService::getFollowUpIcons(),
2025-07-05 16:15:56 +02:00
'progression7Days' => $progression7Days,
2025-07-05 15:25:33 +02:00
'all_types' => \App\Service\FollowUpService::getFollowUpThemes(),
2025-07-05 16:53:12 +02:00
'getTagEmoji' => [self::class, 'getTagEmoji'],
'completion_tags' => \App\Service\FollowUpService::getFollowUpCompletionTags(),
2025-07-14 18:17:41 +02:00
'ctc_completion_series' => $ctc_completion_series,
]);
}
2025-07-05 15:25:33 +02:00
#[Route('/admin/stats/{insee_code}/followup-graph/{theme}', name: 'admin_followup_theme_graph', requirements: ['insee_code' => '\d+'])]
2025-07-05 12:37:01 +02:00
public function followupThemeGraph(string $insee_code, string $theme): Response
{
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]);
if (!$stats) {
2025-07-15 21:22:02 +02:00
$this->addFlash('error', '2 Aucune stats trouvée pour ce code INSEE.');
2025-07-05 12:37:01 +02:00
return $this->redirectToRoute('app_admin');
}
$themes = \App\Service\FollowUpService::getFollowUpThemes();
if (!isset($themes[$theme])) {
$this->addFlash('error', 'Thème non reconnu.');
return $this->redirectToRoute('app_admin_stats', ['insee_code' => $insee_code]);
}
// Récupérer toutes les données de followup pour ce thème
$followups = $stats->getCityFollowUps();
$countData = [];
$completionData = [];
2025-07-05 16:15:56 +02:00
2025-07-05 12:37:01 +02:00
foreach ($followups as $fu) {
if ($fu->getName() === $theme . '_count') {
$countData[] = [
'date' => $fu->getDate()->format('Y-m-d'),
'value' => $fu->getMeasure()
];
}
if ($fu->getName() === $theme . '_completion') {
$completionData[] = [
'date' => $fu->getDate()->format('Y-m-d'),
'value' => $fu->getMeasure()
];
}
}
// Trier par date
usort($countData, fn($a, $b) => $a['date'] <=> $b['date']);
usort($completionData, fn($a, $b) => $a['date'] <=> $b['date']);
2025-07-05 15:25:33 +02:00
// Récupérer les objets du thème (Place) pour la ville
$places = $this->entityManager->getRepository(Place::class)->findBy(['zip_code' => $insee_code]);
$motocultrice = $this->motocultrice;
$objects = [];
// Récupérer la correspondance thème <-> requête Overpass
$themeQueries = \App\Service\FollowUpService::getFollowUpOverpassQueries();
$overpass_type_query = $themeQueries[$theme] ?? '';
if ($overpass_type_query) {
$overpass_query = "[out:json][timeout:60];\narea[\"ref:INSEE\"=\"$insee_code\"]->.searchArea;\n($overpass_type_query);\n(._;>;);\nout meta;\n>;";
$josm_url = 'http://127.0.0.1:8111/import?url=https://overpass-api.de/api/interpreter?data=' . urlencode($overpass_query);
} else {
$josm_url = null;
}
// Fonction utilitaire pour extraire clé/valeur de la requête Overpass
2025-07-05 16:15:56 +02:00
$extractTag = function ($query) {
2025-07-05 15:25:33 +02:00
if (preg_match('/\\[([a-zA-Z0-9:_-]+)\\]="([^"]+)"/', $query, $matches)) {
return [$matches[1], $matches[2]];
}
return [null, null];
};
list($tagKey, $tagValue) = $extractTag($themeQueries[$theme] ?? '');
2025-07-05 17:47:00 +02:00
// DEBUG : journaliser les main_tag et le filtrage
$all_main_tags = array_map(fn($p) => $p->getMainTag(), $places);
$debug_info = [
'theme' => $theme,
'tagKey' => $tagKey,
'tagValue' => $tagValue,
'main_tags' => $all_main_tags,
'places_count' => count($places),
];
$debug_filtered = [];
2025-07-05 15:25:33 +02:00
foreach ($places as $place) {
$match = false;
2025-07-05 17:35:20 +02:00
$main_tag = $place->getMainTag();
// Cas particuliers multi-valeurs (ex: healthcare)
2025-07-05 15:25:33 +02:00
if ($theme === 'healthcare') {
if ($main_tag && (
str_starts_with($main_tag, 'healthcare=') ||
in_array($main_tag, [
'amenity=doctors',
'amenity=pharmacy',
'amenity=hospital',
'amenity=clinic',
'amenity=social_facility'
])
)) {
$match = true;
}
2025-07-05 17:35:20 +02:00
} else {
// Détection générique : si le mainTag correspond à la clé/valeur du thème
if ($tagKey && $tagValue && $main_tag === "$tagKey=$tagValue") {
2025-07-05 15:25:33 +02:00
$match = true;
}
}
2025-07-05 17:35:20 +02:00
// Ajouter l'objet si match
2025-07-05 15:25:33 +02:00
if ($match && $place->getLat() && $place->getLon()) {
$objects[] = [
'id' => $place->getOsmId(),
'osm_kind' => $place->getOsmKind(),
'lat' => $place->getLat(),
'lon' => $place->getLon(),
'name' => $place->getName(),
2025-07-05 16:15:56 +02:00
'tags' => ['main_tag' => $place->getMainTag()],
2025-07-05 15:25:33 +02:00
'is_complete' => !empty($place->getName()),
'osm_url' => 'https://www.openstreetmap.org/' . $place->getOsmKind() . '/' . $place->getOsmId(),
'uuid' => $place->getUuidForUrl(),
'zip_code' => $place->getZipCode(),
2025-07-05 15:25:33 +02:00
];
2025-07-05 17:47:00 +02:00
$debug_filtered[] = $main_tag;
2025-07-05 15:25:33 +02:00
}
}
2025-07-05 17:47:00 +02:00
$debug_info['filtered_count'] = count($objects);
$debug_info['filtered_main_tags'] = $debug_filtered;
if (property_exists($this, 'actionLogger') && $this->actionLogger) {
$this->actionLogger->log('[DEBUG][followupThemeGraph]', $debug_info);
} else {
error_log('[DEBUG][followupThemeGraph] ' . json_encode($debug_info));
}
2025-07-05 15:25:33 +02:00
$geojson = [
'type' => 'FeatureCollection',
2025-07-05 16:15:56 +02:00
'features' => array_map(function ($obj) {
2025-07-05 15:25:33 +02:00
return [
'type' => 'Feature',
'geometry' => [
'type' => 'Point',
'coordinates' => [$obj['lon'], $obj['lat']]
],
'properties' => $obj
];
}, $objects)
];
// Centre de la carte : centre géographique des objets ou de la ville
$center = null;
if (count($objects) > 0) {
$lat = array_sum(array_column($objects, 'lat')) / count($objects);
$lon = array_sum(array_column($objects, 'lon')) / count($objects);
$center = [$lon, $lat];
} elseif ($stats->getPlaces()->count() > 0) {
$first = $stats->getPlaces()->first();
$center = [$first->getLon(), $first->getLat()];
}
2025-07-05 12:37:01 +02:00
return $this->render('admin/followup_theme_graph.html.twig', [
'stats' => $stats,
'theme' => $theme,
'theme_label' => $themes[$theme],
'count_data' => json_encode($countData),
'completion_data' => json_encode($completionData),
'icons' => \App\Service\FollowUpService::getFollowUpIcons(),
'followup_labels' => $themes,
2025-07-05 15:25:33 +02:00
'geojson' => json_encode($geojson),
'overpass_query' => $overpass_query,
2025-07-05 15:25:33 +02:00
'josm_url' => $josm_url,
'center' => $center,
'maptiler_token' => $_ENV['MAPTILER_TOKEN'] ?? null,
'completion_tags' => \App\Service\FollowUpService::getFollowUpCompletionTags(),
2025-07-05 17:47:00 +02:00
'debug_info' => $debug_info,
2025-07-05 12:37:01 +02:00
]);
}
2025-06-01 23:35:15 +02:00
#[Route('/admin/placeType/{osm_kind}/{osm_id}', name: 'app_admin_by_osm_id')]
public function placeType(string $osm_kind, string $osm_id): Response
{
2025-07-05 16:15:56 +02:00
2025-06-01 23:35:15 +02:00
$place = $this->entityManager->getRepository(Place::class)->findOneBy(['osm_kind' => $osm_kind, 'osmId' => $osm_id]);
2025-06-27 00:35:11 +02:00
if ($place) {
2025-07-05 16:15:56 +02:00
$this->actionLogger->log('admin/placeType', [
'osm_kind' => $osm_kind,
'osm_id' => $osm_id,
'name' => $place->getName(),
'code_insee' => $place->getZipCode(),
'uuid' => $place->getUuidForUrl()
]);
2025-06-01 23:35:15 +02:00
return $this->redirectToRoute('app_admin_commerce', ['id' => $place->getId()]);
} else {
2025-07-05 12:37:01 +02:00
$this->actionLogger->log('ERROR_admin/placeType', ['osm_kind' => $osm_kind, 'osm_id' => $osm_id]);
2025-06-01 23:35:15 +02:00
$this->addFlash('error', 'Le lieu n\'existe pas.');
2025-06-27 00:35:11 +02:00
$this->actionLogger->log('ERROR_admin/placeType', ['osm_kind' => $osm_kind, 'osm_id' => $osm_id]);
2025-06-01 23:35:15 +02:00
return $this->redirectToRoute('app_public_index');
}
}
/**
* rediriger vers l'url unique quand on est admin
*/
2025-05-28 16:24:34 +02:00
#[Route('/admin/commerce/{id}', name: 'app_admin_commerce')]
public function commerce(int $id): Response
{
2025-06-27 00:35:11 +02:00
2025-05-28 16:24:34 +02:00
// Vérifier si on est en prod
if ($this->getParameter('kernel.environment') === 'prod') {
$this->addFlash('error', 'Vous n\'avez pas accès à cette page en production.');
return $this->redirectToRoute('app_public_index');
}
$commerce = $this->entityManager->getRepository(Place::class)->find($id);
if (!$commerce) {
throw $this->createNotFoundException('Commerce non trouvé');
2025-06-27 00:35:11 +02:00
$this->actionLogger->log('ERROR_admin_show_commerce_form_id', ['id' => $id]);
2025-05-28 16:24:34 +02:00
}
$this->actionLogger->log('admin_show_commerce_form_id', [
2025-06-27 00:35:11 +02:00
'id' => $id,
'name' => $commerce->getName(),
'code_insee' => $commerce->getZipCode(),
'uuid' => $commerce->getUuidForUrl()
]);
2025-05-28 16:24:34 +02:00
// Redirection vers la page de modification avec les paramètres nécessaires
return $this->redirectToRoute('app_public_edit', [
'zipcode' => $commerce->getZipCode(),
2025-06-27 00:35:11 +02:00
'name' => $commerce->getName() != '' ? $commerce->getName() : '?',
2025-05-28 16:24:34 +02:00
'uuid' => $commerce->getUuidForUrl()
]);
}
/**
2025-06-17 18:27:19 +02:00
* récupérer les commerces de la zone selon le code INSEE, créer les nouveaux lieux, et mettre à jour les existants
*/
2025-06-17 18:27:19 +02:00
#[Route('/admin/labourer/{insee_code}', name: 'app_admin_labourer')]
2025-06-21 11:28:31 +02:00
public function labourer(Request $request, string $insee_code, bool $updateExisting = true): Response
{
2025-06-21 11:28:31 +02:00
$deleteMissing = $request->query->getBoolean('deleteMissing', true);
2025-07-05 10:29:53 +02:00
$disableFollowUpCleanup = $request->query->getBoolean('disableFollowUpCleanup', false);
2025-07-05 14:31:50 +02:00
$debug = $request->query->getBoolean('debug', false);
2025-06-26 23:40:37 +02:00
$this->actionLogger->log('labourer', ['insee_code' => $insee_code]);
// Vérifier si le code INSEE est valide (composé uniquement de chiffres)
if (!ctype_digit($insee_code) || $insee_code == 'undefined' || $insee_code == '') {
$this->addFlash('error', 'Code INSEE invalide : il doit être composé uniquement de chiffres.');
2025-06-26 23:40:37 +02:00
$this->actionLogger->log('ERROR_labourer_bad_insee', ['insee_code' => $insee_code]);
return $this->redirectToRoute('app_public_index');
}
2025-07-14 18:17:41 +02:00
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]);
if (!$stats) {
2025-07-15 21:46:30 +02:00
$stats = new Stats();
$stats->setZone($insee_code);
// $this->addFlash('error', '3 Aucune stats trouvée pour ce code INSEE.');
// return $this->redirectToRoute('app_public_index');
2025-07-14 18:17:41 +02:00
}
2025-07-15 23:23:32 +02:00
// Compléter le nom si manquant
if (!$stats->getName()) {
$cityName = $this->motocultrice->get_city_osm_from_zip_code($insee_code);
if ($cityName) {
$stats->setName($cityName);
}
}
// Compléter la population si manquante
if (!$stats->getPopulation()) {
try {
$apiUrl = 'https://geo.api.gouv.fr/communes/' . $insee_code;
$response = @file_get_contents($apiUrl);
if ($response !== false) {
$data = json_decode($response, true);
if (isset($data['population'])) {
$stats->setPopulation((int)$data['population']);
}
}
} catch (\Exception $e) {}
}
// Compléter le budget si manquant
if (!$stats->getBudgetAnnuel()) {
$budget = $this->budgetService->getBudgetAnnuel($insee_code);
if ($budget !== null) {
$stats->setBudgetAnnuel((string)$budget);
}
}
// Compléter les lieux d'intérêt si manquants (lat/lon)
if (!$stats->getLat() || !$stats->getLon()) {
// On tente de récupérer le centre de la ville via l'API geo.gouv.fr
try {
$apiUrl = 'https://geo.api.gouv.fr/communes/' . $insee_code . '?fields=centre';
$response = @file_get_contents($apiUrl);
if ($response !== false) {
$data = json_decode($response, true);
if (isset($data['centre']['coordinates']) && count($data['centre']['coordinates']) === 2) {
$stats->setLon((string)$data['centre']['coordinates'][0]);
$stats->setLat((string)$data['centre']['coordinates'][1]);
}
}
} catch (\Exception $e) {}
}
2025-07-14 18:17:41 +02:00
// Mettre à jour la date de requête de labourage
$stats->setDateLabourageRequested(new \DateTime());
$this->entityManager->persist($stats);
$this->entityManager->flush();
2025-06-27 00:35:11 +02:00
2025-07-14 18:17:41 +02:00
// Vérifier la RAM disponible (>= 1 Go)
$meminfo = @file_get_contents('/proc/meminfo');
$ram_ok = false;
if ($meminfo !== false && preg_match('/^MemAvailable:\s+(\d+)/m', $meminfo, $matches)) {
$mem_kb = (int)$matches[1];
$ram_ok = ($mem_kb >= 1024 * 1024); // 1 Go
}
if ($ram_ok) {
// Effectuer le labourage complet (objets Place)
// ... (reprendre ici la logique existante de création/màj des objets Place) ...
// À la fin, mettre à jour la date de fin de labourage
$stats->setDateLabourageDone(new \DateTime());
2025-06-05 16:20:20 +02:00
$this->entityManager->persist($stats);
$this->entityManager->flush();
2025-07-14 18:17:41 +02:00
$this->addFlash('success', 'Labourage effectué immédiatement (RAM disponible suffisante).');
} else {
// Ne pas toucher aux objets Place, juste message flash
$this->addFlash('warning', "Le serveur est trop sollicité actuellement (RAM insuffisante). La mise à jour des lieux sera effectuée plus tard automatiquement.");
}
2025-07-14 18:17:41 +02:00
// Toujours générer les CityFollowUp (mais ne jamais les supprimer)
2025-07-15 21:46:30 +02:00
// $themes = \App\Service\FollowUpService::getFollowUpThemes();
// foreach (array_keys($themes) as $theme) {
2025-07-15 23:23:32 +02:00
$this->followUpService->generateCityFollowUps($stats, $this->motocultrice, $this->entityManager, true);
2025-07-15 21:46:30 +02:00
// }
2025-07-14 18:17:41 +02:00
$this->entityManager->flush();
2025-06-17 19:38:44 +02:00
return $this->redirectToRoute('app_admin_stats', ['insee_code' => $insee_code]);
}
#[Route('/admin/delete/{id}', name: 'app_admin_delete')]
public function delete(int $id): Response
{
2025-06-26 23:56:51 +02:00
$this->actionLogger->log('admin/delete_place', ['id' => $id]);
$commerce = $this->entityManager->getRepository(Place::class)->find($id);
2025-06-27 00:35:11 +02:00
if ($commerce) {
2025-05-28 16:24:34 +02:00
$this->entityManager->remove($commerce);
2025-06-27 00:35:11 +02:00
$this->entityManager->flush();
2025-06-27 00:35:11 +02:00
$this->addFlash('success', 'Le lieu ' . $commerce->getName() . ' a été supprimé avec succès de OSM Mes commerces, mais pas dans OpenStreetMap.');
2025-05-28 16:24:34 +02:00
} else {
$this->addFlash('error', 'Le lieu n\'existe pas.');
}
2025-05-28 16:24:34 +02:00
return $this->redirectToRoute('app_public_dashboard');
}
2025-06-17 18:27:19 +02:00
#[Route('/admin/delete_by_zone/{insee_code}', name: 'app_admin_delete_by_zone')]
public function delete_by_zone(string $insee_code): Response
{
2025-06-26 23:56:51 +02:00
$this->actionLogger->log('admin/delete_by_zone', ['insee_code' => $insee_code]);
2025-06-17 18:27:19 +02:00
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]);
2025-06-21 11:28:31 +02:00
if (!$stats) {
$this->addFlash('error', 'Aucune statistique trouvée pour la zone ' . $insee_code);
return $this->redirectToRoute('app_public_dashboard');
}
2025-06-21 11:28:31 +02:00
try {
// 1. Supprimer tous les StatsHistory associés
foreach ($stats->getStatsHistories() as $history) {
$this->entityManager->remove($history);
}
// 2. Supprimer tous les Places associées
foreach ($stats->getPlaces() as $place) {
$this->entityManager->remove($place);
}
// 3. Supprimer l'objet Stats lui-même
$this->entityManager->remove($stats);
// 4. Appliquer les changements à la base de données
$this->entityManager->flush();
$this->addFlash('success', 'La zone ' . $insee_code . ' et toutes les données associées ont été supprimées avec succès.');
} catch (\Exception $e) {
$this->addFlash('error', 'Une erreur est survenue lors de la suppression de la zone ' . $insee_code . ': ' . $e->getMessage());
}
2025-05-28 16:24:34 +02:00
return $this->redirectToRoute('app_public_dashboard');
}
#[Route('/admin/export', name: 'app_admin_export')]
public function export(): Response
{
2025-06-26 23:40:37 +02:00
$this->actionLogger->log('export_all_places', []);
$places = $this->entityManager->getRepository(Place::class)->findAll();
2025-06-27 00:35:11 +02:00
$csvData = [];
$csvData[] = [
'Nom',
2025-06-27 00:35:11 +02:00
'Email',
'Code postal',
'ID OSM',
'Type OSM',
'Date de modification',
'Date dernier contact',
'Note',
'Désabonné',
'Inactif',
'Support humain demandé',
'A des horaires',
2025-06-27 00:35:11 +02:00
'A une adresse',
'A un site web',
'A accessibilité',
'A une note'
];
foreach ($places as $place) {
$csvData[] = [
$place->getName(),
$place->getEmail(),
$place->getZipCode(),
$place->getOsmId(),
$place->getOsmKind(),
$place->getModifiedDate() ? $place->getModifiedDate()->format('Y-m-d H:i:s') : '',
$place->getLastContactAttemptDate() ? $place->getLastContactAttemptDate()->format('Y-m-d H:i:s') : '',
$place->getNote(),
$place->isOptedOut() ? 'Oui' : 'Non',
2025-06-27 00:35:11 +02:00
$place->isDead() ? 'Oui' : 'Non',
$place->isAskedHumainsSupport() ? 'Oui' : 'Non',
$place->hasOpeningHours() ? 'Oui' : 'Non',
$place->hasAddress() ? 'Oui' : 'Non',
$place->hasWebsite() ? 'Oui' : 'Non',
$place->hasWheelchair() ? 'Oui' : 'Non',
$place->hasNote() ? 'Oui' : 'Non'
];
}
$response = new Response();
$response->headers->set('Content-Type', 'text/csv');
$response->headers->set('Content-Disposition', 'attachment; filename="export_places.csv"');
$handle = fopen('php://temp', 'r+');
foreach ($csvData as $row) {
fputcsv($handle, $row, ';');
}
rewind($handle);
$response->setContent(stream_get_contents($handle));
fclose($handle);
2025-06-27 00:35:11 +02:00
2025-06-26 23:40:37 +02:00
return $response;
}
2025-06-17 18:27:19 +02:00
#[Route('/admin/export_csv/{insee_code}', name: 'app_admin_export_csv')]
public function export_csv(string $insee_code): Response
2025-06-27 00:35:11 +02:00
{
2025-06-26 23:56:51 +02:00
$this->actionLogger->log('admin/export_csv', ['insee_code' => $insee_code]);
2025-06-17 18:27:19 +02:00
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]);
$response = new Response($this->motocultrice->export($insee_code));
2025-06-03 13:04:09 +02:00
$response->headers->set('Content-Type', 'text/csv');
2025-06-27 00:35:11 +02:00
2025-06-03 13:04:09 +02:00
$slug_name = str_replace(' ', '-', $stats->getName());
2025-06-03 16:19:07 +02:00
2025-06-27 00:35:11 +02:00
$this->actionLogger->log('export_csv', ['insee_code' => $insee_code, 'slug_name' => $slug_name]);
2025-06-26 23:40:37 +02:00
2025-06-17 18:27:19 +02:00
$response->headers->set('Content-Disposition', 'attachment; filename="osm-commerces-export_' . $insee_code . '_' . $slug_name . '_' . date('Y-m-d_H-i-s') . '.csv"');
2025-06-03 13:04:09 +02:00
return $response;
}
#[Route('/admin/make_email_for_place/{id}', name: 'app_admin_make_email_for_place')]
public function make_email_for_place(Place $place): Response
{
2025-06-26 23:56:51 +02:00
$this->actionLogger->log('admin/make_email_for_place', ['insee_code' => $place->getId()]);
2025-06-27 00:35:11 +02:00
return $this->render('admin/view_email_for_place.html.twig', ['place' => $place]);
}
#[Route('/admin/no_more_sollicitation_for_place/{id}', name: 'app_admin_no_more_sollicitation_for_place')]
public function no_more_sollicitation_for_place(Place $place): Response
{
2025-06-27 00:35:11 +02:00
$this->actionLogger->log('no_more_sollicitation_for_place', ['place_id' => $place->getId()]);
2025-06-26 23:40:37 +02:00
$place->setOptedOut(true);
$this->entityManager->persist($place);
$this->entityManager->flush();
2025-06-27 00:35:11 +02:00
$this->addFlash('success', 'Votre lieu ' . $place->getName() . ' ne sera plus sollicité pour mettre à jour ses informations.');
return $this->redirectToRoute('app_public_index');
}
#[Route('/admin/send_email_to_place/{id}', name: 'app_admin_send_email_to_place')]
public function send_email_to_place(Place $place, \Symfony\Component\Mailer\MailerInterface $mailer): Response
{
2025-06-27 00:35:11 +02:00
$this->actionLogger->log('send_email_to_place', ['place_id' => $place->getId()]);
// Vérifier si le lieu est opted out
if ($place->isOptedOut()) {
$this->addFlash('error', 'Ce lieu a demandé à ne plus être sollicité pour mettre à jour ses informations.');
2025-06-27 00:35:11 +02:00
$this->actionLogger->log('could_not_send_email_to_opted_out_place', ['place_id' => $place->getId()]);
return $this->redirectToRoute('app_public_index');
}
// Vérifier si le lieu a déjà été contacté
if ($place->getLastContactAttemptDate() !== null) {
$this->addFlash('error', 'Ce lieu a déjà été contacté le ' . $place->getLastContactAttemptDate()->format('d/m/Y H:i:s'));
return $this->redirectToRoute('app_public_index');
}
// Générer le contenu de l'email avec le template
$emailContent = $this->renderView('admin/email_content.html.twig', [
'place' => $place
]);
// Envoyer l'email
$email = (new \Symfony\Component\Mime\Email())
->from('contact@openstreetmap.fr')
->to('contact+send_email@cipherbliss.com')
->subject('Mise à jour des informations de votre établissement dans OpenStreetMap')
->html($emailContent);
2025-06-26 23:40:37 +02:00
try {
$mailer->send($email);
} catch (\Throwable $e) {
$this->actionLogger->log('ERROR_envoi_email', [
'place_id' => $place->getId(),
'message' => $e->getMessage(),
]);
$this->addFlash('error', 'Erreur lors de l\'envoi de l\'email : ' . $e->getMessage());
return $this->redirectToRoute('app_public_index');
}
// Mettre à jour la date de dernier contact
$place->setLastContactAttemptDate(new \DateTime());
$this->entityManager->persist($place);
$this->entityManager->flush();
2025-06-26 23:40:37 +02:00
$place->setLastContactAttemptDate(new \DateTime());
2025-06-27 00:35:11 +02:00
$this->addFlash('success', 'Email envoyé avec succès à ' . $place->getName() . ' le ' . $place->getLastContactAttemptDate()->format('d/m/Y H:i:s'));
return $this->redirectToRoute('app_public_index');
2025-06-27 00:35:11 +02:00
}
2025-06-24 00:29:15 +02:00
#[Route('/admin/fraicheur/histogramme', name: 'admin_fraicheur_histogramme')]
public function showFraicheurHistogramme(): Response
{
$jsonPath = $this->getParameter('kernel.project_dir') . '/var/fraicheur_osm.json';
if (!file_exists($jsonPath)) {
// Générer le fichier si absent
$this->calculateFraicheur();
}
return $this->render('admin/fraicheur_histogramme.html.twig');
}
#[Route('/admin/fraicheur/calculate', name: 'admin_fraicheur_calculate')]
public function calculateFraicheur(): Response
{
2025-06-26 23:40:37 +02:00
// Ajout d'un log d'action avec le service ActionLogger
2025-06-27 00:35:11 +02:00
$this->actionLogger->log('fraicheur/calculate', []);
2025-06-24 00:29:15 +02:00
$filesystem = new Filesystem();
$jsonPath = $this->getParameter('kernel.project_dir') . '/var/fraicheur_osm.json';
$now = new \DateTime();
// Si le fichier existe et a moins de 12h, on ne régénère pas
if ($filesystem->exists($jsonPath)) {
$fileMTime = filemtime($jsonPath);
if ($fileMTime && ($now->getTimestamp() - $fileMTime) < 43200) { // 12h = 43200s
return $this->redirectToRoute('admin_fraicheur_histogramme');
}
}
$places = $this->entityManager->getRepository(Place::class)->findAll();
$histogram = [];
$total = 0;
foreach ($places as $place) {
$date = $place->getOsmDataDate();
if ($date) {
$key = $date->format('Y-m');
if (!isset($histogram[$key])) {
$histogram[$key] = 0;
}
$histogram[$key]++;
$total++;
}
}
ksort($histogram);
$data = [
'generated_at' => $now->format('c'),
'total' => $total,
'histogram' => $histogram
];
2025-06-27 00:35:11 +02:00
$filesystem->dumpFile($jsonPath, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
2025-06-24 00:29:15 +02:00
// --- Distribution villes selon lieux/habitants ---
$distJsonPath = $this->getParameter('kernel.project_dir') . '/var/distribution_villes_lieux_par_habitant.json';
// Toujours régénérer
$statsRepo = $this->entityManager->getRepository(Stats::class);
$allStats = $statsRepo->findAll();
$histogram_lieux_par_habitant = [];
$histogram_habitants_par_lieu = [];
$totalVilles = 0;
foreach ($allStats as $stat) {
$places = $stat->getPlacesCount();
$population = $stat->getPopulation();
if ($places && $population && $population > 0) {
// lieux par habitant (pas de 0.01)
$ratio_lph = round($places / $population, 4);
$bin_lph = round(floor($ratio_lph / 0.01) * 0.01, 2);
if (!isset($histogram_lieux_par_habitant[$bin_lph])) $histogram_lieux_par_habitant[$bin_lph] = 0;
$histogram_lieux_par_habitant[$bin_lph]++;
// habitants par lieu (pas de 10)
$ratio_hpl = ceil($population / $places);
$bin_hpl = ceil($ratio_hpl / 10) * 10;
if (!isset($histogram_habitants_par_lieu[$bin_hpl])) $histogram_habitants_par_lieu[$bin_hpl] = 0;
$histogram_habitants_par_lieu[$bin_hpl]++;
$totalVilles++;
}
}
ksort($histogram_lieux_par_habitant);
ksort($histogram_habitants_par_lieu);
$distData = [
'generated_at' => $now->format('c'),
'total_villes' => $totalVilles,
'histogram_001' => $histogram_lieux_par_habitant,
'histogram_10' => $histogram_habitants_par_lieu
];
2025-06-27 00:35:11 +02:00
$filesystem->dumpFile($distJsonPath, json_encode($distData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
2025-06-24 00:29:15 +02:00
return $this->redirectToRoute('admin_fraicheur_histogramme');
}
#[Route('/admin/fraicheur/download', name: 'admin_fraicheur_download')]
public function downloadFraicheur(): JsonResponse
{
$jsonPath = $this->getParameter('kernel.project_dir') . '/var/fraicheur_osm.json';
if (!file_exists($jsonPath)) {
return new JsonResponse(['error' => 'Fichier non généré'], 404);
}
$content = file_get_contents($jsonPath);
$data = json_decode($content, true);
return new JsonResponse($data);
}
#[Route('/admin/distribution_villes_lieux_par_habitant_download', name: 'admin_distribution_villes_lieux_par_habitant_download')]
public function downloadDistributionVillesLieuxParHabitant(): JsonResponse
{
$jsonPath = $this->getParameter('kernel.project_dir') . '/var/distribution_villes_lieux_par_habitant.json';
if (!file_exists($jsonPath)) {
// Générer à la volée si absent
$now = new \DateTime();
$filesystem = new \Symfony\Component\Filesystem\Filesystem();
$statsRepo = $this->entityManager->getRepository(\App\Entity\Stats::class);
$allStats = $statsRepo->findAll();
$distribution = [];
$histogram = [];
$totalVilles = 0;
foreach ($allStats as $stat) {
$places = $stat->getPlacesCount();
$population = $stat->getPopulation();
if ($places && $population && $population > 0) {
$ratio = round($places / $population, 4); // lieux par habitant
$bin = round(floor($ratio / 0.01) * 0.01, 2); // pas de 0.01
if (!isset($histogram[$bin])) $histogram[$bin] = 0;
$histogram[$bin]++;
$totalVilles++;
}
}
ksort($histogram);
$distData = [
'generated_at' => $now->format('c'),
'total_villes' => $totalVilles,
'histogram_001' => $histogram
];
2025-06-27 00:35:11 +02:00
$filesystem->dumpFile($jsonPath, json_encode($distData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
2025-06-24 00:29:15 +02:00
}
$content = file_get_contents($jsonPath);
$data = json_decode($content, true);
return new JsonResponse($data);
}
#[Route('/admin/distribution_villes_lieux_par_habitant_villes', name: 'admin_distribution_villes_lieux_par_habitant_villes')]
public function downloadDistributionVillesLieuxParHabitantVilles(): JsonResponse
{
$statsRepo = $this->entityManager->getRepository(\App\Entity\Stats::class);
$allStats = $statsRepo->findAll();
$villesByBin = [];
foreach ($allStats as $stat) {
$places = $stat->getPlacesCount();
$population = $stat->getPopulation();
$name = $stat->getName();
if ($places && $population && $population > 0 && $name) {
$ratio = round($places / $population, 4); // lieux par habitant
$bin = round(floor($ratio / 0.01) * 0.01, 2); // pas de 0.01
if (!isset($villesByBin[$bin])) $villesByBin[$bin] = [];
$villesByBin[$bin][] = $name;
}
}
ksort($villesByBin);
return new JsonResponse(['villes_by_bin' => $villesByBin]);
}
#[Route('/admin/labourer-tous-les-budgets', name: 'app_admin_labourer_tous_les_budgets')]
public function labourerTousLesBudgets(): Response
{
$statsRepo = $this->entityManager->getRepository(Stats::class);
$query = $statsRepo->createQueryBuilder('s')->getQuery();
$allStats = $query->toIterable();
$budgetsMisAJour = 0;
foreach ($allStats as $stat) {
if (!$stat->getBudgetAnnuel() && $stat->getZone()) {
$budget = $this->budgetService->getBudgetAnnuel($stat->getZone());
if ($budget !== null) {
$stat->setBudgetAnnuel((string)$budget);
$this->entityManager->persist($stat);
$budgetsMisAJour++;
continue;
}
}
}
if ($budgetsMisAJour > 0) {
$this->entityManager->flush();
}
2025-06-27 00:35:11 +02:00
$this->addFlash('success', $budgetsMisAJour . ' budgets mis à jour.');
return $this->redirectToRoute('app_admin');
}
2025-06-26 18:20:43 +02:00
2025-06-27 00:35:11 +02:00
#[Route('/admin/podium-contributeurs-osm', name: 'app_admin_podium_contributeurs_osm')]
public function podiumContributeursOsm(): Response
{
2025-07-05 10:59:37 +02:00
$this->actionLogger->log('admin/podium_contributeurs_osm', []);
2025-07-05 16:15:56 +02:00
2025-07-05 10:59:37 +02:00
// Récupérer tous les lieux avec un utilisateur OSM
$places = $this->entityManager->getRepository(Place::class)->findBy(['osm_user' => null], ['osm_user' => 'ASC']);
$places = array_filter($places, fn($place) => $place->getOsmUser() !== null);
2025-07-05 16:15:56 +02:00
2025-07-05 10:59:37 +02:00
// Compter les contributions par utilisateur
$contributions = [];
foreach ($places as $place) {
$user = $place->getOsmUser();
if ($user) {
if (!isset($contributions[$user])) {
$contributions[$user] = 0;
}
$contributions[$user]++;
}
}
2025-07-05 16:15:56 +02:00
2025-07-05 10:59:37 +02:00
// Trier par nombre de contributions décroissant
arsort($contributions);
2025-07-05 16:15:56 +02:00
2025-07-05 10:59:37 +02:00
// Prendre les 10 premiers
$topContributors = array_slice($contributions, 0, 10, true);
2025-07-05 16:15:56 +02:00
2025-07-05 10:59:37 +02:00
return $this->render('admin/podium_contributeurs_osm.html.twig', [
'contributors' => $topContributors
]);
}
2025-06-27 00:35:11 +02:00
2025-07-05 10:59:37 +02:00
#[Route('/admin/import-stats', name: 'app_admin_import_stats', methods: ['GET', 'POST'])]
public function importStats(Request $request): Response
{
$this->actionLogger->log('admin/import_stats', []);
if ($request->isMethod('POST')) {
$uploadedFile = $request->files->get('json_file');
2025-07-05 16:15:56 +02:00
2025-07-05 10:59:37 +02:00
if (!$uploadedFile) {
$this->addFlash('error', 'Aucun fichier JSON n\'a été fourni.');
return $this->redirectToRoute('app_admin_import_stats');
}
2025-06-27 00:35:11 +02:00
2025-07-05 10:59:37 +02:00
// Vérifier le type de fichier
2025-07-05 16:15:56 +02:00
if (
$uploadedFile->getClientMimeType() !== 'application/json' &&
$uploadedFile->getClientOriginalExtension() !== 'json'
) {
2025-07-05 10:59:37 +02:00
$this->addFlash('error', 'Le fichier doit être au format JSON.');
return $this->redirectToRoute('app_admin_import_stats');
}
2025-06-27 00:35:11 +02:00
2025-07-05 10:59:37 +02:00
try {
// Lire le contenu du fichier
$jsonContent = file_get_contents($uploadedFile->getPathname());
$data = json_decode($jsonContent, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \Exception('Erreur lors du décodage JSON: ' . json_last_error_msg());
}
if (!is_array($data)) {
throw new \Exception('Le fichier JSON doit contenir un tableau d\'objets Stats.');
}
$createdCount = 0;
$skippedCount = 0;
$errors = [];
foreach ($data as $index => $statData) {
try {
// Vérifier que les champs requis sont présents
if (!isset($statData['zone']) || !isset($statData['name'])) {
$errors[] = "Ligne " . ($index + 1) . ": Champs 'zone' et 'name' requis";
continue;
}
$zone = $statData['zone'];
$name = $statData['name'];
// Vérifier si l'objet Stats existe déjà
$existingStats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $zone]);
2025-07-05 16:15:56 +02:00
2025-07-05 10:59:37 +02:00
if ($existingStats) {
$skippedCount++;
continue; // Ignorer les objets existants
}
// Créer un nouvel objet Stats
$stats = new Stats();
$stats->setZone($zone)
2025-07-05 16:15:56 +02:00
->setName($name)
->setDateCreated(new \DateTime())
->setDateModified(new \DateTime());
2025-07-05 10:59:37 +02:00
// Remplir les champs optionnels
if (isset($statData['population'])) {
$stats->setPopulation($statData['population']);
}
if (isset($statData['budgetAnnuel'])) {
$stats->setBudgetAnnuel($statData['budgetAnnuel']);
}
if (isset($statData['siren'])) {
$stats->setSiren($statData['siren']);
}
if (isset($statData['codeEpci'])) {
$stats->setCodeEpci($statData['codeEpci']);
}
if (isset($statData['codesPostaux'])) {
$stats->setCodesPostaux($statData['codesPostaux']);
}
// Remplir les décomptes si disponibles
if (isset($statData['decomptes'])) {
$decomptes = $statData['decomptes'];
if (isset($decomptes['placesCount'])) {
$stats->setPlacesCount($decomptes['placesCount']);
}
if (isset($decomptes['avecHoraires'])) {
$stats->setAvecHoraires($decomptes['avecHoraires']);
}
if (isset($decomptes['avecAdresse'])) {
$stats->setAvecAdresse($decomptes['avecAdresse']);
}
if (isset($decomptes['avecSite'])) {
$stats->setAvecSite($decomptes['avecSite']);
}
if (isset($decomptes['avecAccessibilite'])) {
$stats->setAvecAccessibilite($decomptes['avecAccessibilite']);
}
if (isset($decomptes['avecNote'])) {
$stats->setAvecNote($decomptes['avecNote']);
}
if (isset($decomptes['completionPercent'])) {
$stats->setCompletionPercent($decomptes['completionPercent']);
}
}
$this->entityManager->persist($stats);
$createdCount++;
} catch (\Exception $e) {
$errors[] = "Ligne " . ($index + 1) . ": " . $e->getMessage();
}
}
// Sauvegarder les changements
$this->entityManager->flush();
// Afficher les résultats
$message = "Import terminé : $createdCount objet(s) créé(s), $skippedCount objet(s) ignoré(s) (déjà existants).";
if (!empty($errors)) {
$message .= " Erreurs : " . count($errors);
foreach ($errors as $error) {
$this->addFlash('warning', $error);
}
}
$this->addFlash('success', $message);
$this->actionLogger->log('admin/import_stats_success', [
'created' => $createdCount,
'skipped' => $skippedCount,
'errors' => count($errors)
]);
} catch (\Exception $e) {
$this->addFlash('error', 'Erreur lors de l\'import : ' . $e->getMessage());
$this->actionLogger->log('admin/import_stats_error', ['error' => $e->getMessage()]);
}
2025-07-05 10:59:37 +02:00
return $this->redirectToRoute('app_admin_import_stats');
}
2025-07-05 10:59:37 +02:00
return $this->render('admin/import_stats.html.twig');
}
#[Route('/admin/export-overpass-csv/{insee_code}', name: 'app_admin_export_overpass_csv')]
public function exportOverpassCsv($insee_code): Response
{
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]);
2025-07-05 16:15:56 +02:00
2025-07-05 10:59:37 +02:00
if (!$stats) {
throw $this->createNotFoundException('Stats non trouvées pour ce code INSEE');
2025-06-27 00:35:11 +02:00
}
2025-07-05 10:59:37 +02:00
// Construire la requête Overpass
$overpassQuery = '[out:csv(::id,::type,name,amenity,shop,office,craft,leisure,healthcare,emergency,man_made,power,highway,railway,public_transport,landuse,historic,barrier,tourism,sport,place,waterway,natural,geological,route,military,traffic_sign,traffic_calming,seamark,route_master,water,airway,aerialway,building,other;true;false;false)]' . "\n";
$overpassQuery .= 'area["ref:INSEE"="' . $insee_code . '"]->.searchArea;' . "\n";
$overpassQuery .= 'nwr["amenity"]["name"](area.searchArea);' . "\n";
$overpassQuery .= 'nwr["shop"]["name"](area.searchArea);' . "\n";
$overpassQuery .= 'nwr["office"]["name"](area.searchArea);' . "\n";
$overpassQuery .= 'nwr["craft"]["name"](area.searchArea);' . "\n";
$overpassQuery .= 'nwr["leisure"]["name"](area.searchArea);' . "\n";
$overpassQuery .= 'nwr["healthcare"]["name"](area.searchArea);' . "\n";
$overpassQuery .= 'nwr["emergency"]["name"](area.searchArea);' . "\n";
$overpassQuery .= 'nwr["man_made"]["name"](area.searchArea);' . "\n";
$overpassQuery .= 'nwr["power"]["name"](area.searchArea);' . "\n";
$overpassQuery .= 'nwr["highway"]["name"](area.searchArea);' . "\n";
$overpassQuery .= 'nwr["railway"]["name"](area.searchArea);' . "\n";
$overpassQuery .= 'nwr["public_transport"]["name"](area.searchArea);' . "\n";
$overpassQuery .= 'nwr["landuse"]["name"](area.searchArea);' . "\n";
$overpassQuery .= 'nwr["historic"]["name"](area.searchArea);' . "\n";
$overpassQuery .= 'nwr["barrier"]["name"](area.searchArea);' . "\n";
$overpassQuery .= 'nwr["tourism"]["name"](area.searchArea);' . "\n";
$overpassQuery .= 'nwr["sport"]["name"](area.searchArea);' . "\n";
$overpassQuery .= 'nwr["place"]["name"](area.searchArea);' . "\n";
$overpassQuery .= 'nwr["waterway"]["name"](area.searchArea);' . "\n";
$overpassQuery .= 'nwr["natural"]["name"](area.searchArea);' . "\n";
$overpassQuery .= 'nwr["geological"]["name"](area.searchArea);' . "\n";
$overpassQuery .= 'nwr["route"]["name"](area.searchArea);' . "\n";
$overpassQuery .= 'nwr["military"]["name"](area.searchArea);' . "\n";
$overpassQuery .= 'nwr["traffic_sign"]["name"](area.searchArea);' . "\n";
$overpassQuery .= 'nwr["traffic_calming"]["name"](area.searchArea);' . "\n";
$overpassQuery .= 'nwr["seamark"]["name"](area.searchArea);' . "\n";
$overpassQuery .= 'nwr["route_master"]["name"](area.searchArea);' . "\n";
$overpassQuery .= 'nwr["water"]["name"](area.searchArea);' . "\n";
$overpassQuery .= 'nwr["airway"]["name"](area.searchArea);' . "\n";
$overpassQuery .= 'nwr["aerialway"]["name"](area.searchArea);' . "\n";
$overpassQuery .= 'nwr["building"]["name"](area.searchArea);' . "\n";
$overpassQuery .= 'nwr["other"]["name"](area.searchArea);' . "\n";
$url = 'https://overpass-api.de/api/interpreter?data=' . urlencode($overpassQuery);
// Rediriger vers l'API Overpass
return $this->redirect($url);
}
#[Route('/admin/export-table-csv/{insee_code}', name: 'app_admin_export_table_csv')]
public function exportTableCsv($insee_code): Response
{
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]);
2025-07-05 16:15:56 +02:00
2025-07-05 10:59:37 +02:00
if (!$stats) {
throw $this->createNotFoundException('Stats non trouvées pour ce code INSEE');
}
2025-06-27 00:35:11 +02:00
2025-07-05 10:59:37 +02:00
$response = new Response();
$response->headers->set('Content-Type', 'text/csv; charset=utf-8');
$response->headers->set('Content-Disposition', 'attachment; filename="lieux_' . $insee_code . '_' . date('Y-m-d') . '.csv"');
$output = fopen('php://temp', 'r+');
// En-têtes CSV
fputcsv($output, [
'Nom',
'Email',
'Contenu email',
'Completion %',
'Type',
'Adresse',
'Numéro',
'Rue',
'Site web',
'Accès PMR',
'Note',
'Texte de la note',
'SIRET',
'SIRET clos',
'Dernière modif. OSM',
'Utilisateur OSM',
'Lien OSM'
], ';');
// Données
foreach ($stats->getPlaces() as $place) {
$osmKind = $place->getOsmKind();
$osmId = $place->getOsmId();
$osmLink = ($osmKind && $osmId) ? 'https://www.openstreetmap.org/' . $osmKind . '/' . $osmId : '';
2025-07-05 16:15:56 +02:00
2025-07-05 10:59:37 +02:00
// Construire l'adresse complète
$address = '';
if ($place->getHousenumber() && $place->getStreet()) {
$address = $place->getHousenumber() . ' ' . $place->getStreet();
} elseif ($place->getStreet()) {
$address = $place->getStreet();
}
2025-07-05 16:15:56 +02:00
2025-07-05 10:59:37 +02:00
fputcsv($output, [
$place->getName() ?: '(sans nom)',
$place->getEmail() ?: '',
$place->getEmailContent() ?: '',
$place->getCompletionPercentage(),
$place->getMainTag() ?: '',
$address,
$place->getHousenumber() ?: '',
$place->getStreet() ?: '',
$place->hasWebsite() ? 'Oui' : 'Non',
$place->hasWheelchair() ? 'Oui' : 'Non',
$place->getNote() ? 'Oui' : 'Non',
$place->getNoteContent() ?: '',
$place->getSiret() ?: '',
'', // SIRET clos - à implémenter si nécessaire
$place->getOsmDataDate() ? $place->getOsmDataDate()->format('Y-m-d H:i') : '',
$place->getOsmUser() ?: '',
$osmLink
], ';');
}
rewind($output);
$csv = stream_get_contents($output);
fclose($output);
$response->setContent($csv);
return $response;
}
2025-07-05 14:31:50 +02:00
#[Route('/admin/test-ctc/{insee_code}', name: 'admin_test_ctc', requirements: ['insee_code' => '\d+'], defaults: ['insee_code' => null])]
public function testCTC(Request $request, ?string $insee_code = null): Response
{
$json = null;
$url = null;
$error = null;
$stats = null;
if ($insee_code) {
$stats = $this->entityManager->getRepository(\App\Entity\Stats::class)->findOneBy(['zone' => $insee_code]);
if ($stats) {
$url = $stats->getCTCurlBase();
try {
$json = file_get_contents($url . '_last_stats.json');
} catch (\Exception $e) {
$error = $e->getMessage();
}
} else {
2025-07-15 21:22:02 +02:00
$error = "4 Aucune stats trouvée pour ce code INSEE.";
2025-07-05 14:31:50 +02:00
}
}
return $this->render('admin/test_ctc.html.twig', [
'insee_code' => $insee_code,
'url' => $url ? $url . '_last_stats.json' : null,
'json' => $json,
'error' => $error,
'stats' => $stats
]);
}
2025-07-05 16:53:12 +02:00
#[Route('/admin/export_csv', name: 'app_admin_export_csv_all')]
public function export_csv_all(): Response
{
$statsList = $this->entityManager->getRepository(\App\Entity\Stats::class)->findAll();
$handle = fopen('php://temp', 'r+');
// En-tête CSV
fputcsv($handle, [
'zone', 'name', 'lat', 'lon', 'population', 'budgetAnnuel', 'completionPercent', 'placesCount', 'avecHoraires', 'avecAdresse', 'avecSite', 'avecAccessibilite', 'avecNote', 'siren', 'codeEpci', 'codesPostaux'
]);
foreach ($statsList as $stat) {
fputcsv($handle, [
$stat->getZone(),
$stat->getName(),
$stat->getLat(),
$stat->getLon(),
$stat->getPopulation(),
$stat->getBudgetAnnuel(),
$stat->getCompletionPercent(),
$stat->getPlacesCount(),
$stat->getAvecHoraires(),
$stat->getAvecAdresse(),
$stat->getAvecSite(),
$stat->getAvecAccessibilite(),
$stat->getAvecNote(),
$stat->getSiren(),
$stat->getCodeEpci(),
$stat->getCodesPostaux(),
]);
}
rewind($handle);
$csv = stream_get_contents($handle);
fclose($handle);
$response = new Response($csv);
$response->headers->set('Content-Type', 'text/csv');
$response->headers->set('Content-Disposition', 'attachment; filename="osm-commerces-villes-export_' . date('Y-m-d_H-i-s') . '.csv"');
return $response;
}
public static function getTagEmoji(string $mainTag): string
{
2025-07-12 13:32:08 +02:00
// Si c'est un tag clé=valeur, on garde le match existant
if (str_contains($mainTag, '=')) {
return match ($mainTag) {
'amenity=restaurant', 'amenity=bar', 'amenity=cafe' => '🍽️',
'amenity=townhall', 'amenity=community_centre' => '🏛️',
'amenity=bank', 'amenity=atm' => '🏦',
'amenity=pharmacy', 'amenity=hospital', 'amenity=clinic' => '🏥',
'amenity=school', 'amenity=kindergarten', 'amenity=university' => '🎓',
'amenity=library', 'amenity=museum', 'amenity=artwork' => '📚',
'shop=car_repair', 'shop=car_parts', 'shop=car_wash' => '🚗',
'amenity=post_office' => '📮',
'shop=convenience' => '🏪',
'shop=supermarket' => '🛒',
'shop=clothes' => '👕',
default => '🏷️',
};
}
// Sinon, on regarde si c'est un tag principal simple
2025-07-05 16:53:12 +02:00
return match ($mainTag) {
2025-07-12 13:32:08 +02:00
'bicycle_parking' => '🚲',
'building' => '🏢',
'email' => '📧',
'fire_hydrant' => '🚒',
'charging_station' => '⚡',
'toilets' => '🚻',
'bus_stop' => '🚌',
'defibrillator' => '❤️‍🩹',
'camera' => '📷',
'recycling' => '♻️',
'substation' => '🏭',
'laboratory' => '🧪',
'school' => '🏫',
'police' => '👮',
'healthcare' => '🏥',
'advertising_board' => '🪧',
'bench' => '🪑',
'waste_basket' => '🗑️',
'street_lamp' => '💡',
'drinking_water' => '🚰',
'tree' => '🌳',
'places' => '📍',
'power_pole' => '⚡',
2025-07-05 16:53:12 +02:00
default => '🏷️',
};
}
public function followupEmbedGraph(Request $request, string $insee_code, string $theme): Response
{
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]);
if (!$stats) {
2025-07-15 21:22:02 +02:00
$this->addFlash('error', '5 Aucune stats trouvée pour ce code INSEE.');
return $this->redirectToRoute('app_admin');
}
$themes = \App\Service\FollowUpService::getFollowUpThemes();
if (!isset($themes[$theme])) {
$this->addFlash('error', 'Thème non reconnu.');
return $this->redirectToRoute('app_admin_stats', ['insee_code' => $insee_code]);
}
// Récupérer toutes les données de followup pour ce thème
$followups = $stats->getCityFollowUps();
$countData = [];
$completionData = [];
foreach ($followups as $fu) {
if ($fu->getName() === $theme . '_count') {
$countData[] = [
'date' => $fu->getDate()->format('Y-m-d'),
'value' => $fu->getMeasure()
];
}
if ($fu->getName() === $theme . '_completion') {
$completionData[] = [
'date' => $fu->getDate()->format('Y-m-d'),
'value' => $fu->getMeasure()
];
}
}
// Trier par date
usort($countData, fn($a, $b) => $a['date'] <=> $b['date']);
usort($completionData, fn($a, $b) => $a['date'] <=> $b['date']);
// Récupérer les objets du thème (Place) pour la ville
$places = $this->entityManager->getRepository(Place::class)->findBy(['zip_code' => $insee_code]);
$motocultrice = $this->motocultrice;
$objects = [];
// Récupérer la correspondance thème <-> requête Overpass
$themeQueries = \App\Service\FollowUpService::getFollowUpOverpassQueries();
$overpass_type_query = $themeQueries[$theme] ?? '';
if ($overpass_type_query) {
$overpass_query = "[out:json][timeout:60];\narea[\"ref:INSEE\"=\"$insee_code\"]->.searchArea;\n($overpass_type_query);\n(._;>;);\nout meta;\n>;";
$josm_url = 'http://127.0.0.1:8111/import?url=https://overpass-api.de/api/interpreter?data=' . urlencode($overpass_query);
} else {
$josm_url = null;
}
// Fonction utilitaire pour extraire clé/valeur de la requête Overpass
$extractTag = function ($query) {
if (preg_match('/\\[([a-zA-Z0-9:_-]+)\\]="([^"]+)"/', $query, $matches)) {
return [$matches[1], $matches[2]];
}
return [null, null];
};
list($tagKey, $tagValue) = $extractTag($themeQueries[$theme] ?? '');
foreach ($places as $place) {
$match = false;
2025-07-05 17:35:20 +02:00
$main_tag = $place->getMainTag();
// Cas particuliers multi-valeurs (ex: healthcare)
if ($theme === 'healthcare') {
if ($main_tag && (
str_starts_with($main_tag, 'healthcare=') ||
in_array($main_tag, [
'amenity=doctors',
'amenity=pharmacy',
'amenity=hospital',
'amenity=clinic',
'amenity=social_facility'
])
)) {
$match = true;
}
2025-07-05 17:35:20 +02:00
} else {
// Détection générique : si le mainTag correspond à la clé/valeur du thème
if ($tagKey && $tagValue && $main_tag === "$tagKey=$tagValue") {
$match = true;
}
}
2025-07-05 17:35:20 +02:00
// Ajouter l'objet si match
if ($match && $place->getLat() && $place->getLon()) {
$objects[] = [
'id' => $place->getOsmId(),
'osm_kind' => $place->getOsmKind(),
'lat' => $place->getLat(),
'lon' => $place->getLon(),
'name' => $place->getName(),
'tags' => ['main_tag' => $place->getMainTag()],
'is_complete' => !empty($place->getName()),
'osm_url' => 'https://www.openstreetmap.org/' . $place->getOsmKind() . '/' . $place->getOsmId(),
'uuid' => $place->getUuidForUrl(),
'zip_code' => $place->getZipCode(),
];
}
}
$geojson = [
'type' => 'FeatureCollection',
'features' => array_map(function ($obj) {
return [
'type' => 'Feature',
'geometry' => [
'type' => 'Point',
'coordinates' => [$obj['lon'], $obj['lat']]
],
'properties' => $obj
];
}, $objects)
];
// Centre de la carte : centre géographique des objets ou de la ville
$center = null;
if (count($objects) > 0) {
$lat = array_sum(array_column($objects, 'lat')) / count($objects);
$lon = array_sum(array_column($objects, 'lon')) / count($objects);
$center = [$lon, $lat];
} elseif ($stats->getPlaces()->count() > 0) {
$first = $stats->getPlaces()->first();
$center = [$first->getLon(), $first->getLat()];
}
return $this->render('admin/followup_embed_graph.html.twig', [
'stats' => $stats,
'theme' => $theme,
'theme_label' => $themes[$theme],
'count_data' => json_encode($countData),
'completion_data' => json_encode($completionData),
'icons' => \App\Service\FollowUpService::getFollowUpIcons(),
'geojson' => json_encode($geojson),
'overpass_query' => $overpass_query,
'josm_url' => $josm_url,
'center' => $center,
'maptiler_token' => $_ENV['MAPTILER_TOKEN'] ?? null,
'completion_tags' => \App\Service\FollowUpService::getFollowUpCompletionTags(),
]);
}
#[Route('/admin/followup-graph/{insee_code}', name: 'admin_followup_graph', requirements: ['insee_code' => '\d+'])]
public function followupGraph(Request $request, string $insee_code): Response
{
2025-07-14 18:17:41 +02:00
$ctc_completion_series = [];
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]);
if (!$stats) {
2025-07-15 21:22:02 +02:00
$this->addFlash('error', '6 Aucune stats trouvée pour ce code INSEE.');
2025-07-14 18:17:41 +02:00
return $this->render('admin/followup_graph.html.twig', [
'stats' => null,
'completion_tags' => \App\Service\FollowUpService::getFollowUpCompletionTags(),
'followup_labels' => \App\Service\FollowUpService::getFollowUpThemes(),
'followup_icons' => \App\Service\FollowUpService::getFollowUpIcons(),
'ctc_completion_series' => $ctc_completion_series,
]);
}
$themes = \App\Service\FollowUpService::getFollowUpThemes();
2025-07-14 18:17:41 +02:00
// Ajout : mesures CTC CityFollowUp pour le graphique séparé
foreach ($stats->getCityFollowUps() as $fu) {
if (preg_match('/^(name|hours|website|address|siret)_count$/', $fu->getName())) {
$ctc_completion_series[$fu->getName()][] = [
'date' => $fu->getDate()->format('Y-m-d'),
'value' => $fu->getMeasure(),
];
}
}
foreach ($ctc_completion_series as &$points) {
usort($points, function($a, $b) {
return strcmp($a['date'], $b['date']);
});
}
unset($points);
return $this->render('admin/followup_graph.html.twig', [
'stats' => $stats,
'completion_tags' => \App\Service\FollowUpService::getFollowUpCompletionTags(),
'followup_labels' => \App\Service\FollowUpService::getFollowUpThemes(),
2025-07-05 17:35:20 +02:00
'followup_icons' => \App\Service\FollowUpService::getFollowUpIcons(),
2025-07-14 18:17:41 +02:00
'ctc_completion_series' => $ctc_completion_series,
]);
}
2025-07-07 23:30:09 +02:00
// Dans la méthode de suppression de ville (ex: deleteCity ou similaire)
public function deleteCityAction(Request $request, $id): Response
{
if (!$this->allowDeleteCity) {
$this->addFlash('danger', "La suppression de ville est désactivée par configuration.");
return $this->redirectToRoute('admin_dashboard');
}
// ... logique de suppression existante ...
// Pour éviter l'erreur, on retourne une redirection par défaut si rien n'est fait
return $this->redirectToRoute('admin_dashboard');
}
#[Route('/admin/stats/{insee_code}/street-completion', name: 'admin_street_completion', requirements: ['insee_code' => '\d+'])]
public function streetCompletion(string $insee_code): Response
{
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]);
if (!$stats) {
2025-07-15 21:22:02 +02:00
$this->addFlash('error', '7 Aucune stats trouvée pour ce code INSEE.');
return $this->redirectToRoute('app_admin');
}
$places = $stats->getPlaces();
$rues = [];
foreach ($places as $place) {
$rue = $place->getStreet() ?: '(sans nom)';
if (!isset($rues[$rue])) {
$rues[$rue] = [ 'places' => [], 'completion_sum' => 0 ];
}
$rues[$rue]['places'][] = $place;
$rues[$rue]['completion_sum'] += $place->getCompletionPercentage();
}
$rues_data = [];
foreach ($rues as $nom => $data) {
$count = count($data['places']);
$avg = $count > 0 ? round($data['completion_sum'] / $count, 1) : 0;
$rues_data[] = [
'name' => $nom,
'count' => $count,
'avg_completion' => $avg,
];
}
// Tri décroissant par complétion moyenne
usort($rues_data, fn($a, $b) => $b['avg_completion'] <=> $a['avg_completion']);
return $this->render('admin/street_completion.html.twig', [
'stats' => $stats,
'rues' => $rues_data,
'insee_code' => $insee_code,
]);
}
2025-07-14 19:27:07 +02:00
#[Route('/admin/speed-limit/{insee_code}', name: 'admin_speed_limit', requirements: ['insee_code' => '\d+'])]
public function speedLimit(string $insee_code): Response
{
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]);
if (!$stats) {
2025-07-15 21:22:02 +02:00
$this->addFlash('error', '8 Aucune stats trouvée pour ce code INSEE. Veuillez d\'abord ajouter la ville.');
2025-07-14 19:27:07 +02:00
return $this->redirectToRoute('app_admin_import_stats');
}
// Tags attendus pour la complétion
$expected_tags = ['maxspeed', 'highway'];
// On transmet le code INSEE et le nom de la ville au template
return $this->render('admin/speed_limit.html.twig', [
'stats' => $stats,
'insee_code' => $insee_code,
'expected_tags' => $expected_tags,
'maptiler_token' => $_ENV['MAPTILER_TOKEN'] ?? null,
]);
}
2025-07-16 17:00:09 +02:00
#[Route('/admin/demandes', name: 'app_admin_demandes')]
public function listDemandes(Request $request): Response
{
$status = $request->query->get('status');
$repository = $this->entityManager->getRepository(\App\Entity\Demande::class);
if ($status) {
$demandes = $repository->findByStatus($status);
} else {
$demandes = $repository->findAllOrderedByCreatedAt();
}
// Get all possible statuses for the filter
$allStatuses = ['new', 'email_provided', 'ready', 'email_sent', 'email_failed', 'email_opened', 'edit_form_opened', 'place_modified', 'linked_to_place'];
// Count demandes for each status
$statusCounts = [];
foreach ($allStatuses as $statusValue) {
$statusCounts[$statusValue] = $repository->findByStatus($statusValue);
}
// Get total count
$totalCount = $repository->findAllOrderedByCreatedAt();
return $this->render('admin/demandes/list.html.twig', [
'demandes' => $demandes,
'current_status' => $status,
'all_statuses' => $allStatuses,
'status_counts' => $statusCounts,
'total_count' => count($totalCount)
]);
}
#[Route('/admin/demandes/{id}/edit', name: 'app_admin_demande_edit')]
public function editDemande(int $id, Request $request): Response
{
$demande = $this->entityManager->getRepository(\App\Entity\Demande::class)->find($id);
if (!$demande) {
$this->addFlash('error', 'Demande non trouvée');
return $this->redirectToRoute('app_admin_demandes');
}
if ($request->isMethod('POST')) {
$placeUuid = $request->request->get('placeUuid');
if ($placeUuid) {
// Check if the Place exists
$place = $this->entityManager->getRepository(Place::class)->findOneBy(['uuid_for_url' => $placeUuid]);
if ($place) {
$demande->setPlaceUuid($placeUuid);
$demande->setPlace($place);
$demande->setStatus('linked_to_place');
// Set OSM object type and OSM ID from the Place
$demande->setOsmObjectType($place->getOsmKind());
$demande->setOsmId((int)$place->getOsmId());
$this->entityManager->persist($demande);
$this->entityManager->flush();
$this->addFlash('success', 'Demande mise à jour avec succès');
} else {
$this->addFlash('error', 'Place non trouvée avec cet UUID');
}
}
}
return $this->render('admin/demandes/edit.html.twig', [
'demande' => $demande
]);
}
#[Route('/admin/contacted-places', name: 'app_admin_contacted_places')]
public function listContactedPlaces(): Response
{
$demandes = $this->entityManager->getRepository(\App\Entity\Demande::class)->findPlacesWithContactAttempt();
return $this->render('admin/demandes/contacted_places.html.twig', [
'demandes' => $demandes
]);
}
#[Route('/admin/demandes/{id}/send-email', name: 'app_admin_demande_send_email')]
public function sendEmailToDemande(int $id, \Symfony\Component\Mailer\MailerInterface $mailer): Response
{
$demande = $this->entityManager->getRepository(\App\Entity\Demande::class)->find($id);
if (!$demande) {
$this->addFlash('error', 'Demande non trouvée');
return $this->redirectToRoute('app_admin_demandes');
}
$place = $demande->getPlace();
if (!$place) {
$this->addFlash('error', 'Aucune place associée à cette demande');
return $this->redirectToRoute('app_admin_demande_edit', ['id' => $id]);
}
// Check if the place has an email
if (!$place->getEmail() && !$demande->getEmail()) {
$this->addFlash('error', 'Aucun email associé à cette place ou à cette demande');
return $this->redirectToRoute('app_admin_demande_edit', ['id' => $id]);
}
// Use the email from the place if available, otherwise use the email from the demande
$email = $place->getEmail() ?: $demande->getEmail();
// Generate the email content
$emailContent = $this->renderView('admin/email_content.html.twig', [
'place' => $place
]);
// Only send the email in production environment
if ($this->getParameter('kernel.environment') === 'prod') {
$message = (new \Symfony\Component\Mime\Email())
->from('contact@osm-commerce.fr')
->to($email)
->subject('Votre lien de modification OpenStreetMap')
->html($emailContent);
try {
$mailer->send($message);
} catch (\Throwable $e) {
$this->actionLogger->log('ERROR_envoi_email', [
'demande_id' => $demande->getId(),
'place_id' => $place->getId(),
'message' => $e->getMessage(),
]);
$this->addFlash('error', 'Erreur lors de l\'envoi de l\'email : ' . $e->getMessage());
return $this->redirectToRoute('app_admin_demande_edit', ['id' => $id]);
}
} else {
// In non-production environments, just log the attempt
$this->actionLogger->log('email_would_be_sent', [
'demande_id' => $demande->getId(),
'place_id' => $place->getId(),
'email' => $email,
'content' => $emailContent
]);
$this->addFlash('info', 'En environnement de production, un email serait envoyé à ' . $email);
}
// Update the last contact attempt date and set status to email_sent
$now = new \DateTime();
$demande->setLastContactAttempt($now);
$demande->setStatus('email_sent');
$place->setLastContactAttemptDate($now);
$this->entityManager->persist($demande);
$this->entityManager->persist($place);
$this->entityManager->flush();
$this->addFlash('success', 'Email envoyé avec succès');
return $this->redirectToRoute('app_admin_contacted_places');
}
2025-05-26 11:55:44 +02:00
}