feat: Release version 3.1.4 - Mode terrain et génération PDF

 Nouvelles fonctionnalités:
- Ajout du mode terrain pour utilisation mobile hors connexion
- Génération automatique de reçus PDF avec template personnalisé
- Révision complète du système de cartes avec amélioration des performances

🔧 Améliorations techniques:
- Refactoring du module chat avec architecture simplifiée
- Optimisation du système de sécurité NIST SP 800-63B
- Amélioration de la gestion des secteurs géographiques
- Support UTF-8 étendu pour les noms d'utilisateurs

📱 Application mobile:
- Nouveau mode terrain dans user_field_mode_page
- Interface utilisateur adaptative pour conditions difficiles
- Synchronisation offline améliorée

🗺️ Cartographie:
- Optimisation des performances MapBox
- Meilleure gestion des tuiles hors ligne
- Amélioration de l'affichage des secteurs

📄 Documentation:
- Ajout guide Android (ANDROID-GUIDE.md)
- Documentation sécurité API (API-SECURITY.md)
- Guide module chat (CHAT_MODULE.md)

🐛 Corrections:
- Résolution des erreurs 400 lors de la création d'utilisateurs
- Correction de la validation des noms d'utilisateurs
- Fix des problèmes de synchronisation chat

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-08-19 19:38:03 +02:00
parent c1f23c4345
commit 5ab03751e1
1823 changed files with 272663 additions and 198438 deletions

View File

