Compare commits

...

6 commits

Author SHA1 Message Date
Tykayn
ca00f8c0be affichage fraicheur des données 2025-06-19 12:49:30 +02:00
Tykayn
43139d50d9 float places 2025-06-19 11:48:07 +02:00
Tykayn
123bd8d4d1 plusieurs courbes de progression 2025-06-19 11:07:54 +02:00
Tykayn
d9219db84f courbes d'historique 2025-06-19 11:01:44 +02:00
Tykayn
06ced163e6 add mail action and view, unsubscription 2025-06-19 10:37:29 +02:00
Tykayn
dbe2f62c45 ajout view email proposé pour les commerçants 2025-06-19 10:20:40 +02:00
17 changed files with 911 additions and 56 deletions

View file

@ -20,7 +20,7 @@ body {
} }
.filled { .filled {
background-color: #b0dfa0 !important; background-color: rgba(0, 255, 0, 0.2) !important;
} }
.filled:hover { .filled:hover {

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250619074501 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE place ADD osm_data_date TIMESTAMP(0) WITHOUT TIME ZONE 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 place DROP osm_data_date
SQL);
}
}

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250619074657 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE place ADD osm_data_date TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats ADD osm_data_date_min TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats ADD osm_data_date_avg TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats ADD osm_data_date_max TIMESTAMP(0) WITHOUT TIME ZONE 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 place DROP osm_data_date
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats DROP osm_data_date_min
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats DROP osm_data_date_avg
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE stats DROP osm_data_date_max
SQL);
}
}

View file

