3786 lines
160 KiB
PHP
3786 lines
160 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();
|
|
|
|
// Liste complète des items Osmose à utiliser
|
|
$allOsmoseItems = '7051%2C7070%2C7100%2C7150%2C7160%2C7170%2C7190%2C7220%2C7240%2C7250%2C8010%2C8020%2C8021%2C8030%2C8031%2C8050%2C8051%2C8090%2C8091%2C8101%2C8110%2C8121%2C8150%2C8151%2C8180%2C8191%2C8201%2C8211%2C8221%2C8230%2C8240';
|
|
|
|
// 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';
|
|
|
|
// Calculer la bounding box pour la ville
|
|
$bbox = $this->calculateBoundingBox($city->getLat(), $city->getLon(), 5);
|
|
|
|
// Récupérer les items Osmose correspondant aux thèmes si un thème spécifique est sélectionné
|
|
$itemsParam = $theme !== 'all' ?
|
|
(!empty($this->getOsmoseItemIdsForTheme($theme)) ? implode('%2C', $this->getOsmoseItemIdsForTheme($theme)) : $allOsmoseItems) :
|
|
$allOsmoseItems;
|
|
|
|
// Construire l'URL de l'API Osmose pour le GeoJSON
|
|
$jsonOsmoseUrl = sprintf(
|
|
'https://osmose.openstreetmap.fr/api/0.3/issues.geojson?zoom=14&item=%s&level=1%%2C2%%2C3&class=&source=&limit=500&bbox=%f%%2C%f%%2C%f%%2C%f',
|
|
$itemsParam,
|
|
$bbox['min_lon'],
|
|
$bbox['min_lat'],
|
|
$bbox['max_lon'],
|
|
$bbox['max_lat']
|
|
);
|
|
|
|
return $this->render('admin/osmose_issues_map.html.twig', [
|
|
'city' => $city,
|
|
'stats' => $city,
|
|
'theme' => $theme,
|
|
'themes' => $themes,
|
|
'osmoseIssues' => $osmoseIssues,
|
|
'issuesByTheme' => $issuesByTheme,
|
|
'issuesByLevel' => $issuesByLevel,
|
|
|
|
'mapbox_token' => $_ENV['MAPBOX_TOKEN'] ?? null,
|
|
'maptiler_token' => $_ENV['MAPTILER_TOKEN'] ?? null,
|
|
'jsonOsmose' => $jsonOsmoseUrl,
|
|
'osmoseApiUrl' => sprintf(
|
|
'https://osmose.openstreetmap.fr/fr/map/#zoom=14&lat=%s&lon=%s&item=%s&level=1%%2C2%%2C3&loc=14/%s/%s',
|
|
$city->getLat(),
|
|
$city->getLon(),
|
|
$allOsmoseItems,
|
|
$city->getLat(),
|
|
$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
|
|
$context = stream_context_create([
|
|
'http' => [
|
|
'timeout' => 30, // Augmenter le timeout à 30 secondes
|
|
'user_agent' => 'OSM-Commerces/1.0'
|
|
]
|
|
]);
|
|
|
|
$response = file_get_contents($osmoseApiUrl, false, $context);
|
|
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']) && is_array($data['issues'])) {
|
|
foreach ($data['issues'] as $issue) {
|
|
// Vérifier si l'issue a les propriétés nécessaires
|
|
if (!isset($issue['lat']) || !isset($issue['lon'])) {
|
|
continue;
|
|
}
|
|
|
|
// Vérifier si l'issue est dans les limites de la ville (approximativement)
|
|
// Utiliser un rayon plus grand (10km) pour inclure plus d'issues
|
|
if ($this->isPointInCity($issue['lat'], $issue['lon'], $lat, $lon, 10)) {
|
|
$issues[] = [
|
|
'id' => $issue['id'] ?? '',
|
|
'title' => $issue['properties']['title'] ?? 'Problème sans titre',
|
|
'subtitle' => $issue['properties']['subtitle'] ?? '',
|
|
'lat' => $issue['lat'],
|
|
'lon' => $issue['lon'],
|
|
'item' => $issue['item'] ?? '',
|
|
'class' => $issue['class'] ?? '',
|
|
'level' => $issue['level'] ?? 2,
|
|
'update_timestamp' => $issue['update_timestamp'] ?? null,
|
|
'url' => isset($issue['uuid']) ? sprintf('https://osmose.openstreetmap.fr/fr/issue/%s', $issue['uuid']) : '#'
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
// Si aucune issue n'a été trouvée, ajouter un message de log
|
|
if (empty($issues)) {
|
|
$this->actionLogger->log('osmose_no_issues', [
|
|
'insee_code' => $city->getZone(),
|
|
'theme' => $theme,
|
|
'url' => $osmoseApiUrl
|
|
]);
|
|
}
|
|
} catch (\Exception $e) {
|
|
$this->actionLogger->log('error_osmose_api', [
|
|
'insee_code' => $city->getZone(),
|
|
'error' => $e->getMessage(),
|
|
'url' => $osmoseApiUrl
|
|
]);
|
|
|
|
// Ajouter un message flash pour informer l'utilisateur
|
|
$this->addFlash('warning', 'Impossible de récupérer les problèmes Osmose : ' . $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);
|
|
}
|
|
}
|
|
}
|