enrich exports lat et lon

This commit is contained in:
Tykayn 2025-07-05 10:59:37 +02:00 committed by tykayn
parent 46d3b21cf6
commit b5b2880637
6 changed files with 606 additions and 53 deletions

124
docs/import-stats.md Normal file
View file

@ -0,0 +1,124 @@
# Import d'objets Stats
Cette fonctionnalité permet d'importer des objets Stats à partir d'un fichier JSON via l'interface d'administration.
## Accès
La page d'import est accessible via :
- L'URL : `/admin/import-stats`
- Le menu de navigation : "Import Stats"
## Fonctionnalités
### Sécurité
- **Aucune modification** des objets Stats existants
- Seuls les nouveaux objets sont créés
- Vérification de l'existence par le code INSEE (`zone`)
### Validation
- Vérification du format JSON
- Validation des champs requis (`zone` et `name`)
- Gestion des erreurs par ligne
- Rapport détaillé des résultats
### Champs supportés
#### Champs requis
- `zone` : Code INSEE de la zone (ex: "75056")
- `name` : Nom de la ville/zone (ex: "Paris")
#### Champs optionnels
- `population` : Population de la zone (nombre)
- `budgetAnnuel` : Budget annuel de la collectivité (chaîne)
- `siren` : Code SIREN (nombre)
- `codeEpci` : Code EPCI (nombre)
- `codesPostaux` : Codes postaux séparés par des points-virgules (ex: "75001;75002;75003")
#### Objet `decomptes` (optionnel)
- `placesCount` : Nombre total de lieux
- `avecHoraires` : Nombre de lieux avec horaires
- `avecAdresse` : Nombre de lieux avec adresse
- `avecSite` : Nombre de lieux avec site web
- `avecAccessibilite` : Nombre de lieux avec accessibilité
- `avecNote` : Nombre de lieux avec note
- `completionPercent` : Pourcentage de complétion
## Format JSON attendu
```json
[
{
"zone": "75056",
"name": "Paris",
"population": 2161000,
"budgetAnnuel": "8500000000",
"siren": 200054781,
"codeEpci": 200054781,
"codesPostaux": "75001;75002;75003",
"decomptes": {
"placesCount": 1250,
"avecHoraires": 980,
"avecAdresse": 1200,
"avecSite": 850,
"avecAccessibilite": 450,
"avecNote": 320,
"completionPercent": 75
}
}
]
```
## Utilisation
1. **Préparer le fichier JSON**
- Créer un tableau d'objets Stats
- Inclure au minimum les champs `zone` et `name`
- Valider le format JSON
2. **Accéder à la page d'import**
- Aller sur `/admin/import-stats`
- Ou cliquer sur "Import Stats" dans le menu
3. **Importer le fichier**
- Sélectionner le fichier JSON
- Cliquer sur "Importer"
- Vérifier les messages de résultat
4. **Vérifier les résultats**
- Nombre d'objets créés
- Nombre d'objets ignorés (déjà existants)
- Liste des erreurs éventuelles
## Messages de retour
### Succès
```
Import terminé : X objet(s) créé(s), Y objet(s) ignoré(s) (déjà existants).
```
### Erreurs possibles
- "Aucun fichier JSON n'a été fourni."
- "Le fichier doit être au format JSON."
- "Erreur lors du décodage JSON: [message]"
- "Le fichier JSON doit contenir un tableau d'objets Stats."
- "Ligne X: Champs 'zone' et 'name' requis"
- "Ligne X: [message d'erreur spécifique]"
## Logs
Toutes les actions d'import sont loggées via le service `ActionLogger` :
- `admin/import_stats` : Accès à la page
- `admin/import_stats_success` : Import réussi avec statistiques
- `admin/import_stats_error` : Erreur lors de l'import
## Exemple de fichier de test
Un fichier `test_import_stats.json` est fourni avec des exemples pour Paris, Lyon et Marseille.
## Notes importantes
- Les objets existants ne sont jamais modifiés
- Seuls les nouveaux objets sont créés
- Les dates de création et modification sont automatiquement définies
- Les erreurs sont affichées par ligne pour faciliter le débogage
- Le système est conçu pour être sûr et non destructif

