feat: Version 3.6.2 - Correctifs tâches #17-20

- #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>
This commit is contained in:
2026-01-16 14:11:15 +01:00
parent 7b78037175
commit 232940b1eb
196 changed files with 8483 additions and 7966 deletions

View File

@@ -0,0 +1,484 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
require_once __DIR__ . '/../Services/LogService.php';
require_once __DIR__ . '/../Services/EventStatsService.php';
use PDO;
use Database;
use Response;
use Session;
use App\Services\LogService;
use App\Services\EventStatsService;
use Exception;
/**
* EventStatsController - Contrôleur des statistiques d'événements
*
* Endpoints pour consulter les stats agrégées et le détail des événements.
* Accès réservé aux Admin entité (role_id = 2) et Super-admin (role_id = 1).
*/
class EventStatsController
{
private PDO $db;
private EventStatsService $statsService;
/** @var array Rôles autorisés à consulter les stats */
private const ALLOWED_ROLES = [1, 2]; // Super-admin, Admin
public function __construct()
{
$this->db = Database::getInstance();
$this->statsService = new EventStatsService();
}
/**
* GET /api/events/stats/summary
* Récupère le résumé des stats pour une date
*
* Query params:
* - date: Date (YYYY-MM-DD), défaut = aujourd'hui
*/
public function summary(): void
{
try {
if (!$this->checkAccess()) {
return;
}
$entityId = $this->getEntityIdForQuery();
$date = $_GET['date'] ?? date('Y-m-d');
// Validation de la date
if (!$this->isValidDate($date)) {
Response::json([
'status' => 'error',
'message' => 'Format de date invalide (attendu: YYYY-MM-DD)',
], 400);
return;
}
$summary = $this->statsService->getSummary($entityId, $date);
$this->jsonWithCache([
'status' => 'success',
'data' => $summary,
], true);
} catch (Exception $e) {
LogService::error('Erreur lors de la récupération du résumé des stats', [
'error' => $e->getMessage(),
]);
Response::json([
'status' => 'error',
'message' => 'Erreur lors de la récupération des statistiques',
], 500);
}
}
/**
* GET /api/events/stats/daily
* Récupère les stats journalières sur une plage de dates
*
* Query params:
* - from: Date début (YYYY-MM-DD), requis
* - to: Date fin (YYYY-MM-DD), requis
* - events: Types d'événements (comma-separated), optionnel
*/
public function daily(): void
{
try {
if (!$this->checkAccess()) {
return;
}
$entityId = $this->getEntityIdForQuery();
$from = $_GET['from'] ?? null;
$to = $_GET['to'] ?? null;
$eventTypes = isset($_GET['events']) ? explode(',', $_GET['events']) : [];
// Validation des dates
if (!$from || !$to) {
Response::json([
'status' => 'error',
'message' => 'Les paramètres from et to sont requis',
], 400);
return;
}
if (!$this->isValidDate($from) || !$this->isValidDate($to)) {
Response::json([
'status' => 'error',
'message' => 'Format de date invalide (attendu: YYYY-MM-DD)',
], 400);
return;
}
if ($from > $to) {
Response::json([
'status' => 'error',
'message' => 'La date de début doit être antérieure à la date de fin',
], 400);
return;
}
// Limiter la plage à 90 jours
$daysDiff = (strtotime($to) - strtotime($from)) / 86400;
if ($daysDiff > 90) {
Response::json([
'status' => 'error',
'message' => 'La plage de dates ne peut pas dépasser 90 jours',
], 400);
return;
}
$daily = $this->statsService->getDaily($entityId, $from, $to, $eventTypes);
$this->jsonWithCache([
'status' => 'success',
'data' => [
'from' => $from,
'to' => $to,
'days' => $daily,
],
], true);
} catch (Exception $e) {
LogService::error('Erreur lors de la récupération des stats journalières', [
'error' => $e->getMessage(),
]);
Response::json([
'status' => 'error',
'message' => 'Erreur lors de la récupération des statistiques',
], 500);
}
}
/**
* GET /api/events/stats/weekly
* Récupère les stats hebdomadaires sur une plage de dates
*
* Query params:
* - from: Date début (YYYY-MM-DD), requis
* - to: Date fin (YYYY-MM-DD), requis
* - events: Types d'événements (comma-separated), optionnel
*/
public function weekly(): void
{
try {
if (!$this->checkAccess()) {
return;
}
$entityId = $this->getEntityIdForQuery();
$from = $_GET['from'] ?? null;
$to = $_GET['to'] ?? null;
$eventTypes = isset($_GET['events']) ? explode(',', $_GET['events']) : [];
// Validation des dates
if (!$from || !$to) {
Response::json([
'status' => 'error',
'message' => 'Les paramètres from et to sont requis',
], 400);
return;
}
if (!$this->isValidDate($from) || !$this->isValidDate($to)) {
Response::json([
'status' => 'error',
'message' => 'Format de date invalide (attendu: YYYY-MM-DD)',
], 400);
return;
}
// Limiter la plage à 1 an
$daysDiff = (strtotime($to) - strtotime($from)) / 86400;
if ($daysDiff > 365) {
Response::json([
'status' => 'error',
'message' => 'La plage de dates ne peut pas dépasser 1 an',
], 400);
return;
}
$weekly = $this->statsService->getWeekly($entityId, $from, $to, $eventTypes);
Response::json([
'status' => 'success',
'data' => [
'from' => $from,
'to' => $to,
'weeks' => $weekly,
],
]);
} catch (Exception $e) {
LogService::error('Erreur lors de la récupération des stats hebdomadaires', [
'error' => $e->getMessage(),
]);
Response::json([
'status' => 'error',
'message' => 'Erreur lors de la récupération des statistiques',
], 500);
}
}
/**
* GET /api/events/stats/monthly
* Récupère les stats mensuelles pour une année
*
* Query params:
* - year: Année (YYYY), défaut = année courante
* - events: Types d'événements (comma-separated), optionnel
*/
public function monthly(): void
{
try {
if (!$this->checkAccess()) {
return;
}
$entityId = $this->getEntityIdForQuery();
$year = isset($_GET['year']) ? (int) $_GET['year'] : (int) date('Y');
$eventTypes = isset($_GET['events']) ? explode(',', $_GET['events']) : [];
// Validation de l'année
if ($year < 2020 || $year > (int) date('Y') + 1) {
Response::json([
'status' => 'error',
'message' => 'Année invalide',
], 400);
return;
}
$monthly = $this->statsService->getMonthly($entityId, $year, $eventTypes);
Response::json([
'status' => 'success',
'data' => [
'year' => $year,
'months' => $monthly,
],
]);
} catch (Exception $e) {
LogService::error('Erreur lors de la récupération des stats mensuelles', [
'error' => $e->getMessage(),
]);
Response::json([
'status' => 'error',
'message' => 'Erreur lors de la récupération des statistiques',
], 500);
}
}
/**
* GET /api/events/stats/details
* Récupère le détail des événements (lecture JSONL paginée)
*
* Query params:
* - date: Date (YYYY-MM-DD), requis
* - event: Type d'événement, optionnel
* - limit: Nombre max (défaut 50, max 100)
* - offset: Décalage pour pagination (défaut 0)
*/
public function details(): void
{
try {
if (!$this->checkAccess()) {
return;
}
$entityId = $this->getEntityIdForQuery();
$date = $_GET['date'] ?? null;
$eventType = $_GET['event'] ?? null;
$limit = isset($_GET['limit']) ? min((int) $_GET['limit'], 100) : 50;
$offset = isset($_GET['offset']) ? max((int) $_GET['offset'], 0) : 0;
// Validation de la date
if (!$date) {
Response::json([
'status' => 'error',
'message' => 'Le paramètre date est requis',
], 400);
return;
}
if (!$this->isValidDate($date)) {
Response::json([
'status' => 'error',
'message' => 'Format de date invalide (attendu: YYYY-MM-DD)',
], 400);
return;
}
$details = $this->statsService->getDetails($entityId, $date, $eventType, $limit, $offset);
Response::json([
'status' => 'success',
'data' => $details,
]);
} catch (Exception $e) {
LogService::error('Erreur lors de la récupération du détail des événements', [
'error' => $e->getMessage(),
]);
Response::json([
'status' => 'error',
'message' => 'Erreur lors de la récupération des événements',
], 500);
}
}
/**
* GET /api/events/stats/types
* Récupère la liste des types d'événements disponibles
*/
public function types(): void
{
try {
if (!$this->checkAccess()) {
return;
}
$types = $this->statsService->getEventTypes();
Response::json([
'status' => 'success',
'data' => $types,
]);
} catch (Exception $e) {
LogService::error('Erreur lors de la récupération des types d\'événements', [
'error' => $e->getMessage(),
]);
Response::json([
'status' => 'error',
'message' => 'Erreur lors de la récupération des types',
], 500);
}
}
// ==================== MÉTHODES PRIVÉES ====================
/**
* Vérifie si l'utilisateur a accès aux stats
*/
private function checkAccess(): bool
{
$roleId = Session::getRole();
if (!in_array($roleId, self::ALLOWED_ROLES)) {
Response::json([
'status' => 'error',
'message' => 'Accès non autorisé. Rôle Admin ou Super-admin requis.',
], 403);
return false;
}
return true;
}
/**
* Détermine l'entity_id à utiliser pour la requête
* Super-admin (role_id = 1) peut voir toutes les entités (null)
* Admin (role_id = 2) voit uniquement son entité
*/
private function getEntityIdForQuery(): ?int
{
$roleId = Session::getRole();
// Super-admin : accès global
if ($roleId === 1) {
// Permettre de filtrer par entité si spécifié
if (isset($_GET['entity_id'])) {
return (int) $_GET['entity_id'];
}
return null; // Stats globales
}
// Admin : uniquement son entité
return Session::getEntityId();
}
/**
* Valide le format d'une date
*/
private function isValidDate(string $date): bool
{
$d = \DateTime::createFromFormat('Y-m-d', $date);
return $d && $d->format('Y-m-d') === $date;
}
/**
* Envoie une réponse JSON avec support ETag et compression gzip
*
* @param array $data Données à envoyer
* @param bool $useCache Activer le cache ETag
*/
private function jsonWithCache(array $data, bool $useCache = true): void
{
// Nettoyer tout buffer existant
while (ob_get_level() > 0) {
ob_end_clean();
}
// Encoder en JSON
$jsonResponse = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($jsonResponse === false) {
Response::json([
'status' => 'error',
'message' => 'Erreur d\'encodage de la réponse',
], 500);
return;
}
// Headers CORS
$origin = $_SERVER['HTTP_ORIGIN'] ?? '*';
header("Access-Control-Allow-Origin: $origin");
header('Access-Control-Allow-Credentials: true');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Origin, Content-Type, X-Auth-Token, Authorization, X-Requested-With');
header('Access-Control-Expose-Headers: Content-Length, ETag');
// Content-Type
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
// ETag pour le cache
if ($useCache) {
$etag = '"' . md5($jsonResponse) . '"';
header('ETag: ' . $etag);
header('Cache-Control: private, max-age=300'); // 5 minutes
// Vérifier If-None-Match
$ifNoneMatch = $_SERVER['HTTP_IF_NONE_MATCH'] ?? '';
if ($ifNoneMatch === $etag) {
http_response_code(304); // Not Modified
exit;
}
}
// Compression gzip si supportée
$supportsGzip = isset($_SERVER['HTTP_ACCEPT_ENCODING'])
&& strpos($_SERVER['HTTP_ACCEPT_ENCODING'], 'gzip') !== false;
if ($supportsGzip && strlen($jsonResponse) > 1024) {
$compressed = gzencode($jsonResponse, 6);
if ($compressed !== false) {
header('Content-Encoding: gzip');
header('Content-Length: ' . strlen($compressed));
http_response_code(200);
echo $compressed;
flush();
exit;
}
}
// Réponse non compressée
header('Content-Length: ' . strlen($jsonResponse));
http_response_code(200);
echo $jsonResponse;
flush();
exit;
}
}