@ -82,8 +82,11 @@ final class AdminController extends AbstractController
->setSiret($this->motocultrice->find_siret($placeData['tags']) ?? '') ->setSiret($this->motocultrice->find_siret($placeData['tags']) ?? '')
->setAskedHumainsSupport(false) ->setAskedHumainsSupport(false)
->setLastContactAttemptDate(null) ->setLastContactAttemptDate(null)
->setNote('') ->setNote($this->motocultrice->find_tag($placeData['tags'], 'note') ? true : false)
->setPlaceCount(0); ->setNoteContent($this->motocultrice->find_tag($placeData['tags'], 'note') ?? '')
->setPlaceCount(0)
// ->setOsmData($placeData['modified'] ?? null)
;
// Mettre à jour les données depuis Overpass // Mettre à jour les données depuis Overpass
$place->update_place_from_overpass_data($placeData); $place->update_place_from_overpass_data($placeData);
@ -139,8 +142,74 @@ final class AdminController extends AbstractController
} }
$stats->computeCompletionPercent(); $stats->computeCompletionPercent();
$this->entityManager->persist($stats);
// 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)
// ->setAccessibiliteCount($stats->getAvecAccessibilite())
// ->setNoteCount($stats->getAvecNote())
->setCompletionPercent($stats->getCompletionPercent())
->setStats($stats);
$this->entityManager->persist($statsHistory);
$this->entityManager->persist($stats);
$this->entityManager->flush();
$message = 'Labourage terminé avec succès. ' . $processedCount . ' nouveaux lieux traités.';
if ($updateExisting) {
$message .= ' ' . $updatedCount . ' lieux existants mis à jour pour la zone '.$stats->getName().' ('.$stats->getZone().').';
}
$this->addFlash('success', $message);
} }
$this->entityManager->flush(); $this->entityManager->flush();
@ -175,15 +244,6 @@ final class AdminController extends AbstractController
$urls = $stats->getAllCTCUrlsMap(); $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 // Calculer les statistiques
$calculatedStats = $this->motocultrice->calculateStats($commerces); $calculatedStats = $this->motocultrice->calculateStats($commerces);
@ -206,9 +266,77 @@ final class AdminController extends AbstractController
$this->entityManager->flush(); $this->entityManager->flush();
$stats->computeCompletionPercent(); $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)
// ->setAccessibiliteCount($stats->getAvecAccessibilite())
// ->setNoteCount($stats->getAvecNote())
->setCompletionPercent($stats->getCompletionPercent())
->setStats($stats);
$this->entityManager->persist($statsHistory);
$this->entityManager->persist($stats); $this->entityManager->persist($stats);
$this->entityManager->flush(); $this->entityManager->flush();
$statsHistory = $this->entityManager->getRepository(StatsHistory::class)
->createQueryBuilder('sh')
->where('sh.stats = :stats')
->setParameter('stats', $stats)
->orderBy('sh.id', 'DESC')
->setMaxResults(365)
->getQuery()
->getResult();
return $this->render('admin/stats.html.twig', [ return $this->render('admin/stats.html.twig', [
'stats' => $stats, 'stats' => $stats,
@ -269,6 +397,12 @@ final class AdminController extends AbstractController
#[Route('/admin/labourer/{insee_code}', name: 'app_admin_labourer')] #[Route('/admin/labourer/{insee_code}', name: 'app_admin_labourer')]
public function labourer(string $insee_code, bool $updateExisting = true): Response public function labourer(string $insee_code, bool $updateExisting = true): Response
{ {
// 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.');
return $this->redirectToRoute('app_public_index');
}
try { try {
// Récupérer ou créer les stats pour cette zone // Récupérer ou créer les stats pour cette zone
$stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]); $stats = $this->entityManager->getRepository(Stats::class)->findOneBy(['zone' => $insee_code]);
@ -366,6 +500,28 @@ final class AdminController extends AbstractController
// Mettre à jour les statistiques finales // Mettre à jour les statistiques finales
$stats->computeCompletionPercent(); $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) { if($stats->getDateCreated() == null) {
$stats->setDateCreated(new \DateTime()); $stats->setDateCreated(new \DateTime());
} }
@ -377,11 +533,24 @@ final class AdminController extends AbstractController
$statsHistory->setDate(new \DateTime()) $statsHistory->setDate(new \DateTime())
->setStats($stats); ->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()) $statsHistory->setPlacesCount($stats->getPlaces()->count())
->setOpeningHoursCount($stats->getAvecHoraires()) ->setOpeningHoursCount($stats->getAvecHoraires())
->setAddressCount($stats->getAvecAdresse()) ->setAddressCount($stats->getAvecAdresse())
->setWebsiteCount($stats->getAvecSite()) ->setWebsiteCount($stats->getAvecSite())
->setSiretCount($stats->getAvecSiret()) ->setSiretCount($placesWithSiret)
->setEmailsCount($placesWithEmail)
// ->setAccessibiliteCount($stats->getAvecAccessibilite()) // ->setAccessibiliteCount($stats->getAvecAccessibilite())
// ->setNoteCount($stats->getAvecNote()) // ->setNoteCount($stats->getAvecNote())
->setCompletionPercent($stats->getCompletionPercent()) ->setCompletionPercent($stats->getCompletionPercent())
@ -514,4 +683,62 @@ final class AdminController extends AbstractController
return $response; 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
{
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
{
$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
{
// 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.');
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);
$mailer->send($email);
// Mettre à jour la date de dernier contact
$place->setLastContactAttemptDate(new \DateTime());
$this->entityManager->persist($place);
$this->entityManager->flush();
$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');
}
} }

View file

@ -103,6 +103,54 @@ class Place
#[ORM\Column(nullable: true)] #[ORM\Column(nullable: true)]
private ?int $habitants = null; private ?int $habitants = null;
#[ORM\Column(nullable: true)]
private ?\DateTime $osm_data_date = null;
#[ORM\Column(nullable: true)]
private ?int $osm_version = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $osm_user = null;
#[ORM\Column(nullable: true)]
private ?int $osm_uid = null;
#[ORM\Column(nullable: true)]
private ?int $osm_changeset = null;
public function getPlaceTypeName(): ?string
{
if ($this->main_tag == 'amenity=restaurant') {
return 'restaurant';
}
if ($this->main_tag == 'amenity=bar') {
return 'bar';
}
if ($this->main_tag == 'amenity=cafe') {
return 'café';
}
if ($this->main_tag == 'amenity=hotel') {
return 'hôtel';
}
if ($this->main_tag == 'amenity=supermarket') {
return 'supermarché';
}
if ($this->main_tag == 'amenity=pharmacy') {
return 'pharmacie';
}
if ($this->main_tag == 'amenity=bank') {
return 'banque';
}
if ($this->main_tag == 'amenity=post_office') {
return 'poste';
}
if ($this->main_tag == 'amenity=school') {
return 'école';
}
return 'établissement';
}
public function getMainTag(): ?string public function getMainTag(): ?string
{ {
return $this->main_tag; return $this->main_tag;
@ -161,7 +209,7 @@ class Place
/** /**
* mettre à jour le lieu selon les tags osm * mettre à jour le lieu selon les tags osm
*/ */
public function update_place_from_overpass_data(array $overpass_data) { public function update_place_from_overpass_data(array $overpass_data) {
if ( ! isset($overpass_data['tags']) || $overpass_data['tags'] == null) { if ( ! isset($overpass_data['tags']) || $overpass_data['tags'] == null) {
return; return;
@ -179,7 +227,8 @@ class Place
'addr:street' => '', 'addr:street' => '',
'website' => '', 'website' => '',
'wheelchair' => '', 'wheelchair' => '',
'note' => '' 'note' => '',
'fixme' => '',
], $overpass_data['tags'] ); ], $overpass_data['tags'] );
@ -190,19 +239,41 @@ class Place
$this $this
->setOsmId( $orignal_overpass_data['id']) ->setOsmId( $orignal_overpass_data['id'])
->setOsmKind($orignal_overpass_data['type'] ) ->setOsmKind($orignal_overpass_data['type'] )
->setLat($orignal_overpass_data['lat']) ->setLat((float) $orignal_overpass_data['lat'])
->setLon($orignal_overpass_data['lon']) ->setLon((float) $orignal_overpass_data['lon'])
->setName(isset($overpass_data['name']) && $overpass_data['name'] != '' ? $overpass_data['name'] : null); ->setName(isset($overpass_data['name']) && $overpass_data['name'] != '' ? $overpass_data['name'] : null);
// Traiter le timestamp OSM si disponible
if (isset($orignal_overpass_data['timestamp']) && $orignal_overpass_data['timestamp']) {
try {
$osmDate = new \DateTime($orignal_overpass_data['timestamp']);
$this->setOsmDataDate($osmDate);
} catch (\Exception $e) {
// En cas d'erreur de parsing de la date, on ignore
}
}
// Traiter les autres métadonnées OSM
if (isset($orignal_overpass_data['version'])) {
$this->setOsmVersion($orignal_overpass_data['version']);
}
if (isset($orignal_overpass_data['user'])) {
$this->setOsmUser($orignal_overpass_data['user']);
}
if (isset($orignal_overpass_data['uid'])) {
$this->setOsmUid($orignal_overpass_data['uid']);
}
if (isset($orignal_overpass_data['changeset'])) {
$this->setOsmChangeset($orignal_overpass_data['changeset']);
}
$mapping = [ $mapping = [
['key' => 'postcode', 'setter' => 'setZipCode', 'source' => $overpass_data], ['key' => 'postcode', 'setter' => 'setZipCode', 'source' => $overpass_data],
['key' => 'email', 'setter' => 'setEmail', 'source' => $overpass_data], ['key' => 'email', 'setter' => 'setEmail', 'source' => $overpass_data],
['key' => 'opening_hours', 'setter' => 'setHasOpeningHours', 'source' => $overpass_data['tags'] ?? []], ['key' => 'opening_hours', 'setter' => 'setHasOpeningHours', 'source' => $overpass_data['tags'] ?? []],
['key' => 'note', 'setter' => 'setNote', 'source' => $overpass_data['tags'] ?? []],
['key' => 'addr:housenumber', 'setter' => 'setHasAddress', 'source' => $overpass_data['tags'] ?? []], ['key' => 'addr:housenumber', 'setter' => 'setHasAddress', 'source' => $overpass_data['tags'] ?? []],
['key' => 'website', 'setter' => 'setHasWebsite', 'source' => $overpass_data['tags'] ?? []], ['key' => 'website', 'setter' => 'setHasWebsite', 'source' => $overpass_data['tags'] ?? []],
['key' => 'wheelchair', 'setter' => 'setHasWheelchair', 'source' => $overpass_data['tags'] ?? []], ['key' => 'wheelchair', 'setter' => 'setHasWheelchair', 'source' => $overpass_data['tags'] ?? []],
['key' => 'note', 'setter' => 'setHasNote', 'source' => $overpass_data['tags'] ?? []],
['key' => 'siret', 'setter' => 'setSiret', 'source' => $overpass_data['tags'] ?? []], ['key' => 'siret', 'setter' => 'setSiret', 'source' => $overpass_data['tags'] ?? []],
['key' => 'addr:street', 'setter' => 'setStreet', 'source' => $overpass_data['tags'] ?? []], ['key' => 'addr:street', 'setter' => 'setStreet', 'source' => $overpass_data['tags'] ?? []],
['key' => 'addr:housenumber', 'setter' => 'setHousenumber', 'source' => $overpass_data['tags'] ?? []], ['key' => 'addr:housenumber', 'setter' => 'setHousenumber', 'source' => $overpass_data['tags'] ?? []],
@ -214,6 +285,26 @@ class Place
} }
} }
// Traiter les notes et fixme
$noteContent = '';
$hasNote = false;
if (isset($orignal_overpass_data['tags']['note']) && $orignal_overpass_data['tags']['note'] !== '') {
$noteContent .= $orignal_overpass_data['tags']['note'];
$hasNote = true;
}
if (isset($orignal_overpass_data['tags']['fixme']) && $orignal_overpass_data['tags']['fixme'] !== '') {
if ($noteContent !== '') {
$noteContent .= "\n\n";
}
$noteContent .= "FIXME: " . $orignal_overpass_data['tags']['fixme'];
$hasNote = true;
}
$this->setNoteContent($noteContent);
$this->setHasNote($hasNote);
$this $this
// ->setOsmId($overpass_data['id']) // ->setOsmId($overpass_data['id'])
// ->setOsmKind($overpass_data['type']) // ->setOsmKind($overpass_data['type'])
@ -226,8 +317,7 @@ class Place
->setHasOpeningHours($overpass_data['opening_hours'] ? true : false) ->setHasOpeningHours($overpass_data['opening_hours'] ? true : false)
->setHasAddress($overpass_data['addr:housenumber'] && $overpass_data['addr:street'] ? true : false) ->setHasAddress($overpass_data['addr:housenumber'] && $overpass_data['addr:street'] ? true : false)
->setHasWebsite($overpass_data['website'] ? true : false) ->setHasWebsite($overpass_data['website'] ? true : false)
->setHasWheelchair($overpass_data['wheelchair'] and $overpass_data['wheelchair'] != '' ? true : false) ->setHasWheelchair($overpass_data['wheelchair'] and $overpass_data['wheelchair'] != '' ? true : false);
->setHasNote($overpass_data['note'] and $overpass_data['note'] != '' ? true : false);
} }
public function __construct() public function __construct()
@ -523,24 +613,24 @@ class Place
return $this; return $this;
} }
public function getLat(): ?int public function getLat(): ?float
{ {
return $this->lat; return $this->lat;
} }
public function setLat(?int $lat): static public function setLat(?float $lat): static
{ {
$this->lat = $lat; $this->lat = $lat;
return $this; return $this;
} }
public function getLon(): ?int public function getLon(): ?float
{ {
return $this->lon; return $this->lon;
} }
public function setLon(?int $lon): static public function setLon(?float $lon): static
{ {
$this->lon = $lon; $this->lon = $lon;
@ -594,4 +684,64 @@ class Place
return $this; return $this;
} }
public function getOsmDataDate(): ?\DateTime
{
return $this->osm_data_date;
}
public function setOsmDataDate(?\DateTime $osm_data_date): static
{
$this->osm_data_date = $osm_data_date;
return $this;
}
public function getOsmVersion(): ?int
{
return $this->osm_version;
}
public function setOsmVersion(?int $osm_version): static
{
$this->osm_version = $osm_version;
return $this;
}
public function getOsmUser(): ?string
{
return $this->osm_user;
}
public function setOsmUser(?string $osm_user): static
{
$this->osm_user = $osm_user;
return $this;
}
public function getOsmUid(): ?int
{
return $this->osm_uid;
}
public function setOsmUid(?int $osm_uid): static
{
$this->osm_uid = $osm_uid;
return $this;
}
public function getOsmChangeset(): ?int
{
return $this->osm_changeset;
}
public function setOsmChangeset(?int $osm_changeset): static
{
$this->osm_changeset = $osm_changeset;
return $this;
}
} }

