- #17: Amélioration gestion des secteurs et statistiques - #18: Optimisation services API et logs - #19: Corrections Flutter widgets et repositories - #20: Fix création passage - détection automatique ope_users.id vs users.id Suppression dossier web/ (migration vers app Flutter) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
293 lines
9.4 KiB
PHP
293 lines
9.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services;
|
|
|
|
use Database;
|
|
use PDO;
|
|
|
|
/**
|
|
* Service global pour la gestion des secteurs
|
|
*
|
|
* Fournit des fonctions réutilisables pour :
|
|
* - Géocoder une adresse via api-adresse.data.gouv.fr
|
|
* - Trouver un secteur à partir de coordonnées GPS
|
|
* - Trouver un secteur à partir d'une adresse
|
|
*/
|
|
class SectorService
|
|
{
|
|
private PDO $db;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->db = Database::getInstance();
|
|
}
|
|
|
|
/**
|
|
* Géocode une adresse via api-adresse.data.gouv.fr
|
|
*
|
|
* @param string $num Numéro de rue
|
|
* @param string $bis Complément (bis, ter, etc.)
|
|
* @param string $rue Nom de la rue
|
|
* @param string $ville Nom de la ville
|
|
* @param string $cp Code postal (pour vérifier le département)
|
|
* @return array|null [lat, lng] ou null si non trouvé ou score trop faible
|
|
*/
|
|
public function geocodeAddress(string $num, string $bis, string $rue, string $ville, string $cp): ?array
|
|
{
|
|
try {
|
|
// Construire l'URL de l'API
|
|
$query = trim($num . $bis) . ' ' . $rue . ' ' . $ville;
|
|
$url = 'https://api-adresse.data.gouv.fr/search/?q=' . urlencode($query);
|
|
|
|
LogService::info('[SectorService] Géocodage adresse', [
|
|
'url' => $url,
|
|
'adresse' => $query
|
|
]);
|
|
|
|
// Appel à l'API
|
|
$ch = curl_init();
|
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
curl_setopt($ch, CURLOPT_URL, $url);
|
|
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
|
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
|
|
$json = curl_exec($ch);
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
if ($httpCode !== 200 || empty($json)) {
|
|
LogService::warning('[SectorService] Erreur API géocodage', [
|
|
'http_code' => $httpCode,
|
|
'adresse' => $query
|
|
]);
|
|
return null;
|
|
}
|
|
|
|
$data = json_decode($json);
|
|
|
|
if (empty($data->features)) {
|
|
LogService::info('[SectorService] Aucun résultat de géocodage', [
|
|
'adresse' => $query
|
|
]);
|
|
return null;
|
|
}
|
|
|
|
$score = $data->features[0]->properties->score ?? 0;
|
|
|
|
// Vérifier le score (> 0.7 = 70% de confiance)
|
|
if (floatval($score) <= 0.7) {
|
|
LogService::info('[SectorService] Score géocodage trop faible', [
|
|
'score' => $score,
|
|
'adresse' => $query
|
|
]);
|
|
return null;
|
|
}
|
|
|
|
// Vérifier le département
|
|
$cpTrouve = $data->features[0]->properties->postcode ?? '';
|
|
$deptTrouve = substr($cpTrouve, 0, 2);
|
|
|
|
$cpAmicale = $cp;
|
|
if (strlen($cpAmicale) == 4) {
|
|
$cpAmicale = '0' . $cpAmicale;
|
|
}
|
|
$deptAmicale = substr($cpAmicale, 0, 2);
|
|
|
|
if ($deptTrouve !== $deptAmicale) {
|
|
LogService::warning('[SectorService] Département différent', [
|
|
'dept_trouve' => $deptTrouve,
|
|
'dept_attendu' => $deptAmicale,
|
|
'adresse' => $query
|
|
]);
|
|
return null;
|
|
}
|
|
|
|
// Extraire les coordonnées [lng, lat] -> [lat, lng]
|
|
$coordinates = $data->features[0]->geometry->coordinates;
|
|
$lat = (float)$coordinates[1];
|
|
$lng = (float)$coordinates[0];
|
|
|
|
LogService::info('[SectorService] Géocodage réussi', [
|
|
'lat' => $lat,
|
|
'lng' => $lng,
|
|
'score' => $score,
|
|
'adresse' => $query
|
|
]);
|
|
|
|
return ['lat' => $lat, 'lng' => $lng];
|
|
|
|
} catch (\Exception $e) {
|
|
LogService::error('[SectorService] Erreur géocodage', [
|
|
'error' => $e->getMessage(),
|
|
'adresse' => "$num$bis $rue $ville"
|
|
]);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Trouve le secteur contenant une position GPS pour une opération donnée
|
|
*
|
|
* @param int $operationId ID de l'opération
|
|
* @param float $lat Latitude
|
|
* @param float $lng Longitude
|
|
* @return int|null ID du secteur trouvé ou null
|
|
*/
|
|
public function findSectorByGps(int $operationId, float $lat, float $lng): ?int
|
|
{
|
|
try {
|
|
// Récupérer tous les secteurs de l'opération avec leur polygone
|
|
$query = "SELECT id, sector FROM ope_sectors
|
|
WHERE fk_operation = :operation_id
|
|
AND chk_active = 1
|
|
AND sector IS NOT NULL
|
|
AND sector != ''";
|
|
|
|
$stmt = $this->db->prepare($query);
|
|
$stmt->execute(['operation_id' => $operationId]);
|
|
$sectors = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|
|
|
if (empty($sectors)) {
|
|
LogService::info('[SectorService] Aucun secteur trouvé pour l\'opération', [
|
|
'operation_id' => $operationId
|
|
]);
|
|
return null;
|
|
}
|
|
|
|
// Tester chaque secteur
|
|
foreach ($sectors as $sector) {
|
|
$polygon = $this->parseSectorPolygon($sector['sector']);
|
|
|
|
if (empty($polygon)) {
|
|
continue;
|
|
}
|
|
|
|
if ($this->isPointInsidePolygon($lat, $lng, $polygon)) {
|
|
LogService::info('[SectorService] Secteur trouvé par GPS', [
|
|
'sector_id' => $sector['id'],
|
|
'operation_id' => $operationId,
|
|
'lat' => $lat,
|
|
'lng' => $lng
|
|
]);
|
|
return (int)$sector['id'];
|
|
}
|
|
}
|
|
|
|
LogService::info('[SectorService] Aucun secteur ne contient ce point GPS', [
|
|
'operation_id' => $operationId,
|
|
'lat' => $lat,
|
|
'lng' => $lng,
|
|
'nb_sectors_tested' => count($sectors)
|
|
]);
|
|
|
|
return null;
|
|
|
|
} catch (\Exception $e) {
|
|
LogService::error('[SectorService] Erreur findSectorByGps', [
|
|
'error' => $e->getMessage(),
|
|
'operation_id' => $operationId,
|
|
'lat' => $lat,
|
|
'lng' => $lng
|
|
]);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Trouve le secteur pour une adresse (géocodage + recherche GPS)
|
|
*
|
|
* @param int $operationId ID de l'opération
|
|
* @param string $num Numéro de rue
|
|
* @param string $bis Complément
|
|
* @param string $rue Nom de la rue
|
|
* @param string $ville Nom de la ville
|
|
* @param string $cp Code postal
|
|
* @return array|null ['sector_id' => int, 'gps_lat' => float, 'gps_lng' => float] ou null
|
|
*/
|
|
public function findSectorByAddress(int $operationId, string $num, string $bis, string $rue, string $ville, string $cp): ?array
|
|
{
|
|
// Étape 1 : Géocoder l'adresse
|
|
$coords = $this->geocodeAddress($num, $bis, $rue, $ville, $cp);
|
|
|
|
if (!$coords) {
|
|
return null;
|
|
}
|
|
|
|
// Étape 2 : Chercher le secteur avec les coordonnées obtenues
|
|
$sectorId = $this->findSectorByGps($operationId, $coords['lat'], $coords['lng']);
|
|
|
|
if (!$sectorId) {
|
|
// Retourner quand même les coordonnées GPS trouvées (utiles pour mettre à jour le passage)
|
|
return [
|
|
'sector_id' => null,
|
|
'gps_lat' => $coords['lat'],
|
|
'gps_lng' => $coords['lng']
|
|
];
|
|
}
|
|
|
|
return [
|
|
'sector_id' => $sectorId,
|
|
'gps_lat' => $coords['lat'],
|
|
'gps_lng' => $coords['lng']
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Parse le format de polygone stocké en base (lat/lng#lat/lng#...)
|
|
*
|
|
* @param string $sectorString Format "lat/lng#lat/lng#..."
|
|
* @return array Array de ['lat' => float, 'lng' => float]
|
|
*/
|
|
private function parseSectorPolygon(string $sectorString): array
|
|
{
|
|
$polygon = [];
|
|
$points = explode('#', rtrim($sectorString, '#'));
|
|
|
|
foreach ($points as $point) {
|
|
if (!empty($point) && strpos($point, '/') !== false) {
|
|
list($lat, $lng) = explode('/', $point);
|
|
$polygon[] = [
|
|
'lat' => (float)$lat,
|
|
'lng' => (float)$lng
|
|
];
|
|
}
|
|
}
|
|
|
|
return $polygon;
|
|
}
|
|
|
|
/**
|
|
* Vérifie si un point est à l'intérieur d'un polygone
|
|
* Utilise l'algorithme de ray casting
|
|
*
|
|
* @param float $lat Latitude du point
|
|
* @param float $lng Longitude du point
|
|
* @param array $polygon Array de ['lat' => float, 'lng' => float]
|
|
* @return bool
|
|
*/
|
|
private function isPointInsidePolygon(float $lat, float $lng, array $polygon): bool
|
|
{
|
|
$x = $lat;
|
|
$y = $lng;
|
|
$inside = false;
|
|
$count = count($polygon);
|
|
|
|
for ($i = 0, $j = $count - 1; $i < $count; $j = $i++) {
|
|
$xi = $polygon[$i]['lat'];
|
|
$yi = $polygon[$i]['lng'];
|
|
$xj = $polygon[$j]['lat'];
|
|
$yj = $polygon[$j]['lng'];
|
|
|
|
$intersect = (($yi > $y) != ($yj > $y))
|
|
&& ($x < ($xj - $xi) * ($y - $yi) / ($yj - $yi) + $xi);
|
|
|
|
if ($intersect) {
|
|
$inside = !$inside;
|
|
}
|
|
}
|
|
|
|
return $inside;
|
|
}
|
|
}
|