feat: Gestion des secteurs et migration v3.0.4+304

- Ajout système complet de gestion des secteurs avec contours géographiques
- Import des contours départementaux depuis GeoJSON
- API REST pour la gestion des secteurs (/api/sectors)
- Service de géolocalisation pour déterminer les secteurs
- Migration base de données avec tables x_departements_contours et sectors_adresses
- Interface Flutter pour visualisation et gestion des secteurs
- Ajout thème sombre dans l'application
- Corrections diverses et optimisations

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
pierre
2025-08-07 11:01:45 +02:00
parent 3bbc599ab4
commit 1018b86537
620 changed files with 120502 additions and 91396 deletions

View File

@@ -0,0 +1,318 @@
<?php
/**
* Script d'initialisation des contours des départements français
* À exécuter une seule fois lors de la connexion de l'admin d6soft
*
* Utilise l'API geo.api.gouv.fr pour récupérer les contours GeoJSON
*/
class DepartementContoursInitializer {
private PDO $db;
private array $log = [];
// Liste complète des départements français (métropole + DOM-TOM)
private array $departements = [
// Métropole
'01' => 'Ain', '02' => 'Aisne', '03' => 'Allier', '04' => 'Alpes-de-Haute-Provence',
'05' => 'Hautes-Alpes', '06' => 'Alpes-Maritimes', '07' => 'Ardèche', '08' => 'Ardennes',
'09' => 'Ariège', '10' => 'Aube', '11' => 'Aude', '12' => 'Aveyron',
'13' => 'Bouches-du-Rhône', '14' => 'Calvados', '15' => 'Cantal', '16' => 'Charente',
'17' => 'Charente-Maritime', '18' => 'Cher', '19' => 'Corrèze', '2A' => 'Corse-du-Sud',
'2B' => 'Haute-Corse', '21' => 'Côte-d\'Or', '22' => 'Côtes-d\'Armor', '23' => 'Creuse',
'24' => 'Dordogne', '25' => 'Doubs', '26' => 'Drôme', '27' => 'Eure',
'28' => 'Eure-et-Loir', '29' => 'Finistère', '30' => 'Gard', '31' => 'Haute-Garonne',
'32' => 'Gers', '33' => 'Gironde', '34' => 'Hérault', '35' => 'Ille-et-Vilaine',
'36' => 'Indre', '37' => 'Indre-et-Loire', '38' => 'Isère', '39' => 'Jura',
'40' => 'Landes', '41' => 'Loir-et-Cher', '42' => 'Loire', '43' => 'Haute-Loire',
'44' => 'Loire-Atlantique', '45' => 'Loiret', '46' => 'Lot', '47' => 'Lot-et-Garonne',
'48' => 'Lozère', '49' => 'Maine-et-Loire', '50' => 'Manche', '51' => 'Marne',
'52' => 'Haute-Marne', '53' => 'Mayenne', '54' => 'Meurthe-et-Moselle', '55' => 'Meuse',
'56' => 'Morbihan', '57' => 'Moselle', '58' => 'Nièvre', '59' => 'Nord',
'60' => 'Oise', '61' => 'Orne', '62' => 'Pas-de-Calais', '63' => 'Puy-de-Dôme',
'64' => 'Pyrénées-Atlantiques', '65' => 'Hautes-Pyrénées', '66' => 'Pyrénées-Orientales', '67' => 'Bas-Rhin',
'68' => 'Haut-Rhin', '69' => 'Rhône', '70' => 'Haute-Saône', '71' => 'Saône-et-Loire',
'72' => 'Sarthe', '73' => 'Savoie', '74' => 'Haute-Savoie', '75' => 'Paris',
'76' => 'Seine-Maritime', '77' => 'Seine-et-Marne', '78' => 'Yvelines', '79' => 'Deux-Sèvres',
'80' => 'Somme', '81' => 'Tarn', '82' => 'Tarn-et-Garonne', '83' => 'Var',
'84' => 'Vaucluse', '85' => 'Vendée', '86' => 'Vienne', '87' => 'Haute-Vienne',
'88' => 'Vosges', '89' => 'Yonne', '90' => 'Territoire de Belfort', '91' => 'Essonne',
'92' => 'Hauts-de-Seine', '93' => 'Seine-Saint-Denis', '94' => 'Val-de-Marne', '95' => 'Val-d\'Oise',
// DOM-TOM
'971' => 'Guadeloupe', '972' => 'Martinique', '973' => 'Guyane', '974' => 'La Réunion',
'975' => 'Saint-Pierre-et-Miquelon', '976' => 'Mayotte', '977' => 'Saint-Barthélemy',
'978' => 'Saint-Martin', '984' => 'Terres australes et antarctiques françaises',
'986' => 'Wallis-et-Futuna', '987' => 'Polynésie française', '988' => 'Nouvelle-Calédonie'
];
public function __construct(PDO $db) {
$this->db = $db;
}
/**
* Vérifie si la table existe
*/
public function tableExists(): bool {
try {
$sql = "SHOW TABLES LIKE 'x_departements_contours'";
$stmt = $this->db->query($sql);
return $stmt->rowCount() > 0;
} catch (Exception $e) {
return false;
}
}
/**
* Vérifie si la table est vide
*/
private function isTableEmpty(): bool {
try {
$sql = "SELECT COUNT(*) as count FROM x_departements_contours";
$stmt = $this->db->query($sql);
$result = $stmt->fetch();
return $result['count'] == 0;
} catch (Exception $e) {
return true;
}
}
/**
* Récupère le contour d'un département depuis l'API geo.api.gouv.fr
*/
private function fetchDepartementContour(string $code, string $nom): ?array {
// URL de l'API pour récupérer le contour du département en GeoJSON
$url = "https://geo.api.gouv.fr/departements/{$code}?geometry=contour";
$context = stream_context_create([
'http' => [
'timeout' => 30,
'header' => "User-Agent: Geosector/1.0\r\n"
]
]);
$response = @file_get_contents($url, false, $context);
if ($response === false) {
$this->log[] = "✗ Erreur API pour département $code ($nom)";
return null;
}
$data = json_decode($response, true);
// L'API peut retourner le contour dans 'contour' ou 'geometry'
if (isset($data['contour']) && isset($data['contour']['coordinates'])) {
return $data['contour'];
} elseif (isset($data['geometry']) && isset($data['geometry']['coordinates'])) {
return $data['geometry'];
} else {
$this->log[] = "✗ Pas de contour pour département $code ($nom)";
// Debug : afficher les clés disponibles
if (is_array($data)) {
$this->log[] = " Clés disponibles : " . implode(', ', array_keys($data));
}
return null;
}
}
/**
* Convertit les coordonnées GeoJSON en WKT Polygon pour MySQL
*/
private function geoJsonToWkt(array $coordinates): ?array {
if (empty($coordinates) || !is_array($coordinates[0])) {
return null;
}
// GeoJSON peut avoir plusieurs niveaux d'imbrication selon le type
// Pour un Polygon simple
if (isset($coordinates[0][0]) && is_numeric($coordinates[0][0])) {
$ring = $coordinates;
}
// Pour un MultiPolygon, prendre le premier polygone
elseif (isset($coordinates[0][0][0])) {
$ring = $coordinates[0][0];
}
// Pour un Polygon standard
else {
$ring = $coordinates[0];
}
$points = [];
$lats = [];
$lngs = [];
foreach ($ring as $point) {
if (count($point) >= 2) {
$lng = $point[0];
$lat = $point[1];
$points[] = "$lng $lat";
$lats[] = $lat;
$lngs[] = $lng;
}
}
if (count($points) < 3) {
return null;
}
// Fermer le polygone si nécessaire
if ($points[0] !== $points[count($points) - 1]) {
$points[] = $points[0];
}
return [
'wkt' => 'POLYGON((' . implode(',', $points) . '))',
'bbox' => [
'min_lat' => min($lats),
'max_lat' => max($lats),
'min_lng' => min($lngs),
'max_lng' => max($lngs)
]
];
}
/**
* Importe tous les départements
*/
public function importAll(): array {
$this->log[] = "Début de l'import des contours départementaux";
$this->log[] = "Source : API geo.api.gouv.fr";
$this->log[] = "";
// Vérifier que la table est vide avant d'importer
if (!$this->isTableEmpty()) {
$this->log[] = "✗ La table x_departements_contours contient déjà des données";
return $this->log;
}
// Préparer la requête d'insertion
$sql = "INSERT INTO x_departements_contours
(code_dept, nom_dept, contour, bbox_min_lat, bbox_max_lat, bbox_min_lng, bbox_max_lng)
VALUES
(:code, :nom, ST_GeomFromText(:polygon, 4326), :min_lat, :max_lat, :min_lng, :max_lng)";
$stmt = $this->db->prepare($sql);
$success = 0;
$errors = 0;
// Démarrer une transaction
$this->db->beginTransaction();
try {
foreach ($this->departements as $code => $nom) {
// Petite pause pour ne pas surcharger l'API
usleep(100000); // 100ms
$contour = $this->fetchDepartementContour($code, $nom);
if (!$contour) {
$errors++;
continue;
}
$wktData = $this->geoJsonToWkt($contour['coordinates']);
if (!$wktData) {
$this->log[] = "✗ Conversion échouée pour $code ($nom)";
$errors++;
continue;
}
try {
$stmt->execute([
'code' => $code,
'nom' => $nom,
'polygon' => $wktData['wkt'],
'min_lat' => $wktData['bbox']['min_lat'],
'max_lat' => $wktData['bbox']['max_lat'],
'min_lng' => $wktData['bbox']['min_lng'],
'max_lng' => $wktData['bbox']['max_lng']
]);
$this->log[] = "$code - $nom importé";
$success++;
} catch (Exception $e) {
$this->log[] = "✗ Erreur SQL pour $code ($nom) : " . $e->getMessage();
$errors++;
}
}
// Si tout s'est bien passé, valider la transaction
if ($success > 0) {
$this->db->commit();
$this->log[] = "";
$this->log[] = "✓ Transaction validée";
} else {
$this->db->rollBack();
$this->log[] = "";
$this->log[] = "✗ Transaction annulée (aucun import réussi)";
}
} catch (Exception $e) {
$this->db->rollBack();
$this->log[] = "";
$this->log[] = "✗ Erreur fatale : " . $e->getMessage();
$this->log[] = "✗ Transaction annulée";
$errors = count($this->departements);
}
$this->log[] = "";
$this->log[] = "Import terminé : $success réussis, $errors erreurs";
return $this->log;
}
/**
* Exécute l'initialisation si nécessaire
*/
public static function runIfNeeded(PDO $db, string $username): ?array {
// Vérifier que c'est bien l'admin d6soft
if ($username !== 'd6soft') {
return null;
}
$initializer = new self($db);
// Vérifier si la table existe
if (!$initializer->tableExists()) {
return ["✗ La table x_departements_contours n'existe pas. Veuillez la créer avec le script SQL fourni."];
}
// Vérifier si elle est vide
if (!$initializer->isTableEmpty()) {
return null; // Table déjà remplie, rien à faire
}
// Vérifier si le fichier local existe
$localFile = __DIR__ . '/../docs/contour-des-departements.geojson';
if (file_exists($localFile)) {
// Utiliser le fichier local
require_once __DIR__ . '/import_departements_from_file.php';
$fileImporter = new \DepartementContoursFileImporter($db);
return $fileImporter->importFromFile($localFile);
}
// Sinon, utiliser l'API (qui ne fonctionne pas bien actuellement)
return $initializer->importAll();
}
}
// Si le script est exécuté directement (pour tests)
if (php_sapi_name() === 'cli' && basename(__FILE__) === basename($_SERVER['PHP_SELF'] ?? __FILE__)) {
require_once __DIR__ . '/../src/Config/AppConfig.php';
require_once __DIR__ . '/../src/Core/Database.php';
$appConfig = AppConfig::getInstance();
Database::init($appConfig->getDatabaseConfig());
$db = Database::getInstance();
echo "Test d'import des contours départementaux\n";
echo "========================================\n\n";
$initializer = new DepartementContoursInitializer($db);
$log = $initializer->importAll();
foreach ($log as $line) {
echo $line . "\n";
}
}