compute stats for completion by zone, have base tags, split categories

This commit is contained in:
Tykayn 2025-05-26 23:51:46 +02:00 committed by tykayn
parent f15fec6d18
commit f69b7824af
16 changed files with 1257 additions and 118 deletions

View file

@ -6,7 +6,7 @@ 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\Service\Motocultrice;
use Doctrine\ORM\EntityManagerInterface;
use function uuid_create;
@ -29,6 +29,48 @@ final class AdminController extends AbstractController
]);
}
#[Route('/admin/stats/{zip_code}', name: 'app_admin_stats')]
public function calculer_stats(string $zip_code): Response
{
// Récupérer tous les commerces de la zone
$commerces = $this->entityManager->getRepository(Place::class)->findBy(['zip_code' => $zip_code]);
// Récupérer les stats existantes pour la zone
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $zip_code]);
if(!$stats) {
$stats = new Stats();
$stats->setZone($zip_code);
}
// 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);
$this->entityManager->persist($commerce);
}
$this->entityManager->persist($stats);
$this->entityManager->flush();
return $this->render('admin/stats.html.twig', [
'stats' => $stats,
'zip_code' => $zip_code,
'counters' => $calculatedStats['counters']
]);
}
#[Route('/admin/labourer/{zip_code}', name: 'app_admin_labourer')]
public function labourer_zone(string $zip_code): Response
{
@ -39,10 +81,71 @@ final class AdminController extends AbstractController
// Récupérer les commerces existants dans la base de données pour cette zone
$commerces = $this->entityManager->getRepository(Place::class)->findBy(['zip_code' => $zip_code]);
// Récupérer ou créer les stats pour cette zone
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $zip_code]);
if (!$stats) {
$stats = new Stats();
$stats->setZone($zip_code);
// for commerce, set stats
foreach ($commerces as $commerce) {
$commerce->setStats($stats);
$this->entityManager->persist($commerce);
$stats->addPlace($commerce);
}
// rebuild et persist
$stats->computeCompletionPercent();
$this->entityManager->persist($stats);
$this->entityManager->flush();
}
// Initialiser les compteurs
$counters = [
'avec_horaires' => 0,
'avec_adresse' => 0,
'avec_site' => 0,
'avec_accessibilite' => 0,
'avec_note' => 0
];
// Compter les différents critères pour chaque commerce
foreach ($commerces as $commerce) {
if ($commerce->hasOpeningHours()) {
$counters['avec_horaires']++;
}
if ($commerce->hasAddress()) {
$counters['avec_adresse']++;
}
if ($commerce->hasWebsite()) {
$counters['avec_site']++;
}
if ($commerce->hasWheelchair()) {
$counters['avec_accessibilite']++;
}
if ($commerce->hasNote()) {
$counters['avec_note']++;
}
$commerce->setStats($stats);
}
// Mettre à jour les statistiques
$stats->setPlacesCount(count($commerces));
$stats->setAvecHoraires($counters['avec_horaires']);
$stats->setAvecAdresse($counters['avec_adresse']);
$stats->setAvecSite($counters['avec_site']);
$stats->setAvecAccessibilite($counters['avec_accessibilite']);
$stats->setAvecNote($counters['avec_note']);
$stats->computeCompletionPercent();
$this->entityManager->persist($stats);
$this->entityManager->flush();
$osm_object_ids = [];
if ($commerces) {
if ($commerces) {
// Extraire les osm_object_ids des commerces existants
$osm_object_ids = array_map(function($commerce) {
return $commerce->getOsmId();
@ -53,7 +156,7 @@ final class AdminController extends AbstractController
$results = array_filter($results, function($commerce) use ($osm_object_ids) {
return !in_array($commerce['id'], $osm_object_ids);
});
// on crée un commerce pour chaque résultat qui reste
foreach ($results as $result) {
$commerce = new Place();
@ -65,11 +168,18 @@ final class AdminController extends AbstractController
->setUuidForUrl($this->motocultrice->uuid_create())
->setOptedOut(false)
->setDead(false)
->setNote($result['note'] ?? null)
->setModifiedDate(new \DateTime())
->setAskedHumainsSupport(false)
->setLastContactAttemptDate(null)
->setStats(null);
->setStats(null)
->setNote($result['tags'] && isset($result['tags']['note']) ? isset($result['tags']['note']) : null)
->setHasOpeningHours($result['tags'] && isset($result['tags']['opening_hours']) ? isset($result['tags']['opening_hours']) : null)
->setHasAddress(($result['tags'] && isset($result['tags']['address']) || $result['tags'] && isset($result['tags']['contact:address'])) ? isset($result['tags']['address']) : null)
->setHasWebsite($result['tags'] && isset($result['tags']['website']) ? $result['tags']['website'] : null)
->setHasWheelchair($result['tags'] && isset($result['tags']['wheelchair']) ? $result['tags']['wheelchair'] : null)
->setHasNote($result['tags'] && isset($result['tags']['note']) ? $result['tags']['note'] : null)
;
$this->entityManager->persist($commerce);
}
@ -77,7 +187,99 @@ final class AdminController extends AbstractController
return $this->render('admin/labourage_results.html.twig', [
'results' => $results,
'commerces' => $commerces,
'zone' => $zip_code,
]);
}
#[Route('/admin/delete/{id}', name: 'app_admin_delete')]
public function delete(int $id): Response
{
$commerce = $this->entityManager->getRepository(Place::class)->find($id);
$name = $commerce->getName();
$this->entityManager->remove($commerce);
$this->entityManager->flush();
$this->addFlash('success', 'Le lieu '.$name.' a été supprimé avec succès de OSM Mes commerces, mais pas dans OpenStreetMap.');
return $this->redirectToRoute('app_admin_dashboard');
}
#[Route('/admin/delete_by_zone/{zip_code}', name: 'app_admin_delete_by_zone')]
public function delete_by_zone(string $zip_code): Response
{
$commerces = $this->entityManager->getRepository(Place::class)->findBy(['zip_code' => $zip_code]);
foreach ($commerces as $commerce) {
$this->entityManager->remove($commerce);
}
$this->entityManager->flush();
$this->addFlash('success', 'Tous les commerces de la zone '.$zip_code.' ont été supprimés avec succès de OSM Mes commerces, mais pas dans OpenStreetMap.');
return $this->redirectToRoute('app_admin_dashboard');
}
#[Route('/admin/export', name: 'app_admin_export')]
public function export(): Response
{
$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;
}
}

View file

@ -40,12 +40,31 @@ class PublicController extends AbstractController
{
$place = $this->entityManager->getRepository(Place::class)->findOneBy(['uuid_for_url' => $uuid]);
if (!$place) {
$this->addFlash('warning', 'Ce lien de modification n\'existe pas.');
return $this->redirectToRoute('app_public_index');
}
$commerce = $this->motocultrice->get_osm_object_data($place->getOsmKind(), $place->getOsmId());
if ($place->getOsmKind() === 'relation') {
$this->addFlash('warning', 'Les objets OSM de type "relation" ne sont pas gérés dans cet outil.');
return $this->redirectToRoute('app_public_index');
}
// récupérer les tags de base
$base_tags = $this->motocultrice->base_tags;
$base_tags = array_fill_keys($base_tags, '');
$commerce_overpass = $this->motocultrice->get_osm_object_data($place->getOsmKind(), $place->getOsmId());
// Fusionner les tags de base avec les tags existants
$commerce_overpass['tags_converted'] = array_merge($base_tags, $commerce_overpass['tags_converted']);
// Trier les tags par ordre alphabétique des clés
ksort($commerce_overpass['tags_converted']);
return $this->render('public/edit.html.twig', [
'commerce' => $commerce,
'commerce_overpass' => $commerce_overpass,
'name' => $name,
'commerce' => $place,
'osm_kind' => $place->getOsmKind(),
"mapbox_token" => $_ENV['MAPBOX_TOKEN'],
"maptiler_token" => $_ENV['MAPTILER_TOKEN'],
@ -57,7 +76,8 @@ class PublicController extends AbstractController
{
// get stats
$stats = $this->entityManager->getRepository(Stats::class)->findAll();
$places = $this->entityManager->getRepository(Place::class)->findAll();
$places = $this->entityManager->getRepository(Place::class)->findBy([], ['zip_code' => 'ASC', 'name' => 'ASC']);
return $this->render('public/dashboard.html.twig', [
'controller_name' => 'PublicController',
'stats' => $stats,
@ -108,6 +128,9 @@ class PublicController extends AbstractController
// Récupérer le token OSM depuis les variables d'environnement
$osm_api_token = $_ENV['APP_OSM_BEARER'];
$exception = false;
$exception_message = "";
try {
$client = new Client();
@ -186,6 +209,8 @@ class PublicController extends AbstractController
}
} catch (\Exception $e) {
$status = "Erreur lors de la communication avec l'API OSM: " . $e->getMessage();
$exception = true;
$exception_message = $e->getMessage();
// Debug de la réponse en cas d'erreur
if (method_exists($e, 'getResponse')) {
var_dump($e->getResponse()->getBody()->getContents());
@ -199,6 +224,8 @@ class PublicController extends AbstractController
'controller_name' => 'PublicController',
'commerce' => $commerce,
'status' => $status,
'exception' => $exception,
'exception_message' => $exception_message,
'mapbox_token' => $_ENV['MAPBOX_TOKEN'],
'maptiler_token' => $_ENV['MAPTILER_TOKEN'],
]);

View file

@ -21,7 +21,7 @@ class HistoryFixtures extends Fixture
// Créer quelques places de test
for ($i = 0; $i < 15; $i++) {
$place = new Place();
$place->setName($faker->company)
$place->setName($faker->company . ' (mock)')
->setUuidForUrl($faker->uuid)
->setOsmId((string)$faker->numberBetween(1000000, 9999999))
->setOsmKind($faker->randomElement(['node', 'way', 'relation']))
@ -48,7 +48,7 @@ class HistoryFixtures extends Fixture
// Créer des statistiques de test
for ($i = 0; $i < 3; $i++) {
$stat = new Stats();
$stat->setZone($faker->city)
$stat->setZone($faker->city . ' (mock)')
->setCompletionPercent($faker->numberBetween(0, 100))
->addPlace( $faker->randomElement($places_list))
->addPlace( $faker->randomElement($places_list))

View file

@ -61,6 +61,21 @@ class Place
#[ORM\Column(length: 255, nullable: true)]
private ?string $name = null;
#[ORM\Column(nullable: true)]
private ?bool $has_opening_hours = null;
#[ORM\Column(nullable: true)]
private ?bool $has_address = null;
#[ORM\Column(nullable: true)]
private ?bool $has_website = null;
#[ORM\Column(nullable: true)]
private ?bool $has_wheelchair = null;
#[ORM\Column(nullable: true)]
private ?bool $has_note = null;
public function __construct()
{
$this->histories = new ArrayCollection();
@ -256,4 +271,77 @@ class Place
return $this;
}
public function hasOpeningHours(): ?bool
{
return $this->has_opening_hours;
}
public function setHasOpeningHours(?bool $has_opening_hours): static
{
$this->has_opening_hours = $has_opening_hours;
return $this;
}
public function hasAddress(): ?bool
{
return $this->has_address;
}
public function setHasAddress(?bool $has_address): static
{
$this->has_address = $has_address;
return $this;
}
public function hasWebsite(): ?bool
{
return $this->has_website;
}
public function setHasWebsite(?bool $has_website): static
{
$this->has_website = $has_website;
return $this;
}
public function hasWheelchair(): ?bool
{
return $this->has_wheelchair;
}
public function setHasWheelchair(?bool $has_wheelchair): static
{
$this->has_wheelchair = $has_wheelchair;
return $this;
}
public function hasNote(): ?bool
{
return $this->has_note;
}
public function setHasNote(?bool $has_note): static
{
$this->has_note = $has_note;
return $this;
}
public function getPlaceCount(): ?int
{
return $this->place_count;
}
public function setPlaceCount(int $place_count): static
{
$this->place_count = $place_count;
return $this;
}
}

View file

@ -28,9 +28,54 @@ class Stats
#[ORM\OneToMany(targetEntity: Place::class, mappedBy: 'stats')]
private Collection $places;
#[ORM\Column(type: Types::SMALLINT)]
// nombre de commerces dans la zone
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
private ?int $places_count = null;
// nombre de commerces avec horaires
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
private ?int $avec_horaires = null;
// nombre de commerces avec adresse
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
private ?int $avec_adresse = null;
// nombre de commerces avec site
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
private ?int $avec_site = null;
// nombre de commerces avec accessibilité
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
private ?int $avec_accessibilite = null;
// nombre de commerces avec note
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
private ?int $avec_note = null;
// calcule le pourcentage de complétion de la zone
public function computeCompletionPercent(): ?int
{
// Si aucun commerce, on retourne 0
if ($this->places_count === 0 || $this->places_count === null) {
$this->setCompletionPercent(0);
return 0;
}
// On prend le maximum entre les différents critères
$max = max(
$this->avec_horaires ?? 0,
$this->avec_adresse ?? 0,
$this->avec_site ?? 0,
$this->avec_accessibilite ?? 0,
$this->avec_note ?? 0
);
$computed = round(($max) / $this->places_count * 100);
$this->setCompletionPercent($computed);
return $this->completion_percent;
}
public function __construct()
{
$this->places = new ArrayCollection();
@ -106,4 +151,66 @@ class Stats
return $this;
}
}
public function getAvecHoraires(): ?int
{
return $this->avec_horaires;
}
public function setAvecHoraires(int $avec_horaires): static
{
$this->avec_horaires = $avec_horaires;
return $this;
}
public function getAvecAdresse(): ?int
{
return $this->avec_adresse;
}
public function setAvecAdresse(int $avec_adresse): static
{
$this->avec_adresse = $avec_adresse;
return $this;
}
public function getAvecSite(): ?int
{
return $this->avec_site;
}
public function setAvecSite(int $avec_site): static
{
$this->avec_site = $avec_site;
return $this;
}
public function getAvecAccessibilite(): ?int
{
return $this->avec_accessibilite;
}
public function setAvecAccessibilite(int $avec_accessibilite): static
{
$this->avec_accessibilite = $avec_accessibilite;
return $this;
}
public function getAvecNote(): ?int
{
return $this->avec_note;
}
public function setAvecNote(int $avec_note): static
{
$this->avec_note = $avec_note;
return $this;
}
}

View file

@ -10,12 +10,26 @@ class Motocultrice
private $overpassApiUrl = 'https://overpass-api.de/api/interpreter';
private $osmApiUrl = 'https://www.openstreetmap.org/api/0.6';
public $base_tags = [
'name',
'opening_hours',
'contact:email',
'contact:phone',
'wheelchair',
'addr:housenumber',
'addr:street',
'contact:website',
'contact:mastodon',
// 'EEEEEEEEEEEEEEEEEEE'
];
private $more_tags = ['image', 'ref:FR:SIRET'];
public function __construct(
private HttpClientInterface $client,
private EntityManagerInterface $entityManager
) {
}
public function labourer(string $zone): array
{
if (!$zone) {
@ -58,6 +72,9 @@ QUERY;
if (isset($data['elements'])) {
foreach ($data['elements'] as $element) {
if (isset($element['tags'])) {
$email = $element['tags']['contact:email'] ?? $element['tags']['email'] ?? null;
// On passe si pas d'email
if (!$email) {
@ -86,6 +103,8 @@ QUERY;
public function get_osm_object_data($osm_kind = 'node', $osm_object_id = 12855459190)
{
$object_id = "https://www.openstreetmap.org/api/0.6/".$osm_kind."/".$osm_object_id;
// dump($object_id);
// die();
try {
$response = $this->client->request('GET', $object_id);
@ -96,8 +115,8 @@ QUERY;
throw new \Exception("Impossible de récupérer les données OSM : " . $e->getMessage());
}
// convertir les tags en clés et valeurs
$osm_object_data['tags_converted'] = [];
// convertir les tags en clés et valeurs, remplir avec les tags de base
$osm_object_data['tags_converted'] = $this->base_tags;
// Initialiser le tableau des tags convertis
if (isset($osm_object_data['node'])) {
$osm_object_data['node']['tags_converted'] = [];
@ -236,4 +255,48 @@ QUERY;
throw new \Exception("Erreur lors de la communication avec l'API OSM : " . $e->getMessage());
}
}
public function calculateStats(array $places): array
{
$counters = [
'avec_horaires' => 0,
'avec_adresse' => 0,
'avec_site' => 0,
'avec_accessibilite' => 0,
'avec_note' => 0
];
foreach ($places as $place) {
if ($place->hasOpeningHours()) {
$counters['avec_horaires']++;
}
if ($place->hasAddress()) {
$counters['avec_adresse']++;
}
if ($place->hasWebsite()) {
$counters['avec_site']++;
}
if ($place->hasWheelchair()) {
$counters['avec_accessibilite']++;
}
if ($place->hasNote()) {
$counters['avec_note']++;
}
}
$totalPlaces = count($places);
$completionPercent = 0;
if ($totalPlaces > 0) {
$totalCriteria = 5; // nombre total de critères
$totalCompleted = array_sum($counters);
$completionPercent = round(($totalCompleted / ($totalPlaces * $totalCriteria)) * 100);
}
return [
'places_count' => $totalPlaces,
'completion_percent' => $completionPercent,
'counters' => $counters
];
}
}