diff --git a/.idea/osm-commerce-sf.iml b/.idea/osm-commerce-sf.iml
index ab7b1f8..10aa904 100644
--- a/.idea/osm-commerce-sf.iml
+++ b/.idea/osm-commerce-sf.iml
@@ -148,6 +148,8 @@
+
+
diff --git a/.idea/symfony2.xml b/.idea/symfony2.xml
new file mode 100644
index 0000000..bd98e40
--- /dev/null
+++ b/.idea/symfony2.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/clean_duplicate_stats.sql b/clean_duplicate_stats.sql
new file mode 100644
index 0000000..f3d986a
--- /dev/null
+++ b/clean_duplicate_stats.sql
@@ -0,0 +1,60 @@
+-- Script SQL pour désactiver temporairement la contrainte d'unicité sur l'INSEE de ville,
+-- supprimer les doublons, et réactiver la contrainte
+
+-- ATTENTION: Ce script supprime des enregistrements de la table stats.
+-- Les entités liées (CityFollowUp, Place) seront également supprimées si des contraintes
+-- de clé étrangère avec ON DELETE CASCADE sont définies dans la base de données.
+-- Assurez-vous de faire une sauvegarde de la base de données avant d'exécuter ce script.
+
+-- 1. Désactiver temporairement la contrainte d'unicité
+ALTER TABLE stats DROP CONSTRAINT uniq_stats_zone;
+
+-- 2. Identifier et supprimer les doublons, en gardant l'entrée la plus ancienne
+-- Créer une table temporaire pour stocker les IDs à supprimer
+CREATE TEMP TABLE stats_to_delete AS
+WITH duplicates AS (
+ SELECT
+ id,
+ zone,
+ date_created,
+ ROW_NUMBER() OVER (
+ PARTITION BY zone
+ ORDER BY
+ -- Garder l'entrée la plus ancienne si date_created existe
+ CASE WHEN date_created IS NOT NULL THEN 0 ELSE 1 END,
+ date_created,
+ -- Si date_created est NULL, utiliser l'ID le plus petit (probablement le plus ancien)
+ id
+ ) AS row_num
+ FROM stats
+ WHERE zone IS NOT NULL
+)
+SELECT id FROM duplicates WHERE row_num > 1;
+
+-- Afficher le nombre de doublons qui seront supprimés
+SELECT COUNT(*) AS "Nombre de doublons à supprimer" FROM stats_to_delete;
+
+-- Afficher les détails des doublons qui seront supprimés (pour vérification)
+SELECT s.id, s.zone, s.name, s.date_created
+FROM stats s
+JOIN stats_to_delete std ON s.id = std.id
+ORDER BY s.zone, s.id;
+
+-- 3. Supprimer les doublons
+-- Note: Nous utilisons DELETE ... USING pour éviter les problèmes de contraintes de clé étrangère
+DELETE FROM stats
+USING stats_to_delete
+WHERE stats.id = stats_to_delete.id;
+
+-- 4. Nettoyer la table temporaire
+DROP TABLE stats_to_delete;
+
+-- 5. Réactiver la contrainte d'unicité
+ALTER TABLE stats ADD CONSTRAINT uniq_stats_zone UNIQUE (zone);
+
+-- 6. Vérifier qu'il n'y a plus de doublons
+SELECT zone, COUNT(*)
+FROM stats
+WHERE zone IS NOT NULL
+GROUP BY zone
+HAVING COUNT(*) > 1;
diff --git a/clean_duplicate_stats_README.md b/clean_duplicate_stats_README.md
new file mode 100644
index 0000000..567bfe1
--- /dev/null
+++ b/clean_duplicate_stats_README.md
@@ -0,0 +1,62 @@
+# Script de nettoyage des doublons de villes
+
+Ce dossier contient un script SQL pour résoudre le problème de contrainte d'unicité sur le code INSEE des villes dans la table `stats`.
+
+## Problème
+
+Lorsque vous rencontrez l'erreur suivante :
+
+```
+SQLSTATE[23505]: Unique violation: 7 ERREUR: n'a pas pu créer l'index unique « uniq_stats_zone »
+DETAIL: La clé (zone)=(91111) est dupliquée.
+```
+
+Cela signifie qu'il existe des doublons dans la table `stats` avec le même code INSEE (colonne `zone`), ce qui empêche la création de la contrainte d'unicité.
+
+## Solution
+
+Le script `clean_duplicate_stats.sql` permet de :
+
+1. Désactiver temporairement la contrainte d'unicité
+2. Identifier et supprimer les doublons de villes, en gardant l'entrée la plus ancienne
+3. Réactiver la contrainte d'unicité
+
+## Comment utiliser le script
+
+### Précautions importantes
+
+⚠️ **ATTENTION** : Ce script supprime des données de la base. Assurez-vous de faire une sauvegarde complète de votre base de données avant de l'exécuter.
+
+Les entités liées aux enregistrements `Stats` supprimés (comme `CityFollowUp` et `Place`) seront également supprimées si des contraintes de clé étrangère avec `ON DELETE CASCADE` sont définies dans la base de données.
+
+### Exécution du script
+
+Pour exécuter le script sur une base PostgreSQL :
+
+```bash
+psql -U username -d database_name -f clean_duplicate_stats.sql
+```
+
+Remplacez `username` par votre nom d'utilisateur PostgreSQL et `database_name` par le nom de votre base de données.
+
+### Vérification
+
+Le script inclut des requêtes pour :
+- Afficher le nombre de doublons qui seront supprimés
+- Afficher les détails des doublons avant suppression
+- Vérifier qu'il n'y a plus de doublons après l'opération
+
+## Fonctionnement technique
+
+Le script :
+
+1. Désactive la contrainte d'unicité `uniq_stats_zone`
+2. Crée une table temporaire pour identifier les doublons
+3. Utilise `ROW_NUMBER()` pour conserver l'entrée la plus ancienne de chaque groupe de doublons
+4. Supprime les doublons identifiés
+5. Réactive la contrainte d'unicité
+6. Vérifie qu'il n'y a plus de doublons
+
+## Après l'exécution
+
+Une fois le script exécuté avec succès, vous ne devriez plus rencontrer l'erreur de violation de contrainte d'unicité lors de l'exécution des commandes Symfony.
\ No newline at end of file
diff --git a/migrations/Version20250716124008.php b/migrations/Version20250716124008.php
new file mode 100644
index 0000000..94e96f9
--- /dev/null
+++ b/migrations/Version20250716124008.php
@@ -0,0 +1,101 @@
+addSql(<<<'SQL'
+ CREATE TABLE demande (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, query VARCHAR(255) NOT NULL, email VARCHAR(255) DEFAULT NULL, insee INT DEFAULT NULL, PRIMARY KEY(id))
+ SQL);
+ $this->addSql(<<<'SQL'
+ ALTER TABLE place ALTER email TYPE VARCHAR(255)
+ SQL);
+ $this->addSql(<<<'SQL'
+ ALTER TABLE place ALTER note TYPE TEXT
+ SQL);
+ $this->addSql(<<<'SQL'
+ ALTER TABLE place ALTER name TYPE VARCHAR(255)
+ SQL);
+ $this->addSql(<<<'SQL'
+ ALTER TABLE place ALTER note_content TYPE VARCHAR(255)
+ SQL);
+ $this->addSql(<<<'SQL'
+ ALTER TABLE place ALTER street TYPE VARCHAR(255)
+ SQL);
+ $this->addSql(<<<'SQL'
+ ALTER TABLE place ALTER housenumber TYPE VARCHAR(255)
+ SQL);
+ $this->addSql(<<<'SQL'
+ ALTER TABLE place ALTER siret TYPE VARCHAR(255)
+ SQL);
+ $this->addSql(<<<'SQL'
+ ALTER TABLE place ALTER osm_user TYPE VARCHAR(255)
+ SQL);
+ $this->addSql(<<<'SQL'
+ ALTER TABLE place ALTER email_content TYPE TEXT
+ SQL);
+ $this->addSql(<<<'SQL'
+ ALTER TABLE stats ALTER name TYPE VARCHAR(255)
+ SQL);
+ $this->addSql(<<<'SQL'
+ CREATE UNIQUE INDEX uniq_stats_zone ON stats (zone)
+ SQL);
+ }
+
+ public function down(Schema $schema): void
+ {
+ // this down() migration is auto-generated, please modify it to your needs
+ $this->addSql(<<<'SQL'
+ DROP TABLE demande
+ SQL);
+ $this->addSql(<<<'SQL'
+ DROP INDEX uniq_stats_zone
+ SQL);
+ $this->addSql(<<<'SQL'
+ ALTER TABLE stats ALTER name TYPE VARCHAR(255)
+ SQL);
+ $this->addSql(<<<'SQL'
+ ALTER TABLE place ALTER email TYPE VARCHAR(255)
+ SQL);
+ $this->addSql(<<<'SQL'
+ ALTER TABLE place ALTER note TYPE TEXT
+ SQL);
+ $this->addSql(<<<'SQL'
+ ALTER TABLE place ALTER name TYPE VARCHAR(255)
+ SQL);
+ $this->addSql(<<<'SQL'
+ ALTER TABLE place ALTER note_content TYPE VARCHAR(255)
+ SQL);
+ $this->addSql(<<<'SQL'
+ ALTER TABLE place ALTER street TYPE VARCHAR(255)
+ SQL);
+ $this->addSql(<<<'SQL'
+ ALTER TABLE place ALTER housenumber TYPE VARCHAR(255)
+ SQL);
+ $this->addSql(<<<'SQL'
+ ALTER TABLE place ALTER siret TYPE VARCHAR(255)
+ SQL);
+ $this->addSql(<<<'SQL'
+ ALTER TABLE place ALTER osm_user TYPE VARCHAR(255)
+ SQL);
+ $this->addSql(<<<'SQL'
+ ALTER TABLE place ALTER email_content TYPE TEXT
+ SQL);
+ }
+}
diff --git a/migrations/Version20250716160000.php b/migrations/Version20250716160000.php
new file mode 100644
index 0000000..958eee1
--- /dev/null
+++ b/migrations/Version20250716160000.php
@@ -0,0 +1,41 @@
+addSql(<<<'SQL'
+ ALTER TABLE demande ADD osm_object_type VARCHAR(10) DEFAULT NULL
+ SQL);
+ $this->addSql(<<<'SQL'
+ ALTER TABLE demande ADD osm_id INT DEFAULT NULL
+ SQL);
+ }
+
+ public function down(Schema $schema): void
+ {
+ // this down() migration is auto-generated, please modify it to your needs
+ $this->addSql(<<<'SQL'
+ ALTER TABLE demande DROP osm_object_type
+ SQL);
+ $this->addSql(<<<'SQL'
+ ALTER TABLE demande DROP osm_id
+ SQL);
+ }
+}
\ No newline at end of file
diff --git a/public/css/main.css b/public/css/main.css
index fd657ac..c5f25cb 100644
--- a/public/css/main.css
+++ b/public/css/main.css
@@ -60,6 +60,48 @@
padding: 0;
}
+/* Home page specific styles */
+.hero-image {
+ max-width: 100%;
+ height: auto;
+}
+
+.feature-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 4rem;
+ height: 4rem;
+ margin-bottom: 1rem;
+ font-size: 2rem;
+ color: #fff;
+ border-radius: 0.75rem;
+ background-color: #0d6efd;
+}
+
+.step-circle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 3rem;
+ height: 3rem;
+ border-radius: 50%;
+ background-color: #0d6efd;
+ color: white;
+ font-weight: bold;
+ font-size: 1.25rem;
+ margin-right: 1rem;
+}
+
+.card {
+ transition: transform 0.3s ease, box-shadow 0.3s ease;
+}
+
+.card:hover {
+ transform: translateY(-5px);
+ box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1) !important;
+}
+
/* Media queries */
@media (max-width: 768px) {
.main-header h1 {
@@ -69,4 +111,13 @@
.main-footer {
padding: 1.5rem 0;
}
-}
\ No newline at end of file
+
+ .display-4 {
+ font-size: 2.5rem;
+ }
+
+ .hero-image {
+ max-height: 200px;
+ margin-top: 2rem;
+ }
+}
diff --git a/src/Command/UpdateMostRecentCityFollowupCommand.php b/src/Command/UpdateMostRecentCityFollowupCommand.php
new file mode 100644
index 0000000..f31b7f5
--- /dev/null
+++ b/src/Command/UpdateMostRecentCityFollowupCommand.php
@@ -0,0 +1,225 @@
+setHelp('Cette commande trouve la ville avec la mesure la plus récente, en priorisant les villes sans followup de type "rnb", et met à jour ses followups. Elle nettoie d\'abord les doublons de Stats pour éviter les violations de contrainte unique.');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+ $io->title('Mise à jour des followups pour la ville avec la mesure la plus récente');
+
+ // 1. Nettoyer les doublons de Stats pour éviter les violations de contrainte unique
+ $io->section('Nettoyage des doublons de Stats');
+
+ $statsByZone = [];
+ $statsRepo = $this->entityManager->getRepository(Stats::class);
+ $allStats = $statsRepo->findAll();
+
+ // Regrouper par code INSEE
+ foreach ($allStats as $stat) {
+ $zone = $stat->getZone();
+ if (!$zone) continue;
+ $statsByZone[$zone][] = $stat;
+ }
+
+ $toDelete = [];
+ $toKeep = [];
+ foreach ($statsByZone as $zone => $statsList) {
+ if (count($statsList) > 1) {
+ // Trier par date_created (le plus ancien d'abord), puis par id si date absente
+ usort($statsList, function($a, $b) {
+ $da = $a->getDateCreated();
+ $db = $b->getDateCreated();
+ if ($da && $db) {
+ return $da <=> $db;
+ } elseif ($da) {
+ return -1;
+ } elseif ($db) {
+ return 1;
+ } else {
+ return $a->getId() <=> $b->getId();
+ }
+ });
+ // Garder le premier, supprimer les autres
+ $toKeep[$zone] = $statsList[0];
+ $toDelete[$zone] = array_slice($statsList, 1);
+ }
+ }
+
+ $totalToDelete = array_sum(array_map('count', $toDelete));
+ if ($totalToDelete === 0) {
+ $io->success('Aucun doublon trouvé.');
+ } else {
+ $io->info("$totalToDelete doublons de Stats trouvés à supprimer.");
+
+ foreach ($toDelete as $statsList) {
+ foreach ($statsList as $stat) {
+ $this->entityManager->remove($stat);
+ }
+ }
+ $this->entityManager->flush();
+ $io->success("$totalToDelete doublons supprimés avec succès.");
+ }
+
+ // 2. Récupérer toutes les villes (Stats) sans utiliser les colonnes problématiques
+ try {
+ $allStats = $statsRepo->findAllCitiesWithoutLabourage();
+ } catch (\Exception $e) {
+ $io->error('Erreur lors de la récupération des villes: ' . $e->getMessage());
+ return Command::FAILURE;
+ }
+
+ if (empty($allStats)) {
+ $io->error('Aucune ville trouvée dans la base de données.');
+ return Command::FAILURE;
+ }
+
+ $io->info('Nombre de villes trouvées: ' . count($allStats));
+
+ // 3. Identifier les villes sans followup de type "rnb"
+ $citiesWithoutRnb = [];
+ $citiesWithRnb = [];
+
+ foreach ($allStats as $stats) {
+ $hasRnb = false;
+ foreach ($stats->getCityFollowUps() as $followup) {
+ if ($followup->getName() === 'rnb_count' || $followup->getName() === 'rnb_completion') {
+ $hasRnb = true;
+ break;
+ }
+ }
+
+ if (!$hasRnb) {
+ $citiesWithoutRnb[] = $stats;
+ } else {
+ $citiesWithRnb[] = $stats;
+ }
+ }
+
+ $io->info('Villes sans followup "rnb": ' . count($citiesWithoutRnb));
+ $io->info('Villes avec followup "rnb": ' . count($citiesWithRnb));
+
+ // 4. Trouver la ville avec la mesure la plus récente
+ $selectedCity = null;
+ $latestDate = null;
+
+ // D'abord, chercher parmi les villes sans followup "rnb"
+ if (!empty($citiesWithoutRnb)) {
+ foreach ($citiesWithoutRnb as $stats) {
+ $latestFollowup = $this->findLatestFollowup($stats);
+
+ if ($latestFollowup && ($latestDate === null || $latestFollowup->getDate() > $latestDate)) {
+ $selectedCity = $stats;
+ $latestDate = $latestFollowup->getDate();
+ }
+ }
+ }
+
+ // Si aucune ville sans followup "rnb" n'a été trouvée, chercher parmi toutes les villes
+ if ($selectedCity === null) {
+ foreach ($allStats as $stats) {
+ $latestFollowup = $this->findLatestFollowup($stats);
+
+ if ($latestFollowup && ($latestDate === null || $latestFollowup->getDate() > $latestDate)) {
+ $selectedCity = $stats;
+ $latestDate = $latestFollowup->getDate();
+ }
+ }
+ }
+
+ if ($selectedCity === null) {
+ $io->error('Aucune ville avec des followups n\'a été trouvée.');
+ return Command::FAILURE;
+ }
+
+ $io->section('Ville sélectionnée: ' . $selectedCity->getName() . ' (' . $selectedCity->getZone() . ')');
+ if ($latestDate) {
+ $io->info('Date de la dernière mesure: ' . $latestDate->format('Y-m-d H:i:s'));
+ }
+
+ // 5. Mettre à jour les followups pour la ville sélectionnée
+ $io->section('Mise à jour des followups');
+
+ try {
+ $this->followUpService->generateCityFollowUps(
+ $selectedCity,
+ $this->motocultrice,
+ $this->entityManager
+ );
+
+ $io->success('Followups mis à jour avec succès pour ' . $selectedCity->getName());
+
+ // Afficher les résultats
+ $newFollowups = $selectedCity->getCityFollowUps();
+ $io->section('Résultats');
+
+ $table = [];
+ foreach ($newFollowups as $followup) {
+ if ($followup->getDate() > (new \DateTime())->modify('-1 hour')) {
+ $table[] = [
+ $followup->getName(),
+ $followup->getMeasure(),
+ $followup->getDate()->format('Y-m-d H:i:s')
+ ];
+ }
+ }
+
+ if (!empty($table)) {
+ $io->table(['Nom', 'Valeur', 'Date'], $table);
+ } else {
+ $io->info('Aucun nouveau followup généré dans la dernière heure.');
+ }
+
+ } catch (\Exception $e) {
+ $io->error('Erreur lors de la mise à jour des followups: ' . $e->getMessage());
+ return Command::FAILURE;
+ }
+
+ return Command::SUCCESS;
+ }
+
+ /**
+ * Trouve le followup le plus récent pour une ville donnée
+ */
+ private function findLatestFollowup(Stats $stats): ?CityFollowUp
+ {
+ $latestFollowup = null;
+
+ foreach ($stats->getCityFollowUps() as $followup) {
+ if ($latestFollowup === null || $followup->getDate() > $latestFollowup->getDate()) {
+ $latestFollowup = $followup;
+ }
+ }
+
+ return $latestFollowup;
+ }
+}
\ No newline at end of file
diff --git a/src/Command/UpdateRecentCityFollowupCommand.php b/src/Command/UpdateRecentCityFollowupCommand.php
new file mode 100644
index 0000000..0845293
--- /dev/null
+++ b/src/Command/UpdateRecentCityFollowupCommand.php
@@ -0,0 +1,174 @@
+setHelp('Cette commande trouve la ville avec la mesure la plus récente, en priorisant les villes sans followup de type "rnb", et met à jour ses followups.');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+ $io->title('Mise à jour des followups pour la ville avec la mesure la plus récente');
+
+ // 1. Récupérer toutes les villes (Stats) sans utiliser les colonnes problématiques
+ $statsRepo = $this->entityManager->getRepository(Stats::class);
+ try {
+ $allStats = $statsRepo->findAllCitiesWithoutLabourage();
+ } catch (\Exception $e) {
+ $io->error('Erreur lors de la récupération des villes: ' . $e->getMessage());
+ return Command::FAILURE;
+ }
+
+ if (empty($allStats)) {
+ $io->error('Aucune ville trouvée dans la base de données.');
+ return Command::FAILURE;
+ }
+
+ $io->info('Nombre de villes trouvées: ' . count($allStats));
+
+ // 2. Identifier les villes sans followup de type "rnb"
+ $citiesWithoutRnb = [];
+ $citiesWithRnb = [];
+
+ foreach ($allStats as $stats) {
+ $hasRnb = false;
+ foreach ($stats->getCityFollowUps() as $followup) {
+ if ($followup->getName() === 'rnb_count' || $followup->getName() === 'rnb_completion') {
+ $hasRnb = true;
+ break;
+ }
+ }
+
+ if (!$hasRnb) {
+ $citiesWithoutRnb[] = $stats;
+ } else {
+ $citiesWithRnb[] = $stats;
+ }
+ }
+
+ $io->info('Villes sans followup "rnb": ' . count($citiesWithoutRnb));
+ $io->info('Villes avec followup "rnb": ' . count($citiesWithRnb));
+
+ // 3. Trouver la ville avec la mesure la plus récente
+ $selectedCity = null;
+ $latestDate = null;
+
+ // D'abord, chercher parmi les villes sans followup "rnb"
+ if (!empty($citiesWithoutRnb)) {
+ foreach ($citiesWithoutRnb as $stats) {
+ $latestFollowup = $this->findLatestFollowup($stats);
+
+ if ($latestFollowup && ($latestDate === null || $latestFollowup->getDate() > $latestDate)) {
+ $selectedCity = $stats;
+ $latestDate = $latestFollowup->getDate();
+ }
+ }
+ }
+
+ // Si aucune ville sans followup "rnb" n'a été trouvée, chercher parmi toutes les villes
+ if ($selectedCity === null) {
+ foreach ($allStats as $stats) {
+ $latestFollowup = $this->findLatestFollowup($stats);
+
+ if ($latestFollowup && ($latestDate === null || $latestFollowup->getDate() > $latestDate)) {
+ $selectedCity = $stats;
+ $latestDate = $latestFollowup->getDate();
+ }
+ }
+ }
+
+ if ($selectedCity === null) {
+ $io->error('Aucune ville avec des followups n\'a été trouvée.');
+ return Command::FAILURE;
+ }
+
+ $io->section('Ville sélectionnée: ' . $selectedCity->getName() . ' (' . $selectedCity->getZone() . ')');
+ if ($latestDate) {
+ $io->info('Date de la dernière mesure: ' . $latestDate->format('Y-m-d H:i:s'));
+ }
+
+ // 4. Mettre à jour les followups pour la ville sélectionnée
+ $io->section('Mise à jour des followups');
+
+ try {
+ $this->followUpService->generateCityFollowUps(
+ $selectedCity,
+ $this->motocultrice,
+ $this->entityManager
+ );
+
+ $io->success('Followups mis à jour avec succès pour ' . $selectedCity->getName());
+
+ // Afficher les résultats
+ $newFollowups = $selectedCity->getCityFollowUps();
+ $io->section('Résultats');
+
+ $table = [];
+ foreach ($newFollowups as $followup) {
+ if ($followup->getDate() > (new \DateTime())->modify('-1 hour')) {
+ $table[] = [
+ $followup->getName(),
+ $followup->getMeasure(),
+ $followup->getDate()->format('Y-m-d H:i:s')
+ ];
+ }
+ }
+
+ if (!empty($table)) {
+ $io->table(['Nom', 'Valeur', 'Date'], $table);
+ } else {
+ $io->info('Aucun nouveau followup généré dans la dernière heure.');
+ }
+
+ } catch (\Exception $e) {
+ $io->error('Erreur lors de la mise à jour des followups: ' . $e->getMessage());
+ return Command::FAILURE;
+ }
+
+ return Command::SUCCESS;
+ }
+
+ /**
+ * Trouve le followup le plus récent pour une ville donnée
+ */
+ private function findLatestFollowup(Stats $stats): ?CityFollowUp
+ {
+ $latestFollowup = null;
+
+ foreach ($stats->getCityFollowUps() as $followup) {
+ if ($latestFollowup === null || $followup->getDate() > $latestFollowup->getDate()) {
+ $latestFollowup = $followup;
+ }
+ }
+
+ return $latestFollowup;
+ }
+}
diff --git a/src/Controller/AdminController.php b/src/Controller/AdminController.php
index 36acab7..ea7df39 100644
--- a/src/Controller/AdminController.php
+++ b/src/Controller/AdminController.php
@@ -489,7 +489,7 @@ final class AdminController extends AbstractController
$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) {
@@ -1908,4 +1908,164 @@ final class AdminController extends AbstractController
'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');
+ }
}
diff --git a/src/Controller/FollowUpController.php b/src/Controller/FollowUpController.php
index 1b1687c..f2da51a 100644
--- a/src/Controller/FollowUpController.php
+++ b/src/Controller/FollowUpController.php
@@ -21,6 +21,69 @@ class FollowUpController extends AbstractController
$this->followUpService = $followUpService;
}
+ #[Route('/api/city-followup', name: 'api_city_followup', methods: ['POST'])]
+ public function recordCityFollowUp(
+ EntityManagerInterface $em,
+ \Symfony\Component\HttpFoundation\Request $request
+ ): Response {
+ $insee_code = $request->request->get('insee_code');
+ $measure_label = $request->request->get('measure_label');
+ $measure_value = $request->request->getFloat('measure_value');
+
+ if (!$insee_code || !$measure_label || $measure_value === null) {
+ return $this->json([
+ 'success' => false,
+ 'message' => 'Missing required parameters: insee_code, measure_label, measure_value'
+ ], Response::HTTP_BAD_REQUEST);
+ }
+
+ $stats = $em->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]);
+ if (!$stats) {
+ return $this->json([
+ 'success' => false,
+ 'message' => 'No stats found for this INSEE code'
+ ], Response::HTTP_NOT_FOUND);
+ }
+
+ // Check if the same measure was recorded less than an hour ago
+ $oneHourAgo = new \DateTime('-1 hour');
+ $recentFollowUp = $em->getRepository(CityFollowUp::class)
+ ->findRecentByStatsAndName($stats, $measure_label, $oneHourAgo);
+
+ if ($recentFollowUp) {
+ return $this->json([
+ 'success' => false,
+ 'message' => 'A measure with the same label was recorded less than an hour ago',
+ 'existing_measure' => [
+ 'id' => $recentFollowUp->getId(),
+ 'date' => $recentFollowUp->getDate()->format('Y-m-d H:i:s'),
+ 'value' => $recentFollowUp->getMeasure()
+ ]
+ ], Response::HTTP_TOO_MANY_REQUESTS);
+ }
+
+ // Create and save the new follow-up
+ $followUp = new CityFollowUp();
+ $followUp->setName($measure_label);
+ $followUp->setMeasure($measure_value);
+ $followUp->setDate(new \DateTime());
+ $followUp->setStats($stats);
+
+ $em->persist($followUp);
+ $em->flush();
+
+ return $this->json([
+ 'success' => true,
+ 'message' => 'City follow-up recorded successfully',
+ 'follow_up' => [
+ 'id' => $followUp->getId(),
+ 'name' => $followUp->getName(),
+ 'measure' => $followUp->getMeasure(),
+ 'date' => $followUp->getDate()->format('Y-m-d H:i:s')
+ ]
+ ]);
+ }
+
#[Route('/admin/followup/{insee_code}/delete', name: 'admin_followup_delete', requirements: ['insee_code' => '\\d+'])]
public function deleteFollowups(string $insee_code, EntityManagerInterface $em): Response {
diff --git a/src/Controller/PublicController.php b/src/Controller/PublicController.php
index 8b6411f..6f0bf38 100644
--- a/src/Controller/PublicController.php
+++ b/src/Controller/PublicController.php
@@ -4,16 +4,21 @@ namespace App\Controller;
use App\Entity\Stats;
use App\Entity\Place;
+use App\Entity\CityFollowUp;
+use App\Entity\Demande;
use App\Service\Motocultrice;
+use App\Service\FollowUpService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;
use GuzzleHttp\Client;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Mime\Email;
use Symfony\Component\Mailer\MailerInterface;
use App\Service\ActionLogger;
+use Symfony\Component\HttpFoundation\ResponseHeaderBag;
class PublicController extends AbstractController
{
@@ -24,7 +29,8 @@ class PublicController extends AbstractController
private EntityManagerInterface $entityManager,
private Motocultrice $motocultrice,
private MailerInterface $mailer,
- private ActionLogger $actionLogger
+ private ActionLogger $actionLogger,
+ private FollowUpService $followUpService
) {}
#[Route('/propose-email/{email}/{type}/{id}', name: 'app_public_propose_email')]
@@ -115,6 +121,73 @@ class PublicController extends AbstractController
return $this->redirectToRoute('app_public_index');
}
+ #[Route('/api/demande/create', name: 'app_public_create_demande', methods: ['POST'])]
+ public function createDemande(Request $request): JsonResponse
+ {
+ $data = json_decode($request->getContent(), true);
+
+ if (!isset($data['businessName']) || empty($data['businessName'])) {
+ return new JsonResponse(['success' => false, 'message' => 'Le nom du commerce est requis'], 400);
+ }
+
+ // Create a new Demande
+ $demande = new Demande();
+ $demande->setQuery($data['businessName']);
+ $demande->setStatus('new');
+ $demande->setCreatedAt(new \DateTime());
+
+ // Save the INSEE code if provided
+ if (isset($data['insee']) && !empty($data['insee'])) {
+ $demande->setInsee((int)$data['insee']);
+ }
+
+ // Save the OSM object type if provided
+ if (isset($data['osmObjectType']) && !empty($data['osmObjectType'])) {
+ $demande->setOsmObjectType($data['osmObjectType']);
+ }
+
+ // Save the OSM ID if provided
+ if (isset($data['osmId']) && !empty($data['osmId'])) {
+ $demande->setOsmId((int)$data['osmId']);
+ }
+
+ $this->entityManager->persist($demande);
+ $this->entityManager->flush();
+
+ return new JsonResponse([
+ 'success' => true,
+ 'message' => 'Demande créée avec succès',
+ 'id' => $demande->getId()
+ ]);
+ }
+
+ #[Route('/api/demande/{id}/email', name: 'app_public_update_demande_email', methods: ['POST'])]
+ public function updateDemandeEmail(int $id, Request $request): JsonResponse
+ {
+ $data = json_decode($request->getContent(), true);
+
+ if (!isset($data['email']) || empty($data['email'])) {
+ return new JsonResponse(['success' => false, 'message' => 'L\'email est requis'], 400);
+ }
+
+ $demande = $this->entityManager->getRepository(Demande::class)->find($id);
+
+ if (!$demande) {
+ return new JsonResponse(['success' => false, 'message' => 'Demande non trouvée'], 404);
+ }
+
+ $demande->setEmail($data['email']);
+ $demande->setStatus('email_provided');
+
+ $this->entityManager->persist($demande);
+ $this->entityManager->flush();
+
+ return new JsonResponse([
+ 'success' => true,
+ 'message' => 'Email ajouté avec succès'
+ ]);
+ }
+
#[Route('/', name: 'app_public_index')]
public function index(): Response
{
@@ -122,11 +195,11 @@ class PublicController extends AbstractController
// Préparer les données pour la carte
$citiesForMap = [];
-
+
foreach ($stats as $stat) {
if ($stat->getZone() && $stat->getZone() !== 'undefined' && preg_match('/^\d+$/', $stat->getZone()) && $stat->getZone() !== '00000') {
$cityName = $stat->getName() ?: $stat->getZone();
-
+
// Utiliser les coordonnées stockées si disponibles
if ($stat->getLat() && $stat->getLon()) {
$citiesForMap[] = [
@@ -160,33 +233,33 @@ class PublicController extends AbstractController
{
// Cache simple pour éviter trop d'appels API
$cacheKey = 'city_coords_' . $inseeCode;
-
+
// Vérifier le cache (ici on utilise une approche simple)
// En production, vous pourriez utiliser le cache Symfony
-
+
$query = urlencode($cityName . ', France');
$url = "https://nominatim.openstreetmap.org/search?q={$query}&format=json&limit=1&countrycodes=fr";
-
+
try {
// Ajouter un délai pour respecter les limites de l'API Nominatim
usleep(100000); // 0.1 seconde entre les appels
-
+
$context = stream_context_create([
'http' => [
'timeout' => 5, // Timeout de 5 secondes
'user_agent' => 'OSM-Commerces/1.0'
]
]);
-
+
$response = file_get_contents($url, false, $context);
-
+
if ($response === false) {
error_log("DEBUG: Échec de récupération des coordonnées pour $cityName ($inseeCode)");
return null;
}
-
+
$data = json_decode($response, true);
-
+
if (!empty($data) && isset($data[0]['lat']) && isset($data[0]['lon'])) {
error_log("DEBUG: Coordonnées trouvées pour $cityName ($inseeCode): " . $data[0]['lat'] . ", " . $data[0]['lon']);
return [
@@ -199,7 +272,7 @@ class PublicController extends AbstractController
} catch (\Exception $e) {
error_log("DEBUG: Exception lors de la récupération des coordonnées pour $cityName ($inseeCode): " . $e->getMessage());
}
-
+
return null;
}
@@ -262,7 +335,7 @@ class PublicController extends AbstractController
{
$this->actionLogger->log('dashboard', []);
-
+
$stats_repo = $this->entityManager->getRepository(Stats::class)->findAll();
$stats_for_chart = [];
@@ -291,6 +364,74 @@ class PublicController extends AbstractController
]);
}
+ #[Route('/api/dashboard/regression', name: 'api_dashboard_regression', methods: ['POST'])]
+ public function saveRegressionData(Request $request): JsonResponse
+ {
+ $this->actionLogger->log('save_regression_data', []);
+
+ // Récupérer les données de la requête
+ $data = json_decode($request->getContent(), true);
+
+ if (!isset($data['angle']) || !isset($data['slope']) || !isset($data['intercept'])) {
+ return new JsonResponse([
+ 'success' => false,
+ 'message' => 'Données de régression incomplètes'
+ ], Response::HTTP_BAD_REQUEST);
+ }
+
+ // Récupérer les stats globales (zone 00000)
+ $statsGlobal = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => '00000']);
+
+ if (!$statsGlobal) {
+ // Créer les stats globales si elles n'existent pas
+ $statsGlobal = new Stats();
+ $statsGlobal->setZone('00000');
+ $statsGlobal->setName('toutes les villes');
+ $this->entityManager->persist($statsGlobal);
+ $this->entityManager->flush();
+ }
+
+ // Créer un nouveau followup pour la régression linéaire
+ $followup = new CityFollowUp();
+ $followup->setName('regression_angle');
+ $followup->setMeasure($data['angle']);
+ $followup->setDate(new \DateTime());
+ $followup->setStats($statsGlobal);
+
+ $this->entityManager->persist($followup);
+
+ // Créer un followup pour la pente
+ $followupSlope = new CityFollowUp();
+ $followupSlope->setName('regression_slope');
+ $followupSlope->setMeasure($data['slope']);
+ $followupSlope->setDate(new \DateTime());
+ $followupSlope->setStats($statsGlobal);
+
+ $this->entityManager->persist($followupSlope);
+
+ // Créer un followup pour l'ordonnée à l'origine
+ $followupIntercept = new CityFollowUp();
+ $followupIntercept->setName('regression_intercept');
+ $followupIntercept->setMeasure($data['intercept']);
+ $followupIntercept->setDate(new \DateTime());
+ $followupIntercept->setStats($statsGlobal);
+
+ $this->entityManager->persist($followupIntercept);
+
+ $this->entityManager->flush();
+
+ return new JsonResponse([
+ 'success' => true,
+ 'message' => 'Données de régression enregistrées avec succès',
+ 'followup' => [
+ 'id' => $followup->getId(),
+ 'name' => $followup->getName(),
+ 'measure' => $followup->getMeasure(),
+ 'date' => $followup->getDate()->format('Y-m-d H:i:s')
+ ]
+ ]);
+ }
+
#[Route('/modify/{osm_object_id}/{version}/{changesetID}', name: 'app_public_submit')]
public function submit($osm_object_id, $version, $changesetID): Response
{
@@ -666,7 +807,7 @@ class PublicController extends AbstractController
$followups = $stats->getCityFollowUps();
$countData = [];
$completionData = [];
-
+
foreach ($followups as $fu) {
if ($fu->getName() === $theme . '_count') {
$countData[] = [
@@ -849,4 +990,47 @@ class PublicController extends AbstractController
'places_6mois' => $places_6mois,
]);
}
+
+ #[Route('/rss/demandes', name: 'app_public_rss_demandes')]
+ public function rssDemandes(): Response
+ {
+ $demandes = $this->entityManager->getRepository(Demande::class)->findAllOrderedByCreatedAt();
+
+ $content = $this->renderView('public/rss/demandes.xml.twig', [
+ 'demandes' => $demandes,
+ 'base_url' => $this->getParameter('router.request_context.host'),
+ ]);
+
+ $response = new Response($content);
+ $response->headers->set('Content-Type', 'application/rss+xml');
+
+ return $response;
+ }
+
+ #[Route('/cities', name: 'app_public_cities')]
+ public function cities(): Response
+ {
+ $stats = $this->entityManager->getRepository(Stats::class)->findAll();
+
+ // Prepare data for the map
+ $citiesForMap = [];
+ foreach ($stats as $stat) {
+ if ($stat->getZone() !== 'undefined' && preg_match('/^\d+$/', $stat->getZone())) {
+ $citiesForMap[] = [
+ 'name' => $stat->getName(),
+ 'zone' => $stat->getZone(),
+ 'lat' => $stat->getLat(),
+ 'lon' => $stat->getLon(),
+ 'placesCount' => $stat->getPlacesCount(),
+ 'completionPercent' => $stat->getCompletionPercent(),
+ ];
+ }
+ }
+
+ return $this->render('public/cities.html.twig', [
+ 'stats' => $stats,
+ 'citiesForMap' => $citiesForMap,
+ 'maptiler_token' => $_ENV['MAPTILER_TOKEN'] ?? null,
+ ]);
+ }
}
diff --git a/src/Entity/Demande.php b/src/Entity/Demande.php
new file mode 100644
index 0000000..0e96f05
--- /dev/null
+++ b/src/Entity/Demande.php
@@ -0,0 +1,177 @@
+createdAt = new \DateTime();
+ $this->status = 'new';
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getQuery(): ?string
+ {
+ return $this->query;
+ }
+
+ public function setQuery(string $query): static
+ {
+ $this->query = $query;
+
+ return $this;
+ }
+
+ public function getEmail(): ?string
+ {
+ return $this->email;
+ }
+
+ public function setEmail(?string $email): static
+ {
+ $this->email = $email;
+
+ return $this;
+ }
+
+ public function getInsee(): ?int
+ {
+ return $this->insee;
+ }
+
+ public function setInsee(?int $insee): static
+ {
+ $this->insee = $insee;
+
+ return $this;
+ }
+
+ public function getCreatedAt(): ?\DateTimeInterface
+ {
+ return $this->createdAt;
+ }
+
+ public function setCreatedAt(\DateTimeInterface $createdAt): static
+ {
+ $this->createdAt = $createdAt;
+
+ return $this;
+ }
+
+ public function getStatus(): ?string
+ {
+ return $this->status;
+ }
+
+ public function setStatus(string $status): static
+ {
+ $this->status = $status;
+
+ return $this;
+ }
+
+ public function getPlaceUuid(): ?string
+ {
+ return $this->placeUuid;
+ }
+
+ public function setPlaceUuid(?string $placeUuid): static
+ {
+ $this->placeUuid = $placeUuid;
+
+ return $this;
+ }
+
+ public function getLastContactAttempt(): ?\DateTimeInterface
+ {
+ return $this->lastContactAttempt;
+ }
+
+ public function setLastContactAttempt(?\DateTimeInterface $lastContactAttempt): static
+ {
+ $this->lastContactAttempt = $lastContactAttempt;
+
+ return $this;
+ }
+
+ public function getPlace(): ?Place
+ {
+ return $this->place;
+ }
+
+ public function setPlace(?Place $place): static
+ {
+ $this->place = $place;
+
+ return $this;
+ }
+
+ public function getOsmObjectType(): ?string
+ {
+ return $this->osmObjectType;
+ }
+
+ public function setOsmObjectType(?string $osmObjectType): static
+ {
+ $this->osmObjectType = $osmObjectType;
+
+ return $this;
+ }
+
+ public function getOsmId(): ?int
+ {
+ return $this->osmId;
+ }
+
+ public function setOsmId(?int $osmId): static
+ {
+ $this->osmId = $osmId;
+
+ return $this;
+ }
+}
diff --git a/src/Repository/CityFollowUpRepository.php b/src/Repository/CityFollowUpRepository.php
index df0a522..e2e1b71 100644
--- a/src/Repository/CityFollowUpRepository.php
+++ b/src/Repository/CityFollowUpRepository.php
@@ -16,28 +16,20 @@ class CityFollowUpRepository extends ServiceEntityRepository
parent::__construct($registry, CityFollowUp::class);
}
- // /**
- // * @return CityFollowUp[] Returns an array of CityFollowUp objects
- // */
- // public function findByExampleField($value): array
- // {
- // return $this->createQueryBuilder('c')
- // ->andWhere('c.exampleField = :val')
- // ->setParameter('val', $value)
- // ->orderBy('c.id', 'ASC')
- // ->setMaxResults(10)
- // ->getQuery()
- // ->getResult()
- // ;
- // }
- // public function findOneBySomeField($value): ?CityFollowUp
- // {
- // return $this->createQueryBuilder('c')
- // ->andWhere('c.exampleField = :val')
- // ->setParameter('val', $value)
- // ->getQuery()
- // ->getOneOrNullResult()
- // ;
- // }
+ public function findRecentByStatsAndName(Stats $stats, string $name, \DateTime $since): ?CityFollowUp
+ {
+ return $this->createQueryBuilder('c')
+ ->andWhere('c.stats = :stats')
+ ->andWhere('c.name = :name')
+ ->andWhere('c.date >= :since')
+ ->setParameter('stats', $stats)
+ ->setParameter('name', $name)
+ ->setParameter('since', $since)
+ ->orderBy('c.date', 'DESC')
+ ->setMaxResults(1)
+ ->getQuery()
+ ->getOneOrNullResult()
+ ;
+ }
}
diff --git a/src/Repository/DemandeRepository.php b/src/Repository/DemandeRepository.php
new file mode 100644
index 0000000..4b3809d
--- /dev/null
+++ b/src/Repository/DemandeRepository.php
@@ -0,0 +1,99 @@
+
+ */
+class DemandeRepository extends ServiceEntityRepository
+{
+ public function __construct(ManagerRegistry $registry)
+ {
+ parent::__construct($registry, Demande::class);
+ }
+
+ /**
+ * Find the most recent Demande by query (business name)
+ */
+ public function findMostRecentByQuery(string $query): ?Demande
+ {
+ return $this->createQueryBuilder('d')
+ ->andWhere('d.query = :query')
+ ->setParameter('query', $query)
+ ->orderBy('d.createdAt', 'DESC')
+ ->setMaxResults(1)
+ ->getQuery()
+ ->getOneOrNullResult()
+ ;
+ }
+
+ /**
+ * Find Demandes without an email
+ */
+ public function findWithoutEmail(): array
+ {
+ return $this->createQueryBuilder('d')
+ ->andWhere('d.email IS NULL')
+ ->orderBy('d.createdAt', 'DESC')
+ ->getQuery()
+ ->getResult()
+ ;
+ }
+
+ /**
+ * Find Demandes by status
+ */
+ public function findByStatus(string $status): array
+ {
+ return $this->createQueryBuilder('d')
+ ->andWhere('d.status = :status')
+ ->setParameter('status', $status)
+ ->orderBy('d.createdAt', 'DESC')
+ ->getQuery()
+ ->getResult()
+ ;
+ }
+
+ /**
+ * Find Demandes with a Place UUID
+ */
+ public function findWithPlaceUuid(): array
+ {
+ return $this->createQueryBuilder('d')
+ ->andWhere('d.placeUuid IS NOT NULL')
+ ->orderBy('d.createdAt', 'DESC')
+ ->getQuery()
+ ->getResult()
+ ;
+ }
+
+ /**
+ * Find all Demandes in reverse chronological order
+ */
+ public function findAllOrderedByCreatedAt(): array
+ {
+ return $this->createQueryBuilder('d')
+ ->orderBy('d.createdAt', 'DESC')
+ ->getQuery()
+ ->getResult()
+ ;
+ }
+
+ /**
+ * Find Places that have been contacted, ordered by last contact attempt
+ */
+ public function findPlacesWithContactAttempt(): array
+ {
+ return $this->createQueryBuilder('d')
+ ->andWhere('d.lastContactAttempt IS NOT NULL')
+ ->andWhere('d.place IS NOT NULL')
+ ->orderBy('d.lastContactAttempt', 'DESC')
+ ->getQuery()
+ ->getResult()
+ ;
+ }
+}
diff --git a/src/Repository/StatsRepository.php b/src/Repository/StatsRepository.php
index a5bd992..8dfad18 100644
--- a/src/Repository/StatsRepository.php
+++ b/src/Repository/StatsRepository.php
@@ -16,6 +16,42 @@ class StatsRepository extends ServiceEntityRepository
parent::__construct($registry, Stats::class);
}
+ /**
+ * Find all cities without using problematic columns
+ *
+ * @return Stats[] Returns an array of Stats objects
+ */
+ public function findAllCitiesWithoutLabourage(): array
+ {
+ // Use native SQL to avoid ORM mapping issues with missing columns
+ $conn = $this->getEntityManager()->getConnection();
+ $sql = '
+ SELECT id, zone, completion_percent, places_count, avec_horaires,
+ avec_adresse, avec_site, avec_accessibilite, avec_note,
+ name, population, siren, code_epci, codes_postaux,
+ date_created, date_modified, avec_siret, avec_name,
+ osm_data_date_min, osm_data_date_avg, osm_data_date_max,
+ budget_annuel, lat, lon
+ FROM stats
+ WHERE zone != :global_zone
+ ';
+ $stmt = $conn->prepare($sql);
+ $resultSet = $stmt->executeQuery(['global_zone' => '00000']);
+
+ $results = $resultSet->fetchAllAssociative();
+
+ // Get existing Stats entities by ID
+ $statsEntities = [];
+ foreach ($results as $row) {
+ $stats = $this->find($row['id']);
+ if ($stats) {
+ $statsEntities[] = $stats;
+ }
+ }
+
+ return $statsEntities;
+ }
+
// /**
// * @return Stats[] Returns an array of Stats objects
// */
diff --git a/templates/admin/demandes/contacted_places.html.twig b/templates/admin/demandes/contacted_places.html.twig
new file mode 100644
index 0000000..11732fb
--- /dev/null
+++ b/templates/admin/demandes/contacted_places.html.twig
@@ -0,0 +1,68 @@
+{% extends 'base.html.twig' %}
+
+{% block title %}Places contactées{% endblock %}
+
+{% block body %}
+
+
Places contactées
+
+
+
+
+
+
+
+
+
+
+ ID
+ Nom du commerce
+ Email
+ Place
+ Dernière tentative de contact
+ Actions
+
+
+
+ {% for demande in demandes %}
+
+ {{ demande.id }}
+ {{ demande.query }}
+ {{ demande.email }}
+
+ {% if demande.place %}
+ {{ demande.place.name }}
+ ({{ demande.place.osmKind }}/{{ demande.place.osmId }})
+ {% else %}
+ Non liée
+ {% endif %}
+
+ {{ demande.lastContactAttempt ? demande.lastContactAttempt|date('Y-m-d H:i:s') : '' }}
+
+
+
+
+ {% else %}
+
+ Aucune place contactée trouvée
+
+ {% endfor %}
+
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/templates/admin/demandes/edit.html.twig b/templates/admin/demandes/edit.html.twig
new file mode 100644
index 0000000..bead3af
--- /dev/null
+++ b/templates/admin/demandes/edit.html.twig
@@ -0,0 +1,120 @@
+{% extends 'base.html.twig' %}
+
+{% block title %}Éditer une demande{% endblock %}
+
+{% block body %}
+
+
Éditer une demande
+
+
+
+
+
+
+
+
+
+
+
+ ID
+ {{ demande.id }}
+
+
+ Nom du commerce
+ {{ demande.query }}
+
+
+ Email
+ {{ demande.email }}
+
+
+ Date de création
+ {{ demande.createdAt ? demande.createdAt|date('Y-m-d H:i:s') : '' }}
+
+
+ Statut
+
+
+ {{ demande.status }}
+
+
+
+
+ Place UUID
+ {{ demande.placeUuid }}
+
+
+ Dernière tentative de contact
+ {{ demande.lastContactAttempt ? demande.lastContactAttempt|date('Y-m-d H:i:s') : '' }}
+
+
+
+
+
+
+ {% if demande.place %}
+
+
+
+
+
+
+ ID
+ {{ demande.place.id }}
+
+
+ Nom
+ {{ demande.place.name }}
+
+
+ Email
+ {{ demande.place.email }}
+
+
+ UUID
+ {{ demande.place.uuidForUrl }}
+
+
+ OSM ID
+ {{ demande.place.osmKind }}/{{ demande.place.osmId }}
+
+
+
+
+
+
+
+ {% endif %}
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/templates/admin/demandes/list.html.twig b/templates/admin/demandes/list.html.twig
new file mode 100644
index 0000000..96925ba
--- /dev/null
+++ b/templates/admin/demandes/list.html.twig
@@ -0,0 +1,152 @@
+{% extends 'base.html.twig' %}
+
+{% block title %}Liste des demandes{% endblock %}
+
+{% block body %}
+
+
Liste des demandes
+
+
+
+
+
+
+
+
+
+
+
+
+ ID
+ Nom du commerce
+ Email
+ Date de création
+ Statut
+ Place UUID
+ Dernière tentative de contact
+ Actions
+
+
+
+ {% for demande in demandes %}
+
+ {{ demande.id }}
+ {{ demande.query }}
+ {{ demande.email }}
+ {{ demande.createdAt ? demande.createdAt|date('Y-m-d H:i:s') : '' }}
+
+
+ {{ demande.status }}
+
+
+ {{ demande.placeUuid }}
+ {{ demande.lastContactAttempt ? demande.lastContactAttempt|date('Y-m-d H:i:s') : '' }}
+
+
+
+
+ {% else %}
+
+ Aucune demande trouvée
+
+ {% endfor %}
+
+
+
+
+
+
+{% endblock %}
diff --git a/templates/admin/index.html.twig b/templates/admin/index.html.twig
index 0001148..ed85928 100644
--- a/templates/admin/index.html.twig
+++ b/templates/admin/index.html.twig
@@ -1,15 +1,44 @@
{% extends 'base.html.twig' %}
-{% block title %}Hello AdminController!{% endblock %}
+{% block title %}Administration{% endblock %}
{% block body %}
-
+
+
Administration
-
-
Hello {{ controller_name }}! ✅
-
+
{% endblock %}
diff --git a/templates/public/cities.html.twig b/templates/public/cities.html.twig
new file mode 100644
index 0000000..2abe76c
--- /dev/null
+++ b/templates/public/cities.html.twig
@@ -0,0 +1,138 @@
+{% extends 'base.html.twig' %}
+
+{% block title %}{{ 'display.title'|trans }}{% endblock %}
+
+{% block stylesheets %}
+ {{ parent() }}
+
+
+{% endblock %}
+
+{% block body %}
+{% if citiesForMap is not empty %}
+
+
+
+ Les contributrices et contributeurs aguerris d'OSM peuvent ajouter ici de quoi suivre les évolutions thématiques de complétion dans une ville donnée.
+
+
+
+{% endif %}
+
+
+
+{% endblock %}
+{% block javascripts %}
+ {{ parent() }}
+
+
+{% endblock %}
diff --git a/templates/public/dashboard.html.twig b/templates/public/dashboard.html.twig
index 034ce2b..d9e2606 100644
--- a/templates/public/dashboard.html.twig
+++ b/templates/public/dashboard.html.twig
@@ -88,7 +88,7 @@