View File

@@ -2086,18 +2086,38 @@ class LoginController {
], 201);
}
} catch (Exception $e) {
$this->db->rollBack();
LogService::log('Erreur lors de la création du compte GeoSector', [
// Vérifier si une transaction est active avant de faire rollback
if ($this->db->inTransaction()) {
$this->db->rollBack();
}
// Construire un message d'erreur détaillé pour le logging
$errorDetails = [
'level' => 'error',
'error' => $e->getMessage(),
'email' => $email,
'amicaleName' => $amicaleName,
'postalCode' => $postalCode
]);
'exception_class' => get_class($e),
'error_message' => $e->getMessage(),
'error_code' => $e->getCode(),
'file' => $e->getFile(),
'line' => $e->getLine(),
'email' => $email ?? 'non disponible',
'amicaleName' => $amicaleName ?? 'non disponible',
'postalCode' => $postalCode ?? 'non disponible',
'trace' => $e->getTraceAsString()
];
// Si c'est une PDOException, ajouter les infos SQL
if ($e instanceof PDOException) {
$errorDetails['pdo_error_info'] = $this->db->errorInfo();
}
LogService::log('Erreur lors de la création du compte GeoSector', $errorDetails);
// Retourner un message utilisateur clair (ne pas exposer les détails techniques)
$userMessage = 'Une erreur est survenue lors de la création du compte. Veuillez réessayer ou contacter le support.';
Response::json([
'status' => 'error',
'message' => $e->getMessage()
'message' => $userMessage
], 500);
return;
}

View File