View file

@ -1320,60 +1320,306 @@ final class AdminController extends AbstractController
#[Route('/admin/podium-contributeurs-osm', name: 'app_admin_podium_contributeurs_osm')]
public function podiumContributeursOsm(): Response
{
// Ajout d'un log d'action avec le service ActionLogger
$this->actionLogger->log('podium_contributeurs_osm', []);
// On suppose que le champ "osmUser" existe sur l'entité Place
$placeRepo = $this->entityManager->getRepository(\App\Entity\Place::class);
// Nouvelle requête groupée pour tout calculer d'un coup
$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 != ''")
->groupBy('p.osm_user')
->orderBy('nb', 'DESC')
->setMaxResults(100);
$podium = $qb->getQuery()->getResult();
// Calcul du score pondéré et normalisation
$maxPondere = 0;
foreach ($podium 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'];
$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]++;
}
}
unset($row);
// Normalisation des scores pondérés entre 0 et 100
foreach ($podium 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);
// Trier par nombre de contributions décroissant
arsort($contributions);
// Prendre les 10 premiers
$topContributors = array_slice($contributions, 0, 10, true);
// Tri décroissant sur le score normalisé
usort($podium, function ($a, $b) {
return ($b['completion_pondere_normalisee'] ?? 0) <=> ($a['completion_pondere_normalisee'] ?? 0);
});
return $this->render('admin/podium_contributeurs_osm.html.twig', [
'podium' => $podium
'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;
}
}

View file

@ -0,0 +1,115 @@
{% extends 'base.html.twig' %}
{% block title %}Import d'objets Stats{% endblock %}
{% block body %}
<div class="container mt-4">
<div class="row">
<div class="col-md-8 offset-md-2">
<div class="card">
<div class="card-header">
<h1><i class="bi bi-upload"></i> Import d'objets Stats</h1>
</div>
<div class="card-body">
<div class="alert alert-info">
<h5><i class="bi bi-info-circle"></i> Instructions</h5>
<p>Cette page permet d'importer des objets Stats à partir d'un fichier JSON.</p>
<ul>
<li>Seuls les objets Stats qui n'existent pas encore seront créés</li>
<li>Les objets existants (même code INSEE) seront ignorés</li>
<li>Le fichier JSON doit contenir un tableau d'objets avec les champs requis</li>
</ul>
</div>
<form method="POST" enctype="multipart/form-data">
<div class="mb-3">
<label for="json_file" class="form-label">Fichier JSON</label>
<input type="file"
class="form-control"
id="json_file"
name="json_file"
accept=".json,application/json"
required>
<div class="form-text">
Sélectionnez un fichier JSON contenant un tableau d'objets Stats.
</div>
</div>
<div class="mb-3">
<button type="submit" class="btn btn-primary">
<i class="bi bi-upload"></i> Importer
</button>
<a href="{{ path('app_admin') }}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Retour
</a>
</div>
</form>
<hr>
<h5><i class="bi bi-file-text"></i> Format attendu</h5>
<p>Le fichier JSON doit contenir un tableau d'objets avec la structure suivante :</p>
<div class="bg-light p-3 rounded">
<pre><code>[
{
"zone": "75056",
"name": "Paris",
"population": 2161000,
"budgetAnnuel": "8500000000",
"siren": 200054781,
"codeEpci": 200054781,
"codesPostaux": "75001;75002;75003",
"decomptes": {
"placesCount": 1250,
"avecHoraires": 980,
"avecAdresse": 1200,
"avecSite": 850,
"avecAccessibilite": 450,
"avecNote": 320,
"completionPercent": 75
}
}
]</code></pre>
</div>
<h6 class="mt-3">Champs requis :</h6>
<ul>
<li><strong>zone</strong> : Code INSEE de la zone</li>
<li><strong>name</strong> : Nom de la ville/zone</li>
</ul>
<h6>Champs optionnels :</h6>
<ul>
<li><strong>population</strong> : Population de la zone</li>
<li><strong>budgetAnnuel</strong> : Budget annuel de la collectivité</li>
<li><strong>siren</strong> : Code SIREN</li>
<li><strong>codeEpci</strong> : Code EPCI</li>
<li><strong>codesPostaux</strong> : Codes postaux (séparés par des points-virgules)</li>
<li><strong>decomptes</strong> : Objet contenant les statistiques
<ul>
<li>placesCount : Nombre de lieux</li>
<li>avecHoraires : Lieux avec horaires</li>
<li>avecAdresse : Lieux avec adresse</li>
<li>avecSite : Lieux avec site web</li>
<li>avecAccessibilite : Lieux avec accessibilité</li>
<li>avecNote : Lieux avec note</li>
<li>completionPercent : Pourcentage de complétion</li>
</ul>
</li>
</ul>
<div class="alert alert-warning mt-3">
<h6><i class="bi bi-exclamation-triangle"></i> Important</h6>
<ul class="mb-0">
<li>Les objets Stats existants ne seront pas modifiés</li>
<li>Seuls les nouveaux objets seront créés</li>
<li>Assurez-vous que le fichier JSON est valide avant l'import</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -311,10 +311,16 @@
<h1 class="card-title p-4">Tableau des {{ stats.places |length }} lieux</h1>
</div>
<div class="col-md-6 col-12">
<a class="btn btn-primary pull-right mt-4" href="{{ path('app_admin_export_csv', {'insee_code': stats.zone}) }}" class="btn btn-primary">
<i class="bi bi-filetype-csv"></i>
Exporter en CSV
</a>
<div class="btn-group mt-4" role="group">
<a href="{{ path('app_admin_export_overpass_csv', {'insee_code': stats.zone}) }}" class="btn btn-primary">
<i class="bi bi-filetype-csv"></i>
Export Overpass CSV
</a>
<a href="{{ path('app_admin_export_table_csv', {'insee_code': stats.zone}) }}" class="btn btn-success">
<i class="bi bi-table"></i>
Export Tableau CSV
</a>
</div>
</div>
</div>