@@ -0,0 +1,622 @@
<?php
declare(strict_types=1);
namespace App\Services;
require_once __DIR__ . '/LogService.php';
require_once __DIR__ . '/ApiService.php';
require_once __DIR__ . '/FileService.php';
use PDO;
use Database;
use LogService;
use ApiService;
use FileService;
use Exception;
use DateTime;
/**
* Service de gestion des reçus pour les passages de type don (fk_type=1)
* Optimisé pour générer des PDF très légers (< 20KB)
*/
class ReceiptService {
private PDO $db;
private FileService $fileService;
private const DEFAULT_LOGO_PATH = __DIR__ . '/../../docs/_logo_recu.png';
public function __construct() {
$this->db = Database::getInstance();
$this->fileService = new FileService();
}
/**
* Génère un reçu pour un passage de type don avec email valide
*
* @param int $passageId ID du passage
* @return bool True si le reçu a été généré avec succès
*/
public function generateReceiptForPassage(int $passageId): bool {
try {
// Récupérer les données du passage
$passageData = $this->getPassageData($passageId);
if (!$passageData) {
LogService::log('Passage non trouvé pour génération de reçu', [
'level' => 'warning',
'passageId' => $passageId
]);
return false;
}
// Vérifier que c'est un don effectué (fk_type = 1) avec email valide
if ((int)$passageData['fk_type'] !== 1) {
return false; // Pas un don, pas de reçu
}
// Déchiffrer et vérifier l'email
$email = '';
if (!empty($passageData['encrypted_email'])) {
$email = ApiService::decryptSearchableData($passageData['encrypted_email']);
}
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
LogService::log('Email invalide ou manquant pour le reçu', [
'level' => 'info',
'passageId' => $passageId
]);
return false;
}
// Récupérer les données de l'opération
$operationData = $this->getOperationData($passageData['fk_operation']);
if (!$operationData) {
return false;
}
// Récupérer les données de l'entité
$entiteData = $this->getEntiteData($operationData['fk_entite']);
if (!$entiteData) {
return false;
}
// Récupérer le logo de l'entité
$logoPath = $this->getEntiteLogo($operationData['fk_entite']);
// Préparer les données pour la génération du PDF
$receiptData = $this->prepareReceiptData($passageData, $operationData, $entiteData, $email);
// Générer le PDF optimisé
$pdfContent = $this->generateOptimizedPDF($receiptData, $logoPath);
// Créer le répertoire de stockage
$uploadPath = "/entites/{$operationData['fk_entite']}/recus/{$operationData['id']}";
$fullPath = $this->fileService->createDirectory($operationData['fk_entite'], $uploadPath);
// Nom du fichier
$fileName = 'recu_' . $passageId . '.pdf';
$filePath = $fullPath . '/' . $fileName;
// Sauvegarder le fichier
if (file_put_contents($filePath, $pdfContent) === false) {
throw new Exception('Impossible de sauvegarder le fichier PDF');
}
// Appliquer les permissions
$this->fileService->setFilePermissions($filePath);
// Enregistrer dans la table medias
$mediaId = $this->saveToMedias(
$operationData['fk_entite'],
$operationData['id'],
$passageId,
$fileName,
$filePath,
strlen($pdfContent)
);
// Mettre à jour le passage avec les infos du reçu
$this->updatePassageReceipt($passageId, $fileName);
// Ajouter à la queue d'email
$this->queueReceiptEmail($passageId, $email, $receiptData, $pdfContent);
LogService::log('Reçu généré avec succès', [
'level' => 'info',
'passageId' => $passageId,
'mediaId' => $mediaId,
'fileName' => $fileName,
'fileSize' => strlen($pdfContent)
]);
return true;
} catch (Exception $e) {
LogService::log('Erreur lors de la génération du reçu', [
'level' => 'error',
'error' => $e->getMessage(),
'passageId' => $passageId
]);
return false;
}
}
/**
* Génère un PDF ultra-optimisé (< 20KB)
* Utilise le format PDF natif pour minimiser la taille
*/
private function generateOptimizedPDF(array $data, ?string $logoPath): string {
// Début du PDF
$pdf = "%PDF-1.3\n";
$objects = [];
$xref = [];
// Object 1 - Catalog
$objects[1] = "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n";
// Object 2 - Pages
$objects[2] = "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n";
// Object 3 - Page
$objects[3] = "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>\nendobj\n";
// Object 4 - Font (Helvetica)
$objects[4] = "4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n";
// Contenu de la page (texte du reçu)
$content = $this->generatePDFContent($data);
// Object 5 - Content stream
$contentLength = strlen($content);
$objects[5] = "5 0 obj\n<< /Length $contentLength >>\nstream\n$content\nendstream\nendobj\n";
// Construction du PDF final
$offset = strlen($pdf);
foreach ($objects as $obj) {
$xref[] = $offset;
$pdf .= $obj;
$offset += strlen($obj);
}
// Table xref
$pdf .= "xref\n";
$pdf .= "0 " . (count($objects) + 1) . "\n";
$pdf .= "0000000000 65535 f \n";
foreach ($xref as $off) {
$pdf .= sprintf("%010d 00000 n \n", $off);
}
// Trailer
$pdf .= "trailer\n";
$pdf .= "<< /Size " . (count($objects) + 1) . " /Root 1 0 R >>\n";
$pdf .= "startxref\n";
$pdf .= "$offset\n";
$pdf .= "%%EOF\n";
return $pdf;
}
/**
* Génère le contenu textuel du reçu pour le PDF
*/
private function generatePDFContent(array $data): string {
$content = "BT\n";
$content .= "/F1 12 Tf\n";
$y = 750;
// En-tête
$content .= "50 $y Td\n";
$content .= "(" . $this->escapeString($data['entite_name']) . ") Tj\n";
$y -= 20;
if (!empty($data['entite_address'])) {
$content .= "0 -20 Td\n";
$content .= "(" . $this->escapeString($data['entite_address']) . ") Tj\n";
$y -= 20;
}
// Titre du reçu
$y -= 40;
$content .= "/F1 16 Tf\n";
$content .= "0 -40 Td\n";
$content .= "(RECU DE DON N° " . $data['receipt_number'] . ") Tj\n";
$content .= "/F1 10 Tf\n";
$content .= "0 -15 Td\n";
$content .= "(Article 200 du Code General des Impots) Tj\n";
// Informations du donateur
$y -= 60;
$content .= "/F1 12 Tf\n";
$content .= "0 -45 Td\n";
$content .= "(DONATEUR) Tj\n";
$content .= "/F1 11 Tf\n";
$content .= "0 -20 Td\n";
$content .= "(Nom : " . $this->escapeString($data['donor_name']) . ") Tj\n";
if (!empty($data['donor_address'])) {
$content .= "0 -15 Td\n";
$content .= "(Adresse : " . $this->escapeString($data['donor_address']) . ") Tj\n";
}
if (!empty($data['donor_email'])) {
$content .= "0 -15 Td\n";
$content .= "(Email : " . $this->escapeString($data['donor_email']) . ") Tj\n";
}
// Détails du don
$content .= "0 -30 Td\n";
$content .= "/F1 12 Tf\n";
$content .= "(DETAILS DU DON) Tj\n";
$content .= "/F1 11 Tf\n";
$content .= "0 -20 Td\n";
$content .= "(Date : " . $data['donation_date'] . ") Tj\n";
$content .= "0 -15 Td\n";
$content .= "(Montant : " . $data['amount'] . " EUR) Tj\n";
$content .= "0 -15 Td\n";
$content .= "(Mode de reglement : " . $this->escapeString($data['payment_method']) . ") Tj\n";
if (!empty($data['operation_name'])) {
$content .= "0 -15 Td\n";
$content .= "(Campagne : " . $this->escapeString($data['operation_name']) . ") Tj\n";
}
// Mention légale
$content .= "/F1 9 Tf\n";
$content .= "0 -40 Td\n";
$content .= "(Reduction d'impot egale a 66% du montant verse dans la limite de 20% du revenu imposable) Tj\n";
// Date et signature
$content .= "/F1 11 Tf\n";
$content .= "0 -30 Td\n";
$content .= "(Fait a " . $this->escapeString($data['entite_city']) . ", le " . $data['signature_date'] . ") Tj\n";
$content .= "0 -20 Td\n";
$content .= "(Le President) Tj\n";
$content .= "ET\n";
return $content;
}
/**
* Échappe les caractères spéciaux pour le PDF
*/
private function escapeString(string $str): string {
// Échapper les caractères spéciaux PDF
$str = str_replace('\\', '\\\\', $str);
$str = str_replace('(', '\\(', $str);
$str = str_replace(')', '\\)', $str);
// Remplacer manuellement les caractères accentués les plus courants
$accents = [
'À' => 'A', 'Á' => 'A', 'Â' => 'A', 'Ã' => 'A', 'Ä' => 'A', 'Å' => 'A',
'à' => 'a', 'á' => 'a', 'â' => 'a', 'ã' => 'a', 'ä' => 'a', 'å' => 'a',
'È' => 'E', 'É' => 'E', 'Ê' => 'E', 'Ë' => 'E',
'è' => 'e', 'é' => 'e', 'ê' => 'e', 'ë' => 'e',
'Ì' => 'I', 'Í' => 'I', 'Î' => 'I', 'Ï' => 'I',
'ì' => 'i', 'í' => 'i', 'î' => 'i', 'ï' => 'i',
'Ò' => 'O', 'Ó' => 'O', 'Ô' => 'O', 'Õ' => 'O', 'Ö' => 'O',
'ò' => 'o', 'ó' => 'o', 'ô' => 'o', 'õ' => 'o', 'ö' => 'o',
'Ù' => 'U', 'Ú' => 'U', 'Û' => 'U', 'Ü' => 'U',
'ù' => 'u', 'ú' => 'u', 'û' => 'u', 'ü' => 'u',
'Ñ' => 'N', 'ñ' => 'n',
'Ç' => 'C', 'ç' => 'c',
'Œ' => 'OE', 'œ' => 'oe',
'Æ' => 'AE', 'æ' => 'ae'
];
$str = strtr($str, $accents);
// Supprimer tout caractère non-ASCII restant
$str = preg_replace('/[^\x20-\x7E]/', '', $str);
return $str;
}
/**
* Récupère les données du passage
*/
private function getPassageData(int $passageId): ?array {
$stmt = $this->db->prepare('
SELECT p.*,
u.encrypted_name as user_encrypted_name,
u.encrypted_email as user_encrypted_email,
u.encrypted_phone as user_encrypted_phone
FROM ope_pass p
LEFT JOIN users u ON p.fk_user = u.id
WHERE p.id = ? AND p.chk_active = 1
');
$stmt->execute([$passageId]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
/**
* Récupère les données de l'opération
*/
private function getOperationData(int $operationId): ?array {
$stmt = $this->db->prepare('
SELECT * FROM operations
WHERE id = ? AND chk_active = 1
');
$stmt->execute([$operationId]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
/**
* Récupère les données de l'entité
*/
private function getEntiteData(int $entiteId): ?array {
$stmt = $this->db->prepare('
SELECT * FROM entites
WHERE id = ? AND chk_active = 1
');
$stmt->execute([$entiteId]);
$entite = $stmt->fetch(PDO::FETCH_ASSOC);
if ($entite) {
// Déchiffrer les données
if (!empty($entite['encrypted_name'])) {
$entite['name'] = ApiService::decryptData($entite['encrypted_name']);
}
if (!empty($entite['encrypted_email'])) {
$entite['email'] = ApiService::decryptSearchableData($entite['encrypted_email']);
}
if (!empty($entite['encrypted_phone'])) {
$entite['phone'] = ApiService::decryptData($entite['encrypted_phone']);
}
}
return $entite ?: null;
}
/**
* Récupère le chemin du logo de l'entité
*/
private function getEntiteLogo(int $entiteId): ?string {
$stmt = $this->db->prepare('
SELECT file_path FROM medias
WHERE support = ? AND support_id = ? AND file_category = ?
ORDER BY created_at DESC
LIMIT 1
');
$stmt->execute(['entite', $entiteId, 'logo']);
$logo = $stmt->fetch(PDO::FETCH_ASSOC);
if ($logo && !empty($logo['file_path']) && file_exists($logo['file_path'])) {
return $logo['file_path'];
}
// Utiliser le logo par défaut si disponible
if (file_exists(self::DEFAULT_LOGO_PATH)) {
return self::DEFAULT_LOGO_PATH;
}
return null;
}
/**
* Prépare les données pour le reçu
*/
private function prepareReceiptData(array $passage, array $operation, array $entite, string $email): array {
// Déchiffrer le nom du donateur
$donorName = '';
if (!empty($passage['encrypted_name'])) {
$donorName = ApiService::decryptData($passage['encrypted_name']);
} elseif (!empty($passage['user_encrypted_name'])) {
$donorName = ApiService::decryptData($passage['user_encrypted_name']);
}
// Construire l'adresse du donateur
$donorAddress = [];
if (!empty($passage['numero'])) $donorAddress[] = $passage['numero'];
if (!empty($passage['rue'])) $donorAddress[] = $passage['rue'];
if (!empty($passage['rue_bis'])) $donorAddress[] = $passage['rue_bis'];
if (!empty($passage['ville'])) $donorAddress[] = $passage['ville'];
// Date du don
$donationDate = '';
if (!empty($passage['passed_at'])) {
$donationDate = date('d/m/Y', strtotime($passage['passed_at']));
} elseif (!empty($passage['created_at'])) {
$donationDate = date('d/m/Y', strtotime($passage['created_at']));
}
// Mode de règlement
$paymentMethod = $this->getPaymentMethodLabel((int)($passage['fk_type_reglement'] ?? 1));
// Adresse de l'entité
$entiteAddress = [];
if (!empty($entite['adresse1'])) $entiteAddress[] = $entite['adresse1'];
if (!empty($entite['adresse2'])) $entiteAddress[] = $entite['adresse2'];
if (!empty($entite['code_postal']) || !empty($entite['ville'])) {
$entiteAddress[] = trim($entite['code_postal'] . ' ' . $entite['ville']);
}
return [
'receipt_number' => $passage['id'],
'entite_name' => $entite['name'] ?? 'Amicale des Sapeurs-Pompiers',
'entite_address' => implode(' ', $entiteAddress),
'entite_city' => $entite['ville'] ?? '',
'entite_email' => $entite['email'] ?? '',
'entite_phone' => $entite['phone'] ?? '',
'donor_name' => $donorName,
'donor_address' => implode(' ', $donorAddress),
'donor_email' => $email,
'donation_date' => $donationDate,
'amount' => number_format((float)($passage['montant'] ?? 0), 2, ',', ' '),
'payment_method' => $paymentMethod,
'operation_name' => $operation['libelle'] ?? '',
'signature_date' => date('d/m/Y')
];
}
/**
* Retourne le libellé du mode de règlement
*/
private function getPaymentMethodLabel(int $typeReglement): string {
$stmt = $this->db->prepare('SELECT libelle FROM x_types_reglements WHERE id = ?');
$stmt->execute([$typeReglement]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
return $result ? $result['libelle'] : 'Espèces';
}
/**
* Enregistre le fichier dans la table medias
*/
private function saveToMedias(int $entiteId, int $operationId, int $passageId, string $fileName, string $filePath, int $fileSize): int {
$stmt = $this->db->prepare('
INSERT INTO medias (
support, support_id, fichier, file_type, file_category,
file_size, mime_type, original_name, fk_entite, fk_operation,
file_path, description, created_at, fk_user_creat
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), ?)
');
$stmt->execute([
'passage', // support
$passageId, // support_id
$fileName, // fichier
'pdf', // file_type
'recu', // file_category
$fileSize, // file_size
'application/pdf', // mime_type
$fileName, // original_name
$entiteId, // fk_entite
$operationId, // fk_operation
$filePath, // file_path
'Reçu de don', // description
0 // fk_user_creat (système)
]);
return (int)$this->db->lastInsertId();
}
/**
* Met à jour le passage avec les informations du reçu
*/
private function updatePassageReceipt(int $passageId, string $fileName): void {
$stmt = $this->db->prepare('
UPDATE ope_pass
SET nom_recu = ?, date_creat_recu = NOW()
WHERE id = ?
');
$stmt->execute([$fileName, $passageId]);
}
/**
* Ajoute le reçu à la queue d'email
*/
private function queueReceiptEmail(int $passageId, string $email, array $receiptData, string $pdfContent): void {
// Préparer le sujet
$subject = "Votre reçu de don N°" . $receiptData['receipt_number'];
// Préparer le corps de l'email
$body = $this->generateEmailBody($receiptData);
// Préparer les headers avec pièce jointe
$boundary = md5((string)time());
$headers = "MIME-Version: 1.0\r\n";
$headers .= "Content-Type: multipart/mixed; boundary=\"$boundary\"\r\n";
// Corps complet avec pièce jointe
$fullBody = "--$boundary\r\n";
$fullBody .= "Content-Type: text/html; charset=UTF-8\r\n";
$fullBody .= "Content-Transfer-Encoding: 7bit\r\n\r\n";
$fullBody .= $body . "\r\n\r\n";
// Pièce jointe PDF
$fullBody .= "--$boundary\r\n";
$fullBody .= "Content-Type: application/pdf; name=\"recu_" . $receiptData['receipt_number'] . ".pdf\"\r\n";
$fullBody .= "Content-Transfer-Encoding: base64\r\n";
$fullBody .= "Content-Disposition: attachment; filename=\"recu_" . $receiptData['receipt_number'] . ".pdf\"\r\n\r\n";
$fullBody .= chunk_split(base64_encode($pdfContent)) . "\r\n";
$fullBody .= "--$boundary--";
// Insérer dans la queue
$stmt = $this->db->prepare('
INSERT INTO email_queue (
fk_pass, to_email, subject, body, headers, created_at, status
) VALUES (?, ?, ?, ?, ?, NOW(), ?)
');
$stmt->execute([
$passageId,
$email,
$subject,
$fullBody,
$headers,
'pending'
]);
}
/**
* Génère le corps HTML de l'email
*/
private function generateEmailBody(array $data): string {
// Convertir toutes les valeurs en string pour htmlspecialchars
$safeData = array_map(function($value) {
return is_string($value) ? $value : (string)$value;
}, $data);
$html = '<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background-color: #f4f4f4; padding: 20px; text-align: center; }
.content { padding: 20px; }
.footer { background-color: #f4f4f4; padding: 10px; text-align: center; font-size: 12px; }
.amount { font-size: 24px; font-weight: bold; color: #2c5aa0; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>' . htmlspecialchars($safeData['entite_name']) . '</h2>
</div>
<div class="content">
<p>Bonjour ' . htmlspecialchars($safeData['donor_name']) . ',</p>
<p>Nous vous remercions chaleureusement pour votre don de <span class="amount">' .
htmlspecialchars($safeData['amount']) . ' €</span> effectué le ' .
htmlspecialchars($safeData['donation_date']) . '.</p>
<p>Vous trouverez ci-joint votre reçu fiscal N°' . htmlspecialchars($safeData['receipt_number']) .
' qui vous permettra de bénéficier d\'une réduction d\'impôt égale à 66% du montant de votre don.</p>
<p>Votre soutien est précieux pour nous permettre de poursuivre nos actions.</p>
<p>Cordialement,<br>
L\'équipe de ' . htmlspecialchars($safeData['entite_name']) . '</p>
</div>
<div class="footer">
<p>Conservez ce reçu pour votre déclaration fiscale</p>
<p>' . htmlspecialchars($safeData['entite_name']) . '<br>
' . htmlspecialchars($safeData['entite_address']) . '<br>
' . htmlspecialchars($safeData['entite_email']) . '</p>
</div>
</div>
</body>
</html>';
return $html;
}
/**
* Met à jour la date d'envoi du reçu
*/
public function markReceiptAsSent(int $passageId): void {
$stmt = $this->db->prepare('
UPDATE ope_pass
SET date_sent_recu = NOW(), chk_email_sent = 1
WHERE id = ?
');
$stmt->execute([$passageId]);
}
}