@@ -8,6 +8,7 @@ require_once __DIR__ . '/../Services/LogService.php';
require_once __DIR__ . '/../Services/EventLogService.php';
require_once __DIR__ . '/../Services/ApiService.php';
require_once __DIR__ . '/../Services/ReceiptService.php';
require_once __DIR__ . '/../Services/SectorService.php';
use PDO;
use PDOException;
@@ -19,6 +20,7 @@ use Session;
use App\Services\LogService;
use App\Services\EventLogService;
use App\Services\ApiService;
use App\Services\SectorService;
use Exception;
use DateTime;
@@ -516,14 +518,26 @@ class PassageController {
}
// Récupérer ope_users.id pour l'utilisateur du passage
// $data['fk_user'] contient users.id, on doit le convertir en ope_users.id
// $data['fk_user'] peut contenir soit users.id soit ope_users.id
$passageUserId = (int)$data['fk_user'];
$stmtOpeUser = $this->db->prepare('
// Vérifier d'abord si c'est déjà un ope_users.id valide
$stmtCheckOpeUser = $this->db->prepare('
SELECT id FROM ope_users
WHERE fk_user = ? AND fk_operation = ?
WHERE id = ? AND fk_operation = ?
');
$stmtOpeUser->execute([$passageUserId, $operationId]);
$opeUserId = $stmtOpeUser->fetchColumn();
$stmtCheckOpeUser->execute([$passageUserId, $operationId]);
$opeUserId = $stmtCheckOpeUser->fetchColumn();
if (!$opeUserId) {
// Ce n'est pas un ope_users.id, essayer de le convertir depuis users.id
$stmtOpeUser = $this->db->prepare('
SELECT id FROM ope_users
WHERE fk_user = ? AND fk_operation = ?
');
$stmtOpeUser->execute([$passageUserId, $operationId]);
$opeUserId = $stmtOpeUser->fetchColumn();
}
if (!$opeUserId) {
Response::json([
@@ -533,6 +547,88 @@ class PassageController {
return;
}
// Détermination automatique du secteur
$sectorId = null;
$gpsLat = isset($data['gps_lat']) && $data['gps_lat'] !== '' ? (float)$data['gps_lat'] : null;
$gpsLng = isset($data['gps_lng']) && $data['gps_lng'] !== '' ? (float)$data['gps_lng'] : null;
// 1. Si fk_sector > 0 fourni → l'utiliser directement
if (isset($data['fk_sector']) && (int)$data['fk_sector'] > 0) {
$sectorId = (int)$data['fk_sector'];
LogService::info('[PassageController] Secteur fourni par le client', [
'sector_id' => $sectorId
]);
}
// 2. Si pas de secteur et GPS valide (différent de 0.0) → recherche par GPS
if ($sectorId === null && $gpsLat !== null && $gpsLng !== null && ($gpsLat != 0.0 || $gpsLng != 0.0)) {
$sectorService = new SectorService();
$sectorId = $sectorService->findSectorByGps($operationId, $gpsLat, $gpsLng);
LogService::info('[PassageController] Recherche secteur par GPS', [
'operation_id' => $operationId,
'lat' => $gpsLat,
'lng' => $gpsLng,
'sector_found' => $sectorId
]);
}
// 3. Si toujours pas de secteur et adresse fournie → géocodage + recherche
if ($sectorId === null && !empty($data['numero']) && !empty($data['rue']) && !empty($data['ville'])) {
// Récupérer le code postal de l'entité pour la vérification du département
$stmtEntite = $this->db->prepare('
SELECT e.code_postal FROM entites e
INNER JOIN operations o ON o.fk_entite = e.id
WHERE o.id = ?
');
$stmtEntite->execute([$operationId]);
$entiteCp = $stmtEntite->fetchColumn() ?: '';
$sectorService = new SectorService();
$result = $sectorService->findSectorByAddress(
$operationId,
trim($data['numero']),
$data['rue_bis'] ?? '',
trim($data['rue']),
trim($data['ville']),
$entiteCp
);
if ($result) {
$sectorId = $result['sector_id'];
// Mettre à jour les coordonnées GPS si le géocodage les a trouvées
if ($result['gps_lat'] && $result['gps_lng']) {
$gpsLat = $result['gps_lat'];
$gpsLng = $result['gps_lng'];
}
LogService::info('[PassageController] Recherche secteur par adresse', [
'operation_id' => $operationId,
'adresse' => $data['numero'] . ' ' . $data['rue'] . ' ' . $data['ville'],
'sector_found' => $sectorId,
'gps_geocoded' => ($result['gps_lat'] && $result['gps_lng'])
]);
}
}
// 4. Fallback : si toujours pas de secteur, prendre le 1er secteur de l'opération
if ($sectorId === null) {
$stmtFirstSector = $this->db->prepare('
SELECT id FROM ope_sectors
WHERE fk_operation = ? AND chk_active = 1
ORDER BY id ASC
LIMIT 1
');
$stmtFirstSector->execute([$operationId]);
$firstSectorId = $stmtFirstSector->fetchColumn();
if ($firstSectorId) {
$sectorId = (int)$firstSectorId;
LogService::info('[PassageController] Fallback: premier secteur de l\'opération', [
'operation_id' => $operationId,
'sector_id' => $sectorId
]);
}
}
// Chiffrement des données sensibles
$encryptedName = '';
if (isset($data['name']) && !empty(trim($data['name']))) {
@@ -549,7 +645,7 @@ class PassageController {
// Préparation des données pour l'insertion
$insertData = [
'fk_operation' => $operationId,
'fk_sector' => isset($data['fk_sector']) ? (int)$data['fk_sector'] : 0,
'fk_sector' => $sectorId, // Peut être NULL si aucun secteur trouvé
'fk_user' => $opeUserId,
'fk_adresse' => $data['fk_adresse'] ?? '',
'passed_at' => isset($data['passed_at']) ? $data['passed_at'] : null,
@@ -562,8 +658,8 @@ class PassageController {
'appt' => $data['appt'] ?? '',
'niveau' => $data['niveau'] ?? '',
'residence' => $data['residence'] ?? '',
'gps_lat' => $data['gps_lat'] ?? '',
'gps_lng' => $data['gps_lng'] ?? '',
'gps_lat' => $gpsLat ?? '',
'gps_lng' => $gpsLng ?? '',
'encrypted_name' => $encryptedName,
'montant' => isset($data['montant']) ? (float)$data['montant'] : 0.00,
'fk_type_reglement' => isset($data['fk_type_reglement']) ? (int)$data['fk_type_reglement'] : 1,
@@ -596,7 +692,7 @@ class PassageController {
EventLogService::logPassageCreated(
(int)$passageId,
$insertData['fk_operation'],
$insertData['fk_sector'],
$insertData['fk_sector'] ?? 0, // 0 si secteur non trouvé
$insertData['montant'],
(string)$insertData['fk_type_reglement']
);

View File

@@ -12,17 +12,15 @@ use App\Services\DepartmentBoundaryService;
require_once __DIR__ . '/../Services/EventLogService.php';
require_once __DIR__ . '/../Services/ApiService.php';
class SectorController
class SectorController
{
private \PDO $db;
private LogService $logService;
private AddressService $addressService;
private DepartmentBoundaryService $boundaryService;
public function __construct()
{
$this->db = Database::getInstance();
$this->logService = new LogService();
$this->addressService = new AddressService();
$this->boundaryService = new DepartmentBoundaryService();
}
@@ -72,7 +70,7 @@ class SectorController
Response::json(['status' => 'success', 'data' => $sectors]);
} catch (\Exception $e) {
$this->logService->error('Erreur lors de la récupération des secteurs', [
LogService::error('Erreur lors de la récupération des secteurs', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
@@ -152,14 +150,14 @@ class SectorController
$departmentsTouched = $this->boundaryService->getDepartmentsForSector($coordinates);
if (empty($departmentsTouched)) {
$this->logService->warning('Aucun département trouvé pour le secteur', [
LogService::warning('Aucun département trouvé pour le secteur', [
'libelle' => $data['libelle'],
'entity_id' => $entityId,
'entity_dept' => $departement
]);
}
} catch (\Exception $e) {
$this->logService->warning('Impossible de vérifier les limites départementales', [
LogService::warning('Impossible de vérifier les limites départementales', [
'error' => $e->getMessage(),
'libelle' => $data['libelle']
]);
@@ -169,7 +167,7 @@ class SectorController
try {
$addressCount = $this->addressService->countAddressesInPolygon($coordinates, $entityId);
} catch (\Exception $e) {
$this->logService->warning('Impossible de récupérer les adresses du secteur', [
LogService::warning('Impossible de récupérer les adresses du secteur', [
'error' => $e->getMessage(),
'libelle' => $data['libelle'],
'entity_id' => $entityId
@@ -208,7 +206,7 @@ class SectorController
$opeUserId = $stmtOpeUser->fetchColumn();
if (!$opeUserId) {
$this->logService->warning('ope_users.id non trouvé pour cette opération', [
LogService::warning('ope_users.id non trouvé pour cette opération', [
'ope_users_id' => $memberId,
'operation_id' => $operationId
]);
@@ -275,7 +273,7 @@ class SectorController
}
} catch (\Exception $e) {
$this->logService->warning('Erreur lors de la récupération des passages orphelins', [
LogService::warning('Erreur lors de la récupération des passages orphelins', [
'sector_id' => $sectorId,
'error' => $e->getMessage()
]);
@@ -335,7 +333,7 @@ class SectorController
$firstOpeUserId = $stmtFirstOpeUser->fetchColumn();
if (!$firstOpeUserId) {
$this->logService->warning('Premier ope_users.id non trouvé pour cette opération', [
LogService::warning('Premier ope_users.id non trouvé pour cette opération', [
'ope_users_id' => $users[0],
'operation_id' => $operationId
]);
@@ -401,7 +399,7 @@ class SectorController
// Log pour vérifier l'uniformisation GPS (surtout pour immeubles)
if ($fkHabitat == 2 && $nbLog > 1) {
$this->logService->info('[SectorController] Création passages immeuble avec GPS uniformisés', [
LogService::info('[SectorController] Création passages immeuble avec GPS uniformisés', [
'address_id' => $address['id'],
'nb_passages' => $nbLog,
'gps_lat' => $gpsLat,
@@ -410,7 +408,7 @@ class SectorController
]);
}
} catch (\Exception $e) {
$this->logService->warning('Erreur lors de la création d\'un passage', [
LogService::warning('Erreur lors de la création d\'un passage', [
'address_id' => $address['id'],
'error' => $e->getMessage()
]);
@@ -421,7 +419,7 @@ class SectorController
}
} catch (\Exception $e) {
// En cas d'erreur avec les adresses, on ne bloque pas la création du secteur
$this->logService->error('Erreur lors du stockage des adresses du secteur', [
LogService::error('Erreur lors du stockage des adresses du secteur', [
'sector_id' => $sectorId,
'error' => $e->getMessage(),
'entity_id' => $entityId
@@ -525,7 +523,7 @@ class SectorController
$responseData['users_sectors'][] = $userData;
}
$this->logService->info('Secteur créé', [
LogService::info('Secteur créé', [
'sector_id' => $sectorId,
'libelle' => $sectorData['libelle'],
'entity_id' => $entityId,
@@ -567,7 +565,7 @@ class SectorController
if ($this->db->inTransaction()) {
$this->db->rollBack();
}
$this->logService->error('Erreur lors de la création du secteur', [
LogService::error('Erreur lors de la création du secteur', [
'error' => $e->getMessage(),
'data' => $data ?? null
]);
@@ -634,7 +632,7 @@ class SectorController
// Gestion des membres (reçus comme 'users' depuis Flutter)
if (isset($data['users'])) {
$this->logService->info('[UPDATE USERS] Début modification des membres', [
LogService::info('[UPDATE USERS] Début modification des membres', [
'sector_id' => $id,
'users_demandes' => $data['users'],
'nb_users' => count($data['users'])
@@ -642,27 +640,27 @@ class SectorController
// Récupérer l'opération du secteur pour l'INSERT
$opQuery = "SELECT fk_operation FROM ope_sectors WHERE id = :sector_id";
$this->logService->info('[UPDATE USERS] SQL - Récupération fk_operation', [
LogService::info('[UPDATE USERS] SQL - Récupération fk_operation', [
'query' => $opQuery,
'params' => ['sector_id' => $id]
]);
$opStmt = $this->db->prepare($opQuery);
$opStmt->execute(['sector_id' => $id]);
$operationId = $opStmt->fetch()['fk_operation'];
$this->logService->info('[UPDATE USERS] fk_operation récupéré', [
LogService::info('[UPDATE USERS] fk_operation récupéré', [
'operation_id' => $operationId
]);
// Supprimer les affectations existantes
$deleteQuery = "DELETE FROM ope_users_sectors WHERE fk_sector = :sector_id";
$this->logService->info('[UPDATE USERS] SQL - Suppression des anciens membres', [
LogService::info('[UPDATE USERS] SQL - Suppression des anciens membres', [
'query' => $deleteQuery,
'params' => ['sector_id' => $id]
]);
$deleteStmt = $this->db->prepare($deleteQuery);
$deleteStmt->execute(['sector_id' => $id]);
$deletedCount = $deleteStmt->rowCount();
$this->logService->info('[UPDATE USERS] Membres supprimés', [
LogService::info('[UPDATE USERS] Membres supprimés', [
'nb_deleted' => $deletedCount
]);
@@ -670,7 +668,7 @@ class SectorController
if (!empty($data['users'])) {
$insertQuery = "INSERT INTO ope_users_sectors (fk_operation, fk_user, fk_sector, created_at, updated_at, fk_user_creat, chk_active)
VALUES (:operation_id, :user_id, :sector_id, NOW(), NOW(), :user_creat, 1)";
$this->logService->info('[UPDATE USERS] SQL - Requête INSERT préparée', [
LogService::info('[UPDATE USERS] SQL - Requête INSERT préparée', [
'query' => $insertQuery
]);
$insertStmt = $this->db->prepare($insertQuery);
@@ -689,7 +687,7 @@ class SectorController
$opeUserId = $stmtOpeUser->fetchColumn();
if (!$opeUserId) {
$this->logService->warning('[UPDATE USERS] ope_users.id non trouvé pour cette opération', [
LogService::warning('[UPDATE USERS] ope_users.id non trouvé pour cette opération', [
'ope_users_id' => $memberId,
'operation_id' => $operationId
]);
@@ -703,17 +701,17 @@ class SectorController
'sector_id' => $id,
'user_creat' => $_SESSION['user_id'] ?? null
];
$this->logService->info('[UPDATE USERS] SQL - INSERT user', [
LogService::info('[UPDATE USERS] SQL - INSERT user', [
'params' => $params
]);
$insertStmt->execute($params);
$insertedUsers[] = $memberId;
$this->logService->info('[UPDATE USERS] User inséré avec succès', [
LogService::info('[UPDATE USERS] User inséré avec succès', [
'user_id' => $memberId
]);
} catch (\PDOException $e) {
$failedUsers[] = $memberId;
$this->logService->warning('[UPDATE USERS] ERREUR insertion user', [
LogService::warning('[UPDATE USERS] ERREUR insertion user', [
'sector_id' => $id,
'user_id' => $memberId,
'error' => $e->getMessage(),
@@ -722,7 +720,7 @@ class SectorController
}
}
$this->logService->info('[UPDATE USERS] Résultat des insertions', [
LogService::info('[UPDATE USERS] Résultat des insertions', [
'users_demandes' => $data['users'],
'users_inseres' => $insertedUsers,
'users_echoues' => $failedUsers,
@@ -744,7 +742,7 @@ class SectorController
$chkAdressesChange = $data['chk_adresses_change'] ?? 1;
if (isset($data['sector']) && $chkAdressesChange == 0) {
$this->logService->info('[UPDATE] Modification secteur sans recalcul adresses/passages', [
LogService::info('[UPDATE] Modification secteur sans recalcul adresses/passages', [
'sector_id' => $id,
'chk_adresses_change' => $chkAdressesChange
]);
@@ -770,7 +768,7 @@ class SectorController
}
// Récupérer et stocker les nouvelles adresses
$this->logService->info('[UPDATE] Récupération des adresses', [
LogService::info('[UPDATE] Récupération des adresses', [
'sector_id' => $id,
'entity_id' => $entityId,
'nb_points' => count($coordinates)
@@ -781,7 +779,7 @@ class SectorController
// Enrichir les adresses avec les données bâtiments
$addresses = $this->addressService->enrichAddressesWithBuildings($addresses, $entityId);
$this->logService->info('[UPDATE] Adresses récupérées', [
LogService::info('[UPDATE] Adresses récupérées', [
'sector_id' => $id,
'nb_addresses' => count($addresses)
]);
@@ -815,12 +813,12 @@ class SectorController
]);
}
$this->logService->info('[UPDATE] Adresses stockées dans sectors_adresses', [
LogService::info('[UPDATE] Adresses stockées dans sectors_adresses', [
'sector_id' => $id,
'nb_stored' => count($addresses)
]);
} else {
$this->logService->warning('[UPDATE] Aucune adresse trouvée pour le secteur', [
LogService::warning('[UPDATE] Aucune adresse trouvée pour le secteur', [
'sector_id' => $id,
'entity_id' => $entityId
]);
@@ -828,19 +826,19 @@ class SectorController
// Vérifier si c'est un problème de connexion à la base d'adresses
if (!$this->addressService->isConnected()) {
$this->logService->warning('[UPDATE] Base d\'adresses non accessible - passages créés sans adresses', [
LogService::warning('[UPDATE] Base d\'adresses non accessible - passages créés sans adresses', [
'sector_id' => $id
]);
}
} catch (\Exception $e) {
$this->logService->error('[UPDATE] Erreur lors de la mise à jour des adresses du secteur', [
LogService::error('[UPDATE] Erreur lors de la mise à jour des adresses du secteur', [
'sector_id' => $id,
'error' => $e->getMessage()
]);
}
// Maintenant que les adresses sont mises à jour, traiter les passages
$this->logService->info('[UPDATE] Début mise à jour des passages', ['sector_id' => $id]);
LogService::info('[UPDATE] Début mise à jour des passages', ['sector_id' => $id]);
$passageCounters = $this->updatePassagesForSector($id, $data['sector']);
}
@@ -934,7 +932,7 @@ class SectorController
WHERE ous.fk_sector = :sector_id
ORDER BY u.id";
$this->logService->info('[UPDATE USERS] SQL - Récupération finale des users', [
LogService::info('[UPDATE USERS] SQL - Récupération finale des users', [
'query' => $usersQuery,
'params' => ['sector_id' => $id]
]);
@@ -944,7 +942,7 @@ class SectorController
$usersSectors = $usersStmt->fetchAll(\PDO::FETCH_ASSOC);
$userIds = array_column($usersSectors, 'id');
$this->logService->info('[UPDATE USERS] Users récupérés après commit', [
LogService::info('[UPDATE USERS] Users récupérés après commit', [
'sector_id' => $id,
'users_ids' => $userIds,
'nb_users' => count($userIds),
@@ -971,7 +969,7 @@ class SectorController
$usersDecrypted[] = $userData;
}
$this->logService->info('Secteur modifié', [
LogService::info('Secteur modifié', [
'sector_id' => $id,
'updates' => array_keys($data),
'passage_counters' => $passageCounters,
@@ -999,7 +997,7 @@ class SectorController
if ($this->db->inTransaction()) {
$this->db->rollBack();
}
$this->logService->error('Erreur lors de la modification du secteur', [
LogService::error('Erreur lors de la modification du secteur', [
'sector_id' => $id,
'error' => $e->getMessage()
]);
@@ -1065,7 +1063,7 @@ class SectorController
]);
} catch (\Exception $e) {
$this->logService->error('Erreur lors de la récupération des adresses du secteur', [
LogService::error('Erreur lors de la récupération des adresses du secteur', [
'sector_id' => $id,
'error' => $e->getMessage()
]);
@@ -1198,7 +1196,7 @@ class SectorController
$passagesDecrypted[] = $passage;
}
$this->logService->info('Secteur supprimé', [
LogService::info('Secteur supprimé', [
'sector_id' => $id,
'libelle' => $sector['libelle'],
'passages_deleted' => $passagesToDelete,
@@ -1216,7 +1214,7 @@ class SectorController
} catch (\Exception $e) {
$this->db->rollBack();
$this->logService->error('Erreur lors de la suppression du secteur', [
LogService::error('Erreur lors de la suppression du secteur', [
'sector_id' => $id,
'error' => $e->getMessage()
]);
@@ -1238,7 +1236,7 @@ class SectorController
]);
} catch (\Exception $e) {
$this->logService->error('Erreur lors de la vérification des contours départementaux', [
LogService::error('Erreur lors de la vérification des contours départementaux', [
'error' => $e->getMessage()
]);
Response::json(['status' => 'error', 'message' => 'Erreur lors de la vérification'], 500);
@@ -1298,7 +1296,7 @@ class SectorController
]);
} catch (\Exception $e) {
$this->logService->error('Erreur lors de la vérification des limites', [
LogService::error('Erreur lors de la vérification des limites', [
'error' => $e->getMessage()
]);
Response::json(['status' => 'error', 'message' => 'Erreur lors de la vérification'], 500);
@@ -1422,7 +1420,7 @@ class SectorController
$addressesStmt->execute(['sector_id' => $sectorId]);
$addresses = $addressesStmt->fetchAll();
$this->logService->info('[updatePassagesForSector] Adresses dans sectors_adresses', [
LogService::info('[updatePassagesForSector] Adresses dans sectors_adresses', [
'sector_id' => $sectorId,
'nb_addresses' => count($addresses)
]);
@@ -1435,7 +1433,7 @@ class SectorController
$firstUserId = $firstUser ? $firstUser['fk_user'] : null;
if ($firstUserId && !empty($addresses)) {
$this->logService->info('[updatePassagesForSector] Traitement des passages', [
LogService::info('[updatePassagesForSector] Traitement des passages', [
'user_id' => $firstUserId,
'nb_addresses' => count($addresses)
]);
@@ -1594,7 +1592,7 @@ class SectorController
$insertStmt->execute($insertParams);
$counters['passages_created'] = count($toInsert);
} catch (\Exception $e) {
$this->logService->error('Erreur lors de l\'insertion multiple des passages', [
LogService::error('Erreur lors de l\'insertion multiple des passages', [
'sector_id' => $sectorId,
'error' => $e->getMessage()
]);
@@ -1658,12 +1656,12 @@ class SectorController
$counters['passages_updated'] = count($toUpdate);
// Log pour vérifier l'uniformisation GPS (surtout pour immeubles)
$this->logService->info('[updatePassagesForSector] Passages mis à jour avec GPS uniformisés', [
LogService::info('[updatePassagesForSector] Passages mis à jour avec GPS uniformisés', [
'nb_updated' => count($toUpdate),
'sector_id' => $sectorId
]);
} catch (\Exception $e) {
$this->logService->error('Erreur lors de la mise à jour multiple des passages', [
LogService::error('Erreur lors de la mise à jour multiple des passages', [
'sector_id' => $sectorId,
'error' => $e->getMessage()
]);
@@ -1680,7 +1678,7 @@ class SectorController
$deleteStmt->execute($toDelete);
$counters['passages_deleted'] += count($toDelete);
} catch (\Exception $e) {
$this->logService->error('Erreur lors de la suppression multiple des passages', [
LogService::error('Erreur lors de la suppression multiple des passages', [
'sector_id' => $sectorId,
'error' => $e->getMessage()
]);
@@ -1688,7 +1686,7 @@ class SectorController
}
} else {
$this->logService->warning('[updatePassagesForSector] Pas de création de passages', [
LogService::warning('[updatePassagesForSector] Pas de création de passages', [
'reason' => !$firstUserId ? 'Pas d\'utilisateur affecté' : 'Pas d\'adresses',
'first_user_id' => $firstUserId,
'nb_addresses' => count($addresses)
@@ -1697,14 +1695,14 @@ class SectorController
// Retourner les compteurs détaillés
$this->logService->info('[updatePassagesForSector] Fin traitement', [
LogService::info('[updatePassagesForSector] Fin traitement', [
'sector_id' => $sectorId,
'counters' => $counters
]);
return $counters;
} catch (\Exception $e) {
$this->logService->error('Erreur lors de la mise à jour des passages', [
LogService::error('Erreur lors de la mise à jour des passages', [
'sector_id' => $sectorId,
'error' => $e->getMessage()
]);

View File

@@ -196,7 +196,8 @@ class StripeController extends Controller {
SELECT p.*, o.fk_entite, o.id as operation_id
FROM ope_pass p
JOIN operations o ON p.fk_operation = o.id
WHERE p.id = ? AND p.fk_user = ?
JOIN ope_users ou ON p.fk_user = ou.id
WHERE p.id = ? AND ou.fk_user = ?
');
$stmt->execute([$passageId, Session::getUserId()]);
$passage = $stmt->fetch();
@@ -468,71 +469,7 @@ class StripeController extends Controller {
$this->sendError('Erreur: ' . $e->getMessage());
}
}
/**
* POST /api/stripe/devices/check-tap-to-pay
* Vérifier la compatibilité Tap to Pay d'un appareil
*/
public function checkTapToPayCapability(): void {
try {
$data = $this->getJsonInput();
$platform = $data['platform'] ?? '';
if ($platform === 'ios') {
// Pour iOS, on vérifie côté client (iPhone XS+ avec iOS 16.4+)
$this->sendSuccess([
'message' => 'Vérification iOS à faire côté client',
'requirements' => 'iPhone XS ou plus récent avec iOS 16.4+',
'details' => 'iOS 16.4 minimum requis pour le support PIN complet'
]);
return;
}
if ($platform === 'android') {
$manufacturer = $data['manufacturer'] ?? '';
$model = $data['model'] ?? '';
if (!$manufacturer || !$model) {
$this->sendError('Manufacturer et model requis pour Android', 400);
return;
}
$result = $this->stripeService->checkAndroidTapToPayCompatibility($manufacturer, $model);
if ($result['success']) {
$this->sendSuccess($result);
} else {
$this->sendError($result['message'], 400);
}
} else {
$this->sendError('Platform doit être ios ou android', 400);
}
} catch (Exception $e) {
$this->sendError('Erreur: ' . $e->getMessage());
}
}
/**
* GET /api/stripe/devices/certified-android
* Récupérer la liste des appareils Android certifiés
*/
public function getCertifiedAndroidDevices(): void {
try {
$result = $this->stripeService->getCertifiedAndroidDevices();
if ($result['success']) {
$this->sendSuccess(['devices' => $result['devices']]);
} else {
$this->sendError($result['message'], 400);
}
} catch (Exception $e) {
$this->sendError('Erreur: ' . $e->getMessage());
}
}
/**
* GET /api/stripe/config
* Récupérer la configuration publique Stripe
@@ -784,4 +721,117 @@ class StripeController extends Controller {
$this->sendError('Erreur lors de la création de la location: ' . $e->getMessage());
}
}
/**
* POST /api/stripe/terminal/connection-token
* Créer un Connection Token pour Stripe Terminal/Tap to Pay
* Requis par le SDK Stripe Terminal pour se connecter aux readers
*/
public function createConnectionToken(): void {
try {
$this->requireAuth();
$data = $this->getJsonInput();
$entiteId = $data['amicale_id'] ?? Session::getEntityId();
if (!$entiteId) {
$this->sendError('ID entité requis', 400);
return;
}
// Vérifier les droits sur cette entité
$userRole = Session::getRole() ?? 0;
if (Session::getEntityId() != $entiteId && $userRole < 3) {
$this->sendError('Non autorisé pour cette entité', 403);
return;
}
$result = $this->stripeService->createConnectionToken($entiteId);
if ($result['success']) {
$this->sendSuccess([
'secret' => $result['secret']
]);
} else {
$this->sendError($result['message'], 400);
}
} catch (Exception $e) {
$this->sendError('Erreur lors de la création du connection token: ' . $e->getMessage());
}
}
/**
* POST /api/stripe/payments/cancel
* Annuler un PaymentIntent Stripe
*
* Payload:
* {
* "payment_intent_id": "pi_3SWvho2378xpV4Rn1J43Ks7M"
* }
*/
public function cancelPayment(): void {
try {
$this->requireAuth();
$data = $this->getJsonInput();
// Validation
if (!isset($data['payment_intent_id'])) {
$this->sendError('payment_intent_id requis', 400);
return;
}
$paymentIntentId = $data['payment_intent_id'];
// Vérifier que le passage existe et appartient à l'utilisateur
$stmt = $this->db->prepare('
SELECT p.*, o.fk_entite, ou.fk_user as ope_user_id
FROM ope_pass p
JOIN operations o ON p.fk_operation = o.id
JOIN ope_users ou ON p.fk_user = ou.id
WHERE p.stripe_payment_id = ?
');
$stmt->execute([$paymentIntentId]);
$passage = $stmt->fetch();
if (!$passage) {
$this->sendError('Paiement non trouvé', 404);
return;
}
// Vérifier les droits
$userId = Session::getUserId();
$userEntityId = Session::getEntityId();
if ($passage['ope_user_id'] != $userId && $passage['fk_entite'] != $userEntityId) {
$this->sendError('Non autorisé', 403);
return;
}
// Annuler le PaymentIntent via StripeService
$result = $this->stripeService->cancelPaymentIntent($paymentIntentId);
if ($result['success']) {
// Retirer le stripe_payment_id du passage
$stmt = $this->db->prepare('
UPDATE ope_pass
SET stripe_payment_id = NULL, updated_at = NOW()
WHERE id = ?
');
$stmt->execute([$passage['id']]);
$this->sendSuccess([
'status' => 'canceled',
'payment_intent_id' => $paymentIntentId,
'passage_id' => $passage['id']
]);
} else {
$this->sendError($result['message'], 400);
}
} catch (Exception $e) {
$this->sendError('Erreur: ' . $e->getMessage());
}
}
}

View File

@@ -202,85 +202,46 @@ class StripeWebhookController extends Controller {
* Gérer un paiement réussi
*/
private function handlePaymentIntentSucceeded($paymentIntent): void {
// Mettre à jour le statut en base
$stmt = $this->db->prepare(
"UPDATE stripe_payment_intents
SET status = :status, updated_at = NOW()
WHERE stripe_payment_intent_id = :pi_id"
);
$stmt->execute([
'status' => 'succeeded',
'pi_id' => $paymentIntent->id
]);
// Enregistrer dans l'historique
$stmt = $this->db->prepare(
"SELECT id FROM stripe_payment_intents WHERE stripe_payment_intent_id = :pi_id"
);
// Pour Tap to Pay, le paiement est instantané et déjà enregistré dans ope_pass
// Ce webhook sert principalement pour les Payment Links (QR Code) asynchrones
// Vérifier si le passage existe et mettre à jour si nécessaire
$stmt = $this->db->prepare("
SELECT id FROM ope_pass
WHERE stripe_payment_id = :pi_id
");
$stmt->execute(['pi_id' => $paymentIntent->id]);
$localPayment = $stmt->fetch();
if ($localPayment) {
$stmt = $this->db->prepare(
"INSERT INTO stripe_payment_history
(fk_payment_intent, event_type, event_data, created_at)
VALUES (:fk_pi, 'succeeded', :data, NOW())"
);
$stmt->execute([
'fk_pi' => $localPayment['id'],
'data' => json_encode([
'amount' => $paymentIntent->amount,
'currency' => $paymentIntent->currency,
'payment_method' => $paymentIntent->payment_method,
'charges' => $paymentIntent->charges->data
])
]);
$passage = $stmt->fetch();
if ($passage) {
error_log("Payment succeeded: {$paymentIntent->id} for {$paymentIntent->amount} {$paymentIntent->currency} - Passage {$passage['id']}");
// TODO: Envoyer un reçu par email
// TODO: Mettre à jour les statistiques en temps réel
} else {
error_log("Payment succeeded: {$paymentIntent->id} but no passage found in ope_pass");
}
// TODO: Envoyer un reçu par email
// TODO: Mettre à jour les statistiques en temps réel
error_log("Payment succeeded: {$paymentIntent->id} for {$paymentIntent->amount} {$paymentIntent->currency}");
}
/**
* Gérer un paiement échoué
*/
private function handlePaymentIntentFailed($paymentIntent): void {
// Mettre à jour le statut
$stmt = $this->db->prepare(
"UPDATE stripe_payment_intents
SET status = :status, updated_at = NOW()
WHERE stripe_payment_intent_id = :pi_id"
);
$stmt->execute([
'status' => 'failed',
'pi_id' => $paymentIntent->id
]);
// Enregistrer dans l'historique avec la raison de l'échec
$stmt = $this->db->prepare(
"SELECT id FROM stripe_payment_intents WHERE stripe_payment_intent_id = :pi_id"
);
// Vérifier si le passage existe
$stmt = $this->db->prepare("
SELECT id FROM ope_pass
WHERE stripe_payment_id = :pi_id
");
$stmt->execute(['pi_id' => $paymentIntent->id]);
$localPayment = $stmt->fetch();
if ($localPayment) {
$stmt = $this->db->prepare(
"INSERT INTO stripe_payment_history
(fk_payment_intent, event_type, event_data, created_at)
VALUES (:fk_pi, 'failed', :data, NOW())"
);
$stmt->execute([
'fk_pi' => $localPayment['id'],
'data' => json_encode([
'error' => $paymentIntent->last_payment_error,
'cancellation_reason' => $paymentIntent->cancellation_reason
])
]);
$passage = $stmt->fetch();
if ($passage) {
// Optionnel : Marquer le passage comme échec ou supprimer le stripe_payment_id
// Pour l'instant on log seulement
error_log("Payment failed: {$paymentIntent->id} for passage {$passage['id']}, reason: " . json_encode($paymentIntent->last_payment_error));
} else {
error_log("Payment failed: {$paymentIntent->id}, reason: " . json_encode($paymentIntent->last_payment_error));
}
error_log("Payment failed: {$paymentIntent->id}, reason: " . json_encode($paymentIntent->last_payment_error));
}
/**
@@ -358,17 +319,8 @@ class StripeWebhookController extends Controller {
* Gérer une action réussie sur un Terminal reader
*/
private function handleTerminalReaderActionSucceeded($reader): void {
// Mettre à jour le statut du reader
$stmt = $this->db->prepare(
"UPDATE stripe_terminal_readers
SET status = :status, last_seen_at = NOW()
WHERE stripe_reader_id = :reader_id"
);
$stmt->execute([
'status' => 'online',
'reader_id' => $reader->id
]);
// Note: Pour Tap to Pay, il n'y a pas de readers physiques
// Ce webhook concerne uniquement les terminaux externes (non utilisés pour l'instant)
error_log("Terminal reader action succeeded: {$reader->id}");
}
@@ -376,17 +328,8 @@ class StripeWebhookController extends Controller {
* Gérer une action échouée sur un Terminal reader
*/
private function handleTerminalReaderActionFailed($reader): void {
// Mettre à jour le statut du reader
$stmt = $this->db->prepare(
"UPDATE stripe_terminal_readers
SET status = :status, last_seen_at = NOW()
WHERE stripe_reader_id = :reader_id"
);
$stmt->execute([
'status' => 'error',
'reader_id' => $reader->id
]);
// Note: Pour Tap to Pay, il n'y a pas de readers physiques
// Ce webhook concerne uniquement les terminaux externes (non utilisés pour l'instant)
error_log("Terminal reader action failed: {$reader->id}");
}
}

View File

@@ -135,13 +135,13 @@ class Router {
$this->get('stripe/accounts/:entityId/status', ['StripeController', 'getAccountStatus']);
$this->post('stripe/accounts/:accountId/onboarding-link', ['StripeController', 'createOnboardingLink']);
// Tap to Pay - Vérification compatibilité et configuration
$this->post('stripe/devices/check-tap-to-pay', ['StripeController', 'checkTapToPayCapability']);
$this->get('stripe/devices/certified-android', ['StripeController', 'getCertifiedAndroidDevices']);
// Tap to Pay - Configuration
$this->post('stripe/locations', ['StripeController', 'createLocation']);
$this->post('stripe/terminal/connection-token', ['StripeController', 'createConnectionToken']);
// Paiements
$this->post('stripe/payments/create-intent', ['StripeController', 'createPaymentIntent']);
$this->post('stripe/payments/cancel', ['StripeController', 'cancelPayment']);
$this->post('stripe/payment-links', ['StripeController', 'createPaymentLink']);
$this->get('stripe/payments/:paymentIntentId', ['StripeController', 'getPaymentStatus']);
@@ -152,6 +152,14 @@ class Router {
// Webhook (IMPORTANT: pas d'authentification requise pour les webhooks Stripe)
$this->post('stripe/webhooks', ['StripeWebhookController', 'handleWebhook']);
// Routes Statistiques Events (Admin uniquement)
$this->get('events/stats/summary', ['EventStatsController', 'summary']);
$this->get('events/stats/daily', ['EventStatsController', 'daily']);
$this->get('events/stats/weekly', ['EventStatsController', 'weekly']);
$this->get('events/stats/monthly', ['EventStatsController', 'monthly']);
$this->get('events/stats/details', ['EventStatsController', 'details']);
$this->get('events/stats/types', ['EventStatsController', 'types']);
// Routes Migration (Admin uniquement)
$this->get('migrations/test-connections', ['MigrationController', 'testConnections']);
$this->get('migrations/entities/available', ['MigrationController', 'getAvailableEntities']);

View File

@@ -21,19 +21,16 @@ class AddressService
{
private ?PDO $addressesDb = null;
private PDO $mainDb;
private $logService;
private $buildingService;
public function __construct()
{
$this->logService = new LogService();
try {
$this->addressesDb = \AddressesDatabase::getInstance();
$this->logService->info('[AddressService] Connexion à la base d\'adresses réussie');
LogService::info('[AddressService] Connexion à la base d\'adresses réussie');
} catch (\Exception $e) {
// Si la connexion échoue, on continue sans la base d'adresses
$this->logService->error('[AddressService] Connexion à la base d\'adresses impossible', [
LogService::error('[AddressService] Connexion à la base d\'adresses impossible', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
@@ -94,13 +91,13 @@ class AddressService
{
// Si pas de connexion à la base d'adresses, retourner un tableau vide
if (!$this->addressesDb) {
$this->logService->error('[AddressService] Pas de connexion à la base d\'adresses externe', [
LogService::error('[AddressService] Pas de connexion à la base d\'adresses externe', [
'entity_id' => $entityId
]);
return [];
}
$this->logService->info('[AddressService] Début recherche adresses', [
LogService::info('[AddressService] Début recherche adresses', [
'entity_id' => $entityId,
'nb_coordinates' => count($coordinates)
]);
@@ -117,11 +114,11 @@ class AddressService
// Si aucun département n'est trouvé par analyse spatiale,
// chercher d'abord dans le département de l'entité et ses limitrophes
$entityDept = $this->getDepartmentForEntity($entityId);
$this->logService->info('[AddressService] Département de l\'entité', [
LogService::info('[AddressService] Département de l\'entité', [
'departement' => $entityDept
]);
if (!$entityDept) {
$this->logService->error('[AddressService] Impossible de déterminer le département de l\'entité', [
LogService::error('[AddressService] Impossible de déterminer le département de l\'entité', [
'entity_id' => $entityId
]);
throw new RuntimeException("Impossible de déterminer le département");
@@ -131,7 +128,7 @@ class AddressService
$priorityDepts = $boundaryService->getPriorityDepartments($entityDept);
// Log pour debug
$this->logService->warning('[AddressService] Aucun département trouvé par analyse spatiale', [
LogService::warning('[AddressService] Aucun département trouvé par analyse spatiale', [
'departements_prioritaires' => implode(', ', $priorityDepts)
]);
@@ -204,7 +201,7 @@ class AddressService
}
// Log pour debug
$this->logService->info('[AddressService] Recherche dans table', [
LogService::info('[AddressService] Recherche dans table', [
'table' => $tableName,
'departement' => $deptCode,
'nb_adresses' => count($addresses)
@@ -212,7 +209,7 @@ class AddressService
} catch (PDOException $e) {
// Log l'erreur mais continue avec les autres départements
$this->logService->error('[AddressService] Erreur SQL', [
LogService::error('[AddressService] Erreur SQL', [
'table' => $tableName,
'departement' => $deptCode,
'error' => $e->getMessage(),
@@ -221,7 +218,7 @@ class AddressService
}
}
$this->logService->info('[AddressService] Fin recherche adresses', [
LogService::info('[AddressService] Fin recherche adresses', [
'total_adresses' => count($allAddresses)
]);
return $allAddresses;
@@ -243,7 +240,7 @@ class AddressService
return [];
}
$this->logService->info('[AddressService] Début enrichissement avec bâtiments', [
LogService::info('[AddressService] Début enrichissement avec bâtiments', [
'entity_id' => $entityId,
'nb_addresses' => count($addresses)
]);
@@ -262,7 +259,7 @@ class AddressService
}
}
$this->logService->info('[AddressService] Fin enrichissement avec bâtiments', [
LogService::info('[AddressService] Fin enrichissement avec bâtiments', [
'total_adresses' => count($enrichedAddresses),
'nb_immeubles' => $nbImmeubles,
'nb_maisons' => $nbMaisons
@@ -271,7 +268,7 @@ class AddressService
return $enrichedAddresses;
} catch (\Exception $e) {
$this->logService->error('[AddressService] Erreur lors de l\'enrichissement', [
LogService::error('[AddressService] Erreur lors de l\'enrichissement', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);

View File

@@ -231,7 +231,7 @@ class ApiService {
* @param int $minLength Longueur minimale du nom d'utilisateur (par défaut 10)
* @return string Nom d'utilisateur généré
*/
public static function generateUserName(PDO $db, string $name, string $postalCode, string $cityName, int $minLength = 10): string {
public static function generateUserName(\PDO $db, string $name, string $postalCode, string $cityName, int $minLength = 10): string {
// Nettoyer et préparer les chaînes
$name = preg_replace('/[^a-zA-Z0-9]/', '', strtolower($name));
$postalCode = preg_replace('/[^0-9]/', '', $postalCode);
@@ -277,7 +277,7 @@ class ApiService {
// Vérifier si le nom d'utilisateur existe déjà
$stmt = $db->prepare('SELECT COUNT(*) as count FROM users WHERE encrypted_user_name = ?');
$stmt->execute([$encryptedUsername]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
$result = $stmt->fetch(\PDO::FETCH_ASSOC);
if ($result && $result['count'] == 0) {
$isUnique = true;

View File

@@ -14,9 +14,9 @@ class EmailTemplates {
Votre compte a été créé avec succès sur <b>GeoSector</b>.<br><br>
<b>Identifiant :</b> $username<br>
<b>Mot de passe :</b> $password<br><br>
Vous pouvez vous connecter dès maintenant sur <a href=\"https://app3.geosector.fr\">app3.geosector.fr</a><br><br>
À très bientôt,<br>
L'équipe GeoSector";
L'équipe GeoSector<br>
<span style='color:#666; font-size:12px;'>Support : support@geosector.fr</span>";
}
/**
@@ -80,9 +80,9 @@ class EmailTemplates {
Bonjour $name,<br><br>
Vous avez demandé la réinitialisation de votre mot de passe sur <b>GeoSector</b>.<br><br>
<b>Nouveau mot de passe :</b> $password<br><br>
Vous pouvez vous connecter avec ce nouveau mot de passe sur <a href=\"https://app3.geosector.fr\">app3.geosector.fr</a><br><br>
À très bientôt,<br>
L'équipe GeoSector";
L'équipe GeoSector<br>
<span style='color:#666; font-size:12px;'>Support : support@geosector.fr</span>";
}
/**

View File

@@ -305,6 +305,141 @@ class EventLogService
]);
}
// ==================== MÉTHODES STRIPE ====================
/**
* Log la création d'un PaymentIntent
*
* @param string $paymentIntentId ID Stripe du PaymentIntent
* @param int $passageId ID du passage
* @param int $amount Montant en centimes
* @param string $method Méthode (tap_to_pay, qr_code, web)
*/
public static function logStripePaymentCreated(
string $paymentIntentId,
int $passageId,
int $amount,
string $method
): void {
$entityId = Session::getEntityId();
self::writeEvent('stripe_payment_created', [
'payment_intent_id' => $paymentIntentId,
'passage_id' => $passageId,
'entity_id' => $entityId,
'amount' => $amount,
'method' => $method
]);
}
/**
* Log un paiement Stripe réussi
*
* @param string $paymentIntentId ID Stripe du PaymentIntent
* @param int $passageId ID du passage
* @param int $amount Montant en centimes
* @param string $method Méthode (tap_to_pay, qr_code, web)
*/
public static function logStripePaymentSuccess(
string $paymentIntentId,
int $passageId,
int $amount,
string $method
): void {
$entityId = Session::getEntityId();
self::writeEvent('stripe_payment_success', [
'payment_intent_id' => $paymentIntentId,
'passage_id' => $passageId,
'entity_id' => $entityId,
'amount' => $amount,
'method' => $method
]);
}
/**
* Log un paiement Stripe échoué
*
* @param string|null $paymentIntentId ID Stripe (peut être null si création échouée)
* @param int|null $passageId ID du passage (peut être null)
* @param int|null $amount Montant en centimes (peut être null)
* @param string $method Méthode tentée
* @param string $errorCode Code d'erreur
* @param string $errorMessage Message d'erreur
*/
public static function logStripePaymentFailed(
?string $paymentIntentId,
?int $passageId,
?int $amount,
string $method,
string $errorCode,
string $errorMessage
): void {
$entityId = Session::getEntityId();
$data = [
'entity_id' => $entityId,
'method' => $method,
'error_code' => $errorCode,
'error_message' => $errorMessage
];
if ($paymentIntentId !== null) {
$data['payment_intent_id'] = $paymentIntentId;
}
if ($passageId !== null) {
$data['passage_id'] = $passageId;
}
if ($amount !== null) {
$data['amount'] = $amount;
}
self::writeEvent('stripe_payment_failed', $data);
}
/**
* Log l'annulation d'un paiement Stripe
*
* @param string $paymentIntentId ID Stripe du PaymentIntent
* @param int|null $passageId ID du passage (peut être null)
* @param string $reason Raison (user_cancelled, timeout, error, etc.)
*/
public static function logStripePaymentCancelled(
string $paymentIntentId,
?int $passageId,
string $reason
): void {
$entityId = Session::getEntityId();
$data = [
'payment_intent_id' => $paymentIntentId,
'entity_id' => $entityId,
'reason' => $reason
];
if ($passageId !== null) {
$data['passage_id'] = $passageId;
}
self::writeEvent('stripe_payment_cancelled', $data);
}
/**
* Log une erreur du Terminal Tap to Pay
*
* @param string $errorCode Code d'erreur (cardReadTimedOut, device_not_compatible, etc.)
* @param string $errorMessage Message d'erreur
* @param array $metadata Métadonnées supplémentaires (device_model, is_simulated, etc.)
*/
public static function logStripeTerminalError(
string $errorCode,
string $errorMessage,
array $metadata = []
): void {
$entityId = Session::getEntityId();
self::writeEvent('stripe_terminal_error', array_merge([
'entity_id' => $entityId,
'error_code' => $errorCode,
'error_message' => $errorMessage
], $metadata));
}
// ==================== MÉTHODES OPÉRATIONS ====================
/**

View File

@@ -0,0 +1,535 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Database;
use PDO;
/**
* EventStatsService - Service de statistiques d'événements
*
* Fournit des méthodes pour récupérer les statistiques agrégées
* depuis la table event_stats_daily et le détail depuis les fichiers JSONL.
*
* @see docs/TECHBOOK.md section "Statistiques Events"
*/
class EventStatsService
{
/** @var string Chemin du dossier des logs événements */
private const EVENT_LOG_DIR = __DIR__ . '/../../logs/events';
/** @var int Limite max pour le détail */
private const MAX_DETAILS_LIMIT = 100;
/** @var PDO Instance de la base de données */
private PDO $db;
public function __construct()
{
$this->db = Database::getInstance();
}
// ==================== MÉTHODES PUBLIQUES ====================
/**
* Récupère le résumé des stats pour une date donnée
*
* @param int|null $entityId ID entité (null = toutes entités pour super-admin)
* @param string|null $date Date (YYYY-MM-DD), défaut = aujourd'hui
* @return array Stats résumées par catégorie
*/
public function getSummary(?int $entityId, ?string $date = null): array
{
$date = $date ?? date('Y-m-d');
$sql = "
SELECT event_type, count, sum_amount, unique_users, metadata
FROM event_stats_daily
WHERE stat_date = :date
";
$params = ['date' => $date];
if ($entityId !== null) {
$sql .= " AND entity_id = :entity_id";
$params['entity_id'] = $entityId;
} else {
$sql .= " AND entity_id IS NULL";
}
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return $this->formatSummary($date, $rows);
}
/**
* Récupère les stats journalières sur une plage de dates
*
* @param int|null $entityId ID entité (null = toutes entités)
* @param string $from Date début (YYYY-MM-DD)
* @param string $to Date fin (YYYY-MM-DD)
* @param array $eventTypes Filtrer par types d'événements (optionnel)
* @return array Stats par jour
*/
public function getDaily(?int $entityId, string $from, string $to, array $eventTypes = []): array
{
$sql = "
SELECT stat_date, event_type, count, sum_amount, unique_users
FROM event_stats_daily
WHERE stat_date BETWEEN :from AND :to
";
$params = ['from' => $from, 'to' => $to];
if ($entityId !== null) {
$sql .= " AND entity_id = :entity_id";
$params['entity_id'] = $entityId;
} else {
$sql .= " AND entity_id IS NULL";
}
if (!empty($eventTypes)) {
$placeholders = [];
foreach ($eventTypes as $i => $type) {
$key = "event_type_{$i}";
$placeholders[] = ":{$key}";
$params[$key] = $type;
}
$sql .= " AND event_type IN (" . implode(', ', $placeholders) . ")";
}
$sql .= " ORDER BY stat_date ASC, event_type ASC";
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return $this->formatDaily($rows);
}
/**
* Récupère les stats hebdomadaires (calculées depuis daily)
*
* @param int|null $entityId ID entité
* @param string $from Date début
* @param string $to Date fin
* @param array $eventTypes Filtrer par types d'événements
* @return array Stats par semaine
*/
public function getWeekly(?int $entityId, string $from, string $to, array $eventTypes = []): array
{
$daily = $this->getDaily($entityId, $from, $to, $eventTypes);
$weekly = [];
foreach ($daily as $day) {
$date = new \DateTime($day['date']);
$weekStart = clone $date;
$weekStart->modify('monday this week');
$weekKey = $weekStart->format('Y-m-d');
if (!isset($weekly[$weekKey])) {
$weekly[$weekKey] = [
'week_start' => $weekKey,
'week_number' => (int) $date->format('W'),
'year' => (int) $date->format('Y'),
'events' => [],
'totals' => [
'count' => 0,
'sum_amount' => 0.0,
],
];
}
// Agréger les événements
foreach ($day['events'] as $eventType => $stats) {
if (!isset($weekly[$weekKey]['events'][$eventType])) {
$weekly[$weekKey]['events'][$eventType] = [
'count' => 0,
'sum_amount' => 0.0,
'unique_users' => 0,
];
}
$weekly[$weekKey]['events'][$eventType]['count'] += $stats['count'];
$weekly[$weekKey]['events'][$eventType]['sum_amount'] += $stats['sum_amount'];
$weekly[$weekKey]['events'][$eventType]['unique_users'] += $stats['unique_users'];
}
$weekly[$weekKey]['totals']['count'] += $day['totals']['count'];
$weekly[$weekKey]['totals']['sum_amount'] += $day['totals']['sum_amount'];
}
return array_values($weekly);
}
/**
* Récupère les stats mensuelles (calculées depuis daily)
*
* @param int|null $entityId ID entité
* @param int $year Année
* @param array $eventTypes Filtrer par types d'événements
* @return array Stats par mois
*/
public function getMonthly(?int $entityId, int $year, array $eventTypes = []): array
{
$from = "{$year}-01-01";
$to = "{$year}-12-31";
$daily = $this->getDaily($entityId, $from, $to, $eventTypes);
$monthly = [];
foreach ($daily as $day) {
$monthKey = substr($day['date'], 0, 7); // YYYY-MM
if (!isset($monthly[$monthKey])) {
$monthly[$monthKey] = [
'month' => $monthKey,
'year' => $year,
'month_number' => (int) substr($monthKey, 5, 2),
'events' => [],
'totals' => [
'count' => 0,
'sum_amount' => 0.0,
],
];
}
// Agréger les événements
foreach ($day['events'] as $eventType => $stats) {
if (!isset($monthly[$monthKey]['events'][$eventType])) {
$monthly[$monthKey]['events'][$eventType] = [
'count' => 0,
'sum_amount' => 0.0,
'unique_users' => 0,
];
}
$monthly[$monthKey]['events'][$eventType]['count'] += $stats['count'];
$monthly[$monthKey]['events'][$eventType]['sum_amount'] += $stats['sum_amount'];
$monthly[$monthKey]['events'][$eventType]['unique_users'] += $stats['unique_users'];
}
$monthly[$monthKey]['totals']['count'] += $day['totals']['count'];
$monthly[$monthKey]['totals']['sum_amount'] += $day['totals']['sum_amount'];
}
return array_values($monthly);
}
/**
* Récupère le détail des événements depuis le fichier JSONL
*
* @param int|null $entityId ID entité (null = tous)
* @param string $date Date (YYYY-MM-DD)
* @param string|null $eventType Filtrer par type d'événement
* @param int $limit Nombre max de résultats
* @param int $offset Décalage pour pagination
* @return array Événements détaillés avec pagination
*/
public function getDetails(
?int $entityId,
string $date,
?string $eventType = null,
int $limit = 50,
int $offset = 0
): array {
$limit = min($limit, self::MAX_DETAILS_LIMIT);
$filePath = self::EVENT_LOG_DIR . '/' . $date . '.jsonl';
if (!file_exists($filePath)) {
return [
'date' => $date,
'events' => [],
'pagination' => [
'total' => 0,
'limit' => $limit,
'offset' => $offset,
'has_more' => false,
],
];
}
$events = [];
$total = 0;
$currentIndex = 0;
$handle = fopen($filePath, 'r');
if (!$handle) {
return [
'date' => $date,
'events' => [],
'pagination' => [
'total' => 0,
'limit' => $limit,
'offset' => $offset,
'has_more' => false,
],
'error' => 'Impossible de lire le fichier',
];
}
while (($line = fgets($handle)) !== false) {
$line = trim($line);
if (empty($line)) {
continue;
}
$event = json_decode($line, true);
if (!$event || !isset($event['event'])) {
continue;
}
// Filtrer par entity_id
if ($entityId !== null) {
$eventEntityId = $event['entity_id'] ?? null;
if ($eventEntityId !== $entityId) {
continue;
}
}
// Filtrer par event_type
if ($eventType !== null && ($event['event'] ?? '') !== $eventType) {
continue;
}
$total++;
// Pagination
if ($currentIndex >= $offset && count($events) < $limit) {
$events[] = $this->sanitizeEventForOutput($event);
}
$currentIndex++;
}
fclose($handle);
return [
'date' => $date,
'events' => $events,
'pagination' => [
'total' => $total,
'limit' => $limit,
'offset' => $offset,
'has_more' => ($offset + $limit) < $total,
],
];
}
/**
* Récupère les types d'événements disponibles
*
* @return array Liste des types d'événements
*/
public function getEventTypes(): array
{
return [
'auth' => ['login_success', 'login_failed', 'logout'],
'passages' => ['passage_created', 'passage_updated', 'passage_deleted'],
'sectors' => ['sector_created', 'sector_updated', 'sector_deleted'],
'users' => ['user_created', 'user_updated', 'user_deleted'],
'entities' => ['entity_created', 'entity_updated', 'entity_deleted'],
'operations' => ['operation_created', 'operation_updated', 'operation_deleted'],
'stripe' => [
'stripe_payment_created',
'stripe_payment_success',
'stripe_payment_failed',
'stripe_payment_cancelled',
'stripe_terminal_error',
],
];
}
/**
* Vérifie si des stats existent pour une date
*
* @param string $date Date à vérifier
* @return bool
*/
public function hasStatsForDate(string $date): bool
{
$stmt = $this->db->prepare("
SELECT COUNT(*) FROM event_stats_daily WHERE stat_date = :date
");
$stmt->execute(['date' => $date]);
return (int) $stmt->fetchColumn() > 0;
}
// ==================== MÉTHODES PRIVÉES ====================
/**
* Formate le résumé des stats par catégorie
*/
private function formatSummary(string $date, array $rows): array
{
$summary = [
'date' => $date,
'stats' => [
'auth' => ['success' => 0, 'failed' => 0, 'logout' => 0],
'passages' => ['created' => 0, 'updated' => 0, 'deleted' => 0, 'amount' => 0.0],
'users' => ['created' => 0, 'updated' => 0, 'deleted' => 0],
'sectors' => ['created' => 0, 'updated' => 0, 'deleted' => 0],
'entities' => ['created' => 0, 'updated' => 0, 'deleted' => 0],
'operations' => ['created' => 0, 'updated' => 0, 'deleted' => 0],
'stripe' => ['created' => 0, 'success' => 0, 'failed' => 0, 'cancelled' => 0, 'amount' => 0.0],
],
'totals' => [
'events' => 0,
'unique_users' => 0,
],
];
$uniqueUsersSet = [];
foreach ($rows as $row) {
$eventType = $row['event_type'];
$count = (int) $row['count'];
$amount = (float) $row['sum_amount'];
$uniqueUsers = (int) $row['unique_users'];
$summary['totals']['events'] += $count;
$uniqueUsersSet[$eventType] = $uniqueUsers;
// Mapper vers les catégories
switch ($eventType) {
case 'login_success':
$summary['stats']['auth']['success'] = $count;
break;
case 'login_failed':
$summary['stats']['auth']['failed'] = $count;
break;
case 'logout':
$summary['stats']['auth']['logout'] = $count;
break;
case 'passage_created':
$summary['stats']['passages']['created'] = $count;
$summary['stats']['passages']['amount'] += $amount;
break;
case 'passage_updated':
$summary['stats']['passages']['updated'] = $count;
break;
case 'passage_deleted':
$summary['stats']['passages']['deleted'] = $count;
break;
case 'user_created':
$summary['stats']['users']['created'] = $count;
break;
case 'user_updated':
$summary['stats']['users']['updated'] = $count;
break;
case 'user_deleted':
$summary['stats']['users']['deleted'] = $count;
break;
case 'sector_created':
$summary['stats']['sectors']['created'] = $count;
break;
case 'sector_updated':
$summary['stats']['sectors']['updated'] = $count;
break;
case 'sector_deleted':
$summary['stats']['sectors']['deleted'] = $count;
break;
case 'entity_created':
$summary['stats']['entities']['created'] = $count;
break;
case 'entity_updated':
$summary['stats']['entities']['updated'] = $count;
break;
case 'entity_deleted':
$summary['stats']['entities']['deleted'] = $count;
break;
case 'operation_created':
$summary['stats']['operations']['created'] = $count;
break;
case 'operation_updated':
$summary['stats']['operations']['updated'] = $count;
break;
case 'operation_deleted':
$summary['stats']['operations']['deleted'] = $count;
break;
case 'stripe_payment_created':
$summary['stats']['stripe']['created'] = $count;
break;
case 'stripe_payment_success':
$summary['stats']['stripe']['success'] = $count;
$summary['stats']['stripe']['amount'] += $amount;
break;
case 'stripe_payment_failed':
$summary['stats']['stripe']['failed'] = $count;
break;
case 'stripe_payment_cancelled':
$summary['stats']['stripe']['cancelled'] = $count;
break;
}
}
// Estimation des utilisateurs uniques (max des catégories car overlap possible)
$summary['totals']['unique_users'] = !empty($uniqueUsersSet) ? max($uniqueUsersSet) : 0;
return $summary;
}
/**
* Formate les stats journalières
*/
private function formatDaily(array $rows): array
{
$daily = [];
foreach ($rows as $row) {
$date = $row['stat_date'];
if (!isset($daily[$date])) {
$daily[$date] = [
'date' => $date,
'events' => [],
'totals' => [
'count' => 0,
'sum_amount' => 0.0,
],
];
}
$eventType = $row['event_type'];
$count = (int) $row['count'];
$amount = (float) $row['sum_amount'];
$daily[$date]['events'][$eventType] = [
'count' => $count,
'sum_amount' => $amount,
'unique_users' => (int) $row['unique_users'],
];
$daily[$date]['totals']['count'] += $count;
$daily[$date]['totals']['sum_amount'] += $amount;
}
return array_values($daily);
}
/**
* Nettoie un événement pour l'affichage (supprime données sensibles)
*/
private function sanitizeEventForOutput(array $event): array
{
// Supprimer l'IP complète, garder seulement les 2 premiers octets
if (isset($event['ip'])) {
$parts = explode('.', $event['ip']);
if (count($parts) === 4) {
$event['ip'] = $parts[0] . '.' . $parts[1] . '.x.x';
}
}
// Supprimer le user_agent complet
unset($event['user_agent']);
// Supprimer les données chiffrées si présentes
unset($event['encrypted_name']);
unset($event['encrypted_email']);
return $event;
}
}

View File

@@ -58,7 +58,7 @@ class ExportService {
$filepath = $exportDir . '/' . $filename;
// Créer le spreadsheet
$spreadsheet = new PhpOffice\PhpSpreadsheet\Spreadsheet();
$spreadsheet = new Spreadsheet();
// Insérer les données
$this->createPassagesSheet($spreadsheet, $operationId, $userId);
@@ -283,11 +283,11 @@ class ExportService {
$dateEve = $passage['passed_at'] ? date('d/m/Y', strtotime($passage['passed_at'])) : '';
$heureEve = $passage['passed_at'] ? date('H:i', strtotime($passage['passed_at'])) : '';
// Déchiffrer les données
$donateur = ApiService::decryptData($passage['encrypted_name']);
// Déchiffrer les données (avec vérification null)
$donateur = !empty($passage['encrypted_name']) ? ApiService::decryptData($passage['encrypted_name']) : '';
$email = !empty($passage['encrypted_email']) ? ApiService::decryptSearchableData($passage['encrypted_email']) : '';
$phone = !empty($passage['encrypted_phone']) ? ApiService::decryptData($passage['encrypted_phone']) : '';
$userName = ApiService::decryptData($passage['user_name']);
$userName = !empty($passage['user_name']) ? ApiService::decryptData($passage['user_name']) : '';
// Type de passage
$typeLabels = [
@@ -382,7 +382,7 @@ class ExportService {
foreach ($users as $user) {
$rowData = [
$user['id'],
ApiService::decryptData($user['encrypted_name']),
!empty($user['encrypted_name']) ? ApiService::decryptData($user['encrypted_name']) : '',
$user['first_name'],
!empty($user['encrypted_email']) ? ApiService::decryptSearchableData($user['encrypted_email']) : '',
!empty($user['encrypted_phone']) ? ApiService::decryptData($user['encrypted_phone']) : '',
@@ -480,7 +480,7 @@ class ExportService {
$row = 2;
foreach ($userSectors as $us) {
$userName = ApiService::decryptData($us['user_name']);
$userName = !empty($us['user_name']) ? ApiService::decryptData($us['user_name']) : '';
$fullUserName = $us['first_name'] ? $us['first_name'] . ' ' . $userName : $userName;
$rowData = [
@@ -690,11 +690,11 @@ class ExportService {
$dateEve = $p["passed_at"] ? date("d/m/Y", strtotime($p["passed_at"])) : "";
$heureEve = $p["passed_at"] ? date("H:i", strtotime($p["passed_at"])) : "";
// Déchiffrer les données
$donateur = ApiService::decryptData($p["encrypted_name"]);
// Déchiffrer les données (avec vérification null)
$donateur = !empty($p["encrypted_name"]) ? ApiService::decryptData($p["encrypted_name"]) : "";
$email = !empty($p["encrypted_email"]) ? ApiService::decryptSearchableData($p["encrypted_email"]) : "";
$phone = !empty($p["encrypted_phone"]) ? ApiService::decryptData($p["encrypted_phone"]) : "";
$userName = ApiService::decryptData($p["user_name"]);
$userName = !empty($p["user_name"]) ? ApiService::decryptData($p["user_name"]) : "";
// Nettoyer les données (comme dans l'ancienne version)
$nom = str_replace("/", "-", $userName);

View File

@@ -8,6 +8,12 @@ use AppConfig;
use ClientDetector;
class LogService {
/** @var int Permissions du dossier */
private const DIR_PERMISSIONS = 0750;
/** @var int Permissions des fichiers */
private const FILE_PERMISSIONS = 0640;
public static function log(string $message, array $metadata = []): void {
// Obtenir les informations client via ClientDetector
$clientInfo = ClientDetector::getClientInfo();
@@ -67,12 +73,10 @@ class LogService {
// Créer le dossier logs s'il n'existe pas
if (!is_dir($logDir)) {
if (!mkdir($logDir, 0777, true)) {
if (!mkdir($logDir, self::DIR_PERMISSIONS, true)) {
error_log("Impossible de créer le dossier de logs: {$logDir}");
return; // Sortir de la fonction si on ne peut pas créer le dossier
}
// S'assurer que les permissions sont correctes
chmod($logDir, 0777);
}
// Vérifier si le dossier est accessible en écriture
@@ -139,26 +143,29 @@ class LogService {
$message
]) . "\n";
// Écrire dans le fichier avec gestion d'erreur
if (file_put_contents($filename, $logLine, FILE_APPEND) === false) {
// Écrire dans le fichier avec gestion d'erreur et verrouillage
if (file_put_contents($filename, $logLine, FILE_APPEND | LOCK_EX) === false) {
error_log("Impossible d'écrire dans le fichier de logs: {$filename}");
} else {
// Appliquer les permissions au fichier
@chmod($filename, self::FILE_PERMISSIONS);
}
} catch (\Exception $e) {
error_log("Erreur lors de l'écriture des logs: " . $e->getMessage());
}
}
public function info(string $message, array $metadata = []): void {
public static function info(string $message, array $metadata = []): void {
$metadata['level'] = 'info';
self::log($message, $metadata);
}
public function warning(string $message, array $metadata = []): void {
public static function warning(string $message, array $metadata = []): void {
$metadata['level'] = 'warning';
self::log($message, $metadata);
}
public function error(string $message, array $metadata = []): void {
public static function error(string $message, array $metadata = []): void {
$metadata['level'] = 'error';
self::log($message, $metadata);
}

View File

@@ -0,0 +1,292 @@
<?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;
}
}

View File

@@ -465,64 +465,68 @@ class StripeService {
$entiteId = $params['fk_entite'] ?? 0;
$userId = $params['fk_user'] ?? 0;
$metadata = $params['metadata'] ?? [];
$paymentMethodTypes = $params['payment_method_types'] ?? ['card_present'];
if ($amount < 100) {
throw new Exception("Le montant minimum est de 1€");
}
// Récupérer le compte Stripe
$stmt = $this->db->prepare(
"SELECT * FROM stripe_accounts WHERE fk_entite = :fk_entite"
);
$stmt->execute(['fk_entite' => $entiteId]);
$account = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$account) {
throw new Exception("Compte Stripe non trouvé");
}
// Pas de commission plateforme - 100% pour l'amicale
// Créer le PaymentIntent sans commission
$paymentIntent = $this->stripe->paymentIntents->create([
// Déterminer le mode : Tap to Pay (card_present) ou Payment Link (card)
$isTapToPay = in_array('card_present', $paymentMethodTypes);
// Configuration du PaymentIntent selon le mode
$paymentIntentData = [
'amount' => $amount,
'currency' => 'eur',
'payment_method_types' => ['card_present'],
'payment_method_types' => $paymentMethodTypes,
'capture_method' => 'automatic',
// Pas d'application_fee_amount - tout va à l'amicale
'transfer_data' => [
'destination' => $account['stripe_account_id'],
],
'metadata' => array_merge($metadata, [
'entite_id' => $entiteId,
'user_id' => $userId,
'calendrier_annee' => date('Y'),
]),
]);
// Sauvegarder en base
$stmt = $this->db->prepare(
"INSERT INTO stripe_payment_intents
(stripe_payment_intent_id, fk_entite, fk_user, amount, currency, status, application_fee, metadata, created_at)
VALUES (:pi_id, :fk_entite, :fk_user, :amount, :currency, :status, :app_fee, :metadata, NOW())"
];
// Options Stripe (avec ou sans stripe_account)
$stripeOptions = [];
if ($isTapToPay) {
// TAP TO PAY : Paiement direct sur le compte connecté
// Le PaymentIntent est créé sur le compte de l'amicale
$stripeOptions['stripe_account'] = $account['stripe_account_id'];
} else {
// PAYMENT LINK / WEB : Paiement via la plateforme avec transfert
// Le PaymentIntent est créé sur la plateforme et transféré
$paymentIntentData['transfer_data'] = [
'destination' => $account['stripe_account_id'],
];
}
// Créer le PaymentIntent
$paymentIntent = $this->stripe->paymentIntents->create(
$paymentIntentData,
$stripeOptions
);
$stmt->execute([
'pi_id' => $paymentIntent->id,
'fk_entite' => $entiteId,
'fk_user' => $userId,
'amount' => $amount,
'currency' => 'eur',
'status' => $paymentIntent->status,
'app_fee' => 0, // Pas de commission
'metadata' => json_encode($metadata)
]);
// Note : Le payment_intent_id est sauvegardé dans ope_pass.stripe_payment_id par le controller
return [
'success' => true,
'client_secret' => $paymentIntent->client_secret,
'payment_intent_id' => $paymentIntent->id,
'amount' => $amount,
'application_fee' => 0 // Pas de commission
'mode' => $isTapToPay ? 'tap_to_pay' : 'payment_link'
];
} catch (Exception $e) {
@@ -532,76 +536,7 @@ class StripeService {
];
}
}
/**
* Vérifier la compatibilité Tap to Pay d'un appareil Android
*/
public function checkAndroidTapToPayCompatibility(string $manufacturer, string $model): array {
try {
$stmt = $this->db->prepare(
"SELECT * FROM stripe_android_certified_devices
WHERE manufacturer = :manufacturer
AND model = :model
AND tap_to_pay_certified = 1
AND country = 'FR'"
);
$stmt->execute([
'manufacturer' => $manufacturer,
'model' => $model
]);
$device = $stmt->fetch(PDO::FETCH_ASSOC);
if ($device) {
return [
'success' => true,
'tap_to_pay_supported' => true,
'message' => 'Tap to Pay disponible sur cet appareil',
'min_android_version' => $device['min_android_version']
];
}
return [
'success' => true,
'tap_to_pay_supported' => false,
'message' => 'Appareil non certifié pour Tap to Pay en France',
'alternative' => 'Utilisez un iPhone XS ou plus récent avec iOS 16.4+'
];
} catch (Exception $e) {
return [
'success' => false,
'message' => 'Erreur: ' . $e->getMessage()
];
}
}
/**
* Récupérer les appareils Android certifiés
*/
public function getCertifiedAndroidDevices(): array {
try {
$stmt = $this->db->prepare(
"SELECT manufacturer, model, model_identifier, min_android_version
FROM stripe_android_certified_devices
WHERE tap_to_pay_certified = 1 AND country = 'FR'
ORDER BY manufacturer, model"
);
$stmt->execute();
return [
'success' => true,
'devices' => $stmt->fetchAll(PDO::FETCH_ASSOC)
];
} catch (Exception $e) {
return [
'success' => false,
'message' => 'Erreur: ' . $e->getMessage()
];
}
}
/**
* Créer un Payment Link Stripe pour paiement par QR Code
*
@@ -747,6 +682,47 @@ class StripeService {
}
}
/**
* Annuler un PaymentIntent Stripe
*
* @param string $paymentIntentId L'ID du PaymentIntent à annuler
* @return array ['success' => bool, 'status' => string|null, 'message' => string|null]
*/
public function cancelPaymentIntent(string $paymentIntentId): array {
try {
// Annuler le PaymentIntent via l'API Stripe
$paymentIntent = $this->stripe->paymentIntents->cancel($paymentIntentId);
LogService::log('PaymentIntent annulé', [
'payment_intent_id' => $paymentIntentId,
'status' => $paymentIntent->status
]);
return [
'success' => true,
'status' => $paymentIntent->status,
'payment_intent_id' => $paymentIntentId
];
} catch (ApiErrorException $e) {
LogService::log('Erreur annulation PaymentIntent Stripe', [
'level' => 'error',
'payment_intent_id' => $paymentIntentId,
'error' => $e->getMessage()
]);
return [
'success' => false,
'message' => 'Erreur Stripe: ' . $e->getMessage()
];
} catch (Exception $e) {
return [
'success' => false,
'message' => 'Erreur: ' . $e->getMessage()
];
}
}
/**
* Obtenir le mode actuel (test ou live)
*/