View file

@ -53,6 +53,12 @@
Suivi global OSM
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ path('app_admin_import_stats') }}">
<i class="bi bi-upload"></i>
Import Stats
</a>
</li>
</ul>
</div>
</div>

56
test_import_stats.json Normal file
View file

@ -0,0 +1,56 @@
[
{
"zone": "75056",
"name": "Paris",
"population": 2161000,
"budgetAnnuel": "8500000000",
"siren": 200054781,
"codeEpci": 200054781,
"codesPostaux": "75001;75002;75003;75004;75005;75006;75007;75008;75009;75010;75011;75012;75013;75014;75015;75016;75017;75018;75019;75020",
"decomptes": {
"placesCount": 1250,
"avecHoraires": 980,
"avecAdresse": 1200,
"avecSite": 850,
"avecAccessibilite": 450,
"avecNote": 320,
"completionPercent": 75
}
},
{
"zone": "69123",
"name": "Lyon",
"population": 513275,
"budgetAnnuel": "1200000000",
"siren": 200046977,
"codeEpci": 200046977,
"codesPostaux": "69001;69002;69003;69004;69005;69006;69007;69008;69009",
"decomptes": {
"placesCount": 850,
"avecHoraires": 680,
"avecAdresse": 820,
"avecSite": 580,
"avecAccessibilite": 320,
"avecNote": 250,
"completionPercent": 72
}
},
{
"zone": "13055",
"name": "Marseille",
"population": 861635,
"budgetAnnuel": "1500000000",
"siren": 200054807,
"codeEpci": 200054807,
"codesPostaux": "13001;13002;13003;13004;13005;13006;13007;13008;13009;13010;13011;13012;13013;13014;13015;13016",
"decomptes": {
"placesCount": 1100,
"avecHoraires": 880,
"avecAdresse": 1050,
"avecSite": 720,
"avecAccessibilite": 380,
"avecNote": 290,
"completionPercent": 70
}
}
]