osm-labo/src/Controller/AdminController.php
2025-08-31 17:57:28 +02:00

3727 lines
157 KiB
PHP

<?php
namespace App\Controller;
use App\Entity\Place;
use App\Entity\Stats;
use App\Entity\StatsHistory;
use App\Service\ActionLogger;
use App\Service\BudgetService;
use App\Service\FollowUpService;
use App\Service\Motocultrice;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Twig\Environment;
use function uuid_create;
final class AdminController extends AbstractController
{
private FollowUpService $followUpService;
// Flag pour activer/désactiver la suppression de ville
private $allowDeleteCity = false;
public function __construct(
private EntityManagerInterface $entityManager,
private Motocultrice $motocultrice,
private BudgetService $budgetService,
private Environment $twig,
private ActionLogger $actionLogger,
FollowUpService $followUpService
)
{
$this->followUpService = $followUpService;
}
#[Route('/admin/labourer-toutes-les-zones', name: 'app_admin_labourer_tout')]
public function labourer_tout(): Response
{
$this->actionLogger->log('labourer_toutes_les_zones', []);
$updateExisting = true;
$stats_all = $this->entityManager->getRepository(Stats::class)->findAll();
echo 'on a trouvé ' . count($stats_all) . ' zones à labourer<br>';
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
// 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;
}
}
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) {
// Vérifier si le lieu existe déjà
$existingPlace = $this->entityManager->getRepository(Place::class)
->findOneBy(['osmId' => $placeData['id']]);
if (!$existingPlace) {
$place = new Place();
$place->setOsmId($placeData['id'])
->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)
;
// Mettre à jour les données depuis Overpass
$place->update_place_from_overpass_data($placeData);
$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);
$stats->addPlace($existingPlace);
$this->entityManager->persist($existingPlace);
$updatedCount++;
}
}
// 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
$stats_exist = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]);
if ($stats_exist) {
$stats = $stats_exist;
} else {
$stats = new Stats();
// dump('nouvelle stat', $insee_code);
// die();
$stats->setZone($insee_code);
}
$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();
// 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']);
$stats->setCompletionPercent($calculatedStats['completion_percent']);
// Associer les stats à chaque commerce
foreach ($commerces as $commerce) {
$commerce->setStats($stats);
// Injection de l'emoji pour le template
$mainTag = $commerce->getMainTag();
$emoji = self::getTagEmoji($mainTag);
$commerce->tagEmoji = $emoji;
$this->entityManager->persist($commerce);
}
$stats->computeCompletionPercent();
// Calculer les statistiques de fraîcheur des données OSM
$timestamps = [];
foreach ($stats->getPlaces() as $place) {
if ($place->getOsmDataDate()) {
$timestamps[] = $place->getOsmDataDate()->getTimestamp();
}
}
if (!empty($timestamps)) {
// Date la plus ancienne (min)
$minTimestamp = min($timestamps);
$stats->setOsmDataDateMin(new \DateTime('@' . $minTimestamp));
// Date la plus récente (max)
$maxTimestamp = max($timestamps);
$stats->setOsmDataDateMax(new \DateTime('@' . $maxTimestamp));
// Date moyenne
$avgTimestamp = array_sum($timestamps) / count($timestamps);
$stats->setOsmDataDateAvg(new \DateTime('@' . (int)$avgTimestamp));
}
if ($stats->getDateCreated() == null) {
$stats->setDateCreated(new \DateTime());
}
$stats->setDateModified(new \DateTime());
// Créer un historique des statistiques
$statsHistory = new StatsHistory();
$statsHistory->setDate(new \DateTime())
->setStats($stats);
// 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())
->setOpeningHoursCount($stats->getAvecHoraires())
->setAddressCount($stats->getAvecAdresse())
->setWebsiteCount($stats->getAvecSite())
->setSiretCount($placesWithSiret)
->setEmailsCount($placesWithEmail)
->setCompletionPercent($stats->getCompletionPercent())
->setStats($stats);
$this->entityManager->persist($statsHistory);
$this->entityManager->persist($stats);
$this->entityManager->flush();
// 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();
// 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);
}
$this->entityManager->flush();
// 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]);
}
$this->entityManager->flush();
$this->addFlash('success', 'Labourage des ' . count($stats_all) . ' zones terminé avec succès.');
return $this->redirectToRoute('app_public_dashboard');
}
#[Route('/admin', name: 'app_admin')]
public function index(): Response
{
return $this->render('admin/index.html.twig', [
'controller_name' => 'AdminController',
]);
}
#[Route('/admin/stats/{insee_code}', name: 'app_admin_stats', requirements: ['insee_code' => '\\d+'])]
public function stats(string $insee_code): Response
{
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]);
if (!$stats) {
$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');
}
// $completion = $stats->getCompletionPercent();
// if (!$completion) {
// $stats->computeCompletionPercent();
// $completion = $stats->getCompletionPercent();
// }
$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) {
$this->followUpService->generateCityFollowUps($stats, $this->motocultrice, $this->entityManager);
$followups = $stats->getCityFollowUps();
}
$commerces = $stats->getPlacesCount();
// Remove existing duplicates
$this->removeDuplicatePlaces($stats);
// labourer
$places_found = $this->motocultrice->labourer($insee_code);
if (count($places_found)) {
// var_dump(count($places_found));
$placeRepo = $this->entityManager->getRepository(Place::class);
$addedCount = 0;
foreach ($places_found as $placeData) {
// Check if a place with the same osm_id and osm_kind already exists
$existingPlace = $placeRepo->findOneBy([
'osmId' => $placeData['id'],
'osm_kind' => $placeData['type'],
'stats' => $stats
]);
if (!$existingPlace) {
// Create a new place only if it doesn't already exist
$newPlace = new Place();
$newPlace->setOsmId($placeData['id'])
->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)
;
// Mettre à jour les données depuis Overpass
$newPlace->update_place_from_overpass_data($placeData);
$stats->addPlace($newPlace);
$newPlace->setStats($stats);
$this->entityManager->persist($newPlace);
$addedCount++;
}
}
// Update the places_count property
$stats->setPlacesCount($stats->getPlaces()->count());
// $this->entityManager->persist($stats);
}
$this->entityManager->flush();
$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]);
if (!$stats) {
// 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);
$stats->setName('Nouvelle zone non labourée');
}
$urls = $stats->getAllCTCUrlsMap();
$statsHistory = $this->entityManager->getRepository(StatsHistory::class)
->createQueryBuilder('sh')
->where('sh.stats = :stats')
->setParameter('stats', $stats)
->orderBy('sh.id', 'DESC')
->setMaxResults(100)
->getQuery()
->getResult();
// Données pour le graphique des modifications par trimestre
$modificationsByQuarter = [];
$totalDates = 0;
$sumDates = 0;
$averageUpdateDate = null;
$daysSinceUpdate = null;
$commerces = $stats->getPlaces();
if (isset($commerces) && count($commerces) > 0 && is_iterable($commerces)) {
$allDates = [];
foreach ($commerces as $commerce) {
$date = null;
if ($commerce->getOsmDataDate()) {
$date = $commerce->getOsmDataDate();
$allDates[] = $date;
$totalDates++;
$sumDates += $date->getTimestamp();
}
if ($date) {
$year = $date->format('Y');
$quarter = ceil($date->format('n') / 3);
$key = $year . '-Q' . $quarter;
if (!isset($modificationsByQuarter[$key])) {
$modificationsByQuarter[$key] = 0;
}
$modificationsByQuarter[$key]++;
}
}
// Calculer la date moyenne de mise à jour
if ($totalDates > 0) {
$averageTimestamp = $sumDates / $totalDates;
$averageUpdateDate = new \DateTime();
$averageUpdateDate->setTimestamp((int)$averageTimestamp);
// Calculer le nombre de jours depuis la date moyenne
$now = new \DateTime();
$daysSinceUpdate = $now->diff($averageUpdateDate)->days;
}
}
ksort($modificationsByQuarter); // Trier par clé (année-trimestre)
$geojson = [
'type' => 'FeatureCollection',
'features' => []
];
if (isset($commerces) && is_iterable($commerces)) {
foreach ($commerces as $commerce) {
if ($commerce->getLat() && $commerce->getLon()) {
// Collect missing tags
$missingTags = [];
if (!$commerce->getName()) $missingTags[] = 'name';
if (!$commerce->hasAddress()) $missingTags[] = 'address';
if (!$commerce->hasOpeningHours()) $missingTags[] = 'opening_hours';
if (!$commerce->hasWebsite()) $missingTags[] = 'website';
if (!$commerce->hasWheelchair()) $missingTags[] = 'wheelchair';
if (!$commerce->getSiret()) $missingTags[] = 'siret';
$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(),
'completion' => $commerce->getCompletionPercentage(),
'missing_tags' => $missingTags
]
];
}
}
}
// 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);
// 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;
}
}
unset($row);
// Tri décroissant sur le score normalisé
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 = [];
$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') {
if ($count === null) {
$count = $fu;
} else if ($fu->getDate() > $count->getDate()) {
$count = $fu;
}
}
if ($fu->getName() === $type . '_completion') {
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') {
if ($count === null) {
$count = $fu;
} else if ($fu->getDate() > $count->getDate()) {
$count = $fu;
}
}
if ($fu->getName() === 'places_completion') {
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;
// 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');
// --- 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);
return $this->render('admin/stats.html.twig', [
'stats' => $stats,
'commerces' => $commerces,
'urls' => $urls,
'geojson' => json_encode($geojson),
'modificationsByQuarter' => json_encode($modificationsByQuarter),
'maptiler_token' => $_ENV['MAPTILER_TOKEN'],
'statsHistory' => $statsHistory,
'CTC_urls' => $urls,
'overpass' => '',
'podium_local' => $podium_local,
'latestFollowups' => $latestFollowups,
'followup_labels' => \App\Service\FollowUpService::getFollowUpThemes(),
'followup_icons' => \App\Service\FollowUpService::getFollowUpIcons(),
'progression7Days' => $progression7Days,
'all_types' => \App\Service\FollowUpService::getFollowUpThemes(),
'getTagEmoji' => [self::class, 'getTagEmoji'],
'completion_tags' => \App\Service\FollowUpService::getFollowUpCompletionTags(),
'ctc_completion_series' => $ctc_completion_series,
'averageUpdateDate' => $averageUpdateDate,
'daysSinceUpdate' => $daysSinceUpdate,
]);
}
#[Route('/admin/stats/{insee_code}/followup-graph/{theme}', name: 'admin_followup_theme_graph', requirements: ['insee_code' => '\d+'])]
public function followupThemeGraph(string $insee_code, string $theme): Response
{
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]);
if (!$stats) {
// Si aucune stats n'existe, rechercher dans l'API geo.api.gouv.fr
$apiUrl = "https://geo.api.gouv.fr/communes/{$insee_code}";
$response = @file_get_contents($apiUrl);
if ($response === false) {
$this->addFlash('error', 'Aucune stats trouvée pour ce code INSEE et impossible de récupérer les informations depuis l\'API geo.api.gouv.fr.');
return $this->redirectToRoute('app_admin');
}
$communeData = json_decode($response, true);
if (!$communeData || !isset($communeData['nom'])) {
$this->addFlash('error', 'Aucune commune trouvée avec ce code INSEE dans l\'API geo.api.gouv.fr.');
return $this->redirectToRoute('app_admin');
}
// Créer un nouvel objet Stats avec les données de l'API
$stats = new Stats();
$stats->setZone($insee_code)
->setName($communeData['nom'])
->setDateCreated(new \DateTime())
->setDateModified(new \DateTime())
->setKind('request');
// Ajouter la population si disponible
if (isset($communeData['population'])) {
$stats->setPopulation($communeData['population']);
}
// Ajouter les coordonnées si disponibles
if (isset($communeData['centre']) && isset($communeData['centre']['coordinates'])) {
$stats->setLon((string)$communeData['centre']['coordinates'][0]);
$stats->setLat((string)$communeData['centre']['coordinates'][1]);
}
// Ajouter les codes postaux si disponibles
if (isset($communeData['codesPostaux']) && !empty($communeData['codesPostaux'])) {
$stats->setCodesPostaux(implode(',', $communeData['codesPostaux']));
}
// Ajouter le code EPCI si disponible
if (isset($communeData['codeEpci'])) {
$stats->setCodeEpci((int)$communeData['codeEpci']);
}
// Ajouter le SIREN si disponible
if (isset($communeData['siren'])) {
$stats->setSiren((int)$communeData['siren']);
}
// Persister l'objet Stats
$this->entityManager->persist($stats);
$this->entityManager->flush();
$this->addFlash('success', 'Nouvelle commune ajoutée à partir des données de l\'API geo.api.gouv.fr.');
}
$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] ?? '');
// 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 = [];
foreach ($places as $place) {
$match = false;
$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;
}
} 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;
}
}
// 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(),
];
$debug_filtered[] = $main_tag;
}
}
$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));
}
$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()];
}
// Calculate current metrics from objects array (from Overpass data)
$currentCount = count($objects);
// Calculate current completion percentage
$completionTags = \App\Service\FollowUpService::getFollowUpCompletionTags()[$theme] ?? [];
$currentCompletion = 0;
if ($currentCount > 0 && !empty($completionTags)) {
$totalTags = count($completionTags) * $currentCount;
$filledTags = 0;
foreach ($objects as $obj) {
// Get the original Place object to check tags
$place = null;
foreach ($places as $p) {
if ($p->getOsmId() === $obj['id'] && $p->getOsmKind() === $obj['osm_kind']) {
$place = $p;
break;
}
}
if ($place) {
foreach ($completionTags as $tag) {
// Simple check for name tag
if ($tag === 'name' && !empty($place->getName())) {
$filledTags++;
}
// Add more tag checks as needed
}
}
}
$currentCompletion = $totalTags > 0 ? round(($filledTags / $totalTags) * 100) : 0;
}
// Add current data to history if empty
if (empty($countData)) {
$countData[] = [
'date' => (new \DateTime())->format('Y-m-d'),
'value' => $currentCount
];
}
if (empty($completionData)) {
$completionData[] = [
'date' => (new \DateTime())->format('Y-m-d'),
'value' => $currentCompletion
];
}
return $this->render('admin/followup_theme_graph.html.twig', [
'stats' => $stats,
'theme' => $theme,
'theme_label' => $themes[$theme],
'count_data' => $countData,
'completion_data' => $completionData,
'current_count' => $currentCount,
'current_completion' => $currentCompletion,
'icons' => \App\Service\FollowUpService::getFollowUpIcons(),
'followup_labels' => $themes,
'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(),
'debug_info' => $debug_info,
]);
}
#[Route('/admin/placeType/{osm_kind}/{osm_id}', name: 'app_admin_by_osm_id')]
public function placeType(string $osm_kind, string $osm_id): Response
{
$place = $this->entityManager->getRepository(Place::class)->findOneBy(['osm_kind' => $osm_kind, 'osmId' => $osm_id]);
if ($place) {
$this->actionLogger->log('admin/placeType', [
'osm_kind' => $osm_kind,
'osm_id' => $osm_id,
'name' => $place->getName(),
'code_insee' => $place->getZipCode(),
'uuid' => $place->getUuidForUrl()
]);
return $this->redirectToRoute('app_admin_commerce', ['id' => $place->getId()]);
} else {
$this->actionLogger->log('ERROR_admin/placeType', ['osm_kind' => $osm_kind, 'osm_id' => $osm_id]);
$this->addFlash('error', 'Le lieu n\'existe pas.');
$this->actionLogger->log('ERROR_admin/placeType', ['osm_kind' => $osm_kind, 'osm_id' => $osm_id]);
return $this->redirectToRoute('app_public_index');
}
}
/**
* rediriger vers l'url unique quand on est admin
*/
#[Route('/admin/commerce/{id}', name: 'app_admin_commerce')]
public function commerce(int $id): Response
{
// 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é');
$this->actionLogger->log('ERROR_admin_show_commerce_form_id', ['id' => $id]);
}
$this->actionLogger->log('admin_show_commerce_form_id', [
'id' => $id,
'name' => $commerce->getName(),
'code_insee' => $commerce->getZipCode(),
'uuid' => $commerce->getUuidForUrl()
]);
// Redirection vers la page de modification avec les paramètres nécessaires
return $this->redirectToRoute('app_public_edit', [
'zipcode' => $commerce->getZipCode(),
'name' => $commerce->getName() != '' ? $commerce->getName() : '?',
'uuid' => $commerce->getUuidForUrl()
]);
}
/**
* récupérer les commerces de la zone selon le code INSEE, créer les nouveaux lieux, et mettre à jour les existants
*/
#[Route('/admin/labourer/{insee_code}', name: 'app_admin_labourer')]
public function labourer(Request $request, string $insee_code, bool $updateExisting = true): Response
{
$deleteMissing = $request->query->getBoolean('deleteMissing', true);
$disableFollowUpCleanup = $request->query->getBoolean('disableFollowUpCleanup', false);
$debug = $request->query->getBoolean('debug', false);
$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.');
$this->actionLogger->log('ERROR_labourer_bad_insee', ['insee_code' => $insee_code]);
return $this->redirectToRoute('app_public_index');
}
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]);
if (!$stats) {
$stats = new Stats();
$stats->setZone($insee_code);
$stats->setKind('request'); // Set the kind to 'request' as it's created from an admin request
// $this->addFlash('error', '3 Aucune stats trouvée pour ce code INSEE.');
// return $this->redirectToRoute('app_public_index');
}
// 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) {
}
}
// Mettre à jour la date de requête de labourage
$stats->setDateLabourageRequested(new \DateTime());
$stats->computeCompletionPercent();
$this->entityManager->persist($stats);
$this->entityManager->flush();
// 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());
$this->entityManager->persist($stats);
$this->entityManager->flush();
$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.");
}
// Toujours générer les CityFollowUp (mais ne jamais les supprimer)
// $themes = \App\Service\FollowUpService::getFollowUpThemes();
// foreach (array_keys($themes) as $theme) {
$this->followUpService->generateCityFollowUps($stats, $this->motocultrice, $this->entityManager, true);
// }
$this->entityManager->flush();
return $this->redirectToRoute('app_admin_stats', ['insee_code' => $insee_code]);
}
#[Route('/admin/delete/{id}', name: 'app_admin_delete')]
public function delete(int $id): Response
{
$this->actionLogger->log('admin/delete_place', ['id' => $id]);
$commerce = $this->entityManager->getRepository(Place::class)->find($id);
if ($commerce) {
$this->entityManager->remove($commerce);
$this->entityManager->flush();
$this->addFlash('success', 'Le lieu ' . $commerce->getName() . ' a été supprimé avec succès de OSM Mes commerces, mais pas dans OpenStreetMap.');
} else {
$this->addFlash('error', 'Le lieu n\'existe pas.');
}
return $this->redirectToRoute('app_public_dashboard');
}
#[Route('/admin/delete_by_zone/{insee_code}', name: 'app_admin_delete_by_zone')]
public function delete_by_zone(string $insee_code): Response
{
$this->actionLogger->log('admin/delete_by_zone', ['insee_code' => $insee_code]);
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]);
if (!$stats) {
$this->addFlash('error', 'Aucune statistique trouvée pour la zone ' . $insee_code);
return $this->redirectToRoute('app_public_dashboard');
}
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());
}
return $this->redirectToRoute('app_public_dashboard');
}
#[Route('/admin/export', name: 'app_admin_export')]
public function export(): Response
{
$this->actionLogger->log('export_all_places', []);
$places = $this->entityManager->getRepository(Place::class)->findAll();
$csvData = [];
$csvData[] = [
'Nom',
'Email',
'Code postal',
'ID OSM',
'Type OSM',
'Date de modification',
'Date dernier contact',
'Note',
'Désabonné',
'Inactif',
'Support humain demandé',
'A des horaires',
'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',
$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);
return $response;
}
#[Route('/admin/export_csv/{insee_code}', name: 'app_admin_export_csv')]
public function export_csv(string $insee_code): Response
{
$this->actionLogger->log('admin/export_csv', ['insee_code' => $insee_code]);
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]);
$response = new Response($this->motocultrice->export($insee_code));
$response->headers->set('Content-Type', 'text/csv');
$slug_name = str_replace(' ', '-', $stats->getName());
$this->actionLogger->log('export_csv', ['insee_code' => $insee_code, 'slug_name' => $slug_name]);
$response->headers->set('Content-Disposition', 'attachment; filename="osm-commerces-export_' . $insee_code . '_' . $slug_name . '_' . date('Y-m-d_H-i-s') . '.csv"');
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
{
$this->actionLogger->log('admin/make_email_for_place', ['insee_code' => $place->getId()]);
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
{
$this->actionLogger->log('no_more_sollicitation_for_place', ['place_id' => $place->getId()]);
$place->setOptedOut(true);
$this->entityManager->persist($place);
$this->entityManager->flush();
$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
{
$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.');
$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);
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();
$place->setLastContactAttemptDate(new \DateTime());
$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');
}
#[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
{
// Ajout d'un log d'action avec le service ActionLogger
$this->actionLogger->log('fraicheur/calculate', []);
$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
];
$filesystem->dumpFile($jsonPath, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
// --- 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
];
$filesystem->dumpFile($distJsonPath, json_encode($distData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
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
];
$filesystem->dumpFile($jsonPath, json_encode($distData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
}
$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();
}
$this->addFlash('success', $budgetsMisAJour . ' budgets mis à jour.');
return $this->redirectToRoute('app_admin');
}
#[Route('/admin/import-stats-from-csv', name: 'app_admin_import_stats_from_csv')]
public function importStatsFromCsv(): Response
{
$this->actionLogger->log('admin/import_stats_from_csv', []);
$csvFile = 'communes_france.csv';
if (!file_exists($csvFile)) {
$this->addFlash('error', 'Le fichier CSV des communes n\'existe pas. Veuillez exécuter le script fetch_communes.py pour le générer.');
return $this->redirectToRoute('app_admin');
}
$statsRepo = $this->entityManager->getRepository(Stats::class);
$createdCount = 0;
$skippedCount = 0;
$errorCount = 0;
// Ouvrir le fichier CSV
$handle = fopen($csvFile, 'r');
if (!$handle) {
$this->addFlash('error', 'Impossible d\'ouvrir le fichier CSV des communes.');
return $this->redirectToRoute('app_admin');
}
// Lire l'en-tête pour déterminer les indices des colonnes
$header = fgetcsv($handle);
$indices = array_flip($header);
// Vérifier que les colonnes nécessaires existent
$requiredColumns = ['code', 'nom'];
foreach ($requiredColumns as $column) {
if (!isset($indices[$column])) {
$this->addFlash('error', "La colonne '$column' est manquante dans le fichier CSV.");
fclose($handle);
return $this->redirectToRoute('app_admin');
}
}
// Traiter chaque ligne du CSV
while (($data = fgetcsv($handle)) !== false) {
try {
$inseeCode = $data[$indices['code']];
// Vérifier si une Stats existe déjà pour ce code INSEE
$existingStat = $statsRepo->findOneBy(['zone' => $inseeCode]);
if ($existingStat) {
$skippedCount++;
continue;
}
// Créer un nouvel objet Stats
$stat = new Stats();
$stat->setZone($inseeCode)
->setDateCreated(new \DateTime())
->setDateModified(new \DateTime())
->setKind('request');
// Ajouter le nom si disponible
if (isset($indices['nom']) && !empty($data[$indices['nom']])) {
$stat->setName($data[$indices['nom']]);
}
// Ajouter la population si disponible
if (isset($indices['population']) && !empty($data[$indices['population']])) {
$stat->setPopulation((int)$data[$indices['population']]);
}
// Ajouter les codes postaux si disponibles
if (isset($indices['codesPostaux']) && !empty($data[$indices['codesPostaux']])) {
$stat->setCodesPostaux($data[$indices['codesPostaux']]);
}
// Ajouter le SIREN si disponible
if (isset($indices['siren']) && !empty($data[$indices['siren']])) {
$stat->setSiren((int)$data[$indices['siren']]);
}
// Ajouter le code EPCI si disponible
if (isset($indices['codeEpci']) && !empty($data[$indices['codeEpci']])) {
$stat->setCodeEpci((int)$data[$indices['codeEpci']]);
}
// Ajouter les coordonnées si disponibles
if (isset($indices['longitude']) && isset($indices['latitude']) &&
!empty($data[$indices['longitude']]) && !empty($data[$indices['latitude']])) {
$stat->setLon((string)$data[$indices['longitude']])
->setLat((string)$data[$indices['latitude']]);
}
// Persister l'objet Stats
$this->entityManager->persist($stat);
$createdCount++;
// Flush tous les 100 objets pour éviter de surcharger la mémoire
if ($createdCount % 100 === 0) {
$this->entityManager->flush();
$this->entityManager->clear(Stats::class);
}
} catch (\Exception $e) {
$errorCount++;
}
}
// Flush les derniers objets
$this->entityManager->flush();
fclose($handle);
$this->addFlash('success', "Import terminé : $createdCount communes ajoutées, $skippedCount déjà existantes, $errorCount erreurs.");
return $this->redirectToRoute('app_admin');
}
#[Route('/admin/create-missing-stats-from-csv', name: 'app_admin_create_missing_stats_from_csv')]
public function createMissingStatsFromCsv(): Response
{
$this->actionLogger->log('admin/create_missing_stats_from_csv', []);
$csvFile = 'communes_france.csv';
if (!file_exists($csvFile)) {
$this->addFlash('error', 'Le fichier CSV des communes n\'existe pas. Veuillez exécuter le script fetch_communes.py pour le générer.');
return $this->redirectToRoute('app_admin');
}
$statsRepo = $this->entityManager->getRepository(Stats::class);
$createdCount = 0;
$skippedCount = 0;
$errorCount = 0;
// Ouvrir le fichier CSV
$handle = fopen($csvFile, 'r');
if (!$handle) {
$this->addFlash('error', 'Impossible d\'ouvrir le fichier CSV des communes.');
return $this->redirectToRoute('app_admin');
}
// Lire l'en-tête pour déterminer les indices des colonnes
$header = fgetcsv($handle);
$indices = array_flip($header);
// Vérifier que les colonnes nécessaires existent
$requiredColumns = ['code', 'nom'];
foreach ($requiredColumns as $column) {
if (!isset($indices[$column])) {
$this->addFlash('error', "La colonne '$column' est manquante dans le fichier CSV.");
fclose($handle);
return $this->redirectToRoute('app_admin');
}
}
// Traiter chaque ligne du CSV
while (($data = fgetcsv($handle)) !== false) {
try {
$inseeCode = $data[$indices['code']];
// Vérifier si une Stats existe déjà pour ce code INSEE
$existingStat = $statsRepo->findOneBy(['zone' => $inseeCode]);
if ($existingStat) {
$skippedCount++;
continue;
}
// Créer un nouvel objet Stats
$stat = new Stats();
$stat->setZone($inseeCode)
->setDateCreated(new \DateTime())
->setDateModified(new \DateTime())
->setKind('command'); // Utiliser 'command' comme source
// Ajouter le nom si disponible
if (isset($indices['nom']) && !empty($data[$indices['nom']])) {
$stat->setName($data[$indices['nom']]);
}
// Ajouter la population si disponible
if (isset($indices['population']) && !empty($data[$indices['population']])) {
$stat->setPopulation((int)$data[$indices['population']]);
}
// Ajouter les codes postaux si disponibles
if (isset($indices['codesPostaux']) && !empty($data[$indices['codesPostaux']])) {
$stat->setCodesPostaux($data[$indices['codesPostaux']]);
}
// Ajouter le SIREN si disponible
if (isset($indices['siren']) && !empty($data[$indices['siren']])) {
$stat->setSiren((int)$data[$indices['siren']]);
}
// Ajouter le code EPCI si disponible
if (isset($indices['codeEpci']) && !empty($data[$indices['codeEpci']])) {
$stat->setCodeEpci((int)$data[$indices['codeEpci']]);
}
// Compléter les données manquantes (coordonnées, budget, etc.)
$this->completeStatsData($stat);
// Persister l'objet Stats
$this->entityManager->persist($stat);
$createdCount++;
// Flush tous les 100 objets pour éviter de surcharger la mémoire
if ($createdCount % 100 === 0) {
$this->entityManager->flush();
$this->entityManager->clear(Stats::class);
}
} catch (\Exception $e) {
$errorCount++;
$this->actionLogger->log('error_create_missing_stats_from_csv', [
'insee_code' => $inseeCode ?? 'unknown',
'error' => $e->getMessage()
]);
}
}
// Flush les derniers objets
$this->entityManager->flush();
fclose($handle);
$this->addFlash('success', "Création des Stats manquantes terminée : $createdCount communes ajoutées, $skippedCount déjà existantes, $errorCount erreurs.");
return $this->redirectToRoute('app_admin');
}
#[Route('/admin/create-stats-from-insee-csv', name: 'app_admin_create_stats_from_insee_csv')]
public function createStatsFromInseeCsv(): Response
{
$this->actionLogger->log('admin/create_stats_from_insee_csv', []);
$csvFile = 'communes_france.csv';
if (!file_exists($csvFile)) {
$this->addFlash('error', 'Le fichier CSV des communes n\'existe pas. Veuillez exécuter le script fetch_communes.py pour le générer.');
return $this->redirectToRoute('app_admin');
}
$statsRepo = $this->entityManager->getRepository(Stats::class);
$createdCount = 0;
$skippedCount = 0;
$errorCount = 0;
// Ouvrir le fichier CSV
$handle = fopen($csvFile, 'r');
if (!$handle) {
$this->addFlash('error', 'Impossible d\'ouvrir le fichier CSV des communes.');
return $this->redirectToRoute('app_admin');
}
// Lire l'en-tête pour déterminer les indices des colonnes
$header = fgetcsv($handle);
$indices = array_flip($header);
// Vérifier que les colonnes nécessaires existent
$requiredColumns = ['code', 'nom'];
foreach ($requiredColumns as $column) {
if (!isset($indices[$column])) {
$this->addFlash('error', "La colonne '$column' est manquante dans le fichier CSV.");
fclose($handle);
return $this->redirectToRoute('app_admin');
}
}
// Traiter chaque ligne du CSV
while (($data = fgetcsv($handle)) !== false) {
try {
$inseeCode = $data[$indices['code']];
// Vérifier si une Stats existe déjà pour ce code INSEE
$existingStat = $statsRepo->findOneBy(['zone' => $inseeCode]);
if ($existingStat) {
$skippedCount++;
continue;
}
// Créer un nouvel objet Stats
$stat = new Stats();
$stat->setZone($inseeCode)
->setDateCreated(new \DateTime())
->setDateModified(new \DateTime())
->setKind('insee_csv'); // Utiliser 'insee_csv' comme source
// Ajouter le nom si disponible
if (isset($indices['nom']) && !empty($data[$indices['nom']])) {
$stat->setName($data[$indices['nom']]);
}
// Ajouter la population si disponible
if (isset($indices['population']) && !empty($data[$indices['population']])) {
$stat->setPopulation((int)$data[$indices['population']]);
}
// Ajouter les codes postaux si disponibles
if (isset($indices['codesPostaux']) && !empty($data[$indices['codesPostaux']])) {
$stat->setCodesPostaux($data[$indices['codesPostaux']]);
}
// Ajouter le SIREN si disponible
if (isset($indices['siren']) && !empty($data[$indices['siren']])) {
$stat->setSiren((int)$data[$indices['siren']]);
}
// Ajouter le code EPCI si disponible
if (isset($indices['codeEpci']) && !empty($data[$indices['codeEpci']])) {
$stat->setCodeEpci((int)$data[$indices['codeEpci']]);
}
// Ne pas faire de labourage des objets avant la sauvegarde
// Persister l'objet Stats
$this->entityManager->persist($stat);
$createdCount++;
// Flush tous les 100 objets pour éviter de surcharger la mémoire
if ($createdCount % 100 === 0) {
$this->entityManager->flush();
$this->entityManager->clear(Stats::class);
}
} catch (\Exception $e) {
$errorCount++;
$this->actionLogger->log('error_create_stats_from_insee_csv', [
'insee_code' => $inseeCode ?? 'unknown',
'error' => $e->getMessage()
]);
}
}
// Flush les derniers objets
$this->entityManager->flush();
fclose($handle);
$this->addFlash('success', "Création des Stats depuis le CSV INSEE terminée : $createdCount communes ajoutées, $skippedCount déjà existantes, $errorCount erreurs.");
return $this->redirectToRoute('app_admin');
}
#[Route('/admin/retrieve-city-polygons', name: 'app_admin_retrieve_city_polygons')]
public function retrieveCityPolygons(): Response
{
$this->actionLogger->log('admin/retrieve_city_polygons', []);
// Vérifier que le dossier polygons existe, sinon le créer
$polygonsDir = __DIR__ . '/../../counting_osm_objects/polygons';
if (!is_dir($polygonsDir)) {
mkdir($polygonsDir, 0755, true);
}
// Récupérer toutes les Stats
$statsRepo = $this->entityManager->getRepository(Stats::class);
$allStats = $statsRepo->findAll();
$totalCount = count($allStats);
$existingCount = 0;
$createdCount = 0;
$errorCount = 0;
// Pour chaque Stats, récupérer le polygone si nécessaire
foreach ($allStats as $stat) {
$inseeCode = $stat->getZone();
if (!$inseeCode) {
continue;
}
$polygonFile = $polygonsDir . '/commune_' . $inseeCode . '.poly';
// Vérifier si le polygone existe déjà
if (file_exists($polygonFile)) {
$existingCount++;
continue;
}
try {
// Utiliser le script Python existant pour récupérer le polygone
$command = 'cd ' . __DIR__ . '/../../counting_osm_objects && python3 get_poly.py ' . $inseeCode;
$output = [];
$returnVar = 0;
exec($command, $output, $returnVar);
if ($returnVar === 0 && file_exists($polygonFile)) {
$createdCount++;
} else {
$errorCount++;
$this->actionLogger->log('error_retrieve_city_polygon', [
'insee_code' => $inseeCode,
'error' => 'Failed to retrieve polygon: ' . implode("\n", $output)
]);
}
} catch (\Exception $e) {
$errorCount++;
$this->actionLogger->log('error_retrieve_city_polygon', [
'insee_code' => $inseeCode,
'error' => $e->getMessage()
]);
}
}
$this->addFlash('success', "Récupération des polygones terminée : $createdCount polygones créés, $existingCount déjà existants, $errorCount erreurs sur un total de $totalCount communes.");
return $this->redirectToRoute('app_admin');
}
#[Route('/admin/extract-insee-zones', name: 'app_admin_extract_insee_zones')]
public function extractInseeZones(): Response
{
$this->actionLogger->log('admin/extract_insee_zones', []);
// Créer le dossier oss_data s'il n'existe pas
$ossDataDir = __DIR__ . '/../../oss_data';
if (!is_dir($ossDataDir)) {
mkdir($ossDataDir, 0755, true);
}
// Vérifier que le fichier france-latest.osm.pbf existe
$francePbfFile = $ossDataDir . '/france-latest.osm.pbf';
if (!file_exists($francePbfFile)) {
$this->actionLogger->log('admin/download_france_pbf', []);
$this->addFlash('info', 'Le fichier france-latest.osm.pbf n\'existe pas. Téléchargement en cours depuis Geofabrik...');
// URL de téléchargement
$downloadUrl = 'https://download.geofabrik.de/europe/france-latest.osm.pbf';
// Télécharger le fichier
try {
$context = stream_context_create([
'http' => [
'header' => "User-Agent: OSM-Commerces/1.0\r\n"
]
]);
// Utiliser file_get_contents pour télécharger le fichier
$fileContent = file_get_contents($downloadUrl, false, $context);
if ($fileContent === false) {
throw new \Exception('Échec du téléchargement');
}
// Sauvegarder le fichier
if (file_put_contents($francePbfFile, $fileContent) === false) {
throw new \Exception('Échec de l\'écriture du fichier');
}
$this->addFlash('success', 'Le fichier france-latest.osm.pbf a été téléchargé avec succès.');
} catch (\Exception $e) {
$this->actionLogger->log('error_download_france_pbf', [
'error' => $e->getMessage()
]);
$this->addFlash('error', 'Erreur lors du téléchargement du fichier france-latest.osm.pbf: ' . $e->getMessage());
return $this->redirectToRoute('app_admin');
}
}
// Vérifier que le dossier polygons existe
$polygonsDir = __DIR__ . '/../../counting_osm_objects/polygons';
if (!is_dir($polygonsDir)) {
$this->addFlash('error', 'Le dossier des polygones n\'existe pas. Veuillez d\'abord exécuter l\'action "Récupérer les polygones des villes".');
return $this->redirectToRoute('app_admin');
}
// Créer le dossier pour les extractions JSON si nécessaire
$extractsDir = __DIR__ . '/../../insee_extracts';
if (!is_dir($extractsDir)) {
mkdir($extractsDir, 0755, true);
}
// Récupérer toutes les Stats
$statsRepo = $this->entityManager->getRepository(Stats::class);
$allStats = $statsRepo->findAll();
$totalCount = count($allStats);
$existingCount = 0;
$createdCount = 0;
$errorCount = 0;
// Pour chaque Stats, extraire les données si nécessaire
foreach ($allStats as $stat) {
$inseeCode = $stat->getZone();
if (!$inseeCode) {
continue;
}
$polygonFile = $polygonsDir . '/commune_' . $inseeCode . '.poly';
$extractPbfFile = $extractsDir . '/commune_' . $inseeCode . '.osm.pbf';
$extractJsonFile = $extractsDir . '/commune_' . $inseeCode . '.json';
// Vérifier si le polygone existe
if (!file_exists($polygonFile)) {
$this->actionLogger->log('error_extract_insee_zone', [
'insee_code' => $inseeCode,
'error' => 'Polygon file does not exist'
]);
$errorCount++;
continue;
}
// Vérifier si l'extraction JSON existe déjà
if (file_exists($extractJsonFile)) {
$existingCount++;
continue;
}
try {
// Étape 1: Extraire les données de france-latest.osm.pbf vers un fichier PBF pour la zone
$extractCommand = 'osmium extract -p ' . $polygonFile . ' ' . $francePbfFile . ' -o ' . $extractPbfFile;
$output = [];
$returnVar = 0;
exec($extractCommand, $output, $returnVar);
if ($returnVar !== 0 || !file_exists($extractPbfFile)) {
$this->actionLogger->log('error_extract_insee_zone', [
'insee_code' => $inseeCode,
'error' => 'Failed to extract PBF: ' . implode("\n", $output)
]);
$errorCount++;
continue;
}
// Étape 2: Convertir le fichier PBF en JSON
$exportCommand = 'osmium export ' . $extractPbfFile . ' -f json -o ' . $extractJsonFile;
$output = [];
$returnVar = 0;
exec($exportCommand, $output, $returnVar);
if ($returnVar === 0 && file_exists($extractJsonFile)) {
$createdCount++;
} else {
$this->actionLogger->log('error_extract_insee_zone', [
'insee_code' => $inseeCode,
'error' => 'Failed to export to JSON: ' . implode("\n", $output)
]);
$errorCount++;
}
// Supprimer le fichier PBF intermédiaire pour économiser de l'espace
if (file_exists($extractPbfFile)) {
unlink($extractPbfFile);
}
} catch (\Exception $e) {
$errorCount++;
$this->actionLogger->log('error_extract_insee_zone', [
'insee_code' => $inseeCode,
'error' => $e->getMessage()
]);
}
}
$this->addFlash('success', "Extraction des zones INSEE terminée : $createdCount extractions créées, $existingCount déjà existantes, $errorCount erreurs sur un total de $totalCount communes.");
return $this->redirectToRoute('app_admin');
}
#[Route('/admin/process-insee-extracts', name: 'app_admin_process_insee_extracts')]
public function processInseeExtracts(): Response
{
$this->actionLogger->log('admin/process_insee_extracts', []);
// Vérifier que le dossier des extractions existe
$extractsDir = __DIR__ . '/../../insee_extracts';
if (!is_dir($extractsDir)) {
$this->addFlash('error', 'Le dossier des extractions n\'existe pas. Veuillez d\'abord exécuter l\'action "Extraire les données des zones INSEE".');
return $this->redirectToRoute('app_admin');
}
// Récupérer toutes les Stats
$statsRepo = $this->entityManager->getRepository(Stats::class);
$allStats = $statsRepo->findAll();
$totalCount = count($allStats);
$processedCount = 0;
$skippedCount = 0;
$errorCount = 0;
// Pour chaque Stats, traiter les données si nécessaire
foreach ($allStats as $stat) {
$inseeCode = $stat->getZone();
if (!$inseeCode) {
continue;
}
$extractJsonFile = $extractsDir . '/commune_' . $inseeCode . '.json';
// Vérifier si l'extraction JSON existe
if (!file_exists($extractJsonFile)) {
$this->actionLogger->log('error_process_insee_extract', [
'insee_code' => $inseeCode,
'error' => 'JSON extract file does not exist'
]);
$errorCount++;
continue;
}
try {
// Utiliser la Motocultrice pour traiter les données
$result = $this->motocultrice->labourer($inseeCode);
if ($result) {
// Mettre à jour la date de labourage
$stat->setDateLabourageDone(new \DateTime());
$this->entityManager->persist($stat);
$processedCount++;
// Flush tous les 10 objets pour éviter de surcharger la mémoire
if ($processedCount % 10 === 0) {
$this->entityManager->flush();
}
} else {
$this->actionLogger->log('error_process_insee_extract', [
'insee_code' => $inseeCode,
'error' => 'Failed to process extract with Motocultrice'
]);
$errorCount++;
}
} catch (\Exception $e) {
$errorCount++;
$this->actionLogger->log('error_process_insee_extract', [
'insee_code' => $inseeCode,
'error' => $e->getMessage()
]);
}
}
// Flush les derniers objets
$this->entityManager->flush();
$this->addFlash('success', "Traitement des extractions INSEE terminé : $processedCount communes traitées, $skippedCount ignorées, $errorCount erreurs sur un total de $totalCount communes.");
return $this->redirectToRoute('app_admin');
}
#[Route('/admin/completion-statistics', name: 'app_admin_completion_statistics')]
public function completionStatistics(Request $request): Response
{
$this->actionLogger->log('admin/completion_statistics', []);
// Récupérer le thème sélectionné (par défaut: 'places')
$theme = $request->query->get('theme', 'places');
// Récupérer le niveau géographique sélectionné (par défaut: 'department')
$level = $request->query->get('level', 'department');
// Récupérer tous les thèmes disponibles
$themes = $this->followUpService->getFollowUpThemes();
// Récupérer les données de complétion selon le niveau géographique
$completionData = [];
$chartLabels = [];
$chartData = [];
switch ($level) {
case 'department':
// Agréger les données par département
$completionData = $this->aggregateCompletionByDepartment($theme);
break;
case 'region':
// Agréger les données par région
$completionData = $this->aggregateCompletionByRegion($theme);
break;
case 'country':
// Agréger les données pour la France entière
$completionData = $this->aggregateCompletionForCountry($theme);
break;
case 'city':
// Récupérer les données pour les villes (limité aux 50 plus grandes)
$completionData = $this->aggregateCompletionForTopCities($theme, 50);
break;
}
// Préparer les données pour les graphiques
foreach ($completionData as $key => $value) {
$chartLabels[] = $key;
$chartData[] = $value;
}
return $this->render('admin/completion_statistics.html.twig', [
'theme' => $theme,
'level' => $level,
'themes' => $themes,
'chartLabels' => json_encode($chartLabels),
'chartData' => json_encode($chartData),
'completionData' => $completionData
]);
}
/**
* Agrège les données de complétion par département
*/
private function aggregateCompletionByDepartment(string $theme): array
{
$result = [];
// Récupérer toutes les Stats
$statsRepo = $this->entityManager->getRepository(Stats::class);
$allStats = $statsRepo->findAll();
// Grouper par département (2 premiers chiffres du code INSEE)
$departmentData = [];
$departmentCounts = [];
foreach ($allStats as $stat) {
$inseeCode = $stat->getZone();
if (!$inseeCode || strlen($inseeCode) < 2) {
continue;
}
// Extraire le code département (2 premiers chiffres du code INSEE)
$departmentCode = substr($inseeCode, 0, 2);
// Cas particuliers pour la Corse
if ($departmentCode === '2A' || $departmentCode === '2B') {
$departmentCode = substr($inseeCode, 0, 3);
}
// Récupérer les mesures pour le thème spécifié
$latestMeasure = $this->getLatestMeasureForTheme($stat, $theme);
if ($latestMeasure !== null) {
if (!isset($departmentData[$departmentCode])) {
$departmentData[$departmentCode] = 0;
$departmentCounts[$departmentCode] = 0;
}
$departmentData[$departmentCode] += $latestMeasure;
$departmentCounts[$departmentCode]++;
}
}
// Calculer les moyennes par département
foreach ($departmentData as $departmentCode => $total) {
if ($departmentCounts[$departmentCode] > 0) {
$result[$departmentCode] = round($total / $departmentCounts[$departmentCode], 2);
}
}
// Trier par code département
ksort($result);
return $result;
}
/**
* Agrège les données de complétion par région
*/
private function aggregateCompletionByRegion(string $theme): array
{
$result = [];
// Mapping des départements vers les régions
$departmentToRegion = $this->getDepartmentToRegionMapping();
// Récupérer les données par département
$departmentData = $this->aggregateCompletionByDepartment($theme);
// Grouper par région
$regionData = [];
$regionCounts = [];
foreach ($departmentData as $departmentCode => $value) {
// Trouver la région correspondante
$region = $departmentToRegion[$departmentCode] ?? 'Autre';
if (!isset($regionData[$region])) {
$regionData[$region] = 0;
$regionCounts[$region] = 0;
}
$regionData[$region] += $value;
$regionCounts[$region]++;
}
// Calculer les moyennes par région
foreach ($regionData as $region => $total) {
if ($regionCounts[$region] > 0) {
$result[$region] = round($total / $regionCounts[$region], 2);
}
}
// Trier par nom de région
ksort($result);
return $result;
}
/**
* Agrège les données de complétion pour la France entière
*/
private function aggregateCompletionForCountry(string $theme): array
{
$result = [];
// Récupérer toutes les Stats
$statsRepo = $this->entityManager->getRepository(Stats::class);
$allStats = $statsRepo->findAll();
// Récupérer les mesures pour le thème spécifié
$totalMeasure = 0;
$count = 0;
foreach ($allStats as $stat) {
$latestMeasure = $this->getLatestMeasureForTheme($stat, $theme);
if ($latestMeasure !== null) {
$totalMeasure += $latestMeasure;
$count++;
}
}
// Calculer la moyenne nationale
if ($count > 0) {
$result['France'] = round($totalMeasure / $count, 2);
}
return $result;
}
/**
* Agrège les données de complétion pour les principales villes
*/
private function aggregateCompletionForTopCities(string $theme, int $limit = 50): array
{
$result = [];
// Récupérer les Stats triées par population (descendant)
$statsRepo = $this->entityManager->getRepository(Stats::class);
$topCities = $statsRepo->findBy(
['population' => null], // Critère (tous)
['population' => 'DESC'], // Tri par population descendante
$limit // Limite
);
// Récupérer les mesures pour le thème spécifié
foreach ($topCities as $stat) {
$cityName = $stat->getName() ?? $stat->getZone();
$latestMeasure = $this->getLatestMeasureForTheme($stat, $theme);
if ($latestMeasure !== null) {
$result[$cityName] = $latestMeasure;
}
}
// Trier par valeur décroissante
arsort($result);
return $result;
}
/**
* Récupère la dernière mesure pour un thème donné
*/
private function getLatestMeasureForTheme(Stats $stat, string $theme): ?float
{
$cityFollowUps = $stat->getCityFollowUps();
// Filtrer les CityFollowUp par thème et trier par date (descendant)
$filteredFollowUps = $cityFollowUps->filter(function(CityFollowUp $followUp) use ($theme) {
return $followUp->getName() === $theme;
});
if ($filteredFollowUps->isEmpty()) {
return null;
}
// Trier par date (du plus récent au plus ancien)
$iterator = $filteredFollowUps->getIterator();
$iterator->uasort(function(CityFollowUp $a, CityFollowUp $b) {
return $b->getDate() <=> $a->getDate();
});
// Récupérer la mesure la plus récente
$latestFollowUp = $iterator->current();
return $latestFollowUp->getMeasure();
}
#[Route('/admin/osmose-issues-map/{inseeCode}', name: 'app_admin_osmose_issues_map')]
public function osmoseIssuesMap(Request $request, ?string $inseeCode = null): Response
{
$this->actionLogger->log('admin/osmose_issues_map', [
'insee_code' => $inseeCode
]);
// Si aucun code INSEE n'est fourni, rediriger vers la liste des villes
if (!$inseeCode) {
$this->addFlash('info', 'Veuillez sélectionner une ville pour afficher les problèmes Osmose.');
return $this->redirectToRoute('app_admin');
}
// Récupérer la ville correspondante
$statsRepo = $this->entityManager->getRepository(Stats::class);
$city = $statsRepo->findOneBy(['zone' => $inseeCode]);
if (!$city) {
// Si aucune stats n'existe, rechercher dans l'API geo.api.gouv.fr
$apiUrl = "https://geo.api.gouv.fr/communes/{$inseeCode}";
$response = @file_get_contents($apiUrl);
if ($response === false) {
$this->addFlash('error', 'Ville non trouvée pour le code INSEE ' . $inseeCode . ' et impossible de récupérer les informations depuis l\'API geo.api.gouv.fr.');
return $this->redirectToRoute('app_admin');
}
$communeData = json_decode($response, true);
if (!$communeData || !isset($communeData['nom'])) {
$this->addFlash('error', 'Aucune commune trouvée avec ce code INSEE dans l\'API geo.api.gouv.fr.');
return $this->redirectToRoute('app_admin');
}
// Créer un nouvel objet Stats avec les données de l'API
$city = new Stats();
$city->setZone($inseeCode)
->setName($communeData['nom'])
->setDateCreated(new \DateTime())
->setDateModified(new \DateTime())
->setKind('osmose_request');
// Ajouter la population si disponible
if (isset($communeData['population'])) {
$city->setPopulation($communeData['population']);
}
// Ajouter les coordonnées si disponibles
if (isset($communeData['centre']) && isset($communeData['centre']['coordinates'])) {
$city->setLon((string)$communeData['centre']['coordinates'][0]);
$city->setLat((string)$communeData['centre']['coordinates'][1]);
} else {
// Si les coordonnées ne sont pas dans la réponse initiale, faire une requête spécifique
try {
$apiUrl = 'https://geo.api.gouv.fr/communes/' . $inseeCode . '?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) {
$city->setLon((string)$data['centre']['coordinates'][0]);
$city->setLat((string)$data['centre']['coordinates'][1]);
}
}
} catch (\Exception $e) {
// Ignorer les erreurs lors de la récupération des coordonnées
}
}
// Ajouter les codes postaux si disponibles
if (isset($communeData['codesPostaux']) && !empty($communeData['codesPostaux'])) {
$city->setCodesPostaux(implode(',', $communeData['codesPostaux']));
}
// Ajouter le code EPCI si disponible
if (isset($communeData['codeEpci'])) {
$city->setCodeEpci((int)$communeData['codeEpci']);
}
// Ajouter le SIREN si disponible
if (isset($communeData['siren'])) {
$city->setSiren((int)$communeData['siren']);
}
// Ne pas faire de labourage des Places pour cette ville
// Persister l'objet Stats
$this->entityManager->persist($city);
$this->entityManager->flush();
}
// Récupérer le thème sélectionné (par défaut: tous)
$theme = $request->query->get('theme', 'all');
// Récupérer tous les thèmes disponibles
$themes = $this->followUpService->getFollowUpThemes();
// Récupérer les problèmes Osmose pour cette ville
$osmoseIssues = $this->getOsmoseIssuesForCity($city, $theme);
// Créer un mapping inverse des items Osmose vers les thèmes
$itemToThemeMapping = [];
$themeToItemsMapping = [
'charging_station' => [8410, 8411],
'school' => [8031],
'healthcare' => [8211, 7220, 8331],
'laboratory' => [7240, 8351],
'police' => [8190, 8191],
'defibrillator' => [8370],
'places' => [7240, 8351, 8211, 7220, 8331, 8031],
'restaurants' => [8030, 8031, 8032],
'hotels' => [8040, 8041, 8042],
'tourism' => [8010, 8011, 8012, 8013],
'leisure' => [8050, 8051, 8052],
'transportation' => [4010, 4020, 4030, 4040],
'amenities' => [8080, 8081, 8082],
];
foreach ($themeToItemsMapping as $themeName => $itemIds) {
foreach ($itemIds as $itemId) {
if (!isset($itemToThemeMapping[$itemId])) {
$itemToThemeMapping[$itemId] = [];
}
$itemToThemeMapping[$itemId][] = $themeName;
}
}
// Compter les problèmes par thème
$issuesByTheme = [];
foreach ($themes as $themeKey => $themeLabel) {
$issuesByTheme[$themeKey] = 0;
}
// Ajouter un compteur pour "Autres" (problèmes qui ne correspondent à aucun thème)
$issuesByTheme['other'] = 0;
// Compter les problèmes par niveau de sévérité
$issuesByLevel = [
1 => 0, // Critique
2 => 0, // Important
3 => 0, // Avertissement
];
foreach ($osmoseIssues as $issue) {
// Compter par niveau de sévérité
$level = (int)$issue['level'];
if (isset($issuesByLevel[$level])) {
$issuesByLevel[$level]++;
}
// Compter par thème
$itemId = (int)$issue['item'];
$counted = false;
if (isset($itemToThemeMapping[$itemId])) {
foreach ($itemToThemeMapping[$itemId] as $themeName) {
if (isset($issuesByTheme[$themeName])) {
$issuesByTheme[$themeName]++;
$counted = true;
}
}
}
// Si le problème n'a été compté dans aucun thème, l'ajouter à "Autres"
if (!$counted) {
$issuesByTheme['other']++;
}
}
// Ajouter le libellé pour "Autres"
$themes['other'] = 'Autres problèmes';
return $this->render('admin/osmose_issues_map.html.twig', [
'city' => $city,
'theme' => $theme,
'themes' => $themes,
'osmoseIssues' => $osmoseIssues,
'issuesByTheme' => $issuesByTheme,
'issuesByLevel' => $issuesByLevel,
'osmoseApiUrl' => 'https://osmose.openstreetmap.fr/fr/map/#zoom=14&lat=' . $city->getLat() . '&lon=' . $city->getLon()
]);
}
/**
* Récupère les problèmes Osmose pour une ville donnée
*/
private function getOsmoseIssuesForCity(Stats $city, string $theme = 'all'): array
{
$issues = [];
// Coordonnées de la ville
$lat = $city->getLat();
$lon = $city->getLon();
if (!$lat || !$lon) {
return $issues;
}
// Construire l'URL de l'API Osmose
$bbox = $this->calculateBoundingBox($lat, $lon, 5); // 5km autour du centre de la ville
$osmoseApiUrl = sprintf(
'https://osmose.openstreetmap.fr/api/0.3/issues?bbox=%f,%f,%f,%f&item=xxxx&limit=500',
$bbox['min_lon'],
$bbox['min_lat'],
$bbox['max_lon'],
$bbox['max_lat']
);
// Récupérer les items Osmose correspondant aux thèmes
$itemIds = $this->getOsmoseItemIdsForTheme($theme);
if (!empty($itemIds)) {
$osmoseApiUrl = str_replace('xxxx', implode(',', $itemIds), $osmoseApiUrl);
} else {
$osmoseApiUrl = str_replace('&item=xxxx', '', $osmoseApiUrl);
}
try {
// Appeler l'API Osmose
$response = file_get_contents($osmoseApiUrl);
if ($response === false) {
throw new \Exception('Échec de la récupération des données Osmose');
}
$data = json_decode($response, true);
if (isset($data['issues'])) {
foreach ($data['issues'] as $issue) {
// Vérifier si l'issue est dans les limites de la ville (approximativement)
if ($this->isPointInCity($issue['lat'], $issue['lon'], $lat, $lon, 5)) {
$issues[] = [
'id' => $issue['id'],
'title' => $issue['title'] ?? 'Problème sans titre',
'subtitle' => $issue['subtitle'] ?? '',
'lat' => $issue['lat'],
'lon' => $issue['lon'],
'item' => $issue['item'],
'class' => $issue['class'],
'level' => $issue['level'] ?? 2,
'update_timestamp' => $issue['update_timestamp'] ?? null,
'url' => sprintf('https://osmose.openstreetmap.fr/fr/issue/%s', $issue['uuid'])
];
}
}
}
} catch (\Exception $e) {
$this->actionLogger->log('error_osmose_api', [
'insee_code' => $city->getZone(),
'error' => $e->getMessage()
]);
}
return $issues;
}
/**
* Calcule une bounding box autour d'un point
*/
private function calculateBoundingBox(string $lat, string $lon, int $distanceKm = 5): array
{
$lat = (float)$lat;
$lon = (float)$lon;
// Approximation: 1 degré de latitude = 111 km
$latDelta = $distanceKm / 111.0;
// Approximation: 1 degré de longitude = 111 * cos(latitude) km
$lonDelta = $distanceKm / (111.0 * cos(deg2rad($lat)));
return [
'min_lat' => $lat - $latDelta,
'max_lat' => $lat + $latDelta,
'min_lon' => $lon - $lonDelta,
'max_lon' => $lon + $lonDelta
];
}
/**
* Vérifie si un point est dans un rayon donné autour d'une ville
*/
private function isPointInCity(float $pointLat, float $pointLon, string $cityLat, string $cityLon, int $radiusKm = 5): bool
{
$cityLat = (float)$cityLat;
$cityLon = (float)$cityLon;
// Calcul de la distance en km (formule de Haversine)
$earthRadius = 6371; // Rayon de la Terre en km
$dLat = deg2rad($pointLat - $cityLat);
$dLon = deg2rad($pointLon - $cityLon);
$a = sin($dLat/2) * sin($dLat/2) +
cos(deg2rad($cityLat)) * cos(deg2rad($pointLat)) *
sin($dLon/2) * sin($dLon/2);
$c = 2 * atan2(sqrt($a), sqrt(1-$a));
$distance = $earthRadius * $c;
return $distance <= $radiusKm;
}
/**
* Retourne les IDs des items Osmose correspondant à un thème
*/
private function getOsmoseItemIdsForTheme(string $theme): array
{
// Mapping des thèmes vers les items Osmose
$themeToItemsMapping = [
'charging_station' => [8410, 8411],
'school' => [8031],
'healthcare' => [8211, 7220, 8331],
'laboratory' => [7240, 8351],
'police' => [8190, 8191],
'defibrillator' => [8370],
'places' => [7240, 8351, 8211, 7220, 8331, 8031],
'restaurants' => [8030, 8031, 8032], // Restaurants et cafés
'hotels' => [8040, 8041, 8042], // Hébergements
'tourism' => [8010, 8011, 8012, 8013], // Tourisme
'leisure' => [8050, 8051, 8052], // Loisirs
'transportation' => [4010, 4020, 4030, 4040], // Transport
'amenities' => [8080, 8081, 8082], // Équipements
// Si d'autres thèmes sont nécessaires, ajoutez-les ici
];
// Si le thème est 'all', retourner tous les items uniques de tous les thèmes
if ($theme === 'all') {
$allItems = [];
foreach ($themeToItemsMapping as $items) {
$allItems = array_merge($allItems, $items);
}
return array_unique($allItems);
}
// Si le thème n'existe pas dans le mapping, retourner un tableau vide
if (!isset($themeToItemsMapping[$theme])) {
return [];
}
return $themeToItemsMapping[$theme];
}
/**
* Retourne un mapping des départements vers les régions
*/
private function getDepartmentToRegionMapping(): array
{
return [
// Auvergne-Rhône-Alpes
'01' => 'Auvergne-Rhône-Alpes', // Ain
'03' => 'Auvergne-Rhône-Alpes', // Allier
'07' => 'Auvergne-Rhône-Alpes', // Ardèche
'15' => 'Auvergne-Rhône-Alpes', // Cantal
'26' => 'Auvergne-Rhône-Alpes', // Drôme
'38' => 'Auvergne-Rhône-Alpes', // Isère
'42' => 'Auvergne-Rhône-Alpes', // Loire
'43' => 'Auvergne-Rhône-Alpes', // Haute-Loire
'63' => 'Auvergne-Rhône-Alpes', // Puy-de-Dôme
'69' => 'Auvergne-Rhône-Alpes', // Rhône
'73' => 'Auvergne-Rhône-Alpes', // Savoie
'74' => 'Auvergne-Rhône-Alpes', // Haute-Savoie
// Bourgogne-Franche-Comté
'21' => 'Bourgogne-Franche-Comté', // Côte-d'Or
'25' => 'Bourgogne-Franche-Comté', // Doubs
'39' => 'Bourgogne-Franche-Comté', // Jura
'58' => 'Bourgogne-Franche-Comté', // Nièvre
'70' => 'Bourgogne-Franche-Comté', // Haute-Saône
'71' => 'Bourgogne-Franche-Comté', // Saône-et-Loire
'89' => 'Bourgogne-Franche-Comté', // Yonne
'90' => 'Bourgogne-Franche-Comté', // Territoire de Belfort
// Bretagne
'22' => 'Bretagne', // Côtes-d'Armor
'29' => 'Bretagne', // Finistère
'35' => 'Bretagne', // Ille-et-Vilaine
'56' => 'Bretagne', // Morbihan
// Centre-Val de Loire
'18' => 'Centre-Val de Loire', // Cher
'28' => 'Centre-Val de Loire', // Eure-et-Loir
'36' => 'Centre-Val de Loire', // Indre
'37' => 'Centre-Val de Loire', // Indre-et-Loire
'41' => 'Centre-Val de Loire', // Loir-et-Cher
'45' => 'Centre-Val de Loire', // Loiret
// Corse
'2A' => 'Corse', // Corse-du-Sud
'2B' => 'Corse', // Haute-Corse
// Grand Est
'08' => 'Grand Est', // Ardennes
'10' => 'Grand Est', // Aube
'51' => 'Grand Est', // Marne
'52' => 'Grand Est', // Haute-Marne
'54' => 'Grand Est', // Meurthe-et-Moselle
'55' => 'Grand Est', // Meuse
'57' => 'Grand Est', // Moselle
'67' => 'Grand Est', // Bas-Rhin
'68' => 'Grand Est', // Haut-Rhin
'88' => 'Grand Est', // Vosges
// Hauts-de-France
'02' => 'Hauts-de-France', // Aisne
'59' => 'Hauts-de-France', // Nord
'60' => 'Hauts-de-France', // Oise
'62' => 'Hauts-de-France', // Pas-de-Calais
'80' => 'Hauts-de-France', // Somme
// Île-de-France
'75' => 'Île-de-France', // Paris
'77' => 'Île-de-France', // Seine-et-Marne
'78' => 'Île-de-France', // Yvelines
'91' => 'Île-de-France', // Essonne
'92' => 'Île-de-France', // Hauts-de-Seine
'93' => 'Île-de-France', // Seine-Saint-Denis
'94' => 'Île-de-France', // Val-de-Marne
'95' => 'Île-de-France', // Val-d'Oise
// Normandie
'14' => 'Normandie', // Calvados
'27' => 'Normandie', // Eure
'50' => 'Normandie', // Manche
'61' => 'Normandie', // Orne
'76' => 'Normandie', // Seine-Maritime
// Nouvelle-Aquitaine
'16' => 'Nouvelle-Aquitaine', // Charente
'17' => 'Nouvelle-Aquitaine', // Charente-Maritime
'19' => 'Nouvelle-Aquitaine', // Corrèze
'23' => 'Nouvelle-Aquitaine', // Creuse
'24' => 'Nouvelle-Aquitaine', // Dordogne
'33' => 'Nouvelle-Aquitaine', // Gironde
'40' => 'Nouvelle-Aquitaine', // Landes
'47' => 'Nouvelle-Aquitaine', // Lot-et-Garonne
'64' => 'Nouvelle-Aquitaine', // Pyrénées-Atlantiques
'79' => 'Nouvelle-Aquitaine', // Deux-Sèvres
'86' => 'Nouvelle-Aquitaine', // Vienne
'87' => 'Nouvelle-Aquitaine', // Haute-Vienne
// Occitanie
'09' => 'Occitanie', // Ariège
'11' => 'Occitanie', // Aude
'12' => 'Occitanie', // Aveyron
'30' => 'Occitanie', // Gard
'31' => 'Occitanie', // Haute-Garonne
'32' => 'Occitanie', // Gers
'34' => 'Occitanie', // Hérault
'46' => 'Occitanie', // Lot
'48' => 'Occitanie', // Lozère
'65' => 'Occitanie', // Hautes-Pyrénées
'66' => 'Occitanie', // Pyrénées-Orientales
'81' => 'Occitanie', // Tarn
'82' => 'Occitanie', // Tarn-et-Garonne
// Pays de la Loire
'44' => 'Pays de la Loire', // Loire-Atlantique
'49' => 'Pays de la Loire', // Maine-et-Loire
'53' => 'Pays de la Loire', // Mayenne
'72' => 'Pays de la Loire', // Sarthe
'85' => 'Pays de la Loire', // Vendée
// Provence-Alpes-Côte d'Azur
'04' => 'Provence-Alpes-Côte d\'Azur', // Alpes-de-Haute-Provence
'05' => 'Provence-Alpes-Côte d\'Azur', // Hautes-Alpes
'06' => 'Provence-Alpes-Côte d\'Azur', // Alpes-Maritimes
'13' => 'Provence-Alpes-Côte d\'Azur', // Bouches-du-Rhône
'83' => 'Provence-Alpes-Côte d\'Azur', // Var
'84' => 'Provence-Alpes-Côte d\'Azur', // Vaucluse
// Départements d'outre-mer
'971' => 'Guadeloupe',
'972' => 'Martinique',
'973' => 'Guyane',
'974' => 'La Réunion',
'976' => 'Mayotte'
];
}
/**
* Complète les données manquantes d'un objet Stats (coordonnées, budget, etc.)
*/
private function completeStatsData(Stats $stat): void
{
$insee_code = $stat->getZone();
// Compléter les coordonnées si manquantes
if (!$stat->getLat() || !$stat->getLon()) {
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) {
$stat->setLon((string)$data['centre']['coordinates'][0]);
$stat->setLat((string)$data['centre']['coordinates'][1]);
}
}
} catch (\Exception $e) {
$this->actionLogger->log('error_complete_stats_data', [
'insee_code' => $insee_code,
'error' => 'Failed to fetch coordinates: ' . $e->getMessage()
]);
}
}
// Compléter le budget si manquant
if (!$stat->getBudgetAnnuel() && property_exists($this, 'budgetService') && $this->budgetService !== null) {
try {
$budget = $this->budgetService->getBudgetAnnuel($insee_code);
if ($budget !== null) {
$stat->setBudgetAnnuel((string)$budget);
}
} catch (\Exception $e) {
$this->actionLogger->log('error_complete_stats_data', [
'insee_code' => $insee_code,
'error' => 'Failed to fetch budget: ' . $e->getMessage()
]);
}
}
// Calculer le pourcentage de complétion
$stat->computeCompletionPercent();
}
#[Route('/admin/podium-contributeurs-osm', name: 'app_admin_podium_contributeurs_osm')]
public function podiumContributeursOsm(): Response
{
$this->actionLogger->log('admin/podium_contributeurs_osm', []);
// 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);
// Compter les contributions par utilisateur
$contributions = [];
foreach ($places as $place) {
$user = $place->getOsmUser();
if ($user) {
if (!isset($contributions[$user])) {
$contributions[$user] = 0;
}
$contributions[$user]++;
}
}
// Trier par nombre de contributions décroissant
arsort($contributions);
// Prendre les 10 premiers
$topContributors = array_slice($contributions, 0, 10, true);
return $this->render('admin/podium_contributeurs_osm.html.twig', [
'podium' => $topContributors
]);
}
#[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');
if (!$uploadedFile) {
$this->addFlash('error', 'Aucun fichier JSON n\'a été fourni.');
return $this->redirectToRoute('app_admin_import_stats');
}
// Vérifier le type de fichier
if (
$uploadedFile->getClientMimeType() !== 'application/json' &&
$uploadedFile->getClientOriginalExtension() !== 'json'
) {
$this->addFlash('error', 'Le fichier doit être au format JSON.');
return $this->redirectToRoute('app_admin_import_stats');
}
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]);
if ($existingStats) {
$skippedCount++;
continue; // Ignorer les objets existants
}
// Créer un nouvel objet Stats
$stats = new Stats();
$stats->setZone($zone)
->setName($name)
->setDateCreated(new \DateTime())
->setDateModified(new \DateTime())
->setKind('request'); // Set the kind to 'request' as it's created from an admin import
// 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()]);
}
return $this->redirectToRoute('app_admin_import_stats');
}
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]);
if (!$stats) {
throw $this->createNotFoundException('Stats non trouvées pour ce code INSEE');
}
// 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]);
if (!$stats) {
throw $this->createNotFoundException('Stats non trouvées pour ce code INSEE');
}
$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 : '';
// Construire l'adresse complète
$address = '';
if ($place->getHousenumber() && $place->getStreet()) {
$address = $place->getHousenumber() . ' ' . $place->getStreet();
} elseif ($place->getStreet()) {
$address = $place->getStreet();
}
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;
}
#[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 {
$error = "4 Aucune stats trouvée pour ce code INSEE.";
}
}
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
]);
}
#[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
{
// 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
return match ($mainTag) {
'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' => '⚡',
default => '🏷️',
};
}
public function followupEmbedGraph(Request $request, string $insee_code, string $theme): Response
{
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]);
if (!$stats) {
$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;
$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;
}
} 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;
}
}
// 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
{
$ctc_completion_series = [];
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]);
if (!$stats) {
$this->addFlash('error', '6 Aucune stats trouvée pour ce code INSEE.');
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();
// 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);
$now = new \DateTime();
$last_week = new \DateTime();
$last_week->sub(new \DateInterval('P7D'));
$adiff_query = '[timeout:60]
[adiff:"' . $now->format('Y-m-d') . 'T' . $now->format('H:i:s') . 'Z","2025-08-05T00:00:00Z"][out:xml];
area["ref:INSEE"="91111"]->.searchArea;
(
nwr(area.searchArea);
);
out meta;';
return $this->render('admin/followup_graph.html.twig', [
'adiff_query' => $adiff_query,
'stats' => $stats,
'completion_tags' => \App\Service\FollowUpService::getFollowUpCompletionTags(),
'followup_labels' => \App\Service\FollowUpService::getFollowUpThemes(),
'followup_icons' => \App\Service\FollowUpService::getFollowUpIcons(),
'ctc_completion_series' => $ctc_completion_series,
]);
}
// 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) {
$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,
]);
}
#[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) {
$this->addFlash('error', '8 Aucune stats trouvée pour ce code INSEE. Veuillez d\'abord ajouter la ville.');
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,
]);
}
#[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($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');
}
/**
* Remove duplicate places from a Stats entity
* Duplicates are identified by having the same osm_id and osm_kind
*/
private function removeDuplicatePlaces(Stats $stats): void
{
$placeRepo = $this->entityManager->getRepository(Place::class);
// Get all places for this stats
$places = $placeRepo->findBy(['stats' => $stats]);
// Group places by osm_id and osm_kind
$groupedPlaces = [];
foreach ($places as $place) {
$key = $place->getOsmKind() . '_' . $place->getOsmId();
if (!isset($groupedPlaces[$key])) {
$groupedPlaces[$key] = [];
}
$groupedPlaces[$key][] = $place;
}
// For each group with more than one place, keep the first one and remove the rest
$removedCount = 0;
foreach ($groupedPlaces as $key => $group) {
if (count($group) > 1) {
// Keep the first place
$keepPlace = array_shift($group);
// Remove the rest
foreach ($group as $duplicatePlace) {
$stats->removePlace($duplicatePlace);
$this->entityManager->remove($duplicatePlace);
$removedCount++;
}
}
}
// If places were removed, update the places_count
if ($removedCount > 0) {
$stats->setPlacesCount($stats->getPlaces()->count());
$this->entityManager->persist($stats);
}
}
}