View file

@ -86,6 +86,15 @@ class Stats
#[ORM\Column(nullable: true)] #[ORM\Column(nullable: true)]
private ?int $avec_name = null; private ?int $avec_name = null;
#[ORM\Column(nullable: true)]
private ?\DateTime $osm_data_date_min = null;
#[ORM\Column(nullable: true)]
private ?\DateTime $osm_data_date_avg = null;
#[ORM\Column(nullable: true)]
private ?\DateTime $osm_data_date_max = null;
public function getCTCurlBase(): ?string public function getCTCurlBase(): ?string
{ {
$base = 'https://complete-tes-commerces.fr/'; $base = 'https://complete-tes-commerces.fr/';
@ -494,5 +503,41 @@ class Stats
return $this; return $this;
} }
public function getOsmDataDateMin(): ?\DateTime
{
return $this->osm_data_date_min;
}
public function setOsmDataDateMin(?\DateTime $osm_data_date_min): static
{
$this->osm_data_date_min = $osm_data_date_min;
return $this;
}
public function getOsmDataDateAvg(): ?\DateTime
{
return $this->osm_data_date_avg;
}
public function setOsmDataDateAvg(?\DateTime $osm_data_date_avg): static
{
$this->osm_data_date_avg = $osm_data_date_avg;
return $this;
}
public function getOsmDataDateMax(): ?\DateTime
{
return $this->osm_data_date_max;
}
public function setOsmDataDateMax(?\DateTime $osm_data_date_max): static
{
$this->osm_data_date_max = $osm_data_date_max;
return $this;
}
} }

