Publier Globinours 1.0.0-rc.3

This commit is contained in:
Alexandre NOEL 2026-09-03 12:39:15 +02:00
commit 9a2b4068da
325 changed files with 38230 additions and 20 deletions

18
.editorconfig Normal file
View file

@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.php]
indent_style = space
indent_size = 4
[*.{css,js,json,yml,yaml}]
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
.env
data/**
resources/documents/**
!resources/documents/README.md
public/media/**
!public/media/.gitkeep
storage/**
tmp/**
!tmp/.gitkeep

48
CHANGELOG.md Normal file
View file

@ -0,0 +1,48 @@
# Journal des évolutions de Globinours
Toutes les modifications importantes du projet sont consignées ici. Le format s’inspire de [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le projet suit le [versionnage sémantique](https://semver.org/lang/fr/).
## [1.0.0-rc.3] — 2026-09-03
### Stabilisation
- gel fonctionnel avant la publication de la version 1.0 ;
- séparation des styles, scripts de navigation, sous-vues communes et routes web ;
- recherche universelle accessible au clavier et filtrée par permissions ;
- correction de l’installation vierge et réparation automatique des schémas de médias incomplets ;
- retrait des scripts ponctuels, paramètres partenaires et récits contenant des données propres à l’instance pilote ;
- durcissement des exclusions Git pour les bases, médias, documents, sauvegardes et journaux ;
- jeu fictif recentré sur une fiche Twix complète : photographie optimisée, quarantaine, médical, courbe de poids, vaccination, vermifugation, antécédent, prise de sang et rendez-vous d’agenda ;
- normalisation de la présentation des sources PHP selon PSR-12, ajout des conventions d’éditeur et retrait de l’ancien seed de développement devenu redondant ;
- validation de 25 parcours automatisés, de la syntaxe PHP et des traductions françaises et anglaises.
## [1.0.0-rc.2] — 2026-09-02
### Recherche et découpage
- recherche globale des animaux, contacts, événements et factures, sans indexation du contenu médical sensible ;
- découpage supplémentaire du contrôleur animal ;
- alignement de la page Évolutions et de la documentation sur la version candidate.
## [1.0.0-rc.1] — 2026-09-02
### Première stabilisation pré-1.0
- nettoyage conservateur du projet et retrait des sources historiques du paquet applicatif ;
- découpage de la fiche et du contrôleur animal par domaines métier ;
- sauvegardes SQLite compatibles avec les volumes synchronisés ;
- validation des parcours essentiels, des traductions et de la syntaxe PHP.
## [0.1.0] — 2026-08-24
### Socle initial
- gestion des animaux, liens familiaux, placements, adoptions et décès ;
- suivi médical, vaccinations, traitements, mesures et documents privés ;
- annuaire, tournées, statistiques, registre administratif et exports ;
- comptes, rôles, permissions, audit et sauvegardes ;
- import ASM3 en simulation puis transaction protégée ;
- interface responsive, PWA, mini-site public facultatif et internationalisation FR/EN.
[1.0.0]: https://nnsprod.com/git/oversu/Globinours
[0.1.0]: https://nnsprod.com/git/oversu/Globinours

23
HISTORIQUE.md Normal file
View file

@ -0,0 +1,23 @@
# Histoire de Globinours
Globinours est né d’un besoin de terrain : réunir dans une application légère les informations qu’un refuge utilise chaque jour, sans imposer une infrastructure complexe. Le projet s’est construit autour de PHP et SQLite afin de rester facile à installer, sauvegarder et déplacer.
Les premières versions ont posé la fiche animale, le suivi médical, les placements, les adoptions, les documents et les tournées. L’application s’est ensuite enrichie d’un annuaire, de statistiques, d’outils administratifs, d’une pharmacie, d’un agenda partagé et d’un mini-site public séparant strictement les contenus publiés des informations internes.
La sécurité et la continuité ont progressivement rejoint le cœur du projet : authentification, permissions, journal d’audit, médias privés hors de la racine web, sauvegardes vérifiées, restauration contrôlée, protection CSRF, limitation des connexions et paramètres adaptés aux reverse proxies de confiance.
L’import depuis Animal Shelter Manager a été conçu comme un parcours prudent. Les analyses commencent en simulation, les rapprochements ambigus restent à vérifier et les imports réels sont précédés d’une sauvegarde. Aucun fichier source ni aucune donnée d’un refuge pilote n’est livré avec Globinours.
Avant la version 1.0, le projet a fait l’objet d’un nettoyage structurel et d’un découpage de ses fichiers les plus volumineux. La recherche universelle, les salles configurables, les traductions française et anglaise et une suite de contrôles isolés ont complété cette phase de stabilisation.
## `1.0.0-rc.1` — Première stabilisation
Nettoyage du projet, séparation des principales vues animales, découpage du contrôleur et validation initiale des parcours essentiels.
## `1.0.0-rc.2` — Recherche universelle
Recherche transversale respectant les permissions et poursuite du découpage du cœur applicatif.
## `1.0.0-rc.3` — Gel fonctionnel
Séparation des ressources visuelles et des routes, correction de l’installation vierge, anonymisation du dépôt public, création d’une fiche Twix fictive complète et harmonisation de la présentation du code avant le gel fonctionnel de la version 1.0.

22
LICENSE.md Normal file
View file

@ -0,0 +1,22 @@
# Licence de Globinours
Copyright © 2026 OverSu
Le code source de Globinours est distribué selon les termes de la **GNU Affero General Public License, version 3 ou toute version ultérieure** (`AGPL-3.0-or-later`).
Vous pouvez utiliser, étudier, modifier et redistribuer ce code selon les conditions de cette licence. Si vous modifiez Globinours et permettez à des utilisateurs d’interagir avec cette version à travers un réseau, vous devez leur proposer l’accès au code source correspondant dans les conditions prévues par l’AGPL.
Le texte officiel complet de la licence est disponible auprès du projet GNU :
<https://www.gnu.org/licenses/agpl-3.0.html>
## Éléments distincts
Sauf mention explicite contraire, la licence du code ne confère aucun droit sur :
- les données, photographies, documents médicaux ou informations personnelles d’une installation ;
- le logo et l’identité d’une association utilisatrice ;
- les modèles administratifs fournis par une association ;
- les marques, noms et contenus appartenant à des tiers.
Le nom et l’identité graphique **Globinours** restent associés au projet d’origine. Une version modifiée ne doit pas laisser croire qu’elle est publiée ou hébergée officiellement par Globinours sans autorisation.

View file

@ -1,5 +1,9 @@
<p align="center"><img src="resources/branding/Logo_Globinours_maxi.png" alt="Logo Globinours" width="180"></p>
<h1 align="center">Globinours</h1>
<p align="center"><strong>Nouveau :</strong> suivi structuré des chirurgies et comptabilité vétérinaire légère (factures, avoirs, règlements et justificatifs privés).</p>
<p align="center">Les factures sont corrigeables depuis leur ligne et leur suppression passe par une corbeille qui les exclut des totaux sans effacer la preuve.</p>
<p align="center">Le pilotage comptable analyse les paiements par structure et par année, les échéances, les règlements et le coût moyen rapproché des animaux, avec exports CSV et impression PDF.</p>
<p align="center">Les factures se filtrent par recherche, année, structure, statut, type de pièce et moyen de règlement ; les indicateurs et l’export suivent le même périmètre.</p>
<p align="center"><strong>La gestion quotidienne d’un refuge, pensée pour rester humaine, rapide et lisible.</strong><br>Né de l’histoire de Twix, conçu pour protéger les suivants.</p>
<p align="center">
<img alt="PHP 8.1+" src="https://img.shields.io/badge/PHP-8.1%2B-777BB4?logo=php&logoColor=white">
@ -13,7 +17,7 @@
## 🐾 Le projet
Globinours est une application web de gestion de refuge animalier, développée au plus près du fonctionnement réel de l’association **Les Coussinets**.
Globinours est une application web de gestion de refuge animalier, développée à partir de besoins concrets observés sur le terrain.
Elle centralise les animaux, leur santé, leurs documents, leurs liens familiaux, les familles d’accueil, les contacts et les actions quotidiennes. Son objectif est simple : permettre aux bénévoles et responsables de trouver ou saisir une information en quelques secondes, sans traverser une succession d’écrans complexes.
@ -25,6 +29,8 @@ Globinours privilégie :
- la traçabilité des actions importantes ;
- la maîtrise locale des données du refuge.
Une recherche globale accessible par `Ctrl+K` ou `⌘K` retrouve instantanément animaux, contacts, événements et factures. Chaque utilisateur ne voit que les catégories autorisées par son rôle ; aucune note ni pièce médicale sensible n’est indexée.
L’identité de l’association est personnalisable, logo compris. Ce logo est conservé dans l’espace privé de l’installation, sauvegardé avec les données et prévu pour alimenter les documents officiels.
Le pied de page identifie clairement le projet, son année de développement, sa version et son développeur, [OverSu](https://oversu.fr), avec un accès direct au [code source](https://nnsprod.com/git/oversu/Globinours).
@ -33,7 +39,7 @@ Le récit complet est conservé dans l’[Histoire de TWIX et Globinours](HISTOR
## 🐈 De TWIX à Globinours
Le projet est né au sein de l’association **Les Coussinets**, face à un constat très concret : les logiciels de refuge existants répondaient mal au travail quotidien. Les informations restaient dispersées, certaines opérations exigeaient trop de manipulations et des besoins vétérinaires ou administratifs essentiels manquaient.
Le projet est né au sein d’un refuge pilote, face à un constat très concret : les logiciels existants répondaient mal au travail quotidien. Les informations restaient dispersées, certaines opérations exigeaient trop de manipulations et des besoins vétérinaires ou administratifs essentiels manquaient.
L’ambition initiale n’était pas de construire un produit commercial, mais un compagnon de travail **pensé par un refuge, pour un refuge**. Le premier nom du logiciel fut **TWIX**, en hommage au premier chat sauvé par l’association.
@ -86,8 +92,8 @@ L’architecture a continué à s’alléger : DomPDF et les dépendances Compos
### Suivi médical et tournées sur tablette
- tournée du matin ou du soir ;
- salles collectives Silver et Twix ;
- salles de soins et de quarantaine avec box modulables ;
- salles collectives ou compartimentées entièrement configurables (nom, couleur, ordre et statut associé), avec deux salles collectives, une infirmerie et une quarantaine proposées par défaut ;
- nombre et libellé des box adaptables à chaque refuge, sans effacer l’historique des espaces désactivés ;
- plusieurs animaux par box ;
- traitements attendus affichés pendant la tournée ;
- référentiel de médicaments trié alphabétiquement et création à la volée lors du démarrage d’un traitement ;
@ -115,7 +121,7 @@ L’architecture a continué à s’alléger : DomPDF et les dépendances Compos
- registre annuel des entrées et sorties ;
- bilans statistiques filtrables et imprimables par commune d’origine pour accompagner les demandes de subvention ;
- modèles ODT accessibles depuis l’espace administratif ;
- prise en charge de modèles ODT propres à chaque association depuis `resources/documents/` ;
- contrats d’adoption et documents d’abandon préremplis ;
- certificat de visite pré-adoption disponible en ODT et en PDF imprimable depuis l’administration ;
- documents pour bénévoles et familles d’accueil ;
@ -131,6 +137,16 @@ L’architecture a continué à s’alléger : DomPDF et les dépendances Compos
- sauvegarde ZIP complète, téléchargeable et restaurable ;
- sauvegarde d’urgence automatique avant restauration.
## 📚 Documentation utilisateur
Le [manuel d’utilisation complet](docs/README.md) est écrit en Markdown et se consulte directement dans Forgejo. Son sommaire accompagne les bénévoles et responsables depuis la première connexion jusqu’aux animaux, soins, tournées, statistiques, documents et fonctions administratives. Des chapitres séparés couvrent les paramètres, permissions, sauvegardes, migrations ASM3, confidentialité, PWA et application Android.
La documentation suit les écrans réellement disponibles et reste versionnée avec le code : une installation donnée conserve ainsi le manuel correspondant à sa version.
Le projet possède également un **site promotionnel autonome** dans le dépôt voisin `Website/`. Cette vitrine statique, sans CDN ni compilation, présente clairement Globinours comme un « Logiciel de gestion de refuge » puis explique qu’il s’agit d’une application web utilisable dans un navigateur, sans installation sur les postes de l’équipe. Elle met en avant son origine bretonne avec le Gwen ha Du, illustre ses principaux parcours et prépare l’intégration d’une vidéo de démonstration sous-titrée.
Les repères français et bretons du site utilisent des drapeaux SVG locaux. Le bloc historique repose sur une silhouette cartographique réelle de la Bretagne, créditée dans le projet `Website`, et la grille fonctionnelle présente également la continuité par sauvegarde ainsi que le référentiel multi-espèces.
## 🖥️ Philosophie produit
Globinours ne cherche pas à devenir une suite universelle. Il couvre précisément le quotidien d’un refuge, avec des parcours courts et compréhensibles.
@ -156,7 +172,7 @@ refuge/
├── public/ # Racine web, assets et médias
├── resources/
│ ├── branding/ # Identité visuelle livrée avec Globinours
│ └── documents/ # Modèles ODT du logiciel
│ └── documents/ # Modèles ODT locaux de l’association, non versionnés
├── storage/
│ ├── backups/ # Sauvegardes ZIP
│ └── logs/ # Journal PHP local et privé
@ -179,7 +195,7 @@ refuge/
| Images | ImageMagick, WebP et conservation de l’original en mode de repli |
| Sauvegardes | ZipArchive, SHA-256 et contrôle d’intégrité SQLite |
Les bibliothèques d’interface sont actuellement chargées depuis leurs CDN. Aucun `composer install` ni `npm install` n’est nécessaire.
Les bibliothèques d’interface (Tabler, Chart.js, Flatpickr et Leaflet) sont livrées dans `public/assets/vendor/`. Aucun CDN, `composer install` ni `npm install` n’est nécessaire pour utiliser l’interface sur le réseau local.
## 🔐 Sécurité
@ -188,7 +204,9 @@ Les bibliothèques d’interface sont actuellement chargées depuis leurs CDN. A
- mots de passe avec `password_hash()` et `password_verify()` ;
- minimum de 10 caractères dans l’interface ;
- régénération de l’identifiant de session après connexion ;
- expiration après 30 minutes d’inactivité et durée absolue de 12 heures, configurables par environnement ;
- verrouillage de 15 minutes après 5 échecs ;
- limitation complémentaire des échecs par adresse IP et comparaison factice pour les comptes inconnus ;
- comptes désactivables ;
- protection empêchant un administrateur de retirer ses propres droits.
@ -196,12 +214,15 @@ Les bibliothèques d’interface sont actuellement chargées depuis leurs CDN. A
- cookies `HttpOnly` et `SameSite=Strict` ;
- attribut `Secure` activé sous HTTPS ;
- mode de session PHP strict et cookies uniquement, avec prise en charge explicite des reverse proxies de confiance ;
- jeton CSRF aléatoire vérifié sur les requêtes POST ;
- écritures autorisées selon le rôle ;
- redirections après connexion limitées aux chemins internes.
- en-têtes `nosniff`, anti-iframe, politique de référent limitée à l’origine et permissions navigateur restrictives ;
- redirections après connexion limitées aux chemins internes ;
- en-têtes `nosniff`, anti-iframe, HSTS sous HTTPS, politique CSP, politique de référent limitée à l’origine et permissions navigateur restrictives ;
- routes de maintenance limitées au mode développement, à la machine locale et aux administrateurs.
Les délais peuvent être adaptés avec `GLOBINOURS_SESSION_IDLE_SECONDS` et `GLOBINOURS_SESSION_ABSOLUTE_SECONDS`. Derrière YunoHost ou un autre reverse proxy non local, `GLOBINOURS_TRUSTED_PROXIES` doit contenir exclusivement ses adresses IP séparées par des virgules ; les en-têtes transférés provenant d’autres clients sont ignorés.
### Base et affichage
- requêtes paramétrées PDO et émulation désactivée ;
@ -213,7 +234,8 @@ Les bibliothèques d’interface sont actuellement chargées depuis leurs CDN. A
### Téléversements et audit
- inspection MIME des photos côté serveur ;
- distinction applicative des médias privés ;
- médias privés stockés hors de `public/`, servis uniquement par une route authentifiée et autorisée ;
- documents médicaux réservés aux rôles médicaux et chaque consultation privée journalisée ;
- audit nominatif des actions ;
- mots de passe et jetons CSRF exclus des métadonnées d’audit.
@ -221,7 +243,7 @@ Les bibliothèques d’interface sont actuellement chargées depuis leurs CDN. A
- snapshot cohérent par `VACUUM INTO` ;
- manifeste versionné et empreinte SHA-256 de chaque fichier ;
- rejet des chemins ZIP dangereux ;
- extraction fichier par fichier et rejet des chemins dangereux, liens symboliques et entrées absentes du manifeste ;
- `PRAGMA integrity_check` avant restauration ;
- sauvegarde automatique avant remplacement ;
- reconnexion obligatoire après restauration.
@ -243,7 +265,7 @@ Les bibliothèques d’interface sont actuellement chargées depuis leurs CDN. A
- LibreOffice, facultatif mais nécessaire pour convertir les ODT en PDF ;
- ImageMagick, recommandé pour optimiser automatiquement les images téléversées ;
- écriture autorisée dans `data`, `public/media` et `storage` ;
- accès Internet pour les CDN, la cartographie et les intégrations externes.
- accès Internet uniquement pour les fonds cartographiques et les intégrations externes (géocodage, I-CAD, notifications distantes) ; le cœur de l’interface reste utilisable sans Internet.
Exemple Debian/Ubuntu :
@ -282,7 +304,7 @@ Pour une démonstration temporaire à distance, le routeur `public/router.php` p
La racine du serveur web doit pointer vers `public/`, jamais vers la racine complète du projet. `data/` et `storage/` ne doivent pas être servis par HTTP.
Ainsi, si le projet se trouve dans `/var/www/globinours`, la racine documentaire du domaine doit être `/var/www/globinours/public`. L’adresse publique reste alors `https://lescoussinets.org/` — il ne faut pas ajouter `/public/` dans l’URL. Le fichier `public/.htaccess` fourni redirige les routes applicatives vers `index.php` sur Apache ou LiteSpeed, sous réserve que les règles `.htaccess` soient autorisées.
Ainsi, si le projet se trouve dans `/var/www/globinours`, la racine documentaire du domaine doit être `/var/www/globinours/public`. L’adresse publique reste alors `https://refuge.example.org/` — il ne faut pas ajouter `/public/` dans l’URL. Le fichier `public/.htaccess` fourni redirige les routes applicatives vers `index.php` sur Apache ou LiteSpeed, sous réserve que les règles `.htaccess` soient autorisées.
Configuration Nginx équivalente dans le bloc du domaine :
@ -316,6 +338,8 @@ Les 10 sauvegardes les plus récentes sont conservées localement. Elles peuvent
## 🧪 Vérifications
L’organisation physique du dépôt et les éléments temporaires sont détaillés dans [l’audit des fichiers](docs/15-audit-structure-fichiers.md). Les anciennes sources de récupération résident dans le dossier voisin `ARCHIVES/Globinours-recuperation-2026/` et les scripts concernés demandent leur chemin explicitement. Les migrations SQL font partie intégrante de l’application et ne doivent jamais être supprimées d’un paquet ou d’un déploiement.
```bash
find app public -name '*.php' -print0 | xargs -0 -n1 php -l
php scripts/check_translations.php
@ -340,13 +364,13 @@ La commande respecte la fréquence et la date du dernier envoi. Elle utilise la
| Étape | État | Contenu |
|---|:---:|---|
| Origines de TWIX | ✅ Historique | Besoin terrain des Coussinets, socle PHP/SQLite, premières fiches animales, médical et documents PDF |
| Origines du projet | ✅ Historique | Besoin terrain d’un refuge pilote, socle PHP/SQLite, premières fiches animales, médical et documents PDF |
| Passage à Globinours | ✅ Historique | Ouverture à d’autres associations, réorganisation du code, nouvelle identité et hommage durable à Twix |
| Fondations | ✅ Livré | Animaux, portées, médical, annuaire, documents, adoptions et tournées tablette |
| Continuité | ✅ Livré | Utilisateurs, audit, sauvegarde/restauration, archives recherchables et triables, consultation des fiches archivées, corbeille et décès |
| Dashboard opérationnel | ✅ Livré | Synthèse des urgences, soins, échéances, quarantaines, poids, tournées du jour et qualité des fiches |
| Statistiques du refuge | ✅ Livré | Comparaison N−1, activité, médical, profils, couleurs, races, âges, origines, séjours et photographie actuelle |
| Bilans communaux | ✅ Livré | Filtre annuel ou global par commune d’origine, villes cliquables, indicateurs recalculés et impression pour les subventions |
| Bilans communaux | ✅ Livré | Filtre annuel ou global, villes cliquables, indicateurs recalculés, impression et couverture distinguant communes documentées, déduites par fratrie et inconnues |
| Rôles et permissions | ✅ Livré | Accès par groupe et par module, séparation voir/modifier, navigation adaptée et contrôle serveur |
| Lieux de vie | ✅ Livré | Chronologie avant/après des statuts, salles, box, familles et adresses, attribution utilisateur et export CSV |
| Médias plus légers | ✅ Livré | Compression, orientation, redimensionnement et nettoyage des métadonnées à l’import |
@ -375,7 +399,7 @@ La commande respecte la fréquence et la date du dernier envoi. Elle utilise la
| Décès connus à l’année | ✅ Livré | Précision annuelle sans fausse date, âge au décès, décès après adoption et médailles mémorielles conservés dans les imports historiques |
| Statistiques historiques réconciliées | ✅ Livré | Entrées, sorties, adoptions, naissances, vaccinations et médical consolidés depuis les fiches métier, avec événements non datés séparés de la chronologie |
| Vermifuges | ✅ Livré | Catalogue distinct des médicaments (9 produits initiaux), historique structuré, échéances selon l’âge, rythme trimestriel adulte, administration groupée des portées et aperçu médical |
| Ordonnances et analyses | ✅ Livré | Documents privés, photo sur tablette, liaison aux traitements, résultats structurés, courbes par paramètre et références configurables par analyseur et espèce |
| Ordonnances et analyses | ✅ Livré | Documents privés, photo sur tablette, saisie verticale au clavier, résultats structurés, courbes et quatre bilans configurables par espèce : complet, rénal, hépatique et thyroïdien |
| Mini-site public | ✅ Livré | Accueil, navigation directe par espèce, fiches d’adoption, page libre, dons, contact cartographié, identité légale en pied de page, coordonnées protégées des robots simples et trois thèmes CSS légers |
| Installation guidée | ✅ Livré | Contrôle des prérequis et écritures, identité associative, langue, mode public, premier administrateur et verrouillage après installation |
| Santé et maintenance | ✅ Livré | Intégrité SQLite, migrations, stockage, sauvegardes, médias, configuration PHP et erreurs récentes depuis une page en lecture seule |
@ -388,13 +412,50 @@ La commande respecte la fréquence et la date du dernier envoi. Elle utilise la
| Fiches de l’annuaire | ✅ Livré | Lignes entièrement cliquables, animaux adoptés/accueillis/déposés — y compris archivés ou décédés —, personnes rattachées, compteur d’adoptions et dépenses cumulées des structures |
| Réconciliation I‑CAD | ✅ Livré | Consolidation transactionnelle des adoptants et dates d’adoption, création des fiches historiques manquantes et fusion contrôlée des doublons de contacts |
| Cohérence du parcours | ✅ Livré | Statut et emplacement recalculés depuis le dernier placement, historique et sortie synchronisés, corrections d’archives sans restauration |
| Qualité de production | 🚧 En progression | Étendre les tests aux parcours HTTP complets, rendre le déploiement reproductible et documenter la comparaison avec Animal Shelter Manager |
| Confidentialité et conservation | ✅ Livré | Export d’un contact, anonymisation contrôlée précédée d’une sauvegarde, révision selon une durée configurable, registre des fichiers privés et pages légales du mini-site |
| Qualité de production | 🚧 En progression | Étendre les tests aux parcours HTTP complets et documenter la comparaison avec Animal Shelter Manager |
| Déploiement reproductible | ✅ Livré | Nginx/PHP-FPM/systemd, maintenance, sauvegarde avant mise à niveau, migrations contrôlées et retour arrière du code |
| Publication YunoHost | 🧪 Prêt à tester | Paquet v2 audité et bundle local signé disponibles ; valider installation, mise à niveau, sauvegarde et restauration sur une instance YunoHost avant de figer l’archive publique |
| Paquets ZimaOS et CasaOS | 💡 À étudier | Préparer des applications légères fondées sur Docker, avec volumes persistants, installation guidée et mises à niveau sauvegardées ; vérifier d’abord les contraintes propres à chaque catalogue |
| Interface mobile et PWA | ✅ Socle livré | Navigation tactile, formulaires et fenêtres adaptatifs, installation PWA sans mise en cache des données métier |
| Appareils de confiance | ✅ Livré | Session persistante 90 jours sans stockage du mot de passe, registre personnel et révocation unitaire ou globale |
| Wrapper Android | 🧪 APK debug livré | Dépôt Java natif autonome sans bibliothèque applicative, instance HTTPS configurable, WebView cloisonnée, cookies persistants, verrouillage par la sécurité Android et APK signé de test ; validation sur appareils à réaliser |
La roadmap illustrée et l’historique des grandes évolutions sont également consultables directement dans l’application depuis le lien **Évolutions** du pied de page. Le détail exhaustif destiné au suivi Git se trouve dans [CHANGELOG.md](CHANGELOG.md).
### Confidentialité et conservation
La rubrique **Paramètres → Confidentialité / RGPD** offre un export portable par contact et des durées distinctes pour adoptants, familles d’accueil, bénévoles et autres contacts. La liste de révision bloque les dossiers encore actifs ; l’anonymisation exige un motif, la confirmation d’une vérification humaine et une sauvegarde préalable. Les relations non nominatives nécessaires aux statistiques sont conservées. Le registre des accès aux fichiers privés possède sa propre rétention et peut être purgé par la maintenance planifiée, sans jamais supprimer automatiquement un contact.
## 💊 Pharmacie et stocks
La pharmacie sépare médicaments, vermifuges et vaccins. Chaque produit possède une unité et un seuil minimal ; chaque lot conserve quantité, fournisseur, coût unitaire, numéro et péremption. Réceptions, administrations, pertes et corrections forment un journal nominatif. Les vaccins et vermifuges peuvent consommer directement un lot depuis la fiche animale. Une consommation tarifée rejoint automatiquement le coût estimé de l’animal et les statistiques financières. Les seuils bas et péremptions sous trente jours alimentent les notifications.
## ⏱ Sauvegarde automatique vérifiée
La fréquence quotidienne ou hebdomadaire se règle dans **Paramètres → Sauvegardes**. La commande `php scripts/maintenance.php run`, appelée régulièrement par cron ou systemd, crée uniquement les archives arrivées à échéance. Chaque ZIP est immédiatement rouvert : manifeste, empreintes SHA-256 et intégrité SQLite doivent tous être valides avant que l’exécution soit considérée réussie. La même page permet de revérifier manuellement n’importe quelle archive.
## 📅 Agenda partagé
L’agenda réunit rendez-vous généraux ou vétérinaires, transports, visites pré-adoption et tâches. Chaque élément peut être relié à un animal, un contact, un utilisateur responsable et plusieurs bénévoles issus de l’annuaire, avec priorité, lieu, description, horaire ou journée entière et rappel. Les filtres isolent son propre agenda, un responsable, un bénévole, une catégorie ou une recherche ; les rappels médicaux peuvent être masqués temporairement. Globinours avertit lorsqu’une même personne reçoit deux missions qui se chevauchent. L’équipe choisit une vue mensuelle ou hebdomadaire ; ce choix est conservé sur son compte. Un clic dans l’espace libre d’une journée ouvre la création avec la date déjà renseignée, y compris au clavier. La période affichée peut être téléchargée au format iCalendar (`.ics`). Chaque utilisateur peut également créer un lien d’abonnement privé compatible avec les principaux agendas, puis le révoquer ou le régénérer ; seul son hash SHA-256 est conservé en base. Les répétitions quotidiennes, hebdomadaires et mensuelles sont développées dans le calendrier sans dupliquer les enregistrements. Les rappels vaccinaux, prochains vermifuges ainsi que les dates de début et de fin des traitements déjà enregistrés dans les fiches animales apparaissent automatiquement dans le calendrier et ouvrent directement la bonne section du suivi médical, sans double saisie. Les échéances sous vingt-quatre heures rejoignent le dashboard et les notifications. Les droits de consultation et de modification sont configurables par groupe comme les autres modules.
### Déploiement et YunoHost
Les exemples pour Nginx, PHP-FPM et le service de démonstration sont dans [`deploy/`](deploy/README.md). `scripts/update.sh` vérifie une archive de version, sauvegarde les données, conserve le code précédent, active le mode maintenance, applique les migrations et contrôle SQLite ; `scripts/rollback.sh` restaure explicitement un snapshot de code sans tenter de rétrograder silencieusement la base.
Après la finalisation du paquet YunoHost, la feuille de route prévoit l’étude de paquets **ZimaOS** et **CasaOS**. Globinours devra disposer d’une image reproductible, de volumes persistants clairement séparés et d’un parcours de sauvegarde et de mise à niveau sûr avant toute publication dans un catalogue.
Le paquet **YunoHost packaging v2** vit désormais dans un dépôt voisin léger, `yunohost/`, et ne duplique plus le code de Globinours. Après le premier tag stable, il téléchargera l’archive officielle avec contrôle SHA-256. Les données réelles, médias et documents privés vivront dans le répertoire persistant YunoHost et ne seront pas exposés par la racine web.
### Mobile, PWA et Android
L’interface dispose d’un socle responsive commun aux ordinateurs, tablettes et smartphones : barre supérieure défilable, navigation tactile, onglets horizontaux, formulaires lisibles sans zoom iOS, modales adaptées et zones d’action suffisamment grandes. Le manifeste PWA permet d’installer Globinours depuis un navigateur compatible. Son service worker ne met en cache que l’enveloppe graphique publique et jamais les pages authentifiées ni les données du refuge.
La connexion peut créer un appareil de confiance pendant 90 jours sans conserver le mot de passe. Chaque utilisateur retrouve ses appareils depuis le menu du compte et peut les révoquer individuellement ou tous ensemble. Le dépôt voisin `Android/` contient un wrapper Java fondé uniquement sur les API natives : choix d’une instance HTTPS, WebView limitée à ce domaine, ouverture des liens externes dans le navigateur, cookies persistants et déverrouillage par la sécurité biométrique ou le code Android. Un APK debug signé est fourni pour les essais, sans vocation à être publié sur un store.
### Remise à zéro et base de démonstration
La page **Paramètres → Données et démo** est réservée aux administrateurs. Elle propose deux opérations protégées par une phrase de confirmation : vider toutes les données métier, ou les remplacer par un jeu entièrement fictif couvrant animaux, annuaire, portée, groupe inséparable, famille d’accueil, médical, poids, vaccins et décès. Une sauvegarde ZIP complète est créée avant chaque opération.
La page **Paramètres → Données et démo** est réservée aux administrateurs. Elle propose deux opérations protégées par une phrase de confirmation : vider toutes les données métier, ou les remplacer par un jeu entièrement fictif. Sa fiche vitrine Twix réunit photographie, identité, quarantaine, consultation, traitement, poids, vaccination, vermifugation, antécédent résolu, prise de sang et rendez-vous d’agenda ; les autres animaux restent volontairement plus légers pour illustrer l’annuaire, une portée, un groupe inséparable, une famille d’accueil et un décès. Une sauvegarde ZIP complète est créée avant chaque opération.
Les utilisateurs, rôles et permissions, paramètres de l’association, identité visuelle, modèles documentaires et sauvegardes existantes sont conservés. Les médias animaliers et justificatifs de subvention liés aux anciennes données sont retirés après validation de la transaction. Le jeu fictif est destiné aux démonstrations et ne doit jamais être confondu avec des données réelles.
@ -423,7 +484,31 @@ php scripts/recover-asm3-intake.php chemin/vers/dump.sql --apply
Le premier rapport inventorie les tables et les domaines migrables. Le plan JSON convertit ensuite chaque fiche animale vers les champs Globinours et la classe en `create`, `match` ou `review`. Une identification identique ou le couple nom + naissance permettent un rapprochement sûr. Le code refuge n’est jamais utilisé seul entre deux sauvegardes historiques : ASM3 peut l’avoir réattribué après une remise à zéro et Globinours suffixe alors le code importé sans confondre deux animaux.
La reprise des entrées lit le champ ASM3 `REASONFORENTRY`, conserve son texte dans les circonstances et ne structure une commune que lorsqu’elle a été relue comme lieu réel de prise en charge. Une sauvegarde précède l’écriture et un CSV distingue les communes certaines des dossiers ambigus.
La reprise des entrées lit le champ ASM3 `REASONFORENTRY`, conserve son texte dans les circonstances et ne structure une commune que lorsqu’elle a été relue comme lieu réel de prise en charge. Une sauvegarde précède l’écriture et un CSV distingue les communes certaines des dossiers ambigus. Une seconde moulinette peut compléter les membres d’une portée lorsque la mère, les frères et sœurs, les dates et les codes refuge rendent le rapprochement solide : ces valeurs restent marquées `inferred_family`, accompagnées de leur justification, et ne sont jamais confondues avec une saisie documentée.
Les animaux nés sous la responsabilité du refuge restent volontairement sans commune de récupération : leur entrée structurée `born_in_care` / `birth` les distingue des origines réellement inconnues dans les statistiques. Une naissance au refuge n’est donc jamais transformée artificiellement en récupération sur la commune où se trouvent les locaux.
Les statistiques historiques réunissent une date d’entrée portée par la fiche et un éventuel mouvement d’entrée, avec dédoublonnage par animal. Lorsqu’un décès sous prise en charge possède une année certaine mais aucune date d’entrée, l’animal contribue au nombre de prises en charge de cette année sans être présenté comme une entrée datée. Les événements sans année restent dans « Toutes les années » et ne sont jamais répartis artificiellement.
```bash
php scripts/reconstruct-asm3-rescue-locations.php /chemin/dump_asm3.sql
php scripts/reconstruct-asm3-rescue-locations.php /chemin/dump_asm3.sql --apply
```
La première commande produit uniquement un rapport de simulation. L’application refuse tout conflit, crée une sauvegarde, synchronise le premier mouvement d’entrée et inscrit la justification dans l’historique. Les adresses de vétérinaires, adoptants ou tiers ne sont pas utilisées comme communes d’origine sans preuve explicite.
Les classeurs historiques 2022-2023 peuvent ensuite compléter les communes grâce au code, au nom et à l’espèce. Un identifiant réutilisé ne suffit jamais à lui seul et une même fiche retrouvée dans plusieurs communes reste dans le rapport des conflits.
```bash
php scripts/recover-2022-2023-xlsx.php /chemin/listing.xlsx
php scripts/recover-2022-2023-xlsx.php /chemin/listing.xlsx --apply
```
La même commande reconstitue, de façon explicitement estimative, la stérilisation des chats dont la première adoption connue intervient après six mois. Elle ne fabrique pas de date médicale : la date d’adoption sert uniquement de date comptable, le raisonnement et la source tarifaire restant inscrits sur la dépense et dans l’historique.
Lorsqu’un animal est attesté dans un registre mais absent de la base, il doit être recréé comme fiche historique distincte plutôt que fusionné avec le porteur actuel d’un ancien code réutilisé. Les informations certaines sont conservées ; dates, parentés ou actes non prouvés restent vides.
Les tarifs des partenaires doivent être saisis ou importés par chaque installation depuis ses propres sources. Aucune grille tarifaire réelle n’est livrée dans le dépôt.
Sans `--apply`, les commandes restent en simulation. En mode réel, elles exigent une confirmation textuelle, créent une sauvegarde, utilisent des transactions et mémorisent les correspondances ASM3 afin qu’une reprise ne duplique rien. Les rapprochements et cas ambigus ne sont jamais fusionnés automatiquement.
@ -470,6 +555,8 @@ Le dashboard signale également les fiches actives auxquelles il manque une date
Les dépenses vétérinaires peuvent être analysées par commune d’origine, cabinet et catégorie d’acte. Globinours affiche le coût total, le nombre d’animaux du périmètre, ceux ayant réellement généré une dépense, la moyenne sur l’ensemble des animaux, la moyenne sur les seuls animaux avec frais et leur médiane. Cette lecture produit des chiffres directement exploitables pour argumenter une demande de subvention communale sans laisser un cas médical exceptionnel fausser toute l’analyse.
Dans le bilan par structure, chaque cabinet, crématorium ou fourrière identifié est cliquable. Sa fiche reprend exactement la période — et, le cas échéant, la commune — sélectionnée dans les statistiques, puis détaille le total payé ou facturé et chaque dépense par animal. Le refuge peut ainsi chiffrer objectivement son activité apportée à un partenaire pour une année donnée.
Pour une année, le périmètre financier comprend les animaux pris en charge pendant cette année ou ayant généré une dépense pendant celle-ci. Le bilan global utilise toutes les fiches non supprimées. La commune est déduite du lieu de récupération, jamais du cabinet vétérinaire ni de la famille d’accueil.
Les formulaires officiels des communes ne sont pas générés par Globinours : ils évoluent trop souvent pour garantir un résultat fiable. Le logiciel réunit plutôt les chiffres, le bilan et les justificatifs nécessaires à leur saisie.
@ -500,6 +587,10 @@ Le contrôle `php scripts/check_translations.php` vérifie les clés utilisées,
Les confirmations destructives sont elles aussi localisées et contrôlées côté serveur. Par exemple, la remise à zéro attend `VIDER LA BASE` en français ou `CLEAR DATABASE` en anglais ; le chargement de démonstration et la migration ASM3 suivent la même règle. La phrase attendue provient toujours du catalogue actif, jamais d’une constante cachée dans le contrôleur.
## 📣 Présentation publique du projet
Le site promotionnel autonome documente désormais précisément les publics visés, les différences avec une organisation par tableurs ou une solution généraliste, le parcours sécurisé de migration ASM3 et les trois modes de déploiement. La démonstration publique, les captures définitives et la vidéo finale sont volontairement réservées à la version 1.0. Le scénario, les sous-titres et la liste de vues à produire sont déjà préparés ; toutes les images devront provenir du jeu de données fictif intégré.
## ⚠️ Préparer le dépôt Git
Ne jamais publier les données réelles du refuge. Au minimum :
@ -519,8 +610,22 @@ Les modèles ODT et les éléments graphiques propres à Globinours placés dans
## 📄 Licence
Aucune licence de redistribution n’est encore fournie. Avant de rendre le dépôt public, choisissez une licence et vérifiez que les documents, logos et modèles peuvent être diffusés.
Le code source de Globinours est distribué sous licence **GNU AGPLv3 ou ultérieure**. Elle autorise l’utilisation, l’étude, la modification et la redistribution, tout en demandant qu’une version modifiée proposée à travers un réseau rende également son code source correspondant accessible à ses utilisateurs. Consultez [LICENSE.md](LICENSE.md).
Les données d’une installation, les photographies, les documents privés, l’identité d’une association et les modèles fournis par des tiers ne deviennent jamais libres du seul fait qu’ils sont utilisés avec Globinours. Le nom et l’identité graphique Globinours ne doivent pas être employés pour faire passer une version modifiée pour une offre officielle.
---
<p align="center"><strong>Globinours</strong><br>Un outil léger, construit autour des animaux et des personnes qui prennent soin d’eux.</p>
### Candidates de publication 1.0
| Candidate | Date | État | Jalons principaux |
|---|---|---|---|
| `1.0.0-rc.1` | 2 septembre 2026 | Remplacée | Nettoyage du projet, première découpe de la fiche et du contrôleur animal, sauvegardes compatibles avec les volumes synchronisés. |
| `1.0.0-rc.2` | 2 septembre 2026 | Remplacée | Recherche universelle, second découpage du contrôleur animal et 23e test automatisé. |
| `1.0.0-rc.3` | 3 septembre 2026 | **Candidate gelée** | Squelette visuel et routes séparés, installation vierge corrigée, démonstration Twix complète, sources PHP harmonisées, 25 contrôles validés et gel fonctionnel. |
**Version candidate actuelle : `1.0.0-rc.3`.** Aucun nouveau développement fonctionnel n’entre désormais dans la branche 1.0 : seuls les défauts bloquants constatés pendant les dernières validations terrain peuvent être corrigés avant l’attribution de la version `1.0.0`.
Le résultat du contrôle de stabilisation est consultable dans [docs/AUDIT-1.0.md](docs/AUDIT-1.0.md).

1
VERSION Normal file
View file

@ -0,0 +1 @@
1.0.0-rc.3

View file

@ -0,0 +1,513 @@
<?php
declare(strict_types=1);
final class AccountingController
{
private static function dir(): string
{
$d = dirname(__DIR__, 2) . '/data/accounting-documents';
if (!is_dir($d)) {
mkdir($d, 0700, true);
}
return $d;
}
private static function vendors(PDO $db): array
{
return $db
->query(
"SELECT DISTINCT dc.id,dc.name FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id WHERE dc.deleted_at IS NULL AND r.role IN ('cabinet','crematorium','fourriere','structure_veterinaire') ORDER BY dc.name COLLATE NOCASE",
)
->fetchAll(PDO::FETCH_ASSOC);
}
public static function index(): void
{
$db = DB::pdo();
$year = (int) ($_GET['year'] ?? 0);
$where = ['ai.deleted_at IS NULL'];
$params = [];
if ($year) {
$where[] = "strftime('%Y',ai.invoice_date)=:year";
$params[':year'] = (string) $year;
}
$s = $db->prepare(
'SELECT ai.*,dc.name vendor_name FROM accounting_invoices ai LEFT JOIN directory_contacts dc ON dc.id=ai.vendor_contact_id WHERE ' .
implode(' AND ', $where) .
' ORDER BY date(ai.invoice_date) DESC,ai.id DESC',
);
$s->execute($params);
$rows = $s->fetchAll(PDO::FETCH_ASSOC);
$years = $db
->query(
"SELECT DISTINCT strftime('%Y',invoice_date) y FROM accounting_invoices WHERE deleted_at IS NULL ORDER BY y DESC",
)
->fetchAll(PDO::FETCH_COLUMN);
render('accounting.php', [
'title' => 'Comptabilité',
'invoices' => $rows,
'vendors' => self::vendors($db),
'years' => $years,
'year' => $year,
]);
}
public static function edit(): void
{
$id = (int) ($_GET['id'] ?? 0);
$db = DB::pdo();
$s = $db->prepare('SELECT * FROM accounting_invoices WHERE id=? AND deleted_at IS NULL');
$s->execute([$id]);
$invoice = $s->fetch(PDO::FETCH_ASSOC);
if (!$invoice) {
http_response_code(404);
return;
}
render('accounting_edit.php', [
'title' => 'Modifier la facture ' . $invoice['reference'],
'invoice' => $invoice,
'vendors' => self::vendors($db),
]);
}
public static function save(): void
{
$values = self::values();
$file = self::upload();
$db = DB::pdo();
$s = $db->prepare(
'INSERT INTO accounting_invoices(vendor_contact_id,reference,invoice_date,due_date,document_type,status,payment_method,paid_on,total_ht_cents,total_ttc_cents,amount_due_cents,notes,source,original_filename,stored_filename,mime_type,file_size,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
);
$s->execute([
...$values,
'Saisie manuelle',
$file['original'] ?? null,
$file['stored'] ?? null,
$file['mime'] ?? null,
$file['size'] ?? null,
Auth::id(),
]);
AuditService::log(
'accounting_invoice_created',
'/accounting/save',
'Facture ajoutée',
'accounting_invoice',
(int) $db->lastInsertId(),
['reference' => $values[1]],
);
header('Location: /accounting?year=0');
}
public static function update(): void
{
$id = (int) ($_POST['id'] ?? 0);
$db = DB::pdo();
$q = $db->prepare('SELECT * FROM accounting_invoices WHERE id=? AND deleted_at IS NULL');
$q->execute([$id]);
$old = $q->fetch(PDO::FETCH_ASSOC);
if (!$old) {
http_response_code(404);
return;
}
$values = self::values();
$file = self::upload();
$sql =
"UPDATE accounting_invoices SET vendor_contact_id=?,reference=?,invoice_date=?,due_date=?,document_type=?,status=?,payment_method=?,paid_on=?,total_ht_cents=?,total_ttc_cents=?,amount_due_cents=?,notes=?,updated_at=datetime('now')";
if ($file) {
$sql .= ',original_filename=?,stored_filename=?,mime_type=?,file_size=?';
array_push($values, $file['original'], $file['stored'], $file['mime'], $file['size']);
}
$sql .= ' WHERE id=? AND deleted_at IS NULL';
$values[] = $id;
$db->prepare($sql)->execute($values);
if ($file && !empty($old['stored_filename'])) {
@unlink(self::dir() . '/' . basename((string) $old['stored_filename']));
}
AuditService::log(
'accounting_invoice_updated',
'/accounting/update',
'Facture modifiée',
'accounting_invoice',
$id,
);
header('Location: /accounting?year=0');
}
public static function delete(): void
{
$id = (int) ($_POST['id'] ?? 0);
$db = DB::pdo();
$s = $db->prepare(
"UPDATE accounting_invoices SET deleted_at=datetime('now'),deleted_by=?,updated_at=datetime('now') WHERE id=? AND deleted_at IS NULL",
);
$s->execute([Auth::id(), $id]);
if ($s->rowCount()) {
AuditService::log(
'accounting_invoice_deleted',
'/accounting/delete',
'Facture mise à la corbeille',
'accounting_invoice',
$id,
);
}
header('Location: /accounting?year=0');
}
public static function document(): void
{
$id = (int) ($_GET['id'] ?? 0);
$s = DB::pdo()->prepare('SELECT * FROM accounting_invoices WHERE id=? AND deleted_at IS NULL');
$s->execute([$id]);
$r = $s->fetch(PDO::FETCH_ASSOC);
if (!$r || !$r['stored_filename']) {
http_response_code(404);
return;
}
$p = self::dir() . '/' . basename((string) $r['stored_filename']);
if (!is_file($p)) {
http_response_code(404);
return;
}
header('Content-Type: ' . $r['mime_type']);
header(
'Content-Disposition: inline; filename="' . str_replace('"', '', (string) $r['original_filename']) . '"',
);
header('Content-Length: ' . filesize($p));
readfile($p);
}
private static function values(): array
{
$ref = trim((string) ($_POST['reference'] ?? ''));
$date = (string) ($_POST['invoice_date'] ?? '');
$ttc = self::money($_POST['total_ttc'] ?? '');
if ($ref === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) || $ttc === null) {
http_response_code(400);
exit(t('accounting.required_fields'));
}
$status = (string) ($_POST['status'] ?? 'to_pay');
$due = self::money($_POST['amount_due'] ?? '');
if ($due === null) {
$due = $status === 'paid' ? 0 : $ttc;
}
return [
(int) ($_POST['vendor_contact_id'] ?? 0) ?: null,
$ref,
$date,
$_POST['due_date'] ?? '' ?: null,
(string) ($_POST['document_type'] ?? 'invoice'),
$status,
$_POST['payment_method'] ?? '' ?: null,
$_POST['paid_on'] ?? '' ?: null,
self::money($_POST['total_ht'] ?? ''),
$ttc,
$due,
trim((string) ($_POST['notes'] ?? '')) ?: null,
];
}
private static function money(mixed $v): ?int
{
$v = trim(str_replace([' ', '€', "\xc2\xa0"], ['', '', ''], (string) $v));
if ($v === '') {
return null;
}
$v = str_replace(',', '.', $v);
return is_numeric($v) ? (int) round((float) $v * 100) : null;
}
private static function upload(): array
{
if (empty($_FILES['document']['tmp_name'])) {
return [];
}
$f = $_FILES['document'];
if (($f['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || ($f['size'] ?? 0) > 20 * 1024 * 1024) {
throw new RuntimeException(t('accounting.document_invalid'));
}
$mime = new finfo(FILEINFO_MIME_TYPE)->file($f['tmp_name']);
$ext =
['application/pdf' => 'pdf', 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'][$mime] ??
null;
if (!$ext) {
throw new RuntimeException(t('accounting.document_format'));
}
$stored = bin2hex(random_bytes(20)) . '.' . $ext;
if (!move_uploaded_file($f['tmp_name'], self::dir() . '/' . $stored)) {
throw new RuntimeException(t('accounting.document_save_failed'));
}
return [
'original' => basename((string) $f['name']),
'stored' => $stored,
'mime' => $mime,
'size' => (int) $f['size'],
];
}
public static function dashboard(): void
{
$db = DB::pdo();
$year = (int) ($_GET['year'] ?? 0);
$where = ['ai.deleted_at IS NULL'];
$params = [];
if ($year) {
$where[] = "strftime('%Y',ai.invoice_date)=:year";
$params[':year'] = (string) $year;
}
$sql =
'SELECT ai.*,dc.name vendor_name FROM accounting_invoices ai LEFT JOIN directory_contacts dc ON dc.id=ai.vendor_contact_id WHERE ' .
implode(' AND ', $where) .
' ORDER BY date(ai.invoice_date) DESC,ai.id DESC';
$s = $db->prepare($sql);
$s->execute($params);
$rows = $s->fetchAll(PDO::FETCH_ASSOC);
[$rows, $filters] = self::filterRows($rows);
$vendors = [];
$annual = [];
$statuses = [];
$methods = [];
$totals = ['billed' => 0, 'due' => 0, 'documents' => count($rows), 'overdue' => 0, 'to_pay' => 0];
foreach ($rows as $r) {
$ttc = (int) $r['total_ttc_cents'];
$due = (int) ($r['amount_due_cents'] ?? 0);
$totals['billed'] += $ttc;
$totals['due'] += $due;
if ($due > 0) {
$totals['to_pay']++;
}
if ($due > 0 && !empty($r['due_date']) && $r['due_date'] < date('Y-m-d')) {
$totals['overdue']++;
}
$vendorId = (int) ($r['vendor_contact_id'] ?? 0);
$vendorLabel = $r['vendor_name'] ?: 'Structure non renseignée';
$vendors[$vendorId] ??= [
'id' => $vendorId,
'label' => $vendorLabel,
'billed' => 0,
'due' => 0,
'documents' => 0,
'paid_documents' => 0,
'animals' => 0,
'animal_cost' => 0,
];
$vendors[$vendorId]['billed'] += $ttc;
$vendors[$vendorId]['due'] += $due;
$vendors[$vendorId]['documents']++;
if ($r['status'] === 'paid') {
$vendors[$vendorId]['paid_documents']++;
}
$y = substr((string) $r['invoice_date'], 0, 4);
$annual[$y] ??= ['year' => $y, 'billed' => 0, 'due' => 0, 'documents' => 0];
$annual[$y]['billed'] += $ttc;
$annual[$y]['due'] += $due;
$annual[$y]['documents']++;
$statuses[$r['status']] = ($statuses[$r['status']] ?? 0) + 1;
$method = $r['payment_method'] ?: 'unknown';
$methods[$method] = ($methods[$method] ?? 0) + 1;
}
$expenseWhere = $year ? " AND strftime('%Y',ae.occurred_on)=:expense_year" : '';
$expense = $db->prepare(
"SELECT ae.clinic_contact_id,COUNT(DISTINCT ae.animal_id) animals,SUM(ae.total_cents) total FROM animal_expenses ae WHERE ae.clinic_contact_id IS NOT NULL $expenseWhere GROUP BY ae.clinic_contact_id",
);
$expense->execute($year ? [':expense_year' => (string) $year] : []);
foreach ($expense->fetchAll(PDO::FETCH_ASSOC) as $e) {
if (isset($vendors[(int) $e['clinic_contact_id']])) {
$vendors[(int) $e['clinic_contact_id']]['animals'] = (int) $e['animals'];
$vendors[(int) $e['clinic_contact_id']]['animal_cost'] = (int) $e['total'];
}
}
usort($vendors, fn($a, $b) => $b['billed'] <=> $a['billed']);
ksort($annual);
$years = $db
->query(
"SELECT DISTINCT strftime('%Y',invoice_date) y FROM accounting_invoices WHERE deleted_at IS NULL ORDER BY y DESC",
)
->fetchAll(PDO::FETCH_COLUMN);
render('accounting.php', [
'title' => 'Comptabilité',
'invoices' => $rows,
'vendors' => self::vendors($db),
'years' => $years,
'year' => $year,
'accountingFilters' => $filters,
'accountingTotals' => $totals,
'vendorStats' => $vendors,
'annualStats' => array_values($annual),
'statusStats' => $statuses,
'methodStats' => $methods,
]);
}
public static function exportCsv(): void
{
self::export(false);
}
private static function export(bool $unused): void
{
$db = DB::pdo();
$year = (int) ($_GET['year'] ?? 0);
$where = ['ai.deleted_at IS NULL'];
$params = [];
if ($year) {
$where[] = "strftime('%Y',ai.invoice_date)=:year";
$params[':year'] = (string) $year;
}
$s = $db->prepare(
'SELECT ai.*,dc.name vendor_name FROM accounting_invoices ai LEFT JOIN directory_contacts dc ON dc.id=ai.vendor_contact_id WHERE ' .
implode(' AND ', $where) .
' ORDER BY date(ai.invoice_date),ai.id',
);
$s->execute($params);
header('Content-Type: text/csv; charset=UTF-8');
header(
'Content-Disposition: attachment; filename="globinours-comptabilite-' .
($year ?: 'toutes-annees') .
'.csv"',
);
echo "\xEF\xBB\xBF";
$out = fopen('php://output', 'wb');
fputcsv(
$out,
[
'Date',
'Référence',
'Type',
'Structure',
'HT €',
'TTC €',
'Statut',
'Reste dû €',
'Règlement',
'Échéance',
'Notes',
],
';',
);
$safe = static function ($v) {
$v = (string) $v;
return preg_match('/^[=+\-@]/', $v) ? "'" . $v : $v;
};
foreach ($s->fetchAll(PDO::FETCH_ASSOC) as $r) {
fputcsv(
$out,
[
$r['invoice_date'],
$safe($r['reference']),
$r['document_type'],
$safe($r['vendor_name'] ?? ''),
$r['total_ht_cents'] === null ? '' : number_format($r['total_ht_cents'] / 100, 2, '.', ''),
number_format($r['total_ttc_cents'] / 100, 2, '.', ''),
$r['status'],
number_format(($r['amount_due_cents'] ?? 0) / 100, 2, '.', ''),
$r['payment_method'],
$r['due_date'],
$safe($r['notes'] ?? ''),
],
';',
);
}
fclose($out);
}
private static function filterRows(array $rows): array
{
$filters = [
'q' => trim((string) ($_GET['q'] ?? '')),
'vendor_id' => (int) ($_GET['vendor_id'] ?? 0),
'status' => (string) ($_GET['status'] ?? ''),
'type' => (string) ($_GET['type'] ?? ''),
'payment_method' => (string) ($_GET['payment_method'] ?? ''),
];
$rows = array_values(
array_filter($rows, static function ($r) use ($filters) {
if (
$filters['q'] !== '' &&
!str_contains(
mb_strtolower(
implode(' ', [$r['reference'] ?? '', $r['vendor_name'] ?? '', $r['notes'] ?? '']),
),
mb_strtolower($filters['q']),
)
) {
return false;
}
if ($filters['vendor_id'] > 0 && (int) $r['vendor_contact_id'] !== $filters['vendor_id']) {
return false;
}
if ($filters['status'] !== '' && $r['status'] !== $filters['status']) {
return false;
}
if ($filters['type'] !== '' && $r['document_type'] !== $filters['type']) {
return false;
}
if ($filters['payment_method'] === 'unknown' && !empty($r['payment_method'])) {
return false;
}
if (
$filters['payment_method'] !== '' &&
$filters['payment_method'] !== 'unknown' &&
$r['payment_method'] !== $filters['payment_method']
) {
return false;
}
return true;
}),
);
return [$rows, $filters];
}
public static function exportFilteredCsv(): void
{
$db = DB::pdo();
$year = (int) ($_GET['year'] ?? 0);
$s = $db->prepare(
'SELECT ai.*,dc.name vendor_name FROM accounting_invoices ai LEFT JOIN directory_contacts dc ON dc.id=ai.vendor_contact_id WHERE ai.deleted_at IS NULL' .
($year ? " AND strftime('%Y',ai.invoice_date)=:year" : '') .
' ORDER BY date(ai.invoice_date),ai.id',
);
$s->execute($year ? [':year' => (string) $year] : []);
[$rows] = self::filterRows($s->fetchAll(PDO::FETCH_ASSOC));
header('Content-Type: text/csv; charset=UTF-8');
header(
'Content-Disposition: attachment; filename="globinours-comptabilite-' .
($year ?: 'toutes-annees') .
'.csv"',
);
echo "\xEF\xBB\xBF";
$out = fopen('php://output', 'wb');
fputcsv(
$out,
[
'Date',
'Référence',
'Type',
'Structure',
'HT €',
'TTC €',
'Statut',
'Reste dû €',
'Règlement',
'Échéance',
'Notes',
],
';',
);
$safe = static fn($v) => preg_match('/^[=+\-@]/', (string) $v) ? "'" . $v : (string) $v;
foreach ($rows as $r) {
fputcsv(
$out,
[
$r['invoice_date'],
$safe($r['reference']),
$r['document_type'],
$safe($r['vendor_name'] ?? ''),
$r['total_ht_cents'] === null ? '' : number_format($r['total_ht_cents'] / 100, 2, '.', ''),
number_format($r['total_ttc_cents'] / 100, 2, '.', ''),
$r['status'],
number_format(($r['amount_due_cents'] ?? 0) / 100, 2, '.', ''),
$r['payment_method'],
$r['due_date'],
$safe($r['notes'] ?? ''),
],
';',
);
}
fclose($out);
}
public static function editByReference(): void
{
$ref = trim((string) ($_GET['reference'] ?? ''));
$s = DB::pdo()->prepare(
'SELECT id FROM accounting_invoices WHERE reference=? AND deleted_at IS NULL ORDER BY id DESC LIMIT 1',
);
$s->execute([$ref]);
$_GET['id'] = (int) $s->fetchColumn();
self::edit();
}
}

View file

@ -0,0 +1,101 @@
<?php
class AdminController
{
public static function exports(): void
{
self::adminOnly();
render('admin_exports.php', ['title' => t('admin.exports_title'), 'datasets' => ExportService::catalog()]);
}
public static function downloadExport(): void
{
self::adminOnly();
$type = (string) ($_GET['type'] ?? '');
try {
AuditService::log(
'data_export',
'/admin/exports/download',
$type === 'all' ? 'Export complet des données' : 'Export CSV : ' . $type,
);
if ($type === 'all') {
ExportService::downloadZip();
} else {
ExportService::downloadCsv($type);
}
} catch (InvalidArgumentException) {
http_response_code(404);
echo h(t('admin.unknown_export'));
} catch (Throwable $e) {
error_log('Échec export ' . $type . ' : ' . $e->getMessage());
http_response_code(500);
echo h(t('admin.export_failed'));
}
}
public static function register(): void
{
[$rows, $year, $q, $species, $sort, $dir] = self::registerData();
render(
'admin_register.php',
compact('rows', 'year', 'q', 'species', 'sort', 'dir') + ['title' => t('admin.title')],
);
}
public static function registerPdf(): void
{
[$rows, $year, $q, $species, $sort, $dir] = self::registerData();
$generatedAt = new DateTimeImmutable('now', new DateTimeZone('Europe/Paris'));
header('Content-Type: text/html; charset=UTF-8');
require __DIR__ . '/../Views/admin_register_print.php';
}
private static function registerData(): array
{
$pdo = DB::pdo();
$year = isset($_GET['year']) ? (int) $_GET['year'] : (int) date('Y');
$q = trim((string) ($_GET['q'] ?? ''));
$species = trim((string) ($_GET['species'] ?? ''));
$allowedSort = [
'date' => 'm.created_at',
'name' => 'a.name',
'species' => 'a.species',
'sex' => 'a.sex',
'type' => 'm.kind',
];
$sort = (string) ($_GET['sort'] ?? 'date');
if (!isset($allowedSort[$sort])) {
$sort = 'date';
}
$dir = strtolower((string) ($_GET['dir'] ?? 'asc')) === 'desc' ? 'desc' : 'asc';
$sql = "SELECT m.*, a.id AS animal_id, a.internal_code, a.name, a.sex, a.species, a.birth_date, a.chip_id,
d.cause_code death_cause_code,d.cause_details death_cause_details,d.euthanized death_euthanized,d.body_disposition death_disposition,d.cremation_date,
vet.name death_veterinarian,crem.name death_crematorium
FROM animal_movements m JOIN animals a ON a.id=m.animal_id
LEFT JOIN animal_deaths d ON d.animal_id=m.animal_id AND m.kind='exit' AND lower(m.lieu) IN ('décès','deces','décès de l''animal','deces de l''animal')
LEFT JOIN directory_contacts vet ON vet.id=d.veterinarian_contact_id
LEFT JOIN directory_contacts crem ON crem.id=d.crematorium_contact_id
WHERE a.deleted_at IS NULL AND a.archived_at IS NULL
AND m.created_at BETWEEN :ys AND :ye";
$params = [':ys' => sprintf('%04d-01-01 00:00:00', $year), ':ye' => sprintf('%04d-12-31 23:59:59', $year)];
if ($q !== '') {
$sql .= ' AND (a.name LIKE :q OR a.internal_code LIKE :q OR a.chip_id LIKE :q)';
$params[':q'] = "%$q%";
}
if ($species !== '') {
$sql .= ' AND lower(a.species)=:species';
$params[':species'] = strtolower($species);
}
$sql .= ' ORDER BY ' . $allowedSort[$sort] . ' ' . strtoupper($dir);
$statement = $pdo->prepare($sql);
$statement->execute($params);
return [$statement->fetchAll(PDO::FETCH_ASSOC), $year, $q, $species, $sort, $dir];
}
private static function adminOnly(): void
{
if (!PermissionService::can('administrative', 'view')) {
http_response_code(403);
echo h(t('admin.forbidden'));
exit();
}
}
}

View file

@ -0,0 +1,70 @@
<?php
require_once __DIR__ . '/../Services/AdoptionService.php';
class AdoptionsController
{
public static function store(): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /animals');
exit();
}
$animalId = (int) ($_POST['animal_id'] ?? 0);
$name = trim($_POST['adopter_name'] ?? '');
if (!$animalId || $name === '') {
header('Location: /animal/show?id=' . $animalId);
exit();
}
$data = [
'animal_id' => $animalId,
'adopter_name' => $name,
'adopter_phone' => trim($_POST['adopter_phone'] ?? ''),
'adopter_email' => trim($_POST['adopter_email'] ?? ''),
'adopter_address' => trim($_POST['adopter_address'] ?? ''),
'adopter_postal_code' => trim($_POST['adopter_postal_code'] ?? ''),
'adopter_city' => trim($_POST['adopter_city'] ?? ''),
'adoption_date' => $_POST['adoption_date'] ?? date('Y-m-d'),
'notes' => trim($_POST['notes'] ?? ''),
];
$db = DB::pdo();
$contactId = (int) ($_POST['adopter_contact_id'] ?? 0);
$contactData = [
':name' => $data['adopter_name'],
':phone' => $data['adopter_phone'] ?: null,
':email' => $data['adopter_email'] ?: null,
':address' => $data['adopter_address'] ?: null,
':postal' => $data['adopter_postal_code'] ?: null,
':city' => $data['adopter_city'] ?: null,
];
if ($contactId > 0) {
$contactData[':id'] = $contactId;
$db->prepare(
"UPDATE directory_contacts SET name=:name,phone=:phone,email=:email,address=:address,postal_code=:postal,city=:city,updated_at=datetime('now') WHERE id=:id AND deleted_at IS NULL",
)->execute($contactData);
} else {
$db->prepare(
"INSERT INTO directory_contacts(kind,name,phone,email,address,postal_code,city) VALUES('person',:name,:phone,:email,:address,:postal,:city)",
)->execute($contactData);
$contactId = (int) $db->lastInsertId();
}
$db->prepare("INSERT OR IGNORE INTO directory_contact_roles(contact_id,role) VALUES(:id,'adoptant')")->execute([
':id' => $contactId,
]);
$data['adopter_contact_id'] = $contactId;
try {
AdoptionService::adopt($data);
header('Location: /animal?id=' . $animalId);
exit();
} catch (Throwable $e) {
error_log('Échec adoption animal #' . $animalId . ' : ' . $e->getMessage());
http_response_code(500);
echo h(t('error.adoption_failed'));
}
}
}

View file

@ -0,0 +1,267 @@
<?php
declare(strict_types=1);
final class AgendaController
{
public static function index(): void
{
$month = (string) ($_GET['month'] ?? date('Y-m'));
if (!preg_match('/^\d{4}-\d{2}$/', $month)) {
$month = date('Y-m');
}
$cursor =
DateTimeImmutable::createFromFormat('!Y-m', $month) ?: new DateTimeImmutable('first day of this month');
$date = (string) ($_GET['date'] ?? date('Y-m-d'));
$weekCursor = self::date($date) ?: new DateTimeImmutable('today');
$db = DB::pdo();
$user = Auth::user();
$requestedView = (string) ($_GET['view'] ?? '');
$viewMode = in_array($requestedView, ['month', 'week'], true)
? $requestedView
: (string) ($_SESSION['agenda_view'] ?? ($user['agenda_view'] ?? 'month'));
if (!in_array($viewMode, ['month', 'week'], true)) {
$viewMode = 'month';
}
if ($requestedView !== '') {
$_SESSION['agenda_view'] = $viewMode;
}
$filters = self::filters();
$items = self::filtered(
$viewMode === 'week' ? AgendaService::week($weekCursor) : AgendaService::month($cursor),
$filters,
(int) $user['id'],
);
$upcoming = self::filtered(AgendaService::upcoming(), $filters, (int) $user['id']);
$feed = $db->prepare('SELECT agenda_feed_token_hash,agenda_feed_created_at FROM users WHERE id=?');
$feed->execute([(int) $user['id']]);
$feedState = $feed->fetch(PDO::FETCH_ASSOC) ?: [];
$token = (string) ($_SESSION['agenda_feed_token_once'] ?? '');
unset($_SESSION['agenda_feed_token_once']);
$volunteers = $db
->query(
"SELECT DISTINCT dc.id,dc.name FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id AND r.role='benevole' WHERE dc.deleted_at IS NULL ORDER BY dc.name COLLATE NOCASE",
)
->fetchAll(PDO::FETCH_ASSOC);
$edit = (int) ($_GET['edit'] ?? 0);
render('agenda.php', [
'title' => t('agenda.title'),
'cursor' => $cursor,
'weekCursor' => $weekCursor,
'viewMode' => $viewMode,
'items' => $items,
'upcoming' => $upcoming,
'filters' => $filters,
'filterSuffix' => self::filterSuffix($filters),
'feedEnabled' => !empty($feedState['agenda_feed_token_hash']),
'feedCreatedAt' => $feedState['agenda_feed_created_at'] ?? null,
'feedUrl' => $token !== '' ? CalendarFeedService::absoluteUrl('/agenda/feed.ics?token=' . $token) : '',
'editItem' => $edit ? AgendaService::find($edit) : null,
'animals' => $db
->query(
'SELECT id,name FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL ORDER BY name COLLATE NOCASE',
)
->fetchAll(),
'contacts' => $db
->query('SELECT id,name FROM directory_contacts WHERE deleted_at IS NULL ORDER BY name COLLATE NOCASE')
->fetchAll(),
'volunteers' => $volunteers,
'users' => $db
->query(
"SELECT id,COALESCE(NULLIF(display_name,''),username) name FROM users WHERE active=1 ORDER BY name COLLATE NOCASE",
)
->fetchAll(),
'status' => (string) ($_GET['status'] ?? ''),
'error' => (string) ($_GET['error'] ?? ''),
]);
}
public static function save(): void
{
try {
$id = (int) ($_POST['id'] ?? 0);
$saved = AgendaService::save($_POST, $id ?: null);
$conflicts = AgendaService::conflicts($saved);
AuditService::log(
$id ? 'agenda_updated' : 'agenda_created',
'/agenda/save',
$id ? 'Événement modifié' : 'Événement créé',
'agenda',
$saved,
['conflicts' => count($conflicts)],
);
header('Location: /agenda?status=' . ($conflicts ? 'conflict' : 'saved'));
} catch (Throwable $e) {
header('Location: /agenda?error=' . rawurlencode(SecurityService::publicError($e)));
}
exit();
}
public static function status(): void
{
try {
$id = (int) ($_POST['id'] ?? 0);
AgendaService::setStatus($id, (string) ($_POST['status'] ?? ''));
AuditService::log('agenda_status', '/agenda/status', 'État de l’agenda modifié', 'agenda', $id, [
'status' => $_POST['status'] ?? '',
]);
header('Location: /agenda?status=updated');
} catch (Throwable $e) {
header('Location: /agenda?error=' . rawurlencode(SecurityService::publicError($e)));
}
exit();
}
public static function export(): void
{
$view = (string) ($_GET['view'] ?? 'month');
if ($view === 'week') {
$date = self::date((string) ($_GET['date'] ?? '')) ?: new DateTimeImmutable('today');
$items = AgendaService::week($date);
$name = 'agenda-semaine-' . $date->modify('monday this week')->format('Y-m-d') . '.ics';
} else {
$month = (string) ($_GET['month'] ?? date('Y-m'));
$date = preg_match('/^\d{4}-\d{2}$/', $month) ? DateTimeImmutable::createFromFormat('!Y-m', $month) : false;
$date = $date ?: new DateTimeImmutable('first day of this month');
$items = AgendaService::month($date);
$name = 'agenda-' . $date->format('Y-m') . '.ics';
}
$items = self::filtered($items, self::filters(), (int) Auth::id());
self::output(
CalendarFeedService::render($items, AppSettings::get('association_name') . ' — ' . t('agenda.title')),
$name,
);
}
public static function feed(): void
{
$user = CalendarFeedService::userForToken((string) ($_GET['token'] ?? ''));
if (!$user) {
http_response_code(404);
header('Content-Type: text/plain; charset=utf-8');
echo t('agenda.feed_invalid');
return;
}
$items = AgendaService::range(new DateTimeImmutable('-90 days'), new DateTimeImmutable('+365 days 23:59:59'));
self::output(
CalendarFeedService::render($items, AppSettings::get('association_name') . ' — ' . t('agenda.title')),
'globinours-agenda.ics',
false,
);
}
public static function regenerateFeed(): void
{
self::feedPost();
$_SESSION['agenda_feed_token_once'] = CalendarFeedService::generateToken((int) Auth::id());
AuditService::log('agenda_feed_regenerated', '/agenda/feed/regenerate', 'Lien privé d’agenda régénéré');
header('Location: /agenda?status=feed');
exit();
}
public static function revokeFeed(): void
{
self::feedPost();
CalendarFeedService::revoke((int) Auth::id());
AuditService::log('agenda_feed_revoked', '/agenda/feed/revoke', 'Lien privé d’agenda révoqué');
header('Location: /agenda?status=feed-revoked');
exit();
}
private static function filters(): array
{
$types = [...AgendaService::TYPES, 'vaccine', 'deworming', 'treatment_start', 'treatment_end'];
$scope = ($_GET['scope'] ?? 'all') === 'mine' ? 'mine' : 'all';
$type = in_array($t = (string) ($_GET['category'] ?? ''), $types, true) ? $t : '';
return [
'scope' => $scope,
'assigned_user_id' => max(0, (int) ($_GET['assigned_user_id'] ?? 0)),
'volunteer_contact_id' => max(0, (int) ($_GET['volunteer_contact_id'] ?? 0)),
'category' => $type,
'show_medical' => (string) ($_GET['show_medical'] ?? '1') !== '0',
'q' => mb_substr(trim((string) ($_GET['q'] ?? '')), 0, 100),
];
}
private static function filtered(array $items, array $f, int $userId): array
{
return array_values(
array_filter($items, static function ($row) use ($f, $userId): bool {
$source = (string) ($row['source_type'] ?? 'agenda');
if ($f['scope'] === 'mine' && (int) ($row['assigned_user_id'] ?? 0) !== $userId) {
return false;
}
if ($f['assigned_user_id'] && (int) ($row['assigned_user_id'] ?? 0) !== $f['assigned_user_id']) {
return false;
}
if (
$f['volunteer_contact_id'] &&
!in_array(
$f['volunteer_contact_id'],
array_map('intval', array_filter(explode(',', (string) ($row['volunteer_ids'] ?? '')))),
true,
)
) {
return false;
}
if ($f['category'] !== '' && (string) $row['item_type'] !== $f['category']) {
return false;
}
if (!$f['show_medical'] && $source !== 'agenda') {
return false;
}
if (
$f['q'] !== '' &&
!str_contains(
mb_strtolower(
implode(' ', [
(string) ($row['title'] ?? ''),
(string) ($row['animal_name'] ?? ''),
(string) ($row['location'] ?? ''),
(string) ($row['assigned_name'] ?? ''),
(string) ($row['volunteer_names'] ?? ''),
]),
),
mb_strtolower($f['q']),
)
) {
return false;
}
return true;
}),
);
}
private static function filterSuffix(array $f): string
{
$query = http_build_query(
[
'scope' => $f['scope'],
'assigned_user_id' => $f['assigned_user_id'] ?: null,
'volunteer_contact_id' => $f['volunteer_contact_id'] ?: null,
'category' => $f['category'] ?: null,
'show_medical' => $f['show_medical'] ? '1' : '0',
'q' => $f['q'] ?: null,
],
'',
'&',
PHP_QUERY_RFC3986,
);
return $query !== '' ? '&amp;' . str_replace('&', '&amp;', $query) : '';
}
private static function feedPost(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST' || !Auth::validCsrf($_POST['csrf'] ?? null)) {
http_response_code(419);
exit();
}
if (!Auth::user() || !PermissionService::can('agenda')) {
http_response_code(403);
exit();
}
}
private static function output(string $content, string $filename, bool $attachment = true): void
{
header('Content-Type: text/calendar; charset=utf-8');
header('Content-Disposition: ' . ($attachment ? 'attachment' : 'inline') . '; filename="' . $filename . '"');
header('Cache-Control: private, no-store');
echo $content;
exit();
}
private static function date(string $value): ?DateTimeImmutable
{
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $value);
return $date && $date->format('Y-m-d') === $value ? $date : null;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
trait AnimalLifecycleActions
{
public static function delete(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$id = (int) ($_POST['id'] ?? 0);
if ($id <= 0) {
http_response_code(400);
return;
}
$db = DB::pdo();
$reason = trim((string) ($_POST['reason'] ?? ''));
$db->prepare(
"
UPDATE animals
SET deleted_at = datetime('now'),
archived_at = NULL,
deletion_reason = :reason,
updated_at = datetime('now')
WHERE id = :id
",
)->execute([':id' => $id, ':reason' => $reason !== '' ? $reason : null]);
self::logHistory($id, 'delete', 'Animal placé dans la corbeille', $reason);
header('Location: /animals');
exit();
}
public static function archive(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$id = (int) ($_POST['id'] ?? 0);
if ($id <= 0) {
http_response_code(400);
return;
}
$db = DB::pdo();
$db->prepare(
"
UPDATE animals SET archived_at = datetime('now'), deleted_at = NULL,
deletion_reason = NULL, updated_at = datetime('now')
WHERE id = :id
",
)->execute([':id' => $id]);
self::logHistory($id, 'update', 'Animal archivé');
header('Location: /animals');
exit();
}
public static function restore(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$id = (int) ($_POST['id'] ?? 0);
if ($id <= 0) {
http_response_code(400);
return;
}
$db = DB::pdo();
$db->prepare(
"
UPDATE animals SET archived_at = NULL, deleted_at = NULL,
deletion_reason = NULL, updated_at = datetime('now')
WHERE id = :id
",
)->execute([':id' => $id]);
self::logHistory($id, 'update', 'Animal restauré');
header('Location: /animals');
exit();
}
}

View file

@ -0,0 +1,706 @@
<?php
declare(strict_types=1);
trait AnimalMedicalActions
{
public static function updateMedicalStatus(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$animalId = (int) ($_POST['animal_id'] ?? 0);
if ($animalId <= 0) {
http_response_code(400);
return;
}
$allowedTestStatuses = ['unknown', 'neg', 'pos', 'doubtful'];
$fivStatus = (string) ($_POST['fiv_status'] ?? 'unknown');
$felvStatus = (string) ($_POST['felv_status'] ?? 'unknown');
if (!in_array($fivStatus, $allowedTestStatuses, true)) {
$fivStatus = 'unknown';
}
if (!in_array($felvStatus, $allowedTestStatuses, true)) {
$felvStatus = 'unknown';
}
$testDate = trim((string) ($_POST['test_date'] ?? date('Y-m-d')));
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $testDate)) {
$testDate = date('Y-m-d');
}
$testNotes = trim((string) ($_POST['test_notes'] ?? ''));
$conditions = is_array($_POST['conditions'] ?? null) ? $_POST['conditions'] : [];
$db = DB::pdo();
$animalStmt = $db->prepare('SELECT fiv, felv, fiv_status, felv_status FROM animals WHERE id = :id');
$animalStmt->execute([':id' => $animalId]);
$old = $animalStmt->fetch(PDO::FETCH_ASSOC);
if (!$old) {
http_response_code(404);
return;
}
$oldFivStatus = (string) ($old['fiv_status'] ?: ((int) $old['fiv'] === 1 ? 'pos' : 'neg'));
$oldFelvStatus = (string) ($old['felv_status'] ?: ((int) $old['felv'] === 1 ? 'pos' : 'neg'));
$db->beginTransaction();
try {
$db->prepare(
"
UPDATE animals
SET fiv_status = :fiv_status,
felv_status = :felv_status,
fiv = :fiv,
felv = :felv,
updated_at = datetime('now')
WHERE id = :id
",
)->execute([
':fiv_status' => $fivStatus,
':felv_status' => $felvStatus,
':fiv' => $fivStatus === 'pos' ? 1 : 0,
':felv' => $felvStatus === 'pos' ? 1 : 0,
':id' => $animalId,
]);
$noteStmt = $db->prepare("
INSERT INTO medical_notes
(animal_id, noted_at, kind, reason, diagnosis, plan, created_at)
VALUES
(:animal_id, :noted_at, 'diagnostic', :reason, :diagnosis, :plan, datetime('now'))
");
foreach (
[
['label' => 'FIV', 'old' => $oldFivStatus, 'new' => $fivStatus],
['label' => 'FeLV', 'old' => $oldFelvStatus, 'new' => $felvStatus],
]
as $test
) {
if ($test['new'] === 'pos' && $test['old'] !== 'pos') {
$diagnosis = $test['label'] . ' positif';
$noteStmt->execute([
':animal_id' => $animalId,
':noted_at' => $testDate . ' 12:00:00',
':reason' => 'Résultat de test ' . $test['label'],
':diagnosis' => $diagnosis,
':plan' => $testNotes !== '' ? $testNotes : null,
]);
self::logHistory(
$animalId,
'medical',
$diagnosis,
'Test positif le ' . $testDate . ($testNotes !== '' ? "\n" . $testNotes : ''),
);
}
}
$conditionSelect = $db->prepare(
'SELECT status FROM animal_health_conditions WHERE id = :id AND animal_id = :animal_id',
);
$conditionUpdate = $db->prepare("
UPDATE animal_health_conditions
SET name = :name, status = :status, diagnosed_at = :diagnosed_at,
resolved_at = :resolved_at, notes = :notes, updated_at = datetime('now')
WHERE id = :id AND animal_id = :animal_id
");
$conditionInsert = $db->prepare('
INSERT INTO animal_health_conditions
(animal_id, name, status, diagnosed_at, resolved_at, notes)
VALUES
(:animal_id, :name, :status, :diagnosed_at, :resolved_at, :notes)
');
foreach ($conditions as $key => $condition) {
if (!is_array($condition)) {
continue;
}
$name = trim((string) ($condition['name'] ?? ''));
if ($name === '') {
continue;
}
$status = ($condition['status'] ?? 'active') === 'resolved' ? 'resolved' : 'active';
$diagnosedAt = trim((string) ($condition['diagnosed_at'] ?? '')) ?: null;
$notes = trim((string) ($condition['notes'] ?? '')) ?: null;
$conditionId = ctype_digit((string) $key) ? (int) $key : 0;
$previousStatus = null;
if ($conditionId > 0) {
$conditionSelect->execute([':id' => $conditionId, ':animal_id' => $animalId]);
$previousStatus = $conditionSelect->fetchColumn();
if ($previousStatus === false) {
continue;
}
$conditionUpdate->execute([
':name' => $name,
':status' => $status,
':diagnosed_at' => $diagnosedAt,
':resolved_at' => $status === 'resolved' ? date('Y-m-d') : null,
':notes' => $notes,
':id' => $conditionId,
':animal_id' => $animalId,
]);
} else {
$conditionInsert->execute([
':animal_id' => $animalId,
':name' => $name,
':status' => $status,
':diagnosed_at' => $diagnosedAt,
':resolved_at' => $status === 'resolved' ? date('Y-m-d') : null,
':notes' => $notes,
]);
}
if ($status === 'active' && $previousStatus !== 'active') {
$noteStmt->execute([
':animal_id' => $animalId,
':noted_at' => ($diagnosedAt ?: date('Y-m-d')) . ' 12:00:00',
':reason' => 'Statut médical global',
':diagnosis' => $name,
':plan' => $notes,
]);
self::logHistory(
$animalId,
'medical',
'Pathologie / vigilance ajoutée',
$name . ($notes ? "\n" . $notes : ''),
);
}
}
$changes = [];
if ($oldFivStatus !== $fivStatus) {
$changes[] = 'FIV : ' . $oldFivStatus . ' → ' . $fivStatus;
}
if ($oldFelvStatus !== $felvStatus) {
$changes[] = 'FeLV : ' . $oldFelvStatus . ' → ' . $felvStatus;
}
if ($changes) {
self::logHistory($animalId, 'medical', 'Statut médical mis à jour', implode("\n", $changes));
}
$db->commit();
} catch (Throwable $e) {
$db->rollBack();
throw $e;
}
header('Location: /animal?id=' . $animalId . '#tab-med');
exit();
}
public static function addMedical(): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
return;
}
$animalId = (int) ($_POST['animal_id'] ?? 0);
if ($animalId <= 0) {
http_response_code(400);
return;
}
$kind = trim($_POST['kind'] ?? 'consult');
$reason = trim($_POST['reason'] ?? '');
$symptoms = trim($_POST['symptoms'] ?? '');
$exam = trim($_POST['exam'] ?? '');
$diagnosis = trim($_POST['diagnosis'] ?? '');
$plan = trim($_POST['plan'] ?? '');
$markSterilized = !empty($_POST['mark_sterilized']);
if ($markSterilized) {
$kind = 'acte';
if ($reason === '') {
$reason = 'Stérilisation';
}
}
$temperature = $_POST['temperature_c'] ?? null;
$weight = $_POST['weight_kg'] ?? null;
$clinicId = (int) ($_POST['clinic_id'] ?? 0);
$clinicId = $clinicId > 0 ? $clinicId : null;
$db = DB::pdo();
$clinicContact = self::resolveDirectoryContact(
$db,
(int) ($_POST['clinic_contact_id'] ?? 0),
(string) ($_POST['new_clinic_name'] ?? ''),
'organization',
'cabinet',
);
$vetContact = self::resolveDirectoryContact(
$db,
(int) ($_POST['vet_contact_id'] ?? 0),
(string) ($_POST['new_vet_name'] ?? ''),
'person',
'veterinaire',
);
self::normalizeVeterinaryClinic($db, $vetContact, $clinicContact);
if (!$vetContact || !$clinicContact) {
http_response_code(400);
echo h(t('error.vet_clinic_required'));
return;
}
$vetName = $vetContact['name'] ?? null;
// 1️⃣ Insertion note médicale
$stmt = $db->prepare('
INSERT INTO medical_notes
(animal_id, kind, reason, symptoms, exam, diagnosis, plan, temperature_c, weight_kg, clinic_id, vet_name, clinic_contact_id, vet_contact_id, created_by, created_at)
VALUES
(:aid, :kind, :reason, :symptoms, :exam, :diagnosis, :plan, :temp, :weight, :clinic, :vet, :clinic_contact, :vet_contact, :created_by, :created)
');
$stmt->execute([
':aid' => $animalId,
':kind' => $kind,
':reason' => $reason,
':symptoms' => $symptoms,
':exam' => $exam,
':diagnosis' => $diagnosis,
':plan' => $plan,
':temp' => $temperature !== '' ? (float) $temperature : null,
':weight' => $weight !== '' ? (float) $weight : null,
':clinic' => $clinicId,
':vet' => $vetName,
':clinic_contact' => $clinicContact['id'] ?? null,
':vet_contact' => $vetContact['id'] ?? null,
':created_by' => Auth::id(),
':created' => self::now(),
]);
// 👉 RÉCUPÉRATION ID NOTE
$medicalNoteId = $db->lastInsertId();
if ((int) ($_POST['tariff_id'] ?? 0) > 0) {
PricingService::record(
$db,
$animalId,
(int) $_POST['tariff_id'],
1,
substr(self::now(), 0, 10),
'Ajoutée avec la note médicale',
);
}
// ==========================
// 2️⃣ Upload photos médicales
// ==========================
if (!empty($_FILES['medical_photos']['name'][0])) {
$uploadDir = PrivateMediaService::medicalDir($animalId);
if (!is_dir($uploadDir)) {
PrivateMediaService::ensure($uploadDir);
}
foreach ($_FILES['medical_photos']['tmp_name'] as $key => $tmpName) {
if ($_FILES['medical_photos']['error'][$key] !== UPLOAD_ERR_OK) {
continue;
}
if ($_FILES['medical_photos']['size'][$key] > 20 * 1024 * 1024) {
continue;
}
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $tmpName);
$allowedTypes = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
];
if (!isset($allowedTypes[$mime])) {
continue;
}
try {
$stored = ImageService::storeUploaded($tmpName, $uploadDir, 'med', 'medical');
$safeName = $stored['filename'];
@chmod($stored['path'], 0600);
$stmtPhoto = $db->prepare('
INSERT INTO medical_photos (animal_id, medical_note_id, filename)
VALUES (?, ?, ?)
');
$stmtPhoto->execute([$animalId, $medicalNoteId, $safeName]);
} catch (Throwable) {
continue;
}
}
}
// 3️⃣ Poids → measurements (graph)
if ($weight !== '' && is_numeric($weight)) {
$db->prepare(
"
INSERT INTO measurements (animal_id, measured_at, type, value, unit, notes, created_by)
VALUES (:aid, :at, 'weight', :v, 'kg', 'from medical note', :created_by)
",
)->execute([
':aid' => $animalId,
':at' => self::now(),
':v' => (float) $weight,
':created_by' => Auth::id(),
]);
}
// 4️⃣ Historique
self::logHistory($animalId, 'medical', 'Note médicale ajoutée', $reason ?: null);
// 5️⃣ Touch animal
if ($markSterilized) {
$db->prepare(
"UPDATE animals SET sterilized=1,sterilization_status='yes',sterilization_date=COALESCE(sterilization_date,date(:date)),sterilization_vet_contact_id=COALESCE(:vet,sterilization_vet_contact_id),sterilization_clinic_contact_id=COALESCE(:clinic,sterilization_clinic_contact_id) WHERE id=:id",
)->execute([
':date' => self::now(),
':vet' => $vetContact['id'] ?? null,
':clinic' => $clinicContact['id'] ?? null,
':id' => $animalId,
]);
}
$db->prepare(
'
UPDATE animals SET updated_at = :u WHERE id = :id
',
)->execute([
':u' => self::now(),
':id' => $animalId,
]);
self::redirectToAnimal($animalId);
}
public static function addTreatment(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
echo h(t('error.method_not_allowed'));
return;
}
$id = (int) ($_POST['animal_id'] ?? 0);
$medId = (int) ($_POST['medication_id'] ?? 0);
$createMedication = !empty($_POST['create_medication']);
if ($id <= 0 || (!$createMedication && $medId <= 0)) {
http_response_code(400);
echo h(t('error.bad_request'));
return;
}
$route = trim((string) ($_POST['route'] ?? ''));
$dose = trim((string) ($_POST['dose_text'] ?? ''));
$start = trim((string) ($_POST['start_date'] ?? date('Y-m-d')));
$end = trim((string) ($_POST['end_date'] ?? ''));
$notes = trim((string) ($_POST['notes'] ?? ''));
$giveMorning = !empty($_POST['give_morning']) ? 1 : 0;
$giveEvening = !empty($_POST['give_evening']) ? 1 : 0;
$giveAsNeeded = !empty($_POST['give_as_needed']) ? 1 : 0;
$clinic_id = (int) ($_POST['clinic_id'] ?? 0);
$clinic_id = $clinic_id > 0 ? $clinic_id : null;
$vet_name = trim((string) ($_POST['vet_name'] ?? ''));
$vet_name = $vet_name !== '' ? mb_strtoupper($vet_name, 'UTF-8') : null;
if ($dose === '') {
http_response_code(400);
echo h(t('error.dose_required'));
return;
}
$db = DB::pdo();
if ($createMedication) {
$medicationName = trim((string) ($_POST['new_medication_name'] ?? ''));
$medicationMolecule = trim((string) ($_POST['new_medication_molecule'] ?? ''));
$medicationForm = trim((string) ($_POST['new_medication_form'] ?? ''));
if ($medicationName === '') {
http_response_code(400);
echo h(t('error.medication_name_required'));
return;
}
$existing = $db->prepare('SELECT id FROM ref_medications WHERE name = :name COLLATE NOCASE LIMIT 1');
$existing->execute([':name' => $medicationName]);
$medId = (int) ($existing->fetchColumn() ?: 0);
if ($medId <= 0) {
$insertMedication = $db->prepare(
'INSERT INTO ref_medications(name, molecule, form) VALUES(:name,:molecule,:form)',
);
$insertMedication->execute([
':name' => mb_substr($medicationName, 0, 150),
':molecule' => $medicationMolecule !== '' ? mb_substr($medicationMolecule, 0, 150) : null,
':form' => $medicationForm !== '' ? mb_substr($medicationForm, 0, 100) : null,
]);
$medId = (int) $db->lastInsertId();
}
}
$clinicContact = self::resolveDirectoryContact(
$db,
(int) ($_POST['clinic_contact_id'] ?? 0),
(string) ($_POST['new_clinic_name'] ?? ''),
'organization',
'cabinet',
);
$vetContact = self::resolveDirectoryContact(
$db,
(int) ($_POST['vet_contact_id'] ?? 0),
(string) ($_POST['new_vet_name'] ?? ''),
'person',
'veterinaire',
);
self::normalizeVeterinaryClinic($db, $vetContact, $clinicContact);
if (!$vetContact || !$clinicContact) {
http_response_code(400);
echo h(t('error.vet_clinic_required'));
return;
}
$vet_name = $vetContact['name'] ?? $vet_name;
$stmt = $db->prepare('
INSERT INTO treatments(animal_id, medication_id, route, dose_text, start_date, end_date, ongoing, notes, clinic_id, vet_name, clinic_contact_id, vet_contact_id, give_morning, give_evening, give_as_needed, created_by)
VALUES(:aid,:mid,:route,:dose,:start,:end,1,:notes,:clinic_id,:vet_name,:clinic_contact_id,:vet_contact_id,:give_morning,:give_evening,:give_as_needed,:created_by)
');
$stmt->execute([
':aid' => $id,
':mid' => $medId,
':route' => $route !== '' ? $route : null,
':dose' => $dose,
':start' => $start,
':end' => $end !== '' ? $end : null,
':notes' => $notes !== '' ? $notes : null,
':clinic_id' => $clinic_id,
':vet_name' => $vet_name,
':clinic_contact_id' => $clinicContact['id'] ?? null,
':vet_contact_id' => $vetContact['id'] ?? null,
':give_morning' => $giveMorning,
':give_evening' => $giveEvening,
':give_as_needed' => $giveAsNeeded,
':created_by' => Auth::id(),
]);
if ((int) ($_POST['tariff_id'] ?? 0) > 0) {
PricingService::record($db, $id, (int) $_POST['tariff_id'], 1, $start, 'Ajoutée avec le traitement');
}
self::logHistory(
$id,
'treatment',
'Traitement ajouté',
"Médicament #$medId\nDose : $dose\nDébut : $start" .
($end !== '' ? "\nFin : $end" : '') .
($notes !== '' ? "\nNotes : $notes" : ''),
);
$db->prepare("UPDATE animals SET updated_at = datetime('now') WHERE id = :id")->execute([':id' => $id]);
self::redirectToAnimal($id);
}
public static function addExpense(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
return;
}
$animal = (int) ($_POST['animal_id'] ?? 0);
$tariff = (int) ($_POST['tariff_id'] ?? 0);
$qty = (float) str_replace(',', '.', (string) ($_POST['quantity'] ?? 1));
$date = (string) ($_POST['occurred_on'] ?? date('Y-m-d'));
if (!$animal || !$tariff || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
http_response_code(400);
echo h(t('error.invalid_expense'));
return;
}
$db = DB::pdo();
try {
$total = PricingService::record($db, $animal, $tariff, $qty, $date, trim((string) ($_POST['notes'] ?? '')));
self::logHistory(
$animal,
'medical',
'Dépense vétérinaire ajoutée',
number_format($total / 100, 2, ',', ' ') . ' €',
);
AuditService::log(
'animal_expense_added',
'/animal/add-expense',
'Dépense vétérinaire enregistrée',
'animal',
$animal,
['total_cents' => $total],
);
self::redirectToAnimal($animal);
} catch (Throwable $e) {
http_response_code(400);
echo h($e->getMessage());
}
}
public static function addVaccine(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
echo h(t('error.method_not_allowed'));
return;
}
$db = DB::pdo();
$id = (int) ($_POST['animal_id'] ?? 0);
$vacId = (int) ($_POST['vaccine_id'] ?? 0);
if ($id <= 0 || $vacId <= 0) {
http_response_code(400);
echo h(t('error.bad_request'));
return;
}
$done = trim((string) ($_POST['done_date'] ?? date('Y-m-d')));
$due = trim((string) ($_POST['due_date'] ?? ''));
// Si due_date est fourni, on respecte.
// Sinon auto selon protocole.
if ($due === '') {
// Birthdate (pour l'âge)
$stmt = $db->prepare('SELECT birth_date FROM animals WHERE id = :id');
$stmt->execute([':id' => $id]);
$birth = (string) ($stmt->fetchColumn() ?: '');
$isUnderOne = false;
if ($birth !== '') {
$birthDt = new DateTime($birth);
$doneDt = new DateTime($done);
$ageDays = (int) $birthDt->diff($doneDt)->format('%r%a');
if ($ageDays >= 0) {
$isUnderOne = $ageDays < 365;
}
}
// Protocole vaccin (family TC / L)
$stmt = $db->prepare('SELECT family, name FROM ref_vaccines WHERE id = :vid');
$stmt->execute([':vid' => $vacId]);
$vac = $stmt->fetch() ?: [];
$family = strtoupper((string) ($vac['family'] ?? ''));
// Détermine si c'est une primo pour la famille L (Leucose)
$isPrimoL = false;
if ($family === 'L') {
$stmt = $db->prepare("
SELECT COUNT(*)
FROM vaccinations v
JOIN ref_vaccines rv ON rv.id = v.vaccine_id
WHERE v.animal_id = :aid AND upper(coalesce(rv.family,'')) = 'L'
");
$stmt->execute([':aid' => $id]);
$countL = (int) $stmt->fetchColumn();
$isPrimoL = $countL === 0;
}
// Règles
if ($family === 'L') {
$due = $isPrimoL
? date('Y-m-d', strtotime($done . ' +1 month'))
: date('Y-m-d', strtotime($done . ' +1 year'));
} else {
// TC (ou inconnu => TC)
$due = $isUnderOne
? date('Y-m-d', strtotime($done . ' +1 month'))
: date('Y-m-d', strtotime($done . ' +1 year'));
}
}
$lot = trim((string) ($_POST['lot'] ?? ''));
$manufacturer = trim((string) ($_POST['manufacturer'] ?? ''));
$batchExpiresOn = trim((string) ($_POST['batch_expires_on'] ?? ''));
if ($batchExpiresOn !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $batchExpiresOn)) {
http_response_code(400);
echo h(t('error.invalid_batch_expiry'));
return;
}
$administratorUserId = (int) ($_POST['administered_by_user_id'] ?? 0) ?: null;
$administratorName = trim((string) ($_POST['administered_by_name'] ?? ''));
if ($administratorUserId) {
$check = $db->prepare('SELECT 1 FROM users WHERE id=:id AND active=1');
$check->execute([':id' => $administratorUserId]);
if (!$check->fetchColumn()) {
$administratorUserId = null;
}
}
$notes = trim((string) ($_POST['notes'] ?? ''));
$clinic_id = (int) ($_POST['clinic_id'] ?? 0);
$clinic_id = $clinic_id > 0 ? $clinic_id : null;
$vet_name = trim((string) ($_POST['vet_name'] ?? ''));
$vet_name = $vet_name !== '' ? mb_strtoupper($vet_name, 'UTF-8') : null;
$clinicContact = self::resolveDirectoryContact(
$db,
(int) ($_POST['clinic_contact_id'] ?? 0),
(string) ($_POST['new_clinic_name'] ?? ''),
'organization',
'cabinet',
);
$vetContact = self::resolveDirectoryContact(
$db,
(int) ($_POST['vet_contact_id'] ?? 0),
(string) ($_POST['new_vet_name'] ?? ''),
'person',
'veterinaire',
);
self::normalizeVeterinaryClinic($db, $vetContact, $clinicContact);
if (!$vetContact || !$clinicContact) {
http_response_code(400);
echo h(t('error.vet_clinic_required'));
return;
}
$vet_name = $vetContact['name'] ?? $vet_name;
$stmt = $db->prepare('
INSERT INTO vaccinations(animal_id, vaccine_id, done_date, due_date, lot,manufacturer,batch_expires_on,administered_by_user_id,administered_by_name, notes, clinic_id, vet_name, clinic_contact_id, vet_contact_id, created_by)
VALUES(:aid,:vid,:done,:due,:lot,:manufacturer,:batch_expiry,:administrator_user,:administrator_name,:notes,:clinic_id,:vet_name,:clinic_contact_id,:vet_contact_id,:created_by)
');
$stmt->execute([
':aid' => $id,
':vid' => $vacId,
':done' => $done,
':due' => $due !== '' ? $due : null,
':lot' => $lot !== '' ? $lot : null,
':manufacturer' => $manufacturer ?: null,
':batch_expiry' => $batchExpiresOn ?: null,
':administrator_user' => $administratorUserId,
':administrator_name' => $administratorName ?: null,
':notes' => $notes !== '' ? $notes : null,
':clinic_id' => $clinic_id,
':vet_name' => $vet_name,
':clinic_contact_id' => $clinicContact['id'] ?? null,
':vet_contact_id' => $vetContact['id'] ?? null,
':created_by' => Auth::id(),
]);
InventoryService::consumeBatch(
(int) ($_POST['inventory_batch_id'] ?? 0),
(float) str_replace(',', '.', (string) ($_POST['inventory_quantity'] ?? 0)),
$id,
'Vaccination du ' . $done,
);
if ((int) ($_POST['tariff_id'] ?? 0) > 0) {
PricingService::record($db, $id, (int) $_POST['tariff_id'], 1, $done, 'Ajoutée avec la vaccination');
}
self::logHistory(
$id,
'vaccine',
'Vaccin ajouté',
"Vaccin #$vacId\nFait le : $done" .
($due !== '' ? "\nRappel : $due" : '') .
($manufacturer !== '' ? "\nFabricant : $manufacturer" : '') .
($lot !== '' ? "\nLot : $lot" : '') .
($batchExpiresOn !== '' ? "\nExpiration du lot : $batchExpiresOn" : '') .
($administratorName !== '' || $administratorUserId
? "\nAdministré par : " .
($administratorName !== '' ? $administratorName : 'utilisateur #' . $administratorUserId)
: ''),
);
$db->prepare("UPDATE animals SET updated_at = datetime('now') WHERE id = :id")->execute([':id' => $id]);
self::redirectToAnimal($id);
}
}

View file

@ -0,0 +1,296 @@
<?php
declare(strict_types=1);
trait AnimalPhotoActions
{
public static function uploadPhoto(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
echo h(t('error.method_not_allowed'));
return;
}
$id = (int) ($_POST['animal_id'] ?? 0);
if ($id <= 0) {
http_response_code(400);
echo h(t('error.bad_request'));
return;
}
if (!isset($_FILES['photo']) || $_FILES['photo']['error'] !== UPLOAD_ERR_OK) {
http_response_code(400);
echo h(t('error.upload_failed'));
return;
}
$db = DB::pdo();
$tmp = $_FILES['photo']['tmp_name'];
$orig = (string) ($_FILES['photo']['name'] ?? 'photo');
$size = (int) ($_FILES['photo']['size'] ?? 0);
$mime = (string) ($_FILES['photo']['type'] ?? '');
$base = preg_replace('/[^a-zA-Z0-9._-]+/', '_', pathinfo($orig, PATHINFO_FILENAME));
$base = $base ?: 'photo';
$dir = __DIR__ . '/../../public/media/animals/' . $id;
try {
$stored = ImageService::storeUploaded($tmp, $dir, $base, 'photo');
$filename = $stored['filename'];
$mime = $stored['mime'];
$size = $stored['size'];
} catch (Throwable $e) {
http_response_code(400);
echo h($e->getMessage());
return;
}
// si aucune photo primaire, celle-ci devient primaire
$stmt = $db->prepare('SELECT COUNT(*) FROM animal_photos WHERE animal_id=:id AND is_primary=1');
$stmt->execute([':id' => $id]);
$hasPrimary = (int) $stmt->fetchColumn() > 0;
$stmt = $db->prepare('
INSERT INTO animal_photos(animal_id, filename, original_name, mime, size_bytes, is_primary)
VALUES(:aid,:fn,:orig,:mime,:sz,:prim)
');
$stmt->execute([
':aid' => $id,
':fn' => $filename,
':orig' => $orig,
':mime' => $mime,
':sz' => $size,
':prim' => $hasPrimary ? 0 : 1,
]);
header('Location: /animal?id=' . $id);
exit();
}
public static function uploadPhotos(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$animalId = (int) ($_POST['animal_id'] ?? 0);
if ($animalId <= 0) {
http_response_code(400);
echo h(t('error.bad_request'));
return;
}
if (empty($_FILES['photos'])) {
http_response_code(400);
echo h(t('error.no_files'));
return;
}
$isPublic = (int) ($_POST['is_public'] ?? 1 ? 1 : 0);
$redirectHash = trim((string) ($_POST['redirect_hash'] ?? ''));
$db = DB::pdo();
self::ensureAnimalMediaDir($animalId);
$files = $_FILES['photos'];
$count = is_array($files['name']) ? count($files['name']) : 1;
// Y a-t-il déjà une photo principale ?
$hasPrimary = (bool) $db
->query(
'
SELECT 1 FROM animal_photos
WHERE animal_id=' .
(int) $animalId .
'
AND is_primary=1
LIMIT 1
',
)
->fetchColumn();
for ($i = 0; $i < $count; $i++) {
$name = is_array($files['name']) ? $files['name'][$i] : $files['name'];
$tmp = is_array($files['tmp_name']) ? $files['tmp_name'][$i] : $files['tmp_name'];
$err = is_array($files['error']) ? $files['error'][$i] : $files['error'];
if ($err !== UPLOAD_ERR_OK) {
continue;
}
if (!is_uploaded_file($tmp)) {
continue;
}
if (!self::isAllowedImage($tmp)) {
continue;
}
$base = self::safeFilename(pathinfo($name, PATHINFO_FILENAME));
$targetDir = $isPublic === 1 ? self::animalMediaDir($animalId) : PrivateMediaService::animalDir($animalId);
if ($isPublic === 0) {
PrivateMediaService::ensure($targetDir);
}
try {
$stored = ImageService::storeUploaded($tmp, $targetDir, $base, 'photo');
$final = $stored['filename'];
if ($isPublic === 0) {
@chmod($stored['path'], 0600);
}
} catch (Throwable) {
continue;
}
// RÈGLE : jamais une privée en principale
$isPrimary = !$hasPrimary && $isPublic === 1 ? 1 : 0;
if ($isPrimary === 1) {
$hasPrimary = true;
}
$st = $db->prepare('
INSERT INTO animal_photos(animal_id, filename, is_primary, is_public)
VALUES(:aid,:fn,:p,:pub)
');
$st->execute([
':aid' => $animalId,
':fn' => $final,
':p' => $isPrimary,
':pub' => $isPublic,
]);
}
$db->prepare(
"
UPDATE animals
SET updated_at=datetime('now')
WHERE id=:id
",
)->execute([':id' => $animalId]);
// Retour direct sur onglet photos
$hash = $redirectHash !== '' && $redirectHash[0] === '#' ? $redirectHash : '';
header('Location: /animal?id=' . $animalId . $hash);
exit();
}
public static function setPrimaryPhoto(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$animalId = (int) ($_POST['animal_id'] ?? 0);
$photoId = (int) ($_POST['photo_id'] ?? 0);
if ($animalId <= 0 || $photoId <= 0) {
http_response_code(400);
return;
}
$db = DB::pdo();
$stmt = $db->prepare('SELECT filename,is_public FROM animal_photos WHERE id=:pid AND animal_id=:aid');
$stmt->execute([':pid' => $photoId, ':aid' => $animalId]);
$photo = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$photo) {
http_response_code(404);
echo h(t('error.photo_not_found'));
return;
}
$moved = false;
try {
if ((int) $photo['is_public'] !== 1) {
PrivateMediaService::moveVisibility($animalId, (string) $photo['filename'], true);
$moved = true;
}
$db->beginTransaction();
$db->prepare('UPDATE animal_photos SET is_primary=0 WHERE animal_id=:aid')->execute([':aid' => $animalId]);
$db->prepare('UPDATE animal_photos SET is_primary=1,is_public=1 WHERE id=:pid AND animal_id=:aid')->execute(
[':pid' => $photoId, ':aid' => $animalId],
);
$db->commit();
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
if ($moved) {
try {
PrivateMediaService::moveVisibility($animalId, (string) $photo['filename'], false);
} catch (Throwable) {
}
}
throw $e;
}
// Historique si tu veux
if (method_exists(__CLASS__, 'logHistory')) {
self::logHistory($animalId, 'photo', 'Photo principale modifiée');
}
header('Location: /animal?id=' . $animalId);
exit();
}
public static function deletePhoto(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$animalId = (int) ($_POST['animal_id'] ?? 0);
$photoId = (int) ($_POST['photo_id'] ?? 0);
if ($animalId <= 0 || $photoId <= 0) {
http_response_code(400);
return;
}
$db = DB::pdo();
$st = $db->prepare(
'SELECT filename, is_primary, is_public FROM animal_photos WHERE id=:pid AND animal_id=:aid',
);
$st->execute([':pid' => $photoId, ':aid' => $animalId]);
$row = $st->fetch(PDO::FETCH_ASSOC);
if (!$row) {
header('Location: /animal?id=' . $animalId);
exit();
}
$filename = (string) $row['filename'];
$wasPrimary = (int) $row['is_primary'] === 1;
$db->prepare('DELETE FROM animal_photos WHERE id=:pid AND animal_id=:aid')->execute([
':pid' => $photoId,
':aid' => $animalId,
]);
$path =
((int) ($row['is_public'] ?? 1) === 1
? self::animalMediaDir($animalId)
: PrivateMediaService::animalDir($animalId)) .
'/' .
$filename;
if (is_file($path)) {
@unlink($path);
}
// Si on a supprimé la principale, on met la plus récente restante en principale
if ($wasPrimary) {
$pid = $db
->query('SELECT id FROM animal_photos WHERE animal_id=' . (int) $animalId . ' ORDER BY id DESC LIMIT 1')
->fetchColumn();
if ($pid) {
$db->prepare('UPDATE animal_photos SET is_primary=1 WHERE id=:pid')->execute([':pid' => (int) $pid]);
}
}
$db->prepare("UPDATE animals SET updated_at=datetime('now') WHERE id=:id")->execute([':id' => $animalId]);
if (method_exists(__CLASS__, 'logHistory')) {
self::logHistory($animalId, 'photo', 'Photo supprimée');
}
header('Location: /animal?id=' . $animalId);
exit();
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,443 @@
<?php
declare(strict_types=1);
trait AnimalRelationshipActions
{
public static function litterForm(): void
{
$db = DB::pdo();
$sourceAnimalId = (int) ($_GET['animal_id'] ?? 0);
$motherId = 0;
$fatherId = 0;
if ($sourceAnimalId > 0) {
$stmt = $db->prepare(
'SELECT id, sex FROM animals WHERE id = :id AND deleted_at IS NULL AND archived_at IS NULL',
);
$stmt->execute([':id' => $sourceAnimalId]);
$source = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$source) {
http_response_code(404);
echo h(t('error.animal_not_found'));
return;
}
if (($source['sex'] ?? '') === 'F') {
$motherId = $sourceAnimalId;
}
if (($source['sex'] ?? '') === 'M') {
$fatherId = $sourceAnimalId;
}
}
$eligibleSql = "
SELECT id, name FROM animals
WHERE deleted_at IS NULL AND archived_at IS NULL AND lower(species) IN ('chat', 'cat') AND sex = :sex
AND lower(status) NOT IN ('adopte', 'adopté', 'decede', 'décédé')
ORDER BY name COLLATE NOCASE
";
$stmt = $db->prepare($eligibleSql);
$stmt->execute([':sex' => 'F']);
$mothers = $stmt->fetchAll(PDO::FETCH_ASSOC);
$stmt->execute([':sex' => 'M']);
$fathers = $stmt->fetchAll(PDO::FETCH_ASSOC);
$existingKittens = $db
->query(
"SELECT a.id,a.name,a.internal_code,a.color,a.sex,a.status,a.archived_at FROM animals a WHERE a.deleted_at IS NULL AND lower(a.species) IN ('chat','cat') AND NOT EXISTS (SELECT 1 FROM litter_kittens lk WHERE lk.animal_id=a.id) ORDER BY CASE WHEN a.archived_at IS NULL THEN 0 ELSE 1 END,a.name COLLATE NOCASE,a.id",
)
->fetchAll(PDO::FETCH_ASSOC);
render('litter_form.php', [
'title' => t('litter.create'),
'pageDescription' => 'Création groupée des fiches d’une portée de chatons.',
'sourceAnimalId' => $sourceAnimalId,
'motherId' => $motherId,
'fatherId' => $fatherId,
'mothers' => $mothers,
'fathers' => $fathers,
'existingKittens' => $existingKittens,
]);
}
public static function saveLitter(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$db = DB::pdo();
$motherId = (int) ($_POST['mother_id'] ?? 0) ?: null;
$fatherId = (int) ($_POST['father_id'] ?? 0) ?: null;
$birthDate = trim((string) ($_POST['birth_date'] ?? '')) ?: null;
$kittenMode = in_array((string) ($_POST['kitten_mode'] ?? 'new'), ['new', 'existing'], true)
? (string) $_POST['kitten_mode']
: 'new';
if ($birthDate !== null && preg_match('/^(\d{2})\/(\d{2})\/(\d{4})$/', $birthDate, $dateParts)) {
$birthDate = $dateParts[3] . '-' . $dateParts[2] . '-' . $dateParts[1];
}
$kittens = is_array($_POST['kittens'] ?? null) ? array_values($_POST['kittens']) : [];
$existingKittenIds = is_array($_POST['existing_kitten_ids'] ?? null)
? array_values(array_unique(array_filter(array_map('intval', $_POST['existing_kitten_ids']))))
: [];
if ($kittenMode === 'new') {
$existingKittenIds = [];
} else {
$kittens = [];
}
if (!$kittens && !$existingKittenIds) {
http_response_code(400);
echo h(t('litter.need_kitten'));
return;
}
if ($motherId !== null && $fatherId !== null && $motherId === $fatherId) {
http_response_code(400);
echo h(t('litter.different_parents'));
return;
}
if ($birthDate !== null && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $birthDate)) {
http_response_code(400);
echo h(t('error.invalid_birth_date'));
return;
}
$parentIds = array_values(array_filter([$motherId, $fatherId]));
if ($parentIds) {
$placeholders = implode(',', array_fill(0, count($parentIds), '?'));
$stmt = $db->prepare(
"SELECT id, name, sex FROM animals WHERE id IN ($placeholders) AND deleted_at IS NULL AND archived_at IS NULL AND lower(species) IN ('chat','cat') AND lower(status) NOT IN ('adopte','adopté','decede','décédé')",
);
$stmt->execute($parentIds);
$parents = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $parent) {
$parents[(int) $parent['id']] = $parent;
}
if (
($motherId && ($parents[$motherId]['sex'] ?? '') !== 'F') ||
($fatherId && ($parents[$fatherId]['sex'] ?? '') !== 'M')
) {
http_response_code(400);
echo h(t('litter.invalid_parent'));
return;
}
} else {
$parents = [];
}
if ($existingKittenIds) {
$placeholders = implode(',', array_fill(0, count($existingKittenIds), '?'));
$stmt = $db->prepare(
"SELECT id FROM animals WHERE id IN ($placeholders) AND deleted_at IS NULL AND lower(species) IN ('chat','cat') AND id NOT IN (?,?) AND NOT EXISTS (SELECT 1 FROM litter_kittens lk WHERE lk.animal_id=animals.id)",
);
$stmt->execute([...$existingKittenIds, (int) ($motherId ?? 0), (int) ($fatherId ?? 0)]);
if (count($stmt->fetchAll(PDO::FETCH_ASSOC)) !== count($existingKittenIds)) {
http_response_code(400);
echo h(t('litter.invalid_selected'));
return;
}
}
$baseName = trim((string) ($parents[$motherId]['name'] ?? ($parents[$fatherId]['name'] ?? 'Portée')));
$allowedSexes = ['F', 'M', 'U'];
try {
PrivateMediaService::moveVisibility($animalId, (string) $photo['filename'], $isPublic === 1);
} catch (Throwable $e) {
http_response_code(500);
echo h(t('error.upload_failed'));
return;
}
$db->beginTransaction();
try {
$stmt = $db->prepare(
'INSERT INTO litters(mother_id, father_id, birth_date) VALUES(:mother, :father, :birth)',
);
$stmt->execute([':mother' => $motherId, ':father' => $fatherId, ':birth' => $birthDate]);
$litterId = (int) $db->lastInsertId();
$insertAnimal = $db->prepare("
INSERT INTO animals(name, internal_code, status, species, sex, breed, color, birth_date, birth_is_estimated,intake_date,intake_type,intake_reason, created_at, updated_at)
VALUES(:name, :code, 'refuge', 'chat', :sex, 'Chat Européen', :color, :birth, 0,:birth,'born_in_care','birth', datetime('now'), datetime('now'))
");
$link = $db->prepare(
'INSERT INTO litter_kittens(litter_id, animal_id, position) VALUES(:litter, :animal, :position)',
);
foreach ($kittens as $index => $kitten) {
if (!is_array($kitten)) {
continue;
}
$position = $index + 1;
$name = trim((string) ($kitten['name'] ?? ''));
if ($name === '') {
$name = $baseName . '-Bébé-' . $position;
}
$sex = (string) ($kitten['sex'] ?? 'U');
if (!in_array($sex, $allowedSexes, true)) {
$sex = 'U';
}
$color = trim((string) ($kitten['color'] ?? '')) ?: null;
$insertAnimal->execute([
':name' => $name,
':code' => Ids::nextAnimalCode($name),
':sex' => $sex,
':color' => $color,
':birth' => $birthDate,
]);
$animalId = (int) $db->lastInsertId();
$link->execute([':litter' => $litterId, ':animal' => $animalId, ':position' => $position]);
self::syncIntakeMovement(
$db,
$animalId,
$birthDate,
'born_in_care',
'birth',
null,
'',
'',
'Naissance enregistrée avec la portée n°' . $litterId,
);
LocationHistoryService::record(
$db,
$animalId,
null,
['status' => 'refuge', 'refuge_room' => null, 'care_box_key' => null, 'current_address' => null],
'litter_creation',
'Emplacement lors de la création de la portée',
$birthDate ? $birthDate . ' 12:00:00' : null,
);
self::logHistory($animalId, 'create', 'Chaton ajouté avec sa portée', 'Portée n°' . $litterId);
}
$position = count($kittens);
foreach ($existingKittenIds as $animalId) {
$link->execute([':litter' => $litterId, ':animal' => $animalId, ':position' => ++$position]);
self::logHistory($animalId, 'update', 'Rattaché à une nouvelle portée', 'Portée n°' . $litterId);
}
$db->commit();
$targetId = (int) ($_POST['source_animal_id'] ?? 0) ?: ($motherId ?: ($fatherId ?: 0));
header('Location: ' . ($targetId ? '/animal?id=' . $targetId . '#tab-litters' : '/animals'));
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
}
public static function litterAddExistingForm(): void
{
$db = DB::pdo();
$litterId = (int) ($_GET['litter_id'] ?? 0);
$sourceAnimalId = (int) ($_GET['animal_id'] ?? 0);
$stmt = $db->prepare(
'SELECT l.*,m.name mother_name,f.name father_name FROM litters l LEFT JOIN animals m ON m.id=l.mother_id LEFT JOIN animals f ON f.id=l.father_id WHERE l.id=:id',
);
$stmt->execute([':id' => $litterId]);
$litter = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$litter) {
http_response_code(404);
echo h(t('litter.not_found'));
return;
}
$stmt = $db->prepare("
SELECT a.id,a.name,a.internal_code,a.color,a.sex,a.status,a.archived_at
FROM animals a
WHERE a.deleted_at IS NULL AND lower(a.species) IN ('chat','cat')
AND a.id NOT IN (COALESCE(:mother,0),COALESCE(:father,0))
AND NOT EXISTS (SELECT 1 FROM litter_kittens lk WHERE lk.animal_id=a.id)
ORDER BY CASE WHEN a.archived_at IS NULL THEN 0 ELSE 1 END,a.name COLLATE NOCASE,a.id
");
$stmt->execute([':mother' => $litter['mother_id'], ':father' => $litter['father_id']]);
render('litter_add_existing.php', [
'title' => t('litter.add_title'),
'pageDescription' => 'Rattacher des fiches animales existantes à une portée.',
'litter' => $litter,
'animals' => $stmt->fetchAll(PDO::FETCH_ASSOC),
'sourceAnimalId' => $sourceAnimalId,
]);
}
public static function saveExistingLitterKittens(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$db = DB::pdo();
$litterId = (int) ($_POST['litter_id'] ?? 0);
$sourceAnimalId = (int) ($_POST['source_animal_id'] ?? 0);
$ids = is_array($_POST['animal_ids'] ?? null)
? array_values(array_unique(array_filter(array_map('intval', $_POST['animal_ids']))))
: [];
if ($litterId <= 0 || !$ids) {
http_response_code(400);
echo h(t('litter.select_one'));
return;
}
$litterStmt = $db->prepare('SELECT id,mother_id,father_id FROM litters WHERE id=:id');
$litterStmt->execute([':id' => $litterId]);
$litter = $litterStmt->fetch(PDO::FETCH_ASSOC);
if (!$litter) {
http_response_code(404);
echo h(t('litter.not_found'));
return;
}
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $db->prepare(
"SELECT id FROM animals WHERE id IN ($placeholders) AND deleted_at IS NULL AND lower(species) IN ('chat','cat') AND id NOT IN (?,?) AND NOT EXISTS (SELECT 1 FROM litter_kittens lk WHERE lk.animal_id=animals.id)",
);
$stmt->execute([...$ids, (int) ($litter['mother_id'] ?? 0), (int) ($litter['father_id'] ?? 0)]);
if (count($stmt->fetchAll(PDO::FETCH_ASSOC)) !== count($ids)) {
http_response_code(400);
echo h(t('litter.invalid_existing'));
return;
}
$db->beginTransaction();
try {
$position = (int) $db
->query('SELECT COALESCE(MAX(position),0) FROM litter_kittens WHERE litter_id=' . $litterId)
->fetchColumn();
$link = $db->prepare(
'INSERT INTO litter_kittens(litter_id,animal_id,position) VALUES(:litter,:animal,:position)',
);
foreach ($ids as $animalId) {
$link->execute([':litter' => $litterId, ':animal' => $animalId, ':position' => ++$position]);
self::logHistory($animalId, 'update', 'Rattaché à une portée existante', 'Portée n°' . $litterId);
}
$db->commit();
header(
'Location: /animal?id=' .
($sourceAnimalId ?: ($litter['mother_id'] ?: ($litter['father_id'] ?: $ids[0]))) .
'#tab-litters',
);
exit();
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
}
public static function bondedForm(): void
{
$db = DB::pdo();
$sourceAnimalId = (int) ($_GET['animal_id'] ?? 0);
if ($sourceAnimalId <= 0) {
http_response_code(400);
return;
}
$stmt = $db->prepare("
SELECT a.id, a.name, a.color, a.species, a.status
FROM animals a
WHERE a.deleted_at IS NULL AND a.archived_at IS NULL
AND lower(a.species) IN ('chat', 'cat')
AND lower(a.status) NOT IN ('adopte', 'adopté', 'decede', 'décédé')
AND (a.id = :source OR NOT EXISTS (
SELECT 1 FROM bonded_group_members bgm
JOIN bonded_groups bg ON bg.id = bgm.group_id AND bg.active = 1
WHERE bgm.animal_id = a.id
))
ORDER BY a.name COLLATE NOCASE
");
$stmt->execute([':source' => $sourceAnimalId]);
$animals = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (!array_filter($animals, fn(array $animal): bool => (int) $animal['id'] === $sourceAnimalId)) {
http_response_code(404);
echo h(t('bonded.unavailable'));
return;
}
render('bonded_form.php', [
'title' => t('bonded.create'),
'pageDescription' => 'Associer plusieurs chats qui doivent rester ensemble.',
'sourceAnimalId' => $sourceAnimalId,
'animals' => $animals,
]);
}
public static function saveBondedGroup(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$ids = is_array($_POST['animal_ids'] ?? null)
? array_values(array_unique(array_filter(array_map('intval', $_POST['animal_ids']))))
: [];
if (count($ids) < 2) {
http_response_code(400);
echo h(t('bonded.need_two'));
return;
}
$db = DB::pdo();
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $db->prepare("
SELECT a.id FROM animals a
WHERE a.id IN ($placeholders) AND a.deleted_at IS NULL AND a.archived_at IS NULL
AND lower(a.species) IN ('chat', 'cat')
AND lower(a.status) NOT IN ('adopte', 'adopté', 'decede', 'décédé')
AND NOT EXISTS (
SELECT 1 FROM bonded_group_members bgm JOIN bonded_groups bg ON bg.id=bgm.group_id
WHERE bgm.animal_id=a.id AND bg.active=1
)
");
$stmt->execute($ids);
if (count($stmt->fetchAll()) !== count($ids)) {
http_response_code(400);
echo h(t('bonded.invalid_member'));
return;
}
$name = trim((string) ($_POST['name'] ?? '')) ?: null;
$notes = trim((string) ($_POST['notes'] ?? '')) ?: null;
$db->beginTransaction();
try {
$stmt = $db->prepare('INSERT INTO bonded_groups(name, notes) VALUES(:name, :notes)');
$stmt->execute([':name' => $name, ':notes' => $notes]);
$groupId = (int) $db->lastInsertId();
$link = $db->prepare(
'INSERT INTO bonded_group_members(group_id, animal_id, position) VALUES(:group_id, :animal_id, :position)',
);
foreach ($ids as $index => $animalId) {
$link->execute([':group_id' => $groupId, ':animal_id' => $animalId, ':position' => $index + 1]);
self::logHistory(
$animalId,
'update',
'Ajouté à un groupe inséparable',
$name ?: 'Groupe n°' . $groupId,
);
}
$db->commit();
header('Location: /animal?id=' . $ids[0]);
exit();
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
}
public static function dissolveBondedGroup(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$groupId = (int) ($_POST['group_id'] ?? 0);
$animalId = (int) ($_POST['animal_id'] ?? 0);
if ($groupId <= 0) {
http_response_code(400);
return;
}
$db = DB::pdo();
$members = $db->prepare('SELECT animal_id FROM bonded_group_members WHERE group_id=:id');
$members->execute([':id' => $groupId]);
$memberIds = array_map('intval', array_column($members->fetchAll(PDO::FETCH_ASSOC), 'animal_id'));
$stmt = $db->prepare(
"UPDATE bonded_groups SET active=0, dissolved_at=datetime('now') WHERE id=:id AND active=1",
);
$stmt->execute([':id' => $groupId]);
foreach ($memberIds as $memberId) {
self::logHistory($memberId, 'update', 'Groupe inséparable dissous');
}
header('Location: ' . ($animalId > 0 ? '/animal?id=' . $animalId : '/animals'));
exit();
}
}

View file

@ -0,0 +1,602 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/AnimalMedicalActions.php';
require_once __DIR__ . '/AnimalPhotoActions.php';
require_once __DIR__ . '/AnimalRelationshipActions.php';
require_once __DIR__ . '/AnimalLifecycleActions.php';
require_once __DIR__ . '/AnimalBrowseActions.php';
require_once __DIR__ . '/AnimalRecordActions.php';
final class AnimalsController
{
use AnimalBrowseActions;
use AnimalMedicalActions;
use AnimalPhotoActions;
use AnimalRecordActions;
use AnimalLifecycleActions;
private const INTAKE_TYPES = [
'unknown' => 'Non renseigné',
'found' => 'Trouvé',
'owner_surrender' => 'Cession par le propriétaire',
'transfer' => 'Transfert d’une autre structure',
'born_in_care' => 'Né sous la responsabilité du refuge',
'return' => 'Retour après placement',
'seizure' => 'Saisie ou réquisition',
'other' => 'Autre mode d’entrée',
];
private const INTAKE_REASONS = [
'unknown' => 'Non renseigné',
'stray_found' => 'Animal errant ou trouvé',
'unwanted_litter' => 'Portée non désirée',
'owner_health' => 'Santé ou hospitalisation du propriétaire',
'owner_death' => 'Décès du propriétaire',
'owner_care_home' => 'Entrée en EHPAD ou établissement spécialisé du propriétaire',
'allergy' => 'Allergie',
'housing' => 'Logement ou déménagement',
'financial' => 'Difficultés financières',
'behavior' => 'Difficultés comportementales',
'abandonment' => 'Abandon sur place',
'danger' => 'Mise en danger ou maltraitance',
'transfer' => 'Transfert entre structures',
'birth' => 'Naissance',
'other' => 'Autre motif',
];
private static function intakeTypes(): array
{
$out = [];
foreach (self::INTAKE_TYPES as $key => $fallback) {
$out[$key] = t('statistics.intake.' . $key, [], $fallback);
}
return $out;
}
private static function intakeReasons(): array
{
$out = [];
foreach (self::INTAKE_REASONS as $key => $fallback) {
$out[$key] = t('statistics.reason.' . $key, [], $fallback);
}
return $out;
}
public static function history(): void
{
$db = DB::pdo();
$id = (int) ($_GET['id'] ?? 0);
if ($id <= 0) {
http_response_code(400);
return;
}
$a = $db->prepare('SELECT * FROM animals WHERE id=:id');
$a->execute([':id' => $id]);
$animal = $a->fetch(PDO::FETCH_ASSOC);
if (!$animal) {
http_response_code(404);
return;
}
$h = $db->prepare('
SELECT ah.*,COALESCE(u.display_name,u.username) actor_name FROM animal_history ah
LEFT JOIN users u ON u.id=ah.user_id
WHERE ah.animal_id=:id
ORDER BY ah.created_at DESC,ah.id DESC
LIMIT 500
');
$h->execute([':id' => $id]);
$history = $h->fetchAll(PDO::FETCH_ASSOC);
if (!PermissionService::can('medical')) {
$history = array_values(
array_filter(
$history,
static fn(array $row): bool => !in_array(
(string) ($row['type'] ?? ''),
['medical', 'treatment', 'vaccine', 'deworming', 'death'],
true,
),
),
);
}
self::render('animal_history', [
'title' => 'Historique – ' . ($animal['name'] ?? ''),
'animal' => $animal,
'history' => $history,
]);
}
private static function logHistory(int $animalId, string $type, string $label, ?string $details = null): void
{
$db = DB::pdo();
$db->prepare(
'
INSERT INTO animal_history (animal_id, type, label, details, user_id)
VALUES (:aid, :type, :label, :details, :user)
',
)->execute([
':aid' => $animalId,
':type' => $type,
':label' => $label,
':details' => $details,
':user' => Auth::id(),
]);
}
private static function syncIntakeMovement(
PDO $db,
int $animalId,
?string $date,
string $type,
string $reason,
?int $depositorId,
string $municipality,
string $address,
string $circumstances,
): void {
$contact = null;
if ($depositorId) {
$s = $db->prepare('SELECT name,phone,email FROM directory_contacts WHERE id=:id AND deleted_at IS NULL');
$s->execute([':id' => $depositorId]);
$contact = $s->fetch(PDO::FETCH_ASSOC) ?: null;
}
$s = $db->prepare(
"SELECT id FROM animal_movements WHERE animal_id=:animal AND kind='entry' ORDER BY id ASC LIMIT 1",
);
$s->execute([':animal' => $animalId]);
$movementId = (int) $s->fetchColumn();
$typeLabel = self::intakeTypes()[$type] ?? self::intakeTypes()['unknown'];
$reasonLabel = self::intakeReasons()[$reason] ?? self::intakeReasons()['unknown'];
$note = 'Motif : ' . $reasonLabel;
if ($circumstances !== '') {
$note .= "\nCirconstances : " . $circumstances;
}
$values = [
':animal' => $animalId,
':place' => $municipality !== '' ? $municipality : ($address !== '' ? $address : null),
':lieu' => $typeLabel,
':name' => $contact['name'] ?? null,
':phone' => $contact['phone'] ?? null,
':email' => $contact['email'] ?? null,
':note' => $note,
':at' => ($date ?: date('Y-m-d')) . ' 12:00:00',
];
if ($movementId) {
$values[':id'] = $movementId;
$db->prepare(
'UPDATE animal_movements SET place=:place,lieu=:lieu,contact_name=:name,contact_phone=:phone,contact_email=:email,note=:note,created_at=:at WHERE id=:id AND animal_id=:animal',
)->execute($values);
} else {
$db->prepare(
"INSERT INTO animal_movements(animal_id,kind,place,lieu,contact_name,contact_phone,contact_email,note,created_at) VALUES(:animal,'entry',:place,:lieu,:name,:phone,:email,:note,:at)",
)->execute($values);
}
}
private static function now(): string
{
return date('Y-m-d H:i:s');
}
private static function animalMediaDir(int $animalId): string
{
// public/media/animals/{id}
return __DIR__ . '/../../public/media/animals/' . $animalId;
}
private static function ensureAnimalMediaDir(int $animalId): void
{
$dir = self::animalMediaDir($animalId);
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
}
private static function safeFilename(string $name): string
{
$name = preg_replace('~[^a-zA-Z0-9._-]+~', '_', $name);
$name = trim($name, '._-');
return $name === '' ? 'photo' : $name;
}
private static function resolveDirectoryContact(
PDO $db,
int $id,
string $newName,
string $kind,
string $role,
): ?array {
if ($id > 0) {
$stmt = $db->prepare(
'SELECT dc.id,dc.name,dc.organization_id FROM directory_contacts dc WHERE dc.id=:id AND dc.kind=:kind AND dc.deleted_at IS NULL AND EXISTS(SELECT 1 FROM directory_contact_roles r WHERE r.contact_id=dc.id AND r.role=:role)',
);
$stmt->execute([':id' => $id, ':kind' => $kind, ':role' => $role]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
$newName = trim($newName);
if ($newName === '') {
return null;
}
$stmt = $db->prepare(
'SELECT id,name FROM directory_contacts WHERE deleted_at IS NULL AND kind=:kind AND lower(name)=lower(:name) LIMIT 1',
);
$stmt->execute([':kind' => $kind, ':name' => $newName]);
$contact = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$contact) {
$db->prepare('INSERT INTO directory_contacts(kind,name) VALUES(:kind,:name)')->execute([
':kind' => $kind,
':name' => $newName,
]);
$contact = ['id' => (int) $db->lastInsertId(), 'name' => $newName, 'organization_id' => null];
}
$db->prepare('INSERT OR IGNORE INTO directory_contact_roles(contact_id,role) VALUES(:id,:role)')->execute([
':id' => $contact['id'],
':role' => $role,
]);
return $contact;
}
private static function normalizeVeterinaryClinic(PDO $db, ?array &$vet, ?array &$clinic): void
{
if (!$vet) {
return;
}
$organizationId = (int) ($vet['organization_id'] ?? 0);
if ($organizationId > 0) {
$s = $db->prepare(
"SELECT id,name,organization_id FROM directory_contacts WHERE id=:id AND kind='organization' AND deleted_at IS NULL",
);
$s->execute([':id' => $organizationId]);
$linked = $s->fetch(PDO::FETCH_ASSOC);
if ($linked) {
$clinic = $linked;
}
return;
}
if (!$clinic) {
return;
}
$db->prepare(
"UPDATE directory_contacts SET organization_id=:clinic,updated_at=datetime('now') WHERE id=:vet AND kind='person'",
)->execute([':clinic' => $clinic['id'], ':vet' => $vet['id']]);
$vet['organization_id'] = $clinic['id'];
}
private static function isAllowedImage(string $tmpPath): bool
{
$info = @getimagesize($tmpPath);
if (!$info) {
return false;
}
$mime = $info['mime'] ?? '';
return in_array($mime, ['image/jpeg', 'image/png', 'image/webp', 'image/gif'], true);
}
public static function togglePhotoVisibility(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$animalId = (int) ($_POST['animal_id'] ?? 0);
$photoId = (int) ($_POST['photo_id'] ?? 0);
$isPublic = (int) ($_POST['is_public'] ?? 1 ? 1 : 0);
if ($animalId <= 0 || $photoId <= 0) {
http_response_code(400);
echo h(t('error.bad_request'));
return;
}
// onglet retour
$redirectHash = trim((string) ($_POST['redirect_hash'] ?? '#tab-photos'));
$hash = $redirectHash !== '' && $redirectHash[0] === '#' ? $redirectHash : '#tab-photos';
$db = DB::pdo();
// Récupère la photo ciblée + état actuel
$st = $db->prepare('
SELECT id, is_primary, is_public, filename
FROM animal_photos
WHERE id = :pid AND animal_id = :aid
LIMIT 1
');
$st->execute([':pid' => $photoId, ':aid' => $animalId]);
$photo = $st->fetch(PDO::FETCH_ASSOC);
if (!$photo) {
http_response_code(404);
echo h(t('error.photo_not_found'));
return;
}
$wasPrimary = (int) ($photo['is_primary'] ?? 0) === 1;
$curPublic = (int) ($photo['is_public'] ?? 1);
// Rien à faire si déjà le bon état
if ($curPublic === $isPublic) {
header('Location: /animal?id=' . $animalId . $hash);
exit();
}
try {
PrivateMediaService::moveVisibility($animalId, (string) $photo['filename'], $isPublic === 1);
$db->beginTransaction();
// 1) Toggle visibilité
$db->prepare(
'
UPDATE animal_photos
SET is_public = :pub
WHERE id = :pid AND animal_id = :aid
',
)->execute([
':pub' => $isPublic,
':pid' => $photoId,
':aid' => $animalId,
]);
// 2) Si on rend PRIVÉ une photo qui était PRINCIPALE → on lui retire le flag principale
// et on promeut une autre photo publique (la plus récente) si possible.
if ($isPublic === 0 && $wasPrimary) {
// Retire principale à celle-ci
$db->prepare(
'
UPDATE animal_photos
SET is_primary = 0
WHERE id = :pid AND animal_id = :aid
',
)->execute([':pid' => $photoId, ':aid' => $animalId]);
// Cherche une photo publique à promouvoir
$newPrimaryId = $db
->query(
'
SELECT id
FROM animal_photos
WHERE animal_id = ' .
(int) $animalId .
'
AND is_public = 1
ORDER BY is_primary DESC, id DESC
LIMIT 1
',
)
->fetchColumn();
if ($newPrimaryId) {
// Met toutes les autres à 0 puis celle-là à 1 (propre)
$db->prepare('UPDATE animal_photos SET is_primary=0 WHERE animal_id=:aid')->execute([
':aid' => $animalId,
]);
$db->prepare('UPDATE animal_photos SET is_primary=1 WHERE id=:pid AND animal_id=:aid')->execute([
':pid' => (int) $newPrimaryId,
':aid' => $animalId,
]);
} else {
// aucune publique => aucune principale (OK)
$db->prepare('UPDATE animal_photos SET is_primary=0 WHERE animal_id=:aid')->execute([
':aid' => $animalId,
]);
}
}
// 3) Si on rend PUBLIC et qu'il n'y a aucune principale → cette photo devient principale
if ($isPublic === 1) {
$hasPrimary = (bool) $db
->query(
'
SELECT 1
FROM animal_photos
WHERE animal_id=' .
(int) $animalId .
' AND is_primary=1
LIMIT 1
',
)
->fetchColumn();
if (!$hasPrimary) {
// On peut la promouvoir
$db->prepare('UPDATE animal_photos SET is_primary=0 WHERE animal_id=:aid')->execute([
':aid' => $animalId,
]);
$db->prepare('UPDATE animal_photos SET is_primary=1 WHERE id=:pid AND animal_id=:aid')->execute([
':pid' => $photoId,
':aid' => $animalId,
]);
}
}
$db->commit();
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
try {
PrivateMediaService::moveVisibility($animalId, (string) $photo['filename'], $isPublic !== 1);
} catch (Throwable) {
}
throw $e;
}
// Historique (optionnel)
if (method_exists(__CLASS__, 'logHistory')) {
self::logHistory(
$animalId,
'photo',
$isPublic ? 'Photo rendue publique' : 'Photo rendue privée',
(string) ($photo['filename'] ?? ''),
);
}
$db->prepare("UPDATE animals SET updated_at=datetime('now') WHERE id=:id")->execute([':id' => $animalId]);
header('Location: /animal?id=' . $animalId . $hash);
exit();
}
public static function icad(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
header('Allow: POST');
return;
}
$db = DB::pdo();
$id = (int) ($_POST['id'] ?? 0);
if ($id <= 0) {
http_response_code(400);
echo h(t('error.bad_request'));
return;
}
$force = (int) ($_POST['force'] ?? 0) === 1;
// animal + puce
$st = $db->prepare('SELECT id, chip_id FROM animals WHERE id=:id LIMIT 1');
$st->execute([':id' => $id]);
$animal = $st->fetch(PDO::FETCH_ASSOC);
if (!$animal) {
http_response_code(404);
echo h(t('error.animal_not_found'));
return;
}
$chipId = preg_replace('/\D+/', '', (string) ($animal['chip_id'] ?? ''));
if ($chipId === '') {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['cached' => null, 'stale' => true, 'error' => 'chip_id vide'], JSON_UNESCAPED_UNICODE);
return;
}
require_once __DIR__ . '/../Services/IcadService.php';
$svc = new IcadService($db);
$cached = $svc->getCachedByAnimal($id);
$stale = $svc->isStale($cached, 86400);
if ($force || $stale) {
try {
$svc->refreshByChip($id, $chipId);
} catch (Throwable $e) {
// on laisse le cache en "error"
}
$cached = $svc->getCachedByAnimal($id);
$stale = $svc->isStale($cached, 86400);
}
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['cached' => $cached, 'stale' => $stale], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
public static function geocode(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
header('Allow: POST');
return;
}
$db = DB::pdo();
$id = (int) ($_POST['id'] ?? 0);
if ($id <= 0) {
http_response_code(400);
echo h(t('error.bad_request'));
return;
}
$force = (int) ($_POST['force'] ?? 0) === 1;
// Récupérer les adresses + coords existantes
$st = $db->prepare('
SELECT id,
rescue_address, rescue_lat, rescue_lng,
current_address, current_lat, current_lng
FROM animals
WHERE id=:id
LIMIT 1
');
$st->execute([':id' => $id]);
$a = $st->fetch(PDO::FETCH_ASSOC);
if (!$a) {
http_response_code(404);
echo h(t('error.animal_not_found'));
return;
}
require_once __DIR__ . '/../Services/GeocodeService.php';
$updates = [];
$debug = [
'animal_id' => $id,
'force' => $force,
'rescue' => null,
'current' => null,
];
// Rescue
$rescueAddr = trim((string) ($a['rescue_address'] ?? ''));
$hasRescueCoords = $a['rescue_lat'] !== null && $a['rescue_lng'] !== null;
if ($rescueAddr !== '' && ($force || !$hasRescueCoords)) {
$g = GeocodeService::geocode($rescueAddr);
$debug['rescue'] = ['query' => $rescueAddr, 'result' => $g];
if ($g) {
$updates['rescue_lat'] = $g['lat'];
$updates['rescue_lng'] = $g['lng'];
}
}
// Current
$currentAddr = trim((string) ($a['current_address'] ?? ''));
$hasCurrentCoords = $a['current_lat'] !== null && $a['current_lng'] !== null;
if ($currentAddr !== '' && ($force || !$hasCurrentCoords)) {
$g = GeocodeService::geocode($currentAddr);
$debug['current'] = ['query' => $currentAddr, 'result' => $g];
if ($g) {
$updates['current_lat'] = $g['lat'];
$updates['current_lng'] = $g['lng'];
}
}
if (!empty($updates)) {
$set = [];
$params = [':id' => $id];
foreach ($updates as $k => $v) {
$set[] = "$k = :$k";
$params[":$k"] = $v;
}
$sql = 'UPDATE animals SET ' . implode(', ', $set) . ", updated_at=datetime('now') WHERE id=:id";
$u = $db->prepare($sql);
$u->execute($params);
}
// Si appel AJAX/JSON
$wantsJson = isset($_POST['json']) && (int) $_POST['json'] === 1;
if ($wantsJson) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(
[
'ok' => true,
'updated' => $updates,
'debug' => $debug,
],
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
);
return;
}
// Sinon redirection propre vers l'onglet identité
header('Location: /animal?id=' . $id . '#tab-ident');
exit();
}
}

View file

@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
final class Asm3ImportController
{
public static function index(): void
{
self::admin();
$jobs = DB::pdo()
->query(
'SELECT j.*,COALESCE(u.display_name,u.username) actor FROM asm3_import_jobs j LEFT JOIN users u ON u.id=j.created_by ORDER BY j.id DESC LIMIT 20',
)
->fetchAll(PDO::FETCH_ASSOC);
render('settings_asm3.php', [
'title' => t('asm3.title'),
'jobs' => $jobs,
'error' => (string) ($_GET['error'] ?? ''),
'success' => (string) ($_GET['success'] ?? ''),
]);
}
public static function upload(): void
{
self::admin();
$f = $_FILES['dump'] ?? null;
if (!is_array($f)) {
self::fail(
self::postTooLarge()
? t('asm3.post_too_large', ['limit' => ini_get('post_max_size')])
: t('asm3.select_dump'),
);
}
$uploadError = (int) ($f['error'] ?? UPLOAD_ERR_NO_FILE);
if ($uploadError !== UPLOAD_ERR_OK) {
self::fail(self::uploadErrorMessage($uploadError));
}
if (!is_uploaded_file((string) $f['tmp_name'])) {
self::fail(t('asm3.upload_unverified'));
}
if ((int) $f['size'] > 250_000_000) {
self::fail(t('asm3.over_250'));
}
$name = (string) $f['name'];
$extension = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if (!in_array($extension, ['sql', 'pgsql'], true)) {
self::fail(t('asm3.extension'));
}
$dir = dirname(__DIR__, 2) . '/storage/asm3-imports';
if (!is_dir($dir) && !mkdir($dir, 0700, true) && !is_dir($dir)) {
self::fail(t('asm3.storage_unavailable'));
}
$stored = bin2hex(random_bytes(16)) . '.' . $extension;
$path = $dir . '/' . $stored;
if (!move_uploaded_file((string) $f['tmp_name'], $path)) {
self::fail(t('asm3.storage_failed'));
}
chmod($path, 0600);
try {
$analysis = Asm3Analyzer::analyze($path);
if (($analysis['tables']['animal'] ?? 0) < 1) {
throw new RuntimeException(t('asm3.no_animal'));
}
$animals = Asm3MigrationPlanner::plan($path, DB::pdo());
$contacts = Asm3ContactPlanner::plan($path, DB::pdo());
$report = [
'analysis' => $analysis,
'animals' => $animals['summary'],
'contacts' => $contacts['summary'],
'movements' => Asm3MovementImporter::run($path, DB::pdo(), false)['summary'],
'clinical' => Asm3ClinicalImporter::run($path, DB::pdo(), false),
'litters' => Asm3LitterImporter::run($path, DB::pdo(), false),
'media' => [
'metadata' => $analysis['tables']['media'] ?? 0,
'importable' => 0,
'reason' => 'Le dump SQL ne contient pas les fichiers binaires ASM3.',
],
];
$db = DB::pdo();
$db->prepare(
'INSERT INTO asm3_import_jobs(original_name,stored_name,source_sha256,analysis_json,created_by) VALUES(:original,:stored,:hash,:analysis,:user)',
)->execute([
':original' => basename($name),
':stored' => $stored,
':hash' => hash_file('sha256', $path),
':analysis' => json_encode($report, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
':user' => Auth::id(),
]);
AuditService::log('asm3_analyzed', '/settings/asm3', 'Dump ASM3 analysé : ' . basename($name));
header('Location: /settings/asm3?success=analyzed');
exit();
} catch (Throwable $e) {
@unlink($path);
self::fail($e->getMessage());
}
}
public static function apply(): void
{
self::admin();
$id = (int) ($_POST['id'] ?? 0);
$mode = ($_POST['mode'] ?? 'merge') === 'replace' ? 'replace' : 'merge';
$expected = t($mode === 'replace' ? 'asm3.confirm_replace_phrase' : 'asm3.confirm_import_phrase');
if (trim((string) ($_POST['confirmation'] ?? '')) !== $expected) {
self::fail(t('asm3.confirm_required', ['phrase' => $expected]));
}
$db = DB::pdo();
$q = $db->prepare("SELECT * FROM asm3_import_jobs WHERE id=:id AND status='analyzed'");
$q->execute([':id' => $id]);
$job = $q->fetch(PDO::FETCH_ASSOC);
$q->closeCursor();
unset($q);
if (!$job) {
self::fail(t('asm3.analysis_unavailable'));
}
$path = dirname(__DIR__, 2) . '/storage/asm3-imports/' . basename($job['stored_name']);
if (!is_file($path) || !hash_equals($job['source_sha256'], hash_file('sha256', $path))) {
self::fail(t('asm3.dump_altered'));
}
try {
$backup = BackupService::create('pre-asm3-import');
$db->beginTransaction();
$removed = $mode === 'replace' ? Asm3ResetService::clear($db) : null;
$results = [
'mode' => $mode,
'backup' => $backup['name'],
'removed' => $removed,
'animals' => Asm3AnimalImporter::run($path, $db, true, false),
'contacts' => Asm3ContactImporter::run($path, $db, true, false),
'movements' => Asm3MovementImporter::run($path, $db, true, false),
'clinical' => Asm3ClinicalImporter::run($path, $db, true, false),
'litters' => Asm3LitterImporter::run($path, $db, true, false),
];
$db->prepare(
"UPDATE asm3_import_jobs SET status='completed',result_json=:result,completed_at=datetime('now') WHERE id=:id",
)->execute([
':result' => json_encode($results, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
':id' => $id,
]);
$db->commit();
if ($mode === 'replace') {
Asm3ResetService::clearMedia();
}
AuditService::log(
'asm3_imported',
'/settings/asm3/apply',
'Migration ASM3 terminée en mode ' . $mode . ' depuis ' . $job['original_name'],
);
header('Location: /settings/asm3?success=imported');
exit();
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
$db->prepare(
"UPDATE asm3_import_jobs SET status='failed',result_json=:result,completed_at=datetime('now') WHERE id=:id",
)->execute([
':result' => json_encode(['error' => $e->getMessage(), 'mode' => $mode], JSON_UNESCAPED_UNICODE),
':id' => $id,
]);
self::fail(t('asm3.import_failed', ['error' => $e->getMessage()]));
}
}
private static function uploadErrorMessage(int $error): string
{
return match ($error) {
UPLOAD_ERR_INI_SIZE => t('asm3.upload_ini', ['limit' => ini_get('upload_max_filesize')]),
UPLOAD_ERR_FORM_SIZE => t('asm3.upload_form'),
UPLOAD_ERR_PARTIAL => t('asm3.upload_partial'),
UPLOAD_ERR_NO_FILE => t('asm3.select_dump'),
UPLOAD_ERR_NO_TMP_DIR => t('asm3.tmp_unavailable'),
UPLOAD_ERR_CANT_WRITE => t('asm3.write_failed'),
UPLOAD_ERR_EXTENSION => t('asm3.extension_stopped'),
default => t('asm3.upload_failed', ['code' => $error]),
};
}
private static function postTooLarge(): bool
{
$length = (int) ($_SERVER['CONTENT_LENGTH'] ?? 0);
$limit = self::iniBytes((string) ini_get('post_max_size'));
return $length > 0 && $limit > 0 && $length > $limit;
}
private static function iniBytes(string $value): int
{
$value = trim($value);
if ($value === '') {
return 0;
}
$number = (float) $value;
return (int) round(
$number *
match (strtolower(substr($value, -1))) {
'g' => 1024 ** 3,
'm' => 1024 ** 2,
'k' => 1024,
default => 1,
},
);
}
private static function fail(string $message): never
{
header('Location: /settings/asm3?error=' . rawurlencode($message));
exit();
}
private static function admin(): void
{
if (!Auth::is('admin')) {
http_response_code(403);
exit();
}
}
}

View file

@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
final class AuthController
{
public static function logo(): void
{
header('Content-Type: image/png');
header('Cache-Control: public,max-age=86400');
readfile(__DIR__ . '/../../resources/branding/Logo_Globinours_maxi.png');
exit();
}
public static function pwaIcon(int $size): void
{
$size = in_array($size, [192, 512], true) ? $size : 192;
$source = __DIR__ . '/../../resources/branding/Logo_Globinours_maxi.png';
if (!extension_loaded('gd') || !is_file($source)) {
self::logo();
return;
}
$src = imagecreatefrompng($source);
if (!$src) {
self::logo();
return;
}
$dst = imagecreatetruecolor($size, $size);
imagesavealpha($dst, true);
$transparent = imagecolorallocatealpha($dst, 0, 0, 0, 127);
imagefill($dst, 0, 0, $transparent);
$sw = imagesx($src);
$sh = imagesy($src);
$ratio = min(($size * 0.88) / $sw, ($size * 0.88) / $sh);
$w = (int) round($sw * $ratio);
$h = (int) round($sh * $ratio);
imagecopyresampled($dst, $src, (int) (($size - $w) / 2), (int) (($size - $h) / 2), 0, 0, $w, $h, $sw, $sh);
header('Content-Type: image/png');
header('Cache-Control: public,max-age=604800,immutable');
imagepng($dst);
imagedestroy($src);
imagedestroy($dst);
exit();
}
public static function setup(): void
{
if (Auth::countUsers() > 0) {
header('Location: /login');
exit();
}
$error = null;
$checks = InstallationService::runtimeChecks();
$canInstall = InstallationService::canInstall($checks);
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
if (!Auth::validCsrf($_POST['csrf'] ?? null)) {
$error = t('auth.session_expired');
} elseif (!$canInstall) {
$error = t('install.requirements_block');
} else {
$username = trim((string) ($_POST['username'] ?? ''));
$display = trim((string) ($_POST['display_name'] ?? ''));
$password = (string) ($_POST['password'] ?? '');
$confirm = (string) ($_POST['password_confirm'] ?? '');
$association = trim((string) ($_POST['association_name'] ?? ''));
$email = trim((string) ($_POST['association_email'] ?? ''));
$language = (string) ($_POST['app_language'] ?? 'fr');
$routing = (string) ($_POST['public_site_routing'] ?? 'integrated');
if ($association === '') {
$error = t('install.association_required');
} elseif ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$error = t('settings.invalid_email');
} elseif (!preg_match('/^[a-zA-Z0-9._-]{3,40}$/', $username)) {
$error = t('auth.invalid_username');
} elseif ($display === '') {
$error = t('auth.display_required');
} elseif (strlen($password) < 10) {
$error = t('auth.password_min', ['count' => 10]);
} elseif ($password !== $confirm) {
$error = t('auth.password_mismatch');
} else {
$db = DB::pdo();
try {
$db->beginTransaction();
$db->prepare(
"INSERT INTO users(username,password_hash,display_name,role) VALUES(:u,:p,:d,'admin')",
)->execute([
':u' => $username,
':p' => password_hash($password, PASSWORD_DEFAULT),
':d' => $display,
]);
AppSettings::save(
[
'association_name' => $association,
'association_address' => trim((string) ($_POST['association_address'] ?? '')),
'association_postal_code' => trim((string) ($_POST['association_postal_code'] ?? '')),
'association_city' => trim((string) ($_POST['association_city'] ?? '')),
'association_phone' => trim((string) ($_POST['association_phone'] ?? '')),
'association_email' => $email,
'association_siret' => trim((string) ($_POST['association_siret'] ?? '')),
'association_rna' => trim((string) ($_POST['association_rna'] ?? '')),
'app_language' => isset(I18n::LOCALES[$language]) ? $language : 'fr',
'public_site_routing' => in_array($routing, ['integrated', 'root'], true)
? $routing
: 'integrated',
],
(int) $db->lastInsertId(),
);
$db->commit();
Auth::login($username, $password);
AuditService::log(
'setup',
'/setup',
'Installation initiale et création du premier administrateur',
'user',
Auth::id(),
);
header('Location: /settings/health?installed=1');
exit();
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
$error = SecurityService::publicError($e);
}
}
}
}
self::view(t('auth.first_setup'), 'setup', $error, [
'checks' => $checks,
'canInstall' => $canInstall,
'publicPath' => InstallationService::publicPath(),
]);
}
public static function setupUnavailable(array $checks): void
{
self::view(t('auth.first_setup'), 'setup', $error = t('install.runtime_missing'), [
'checks' => $checks,
'canInstall' => false,
'publicPath' => InstallationService::publicPath(),
]);
}
public static function login(): void
{
if (Auth::countUsers() === 0) {
header('Location: /setup');
exit();
}
if (Auth::user()) {
header('Location: ' . PermissionService::firstAllowedPath());
exit();
}
$error = null;
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
if (!Auth::validCsrf($_POST['csrf'] ?? null)) {
$error = t('auth.session_expired');
} elseif (
Auth::login(
(string) ($_POST['username'] ?? ''),
(string) ($_POST['password'] ?? ''),
isset($_POST['remember_device']),
(string) ($_POST['device_name'] ?? ''),
)
) {
AuditService::log('login', '/login', 'Connexion réussie');
$next = (string) ($_POST['next'] ?? '');
if (
!str_starts_with($next, '/') ||
str_starts_with($next, '//') ||
!PermissionService::canOpenPath((string) (parse_url($next, PHP_URL_PATH) ?: '/'))
) {
$next = PermissionService::firstAllowedPath();
}
header('Location: ' . $next);
exit();
} else {
AuditService::log('login_failed', '/login', 'Échec de connexion', null, null, [
'username_hash' => hash('sha256', mb_strtolower(trim((string) ($_POST['username'] ?? '')))),
]);
$error = t('auth.invalid_credentials');
}
}
self::view(t('auth.login'), 'login', $error);
}
public static function logout(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST' || !Auth::validCsrf($_POST['csrf'] ?? null)) {
http_response_code(400);
return;
}
AuditService::log('logout', '/logout', 'Déconnexion');
Auth::logout();
header('Location: /login');
exit();
}
private static function view(string $title, string $mode, ?string $error, array $vars = []): void
{
extract($vars, EXTR_SKIP);
require __DIR__ . '/../Views/auth.php';
}
}

View file

@ -0,0 +1,463 @@
<?php
declare(strict_types=1);
final class CareRoundsController
{
public static function index(): void
{
$db = DB::pdo();
$period = ($_GET['period'] ?? 'morning') === 'evening' ? 'evening' : 'morning';
$date = preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) ($_GET['date'] ?? ''))
? (string) $_GET['date']
: date('Y-m-d');
$stmt = $db->prepare('SELECT * FROM care_rounds WHERE round_date=:date AND period=:period');
$stmt->execute([':date' => $date, ':period' => $period]);
$round = $stmt->fetch();
if (!$round) {
$round = ['id' => 0, 'round_date' => $date, 'period' => $period, 'performed_by_user_id' => null];
}
$layouts = [];
foreach ($db->query('SELECT * FROM care_room_layouts')->fetchAll() as $l) {
$layouts[$l['room']] = $l;
}
$animals = $db
->query(
"
SELECT a.*, p.filename AS primary_photo
FROM animals a LEFT JOIN animal_photos p ON p.animal_id=a.id AND p.is_primary=1
WHERE a.deleted_at IS NULL AND a.archived_at IS NULL
AND lower(a.status) IN ('refuge','soin','quarantaine')
ORDER BY a.name COLLATE NOCASE
",
)
->fetchAll();
$observations = [];
$administrations = [];
if ((int) $round['id'] > 0) {
$stmt = $db->prepare('SELECT * FROM care_round_observations WHERE round_id=:id');
$stmt->execute([':id' => $round['id']]);
foreach ($stmt->fetchAll() as $o) {
$observations[(int) $o['animal_id']] = $o;
}
$stmt = $db->prepare('SELECT treatment_id,status FROM treatment_administrations WHERE round_id=:id');
$stmt->execute([':id' => $round['id']]);
foreach ($stmt->fetchAll() as $a) {
$administrations[(int) $a['treatment_id']] = $a['status'];
}
}
$treatments = [];
$stmt = $db->prepare("
SELECT t.*,m.name AS medication_name FROM treatments t JOIN ref_medications m ON m.id=t.medication_id
WHERE t.ongoing=1 AND ((:period='morning' AND t.give_morning=1) OR (:period='evening' AND t.give_evening=1))
ORDER BY m.name
");
$stmt->execute([':period' => $period]);
foreach ($stmt->fetchAll() as $t) {
$treatments[(int) $t['animal_id']][] = $t;
}
$unconfigured = [];
$rows = $db
->query(
'SELECT t.id,t.animal_id,t.dose_text,m.name medication_name FROM treatments t JOIN ref_medications m ON m.id=t.medication_id WHERE t.ongoing=1 AND t.give_morning=0 AND t.give_evening=0 AND t.give_as_needed=0 ORDER BY m.name',
)
->fetchAll();
foreach ($rows as $r) {
$unconfigured[(int) $r['animal_id']][] = $r;
}
render('care_round.php', [
'title' => t('care.title'),
'pageDescription' => t('care.description'),
'period' => $period,
'date' => $date,
'round' => $round,
'layouts' => $layouts,
'rooms' => ShelterRoomService::all(true),
'animals' => $animals,
'treatmentsByAnimal' => $treatments,
'unconfiguredTreatments' => $unconfigured,
'observations' => $observations,
'administrations' => $administrations,
]);
}
public static function saveObservation(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$db = DB::pdo();
$roundId = self::ensureRound($db);
$animalId = (int) ($_POST['animal_id'] ?? 0);
if (!$roundId || !$animalId) {
http_response_code(400);
return;
}
$animalCheck = $db->prepare(
"SELECT 1 FROM animals WHERE id=? AND deleted_at IS NULL AND archived_at IS NULL AND lower(status) IN ('refuge','soin','quarantaine')",
);
$animalCheck->execute([$animalId]);
if (!$animalCheck->fetchColumn()) {
http_response_code(404);
return;
}
$allowed = [
'food' => ['normal', 'little', 'refused'],
'water' => ['normal', 'renewed', 'abnormal'],
'urine' => ['normal', 'absent', 'abnormal'],
'stool' => ['normal', 'absent', 'abnormal'],
'general_state' => ['good', 'watch', 'alert'],
];
$values = [];
foreach ($allowed as $field => $opts) {
$value = (string) ($_POST[$field] ?? $opts[0]);
$values[$field] = in_array($value, $opts, true) ? $value : $opts[0];
}
$db->prepare(
'UPDATE care_rounds SET performed_by_user_id=COALESCE(performed_by_user_id,:user) WHERE id=:id',
)->execute([':user' => Auth::id(), ':id' => $roundId]);
$comment = trim((string) ($_POST['comment'] ?? ''));
$photoComment = trim((string) ($_POST['photo_comment'] ?? ''));
$photo = $_FILES['care_photo'] ?? null;
$hasPhoto = is_array($photo) && ($photo['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE;
if ($hasPhoto) {
if (
($photo['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK ||
!is_uploaded_file((string) $photo['tmp_name']) ||
($photo['size'] ?? 0) > 20 * 1024 * 1024 ||
$photoComment === ''
) {
http_response_code(400);
echo h(t('care.invalid_photo'));
return;
}
$imageInfo = @getimagesize((string) $photo['tmp_name']);
$mime = (string) ($imageInfo['mime'] ?? '');
$extensions = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif'];
if (!isset($extensions[$mime])) {
http_response_code(400);
echo h(t('care.invalid_photo_format'));
return;
}
}
$admins = is_array($_POST['treatments'] ?? null) ? $_POST['treatments'] : [];
$skippedIds = [];
foreach ($admins as $treatmentId => $status) {
if ($status === 'skipped') {
$skippedIds[] = (int) $treatmentId;
}
}
$alerts = [];
if ($values['food'] === 'little') {
$alerts[] = 'A peu mangé';
} elseif ($values['food'] === 'refused') {
$alerts[] = 'A refusé de manger';
}
if ($values['water'] === 'abnormal') {
$alerts[] = 'Eau anormale';
}
if ($values['urine'] === 'absent') {
$alerts[] = 'Urines absentes';
} elseif ($values['urine'] === 'abnormal') {
$alerts[] = 'Urines anormales';
}
if ($values['stool'] === 'absent') {
$alerts[] = 'Selles absentes';
} elseif ($values['stool'] === 'abnormal') {
$alerts[] = 'Selles anormales';
}
if ($values['general_state'] === 'watch') {
$alerts[] = 'État général à surveiller';
} elseif ($values['general_state'] === 'alert') {
$alerts[] = 'Alerte sur l’état général';
}
if ($comment !== '') {
$alerts[] = 'Commentaire : ' . $comment;
}
foreach ($skippedIds as $treatmentId) {
$nameStmt = $db->prepare(
'SELECT m.name FROM treatments t LEFT JOIN ref_medications m ON m.id=t.medication_id WHERE t.id=:id AND t.animal_id=:animal',
);
$nameStmt->execute([':id' => $treatmentId, ':animal' => $animalId]);
$alerts[] = 'Traitement non donné : ' . ($nameStmt->fetchColumn() ?: 'traitement #' . $treatmentId);
}
$signature = $alerts
? hash('sha256', json_encode([$values, $comment, $skippedIds], JSON_UNESCAPED_UNICODE))
: null;
$previousStmt = $db->prepare(
'SELECT alert_signature FROM care_round_observations WHERE round_id=:round AND animal_id=:animal',
);
$previousStmt->execute([':round' => $roundId, ':animal' => $animalId]);
$previousSignature = $previousStmt->fetchColumn() ?: null;
$savedPhotoPath = null;
$db->beginTransaction();
try {
$stmt = $db->prepare(
"INSERT INTO care_round_observations(round_id,animal_id,food,water,urine,stool,general_state,comment,alert_signature) VALUES(:round,:animal,:food,:water,:urine,:stool,:general,:comment,:signature) ON CONFLICT(round_id,animal_id) DO UPDATE SET food=excluded.food,water=excluded.water,urine=excluded.urine,stool=excluded.stool,general_state=excluded.general_state,comment=excluded.comment,alert_signature=excluded.alert_signature,checked_at=datetime('now')",
);
$stmt->execute([
':round' => $roundId,
':animal' => $animalId,
':food' => $values['food'],
':water' => $values['water'],
':urine' => $values['urine'],
':stool' => $values['stool'],
':general' => $values['general_state'],
':comment' => $comment ?: null,
':signature' => $signature,
]);
$ins = $db->prepare(
"INSERT INTO treatment_administrations(round_id,treatment_id,animal_id,status,comment) VALUES(:round,:treatment,:animal,:status,:comment) ON CONFLICT(round_id,treatment_id) DO UPDATE SET status=excluded.status,comment=excluded.comment,administered_at=datetime('now')",
);
$treatmentCheck = $db->prepare('SELECT 1 FROM treatments WHERE id=? AND animal_id=? AND ongoing=1');
foreach ($admins as $treatmentId => $status) {
if (!in_array($status, ['given', 'skipped'], true)) {
continue;
}
$treatmentCheck->execute([(int) $treatmentId, $animalId]);
if (!$treatmentCheck->fetchColumn()) {
continue;
}
$ins->execute([
':round' => $roundId,
':treatment' => (int) $treatmentId,
':animal' => $animalId,
':status' => $status,
':comment' => null,
]);
}
if ($hasPhoto) {
$dir = PrivateMediaService::animalDir($animalId);
PrivateMediaService::ensure($dir);
$stored = ImageService::storeUploaded((string) $photo['tmp_name'], $dir, 'tournee', 'medical');
$filename = $stored['filename'];
$savedPhotoPath = $stored['path'];
$mime = $stored['mime'];
@chmod($savedPhotoPath, 0600);
$photoStmt = $db->prepare(
'INSERT INTO animal_photos(animal_id,filename,original_name,mime,size_bytes,is_primary,is_public,caption,care_round_id) VALUES(:animal,:filename,:original,:mime,:size,0,0,:caption,:round)',
);
$photoStmt->execute([
':animal' => $animalId,
':filename' => $filename,
':original' => (string) ($photo['name'] ?? 'photo'),
':mime' => $mime,
':size' => $stored['size'],
':caption' => $photoComment,
':round' => $roundId,
]);
$alerts[] = 'Photo privée ajoutée : ' . $photoComment;
}
if (($signature !== null && $signature !== $previousSignature) || $hasPhoto) {
$roundStmt = $db->prepare('SELECT round_date,period FROM care_rounds WHERE id=:id');
$roundStmt->execute([':id' => $roundId]);
$roundInfo = $roundStmt->fetch();
$roundDate = $roundInfo ? date('d/m/Y', strtotime((string) $roundInfo['round_date'])) : date('d/m/Y');
$roundPeriod = ($roundInfo['period'] ?? 'morning') === 'evening' ? 'soir' : 'matin';
$history = $db->prepare(
"INSERT INTO animal_history(animal_id,type,label,details,user_id) VALUES(:animal,'care_round',:label,:details,:user)",
);
$history->execute([
':animal' => $animalId,
':label' => 'Anomalie durant la tournée du ' . $roundPeriod . ' — ' . $roundDate,
':details' => implode("\n", $alerts),
':user' => Auth::id(),
]);
}
$db->prepare("UPDATE animals SET updated_at=datetime('now') WHERE id=:id")->execute([':id' => $animalId]);
$db->commit();
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
if ($savedPhotoPath && is_file($savedPhotoPath)) {
@unlink($savedPhotoPath);
}
throw $e;
}
$period = ($_POST['period'] ?? 'morning') === 'evening' ? 'evening' : 'morning';
$date = (string) ($_POST['date'] ?? date('Y-m-d'));
header('Location: /care-round?period=' . $period . '&date=' . rawurlencode($date) . '#animal-' . $animalId);
exit();
}
public static function saveRoom(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$db = DB::pdo();
$roundId = self::ensureRound($db);
$room = (string) ($_POST['room'] ?? '');
$rooms = ShelterRoomService::byCode(true);
if (!$roundId || !isset($rooms[$room]) || empty($rooms[$room]['bulk_validation'])) {
http_response_code(400);
return;
}
$animals = $db
->query(
"SELECT id,status,refuge_room FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND lower(status) IN ('refuge','soin','quarantaine')",
)
->fetchAll(PDO::FETCH_ASSOC);
$stmt = $db->prepare(
"INSERT OR IGNORE INTO care_round_observations(round_id,animal_id,food,water,urine,stool,general_state) VALUES(:round,:animal,'normal','normal','normal','normal','good')",
);
foreach ($animals as $animal) {
if (ShelterRoomService::resolveAnimal($animal, $rooms) === $room) {
$stmt->execute([':round' => $roundId, ':animal' => $animal['id']]);
}
}
$period = ($_POST['period'] ?? 'morning') === 'evening' ? 'evening' : 'morning';
$date = preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) ($_POST['date'] ?? ''))
? (string) $_POST['date']
: date('Y-m-d');
header('Location: /care-round?period=' . $period . '&date=' . rawurlencode($date) . '#room-' . $room);
exit();
}
public static function setup(): void
{
$db = DB::pdo();
$layouts = [];
foreach ($db->query('SELECT * FROM care_room_layouts')->fetchAll() as $l) {
$layouts[$l['room']] = $l;
}
$animals = $db
->query(
"SELECT id,name,status,refuge_room,care_box_key,current_address FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND lower(status) IN ('refuge','soin','quarantaine') ORDER BY name COLLATE NOCASE",
)
->fetchAll();
render('care_setup.php', [
'title' => t('care.setup_title'),
'layouts' => $layouts,
'rooms' => ShelterRoomService::all(true),
'animals' => $animals,
]);
}
public static function saveSetup(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$db = DB::pdo();
$topOpts = ['split', 'left2', 'right2', 'merged'];
$bottomOpts = ['split', 'merged'];
foreach (['care', 'quarantine'] as $room) {
$top = (string) ($_POST[$room . '_top'] ?? 'split');
$bottom = (string) ($_POST[$room . '_bottom'] ?? 'split');
if (!in_array($top, $topOpts, true)) {
$top = 'split';
}
if (!in_array($bottom, $bottomOpts, true)) {
$bottom = 'split';
}
$db->prepare(
"UPDATE care_room_layouts SET top_layout=:top,bottom_layout=:bottom,updated_at=datetime('now') WHERE room=:room",
)->execute([':top' => $top, ':bottom' => $bottom, ':room' => $room]);
}
$assignments = is_array($_POST['box'] ?? null) ? $_POST['box'] : [];
$roomAssignments = is_array($_POST['room'] ?? null) ? $_POST['room'] : [];
$rooms = ShelterRoomService::byCode(true);
$locationReason = trim((string) ($_POST['location_change_reason'] ?? ''));
$select = $db->prepare(
'SELECT status,refuge_room,care_box_key,current_address FROM animals WHERE id=:id AND deleted_at IS NULL AND archived_at IS NULL',
);
$stmt = $db->prepare(
"UPDATE animals SET status=:status,refuge_room=:room,care_box_key=:box,updated_at=datetime('now') WHERE id=:id AND deleted_at IS NULL AND archived_at IS NULL",
);
foreach ($roomAssignments as $id => $roomCode) {
$animalId = (int) $id;
$select->execute([':id' => $animalId]);
$before = $select->fetch(PDO::FETCH_ASSOC);
if (!$before) {
continue;
}
$roomCode = (string) $roomCode;
if (!isset($rooms[$roomCode])) {
continue;
}
$room = $rooms[$roomCode];
$allowedBoxes = array_column(
array_filter($room['boxes'], static fn($box) => (int) $box['active'] === 1),
'code',
);
if (in_array($roomCode, ['care', 'quarantine'], true)) {
$allowedBoxes = array_values(
array_unique(array_merge($allowedBoxes, ['top_ab', 'top_bc', 'top_all', 'bottom_all'])),
);
}
$newBox = trim((string) ($assignments[$id] ?? ''));
if ($room['room_type'] !== 'boxes' || !in_array($newBox, $allowedBoxes, true)) {
$newBox = null;
}
$status = (string) $room['status_code'];
$stmt->execute([':status' => $status, ':room' => $roomCode, ':box' => $newBox, ':id' => $animalId]);
LocationHistoryService::record(
$db,
$animalId,
$before,
array_merge($before, ['status' => $status, 'refuge_room' => $roomCode, 'care_box_key' => $newBox]),
'care_setup',
$locationReason ?: 'Attribution de la salle ou du box depuis la tournée',
);
}
header('Location: /care-round');
exit();
}
public static function saveTreatmentSchedule(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$id = (int) ($_POST['treatment_id'] ?? 0);
$schedule = (string) ($_POST['schedule'] ?? '');
$values = match ($schedule) {
'morning' => [1, 0, 0],
'evening' => [0, 1, 0],
'both' => [1, 1, 0],
'needed' => [0, 0, 1],
default => null,
};
if (!$id || !$values) {
http_response_code(400);
return;
}
DB::pdo()
->prepare('UPDATE treatments SET give_morning=?,give_evening=?,give_as_needed=? WHERE id=?')
->execute([$values[0], $values[1], $values[2], $id]);
$period = ($_POST['period'] ?? 'morning') === 'evening' ? 'evening' : 'morning';
header('Location: /care-round?period=' . $period);
exit();
}
private static function ensureRound(PDO $db): int
{
$date = preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) ($_POST['date'] ?? ''))
? (string) $_POST['date']
: date('Y-m-d');
$period = ($_POST['period'] ?? 'morning') === 'evening' ? 'evening' : 'morning';
$requested = (int) ($_POST['round_id'] ?? 0);
if ($requested) {
$s = $db->prepare('SELECT id FROM care_rounds WHERE id=? AND round_date=? AND period=?');
$s->execute([$requested, $date, $period]);
if ($s->fetchColumn()) {
return $requested;
}
}
$db->prepare(
'INSERT OR IGNORE INTO care_rounds(round_date,period,performed_by_user_id) VALUES(?,?,?)',
)->execute([$date, $period, Auth::id()]);
$s = $db->prepare('SELECT id FROM care_rounds WHERE round_date=? AND period=?');
$s->execute([$date, $period]);
return (int) $s->fetchColumn();
}
}

View file

@ -0,0 +1,300 @@
<?php
declare(strict_types=1);
final class DashboardController
{
public static function index(): void
{
$db = DB::pdo();
// Complète les anciennes quarantaines dont la date d’entrée était absente.
$db->exec("
UPDATE animals
SET intake_date = date(updated_at)
WHERE lower(trim(status))='quarantaine'
AND deleted_at IS NULL AND archived_at IS NULL
AND (intake_date IS NULL OR trim(intake_date)='')
AND updated_at IS NOT NULL AND trim(updated_at)<>''
");
$quarantineDays = AppSettings::int('quarantine_days', 1, 90);
$stmt = $db->prepare("
UPDATE animals
SET quarantine_until = date(intake_date, '+' || :days || ' day')
WHERE lower(trim(status))='quarantaine'
AND deleted_at IS NULL AND archived_at IS NULL
AND (quarantine_until IS NULL OR trim(quarantine_until)='')
AND intake_date IS NOT NULL AND trim(intake_date)<>''
");
$stmt->execute([':days' => $quarantineDays]);
// Stats par statut (avec tolérance accents)
$stats = $db
->query(
"
SELECT
SUM(CASE WHEN lower(status) NOT IN ('adopte','adopté','decede','décédé') THEN 1 ELSE 0 END) AS total,
SUM(CASE WHEN lower(status) NOT IN ('adopte','adopté','decede','décédé') AND birth_date IS NOT NULL AND trim(birth_date) != '' AND date(birth_date) > date('now','-12 months') THEN 1 ELSE 0 END) AS kittens,
SUM(CASE WHEN lower(status) NOT IN ('adopte','adopté','decede','décédé') AND birth_date IS NOT NULL AND trim(birth_date) != '' AND date(birth_date) <= date('now','-12 months') THEN 1 ELSE 0 END) AS adults,
SUM(CASE WHEN lower(status) NOT IN ('adopte','adopté','decede','décédé') AND (birth_date IS NULL OR trim(birth_date) = '') THEN 1 ELSE 0 END) AS age_unknown,
SUM(CASE WHEN status='refuge' THEN 1 ELSE 0 END) AS refuge,
SUM(CASE WHEN status='refuge' AND refuge_room='silver' THEN 1 ELSE 0 END) AS refuge_silver,
SUM(CASE WHEN status='refuge' AND refuge_room='twix' THEN 1 ELSE 0 END) AS refuge_twix,
SUM(CASE WHEN status='refuge' AND (refuge_room IS NULL OR trim(refuge_room)='') THEN 1 ELSE 0 END) AS refuge_unassigned,
SUM(CASE WHEN status='fa' THEN 1 ELSE 0 END) AS fa,
SUM(CASE WHEN status='fa_permanente' THEN 1 ELSE 0 END) AS fa_permanente,
SUM(CASE WHEN status='quarantaine' THEN 1 ELSE 0 END) AS quarantaine,
SUM(CASE WHEN status='soin' THEN 1 ELSE 0 END) AS soin,
SUM(CASE WHEN status='isolation' THEN 1 ELSE 0 END) AS isolation,
SUM(CASE WHEN status='hospitalise' OR status='hospitalisé' THEN 1 ELSE 0 END) AS hospitalise,
SUM(CASE WHEN status='reserve' OR status='réservé' THEN 1 ELSE 0 END) AS reserve,
SUM(CASE WHEN status='adopte' OR status='adopté' THEN 1 ELSE 0 END) AS adopte,
SUM(CASE WHEN status='decede' OR status='décédé' THEN 1 ELSE 0 END) AS dece,
SUM(CASE WHEN status='enfui' THEN 1 ELSE 0 END) AS enfui
FROM animals
WHERE deleted_at IS NULL AND archived_at IS NULL
",
)
->fetch();
$shelterRoomCounts = [];
$configuredRooms = ShelterRoomService::byCode(true);
$roomAnimals = $db
->query(
"SELECT status,refuge_room FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND status IN ('refuge','soin','quarantaine')",
)
->fetchAll(PDO::FETCH_ASSOC);
foreach ($configuredRooms as $code => $room) {
$shelterRoomCounts[$code] = [
'name' => $room['name'],
'color' => $room['color'],
'status' => $room['status_code'],
'count' => 0,
];
}
foreach ($roomAnimals as $roomAnimal) {
$code = ShelterRoomService::resolveAnimal($roomAnimal, $configuredRooms);
if (isset($shelterRoomCounts[$code])) {
$shelterRoomCounts[$code]['count']++;
}
}
$quality =
$db
->query(
"SELECT COUNT(*) total,
SUM(CASE WHEN birth_date IS NULL OR trim(birth_date)='' THEN 1 ELSE 0 END) missing_birth_date,
SUM(CASE WHEN sex IS NULL OR trim(sex)='' OR sex='U' THEN 1 ELSE 0 END) missing_sex,
SUM(CASE WHEN color IS NULL OR trim(color)='' THEN 1 ELSE 0 END) missing_color,
SUM(CASE WHEN breed IS NULL OR trim(breed)='' THEN 1 ELSE 0 END) missing_breed,
SUM(CASE WHEN (rescue_location_name IS NULL OR trim(rescue_location_name)='') AND (rescue_address IS NULL OR trim(rescue_address)='') THEN 1 ELSE 0 END) missing_origin,
SUM(CASE WHEN (chip_id IS NULL OR trim(chip_id)='') AND (chip IS NULL OR trim(chip)='') THEN 1 ELSE 0 END) missing_chip,
SUM(CASE WHEN COALESCE(NULLIF(identification_registration_status,''),'unknown')='unknown' THEN 1 ELSE 0 END) unknown_identification_registration,
SUM(CASE WHEN COALESCE(NULLIF(adoption_availability,''),'unknown')='unknown' THEN 1 ELSE 0 END) unknown_adoption_availability,
SUM(CASE WHEN sterilization_status='no' THEN 1 ELSE 0 END) unsterilized,
SUM(CASE WHEN COALESCE(NULLIF(sterilization_status,''),'unknown')='unknown' THEN 1 ELSE 0 END) unknown_sterilization,
SUM(CASE WHEN birth_date IS NULL OR trim(birth_date)='' OR sex IS NULL OR trim(sex)='' OR sex='U' OR color IS NULL OR trim(color)='' OR breed IS NULL OR trim(breed)='' OR ((rescue_location_name IS NULL OR trim(rescue_location_name)='') AND (rescue_address IS NULL OR trim(rescue_address)='')) OR ((chip_id IS NULL OR trim(chip_id)='') AND (chip IS NULL OR trim(chip)='')) OR COALESCE(NULLIF(identification_registration_status,''),'unknown')='unknown' OR COALESCE(NULLIF(adoption_availability,''),'unknown')='unknown' OR COALESCE(NULLIF(sterilization_status,''),'unknown')='unknown' THEN 1 ELSE 0 END) incomplete
FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND lower(status) NOT IN ('adopte','adopté','decede','décédé','enfui','fugue')",
)
->fetch(PDO::FETCH_ASSOC) ?:
[];
// Activité
$activity = $db
->query(
"
SELECT
(SELECT COUNT(*) FROM medical_notes mn JOIN animals a ON a.id=mn.animal_id WHERE a.deleted_at IS NULL AND a.archived_at IS NULL AND date(mn.noted_at) >= date('now','-7 day')) AS notes_7d,
(SELECT COUNT(*) FROM vaccinations v JOIN animals a ON a.id=v.animal_id WHERE a.deleted_at IS NULL AND a.archived_at IS NULL AND v.done_date IS NOT NULL AND date(v.done_date) >= date('now','-30 day')) AS vaccines_30d,
(SELECT COUNT(*) FROM treatments t JOIN animals a ON a.id=t.animal_id WHERE a.deleted_at IS NULL AND a.archived_at IS NULL AND t.start_date IS NOT NULL AND date(t.start_date) >= date('now','-30 day')) AS treatments_30d
",
)
->fetch();
// Vaccins en retard
$stmt = $db->prepare("
SELECT a.id, a.name, a.internal_code AS code, rv.name AS vaccine_name, v.due_date
FROM vaccinations v
JOIN animals a ON a.id = v.animal_id
JOIN ref_vaccines rv ON rv.id = v.vaccine_id
WHERE v.due_date IS NOT NULL
AND a.deleted_at IS NULL AND a.archived_at IS NULL
AND lower(a.status) NOT IN ('decede','décédé')
AND date(v.due_date) < date('now')
ORDER BY date(v.due_date) ASC
LIMIT 50
");
$stmt->execute();
$vaccines_overdue = $stmt->fetchAll();
// Vaccins ≤ 7 jours
$stmt = $db->prepare("
SELECT a.id, a.name, a.internal_code AS code, rv.name AS vaccine_name, v.due_date
FROM vaccinations v
JOIN animals a ON a.id = v.animal_id
JOIN ref_vaccines rv ON rv.id = v.vaccine_id
WHERE v.due_date IS NOT NULL
AND a.deleted_at IS NULL AND a.archived_at IS NULL
AND lower(a.status) NOT IN ('decede','décédé')
AND date(v.due_date) >= date('now')
AND date(v.due_date) <= date('now', '+7 day')
ORDER BY date(v.due_date) ASC
LIMIT 50
");
$stmt->execute();
$vaccines_7d = $stmt->fetchAll();
// Vaccins ≤ 30 jours
$stmt = $db->prepare("
SELECT a.id, a.name, a.internal_code AS code, rv.name AS vaccine_name, v.due_date
FROM vaccinations v
JOIN animals a ON a.id = v.animal_id
JOIN ref_vaccines rv ON rv.id = v.vaccine_id
WHERE v.due_date IS NOT NULL
AND a.deleted_at IS NULL AND a.archived_at IS NULL
AND lower(a.status) NOT IN ('decede','décédé')
AND date(v.due_date) > date('now', '+7 day')
AND date(v.due_date) <= date('now', '+30 day')
ORDER BY date(v.due_date) ASC
LIMIT 50
");
$stmt->execute();
$vaccines_30d = $stmt->fetchAll();
// Traitements en cours
$stmt = $db->prepare('
SELECT a.id, a.name, a.internal_code AS code,
m.name AS medication_name,
t.start_date, t.end_date, t.dose_text, t.route
FROM treatments t
JOIN animals a ON a.id = t.animal_id
JOIN ref_medications m ON m.id = t.medication_id
WHERE t.ongoing = 1
AND a.deleted_at IS NULL AND a.archived_at IS NULL
ORDER BY date(t.start_date) DESC, a.name ASC
LIMIT 50
');
$stmt->execute();
$treatments = $stmt->fetchAll();
// Traitements à clôturer (fin dépassée mais ongoing=1)
$stmt = $db->prepare("
SELECT a.id, a.name, a.internal_code AS code, m.name AS medication_name, t.end_date
FROM treatments t
JOIN animals a ON a.id = t.animal_id
JOIN ref_medications m ON m.id = t.medication_id
WHERE t.ongoing = 1
AND a.deleted_at IS NULL AND a.archived_at IS NULL
AND t.end_date IS NOT NULL
AND date(t.end_date) < date('now')
ORDER BY date(t.end_date) ASC
LIMIT 50
");
$stmt->execute();
$treatments_overdue = $stmt->fetchAll();
// Dernières notes médicales
$stmt = $db->prepare('
SELECT mn.noted_at, mn.kind, mn.reason,
a.id AS animal_id, a.name, a.internal_code AS code
FROM medical_notes mn
JOIN animals a ON a.id = mn.animal_id
WHERE a.deleted_at IS NULL AND a.archived_at IS NULL
ORDER BY datetime(mn.noted_at) DESC, mn.id DESC
LIMIT 30
');
$stmt->execute();
$recent_notes = $stmt->fetchAll();
// Quarantaine : liste + J-... (si dates renseignées)
$stmt = $db->prepare("
SELECT
id, name, internal_code AS code,
intake_date,
quarantine_until,
CASE
WHEN quarantine_until IS NULL OR quarantine_until = '' THEN NULL
ELSE CAST(julianday(date(quarantine_until)) - julianday(date('now','localtime')) AS INT)
END AS days_left
FROM animals
WHERE lower(trim(status))='quarantaine'
AND deleted_at IS NULL AND archived_at IS NULL
ORDER BY
CASE
WHEN quarantine_until IS NULL OR quarantine_until = '' THEN 9999
ELSE days_left
END ASC,
name ASC
LIMIT 100
");
$stmt->execute();
$quarantine = $stmt->fetchAll();
$stmt = $db->prepare('
SELECT
a.id, a.name, a.internal_code AS code,
wa.last_weight, wa.w30_weight, wa.w30_date,
ROUND(((wa.last_weight - wa.w30_weight) / wa.w30_weight) * 100, 1) AS pct
FROM v_animals_weight_alerts wa
JOIN animals a ON a.id = wa.animal_id
WHERE wa.last_weight IS NOT NULL
AND a.deleted_at IS NULL AND a.archived_at IS NULL
AND wa.w30_weight IS NOT NULL
AND wa.w30_weight > 0
AND ABS((wa.last_weight - wa.w30_weight) / wa.w30_weight) >= 0.15
ORDER BY ABS((wa.last_weight - wa.w30_weight) / wa.w30_weight) DESC
LIMIT 50
');
$stmt->execute();
$weight_alerts = $stmt->fetchAll();
$careToday = [];
$rows = $db
->query(
"SELECT periods.period,
(SELECT COUNT(*) FROM animals a WHERE a.deleted_at IS NULL AND a.archived_at IS NULL AND lower(a.status) IN ('refuge','soin','quarantaine')) expected,
COUNT(o.id) checked,
SUM(CASE WHEN o.alert_signature IS NOT NULL THEN 1 ELSE 0 END) alerts
FROM (SELECT 'morning' period UNION ALL SELECT 'evening') periods
LEFT JOIN care_rounds r ON r.round_date=date('now','localtime') AND r.period=periods.period
LEFT JOIN care_round_observations o ON o.round_id=r.id
GROUP BY periods.period ORDER BY periods.period DESC",
)
->fetchAll();
foreach ($rows as $row) {
$careToday[$row['period']] = $row;
}
$stmt = $db->query(
"SELECT a.id,a.name,r.period,o.comment FROM care_round_observations o JOIN care_rounds r ON r.id=o.round_id JOIN animals a ON a.id=o.animal_id WHERE r.round_date=date('now','localtime') AND o.alert_signature IS NOT NULL AND a.deleted_at IS NULL ORDER BY o.checked_at DESC LIMIT 10",
);
$careAlertsToday = $stmt->fetchAll();
$grantAlerts = PermissionService::can('grants') ? GrantService::alerts((int) date('Y')) : [];
$agendaDue = PermissionService::can('agenda') ? AgendaService::due() : [];
render('dashboard.php', [
'title' => 'Dashboard',
'stats' => $stats,
'shelterRoomCounts' => $shelterRoomCounts,
'quality' => $quality,
'activity' => $activity,
'vaccines_overdue' => $vaccines_overdue,
'vaccines_7d' => $vaccines_7d,
'vaccines_30d' => $vaccines_30d,
'treatments' => $treatments,
'treatments_overdue' => $treatments_overdue,
'recent_notes' => $recent_notes,
'quarantine' => $quarantine,
'weight_alerts' => $weight_alerts,
'careToday' => $careToday,
'careAlertsToday' => $careAlertsToday,
'grantAlerts' => $grantAlerts,
'agendaDue' => $agendaDue,
]);
}
}

View file

@ -0,0 +1,305 @@
<?php
declare(strict_types=1);
final class DeathController
{
public const CAUSES = [
'disease' => 'Maladie',
'accident' => 'Accident',
'old_age' => 'Vieillesse',
'congenital' => 'Malformation ou affection congénitale',
'surgery_complication' => 'Complication médicale ou chirurgicale',
'poisoning' => 'Empoisonnement',
'unknown' => 'Cause inconnue',
'other' => 'Autre cause',
];
public const PLACES = [
'refuge' => 'Au refuge',
'fa' => 'En famille d’accueil',
'veterinaire' => 'Chez le vétérinaire',
'exterieur' => 'À l’extérieur',
'inconnu' => 'Lieu inconnu',
'autre' => 'Autre lieu',
];
public const DISPOSITIONS = [
'pending' => 'À organiser',
'collective_cremation' => 'Crémation collective',
'individual_cremation' => 'Crémation individuelle',
'returned_family' => 'Corps ou cendres remis à la famille',
'burial' => 'Inhumation',
'other' => 'Autre prise en charge',
];
public static function causeLabel(string $key): string
{
return t('death.cause.' . $key, [], self::CAUSES[$key] ?? $key);
}
public static function placeLabel(string $key): string
{
return t('death.place.' . $key, [], self::PLACES[$key] ?? $key);
}
public static function dispositionLabel(string $key): string
{
return t('death.disposition.' . $key, [], self::DISPOSITIONS[$key] ?? $key);
}
public static function form(): void
{
$db = DB::pdo();
$id = (int) ($_GET['id'] ?? 0);
$animal = self::animal($db, $id);
$stmt = $db->prepare(
'SELECT ad.*,vet.name veterinarian_name,crem.name crematorium_name FROM animal_deaths ad LEFT JOIN directory_contacts vet ON vet.id=ad.veterinarian_contact_id LEFT JOIN directory_contacts crem ON crem.id=ad.crematorium_contact_id WHERE ad.animal_id=:id',
);
$stmt->execute([':id' => $id]);
$death = $stmt->fetch(PDO::FETCH_ASSOC) ?: [
'animal_id' => $id,
'deceased_date' => date('Y-m-d'),
'cause_code' => 'disease',
'cause_details' => '',
'occurred_in_care' => !in_array($animal['status'], ['adopte', 'adopté'], true) ? 1 : 0,
'place_type' => 'refuge',
'place_details' => '',
'euthanized' => 0,
'veterinarian_contact_id' => '',
'crematorium_contact_id' => '',
'body_disposition' => 'pending',
'cremation_date' => '',
'notes' => '',
];
$veterinarians = self::contacts($db, 'veterinaire');
$crematoriums = self::contacts($db, 'crematorium');
render('animal_death_form.php', [
'title' => t('death.title', ['name' => $animal['name']]),
'animal' => $animal,
'death' => $death,
'causes' => self::CAUSES,
'places' => self::PLACES,
'dispositions' => self::DISPOSITIONS,
'veterinarians' => $veterinarians,
'crematoriums' => $crematoriums,
]);
}
public static function save(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$db = DB::pdo();
$id = (int) ($_POST['animal_id'] ?? 0);
$animal = self::animal($db, $id);
$date = trim((string) ($_POST['deceased_date'] ?? ''));
$cause = (string) ($_POST['cause_code'] ?? '');
$place = (string) ($_POST['place_type'] ?? '');
$disposition = (string) ($_POST['body_disposition'] ?? 'pending');
if (
!self::validDate($date) ||
$date > date('Y-m-d') ||
!isset(self::CAUSES[$cause]) ||
!isset(self::PLACES[$place]) ||
!isset(self::DISPOSITIONS[$disposition])
) {
http_response_code(400);
echo h(t('death.invalid'));
return;
}
if ($cause === 'other' && trim((string) ($_POST['cause_details'] ?? '')) === '') {
http_response_code(400);
echo h(t('death.cause_required'));
return;
}
$vet = self::validContact($db, (int) ($_POST['veterinarian_contact_id'] ?? 0), 'veterinaire');
$crem = self::validContact($db, (int) ($_POST['crematorium_contact_id'] ?? 0), 'crematorium');
$cremationDate = trim((string) ($_POST['cremation_date'] ?? ''));
if ($cremationDate !== '' && (!self::validDate($cremationDate) || $cremationDate < $date)) {
http_response_code(400);
echo h(t('death.cremation_after'));
return;
}
$check = $db->prepare('SELECT 1 FROM animal_deaths WHERE animal_id=:id');
$check->execute([':id' => $id]);
$isUpdate = (bool) $check->fetchColumn();
$beforeLocation = LocationHistoryService::snapshot($animal);
$params = [
':animal' => $id,
':date' => $date,
':cause' => $cause,
':details' => trim((string) ($_POST['cause_details'] ?? '')) ?: null,
':care' => isset($_POST['occurred_in_care']) ? 1 : 0,
':place' => $place,
':place_details' => trim((string) ($_POST['place_details'] ?? '')) ?: null,
':euthanized' => isset($_POST['euthanized']) ? 1 : 0,
':vet' => $vet,
':crem' => $crem,
':disposition' => $disposition,
':cremation_date' => $cremationDate ?: null,
':notes' => trim((string) ($_POST['notes'] ?? '')) ?: null,
':user' => Auth::id(),
];
$db->beginTransaction();
try {
$db->prepare(
"INSERT INTO animal_deaths(animal_id,deceased_date,cause_code,cause_details,occurred_in_care,place_type,place_details,euthanized,veterinarian_contact_id,crematorium_contact_id,body_disposition,cremation_date,notes,created_by,updated_by) VALUES(:animal,:date,:cause,:details,:care,:place,:place_details,:euthanized,:vet,:crem,:disposition,:cremation_date,:notes,:user,:user) ON CONFLICT(animal_id) DO UPDATE SET deceased_date=excluded.deceased_date,cause_code=excluded.cause_code,cause_details=excluded.cause_details,occurred_in_care=excluded.occurred_in_care,place_type=excluded.place_type,place_details=excluded.place_details,euthanized=excluded.euthanized,veterinarian_contact_id=excluded.veterinarian_contact_id,crematorium_contact_id=excluded.crematorium_contact_id,body_disposition=excluded.body_disposition,cremation_date=excluded.cremation_date,notes=excluded.notes,updated_by=excluded.updated_by,updated_at=datetime('now')",
)->execute($params);
$db->prepare(
"UPDATE animals SET status='decede',refuge_room=NULL,care_box_key=NULL,updated_at=datetime('now') WHERE id=:id",
)->execute([':id' => $id]);
LocationHistoryService::record(
$db,
$id,
$beforeLocation,
[
'status' => 'decede',
'refuge_room' => null,
'care_box_key' => null,
'current_address' => $animal['current_address'] ?? null,
],
'death',
'Décès enregistré',
$date . ' 12:00:00',
);
$db->prepare(
'UPDATE treatments SET ongoing=0,end_date=COALESCE(end_date,:date) WHERE animal_id=:id AND ongoing=1',
)->execute([':date' => $date, ':id' => $id]);
$details =
'Date : ' .
self::frDate($date) .
'\nCause : ' .
self::CAUSES[$cause] .
'\nContexte : ' .
($params[':care'] ? 'Sous la responsabilité du refuge' : 'Après la prise en charge du refuge');
if ($params[':euthanized']) {
$details .= '\nEuthanasie : oui';
}
$db->prepare(
"INSERT INTO animal_history(animal_id,type,label,details,user_id) VALUES(:id,'death',:label,:details,:user)",
)->execute([
':id' => $id,
':label' => $isUpdate ? 'Informations de décès modifiées' : 'Décès enregistré',
':details' => $details,
':user' => Auth::id(),
]);
self::syncMovement(
$db,
$id,
$date,
$cause,
$params[':details'],
$place,
$params[':place_details'],
$params[':euthanized'] === 1,
$vet,
$crem,
$disposition,
);
$db->commit();
header('Location: /animal?id=' . $id);
exit();
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
}
private static function animal(PDO $db, int $id): array
{
$s = $db->prepare('SELECT * FROM animals WHERE id=:id AND deleted_at IS NULL');
$s->execute([':id' => $id]);
$a = $s->fetch(PDO::FETCH_ASSOC);
if (!$a) {
http_response_code(404);
echo h(t('error.animal_not_found'));
exit();
}
return $a;
}
private static function contacts(PDO $db, string $role): array
{
$s = $db->prepare(
'SELECT dc.id,dc.name,dc.city FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id WHERE r.role=:role AND dc.deleted_at IS NULL ORDER BY dc.name COLLATE NOCASE',
);
$s->execute([':role' => $role]);
return $s->fetchAll(PDO::FETCH_ASSOC);
}
private static function validContact(PDO $db, int $id, string $role): ?int
{
if (!$id) {
return null;
}
$s = $db->prepare(
'SELECT 1 FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id WHERE dc.id=:id AND r.role=:role AND dc.deleted_at IS NULL',
);
$s->execute([':id' => $id, ':role' => $role]);
return $s->fetchColumn() ? $id : null;
}
private static function syncMovement(
PDO $db,
int $animalId,
string $date,
string $cause,
?string $causeDetails,
string $place,
?string $placeDetails,
bool $euthanized,
?int $vetId,
?int $cremId,
string $disposition,
): void {
$names = [];
if ($vetId || $cremId) {
$s = $db->prepare('SELECT id,name FROM directory_contacts WHERE id IN (:vet,:crem)');
$s->execute([':vet' => $vetId ?? 0, ':crem' => $cremId ?? 0]);
foreach ($s->fetchAll(PDO::FETCH_ASSOC) as $row) {
$names[(int) $row['id']] = $row['name'];
}
}
$noteParts = ['Cause : ' . self::CAUSES[$cause] . ($causeDetails ? ' — ' . $causeDetails : '')];
if ($euthanized) {
$noteParts[] = 'Euthanasie : oui';
}
if ($cremId && isset($names[$cremId])) {
$noteParts[] = 'Crématorium : ' . $names[$cremId];
}
$noteParts[] = 'Prise en charge : ' . self::DISPOSITIONS[$disposition];
$s = $db->prepare(
"SELECT id FROM animal_movements WHERE animal_id=:animal AND kind='exit' AND lower(lieu) IN ('décès','deces','décès de l''animal','deces de l''animal') ORDER BY id DESC LIMIT 1",
);
$s->execute([':animal' => $animalId]);
$movementId = (int) $s->fetchColumn();
$values = [
':animal' => $animalId,
':place' => self::PLACES[$place] . ($placeDetails ? ' — ' . $placeDetails : ''),
':contact' => $vetId && isset($names[$vetId]) ? $names[$vetId] : null,
':note' => implode("\n", $noteParts),
':at' => $date . ' 12:00:00',
];
if ($movementId) {
$values[':movement'] = $movementId;
$db->prepare(
"UPDATE animal_movements SET place=:place,lieu='Décès de l''animal',contact_name=:contact,note=:note,created_at=:at WHERE id=:movement AND animal_id=:animal",
)->execute($values);
} else {
$db->prepare(
"INSERT INTO animal_movements(animal_id,kind,place,lieu,contact_name,note,created_at) VALUES(:animal,'exit',:place,'Décès de l''animal',:contact,:note,:at)",
)->execute($values);
}
}
private static function validDate(string $date): bool
{
$d = DateTimeImmutable::createFromFormat('!Y-m-d', $date);
$errors = DateTimeImmutable::getLastErrors();
return $d !== false &&
($errors === false || ((int) $errors['warning_count'] === 0 && (int) $errors['error_count'] === 0)) &&
$d->format('Y-m-d') === $date;
}
private static function frDate(string $date): string
{
$d = DateTimeImmutable::createFromFormat('Y-m-d', $date);
return $d ? $d->format('d/m/Y') : $date;
}
}

View file

@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
final class DevController
{
public static function backfillWeights(): void
{
header('Content-Type: text/plain; charset=utf-8');
// sécurité : local only
$addr = $_SERVER['REMOTE_ADDR'] ?? '';
if ($addr !== '127.0.0.1' && $addr !== '::1') {
http_response_code(403);
echo "Forbidden\n";
return;
}
$db = DB::pdo();
$db->beginTransaction();
try {
// Copie chaque note médicale ayant un poids -> measurements (sans dupliquer le même jour)
$sql = "
INSERT INTO measurements(animal_id, measured_at, type, value, unit, notes)
SELECT mn.animal_id, mn.noted_at, 'weight', mn.weight_kg, 'kg', 'backfill from medical_notes'
FROM medical_notes mn
WHERE mn.weight_kg IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM measurements m
WHERE m.animal_id = mn.animal_id
AND m.type = 'weight'
AND date(m.measured_at) = date(mn.noted_at)
)
";
$count = $db->exec($sql);
$db->commit();
echo 'Backfill OK. Inserted rows: ' . (int) $count . "\n";
echo "Retourne sur /animal?id=...\n";
} catch (Throwable $e) {
$db->rollBack();
http_response_code(500);
echo 'Backfill FAILED: ' . $e->getMessage() . "\n";
}
}
public static function backup(): void
{
header('Content-Type: text/plain; charset=utf-8');
$addr = $_SERVER['REMOTE_ADDR'] ?? '';
if ($addr !== '127.0.0.1' && $addr !== '::1') {
http_response_code(403);
echo "Forbidden\n";
return;
}
$src = __DIR__ . '/../../data/refuge.sqlite';
$dir = __DIR__ . '/../../storage/backups';
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
$dst = $dir . '/refuge-' . date('Ymd-His') . '.sqlite';
// Copie cohérente SQLite
$pdo = DB::pdo();
$pdo->exec('VACUUM INTO ' . $pdo->quote($dst));
echo 'Backup OK: ' . basename($dst) . "\n";
}
public static function backfillMovements(): void
{
$pdo = DB::pdo();
$animals = $pdo->query('SELECT * FROM animals')->fetchAll(PDO::FETCH_ASSOC);
$pdo->beginTransaction();
try {
foreach ($animals as $a) {
// Vérifie si déjà migré
$check = $pdo->prepare('SELECT COUNT(*) FROM animal_movements WHERE animal_id = ?');
$check->execute([$a['id']]);
if ($check->fetchColumn() > 0) {
continue;
}
// ENTRY
$stmt = $pdo->prepare("
INSERT INTO animal_movements
(animal_id, kind, moved_at, provenance, place, note)
VALUES (?, 'entry', ?, ?, ?, ?)
");
$stmt->execute([
$a['id'],
$a['created_at'] ?? date('Y-m-d H:i:s'),
'Migration ancienne donnée',
$a['rescue_address'] ?? '',
'Migration automatique',
]);
// EXIT (si statut final)
if (in_array($a['status'], ['adopted', 'deceased', 'transferred', 'fugue'])) {
$stmt = $pdo->prepare("
INSERT INTO animal_movements
(animal_id, kind, moved_at, exit_type, place, note)
VALUES (?, 'exit', ?, ?, ?, ?)
");
$stmt->execute([
$a['id'],
$a['updated_at'] ?? date('Y-m-d H:i:s'),
$a['status'],
$a['current_address'] ?? '',
'Migration automatique',
]);
}
}
$pdo->commit();
echo 'Backfill OK.';
} catch (Throwable $e) {
$pdo->rollBack();
echo 'Erreur: ' . $e->getMessage();
}
}
}

View file

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
final class DevicesController
{
public static function index(): void
{
render('account_devices.php', [
'title' => t('devices.title'),
'devices' => TrustedDeviceService::devices(),
'currentSelector' => TrustedDeviceService::currentSelector(),
'saved' => isset($_GET['saved']),
]);
}
public static function revoke(): void
{
self::postOnly();
$id = (int) ($_POST['id'] ?? 0);
TrustedDeviceService::revoke($id, (int) Auth::id());
AuditService::log(
'trusted_device_revoked',
'/account/devices/revoke',
'Appareil de confiance révoqué',
'trusted_device',
$id,
);
header('Location: /account/devices?saved=1');
exit();
}
public static function revokeAll(): void
{
self::postOnly();
DB::pdo()
->prepare("UPDATE trusted_devices SET revoked_at=datetime('now') WHERE user_id=? AND revoked_at IS NULL")
->execute([Auth::id()]);
AuditService::log(
'trusted_devices_revoked',
'/account/devices/revoke-all',
'Tous les appareils de confiance ont été révoqués',
);
Auth::logout();
header('Location: /login');
exit();
}
private static function postOnly(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
header('Allow: POST');
exit();
}
if (!Auth::validCsrf($_POST['csrf'] ?? null)) {
http_response_code(419);
echo h(t('auth.session_expired'));
exit();
}
}
}

View file

@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
final class DewormingController
{
public static function save(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
if (!PermissionService::can('medical')) {
http_response_code(403);
echo h(t('error.access_denied'));
return;
}
$db = DB::pdo();
$animalId = (int) ($_POST['animal_id'] ?? 0);
$date = trim((string) ($_POST['administered_on'] ?? ''));
$dewormer = (int) ($_POST['dewormer_id'] ?? 0);
if (!$animalId || !self::date($date)) {
http_response_code(400);
echo h(t('deworm.invalid_date'));
return;
}
$s = $db->prepare('SELECT 1 FROM animals WHERE id=? AND deleted_at IS NULL');
$s->execute([$animalId]);
if (!$s->fetchColumn()) {
http_response_code(404);
echo h(t('error.animal_not_found'));
return;
}
if (isset($_POST['create_dewormer'])) {
$name = trim((string) ($_POST['new_dewormer_name'] ?? ''));
if ($name === '') {
http_response_code(400);
echo h(t('deworm.name_required'));
return;
}
$s = $db->prepare('SELECT id FROM ref_dewormers WHERE name=? COLLATE NOCASE');
$s->execute([$name]);
$dewormer = (int) $s->fetchColumn();
if (!$dewormer) {
$db->prepare('INSERT INTO ref_dewormers(name,active_ingredient,form) VALUES(?,?,?)')->execute([
mb_substr($name, 0, 150),
trim((string) ($_POST['new_dewormer_ingredient'] ?? '')) ?: null,
trim((string) ($_POST['new_dewormer_form'] ?? '')) ?: null,
]);
$dewormer = (int) $db->lastInsertId();
}
}
if ($dewormer) {
$s = $db->prepare('SELECT 1 FROM ref_dewormers WHERE id=? AND active=1');
$s->execute([$dewormer]);
if (!$s->fetchColumn()) {
$dewormer = 0;
}
}
$ids = [$animalId];
$litterId = null;
if (isset($_POST['apply_litter'])) {
$s = $db->prepare(
'SELECT l.id FROM litters l LEFT JOIN litter_kittens lk ON lk.litter_id=l.id WHERE l.mother_id=? OR lk.animal_id=? ORDER BY l.id DESC LIMIT 1',
);
$s->execute([$animalId, $animalId]);
$litterId = (int) $s->fetchColumn() ?: null;
if ($litterId) {
$s = $db->prepare(
'SELECT animal_id FROM litter_kittens WHERE litter_id=? UNION SELECT mother_id FROM litters WHERE id=? AND mother_id IS NOT NULL',
);
$s->execute([$litterId, $litterId]);
$ids = array_values(array_unique(array_map('intval', $s->fetchAll(PDO::FETCH_COLUMN))));
}
}
$manual = trim((string) ($_POST['next_due_date'] ?? ''));
if ($manual !== '' && !self::date($manual)) {
http_response_code(400);
echo h(t('deworm.invalid_next_due'));
return;
}
$db->beginTransaction();
try {
$insert = $db->prepare(
'INSERT INTO dewormings(animal_id,dewormer_id,administered_on,dose_text,weight_kg,next_due_date,notes,source_litter_id,created_by) VALUES(?,?,?,?,?,?,?,?,?)',
);
$history = $db->prepare(
"INSERT INTO animal_history(animal_id,type,label,details,user_id) VALUES(?,'deworming','Vermifuge administré',?,?)",
);
foreach ($ids as $id) {
$s = $db->prepare('SELECT name,birth_date FROM animals WHERE id=? AND deleted_at IS NULL');
$s->execute([$id]);
$animal = $s->fetch();
if (!$animal) {
continue;
}
$next = $manual ?: self::nextDue($animal['birth_date'] ?? null, $date);
$insert->execute([
$id,
$dewormer ?: null,
$date,
trim((string) ($_POST['dose_text'] ?? '')) ?: null,
($_POST['weight_kg'] ?? '') !== '' ? (float) $_POST['weight_kg'] : null,
$next,
trim((string) ($_POST['notes'] ?? '')) ?: null,
$litterId,
Auth::id(),
]);
$history->execute([
$id,
'Administration : ' . self::fr($date) . ($next ? ' · Prochaine échéance : ' . self::fr($next) : ''),
Auth::id(),
]);
}
$perAnimal = (float) str_replace(',', '.', (string) ($_POST['inventory_quantity'] ?? 0));
InventoryService::consumeBatch(
(int) ($_POST['inventory_batch_id'] ?? 0),
$perAnimal * count($ids),
$animalId,
'Vermifuge du ' . $date . ($litterId ? ' · portée #' . $litterId : ''),
);
$db->commit();
header('Location: /animal?id=' . $animalId . '#tab-med');
exit();
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
}
public static function nextDue(?string $birth, string $given): string
{
$admin = new DateTimeImmutable($given);
if ($birth && ($born = DateTimeImmutable::createFromFormat('!Y-m-d', $birth))) {
$targets = [
$born->modify('+3 weeks'),
$born->modify('+5 weeks'),
$born->modify('+7 weeks'),
$born->modify('+3 months'),
$born->modify('+4 months'),
$born->modify('+5 months'),
$born->modify('+6 months'),
];
foreach ($targets as $target) {
if ($target > $admin) {
return $target->format('Y-m-d');
}
}
}
return $admin->modify('+3 months')->format('Y-m-d');
}
private static function date(string $v): bool
{
$d = DateTimeImmutable::createFromFormat('!Y-m-d', $v);
return $d && $d->format('Y-m-d') === $v;
}
private static function fr(string $v): string
{
$d = DateTimeImmutable::createFromFormat('!Y-m-d', $v);
return $d ? $d->format('d/m/Y') : $v;
}
}

View file

@ -0,0 +1,279 @@
<?php
declare(strict_types=1);
final class DirectoryController
{
private static function roles(): array
{
return [
'adoptant' => t('role.adoptant'),
'benevole' => t('role.benevole'),
'cabinet' => t('role.cabinet'),
'crematorium' => t('role.crematorium'),
'deposant' => t('role.deposant'),
'dirigeant' => t('role.dirigeant'),
'fa' => t('role.fa'),
'fourriere' => t('role.fourriere'),
'veterinaire' => t('role.veterinaire'),
];
}
public static function index(): void
{
$db = DB::pdo();
$q = trim((string) ($_GET['q'] ?? ''));
$role = trim((string) ($_GET['role'] ?? ''));
$where = ['dc.deleted_at IS NULL'];
$params = [];
if ($q !== '') {
$where[] = '(dc.name LIKE :q OR dc.phone LIKE :q OR dc.email LIKE :q OR dc.city LIKE :q)';
$params[':q'] = '%' . $q . '%';
}
$roles = self::roles();
if (isset($roles[$role])) {
$where[] = 'EXISTS(SELECT 1 FROM directory_contact_roles f WHERE f.contact_id=dc.id AND f.role=:role)';
$params[':role'] = $role;
}
$stmt = $db->prepare(
"
SELECT dc.*, org.name AS organization_name, group_concat(dcr.role, ',') AS roles,
(SELECT COUNT(*) FROM (
SELECT animal_id FROM adoptions ad WHERE ad.adopter_contact_id=dc.id OR lower(trim(ad.adopter_name))=lower(trim(dc.name))
UNION SELECT animal_id FROM animal_placements ap WHERE ap.contact_id=dc.id AND ap.event_type='adoption'
UNION SELECT animal_id FROM animal_movements am WHERE am.kind='exit' AND lower(trim(am.contact_name))=lower(trim(dc.name)) AND lower(COALESCE(am.lieu,'')) LIKE 'adopt%'
)) AS adoption_count,
(SELECT COALESCE(SUM(ae.total_cents),0) FROM animal_expenses ae WHERE ae.clinic_contact_id=dc.id) AS expense_total_cents
FROM directory_contacts dc
LEFT JOIN directory_contacts org ON org.id=dc.organization_id
LEFT JOIN directory_contact_roles dcr ON dcr.contact_id=dc.id
WHERE " .
implode(' AND ', $where) .
'
GROUP BY dc.id ORDER BY dc.name COLLATE NOCASE
',
);
$stmt->execute($params);
render('directory_list.php', [
'title' => t('directory.title'),
'contacts' => $stmt->fetchAll(),
'roles' => $roles,
'q' => $q,
'role' => $role,
]);
}
public static function show(): void
{
$db = DB::pdo();
$id = (int) ($_GET['id'] ?? 0);
$stmt = $db->prepare(
"SELECT dc.*,org.name organization_name,group_concat(r.role,',') roles FROM directory_contacts dc LEFT JOIN directory_contacts org ON org.id=dc.organization_id LEFT JOIN directory_contact_roles r ON r.contact_id=dc.id WHERE dc.id=:id AND dc.deleted_at IS NULL GROUP BY dc.id",
);
$stmt->execute([':id' => $id]);
$contact = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$contact) {
http_response_code(404);
echo h(t('directory.not_found'));
return;
}
$adoptions = $db->prepare("SELECT a.id,a.name,a.internal_code,a.species,a.status,x.event_date FROM animals a JOIN (SELECT animal_id,MAX(event_date) event_date FROM (
SELECT animal_id,adoption_date event_date FROM adoptions WHERE adopter_contact_id=:id1 OR lower(trim(adopter_name))=lower(trim(:name1))
UNION ALL SELECT animal_id,event_date FROM animal_placements WHERE contact_id=:id2 AND event_type='adoption'
UNION ALL SELECT animal_id,created_at event_date FROM animal_movements WHERE kind='exit' AND lower(trim(contact_name))=lower(trim(:name2)) AND lower(COALESCE(lieu,'')) LIKE 'adopt%'
) GROUP BY animal_id) x ON x.animal_id=a.id ORDER BY date(x.event_date) DESC,a.name COLLATE NOCASE");
$adoptions->execute([':id1' => $id, ':name1' => $contact['name'], ':id2' => $id, ':name2' => $contact['name']]);
$fosters = $db->prepare(
"SELECT a.id,a.name,a.internal_code,a.species,a.status,MAX(ap.event_date) event_date FROM animal_placements ap JOIN animals a ON a.id=ap.animal_id WHERE ap.contact_id=:id AND ap.event_type='foster' GROUP BY a.id ORDER BY date(event_date) DESC,a.name COLLATE NOCASE",
);
$fosters->execute([':id' => $id]);
$deposited = $db->prepare(
'SELECT id,name,internal_code,species,status,intake_date event_date FROM animals WHERE depositor_contact_id=:id ORDER BY date(intake_date) DESC,name COLLATE NOCASE',
);
$deposited->execute([':id' => $id]);
$periodRequested = array_key_exists('year', $_GET);
$periodRaw = (string) ($_GET['year'] ?? 'all');
$periodYear =
ctype_digit($periodRaw) && (int) $periodRaw >= 2000 && (int) $periodRaw <= 2100 ? (int) $periodRaw : null;
$periodMunicipality = trim((string) ($_GET['municipality'] ?? ''));
$expenseWhere = ['ae.clinic_contact_id=:id', 'a.deleted_at IS NULL'];
$expenseParams = [':id' => $id];
if ($periodYear !== null) {
$expenseWhere[] = 'date(ae.occurred_on) BETWEEN :expense_start AND :expense_end';
$expenseParams[':expense_start'] = sprintf('%04d-01-01', $periodYear);
$expenseParams[':expense_end'] = sprintf('%04d-12-31', $periodYear);
}
$expenses = $db->prepare(
'SELECT ae.*,a.name animal_name,a.internal_code,a.rescue_location_name,a.rescue_address FROM animal_expenses ae JOIN animals a ON a.id=ae.animal_id WHERE ' .
implode(' AND ', $expenseWhere) .
' ORDER BY date(ae.occurred_on) DESC,ae.id DESC',
);
$expenses->execute($expenseParams);
$expenseRows = $expenses->fetchAll(PDO::FETCH_ASSOC);
if ($periodMunicipality !== '') {
$expenseRows = array_values(
array_filter(
$expenseRows,
static fn($row) => GrantService::municipality(
(string) ($row['rescue_location_name'] ?: $row['rescue_address']),
) === $periodMunicipality,
),
);
}
$expenseTotal = array_sum(array_map(static fn($row) => (int) $row['total_cents'], $expenseRows));
$expenseCount = count($expenseRows);
$expenseRows = array_slice($expenseRows, 0, 250);
$invoiceWhere = ['vendor_contact_id=:vendor', 'deleted_at IS NULL'];
$invoiceParams = [':vendor' => $id];
if ($periodYear !== null) {
$invoiceWhere[] = "strftime('%Y',invoice_date)=:invoice_year";
$invoiceParams[':invoice_year'] = (string) $periodYear;
}
$invoiceStmt = $db->prepare(
'SELECT * FROM accounting_invoices WHERE ' .
implode(' AND ', $invoiceWhere) .
' ORDER BY date(invoice_date) DESC,id DESC',
);
$invoiceStmt->execute($invoiceParams);
$contactInvoices = $invoiceStmt->fetchAll(PDO::FETCH_ASSOC);
$contactInvoiceTotal = array_sum(array_column($contactInvoices, 'total_ttc_cents'));
$contactInvoiceDue = array_sum(array_column($contactInvoices, 'amount_due_cents'));
$linkedPeople = [];
if ($contact['kind'] === 'organization') {
$s = $db->prepare(
"SELECT dc.id,dc.name,dc.phone,dc.email,group_concat(r.role,',') roles FROM directory_contacts dc LEFT JOIN directory_contact_roles r ON r.contact_id=dc.id WHERE dc.organization_id=:id AND dc.deleted_at IS NULL GROUP BY dc.id ORDER BY dc.name COLLATE NOCASE",
);
$s->execute([':id' => $id]);
$linkedPeople = $s->fetchAll(PDO::FETCH_ASSOC);
}
render('directory_show_accounting.php', [
'title' => $contact['name'],
'contact' => $contact,
'roles' => self::roles(),
'adoptions' => $adoptions->fetchAll(PDO::FETCH_ASSOC),
'fosters' => $fosters->fetchAll(PDO::FETCH_ASSOC),
'deposited' => $deposited->fetchAll(PDO::FETCH_ASSOC),
'expenses' => $expenseRows,
'expenseTotal' => $expenseTotal,
'expenseCount' => $expenseCount,
'expensePeriodRequested' => $periodRequested,
'expensePeriodYear' => $periodYear,
'expensePeriodMunicipality' => $periodMunicipality,
'contactInvoices' => $contactInvoices,
'contactInvoiceTotal' => $contactInvoiceTotal,
'contactInvoiceDue' => $contactInvoiceDue,
'linkedPeople' => $linkedPeople,
]);
}
public static function form(): void
{
$db = DB::pdo();
$id = (int) ($_GET['id'] ?? 0);
$contact = [
'id' => 0,
'kind' => 'person',
'name' => '',
'phone' => '',
'email' => '',
'address' => '',
'postal_code' => '',
'city' => '',
'country' => 'France',
'organization_id' => '',
'notes' => '',
];
$selected = [];
if ($id) {
$stmt = $db->prepare('SELECT * FROM directory_contacts WHERE id=:id AND deleted_at IS NULL');
$stmt->execute([':id' => $id]);
$contact = $stmt->fetch() ?: $contact;
$stmt = $db->prepare('SELECT role FROM directory_contact_roles WHERE contact_id=:id');
$stmt->execute([':id' => $id]);
$selected = $stmt->fetchAll(PDO::FETCH_COLUMN);
}
$organizations = $db
->query(
"SELECT id,name FROM directory_contacts WHERE kind='organization' AND deleted_at IS NULL ORDER BY name COLLATE NOCASE",
)
->fetchAll();
render('directory_form.php', [
'title' => $id ? t('directory.edit') : t('directory.add'),
'contact' => $contact,
'selectedRoles' => $selected,
'roles' => self::roles(),
'organizations' => $organizations,
]);
}
public static function save(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$db = DB::pdo();
$id = (int) ($_POST['id'] ?? 0);
$name = trim((string) ($_POST['name'] ?? ''));
if ($name === '') {
http_response_code(400);
echo h(t('directory.name_required'));
return;
}
$kind = ($_POST['kind'] ?? 'person') === 'organization' ? 'organization' : 'person';
$roles = array_values(
array_intersect(array_keys(self::roles()), is_array($_POST['roles'] ?? null) ? $_POST['roles'] : []),
);
$organizationId = $kind === 'person' ? ((int) ($_POST['organization_id'] ?? 0) ?: null) : null;
if ($organizationId !== null) {
$check = $db->prepare(
"SELECT 1 FROM directory_contacts WHERE id=:id AND kind='organization' AND deleted_at IS NULL",
);
$check->execute([':id' => $organizationId]);
if (!$check->fetchColumn() || $organizationId === $id) {
http_response_code(400);
echo h(t('directory.invalid_organization'));
return;
}
}
$data = [
':kind' => $kind,
':name' => $name,
':phone' => trim((string) ($_POST['phone'] ?? '')) ?: null,
':email' => trim((string) ($_POST['email'] ?? '')) ?: null,
':address' => trim((string) ($_POST['address'] ?? '')) ?: null,
':postal' => trim((string) ($_POST['postal_code'] ?? '')) ?: null,
':city' => trim((string) ($_POST['city'] ?? '')) ?: null,
':country' => trim((string) ($_POST['country'] ?? 'France')) ?: 'France',
':org' => $organizationId,
':notes' => trim((string) ($_POST['notes'] ?? '')) ?: null,
];
$db->beginTransaction();
try {
if ($id) {
$data[':id'] = $id;
$db->prepare(
"UPDATE directory_contacts SET kind=:kind,name=:name,phone=:phone,email=:email,address=:address,postal_code=:postal,city=:city,country=:country,organization_id=:org,notes=:notes,updated_at=datetime('now') WHERE id=:id",
)->execute($data);
} else {
$db->prepare(
'INSERT INTO directory_contacts(kind,name,phone,email,address,postal_code,city,country,organization_id,notes) VALUES(:kind,:name,:phone,:email,:address,:postal,:city,:country,:org,:notes)',
)->execute($data);
$id = (int) $db->lastInsertId();
}
$db->prepare('DELETE FROM directory_contact_roles WHERE contact_id=:id')->execute([':id' => $id]);
$ins = $db->prepare('INSERT INTO directory_contact_roles(contact_id,role) VALUES(:id,:role)');
foreach ($roles as $r) {
$ins->execute([':id' => $id, ':role' => $r]);
}
$db->commit();
header('Location: /directory');
exit();
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
}
}

View file

@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
final class DocumentsController
{
public static function blankAdoption(): void
{
$format = ($_GET['format'] ?? 'odt') === 'pdf' ? 'pdf' : 'odt';
$path = DocumentsService::adoptionTemplate();
$generated = null;
if ($format === 'pdf') {
$dir = sys_get_temp_dir() . '/globinours-doc-' . bin2hex(random_bytes(8));
mkdir($dir, 0775, true);
$odt = $dir . '/contrat-adoption-vierge.odt';
copy($path, $odt);
$path = DocumentsService::toPdf($odt);
$generated = $path;
}
self::send($path, 'contrat-adoption-vierge.' . $format, $format, $generated);
}
public static function blank(): void
{
$type = (string) ($_GET['type'] ?? '');
$names = [
'adoption' => 'contrat-adoption',
'abandon' => 'abandon-trouvaille',
'benevole' => 'contrat-benevole',
'fa' => 'proposition-fa',
'pre_adoption' => 'certificat-visite-pre-adoption',
];
if (!isset($names[$type])) {
http_response_code(404);
return;
}
$format = ($_GET['format'] ?? 'odt') === 'pdf' ? 'pdf' : 'odt';
$source = DocumentsService::template($type);
$dir = sys_get_temp_dir() . '/globinours-doc-' . bin2hex(random_bytes(8));
mkdir($dir, 0775, true);
$odt = $dir . '/' . $names[$type] . '-vierge.odt';
copy($source, $odt);
$path = $format === 'pdf' ? DocumentsService::toPdf($odt) : $odt;
self::send($path, $names[$type] . '-vierge.' . $format, $format, $path);
}
public static function adoption(): void
{
$id = (int) ($_GET['animal_id'] ?? 0);
if ($id <= 0) {
http_response_code(400);
return;
}
$db = DB::pdo();
$stmt = $db->prepare('SELECT * FROM animals WHERE id=:id AND deleted_at IS NULL');
$stmt->execute([':id' => $id]);
$animal = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$animal) {
http_response_code(404);
return;
}
$stmt = $db->prepare('SELECT * FROM adoptions WHERE animal_id=:id ORDER BY id DESC LIMIT 1');
$stmt->execute([':id' => $id]);
$adoption = $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
$stmt = $db->prepare(
'SELECT done_date,due_date FROM vaccinations WHERE animal_id=:id ORDER BY done_date DESC,id DESC LIMIT 1',
);
$stmt->execute([':id' => $id]);
$vaccine = $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
$format = ($_GET['format'] ?? 'pdf') === 'odt' ? 'odt' : 'pdf';
$odt = DocumentsService::generateAdoption($animal, $adoption, $vaccine);
$path = $format === 'pdf' ? DocumentsService::toPdf($odt) : $odt;
self::send(
$path,
'contrat-adoption-' . self::safeName((string) $animal['name']) . '.' . $format,
$format,
$path,
);
}
public static function abandon(): void
{
$id = (int) ($_GET['animal_id'] ?? 0);
$db = DB::pdo();
$stmt = $db->prepare('SELECT * FROM animals WHERE id=:id AND deleted_at IS NULL');
$stmt->execute([':id' => $id]);
$animal = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$animal) {
http_response_code(404);
return;
}
$format = ($_GET['format'] ?? 'pdf') === 'odt' ? 'odt' : 'pdf';
$odt = DocumentsService::generateAbandon($animal);
$path = $format === 'pdf' ? DocumentsService::toPdf($odt) : $odt;
self::send(
$path,
'abandon-trouvaille-' . self::safeName((string) $animal['name']) . '.' . $format,
$format,
$path,
);
}
public static function contact(): void
{
$id = (int) ($_GET['contact_id'] ?? 0);
$type = (string) ($_GET['type'] ?? '');
$role = $type === 'benevole' ? 'benevole' : ($type === 'fa' ? 'fa' : null);
if (!$role) {
http_response_code(400);
return;
}
$db = DB::pdo();
$stmt = $db->prepare(
'SELECT dc.* FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id AND r.role=:role WHERE dc.id=:id AND dc.deleted_at IS NULL',
);
$stmt->execute([':role' => $role, ':id' => $id]);
$contact = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$contact) {
http_response_code(404);
return;
}
$format = ($_GET['format'] ?? 'pdf') === 'odt' ? 'odt' : 'pdf';
$odt = DocumentsService::generateContact($type, $contact);
$path = $format === 'pdf' ? DocumentsService::toPdf($odt) : $odt;
self::send($path, $type . '-' . self::safeName((string) $contact['name']) . '.' . $format, $format, $path);
}
private static function send(string $path, string $filename, string $format, ?string $cleanup): void
{
if (!is_file($path)) {
http_response_code(500);
return;
}
header('Content-Type: ' . ($format === 'pdf' ? 'application/pdf' : 'application/vnd.oasis.opendocument.text'));
header('Content-Length: ' . filesize($path));
header(
'Content-Disposition: ' . ($format === 'pdf' ? 'inline' : 'attachment') . '; filename="' . $filename . '"',
);
readfile($path);
if ($cleanup) {
DocumentsService::cleanup($cleanup);
}
exit();
}
private static function safeName(string $name): string
{
$name = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $name) ?: 'chat';
return strtolower(trim(preg_replace('/[^a-zA-Z0-9]+/', '-', $name), '-')) ?: 'chat';
}
}

View file

@ -0,0 +1,240 @@
<?php
declare(strict_types=1);
final class GrantsController
{
private static function admin(): void
{
$action = ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST' ? 'edit' : 'view';
if (!PermissionService::can('grants', $action)) {
http_response_code(403);
echo h(t('grant.forbidden'));
exit();
}
}
private static function year(): int
{
return max(2000, min(2200, (int) ($_REQUEST['year'] ?? date('Y'))));
}
public static function index(): void
{
self::admin();
$year = self::year();
$applications = GrantService::applications($year);
render('admin_grants.php', [
'title' => t('grant.title'),
'year' => $year,
'annual' => GrantService::ensureYear($year),
'stats' => GrantService::statistics($year),
'details' => GrantService::detailedStatistics($year),
'comparison' => GrantService::comparison($year),
'applications' => $applications,
'checklist' => GrantService::checklist($year),
'documents' => GrantService::documents($year),
'saved' => isset($_GET['saved']),
'error' => (string) ($_GET['error'] ?? ''),
]);
}
public static function saveAnnual(): void
{
self::admin();
$year = self::year();
GrantService::ensureYear($year);
$ints = ['members_count', 'volunteers_count', 'employees_count'];
$nums = [
'employees_fte',
'treasury_amount',
'actual_expenses',
'actual_income',
'forecast_expenses',
'forecast_income',
'voluntary_contributions',
];
$texts = [
'annual_report',
'completed_actions',
'planned_actions',
'beneficiaries',
'officers_notes',
'in_kind_support',
];
$values = [
':year' => $year,
':last_ag_date' => self::date($_POST['last_ag_date'] ?? null),
':uid' => Auth::id(),
];
foreach ($ints as $f) {
$values[':' . $f] = max(0, (int) ($_POST[$f] ?? 0));
}
foreach ($nums as $f) {
$values[':' . $f] = max(0, self::amount($_POST[$f] ?? 0));
}
foreach ($texts as $f) {
$values[':' . $f] = trim((string) ($_POST[$f] ?? ''));
}
$sets = [];
foreach (array_merge($ints, $nums, $texts, ['last_ag_date']) as $f) {
$sets[] = "$f=:$f";
}
$sql =
'UPDATE grant_years SET ' .
implode(',', $sets) .
',updated_by=:uid,updated_at=datetime(\'now\') WHERE year=:year';
DB::pdo()->prepare($sql)->execute($values);
AuditService::log('grant_year_saved', '/admin/grants/save', 'Bilan annuel ' . $year . ' mis à jour');
header('Location: /admin/grants?year=' . $year . '&saved=1');
}
public static function saveApplication(): void
{
self::admin();
$year = self::year();
GrantService::ensureYear($year);
$id = (int) ($_POST['application_id'] ?? 0);
$existing = [];
if ($id) {
$q = DB::pdo()->prepare('SELECT * FROM grant_applications WHERE id=? AND year=?');
$q->execute([$id, $year]);
$existing = $q->fetch() ?: [];
}
$name = trim((string) ($_POST['organization_name'] ?? ($existing['organization_name'] ?? '')));
if ($name === '') {
header('Location: /admin/grants?year=' . $year . '&error=organisme');
return;
}
$status = (string) ($_POST['status'] ?? ($existing['status'] ?? 'draft'));
if (!isset(GrantService::STATUSES[$status])) {
$status = 'draft';
}
$awardedRaw = $_POST['awarded_amount'] ?? ($existing['awarded_amount'] ?? '');
$params = [
':year' => $year,
':name' => $name,
':deadline' => self::date($_POST['deadline'] ?? ($existing['deadline'] ?? null)),
':requested' => self::amount($_POST['requested_amount'] ?? ($existing['requested_amount'] ?? 0)),
':previous' => self::amount($_POST['previous_amount'] ?? ($existing['previous_amount'] ?? 0)),
':awarded' => trim((string) $awardedRaw) === '' ? null : self::amount($awardedRaw),
':status' => $status,
':notes' => trim((string) ($_POST['notes'] ?? ($existing['notes'] ?? ''))),
':uid' => Auth::id(),
];
if ($id) {
$params[':id'] = $id;
DB::pdo()
->prepare(
'UPDATE grant_applications SET organization_name=:name,deadline=:deadline,requested_amount=:requested,previous_amount=:previous,awarded_amount=:awarded,status=:status,notes=:notes,updated_by=:uid,updated_at=datetime(\'now\') WHERE id=:id AND year=:year',
)
->execute($params);
} else {
DB::pdo()
->prepare(
'INSERT INTO grant_applications(year,organization_name,deadline,requested_amount,previous_amount,awarded_amount,status,notes,created_by,updated_by) VALUES(:year,:name,:deadline,:requested,:previous,:awarded,:status,:notes,:uid,:uid)',
)
->execute($params);
}
header('Location: /admin/grants?year=' . $year . '&saved=1');
}
public static function upload(): void
{
self::admin();
$year = self::year();
GrantService::ensureYear($year);
$f = $_FILES['document'] ?? null;
if (!$f || ($f['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || ($f['size'] ?? 0) > 20_000_000) {
header('Location: /admin/grants?year=' . $year . '&error=document');
return;
}
$mime = new finfo(FILEINFO_MIME_TYPE)->file($f['tmp_name']) ?: 'application/octet-stream';
$allowed = ['application/pdf' => 'pdf', 'application/vnd.oasis.opendocument.text' => 'odt'];
if (!isset($allowed[$mime])) {
header('Location: /admin/grants?year=' . $year . '&error=format');
return;
}
$dir = dirname(__DIR__, 2) . '/data/grants';
if (!is_dir($dir)) {
mkdir($dir, 0770, true);
}
$stored = bin2hex(random_bytes(18)) . '.' . $allowed[$mime];
if (!move_uploaded_file($f['tmp_name'], $dir . '/' . $stored)) {
throw new RuntimeException(t('error.document_storage'));
}
$category = (string) ($_POST['category'] ?? 'other');
if (!isset(GrantService::CATEGORIES[$category])) {
$category = 'other';
}
$global = isset($_POST['global_document']);
$applicationId = (int) ($_POST['application_id'] ?? 0);
if ($applicationId) {
$check = DB::pdo()->prepare('SELECT 1 FROM grant_applications WHERE id=? AND year=?');
$check->execute([$applicationId, $year]);
if (!$check->fetchColumn()) {
$applicationId = 0;
}
}
if ($category === 'official_form' && !$applicationId) {
@unlink($dir . '/' . $stored);
header('Location: /admin/grants?year=' . $year . '&error=organisme');
return;
}
DB::pdo()
->prepare(
'INSERT INTO grant_documents(year,application_id,category,title,stored_name,original_name,mime_type,size_bytes,valid_until,notes,uploaded_by) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
)
->execute([
$global ? null : $year,
$applicationId ?: null,
$category,
trim((string) ($_POST['title'] ?? '')) ?: pathinfo((string) $f['name'], PATHINFO_FILENAME),
$stored,
basename((string) $f['name']),
$mime,
(int) $f['size'],
self::date($_POST['valid_until'] ?? null),
trim((string) ($_POST['notes'] ?? '')),
Auth::id(),
]);
header('Location: /admin/grants?year=' . $year . '&saved=1');
}
public static function download(): void
{
self::admin();
$s = DB::pdo()->prepare('SELECT * FROM grant_documents WHERE id=?');
$s->execute([(int) ($_GET['id'] ?? 0)]);
$d = $s->fetch();
$path = dirname(__DIR__, 2) . '/data/grants/' . ($d['stored_name'] ?? '');
if (!$d || !is_file($path)) {
http_response_code(404);
echo h(t('grant.document_not_found'));
return;
}
PrivacyService::logAccess('grant_document', (int) $d['id'], null, (string) $d['original_name']);
header('Content-Type: ' . $d['mime_type']);
header('Content-Disposition: inline; filename="' . rawurlencode($d['original_name']) . '"');
header('Content-Length: ' . filesize($path));
header('Cache-Control: private,no-store');
readfile($path);
}
public static function print(): void
{
self::admin();
$year = self::year();
$annual = GrantService::ensureYear($year);
$stats = GrantService::statistics($year);
$details = GrantService::detailedStatistics($year);
$comparison = GrantService::comparison($year);
$applications = GrantService::applications($year);
$documents = GrantService::documents($year);
$generatedAt = new DateTimeImmutable('now', new DateTimeZone('Europe/Paris'));
header('Content-Type: text/html; charset=UTF-8');
require __DIR__ . '/../Views/admin_grants_print.php';
}
private static function date(mixed $v): ?string
{
$v = trim((string) $v);
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $v) ? $v : null;
}
private static function amount(mixed $v): float
{
return round((float) str_replace([' ', ' ', ','], ['', '', '.'], (string) $v), 2);
}
}

View file

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
final class InventoryController
{
private static function admin(): void
{
if (!Auth::is('admin')) {
http_response_code(403);
echo h(t('settings.admin_only'));
exit();
}
}
public static function index(): void
{
self::admin();
$db = DB::pdo();
render('settings_inventory.php', [
'title' => t('inventory.title'),
'products' => InventoryService::products(),
'batches' => InventoryService::batches(),
'movements' => InventoryService::movements(),
'alerts' => InventoryService::alerts(),
'references' => [
'medication' => $db
->query('SELECT id,name FROM ref_medications ORDER BY name COLLATE NOCASE')
->fetchAll(),
'dewormer' => $db->query('SELECT id,name FROM ref_dewormers ORDER BY name COLLATE NOCASE')->fetchAll(),
'vaccine' => $db->query('SELECT id,name FROM ref_vaccines ORDER BY name COLLATE NOCASE')->fetchAll(),
],
'suppliers' => $db
->query(
"SELECT DISTINCT dc.id,dc.name FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id WHERE dc.deleted_at IS NULL AND dc.kind='organization' AND r.role IN ('cabinet','fourriere') ORDER BY dc.name COLLATE NOCASE",
)
->fetchAll(),
'animals' => $db
->query(
'SELECT id,name FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL ORDER BY name COLLATE NOCASE',
)
->fetchAll(),
'status' => (string) ($_GET['status'] ?? ''),
'error' => (string) ($_GET['error'] ?? ''),
]);
}
public static function product(): void
{
self::admin();
self::run(fn() => InventoryService::saveProduct($_POST), 'product');
}
public static function receive(): void
{
self::admin();
self::run(fn() => InventoryService::receive($_POST), 'received');
}
public static function move(): void
{
self::admin();
self::run(fn() => InventoryService::move($_POST), 'moved');
}
private static function run(callable $fn, string $status): void
{
try {
$fn();
header('Location: /settings/inventory?status=' . $status);
} catch (RuntimeException $e) {
header('Location: /settings/inventory?error=' . rawurlencode($e->getMessage()));
} catch (Throwable $e) {
header('Location: /settings/inventory?error=' . rawurlencode(SecurityService::publicError($e)));
}
exit();
}
}

View file

@ -0,0 +1,334 @@
<?php
declare(strict_types=1);
final class MedicalDocumentsController
{
private const MAX_BYTES = 20 * 1024 * 1024;
private const TYPES = [
'application/pdf' => 'pdf',
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
];
public static function savePrescription(): void
{
$animalId = self::animalId();
$date = self::date((string) ($_POST['prescribed_on'] ?? ''));
if (!$animalId || !$date) {
self::fail(t('medical_document.invalid_data'));
return;
}
try {
$file = self::storeUpload('document', $animalId, 'prescription', true);
} catch (Throwable $e) {
self::fail($e->getMessage());
return;
}
$db = DB::pdo();
try {
$db->beginTransaction();
$stmt = $db->prepare(
'INSERT INTO medical_prescriptions(animal_id,prescribed_on,veterinarian_contact_id,clinic_contact_id,status,notes,original_name,stored_name,mime_type,size_bytes,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
);
$status = in_array($_POST['status'] ?? '', ['active', 'finished', 'replaced'], true)
? $_POST['status']
: 'active';
$stmt->execute([
$animalId,
$date,
self::contact('veterinarian_contact_id'),
self::contact('clinic_contact_id'),
$status,
self::text('notes'),
$file['original'],
$file['stored'],
$file['mime'],
$file['size'],
Auth::id(),
]);
$id = (int) $db->lastInsertId();
$link = $db->prepare(
'INSERT OR IGNORE INTO prescription_treatments(prescription_id,treatment_id) SELECT ?,id FROM treatments WHERE id=? AND animal_id=?',
);
foreach ((array) ($_POST['treatment_ids'] ?? []) as $treatmentId) {
$link->execute([$id, (int) $treatmentId, $animalId]);
}
self::history($db, $animalId, 'Ordonnance ajoutée', self::fr($date));
$db->commit();
AuditService::log(
'prescription_added',
'/animal/prescription/save',
t('medical_document.prescription_added'),
'animal',
$animalId,
['prescription_id' => $id],
);
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
self::remove($animalId, $file['stored']);
throw $e;
}
self::back($animalId);
}
public static function saveLabReport(): void
{
$animalId = self::animalId();
$date = self::date((string) ($_POST['sampled_on'] ?? ''));
$names = (array) ($_POST['parameter_name'] ?? []);
$values = (array) ($_POST['value'] ?? []);
if (!$animalId || !$date) {
self::fail(t('medical_document.invalid_data'));
return;
}
try {
$file = self::storeUpload('document', $animalId, 'analysis', false);
} catch (Throwable $e) {
self::fail($e->getMessage());
return;
}
$rows = [];
foreach ($names as $i => $name) {
$name = trim((string) $name);
$raw = str_replace(',', '.', trim((string) ($values[$i] ?? '')));
if ($name === '' && $raw === '') {
continue;
}
if ($name === '' || !is_numeric($raw)) {
if ($file) {
self::remove($animalId, $file['stored']);
}
self::fail(t('medical_document.invalid_result'));
return;
}
$min = self::numberOrNull(($_POST['reference_min'] ?? [])[$i] ?? null);
$max = self::numberOrNull(($_POST['reference_max'] ?? [])[$i] ?? null);
if ($min !== null && $max !== null && $min > $max) {
if ($file) {
self::remove($animalId, $file['stored']);
}
self::fail(t('medical_document.invalid_result'));
return;
}
$rows[] = [
'name' => mb_substr($name, 0, 120),
'value' => (float) $raw,
'unit' => self::arrayText('unit', $i, 30),
'min' => $min,
'max' => $max,
'notes' => self::arrayText('result_notes', $i, 255),
];
}
if (!$rows && !$file) {
self::fail(t('medical_document.result_or_document_required'));
return;
}
$db = DB::pdo();
try {
$db->beginTransaction();
$stmt = $db->prepare(
'INSERT INTO lab_reports(animal_id,sampled_on,report_type,laboratory_name,veterinarian_contact_id,clinic_contact_id,notes,original_name,stored_name,mime_type,size_bytes,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)',
);
$stmt->execute([
$animalId,
$date,
self::text('report_type', 120),
self::text('laboratory_name', 150),
self::contact('veterinarian_contact_id'),
self::contact('clinic_contact_id'),
self::text('notes'),
$file['original'] ?? null,
$file['stored'] ?? null,
$file['mime'] ?? null,
$file['size'] ?? 0,
Auth::id(),
]);
$reportId = (int) $db->lastInsertId();
$insert = $db->prepare(
'INSERT INTO lab_results(report_id,parameter_name,value,unit,reference_min,reference_max,notes,position) VALUES(?,?,?,?,?,?,?,?)',
);
foreach ($rows as $i => $row) {
$insert->execute([
$reportId,
$row['name'],
$row['value'],
$row['unit'],
$row['min'],
$row['max'],
$row['notes'],
$i,
]);
}
self::history(
$db,
$animalId,
'Résultats d’analyse ajoutés',
self::fr($date) . ($rows ? ' · ' . count($rows) . ' paramètre(s)' : ''),
);
$db->commit();
AuditService::log(
'lab_report_added',
'/animal/lab-report/save',
t('medical_document.analysis_added'),
'animal',
$animalId,
['report_id' => $reportId, 'results' => count($rows)],
);
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
if ($file) {
self::remove($animalId, $file['stored']);
}
throw $e;
}
self::back($animalId);
}
public static function download(): void
{
$id = (int) ($_GET['id'] ?? 0);
$kind = (string) ($_GET['kind'] ?? '');
$table = $kind === 'prescription' ? 'medical_prescriptions' : ($kind === 'analysis' ? 'lab_reports' : '');
if (!$id || !$table) {
http_response_code(404);
return;
}
$stmt = DB::pdo()->prepare(
"SELECT d.animal_id,d.original_name,d.stored_name,d.mime_type FROM $table d JOIN animals a ON a.id=d.animal_id WHERE d.id=? AND a.deleted_at IS NULL",
);
$stmt->execute([$id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row || !$row['stored_name']) {
http_response_code(404);
return;
}
$path = self::directory((int) $row['animal_id']) . '/' . basename((string) $row['stored_name']);
if (!is_file($path)) {
http_response_code(404);
return;
}
PrivacyService::logAccess('medical_' . $kind, $id, (int) $row['animal_id'], (string) $row['original_name']);
AuditService::log(
'private_file_viewed',
'/animal/medical-document',
'Document médical privé consulté',
'animal',
(int) $row['animal_id'],
['kind' => $kind, 'document_id' => $id],
);
header('Content-Type: ' . ($row['mime_type'] ?: 'application/octet-stream'));
header('Content-Length: ' . filesize($path));
header("Content-Disposition: inline; filename*=UTF-8''" . rawurlencode((string) $row['original_name']));
header('Cache-Control: private, no-store');
readfile($path);
}
private static function animalId(): int
{
$id = (int) ($_POST['animal_id'] ?? 0);
if (!$id) {
return 0;
}
$s = DB::pdo()->prepare('SELECT 1 FROM animals WHERE id=? AND deleted_at IS NULL');
$s->execute([$id]);
return $s->fetchColumn() ? $id : 0;
}
private static function date(string $v): ?string
{
$d = DateTimeImmutable::createFromFormat('!Y-m-d', $v);
return $d && $d->format('Y-m-d') === $v ? $v : null;
}
private static function contact(string $key): ?int
{
$id = (int) ($_POST[$key] ?? 0);
if ($id <= 0) {
return null;
}
$role = $key === 'veterinarian_contact_id' ? 'veterinaire' : 'cabinet';
$s = DB::pdo()->prepare(
'SELECT 1 FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id WHERE dc.id=? AND dc.deleted_at IS NULL AND r.role=?',
);
$s->execute([$id, $role]);
return $s->fetchColumn() ? $id : null;
}
private static function text(string $key, int $max = 2000): ?string
{
$v = trim((string) ($_POST[$key] ?? ''));
return $v === '' ? null : mb_substr($v, 0, $max);
}
private static function arrayText(string $key, int $i, int $max): ?string
{
$v = trim((string) (($_POST[$key] ?? [])[$i] ?? ''));
return $v === '' ? null : mb_substr($v, 0, $max);
}
private static function numberOrNull(mixed $v): ?float
{
$v = str_replace(',', '.', trim((string) $v));
return $v !== '' && is_numeric($v) ? (float) $v : null;
}
private static function directory(int $animalId): string
{
return dirname(__DIR__, 2) . '/data/medical-documents/' . $animalId;
}
private static function storeUpload(string $key, int $animalId, string $prefix, bool $required): ?array
{
$f = $_FILES[$key] ?? null;
if (!$f || ($f['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
if ($required) {
throw new RuntimeException(t('medical_document.file_required'));
}
return null;
}
if (($f['error'] ?? 1) !== UPLOAD_ERR_OK || ($f['size'] ?? 0) <= 0 || $f['size'] > self::MAX_BYTES) {
throw new RuntimeException(t('medical_document.file_invalid'));
}
$mime = new finfo(FILEINFO_MIME_TYPE)->file($f['tmp_name']);
if (!isset(self::TYPES[$mime])) {
throw new RuntimeException(t('medical_document.file_type'));
}
$dir = self::directory($animalId);
if (!is_dir($dir) && !mkdir($dir, 0770, true) && !is_dir($dir)) {
throw new RuntimeException(t('medical_document.storage_failed'));
}
$stored = $prefix . '-' . bin2hex(random_bytes(16)) . '.' . self::TYPES[$mime];
if (!move_uploaded_file($f['tmp_name'], $dir . '/' . $stored)) {
throw new RuntimeException(t('medical_document.storage_failed'));
}
return [
'original' => mb_substr(basename((string) $f['name']), 0, 240),
'stored' => $stored,
'mime' => $mime,
'size' => (int) $f['size'],
];
}
private static function remove(int $animalId, string $name): void
{
@unlink(self::directory($animalId) . '/' . basename($name));
}
private static function history(PDO $db, int $animalId, string $label, string $details): void
{
$db->prepare(
"INSERT INTO animal_history(animal_id,type,label,details,user_id) VALUES(?,'medical',?,?,?)",
)->execute([$animalId, $label, $details, Auth::id()]);
}
private static function fr(string $date): string
{
return new DateTimeImmutable($date)->format('d/m/Y');
}
private static function fail(string $message): void
{
http_response_code(400);
echo h($message);
}
private static function back(int $id): never
{
header('Location: /animal?id=' . $id . '#tab-med');
exit();
}
}

View file

@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
final class PlacementsController
{
private const TYPES = [
'reservation' => 'Réservation',
'foster' => 'Placement en famille d’accueil',
'return' => 'Retour au refuge',
'cancellation' => 'Annulation',
];
public static function save(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$db = DB::pdo();
$animalId = (int) ($_POST['animal_id'] ?? 0);
$type = (string) ($_POST['event_type'] ?? '');
$date = (string) ($_POST['event_date'] ?? '');
if (!$animalId || !isset(self::TYPES[$type]) || !self::validDate($date)) {
http_response_code(400);
echo h(t('error.invalid_placement'));
return;
}
$reason = trim((string) ($_POST['reason'] ?? ''));
if (in_array($type, ['return', 'cancellation'], true) && $reason === '') {
http_response_code(400);
echo h(t('error.placement_reason_required'));
return;
}
$s = $db->prepare('SELECT * FROM animals WHERE id=:id AND deleted_at IS NULL AND archived_at IS NULL');
$s->execute([':id' => $animalId]);
$animal = $s->fetch(PDO::FETCH_ASSOC);
if (!$animal) {
http_response_code(404);
return;
}
$contactId = (int) ($_POST['contact_id'] ?? 0) ?: null;
$contact = null;
if ($contactId) {
$s = $db->prepare('SELECT * FROM directory_contacts WHERE id=:id AND deleted_at IS NULL');
$s->execute([':id' => $contactId]);
$contact = $s->fetch(PDO::FETCH_ASSOC) ?: null;
if (!$contact) {
$contactId = null;
}
}
$status = match ($type) {
'reservation' => 'reserve',
'foster' => 'fa',
'return' => 'refuge',
'cancellation' => in_array($animal['status'], ['reserve', 'réservé'], true) ? 'refuge' : $animal['status'],
};
$currentAddress = (string) ($animal['current_address'] ?? '');
if ($type === 'foster' && $contact) {
$currentAddress = implode(
' ',
array_filter([$contact['address'] ?? '', $contact['postal_code'] ?? '', $contact['city'] ?? '']),
);
}
if ($type === 'return') {
$currentAddress = implode(
' ',
array_filter([
AppSettings::get('association_address'),
AppSettings::get('association_postal_code'),
AppSettings::get('association_city'),
]),
);
}
$notes = trim((string) ($_POST['notes'] ?? ''));
$db->beginTransaction();
try {
$db->prepare(
'INSERT INTO animal_placements(animal_id,event_type,event_date,contact_id,reason,notes,created_by) VALUES(:animal,:type,:date,:contact,:reason,:notes,:user)',
)->execute([
':animal' => $animalId,
':type' => $type,
':date' => $date,
':contact' => $contactId,
':reason' => $reason ?: null,
':notes' => $notes ?: null,
':user' => Auth::id(),
]);
$db->prepare(
'UPDATE animals SET status=:status,refuge_room=NULL,care_box_key=NULL,current_address=:address,current_lat=NULL,current_lng=NULL,updated_at=datetime(\'now\') WHERE id=:id',
)->execute([':status' => $status, ':address' => $currentAddress ?: null, ':id' => $animalId]);
$after = $animal;
$after['status'] = $status;
$after['refuge_room'] = null;
$after['care_box_key'] = null;
$after['current_address'] = $currentAddress;
LocationHistoryService::record(
$db,
$animalId,
$animal,
$after,
'placement',
self::TYPES[$type] . ($reason !== '' ? ' — ' . $reason : ''),
$date . ' 12:00:00',
);
$details = [];
if ($reason !== '') {
$details[] = 'Motif : ' . $reason;
}
if ($notes !== '') {
$details[] = 'Notes : ' . $notes;
}
$db->prepare(
'INSERT INTO animal_history(animal_id,type,label,details,user_id,created_at) VALUES(:animal,\'placement\',:label,:details,:user,:date)',
)->execute([
':animal' => $animalId,
':label' => self::TYPES[$type],
':details' => $details ? implode("\n", $details) : null,
':user' => Auth::id(),
':date' => $date . ' 12:00:00',
]);
if ($type === 'foster' || $type === 'return') {
self::movement($db, $animalId, $type, $contactId, $reason, $notes, $date);
}
$db->commit();
header('Location: /animal?id=' . $animalId . '#tab-placements');
exit();
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
}
private static function movement(
PDO $db,
int $animalId,
string $type,
?int $contactId,
string $reason,
string $notes,
string $date,
): void {
$contact = null;
if ($contactId) {
$s = $db->prepare('SELECT name,phone,email,city FROM directory_contacts WHERE id=:id');
$s->execute([':id' => $contactId]);
$contact = $s->fetch(PDO::FETCH_ASSOC) ?: null;
}
$db->prepare(
'INSERT INTO animal_movements(animal_id,kind,place,lieu,contact_name,contact_phone,contact_email,note,created_at) VALUES(:animal,:kind,:place,:lieu,:name,:phone,:email,:note,:date)',
)->execute([
':animal' => $animalId,
':kind' => $type === 'return' ? 'entry' : 'exit',
':place' => $contact['city'] ?? null,
':lieu' => $type === 'return' ? 'Retour de placement' : 'Placement en FA',
':name' => $contact['name'] ?? null,
':phone' => $contact['phone'] ?? null,
':email' => $contact['email'] ?? null,
':note' =>
trim(($reason !== '' ? 'Motif : ' . $reason : '') . ($notes !== '' ? "\nNotes : " . $notes : '')) ?:
null,
':date' => $date . ' 12:00:00',
]);
}
private static function validDate(string $date): bool
{
$d = DateTimeImmutable::createFromFormat('!Y-m-d', $date);
return $d !== false && $d->format('Y-m-d') === $date;
}
}

View file

@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
final class PrivacyController
{
private static function admin(): void
{
if (!Auth::is('admin')) {
http_response_code(403);
echo h(t('settings.admin_only'));
exit();
}
}
public static function index(): void
{
self::admin();
$settings = AppSettings::all();
$years = max(1, (int) ($settings['privacy_retention_years'] ?? 5));
render('settings_privacy.php', [
'title' => t('privacy.title'),
'settings' => $settings,
'review' => PrivacyService::retentionReview($years),
'accesses' => PrivacyService::accessLog(),
'saved' => isset($_GET['saved']),
'error' => (string) ($_GET['error'] ?? ''),
]);
}
public static function save(): void
{
self::admin();
self::postOnly();
$values = [];
foreach (
[
'privacy_retention_years' => 5,
'privacy_retention_adopter_years' => 5,
'privacy_retention_foster_years' => 5,
'privacy_retention_volunteer_years' => 5,
]
as $key => $default
) {
$values[$key] = (string) max(1, min(30, (int) ($_POST[$key] ?? $default)));
}
$values['privacy_access_log_months'] = (string) max(
1,
min(120, (int) ($_POST['privacy_access_log_months'] ?? 24)),
);
AppSettings::save($values, Auth::id());
AuditService::log(
'privacy_settings_saved',
'/settings/privacy/save',
'Politique de conservation modifiée',
null,
null,
$values,
);
header('Location: /settings/privacy?saved=1');
exit();
}
public static function export(): void
{
self::admin();
$id = (int) ($_GET['id'] ?? 0);
try {
$path = PrivacyService::exportContact($id);
PrivacyService::logAccess('contact_export', $id, null, 'contact-' . $id . '.zip', 'download');
AuditService::log(
'contact_exported',
'/settings/privacy/export',
'Export RGPD d’un contact',
'contact',
$id,
);
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="contact-' . $id . '-globinours.zip"');
header('Content-Length: ' . filesize($path));
header('Cache-Control: private,no-store');
readfile($path);
@unlink($path);
} catch (RuntimeException $e) {
http_response_code(404);
echo h($e->getMessage());
} catch (Throwable $e) {
http_response_code(500);
echo h(SecurityService::publicError($e));
}
}
public static function anonymize(): void
{
self::admin();
self::postOnly();
$id = (int) ($_POST['id'] ?? 0);
try {
if (!isset($_POST['confirm_reviewed'])) {
throw new RuntimeException(t('privacy.review_required'));
}
PrivacyService::anonymize($id, (string) ($_POST['reason'] ?? ''));
header('Location: /settings/privacy?saved=1');
} catch (Throwable $e) {
header('Location: /settings/privacy?error=' . rawurlencode($e->getMessage()));
}
exit();
}
private static function postOnly(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
header('Allow: POST');
exit();
}
}
}

View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
final class ProjectController
{
public static function evolutions(): void
{
render('evolutions.php', [
'title' => t('footer.evolutions'),
'pageDescription' => t('project.evolutions_description'),
]);
}
}

View file

@ -0,0 +1,272 @@
<?php
declare(strict_types=1);
final class PublicSiteController
{
public static function usesRoot(): bool
{
return AppSettings::get('public_site_routing') === 'root';
}
public static function handles(string $path): bool
{
if ($path === '/site' || str_starts_with($path, '/site/')) {
return true;
}
return self::usesRoot() &&
in_array(
$path,
[
'/',
'/animaux',
'/adoption',
'/presentation',
'/dons',
'/contact',
'/mentions-legales',
'/confidentialite',
'/logo-association',
],
true,
);
}
public static function url(string $page = 'home', array $query = []): string
{
$root = self::usesRoot();
$path = match ($page) {
'home' => $root ? '/' : '/site',
'animals' => $root ? '/animaux' : '/site/animaux',
'animal' => $root ? '/adoption' : '/site/animal',
'about' => $root ? '/presentation' : '/site/presentation',
'donation' => $root ? '/dons' : '/site/dons',
'contact' => $root ? '/contact' : '/site/contact',
'legal' => $root ? '/mentions-legales' : '/site/mentions-legales',
'privacy' => $root ? '/confidentialite' : '/site/confidentialite',
'logo' => $root ? '/logo-association' : '/site/logo',
default => $root ? '/' : '/site',
};
return $path . ($query ? '?' . http_build_query($query) : '');
}
public static function dispatch(string $path): void
{
$path = self::normalize($path);
if (AppSettings::get('public_site_enabled') !== '1') {
http_response_code(404);
self::render('public_site_unavailable.php', ['title' => t('public_site.unavailable')]);
return;
}
if ($path === '/site/logo') {
self::logo();
return;
}
match ($path) {
'/site', '/site/' => self::home(),
'/site/animaux' => self::animals(),
'/site/animal' => self::animal(),
'/site/dons' => self::donation(),
'/site/contact' => self::contact(),
'/site/presentation' => self::about(),
'/site/mentions-legales' => self::legal(),
'/site/confidentialite' => self::privacy(),
default => self::notFound(),
};
}
private static function normalize(string $path): string
{
return match ($path) {
'/' => '/site',
'/animaux' => '/site/animaux',
'/adoption' => '/site/animal',
'/presentation' => '/site/presentation',
'/dons' => '/site/dons',
'/contact' => '/site/contact',
'/mentions-legales' => '/site/mentions-legales',
'/confidentialite' => '/site/confidentialite',
'/logo-association' => '/site/logo',
default => $path,
};
}
private static function home(): void
{
$db = DB::pdo();
$animals = self::animalQuery('', 8);
$counts = $db
->query(
"SELECT species,COUNT(*) count FROM animals WHERE website_published=1 AND adoption_availability='available' AND archived_at IS NULL AND deleted_at IS NULL AND status NOT IN ('adopte','decede') GROUP BY species ORDER BY species",
)
->fetchAll(PDO::FETCH_ASSOC);
self::render('public_site_home.php', [
'title' => AppSettings::get('public_site_home_title'),
'animals' => $animals,
'speciesCounts' => $counts,
]);
}
private static function animals(): void
{
$species = SpeciesService::normalize((string) ($_GET['espece'] ?? ''));
if ($species !== '' && !SpeciesService::exists($species, false)) {
$species = '';
}
self::render('public_site_animals.php', [
'title' => t('public_site.animals'),
'animals' => self::animalQuery($species, 200),
'selectedSpecies' => $species,
'publicSpecies' => self::species(),
]);
}
private static function animal(): void
{
$id = (int) ($_GET['id'] ?? 0);
$s = DB::pdo()->prepare(
"SELECT a.*,s.name species_name,s.icon species_icon,(SELECT filename FROM animal_photos p WHERE p.animal_id=a.id AND p.is_public=1 ORDER BY p.is_primary DESC,p.id DESC LIMIT 1) primary_photo FROM animals a LEFT JOIN ref_species s ON s.code=a.species WHERE a.id=? AND a.website_published=1 AND a.adoption_availability='available' AND a.archived_at IS NULL AND a.deleted_at IS NULL AND a.status NOT IN ('adopte','decede')",
);
$s->execute([$id]);
$animal = $s->fetch(PDO::FETCH_ASSOC);
if (!$animal) {
self::notFound();
return;
}
$p = DB::pdo()->prepare(
'SELECT filename,caption FROM animal_photos WHERE animal_id=? AND is_public=1 ORDER BY is_primary DESC,id DESC',
);
$p->execute([$id]);
self::render('public_site_animal.php', [
'title' => $animal['name'],
'animal' => $animal,
'photos' => $p->fetchAll(PDO::FETCH_ASSOC),
]);
}
private static function donation(): void
{
self::render('public_site_text.php', [
'title' => AppSettings::get('public_site_donation_title'),
'content' => AppSettings::get('public_site_donation_text'),
'kind' => 'donation',
]);
}
private static function contact(): void
{
self::render('public_site_contact.php', ['title' => t('public_site.contact')]);
}
private static function about(): void
{
if (AppSettings::get('public_site_about_enabled') !== '1') {
self::notFound();
return;
}
self::render('public_site_text.php', [
'title' => AppSettings::get('public_site_about_title'),
'content' => AppSettings::get('public_site_about_text'),
'kind' => 'about',
]);
}
private static function legal(): void
{
self::render('public_site_text.php', [
'title' => t('public_site.legal'),
'content' => self::legalText(),
'kind' => 'legal',
]);
}
private static function privacy(): void
{
self::render('public_site_text.php', [
'title' => t('public_site.privacy'),
'content' => self::privacyText(),
'kind' => 'privacy',
]);
}
public static function legalText(?array $settings = null): string
{
$settings ??= AppSettings::all();
$custom = trim((string) ($settings['public_site_legal_text'] ?? ''));
if ($custom !== '') {
return $custom;
}
$address =
implode(
', ',
array_filter([
$settings['association_address'] ?? '',
trim(($settings['association_postal_code'] ?? '') . ' ' . ($settings['association_city'] ?? '')),
]),
) ?:
t('public_site.to_complete');
return t('public_site.default_legal', [
'association' => $settings['association_name'] ?: t('public_site.to_complete'),
'address' => $address,
'email' => $settings['association_email'] ?: t('public_site.to_complete'),
'rna' => $settings['association_rna'] ?: t('public_site.to_complete'),
'siret' => $settings['association_siret'] ?: t('public_site.to_complete'),
]);
}
public static function privacyText(?array $settings = null): string
{
$settings ??= AppSettings::all();
$custom = trim((string) ($settings['public_site_privacy_text'] ?? ''));
if ($custom !== '') {
return $custom;
}
$address =
implode(
', ',
array_filter([
$settings['association_address'] ?? '',
trim(($settings['association_postal_code'] ?? '') . ' ' . ($settings['association_city'] ?? '')),
]),
) ?:
t('public_site.to_complete');
return t('public_site.default_privacy', [
'association' => $settings['association_name'] ?: t('public_site.to_complete'),
'address' => $address,
'email' => $settings['association_email'] ?: t('public_site.to_complete'),
]);
}
private static function logo(): void
{
$path = AssociationBrand::path();
if (!$path) {
http_response_code(404);
return;
}
$mime = new finfo(FILEINFO_MIME_TYPE)->file($path) ?: 'application/octet-stream';
header('Content-Type: ' . $mime);
header('Content-Length: ' . filesize($path));
header('Cache-Control: public,max-age=3600');
readfile($path);
}
private static function animalQuery(string $species, int $limit): array
{
$sql =
"SELECT a.id,a.name,a.species,a.sex,a.birth_date,a.birth_is_estimated,a.birth_estimated_months,a.breed,a.color,a.sterilization_status,a.compatibility_dogs,a.compatibility_cats,a.compatibility_children,a.house_trained,a.website_description,s.name species_name,s.icon species_icon,(SELECT filename FROM animal_photos p WHERE p.animal_id=a.id AND p.is_public=1 ORDER BY p.is_primary DESC,p.id DESC LIMIT 1) primary_photo FROM animals a LEFT JOIN ref_species s ON s.code=a.species WHERE a.website_published=1 AND a.adoption_availability='available' AND a.archived_at IS NULL AND a.deleted_at IS NULL AND a.status NOT IN ('adopte','decede')";
$params = [];
if ($species !== '') {
$sql .= ' AND a.species=:species';
$params[':species'] = $species;
}
$sql .= ' ORDER BY a.updated_at DESC,a.name COLLATE NOCASE LIMIT ' . max(1, min(500, $limit));
$s = DB::pdo()->prepare($sql);
$s->execute($params);
return $s->fetchAll(PDO::FETCH_ASSOC);
}
private static function species(): array
{
return DB::pdo()
->query(
"SELECT s.code,s.name,s.icon,COUNT(a.id) count FROM ref_species s JOIN animals a ON a.species=s.code AND a.website_published=1 AND a.adoption_availability='available' AND a.archived_at IS NULL AND a.deleted_at IS NULL AND a.status NOT IN ('adopte','decede') GROUP BY s.id ORDER BY s.sort_order,s.name COLLATE NOCASE",
)
->fetchAll(PDO::FETCH_ASSOC);
}
private static function render(string $view, array $vars = []): void
{
extract($vars, EXTR_SKIP);
$settings = AppSettings::all();
$publicSpecies = self::species();
require dirname(__DIR__) . '/Views/public_site_layout.php';
}
private static function notFound(): void
{
http_response_code(404);
self::render('public_site_unavailable.php', ['title' => t('public_site.not_found')]);
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
final class SearchController
{
public static function index(): void
{
$q = mb_substr(trim((string) ($_GET['q'] ?? '')), 0, 120);
render('search.php', [
'title' => t('search.title'),
'searchQuery' => $q,
'searchResults' => GlobalSearchService::search($q, 20),
]);
}
public static function json(): void
{
$q = mb_substr(trim((string) ($_GET['q'] ?? '')), 0, 120);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: private, no-store');
echo json_encode(
['query' => $q, 'groups' => GlobalSearchService::search($q)],
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR,
);
}
}

View file

@ -0,0 +1,998 @@
<?php
declare(strict_types=1);
final class SettingsController
{
private const ROLES = PermissionService::ROLES;
public static function general(): void
{
self::admin();
render('settings_general.php', [
'title' => t('settings.general_title'),
'settings' => AppSettings::all(),
'saved' => isset($_GET['saved']),
'error' => (string) ($_GET['error'] ?? ''),
'associationLogo' => AssociationBrand::path(),
]);
}
public static function saveGeneral(): void
{
self::admin();
self::postOnly();
$values = [];
foreach (
[
'association_name',
'association_address',
'association_postal_code',
'association_city',
'association_phone',
'association_email',
'association_siret',
'association_rna',
]
as $key
) {
$values[$key] = trim((string) ($_POST[$key] ?? ''));
}
if ($values['association_name'] === '') {
http_response_code(400);
echo h(t('settings.association_required'));
return;
}
if ($values['association_email'] !== '' && !filter_var($values['association_email'], FILTER_VALIDATE_EMAIL)) {
http_response_code(400);
echo h(t('settings.invalid_email'));
return;
}
$values['quarantine_days'] = (string) max(1, min(90, (int) ($_POST['quarantine_days'] ?? 15)));
$values['backup_retention'] = (string) max(1, min(50, (int) ($_POST['backup_retention'] ?? 10)));
$values['app_language'] = isset(I18n::LOCALES[(string) ($_POST['app_language'] ?? 'fr')])
? (string) $_POST['app_language']
: 'fr';
try {
$fullAddress = implode(
', ',
array_filter([
$values['association_address'],
trim($values['association_postal_code'] . ' ' . $values['association_city']),
]),
);
if ($fullAddress === '') {
$values['association_latitude'] = '';
$values['association_longitude'] = '';
} elseif ($point = GeoService::geocode(DB::pdo(), $fullAddress)) {
$values['association_latitude'] = (string) $point['lat'];
$values['association_longitude'] = (string) $point['lng'];
}
if (isset($_POST['delete_association_logo'])) {
AssociationBrand::delete();
} elseif (isset($_FILES['association_logo'])) {
AssociationBrand::store($_FILES['association_logo']);
}
AppSettings::save($values, Auth::id());
AuditService::log(
'settings_saved',
'/settings/general/save',
'Paramètres généraux et identité visuelle modifiés',
);
header('Location: /settings/general?saved=1');
} catch (Throwable $e) {
header('Location: /settings/general?error=' . rawurlencode(SecurityService::publicError($e)));
}
exit();
}
public static function associationLogo(): void
{
self::admin();
$path = AssociationBrand::path();
if (!$path) {
http_response_code(404);
return;
}
$mime = new finfo(FILEINFO_MIME_TYPE)->file($path) ?: 'application/octet-stream';
header('Content-Type: ' . $mime);
header('Content-Length: ' . filesize($path));
header('Cache-Control: private, max-age=3600');
header('X-Content-Type-Options: nosniff');
readfile($path);
}
public static function users(): void
{
self::admin();
$users = DB::pdo()
->query('SELECT * FROM users ORDER BY active DESC,display_name COLLATE NOCASE,username COLLATE NOCASE')
->fetchAll(PDO::FETCH_ASSOC);
render('settings_users.php', ['title' => t('settings.users_title'), 'users' => $users, 'roles' => self::ROLES]);
}
public static function saveUser(): void
{
self::admin();
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$db = DB::pdo();
$id = (int) ($_POST['id'] ?? 0);
$username = trim((string) ($_POST['username'] ?? ''));
$display = trim((string) ($_POST['display_name'] ?? ''));
$role = (string) ($_POST['role'] ?? 'lecture');
$active = isset($_POST['active']) ? 1 : 0;
$password = (string) ($_POST['password'] ?? '');
if (!preg_match('/^[a-zA-Z0-9._-]{3,40}$/', $username) || $display === '' || !isset(self::ROLES[$role])) {
http_response_code(400);
echo h(t('settings.invalid_data'));
return;
}
if ($id === Auth::id() && (!$active || $role !== 'admin')) {
http_response_code(400);
echo h(t('settings.keep_admin'));
return;
}
if ($id) {
$params = [':u' => $username, ':d' => $display, ':r' => $role, ':a' => $active, ':id' => $id];
$sql = "UPDATE users SET username=:u,display_name=:d,role=:r,active=:a,updated_at=datetime('now')";
if ($password !== '') {
if (strlen($password) < 10) {
http_response_code(400);
echo h(t('settings.password_short'));
return;
}
$sql .= ',password_hash=:p';
$params[':p'] = password_hash($password, PASSWORD_DEFAULT);
}
$sql .= ' WHERE id=:id';
$db->prepare($sql)->execute($params);
$summary = 'Utilisateur modifié : ' . $display;
} else {
if (strlen($password) < 10) {
http_response_code(400);
echo h(t('settings.password_required'));
return;
}
$db->prepare(
'INSERT INTO users(username,password_hash,display_name,role,active) VALUES(:u,:p,:d,:r,:a)',
)->execute([
':u' => $username,
':p' => password_hash($password, PASSWORD_DEFAULT),
':d' => $display,
':r' => $role,
':a' => $active,
]);
$id = (int) $db->lastInsertId();
$summary = 'Utilisateur créé : ' . $display;
}
AuditService::log('user_saved', '/settings/users/save', $summary, 'user', $id, [
'role' => $role,