2071 lines
90 KiB
PHP
2071 lines
90 KiB
PHP
<?php
|
|
|
|
|
|
namespace App\Controller;
|
|
|
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
use Symfony\Component\Routing\Attribute\Route;
|
|
use App\Entity\Place;
|
|
use App\Entity\Stats;
|
|
use App\Entity\StatsHistory;
|
|
use App\Service\Motocultrice;
|
|
use App\Service\BudgetService;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
use function uuid_create;
|
|
use Symfony\Component\Filesystem\Filesystem;
|
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
|
use Twig\Environment;
|
|
use App\Service\ActionLogger;
|
|
use DateTime;
|
|
use App\Service\FollowUpService;
|
|
use phpDocumentor\Reflection\DocBlock\Tags\Var_;
|
|
|
|
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');
|
|
}
|
|
$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->getPlaces();
|
|
$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 = [];
|
|
foreach ($commerces as $commerce) {
|
|
if ($commerce->getOsmDataDate()) {
|
|
$date = $commerce->getOsmDataDate();
|
|
$year = $date->format('Y');
|
|
$quarter = ceil($date->format('n') / 3);
|
|
$key = $year . '-Q' . $quarter;
|
|
if (!isset($modificationsByQuarter[$key])) {
|
|
$modificationsByQuarter[$key] = 0;
|
|
}
|
|
$modificationsByQuarter[$key]++;
|
|
}
|
|
}
|
|
ksort($modificationsByQuarter); // Trier par clé (année-trimestre)
|
|
|
|
$geojson = [
|
|
'type' => 'FeatureCollection',
|
|
'features' => []
|
|
];
|
|
|
|
foreach ($commerces as $commerce) {
|
|
if ($commerce->getLat() && $commerce->getLon()) {
|
|
$geojson['features'][] = [
|
|
'type' => 'Feature',
|
|
'geometry' => [
|
|
'type' => 'Point',
|
|
'coordinates' => [$commerce->getLon(), $commerce->getLat()]
|
|
],
|
|
'properties' => [
|
|
'id' => $commerce->getOsmId(),
|
|
'name' => $commerce->getName(),
|
|
'main_tag' => $commerce->getMainTag(),
|
|
'address' => $commerce->getStreet() . ' ' . $commerce->getHousenumber(),
|
|
'note' => $commerce->getNoteContent(),
|
|
'osm_url' => 'https://www.openstreetmap.org/' . $commerce->getOsmKind() . '/' . $commerce->getOsmId()
|
|
]
|
|
];
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
]);
|
|
}
|
|
|
|
#[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) {
|
|
$this->addFlash('error', '2 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] ?? '');
|
|
// 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()];
|
|
}
|
|
|
|
return $this->render('admin/followup_theme_graph.html.twig', [
|
|
'stats' => $stats,
|
|
'theme' => $theme,
|
|
'theme_label' => $themes[$theme],
|
|
'count_data' => json_encode($countData),
|
|
'completion_data' => json_encode($completionData),
|
|
'icons' => \App\Service\FollowUpService::getFollowUpIcons(),
|
|
'followup_labels' => $themes,
|
|
'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);
|
|
// $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());
|
|
$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/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', [
|
|
'contributors' => $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());
|
|
|
|
// 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);
|
|
|
|
return $this->render('admin/followup_graph.html.twig', [
|
|
'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((int)$place->getOsmId());
|
|
|
|
$this->entityManager->persist($demande);
|
|
$this->entityManager->flush();
|
|
|
|
$this->addFlash('success', 'Demande mise à jour avec succès');
|
|
} else {
|
|
$this->addFlash('error', 'Place non trouvée avec cet UUID');
|
|
}
|
|
}
|
|
}
|
|
|
|
return $this->render('admin/demandes/edit.html.twig', [
|
|
'demande' => $demande
|
|
]);
|
|
}
|
|
|
|
#[Route('/admin/contacted-places', name: 'app_admin_contacted_places')]
|
|
public function listContactedPlaces(): Response
|
|
{
|
|
$demandes = $this->entityManager->getRepository(\App\Entity\Demande::class)->findPlacesWithContactAttempt();
|
|
|
|
return $this->render('admin/demandes/contacted_places.html.twig', [
|
|
'demandes' => $demandes
|
|
]);
|
|
}
|
|
|
|
#[Route('/admin/demandes/{id}/send-email', name: 'app_admin_demande_send_email')]
|
|
public function sendEmailToDemande(int $id, \Symfony\Component\Mailer\MailerInterface $mailer): Response
|
|
{
|
|
$demande = $this->entityManager->getRepository(\App\Entity\Demande::class)->find($id);
|
|
|
|
if (!$demande) {
|
|
$this->addFlash('error', 'Demande non trouvée');
|
|
return $this->redirectToRoute('app_admin_demandes');
|
|
}
|
|
|
|
$place = $demande->getPlace();
|
|
|
|
if (!$place) {
|
|
$this->addFlash('error', 'Aucune place associée à cette demande');
|
|
return $this->redirectToRoute('app_admin_demande_edit', ['id' => $id]);
|
|
}
|
|
|
|
// Check if the place has an email
|
|
if (!$place->getEmail() && !$demande->getEmail()) {
|
|
$this->addFlash('error', 'Aucun email associé à cette place ou à cette demande');
|
|
return $this->redirectToRoute('app_admin_demande_edit', ['id' => $id]);
|
|
}
|
|
|
|
// Use the email from the place if available, otherwise use the email from the demande
|
|
$email = $place->getEmail() ?: $demande->getEmail();
|
|
|
|
// Generate the email content
|
|
$emailContent = $this->renderView('admin/email_content.html.twig', [
|
|
'place' => $place
|
|
]);
|
|
|
|
// Only send the email in production environment
|
|
if ($this->getParameter('kernel.environment') === 'prod') {
|
|
$message = (new \Symfony\Component\Mime\Email())
|
|
->from('contact@osm-commerce.fr')
|
|
->to($email)
|
|
->subject('Votre lien de modification OpenStreetMap')
|
|
->html($emailContent);
|
|
|
|
try {
|
|
$mailer->send($message);
|
|
} catch (\Throwable $e) {
|
|
$this->actionLogger->log('ERROR_envoi_email', [
|
|
'demande_id' => $demande->getId(),
|
|
'place_id' => $place->getId(),
|
|
'message' => $e->getMessage(),
|
|
]);
|
|
$this->addFlash('error', 'Erreur lors de l\'envoi de l\'email : ' . $e->getMessage());
|
|
return $this->redirectToRoute('app_admin_demande_edit', ['id' => $id]);
|
|
}
|
|
} else {
|
|
// In non-production environments, just log the attempt
|
|
$this->actionLogger->log('email_would_be_sent', [
|
|
'demande_id' => $demande->getId(),
|
|
'place_id' => $place->getId(),
|
|
'email' => $email,
|
|
'content' => $emailContent
|
|
]);
|
|
$this->addFlash('info', 'En environnement de production, un email serait envoyé à ' . $email);
|
|
}
|
|
|
|
// Update the last contact attempt date and set status to email_sent
|
|
$now = new \DateTime();
|
|
$demande->setLastContactAttempt($now);
|
|
$demande->setStatus('email_sent');
|
|
$place->setLastContactAttemptDate($now);
|
|
|
|
$this->entityManager->persist($demande);
|
|
$this->entityManager->persist($place);
|
|
$this->entityManager->flush();
|
|
|
|
$this->addFlash('success', 'Email envoyé avec succès');
|
|
return $this->redirectToRoute('app_admin_contacted_places');
|
|
}
|
|
}
|