View file

@ -101,10 +101,10 @@ public function find_siret($tags) {
public function get_export_query($zone) { public function get_export_query($zone) {
return <<<QUERY return <<<QUERY
[out:csv(::id,::type,::lat,::lon,name,amenity,shop,office,healthcare,"contact:email",email,"contact:phone",phone,"contact:website",website,image,url,wikidata, opening_hours,"contact:housenumber","addr:housenumber","contact:street","addr:street",note,fixme,harassment_prevention,cuisine,brand,tourism,source,zip_code,"ref:FR:SIRET")]; [out:csv(::id,::type,::lat,::lon,::timestamp,::version,::user,::uid,::changeset,name,amenity,shop,office,healthcare,"contact:email",email,"contact:phone",phone,"contact:website",website,image,url,wikidata, opening_hours,"contact:housenumber","addr:housenumber","contact:street","addr:street",note,fixme,harassment_prevention,cuisine,brand,tourism,source,zip_code,"ref:FR:SIRET")];
{{geocodeArea:"{$zone}, France"}}->.searchArea; {{geocodeArea:"{$zone}, France"}}->.searchArea;
{$this->overpass_base_places} {$this->overpass_base_places}
out skel qt; out meta;
QUERY; QUERY;
} }
@ -112,7 +112,7 @@ QUERY;
return '[out:json][timeout:25]; return '[out:json][timeout:25];
area["ref:INSEE"="'.$zone.'"]->.searchArea; area["ref:INSEE"="'.$zone.'"]->.searchArea;
'.$this->overpass_base_places.' '.$this->overpass_base_places.'
out center tags;'; out meta;';
} }
private $more_tags = ['image', 'ref:FR:SIRET']; private $more_tags = ['image', 'ref:FR:SIRET'];
@ -180,7 +180,14 @@ out center tags;';
'name' => $element['tags']['name'] ?? '', 'name' => $element['tags']['name'] ?? '',
'lat' => $element['lat'] ?? null, 'lat' => $element['lat'] ?? null,
'lon' => $element['lon'] ?? null, 'lon' => $element['lon'] ?? null,
'tags' => $element['tags'] 'tags' => $element['tags'],
// Métadonnées OSM
'timestamp' => $element['timestamp'] ?? null,
'version' => $element['version'] ?? null,
'user' => $element['user'] ?? null,
'uid' => $element['uid'] ?? null,
'changeset' => $element['changeset'] ?? null,
'modified' => $element['timestamp'] ?? null
]; ];
} }
} }
@ -216,6 +223,13 @@ out center tags;';
return null; return null;
} }
public function find_tag($tags, $tag) {
if(isset($tags[$tag]) && $tags[$tag] != '') {
return $tags[$tag];
}
return null;
}
public function get_city_osm_from_zip_code($zip_code) { public function get_city_osm_from_zip_code($zip_code) {
// Requête Overpass pour obtenir la zone administrative de niveau 8 avec un nom // Requête Overpass pour obtenir la zone administrative de niveau 8 avec un nom
$query = "[out:json][timeout:25]; $query = "[out:json][timeout:25];

View file

@ -0,0 +1,32 @@
<div class="content">
<i class="bi bi-shop-window"></i>
<p>Bonjour, votre {{place.getPlaceTypeName()}} "{{place.name }}" est présent dans la base de données mondiale OpenStreetMap (OSM) avec 650 000 autres en France.
<br>
Ces informations sont utilisées dans des milliers de sites web et annuaires, par Île de France mobilités, TomTom, Geovelo, Cartes IGN, Facebook, Instagram, Apple Plans et bien d'autres.
<br>
Plus les informations seront à jour et plus vous aurez de chances d'avoir des clients satisfaits.</p>
<p> Vous pouvez le modifier en cliquant sur le bouton ci-dessous, c'est gratuit et sans engagement.</p>
<a href="{{ path('app_public_edit', {'zipcode': place.zipCode, 'name': place.name != '' ? place.name : '?', 'uuid': place.uuidForUrl}) }}" class="btn btn-primary">
<i class="bi bi-pencil-square"></i>
Compléter les informations de mon commerce
</a>
<br>
<br>
Les bénévoles de l'association OpenStreetMap France ont mis en place cet outil pour faciliter la mise à jour des informations de vos commerces et améliorer la souveraineté numérique. Si vous avez besoin d'aide, n'hésitez pas à nous contacter à l'adresse <a href="mailto:contact@openstreetmap.fr">contact@openstreetmap.fr</a>.
<br>
<br>
Pour des besoins de prestation de services concernant l'intégration ou l'exportation de données depuis OSM, vous pouvez contacter la fédération des pros d'OpenStreetMap France sur <a href="https://fposm.fr">https://fposm.fr</a>.
<br>
<br>
En vous souhaitant une bonne journée.
<br>
- Les bénévoles de l'association OpenStreetMap France.
<br>
<hr>
<a href="{{ path('app_admin_commerce', {'id': place.id}) }}">Ne plus être sollicité pour mettre à jour mon commerce</a>
</div>

View file

@ -19,6 +19,28 @@
.completion-info { .completion-info {
margin-bottom: 2rem; margin-bottom: 2rem;
} }
.osm-modification-info {
font-size: 0.85rem;
line-height: 1.3;
}
.osm-modification-info .text-muted {
font-size: 0.75rem;
}
.osm-modification-info a {
text-decoration: none;
color: #0d6efd;
}
.osm-modification-info a:hover {
text-decoration: underline;
}
.osm-freshness-info {
font-size: 0.95rem;
line-height: 1.4;
}
.osm-freshness-info .alert {
border-left: 4px solid #0dcaf0;
background-color: #f8f9fa;
}
</style> </style>
{% endblock %} {% endblock %}
@ -59,6 +81,74 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{# Affichage de la fraîcheur des données OSM #}
{% if stats.osmDataDateMin and stats.osmDataDateMax and stats.osmDataDateAvg %}
{% set now = "now"|date("U") %}
{% set minDate = stats.osmDataDateMin|date("U") %}
{% set maxDate = stats.osmDataDateMax|date("U") %}
{% set avgDate = stats.osmDataDateAvg|date("U") %}
{% set minDiff = now - minDate %}
{% set maxDiff = now - maxDate %}
{% set avgDiff = now - avgDate %}
{% set minYears = (minDiff / 31536000)|round(0, 'floor') %}
{% set minMonths = ((minDiff % 31536000) / 2592000)|round(0, 'floor') %}
{% set maxYears = (maxDiff / 31536000)|round(0, 'floor') %}
{% set maxMonths = ((maxDiff % 31536000) / 2592000)|round(0, 'floor') %}
{% set avgYears = (avgDiff / 31536000)|round(0, 'floor') %}
{% set avgMonths = ((avgDiff % 31536000) / 2592000)|round(0, 'floor') %}
<div class="row mb-3">
<div class="col-12">
<div class="alert alert-info osm-freshness-info">
<i class="bi bi-clock-history"></i>
<strong>Fraîcheur des données OSM :</strong>
{% if minYears == maxYears and minMonths == maxMonths %}
Toutes les modifications ont été faites il y a
{% if minYears > 0 %}
{{ minYears }} an{{ minYears > 1 ? 's' : '' }}
{% if minMonths > 0 %}, {{ minMonths }} mois{% endif %}
{% elseif minMonths > 0 %}
{{ minMonths }} mois
{% else %}
moins d'un mois
{% endif %}
{% else %}
Les modifications ont été faites entre il y a
{% if maxYears > 0 %}
{{ maxYears }} an{{ maxYears > 1 ? 's' : '' }}
{% if maxMonths > 0 %}, {{ maxMonths }} mois{% endif %}
{% elseif maxMonths > 0 %}
{{ maxMonths }} mois
{% else %}
moins d'un mois
{% endif %}
et il y a
{% if minYears > 0 %}
{{ minYears }} an{{ minYears > 1 ? 's' : '' }}
{% if minMonths > 0 %}, {{ minMonths }} mois{% endif %}
{% elseif minMonths > 0 %}
{{ minMonths }} mois
{% else %}
moins d'un mois
{% endif %},
en moyenne il y a
{% if avgYears > 0 %}
{{ avgYears }} an{{ avgYears > 1 ? 's' : '' }}
{% if avgMonths > 0 %}, {{ avgMonths }} mois{% endif %}
{% elseif avgMonths > 0 %}
{{ avgMonths }} mois
{% else %}
moins d'un mois
{% endif %}
{% endif %}
</div>
</div>
</div>
{% endif %}
<div class="row"> <div class="row">
<div class="col-md-3 col-12"> <div class="col-md-3 col-12">
<span class="badge {% if stats.getCompletionPercent() > 85 %}bg-success{% else %}bg-warning{% endif %}"> <span class="badge {% if stats.getCompletionPercent() > 85 %}bg-success{% else %}bg-warning{% endif %}">
@ -954,8 +1044,8 @@ window.updateMarkers = updateMarkers;
{# markClosedSiretsOnTable(); #} {# markClosedSiretsOnTable(); #}
function makeDonutGraphOfTags() { function makeDonutGraphOfTags() {
// Récupérer tous les tags de la colonne 2 // Récupérer tous les tags de la colonne 4 (Type)
const tags = Array.from(document.querySelectorAll('table tbody tr td:nth-child(3)')) const tags = Array.from(document.querySelectorAll('table tbody tr td:nth-child(4)'))
.map(cell => cell.textContent.trim()) .map(cell => cell.textContent.trim())
.filter(tag => tag.includes('=')) // Filtrer les cellules qui ne contiennent pas de = .filter(tag => tag.includes('=')) // Filtrer les cellules qui ne contiennent pas de =
.filter(tag => tag); // Filtrer les cellules vides .filter(tag => tag); // Filtrer les cellules vides

View file

@ -10,6 +10,12 @@
{% endif %} {% endif %}
</a> </a>
</td> </td>
<td>
<a href="{{ path('app_admin_make_email_for_place', {'id': commerce.id}) }}">
voir email
<i class="bi bi-envelope-fill"></i>
</a>
</td>
<td class="text-right completion-cell" <td class="text-right completion-cell"
style="background : rgba(0,255,0,{{ commerce.getCompletionPercentage() / 100 }})" style="background : rgba(0,255,0,{{ commerce.getCompletionPercentage() / 100 }})"
data-bs-toggle="popover" data-bs-toggle="popover"
@ -84,11 +90,67 @@
<td class="{{ commerce.hasWheelchair() ? 'filled' : '' }}">{{ commerce.wheelchair }}</td> <td class="{{ commerce.hasWheelchair() ? 'filled' : '' }}">{{ commerce.wheelchair }}</td>
<td class="{{ commerce.hasNote() ? 'filled' : '' }}">{{ commerce.note }}</td> <td class="{{ commerce.hasNote() ? 'filled' : '' }}">{{ commerce.note }}</td>
<td class="{{ commerce.noteContent ? 'filled' : '' }}">{{ commerce.noteContent }}</td> <td class="{{ commerce.noteContent ? 'filled' : '' }}">{{ commerce.noteContent }}</td>
<td class="{{ commerce.siret ? 'filled' : '' }}"> <a href="https://annuaire-entreprises.data.gouv.fr/etablissement/{{ commerce.siret }}" > {{ commerce.siret }}</a></td> <td class="{{ commerce.siret ? 'filled' : '' }}">
{% if commerce.siret %}
{% set sirets = commerce.siret|split(';')|map(siret => siret|trim)|filter(siret => siret) %}
{% for siret in sirets %}
{% if not loop.first %}, {% endif %}
<a href="https://annuaire-entreprises.data.gouv.fr/etablissement/{{ siret }}" >{{ siret }}</a>
{% endfor %}
{% else %}
<a href="https://annuaire-entreprises.data.gouv.fr/etablissement/{{ commerce.siret }}" >
{% endif %}
<td> <td>
{# (si siret clos) #} {# (si siret clos) #}
</td> </td>
<td> <td>
{% if commerce.osmDataDate %}
{% set now = "now"|date("U") %}
{% set osmDate = commerce.osmDataDate|date("U") %}
{% set diff = now - osmDate %}
{% set years = (diff / 31536000)|round(0, 'floor') %}
{% set months = ((diff % 31536000) / 2592000)|round(0, 'floor') %}
{% set days = ((diff % 2592000) / 86400)|round(0, 'floor') %}
<div class="small osm-modification-info">
<div>
<i class="bi bi-calendar"></i>
{{ commerce.osmDataDate|date('d/m/Y H:i') }}
</div>
{% if commerce.osmUser %}
<div>
<i class="bi bi-person"></i>
<a href="https://www.openstreetmap.org/user/{{ commerce.osmUser }}" target="_blank" title="Voir le profil OSM">
{{ commerce.osmUser }}
</a>
</div>
{% endif %}
<div class="text-muted">
<small>
{% if diff < 86400 %}
Aujourd'hui
{% elseif years > 0 %}
{{ years }} an{{ years > 1 ? 's' : '' }}
{% if months > 0 %}, {{ months }} mois{% endif %}
{% elseif months > 0 %}
{{ months }} mois
{% if days > 0 %}, {{ days }} jour{{ days > 1 ? 's' : '' }}{% endif %}
{% elseif days > 0 %}
{{ days }} jour{{ days > 1 ? 's' : '' }}
{% else %}
Aujourd'hui
{% endif %}
</small>
</div>
</div>
{% else %}
<span class="text-muted">Non disponible</span>
{% endif %}
</td>
<td>
<a href="https://www.openstreetmap.org/{{ commerce.osmKind }}/{{ commerce.osmId }}" title="{{ commerce.osmKind }} - {{ commerce.osmId }} " > <a href="https://www.openstreetmap.org/{{ commerce.osmKind }}/{{ commerce.osmId }}" title="{{ commerce.osmKind }} - {{ commerce.osmId }} " >
<i class="bi bi-globe"></i> <i class="bi bi-globe"></i>

View file

@ -2,6 +2,10 @@
<tr> <tr>
<th>Nom ({{ stats.places|length }})</th> <th>Nom ({{ stats.places|length }})</th>
<th> <th>
<i class="bi bi-envelope-fill"></i>
Email
</th>
<th>
<i class="bi bi-circle-fill"></i> <i class="bi bi-circle-fill"></i>
Completion % Completion %
</th> </th>
@ -46,6 +50,10 @@
Siret clos Siret clos
</th> </th>
<th> <th>
<i class="bi bi-clock-history"></i>
Dernière modif. OSM
</th>
<th>
Osm id</th> Osm id</th>
<th> <th>

View file

@ -11,33 +11,143 @@
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const ctx = document.getElementById('completionHistoryChart').getContext('2d'); const ctx = document.getElementById('completionHistoryChart').getContext('2d');
// Préparer les données pour chaque aspect
const labels = [
{% for stat in statsHistory|reverse %}
'{{ stat.date|date('d/m/Y') }}'{% if not loop.last %},{% endif %}
{% endfor %}
];
const completionData = [
{% for stat in statsHistory|reverse %}
{{ stat.completionPercent }}{% if not loop.last %},{% endif %}
{% endfor %}
];
const openingHoursData = [
{% for stat in statsHistory|reverse %}
{% if stat.placesCount > 0 %}
{{ ((stat.openingHoursCount / stat.placesCount) * 100)|round(1) }}{% if not loop.last %},{% endif %}
{% else %}
0{% if not loop.last %},{% endif %}
{% endif %}
{% endfor %}
];
const addressData = [
{% for stat in statsHistory|reverse %}
{% if stat.placesCount > 0 %}
{{ ((stat.addressCount / stat.placesCount) * 100)|round(1) }}{% if not loop.last %},{% endif %}
{% else %}
0{% if not loop.last %},{% endif %}
{% endif %}
{% endfor %}
];
const websiteData = [
{% for stat in statsHistory|reverse %}
{% if stat.placesCount > 0 %}
{{ ((stat.websiteCount / stat.placesCount) * 100)|round(1) }}{% if not loop.last %},{% endif %}
{% else %}
0{% if not loop.last %},{% endif %}
{% endif %}
{% endfor %}
];
const siretData = [
{% for stat in statsHistory|reverse %}
{% if stat.placesCount > 0 %}
{{ ((stat.siretCount / stat.placesCount) * 100)|round(1) }}{% if not loop.last %},{% endif %}
{% else %}
0{% if not loop.last %},{% endif %}
{% endif %}
{% endfor %}
];
const emailData = [
{% for stat in statsHistory|reverse %}
{% if stat.placesCount > 0 %}
{{ ((stat.emailsCount / stat.placesCount) * 100)|round(1) }}{% if not loop.last %},{% endif %}
{% else %}
0{% if not loop.last %},{% endif %}
{% endif %}
{% endfor %}
];
new Chart(ctx, { new Chart(ctx, {
type: 'line', type: 'line',
data: { data: {
labels: [ labels: labels,
{% for stat in statsHistory %} datasets: [
'{{ stat.date|date('d/m/Y') }}'{% if not loop.last %},{% endif %} {
{% endfor %} label: 'Taux de complétion global (%)',
], data: completionData,
datasets: [{ borderColor: 'rgb(75, 192, 192)',
label: 'Taux de complétion (%)', backgroundColor: 'rgba(75, 192, 192, 0.1)',
data: [ tension: 0.3,
{% for stat in statsHistory %} fill: false,
{{ stat.completionPercent }}{% if not loop.last %},{% endif %} borderWidth: 3
{% endfor %} },
], {
borderColor: 'rgb(75, 192, 192)', label: 'Horaires d\'ouverture (%)',
backgroundColor: 'rgba(75, 192, 192, 0.2)', data: openingHoursData,
tension: 0.3, borderColor: 'rgb(255, 99, 132)',
fill: true backgroundColor: 'rgba(255, 99, 132, 0.1)',
}] tension: 0.3,
fill: false,
borderWidth: 2
},
{
label: 'Adresses (%)',
data: addressData,
borderColor: 'rgb(54, 162, 235)',
backgroundColor: 'rgba(54, 162, 235, 0.1)',
tension: 0.3,
fill: false,
borderWidth: 2
},
{
label: 'Sites web (%)',
data: websiteData,
borderColor: 'rgb(255, 205, 86)',
backgroundColor: 'rgba(255, 205, 86, 0.1)',
tension: 0.3,
fill: false,
borderWidth: 2
},
{
label: 'SIRET (%)',
data: siretData,
borderColor: 'rgb(153, 102, 255)',
backgroundColor: 'rgba(153, 102, 255, 0.1)',
tension: 0.3,
fill: false,
borderWidth: 2
},
{
label: 'Emails (%)',
data: emailData,
borderColor: 'rgb(199, 199, 199)',
backgroundColor: 'rgba(199, 199, 199, 0.1)',
tension: 0.3,
fill: false,
borderWidth: 2
}
]
}, },
options: { options: {
responsive: true, responsive: true,
plugins: { plugins: {
title: { title: {
display: true, display: true,
text: 'Évolution du taux de complétion au fil du temps' text: 'Évolution des taux de complétion par aspect au fil du temps'
},
legend: {
position: 'top',
labels: {
usePointStyle: true,
padding: 20
}
} }
}, },
scales: { scales: {
@ -46,7 +156,7 @@ document.addEventListener('DOMContentLoaded', function() {
max: 100, max: 100,
title: { title: {
display: true, display: true,
text: 'Complétion (%)' text: 'Pourcentage (%)'
} }
}, },
x: { x: {
@ -55,6 +165,10 @@ document.addEventListener('DOMContentLoaded', function() {
text: 'Date' text: 'Date'
} }
} }
},
interaction: {
intersect: false,
mode: 'index'
} }
} }
}); });

View file

@ -0,0 +1,22 @@
{% extends 'base.html.twig' %}
{% block title %}Email pour {{ place.name }}{% endblock %}
{% block body %}
<div class="container mt-4">
<h1>Email pour {{ place.name }}</h1>
<div class="content">
{% include 'admin/email_content.html.twig' with {'place': place} %}
</div>
</div>
<div class="container">
<div class="row">
<div class="col-12">
<a href="{{ path('app_admin_send_email_to_place', {'id': place.id}) }}" class="btn btn-primary">
<i class="bi bi-envelope-fill"></i>
Envoyer l'email
</a>
</div>
</div>
</div>
{% endblock %}

View file

@ -36,7 +36,7 @@
</div> </div>
{% for label, messages in app.flashes %} {% for label, messages in app.flashes %}
{% for message in messages %} {% for message in messages %}
<div class="alert alert-{{ label }} alert-dismissible fade show mt-3" role="alert"> <div class="alert alert-{{ label }} is-{{ label }} alert-dismissible fade show mt-3" role="alert">
{{ message }} {{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div> </div>

View file

@ -97,6 +97,7 @@
> >
<i class="bi bi-recycle"></i> <i class="bi bi-recycle"></i>
</a> </a>
<a href="{{ path('app_admin_delete_by_zone', {'insee_code': stat.zone}) }}" <a href="{{ path('app_admin_delete_by_zone', {'insee_code': stat.zone}) }}"
class="btn btn-sm btn-danger" class="btn btn-sm btn-danger"
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette zone ?')" onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette zone ?')"

View file

@ -128,7 +128,7 @@
{% block javascripts %} {% block javascripts %}
{{ parent() }} {{ parent() }}
<script src='{{ asset('js/utils.js') }}'></script> {# <script src='{{ asset('js/utils.js') }}'></script> #}
<script type="module"> <script type="module">
import { adjustListGroupFontSize } from '{{ asset('js/utils.js') }}'; import { adjustListGroupFontSize } from '{{ asset('js/utils.js') }}';
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {

View file

@ -9,8 +9,9 @@
<thead> <thead>
<tr> <tr>
<th>Nom</th> <th>Nom</th>
<th>Code postal</th> <th>Code insee</th>
<th>Note</th> <th>Note</th>
<th>contenu de note</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
@ -25,6 +26,7 @@
<td> <td>
{{place.zipcode}} {{place.zipcode}}
</td> </td>
<td>{{ place.note }}</td>
<td>{{ place.noteContent }}</td> <td>{{ place.noteContent }}</td>
<td><a class="btn btn-primary" href="{{ path('app_admin_commerce', {'id': place.id}) }}"> <td><a class="btn btn-primary" href="{{ path('app_admin_commerce', {'id': place.id}) }}">
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i>