Publier Globinours 1.0.0-rc.3
This commit is contained in:
parent
ea8c24d622
commit
9a2b4068da
325 changed files with 38230 additions and 20 deletions
18
.editorconfig
Normal file
18
.editorconfig
Normal 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
9
.gitignore
vendored
Normal 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
48
CHANGELOG.md
Normal 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
23
HISTORIQUE.md
Normal 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
22
LICENSE.md
Normal 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.
|
||||
|
|
@ -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
1
VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
1.0.0-rc.3
|
||||
513
app/Controllers/AccountingController.php
Normal file
513
app/Controllers/AccountingController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
101
app/Controllers/AdminController.php
Normal file
101
app/Controllers/AdminController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
70
app/Controllers/AdoptionsController.php
Normal file
70
app/Controllers/AdoptionsController.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
}
|
||||
267
app/Controllers/AgendaController.php
Normal file
267
app/Controllers/AgendaController.php
Normal 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 !== '' ? '&' . str_replace('&', '&', $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;
|
||||
}
|
||||
}
|
||||
1017
app/Controllers/AnimalBrowseActions.php
Normal file
1017
app/Controllers/AnimalBrowseActions.php
Normal file
File diff suppressed because it is too large
Load diff
87
app/Controllers/AnimalLifecycleActions.php
Normal file
87
app/Controllers/AnimalLifecycleActions.php
Normal 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();
|
||||
}
|
||||
}
|
||||
706
app/Controllers/AnimalMedicalActions.php
Normal file
706
app/Controllers/AnimalMedicalActions.php
Normal 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);
|
||||
}
|
||||
}
|
||||
296
app/Controllers/AnimalPhotoActions.php
Normal file
296
app/Controllers/AnimalPhotoActions.php
Normal 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();
|
||||
}
|
||||
}
|
||||
1041
app/Controllers/AnimalRecordActions.php
Normal file
1041
app/Controllers/AnimalRecordActions.php
Normal file
File diff suppressed because it is too large
Load diff
443
app/Controllers/AnimalRelationshipActions.php
Normal file
443
app/Controllers/AnimalRelationshipActions.php
Normal 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();
|
||||
}
|
||||
}
|
||||
602
app/Controllers/AnimalsController.php
Normal file
602
app/Controllers/AnimalsController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
209
app/Controllers/Asm3ImportController.php
Normal file
209
app/Controllers/Asm3ImportController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
199
app/Controllers/AuthController.php
Normal file
199
app/Controllers/AuthController.php
Normal 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';
|
||||
}
|
||||
}
|
||||
463
app/Controllers/CareRoundsController.php
Normal file
463
app/Controllers/CareRoundsController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
300
app/Controllers/DashboardController.php
Normal file
300
app/Controllers/DashboardController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
305
app/Controllers/DeathController.php
Normal file
305
app/Controllers/DeathController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
129
app/Controllers/DevController.php
Normal file
129
app/Controllers/DevController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
58
app/Controllers/DevicesController.php
Normal file
58
app/Controllers/DevicesController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
163
app/Controllers/DewormingController.php
Normal file
163
app/Controllers/DewormingController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
279
app/Controllers/DirectoryController.php
Normal file
279
app/Controllers/DirectoryController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
150
app/Controllers/DocumentsController.php
Normal file
150
app/Controllers/DocumentsController.php
Normal 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';
|
||||
}
|
||||
}
|
||||
240
app/Controllers/GrantsController.php
Normal file
240
app/Controllers/GrantsController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
72
app/Controllers/InventoryController.php
Normal file
72
app/Controllers/InventoryController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
334
app/Controllers/MedicalDocumentsController.php
Normal file
334
app/Controllers/MedicalDocumentsController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
172
app/Controllers/PlacementsController.php
Normal file
172
app/Controllers/PlacementsController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
113
app/Controllers/PrivacyController.php
Normal file
113
app/Controllers/PrivacyController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
14
app/Controllers/ProjectController.php
Normal file
14
app/Controllers/ProjectController.php
Normal 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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
272
app/Controllers/PublicSiteController.php
Normal file
272
app/Controllers/PublicSiteController.php
Normal 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')]);
|
||||
}
|
||||
}
|
||||
26
app/Controllers/SearchController.php
Normal file
26
app/Controllers/SearchController.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
998
app/Controllers/SettingsController.php
Normal file
998
app/Controllers/SettingsController.php
Normal 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,
|
||||
'active' => $active,
|
||||
]);
|
||||
header('Location: /settings/users');
|
||||
exit();
|
||||
}
|
||||
public static function permissions(): void
|
||||
{
|
||||
self::admin();
|
||||
render('settings_permissions.php', [
|
||||
'title' => t('settings.permissions_title'),
|
||||
'roles' => PermissionService::ROLES,
|
||||
'modules' => PermissionService::MODULES,
|
||||
'matrix' => PermissionService::matrix(),
|
||||
'saved' => isset($_GET['saved']),
|
||||
]);
|
||||
}
|
||||
public static function savePermissions(): void
|
||||
{
|
||||
self::admin();
|
||||
self::postOnly();
|
||||
PermissionService::save((array) ($_POST['permissions'] ?? []), Auth::id());
|
||||
AuditService::log('permissions_saved', '/settings/permissions/save', 'Permissions des rôles modifiées');
|
||||
header('Location: /settings/permissions?saved=1');
|
||||
exit();
|
||||
}
|
||||
public static function species(): void
|
||||
{
|
||||
self::admin();
|
||||
render('settings_species.php', [
|
||||
'title' => t('settings.species_title'),
|
||||
'species' => SpeciesService::all(),
|
||||
'categories' => SpeciesService::CATEGORIES,
|
||||
'saved' => isset($_GET['saved']),
|
||||
'error' => (string) ($_GET['error'] ?? ''),
|
||||
]);
|
||||
}
|
||||
public static function rooms(): void
|
||||
{
|
||||
self::admin();
|
||||
render('settings_rooms.php', [
|
||||
'title' => t('rooms.title'),
|
||||
'rooms' => ShelterRoomService::all(),
|
||||
'saved' => isset($_GET['saved']),
|
||||
'error' => (string) ($_GET['error'] ?? ''),
|
||||
]);
|
||||
}
|
||||
public static function saveRooms(): void
|
||||
{
|
||||
self::admin();
|
||||
self::postOnly();
|
||||
$db = DB::pdo();
|
||||
$submitted = (array) ($_POST['rooms'] ?? []);
|
||||
try {
|
||||
$db->beginTransaction();
|
||||
foreach ($submitted as $id => $row) {
|
||||
$id = (int) $id;
|
||||
if (!$id) {
|
||||
continue;
|
||||
}
|
||||
$name = trim((string) ($row['name'] ?? ''));
|
||||
$type = (string) ($row['room_type'] ?? 'collective');
|
||||
$status = (string) ($row['status_code'] ?? 'refuge');
|
||||
$color = (string) ($row['color'] ?? '#6c757d');
|
||||
if (
|
||||
$name === '' ||
|
||||
!in_array($type, ['collective', 'boxes'], true) ||
|
||||
!in_array($status, ['refuge', 'soin', 'quarantaine'], true) ||
|
||||
!preg_match('/^#[0-9a-fA-F]{6}$/', $color)
|
||||
) {
|
||||
throw new RuntimeException(t('rooms.invalid'));
|
||||
}
|
||||
$db->prepare(
|
||||
"UPDATE shelter_rooms SET name=?,room_type=?,status_code=?,color=?,bulk_validation=?,active=?,sort_order=?,updated_at=datetime('now') WHERE id=?",
|
||||
)->execute([
|
||||
$name,
|
||||
$type,
|
||||
$status,
|
||||
$color,
|
||||
isset($row['bulk_validation']) ? 1 : 0,
|
||||
isset($row['active']) ? 1 : 0,
|
||||
max(0, (int) ($row['sort_order'] ?? 100)),
|
||||
$id,
|
||||
]);
|
||||
self::syncBoxes($db, $id, (string) ($row['boxes'] ?? ''));
|
||||
}
|
||||
$new = (array) ($_POST['new_room'] ?? []);
|
||||
$newName = trim((string) ($new['name'] ?? ''));
|
||||
if ($newName !== '') {
|
||||
$code = ShelterRoomService::normalizeCode((string) ($new['code'] ?? $newName));
|
||||
if (strlen($code) < 2) {
|
||||
throw new RuntimeException(t('rooms.invalid'));
|
||||
}
|
||||
$type = in_array($new['room_type'] ?? '', ['collective', 'boxes'], true)
|
||||
? (string) $new['room_type']
|
||||
: 'collective';
|
||||
$status = in_array($new['status_code'] ?? '', ['refuge', 'soin', 'quarantaine'], true)
|
||||
? (string) $new['status_code']
|
||||
: 'refuge';
|
||||
$color = preg_match('/^#[0-9a-fA-F]{6}$/', (string) ($new['color'] ?? ''))
|
||||
? (string) $new['color']
|
||||
: '#6c757d';
|
||||
$db->prepare(
|
||||
'INSERT INTO shelter_rooms(code,name,room_type,status_code,color,bulk_validation,sort_order) VALUES(?,?,?,?,?,?,?)',
|
||||
)->execute([$code, $newName, $type, $status, $color, isset($new['bulk_validation']) ? 1 : 0, 100]);
|
||||
self::syncBoxes($db, (int) $db->lastInsertId(), (string) ($new['boxes'] ?? ''));
|
||||
}
|
||||
$db->commit();
|
||||
AuditService::log('rooms_saved', '/settings/rooms/save', 'Salles et box du refuge modifiés');
|
||||
header('Location: /settings/rooms?saved=1');
|
||||
} catch (Throwable $e) {
|
||||
if ($db->inTransaction()) {
|
||||
$db->rollBack();
|
||||
}
|
||||
header('Location: /settings/rooms?error=' . rawurlencode(SecurityService::publicError($e)));
|
||||
}
|
||||
exit();
|
||||
}
|
||||
private static function syncBoxes(PDO $db, int $roomId, string $lines): void
|
||||
{
|
||||
$names = array_values(
|
||||
array_filter(array_map('trim', preg_split('/\R/u', $lines) ?: []), static fn($name) => $name !== ''),
|
||||
);
|
||||
$existing = $db->prepare('SELECT id,code FROM shelter_boxes WHERE room_id=? ORDER BY sort_order,id');
|
||||
$existing->execute([$roomId]);
|
||||
$known = $existing->fetchAll(PDO::FETCH_ASSOC);
|
||||
$kept = [];
|
||||
foreach ($names as $index => $name) {
|
||||
$position = ($index + 1) * 10;
|
||||
if (isset($known[$index])) {
|
||||
$id = (int) $known[$index]['id'];
|
||||
$kept[] = $id;
|
||||
$db->prepare(
|
||||
"UPDATE shelter_boxes SET name=?,active=1,sort_order=?,updated_at=datetime('now') WHERE id=?",
|
||||
)->execute([$name, $position, $id]);
|
||||
continue;
|
||||
}
|
||||
$code = ShelterRoomService::normalizeCode($name) ?: 'box-' . ($index + 1);
|
||||
$base = $code;
|
||||
$suffix = 2;
|
||||
$exists = $db->prepare('SELECT 1 FROM shelter_boxes WHERE room_id=? AND code=?');
|
||||
while (true) {
|
||||
$exists->execute([$roomId, $code]);
|
||||
if (!$exists->fetchColumn()) {
|
||||
break;
|
||||
}
|
||||
$code = $base . '-' . $suffix++;
|
||||
}
|
||||
$db->prepare('INSERT INTO shelter_boxes(room_id,code,name,sort_order) VALUES(?,?,?,?)')->execute([
|
||||
$roomId,
|
||||
$code,
|
||||
$name,
|
||||
$position,
|
||||
]);
|
||||
$kept[] = (int) $db->lastInsertId();
|
||||
}
|
||||
foreach ($known as $box) {
|
||||
if (!in_array((int) $box['id'], $kept, true)) {
|
||||
$db->prepare("UPDATE shelter_boxes SET active=0,updated_at=datetime('now') WHERE id=?")->execute([
|
||||
(int) $box['id'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
public static function saveSpecies(): void
|
||||
{
|
||||
self::admin();
|
||||
self::postOnly();
|
||||
$db = DB::pdo();
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$name = trim((string) ($_POST['name'] ?? ''));
|
||||
$code = SpeciesService::normalize((string) ($_POST['code'] ?? ''));
|
||||
$category = (string) ($_POST['category'] ?? 'autre');
|
||||
$icon = trim((string) ($_POST['icon'] ?? '')) ?: '🐾';
|
||||
$color = trim((string) ($_POST['color'] ?? '#6c757d'));
|
||||
$active = isset($_POST['active']) ? 1 : 0;
|
||||
$sort = max(0, min(999, (int) ($_POST['sort_order'] ?? 100)));
|
||||
if (
|
||||
$name === '' ||
|
||||
!preg_match('/^[a-z0-9][a-z0-9-]{1,49}$/', $code) ||
|
||||
!isset(SpeciesService::CATEGORIES[$category]) ||
|
||||
!preg_match('/^#[0-9a-fA-F]{6}$/', $color)
|
||||
) {
|
||||
header('Location: /settings/species?error=' . rawurlencode(t('settings.species_invalid')));
|
||||
exit();
|
||||
}
|
||||
try {
|
||||
if ($id > 0) {
|
||||
$old = $db->prepare('SELECT code,is_system FROM ref_species WHERE id=:id');
|
||||
$old->execute([':id' => $id]);
|
||||
$oldRow = $old->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$oldRow) {
|
||||
throw new RuntimeException(t('settings.species_not_found'));
|
||||
}
|
||||
$oldCode = (string) $oldRow['code'];
|
||||
if ((int) $oldRow['is_system'] === 1) {
|
||||
$code = $oldCode;
|
||||
}
|
||||
$db->beginTransaction();
|
||||
$db->prepare(
|
||||
'UPDATE ref_species SET code=:code,name=:name,category=:category,icon=:icon,color=:color,active=:active,sort_order=:sort,updated_at=datetime(\'now\') WHERE id=:id',
|
||||
)->execute([
|
||||
':code' => $code,
|
||||
':name' => $name,
|
||||
':category' => $category,
|
||||
':icon' => $icon,
|
||||
':color' => $color,
|
||||
':active' => $active,
|
||||
':sort' => $sort,
|
||||
':id' => $id,
|
||||
]);
|
||||
if ($oldCode !== $code) {
|
||||
$db->prepare('UPDATE animals SET species=:new WHERE lower(trim(species))=lower(:old)')->execute([
|
||||
':new' => $code,
|
||||
':old' => $oldCode,
|
||||
]);
|
||||
}
|
||||
$db->commit();
|
||||
} else {
|
||||
$db->prepare(
|
||||
'INSERT INTO ref_species(code,name,category,icon,color,active,sort_order) VALUES(:code,:name,:category,:icon,:color,:active,:sort)',
|
||||
)->execute([
|
||||
':code' => $code,
|
||||
':name' => $name,
|
||||
':category' => $category,
|
||||
':icon' => $icon,
|
||||
':color' => $color,
|
||||
':active' => $active,
|
||||
':sort' => $sort,
|
||||
]);
|
||||
}
|
||||
AuditService::log(
|
||||
'species_saved',
|
||||
'/settings/species/save',
|
||||
'Espèce enregistrée : ' . $name,
|
||||
'species',
|
||||
$id ?: ((int) $db->lastInsertId()),
|
||||
);
|
||||
header('Location: /settings/species?saved=1');
|
||||
} catch (Throwable $e) {
|
||||
if ($db->inTransaction()) {
|
||||
$db->rollBack();
|
||||
}
|
||||
header('Location: /settings/species?error=' . rawurlencode(SecurityService::publicError($e)));
|
||||
}
|
||||
exit();
|
||||
}
|
||||
public static function pricing(): void
|
||||
{
|
||||
self::admin();
|
||||
$db = DB::pdo();
|
||||
$clinics = $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 IN ('cabinet','crematorium','fourriere') WHERE dc.kind='organization' AND dc.deleted_at IS NULL ORDER BY dc.name COLLATE NOCASE",
|
||||
)
|
||||
->fetchAll(PDO::FETCH_ASSOC);
|
||||
$discounts = [];
|
||||
foreach ($db->query('SELECT * FROM clinic_discount_rules')->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||
$discounts[(int) $r['clinic_contact_id']][(string) $r['discount_group']] = $r;
|
||||
}
|
||||
render('settings_pricing.php', [
|
||||
'title' => t('pricing.title'),
|
||||
'tariffs' => PricingService::tariffs(),
|
||||
'clinics' => $clinics,
|
||||
'discounts' => $discounts,
|
||||
'categories' => PricingService::CATEGORIES,
|
||||
'groups' => PricingService::GROUPS,
|
||||
'medications' => $db
|
||||
->query('SELECT id,name FROM ref_medications ORDER BY name COLLATE NOCASE')
|
||||
->fetchAll(PDO::FETCH_ASSOC),
|
||||
'vaccines' => $db
|
||||
->query('SELECT id,name FROM ref_vaccines ORDER BY name COLLATE NOCASE')
|
||||
->fetchAll(PDO::FETCH_ASSOC),
|
||||
'dewormers' => $db
|
||||
->query('SELECT id,name FROM ref_dewormers ORDER BY name COLLATE NOCASE')
|
||||
->fetchAll(PDO::FETCH_ASSOC),
|
||||
'settings' => AppSettings::all(),
|
||||
'saved' => isset($_GET['saved']),
|
||||
'error' => (string) ($_GET['error'] ?? ''),
|
||||
]);
|
||||
}
|
||||
public static function saveTariff(): void
|
||||
{
|
||||
self::admin();
|
||||
self::postOnly();
|
||||
$db = DB::pdo();
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$clinic = (int) ($_POST['clinic_contact_id'] ?? 0);
|
||||
$label = trim((string) ($_POST['label'] ?? ''));
|
||||
$category = (string) ($_POST['category'] ?? 'other');
|
||||
$group = (string) ($_POST['discount_group'] ?? 'act');
|
||||
$unit = trim((string) ($_POST['unit_label'] ?? 'acte')) ?: 'acte';
|
||||
$amount = PricingService::cents((string) ($_POST['amount'] ?? '0'));
|
||||
$active = isset($_POST['active']) ? 1 : 0;
|
||||
$eligible = $db->prepare(
|
||||
"SELECT 1 FROM directory_contacts dc WHERE dc.id=:id AND dc.kind='organization' AND dc.deleted_at IS NULL AND EXISTS(SELECT 1 FROM directory_contact_roles r WHERE r.contact_id=dc.id AND r.role IN ('cabinet','crematorium','fourriere'))",
|
||||
);
|
||||
$eligible->execute([':id' => $clinic]);
|
||||
if (
|
||||
!$clinic ||
|
||||
!$eligible->fetchColumn() ||
|
||||
$label === '' ||
|
||||
!isset(PricingService::CATEGORIES[$category]) ||
|
||||
!isset(PricingService::GROUPS[$group])
|
||||
) {
|
||||
header('Location: /settings/pricing?error=' . rawurlencode(t('pricing.invalid_structure')));
|
||||
exit();
|
||||
}
|
||||
$params = [
|
||||
$clinic,
|
||||
mb_substr($label, 0, 180),
|
||||
$category,
|
||||
$group,
|
||||
mb_substr($unit, 0, 50),
|
||||
(int) ($_POST['medication_id'] ?? 0) ?: null,
|
||||
(int) ($_POST['vaccine_id'] ?? 0) ?: null,
|
||||
(int) ($_POST['dewormer_id'] ?? 0) ?: null,
|
||||
$amount,
|
||||
$active,
|
||||
trim((string) ($_POST['notes'] ?? '')) ?: null,
|
||||
];
|
||||
try {
|
||||
if ($id) {
|
||||
$params[] = $id;
|
||||
$db->prepare(
|
||||
"UPDATE clinic_tariffs SET clinic_contact_id=?,label=?,category=?,discount_group=?,unit_label=?,medication_id=?,vaccine_id=?,dewormer_id=?,amount_cents=?,active=?,notes=?,updated_at=datetime('now') WHERE id=?",
|
||||
)->execute($params);
|
||||
} else {
|
||||
$db->prepare(
|
||||
'INSERT INTO clinic_tariffs(clinic_contact_id,label,category,discount_group,unit_label,medication_id,vaccine_id,dewormer_id,amount_cents,active,notes) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
|
||||
)->execute($params);
|
||||
$id = (int) $db->lastInsertId();
|
||||
}
|
||||
AuditService::log(
|
||||
'tariff_saved',
|
||||
'/settings/pricing/tariff',
|
||||
'Tarif enregistré : ' . $label,
|
||||
'clinic_tariff',
|
||||
$id,
|
||||
);
|
||||
header('Location: /settings/pricing?saved=1');
|
||||
} catch (Throwable $e) {
|
||||
header('Location: /settings/pricing?error=' . rawurlencode(SecurityService::publicError($e)));
|
||||
}
|
||||
exit();
|
||||
}
|
||||
public static function saveDiscount(): void
|
||||
{
|
||||
self::admin();
|
||||
self::postOnly();
|
||||
$clinic = (int) ($_POST['clinic_contact_id'] ?? 0);
|
||||
$submitted = (array) ($_POST['discounts'] ?? []);
|
||||
$db = DB::pdo();
|
||||
$eligible = $db->prepare(
|
||||
"SELECT 1 FROM directory_contacts dc WHERE dc.id=:id AND dc.kind='organization' AND dc.deleted_at IS NULL AND EXISTS(SELECT 1 FROM directory_contact_roles r WHERE r.contact_id=dc.id AND r.role IN ('cabinet','crematorium','fourriere'))",
|
||||
);
|
||||
$eligible->execute([':id' => $clinic]);
|
||||
if (!$clinic || !$eligible->fetchColumn()) {
|
||||
http_response_code(400);
|
||||
echo h(t('pricing.invalid_discount_structure'));
|
||||
return;
|
||||
}
|
||||
$stmt = $db->prepare(
|
||||
'INSERT INTO clinic_discount_rules(clinic_contact_id,discount_group,label,percent) VALUES(?,?,?,?) ON CONFLICT(clinic_contact_id,discount_group) DO UPDATE SET label=excluded.label,percent=excluded.percent',
|
||||
);
|
||||
$saved = [];
|
||||
try {
|
||||
$db->beginTransaction();
|
||||
foreach (PricingService::GROUPS as $group => $label) {
|
||||
if ($group === 'none' || !array_key_exists($group, $submitted)) {
|
||||
continue;
|
||||
}
|
||||
$percent = max(0, min(100, (float) str_replace(',', '.', (string) $submitted[$group])));
|
||||
$stmt->execute([$clinic, $group, $label, $percent]);
|
||||
$saved[$group] = $percent;
|
||||
}
|
||||
$db->commit();
|
||||
AuditService::log(
|
||||
'discount_saved',
|
||||
'/settings/pricing/discount',
|
||||
'Remises tarifaires modifiées',
|
||||
'clinic',
|
||||
$clinic,
|
||||
$saved,
|
||||
);
|
||||
header('Location: /settings/pricing?saved=1');
|
||||
} catch (Throwable $e) {
|
||||
if ($db->inTransaction()) {
|
||||
$db->rollBack();
|
||||
}
|
||||
header('Location: /settings/pricing?error=' . rawurlencode(SecurityService::publicError($e)));
|
||||
}
|
||||
exit();
|
||||
}
|
||||
public static function saveAdoptionFees(): void
|
||||
{
|
||||
self::admin();
|
||||
self::postOnly();
|
||||
AppSettings::save(
|
||||
[
|
||||
'adoption_fee_sterilized' => (string) max(
|
||||
0,
|
||||
min(2000, (int) ($_POST['adoption_fee_sterilized'] ?? 220)),
|
||||
),
|
||||
'adoption_fee_unsterilized' => (string) max(
|
||||
0,
|
||||
min(2000, (int) ($_POST['adoption_fee_unsterilized'] ?? 200)),
|
||||
),
|
||||
],
|
||||
Auth::id(),
|
||||
);
|
||||
AuditService::log('adoption_fees_saved', '/settings/pricing/adoption', 'Tarifs d’adoption modifiés');
|
||||
header('Location: /settings/pricing?saved=1');
|
||||
exit();
|
||||
}
|
||||
public static function labReferences(): void
|
||||
{
|
||||
self::admin();
|
||||
$rows = LabReferenceService::all();
|
||||
$groups = [];
|
||||
foreach ($rows as $row) {
|
||||
$groups[$row['analyzer_name']][$row['species_code']][] = $row;
|
||||
}
|
||||
render('settings_lab_references.php', [
|
||||
'title' => t('lab_reference.title'),
|
||||
'groups' => $groups,
|
||||
'saved' => isset($_GET['saved']),
|
||||
'error' => (string) ($_GET['error'] ?? ''),
|
||||
]);
|
||||
}
|
||||
public static function saveLabReferences(): void
|
||||
{
|
||||
self::admin();
|
||||
self::postOnly();
|
||||
$db = DB::pdo();
|
||||
$input = (array) ($_POST['ranges'] ?? []);
|
||||
$stmt = $db->prepare(
|
||||
"UPDATE lab_reference_ranges SET parameter_name=?,unit=?,reference_min=?,reference_max=?,source_note=?,active=?,updated_by=?,updated_at=datetime('now') WHERE id=?",
|
||||
);
|
||||
try {
|
||||
$db->beginTransaction();
|
||||
foreach ($input as $id => $row) {
|
||||
$id = (int) $id;
|
||||
$name = trim((string) ($row['name'] ?? ''));
|
||||
$unit = trim((string) ($row['unit'] ?? ''));
|
||||
$min = self::nullableNumber($row['min'] ?? '');
|
||||
$max = self::nullableNumber($row['max'] ?? '');
|
||||
if (!$id || $name === '' || $unit === '' || ($min !== null && $max !== null && $min > $max)) {
|
||||
throw new RuntimeException(t('lab_reference.invalid'));
|
||||
}
|
||||
$stmt->execute([
|
||||
mb_substr($name, 0, 120),
|
||||
mb_substr($unit, 0, 30),
|
||||
$min,
|
||||
$max,
|
||||
mb_substr(trim((string) ($row['source'] ?? '')), 0, 255) ?: null,
|
||||
isset($row['active']) ? 1 : 0,
|
||||
Auth::id(),
|
||||
$id,
|
||||
]);
|
||||
}
|
||||
$db->commit();
|
||||
AuditService::log(
|
||||
'lab_references_saved',
|
||||
'/settings/lab-references/save',
|
||||
'Intervalles de référence biologiques modifiés',
|
||||
);
|
||||
header('Location: /settings/lab-references?saved=1');
|
||||
} catch (RuntimeException $e) {
|
||||
if ($db->inTransaction()) {
|
||||
$db->rollBack();
|
||||
}
|
||||
header('Location: /settings/lab-references?error=' . rawurlencode($e->getMessage()));
|
||||
} catch (Throwable $e) {
|
||||
if ($db->inTransaction()) {
|
||||
$db->rollBack();
|
||||
}
|
||||
header('Location: /settings/lab-references?error=' . rawurlencode(SecurityService::publicError($e)));
|
||||
}
|
||||
exit();
|
||||
}
|
||||
public static function publicSite(): void
|
||||
{
|
||||
self::admin();
|
||||
render('settings_public_site.php', [
|
||||
'title' => t('public_site.settings_title'),
|
||||
'settings' => AppSettings::all(),
|
||||
'saved' => isset($_GET['saved']),
|
||||
'error' => (string) ($_GET['error'] ?? ''),
|
||||
]);
|
||||
}
|
||||
public static function health(): void
|
||||
{
|
||||
self::admin();
|
||||
render('settings_health.php', [
|
||||
'title' => t('health.page_title'),
|
||||
'report' => SystemHealthService::report(),
|
||||
'installed' => isset($_GET['installed']),
|
||||
]);
|
||||
}
|
||||
public static function notifications(): void
|
||||
{
|
||||
self::admin();
|
||||
render('settings_notifications.php', [
|
||||
'title' => t('notification.page_title'),
|
||||
'settings' => AppSettings::all(),
|
||||
'saved' => isset($_GET['saved']),
|
||||
'sent' => (string) ($_GET['sent'] ?? ''),
|
||||
]);
|
||||
}
|
||||
public static function saveNotifications(): void
|
||||
{
|
||||
self::admin();
|
||||
self::postOnly();
|
||||
$email = trim((string) ($_POST['notification_email_recipient'] ?? ''));
|
||||
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
header('Location: /settings/notifications?sent=invalid');
|
||||
exit();
|
||||
}
|
||||
$frequency = in_array((string) ($_POST['notification_email_frequency'] ?? ''), ['daily', 'weekly'], true)
|
||||
? (string) $_POST['notification_email_frequency']
|
||||
: 'weekly';
|
||||
AppSettings::save(
|
||||
[
|
||||
'notification_email_enabled' => isset($_POST['notification_email_enabled']) ? '1' : '0',
|
||||
'notification_email_frequency' => $frequency,
|
||||
'notification_email_recipient' => $email,
|
||||
],
|
||||
Auth::id(),
|
||||
);
|
||||
AuditService::log('notifications_saved', '/settings/notifications', 'Configuration des notifications modifiée');
|
||||
header('Location: /settings/notifications?saved=1');
|
||||
exit();
|
||||
}
|
||||
public static function sendNotifications(): void
|
||||
{
|
||||
self::admin();
|
||||
self::postOnly();
|
||||
$result = NotificationService::send(true);
|
||||
AuditService::log(
|
||||
'notification_digest',
|
||||
'/settings/notifications/send',
|
||||
'Envoi manuel du résumé : ' . $result['reason'],
|
||||
);
|
||||
header('Location: /settings/notifications?sent=' . rawurlencode((string) $result['reason']));
|
||||
exit();
|
||||
}
|
||||
public static function savePublicSite(): void
|
||||
{
|
||||
self::admin();
|
||||
self::postOnly();
|
||||
$theme = (string) ($_POST['public_site_theme'] ?? 'warm');
|
||||
if (!in_array($theme, ['warm', 'nature', 'minimal'], true)) {
|
||||
$theme = 'warm';
|
||||
}
|
||||
$routing = (string) ($_POST['public_site_routing'] ?? 'integrated');
|
||||
if (!in_array($routing, ['integrated', 'root'], true)) {
|
||||
$routing = 'integrated';
|
||||
}
|
||||
$values = [
|
||||
'public_site_enabled' => isset($_POST['public_site_enabled']) ? '1' : '0',
|
||||
'public_site_theme' => $theme,
|
||||
'public_site_routing' => $routing,
|
||||
'public_site_about_enabled' => isset($_POST['public_site_about_enabled']) ? '1' : '0',
|
||||
];
|
||||
foreach (
|
||||
[
|
||||
'public_site_home_title' => 150,
|
||||
'public_site_home_text' => 5000,
|
||||
'public_site_about_title' => 150,
|
||||
'public_site_about_text' => 10000,
|
||||
'public_site_donation_title' => 150,
|
||||
'public_site_donation_text' => 10000,
|
||||
'public_site_contact_text' => 5000,
|
||||
'public_site_legal_text' => 15000,
|
||||
'public_site_privacy_text' => 15000,
|
||||
]
|
||||
as $key => $max
|
||||
) {
|
||||
$values[$key] = mb_substr(trim((string) ($_POST[$key] ?? '')), 0, $max);
|
||||
}
|
||||
foreach (['public_site_donation_url', 'public_site_facebook_url', 'public_site_instagram_url'] as $key) {
|
||||
$url = trim((string) ($_POST[$key] ?? ''));
|
||||
$scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
|
||||
if ($url !== '' && (!filter_var($url, FILTER_VALIDATE_URL) || $scheme !== 'https')) {
|
||||
header('Location: /settings/public-site?error=' . rawurlencode(t('public_site.invalid_url')));
|
||||
exit();
|
||||
}
|
||||
$values[$key] = $url;
|
||||
}
|
||||
AppSettings::save($values, Auth::id());
|
||||
AuditService::log('public_site_saved', '/settings/public-site/save', 'Configuration du site public modifiée');
|
||||
header('Location: /settings/public-site?saved=1');
|
||||
exit();
|
||||
}
|
||||
public static function audit(): void
|
||||
{
|
||||
self::admin();
|
||||
$rows = DB::pdo()
|
||||
->query(
|
||||
'SELECT al.*,COALESCE(u.display_name,u.username) actor FROM audit_log al LEFT JOIN users u ON u.id=al.user_id ORDER BY al.id DESC LIMIT 500',
|
||||
)
|
||||
->fetchAll(PDO::FETCH_ASSOC);
|
||||
render('settings_audit.php', ['title' => t('audit.title'), 'rows' => $rows]);
|
||||
}
|
||||
public static function dataTools(): void
|
||||
{
|
||||
self::admin();
|
||||
$db = DB::pdo();
|
||||
render('settings_data.php', [
|
||||
'title' => t('data.title'),
|
||||
'counts' => [
|
||||
'animals' => (int) $db->query('SELECT COUNT(*) FROM animals')->fetchColumn(),
|
||||
'contacts' => (int) $db->query('SELECT COUNT(*) FROM directory_contacts')->fetchColumn(),
|
||||
],
|
||||
'status' => (string) ($_GET['status'] ?? ''),
|
||||
'backup' => (string) ($_GET['backup'] ?? ''),
|
||||
'error' => (string) ($_GET['error'] ?? ''),
|
||||
]);
|
||||
}
|
||||
public static function resetData(): void
|
||||
{
|
||||
self::dataOperation(false);
|
||||
}
|
||||
public static function loadDemoData(): void
|
||||
{
|
||||
self::dataOperation(true);
|
||||
}
|
||||
private static function dataOperation(bool $demo): void
|
||||
{
|
||||
self::admin();
|
||||
self::postOnly();
|
||||
$expected = t($demo ? 'data.confirm_demo_phrase' : 'data.confirm_reset_phrase');
|
||||
if (trim((string) ($_POST['confirmation'] ?? '')) !== $expected) {
|
||||
header(
|
||||
'Location: /settings/data?error=' . rawurlencode(t('data.confirm_required', ['phrase' => $expected])),
|
||||
);
|
||||
exit();
|
||||
}
|
||||
$db = DB::pdo();
|
||||
try {
|
||||
$backup = BackupService::create($demo ? 'pre-demo-data' : 'pre-data-reset');
|
||||
$db->beginTransaction();
|
||||
$removed = DemoDataService::reset($db);
|
||||
$demo ? DemoDataService::seed($db, Auth::id()) : null;
|
||||
$db->commit();
|
||||
DemoDataService::clearFiles();
|
||||
if ($demo) {
|
||||
DemoDataService::installFiles($db);
|
||||
}
|
||||
AuditService::log(
|
||||
$demo ? 'demo_data_loaded' : 'business_data_reset',
|
||||
'/settings/data',
|
||||
$demo ? 'Base fictive de démonstration chargée' : 'Données métier remises à zéro',
|
||||
null,
|
||||
null,
|
||||
[
|
||||
'removed_animals' => $removed['animals'],
|
||||
'removed_contacts' => $removed['contacts'],
|
||||
'backup' => $backup['name'],
|
||||
],
|
||||
);
|
||||
header(
|
||||
'Location: /settings/data?status=' .
|
||||
($demo ? 'demo' : 'reset') .
|
||||
'&backup=' .
|
||||
rawurlencode($backup['name']),
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
if ($db->inTransaction()) {
|
||||
$db->rollBack();
|
||||
}
|
||||
header('Location: /settings/data?error=' . rawurlencode(SecurityService::publicError($e)));
|
||||
}
|
||||
exit();
|
||||
}
|
||||
public static function backups(): void
|
||||
{
|
||||
self::admin();
|
||||
render('settings_backups.php', [
|
||||
'title' => t('backups.title'),
|
||||
'backups' => BackupService::list(),
|
||||
'status' => (string) ($_GET['status'] ?? ''),
|
||||
'error' => (string) ($_GET['error'] ?? ''),
|
||||
]);
|
||||
}
|
||||
public static function createBackup(): void
|
||||
{
|
||||
self::admin();
|
||||
self::postOnly();
|
||||
try {
|
||||
$backup = BackupService::create();
|
||||
AuditService::log('backup_created', '/settings/backups/create', 'Sauvegarde créée : ' . $backup['name']);
|
||||
header('Location: /settings/backups?status=created');
|
||||
} catch (Throwable $e) {
|
||||
header('Location: /settings/backups?error=' . rawurlencode(SecurityService::publicError($e)));
|
||||
}
|
||||
exit();
|
||||
}
|
||||
public static function saveBackupSchedule(): void
|
||||
{
|
||||
self::admin();
|
||||
self::postOnly();
|
||||
$frequency = (string) ($_POST['backup_schedule_frequency'] ?? 'daily');
|
||||
if (!in_array($frequency, ['daily', 'weekly'], true)) {
|
||||
$frequency = 'daily';
|
||||
}
|
||||
AppSettings::save(
|
||||
[
|
||||
'backup_schedule_enabled' => isset($_POST['backup_schedule_enabled']) ? '1' : '0',
|
||||
'backup_schedule_frequency' => $frequency,
|
||||
],
|
||||
Auth::id(),
|
||||
);
|
||||
AuditService::log(
|
||||
'backup_schedule_saved',
|
||||
'/settings/backups/schedule',
|
||||
'Planification des sauvegardes modifiée',
|
||||
);
|
||||
header('Location: /settings/backups?status=schedule');
|
||||
exit();
|
||||
}
|
||||
public static function verifyBackup(): void
|
||||
{
|
||||
self::admin();
|
||||
self::postOnly();
|
||||
try {
|
||||
$result = BackupService::verify((string) ($_POST['file'] ?? ''));
|
||||
AppSettings::save(['backup_last_verified_at' => date('Y-m-d H:i:s')], Auth::id());
|
||||
AuditService::log(
|
||||
'backup_verified',
|
||||
'/settings/backups/verify',
|
||||
'Sauvegarde vérifiée : ' . $result['name'],
|
||||
);
|
||||
header('Location: /settings/backups?status=verified');
|
||||
} catch (RuntimeException $e) {
|
||||
header('Location: /settings/backups?error=' . rawurlencode($e->getMessage()));
|
||||
} catch (Throwable $e) {
|
||||
header('Location: /settings/backups?error=' . rawurlencode(SecurityService::publicError($e)));
|
||||
}
|
||||
exit();
|
||||
}
|
||||
public static function downloadBackup(): void
|
||||
{
|
||||
self::admin();
|
||||
$path = BackupService::path((string) ($_GET['file'] ?? ''));
|
||||
if (!$path) {
|
||||
http_response_code(404);
|
||||
echo h(t('backups.not_found'));
|
||||
return;
|
||||
}
|
||||
PrivacyService::logAccess('backup', null, null, basename($path));
|
||||
AuditService::log('private_file_viewed', '/settings/backups/download', 'Sauvegarde privée téléchargée');
|
||||
header('Content-Type: application/zip');
|
||||
header('Content-Disposition: attachment; filename="' . basename($path) . '"');
|
||||
header('Content-Length: ' . filesize($path));
|
||||
header('Cache-Control: private,no-store');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
readfile($path);
|
||||
}
|
||||
public static function deleteBackup(): void
|
||||
{
|
||||
self::admin();
|
||||
self::postOnly();
|
||||
$name = (string) ($_POST['file'] ?? '');
|
||||
try {
|
||||
BackupService::delete($name);
|
||||
AuditService::log('backup_deleted', '/settings/backups/delete', 'Sauvegarde supprimée : ' . $name);
|
||||
header('Location: /settings/backups?status=deleted');
|
||||
} catch (RuntimeException $e) {
|
||||
header('Location: /settings/backups?error=' . rawurlencode($e->getMessage()));
|
||||
} catch (Throwable $e) {
|
||||
header('Location: /settings/backups?error=' . rawurlencode(SecurityService::publicError($e)));
|
||||
}
|
||||
exit();
|
||||
}
|
||||
public static function restoreBackup(): void
|
||||
{
|
||||
self::admin();
|
||||
self::postOnly();
|
||||
$upload = $_FILES['backup'] ?? null;
|
||||
if (
|
||||
!is_array($upload) ||
|
||||
($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK ||
|
||||
!is_uploaded_file((string) $upload['tmp_name'])
|
||||
) {
|
||||
header('Location: /settings/backups?error=' . rawurlencode(t('backups.invalid_file')));
|
||||
exit();
|
||||
}
|
||||
if ((int) ($upload['size'] ?? 0) > 1_000_000_000) {
|
||||
header('Location: /settings/backups?error=' . rawurlencode(t('backups.too_large')));
|
||||
exit();
|
||||
}
|
||||
try {
|
||||
BackupService::restore((string) $upload['tmp_name']);
|
||||
Auth::logout();
|
||||
header('Location: /login?restored=1');
|
||||
} catch (RuntimeException $e) {
|
||||
header('Location: /settings/backups?error=' . rawurlencode($e->getMessage()));
|
||||
} catch (Throwable $e) {
|
||||
header('Location: /settings/backups?error=' . rawurlencode(SecurityService::publicError($e)));
|
||||
}
|
||||
exit();
|
||||
}
|
||||
private static function postOnly(): void
|
||||
{
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||
http_response_code(405);
|
||||
header('Allow: POST');
|
||||
exit();
|
||||
}
|
||||
}
|
||||
private static function nullableNumber(mixed $value): ?float
|
||||
{
|
||||
$value = str_replace(',', '.', trim((string) $value));
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
if (!is_numeric($value)) {
|
||||
throw new RuntimeException(t('lab_reference.invalid'));
|
||||
}
|
||||
return (float) $value;
|
||||
}
|
||||
private static function admin(): void
|
||||
{
|
||||
if (!Auth::is('admin')) {
|
||||
http_response_code(403);
|
||||
echo h(t('settings.admin_only'));
|
||||
exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
42
app/Controllers/StatisticsController.php
Normal file
42
app/Controllers/StatisticsController.php
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class StatisticsController
|
||||
{
|
||||
public static function index(): void
|
||||
{
|
||||
$requested = (string) ($_GET['year'] ?? date('Y'));
|
||||
$all = $requested === 'all';
|
||||
$year = $all ? null : max(2000, min(2200, (int) $requested));
|
||||
$years = StatisticsService::years();
|
||||
$municipalities = StatisticsService::municipalities();
|
||||
$municipality = trim((string) ($_GET['municipality'] ?? ''));
|
||||
if ($municipality !== '' && !isset($municipalities[$municipality])) {
|
||||
$municipality = '';
|
||||
}
|
||||
$ids = $municipality !== '' ? StatisticsService::animalIdsForMunicipality($municipality) : null;
|
||||
render('statistics.php', [
|
||||
'title' => t('statistics.title'),
|
||||
'pageDescription' => t('statistics.description'),
|
||||
'year' => $year,
|
||||
'allYears' => $all,
|
||||
'years' => $years,
|
||||
'municipality' => $municipality,
|
||||
'municipalities' => $municipalities,
|
||||
'municipalityCoverage' => StatisticsService::municipalityCoverage(),
|
||||
'stats' => $all ? StatisticsService::all($ids) : StatisticsService::annual($year, $ids),
|
||||
'comparison' => $all ? [] : StatisticsService::comparison($year, $ids),
|
||||
'timeline' => StatisticsService::timeline($years, $ids),
|
||||
'undatedHandled' => StatisticsService::undatedHandled($ids),
|
||||
'undatedDeaths' => StatisticsService::undatedDeaths($ids),
|
||||
'undatedBirths' => StatisticsService::undatedBirths($ids),
|
||||
'undatedExits' => StatisticsService::undatedExits($ids),
|
||||
'scopeCounts' => StatisticsService::scopeCounts($ids),
|
||||
'distributions' => StatisticsService::distributions($year, $ids, $municipality ?: null),
|
||||
'health' => StatisticsService::currentHealth($ids),
|
||||
'costs' => StatisticsService::costs($year, $ids),
|
||||
'municipalityCosts' => StatisticsService::costsByMunicipality($year),
|
||||
]);
|
||||
}
|
||||
}
|
||||
116
app/Controllers/SurgeriesController.php
Normal file
116
app/Controllers/SurgeriesController.php
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
final class SurgeriesController
|
||||
{
|
||||
public static function form(): void
|
||||
{
|
||||
$id = (int) ($_GET['animal_id'] ?? 0);
|
||||
$db = DB::pdo();
|
||||
$s = $db->prepare('SELECT id,name,species FROM animals WHERE id=? AND deleted_at IS NULL');
|
||||
$s->execute([$id]);
|
||||
$animal = $s->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$animal) {
|
||||
http_response_code(404);
|
||||
return;
|
||||
}
|
||||
$clinics = $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','structure_veterinaire') ORDER BY dc.name COLLATE NOCASE",
|
||||
)
|
||||
->fetchAll(PDO::FETCH_ASSOC);
|
||||
$vets = $db
|
||||
->query(
|
||||
"SELECT dc.id,dc.name,dc.organization_id FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id AND r.role='veterinaire' WHERE dc.deleted_at IS NULL ORDER BY dc.name COLLATE NOCASE",
|
||||
)
|
||||
->fetchAll(PDO::FETCH_ASSOC);
|
||||
$tariffs = $db
|
||||
->query(
|
||||
"SELECT ct.id,ct.clinic_contact_id,ct.label,ct.amount_cents,COALESCE(dr.percent,0) discount FROM clinic_tariffs ct LEFT JOIN clinic_discount_rules dr ON dr.clinic_contact_id=ct.clinic_contact_id AND dr.discount_group=ct.discount_group WHERE ct.active=1 AND ct.category IN ('surgery','dental','imaging') ORDER BY ct.label COLLATE NOCASE",
|
||||
)
|
||||
->fetchAll(PDO::FETCH_ASSOC);
|
||||
render('surgery_form.php', [
|
||||
'title' => 'Chirurgie de ' . $animal['name'],
|
||||
'animal' => $animal,
|
||||
'clinics' => $clinics,
|
||||
'vets' => $vets,
|
||||
'tariffs' => $tariffs,
|
||||
]);
|
||||
}
|
||||
public static function save(): void
|
||||
{
|
||||
$animal = (int) ($_POST['animal_id'] ?? 0);
|
||||
$name = trim((string) ($_POST['procedure_name'] ?? ''));
|
||||
$date = (string) ($_POST['surgery_date'] ?? '');
|
||||
$category = (string) ($_POST['category'] ?? 'other');
|
||||
$status = (string) ($_POST['status'] ?? 'completed');
|
||||
if (
|
||||
!$animal ||
|
||||
$name === '' ||
|
||||
!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) ||
|
||||
!in_array($status, ['planned', 'completed', 'cancelled'], true)
|
||||
) {
|
||||
http_response_code(400);
|
||||
echo h(t('surgery.invalid_data'));
|
||||
return;
|
||||
}
|
||||
$db = DB::pdo();
|
||||
$tariff = (int) ($_POST['tariff_id'] ?? 0) ?: null;
|
||||
$amountRaw = str_replace(',', '.', trim((string) ($_POST['amount'] ?? '')));
|
||||
$amount = $amountRaw === '' ? null : (int) round(max(0, (float) $amountRaw) * 100);
|
||||
if ($amount === null && $tariff) {
|
||||
$q = $db->prepare(
|
||||
'SELECT amount_cents*(1-COALESCE((SELECT percent FROM clinic_discount_rules WHERE clinic_contact_id=ct.clinic_contact_id AND discount_group=ct.discount_group),0)/100.0) FROM clinic_tariffs ct WHERE id=?',
|
||||
);
|
||||
$q->execute([$tariff]);
|
||||
$v = $q->fetchColumn();
|
||||
$amount = $v === false ? null : (int) round((float) $v);
|
||||
}
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
$s = $db->prepare(
|
||||
'INSERT INTO animal_surgeries(animal_id,category,procedure_name,surgery_date,status,clinic_contact_id,veterinarian_contact_id,tariff_id,amount_cents,anesthesia,notes,follow_up,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
);
|
||||
$s->execute([
|
||||
$animal,
|
||||
$category,
|
||||
$name,
|
||||
$date,
|
||||
$status,
|
||||
(int) ($_POST['clinic_contact_id'] ?? 0) ?: null,
|
||||
(int) ($_POST['veterinarian_contact_id'] ?? 0) ?: null,
|
||||
$tariff,
|
||||
$amount,
|
||||
trim((string) ($_POST['anesthesia'] ?? '')) ?: null,
|
||||
trim((string) ($_POST['notes'] ?? '')) ?: null,
|
||||
trim((string) ($_POST['follow_up'] ?? '')) ?: null,
|
||||
Auth::id(),
|
||||
]);
|
||||
$id = (int) $db->lastInsertId();
|
||||
if ($status === 'completed' && $amount !== null) {
|
||||
$db->prepare(
|
||||
'INSERT INTO animal_expenses(animal_id,clinic_contact_id,tariff_id,source_type,source_id,label,occurred_on,quantity,catalog_unit_cents,discount_percent,total_cents,notes,created_by) VALUES(?,?,?,\'surgery\',?,?,?,1,?,0,?,?,?)',
|
||||
)->execute([
|
||||
$animal,
|
||||
(int) ($_POST['clinic_contact_id'] ?? 0) ?: null,
|
||||
$tariff,
|
||||
$id,
|
||||
$name,
|
||||
$date,
|
||||
$amount,
|
||||
$amount,
|
||||
trim((string) ($_POST['notes'] ?? '')) ?: null,
|
||||
Auth::id(),
|
||||
]);
|
||||
}
|
||||
$db->prepare(
|
||||
"INSERT INTO animal_history(animal_id,type,label,details,user_id) VALUES(?,'medical','Chirurgie enregistrée',?,?)",
|
||||
)->execute([$animal, $name . ' — ' . $date, Auth::id()]);
|
||||
$db->commit();
|
||||
header('Location: /animal?id=' . $animal . '#tab-med');
|
||||
} catch (Throwable $e) {
|
||||
$db->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
441
app/Routes/web.php
Normal file
441
app/Routes/web.php
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
switch ($path) {
|
||||
case '/search':
|
||||
SearchController::index();
|
||||
break;
|
||||
case '/search.json':
|
||||
SearchController::json();
|
||||
break;
|
||||
case '/accounting':
|
||||
AccountingController::dashboard();
|
||||
break;
|
||||
case '/accounting/export.csv':
|
||||
AccountingController::exportFilteredCsv();
|
||||
break;
|
||||
case '/accounting/edit':
|
||||
AccountingController::edit();
|
||||
break;
|
||||
case '/accounting/edit-by-reference':
|
||||
AccountingController::editByReference();
|
||||
break;
|
||||
case '/accounting/save':
|
||||
AccountingController::save();
|
||||
break;
|
||||
case '/accounting/update':
|
||||
AccountingController::update();
|
||||
break;
|
||||
case '/accounting/delete':
|
||||
AccountingController::delete();
|
||||
break;
|
||||
case '/accounting/document':
|
||||
AccountingController::document();
|
||||
break;
|
||||
case '/agenda':
|
||||
AgendaController::index();
|
||||
break;
|
||||
case '/agenda/save':
|
||||
AgendaController::save();
|
||||
break;
|
||||
case '/agenda/status':
|
||||
AgendaController::status();
|
||||
break;
|
||||
case '/agenda/export.ics':
|
||||
AgendaController::export();
|
||||
break;
|
||||
case '/':
|
||||
DashboardController::index();
|
||||
break;
|
||||
case '/animals':
|
||||
AnimalsController::index();
|
||||
break;
|
||||
case '/animals/archived':
|
||||
AnimalsController::inactiveList('archived');
|
||||
break;
|
||||
case '/animals/trash':
|
||||
AnimalsController::inactiveList('trash');
|
||||
break;
|
||||
case '/animal':
|
||||
AnimalsController::show();
|
||||
break;
|
||||
|
||||
case '/animal/add-medical':
|
||||
AnimalsController::addMedical();
|
||||
break;
|
||||
case '/animal/prescription/save':
|
||||
MedicalDocumentsController::savePrescription();
|
||||
break;
|
||||
case '/animal/lab-report/save':
|
||||
MedicalDocumentsController::saveLabReport();
|
||||
break;
|
||||
case '/animal/medical-document':
|
||||
MedicalDocumentsController::download();
|
||||
break;
|
||||
case '/private-media':
|
||||
PrivateMediaService::send();
|
||||
break;
|
||||
|
||||
case '/animal/medical-status':
|
||||
AnimalsController::updateMedicalStatus();
|
||||
break;
|
||||
|
||||
case '/animal/add-treatment':
|
||||
AnimalsController::addTreatment();
|
||||
break;
|
||||
|
||||
case '/animal/add-deworming':
|
||||
DewormingController::save();
|
||||
break;
|
||||
|
||||
case '/animal/add-vaccine':
|
||||
AnimalsController::addVaccine();
|
||||
break;
|
||||
case '/animal/add-expense':
|
||||
AnimalsController::addExpense();
|
||||
break;
|
||||
case '/animal/surgery/new':
|
||||
SurgeriesController::form();
|
||||
break;
|
||||
case '/animal/surgery/save':
|
||||
SurgeriesController::save();
|
||||
break;
|
||||
|
||||
case '/dev/backfill-weights':
|
||||
DevController::backfillWeights();
|
||||
break;
|
||||
|
||||
case '/animal/upload-photos':
|
||||
AnimalsController::uploadPhotos();
|
||||
break;
|
||||
|
||||
case '/animal/set-primary-photo':
|
||||
AnimalsController::setPrimaryPhoto();
|
||||
break;
|
||||
|
||||
case '/dev/backup':
|
||||
DevController::backup();
|
||||
break;
|
||||
|
||||
case '/dev/backfill-movements':
|
||||
DevController::backfillMovements();
|
||||
break;
|
||||
|
||||
case '/animal/pdf':
|
||||
AnimalsController::pdf();
|
||||
break;
|
||||
|
||||
case '/dashboard':
|
||||
DashboardController::index();
|
||||
break;
|
||||
case '/statistics':
|
||||
StatisticsController::index();
|
||||
break;
|
||||
case '/evolutions':
|
||||
ProjectController::evolutions();
|
||||
break;
|
||||
case '/directory':
|
||||
DirectoryController::index();
|
||||
break;
|
||||
case '/directory/view':
|
||||
DirectoryController::show();
|
||||
break;
|
||||
case '/directory/new':
|
||||
case '/directory/edit':
|
||||
DirectoryController::form();
|
||||
break;
|
||||
case '/directory/save':
|
||||
DirectoryController::save();
|
||||
break;
|
||||
case '/care-round':
|
||||
CareRoundsController::index();
|
||||
break;
|
||||
case '/care-round/save':
|
||||
CareRoundsController::saveObservation();
|
||||
break;
|
||||
case '/care-round/save-room':
|
||||
CareRoundsController::saveRoom();
|
||||
break;
|
||||
case '/care-round/setup':
|
||||
CareRoundsController::setup();
|
||||
break;
|
||||
case '/care-round/setup/save':
|
||||
CareRoundsController::saveSetup();
|
||||
break;
|
||||
case '/care-round/treatment-schedule':
|
||||
CareRoundsController::saveTreatmentSchedule();
|
||||
break;
|
||||
|
||||
case '/animal/new':
|
||||
AnimalsController::form();
|
||||
break;
|
||||
case '/animal/edit':
|
||||
AnimalsController::form();
|
||||
break;
|
||||
case '/animal/save':
|
||||
AnimalsController::save();
|
||||
break;
|
||||
case '/animal/death':
|
||||
DeathController::form();
|
||||
break;
|
||||
case '/animal/death/save':
|
||||
DeathController::save();
|
||||
break;
|
||||
|
||||
case '/litter/new':
|
||||
AnimalsController::litterForm();
|
||||
break;
|
||||
case '/litter/save':
|
||||
AnimalsController::saveLitter();
|
||||
break;
|
||||
case '/litter/add-existing':
|
||||
AnimalsController::litterAddExistingForm();
|
||||
break;
|
||||
case '/litter/add-existing/save':
|
||||
AnimalsController::saveExistingLitterKittens();
|
||||
break;
|
||||
case '/bonded/new':
|
||||
AnimalsController::bondedForm();
|
||||
break;
|
||||
case '/bonded/save':
|
||||
AnimalsController::saveBondedGroup();
|
||||
break;
|
||||
case '/bonded/dissolve':
|
||||
AnimalsController::dissolveBondedGroup();
|
||||
break;
|
||||
|
||||
case '/animal/history':
|
||||
AnimalsController::history();
|
||||
break;
|
||||
|
||||
case '/animal/delete':
|
||||
AnimalsController::delete();
|
||||
break;
|
||||
case '/animal/archive':
|
||||
AnimalsController::archive();
|
||||
break;
|
||||
case '/animal/restore':
|
||||
AnimalsController::restore();
|
||||
break;
|
||||
|
||||
case '/animal/photos/upload':
|
||||
AnimalsController::uploadPhotos();
|
||||
break;
|
||||
|
||||
case '/animal/photos/primary':
|
||||
AnimalsController::setPrimaryPhoto();
|
||||
break;
|
||||
|
||||
case '/animal/photos/delete':
|
||||
AnimalsController::deletePhoto();
|
||||
break;
|
||||
|
||||
case '/animal/photos/toggle':
|
||||
AnimalsController::togglePhotoVisibility();
|
||||
break;
|
||||
|
||||
case '/animal/icad':
|
||||
AnimalsController::icad();
|
||||
break;
|
||||
|
||||
case '/animal/geocode':
|
||||
AnimalsController::geocode();
|
||||
break;
|
||||
|
||||
case '/animal/adopt':
|
||||
AdoptionsController::store();
|
||||
break;
|
||||
case '/animal/placement/save':
|
||||
PlacementsController::save();
|
||||
break;
|
||||
|
||||
//===== ADMIN
|
||||
case '/admin/register':
|
||||
AdminController::register();
|
||||
break;
|
||||
|
||||
case '/admin/register/pdf':
|
||||
AdminController::registerPdf();
|
||||
break;
|
||||
case '/admin/exports':
|
||||
AdminController::exports();
|
||||
break;
|
||||
case '/admin/exports/download':
|
||||
AdminController::downloadExport();
|
||||
break;
|
||||
case '/admin/grants':
|
||||
GrantsController::index();
|
||||
break;
|
||||
case '/admin/grants/save':
|
||||
GrantsController::saveAnnual();
|
||||
break;
|
||||
case '/admin/grants/application':
|
||||
GrantsController::saveApplication();
|
||||
break;
|
||||
case '/admin/grants/document':
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
|
||||
GrantsController::upload();
|
||||
} else {
|
||||
GrantsController::download();
|
||||
}
|
||||
break;
|
||||
case '/admin/grants/print':
|
||||
GrantsController::print();
|
||||
break;
|
||||
case '/documents/adoption/blank':
|
||||
DocumentsController::blankAdoption();
|
||||
break;
|
||||
case '/documents/blank':
|
||||
DocumentsController::blank();
|
||||
break;
|
||||
case '/animal/documents/adoption':
|
||||
DocumentsController::adoption();
|
||||
break;
|
||||
case '/animal/documents/abandon':
|
||||
DocumentsController::abandon();
|
||||
break;
|
||||
case '/directory/documents':
|
||||
DocumentsController::contact();
|
||||
break;
|
||||
case '/settings':
|
||||
case '/settings/general':
|
||||
SettingsController::general();
|
||||
break;
|
||||
case '/settings/association-logo':
|
||||
SettingsController::associationLogo();
|
||||
break;
|
||||
case '/settings/users':
|
||||
SettingsController::users();
|
||||
break;
|
||||
case '/settings/permissions':
|
||||
SettingsController::permissions();
|
||||
break;
|
||||
case '/settings/species':
|
||||
SettingsController::species();
|
||||
break;
|
||||
case '/settings/species/save':
|
||||
SettingsController::saveSpecies();
|
||||
break;
|
||||
case '/settings/rooms':
|
||||
SettingsController::rooms();
|
||||
break;
|
||||
case '/settings/rooms/save':
|
||||
SettingsController::saveRooms();
|
||||
break;
|
||||
case '/settings/pricing':
|
||||
SettingsController::pricing();
|
||||
break;
|
||||
case '/settings/pricing/tariff':
|
||||
SettingsController::saveTariff();
|
||||
break;
|
||||
case '/settings/pricing/discount':
|
||||
SettingsController::saveDiscount();
|
||||
break;
|
||||
case '/settings/pricing/adoption':
|
||||
SettingsController::saveAdoptionFees();
|
||||
break;
|
||||
case '/settings/lab-references':
|
||||
SettingsController::labReferences();
|
||||
break;
|
||||
case '/settings/lab-references/save':
|
||||
SettingsController::saveLabReferences();
|
||||
break;
|
||||
case '/settings/public-site':
|
||||
SettingsController::publicSite();
|
||||
break;
|
||||
case '/settings/public-site/save':
|
||||
SettingsController::savePublicSite();
|
||||
break;
|
||||
case '/settings/privacy':
|
||||
PrivacyController::index();
|
||||
break;
|
||||
case '/settings/privacy/save':
|
||||
PrivacyController::save();
|
||||
break;
|
||||
case '/settings/privacy/export':
|
||||
PrivacyController::export();
|
||||
break;
|
||||
case '/settings/privacy/anonymize':
|
||||
PrivacyController::anonymize();
|
||||
break;
|
||||
case '/settings/inventory':
|
||||
InventoryController::index();
|
||||
break;
|
||||
case '/settings/inventory/product':
|
||||
InventoryController::product();
|
||||
break;
|
||||
case '/settings/inventory/receive':
|
||||
InventoryController::receive();
|
||||
break;
|
||||
case '/settings/inventory/move':
|
||||
InventoryController::move();
|
||||
break;
|
||||
case '/settings/health':
|
||||
SettingsController::health();
|
||||
break;
|
||||
case '/settings/notifications':
|
||||
SettingsController::notifications();
|
||||
break;
|
||||
case '/settings/notifications/save':
|
||||
SettingsController::saveNotifications();
|
||||
break;
|
||||
case '/settings/notifications/send':
|
||||
SettingsController::sendNotifications();
|
||||
break;
|
||||
case '/settings/general/save':
|
||||
SettingsController::saveGeneral();
|
||||
break;
|
||||
case '/settings/users/save':
|
||||
SettingsController::saveUser();
|
||||
break;
|
||||
case '/settings/permissions/save':
|
||||
SettingsController::savePermissions();
|
||||
break;
|
||||
case '/settings/audit':
|
||||
SettingsController::audit();
|
||||
break;
|
||||
case '/settings/data':
|
||||
SettingsController::dataTools();
|
||||
break;
|
||||
case '/settings/data/reset':
|
||||
SettingsController::resetData();
|
||||
break;
|
||||
case '/settings/data/demo':
|
||||
SettingsController::loadDemoData();
|
||||
break;
|
||||
case '/settings/backups':
|
||||
SettingsController::backups();
|
||||
break;
|
||||
case '/settings/backups/create':
|
||||
SettingsController::createBackup();
|
||||
break;
|
||||
case '/settings/backups/schedule':
|
||||
SettingsController::saveBackupSchedule();
|
||||
break;
|
||||
case '/settings/backups/verify':
|
||||
SettingsController::verifyBackup();
|
||||
break;
|
||||
case '/settings/backups/download':
|
||||
SettingsController::downloadBackup();
|
||||
break;
|
||||
case '/settings/backups/delete':
|
||||
SettingsController::deleteBackup();
|
||||
break;
|
||||
case '/settings/backups/restore':
|
||||
SettingsController::restoreBackup();
|
||||
break;
|
||||
case '/settings/asm3':
|
||||
Asm3ImportController::index();
|
||||
break;
|
||||
case '/settings/asm3/upload':
|
||||
Asm3ImportController::upload();
|
||||
break;
|
||||
case '/settings/asm3/apply':
|
||||
Asm3ImportController::apply();
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(404);
|
||||
echo '404';
|
||||
}
|
||||
192
app/Services/AdoptionService.php
Normal file
192
app/Services/AdoptionService.php
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
<?php
|
||||
|
||||
require_once __DIR__ . '/DB.php';
|
||||
require_once __DIR__ . '/GeocodeService.php';
|
||||
|
||||
class AdoptionService
|
||||
{
|
||||
public static function adopt(array $data): void
|
||||
{
|
||||
$db = DB::pdo();
|
||||
$db->beginTransaction();
|
||||
|
||||
try {
|
||||
$animalId = (int) $data['animal_id'];
|
||||
$date = $data['adoption_date'] ?? date('Y-m-d');
|
||||
$beforeStmt = $db->prepare(
|
||||
'SELECT status,refuge_room,care_box_key,current_address FROM animals WHERE id=:id',
|
||||
);
|
||||
$beforeStmt->execute([':id' => $animalId]);
|
||||
$beforeLocation = $beforeStmt->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
|
||||
// 1. Enregistrement de l’adoption
|
||||
$stmt = $db->prepare('
|
||||
INSERT INTO adoptions (
|
||||
animal_id,
|
||||
adopter_name,
|
||||
adopter_phone,
|
||||
adopter_email,
|
||||
adopter_address,
|
||||
adopter_postal_code,
|
||||
adopter_city,
|
||||
adoption_date,
|
||||
notes,
|
||||
adopter_contact_id,
|
||||
created_at
|
||||
) VALUES (
|
||||
:animal_id,
|
||||
:name,
|
||||
:phone,
|
||||
:email,
|
||||
:address,
|
||||
:postal,
|
||||
:city,
|
||||
:date,
|
||||
:notes,
|
||||
:contact_id,
|
||||
CURRENT_TIMESTAMP
|
||||
)
|
||||
');
|
||||
|
||||
$stmt->execute([
|
||||
':animal_id' => $animalId,
|
||||
':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,
|
||||
':date' => $date,
|
||||
':notes' => $data['notes'] ?: null,
|
||||
':contact_id' => $data['adopter_contact_id'] ?? null,
|
||||
]);
|
||||
$adoptionId = (int) $db->lastInsertId();
|
||||
$db->prepare(
|
||||
"INSERT INTO animal_placements(animal_id,event_type,event_date,contact_id,reason,notes,adoption_id,created_by) VALUES(:animal,'adoption',:date,:contact,'Adoption définitive',:notes,:adoption,:user)",
|
||||
)->execute([
|
||||
':animal' => $animalId,
|
||||
':date' => $date,
|
||||
':contact' => $data['adopter_contact_id'] ?? null,
|
||||
':notes' => $data['notes'] ?: null,
|
||||
':adoption' => $adoptionId,
|
||||
':user' => Auth::id(),
|
||||
]);
|
||||
|
||||
// 2. Mise à jour du statut de l’animal
|
||||
$currentAddress = implode(
|
||||
' ',
|
||||
array_filter([
|
||||
$data['adopter_address'] ?? '',
|
||||
$data['adopter_postal_code'] ?? '',
|
||||
$data['adopter_city'] ?? '',
|
||||
]),
|
||||
);
|
||||
|
||||
$lat = null;
|
||||
$lng = null;
|
||||
|
||||
if (!empty($currentAddress)) {
|
||||
$coords = GeocodeService::geocode($currentAddress);
|
||||
|
||||
if ($coords) {
|
||||
$lat = $coords['lat'] ?? null;
|
||||
$lng = $coords['lng'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
$db->prepare(
|
||||
"
|
||||
UPDATE animals
|
||||
SET
|
||||
status = 'adopte',
|
||||
refuge_room = NULL,
|
||||
care_box_key = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP,
|
||||
current_address = :currentAddress,
|
||||
current_lat = :lat,
|
||||
current_lng = :lng
|
||||
WHERE id = :id
|
||||
",
|
||||
)->execute([':id' => $animalId, ':currentAddress' => $currentAddress, ':lat' => $lat, ':lng' => $lng]);
|
||||
|
||||
LocationHistoryService::record(
|
||||
$db,
|
||||
$animalId,
|
||||
$beforeLocation,
|
||||
[
|
||||
'status' => 'adopte',
|
||||
'refuge_room' => null,
|
||||
'care_box_key' => null,
|
||||
'current_address' => $currentAddress,
|
||||
],
|
||||
'adoption',
|
||||
'Adoption finalisée',
|
||||
$date . ' 12:00:00',
|
||||
);
|
||||
|
||||
// 3. Ajout dans les mouvements administratifs (sortie)
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO animal_movements (
|
||||
animal_id,
|
||||
kind,
|
||||
place,
|
||||
lieu,
|
||||
contact_name,
|
||||
contact_phone,
|
||||
contact_email,
|
||||
note,
|
||||
created_at
|
||||
) VALUES (
|
||||
:animal_id,
|
||||
'exit',
|
||||
:place,
|
||||
'Adoption',
|
||||
:contact_name,
|
||||
:contact_phone,
|
||||
:contact_email,
|
||||
:note,
|
||||
CURRENT_TIMESTAMP
|
||||
)
|
||||
");
|
||||
|
||||
$stmt->execute([
|
||||
':animal_id' => $animalId,
|
||||
':place' => $data['adopter_address'] ?: null,
|
||||
':contact_name' => $data['adopter_name'] ?: null,
|
||||
':contact_phone' => $data['adopter_phone'] ?: null,
|
||||
':contact_email' => $data['adopter_email'] ?: null,
|
||||
':note' => 'Adoption finalisée',
|
||||
]);
|
||||
|
||||
// 4. Ajout dans l’historique
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO animal_history (
|
||||
animal_id,
|
||||
type,
|
||||
label,
|
||||
details,
|
||||
user_id,
|
||||
created_at
|
||||
) VALUES (
|
||||
:animal_id,
|
||||
'adoption',
|
||||
'Adoption finalisée',
|
||||
:details,
|
||||
:user_id,
|
||||
CURRENT_TIMESTAMP
|
||||
)
|
||||
");
|
||||
|
||||
$stmt->execute([
|
||||
':animal_id' => $animalId,
|
||||
':details' => 'Par ' . $data['adopter_name'] . ' - Note(s) : ' . $data['notes'],
|
||||
':user_id' => Auth::id(),
|
||||
]);
|
||||
|
||||
$db->commit();
|
||||
} catch (Exception $e) {
|
||||
$db->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
351
app/Services/AgendaService.php
Normal file
351
app/Services/AgendaService.php
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
final class AgendaService
|
||||
{
|
||||
public const TYPES = ['appointment', 'veterinary', 'transport', 'pre_adoption', 'task', 'other'];
|
||||
public static function save(array $data, ?int $id = null): int
|
||||
{
|
||||
$type = (string) ($data['item_type'] ?? 'task');
|
||||
$title = trim((string) ($data['title'] ?? ''));
|
||||
$date = trim((string) ($data['date'] ?? ''));
|
||||
$time = trim((string) ($data['time'] ?? ''));
|
||||
$allDay = isset($data['all_day']);
|
||||
if (!in_array($type, self::TYPES, true) || $title === '' || !self::date($date)) {
|
||||
throw new RuntimeException(t('agenda.invalid'));
|
||||
}
|
||||
if (!$allDay && !preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $time)) {
|
||||
throw new RuntimeException(t('agenda.invalid_time'));
|
||||
}
|
||||
$start = $date . ' ' . ($allDay ? '00:00:00' : $time . ':00');
|
||||
$endDate = trim((string) ($data['end_date'] ?? ''));
|
||||
$endTime = trim((string) ($data['end_time'] ?? ''));
|
||||
$end = null;
|
||||
if ($endDate !== '' || $endTime !== '') {
|
||||
if (!self::date($endDate) || !preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $endTime)) {
|
||||
throw new RuntimeException(t('agenda.invalid_end'));
|
||||
}
|
||||
$end = $endDate . ' ' . $endTime . ':00';
|
||||
if ($end < $start) {
|
||||
throw new RuntimeException(t('agenda.end_before_start'));
|
||||
}
|
||||
}
|
||||
$repeat = (string) ($data['repeat_rule'] ?? 'none');
|
||||
if (!in_array($repeat, ['none', 'daily', 'weekly', 'monthly'], true)) {
|
||||
$repeat = 'none';
|
||||
}
|
||||
$until = trim((string) ($data['repeat_until'] ?? ''));
|
||||
if ($until !== '' && !self::date($until)) {
|
||||
throw new RuntimeException(t('agenda.invalid_repeat'));
|
||||
}
|
||||
$values = [
|
||||
':type' => $type,
|
||||
':title' => mb_substr($title, 0, 180),
|
||||
':description' => trim((string) ($data['description'] ?? '')) ?: null,
|
||||
':start' => $start,
|
||||
':end' => $end,
|
||||
':all_day' => $allDay ? 1 : 0,
|
||||
':location' => trim((string) ($data['location'] ?? '')) ?: null,
|
||||
':priority' => in_array(
|
||||
$p = (string) ($data['priority'] ?? 'normal'),
|
||||
['low', 'normal', 'high', 'urgent'],
|
||||
true,
|
||||
)
|
||||
? $p
|
||||
: 'normal',
|
||||
':assigned' => (int) ($data['assigned_user_id'] ?? 0) ?: null,
|
||||
':animal' => (int) ($data['animal_id'] ?? 0) ?: null,
|
||||
':contact' => (int) ($data['contact_id'] ?? 0) ?: null,
|
||||
':reminder' => ($data['reminder_minutes'] ?? '') === '' ? null : max(0, (int) $data['reminder_minutes']),
|
||||
':repeat' => $repeat,
|
||||
':until' => $until ?: null,
|
||||
':user' => Auth::id(),
|
||||
];
|
||||
$db = DB::pdo();
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
if ($id) {
|
||||
$values[':id'] = $id;
|
||||
$db->prepare(
|
||||
"UPDATE agenda_items SET item_type=:type,title=:title,description=:description,starts_at=:start,ends_at=:end,all_day=:all_day,location=:location,priority=:priority,assigned_user_id=:assigned,animal_id=:animal,contact_id=:contact,reminder_minutes=:reminder,repeat_rule=:repeat,repeat_until=:until,updated_at=datetime('now') WHERE id=:id",
|
||||
)->execute($values);
|
||||
} else {
|
||||
$db->prepare(
|
||||
'INSERT INTO agenda_items(item_type,title,description,starts_at,ends_at,all_day,location,priority,assigned_user_id,animal_id,contact_id,reminder_minutes,repeat_rule,repeat_until,created_by) VALUES(:type,:title,:description,:start,:end,:all_day,:location,:priority,:assigned,:animal,:contact,:reminder,:repeat,:until,:user)',
|
||||
)->execute($values);
|
||||
$id = (int) $db->lastInsertId();
|
||||
}
|
||||
self::saveVolunteers($id, $data['volunteer_contact_ids'] ?? []);
|
||||
$db->commit();
|
||||
return $id;
|
||||
} catch (Throwable $e) {
|
||||
if ($db->inTransaction()) {
|
||||
$db->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
public static function find(int $id): ?array
|
||||
{
|
||||
$s = DB::pdo()->prepare(
|
||||
"SELECT ai.*,(SELECT group_concat(contact_id,',') FROM agenda_volunteers WHERE agenda_item_id=ai.id) volunteer_ids FROM agenda_items ai WHERE ai.id=?",
|
||||
);
|
||||
$s->execute([$id]);
|
||||
return $s->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||
}
|
||||
public static function setStatus(int $id, string $status): void
|
||||
{
|
||||
if (!in_array($status, ['planned', 'completed', 'cancelled'], true)) {
|
||||
throw new RuntimeException(t('agenda.invalid'));
|
||||
}
|
||||
DB::pdo()
|
||||
->prepare(
|
||||
"UPDATE agenda_items SET status=?,completed_at=CASE WHEN ?='completed' THEN datetime('now') ELSE NULL END,completed_by=CASE WHEN ?='completed' THEN ? ELSE NULL END,updated_at=datetime('now') WHERE id=?",
|
||||
)
|
||||
->execute([$status, $status, $status, Auth::id(), $id]);
|
||||
}
|
||||
public static function conflicts(int $id): array
|
||||
{
|
||||
$db = DB::pdo();
|
||||
$s = $db->prepare(
|
||||
"SELECT DISTINCT other.id,other.title,other.starts_at FROM agenda_items current JOIN agenda_items other ON other.id<>current.id AND other.status='planned' AND datetime(other.starts_at)<datetime(COALESCE(current.ends_at,CASE WHEN current.all_day=1 THEN datetime(current.starts_at,'+1 day') ELSE datetime(current.starts_at,'+1 hour') END)) AND datetime(COALESCE(other.ends_at,CASE WHEN other.all_day=1 THEN datetime(other.starts_at,'+1 day') ELSE datetime(other.starts_at,'+1 hour') END))>datetime(current.starts_at) WHERE current.id=:id AND current.status='planned' AND ((current.assigned_user_id IS NOT NULL AND other.assigned_user_id=current.assigned_user_id) OR EXISTS(SELECT 1 FROM agenda_volunteers mine JOIN agenda_volunteers theirs ON theirs.contact_id=mine.contact_id AND theirs.agenda_item_id=other.id WHERE mine.agenda_item_id=current.id)) ORDER BY datetime(other.starts_at)",
|
||||
);
|
||||
$s->execute([':id' => $id]);
|
||||
return $s->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
public static function month(DateTimeImmutable $month): array
|
||||
{
|
||||
return self::between(
|
||||
$month->modify('first day of this month')->setTime(0, 0),
|
||||
$month->modify('last day of this month')->setTime(23, 59, 59),
|
||||
);
|
||||
}
|
||||
public static function week(DateTimeImmutable $date): array
|
||||
{
|
||||
$start = $date->modify('monday this week')->setTime(0, 0);
|
||||
return self::between($start, $start->modify('+6 days')->setTime(23, 59, 59));
|
||||
}
|
||||
public static function range(DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||
{
|
||||
return self::between($from, $to);
|
||||
}
|
||||
public static function upcoming(int $days = 30): array
|
||||
{
|
||||
return self::between(new DateTimeImmutable('today'), new DateTimeImmutable('+' . $days . ' days 23:59:59'));
|
||||
}
|
||||
public static function due(): array
|
||||
{
|
||||
$db = DB::pdo();
|
||||
$overdue = $db
|
||||
->query(
|
||||
"SELECT ai.*,ai.starts_at instance_starts_at,a.name animal_name,COALESCE(u.display_name,u.username) assigned_name FROM agenda_items ai LEFT JOIN animals a ON a.id=ai.animal_id LEFT JOIN users u ON u.id=ai.assigned_user_id WHERE ai.status='planned' AND ai.repeat_rule='none' AND datetime(ai.starts_at)<datetime('now') ORDER BY datetime(ai.starts_at) LIMIT 25",
|
||||
)
|
||||
->fetchAll(PDO::FETCH_ASSOC);
|
||||
$soon = array_values(
|
||||
array_filter(
|
||||
self::upcoming(2),
|
||||
static fn($row) => $row['status'] === 'planned' &&
|
||||
strtotime($row['instance_starts_at']) <= strtotime('+1 day'),
|
||||
),
|
||||
);
|
||||
$seen = [];
|
||||
$out = [];
|
||||
foreach ([...$overdue, ...$soon] as $row) {
|
||||
$key = (int) $row['id'] . '|' . ($row['instance_starts_at'] ?? $row['starts_at']);
|
||||
if (isset($seen[$key])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$key] = true;
|
||||
$out[] = $row;
|
||||
}
|
||||
usort(
|
||||
$out,
|
||||
static fn($a, $b) => strcmp(
|
||||
(string) ($a['instance_starts_at'] ?? $a['starts_at']),
|
||||
(string) ($b['instance_starts_at'] ?? $b['starts_at']),
|
||||
),
|
||||
);
|
||||
return array_slice($out, 0, 25);
|
||||
}
|
||||
private static function between(DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||
{
|
||||
$db = DB::pdo();
|
||||
$s = $db->prepare(
|
||||
"SELECT ai.*,a.name animal_name,dc.name contact_name,COALESCE(u.display_name,u.username) assigned_name,(SELECT group_concat(vdc.name,', ') FROM agenda_volunteers av JOIN directory_contacts vdc ON vdc.id=av.contact_id WHERE av.agenda_item_id=ai.id) volunteer_names,(SELECT group_concat(av.contact_id,',') FROM agenda_volunteers av WHERE av.agenda_item_id=ai.id) volunteer_ids FROM agenda_items ai LEFT JOIN animals a ON a.id=ai.animal_id LEFT JOIN directory_contacts dc ON dc.id=ai.contact_id LEFT JOIN users u ON u.id=ai.assigned_user_id WHERE date(ai.starts_at)<=:to AND (ai.repeat_rule<>'none' OR datetime(ai.starts_at)>=:from) AND ai.status<>'cancelled' ORDER BY datetime(ai.starts_at),ai.priority DESC",
|
||||
);
|
||||
$s->execute([':from' => $from->format('Y-m-d H:i:s'), ':to' => $to->format('Y-m-d')]);
|
||||
$out = [];
|
||||
foreach ($s->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$start = new DateTimeImmutable($row['starts_at']);
|
||||
$end = $row['ends_at'] ? new DateTimeImmutable($row['ends_at']) : null;
|
||||
$step = match ($row['repeat_rule']) {
|
||||
'daily' => '+1 day',
|
||||
'weekly' => '+1 week',
|
||||
'monthly' => '+1 month',
|
||||
default => null,
|
||||
};
|
||||
while ($start < $from && $step) {
|
||||
$start = $start->modify($step);
|
||||
}
|
||||
do {
|
||||
if ($start > $to) {
|
||||
break;
|
||||
}
|
||||
if (!$row['repeat_until'] || $start->format('Y-m-d') <= $row['repeat_until']) {
|
||||
$instance = $row;
|
||||
$instance['instance_starts_at'] = $start->format('Y-m-d H:i:s');
|
||||
$instance['instance_ends_at'] = $end
|
||||
? $end
|
||||
->modify(
|
||||
$start->getTimestamp() -
|
||||
new DateTimeImmutable($row['starts_at'])->getTimestamp() .
|
||||
' seconds',
|
||||
)
|
||||
->format('Y-m-d H:i:s')
|
||||
: null;
|
||||
$instance['source_type'] = 'agenda';
|
||||
if (!empty($instance['volunteer_names'])) {
|
||||
$instance['title'] .= ' · 👥 ' . $instance['volunteer_names'];
|
||||
}
|
||||
$out[] = $instance;
|
||||
}
|
||||
if (!$step) {
|
||||
break;
|
||||
}
|
||||
$start = $start->modify($step);
|
||||
} while (!$row['repeat_until'] || $start->format('Y-m-d') <= $row['repeat_until']);
|
||||
}
|
||||
$vaccines = $db->prepare(
|
||||
"SELECT v.id,v.due_date,rv.name vaccine_name,a.id animal_id,a.name animal_name FROM vaccinations v JOIN ref_vaccines rv ON rv.id=v.vaccine_id JOIN animals a ON a.id=v.animal_id WHERE v.due_date IS NOT NULL AND date(v.due_date) BETWEEN :from AND :to AND a.deleted_at IS NULL AND a.archived_at IS NULL AND lower(a.status) NOT IN ('adopte','adopté','decede','décédé') AND NOT EXISTS(SELECT 1 FROM vaccinations newer WHERE newer.animal_id=v.animal_id AND newer.vaccine_id=v.vaccine_id AND date(newer.done_date)>date(v.done_date)) ORDER BY date(v.due_date),a.name COLLATE NOCASE",
|
||||
);
|
||||
$vaccines->execute([':from' => $from->format('Y-m-d'), ':to' => $to->format('Y-m-d')]);
|
||||
foreach ($vaccines->fetchAll(PDO::FETCH_ASSOC) as $v) {
|
||||
$out[] = self::reminder(
|
||||
-(100000000 + (int) $v['id']),
|
||||
(int) $v['id'],
|
||||
'vaccine',
|
||||
'vaccine',
|
||||
t('agenda.vaccine_reminder', ['vaccine' => $v['vaccine_name']]),
|
||||
$v['due_date'],
|
||||
(int) $v['animal_id'],
|
||||
$v['animal_name'],
|
||||
'high',
|
||||
);
|
||||
}
|
||||
|
||||
$dewormings = $db->prepare(
|
||||
"SELECT d.id,d.next_due_date,COALESCE(rd.name,'') dewormer_name,a.id animal_id,a.name animal_name FROM dewormings d LEFT JOIN ref_dewormers rd ON rd.id=d.dewormer_id JOIN animals a ON a.id=d.animal_id WHERE d.next_due_date IS NOT NULL AND date(d.next_due_date) BETWEEN :from AND :to AND a.deleted_at IS NULL AND a.archived_at IS NULL AND lower(a.status) NOT IN ('adopte','adopté','decede','décédé') AND NOT EXISTS(SELECT 1 FROM dewormings newer WHERE newer.animal_id=d.animal_id AND (date(newer.administered_on)>date(d.administered_on) OR (date(newer.administered_on)=date(d.administered_on) AND newer.id>d.id))) ORDER BY date(d.next_due_date),a.name COLLATE NOCASE",
|
||||
);
|
||||
$dewormings->execute([':from' => $from->format('Y-m-d'), ':to' => $to->format('Y-m-d')]);
|
||||
foreach ($dewormings->fetchAll(PDO::FETCH_ASSOC) as $d) {
|
||||
$out[] = self::reminder(
|
||||
-(200000000 + (int) $d['id']),
|
||||
(int) $d['id'],
|
||||
'deworming',
|
||||
'deworming',
|
||||
t('agenda.deworming_reminder', ['product' => $d['dewormer_name'] ?: t('agenda.deworming')]),
|
||||
$d['next_due_date'],
|
||||
(int) $d['animal_id'],
|
||||
$d['animal_name'],
|
||||
'high',
|
||||
);
|
||||
}
|
||||
|
||||
$treatments = $db->prepare(
|
||||
"SELECT t.id,t.start_date,t.end_date,m.name medication_name,a.id animal_id,a.name animal_name FROM treatments t JOIN ref_medications m ON m.id=t.medication_id JOIN animals a ON a.id=t.animal_id WHERE t.ongoing=1 AND (date(t.start_date) BETWEEN :from AND :to OR (t.end_date IS NOT NULL AND date(t.end_date) BETWEEN :from AND :to)) AND a.deleted_at IS NULL AND a.archived_at IS NULL AND lower(a.status) NOT IN ('adopte','adopté','decede','décédé') ORDER BY date(t.start_date),a.name COLLATE NOCASE",
|
||||
);
|
||||
$treatments->execute([':from' => $from->format('Y-m-d'), ':to' => $to->format('Y-m-d')]);
|
||||
foreach ($treatments->fetchAll(PDO::FETCH_ASSOC) as $t) {
|
||||
if ($t['start_date'] >= $from->format('Y-m-d') && $t['start_date'] <= $to->format('Y-m-d')) {
|
||||
$out[] = self::reminder(
|
||||
-(300000000 + (int) $t['id']),
|
||||
(int) $t['id'],
|
||||
'treatment_start',
|
||||
'treatment_start',
|
||||
t('agenda.treatment_start', ['medication' => $t['medication_name']]),
|
||||
$t['start_date'],
|
||||
(int) $t['animal_id'],
|
||||
$t['animal_name'],
|
||||
'high',
|
||||
);
|
||||
}
|
||||
if ($t['end_date'] && $t['end_date'] >= $from->format('Y-m-d') && $t['end_date'] <= $to->format('Y-m-d')) {
|
||||
$out[] = self::reminder(
|
||||
-(400000000 + (int) $t['id']),
|
||||
(int) $t['id'],
|
||||
'treatment_end',
|
||||
'treatment_end',
|
||||
t('agenda.treatment_end', ['medication' => $t['medication_name']]),
|
||||
$t['end_date'],
|
||||
(int) $t['animal_id'],
|
||||
$t['animal_name'],
|
||||
'high',
|
||||
);
|
||||
}
|
||||
}
|
||||
usort($out, static fn($a, $b) => strcmp($a['instance_starts_at'], $b['instance_starts_at']));
|
||||
return $out;
|
||||
}
|
||||
private static function reminder(
|
||||
int $id,
|
||||
int $sourceId,
|
||||
string $sourceType,
|
||||
string $itemType,
|
||||
string $title,
|
||||
string $date,
|
||||
int $animalId,
|
||||
string $animalName,
|
||||
string $priority,
|
||||
): array {
|
||||
return [
|
||||
'id' => $id,
|
||||
'source_id' => $sourceId,
|
||||
'source_type' => $sourceType,
|
||||
'item_type' => $itemType,
|
||||
'title' => $title,
|
||||
'description' => null,
|
||||
'starts_at' => $date . ' 00:00:00',
|
||||
'instance_starts_at' => $date . ' 00:00:00',
|
||||
'instance_ends_at' => null,
|
||||
'all_day' => 1,
|
||||
'location' => null,
|
||||
'status' => 'planned',
|
||||
'priority' => $priority,
|
||||
'assigned_user_id' => null,
|
||||
'assigned_name' => null,
|
||||
'volunteer_names' => null,
|
||||
'volunteer_ids' => null,
|
||||
'animal_id' => $animalId,
|
||||
'animal_name' => $animalName,
|
||||
'contact_id' => null,
|
||||
'contact_name' => null,
|
||||
'reminder_minutes' => null,
|
||||
'repeat_rule' => 'none',
|
||||
'repeat_until' => null,
|
||||
];
|
||||
}
|
||||
private static function saveVolunteers(int $itemId, mixed $input): void
|
||||
{
|
||||
$ids = is_array($input) ? array_values(array_unique(array_filter(array_map('intval', $input)))) : [];
|
||||
$db = DB::pdo();
|
||||
$db->prepare('DELETE FROM agenda_volunteers WHERE agenda_item_id=?')->execute([$itemId]);
|
||||
if (!$ids) {
|
||||
return;
|
||||
}
|
||||
$check = $db->prepare(
|
||||
"SELECT 1 FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id AND r.role='benevole' WHERE dc.id=? AND dc.deleted_at IS NULL",
|
||||
);
|
||||
$insert = $db->prepare('INSERT INTO agenda_volunteers(agenda_item_id,contact_id) VALUES(?,?)');
|
||||
foreach ($ids as $id) {
|
||||
$check->execute([$id]);
|
||||
if ($check->fetchColumn()) {
|
||||
$insert->execute([$itemId, $id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
private static function date(string $v): bool
|
||||
{
|
||||
$d = DateTimeImmutable::createFromFormat('!Y-m-d', $v);
|
||||
return $d && $d->format('Y-m-d') === $v;
|
||||
}
|
||||
}
|
||||
95
app/Services/AppSettings.php
Normal file
95
app/Services/AppSettings.php
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class AppSettings
|
||||
{
|
||||
private const DEFAULTS = [
|
||||
'association_name' => 'Association',
|
||||
'association_address' => '',
|
||||
'association_postal_code' => '',
|
||||
'association_city' => '',
|
||||
'association_phone' => '',
|
||||
'association_email' => '',
|
||||
'association_siret' => '',
|
||||
'association_rna' => '',
|
||||
'association_latitude' => '',
|
||||
'association_longitude' => '',
|
||||
'adoption_fee_sterilized' => '220',
|
||||
'adoption_fee_unsterilized' => '200',
|
||||
'quarantine_days' => '15',
|
||||
'backup_retention' => '10',
|
||||
'app_language' => 'fr',
|
||||
'public_site_enabled' => '0',
|
||||
'public_site_theme' => 'warm',
|
||||
'public_site_routing' => 'integrated',
|
||||
'public_site_home_title' => 'Ils attendent une famille',
|
||||
'public_site_home_text' => 'Découvrez les animaux actuellement proposés à l’adoption par notre association.',
|
||||
'public_site_about_enabled' => '1',
|
||||
'public_site_about_title' => 'Notre association',
|
||||
'public_site_about_text' => 'Présentez ici votre association, son histoire et ses missions.',
|
||||
'public_site_donation_title' => 'Nous soutenir',
|
||||
'public_site_donation_text' =>
|
||||
'Chaque don contribue directement aux soins, à l’alimentation et à la protection des animaux.',
|
||||
'public_site_donation_url' => '',
|
||||
'public_site_contact_text' => 'Une question sur une adoption ou sur l’association ? Contactez-nous.',
|
||||
'public_site_facebook_url' => '',
|
||||
'public_site_instagram_url' => '',
|
||||
'notification_email_enabled' => '0',
|
||||
'notification_email_frequency' => 'weekly',
|
||||
'notification_email_recipient' => '',
|
||||
'notification_last_sent_at' => '',
|
||||
'privacy_retention_years' => '5',
|
||||
'privacy_retention_adopter_years' => '5',
|
||||
'privacy_retention_foster_years' => '5',
|
||||
'privacy_retention_volunteer_years' => '5',
|
||||
'privacy_access_log_months' => '24',
|
||||
'backup_schedule_enabled' => '0',
|
||||
'backup_schedule_frequency' => 'daily',
|
||||
'backup_last_run_at' => '',
|
||||
'backup_last_verified_at' => '',
|
||||
'public_site_legal_text' => '',
|
||||
'public_site_privacy_text' => '',
|
||||
];
|
||||
private static ?array $cache = null;
|
||||
public static function all(): array
|
||||
{
|
||||
if (self::$cache !== null) {
|
||||
return self::$cache;
|
||||
}
|
||||
$values = self::DEFAULTS;
|
||||
try {
|
||||
foreach (
|
||||
DB::pdo()->query('SELECT setting_key,setting_value FROM app_settings')->fetchAll(PDO::FETCH_ASSOC)
|
||||
as $row
|
||||
) {
|
||||
if (array_key_exists($row['setting_key'], $values)) {
|
||||
$values[$row['setting_key']] = (string) $row['setting_value'];
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
}
|
||||
return self::$cache = $values;
|
||||
}
|
||||
public static function get(string $key): string
|
||||
{
|
||||
return (string) (self::all()[$key] ?? '');
|
||||
}
|
||||
public static function int(string $key, int $min = 0, int $max = PHP_INT_MAX): int
|
||||
{
|
||||
return max($min, min($max, (int) self::get($key)));
|
||||
}
|
||||
public static function save(array $values, ?int $userId): void
|
||||
{
|
||||
$allowed = array_keys(self::DEFAULTS);
|
||||
$stmt = DB::pdo()->prepare(
|
||||
"INSERT INTO app_settings(setting_key,setting_value,updated_by,updated_at) VALUES(:key,:value,:user,datetime('now')) ON CONFLICT(setting_key) DO UPDATE SET setting_value=excluded.setting_value,updated_by=excluded.updated_by,updated_at=datetime('now')",
|
||||
);
|
||||
foreach ($allowed as $key) {
|
||||
if (array_key_exists($key, $values)) {
|
||||
$stmt->execute([':key' => $key, ':value' => (string) $values[$key], ':user' => $userId]);
|
||||
}
|
||||
}
|
||||
self::$cache = null;
|
||||
}
|
||||
}
|
||||
490
app/Services/Asm3Analyzer.php
Normal file
490
app/Services/Asm3Analyzer.php
Normal file
|
|
@ -0,0 +1,490 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class Asm3Analyzer
|
||||
{
|
||||
private const DOMAINS = [
|
||||
'animal' => ['Animaux', 'animals', 'ready'],
|
||||
'owner' => ['Contacts et structures', 'directory_contacts', 'planned'],
|
||||
'adoption' => ['Mouvements, FA et adoptions', 'animal_placements / adoptions', 'planned'],
|
||||
'animalvaccination' => ['Vaccinations', 'vaccinations', 'planned'],
|
||||
'medical' => ['Traitements et actes médicaux', 'medical_notes / treatments', 'planned'],
|
||||
'animalmedical' => ['Traitements et actes médicaux', 'medical_notes / treatments', 'planned'],
|
||||
'media' => ['Médias', 'animal_photos / medical_photos', 'planned'],
|
||||
'animallitter' => ['Portées', 'litters', 'planned'],
|
||||
'animalcontrol' => ['Incidents et contrôles', 'non pris en charge', 'review'],
|
||||
'animaltest' => ['Tests médicaux', 'medical_notes', 'planned'],
|
||||
];
|
||||
|
||||
public static function analyze(string $path): array
|
||||
{
|
||||
if (!is_file($path) || !is_readable($path)) {
|
||||
throw new RuntimeException(t('asm3.unreadable'));
|
||||
}
|
||||
if (self::isCopyDump($path)) {
|
||||
return self::analyzeCopy($path);
|
||||
}
|
||||
$handle = fopen($path, 'rb');
|
||||
if (!$handle) {
|
||||
throw new RuntimeException(t('asm3.open_failed'));
|
||||
}
|
||||
$counts = [];
|
||||
$columns = [];
|
||||
$animalStats = [
|
||||
'active' => 0,
|
||||
'archived' => 0,
|
||||
'adoptable' => 0,
|
||||
'not_adoptable' => 0,
|
||||
'deceased' => 0,
|
||||
'microchip' => 0,
|
||||
'tattoo' => 0,
|
||||
'neutered' => 0,
|
||||
'missing_name' => 0,
|
||||
'missing_birth_date' => 0,
|
||||
'species' => [],
|
||||
];
|
||||
$statement = '';
|
||||
$quoted = false;
|
||||
$bytes = 0;
|
||||
try {
|
||||
while (($chunk = fgets($handle)) !== false) {
|
||||
$bytes += strlen($chunk);
|
||||
$statement .= $chunk;
|
||||
$length = strlen($statement);
|
||||
$quoted = false;
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
if ($statement[$i] !== "'") {
|
||||
continue;
|
||||
}
|
||||
if ($quoted && $i + 1 < $length && $statement[$i + 1] === "'") {
|
||||
$i++;
|
||||
continue;
|
||||
}
|
||||
$quoted = !$quoted;
|
||||
}
|
||||
if ($quoted || !preg_match('/;\s*$/s', $statement)) {
|
||||
continue;
|
||||
}
|
||||
self::consume($statement, $counts, $columns, $animalStats);
|
||||
$statement = '';
|
||||
}
|
||||
if (trim($statement) !== '') {
|
||||
self::consume($statement, $counts, $columns, $animalStats);
|
||||
}
|
||||
} finally {
|
||||
fclose($handle);
|
||||
}
|
||||
arsort($counts);
|
||||
$domains = [];
|
||||
foreach (self::DOMAINS as $table => [$label, $target, $status]) {
|
||||
$domains[] = [
|
||||
'table' => $table,
|
||||
'label' => $label,
|
||||
'target' => $target,
|
||||
'status' => $status,
|
||||
'rows' => $counts[$table] ?? 0,
|
||||
'present' => isset($counts[$table]),
|
||||
];
|
||||
}
|
||||
$warnings = [];
|
||||
if (!isset($counts['animal'])) {
|
||||
$warnings[] = t('asm3.animal_table_missing');
|
||||
}
|
||||
if (($counts['animal'] ?? 0) > 0 && $animalStats['missing_name'] > 0) {
|
||||
$warnings[] = t('asm3.animals_without_name', ['count' => $animalStats['missing_name']]);
|
||||
}
|
||||
if (($counts['animal'] ?? 0) > 0 && $animalStats['missing_birth_date'] > 0) {
|
||||
$warnings[] = t('asm3.animals_without_birth', ['count' => $animalStats['missing_birth_date']]);
|
||||
}
|
||||
$unsupported = [];
|
||||
foreach ($counts as $table => $count) {
|
||||
if (!isset(self::DOMAINS[$table]) && $count > 0) {
|
||||
$unsupported[$table] = $count;
|
||||
}
|
||||
}
|
||||
return [
|
||||
'format' => 'ASM3 SQL INSERT',
|
||||
'source' => basename($path),
|
||||
'size_bytes' => filesize($path),
|
||||
'read_bytes' => $bytes,
|
||||
'generated_at' => date(DATE_ATOM),
|
||||
'tables' => $counts,
|
||||
'columns' => $columns,
|
||||
'domains' => $domains,
|
||||
'animals' => $animalStats,
|
||||
'warnings' => $warnings,
|
||||
'unmapped_tables' => $unsupported,
|
||||
'read_only' => true,
|
||||
];
|
||||
}
|
||||
|
||||
public static function rows(string $path, array $wantedTables): array
|
||||
{
|
||||
if (self::isCopyDump($path)) {
|
||||
return self::copyRows($path, $wantedTables);
|
||||
}
|
||||
$wanted = array_fill_keys(array_map('strtolower', $wantedTables), true);
|
||||
$rows = [];
|
||||
$columns = [];
|
||||
$handle = fopen($path, 'rb');
|
||||
if (!$handle) {
|
||||
throw new RuntimeException(t('asm3.open_failed'));
|
||||
}
|
||||
$statement = '';
|
||||
try {
|
||||
while (($chunk = fgets($handle)) !== false) {
|
||||
$statement .= $chunk;
|
||||
$quoted = false;
|
||||
$length = strlen($statement);
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
if ($statement[$i] !== "'") {
|
||||
continue;
|
||||
}
|
||||
if ($quoted && $i + 1 < $length && $statement[$i + 1] === "'") {
|
||||
$i++;
|
||||
continue;
|
||||
}
|
||||
$quoted = !$quoted;
|
||||
}
|
||||
if ($quoted || !preg_match('/;\s*$/s', $statement)) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
preg_match(
|
||||
'/^\s*INSERT\s+INTO\s+[`"]?([a-zA-Z0-9_]+)[`"]?\s*\((.*?)\)\s*VALUES\s*\((.*)\)\s*;\s*$/is',
|
||||
$statement,
|
||||
$m,
|
||||
)
|
||||
) {
|
||||
$table = strtolower($m[1]);
|
||||
if (isset($wanted[$table])) {
|
||||
$cols = $columns[$table] ??= array_map(
|
||||
static fn($v) => strtoupper(trim($v, " \t\r\n`\"")),
|
||||
explode(',', $m[2]),
|
||||
);
|
||||
$values = self::values($m[3]);
|
||||
$row = [];
|
||||
foreach ($cols as $i => $column) {
|
||||
$row[$column] = $values[$i] ?? null;
|
||||
}
|
||||
$rows[$table][] = $row;
|
||||
}
|
||||
}
|
||||
$statement = '';
|
||||
}
|
||||
} finally {
|
||||
fclose($handle);
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private static function consume(string $sql, array &$counts, array &$columns, array &$animalStats): void
|
||||
{
|
||||
if (
|
||||
!preg_match(
|
||||
'/^\s*INSERT\s+INTO\s+[`"]?([a-zA-Z0-9_]+)[`"]?\s*\((.*?)\)\s*VALUES\s*\((.*)\)\s*;\s*$/is',
|
||||
$sql,
|
||||
$m,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
$table = strtolower($m[1]);
|
||||
$counts[$table] = ($counts[$table] ?? 0) + 1;
|
||||
if (!isset($columns[$table])) {
|
||||
$columns[$table] = array_map(static fn($v) => strtoupper(trim($v, " \t\r\n`\"")), explode(',', $m[2]));
|
||||
}
|
||||
if ($table !== 'animal') {
|
||||
return;
|
||||
}
|
||||
$values = self::values($m[3]);
|
||||
$row = [];
|
||||
foreach ($columns[$table] as $i => $column) {
|
||||
$row[$column] = $values[$i] ?? null;
|
||||
}
|
||||
$truthy = static fn($v): bool => (string) $v === '1';
|
||||
$blank = static fn($v): bool => $v === null || trim((string) $v) === '';
|
||||
$animalStats[$truthy($row['ARCHIVED'] ?? 0) ? 'archived' : 'active']++;
|
||||
$animalStats[$truthy($row['ADOPTABLE'] ?? 0) ? 'adoptable' : 'not_adoptable']++;
|
||||
if (!$blank($row['DECEASEDDATE'] ?? null) || $truthy($row['PUTTOSLEEP'] ?? 0)) {
|
||||
$animalStats['deceased']++;
|
||||
}
|
||||
if (!$blank($row['IDENTICHIPNUMBER'] ?? null) || $truthy($row['IDENTICHIPPED'] ?? 0)) {
|
||||
$animalStats['microchip']++;
|
||||
}
|
||||
if (!$blank($row['TATTOONUMBER'] ?? null) || $truthy($row['TATTOO'] ?? 0)) {
|
||||
$animalStats['tattoo']++;
|
||||
}
|
||||
if ($truthy($row['NEUTERED'] ?? 0)) {
|
||||
$animalStats['neutered']++;
|
||||
}
|
||||
if ($blank($row['ANIMALNAME'] ?? null)) {
|
||||
$animalStats['missing_name']++;
|
||||
}
|
||||
if ($blank($row['DATEOFBIRTH'] ?? null)) {
|
||||
$animalStats['missing_birth_date']++;
|
||||
}
|
||||
$species = (string) ($row['SPECIESID'] ?? 'unknown');
|
||||
$animalStats['species'][$species] = ($animalStats['species'][$species] ?? 0) + 1;
|
||||
}
|
||||
|
||||
private static function isCopyDump(string $path): bool
|
||||
{
|
||||
$handle = @fopen($path, 'rb');
|
||||
if (!$handle) {
|
||||
return false;
|
||||
}
|
||||
$sample = (string) fread($handle, 262144);
|
||||
fclose($handle);
|
||||
return preg_match('/^COPY\s+(?:public\.)?[a-zA-Z0-9_]+\s*\(/mi', $sample) === 1;
|
||||
}
|
||||
|
||||
private static function analyzeCopy(string $path): array
|
||||
{
|
||||
$counts = [];
|
||||
$columns = [];
|
||||
$animalStats = [
|
||||
'active' => 0,
|
||||
'archived' => 0,
|
||||
'adoptable' => 0,
|
||||
'not_adoptable' => 0,
|
||||
'deceased' => 0,
|
||||
'microchip' => 0,
|
||||
'tattoo' => 0,
|
||||
'neutered' => 0,
|
||||
'missing_name' => 0,
|
||||
'missing_birth_date' => 0,
|
||||
'species' => [],
|
||||
];
|
||||
$handle = fopen($path, 'rb');
|
||||
if (!$handle) {
|
||||
throw new RuntimeException(t('asm3.open_failed'));
|
||||
}
|
||||
$table = null;
|
||||
$tableColumns = [];
|
||||
$bytes = 0;
|
||||
try {
|
||||
while (($line = fgets($handle)) !== false) {
|
||||
$bytes += strlen($line);
|
||||
$line = rtrim($line, "\r\n");
|
||||
if ($table === null) {
|
||||
if (
|
||||
preg_match(
|
||||
'/^COPY\s+(?:public\.)?[`"]?([a-zA-Z0-9_]+)[`"]?\s*\((.*?)\)\s+FROM\s+stdin;$/i',
|
||||
$line,
|
||||
$m,
|
||||
)
|
||||
) {
|
||||
$table = strtolower($m[1]);
|
||||
$tableColumns = array_map(static fn($v) => strtoupper(trim($v, " \t`\"")), explode(',', $m[2]));
|
||||
$columns[$table] = $tableColumns;
|
||||
$counts[$table] ??= 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ($line === '\\.') {
|
||||
$table = null;
|
||||
$tableColumns = [];
|
||||
continue;
|
||||
}
|
||||
$counts[$table]++;
|
||||
if ($table === 'animal') {
|
||||
self::accumulateAnimalStats(self::copyRow($line, $tableColumns), $animalStats);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fclose($handle);
|
||||
}
|
||||
arsort($counts);
|
||||
$domains = [];
|
||||
foreach (self::DOMAINS as $name => [$label, $target, $status]) {
|
||||
$domains[] = [
|
||||
'table' => $name,
|
||||
'label' => $label,
|
||||
'target' => $target,
|
||||
'status' => $status,
|
||||
'rows' => $counts[$name] ?? 0,
|
||||
'present' => isset($counts[$name]),
|
||||
];
|
||||
}
|
||||
$warnings = [];
|
||||
if (!isset($counts['animal'])) {
|
||||
$warnings[] = t('asm3.animal_table_missing');
|
||||
}
|
||||
if (($counts['animal'] ?? 0) > 0 && $animalStats['missing_name'] > 0) {
|
||||
$warnings[] = t('asm3.animals_without_name', ['count' => $animalStats['missing_name']]);
|
||||
}
|
||||
if (($counts['animal'] ?? 0) > 0 && $animalStats['missing_birth_date'] > 0) {
|
||||
$warnings[] = t('asm3.animals_without_birth', ['count' => $animalStats['missing_birth_date']]);
|
||||
}
|
||||
$unsupported = [];
|
||||
foreach ($counts as $name => $count) {
|
||||
if (!isset(self::DOMAINS[$name]) && $count > 0) {
|
||||
$unsupported[$name] = $count;
|
||||
}
|
||||
}
|
||||
return [
|
||||
'format' => 'ASM3 PostgreSQL COPY',
|
||||
'source' => basename($path),
|
||||
'size_bytes' => filesize($path),
|
||||
'read_bytes' => $bytes,
|
||||
'generated_at' => date(DATE_ATOM),
|
||||
'tables' => $counts,
|
||||
'columns' => $columns,
|
||||
'domains' => $domains,
|
||||
'animals' => $animalStats,
|
||||
'warnings' => $warnings,
|
||||
'unmapped_tables' => $unsupported,
|
||||
'read_only' => true,
|
||||
];
|
||||
}
|
||||
|
||||
private static function copyRows(string $path, array $wantedTables): array
|
||||
{
|
||||
$wanted = array_fill_keys(array_map('strtolower', $wantedTables), true);
|
||||
$rows = [];
|
||||
$handle = fopen($path, 'rb');
|
||||
if (!$handle) {
|
||||
throw new RuntimeException(t('asm3.open_failed'));
|
||||
}
|
||||
$table = null;
|
||||
$columns = [];
|
||||
$capture = false;
|
||||
try {
|
||||
while (($line = fgets($handle)) !== false) {
|
||||
$line = rtrim($line, "\r\n");
|
||||
if ($table === null) {
|
||||
if (
|
||||
preg_match(
|
||||
'/^COPY\s+(?:public\.)?[`"]?([a-zA-Z0-9_]+)[`"]?\s*\((.*?)\)\s+FROM\s+stdin;$/i',
|
||||
$line,
|
||||
$m,
|
||||
)
|
||||
) {
|
||||
$table = strtolower($m[1]);
|
||||
$capture = isset($wanted[$table]);
|
||||
$columns = $capture
|
||||
? array_map(static fn($v) => strtoupper(trim($v, " \t`\"")), explode(',', $m[2]))
|
||||
: [];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ($line === '\\.') {
|
||||
$table = null;
|
||||
$columns = [];
|
||||
$capture = false;
|
||||
continue;
|
||||
}
|
||||
if ($capture) {
|
||||
$rows[$table][] = self::copyRow($line, $columns);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fclose($handle);
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private static function copyRow(string $line, array $columns): array
|
||||
{
|
||||
$values = explode("\t", $line);
|
||||
$row = [];
|
||||
foreach ($columns as $i => $column) {
|
||||
$row[$column] = self::copyScalar($values[$i] ?? '\\N');
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
private static function copyScalar(string $value): ?string
|
||||
{
|
||||
if ($value === '\\N') {
|
||||
return null;
|
||||
}
|
||||
return preg_replace_callback(
|
||||
'/\\\\([0-7]{1,3}|.)/s',
|
||||
static function (array $m): string {
|
||||
$escape = $m[1];
|
||||
if (ctype_digit($escape)) {
|
||||
return chr(octdec($escape));
|
||||
}
|
||||
return match ($escape) {
|
||||
'b' => "\x08",
|
||||
'f' => "\x0c",
|
||||
'n' => "\n",
|
||||
'r' => "\r",
|
||||
't' => "\t",
|
||||
'v' => "\x0b",
|
||||
'\\' => '\\',
|
||||
default => $escape,
|
||||
};
|
||||
},
|
||||
$value,
|
||||
) ?? $value;
|
||||
}
|
||||
|
||||
private static function accumulateAnimalStats(array $row, array &$animalStats): void
|
||||
{
|
||||
$truthy = static fn($v): bool => (string) $v === '1';
|
||||
$blank = static fn($v): bool => $v === null || trim((string) $v) === '';
|
||||
$animalStats[$truthy($row['ARCHIVED'] ?? 0) ? 'archived' : 'active']++;
|
||||
$animalStats[$truthy($row['ADOPTABLE'] ?? 0) ? 'adoptable' : 'not_adoptable']++;
|
||||
if (!$blank($row['DECEASEDDATE'] ?? null) || $truthy($row['PUTTOSLEEP'] ?? 0)) {
|
||||
$animalStats['deceased']++;
|
||||
}
|
||||
if (!$blank($row['IDENTICHIPNUMBER'] ?? null) || $truthy($row['IDENTICHIPPED'] ?? 0)) {
|
||||
$animalStats['microchip']++;
|
||||
}
|
||||
if (!$blank($row['TATTOONUMBER'] ?? null) || $truthy($row['TATTOO'] ?? 0)) {
|
||||
$animalStats['tattoo']++;
|
||||
}
|
||||
if ($truthy($row['NEUTERED'] ?? 0)) {
|
||||
$animalStats['neutered']++;
|
||||
}
|
||||
if ($blank($row['ANIMALNAME'] ?? null)) {
|
||||
$animalStats['missing_name']++;
|
||||
}
|
||||
if ($blank($row['DATEOFBIRTH'] ?? null)) {
|
||||
$animalStats['missing_birth_date']++;
|
||||
}
|
||||
$species = (string) ($row['SPECIESID'] ?? 'unknown');
|
||||
$animalStats['species'][$species] = ($animalStats['species'][$species] ?? 0) + 1;
|
||||
}
|
||||
|
||||
private static function values(string $input): array
|
||||
{
|
||||
$values = [];
|
||||
$value = '';
|
||||
$quoted = false;
|
||||
$length = strlen($input);
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$char = $input[$i];
|
||||
if ($char === "'") {
|
||||
if ($quoted && $i + 1 < $length && $input[$i + 1] === "'") {
|
||||
$value .= "'";
|
||||
$i++;
|
||||
continue;
|
||||
}
|
||||
$quoted = !$quoted;
|
||||
continue;
|
||||
}
|
||||
if ($char === ',' && !$quoted) {
|
||||
$values[] = self::scalar($value);
|
||||
$value = '';
|
||||
continue;
|
||||
}
|
||||
$value .= $char;
|
||||
}
|
||||
$values[] = self::scalar($value);
|
||||
return $values;
|
||||
}
|
||||
|
||||
private static function scalar(string $value): mixed
|
||||
{
|
||||
$value = trim($value);
|
||||
if (strcasecmp($value, 'null') === 0) {
|
||||
return null;
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
138
app/Services/Asm3AnimalImporter.php
Normal file
138
app/Services/Asm3AnimalImporter.php
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class Asm3AnimalImporter
|
||||
{
|
||||
public static function run(string $path, PDO $db, bool $apply = false, bool $createBackup = true): array
|
||||
{
|
||||
$plan = Asm3MigrationPlanner::plan($path, $db);
|
||||
$hash = hash_file('sha256', $path);
|
||||
if ($hash === false) {
|
||||
throw new RuntimeException(t('asm3.hash_failed'));
|
||||
}
|
||||
$result = [
|
||||
'mode' => $apply ? 'apply' : 'dry-run',
|
||||
'source' => basename($path),
|
||||
'source_sha256' => $hash,
|
||||
'planned' => $plan['summary'],
|
||||
'created' => 0,
|
||||
'linked_matches' => 0,
|
||||
'skipped_existing_import' => 0,
|
||||
'skipped_match' => 0,
|
||||
'skipped_review' => $plan['summary']['review'],
|
||||
'backup' => null,
|
||||
'read_only' => !$apply,
|
||||
];
|
||||
if (!$apply) {
|
||||
$result['would_create'] = $plan['summary']['create'];
|
||||
return $result;
|
||||
}
|
||||
if ($createBackup) {
|
||||
$backup = BackupService::create('pre-asm3-import');
|
||||
$result['backup'] = $backup['name'] ?? null;
|
||||
}
|
||||
$ownsTransaction = !$db->inTransaction();
|
||||
if ($ownsTransaction) {
|
||||
$db->beginTransaction();
|
||||
}
|
||||
try {
|
||||
$run = $db->prepare(
|
||||
"INSERT INTO asm3_import_runs(source_name,source_sha256,status,summary_json,backup_name) VALUES(:name,:hash,'completed','{}',:backup)",
|
||||
);
|
||||
$run->execute([':name' => basename($path), ':hash' => $hash, ':backup' => $result['backup']]);
|
||||
$runId = (int) $db->lastInsertId();
|
||||
foreach ($plan['items'] as $item) {
|
||||
if ($item['action'] === 'review') {
|
||||
continue;
|
||||
}
|
||||
$check = $db->prepare(
|
||||
"SELECT target_id FROM asm3_import_links WHERE source_sha256=:hash AND entity_type='animal' AND source_id=:source",
|
||||
);
|
||||
$check->execute([':hash' => $hash, ':source' => $item['asm3_id']]);
|
||||
if ($check->fetchColumn()) {
|
||||
$result['skipped_existing_import']++;
|
||||
continue;
|
||||
}
|
||||
if ($item['action'] === 'match') {
|
||||
$target = (int) ($item['globinours_candidates'][0]['id'] ?? 0);
|
||||
if ($target <= 0) {
|
||||
$result['skipped_match']++;
|
||||
continue;
|
||||
}
|
||||
$result['linked_matches']++;
|
||||
} else {
|
||||
$target = self::insert($db, $item['mapped'], $item['asm3_id']);
|
||||
$result['created']++;
|
||||
}
|
||||
$db->prepare(
|
||||
"INSERT INTO asm3_import_links(source_sha256,entity_type,source_id,target_id,import_run_id) VALUES(:hash,'animal',:source,:target,:run)",
|
||||
)->execute([':hash' => $hash, ':source' => $item['asm3_id'], ':target' => $target, ':run' => $runId]);
|
||||
}
|
||||
$db->prepare('UPDATE asm3_import_runs SET summary_json=:summary WHERE id=:id')->execute([
|
||||
':summary' => json_encode($result, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||||
':id' => $runId,
|
||||
]);
|
||||
if ($ownsTransaction) {
|
||||
$db->commit();
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
if ($ownsTransaction && $db->inTransaction()) {
|
||||
$db->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
private static function insert(PDO $db, array $a, int $sourceId): int
|
||||
{
|
||||
$code = trim((string) $a['internal_code']);
|
||||
if ($code === '') {
|
||||
$code = 'ASM3-' . $sourceId;
|
||||
}
|
||||
$exists = $db->prepare('SELECT 1 FROM animals WHERE internal_code=:code');
|
||||
$exists->execute([':code' => $code]);
|
||||
if ($exists->fetchColumn()) {
|
||||
$code .= '-ASM3-' . $sourceId;
|
||||
}
|
||||
$stmt = $db->prepare(
|
||||
"INSERT INTO animals(internal_code,name,species,sex,birth_date,birth_is_estimated,chip_id,status,sterilized,sterilization_status,sterilization_date,color,breed,notes,intake_date,archived_at,identification_type,identification_date,identification_registration_status,adoption_availability,adoption_available_from,adoption_unavailability_reason,compatibility_dogs,compatibility_cats,compatibility_children,house_trained,created_at,updated_at) VALUES(:code,:name,:species,:sex,:birth,:estimated,:chip,:status,:sterilized,:sterilization_status,:sterilization_date,:color,:breed,:notes,:intake,:archived,:identification_type,:identification_date,:registration,:availability,:available_from,:unavailable_reason,:dogs,:cats,:children,:trained,datetime('now'),datetime('now'))",
|
||||
);
|
||||
$stmt->execute([
|
||||
':code' => $code,
|
||||
':name' => $a['name'] ?: 'Animal ASM3 ' . $sourceId,
|
||||
':species' => $a['species'] ?: 'inconnu',
|
||||
':sex' => $a['sex'],
|
||||
':birth' => $a['birth_date'],
|
||||
':estimated' => $a['birth_is_estimated'],
|
||||
':chip' => $a['chip_id'] ?: null,
|
||||
':status' => $a['status'],
|
||||
':sterilized' => $a['sterilization_status'] === 'yes' ? 1 : 0,
|
||||
':sterilization_status' => $a['sterilization_status'],
|
||||
':sterilization_date' => $a['sterilization_date'],
|
||||
':color' => $a['color'],
|
||||
':breed' => $a['breed'] ?: null,
|
||||
':notes' => $a['notes'] ?: null,
|
||||
':intake' => $a['intake_date'],
|
||||
':archived' => $a['archived'] ? date('Y-m-d H:i:s') : null,
|
||||
':identification_type' => $a['identification_type'],
|
||||
':identification_date' => $a['identification_date'],
|
||||
':registration' => $a['identification_registration_status'],
|
||||
':availability' => $a['adoption_availability'],
|
||||
':available_from' => $a['adoption_available_from'],
|
||||
':unavailable_reason' => $a['adoption_unavailability_reason'] ?: null,
|
||||
':dogs' => $a['compatibility_dogs'],
|
||||
':cats' => $a['compatibility_cats'],
|
||||
':children' => $a['compatibility_children'],
|
||||
':trained' => $a['house_trained'],
|
||||
]);
|
||||
$id = (int) $db->lastInsertId();
|
||||
if ($a['deceased_date']) {
|
||||
$db->prepare(
|
||||
"INSERT INTO animal_deaths(animal_id,deceased_date,cause_code,cause_details,place_type,body_disposition,notes) VALUES(:animal,:date,'unknown',NULL,'inconnu','pending',:notes)",
|
||||
)->execute([':animal' => $id, ':date' => $a['deceased_date'], ':notes' => 'Importé depuis ASM3']);
|
||||
}
|
||||
return $id;
|
||||
}
|
||||
}
|
||||
169
app/Services/Asm3ClinicalImporter.php
Normal file
169
app/Services/Asm3ClinicalImporter.php
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class Asm3ClinicalImporter
|
||||
{
|
||||
public static function run(string $path, PDO $db, bool $apply = false, bool $createBackup = true): array
|
||||
{
|
||||
$hash = hash_file('sha256', $path);
|
||||
$src = Asm3Analyzer::rows($path, ['animalvaccination', 'vaccinationtype', 'log', 'logtype']);
|
||||
$links = self::animalLinks($db, $hash);
|
||||
$types = [];
|
||||
foreach ($src['vaccinationtype'] ?? [] as $r) {
|
||||
$types[(int) $r['ID']] = trim((string) $r['VACCINATIONTYPE']);
|
||||
}
|
||||
$logTypes = [];
|
||||
foreach ($src['logtype'] ?? [] as $r) {
|
||||
$logTypes[(int) $r['ID']] = trim((string) $r['LOGTYPENAME']);
|
||||
}
|
||||
$result = [
|
||||
'mode' => $apply ? 'apply' : 'dry-run',
|
||||
'read_only' => !$apply,
|
||||
'vaccinations' => 0,
|
||||
'medical_notes' => 0,
|
||||
'weights' => 0,
|
||||
'unresolved' => 0,
|
||||
'already_imported' => 0,
|
||||
'backup' => null,
|
||||
];
|
||||
foreach ($src['animalvaccination'] ?? [] as $r) {
|
||||
$result['vaccinations']++;
|
||||
if (!isset($links[(int) $r['ANIMALID']])) {
|
||||
$result['unresolved']++;
|
||||
}
|
||||
}
|
||||
foreach ($src['log'] ?? [] as $r) {
|
||||
if ((int) ($r['LINKTYPE'] ?? -1) === 0) {
|
||||
if (!isset($links[(int) $r['LINKID']])) {
|
||||
$result['unresolved']++;
|
||||
}
|
||||
$name = mb_strtolower($logTypes[(int) $r['LOGTYPEID']] ?? '');
|
||||
if ($name === 'poids') {
|
||||
$result['weights']++;
|
||||
} else {
|
||||
$result['medical_notes']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!$apply) {
|
||||
return $result;
|
||||
}
|
||||
if ($createBackup) {
|
||||
$b = BackupService::create('pre-asm3-import');
|
||||
$result['backup'] = $b['name'] ?? null;
|
||||
}
|
||||
$ownsTransaction = !$db->inTransaction();
|
||||
if ($ownsTransaction) {
|
||||
$db->beginTransaction();
|
||||
}
|
||||
try {
|
||||
$runId = self::runId($db, $path, $hash, $result['backup'], 'médical');
|
||||
foreach ($src['animalvaccination'] ?? [] as $r) {
|
||||
$animal = $links[(int) $r['ANIMALID']] ?? 0;
|
||||
if (!$animal) {
|
||||
continue;
|
||||
}
|
||||
$source = (int) $r['ID'];
|
||||
if (self::seen($db, $hash, 'vaccination', $source)) {
|
||||
$result['already_imported']++;
|
||||
continue;
|
||||
}
|
||||
$name = $types[(int) $r['VACCINATIONID']] ?? 'Vaccin ASM3 ' . (int) $r['VACCINATIONID'];
|
||||
$db->prepare('INSERT OR IGNORE INTO ref_vaccines(name) VALUES(:name)')->execute([':name' => $name]);
|
||||
$q = $db->prepare('SELECT id FROM ref_vaccines WHERE name=:name');
|
||||
$q->execute([':name' => $name]);
|
||||
$vaccine = (int) $q->fetchColumn();
|
||||
$db->prepare(
|
||||
'INSERT INTO vaccinations(animal_id,vaccine_id,done_date,due_date,lot,notes,manufacturer,batch_expires_on,administered_by_name) VALUES(:animal,:vaccine,:done,:due,:lot,:notes,:manufacturer,:expires,:by)',
|
||||
)->execute([
|
||||
':animal' => $animal,
|
||||
':vaccine' => $vaccine,
|
||||
':done' =>
|
||||
self::date($r['DATEOFVACCINATION'] ?? null) ?:
|
||||
self::date($r['DATEREQUIRED'] ?? null) ?:
|
||||
date('Y-m-d'),
|
||||
':due' => self::date($r['DATEREQUIRED'] ?? null),
|
||||
':lot' => trim((string) $r['BATCHNUMBER']) ?: null,
|
||||
':notes' => trim((string) $r['COMMENTS']) ?: null,
|
||||
':manufacturer' => trim((string) $r['MANUFACTURER']) ?: null,
|
||||
':expires' => self::date($r['BATCHEXPIRYDATE'] ?? null),
|
||||
':by' => trim((string) $r['GIVENBY']) ?: null,
|
||||
]);
|
||||
self::link($db, $hash, 'vaccination', $source, (int) $db->lastInsertId(), $runId);
|
||||
}
|
||||
foreach ($src['log'] ?? [] as $r) {
|
||||
if ((int) ($r['LINKTYPE'] ?? -1) !== 0) {
|
||||
continue;
|
||||
}
|
||||
$animal = $links[(int) $r['LINKID']] ?? 0;
|
||||
if (!$animal) {
|
||||
continue;
|
||||
}
|
||||
$source = (int) $r['ID'];
|
||||
$name = $logTypes[(int) $r['LOGTYPEID']] ?? 'Historique';
|
||||
$entity = mb_strtolower($name) === 'poids' ? 'measurement' : 'medical-log';
|
||||
if (self::seen($db, $hash, $entity, $source)) {
|
||||
continue;
|
||||
}
|
||||
$text = trim((string) $r['COMMENTS']);
|
||||
$date = self::date($r['DATE'] ?? null) ?: date('Y-m-d');
|
||||
if ($entity === 'measurement' && preg_match('/([0-9]+(?:[.,][0-9]+)?)/', $text, $m)) {
|
||||
$value = (float) str_replace(',', '.', $m[1]);
|
||||
$db->prepare(
|
||||
"INSERT INTO measurements(animal_id,measured_at,type,value,unit,notes) VALUES(:animal,:date,'weight',:value,'kg',:notes)",
|
||||
)->execute([':animal' => $animal, ':date' => $date, ':value' => $value, ':notes' => $text]);
|
||||
} else {
|
||||
$db->prepare(
|
||||
"INSERT INTO medical_notes(animal_id,noted_at,kind,reason,plan) VALUES(:animal,:date,'autre',:reason,:plan)",
|
||||
)->execute([':animal' => $animal, ':date' => $date, ':reason' => $name, ':plan' => $text ?: null]);
|
||||
}
|
||||
self::link($db, $hash, $entity, $source, (int) $db->lastInsertId(), $runId);
|
||||
}
|
||||
if ($ownsTransaction) {
|
||||
$db->commit();
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
if ($ownsTransaction && $db->inTransaction()) {
|
||||
$db->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
private static function animalLinks(PDO $db, string $hash): array
|
||||
{
|
||||
$s = $db->prepare(
|
||||
"SELECT source_id,target_id FROM asm3_import_links WHERE source_sha256=:h AND entity_type='animal'",
|
||||
);
|
||||
$s->execute([':h' => $hash]);
|
||||
$o = [];
|
||||
foreach ($s as $r) {
|
||||
$o[(int) $r['source_id']] = (int) $r['target_id'];
|
||||
}
|
||||
return $o;
|
||||
}
|
||||
private static function runId(PDO $db, string $p, string $h, ?string $b, string $label): int
|
||||
{
|
||||
$db->prepare(
|
||||
"INSERT INTO asm3_import_runs(source_name,source_sha256,status,summary_json,backup_name) VALUES(:n,:h,'completed','{}',:b)",
|
||||
)->execute([':n' => basename($p) . ' · ' . $label, ':h' => $h, ':b' => $b]);
|
||||
return (int) $db->lastInsertId();
|
||||
}
|
||||
private static function seen(PDO $db, string $h, string $e, int $s): bool
|
||||
{
|
||||
$q = $db->prepare('SELECT 1 FROM asm3_import_links WHERE source_sha256=:h AND entity_type=:e AND source_id=:s');
|
||||
$q->execute([':h' => $h, ':e' => $e, ':s' => $s]);
|
||||
return (bool) $q->fetchColumn();
|
||||
}
|
||||
private static function link(PDO $db, string $h, string $e, int $s, int $t, int $r): void
|
||||
{
|
||||
$db->prepare(
|
||||
'INSERT INTO asm3_import_links(source_sha256,entity_type,source_id,target_id,import_run_id) VALUES(:h,:e,:s,:t,:r)',
|
||||
)->execute([':h' => $h, ':e' => $e, ':s' => $s, ':t' => $t, ':r' => $r]);
|
||||
}
|
||||
private static function date(mixed $v): ?string
|
||||
{
|
||||
return $v && preg_match('/^\d{4}-\d{2}-\d{2}/', (string) $v, $m) ? $m[0] : null;
|
||||
}
|
||||
}
|
||||
103
app/Services/Asm3ContactImporter.php
Normal file
103
app/Services/Asm3ContactImporter.php
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class Asm3ContactImporter
|
||||
{
|
||||
public static function run(string $path, PDO $db, bool $apply = false, bool $createBackup = true): array
|
||||
{
|
||||
$plan = Asm3ContactPlanner::plan($path, $db);
|
||||
$hash = hash_file('sha256', $path);
|
||||
if ($hash === false) {
|
||||
throw new RuntimeException(t('asm3.hash_unavailable'));
|
||||
}
|
||||
$result = [
|
||||
'mode' => $apply ? 'apply' : 'dry-run',
|
||||
'entity' => 'contacts',
|
||||
'planned' => $plan['summary'],
|
||||
'created' => 0,
|
||||
'linked_matches' => 0,
|
||||
'skipped_match' => 0,
|
||||
'skipped_review' => $plan['summary']['review'],
|
||||
'backup' => null,
|
||||
'read_only' => !$apply,
|
||||
'would_create' => $plan['summary']['create'],
|
||||
];
|
||||
if (!$apply) {
|
||||
return $result;
|
||||
}
|
||||
if ($createBackup) {
|
||||
$backup = BackupService::create('pre-asm3-import');
|
||||
$result['backup'] = $backup['name'] ?? null;
|
||||
}
|
||||
$ownsTransaction = !$db->inTransaction();
|
||||
if ($ownsTransaction) {
|
||||
$db->beginTransaction();
|
||||
}
|
||||
try {
|
||||
$db->prepare(
|
||||
"INSERT INTO asm3_import_runs(source_name,source_sha256,status,summary_json,backup_name) VALUES(:name,:hash,'completed','{}',:backup)",
|
||||
)->execute([':name' => basename($path) . ' · contacts', ':hash' => $hash, ':backup' => $result['backup']]);
|
||||
$runId = (int) $db->lastInsertId();
|
||||
foreach ($plan['items'] as $item) {
|
||||
if ($item['action'] === 'review') {
|
||||
continue;
|
||||
}
|
||||
$check = $db->prepare(
|
||||
"SELECT target_id FROM asm3_import_links WHERE source_sha256=:hash AND entity_type='contact' AND source_id=:source",
|
||||
);
|
||||
$check->execute([':hash' => $hash, ':source' => $item['asm3_id']]);
|
||||
if ($check->fetchColumn()) {
|
||||
continue;
|
||||
}
|
||||
$c = $item['mapped'];
|
||||
if ($item['action'] === 'match') {
|
||||
$target = (int) ($item['globinours_candidates'][0]['id'] ?? 0);
|
||||
if ($target <= 0) {
|
||||
$result['skipped_match']++;
|
||||
continue;
|
||||
}
|
||||
$result['linked_matches']++;
|
||||
} else {
|
||||
$db->prepare(
|
||||
'INSERT INTO directory_contacts(kind,name,phone,email,address,postal_code,city,country,notes) VALUES(:kind,:name,:phone,:email,:address,:postal,:city,:country,:notes)',
|
||||
)->execute([
|
||||
':kind' => $c['kind'],
|
||||
':name' => $c['name'],
|
||||
':phone' => $c['phone'],
|
||||
':email' => $c['email'],
|
||||
':address' => $c['address'],
|
||||
':postal' => $c['postal_code'],
|
||||
':city' => $c['city'],
|
||||
':country' => $c['country'],
|
||||
':notes' => $c['notes'],
|
||||
]);
|
||||
$target = (int) $db->lastInsertId();
|
||||
$result['created']++;
|
||||
}
|
||||
$role = $db->prepare(
|
||||
'INSERT OR IGNORE INTO directory_contact_roles(contact_id,role) VALUES(:contact,:role)',
|
||||
);
|
||||
foreach ($c['roles'] as $value) {
|
||||
$role->execute([':contact' => $target, ':role' => $value]);
|
||||
}
|
||||
$db->prepare(
|
||||
"INSERT INTO asm3_import_links(source_sha256,entity_type,source_id,target_id,import_run_id) VALUES(:hash,'contact',:source,:target,:run)",
|
||||
)->execute([':hash' => $hash, ':source' => $item['asm3_id'], ':target' => $target, ':run' => $runId]);
|
||||
}
|
||||
$db->prepare('UPDATE asm3_import_runs SET summary_json=:summary WHERE id=:id')->execute([
|
||||
':summary' => json_encode($result, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||||
':id' => $runId,
|
||||
]);
|
||||
if ($ownsTransaction) {
|
||||
$db->commit();
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
if ($ownsTransaction && $db->inTransaction()) {
|
||||
$db->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
164
app/Services/Asm3ContactPlanner.php
Normal file
164
app/Services/Asm3ContactPlanner.php
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class Asm3ContactPlanner
|
||||
{
|
||||
public static function plan(string $path, PDO $db): array
|
||||
{
|
||||
$source = Asm3Analyzer::rows($path, ['owner', 'animal', 'adoption', 'animalvaccination']);
|
||||
$adopters = [];
|
||||
$depositors = [];
|
||||
$vets = [];
|
||||
foreach ($source['adoption'] ?? [] as $r) {
|
||||
if ((int) ($r['OWNERID'] ?? 0) > 0) {
|
||||
$adopters[(int) $r['OWNERID']] = true;
|
||||
}
|
||||
}
|
||||
foreach ($source['animal'] ?? [] as $r) {
|
||||
foreach (['BROUGHTINBYOWNERID', 'ORIGINALOWNERID'] as $field) {
|
||||
if ((int) ($r[$field] ?? 0) > 0) {
|
||||
$depositors[(int) $r[$field]] = true;
|
||||
}
|
||||
}
|
||||
foreach (['CURRENTVETID', 'OWNERSVETID'] as $field) {
|
||||
if ((int) ($r[$field] ?? 0) > 0) {
|
||||
$vets[(int) $r[$field]] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($source['animalvaccination'] ?? [] as $r) {
|
||||
if ((int) ($r['ADMINISTERINGVETID'] ?? 0) > 0) {
|
||||
$vets[(int) $r['ADMINISTERINGVETID']] = true;
|
||||
}
|
||||
}
|
||||
$existing = $db
|
||||
->query('SELECT id,kind,name,phone,email,city FROM directory_contacts WHERE deleted_at IS NULL')
|
||||
->fetchAll(PDO::FETCH_ASSOC);
|
||||
$items = [];
|
||||
$summary = ['create' => 0, 'match' => 0, 'review' => 0];
|
||||
foreach ($source['owner'] ?? [] as $row) {
|
||||
$mapped = self::map(
|
||||
$row,
|
||||
isset($adopters[(int) $row['ID']]),
|
||||
isset($depositors[(int) $row['ID']]),
|
||||
isset($vets[(int) $row['ID']]),
|
||||
);
|
||||
$strong = [];
|
||||
$weak = [];
|
||||
$reasons = [];
|
||||
foreach ($existing as $contact) {
|
||||
$email = self::text($mapped['email']);
|
||||
$phone = self::phone($mapped['phone']);
|
||||
$sameEmail = $email !== '' && $email === self::text($contact['email']);
|
||||
$samePhone = $phone !== '' && $phone === self::phone($contact['phone']);
|
||||
$sameIdentity =
|
||||
self::text($mapped['name']) === self::text($contact['name']) &&
|
||||
self::text($mapped['city']) === self::text($contact['city']);
|
||||
if ($sameEmail || $samePhone || $sameIdentity) {
|
||||
$strong[(int) $contact['id']] = $contact;
|
||||
if ($sameEmail) {
|
||||
$reasons[] = 'même adresse e-mail';
|
||||
}
|
||||
if ($samePhone) {
|
||||
$reasons[] = 'même téléphone';
|
||||
}
|
||||
if ($sameIdentity) {
|
||||
$reasons[] = 'même nom et ville';
|
||||
}
|
||||
} elseif (
|
||||
self::text($mapped['name']) !== '' &&
|
||||
self::text($mapped['name']) === self::text($contact['name'])
|
||||
) {
|
||||
$weak[(int) $contact['id']] = $contact;
|
||||
}
|
||||
}
|
||||
if (count($strong) === 1) {
|
||||
$action = 'match';
|
||||
$candidates = $strong;
|
||||
} elseif (count($strong) > 1) {
|
||||
$action = 'review';
|
||||
$candidates = $strong;
|
||||
} elseif ($weak) {
|
||||
$action = 'review';
|
||||
$candidates = $weak;
|
||||
$reasons[] = 'même nom uniquement';
|
||||
} else {
|
||||
$action = 'create';
|
||||
$candidates = [];
|
||||
}
|
||||
$summary[$action]++;
|
||||
$items[] = [
|
||||
'asm3_id' => (int) $row['ID'],
|
||||
'action' => $action,
|
||||
'match_reasons' => array_values(array_unique($reasons)),
|
||||
'globinours_candidates' => array_values(
|
||||
array_map(
|
||||
static fn($c) => ['id' => (int) $c['id'], 'name' => $c['name'], 'city' => $c['city']],
|
||||
$candidates,
|
||||
),
|
||||
),
|
||||
'mapped' => $mapped,
|
||||
];
|
||||
}
|
||||
return ['source' => basename($path), 'read_only' => true, 'summary' => $summary, 'items' => $items];
|
||||
}
|
||||
private static function map(array $r, bool $adopter, bool $depositor, bool $vetReferenced): array
|
||||
{
|
||||
$yes = static fn($v) => (string) $v === '1';
|
||||
$name = trim(
|
||||
(string) ($r['OWNERNAME'] ?? '' ?:
|
||||
trim((string) ($r['OWNERFORENAMES'] ?? '') . ' ' . (string) ($r['OWNERSURNAME'] ?? ''))),
|
||||
);
|
||||
$organization =
|
||||
(bool) preg_match('/\b(cabinet|clinique|cr[eé]matorium|association|refuge|fourri[eè]re)\b/iu', $name) ||
|
||||
$yes($r['ISSHELTER'] ?? 0) ||
|
||||
$yes($r['ISSUPPLIER'] ?? 0);
|
||||
$roles = [];
|
||||
if ($adopter || $yes($r['ISADOPTER'] ?? 0)) {
|
||||
$roles[] = 'adoptant';
|
||||
}
|
||||
if ($yes($r['ISFOSTERER'] ?? 0)) {
|
||||
$roles[] = 'fa';
|
||||
}
|
||||
if ($yes($r['ISVOLUNTEER'] ?? 0)) {
|
||||
$roles[] = 'benevole';
|
||||
}
|
||||
if ($depositor) {
|
||||
$roles[] = 'deposant';
|
||||
}
|
||||
if ($vetReferenced || $yes($r['ISVET'] ?? 0)) {
|
||||
$roles[] = $organization ? 'cabinet' : 'veterinaire';
|
||||
}
|
||||
if (preg_match('/cr[eé]matorium/iu', $name)) {
|
||||
$roles[] = 'crematorium';
|
||||
}
|
||||
$phones = array_values(
|
||||
array_filter([
|
||||
trim((string) ($r['MOBILETELEPHONE'] ?? '')),
|
||||
trim((string) ($r['HOMETELEPHONE'] ?? '')),
|
||||
trim((string) ($r['WORKTELEPHONE'] ?? '')),
|
||||
]),
|
||||
);
|
||||
return [
|
||||
'kind' => $organization ? 'organization' : 'person',
|
||||
'name' => $name ?: 'Contact ASM3 ' . (int) $r['ID'],
|
||||
'phone' => $phones[0] ?? null,
|
||||
'email' => trim((string) ($r['EMAILADDRESS'] ?? '')) ?: null,
|
||||
'address' => trim((string) ($r['OWNERADDRESS'] ?? '')) ?: null,
|
||||
'postal_code' => trim((string) ($r['OWNERPOSTCODE'] ?? '')) ?: null,
|
||||
'city' => trim((string) ($r['OWNERTOWN'] ?? '')) ?: null,
|
||||
'country' => trim((string) ($r['OWNERCOUNTRY'] ?? '')) ?: 'France',
|
||||
'notes' => trim((string) ($r['COMMENTS'] ?? '')) ?: null,
|
||||
'roles' => array_values(array_unique($roles)),
|
||||
];
|
||||
}
|
||||
private static function text(mixed $v): string
|
||||
{
|
||||
return mb_strtolower(trim((string) $v));
|
||||
}
|
||||
private static function phone(mixed $v): string
|
||||
{
|
||||
return (string) preg_replace('/\D+/', '', (string) $v);
|
||||
}
|
||||
}
|
||||
92
app/Services/Asm3IntakeRecovery.php
Normal file
92
app/Services/Asm3IntakeRecovery.php
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class Asm3IntakeRecovery
|
||||
{
|
||||
private const CITIES = [
|
||||
'Betton' => ['betton', 'beton'],
|
||||
'Brignogan-Plages' => ['brignogan'],
|
||||
'Fougères' => ['fougère', 'fougeres'],
|
||||
'Gouesnou' => ['gouesnou'],
|
||||
'Goulven' => ['goulven'],
|
||||
'Guissény' => ['guissény', 'guisseny'],
|
||||
'Kernouës' => ['kernouës', 'kernoues'],
|
||||
'Kerlouan' => ['kerlouan'],
|
||||
'Landerneau' => ['landerneau'],
|
||||
'Lanhouarneau' => ['lanhouarneau'],
|
||||
'Landivisiau' => ['landivisiau'],
|
||||
'Le Drennec' => ['le drennec'],
|
||||
'Le Folgoët' => ['le folgoët', 'le folgoet', 'du folgoët', 'du folgoet'],
|
||||
'Lesneven' => ['lesneven', 'lesnevenet'],
|
||||
'Perros-Guirec' => ['perros guirec', 'perros guierrec'],
|
||||
'Ploudaniel' => ['ploudaniel'],
|
||||
'Plouescat' => ['plouescat'],
|
||||
'Plouguerneau' => ['plouguerneau'],
|
||||
'Plouider' => ['plouider'],
|
||||
'Plounéour-Brignogan-Plages' => [
|
||||
'plounéour trez',
|
||||
'plouneour trez',
|
||||
'plounéour brignogan plage',
|
||||
'plouneour brignogan plage',
|
||||
],
|
||||
'Plounévez-Lochrist' => ['plounevez lochrist', 'plounévez lochrist'],
|
||||
'Plouzané' => ['plouzané', 'plouzane'],
|
||||
'Saint-Frégant' => ['saint frégant', 'saint fregant'],
|
||||
'Saint-Méen' => ['saint méen', 'saint meen'],
|
||||
'Saint-Pol-de-Léon' => ['saint pol de leon', 'saint-pol-de-léon'],
|
||||
];
|
||||
|
||||
public static function reason(array $row): string
|
||||
{
|
||||
return trim((string) ($row['REASONFORENTRY'] ?? ''));
|
||||
}
|
||||
|
||||
public static function municipality(string $reason): ?string
|
||||
{
|
||||
$text = self::plain($reason);
|
||||
if ($text === '') {
|
||||
return null;
|
||||
}
|
||||
$found = [];
|
||||
foreach (self::CITIES as $city => $aliases) {
|
||||
foreach ($aliases as $alias) {
|
||||
if (str_contains($text, self::plain($alias))) {
|
||||
$found[$city] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (count($found) !== 1) {
|
||||
return null;
|
||||
}
|
||||
$city = (string) array_key_first($found);
|
||||
$words =
|
||||
'trouv|errant|recueill|recup|ramass|vient|arriv|fourriere|mairie|police municipale|jardin|terrain|ferme|hangar|grange|ne dans|abandonn|signal';
|
||||
if (preg_match('/^\s*' . self::cityPattern($city) . '\b/u', $text)) {
|
||||
return $city;
|
||||
}
|
||||
if (preg_match('/(?:' . $words . ').{0,100}' . self::cityPattern($city) . '/u', $text)) {
|
||||
return $city;
|
||||
}
|
||||
if (preg_match('/' . self::cityPattern($city) . '.{0,55}(?:' . $words . ')/u', $text)) {
|
||||
return $city;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function cityPattern(string $city): string
|
||||
{
|
||||
$parts = [];
|
||||
foreach (self::CITIES[$city] as $alias) {
|
||||
$parts[] = preg_quote(self::plain($alias), '/');
|
||||
}
|
||||
return '(?:' . implode('|', $parts) . ')';
|
||||
}
|
||||
private static function plain(string $value): string
|
||||
{
|
||||
$value = mb_strtolower(trim($value));
|
||||
$v = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value);
|
||||
return preg_replace('/\s+/u', ' ', is_string($v) ? $v : $value) ?? $value;
|
||||
}
|
||||
}
|
||||
102
app/Services/Asm3LitterImporter.php
Normal file
102
app/Services/Asm3LitterImporter.php
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
final class Asm3LitterImporter
|
||||
{
|
||||
public static function run(string $path, PDO $db, bool $apply = false, bool $createBackup = true): array
|
||||
{
|
||||
$hash = hash_file('sha256', $path);
|
||||
$src = Asm3Analyzer::rows($path, ['animallitter', 'animal']);
|
||||
$links = [];
|
||||
$q = $db->prepare(
|
||||
"SELECT source_id,target_id FROM asm3_import_links WHERE source_sha256=:h AND entity_type='animal'",
|
||||
);
|
||||
$q->execute([':h' => $hash]);
|
||||
foreach ($q as $r) {
|
||||
$links[(int) $r['source_id']] = (int) $r['target_id'];
|
||||
}
|
||||
$animals = [];
|
||||
foreach ($src['animal'] ?? [] as $a) {
|
||||
$animals[(string) $a['ACCEPTANCENUMBER']][] = (int) $a['ID'];
|
||||
}
|
||||
$result = [
|
||||
'mode' => $apply ? 'apply' : 'dry-run',
|
||||
'read_only' => !$apply,
|
||||
'litters' => count($src['animallitter'] ?? []),
|
||||
'would_import' => 0,
|
||||
'unresolved' => 0,
|
||||
'created' => 0,
|
||||
'kittens_linked' => 0,
|
||||
'backup' => null,
|
||||
];
|
||||
foreach ($src['animallitter'] ?? [] as $l) {
|
||||
$parent = (int) $l['PARENTANIMALID'];
|
||||
$kids = array_filter($animals[(string) $l['ACCEPTANCENUMBER']] ?? [], fn($id) => $id !== $parent);
|
||||
if (!isset($links[$parent]) || array_filter($kids, fn($id) => !isset($links[$id]))) {
|
||||
$result['unresolved']++;
|
||||
continue;
|
||||
}
|
||||
$result['would_import']++;
|
||||
}
|
||||
if (!$apply) {
|
||||
return $result;
|
||||
}
|
||||
if ($createBackup) {
|
||||
$b = BackupService::create('pre-asm3-import');
|
||||
$result['backup'] = $b['name'] ?? null;
|
||||
}
|
||||
$owns = !$db->inTransaction();
|
||||
if ($owns) {
|
||||
$db->beginTransaction();
|
||||
}
|
||||
try {
|
||||
$db->prepare(
|
||||
"INSERT INTO asm3_import_runs(source_name,source_sha256,status,summary_json,backup_name) VALUES(:n,:h,'completed','{}',:b)",
|
||||
)->execute([':n' => basename($path) . ' · portées', ':h' => $hash, ':b' => $result['backup']]);
|
||||
$run = (int) $db->lastInsertId();
|
||||
foreach ($src['animallitter'] ?? [] as $l) {
|
||||
$source = (int) $l['ID'];
|
||||
$seen = $db->prepare(
|
||||
"SELECT 1 FROM asm3_import_links WHERE source_sha256=:h AND entity_type='litter' AND source_id=:s",
|
||||
);
|
||||
$seen->execute([':h' => $hash, ':s' => $source]);
|
||||
if ($seen->fetchColumn()) {
|
||||
continue;
|
||||
}
|
||||
$parent = (int) $l['PARENTANIMALID'];
|
||||
$kids = array_values(
|
||||
array_filter($animals[(string) $l['ACCEPTANCENUMBER']] ?? [], fn($id) => $id !== $parent),
|
||||
);
|
||||
if (!isset($links[$parent]) || array_filter($kids, fn($id) => !isset($links[$id]))) {
|
||||
continue;
|
||||
}
|
||||
$db->prepare('INSERT INTO litters(mother_id,birth_date,notes) VALUES(:parent,:date,:notes)')->execute([
|
||||
':parent' => $links[$parent],
|
||||
':date' => substr((string) $l['DATE'], 0, 10),
|
||||
':notes' => trim((string) $l['COMMENTS']) ?: null,
|
||||
]);
|
||||
$target = (int) $db->lastInsertId();
|
||||
$ins = $db->prepare(
|
||||
'INSERT OR IGNORE INTO litter_kittens(litter_id,animal_id,position) VALUES(:litter,:animal,:position)',
|
||||
);
|
||||
foreach ($kids as $i => $kid) {
|
||||
$ins->execute([':litter' => $target, ':animal' => $links[$kid], ':position' => $i + 1]);
|
||||
$result['kittens_linked']++;
|
||||
}
|
||||
$db->prepare(
|
||||
"INSERT INTO asm3_import_links(source_sha256,entity_type,source_id,target_id,import_run_id) VALUES(:h,'litter',:s,:t,:r)",
|
||||
)->execute([':h' => $hash, ':s' => $source, ':t' => $target, ':r' => $run]);
|
||||
$result['created']++;
|
||||
}
|
||||
if ($owns) {
|
||||
$db->commit();
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
if ($owns && $db->inTransaction()) {
|
||||
$db->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
199
app/Services/Asm3MigrationPlanner.php
Normal file
199
app/Services/Asm3MigrationPlanner.php
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class Asm3MigrationPlanner
|
||||
{
|
||||
public static function plan(string $path, PDO $db): array
|
||||
{
|
||||
$source = Asm3Analyzer::rows($path, ['animal', 'species', 'basecolour', 'lksex']);
|
||||
$species = self::lookup($source['species'] ?? [], 'ID', 'SPECIESNAME');
|
||||
$colors = self::lookup($source['basecolour'] ?? [], 'ID', 'BASECOLOUR');
|
||||
$sexes = self::lookup($source['lksex'] ?? [], 'ID', 'SEX');
|
||||
$existing = $db
|
||||
->query('SELECT id,name,internal_code,chip_id,birth_date FROM animals')
|
||||
->fetchAll(PDO::FETCH_ASSOC);
|
||||
$byChip = [];
|
||||
$byCode = [];
|
||||
$byIdentity = [];
|
||||
$byName = [];
|
||||
foreach ($existing as $animal) {
|
||||
$chip = self::identifier($animal['chip_id'] ?? '');
|
||||
if ($chip !== '') {
|
||||
$byChip[$chip][] = $animal;
|
||||
}
|
||||
$code = self::text($animal['internal_code'] ?? '');
|
||||
if ($code !== '') {
|
||||
$byCode[$code][] = $animal;
|
||||
}
|
||||
$identity = self::identity($animal['name'] ?? '', $animal['birth_date'] ?? '');
|
||||
if ($identity !== '') {
|
||||
$byIdentity[$identity][] = $animal;
|
||||
}
|
||||
$name = self::text($animal['name'] ?? '');
|
||||
if ($name !== '') {
|
||||
$byName[$name][] = $animal;
|
||||
}
|
||||
}
|
||||
$items = [];
|
||||
$summary = ['create' => 0, 'match' => 0, 'review' => 0];
|
||||
foreach ($source['animal'] ?? [] as $row) {
|
||||
$mapped = self::mapAnimal($row, $species, $colors, $sexes);
|
||||
$candidates = [];
|
||||
$reasons = [];
|
||||
$chip = self::identifier($mapped['chip_id']);
|
||||
if ($chip !== '' && !empty($byChip[$chip])) {
|
||||
foreach ($byChip[$chip] as $a) {
|
||||
$candidates[(int) $a['id']] = $a;
|
||||
}
|
||||
$reasons[] = 'même identification';
|
||||
}
|
||||
// Les codes refuge peuvent être réutilisés après une remise à zéro d’ASM3.
|
||||
// Un code seul n’est donc jamais une preuve suffisante entre deux sauvegardes historiques.
|
||||
$code = self::text($mapped['internal_code']);
|
||||
$identity = self::identity($mapped['name'], $mapped['birth_date']);
|
||||
if ($identity !== '' && !empty($byIdentity[$identity])) {
|
||||
foreach ($byIdentity[$identity] as $a) {
|
||||
$candidates[(int) $a['id']] = $a;
|
||||
}
|
||||
$reasons[] = 'même nom et naissance';
|
||||
}
|
||||
$strongMatch = count($candidates) > 0;
|
||||
if (!$strongMatch) {
|
||||
$name = self::text($mapped['name']);
|
||||
if ($name !== '' && !empty($byName[$name])) {
|
||||
$weakByName = $byName[$name];
|
||||
$knownDifferentBirths = $mapped['birth_date'] !== '' && $mapped['birth_date'] !== null;
|
||||
foreach ($weakByName as $a) {
|
||||
$knownDifferentBirths =
|
||||
$knownDifferentBirths &&
|
||||
!empty($a['birth_date']) &&
|
||||
$a['birth_date'] !== $mapped['birth_date'];
|
||||
}
|
||||
if (!$knownDifferentBirths) {
|
||||
foreach ($weakByName as $a) {
|
||||
$candidates[(int) $a['id']] = $a;
|
||||
}
|
||||
$reasons[] = 'même nom uniquement';
|
||||
}
|
||||
}
|
||||
}
|
||||
// Un code isolé et réutilisé ne doit ni fusionner, ni bloquer la création.
|
||||
// Asm3AnimalImporter suffixera le code technique si nécessaire.
|
||||
if (!$strongMatch && count($candidates) === 0) {
|
||||
$action = 'create';
|
||||
} elseif ($strongMatch && count($candidates) === 1) {
|
||||
$action = 'match';
|
||||
} else {
|
||||
$action = 'review';
|
||||
}
|
||||
$summary[$action]++;
|
||||
$items[] = [
|
||||
'asm3_id' => (int) ($row['ID'] ?? 0),
|
||||
'action' => $action,
|
||||
'match_reasons' => array_values(array_unique($reasons)),
|
||||
'globinours_candidates' => array_values(
|
||||
array_map(
|
||||
static fn($a) => ['id' => (int) $a['id'], 'name' => $a['name'], 'code' => $a['internal_code']],
|
||||
$candidates,
|
||||
),
|
||||
),
|
||||
'mapped' => $mapped,
|
||||
];
|
||||
}
|
||||
return [
|
||||
'source' => basename($path),
|
||||
'generated_at' => date(DATE_ATOM),
|
||||
'read_only' => true,
|
||||
'summary' => $summary,
|
||||
'items' => $items,
|
||||
'lookups' => ['species' => $species, 'colors' => $colors, 'sexes' => $sexes],
|
||||
];
|
||||
}
|
||||
|
||||
private static function mapAnimal(array $r, array $species, array $colors, array $sexes): array
|
||||
{
|
||||
$date = static fn($v) => $v && preg_match('/^\d{4}-\d{2}-\d{2}/', (string) $v, $m) ? $m[0] : null;
|
||||
$yes = static fn($v) => (string) $v === '1';
|
||||
$tri = static fn($v) => match ((string) $v) {
|
||||
'0' => 'no',
|
||||
'1' => 'yes',
|
||||
default => 'unknown',
|
||||
};
|
||||
$chip = trim((string) ($r['IDENTICHIPNUMBER'] ?? ''));
|
||||
$tattoo = trim((string) ($r['TATTOONUMBER'] ?? ''));
|
||||
$identifier = $chip !== '' ? $chip : $tattoo;
|
||||
$deceased = $date($r['DECEASEDDATE'] ?? null) !== null || $yes($r['PUTTOSLEEP'] ?? 0);
|
||||
$availability = $yes($r['ISNOTAVAILABLEFORADOPTION'] ?? 0)
|
||||
? 'not_available'
|
||||
: ($yes($r['ADOPTABLE'] ?? 0)
|
||||
? 'available'
|
||||
: 'unknown');
|
||||
return [
|
||||
'name' => trim((string) ($r['ANIMALNAME'] ?? '')),
|
||||
'internal_code' => trim((string) ($r['SHELTERCODE'] ?? '' ?: $r['SHORTCODE'] ?? '')),
|
||||
'species' => self::species($species[(string) ($r['SPECIESID'] ?? '')] ?? ''),
|
||||
'sex' => match (mb_strtolower((string) ($sexes[(string) ($r['SEX'] ?? '')] ?? ''))) {
|
||||
'mâle' => 'M',
|
||||
'femelle' => 'F',
|
||||
default => 'U',
|
||||
},
|
||||
'birth_date' => $date($r['DATEOFBIRTH'] ?? null),
|
||||
'birth_is_estimated' => $yes($r['ESTIMATEDDOB'] ?? 0) ? 1 : 0,
|
||||
'breed' => trim((string) ($r['BREEDNAME'] ?? '')),
|
||||
'color' => $colors[(string) ($r['BASECOLOURID'] ?? '')] ?? null,
|
||||
'notes' => trim((string) ($r['ANIMALCOMMENTS'] ?? '')),
|
||||
'intake_date' => $date($r['MOSTRECENTENTRYDATE'] ?? null ?: $r['DATEBROUGHTIN'] ?? null),
|
||||
'status' => $deceased ? 'decede' : ($yes($r['ISQUARANTINE'] ?? 0) ? 'quarantaine' : 'refuge'),
|
||||
'archived' => $yes($r['ARCHIVED'] ?? 0),
|
||||
'deceased_date' => $date($r['DECEASEDDATE'] ?? null),
|
||||
'identification_type' => $chip !== '' ? 'microchip' : ($tattoo !== '' ? 'tattoo' : 'unknown'),
|
||||
'chip_id' => $identifier,
|
||||
'identification_date' => $date($chip !== '' ? $r['IDENTICHIPDATE'] ?? null : $r['TATTOODATE'] ?? null),
|
||||
'identification_registration_status' => match ((string) ($r['IDENTICHIPSTATUS'] ?? '')) {
|
||||
'1' => 'registered',
|
||||
'0' => 'pending',
|
||||
default => 'unknown',
|
||||
},
|
||||
'sterilization_status' => $yes($r['NEUTERED'] ?? 0) ? 'yes' : 'unknown',
|
||||
'sterilization_date' => $date($r['NEUTEREDDATE'] ?? null),
|
||||
'adoption_availability' => $availability,
|
||||
'adoption_available_from' => $date($r['HOLDUNTILDATE'] ?? null),
|
||||
'adoption_unavailability_reason' => trim((string) ($r['REASONNO'] ?? '')),
|
||||
'compatibility_dogs' => $tri($r['ISGOODWITHDOGS'] ?? null),
|
||||
'compatibility_cats' => $tri($r['ISGOODWITHCATS'] ?? null),
|
||||
'compatibility_children' => $tri($r['ISGOODWITHCHILDREN'] ?? null),
|
||||
'house_trained' => $tri($r['ISHOUSETRAINED'] ?? null),
|
||||
];
|
||||
}
|
||||
private static function lookup(array $rows, string $key, string $value): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$out[(string) $row[$key]] = (string) $row[$value];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
private static function species(string $value): string
|
||||
{
|
||||
return match (mb_strtolower($value)) {
|
||||
'chat' => 'chat',
|
||||
'chien' => 'chien',
|
||||
default => mb_strtolower($value) ?: 'inconnu',
|
||||
};
|
||||
}
|
||||
private static function identifier(mixed $value): string
|
||||
{
|
||||
return strtoupper((string) preg_replace('/[^a-zA-Z0-9]/', '', (string) $value));
|
||||
}
|
||||
private static function text(mixed $value): string
|
||||
{
|
||||
return mb_strtolower(trim((string) $value));
|
||||
}
|
||||
private static function identity(mixed $name, mixed $birth): string
|
||||
{
|
||||
$name = self::text($name);
|
||||
$birth = trim((string) $birth);
|
||||
return $name !== '' && $birth !== '' ? $name . '|' . $birth : '';
|
||||
}
|
||||
}
|
||||
271
app/Services/Asm3MovementImporter.php
Normal file
271
app/Services/Asm3MovementImporter.php
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class Asm3MovementImporter
|
||||
{
|
||||
public static function run(string $path, PDO $db, bool $apply = false, bool $createBackup = true): array
|
||||
{
|
||||
$hash = hash_file('sha256', $path);
|
||||
if ($hash === false) {
|
||||
throw new RuntimeException(t('asm3.hash_unavailable'));
|
||||
}
|
||||
$rows = Asm3Analyzer::rows($path, ['adoption'])['adoption'] ?? [];
|
||||
$events = [];
|
||||
$unsupported = 0;
|
||||
foreach ($rows as $row) {
|
||||
$source = (int) ($row['ID'] ?? 0);
|
||||
$type = (int) ($row['MOVEMENTTYPE'] ?? 0);
|
||||
$movementDate = self::date($row['MOVEMENTDATE'] ?? null);
|
||||
$returnDate = self::date($row['RETURNDATE'] ?? null);
|
||||
$reservationDate = self::date($row['RESERVATIONDATE'] ?? null);
|
||||
$cancelDate = self::date($row['RESERVATIONCANCELLEDDATE'] ?? null);
|
||||
if ($reservationDate) {
|
||||
$events[] = self::event($row, $source, 'reservation', $reservationDate, 'Réservation ASM3');
|
||||
}
|
||||
if ($cancelDate) {
|
||||
$events[] = self::event($row, $source, 'cancellation', $cancelDate, 'Annulation de réservation ASM3');
|
||||
}
|
||||
if ($movementDate && $type === 1) {
|
||||
$events[] = self::event($row, $source, 'adoption', $movementDate, 'Adoption définitive ASM3');
|
||||
} elseif ($movementDate && in_array($type, [2, 12], true)) {
|
||||
$events[] = self::event(
|
||||
$row,
|
||||
$source,
|
||||
'foster',
|
||||
$movementDate,
|
||||
$type === 12 || (string) ($row['ISPERMANENTFOSTER'] ?? 0) === '1'
|
||||
? 'FA permanente ASM3'
|
||||
: 'Famille d’accueil ASM3',
|
||||
);
|
||||
} elseif ($movementDate && !in_array($type, [0, 9, 10], true)) {
|
||||
$unsupported++;
|
||||
}
|
||||
if ($returnDate && in_array($type, [1, 2, 12], true)) {
|
||||
$events[] = self::event(
|
||||
$row,
|
||||
$source,
|
||||
'return',
|
||||
$returnDate,
|
||||
trim((string) ($row['REASONFORRETURN'] ?? '')) ?: 'Retour enregistré dans ASM3',
|
||||
);
|
||||
}
|
||||
}
|
||||
$links = self::links($db, $hash);
|
||||
$summary = [
|
||||
'events' => count($events),
|
||||
'adoptions' => 0,
|
||||
'fosters' => 0,
|
||||
'returns' => 0,
|
||||
'reservations' => 0,
|
||||
'cancellations' => 0,
|
||||
'unresolved_animals' => 0,
|
||||
'unresolved_contacts' => 0,
|
||||
'unsupported_movements' => $unsupported,
|
||||
'would_import' => 0,
|
||||
'created' => 0,
|
||||
'already_imported' => 0,
|
||||
];
|
||||
foreach ($events as $event) {
|
||||
$summary[
|
||||
$event['type'] === 'adoption'
|
||||
? 'adoptions'
|
||||
: ($event['type'] === 'foster'
|
||||
? 'fosters'
|
||||
: ($event['type'] === 'return'
|
||||
? 'returns'
|
||||
: ($event['type'] === 'reservation'
|
||||
? 'reservations'
|
||||
: 'cancellations')))
|
||||
]++;
|
||||
if (!isset($links['animal'][$event['animal_source_id']])) {
|
||||
$summary['unresolved_animals']++;
|
||||
continue;
|
||||
}
|
||||
if ($event['contact_source_id'] > 0 && !isset($links['contact'][$event['contact_source_id']])) {
|
||||
$summary['unresolved_contacts']++;
|
||||
}
|
||||
$summary['would_import']++;
|
||||
}
|
||||
if (!$apply) {
|
||||
return ['mode' => 'dry-run', 'read_only' => true, 'summary' => $summary];
|
||||
}
|
||||
if ($createBackup) {
|
||||
$backup = BackupService::create('pre-asm3-import');
|
||||
$backupName = $backup['name'] ?? null;
|
||||
} else {
|
||||
$backupName = null;
|
||||
}
|
||||
$ownsTransaction = !$db->inTransaction();
|
||||
if ($ownsTransaction) {
|
||||
$db->beginTransaction();
|
||||
}
|
||||
try {
|
||||
$db->prepare(
|
||||
"INSERT INTO asm3_import_runs(source_name,source_sha256,status,summary_json,backup_name) VALUES(:name,:hash,'completed','{}',:backup)",
|
||||
)->execute([':name' => basename($path) . ' · mouvements', ':hash' => $hash, ':backup' => $backupName]);
|
||||
$runId = (int) $db->lastInsertId();
|
||||
$affected = [];
|
||||
foreach ($events as $event) {
|
||||
$animalId = $links['animal'][$event['animal_source_id']] ?? 0;
|
||||
if (!$animalId) {
|
||||
continue;
|
||||
}
|
||||
$affected[$animalId] = true;
|
||||
$entity = 'placement-' . $event['type'];
|
||||
$exists = $db->prepare(
|
||||
'SELECT target_id FROM asm3_import_links WHERE source_sha256=:hash AND entity_type=:entity AND source_id=:source',
|
||||
);
|
||||
$exists->execute([':hash' => $hash, ':entity' => $entity, ':source' => $event['source_id']]);
|
||||
if ($exists->fetchColumn()) {
|
||||
$summary['already_imported']++;
|
||||
continue;
|
||||
}
|
||||
$contactId = $links['contact'][$event['contact_source_id']] ?? null;
|
||||
$adoptionId = null;
|
||||
if ($event['type'] === 'adoption') {
|
||||
$contact = self::contact($db, $contactId);
|
||||
$db->prepare(
|
||||
'INSERT INTO adoptions(animal_id,adopter_name,adopter_phone,adopter_email,adopter_address,adopter_postal_code,adopter_city,adopter_country,adoption_date,notes,adopter_contact_id) VALUES(:animal,:name,:phone,:email,:address,:postal,:city,:country,:date,:notes,:contact)',
|
||||
)->execute([
|
||||
':animal' => $animalId,
|
||||
':name' => $contact['name'] ?? 'Adoptant ASM3 non retrouvé',
|
||||
':phone' => $contact['phone'] ?? null,
|
||||
':email' => $contact['email'] ?? null,
|
||||
':address' => $contact['address'] ?? null,
|
||||
':postal' => $contact['postal_code'] ?? null,
|
||||
':city' => $contact['city'] ?? null,
|
||||
':country' => $contact['country'] ?? 'France',
|
||||
':date' => $event['date'],
|
||||
':notes' => $event['notes'] ?: null,
|
||||
':contact' => $contactId,
|
||||
]);
|
||||
$adoptionId = (int) $db->lastInsertId();
|
||||
}
|
||||
$db->prepare(
|
||||
'INSERT INTO animal_placements(animal_id,event_type,event_date,contact_id,reason,notes,adoption_id) VALUES(:animal,:type,:date,:contact,:reason,:notes,:adoption)',
|
||||
)->execute([
|
||||
':animal' => $animalId,
|
||||
':type' => $event['type'],
|
||||
':date' => $event['date'],
|
||||
':contact' => $contactId,
|
||||
':reason' => $event['reason'],
|
||||
':notes' => $event['notes'] ?: null,
|
||||
':adoption' => $adoptionId,
|
||||
]);
|
||||
$placementId = (int) $db->lastInsertId();
|
||||
$db->prepare(
|
||||
'INSERT INTO asm3_import_links(source_sha256,entity_type,source_id,target_id,import_run_id) VALUES(:hash,:entity,:source,:target,:run)',
|
||||
)->execute([
|
||||
':hash' => $hash,
|
||||
':entity' => $entity,
|
||||
':source' => $event['source_id'],
|
||||
':target' => $placementId,
|
||||
':run' => $runId,
|
||||
]);
|
||||
$summary['created']++;
|
||||
}
|
||||
self::syncAnimalStates($db, array_keys($affected));
|
||||
$db->prepare('UPDATE asm3_import_runs SET summary_json=:summary WHERE id=:id')->execute([
|
||||
':summary' => json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||||
':id' => $runId,
|
||||
]);
|
||||
if ($ownsTransaction) {
|
||||
$db->commit();
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
if ($ownsTransaction && $db->inTransaction()) {
|
||||
$db->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
return ['mode' => 'apply', 'read_only' => false, 'backup' => $backupName, 'summary' => $summary];
|
||||
}
|
||||
private static function event(array $row, int $source, string $type, string $date, string $reason): array
|
||||
{
|
||||
return [
|
||||
'source_id' => $source,
|
||||
'type' => $type,
|
||||
'date' => $date,
|
||||
'animal_source_id' => (int) ($row['ANIMALID'] ?? 0),
|
||||
'contact_source_id' => (int) ($row['OWNERID'] ?? 0),
|
||||
'reason' => $reason,
|
||||
'notes' => trim((string) ($row['COMMENTS'] ?? '')),
|
||||
];
|
||||
}
|
||||
private static function date(mixed $value): ?string
|
||||
{
|
||||
return $value && preg_match('/^\d{4}-\d{2}-\d{2}/', (string) $value, $m) ? $m[0] : null;
|
||||
}
|
||||
private static function links(PDO $db, string $hash): array
|
||||
{
|
||||
$out = ['animal' => [], 'contact' => []];
|
||||
$s = $db->prepare(
|
||||
"SELECT entity_type,source_id,target_id FROM asm3_import_links WHERE source_sha256=:hash AND entity_type IN ('animal','contact')",
|
||||
);
|
||||
$s->execute([':hash' => $hash]);
|
||||
foreach ($s as $r) {
|
||||
$out[$r['entity_type']][(int) $r['source_id']] = (int) $r['target_id'];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
private static function contact(PDO $db, ?int $id): ?array
|
||||
{
|
||||
if (!$id) {
|
||||
return null;
|
||||
}
|
||||
$s = $db->prepare('SELECT * FROM directory_contacts WHERE id=:id');
|
||||
$s->execute([':id' => $id]);
|
||||
return $s->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||
}
|
||||
private static function syncAnimalStates(PDO $db, array $animalIds): void
|
||||
{
|
||||
$latest = $db->prepare(
|
||||
'SELECT ap.*,dc.name contact_name,dc.address,dc.postal_code,dc.city FROM animal_placements ap LEFT JOIN directory_contacts dc ON dc.id=ap.contact_id WHERE ap.animal_id=:animal ORDER BY date(ap.event_date) DESC,ap.id DESC LIMIT 1',
|
||||
);
|
||||
foreach ($animalIds as $animalId) {
|
||||
$death = $db->prepare('SELECT 1 FROM animal_deaths WHERE animal_id=?');
|
||||
$death->execute([$animalId]);
|
||||
if ($death->fetchColumn()) {
|
||||
continue;
|
||||
}
|
||||
$latest->execute([':animal' => $animalId]);
|
||||
$event = $latest->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$event) {
|
||||
continue;
|
||||
}
|
||||
$address = implode(
|
||||
' ',
|
||||
array_filter([$event['address'] ?? null, $event['postal_code'] ?? null, $event['city'] ?? null]),
|
||||
);
|
||||
if ($event['event_type'] === 'adoption') {
|
||||
$db->prepare(
|
||||
"UPDATE animals SET status='adopte',is_archived=1,archived_at=:archived,current_address=:address,refuge_room=NULL,care_box_key=NULL,quarantine_until=NULL,updated_at=datetime('now') WHERE id=:id",
|
||||
)->execute([
|
||||
':archived' => $event['event_date'] . ' 12:00:00',
|
||||
':address' =>
|
||||
$address !== ''
|
||||
? $address
|
||||
: 'Adopté par ' . ($event['contact_name'] ?: 'un adoptant non renseigné'),
|
||||
':id' => $animalId,
|
||||
]);
|
||||
} elseif ($event['event_type'] === 'foster') {
|
||||
$db->prepare(
|
||||
"UPDATE animals SET status=CASE WHEN lower(COALESCE(reason,'')) LIKE '%permanente%' THEN 'fa_permanente' ELSE 'fa' END,is_archived=0,archived_at=NULL,current_address=:address,refuge_room=NULL,care_box_key=NULL,updated_at=datetime('now') WHERE id=:id",
|
||||
)->execute([
|
||||
':address' =>
|
||||
$address !== '' ? $address : 'Chez ' . ($event['contact_name'] ?: 'une famille d’accueil'),
|
||||
':id' => $animalId,
|
||||
]);
|
||||
} elseif (in_array($event['event_type'], ['return', 'cancellation'], true)) {
|
||||
$db->prepare(
|
||||
"UPDATE animals SET status='refuge',is_archived=0,archived_at=NULL,current_address=NULL,updated_at=datetime('now') WHERE id=?",
|
||||
)->execute([$animalId]);
|
||||
} elseif ($event['event_type'] === 'reservation') {
|
||||
$db->prepare(
|
||||
"UPDATE animals SET status='reserve',is_archived=0,archived_at=NULL,updated_at=datetime('now') WHERE id=?",
|
||||
)->execute([$animalId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
76
app/Services/Asm3ResetService.php
Normal file
76
app/Services/Asm3ResetService.php
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
final class Asm3ResetService
|
||||
{
|
||||
public static function clear(PDO $db): array
|
||||
{
|
||||
$before = [
|
||||
'animals' => (int) $db->query('SELECT COUNT(*) FROM animals')->fetchColumn(),
|
||||
'contacts' => (int) $db->query('SELECT COUNT(*) FROM directory_contacts')->fetchColumn(),
|
||||
];
|
||||
$tables = [
|
||||
'agenda_volunteers',
|
||||
'agenda_items',
|
||||
'prescription_treatments',
|
||||
'lab_results',
|
||||
'medical_prescriptions',
|
||||
'lab_reports',
|
||||
'treatment_administrations',
|
||||
'care_round_observations',
|
||||
'animal_photos',
|
||||
'medical_photos',
|
||||
'animal_health_conditions',
|
||||
'vaccinations',
|
||||
'treatments',
|
||||
'medical_notes',
|
||||
'measurements',
|
||||
'animal_placements',
|
||||
'adoptions',
|
||||
'animal_movements',
|
||||
'animal_deaths',
|
||||
'animal_location_history',
|
||||
'animal_history',
|
||||
'litter_kittens',
|
||||
'litters',
|
||||
'bonded_group_members',
|
||||
'bonded_groups',
|
||||
'care_rounds',
|
||||
'icad_cache',
|
||||
'asm3_import_links',
|
||||
'asm3_import_runs',
|
||||
'directory_contact_roles',
|
||||
'animals',
|
||||
'directory_contacts',
|
||||
];
|
||||
foreach ($tables as $table) {
|
||||
$db->exec('DELETE FROM ' . $table);
|
||||
}
|
||||
return $before;
|
||||
}
|
||||
public static function clearMedia(): void
|
||||
{
|
||||
foreach (
|
||||
[dirname(__DIR__, 2) . '/public/media/animals', dirname(__DIR__, 2) . '/data/medical-documents']
|
||||
as $root
|
||||
) {
|
||||
if (!is_dir($root)) {
|
||||
continue;
|
||||
}
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::CHILD_FIRST,
|
||||
);
|
||||
foreach ($iterator as $file) {
|
||||
$path = $file->getPathname();
|
||||
if ($file->isLink()) {
|
||||
unlink($path);
|
||||
} elseif ($file->isDir()) {
|
||||
rmdir($path);
|
||||
} else {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
69
app/Services/AssociationBrand.php
Normal file
69
app/Services/AssociationBrand.php
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class AssociationBrand
|
||||
{
|
||||
private const MAX_BYTES = 5_000_000;
|
||||
private const MIME_EXTENSIONS = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/webp' => 'webp'];
|
||||
|
||||
public static function path(): ?string
|
||||
{
|
||||
$files = glob(self::directory() . '/logo.*') ?: [];
|
||||
foreach ($files as $file) {
|
||||
if (is_file($file)) {
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function store(array $upload): void
|
||||
{
|
||||
$error = (int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE);
|
||||
if ($error === UPLOAD_ERR_NO_FILE) {
|
||||
return;
|
||||
}
|
||||
if ($error !== UPLOAD_ERR_OK || !is_uploaded_file((string) ($upload['tmp_name'] ?? ''))) {
|
||||
throw new RuntimeException(t('service.logo.upload_failed'));
|
||||
}
|
||||
if ((int) ($upload['size'] ?? 0) > self::MAX_BYTES) {
|
||||
throw new RuntimeException(t('service.logo.too_large'));
|
||||
}
|
||||
$tmp = (string) $upload['tmp_name'];
|
||||
$mime = new finfo(FILEINFO_MIME_TYPE)->file($tmp);
|
||||
$extension = self::MIME_EXTENSIONS[$mime] ?? null;
|
||||
if (!$extension) {
|
||||
throw new RuntimeException(t('service.logo.format'));
|
||||
}
|
||||
$dimensions = @getimagesize($tmp);
|
||||
if (!$dimensions || $dimensions[0] * $dimensions[1] > 40_000_000) {
|
||||
throw new RuntimeException(t('service.logo.dimensions'));
|
||||
}
|
||||
$dir = self::directory();
|
||||
$stored = ImageService::storeUploaded($tmp, $dir, 'association', 'logo');
|
||||
$target = $dir . '/logo.' . $stored['extension'];
|
||||
foreach (glob($dir . '/logo.*') ?: [] as $old) {
|
||||
if ($old !== $stored['path']) {
|
||||
@unlink($old);
|
||||
}
|
||||
}
|
||||
if (!rename($stored['path'], $target)) {
|
||||
@unlink($stored['path']);
|
||||
throw new RuntimeException(t('service.logo.finalize_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
public static function delete(): void
|
||||
{
|
||||
foreach (glob(self::directory() . '/logo.*') ?: [] as $file) {
|
||||
if (is_file($file)) {
|
||||
@unlink($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
private static function directory(): string
|
||||
{
|
||||
return dirname(__DIR__, 2) . '/data/association';
|
||||
}
|
||||
}
|
||||
40
app/Services/AuditService.php
Normal file
40
app/Services/AuditService.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class AuditService
|
||||
{
|
||||
public static function log(
|
||||
string $action,
|
||||
string $path,
|
||||
?string $summary = null,
|
||||
?string $entityType = null,
|
||||
?int $entityId = null,
|
||||
array $metadata = [],
|
||||
): void {
|
||||
$safe = [];
|
||||
foreach ($metadata as $key => $value) {
|
||||
if (in_array(strtolower((string) $key), ['password', 'password_confirm', 'csrf'], true)) {
|
||||
continue;
|
||||
}
|
||||
if (is_scalar($value) || $value === null) {
|
||||
$safe[$key] = $value;
|
||||
}
|
||||
}
|
||||
DB::pdo()
|
||||
->prepare(
|
||||
'INSERT INTO audit_log(user_id,action,method,path,entity_type,entity_id,summary,metadata,ip_address) VALUES(:user,:action,:method,:path,:type,:entity,:summary,:meta,:ip)',
|
||||
)
|
||||
->execute([
|
||||
':user' => Auth::id(),
|
||||
':action' => $action,
|
||||
':method' => $_SERVER['REQUEST_METHOD'] ?? 'CLI',
|
||||
':path' => $path,
|
||||
':type' => $entityType,
|
||||
':entity' => $entityId,
|
||||
':summary' => $summary,
|
||||
':meta' => $safe ? json_encode($safe, JSON_UNESCAPED_UNICODE) : null,
|
||||
':ip' => $_SERVER['REMOTE_ADDR'] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
201
app/Services/Auth.php
Normal file
201
app/Services/Auth.php
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class Auth
|
||||
{
|
||||
private static ?array $user = null;
|
||||
public static function start(): void
|
||||
{
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
return;
|
||||
}
|
||||
ini_set('session.use_strict_mode', '1');
|
||||
ini_set('session.use_only_cookies', '1');
|
||||
ini_set('session.cookie_httponly', '1');
|
||||
session_name('globinours_session');
|
||||
session_set_cookie_params([
|
||||
'httponly' => true,
|
||||
'secure' => self::isSecureRequest(),
|
||||
'samesite' => 'Strict',
|
||||
'path' => '/',
|
||||
]);
|
||||
session_start();
|
||||
$now = time();
|
||||
$idle = max(300, (int) (getenv('GLOBINOURS_SESSION_IDLE') ?: 1800));
|
||||
$absolute = max($idle, (int) (getenv('GLOBINOURS_SESSION_ABSOLUTE') ?: 43200));
|
||||
if (
|
||||
!empty($_SESSION['user_id']) &&
|
||||
((isset($_SESSION['last_activity']) && $now - (int) $_SESSION['last_activity'] > $idle) ||
|
||||
(isset($_SESSION['authenticated_at']) && $now - (int) $_SESSION['authenticated_at'] > $absolute))
|
||||
) {
|
||||
self::logout(false);
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
if (
|
||||
empty($_SESSION['user_id']) &&
|
||||
class_exists(TrustedDeviceService::class) &&
|
||||
($id = TrustedDeviceService::authenticate())
|
||||
) {
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['user_id'] = $id;
|
||||
$_SESSION['authenticated_at'] = $now;
|
||||
}
|
||||
if (!empty($_SESSION['user_id'])) {
|
||||
$_SESSION['last_activity'] = $now;
|
||||
}
|
||||
}
|
||||
public static function countUsers(): int
|
||||
{
|
||||
return (int) DB::pdo()->query('SELECT COUNT(*) FROM users')->fetchColumn();
|
||||
}
|
||||
public static function user(): ?array
|
||||
{
|
||||
if (self::$user !== null) {
|
||||
return self::$user;
|
||||
}
|
||||
$id = (int) ($_SESSION['user_id'] ?? 0);
|
||||
if (!$id) {
|
||||
return null;
|
||||
}
|
||||
$s = DB::pdo()->prepare(
|
||||
'SELECT id,username,display_name,role,active,last_login_at,animal_list_view,agenda_view FROM users WHERE id=:id AND active=1',
|
||||
);
|
||||
$s->execute([':id' => $id]);
|
||||
$u = $s->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$u) {
|
||||
self::logout(false);
|
||||
return null;
|
||||
}
|
||||
return self::$user = $u;
|
||||
}
|
||||
public static function id(): ?int
|
||||
{
|
||||
return ($u = self::user()) ? (int) $u['id'] : null;
|
||||
}
|
||||
public static function login(
|
||||
string $username,
|
||||
string $password,
|
||||
bool $remember = false,
|
||||
string $deviceName = '',
|
||||
): bool {
|
||||
$db = DB::pdo();
|
||||
$username = trim($username);
|
||||
$ip = mb_substr((string) ($_SERVER['REMOTE_ADDR'] ?? ''), 0, 64);
|
||||
$db->exec("DELETE FROM login_attempts WHERE attempted_at<datetime('now','-7 days')");
|
||||
$rate = $db->prepare(
|
||||
"SELECT COUNT(*) FROM login_attempts WHERE succeeded=0 AND ip_address=? AND attempted_at>=datetime('now','-15 minutes')",
|
||||
);
|
||||
$rate->execute([$ip ?: null]);
|
||||
if ((int) $rate->fetchColumn() >= 20) {
|
||||
self::recordAttempt($username, $ip, false);
|
||||
return false;
|
||||
}
|
||||
$s = $db->prepare('SELECT * FROM users WHERE lower(username)=lower(:u) LIMIT 1');
|
||||
$s->execute([':u' => $username]);
|
||||
$u = $s->fetch(PDO::FETCH_ASSOC);
|
||||
$valid =
|
||||
$u &&
|
||||
(int) $u['active'] === 1 &&
|
||||
(empty($u['locked_until']) || strtotime($u['locked_until']) <= time()) &&
|
||||
password_verify($password, (string) $u['password_hash']);
|
||||
if (!$u) {
|
||||
password_verify($password, '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.');
|
||||
}
|
||||
if (!$valid) {
|
||||
if ($u) {
|
||||
$failed = (int) $u['failed_attempts'] + 1;
|
||||
$lock = $failed >= 5 ? date('Y-m-d H:i:s', time() + 900) : null;
|
||||
$db->prepare('UPDATE users SET failed_attempts=:f,locked_until=:l WHERE id=:id')->execute([
|
||||
':f' => $failed,
|
||||
':l' => $lock,
|
||||
':id' => $u['id'],
|
||||
]);
|
||||
}
|
||||
self::recordAttempt($username, $ip, false);
|
||||
return false;
|
||||
}
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['user_id'] = (int) $u['id'];
|
||||
$_SESSION['authenticated_at'] = time();
|
||||
$_SESSION['last_activity'] = time();
|
||||
self::$user = null;
|
||||
$db->prepare(
|
||||
"UPDATE users SET failed_attempts=0,locked_until=NULL,last_login_at=datetime('now') WHERE id=:id",
|
||||
)->execute([':id' => $u['id']]);
|
||||
self::recordAttempt($username, $ip, true);
|
||||
if ($remember && class_exists(TrustedDeviceService::class)) {
|
||||
TrustedDeviceService::create((int) $u['id'], $deviceName);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
public static function logout(bool $destroy = true): void
|
||||
{
|
||||
if ($destroy && class_exists(TrustedDeviceService::class)) {
|
||||
TrustedDeviceService::revokeCurrent();
|
||||
}
|
||||
self::$user = null;
|
||||
unset($_SESSION['user_id']);
|
||||
if ($destroy) {
|
||||
$_SESSION = [];
|
||||
if (ini_get('session.use_cookies')) {
|
||||
$p = session_get_cookie_params();
|
||||
setcookie(
|
||||
session_name(),
|
||||
'',
|
||||
time() - 42000,
|
||||
$p['path'],
|
||||
$p['domain'] ?? '',
|
||||
(bool) $p['secure'],
|
||||
(bool) $p['httponly'],
|
||||
);
|
||||
}
|
||||
session_destroy();
|
||||
}
|
||||
}
|
||||
public static function csrf(): string
|
||||
{
|
||||
if (empty($_SESSION['csrf'])) {
|
||||
$_SESSION['csrf'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
return (string) $_SESSION['csrf'];
|
||||
}
|
||||
public static function validCsrf(?string $token): bool
|
||||
{
|
||||
return is_string($token) && hash_equals(self::csrf(), $token);
|
||||
}
|
||||
public static function is(string $role): bool
|
||||
{
|
||||
return (self::user()['role'] ?? '') === $role;
|
||||
}
|
||||
public static function canWrite(string $path): bool
|
||||
{
|
||||
$module = PermissionService::moduleForPath($path);
|
||||
return $module === null || PermissionService::can($module, 'edit');
|
||||
}
|
||||
public static function isSecureRequest(): bool
|
||||
{
|
||||
if (!empty($_SERVER['HTTPS']) && strtolower((string) $_SERVER['HTTPS']) !== 'off') {
|
||||
return true;
|
||||
}
|
||||
if (str_starts_with(strtolower((string) getenv('APP_URL')), 'https://')) {
|
||||
return true;
|
||||
}
|
||||
$remote = (string) ($_SERVER['REMOTE_ADDR'] ?? '');
|
||||
$trusted = array_filter(array_map('trim', explode(',', (string) getenv('GLOBINOURS_TRUSTED_PROXIES'))));
|
||||
if (in_array($remote, ['127.0.0.1', '::1'], true) || in_array($remote, $trusted, true)) {
|
||||
return strtolower(trim(explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''))[0] ?? '')) ===
|
||||
'https';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
private static function recordAttempt(string $username, string $ip, bool $success): void
|
||||
{
|
||||
try {
|
||||
DB::pdo()
|
||||
->prepare('INSERT INTO login_attempts(username_hash,ip_address,succeeded) VALUES(?,?,?)')
|
||||
->execute([hash('sha256', mb_strtolower($username)), $ip ?: null, $success ? 1 : 0]);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
467
app/Services/BackupService.php
Normal file
467
app/Services/BackupService.php
Normal file
|
|
@ -0,0 +1,467 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class BackupService
|
||||
{
|
||||
private const FORMAT_VERSION = 1;
|
||||
|
||||
public static function create(string $reason = 'manual'): array
|
||||
{
|
||||
if (!class_exists(ZipArchive::class)) {
|
||||
throw new RuntimeException(t('service.backup.zip_missing'));
|
||||
}
|
||||
$root = dirname(__DIR__, 2);
|
||||
$backupDir = $root . '/storage/backups';
|
||||
if (!is_dir($backupDir) && !mkdir($backupDir, 0775, true) && !is_dir($backupDir)) {
|
||||
throw new RuntimeException(t('service.backup.directory_failed'));
|
||||
}
|
||||
$stamp = new DateTimeImmutable('now', new DateTimeZone('Europe/Paris'))->format('Ymd-His');
|
||||
$suffix = $reason === 'pre-restore' ? '-avant-restauration' : '';
|
||||
$target = $backupDir . '/globinours-' . $stamp . $suffix . '.zip';
|
||||
// Certains volumes synchronisés acceptent les ZIP mais SQLite ne peut pas
|
||||
// y créer de base avec VACUUM INTO. Le snapshot transitoire appartient au
|
||||
// répertoire temporaire système ; seule l'archive finale va dans backups/.
|
||||
$snapshot = tempnam(sys_get_temp_dir(), 'globinours-snapshot-');
|
||||
if ($snapshot === false) {
|
||||
throw new RuntimeException(t('service.backup.prepare_failed'));
|
||||
}
|
||||
@unlink($snapshot); // VACUUM INTO exige que la destination n'existe pas.
|
||||
|
||||
$pdo = DB::pdo();
|
||||
$pdo->exec('PRAGMA wal_checkpoint(FULL)');
|
||||
$pdo->exec('VACUUM INTO ' . $pdo->quote($snapshot));
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($target, ZipArchive::CREATE | ZipArchive::EXCL) !== true) {
|
||||
@unlink($snapshot);
|
||||
throw new RuntimeException(t('service.backup.create_failed'));
|
||||
}
|
||||
$files = [];
|
||||
try {
|
||||
self::addFile($zip, $snapshot, 'database/refuge.sqlite', $files);
|
||||
if (getenv('GLOBINOURS_BACKUP_DATA_ONLY') !== '1') {
|
||||
self::addTree($zip, $root . '/public/media', 'public/media', $files);
|
||||
self::addTree($zip, $root . '/data/association', 'data/association', $files);
|
||||
self::addTree($zip, $root . '/data/grants', 'data/grants', $files);
|
||||
self::addTree($zip, $root . '/data/medical-documents', 'data/medical-documents', $files);
|
||||
self::addTree($zip, $root . '/data/private-media', 'data/private-media', $files);
|
||||
}
|
||||
$manifest = [
|
||||
'application' => 'Globinours',
|
||||
'format_version' => self::FORMAT_VERSION,
|
||||
'created_at' => new DateTimeImmutable('now', new DateTimeZone('Europe/Paris'))->format(
|
||||
DateTimeInterface::ATOM,
|
||||
),
|
||||
'reason' => $reason,
|
||||
'php_version' => PHP_VERSION,
|
||||
'files' => $files,
|
||||
];
|
||||
$zip->addFromString(
|
||||
'manifest.json',
|
||||
json_encode(
|
||||
$manifest,
|
||||
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
$zip->close();
|
||||
@unlink($snapshot);
|
||||
}
|
||||
@chmod($target, 0600);
|
||||
self::prune($backupDir);
|
||||
return self::describe($target);
|
||||
}
|
||||
|
||||
public static function list(): array
|
||||
{
|
||||
$dir = dirname(__DIR__, 2) . '/storage/backups';
|
||||
$files = glob($dir . '/globinours-*.zip') ?: [];
|
||||
usort($files, static fn(string $a, string $b): int => filemtime($b) <=> filemtime($a));
|
||||
return array_map([self::class, 'describe'], $files);
|
||||
}
|
||||
|
||||
public static function path(string $name): ?string
|
||||
{
|
||||
if (
|
||||
$name !== basename($name) ||
|
||||
!preg_match('/^globinours-[0-9]{8}-[0-9]{6}(?:-avant-restauration)?\.zip$/', $name)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
$path = dirname(__DIR__, 2) . '/storage/backups/' . $name;
|
||||
return is_file($path) ? $path : null;
|
||||
}
|
||||
|
||||
public static function delete(string $name): void
|
||||
{
|
||||
$path = self::path($name);
|
||||
if (!$path) {
|
||||
throw new RuntimeException(t('service.backup.not_found'));
|
||||
}
|
||||
if (!unlink($path)) {
|
||||
throw new RuntimeException(t('service.backup.delete_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
public static function verify(string $name): array
|
||||
{
|
||||
$path = self::path($name);
|
||||
if (!$path) {
|
||||
throw new RuntimeException(t('service.backup.not_found'));
|
||||
}
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($path) !== true) {
|
||||
throw new RuntimeException(t('service.backup.unreadable'));
|
||||
}
|
||||
try {
|
||||
$raw = $zip->getFromName('manifest.json');
|
||||
if ($raw === false) {
|
||||
throw new RuntimeException(t('service.backup.manifest_missing'));
|
||||
}
|
||||
$manifest = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
|
||||
if (
|
||||
($manifest['application'] ?? '') !== 'Globinours' ||
|
||||
(int) ($manifest['format_version'] ?? 0) !== self::FORMAT_VERSION
|
||||
) {
|
||||
throw new RuntimeException(t('service.backup.incompatible'));
|
||||
}
|
||||
foreach ($manifest['files'] ?? [] as $file => $meta) {
|
||||
$stream = $zip->getStream((string) $file);
|
||||
if (!$stream) {
|
||||
throw new RuntimeException(t('service.backup.file_altered', ['name' => $file]));
|
||||
}
|
||||
$context = hash_init('sha256');
|
||||
hash_update_stream($context, $stream);
|
||||
fclose($stream);
|
||||
if (!hash_equals((string) ($meta['sha256'] ?? ''), hash_final($context))) {
|
||||
throw new RuntimeException(t('service.backup.file_altered', ['name' => $file]));
|
||||
}
|
||||
}
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'globinours-verify-');
|
||||
if ($tmp === false) {
|
||||
throw new RuntimeException(t('service.backup.prepare_failed'));
|
||||
}
|
||||
$dbBytes = $zip->getFromName('database/refuge.sqlite');
|
||||
if ($dbBytes === false) {
|
||||
@unlink($tmp);
|
||||
throw new RuntimeException(t('service.backup.database_missing'));
|
||||
}
|
||||
file_put_contents($tmp, $dbBytes);
|
||||
self::validateDatabase($tmp);
|
||||
@unlink($tmp);
|
||||
return [
|
||||
'valid' => true,
|
||||
'name' => $name,
|
||||
'created_at' => (string) ($manifest['created_at'] ?? ''),
|
||||
'files' => count($manifest['files'] ?? []),
|
||||
];
|
||||
} finally {
|
||||
$zip->close();
|
||||
}
|
||||
}
|
||||
|
||||
public static function runScheduled(bool $force = false): array
|
||||
{
|
||||
if (AppSettings::get('backup_schedule_enabled') !== '1' && !$force) {
|
||||
return ['created' => false, 'reason' => 'disabled'];
|
||||
}
|
||||
$frequency = AppSettings::get('backup_schedule_frequency');
|
||||
$last = AppSettings::get('backup_last_run_at');
|
||||
$minimum = $frequency === 'weekly' ? 6 * 86400 : 20 * 3600;
|
||||
if (!$force && $last !== '' && time() - (strtotime($last) ?: 0) < $minimum) {
|
||||
return ['created' => false, 'reason' => 'not_due'];
|
||||
}
|
||||
$backup = self::create('scheduled');
|
||||
$verification = self::verify($backup['name']);
|
||||
AppSettings::save(
|
||||
['backup_last_run_at' => date('Y-m-d H:i:s'), 'backup_last_verified_at' => date('Y-m-d H:i:s')],
|
||||
null,
|
||||
);
|
||||
return ['created' => true, 'backup' => $backup, 'verification' => $verification];
|
||||
}
|
||||
|
||||
public static function restore(string $uploadedPath): array
|
||||
{
|
||||
if (!is_file($uploadedPath)) {
|
||||
throw new RuntimeException(t('service.backup.restore_missing'));
|
||||
}
|
||||
$root = dirname(__DIR__, 2);
|
||||
$stage = $root . '/storage/restore-stage-' . bin2hex(random_bytes(6));
|
||||
if (!mkdir($stage, 0700, true)) {
|
||||
throw new RuntimeException(t('service.backup.prepare_failed'));
|
||||
}
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($uploadedPath) !== true) {
|
||||
self::removeTree($stage);
|
||||
throw new RuntimeException(t('service.backup.unreadable'));
|
||||
}
|
||||
try {
|
||||
if ($zip->numFiles > 10000) {
|
||||
throw new RuntimeException(t('service.backup.too_many_files'));
|
||||
}
|
||||
$total = 0;
|
||||
$seen = [];
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$stat = $zip->statIndex($i);
|
||||
$name = (string) ($stat['name'] ?? '');
|
||||
if (isset($seen[$name])) {
|
||||
throw new RuntimeException(t('service.backup.unsafe_path'));
|
||||
}
|
||||
$seen[$name] = true;
|
||||
$total += (int) ($stat['size'] ?? 0);
|
||||
if ($total > 2_000_000_000) {
|
||||
throw new RuntimeException(t('service.backup.too_large'));
|
||||
}
|
||||
if (
|
||||
$name === '' ||
|
||||
str_contains($name, "\0") ||
|
||||
str_starts_with($name, '/') ||
|
||||
preg_match('#(^|/)\.\.(/|$)#', $name)
|
||||
) {
|
||||
throw new RuntimeException(t('service.backup.unsafe_path'));
|
||||
}
|
||||
}
|
||||
$manifestRaw = $zip->getFromName('manifest.json');
|
||||
if ($manifestRaw === false) {
|
||||
throw new RuntimeException(t('service.backup.manifest_missing'));
|
||||
}
|
||||
$manifest = json_decode($manifestRaw, true, 512, JSON_THROW_ON_ERROR);
|
||||
if (
|
||||
($manifest['application'] ?? '') !== 'Globinours' ||
|
||||
(int) ($manifest['format_version'] ?? 0) !== self::FORMAT_VERSION
|
||||
) {
|
||||
throw new RuntimeException(t('service.backup.incompatible'));
|
||||
}
|
||||
$manifestFiles = array_keys(is_array($manifest['files'] ?? null) ? $manifest['files'] : []);
|
||||
$allowed = array_fill_keys(array_merge(['manifest.json'], $manifestFiles), true);
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$stat = $zip->statIndex($i);
|
||||
$name = (string) ($stat['name'] ?? '');
|
||||
if (str_ends_with($name, '/')) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($allowed[$name])) {
|
||||
throw new RuntimeException(t('service.backup.manifest_path'));
|
||||
}
|
||||
if (method_exists($zip, 'getExternalAttributesIndex')) {
|
||||
$opsys = 0;
|
||||
$attr = 0;
|
||||
if ($zip->getExternalAttributesIndex($i, $opsys, $attr) && (($attr >> 16) & 0170000) === 0120000) {
|
||||
throw new RuntimeException(t('service.backup.unsafe_path'));
|
||||
}
|
||||
}
|
||||
}
|
||||
$written = 0;
|
||||
foreach ($manifestFiles as $name) {
|
||||
$source = $zip->getStream($name);
|
||||
if (!$source) {
|
||||
throw new RuntimeException(t('service.backup.extract_failed'));
|
||||
}
|
||||
$target = $stage . '/' . $name;
|
||||
$dir = dirname($target);
|
||||
if (!is_dir($dir) && !mkdir($dir, 0700, true) && !is_dir($dir)) {
|
||||
fclose($source);
|
||||
throw new RuntimeException(t('service.backup.extract_failed'));
|
||||
}
|
||||
$dest = fopen($target, 'xb');
|
||||
if (!$dest) {
|
||||
fclose($source);
|
||||
throw new RuntimeException(t('service.backup.extract_failed'));
|
||||
}
|
||||
while (!feof($source)) {
|
||||
$chunk = fread($source, 1048576);
|
||||
if ($chunk === false) {
|
||||
fclose($source);
|
||||
fclose($dest);
|
||||
throw new RuntimeException(t('service.backup.extract_failed'));
|
||||
}
|
||||
$written += strlen($chunk);
|
||||
if ($written > 2_000_000_000) {
|
||||
fclose($source);
|
||||
fclose($dest);
|
||||
throw new RuntimeException(t('service.backup.too_large'));
|
||||
}
|
||||
fwrite($dest, $chunk);
|
||||
}
|
||||
fclose($source);
|
||||
fclose($dest);
|
||||
@chmod($target, 0600);
|
||||
}
|
||||
} finally {
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
try {
|
||||
foreach ($manifest['files'] ?? [] as $name => $metadata) {
|
||||
if (
|
||||
!is_string($name) ||
|
||||
!preg_match(
|
||||
'#^(database/refuge\.sqlite|public/media/.+|data/grants/.+|data/medical-documents/.+|data/private-media/.+|data/documents/.+|data/association/logo\.(?:png|jpg|webp)|data/(?:Logo_Globinours_64\.png|Logo_Globinours_maxi\.png|favicon\.ico))$#',
|
||||
$name,
|
||||
)
|
||||
) {
|
||||
throw new RuntimeException(t('service.backup.manifest_path'));
|
||||
}
|
||||
$restored = $stage . '/' . $name;
|
||||
$hash = (string) ($metadata['sha256'] ?? '');
|
||||
if (!is_file($restored) || $hash === '' || !hash_equals($hash, hash_file('sha256', $restored))) {
|
||||
throw new RuntimeException(t('service.backup.file_altered', ['name' => $name]));
|
||||
}
|
||||
}
|
||||
$database = $stage . '/database/refuge.sqlite';
|
||||
if (!is_file($database)) {
|
||||
throw new RuntimeException(t('service.backup.database_missing'));
|
||||
}
|
||||
$expected = $manifest['files']['database/refuge.sqlite']['sha256'] ?? '';
|
||||
if ($expected === '' || !hash_equals($expected, hash_file('sha256', $database))) {
|
||||
throw new RuntimeException(t('service.backup.database_hash'));
|
||||
}
|
||||
self::validateDatabase($database);
|
||||
$emergency = self::create('pre-restore');
|
||||
self::replaceInstallation($stage, $root);
|
||||
return ['emergency' => $emergency, 'created_at' => (string) ($manifest['created_at'] ?? '')];
|
||||
} finally {
|
||||
self::removeTree($stage);
|
||||
}
|
||||
}
|
||||
|
||||
private static function validateDatabase(string $path): void
|
||||
{
|
||||
$db = new SQLite3($path, SQLITE3_OPEN_READONLY);
|
||||
try {
|
||||
if ($db->querySingle('PRAGMA integrity_check') !== 'ok') {
|
||||
throw new RuntimeException(t('service.backup.database_corrupt'));
|
||||
}
|
||||
foreach (['animals', 'users', 'animal_history'] as $table) {
|
||||
$safe = SQLite3::escapeString($table);
|
||||
if (!(int) $db->querySingle("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='$safe'")) {
|
||||
throw new RuntimeException(t('service.backup.table_missing', ['table' => $table]));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
$db->close();
|
||||
}
|
||||
}
|
||||
|
||||
private static function replaceInstallation(string $stage, string $root): void
|
||||
{
|
||||
$rollback = $root . '/storage/restore-rollback-' . bin2hex(random_bytes(5));
|
||||
mkdir($rollback, 0700, true);
|
||||
$targets = ['public/media', 'data/association', 'data/grants', 'data/medical-documents', 'data/private-media'];
|
||||
$moved = [];
|
||||
try {
|
||||
foreach ($targets as $relative) {
|
||||
$live = $root . '/' . $relative;
|
||||
$saved = $rollback . '/' . str_replace('/', '__', $relative);
|
||||
$incoming = $stage . '/' . $relative;
|
||||
if (is_dir($live) && !rename($live, $saved)) {
|
||||
throw new RuntimeException(t('service.backup.secure_failed', ['path' => $relative]));
|
||||
}
|
||||
if (is_dir($saved)) {
|
||||
$moved[$relative] = $saved;
|
||||
}
|
||||
if (is_dir($incoming) && !rename($incoming, $live)) {
|
||||
throw new RuntimeException(t('service.backup.restore_path_failed', ['path' => $relative]));
|
||||
}
|
||||
if (!is_dir($live)) {
|
||||
mkdir($live, 0775, true);
|
||||
}
|
||||
}
|
||||
$pdo = DB::pdo();
|
||||
$pdo->exec('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||
unset($pdo);
|
||||
DB::close();
|
||||
foreach (['-wal', '-shm'] as $suffix) {
|
||||
@unlink($root . '/data/refuge.sqlite' . $suffix);
|
||||
}
|
||||
if (!rename($root . '/data/refuge.sqlite', $rollback . '/refuge.sqlite')) {
|
||||
throw new RuntimeException(t('service.backup.secure_database_failed'));
|
||||
}
|
||||
if (!copy($stage . '/database/refuge.sqlite', $root . '/data/refuge.sqlite')) {
|
||||
throw new RuntimeException(t('service.backup.restore_database_failed'));
|
||||
}
|
||||
@chmod($root . '/data/refuge.sqlite', 0600);
|
||||
self::removeTree($rollback);
|
||||
} catch (Throwable $e) {
|
||||
DB::close();
|
||||
if (is_file($rollback . '/refuge.sqlite')) {
|
||||
@unlink($root . '/data/refuge.sqlite');
|
||||
@rename($rollback . '/refuge.sqlite', $root . '/data/refuge.sqlite');
|
||||
}
|
||||
foreach ($targets as $relative) {
|
||||
$live = $root . '/' . $relative;
|
||||
$saved = $rollback . '/' . str_replace('/', '__', $relative);
|
||||
if (is_dir($saved)) {
|
||||
self::removeTree($live);
|
||||
@rename($saved, $live);
|
||||
}
|
||||
}
|
||||
self::removeTree($rollback);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private static function addTree(ZipArchive $zip, string $dir, string $prefix, array &$files): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS));
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->isFile() && !$file->isLink()) {
|
||||
self::addFile(
|
||||
$zip,
|
||||
$file->getPathname(),
|
||||
$prefix . '/' . substr($file->getPathname(), strlen($dir) + 1),
|
||||
$files,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function addFile(ZipArchive $zip, string $source, string $name, array &$files): void
|
||||
{
|
||||
if (!is_file($source)) {
|
||||
return;
|
||||
}
|
||||
$name = str_replace('\\', '/', $name);
|
||||
if (!$zip->addFile($source, $name)) {
|
||||
throw new RuntimeException(t('service.backup.add_failed', ['name' => $name]));
|
||||
}
|
||||
$files[$name] = ['size' => filesize($source), 'sha256' => hash_file('sha256', $source)];
|
||||
}
|
||||
|
||||
private static function describe(string $path): array
|
||||
{
|
||||
$modified = new DateTimeImmutable('@' . (filemtime($path) ?: time()))->setTimezone(
|
||||
new DateTimeZone('Europe/Paris'),
|
||||
);
|
||||
return ['name' => basename($path), 'size' => filesize($path) ?: 0, 'date' => $modified->format('d/m/Y H:i:s')];
|
||||
}
|
||||
|
||||
private static function prune(string $dir): void
|
||||
{
|
||||
$files = glob($dir . '/globinours-*.zip') ?: [];
|
||||
usort($files, static fn($a, $b) => filemtime($b) <=> filemtime($a));
|
||||
$keep = class_exists(AppSettings::class) ? AppSettings::int('backup_retention', 1, 50) : 10;
|
||||
foreach (array_slice($files, $keep) as $file) {
|
||||
@unlink($file);
|
||||
}
|
||||
}
|
||||
|
||||
private static function removeTree(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
$it = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::CHILD_FIRST,
|
||||
);
|
||||
foreach ($it as $item) {
|
||||
$item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
|
||||
}
|
||||
@rmdir($dir);
|
||||
}
|
||||
}
|
||||
124
app/Services/CalendarFeedService.php
Normal file
124
app/Services/CalendarFeedService.php
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class CalendarFeedService
|
||||
{
|
||||
public static function generateToken(int $userId): string
|
||||
{
|
||||
$token = bin2hex(random_bytes(32));
|
||||
DB::pdo()
|
||||
->prepare(
|
||||
"UPDATE users SET agenda_feed_token_hash=:hash,agenda_feed_created_at=datetime('now'),updated_at=datetime('now') WHERE id=:id AND active=1",
|
||||
)
|
||||
->execute([':hash' => hash('sha256', $token), ':id' => $userId]);
|
||||
return $token;
|
||||
}
|
||||
public static function revoke(int $userId): void
|
||||
{
|
||||
DB::pdo()
|
||||
->prepare(
|
||||
"UPDATE users SET agenda_feed_token_hash=NULL,agenda_feed_created_at=NULL,updated_at=datetime('now') WHERE id=?",
|
||||
)
|
||||
->execute([$userId]);
|
||||
}
|
||||
public static function userForToken(string $token): ?array
|
||||
{
|
||||
if (!preg_match('/^[a-f0-9]{64}$/', $token)) {
|
||||
return null;
|
||||
}
|
||||
$s = DB::pdo()->prepare('SELECT id,role FROM users WHERE agenda_feed_token_hash=? AND active=1 LIMIT 1');
|
||||
$s->execute([hash('sha256', $token)]);
|
||||
$user = $s->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||
if (!$user || !PermissionService::can('agenda', 'view', (string) $user['role'])) {
|
||||
return null;
|
||||
}
|
||||
return $user;
|
||||
}
|
||||
public static function absoluteUrl(string $path): string
|
||||
{
|
||||
$configured = rtrim((string) getenv('APP_URL'), '/');
|
||||
if ($configured !== '' && preg_match('~^https?://~i', $configured)) {
|
||||
return $configured . $path;
|
||||
}
|
||||
$proto = (string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '');
|
||||
if (!in_array($proto, ['http', 'https'], true)) {
|
||||
$proto = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' ? 'https' : 'http';
|
||||
}
|
||||
$host = (string) ($_SERVER['HTTP_X_FORWARDED_HOST'] ?? ($_SERVER['HTTP_HOST'] ?? 'localhost'));
|
||||
$host = trim(explode(',', $host)[0]);
|
||||
if (!preg_match('/^[a-z0-9.:-]+$/i', $host)) {
|
||||
$host = 'localhost';
|
||||
}
|
||||
return $proto . '://' . $host . $path;
|
||||
}
|
||||
public static function render(array $items, string $name): string
|
||||
{
|
||||
$lines = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'PRODID:-//Globinours//Agenda refuge//FR',
|
||||
'CALSCALE:GREGORIAN',
|
||||
'METHOD:PUBLISH',
|
||||
'X-WR-CALNAME:' . self::escape($name),
|
||||
'X-WR-TIMEZONE:Europe/Paris',
|
||||
];
|
||||
$stamp = gmdate('Ymd\THis\Z');
|
||||
foreach ($items as $item) {
|
||||
$source = (string) ($item['source_type'] ?? 'agenda');
|
||||
$sourceId = (int) ($item['source_id'] ?? $item['id']);
|
||||
$instance = (string) $item['instance_starts_at'];
|
||||
$uid = $source . '-' . $sourceId . '-' . substr(hash('sha256', $instance), 0, 12) . '@globinours';
|
||||
$lines[] = 'BEGIN:VEVENT';
|
||||
$lines[] = 'UID:' . $uid;
|
||||
$lines[] = 'DTSTAMP:' . $stamp;
|
||||
if ((int) $item['all_day'] === 1) {
|
||||
$start = new DateTimeImmutable(substr($instance, 0, 10));
|
||||
$lines[] = 'DTSTART;VALUE=DATE:' . $start->format('Ymd');
|
||||
$lines[] = 'DTEND;VALUE=DATE:' . $start->modify('+1 day')->format('Ymd');
|
||||
} else {
|
||||
$start = new DateTimeImmutable($instance, new DateTimeZone('Europe/Paris'));
|
||||
$end = !empty($item['instance_ends_at'])
|
||||
? new DateTimeImmutable($item['instance_ends_at'], new DateTimeZone('Europe/Paris'))
|
||||
: $start->modify('+1 hour');
|
||||
$lines[] = 'DTSTART;TZID=Europe/Paris:' . $start->format('Ymd\THis');
|
||||
$lines[] = 'DTEND;TZID=Europe/Paris:' . $end->format('Ymd\THis');
|
||||
}
|
||||
$lines[] = 'SUMMARY:' . self::escape((string) $item['title']);
|
||||
$description = trim((string) ($item['description'] ?? ''));
|
||||
if (!empty($item['animal_name'])) {
|
||||
$description = trim($description . "\n" . t('animal.animal') . ' : ' . $item['animal_name']);
|
||||
}
|
||||
if ($description !== '') {
|
||||
$lines[] = 'DESCRIPTION:' . self::escape($description);
|
||||
}
|
||||
if (!empty($item['location'])) {
|
||||
$lines[] = 'LOCATION:' . self::escape((string) $item['location']);
|
||||
}
|
||||
if (!empty($item['animal_id'])) {
|
||||
$lines[] = 'URL:' . self::escape(self::absoluteUrl('/animal?id=' . (int) $item['animal_id']));
|
||||
}
|
||||
$lines[] = 'STATUS:' . (($item['status'] ?? 'planned') === 'completed' ? 'COMPLETED' : 'CONFIRMED');
|
||||
$lines[] = 'END:VEVENT';
|
||||
}
|
||||
$lines[] = 'END:VCALENDAR';
|
||||
return implode("\r\n", array_map([self::class, 'fold'], $lines)) . "\r\n";
|
||||
}
|
||||
private static function escape(string $value): string
|
||||
{
|
||||
return str_replace(['\\', "\r\n", "\n", "\r", ';', ','], ['\\\\', '\\n', '\\n', '\\n', '\\;', '\\,'], $value);
|
||||
}
|
||||
private static function fold(string $line): string
|
||||
{
|
||||
$out = '';
|
||||
while (strlen($line) > 73) {
|
||||
$cut = 73;
|
||||
while ($cut > 0 && (ord($line[$cut]) & 0xc0) === 0x80) {
|
||||
$cut--;
|
||||
}
|
||||
$out .= substr($line, 0, $cut) . "\r\n ";
|
||||
$line = substr($line, $cut);
|
||||
}
|
||||
return $out . $line;
|
||||
}
|
||||
}
|
||||
41
app/Services/DB.php
Normal file
41
app/Services/DB.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class DB
|
||||
{
|
||||
private static ?PDO $pdo = null;
|
||||
|
||||
public static function pdo(): PDO
|
||||
{
|
||||
if (self::$pdo instanceof PDO) {
|
||||
return self::$pdo;
|
||||
}
|
||||
|
||||
$dbPath = getenv('GLOBINOURS_DB_PATH') ?: __DIR__ . '/../../data/refuge.sqlite';
|
||||
$dir = dirname($dbPath);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0775, true);
|
||||
}
|
||||
|
||||
$pdo = new PDO('sqlite:' . $dbPath, null, null, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
|
||||
$pdo->exec('PRAGMA foreign_keys = ON;');
|
||||
$pdo->exec('PRAGMA journal_mode = WAL;');
|
||||
$pdo->exec('PRAGMA busy_timeout = 3000;');
|
||||
@chmod($dbPath, 0600);
|
||||
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
|
||||
self::$pdo = $pdo;
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
public static function close(): void
|
||||
{
|
||||
self::$pdo = null;
|
||||
}
|
||||
}
|
||||
536
app/Services/DemoDataService.php
Normal file
536
app/Services/DemoDataService.php
Normal file
|
|
@ -0,0 +1,536 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class DemoDataService
|
||||
{
|
||||
private const TABLES = [
|
||||
'agenda_volunteers',
|
||||
'agenda_items',
|
||||
'prescription_treatments',
|
||||
'lab_results',
|
||||
'medical_prescriptions',
|
||||
'lab_reports',
|
||||
'treatment_administrations',
|
||||
'care_round_observations',
|
||||
'care_rounds',
|
||||
'medical_photos',
|
||||
'animal_photos',
|
||||
'animal_expenses',
|
||||
'animal_surgeries',
|
||||
'animal_health_conditions',
|
||||
'dewormings',
|
||||
'vaccinations',
|
||||
'treatments',
|
||||
'medical_notes',
|
||||
'measurements',
|
||||
'animal_placements',
|
||||
'adoptions',
|
||||
'animal_movements',
|
||||
'animal_deaths',
|
||||
'animal_location_history',
|
||||
'animal_history',
|
||||
'litter_kittens',
|
||||
'litters',
|
||||
'bonded_group_members',
|
||||
'bonded_groups',
|
||||
'icad_cache',
|
||||
'asm3_import_links',
|
||||
'asm3_import_runs',
|
||||
'asm3_import_jobs',
|
||||
'directory_contact_roles',
|
||||
'animals',
|
||||
'directory_contacts',
|
||||
'grant_documents',
|
||||
'grant_applications',
|
||||
'grant_years',
|
||||
'care_room_layouts',
|
||||
'clinics',
|
||||
'geocode_cache',
|
||||
'audit_log',
|
||||
];
|
||||
|
||||
public static function reset(PDO $db): array
|
||||
{
|
||||
$before = [
|
||||
'animals' => (int) $db->query('SELECT COUNT(*) FROM animals')->fetchColumn(),
|
||||
'contacts' => (int) $db->query('SELECT COUNT(*) FROM directory_contacts')->fetchColumn(),
|
||||
];
|
||||
$sequence = $db->prepare('DELETE FROM sqlite_sequence WHERE name=?');
|
||||
$exists = $db->prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?");
|
||||
foreach (self::TABLES as $table) {
|
||||
$exists->execute([$table]);
|
||||
if (!$exists->fetchColumn()) {
|
||||
continue;
|
||||
}
|
||||
$db->exec('DELETE FROM ' . $table);
|
||||
$sequence->execute([$table]);
|
||||
}
|
||||
return $before;
|
||||
}
|
||||
|
||||
public static function seed(PDO $db, ?int $userId): array
|
||||
{
|
||||
$contacts = [
|
||||
'fa' => [
|
||||
'person',
|
||||
'Alice Martin',
|
||||
'06 00 00 00 01',
|
||||
'alice.demo@example.org',
|
||||
'12 rue des Lilas',
|
||||
'29260',
|
||||
'Lesneven',
|
||||
],
|
||||
'adopter' => [
|
||||
'person',
|
||||
'Marc Durand',
|
||||
'06 00 00 00 02',
|
||||
'marc.demo@example.org',
|
||||
'8 rue du Port',
|
||||
'29200',
|
||||
'Brest',
|
||||
],
|
||||
'depositor' => [
|
||||
'person',
|
||||
'Sophie Le Gall',
|
||||
'06 00 00 00 03',
|
||||
'sophie.demo@example.org',
|
||||
'4 place du Marché',
|
||||
'29000',
|
||||
'Ville fictive',
|
||||
],
|
||||
'vet' => [
|
||||
'person',
|
||||
'Dr Camille Morel',
|
||||
'02 00 00 00 04',
|
||||
'veterinaire.demo@example.org',
|
||||
'2 avenue des Chats',
|
||||
'29260',
|
||||
'Lesneven',
|
||||
],
|
||||
'clinic' => [
|
||||
'organization',
|
||||
'Clinique vétérinaire Démo',
|
||||
'02 00 00 00 05',
|
||||
'clinique.demo@example.org',
|
||||
'2 avenue des Chats',
|
||||
'29260',
|
||||
'Lesneven',
|
||||
],
|
||||
'crematorium' => [
|
||||
'organization',
|
||||
'Crématorium animalier Démo',
|
||||
'02 00 00 00 06',
|
||||
'crematorium.demo@example.org',
|
||||
'1 route du Souvenir',
|
||||
'29400',
|
||||
'Landivisiau',
|
||||
],
|
||||
];
|
||||
$contactIds = [];
|
||||
$insert = $db->prepare(
|
||||
'INSERT INTO directory_contacts(kind,name,phone,email,address,postal_code,city,notes) VALUES(?,?,?,?,?,?,?,?)',
|
||||
);
|
||||
$role = $db->prepare('INSERT INTO directory_contact_roles(contact_id,role) VALUES(?,?)');
|
||||
foreach ($contacts as $key => $c) {
|
||||
$insert->execute([...$c, 'Donnée fictive de démonstration']);
|
||||
$contactIds[$key] = (int) $db->lastInsertId();
|
||||
$roleName = match ($key) {
|
||||
'adopter' => 'adoptant',
|
||||
'vet' => 'veterinaire',
|
||||
'clinic' => 'cabinet',
|
||||
'crematorium' => 'crematorium',
|
||||
'depositor' => 'deposant',
|
||||
default => 'fa',
|
||||
};
|
||||
$role->execute([$contactIds[$key], $roleName]);
|
||||
}
|
||||
|
||||
$animals = [
|
||||
[
|
||||
'DEMO-001',
|
||||
'Moka',
|
||||
'F',
|
||||
'2019-05-14',
|
||||
'refuge',
|
||||
'silver',
|
||||
'Bicolore Noir / Blanc',
|
||||
'yes',
|
||||
'yes',
|
||||
'yes',
|
||||
'yes',
|
||||
'yes',
|
||||
'available',
|
||||
],
|
||||
[
|
||||
'DEMO-002',
|
||||
'Pixel',
|
||||
'M',
|
||||
'2021-09-03',
|
||||
'refuge',
|
||||
'twix',
|
||||
'Roux',
|
||||
'yes',
|
||||
'yes',
|
||||
'yes',
|
||||
'yes',
|
||||
'no',
|
||||
'available',
|
||||
],
|
||||
[
|
||||
'DEMO-003',
|
||||
'Twix',
|
||||
'M',
|
||||
'2019-04-15',
|
||||
'quarantaine',
|
||||
null,
|
||||
'Roux',
|
||||
'yes',
|
||||
'yes',
|
||||
'yes',
|
||||
'yes',
|
||||
'yes',
|
||||
'not_available',
|
||||
],
|
||||
[
|
||||
'DEMO-004',
|
||||
'Nox',
|
||||
'M',
|
||||
'2023-02-18',
|
||||
'soin',
|
||||
null,
|
||||
'Noir',
|
||||
'no',
|
||||
'yes',
|
||||
'unknown',
|
||||
'yes',
|
||||
'no',
|
||||
'not_available',
|
||||
],
|
||||
[
|
||||
'DEMO-005',
|
||||
'Caramel',
|
||||
'F',
|
||||
'2020-07-21',
|
||||
'fa',
|
||||
null,
|
||||
'Écaille de tortue',
|
||||
'yes',
|
||||
'yes',
|
||||
'yes',
|
||||
'yes',
|
||||
'yes',
|
||||
'available',
|
||||
],
|
||||
[
|
||||
'DEMO-006',
|
||||
'Plume',
|
||||
'F',
|
||||
'2022-04-08',
|
||||
'refuge',
|
||||
'silver',
|
||||
'Tricolore / Calico',
|
||||
'yes',
|
||||
'yes',
|
||||
'yes',
|
||||
'yes',
|
||||
'yes',
|
||||
'available',
|
||||
],
|
||||
[
|
||||
'DEMO-007',
|
||||
'Plume-Bébé-1',
|
||||
'F',
|
||||
date('Y-m-d', strtotime('-4 months')),
|
||||
'refuge',
|
||||
'silver',
|
||||
'Bicolore Gris / Blanc',
|
||||
'unknown',
|
||||
'yes',
|
||||
'unknown',
|
||||
'unknown',
|
||||
'unknown',
|
||||
'not_available',
|
||||
],
|
||||
[
|
||||
'DEMO-008',
|
||||
'Plume-Bébé-2',
|
||||
'M',
|
||||
date('Y-m-d', strtotime('-4 months')),
|
||||
'refuge',
|
||||
'silver',
|
||||
'Gris',
|
||||
'unknown',
|
||||
'yes',
|
||||
'unknown',
|
||||
'unknown',
|
||||
'unknown',
|
||||
'not_available',
|
||||
],
|
||||
[
|
||||
'DEMO-009',
|
||||
'Plume-Bébé-3',
|
||||
'U',
|
||||
date('Y-m-d', strtotime('-4 months')),
|
||||
'refuge',
|
||||
'silver',
|
||||
'Tigré / Tabby',
|
||||
'unknown',
|
||||
'yes',
|
||||
'unknown',
|
||||
'unknown',
|
||||
'unknown',
|
||||
'not_available',
|
||||
],
|
||||
[
|
||||
'DEMO-010',
|
||||
'Lune',
|
||||
'F',
|
||||
'2021-01-12',
|
||||
'refuge',
|
||||
'twix',
|
||||
'Blanc',
|
||||
'yes',
|
||||
'no',
|
||||
'yes',
|
||||
'yes',
|
||||
'yes',
|
||||
'available',
|
||||
],
|
||||
[
|
||||
'DEMO-011',
|
||||
'Soleil',
|
||||
'M',
|
||||
'2021-01-12',
|
||||
'refuge',
|
||||
'twix',
|
||||
'Roux',
|
||||
'yes',
|
||||
'no',
|
||||
'yes',
|
||||
'yes',
|
||||
'yes',
|
||||
'available',
|
||||
],
|
||||
[
|
||||
'DEMO-012',
|
||||
'Étoile',
|
||||
'F',
|
||||
'2012-06-01',
|
||||
'decede',
|
||||
null,
|
||||
'Noir',
|
||||
'yes',
|
||||
'unknown',
|
||||
'unknown',
|
||||
'unknown',
|
||||
'unknown',
|
||||
'not_available',
|
||||
],
|
||||
];
|
||||
$animalIds = [];
|
||||
$animal = $db->prepare(
|
||||
"INSERT INTO animals(internal_code,name,species,sex,birth_date,status,refuge_room,care_box_key,color,sterilized,sterilization_status,compatibility_cats,compatibility_dogs,compatibility_children,house_trained,adoption_availability,intake_date,intake_type,intake_reason,rescue_location_name,current_address,chip_id,identification_type,identification_registration_status,archived_at,notes) VALUES(:code,:name,'chat',:sex,:birth,:status,:room,:box,:color,:sterilized,:sterilization,:cats,:dogs,:children,:clean,:availability,date('now','-'||:days||' days'),'found','stray_found','Commune fictive','5 rue de la Démonstration, 29260 Lesneven',:chip,:identification,:registration,:archived,'Donnée fictive de démonstration')",
|
||||
);
|
||||
foreach ($animals as $i => $a) {
|
||||
[
|
||||
$code,
|
||||
$name,
|
||||
$sex,
|
||||
$birth,
|
||||
$status,
|
||||
$room,
|
||||
$color,
|
||||
$sterilization,
|
||||
$cats,
|
||||
$dogs,
|
||||
$children,
|
||||
$clean,
|
||||
$availability,
|
||||
] = $a;
|
||||
$sterilized = $sterilization === 'yes' ? 1 : 0;
|
||||
$chip = $i < 6 ? '250269900' . str_pad((string) ($i + 1), 6, '0', STR_PAD_LEFT) : null;
|
||||
$animal->execute([
|
||||
':code' => $code,
|
||||
':name' => $name,
|
||||
':sex' => $sex,
|
||||
':birth' => $birth,
|
||||
':status' => $status,
|
||||
':room' => $room,
|
||||
':box' => $status === 'soin' ? 'care-top-1' : null,
|
||||
':color' => $color,
|
||||
':sterilized' => $sterilized,
|
||||
':sterilization' => $sterilization,
|
||||
':cats' => $cats,
|
||||
':dogs' => $dogs,
|
||||
':children' => $children,
|
||||
':clean' => $clean,
|
||||
':availability' => $availability,
|
||||
':days' => 90 + $i * 12,
|
||||
':chip' => $chip,
|
||||
':identification' => $chip ? 'microchip' : 'unknown',
|
||||
':registration' => $chip ? 'registered' : 'unknown',
|
||||
':archived' => $status === 'decede' ? date('Y-m-d H:i:s') : null,
|
||||
]);
|
||||
$animalIds[$name] = (int) $db->lastInsertId();
|
||||
}
|
||||
|
||||
$db->prepare('INSERT INTO litters(mother_id,birth_date,notes) VALUES(?,?,?)')->execute([
|
||||
$animalIds['Plume'],
|
||||
$animals[6][3],
|
||||
'Portée fictive de démonstration',
|
||||
]);
|
||||
$litter = (int) $db->lastInsertId();
|
||||
$link = $db->prepare('INSERT INTO litter_kittens(litter_id,animal_id,position) VALUES(?,?,?)');
|
||||
foreach (['Plume-Bébé-1', 'Plume-Bébé-2', 'Plume-Bébé-3'] as $i => $name) {
|
||||
$link->execute([$litter, $animalIds[$name], $i + 1]);
|
||||
}
|
||||
$db->prepare('INSERT INTO bonded_groups(name,notes) VALUES(?,?)')->execute([
|
||||
'Duo Lune & Soleil',
|
||||
'Chats fictifs à adopter ensemble',
|
||||
]);
|
||||
$group = (int) $db->lastInsertId();
|
||||
$member = $db->prepare('INSERT INTO bonded_group_members(group_id,animal_id,position) VALUES(?,?,?)');
|
||||
$member->execute([$group, $animalIds['Lune'], 1]);
|
||||
$member->execute([$group, $animalIds['Soleil'], 2]);
|
||||
|
||||
$twix = $animalIds['Twix'];
|
||||
$db->prepare(
|
||||
"UPDATE animals SET breed='Européen',birth_is_estimated=0,fiv=0,felv=0,quarantine_until=date('now','+10 days'),intake_circumstances='Trouvé seul, bilan sanitaire programmé à l’arrivée.',depositor_contact_id=?,rescue_location_name='Commune fictive',current_address='Quarantaine — box supérieur gauche',identification_date=date('now','-2 years'),notes='Fiche vitrine entièrement fictive : elle illustre un dossier animal complet.' WHERE id=?",
|
||||
)->execute([$contactIds['depositor'], $twix]);
|
||||
|
||||
$db->prepare(
|
||||
"INSERT OR IGNORE INTO ref_medications(name,molecule,form,notes) VALUES('Doxybactin','doxycycline','comprimé','Référence de démonstration')",
|
||||
)->execute();
|
||||
$med = (int) $db->query("SELECT id FROM ref_medications WHERE name='Doxybactin'")->fetchColumn();
|
||||
$db->prepare(
|
||||
"INSERT INTO treatments(animal_id,medication_id,route,dose_text,start_date,end_date,ongoing,notes,created_by,give_morning,give_evening) VALUES(?,?,'PO','1/2 comprimé matin et soir',date('now','-2 days'),date('now','+5 days'),1,'Traitement fictif de démonstration',?,1,1)",
|
||||
)->execute([$twix, $med, $userId]);
|
||||
$db->prepare(
|
||||
"INSERT INTO medical_notes(animal_id,noted_at,kind,reason,symptoms,exam,diagnosis,plan,weight_kg,temperature_c,created_by,vet_contact_id,clinic_contact_id) VALUES(?,datetime('now','-1 day'),'consult','Visite sanitaire d’entrée','Éternuements occasionnels','État général satisfaisant, auscultation sans anomalie','Coryza léger','Surveillance, traitement court et contrôle avant sortie de quarantaine',4.8,38.4,?,?,?)",
|
||||
)->execute([$twix, $userId, $contactIds['vet'], $contactIds['clinic']]);
|
||||
$weight = $db->prepare(
|
||||
"INSERT INTO measurements(animal_id,type,value,unit,measured_at,notes,created_by) VALUES(?,'weight',?,'kg',datetime('now',?),'Mesure fictive',?)",
|
||||
);
|
||||
foreach ([[4.6, '-30 days'], [4.7, '-14 days'], [4.8, '-1 day']] as $w) {
|
||||
$weight->execute([$twix, $w[0], $w[1], $userId]);
|
||||
}
|
||||
$db->prepare(
|
||||
"INSERT OR IGNORE INTO ref_vaccines(name,notes) VALUES('TCL','Vaccin de démonstration')",
|
||||
)->execute();
|
||||
$vaccine = (int) $db->query("SELECT id FROM ref_vaccines WHERE name='TCL'")->fetchColumn();
|
||||
$db->prepare(
|
||||
"INSERT INTO vaccinations(animal_id,vaccine_id,done_date,due_date,lot,manufacturer,notes,created_by,vet_contact_id,clinic_contact_id) VALUES(?,?,date('now','-11 months'),date('now','+1 month'),'LOT-DEMO-1','Laboratoire Démo','Vaccination fictive à jour',?,?,?)",
|
||||
)->execute([$twix, $vaccine, $userId, $contactIds['vet'], $contactIds['clinic']]);
|
||||
$dewormer = (int) $db->query('SELECT id FROM ref_dewormers ORDER BY id LIMIT 1')->fetchColumn();
|
||||
$db->prepare(
|
||||
"INSERT INTO dewormings(animal_id,dewormer_id,administered_on,dose_text,weight_kg,next_due_date,notes,created_by) VALUES(?,?,date('now','-10 days'),'1 pipette',4.7,date('now','+80 days'),'Administration fictive',?)",
|
||||
)->execute([$twix, $dewormer, $userId]);
|
||||
$db->prepare(
|
||||
"INSERT INTO animal_health_conditions(animal_id,name,status,diagnosed_at,resolved_at,notes) VALUES(?,'Gingivite légère','resolved',date('now','-6 months'),date('now','-5 months'),'Antécédent fictif résolu')",
|
||||
)->execute([$twix]);
|
||||
$db->prepare(
|
||||
"INSERT INTO lab_reports(animal_id,sampled_on,report_type,laboratory_name,veterinarian_contact_id,clinic_contact_id,notes,created_by) VALUES(?,date('now','-1 day'),'Bilan complet','Laboratoire Démo',?,?,'Prise de sang fictive — valeurs dans les intervalles usuels',?)",
|
||||
)->execute([$twix, $contactIds['vet'], $contactIds['clinic'], $userId]);
|
||||
$report = (int) $db->lastInsertId();
|
||||
$result = $db->prepare(
|
||||
'INSERT INTO lab_results(report_id,parameter_name,value,unit,reference_min,reference_max,notes,position) VALUES(?,?,?,?,?,?,?,?)',
|
||||
);
|
||||
foreach (
|
||||
[
|
||||
['Leucocytes', 8.4, '10⁹/L', 3.5, 20.7, 10],
|
||||
['Érythrocytes', 9.6, '10¹²/L', 7.7, 12.8, 20],
|
||||
['Hémoglobine', 13.8, 'g/dL', 10, 17, 30],
|
||||
['Hématocrite', 42.1, '%', 33.7, 55.4, 40],
|
||||
['Plaquettes', 312, '10⁹/L', 125, 618, 50],
|
||||
['Créatinine', 12, 'mg/L', 3, 21, 60],
|
||||
['Urée', 0.42, 'g/L', 0.214, 0.642, 70],
|
||||
]
|
||||
as $r
|
||||
) {
|
||||
$result->execute([$report, $r[0], $r[1], $r[2], $r[3], $r[4], 'Résultat fictif', $r[5]]);
|
||||
}
|
||||
$db->prepare(
|
||||
"INSERT INTO animal_photos(animal_id,filename,original_name,mime,size_bytes,is_primary,is_public,caption) VALUES(?,'twix.webp','twix.jpg','image/webp',0,1,1,'Twix — photographie de démonstration')",
|
||||
)->execute([$twix]);
|
||||
$db->prepare(
|
||||
"INSERT INTO agenda_items(item_type,title,description,starts_at,ends_at,location,status,priority,animal_id,contact_id,reminder_minutes,created_by) VALUES('veterinary','Contrôle de sortie de quarantaine','Rendez-vous fictif : contrôle clinique et validation de la sortie.',datetime('now','+7 days','09:00'),datetime('now','+7 days','09:30'),'Clinique vétérinaire Démo','planned','high',?,?,60,?)",
|
||||
)->execute([$twix, $contactIds['clinic'], $userId]);
|
||||
$db->prepare(
|
||||
"INSERT INTO animal_placements(animal_id,event_type,event_date,contact_id,reason,notes,created_by) VALUES(?,'foster',date('now','-30 days'),?,'Placement de démonstration','Donnée fictive',?)",
|
||||
)->execute([$animalIds['Caramel'], $contactIds['fa'], $userId]);
|
||||
$db->prepare(
|
||||
"INSERT INTO animal_deaths(animal_id,deceased_date,cause_code,cause_details,occurred_in_care,place_type,euthanized,veterinarian_contact_id,crematorium_contact_id,body_disposition,created_by) VALUES(?,date('now','-60 days'),'old_age','Décès fictif',1,'veterinaire',1,?,?,'collective_cremation',?)",
|
||||
)->execute([$animalIds['Étoile'], $contactIds['vet'], $contactIds['crematorium'], $userId]);
|
||||
$history = $db->prepare(
|
||||
"INSERT INTO animal_history(animal_id,action,field,new_value,user_id,created_at) VALUES(?,'created','demo','Donnée entièrement fictive',?,datetime('now'))",
|
||||
);
|
||||
foreach ($animalIds as $id) {
|
||||
$history->execute([$id, $userId]);
|
||||
}
|
||||
return [
|
||||
'animals' => count($animalIds),
|
||||
'contacts' => count($contactIds),
|
||||
'litters' => 1,
|
||||
'bonded_groups' => 1,
|
||||
'treatments' => 1,
|
||||
'vaccinations' => 1,
|
||||
'lab_reports' => 1,
|
||||
'agenda_items' => 1,
|
||||
'photos' => 1,
|
||||
];
|
||||
}
|
||||
|
||||
public static function clearFiles(): void
|
||||
{
|
||||
foreach (
|
||||
[
|
||||
dirname(__DIR__, 2) . '/public/media/animals',
|
||||
dirname(__DIR__, 2) . '/data/grants',
|
||||
dirname(__DIR__, 2) . '/data/medical-documents',
|
||||
]
|
||||
as $root
|
||||
) {
|
||||
if (!is_dir($root)) {
|
||||
continue;
|
||||
}
|
||||
$it = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::CHILD_FIRST,
|
||||
);
|
||||
foreach ($it as $file) {
|
||||
$path = $file->getPathname();
|
||||
if ($file->isLink() || $file->isFile()) {
|
||||
@unlink($path);
|
||||
} elseif ($file->isDir()) {
|
||||
@rmdir($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function installFiles(PDO $db): void
|
||||
{
|
||||
$animalId = (int) $db->query("SELECT id FROM animals WHERE internal_code='DEMO-003' LIMIT 1")->fetchColumn();
|
||||
if ($animalId <= 0) {
|
||||
return;
|
||||
}
|
||||
$source = dirname(__DIR__, 2) . '/resources/demo/twix.webp';
|
||||
$directory = dirname(__DIR__, 2) . '/public/media/animals/' . $animalId;
|
||||
$target = $directory . '/twix.webp';
|
||||
if (!is_file($source)) {
|
||||
throw new RuntimeException(t('service.image.invalid_upload'));
|
||||
}
|
||||
if (!is_dir($directory) && !mkdir($directory, 0755, true) && !is_dir($directory)) {
|
||||
throw new RuntimeException(t('service.image.directory_failed'));
|
||||
}
|
||||
if (!copy($source, $target)) {
|
||||
throw new RuntimeException(t('service.image.save_failed'));
|
||||
}
|
||||
@chmod($target, 0644);
|
||||
$db->prepare("UPDATE animal_photos SET size_bytes=? WHERE animal_id=? AND filename='twix.webp'")->execute([
|
||||
filesize($target),
|
||||
$animalId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
537
app/Services/DocumentsService.php
Normal file
537
app/Services/DocumentsService.php
Normal file
|
|
@ -0,0 +1,537 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class DocumentsService
|
||||
{
|
||||
private const ADOPTION_TEMPLATE = __DIR__ . '/../../resources/documents/CONTRAT ADOPTION V1.5.odt';
|
||||
private const TEMPLATES = [
|
||||
'adoption' => self::ADOPTION_TEMPLATE,
|
||||
'abandon' => __DIR__ . '/../../resources/documents/ABANDON V1.0.odt',
|
||||
'benevole' => __DIR__ . '/../../resources/documents/CONTRAT BENEVOLE V2.0.odt',
|
||||
'fa' => __DIR__ . '/../../resources/documents/PROPOSITION FA V1.1.odt',
|
||||
'pre_adoption' => __DIR__ . '/../../resources/documents/CERTIFICAT VISITE PRE-ADOPTION V1.1.odt',
|
||||
];
|
||||
|
||||
public static function adoptionTemplate(): string
|
||||
{
|
||||
if (!is_file(self::ADOPTION_TEMPLATE)) {
|
||||
throw new RuntimeException(t('service.document.adoption_missing'));
|
||||
}
|
||||
return self::ADOPTION_TEMPLATE;
|
||||
}
|
||||
|
||||
public static function template(string $type): string
|
||||
{
|
||||
$path = self::TEMPLATES[$type] ?? null;
|
||||
if (!$path || !is_file($path)) {
|
||||
throw new RuntimeException(t('service.document.template_missing'));
|
||||
}
|
||||
return $path;
|
||||
}
|
||||
|
||||
public static function generateAbandon(array $animal): string
|
||||
{
|
||||
return self::transformTemplate('abandon', 'abandon-' . self::safeName((string) $animal['name']), function (
|
||||
DOMXPath $xp,
|
||||
) use ($animal): void {
|
||||
$sex = match ($animal['sex'] ?? 'U') {
|
||||
'M' => 'Mâle',
|
||||
'F' => 'Femelle',
|
||||
default => 'Non renseigné',
|
||||
};
|
||||
$birth = trim((string) ($animal['birth_date'] ?? ''));
|
||||
$age = '—';
|
||||
if ($birth !== '') {
|
||||
$born = new DateTimeImmutable($birth);
|
||||
$now = new DateTimeImmutable();
|
||||
$diff = $born->diff($now);
|
||||
$age = $diff->y > 0 ? $diff->y . ' an' . ($diff->y > 1 ? 's' : '') : $diff->m . ' mois';
|
||||
}
|
||||
$chip = trim((string) ($animal['chip'] ?: $animal['chip_id'] ?? ''));
|
||||
self::replaceParagraph($xp, 'Nom de l’animal', [
|
||||
['Nom de l’animal : ', 'label'],
|
||||
[(string) $animal['name'], 'value'],
|
||||
['tab'],
|
||||
['Sexe : ', 'label'],
|
||||
[$sex, 'value'],
|
||||
]);
|
||||
self::replaceParagraph($xp, 'Race :', [
|
||||
['Race : ', 'label'],
|
||||
[(string) ($animal['breed'] ?? '—'), 'value'],
|
||||
['tab'],
|
||||
['Âge : ', 'label'],
|
||||
[$age, 'value'],
|
||||
['break'],
|
||||
['Vaccination : ', 'label'],
|
||||
['À vérifier', 'value'],
|
||||
['tab'],
|
||||
['Vermifugation : ', 'label'],
|
||||
['À vérifier', 'value'],
|
||||
['break'],
|
||||
['Numéro de tatouage ou puce électronique : ', 'label'],
|
||||
[$chip !== '' ? $chip : '—', 'value'],
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public static function generateContact(string $type, array $contact): string
|
||||
{
|
||||
if (!in_array($type, ['benevole', 'fa'], true)) {
|
||||
throw new InvalidArgumentException(t('service.document.invalid_type'));
|
||||
}
|
||||
return self::transformTemplate($type, $type . '-' . self::safeName((string) $contact['name']), function (
|
||||
DOMXPath $xp,
|
||||
) use ($type, $contact): void {
|
||||
$name = (string) $contact['name'];
|
||||
$address = (string) ($contact['address'] ?? '');
|
||||
$postal = (string) ($contact['postal_code'] ?? '');
|
||||
$city = (string) ($contact['city'] ?? '');
|
||||
$phone = (string) ($contact['phone'] ?? '');
|
||||
$email = (string) ($contact['email'] ?? '');
|
||||
if ($type === 'benevole') {
|
||||
self::replaceParagraph(
|
||||
$xp,
|
||||
'Nom :',
|
||||
[['Nom : ', 'label'], [$name, 'value'], ['tab'], ['Prénom : ', 'label'], ['', 'value']],
|
||||
0,
|
||||
);
|
||||
self::replaceParagraph($xp, 'Adresse :', [['Adresse : ', 'label'], [$address, 'value']], 0);
|
||||
self::replaceParagraph(
|
||||
$xp,
|
||||
'Code Postal :',
|
||||
[['Code Postal : ', 'label'], [$postal, 'value'], ['tab'], ['Ville : ', 'label'], [$city, 'value']],
|
||||
0,
|
||||
);
|
||||
self::replaceParagraph(
|
||||
$xp,
|
||||
'Numéro de téléphone',
|
||||
[
|
||||
['Numéro de téléphone : ', 'label'],
|
||||
[$phone, 'value'],
|
||||
['tab'],
|
||||
['Adresse e-mail : ', 'label'],
|
||||
[$email, 'value'],
|
||||
],
|
||||
0,
|
||||
);
|
||||
} else {
|
||||
self::replaceParagraph($xp, 'NOM / Prénom :', [['NOM / Prénom : ', 'label'], [$name, 'value']], 0);
|
||||
self::replaceParagraph($xp, 'Adresse :', [['Adresse : ', 'label'], [$address, 'value']], 0);
|
||||
self::replaceParagraph(
|
||||
$xp,
|
||||
'Code postal :',
|
||||
[['Code postal : ', 'label'], [$postal, 'value'], ['tab'], ['Ville : ', 'label'], [$city, 'value']],
|
||||
0,
|
||||
);
|
||||
self::replaceParagraph(
|
||||
$xp,
|
||||
'Téléphone :',
|
||||
[
|
||||
['Téléphone : ', 'label'],
|
||||
[$phone, 'value'],
|
||||
['tab'],
|
||||
['Adresse mail : ', 'label'],
|
||||
[$email, 'value'],
|
||||
],
|
||||
0,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static function generateAdoption(array $animal, ?array $adoption, ?array $vaccine): string
|
||||
{
|
||||
$dir = self::tempDir();
|
||||
$name = self::safeName((string) $animal['name']);
|
||||
$target = $dir . '/contrat-adoption-' . $name . '.odt';
|
||||
if (!copy(self::adoptionTemplate(), $target)) {
|
||||
throw new RuntimeException(t('service.document.copy_failed'));
|
||||
}
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($target) !== true) {
|
||||
throw new RuntimeException(t('service.document.open_failed'));
|
||||
}
|
||||
$xml = $zip->getFromName('content.xml');
|
||||
if ($xml === false) {
|
||||
$zip->close();
|
||||
throw new RuntimeException(t('service.document.content_invalid'));
|
||||
}
|
||||
$dom = new DOMDocument();
|
||||
$dom->preserveWhiteSpace = true;
|
||||
if (!$dom->loadXML($xml)) {
|
||||
$zip->close();
|
||||
throw new RuntimeException(t('service.document.xml_invalid'));
|
||||
}
|
||||
$xp = new DOMXPath($dom);
|
||||
$xp->registerNamespace('text', 'urn:oasis:names:tc:opendocument:xmlns:text:1.0');
|
||||
$xp->registerNamespace('office', 'urn:oasis:names:tc:opendocument:xmlns:office:1.0');
|
||||
self::addGeneratedFieldStyles($dom, $xp);
|
||||
|
||||
$date = static fn(?string $value): string => $value ? date('d/m/Y', strtotime($value)) : '';
|
||||
$sex = match ($animal['sex'] ?? 'U') {
|
||||
'M' => '☒ Mâle ☐ Femelle',
|
||||
'F' => '☐ Mâle ☒ Femelle',
|
||||
default => '☐ Mâle ☐ Femelle',
|
||||
};
|
||||
$origin = trim((string) ($animal['breed'] ?? ''));
|
||||
$chip = trim((string) ($animal['chip'] ?: $animal['chip_id'] ?? ''));
|
||||
$testStatus =
|
||||
'FIV ' .
|
||||
self::testLabel((string) ($animal['fiv_status'] ?? 'unknown')) .
|
||||
' / FeLV ' .
|
||||
self::testLabel((string) ($animal['felv_status'] ?? 'unknown'));
|
||||
$signatureDate = $date($adoption['adoption_date'] ?? date('Y-m-d'));
|
||||
|
||||
self::setSpan($xp, 'T26', (string) $animal['name']);
|
||||
self::setLabeledSpan($xp, 'T28', 'Sexe : ', $sex);
|
||||
self::setSpan($xp, 'T31', ' ' . $date($animal['birth_date'] ?? null));
|
||||
self::setSpan($xp, 'T33', 'Robe : ', null, 'GeneratedField');
|
||||
self::setSpan($xp, 'T34', (string) ($animal['color'] ?? ''));
|
||||
self::setSpan($xp, 'T41', $origin !== '' ? $origin : '—');
|
||||
self::setSpan($xp, 'T42', '');
|
||||
self::setLabeledSpan($xp, 'T45', 'Puce électronique N° : ', $chip !== '' ? $chip : '—');
|
||||
self::setLabeledSpan(
|
||||
$xp,
|
||||
'T49',
|
||||
' : ',
|
||||
(int) ($animal['sterilized'] ?? 0) === 1
|
||||
? '☒ Effectuée ☐ À effectuer avant le :'
|
||||
: '☐ Effectuée ☒ À effectuer avant le :',
|
||||
);
|
||||
self::setLabeledSpan($xp, 'T53', 'Date du test FIV-FELV : ', $testStatus, 'tab');
|
||||
self::setLabeledSpan($xp, 'T55', 'Date de la dernière vaccination : ', $date($vaccine['done_date'] ?? null));
|
||||
self::setLabeledSpan($xp, 'T56', 'Rappel à effectuer le : ', $date($vaccine['due_date'] ?? null), 'tab');
|
||||
|
||||
self::setSpan($xp, 'T98', (string) ($adoption['adopter_name'] ?? ''));
|
||||
self::setSpan($xp, 'T101', (string) ($adoption['adopter_address'] ?? ''));
|
||||
self::setSpan($xp, 'T104', (string) ($adoption['adopter_postal_code'] ?? ''));
|
||||
self::setSpan($xp, 'T107', (string) ($adoption['adopter_city'] ?? ''));
|
||||
self::setSpan($xp, 'T110', '');
|
||||
self::setSpan($xp, 'T113', (string) ($adoption['adopter_phone'] ?? ''));
|
||||
self::setSpan($xp, 'T116', (string) ($adoption['adopter_email'] ?? ''));
|
||||
$signatureCity = AppSettings::get('association_city') ?: '—';
|
||||
self::setLabeledSpan($xp, 'T155', 'Fait à : ', $signatureCity, null, 'GeneratedSignature');
|
||||
self::setLabeledSpan($xp, 'T156', 'Le : ', $signatureDate, 'tab', 'GeneratedSignature');
|
||||
self::setLabeledSpan($xp, 'T247', 'Fait à : ', $signatureCity, null, 'GeneratedSignature');
|
||||
self::setLabeledSpan($xp, 'T253', 'Le : ', $signatureDate, 'tab', 'GeneratedSignature');
|
||||
self::setSpan($xp, 'T283', (string) $animal['name']);
|
||||
self::setLabeledSpan($xp, 'T367', 'Fait à : ', $signatureCity, null, 'GeneratedSignature');
|
||||
self::setLabeledSpan($xp, 'T373', 'Le : ', $signatureDate, 'tab', 'GeneratedSignature');
|
||||
foreach (
|
||||
[
|
||||
'T25',
|
||||
'T30',
|
||||
'T33',
|
||||
'T36',
|
||||
'T40',
|
||||
'T45',
|
||||
'T48',
|
||||
'T52',
|
||||
'T53',
|
||||
'T55',
|
||||
'T56',
|
||||
'T97',
|
||||
'T100',
|
||||
'T103',
|
||||
'T106',
|
||||
'T109',
|
||||
'T112',
|
||||
'T115',
|
||||
'T281',
|
||||
]
|
||||
as $labelStyle
|
||||
) {
|
||||
self::applySpanStyle($xp, $labelStyle);
|
||||
}
|
||||
|
||||
$updated = $dom->saveXML();
|
||||
if ($updated === false || !$zip->addFromString('content.xml', $updated)) {
|
||||
$zip->close();
|
||||
throw new RuntimeException(t('service.document.update_contract_failed'));
|
||||
}
|
||||
$zip->close();
|
||||
return $target;
|
||||
}
|
||||
|
||||
public static function toPdf(string $odtPath): string
|
||||
{
|
||||
$dir = dirname($odtPath);
|
||||
$profile = $dir . '/lo-profile';
|
||||
mkdir($profile, 0775, true);
|
||||
$cmd = [
|
||||
'/usr/bin/env',
|
||||
'-u',
|
||||
'DISPLAY',
|
||||
'SAL_USE_VCLPLUGIN=svp',
|
||||
'/usr/bin/libreoffice',
|
||||
'-env:UserInstallation=' . self::fileUri($profile),
|
||||
'--headless',
|
||||
'--nologo',
|
||||
'--nodefault',
|
||||
'--nolockcheck',
|
||||
'--nofirststartwizard',
|
||||
'--convert-to',
|
||||
'pdf',
|
||||
'--outdir',
|
||||
$dir,
|
||||
$odtPath,
|
||||
];
|
||||
$pipes = [];
|
||||
$process = proc_open($cmd, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
|
||||
if (!is_resource($process)) {
|
||||
throw new RuntimeException(t('service.document.libreoffice_failed'));
|
||||
}
|
||||
$stdout = stream_get_contents($pipes[1]);
|
||||
$stderr = stream_get_contents($pipes[2]);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
$code = proc_close($process);
|
||||
$pdf = $dir . '/' . pathinfo($odtPath, PATHINFO_FILENAME) . '.pdf';
|
||||
if ($code !== 0 || !is_file($pdf)) {
|
||||
throw new RuntimeException(t('service.document.pdf_failed', ['details' => trim($stdout . ' ' . $stderr)]));
|
||||
}
|
||||
return $pdf;
|
||||
}
|
||||
|
||||
public static function cleanup(string $path): void
|
||||
{
|
||||
$dir = dirname($path);
|
||||
if (!str_starts_with($dir, sys_get_temp_dir() . '/globinours-doc-')) {
|
||||
return;
|
||||
}
|
||||
$items = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::CHILD_FIRST,
|
||||
);
|
||||
foreach ($items as $item) {
|
||||
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
|
||||
}
|
||||
rmdir($dir);
|
||||
}
|
||||
|
||||
private static function setSpan(
|
||||
DOMXPath $xp,
|
||||
string $style,
|
||||
string $value,
|
||||
?string $prefix = null,
|
||||
string $generatedStyle = 'GeneratedValue',
|
||||
): void {
|
||||
$nodes = $xp->query("//text:span[@text:style-name='$style']");
|
||||
if (!$nodes || $nodes->length === 0) {
|
||||
return;
|
||||
}
|
||||
$node = $nodes->item(0);
|
||||
while ($node->firstChild) {
|
||||
$node->removeChild($node->firstChild);
|
||||
}
|
||||
$node->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:text:1.0', 'text:style-name', $generatedStyle);
|
||||
if ($prefix === 'tab') {
|
||||
$node->appendChild(
|
||||
$node->ownerDocument->createElementNS('urn:oasis:names:tc:opendocument:xmlns:text:1.0', 'text:tab'),
|
||||
);
|
||||
}
|
||||
$node->appendChild($node->ownerDocument->createTextNode($value));
|
||||
}
|
||||
|
||||
private static function setLabeledSpan(
|
||||
DOMXPath $xp,
|
||||
string $style,
|
||||
string $label,
|
||||
string $value,
|
||||
?string $prefix = null,
|
||||
string $labelStyle = 'GeneratedField',
|
||||
): void {
|
||||
$nodes = $xp->query("//text:span[@text:style-name='$style']");
|
||||
if (!$nodes || $nodes->length === 0) {
|
||||
return;
|
||||
}
|
||||
$node = $nodes->item(0);
|
||||
while ($node->firstChild) {
|
||||
$node->removeChild($node->firstChild);
|
||||
}
|
||||
$node->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:text:1.0', 'text:style-name', $labelStyle);
|
||||
if ($prefix === 'tab') {
|
||||
$node->appendChild(
|
||||
$node->ownerDocument->createElementNS('urn:oasis:names:tc:opendocument:xmlns:text:1.0', 'text:tab'),
|
||||
);
|
||||
}
|
||||
$node->appendChild($node->ownerDocument->createTextNode($label));
|
||||
$valueSpan = $node->ownerDocument->createElementNS(
|
||||
'urn:oasis:names:tc:opendocument:xmlns:text:1.0',
|
||||
'text:span',
|
||||
);
|
||||
$valueSpan->setAttributeNS(
|
||||
'urn:oasis:names:tc:opendocument:xmlns:text:1.0',
|
||||
'text:style-name',
|
||||
'GeneratedValue',
|
||||
);
|
||||
$valueSpan->appendChild($node->ownerDocument->createTextNode($value));
|
||||
$node->appendChild($valueSpan);
|
||||
}
|
||||
|
||||
private static function applySpanStyle(DOMXPath $xp, string $style, string $generatedStyle = 'GeneratedField'): void
|
||||
{
|
||||
$nodes = $xp->query("//text:span[@text:style-name='$style']");
|
||||
if (!$nodes || $nodes->length === 0) {
|
||||
return;
|
||||
}
|
||||
$nodes
|
||||
->item(0)
|
||||
->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:text:1.0', 'text:style-name', $generatedStyle);
|
||||
}
|
||||
|
||||
private static function addGeneratedFieldStyles(DOMDocument $dom, DOMXPath $xp): void
|
||||
{
|
||||
$automatic = $xp->query('//office:automatic-styles')->item(0);
|
||||
if (!$automatic) {
|
||||
return;
|
||||
}
|
||||
self::appendTextStyle($dom, $automatic, 'GeneratedField', '11pt');
|
||||
self::appendTextStyle($dom, $automatic, 'GeneratedValue', '11pt', true);
|
||||
self::appendTextStyle($dom, $automatic, 'GeneratedSignature', '11pt');
|
||||
}
|
||||
|
||||
private static function appendTextStyle(
|
||||
DOMDocument $dom,
|
||||
DOMNode $automatic,
|
||||
string $name,
|
||||
string $size,
|
||||
bool $bold = false,
|
||||
): void {
|
||||
$style = $dom->createElementNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:style');
|
||||
$style->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:name', $name);
|
||||
$style->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:family', 'text');
|
||||
$props = $dom->createElementNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:text-properties');
|
||||
$props->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:font-name', 'Open Sans');
|
||||
$props->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:font-name-asian', 'Open Sans');
|
||||
$props->setAttributeNS(
|
||||
'urn:oasis:names:tc:opendocument:xmlns:style:1.0',
|
||||
'style:font-name-complex',
|
||||
'Open Sans',
|
||||
);
|
||||
$props->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0', 'fo:font-size', $size);
|
||||
$weight = $bold ? 'bold' : 'normal';
|
||||
$props->setAttributeNS(
|
||||
'urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0',
|
||||
'fo:font-weight',
|
||||
$weight,
|
||||
);
|
||||
$props->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:font-size-asian', $size);
|
||||
$props->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:font-size-complex', $size);
|
||||
$props->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:font-weight-asian', $weight);
|
||||
$props->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:font-weight-complex', $weight);
|
||||
$style->appendChild($props);
|
||||
$automatic->appendChild($style);
|
||||
}
|
||||
|
||||
private static function transformTemplate(string $type, string $filename, callable $transform): string
|
||||
{
|
||||
$dir = self::tempDir();
|
||||
$target = $dir . '/' . $filename . '.odt';
|
||||
if (!copy(self::template($type), $target)) {
|
||||
throw new RuntimeException(t('service.document.copy_failed'));
|
||||
}
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($target) !== true) {
|
||||
throw new RuntimeException(t('service.document.open_failed'));
|
||||
}
|
||||
$xml = $zip->getFromName('content.xml');
|
||||
if ($xml === false) {
|
||||
$zip->close();
|
||||
throw new RuntimeException(t('service.document.content_invalid'));
|
||||
}
|
||||
$dom = new DOMDocument();
|
||||
$dom->preserveWhiteSpace = true;
|
||||
if (!$dom->loadXML($xml)) {
|
||||
$zip->close();
|
||||
throw new RuntimeException(t('service.document.xml_invalid'));
|
||||
}
|
||||
$xp = new DOMXPath($dom);
|
||||
$xp->registerNamespace('text', 'urn:oasis:names:tc:opendocument:xmlns:text:1.0');
|
||||
$xp->registerNamespace('office', 'urn:oasis:names:tc:opendocument:xmlns:office:1.0');
|
||||
self::addGeneratedFieldStyles($dom, $xp);
|
||||
$transform($xp);
|
||||
$updated = $dom->saveXML();
|
||||
if ($updated === false || !$zip->addFromString('content.xml', $updated)) {
|
||||
$zip->close();
|
||||
throw new RuntimeException(t('service.document.update_failed'));
|
||||
}
|
||||
$zip->close();
|
||||
return $target;
|
||||
}
|
||||
|
||||
private static function replaceParagraph(DOMXPath $xp, string $startsWith, array $parts, int $occurrence = 0): void
|
||||
{
|
||||
$matches = [];
|
||||
foreach ($xp->query('//text:p') as $paragraph) {
|
||||
$plain = preg_replace('/\s+/u', ' ', str_replace("\u{00A0}", ' ', $paragraph->textContent));
|
||||
if (str_starts_with(trim((string) $plain), $startsWith)) {
|
||||
$matches[] = $paragraph;
|
||||
}
|
||||
}
|
||||
$node = $matches[$occurrence] ?? null;
|
||||
if (!$node) {
|
||||
return;
|
||||
}
|
||||
while ($node->firstChild) {
|
||||
$node->removeChild($node->firstChild);
|
||||
}
|
||||
foreach ($parts as $part) {
|
||||
if ($part === ['tab']) {
|
||||
$node->appendChild(
|
||||
$node->ownerDocument->createElementNS('urn:oasis:names:tc:opendocument:xmlns:text:1.0', 'text:tab'),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if ($part === ['break']) {
|
||||
$node->appendChild(
|
||||
$node->ownerDocument->createElementNS(
|
||||
'urn:oasis:names:tc:opendocument:xmlns:text:1.0',
|
||||
'text:line-break',
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
[$text, $kind] = $part;
|
||||
$span = $node->ownerDocument->createElementNS(
|
||||
'urn:oasis:names:tc:opendocument:xmlns:text:1.0',
|
||||
'text:span',
|
||||
);
|
||||
$span->setAttributeNS(
|
||||
'urn:oasis:names:tc:opendocument:xmlns:text:1.0',
|
||||
'text:style-name',
|
||||
$kind === 'value' ? 'GeneratedValue' : 'GeneratedField',
|
||||
);
|
||||
$span->appendChild($node->ownerDocument->createTextNode($text));
|
||||
$node->appendChild($span);
|
||||
}
|
||||
}
|
||||
|
||||
private static function testLabel(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
'neg' => 'négatif',
|
||||
'pos' => 'positif',
|
||||
default => 'non renseigné',
|
||||
};
|
||||
}
|
||||
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';
|
||||
}
|
||||
private static function tempDir(): string
|
||||
{
|
||||
$dir = sys_get_temp_dir() . '/globinours-doc-' . bin2hex(random_bytes(8));
|
||||
if (!mkdir($dir, 0775, true)) {
|
||||
throw new RuntimeException(t('service.document.temp_failed'));
|
||||
}
|
||||
return $dir;
|
||||
}
|
||||
private static function fileUri(string $path): string
|
||||
{
|
||||
return 'file://' . str_replace('%2F', '/', rawurlencode($path));
|
||||
}
|
||||
}
|
||||
178
app/Services/ExportService.php
Normal file
178
app/Services/ExportService.php
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class ExportService
|
||||
{
|
||||
private const DATASETS = [
|
||||
'animals' => [
|
||||
'Animaux',
|
||||
'animaux.csv',
|
||||
"SELECT a.id,a.internal_code code_interne,a.name nom,a.species espece,a.sex sexe,a.birth_date date_naissance,a.birth_is_estimated naissance_estimee,a.chip_id numero_identification,a.identification_type type_identification,a.identification_date date_identification,a.identification_registration_status statut_enregistrement_icad,a.adoption_availability disponibilite_adoption,a.adoption_available_from disponible_a_partir_du,a.adoption_unavailability_reason motif_indisponibilite,a.breed race,a.color robe,a.hair_type type_poils,a.compatibility_dogs compatible_chiens,a.compatibility_cats compatible_chats,a.compatibility_children compatible_enfants,a.house_trained proprete,a.status statut,a.refuge_room salle_refuge,a.care_box_key box_soin,a.fiv_status statut_fiv,a.felv_status statut_felv,a.sterilization_status statut_sterilisation,a.sterilization_date date_sterilisation,sv.name veterinaire_sterilisation,sc.name cabinet_sterilisation,a.intake_date date_entree,a.intake_type mode_entree,CASE WHEN a.intake_owner_care_home=1 THEN 'owner_care_home' ELSE a.intake_reason END motif_entree,dep.name deposant,a.intake_circumstances circonstances_entree,a.quarantine_until fin_quarantaine,a.rescue_location_name commune_origine,a.rescue_address adresse_origine,a.notes,a.created_at cree_le,a.updated_at modifie_le,a.archived_at archive_le,a.deleted_at supprime_le,a.deletion_reason motif_suppression FROM animals a LEFT JOIN directory_contacts dep ON dep.id=a.depositor_contact_id LEFT JOIN directory_contacts sv ON sv.id=a.sterilization_vet_contact_id LEFT JOIN directory_contacts sc ON sc.id=a.sterilization_clinic_contact_id ORDER BY a.id",
|
||||
],
|
||||
'contacts' => [
|
||||
'Annuaire',
|
||||
'annuaire.csv',
|
||||
'SELECT c.id,c.kind type,c.name nom,GROUP_CONCAT(DISTINCT r.role) roles,c.phone telephone,c.email,c.address adresse,c.postal_code code_postal,c.city ville,c.country pays,o.name structure_liee,c.notes,c.created_at cree_le,c.updated_at modifie_le,c.deleted_at supprime_le FROM directory_contacts c LEFT JOIN directory_contact_roles r ON r.contact_id=c.id LEFT JOIN directory_contacts o ON o.id=c.organization_id GROUP BY c.id ORDER BY c.name COLLATE NOCASE',
|
||||
],
|
||||
'adoptions' => [
|
||||
'Adoptions',
|
||||
'adoptions.csv',
|
||||
'SELECT ad.id,a.internal_code code_animal,a.name animal,ad.adoption_date date_adoption,ad.adopter_name adoptant,ad.adopter_phone telephone,ad.adopter_email email,ad.adopter_address adresse,ad.adopter_postal_code code_postal,ad.adopter_city ville,ad.adopter_country pays,ad.notes,ad.created_at cree_le FROM adoptions ad JOIN animals a ON a.id=ad.animal_id ORDER BY ad.adoption_date,ad.id',
|
||||
],
|
||||
'placements' => [
|
||||
'Parcours d’accueil et retours',
|
||||
'placements-retours.csv',
|
||||
'SELECT ap.id,a.internal_code code_animal,a.name animal,ap.event_type type_evenement,ap.event_date date_evenement,dc.name contact,dc.city ville,ap.reason motif,ap.notes,COALESCE(u.display_name,u.username) saisi_par,ap.created_at cree_le FROM animal_placements ap JOIN animals a ON a.id=ap.animal_id LEFT JOIN directory_contacts dc ON dc.id=ap.contact_id LEFT JOIN users u ON u.id=ap.created_by ORDER BY ap.event_date,ap.id',
|
||||
],
|
||||
'medical' => [
|
||||
'Notes médicales',
|
||||
'notes-medicales.csv',
|
||||
'SELECT mn.id,a.internal_code code_animal,a.name animal,mn.noted_at date_note,mn.kind type,mn.reason motif,mn.symptoms symptomes,mn.exam examen,mn.diagnosis,mn.plan conduite,mn.temperature_c temperature,mn.weight_kg poids,vet.name veterinaire,clinic.name cabinet,COALESCE(u.display_name,u.username) saisi_par,mn.created_at cree_le FROM medical_notes mn JOIN animals a ON a.id=mn.animal_id LEFT JOIN directory_contacts vet ON vet.id=mn.vet_contact_id LEFT JOIN directory_contacts clinic ON clinic.id=mn.clinic_contact_id LEFT JOIN users u ON u.id=mn.created_by ORDER BY mn.noted_at,mn.id',
|
||||
],
|
||||
'treatments' => [
|
||||
'Traitements',
|
||||
'traitements.csv',
|
||||
'SELECT t.id,a.internal_code code_animal,a.name animal,m.name medicament,t.dose_text dose,t.route voie,t.start_date debut,t.end_date fin,t.ongoing en_cours,t.give_morning matin,t.give_evening soir,t.give_as_needed si_besoin,vet.name veterinaire,clinic.name cabinet,t.notes FROM treatments t JOIN animals a ON a.id=t.animal_id JOIN ref_medications m ON m.id=t.medication_id LEFT JOIN directory_contacts vet ON vet.id=t.vet_contact_id LEFT JOIN directory_contacts clinic ON clinic.id=t.clinic_contact_id ORDER BY t.start_date,t.id',
|
||||
],
|
||||
'vaccinations' => [
|
||||
'Vaccinations',
|
||||
'vaccinations.csv',
|
||||
'SELECT v.id,a.internal_code code_animal,a.name animal,rv.name vaccin,v.done_date date_realisation,v.due_date prochain_rappel,v.manufacturer fabricant,v.lot,v.batch_expires_on expiration_lot,COALESCE(v.administered_by_name,au.display_name,au.username) administre_par,COALESCE(cu.display_name,cu.username) saisi_par,vet.name veterinaire,clinic.name cabinet,v.notes FROM vaccinations v JOIN animals a ON a.id=v.animal_id JOIN ref_vaccines rv ON rv.id=v.vaccine_id LEFT JOIN directory_contacts vet ON vet.id=v.vet_contact_id LEFT JOIN directory_contacts clinic ON clinic.id=v.clinic_contact_id LEFT JOIN users au ON au.id=v.administered_by_user_id LEFT JOIN users cu ON cu.id=v.created_by ORDER BY v.done_date,v.id',
|
||||
],
|
||||
'deaths' => [
|
||||
'Décès',
|
||||
'deces.csv',
|
||||
'SELECT d.animal_id,a.internal_code code_animal,a.name animal,d.deceased_date date_deces,d.date_precision precision_date,d.death_age_value age_deces_valeur,d.death_age_unit age_deces_unite,d.cause_code cause,d.cause_details details_cause,d.occurred_in_care sous_responsabilite,d.place_type type_lieu,d.place_details lieu,d.euthanized euthanasie,vet.name veterinaire,crem.name crematorium,d.body_disposition devenir_corps,d.cremation_date date_cremation,d.memorial_medal_color couleur_medaille,d.memorial_medal_count nombre_medailles,d.recovered_source source_recuperation,d.notes,COALESCE(u.display_name,u.username) enregistre_par,d.created_at cree_le,d.updated_at modifie_le FROM animal_deaths d JOIN animals a ON a.id=d.animal_id LEFT JOIN directory_contacts vet ON vet.id=d.veterinarian_contact_id LEFT JOIN directory_contacts crem ON crem.id=d.crematorium_contact_id LEFT JOIN users u ON u.id=d.created_by ORDER BY d.deceased_date,d.animal_id',
|
||||
],
|
||||
'measurements' => [
|
||||
'Mesures',
|
||||
'mesures.csv',
|
||||
'SELECT me.id,a.internal_code code_animal,a.name animal,me.measured_at date_mesure,me.type,me.value valeur,me.unit unite,me.notes,COALESCE(u.display_name,u.username) saisi_par FROM measurements me JOIN animals a ON a.id=me.animal_id LEFT JOIN users u ON u.id=me.created_by ORDER BY me.measured_at,me.id',
|
||||
],
|
||||
'litters' => [
|
||||
'Portées',
|
||||
'portees.csv',
|
||||
'SELECT l.id,m.internal_code code_mere,m.name mere,f.internal_code code_pere,f.name pere,l.birth_date date_naissance,GROUP_CONCAT(k.internal_code) codes_chatons,GROUP_CONCAT(k.name) chatons,l.notes,l.created_at cree_le FROM litters l LEFT JOIN animals m ON m.id=l.mother_id LEFT JOIN animals f ON f.id=l.father_id LEFT JOIN litter_kittens lk ON lk.litter_id=l.id LEFT JOIN animals k ON k.id=lk.animal_id GROUP BY l.id ORDER BY l.birth_date,l.id',
|
||||
],
|
||||
'bonded' => [
|
||||
'Groupes inséparables',
|
||||
'groupes-inseparables.csv',
|
||||
'SELECT g.id,g.name nom_groupe,g.active actif,GROUP_CONCAT(a.internal_code) codes_animaux,GROUP_CONCAT(a.name) animaux,g.notes,g.created_at cree_le,g.dissolved_at dissous_le FROM bonded_groups g LEFT JOIN bonded_group_members gm ON gm.group_id=g.id LEFT JOIN animals a ON a.id=gm.animal_id GROUP BY g.id ORDER BY g.id',
|
||||
],
|
||||
'care_rounds' => [
|
||||
'Tournées',
|
||||
'tournees.csv',
|
||||
'SELECT r.id,r.round_date date_tournee,r.period periode,a.internal_code code_animal,a.name animal,o.food alimentation,o.water eau,o.urine,o.stool selles,o.general_state etat_general,o.comment,o.checked_at valide_le,COALESCE(u.display_name,u.username,r.performed_by) effectue_par FROM care_rounds r LEFT JOIN care_round_observations o ON o.round_id=r.id LEFT JOIN animals a ON a.id=o.animal_id LEFT JOIN users u ON u.id=r.performed_by_user_id ORDER BY r.round_date,r.period,o.checked_at',
|
||||
],
|
||||
'administrations' => [
|
||||
'Administrations de traitements',
|
||||
'administrations-traitements.csv',
|
||||
'SELECT ta.id,r.round_date date_tournee,r.period periode,a.internal_code code_animal,a.name animal,m.name medicament,ta.status statut,ta.comment,ta.administered_at administre_le FROM treatment_administrations ta JOIN care_rounds r ON r.id=ta.round_id JOIN animals a ON a.id=ta.animal_id JOIN treatments t ON t.id=ta.treatment_id JOIN ref_medications m ON m.id=t.medication_id ORDER BY r.round_date,r.period,ta.id',
|
||||
],
|
||||
'media_index' => [
|
||||
'Index des médias',
|
||||
'index-medias.csv',
|
||||
"SELECT 'animal' type_media,p.id,a.internal_code code_animal,a.name animal,p.filename fichier,p.original_name nom_original,p.mime,p.size_bytes taille_octets,p.is_primary principale,p.is_public publique,p.caption legende,p.care_round_id tournee_id,NULL note_medicale_id,p.created_at ajoute_le FROM animal_photos p JOIN animals a ON a.id=p.animal_id UNION ALL SELECT 'medical',mp.id,a.internal_code,a.name,mp.filename,NULL,NULL,NULL,0,0,NULL,NULL,mp.medical_note_id,mp.created_at FROM medical_photos mp JOIN animals a ON a.id=mp.animal_id ORDER BY 14,2",
|
||||
],
|
||||
'movements' => [
|
||||
'Mouvements',
|
||||
'mouvements.csv',
|
||||
'SELECT m.id,a.internal_code code_animal,a.name animal,m.kind type,m.place emplacement,m.lieu motif,m.contact_name contact,m.contact_phone telephone,m.contact_email email,m.note,m.created_at date_mouvement FROM animal_movements m JOIN animals a ON a.id=m.animal_id ORDER BY m.created_at,m.id',
|
||||
],
|
||||
'locations' => [
|
||||
'Historique des lieux de vie',
|
||||
'historique-emplacements.csv',
|
||||
'SELECT lh.id,a.internal_code code_animal,a.name animal,lh.from_status ancien_statut,lh.from_refuge_room ancienne_salle,lh.from_care_box_key ancien_box,lh.from_address ancienne_adresse,lh.to_status nouveau_statut,lh.to_refuge_room nouvelle_salle,lh.to_care_box_key nouveau_box,lh.to_address nouvelle_adresse,lh.reason motif,lh.source origine_action,COALESCE(u.display_name,u.username) utilisateur,lh.moved_at date_deplacement FROM animal_location_history lh JOIN animals a ON a.id=lh.animal_id LEFT JOIN users u ON u.id=lh.user_id ORDER BY lh.moved_at,lh.id',
|
||||
],
|
||||
'history' => [
|
||||
'Historique',
|
||||
'historique.csv',
|
||||
'SELECT h.id,a.internal_code code_animal,a.name animal,h.type,h.label,h.details,COALESCE(u.display_name,u.username) utilisateur,h.created_at date_action FROM animal_history h JOIN animals a ON a.id=h.animal_id LEFT JOIN users u ON u.id=h.user_id ORDER BY h.created_at,h.id',
|
||||
],
|
||||
];
|
||||
|
||||
public static function catalog(): array
|
||||
{
|
||||
foreach (self::DATASETS as $key => [$label, $file]) {
|
||||
$out[$key] = ['label' => t('export.' . $key), 'filename' => $file];
|
||||
}
|
||||
return $out ?? [];
|
||||
}
|
||||
public static function downloadCsv(string $key): void
|
||||
{
|
||||
[$label, $filename] = self::definition($key);
|
||||
header('Content-Type: text/csv; charset=UTF-8');
|
||||
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
$stream = fopen('php://output', 'wb');
|
||||
self::write($stream, $key);
|
||||
fclose($stream);
|
||||
}
|
||||
public static function downloadZip(): void
|
||||
{
|
||||
if (!class_exists(ZipArchive::class)) {
|
||||
throw new RuntimeException(t('service.export.zip_missing'));
|
||||
}
|
||||
$path = tempnam(sys_get_temp_dir(), 'globinours-export-');
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
throw new RuntimeException(t('service.export.prepare_failed'));
|
||||
}
|
||||
try {
|
||||
foreach (self::DATASETS as $key => [, $filename]) {
|
||||
$stream = fopen('php://temp', 'w+b');
|
||||
self::write($stream, $key);
|
||||
rewind($stream);
|
||||
$zip->addFromString($filename, stream_get_contents($stream));
|
||||
fclose($stream);
|
||||
}
|
||||
$zip->addFromString(
|
||||
'LISEZ-MOI.txt',
|
||||
"Export de portabilité Globinours\r\nGénéré le " .
|
||||
date('d/m/Y à H:i') .
|
||||
"\r\nEncodage : UTF-8 avec séparateur point-virgule.\r\nLes comptes utilisateurs, mots de passe, médias et secrets ne sont pas inclus.\r\n",
|
||||
);
|
||||
} finally {
|
||||
$zip->close();
|
||||
}
|
||||
header('Content-Type: application/zip');
|
||||
header('Content-Disposition: attachment; filename="globinours-export-' . date('Y-m-d-His') . '.zip"');
|
||||
header('Content-Length: ' . filesize($path));
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
readfile($path);
|
||||
@unlink($path);
|
||||
}
|
||||
private static function write($stream, string $key): void
|
||||
{
|
||||
[, , $sql] = self::definition($key);
|
||||
fwrite($stream, "\xEF\xBB\xBF");
|
||||
$stmt = DB::pdo()->query($sql);
|
||||
$headers = [];
|
||||
for ($i = 0; $i < $stmt->columnCount(); $i++) {
|
||||
$headers[] = (string) ($stmt->getColumnMeta($i)['name'] ?? 'colonne_' . $i);
|
||||
}
|
||||
fputcsv($stream, $headers, ';', '"', '\\', "\r\n");
|
||||
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
fputcsv($stream, array_map([self::class, 'safeCell'], $row), ';', '"', '\\', "\r\n");
|
||||
}
|
||||
}
|
||||
private static function safeCell(mixed $value): string
|
||||
{
|
||||
if (is_int($value) || is_float($value)) {
|
||||
return (string) $value;
|
||||
}
|
||||
$value = $value === null ? '' : (string) $value;
|
||||
if ($value !== '' && in_array($value[0], ['=', '+', '-', '@'], true)) {
|
||||
$value = "'" . $value;
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
private static function definition(string $key): array
|
||||
{
|
||||
if (!isset(self::DATASETS[$key])) {
|
||||
throw new InvalidArgumentException(t('service.export.unknown_dataset'));
|
||||
}
|
||||
return self::DATASETS[$key];
|
||||
}
|
||||
}
|
||||
62
app/Services/GeoService.php
Normal file
62
app/Services/GeoService.php
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
final class GeoService
|
||||
{
|
||||
public static function geocode(PDO $db, string $query): ?array
|
||||
{
|
||||
$q = trim(preg_replace('~\s+~', ' ', $query));
|
||||
if ($q === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cache DB
|
||||
$st = $db->prepare('SELECT lat,lng FROM geocode_cache WHERE q=:q LIMIT 1');
|
||||
$st->execute([':q' => $q]);
|
||||
$row = $st->fetch(PDO::FETCH_ASSOC);
|
||||
if ($row && $row['lat'] !== null && $row['lng'] !== null) {
|
||||
return ['lat' => (float) $row['lat'], 'lng' => (float) $row['lng']];
|
||||
}
|
||||
|
||||
// Nominatim (OpenStreetMap) — respecter l'usage: User-Agent + requêtes raisonnables
|
||||
$url = 'https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=' . rawurlencode($q);
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json', 'User-Agent: Globinours/1.0 (contact: local-refuge)'],
|
||||
]);
|
||||
|
||||
$body = curl_exec($ch);
|
||||
if ($body === false) {
|
||||
curl_close($ch);
|
||||
return null;
|
||||
}
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($code < 200 || $code >= 300) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$json = json_decode($body, true);
|
||||
if (!is_array($json) || empty($json[0]['lat']) || empty($json[0]['lon'])) {
|
||||
// Stocker "vide" en cache évite de spammer
|
||||
$db->prepare(
|
||||
"INSERT OR REPLACE INTO geocode_cache(q,lat,lng,raw_json,fetched_at) VALUES(:q,NULL,NULL,:raw,datetime('now'))",
|
||||
)->execute([':q' => $q, ':raw' => $body]);
|
||||
return null;
|
||||
}
|
||||
|
||||
$lat = (float) $json[0]['lat'];
|
||||
$lng = (float) $json[0]['lon'];
|
||||
|
||||
$db->prepare(
|
||||
"INSERT OR REPLACE INTO geocode_cache(q,lat,lng,raw_json,fetched_at) VALUES(:q,:lat,:lng,:raw,datetime('now'))",
|
||||
)->execute([':q' => $q, ':lat' => $lat, ':lng' => $lng, ':raw' => $body]);
|
||||
|
||||
return ['lat' => $lat, 'lng' => $lng];
|
||||
}
|
||||
}
|
||||
70
app/Services/GeocodeService.php
Normal file
70
app/Services/GeocodeService.php
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
final class GeocodeService
|
||||
{
|
||||
/**
|
||||
* Géocodage via Nominatim (OpenStreetMap)
|
||||
* Retour: ['lat'=>float, 'lng'=>float, 'display_name'=>string] ou null
|
||||
*
|
||||
* IMPORTANT:
|
||||
* - Respecte le fair use : pas de spam (cache côté DB chez toi, et TTL)
|
||||
* - User-Agent explicite
|
||||
*/
|
||||
public static function geocode(string $query): ?array
|
||||
{
|
||||
$query = trim($query);
|
||||
if ($query === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Nominatim conseille d'envoyer un UA + un contact
|
||||
$email = getenv('GEOCODER_EMAIL') ?: 'contact@localhost';
|
||||
$ua = 'TwixRefuge/1.0 (geocoding; contact: ' . $email . ')';
|
||||
|
||||
$url =
|
||||
'https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&addressdetails=0&q=' .
|
||||
rawurlencode($query);
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => 12,
|
||||
CURLOPT_CONNECTTIMEOUT => 6,
|
||||
CURLOPT_HTTPHEADER => ['User-Agent: ' . $ua, 'Accept: application/json'],
|
||||
CURLOPT_ENCODING => '', // gzip/br si dispo
|
||||
]);
|
||||
|
||||
$body = curl_exec($ch);
|
||||
if ($body === false) {
|
||||
curl_close($ch);
|
||||
return null;
|
||||
}
|
||||
|
||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($status < 200 || $status >= 300) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$json = json_decode($body, true);
|
||||
if (!is_array($json) || empty($json[0])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$item = $json[0];
|
||||
|
||||
$lat = isset($item['lat']) ? (float) $item['lat'] : null;
|
||||
$lng = isset($item['lon']) ? (float) $item['lon'] : null;
|
||||
if ($lat === null || $lng === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'lat' => $lat,
|
||||
'lng' => $lng,
|
||||
'display_name' => (string) ($item['display_name'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
82
app/Services/GlobalSearchService.php
Normal file
82
app/Services/GlobalSearchService.php
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class GlobalSearchService
|
||||
{
|
||||
public static function search(string $query, int $limit = 8): array
|
||||
{
|
||||
$query = trim(preg_replace('/\s+/u', ' ', $query) ?? '');
|
||||
if (mb_strlen($query) < 2) {
|
||||
return [];
|
||||
}
|
||||
$db = DB::pdo();
|
||||
$like = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $query) . '%';
|
||||
$out = [];
|
||||
if (PermissionService::can('animals')) {
|
||||
$out['animals'] = self::rows(
|
||||
$db,
|
||||
"SELECT id,name,internal_code,status,species FROM animals WHERE deleted_at IS NULL AND (name LIKE :q ESCAPE '\\' OR internal_code LIKE :q ESCAPE '\\' OR COALESCE(chip,'') LIKE :q ESCAPE '\\') ORDER BY name COLLATE NOCASE LIMIT :lim",
|
||||
$like,
|
||||
$limit,
|
||||
static fn($r) => [
|
||||
'title' => $r['name'],
|
||||
'detail' => implode(' · ', array_filter([$r['internal_code'], $r['species'], $r['status']])),
|
||||
'url' => '/animal?id=' . (int) $r['id'],
|
||||
],
|
||||
);
|
||||
}
|
||||
if (PermissionService::can('directory')) {
|
||||
$out['directory'] = self::rows(
|
||||
$db,
|
||||
"SELECT id,name,kind,phone,email,city FROM directory_contacts WHERE deleted_at IS NULL AND (name LIKE :q ESCAPE '\\' OR COALESCE(phone,'') LIKE :q ESCAPE '\\' OR COALESCE(email,'') LIKE :q ESCAPE '\\' OR COALESCE(city,'') LIKE :q ESCAPE '\\') ORDER BY name COLLATE NOCASE LIMIT :lim",
|
||||
$like,
|
||||
$limit,
|
||||
static fn($r) => [
|
||||
'title' => $r['name'],
|
||||
'detail' => implode(' · ', array_filter([$r['city'], $r['phone'], $r['email']])),
|
||||
'url' => '/directory/view?id=' . (int) $r['id'],
|
||||
],
|
||||
);
|
||||
}
|
||||
if (PermissionService::can('agenda')) {
|
||||
$out['agenda'] = self::rows(
|
||||
$db,
|
||||
"SELECT id,title,starts_at,location,status FROM agenda_items WHERE title LIKE :q ESCAPE '\\' OR COALESCE(description,'') LIKE :q ESCAPE '\\' OR COALESCE(location,'') LIKE :q ESCAPE '\\' ORDER BY starts_at DESC LIMIT :lim",
|
||||
$like,
|
||||
$limit,
|
||||
static fn($r) => [
|
||||
'title' => $r['title'],
|
||||
'detail' => implode(
|
||||
' · ',
|
||||
array_filter([substr((string) $r['starts_at'], 0, 16), $r['location'], $r['status']]),
|
||||
),
|
||||
'url' => '/agenda?q=' . rawurlencode($r['title']),
|
||||
],
|
||||
);
|
||||
}
|
||||
if (PermissionService::can('accounting')) {
|
||||
$out['accounting'] = self::rows(
|
||||
$db,
|
||||
"SELECT ai.id,ai.reference,ai.invoice_date,ai.status,dc.name vendor FROM accounting_invoices ai LEFT JOIN directory_contacts dc ON dc.id=ai.vendor_contact_id WHERE ai.deleted_at IS NULL AND (ai.reference LIKE :q ESCAPE '\\' OR COALESCE(ai.notes,'') LIKE :q ESCAPE '\\' OR COALESCE(dc.name,'') LIKE :q ESCAPE '\\') ORDER BY ai.invoice_date DESC LIMIT :lim",
|
||||
$like,
|
||||
$limit,
|
||||
static fn($r) => [
|
||||
'title' => $r['reference'],
|
||||
'detail' => implode(' · ', array_filter([$r['vendor'], $r['invoice_date'], $r['status']])),
|
||||
'url' => '/accounting/edit?id=' . (int) $r['id'],
|
||||
],
|
||||
);
|
||||
}
|
||||
return array_filter($out);
|
||||
}
|
||||
|
||||
private static function rows(PDO $db, string $sql, string $like, int $limit, callable $map): array
|
||||
{
|
||||
$s = $db->prepare($sql);
|
||||
$s->bindValue(':q', $like);
|
||||
$s->bindValue(':lim', max(1, min(20, $limit)), PDO::PARAM_INT);
|
||||
$s->execute();
|
||||
return array_map($map, $s->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
}
|
||||
313
app/Services/GrantService.php
Normal file
313
app/Services/GrantService.php
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class GrantService
|
||||
{
|
||||
public const STATUSES = [
|
||||
'draft' => 'Brouillon',
|
||||
'ready' => 'Prête',
|
||||
'submitted' => 'Déposée',
|
||||
'awarded' => 'Accordée',
|
||||
'refused' => 'Refusée',
|
||||
'withdrawn' => 'Abandonnée',
|
||||
];
|
||||
public const CATEGORIES = [
|
||||
'official_form' => 'Formulaire officiel de l’organisme',
|
||||
'statutes' => 'Statuts',
|
||||
'prefecture' => 'Récépissé de préfecture',
|
||||
'jo' => 'Publication au Journal officiel',
|
||||
'rib' => 'RIB',
|
||||
'insurance' => 'Attestation d’assurance',
|
||||
'ag' => 'Procès-verbal d’assemblée générale',
|
||||
'activity' => 'Rapport d’activité',
|
||||
'accounts' => 'Comptes approuvés',
|
||||
'budget' => 'Budget prévisionnel',
|
||||
'other' => 'Autre',
|
||||
];
|
||||
public const REQUIRED = [
|
||||
'official_form',
|
||||
'statutes',
|
||||
'prefecture',
|
||||
'jo',
|
||||
'rib',
|
||||
'insurance',
|
||||
'ag',
|
||||
'activity',
|
||||
'accounts',
|
||||
'budget',
|
||||
];
|
||||
|
||||
public static function statusLabel(string $key): string
|
||||
{
|
||||
return t('grant.status.' . $key, [], self::STATUSES[$key] ?? $key);
|
||||
}
|
||||
public static function categoryLabel(string $key): string
|
||||
{
|
||||
return t('grant.category.' . $key, [], self::CATEGORIES[$key] ?? $key);
|
||||
}
|
||||
|
||||
public static function ensureYear(int $year): array
|
||||
{
|
||||
$db = DB::pdo();
|
||||
$s = $db->prepare('INSERT OR IGNORE INTO grant_years(year) VALUES(?)');
|
||||
$s->execute([$year]);
|
||||
$s = $db->prepare('SELECT * FROM grant_years WHERE year=?');
|
||||
$s->execute([$year]);
|
||||
return $s->fetch() ?: [];
|
||||
}
|
||||
|
||||
public static function statistics(int $year): array
|
||||
{
|
||||
$db = DB::pdo();
|
||||
$start = sprintf('%04d-01-01', $year);
|
||||
$end = sprintf('%04d-12-31', $year);
|
||||
$scalar = static function (string $sql, array $params = []) use ($db): float {
|
||||
$s = $db->prepare($sql);
|
||||
$s->execute($params);
|
||||
return (float) ($s->fetchColumn() ?: 0);
|
||||
};
|
||||
$p = [':start' => $start, ':end' => $end];
|
||||
$entries = $scalar(
|
||||
"SELECT COUNT(*) FROM (SELECT m.animal_id FROM animal_movements m JOIN animals a ON a.id=m.animal_id WHERE a.deleted_at IS NULL AND m.kind='entry' AND date(m.created_at) BETWEEN :start AND :end UNION SELECT a.id FROM animals a WHERE a.deleted_at IS NULL AND date(a.intake_date) BETWEEN :start AND :end)",
|
||||
$p,
|
||||
);
|
||||
$exits = $scalar(
|
||||
"SELECT (SELECT COUNT(*) FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE a.deleted_at IS NULL AND date(ad.adoption_date) BETWEEN :start AND :end)+(SELECT COUNT(*) FROM animal_deaths d JOIN animals a ON a.id=d.animal_id WHERE a.deleted_at IS NULL AND date(COALESCE(d.deceased_date,printf('%04d-01-01',d.death_year))) BETWEEN :start AND :end AND NOT EXISTS(SELECT 1 FROM adoptions ad WHERE ad.animal_id=d.animal_id))",
|
||||
$p,
|
||||
);
|
||||
$adoptions = $scalar('SELECT COUNT(*) FROM adoptions WHERE date(adoption_date) BETWEEN :start AND :end', $p);
|
||||
$deaths = $scalar(
|
||||
"SELECT COUNT(*) FROM animal_deaths WHERE date(COALESCE(deceased_date,printf('%04d-01-01',death_year))) BETWEEN :start AND :end",
|
||||
$p,
|
||||
);
|
||||
$births = $scalar(
|
||||
"SELECT COUNT(*) FROM litter_kittens lk JOIN litters l ON l.id=lk.litter_id JOIN animals a ON a.id=lk.animal_id WHERE a.deleted_at IS NULL AND date(COALESCE(NULLIF(l.birth_date,''),NULLIF(a.birth_date,''))) BETWEEN :start AND :end",
|
||||
$p,
|
||||
);
|
||||
$vaccinations = $scalar('SELECT COUNT(*) FROM vaccinations WHERE date(done_date) BETWEEN :start AND :end', $p);
|
||||
$treatments = $scalar('SELECT COUNT(*) FROM treatments WHERE date(start_date) BETWEEN :start AND :end', $p);
|
||||
$avg = $scalar(
|
||||
"SELECT AVG(julianday(adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))) FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE date(adoption_date) BETWEEN :start AND :end AND date(adoption_date)>=date(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))",
|
||||
$p,
|
||||
);
|
||||
$current = $scalar(
|
||||
"SELECT COUNT(*) FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND status NOT IN ('adopte','decede')",
|
||||
);
|
||||
return compact(
|
||||
'entries',
|
||||
'exits',
|
||||
'adoptions',
|
||||
'deaths',
|
||||
'births',
|
||||
'vaccinations',
|
||||
'treatments',
|
||||
'avg',
|
||||
'current',
|
||||
);
|
||||
}
|
||||
|
||||
public static function detailedStatistics(int $year): array
|
||||
{
|
||||
$db = DB::pdo();
|
||||
$start = sprintf('%04d-01-01', $year);
|
||||
$end = sprintf('%04d-12-31', $year);
|
||||
$p = [':start' => $start, ':end' => $end];
|
||||
$rows = static function (string $sql, array $params) use ($db): array {
|
||||
$s = $db->prepare($sql);
|
||||
$s->execute($params);
|
||||
return $s->fetchAll(PDO::FETCH_ASSOC);
|
||||
};
|
||||
$map = static function (array $items, string $key = 'label'): array {
|
||||
$out = [];
|
||||
foreach ($items as $item) {
|
||||
$out[(string) $item[$key]] = (int) $item['total'];
|
||||
}
|
||||
return $out;
|
||||
};
|
||||
$base =
|
||||
"WITH entry_sources AS (SELECT m.animal_id,date(m.created_at) entry_date FROM animal_movements m JOIN animals source_animal ON source_animal.id=m.animal_id WHERE source_animal.deleted_at IS NULL AND m.kind='entry' AND date(m.created_at) BETWEEN :start AND :end UNION SELECT id animal_id,date(intake_date) entry_date FROM animals WHERE deleted_at IS NULL AND date(intake_date) BETWEEN :start AND :end), entered AS (SELECT animal_id,MIN(entry_date) entry_date FROM entry_sources GROUP BY animal_id) ";
|
||||
$sex = $map(
|
||||
$rows(
|
||||
$base .
|
||||
"SELECT CASE a.sex WHEN 'F' THEN 'Femelles' WHEN 'M' THEN 'Mâles' ELSE 'Inconnu' END label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY a.sex ORDER BY total DESC",
|
||||
$p,
|
||||
),
|
||||
);
|
||||
$ages = $map(
|
||||
$rows(
|
||||
$base .
|
||||
"SELECT CASE WHEN a.birth_date IS NULL THEN 'Non renseigné' WHEN (julianday(e.entry_date)-julianday(a.birth_date))/365.25<1 THEN 'Moins d’un an' WHEN (julianday(e.entry_date)-julianday(a.birth_date))/365.25<3 THEN '1 à 2 ans' WHEN (julianday(e.entry_date)-julianday(a.birth_date))/365.25<8 THEN '3 à 7 ans' ELSE '8 ans et plus' END label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC",
|
||||
$p,
|
||||
),
|
||||
);
|
||||
$originRows = $rows(
|
||||
$base . 'SELECT a.rescue_location_name,a.rescue_address FROM entered e JOIN animals a ON a.id=e.animal_id',
|
||||
$p,
|
||||
);
|
||||
$origins = [];
|
||||
foreach ($originRows as $originRow) {
|
||||
$label = self::municipality((string) ($originRow['rescue_location_name'] ?: $originRow['rescue_address']));
|
||||
$origins[$label] = ($origins[$label] ?? 0) + 1;
|
||||
}
|
||||
arsort($origins, SORT_NUMERIC);
|
||||
$origins = array_slice($origins, 0, 12, true);
|
||||
$deathCauses = $map(
|
||||
$rows(
|
||||
"SELECT cause_code label,COUNT(*) total FROM animal_deaths WHERE date(COALESCE(deceased_date,printf('%04d-01-01',death_year))) BETWEEN :start AND :end GROUP BY cause_code ORDER BY total DESC",
|
||||
$p,
|
||||
),
|
||||
);
|
||||
$stays = $map(
|
||||
$rows(
|
||||
"SELECT CASE WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<30 THEN 'Moins d’un mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<90 THEN '1 à 3 mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<180 THEN '3 à 6 mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<365 THEN '6 à 12 mois' ELSE 'Plus d’un an' END label,COUNT(*) total FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE date(ad.adoption_date) BETWEEN :start AND :end AND date(ad.adoption_date)>=date(COALESCE(NULLIF(a.intake_date,''),date(a.created_at))) GROUP BY label ORDER BY total DESC",
|
||||
$p,
|
||||
),
|
||||
);
|
||||
return compact('sex', 'ages', 'origins', 'deathCauses', 'stays');
|
||||
}
|
||||
|
||||
public static function comparison(int $year): array
|
||||
{
|
||||
$current = self::statistics($year);
|
||||
$previous = self::statistics($year - 1);
|
||||
$out = [];
|
||||
foreach (['entries', 'exits', 'adoptions', 'deaths', 'births', 'vaccinations', 'treatments', 'avg'] as $key) {
|
||||
$a = (float) $current[$key];
|
||||
$b = (float) $previous[$key];
|
||||
$out[$key] = [
|
||||
'current' => $a,
|
||||
'previous' => $b,
|
||||
'difference' => $a - $b,
|
||||
'percent' => $b != 0 ? (($a - $b) / $b) * 100 : null,
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
public static function municipality(string $value): string
|
||||
{
|
||||
$value = trim(preg_replace('/\s+/u', ' ', $value) ?? '');
|
||||
if ($value === '') {
|
||||
return 'Non renseignée';
|
||||
}
|
||||
if (preg_match('/\b\d{5}\s+([^,;]+)(?:[,;]|$)/u', $value, $match)) {
|
||||
$value = trim($match[1]);
|
||||
} elseif (str_contains($value, ',')) {
|
||||
$parts = array_values(array_filter(array_map('trim', explode(',', $value))));
|
||||
$value = (string) end($parts);
|
||||
}
|
||||
$value = preg_replace('/^(?:FRANCE|FR)\s*[-–—]?\s*/iu', '', $value) ?? $value;
|
||||
return $value !== '' ? mb_convert_case($value, MB_CASE_TITLE, 'UTF-8') : 'Non renseignée';
|
||||
}
|
||||
|
||||
public static function applications(int $year): array
|
||||
{
|
||||
$s = DB::pdo()->prepare(
|
||||
'SELECT * FROM grant_applications WHERE year=? ORDER BY COALESCE(deadline,\'9999-12-31\'), organization_name',
|
||||
);
|
||||
$s->execute([$year]);
|
||||
return $s->fetchAll();
|
||||
}
|
||||
public static function documents(int $year): array
|
||||
{
|
||||
$s = DB::pdo()->prepare(
|
||||
'SELECT d.*,u.display_name uploader,ga.organization_name application_name FROM grant_documents d LEFT JOIN users u ON u.id=d.uploaded_by LEFT JOIN grant_applications ga ON ga.id=d.application_id WHERE d.year IS NULL OR d.year=? ORDER BY d.category,d.created_at DESC',
|
||||
);
|
||||
$s->execute([$year]);
|
||||
return $s->fetchAll();
|
||||
}
|
||||
public static function checklist(int $year): array
|
||||
{
|
||||
$applications = self::applications($year);
|
||||
$documents = self::documents($year);
|
||||
$today = date('Y-m-d');
|
||||
$soon = date('Y-m-d', strtotime('+30 days'));
|
||||
$result = [];
|
||||
foreach ($applications as $application) {
|
||||
$items = [];
|
||||
$valid = 0;
|
||||
$warnings = 0;
|
||||
foreach (self::REQUIRED as $category) {
|
||||
$matches = array_values(
|
||||
array_filter($documents, static function (array $document) use ($category, $application): bool {
|
||||
if ($document['category'] !== $category) {
|
||||
return false;
|
||||
}
|
||||
if ($category === 'official_form') {
|
||||
return (int) ($document['application_id'] ?? 0) === (int) $application['id'];
|
||||
}
|
||||
return empty($document['application_id']) ||
|
||||
(int) $document['application_id'] === (int) $application['id'];
|
||||
}),
|
||||
);
|
||||
usort(
|
||||
$matches,
|
||||
static fn(array $a, array $b): int => strcmp((string) $b['created_at'], (string) $a['created_at']),
|
||||
);
|
||||
$document = $matches[0] ?? null;
|
||||
$state = 'missing';
|
||||
if ($document) {
|
||||
$expiry = (string) ($document['valid_until'] ?? '');
|
||||
if ($expiry !== '' && $expiry < $today) {
|
||||
$state = 'expired';
|
||||
} elseif ($expiry !== '' && $expiry <= $soon) {
|
||||
$state = 'expiring';
|
||||
} else {
|
||||
$state = 'valid';
|
||||
}
|
||||
}
|
||||
if ($state === 'valid') {
|
||||
$valid++;
|
||||
} elseif ($state === 'expiring') {
|
||||
$valid++;
|
||||
$warnings++;
|
||||
} else {
|
||||
$warnings++;
|
||||
}
|
||||
$items[$category] = ['state' => $state, 'document' => $document];
|
||||
}
|
||||
$total = count(self::REQUIRED);
|
||||
$result[(int) $application['id']] = [
|
||||
'items' => $items,
|
||||
'valid' => $valid,
|
||||
'total' => $total,
|
||||
'warnings' => $warnings,
|
||||
'complete' => $valid === $total,
|
||||
];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
public static function alerts(int $year): array
|
||||
{
|
||||
$applications = self::applications($year);
|
||||
$checklist = self::checklist($year);
|
||||
$today = date('Y-m-d');
|
||||
$soon = date('Y-m-d', strtotime('+30 days'));
|
||||
$deadlines = 0;
|
||||
$overdue = 0;
|
||||
$incomplete = 0;
|
||||
$documents = 0;
|
||||
foreach ($applications as $application) {
|
||||
if (in_array($application['status'], ['awarded', 'refused', 'withdrawn'], true)) {
|
||||
continue;
|
||||
}
|
||||
$deadline = (string) ($application['deadline'] ?? '');
|
||||
if ($deadline !== '' && $deadline < $today) {
|
||||
$overdue++;
|
||||
} elseif ($deadline !== '' && $deadline <= $soon) {
|
||||
$deadlines++;
|
||||
}
|
||||
if (!($checklist[(int) $application['id']]['complete'] ?? false)) {
|
||||
$incomplete++;
|
||||
}
|
||||
}
|
||||
foreach (self::documents($year) as $document) {
|
||||
$expiry = (string) ($document['valid_until'] ?? '');
|
||||
if ($expiry !== '' && $expiry <= $soon) {
|
||||
$documents++;
|
||||
}
|
||||
}
|
||||
return compact('deadlines', 'overdue', 'incomplete', 'documents');
|
||||
}
|
||||
}
|
||||
51
app/Services/I18n.php
Normal file
51
app/Services/I18n.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class I18n
|
||||
{
|
||||
public const LOCALES = ['fr' => 'Français', 'en' => 'English'];
|
||||
private static string $locale = 'fr';
|
||||
private static array $catalogues = [];
|
||||
public static function setLocale(string $locale): void
|
||||
{
|
||||
self::$locale = isset(self::LOCALES[$locale]) ? $locale : 'fr';
|
||||
}
|
||||
public static function locale(): string
|
||||
{
|
||||
return self::$locale;
|
||||
}
|
||||
public static function translate(string $key, array $replace = []): string
|
||||
{
|
||||
$value = self::catalogue(self::$locale)[$key] ?? (self::catalogue('fr')[$key] ?? $key);
|
||||
foreach ($replace as $name => $replacement) {
|
||||
$value = str_replace(':' . $name, (string) $replacement, $value);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
public static function choice(string $singular, string $plural, int|float $count, array $replace = []): string
|
||||
{
|
||||
return self::translate($count == 1 ? $singular : $plural, ['count' => $count] + $replace);
|
||||
}
|
||||
private static function catalogue(string $locale): array
|
||||
{
|
||||
if (isset(self::$catalogues[$locale])) {
|
||||
return self::$catalogues[$locale];
|
||||
}
|
||||
$path = __DIR__ . '/../../resources/lang/' . $locale . '.php';
|
||||
$catalogue = is_file($path) ? require $path : [];
|
||||
return self::$catalogues[$locale] = is_array($catalogue) ? $catalogue : [];
|
||||
}
|
||||
}
|
||||
if (!function_exists('t')) {
|
||||
function t(string $key, array $replace = []): string
|
||||
{
|
||||
return I18n::translate($key, $replace);
|
||||
}
|
||||
}
|
||||
if (!function_exists('tn')) {
|
||||
function tn(string $singular, string $plural, int|float $count, array $replace = []): string
|
||||
{
|
||||
return I18n::choice($singular, $plural, $count, $replace);
|
||||
}
|
||||
}
|
||||
363
app/Services/IcadService.php
Normal file
363
app/Services/IcadService.php
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
<?php
|
||||
|
||||
final class IcadService
|
||||
{
|
||||
private PDO $db;
|
||||
|
||||
public function __construct(PDO $db)
|
||||
{
|
||||
$this->db = $db;
|
||||
}
|
||||
|
||||
// ========= CACHE =========
|
||||
|
||||
public function getCachedByAnimal(int $animalId): ?array
|
||||
{
|
||||
$st = $this->db->prepare('SELECT * FROM icad_cache WHERE animal_id=:id LIMIT 1');
|
||||
$st->execute([':id' => $animalId]);
|
||||
$row = $st->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $row['data_json'] ? json_decode($row['data_json'], true) : null;
|
||||
|
||||
return [
|
||||
'animal_id' => (int) $row['animal_id'],
|
||||
'chip_id' => (string) ($row['chip_id'] ?? ''),
|
||||
'status' => (string) ($row['status'] ?? 'empty'), // ok|error|empty
|
||||
'error' => (string) ($row['error_msg'] ?? ''),
|
||||
'fetched_at' => (string) ($row['fetched_at'] ?? ''),
|
||||
'data' => is_array($data) ? $data : null,
|
||||
];
|
||||
}
|
||||
|
||||
public function isStale(?array $cache, int $ttlSeconds = 86400): bool
|
||||
{
|
||||
if (!$cache || empty($cache['fetched_at'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// si en erreur → on retente
|
||||
if (($cache['status'] ?? '') !== 'ok') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$t = strtotime($cache['fetched_at']);
|
||||
if ($t === false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return time() - $t > $ttlSeconds;
|
||||
}
|
||||
|
||||
private function saveCache(int $animalId, string $chipId, string $status, ?string $err, ?array $data): void
|
||||
{
|
||||
$st = $this->db->prepare("
|
||||
INSERT INTO icad_cache(animal_id, chip_id, data_json, fetched_at, status, error_msg)
|
||||
VALUES(:aid,:chip,:json,datetime('now'),:st,:err)
|
||||
ON CONFLICT(animal_id) DO UPDATE SET
|
||||
chip_id = excluded.chip_id,
|
||||
data_json = excluded.data_json,
|
||||
fetched_at = excluded.fetched_at,
|
||||
status = excluded.status,
|
||||
error_msg = excluded.error_msg
|
||||
");
|
||||
$st->execute([
|
||||
':aid' => $animalId,
|
||||
':chip' => $chipId,
|
||||
':json' => $data ? json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null,
|
||||
':st' => $status,
|
||||
':err' => $err,
|
||||
]);
|
||||
}
|
||||
|
||||
// ========= PUBLIC API =========
|
||||
|
||||
public function refreshByChip(int $animalId, string $chipId): array
|
||||
{
|
||||
$chipId = preg_replace('/\D+/', '', $chipId);
|
||||
if ($chipId === '') {
|
||||
throw new RuntimeException(t('service.icad.invalid_chip'));
|
||||
}
|
||||
|
||||
$login = getenv('ICAD_LOGIN') ?: '';
|
||||
$pass = getenv('ICAD_PASSWORD') ?: '';
|
||||
if ($login === '' || $pass === '') {
|
||||
throw new RuntimeException(t('service.icad.credentials_missing'));
|
||||
}
|
||||
|
||||
$cookie = sys_get_temp_dir() . '/twix_icad_' . bin2hex(random_bytes(6)) . '.cookie';
|
||||
|
||||
try {
|
||||
// 1) GET HOME => token _token du formulaire login ACTEUR
|
||||
$r1 = $this->req('https://www.i-cad.fr/', $cookie);
|
||||
|
||||
$token = $this->extractToken($r1['body'], '_token'); // prend le 1er _token trouvé
|
||||
|
||||
// 2) POST login/check/acteur (XHR)
|
||||
$post = http_build_query([
|
||||
'_token' => $token,
|
||||
'login' => $login,
|
||||
'password' => $pass,
|
||||
]);
|
||||
|
||||
$r2 = $this->req('https://www.i-cad.fr/login/check/acteur', $cookie, 'POST', $post, [
|
||||
'Accept: */*',
|
||||
'Content-Type: application/x-www-form-urlencoded; charset=UTF-8',
|
||||
'X-Requested-With: XMLHttpRequest',
|
||||
'Origin: https://www.i-cad.fr',
|
||||
'Referer: https://www.i-cad.fr/',
|
||||
]);
|
||||
|
||||
// 3) Vérif session
|
||||
$r2b = $this->req('https://www.i-cad.fr/pro', $cookie);
|
||||
if (stripos($r2b['body'], 'Se connecter') !== false) {
|
||||
throw new RuntimeException(t('service.icad.login_failed'));
|
||||
}
|
||||
|
||||
// 4) GET recherche. I-CAD a renommé le formulaire
|
||||
// search_animal en animal_search en 2026 : on accepte les deux.
|
||||
$r3 = $this->req('https://www.i-cad.fr/identification/recherche', $cookie);
|
||||
[$searchForm, $searchToken] = $this->extractAnimalSearchForm($r3['body']);
|
||||
|
||||
// 5) POST recherche
|
||||
$post2 = http_build_query([
|
||||
$searchForm . '[insert]' => $chipId,
|
||||
$searchForm . '[tatou]' => '',
|
||||
$searchForm . '[espece]' => '',
|
||||
$searchForm . '[sexe]' => '',
|
||||
$searchForm . '[robe]' => '',
|
||||
$searchForm . '[_token]' => $searchToken,
|
||||
]);
|
||||
|
||||
$r4 = $this->req('https://www.i-cad.fr/identification/recherche', $cookie, 'POST', $post2, [
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
'Origin: https://www.i-cad.fr',
|
||||
'Referer: https://www.i-cad.fr/identification/recherche',
|
||||
]);
|
||||
|
||||
$animalPath = $this->extractAnimalPath($r4);
|
||||
if (!$animalPath) {
|
||||
throw new RuntimeException(t('service.icad.no_result'));
|
||||
}
|
||||
|
||||
// 6) GET fiche animal
|
||||
$r5 = $this->req('https://www.i-cad.fr' . $animalPath, $cookie);
|
||||
$data = $this->parseAnimalHtml($r5['body'], $animalPath);
|
||||
|
||||
$this->saveCache($animalId, $chipId, 'ok', null, $data);
|
||||
return $data;
|
||||
} catch (Throwable $e) {
|
||||
$this->saveCache($animalId, $chipId, 'error', $e->getMessage(), null);
|
||||
throw $e;
|
||||
} finally {
|
||||
@unlink($cookie);
|
||||
}
|
||||
}
|
||||
|
||||
// ========= HTTP =========
|
||||
|
||||
private function req(
|
||||
string $url,
|
||||
string $cookieFile,
|
||||
string $method = 'GET',
|
||||
?string $body = null,
|
||||
array $headers = [],
|
||||
): array {
|
||||
$ch = curl_init($url);
|
||||
|
||||
$defaultHeaders = [
|
||||
'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language: fr,en-US;q=0.9,en;q=0.8',
|
||||
'Connection: keep-alive',
|
||||
'DNT: 1',
|
||||
'Sec-GPC: 1',
|
||||
];
|
||||
$allHeaders = array_merge($defaultHeaders, $headers);
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 10,
|
||||
CURLOPT_HEADER => true,
|
||||
CURLOPT_COOKIEJAR => $cookieFile,
|
||||
CURLOPT_COOKIEFILE => $cookieFile,
|
||||
CURLOPT_USERAGENT => 'Mozilla/5.0 (X11; Linux x86_64; rv:147.0) Gecko/20100101 Firefox/147.0',
|
||||
CURLOPT_TIMEOUT => 25,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
CURLOPT_ENCODING => '', // gzip/br si dispo
|
||||
CURLOPT_HTTPHEADER => $allHeaders,
|
||||
]);
|
||||
|
||||
if ($method === 'POST') {
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body ?? '');
|
||||
}
|
||||
|
||||
$raw = curl_exec($ch);
|
||||
if ($raw === false) {
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
throw new RuntimeException(t('service.icad.network_error', ['error' => $err]));
|
||||
}
|
||||
|
||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$hs = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||||
$effUrl = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
|
||||
curl_close($ch);
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'headers' => substr($raw, 0, $hs),
|
||||
'body' => substr($raw, $hs),
|
||||
'effective_url' => $effUrl,
|
||||
];
|
||||
}
|
||||
|
||||
// ========= TOKENS / PARSING =========
|
||||
|
||||
private function extractToken(string $html, string $fieldName): string
|
||||
{
|
||||
$field = preg_quote($fieldName, '/');
|
||||
|
||||
// 1) cas standard: name="X" value="Y"
|
||||
$re = '/<input\b[^>]*\bname=(["\'])' . $field . '\1[^>]*\bvalue=(["\'])(.*?)\2[^>]*>/is';
|
||||
if (preg_match($re, $html, $m)) {
|
||||
return html_entity_decode($m[3], ENT_QUOTES);
|
||||
}
|
||||
|
||||
// 2) fallback: value avant name
|
||||
$re2 = '/<input\b[^>]*\bvalue=(["\'])(.*?)\1[^>]*\bname=(["\'])' . $field . '\3[^>]*>/is';
|
||||
if (preg_match($re2, $html, $m)) {
|
||||
return html_entity_decode($m[2], ENT_QUOTES);
|
||||
}
|
||||
|
||||
// 3) si fieldName == _token : on prend le 1er _token trouvé
|
||||
if ($fieldName === '_token') {
|
||||
if (preg_match('/<input\b[^>]*\bname=(["\'])_token\1[^>]*\bvalue=(["\'])(.*?)\2[^>]*>/is', $html, $m)) {
|
||||
return html_entity_decode($m[3], ENT_QUOTES);
|
||||
}
|
||||
}
|
||||
|
||||
throw new RuntimeException(t('service.icad.token_missing', ['field' => $fieldName]));
|
||||
}
|
||||
|
||||
private function extractAnimalSearchForm(string $html): array
|
||||
{
|
||||
foreach (['animal_search', 'search_animal'] as $formName) {
|
||||
try {
|
||||
return [$formName, $this->extractToken($html, $formName . '[_token]')];
|
||||
} catch (RuntimeException $e) {
|
||||
// Essayer le nom historique suivant.
|
||||
}
|
||||
}
|
||||
|
||||
throw new RuntimeException(t('service.icad.form_missing'));
|
||||
}
|
||||
|
||||
private function extractAnimalPath(array $resp): ?string
|
||||
{
|
||||
// souvent une redirection finale /animal/XXXX dans effective_url
|
||||
$p = parse_url($resp['effective_url'] ?? '', PHP_URL_PATH);
|
||||
if (is_string($p) && preg_match('~^/animal/\d+~', $p)) {
|
||||
return $p;
|
||||
}
|
||||
|
||||
// fallback HTML
|
||||
if (preg_match('~href=["\'](/animal/\d+)["\']~', $resp['body'], $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
if (preg_match('~(/animal/\d+)~', $resp['body'], $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function parseAnimalHtml(string $html, string $animalPath): array
|
||||
{
|
||||
libxml_use_internal_errors(true);
|
||||
|
||||
$dom = new DOMDocument();
|
||||
$dom->loadHTML($html);
|
||||
$xp = new DOMXPath($dom);
|
||||
|
||||
$txt = function (string $xpath) use ($xp): string {
|
||||
$n = $xp->query($xpath);
|
||||
if (!$n || $n->length === 0) {
|
||||
return '';
|
||||
}
|
||||
return trim(preg_replace('~\s+~', ' ', $n->item(0)->textContent));
|
||||
};
|
||||
|
||||
$name = $txt('//*[@id="animal_informations"]//*[contains(@class,"title")]//span[contains(@class,"emphasis")]');
|
||||
$insertRaw = $txt('//*[@id="animal_informations"]//*[contains(@class,"id")]//span[contains(@class,"number")]');
|
||||
$insert = preg_replace('~\s+~', '', $insertRaw);
|
||||
|
||||
// infos label/value
|
||||
$rows = $xp->query('//*[@id="animal_informations"]//*[contains(@class,"table-row")]');
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
$ln = $xp->query('.//*[contains(@class,"label")]', $row)->item(0);
|
||||
$vn = $xp->query('.//*[contains(@class,"value")]', $row)->item(0);
|
||||
if (!$ln || !$vn) {
|
||||
continue;
|
||||
}
|
||||
$label = trim(preg_replace('~\s+~', ' ', $ln->textContent));
|
||||
$value = trim(preg_replace('~\s+~', ' ', $vn->textContent));
|
||||
if ($label !== '') {
|
||||
$map[$label] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
// events
|
||||
$events = [];
|
||||
$trs = $xp->query('//*[contains(@class,"event-row")]');
|
||||
foreach ($trs as $tr) {
|
||||
$tds = $xp->query('./td', $tr);
|
||||
if (!$tds || $tds->length < 3) {
|
||||
continue;
|
||||
}
|
||||
$events[] = [
|
||||
'date' => trim($tds->item(0)->textContent),
|
||||
'label' => trim(preg_replace('~\s+~', ' ', $tds->item(1)->textContent)),
|
||||
'recorded_at' => trim($tds->item(2)->textContent),
|
||||
];
|
||||
}
|
||||
|
||||
// Coordonnées du détenteur.
|
||||
$ownerName = $txt('//*[@id="down_right_block"]//*[contains(@class,"coords")]//*[contains(@class,"name")]');
|
||||
$ownerEmail = $txt('//*[@id="down_right_block"]//*[contains(@class,"coords")]//*[contains(@class,"address")]');
|
||||
$ownerPhone = $txt(
|
||||
'//*[@id="down_right_block"]//*[contains(@class,"coords")]//*[contains(@class,"phone_numbers")]',
|
||||
);
|
||||
$ownerPhone = preg_replace('~\s+~', ' ', $ownerPhone);
|
||||
|
||||
// Adresse postale: i-CAD ne la met pas toujours dans un bloc unique, mais on tente
|
||||
$ownerPostal = $txt('//*[@id="down_right_block"]//*[contains(@class,"coords")]//*[contains(@class,"postal")]');
|
||||
if ($ownerPostal === '') {
|
||||
// fallback: parfois l’adresse est dans le même bloc coords mais pas taggée
|
||||
$coordsText = $txt('//*[@id="down_right_block"]//*[contains(@class,"coords")]');
|
||||
// on évite de tout recracher, juste si on détecte des numéros/rue
|
||||
$ownerPostal = $coordsText;
|
||||
}
|
||||
|
||||
return [
|
||||
'icad_url' => 'https://www.i-cad.fr' . $animalPath,
|
||||
'name' => $name,
|
||||
'insert' => $insert,
|
||||
'species' => $map['Espèce'] ?? '',
|
||||
'sex' => $map['Sexe'] ?? '',
|
||||
'birth_date' => $map['Né le'] ?? '',
|
||||
'coat_type' => $map['Type de robe'] ?? '',
|
||||
'appearance' => $map['Apparence raciale'] ?? '',
|
||||
'events' => $events,
|
||||
'owner' => [
|
||||
'name' => $ownerName,
|
||||
'phone' => $ownerPhone,
|
||||
'email' => $ownerEmail,
|
||||
'postal' => $ownerPostal,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
50
app/Services/Ids.php
Normal file
50
app/Services/Ids.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class Ids
|
||||
{
|
||||
public static function slugifyUpper(string $s): string
|
||||
{
|
||||
$s = trim($s);
|
||||
if ($s === '') {
|
||||
return 'SANS-NOM';
|
||||
}
|
||||
|
||||
// translit: accents -> ascii (si dispo)
|
||||
$t = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s);
|
||||
if ($t !== false) {
|
||||
$s = $t;
|
||||
}
|
||||
|
||||
$s = strtoupper($s);
|
||||
$s = preg_replace('/[^A-Z0-9]+/', '-', $s) ?? $s;
|
||||
$s = trim($s, '-');
|
||||
|
||||
return $s !== '' ? $s : 'SANS-NOM';
|
||||
}
|
||||
|
||||
public static function nextAnimalCode(string $name, ?int $year = null): string
|
||||
{
|
||||
$year = $year ?? (int) date('Y');
|
||||
$slug = self::slugifyUpper($name);
|
||||
|
||||
$db = DB::pdo();
|
||||
$stmt = $db->prepare(
|
||||
'SELECT internal_code FROM animals WHERE internal_code LIKE :p ORDER BY internal_code DESC LIMIT 1',
|
||||
);
|
||||
$stmt->execute([':p' => $year . '-%']);
|
||||
$last = (string) ($stmt->fetchColumn() ?: '');
|
||||
|
||||
$n = 1;
|
||||
if ($last !== '') {
|
||||
// format attendu: YYYY-NNN-...
|
||||
$parts = explode('-', $last);
|
||||
if (count($parts) >= 2 && ctype_digit($parts[1])) {
|
||||
$n = (int) $parts[1] + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return sprintf('%d-%03d-%s', $year, $n, $slug);
|
||||
}
|
||||
}
|
||||
131
app/Services/ImageService.php
Normal file
131
app/Services/ImageService.php
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class ImageService
|
||||
{
|
||||
private const ALLOWED = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif'];
|
||||
|
||||
public static function inspect(string $path, int $maxBytes = 20_000_000): array
|
||||
{
|
||||
if (!is_file($path) || filesize($path) > $maxBytes) {
|
||||
throw new RuntimeException(t('service.image.missing_large'));
|
||||
}
|
||||
$info = @getimagesize($path);
|
||||
$mime = (string) ($info['mime'] ?? '');
|
||||
if (!$info || !isset(self::ALLOWED[$mime])) {
|
||||
throw new RuntimeException(t('service.image.format'));
|
||||
}
|
||||
if ((int) $info[0] * (int) $info[1] > 60_000_000) {
|
||||
throw new RuntimeException(t('service.image.dimensions'));
|
||||
}
|
||||
return [
|
||||
'mime' => $mime,
|
||||
'extension' => self::ALLOWED[$mime],
|
||||
'width' => (int) $info[0],
|
||||
'height' => (int) $info[1],
|
||||
'size' => (int) filesize($path),
|
||||
];
|
||||
}
|
||||
|
||||
public static function storeUploaded(
|
||||
string $tmp,
|
||||
string $directory,
|
||||
string $prefix = 'photo',
|
||||
string $profile = 'photo',
|
||||
): array {
|
||||
if (!is_uploaded_file($tmp)) {
|
||||
throw new RuntimeException(t('service.image.invalid_upload'));
|
||||
}
|
||||
return self::store($tmp, $directory, $prefix, $profile, true);
|
||||
}
|
||||
|
||||
public static function storeLocal(
|
||||
string $source,
|
||||
string $directory,
|
||||
string $prefix = 'photo',
|
||||
string $profile = 'photo',
|
||||
): array {
|
||||
return self::store($source, $directory, $prefix, $profile, false);
|
||||
}
|
||||
|
||||
private static function store(
|
||||
string $tmp,
|
||||
string $directory,
|
||||
string $prefix,
|
||||
string $profile,
|
||||
bool $moveSource,
|
||||
): array {
|
||||
$source = self::inspect($tmp);
|
||||
if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) {
|
||||
throw new RuntimeException(t('service.image.directory_failed'));
|
||||
}
|
||||
$prefix = preg_replace('/[^a-z0-9_-]+/i', '_', trim($prefix)) ?: 'photo';
|
||||
$useWebp = $profile !== 'logo';
|
||||
$extension = $useWebp ? 'webp' : $source['extension'];
|
||||
$filename = $prefix . '_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.' . $extension;
|
||||
$target = rtrim($directory, '/') . '/' . $filename;
|
||||
$stage = rtrim($directory, '/') . '/.' . $filename . '.part.' . $extension;
|
||||
$optimized = false;
|
||||
$binary = self::magickBinary();
|
||||
if ($binary !== null) {
|
||||
$max = $profile === 'logo' ? '1800x1800>' : '2560x2560>';
|
||||
$quality = $profile === 'medical' ? '88' : '82';
|
||||
$command = [$binary, $tmp . '[0]', '-auto-orient', '-strip', '-resize', $max];
|
||||
if ($extension === 'png') {
|
||||
$command = array_merge($command, ['-define', 'png:compression-level=9']);
|
||||
} else {
|
||||
$command = array_merge($command, ['-quality', (string) $quality]);
|
||||
}
|
||||
$command[] = $stage;
|
||||
$process = proc_open($command, [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
|
||||
if (is_resource($process)) {
|
||||
fclose($pipes[0]);
|
||||
stream_get_contents($pipes[1]);
|
||||
fclose($pipes[1]);
|
||||
stream_get_contents($pipes[2]);
|
||||
fclose($pipes[2]);
|
||||
$code = proc_close($process);
|
||||
$optimized = $code === 0 && is_file($stage) && filesize($stage) > 0 && @getimagesize($stage) !== false;
|
||||
}
|
||||
}
|
||||
if ($optimized) {
|
||||
if (!rename($stage, $target)) {
|
||||
@unlink($stage);
|
||||
throw new RuntimeException(t('service.image.optimized_failed'));
|
||||
}
|
||||
} else {
|
||||
if (is_file($stage)) {
|
||||
@unlink($stage);
|
||||
}
|
||||
$extension = $source['extension'];
|
||||
$filename = $prefix . '_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.' . $extension;
|
||||
$target = rtrim($directory, '/') . '/' . $filename;
|
||||
$saved = $moveSource ? move_uploaded_file($tmp, $target) : copy($tmp, $target);
|
||||
if (!$saved) {
|
||||
throw new RuntimeException(t('service.image.save_failed'));
|
||||
}
|
||||
}
|
||||
@chmod($target, 0644);
|
||||
$stored = self::inspect($target);
|
||||
return array_merge($stored, [
|
||||
'filename' => $filename,
|
||||
'path' => $target,
|
||||
'original_size' => $source['size'],
|
||||
'optimized' => $optimized,
|
||||
]);
|
||||
}
|
||||
|
||||
private static function magickBinary(): ?string
|
||||
{
|
||||
foreach (
|
||||
['/usr/bin/magick', '/usr/local/bin/magick', '/usr/bin/convert', '/usr/local/bin/convert']
|
||||
as $binary
|
||||
) {
|
||||
if (is_executable($binary)) {
|
||||
return $binary;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
137
app/Services/InstallationService.php
Normal file
137
app/Services/InstallationService.php
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class InstallationService
|
||||
{
|
||||
public static function runtimeChecks(bool $withFilesystem = true): array
|
||||
{
|
||||
$checks = [];
|
||||
self::add($checks, 'php', version_compare(PHP_VERSION, '8.1.0', '>='), 'required', 'PHP 8.1+', PHP_VERSION);
|
||||
self::add(
|
||||
$checks,
|
||||
'pdo',
|
||||
extension_loaded('pdo'),
|
||||
'required',
|
||||
'PDO',
|
||||
extension_loaded('pdo') ? 'Disponible' : 'Extension absente',
|
||||
);
|
||||
self::add(
|
||||
$checks,
|
||||
'sqlite',
|
||||
extension_loaded('pdo_sqlite'),
|
||||
'required',
|
||||
'PDO SQLite',
|
||||
extension_loaded('pdo_sqlite') ? 'Disponible' : 'Installez l’extension PHP SQLite',
|
||||
);
|
||||
foreach (
|
||||
['mbstring' => 'mbstring', 'json' => 'JSON', 'fileinfo' => 'Fileinfo', 'session' => 'Sessions']
|
||||
as $extension => $label
|
||||
) {
|
||||
self::add(
|
||||
$checks,
|
||||
$extension,
|
||||
extension_loaded($extension),
|
||||
'required',
|
||||
$label,
|
||||
extension_loaded($extension) ? 'Disponible' : 'Extension absente',
|
||||
);
|
||||
}
|
||||
self::add(
|
||||
$checks,
|
||||
'zip',
|
||||
class_exists('ZipArchive'),
|
||||
'recommended',
|
||||
'ZIP',
|
||||
class_exists('ZipArchive')
|
||||
? 'Sauvegardes ZIP disponibles'
|
||||
: 'Extension ZIP absente : sauvegardes indisponibles',
|
||||
);
|
||||
self::add(
|
||||
$checks,
|
||||
'curl',
|
||||
extension_loaded('curl'),
|
||||
'recommended',
|
||||
'cURL',
|
||||
extension_loaded('curl')
|
||||
? 'Connecteurs externes disponibles'
|
||||
: 'Extension cURL absente : I-CAD et géocodage indisponibles',
|
||||
);
|
||||
self::add(
|
||||
$checks,
|
||||
'image',
|
||||
extension_loaded('imagick') || extension_loaded('gd'),
|
||||
'recommended',
|
||||
'Traitement des images',
|
||||
extension_loaded('imagick')
|
||||
? 'ImageMagick disponible'
|
||||
: (extension_loaded('gd')
|
||||
? 'GD disponible'
|
||||
: 'ImageMagick ou GD conseillé'),
|
||||
);
|
||||
if ($withFilesystem) {
|
||||
foreach (self::directories() as $key => $path) {
|
||||
if (!is_dir($path)) {
|
||||
@mkdir($path, 0775, true);
|
||||
}
|
||||
self::add(
|
||||
$checks,
|
||||
'dir_' . $key,
|
||||
is_dir($path) && is_writable($path),
|
||||
'required',
|
||||
'Écriture · ' . self::relative($path),
|
||||
is_dir($path) && is_writable($path) ? 'Accessible en écriture' : 'Droits d’écriture insuffisants',
|
||||
);
|
||||
}
|
||||
}
|
||||
return $checks;
|
||||
}
|
||||
public static function blockingRuntimeIssues(): array
|
||||
{
|
||||
return array_values(
|
||||
array_filter(
|
||||
self::runtimeChecks(true),
|
||||
static fn(array $c): bool => $c['level'] === 'required' && !$c['ok'],
|
||||
),
|
||||
);
|
||||
}
|
||||
public static function canInstall(array $checks): bool
|
||||
{
|
||||
foreach ($checks as $check) {
|
||||
if ($check['level'] === 'required' && !$check['ok']) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
public static function publicPath(): string
|
||||
{
|
||||
return dirname(__DIR__, 2) . '/public';
|
||||
}
|
||||
public static function directories(): array
|
||||
{
|
||||
$root = dirname(__DIR__, 2);
|
||||
return [
|
||||
'data' => $root . '/data',
|
||||
'storage' => $root . '/storage',
|
||||
'logs' => $root . '/storage/logs',
|
||||
'backups' => $root . '/storage/backups',
|
||||
'media' => $root . '/public/media',
|
||||
'association' => $root . '/data/association',
|
||||
];
|
||||
}
|
||||
private static function add(
|
||||
array &$checks,
|
||||
string $key,
|
||||
bool $ok,
|
||||
string $level,
|
||||
string $label,
|
||||
string $detail,
|
||||
): void {
|
||||
$checks[] = ['key' => $key, 'ok' => $ok, 'level' => $level, 'label' => $label, 'detail' => $detail];
|
||||
}
|
||||
private static function relative(string $path): string
|
||||
{
|
||||
return ltrim(str_replace(dirname(__DIR__, 2), '', $path), '/');
|
||||
}
|
||||
}
|
||||
211
app/Services/InventoryService.php
Normal file
211
app/Services/InventoryService.php
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class InventoryService
|
||||
{
|
||||
public static function products(): array
|
||||
{
|
||||
return DB::pdo()
|
||||
->query(
|
||||
'SELECT p.*,COALESCE(SUM(b.quantity),0) stock,(SELECT MIN(expires_on) FROM inventory_batches x WHERE x.product_id=p.id AND x.quantity>0 AND x.expires_on IS NOT NULL) next_expiry FROM inventory_products p LEFT JOIN inventory_batches b ON b.product_id=p.id GROUP BY p.id ORDER BY p.category,p.name COLLATE NOCASE',
|
||||
)
|
||||
->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
public static function batches(): array
|
||||
{
|
||||
return DB::pdo()
|
||||
->query(
|
||||
'SELECT b.*,p.name product_name,p.unit,p.category,dc.name supplier_name FROM inventory_batches b JOIN inventory_products p ON p.id=b.product_id LEFT JOIN directory_contacts dc ON dc.id=b.supplier_contact_id ORDER BY b.quantity>0 DESC,date(b.expires_on),p.name COLLATE NOCASE',
|
||||
)
|
||||
->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
public static function movements(int $limit = 150): array
|
||||
{
|
||||
$limit = max(1, min(500, $limit));
|
||||
return DB::pdo()
|
||||
->query(
|
||||
"SELECT m.*,p.name product_name,p.unit,b.batch_number,a.name animal_name,COALESCE(u.display_name,u.username) actor FROM inventory_movements m JOIN inventory_products p ON p.id=m.product_id LEFT JOIN inventory_batches b ON b.id=m.batch_id LEFT JOIN animals a ON a.id=m.animal_id LEFT JOIN users u ON u.id=m.created_by ORDER BY datetime(m.occurred_at) DESC,m.id DESC LIMIT $limit",
|
||||
)
|
||||
->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
public static function saveProduct(array $data): int
|
||||
{
|
||||
$category = (string) ($data['category'] ?? '');
|
||||
$name = trim((string) ($data['name'] ?? ''));
|
||||
$unit = trim((string) ($data['unit'] ?? ''));
|
||||
if (!in_array($category, ['medication', 'dewormer', 'vaccine'], true) || $name === '' || $unit === '') {
|
||||
throw new RuntimeException(t('inventory.invalid_product'));
|
||||
}
|
||||
$reference = (int) ($data['reference_id'] ?? 0) ?: null;
|
||||
$db = DB::pdo();
|
||||
if (!$reference) {
|
||||
$table = match ($category) {
|
||||
'medication' => 'ref_medications',
|
||||
'dewormer' => 'ref_dewormers',
|
||||
'vaccine' => 'ref_vaccines',
|
||||
};
|
||||
$lookup = $db->prepare("SELECT id FROM $table WHERE name=? COLLATE NOCASE LIMIT 1");
|
||||
$lookup->execute([$name]);
|
||||
$reference = (int) $lookup->fetchColumn() ?: null;
|
||||
}
|
||||
$minimum = max(0, (float) str_replace(',', '.', (string) ($data['minimum_quantity'] ?? 0)));
|
||||
$s = $db->prepare(
|
||||
'INSERT INTO inventory_products(category,reference_id,name,unit,minimum_quantity) VALUES(?,?,?,?,?)',
|
||||
);
|
||||
$s->execute([$category, $reference, mb_substr($name, 0, 150), mb_substr($unit, 0, 40), $minimum]);
|
||||
return (int) $db->lastInsertId();
|
||||
}
|
||||
public static function receive(array $data): int
|
||||
{
|
||||
$product = (int) ($data['product_id'] ?? 0);
|
||||
$quantity = (float) str_replace(',', '.', (string) ($data['quantity'] ?? 0));
|
||||
if (!$product || $quantity <= 0) {
|
||||
throw new RuntimeException(t('inventory.invalid_quantity'));
|
||||
}
|
||||
$expiry = trim((string) ($data['expires_on'] ?? ''));
|
||||
if ($expiry !== '' && !self::date($expiry)) {
|
||||
throw new RuntimeException(t('inventory.invalid_date'));
|
||||
}
|
||||
$cost = trim((string) ($data['unit_cost'] ?? ''));
|
||||
$cents = $cost === '' ? null : (int) round((float) str_replace(',', '.', $cost) * 100);
|
||||
$db = DB::pdo();
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
$s = $db->prepare(
|
||||
'INSERT INTO inventory_batches(product_id,batch_number,expires_on,supplier_contact_id,unit_cost_cents,quantity,notes) VALUES(?,?,?,?,?,?,?)',
|
||||
);
|
||||
$s->execute([
|
||||
$product,
|
||||
trim((string) ($data['batch_number'] ?? '')) ?: null,
|
||||
$expiry ?: null,
|
||||
(int) ($data['supplier_contact_id'] ?? 0) ?: null,
|
||||
$cents,
|
||||
$quantity,
|
||||
trim((string) ($data['notes'] ?? '')) ?: null,
|
||||
]);
|
||||
$batch = (int) $db->lastInsertId();
|
||||
$db->prepare(
|
||||
"INSERT INTO inventory_movements(product_id,batch_id,movement_type,quantity_delta,reason,created_by) VALUES(?,?,'entry',?,?,?)",
|
||||
)->execute([$product, $batch, $quantity, t('inventory.receipt'), Auth::id()]);
|
||||
$db->commit();
|
||||
return $batch;
|
||||
} catch (Throwable $e) {
|
||||
$db->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
public static function move(array $data): void
|
||||
{
|
||||
$batch = (int) ($data['batch_id'] ?? 0);
|
||||
$type = (string) ($data['movement_type'] ?? '');
|
||||
$quantity = (float) str_replace(',', '.', (string) ($data['quantity'] ?? 0));
|
||||
if (!$batch || !in_array($type, ['administration', 'loss', 'correction'], true) || $quantity <= 0) {
|
||||
throw new RuntimeException(t('inventory.invalid_movement'));
|
||||
}
|
||||
$db = DB::pdo();
|
||||
$s = $db->prepare(
|
||||
'SELECT b.*,p.id product_id FROM inventory_batches b JOIN inventory_products p ON p.id=b.product_id WHERE b.id=?',
|
||||
);
|
||||
$s->execute([$batch]);
|
||||
$row = $s->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) {
|
||||
throw new RuntimeException(t('inventory.batch_not_found'));
|
||||
}
|
||||
$delta =
|
||||
$type === 'correction'
|
||||
? (float) str_replace(',', '.', (string) ($data['signed_quantity'] ?? 0))
|
||||
: -$quantity;
|
||||
if ($delta == 0 || (float) $row['quantity'] + $delta < 0) {
|
||||
throw new RuntimeException(t('inventory.insufficient_stock'));
|
||||
}
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
$db->prepare('UPDATE inventory_batches SET quantity=quantity+? WHERE id=?')->execute([$delta, $batch]);
|
||||
$db->prepare(
|
||||
'INSERT INTO inventory_movements(product_id,batch_id,movement_type,quantity_delta,animal_id,reason,created_by) VALUES(?,?,?,?,?,?,?)',
|
||||
)->execute([
|
||||
(int) $row['product_id'],
|
||||
$batch,
|
||||
$type,
|
||||
$delta,
|
||||
(int) ($data['animal_id'] ?? 0) ?: null,
|
||||
trim((string) ($data['reason'] ?? '')) ?: null,
|
||||
Auth::id(),
|
||||
]);
|
||||
$db->commit();
|
||||
} catch (Throwable $e) {
|
||||
$db->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
public static function alerts(): array
|
||||
{
|
||||
$low = [];
|
||||
$expired = [];
|
||||
foreach (self::products() as $p) {
|
||||
if ((float) $p['stock'] <= (float) $p['minimum_quantity']) {
|
||||
$low[] = $p;
|
||||
}
|
||||
}
|
||||
foreach (self::batches() as $b) {
|
||||
if (
|
||||
(float) $b['quantity'] > 0 &&
|
||||
$b['expires_on'] &&
|
||||
$b['expires_on'] <= date('Y-m-d', strtotime('+30 days'))
|
||||
) {
|
||||
$expired[] = $b;
|
||||
}
|
||||
}
|
||||
return compact('low', 'expired');
|
||||
}
|
||||
public static function consumeBatch(int $batchId, float $quantity, ?int $animalId, string $reason): void
|
||||
{
|
||||
if ($batchId <= 0 || $quantity <= 0) {
|
||||
return;
|
||||
}
|
||||
$db = DB::pdo();
|
||||
$s = $db->prepare(
|
||||
'SELECT b.product_id,b.quantity,b.unit_cost_cents,b.supplier_contact_id,p.name FROM inventory_batches b JOIN inventory_products p ON p.id=b.product_id WHERE b.id=?',
|
||||
);
|
||||
$s->execute([$batchId]);
|
||||
$batch = $s->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$batch) {
|
||||
throw new RuntimeException(t('inventory.batch_not_found'));
|
||||
}
|
||||
if ((float) $batch['quantity'] < $quantity) {
|
||||
throw new RuntimeException(t('inventory.insufficient_stock'));
|
||||
}
|
||||
$u = $db->prepare('UPDATE inventory_batches SET quantity=quantity-? WHERE id=? AND quantity>=?');
|
||||
$u->execute([$quantity, $batchId, $quantity]);
|
||||
if ($u->rowCount() !== 1) {
|
||||
throw new RuntimeException(t('inventory.insufficient_stock'));
|
||||
}
|
||||
$movement = $db->prepare(
|
||||
"INSERT INTO inventory_movements(product_id,batch_id,movement_type,quantity_delta,animal_id,reason,created_by) VALUES(?,?,'administration',?,?,?,?)",
|
||||
);
|
||||
$movement->execute([(int) $batch['product_id'], $batchId, -$quantity, $animalId, $reason, Auth::id()]);
|
||||
$movementId = (int) $db->lastInsertId();
|
||||
if ($animalId && !is_null($batch['unit_cost_cents'])) {
|
||||
$unit = (int) $batch['unit_cost_cents'];
|
||||
$db->prepare(
|
||||
"INSERT INTO animal_expenses(animal_id,clinic_contact_id,source_type,source_id,label,occurred_on,quantity,catalog_unit_cents,discount_percent,total_cents,notes,created_by) VALUES(?,?, 'inventory',?,?,date('now'),?,?,0,?,?,?)",
|
||||
)->execute([
|
||||
$animalId,
|
||||
(int) $batch['supplier_contact_id'] ?: null,
|
||||
$movementId,
|
||||
(string) $batch['name'],
|
||||
$quantity,
|
||||
$unit,
|
||||
(int) round($unit * $quantity),
|
||||
$reason,
|
||||
Auth::id(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
private static function date(string $v): bool
|
||||
{
|
||||
$d = DateTimeImmutable::createFromFormat('!Y-m-d', $v);
|
||||
return $d && $d->format('Y-m-d') === $v;
|
||||
}
|
||||
}
|
||||
36
app/Services/LabReferenceService.php
Normal file
36
app/Services/LabReferenceService.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class LabReferenceService
|
||||
{
|
||||
public static function all(): array
|
||||
{
|
||||
return DB::pdo()
|
||||
->query(
|
||||
'SELECT * FROM lab_reference_ranges ORDER BY analyzer_name COLLATE NOCASE,species_code,sort_order,parameter_code COLLATE NOCASE',
|
||||
)
|
||||
->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
public static function forAnimal(string $species): array
|
||||
{
|
||||
$species = SpeciesService::normalize($species);
|
||||
$s = DB::pdo()->prepare(
|
||||
'SELECT analyzer_code,analyzer_name,parameter_code,parameter_name,unit,reference_min,reference_max FROM lab_reference_ranges WHERE species_code=? AND active=1 ORDER BY analyzer_name COLLATE NOCASE,sort_order,parameter_code COLLATE NOCASE',
|
||||
);
|
||||
$s->execute([$species]);
|
||||
$out = [];
|
||||
foreach ($s->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||
$key = $r['analyzer_code'];
|
||||
$out[$key]['label'] = $r['analyzer_name'];
|
||||
$out[$key]['parameters'][] = [
|
||||
'code' => $r['parameter_code'],
|
||||
'name' => $r['parameter_name'],
|
||||
'unit' => $r['unit'],
|
||||
'min' => $r['reference_min'],
|
||||
'max' => $r['reference_max'],
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
115
app/Services/LocationHistoryService.php
Normal file
115
app/Services/LocationHistoryService.php
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class LocationHistoryService
|
||||
{
|
||||
private const TRACKED = ['status', 'refuge_room', 'care_box_key', 'current_address'];
|
||||
|
||||
public static function snapshot(array $animal): array
|
||||
{
|
||||
$snapshot = [];
|
||||
foreach (self::TRACKED as $field) {
|
||||
$value = trim((string) ($animal[$field] ?? ''));
|
||||
$snapshot[$field] = $value === '' ? null : $value;
|
||||
}
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
public static function record(
|
||||
PDO $db,
|
||||
int $animalId,
|
||||
?array $before,
|
||||
array $after,
|
||||
string $source,
|
||||
?string $reason = null,
|
||||
?string $movedAt = null,
|
||||
): bool {
|
||||
$from = self::snapshot($before ?? []);
|
||||
$to = self::snapshot($after);
|
||||
if ($before !== null && $from === $to) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('INSERT INTO animal_location_history (
|
||||
animal_id,from_status,from_refuge_room,from_care_box_key,from_address,
|
||||
to_status,to_refuge_room,to_care_box_key,to_address,reason,source,moved_at,user_id
|
||||
) VALUES (
|
||||
:animal,:from_status,:from_room,:from_box,:from_address,
|
||||
:to_status,:to_room,:to_box,:to_address,:reason,:source,:moved_at,:user
|
||||
)');
|
||||
$stmt->execute([
|
||||
':animal' => $animalId,
|
||||
':from_status' => $from['status'],
|
||||
':from_room' => $from['refuge_room'],
|
||||
':from_box' => $from['care_box_key'],
|
||||
':from_address' => $from['current_address'],
|
||||
':to_status' => $to['status'],
|
||||
':to_room' => $to['refuge_room'],
|
||||
':to_box' => $to['care_box_key'],
|
||||
':to_address' => $to['current_address'],
|
||||
':reason' => ($reason = trim((string) $reason)) !== '' ? $reason : null,
|
||||
':source' => $source,
|
||||
':moved_at' => $movedAt ?: date('Y-m-d H:i:s'),
|
||||
':user' => Auth::id(),
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function label(array $row, string $prefix): string
|
||||
{
|
||||
$status = trim((string) ($row[$prefix . '_status'] ?? ''));
|
||||
$room = trim((string) ($row[$prefix . '_refuge_room'] ?? ''));
|
||||
$box = trim((string) ($row[$prefix . '_care_box_key'] ?? ''));
|
||||
$address = trim((string) ($row[$prefix . '_address'] ?? ''));
|
||||
|
||||
$label = match ($status) {
|
||||
'refuge' => $room !== '' ? 'Refuge · ' . self::roomName($room) : 'Refuge · Salle non attribuée',
|
||||
'soin' => $room !== '' ? self::roomName($room) : 'Infirmerie',
|
||||
'quarantaine' => $room !== '' ? self::roomName($room) : 'Salle de quarantaine',
|
||||
'fa' => 'Famille d’accueil',
|
||||
'fa_permanente' => 'FA permanente',
|
||||
'reserve', 'réservé' => 'Réservé',
|
||||
'adopte', 'adopté' => 'Adopté',
|
||||
'hospitalise', 'hospitalisé' => 'Hospitalisé',
|
||||
'isolation' => 'Isolation',
|
||||
'decede', 'décédé' => 'Décédé',
|
||||
'enfui', 'fugue' => 'Enfui',
|
||||
'' => 'Emplacement antérieur inconnu',
|
||||
default => ucfirst(str_replace('_', ' ', $status)),
|
||||
};
|
||||
if ($box !== '' && in_array($status, ['soin', 'quarantaine'], true)) {
|
||||
$label .= ' · Box ' . self::boxLabel($box);
|
||||
}
|
||||
if (
|
||||
$address !== '' &&
|
||||
in_array($status, ['fa', 'fa_permanente', 'adopte', 'adopté', 'hospitalise', 'hospitalisé'], true)
|
||||
) {
|
||||
$label .= ' · ' . $address;
|
||||
}
|
||||
return $label;
|
||||
}
|
||||
|
||||
private static function boxLabel(string $box): string
|
||||
{
|
||||
return match ($box) {
|
||||
'top_a' => 'haut gauche',
|
||||
'top_b' => 'haut centre',
|
||||
'top_c' => 'haut droite',
|
||||
'top_ab' => 'haut gauche double',
|
||||
'top_bc' => 'haut droite double',
|
||||
'top_all' => 'haut complet',
|
||||
'bottom_a' => 'bas gauche',
|
||||
'bottom_b' => 'bas droite',
|
||||
'bottom_all' => 'bas complet',
|
||||
default => str_replace('_', ' ', $box),
|
||||
};
|
||||
}
|
||||
|
||||
private static function roomName(string $code): string
|
||||
{
|
||||
static $rooms = null;
|
||||
$rooms ??= ShelterRoomService::byCode(false);
|
||||
return (string) ($rooms[$code]['name'] ?? $code);
|
||||
}
|
||||
}
|
||||
62
app/Services/Migrations.php
Normal file
62
app/Services/Migrations.php
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class Migrations
|
||||
{
|
||||
public static function run(string $migrationsDir): void
|
||||
{
|
||||
$db = DB::pdo();
|
||||
$db->exec('CREATE TABLE IF NOT EXISTS _migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL);');
|
||||
|
||||
$files = glob(rtrim($migrationsDir, '/') . '/*.sql') ?: [];
|
||||
sort($files);
|
||||
|
||||
foreach ($files as $file) {
|
||||
$name = basename($file);
|
||||
|
||||
$stmt = $db->prepare('SELECT 1 FROM _migrations WHERE name = :n');
|
||||
$stmt->execute([':n' => $name]);
|
||||
if ($stmt->fetchColumn()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sql = file_get_contents($file);
|
||||
if ($sql === false) {
|
||||
throw new RuntimeException("Cannot read migration: $name");
|
||||
}
|
||||
|
||||
// Les toutes premières installations utilisaient déjà deleted_at avant
|
||||
// que cette colonne ne soit correctement inscrite dans une migration.
|
||||
if ($name === '025_animal_archive_and_trash.sql') {
|
||||
$columns = array_column($db->query('PRAGMA table_info(animals)')->fetchAll(PDO::FETCH_ASSOC), 'name');
|
||||
if (!in_array('deleted_at', $columns, true)) {
|
||||
$db->exec('ALTER TABLE animals ADD COLUMN deleted_at TEXT');
|
||||
}
|
||||
}
|
||||
|
||||
// Répare les bases créées par une candidate rc.3 incomplète, dans
|
||||
// laquelle les médias privés étaient utilisés avant d'être migrés.
|
||||
if ($name === '089_media_schema_repair.sql') {
|
||||
$columns = array_column(
|
||||
$db->query('PRAGMA table_info(animal_photos)')->fetchAll(PDO::FETCH_ASSOC),
|
||||
'name',
|
||||
);
|
||||
if (!in_array('is_public', $columns, true)) {
|
||||
$db->exec('ALTER TABLE animal_photos ADD COLUMN is_public INTEGER NOT NULL DEFAULT 1');
|
||||
}
|
||||
}
|
||||
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
$db->exec($sql);
|
||||
$ins = $db->prepare("INSERT INTO _migrations(name, applied_at) VALUES(:n, datetime('now'))");
|
||||
$ins->execute([':n' => $name]);
|
||||
$db->commit();
|
||||
} catch (Throwable $e) {
|
||||
$db->rollBack();
|
||||
throw new RuntimeException('Migration ' . $name . ' : ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
142
app/Services/NotificationService.php
Normal file
142
app/Services/NotificationService.php
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class NotificationService
|
||||
{
|
||||
public static function alerts(): array
|
||||
{
|
||||
$db = DB::pdo();
|
||||
$alerts = [];
|
||||
$queries = [
|
||||
[
|
||||
'vaccines',
|
||||
'danger',
|
||||
t('notification.vaccines_overdue'),
|
||||
"SELECT COUNT(*) FROM vaccinations v JOIN animals a ON a.id=v.animal_id WHERE v.due_date IS NOT NULL AND date(v.due_date)<date('now') AND a.deleted_at IS NULL AND a.archived_at IS NULL AND lower(a.status) NOT IN ('decede','décédé','adopte','adopté')",
|
||||
'/dashboard#vaccine-deadlines',
|
||||
],
|
||||
[
|
||||
'treatments',
|
||||
'danger',
|
||||
t('notification.treatments_overdue'),
|
||||
"SELECT COUNT(*) FROM treatments t JOIN animals a ON a.id=t.animal_id WHERE t.ongoing=1 AND t.end_date IS NOT NULL AND date(t.end_date)<date('now') AND a.deleted_at IS NULL AND a.archived_at IS NULL",
|
||||
'/dashboard#active-treatments',
|
||||
],
|
||||
[
|
||||
'dewormings',
|
||||
'warning',
|
||||
t('notification.dewormings_due'),
|
||||
"SELECT COUNT(*) FROM dewormings d JOIN animals a ON a.id=d.animal_id WHERE d.next_due_date IS NOT NULL AND date(d.next_due_date)<=date('now','+7 day') AND a.deleted_at IS NULL AND a.archived_at IS NULL AND NOT EXISTS(SELECT 1 FROM dewormings newer WHERE newer.animal_id=d.animal_id AND date(newer.administered_on)>date(d.administered_on))",
|
||||
'/dashboard',
|
||||
],
|
||||
];
|
||||
foreach ($queries as [$key, $severity, $label, $sql, $url]) {
|
||||
try {
|
||||
$count = (int) $db->query($sql)->fetchColumn();
|
||||
if ($count) {
|
||||
$alerts[] = compact('key', 'severity', 'label', 'count', 'url');
|
||||
}
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
if (class_exists(InventoryService::class)) {
|
||||
try {
|
||||
$stock = InventoryService::alerts();
|
||||
if ($stock['low']) {
|
||||
$alerts[] = [
|
||||
'key' => 'inventory-low',
|
||||
'severity' => 'warning',
|
||||
'label' => t('notification.inventory_low'),
|
||||
'count' => count($stock['low']),
|
||||
'url' => '/settings/inventory',
|
||||
];
|
||||
}
|
||||
if ($stock['expired']) {
|
||||
$alerts[] = [
|
||||
'key' => 'inventory-expiry',
|
||||
'severity' => 'danger',
|
||||
'label' => t('notification.inventory_expiry'),
|
||||
'count' => count($stock['expired']),
|
||||
'url' => '/settings/inventory',
|
||||
];
|
||||
}
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
if (class_exists(AgendaService::class)) {
|
||||
try {
|
||||
$due = AgendaService::due();
|
||||
if ($due) {
|
||||
$alerts[] = [
|
||||
'key' => 'agenda',
|
||||
'severity' => 'warning',
|
||||
'label' => t('notification.agenda_due'),
|
||||
'count' => count($due),
|
||||
'url' => '/agenda',
|
||||
];
|
||||
}
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
$backups = BackupService::list();
|
||||
$days = 999;
|
||||
if (
|
||||
$backups &&
|
||||
($date = DateTimeImmutable::createFromFormat(
|
||||
'd/m/Y H:i:s',
|
||||
(string) $backups[0]['date'],
|
||||
new DateTimeZone('Europe/Paris'),
|
||||
))
|
||||
) {
|
||||
$days = (int) $date->diff(new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')))->format('%a');
|
||||
}
|
||||
if ($days > 7) {
|
||||
$alerts[] = [
|
||||
'key' => 'backup',
|
||||
'severity' => $days > 30 ? 'danger' : 'warning',
|
||||
'label' => t('notification.backup_old'),
|
||||
'count' => $days,
|
||||
'url' => '/settings/backups',
|
||||
];
|
||||
}
|
||||
return $alerts;
|
||||
}
|
||||
public static function send(bool $force = false): array
|
||||
{
|
||||
if (AppSettings::get('notification_email_enabled') !== '1' && !$force) {
|
||||
return ['sent' => false, 'reason' => 'disabled'];
|
||||
}
|
||||
$recipient = AppSettings::get('notification_email_recipient') ?: AppSettings::get('association_email');
|
||||
if (!filter_var($recipient, FILTER_VALIDATE_EMAIL)) {
|
||||
return ['sent' => false, 'reason' => 'recipient'];
|
||||
}
|
||||
$frequency = AppSettings::get('notification_email_frequency');
|
||||
$last = AppSettings::get('notification_last_sent_at');
|
||||
if (!$force && $last !== '') {
|
||||
$elapsed = time() - (strtotime($last) ?: 0);
|
||||
$minimum = $frequency === 'daily' ? 20 * 3600 : 6 * 86400;
|
||||
if ($elapsed < $minimum) {
|
||||
return ['sent' => false, 'reason' => 'not_due'];
|
||||
}
|
||||
}
|
||||
$alerts = self::alerts();
|
||||
$association = AppSettings::get('association_name') ?: 'Globinours';
|
||||
$subject = '[' . $association . '] ' . t('notification.email_subject');
|
||||
$lines = [t('notification.email_intro', ['association' => $association]), ''];
|
||||
if (!$alerts) {
|
||||
$lines[] = t('notification.no_alert');
|
||||
} else {
|
||||
foreach ($alerts as $a) {
|
||||
$lines[] = '- ' . $a['label'] . ' : ' . $a['count'];
|
||||
}
|
||||
}
|
||||
$lines[] = '';
|
||||
$lines[] = t('notification.email_footer');
|
||||
$sent = @mail($recipient, $subject, implode("\n", $lines), 'Content-Type: text/plain; charset=UTF-8');
|
||||
if ($sent) {
|
||||
AppSettings::save(['notification_last_sent_at' => date('Y-m-d H:i:s')], Auth::id());
|
||||
}
|
||||
return ['sent' => $sent, 'reason' => $sent ? 'sent' : 'mail_failed', 'count' => count($alerts)];
|
||||
}
|
||||
}
|
||||
258
app/Services/PermissionService.php
Normal file
258
app/Services/PermissionService.php
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class PermissionService
|
||||
{
|
||||
public const ROLES = [
|
||||
'admin' => 'Administrateur',
|
||||
'responsable' => 'Responsable',
|
||||
'benevole' => 'Bénévole',
|
||||
'lecture' => 'Lecture seule',
|
||||
];
|
||||
public const MODULES = [
|
||||
'dashboard' => ['Dashboard', 'Priorités et situation quotidienne'],
|
||||
'animals' => ['Animaux', 'Fiches, portées, liens, médias et adoptions'],
|
||||
'medical' => ['Médical', 'Notes, traitements, vaccinations et décès'],
|
||||
'care' => ['Tournées', 'Passages tablette et configuration des box'],
|
||||
'directory' => ['Annuaire', 'Personnes, structures et coordonnées'],
|
||||
'agenda' => ['Agenda', 'Rendez-vous, transports, visites et tâches partagées'],
|
||||
'statistics' => ['Statistiques', 'Analyses annuelles et tendances'],
|
||||
'administrative' => ['Administratif', 'Registre, documents et exports'],
|
||||
'accounting' => ['Comptabilité', 'Factures, avoirs et règlements'],
|
||||
'grants' => ['Subventions', 'Bilans, demandes et justificatifs'],
|
||||
];
|
||||
public static function roleLabel(string $key): string
|
||||
{
|
||||
return t('permission.role.' . $key, [], self::ROLES[$key] ?? $key);
|
||||
}
|
||||
public static function moduleLabel(string $key): string
|
||||
{
|
||||
return t('permission.module.' . $key, [], self::MODULES[$key][0] ?? $key);
|
||||
}
|
||||
public static function moduleDescription(string $key): string
|
||||
{
|
||||
return t('permission.module_help.' . $key, [], self::MODULES[$key][1] ?? '');
|
||||
}
|
||||
|
||||
private static array $cache = [];
|
||||
public static function can(string $module, string $action = 'view', ?string $role = null): bool
|
||||
{
|
||||
$role ??= (string) (Auth::user()['role'] ?? '');
|
||||
if ($role === 'admin') {
|
||||
return true;
|
||||
}
|
||||
if (!isset(self::MODULES[$module], self::ROLES[$role])) {
|
||||
return false;
|
||||
}
|
||||
if (!isset(self::$cache[$role])) {
|
||||
$s = DB::pdo()->prepare('SELECT module,can_view,can_edit FROM role_permissions WHERE role=?');
|
||||
$s->execute([$role]);
|
||||
self::$cache[$role] = [];
|
||||
foreach ($s->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
self::$cache[$role][$row['module']] = [
|
||||
'view' => (bool) $row['can_view'],
|
||||
'edit' => (bool) $row['can_edit'],
|
||||
];
|
||||
}
|
||||
}
|
||||
return (bool) (self::$cache[$role][$module][$action === 'edit' ? 'edit' : 'view'] ?? false);
|
||||
}
|
||||
public static function matrix(): array
|
||||
{
|
||||
$matrix = [];
|
||||
foreach (array_keys(self::ROLES) as $role) {
|
||||
foreach (array_keys(self::MODULES) as $module) {
|
||||
$matrix[$role][$module] = [
|
||||
'view' => self::can($module, 'view', $role),
|
||||
'edit' => self::can($module, 'edit', $role),
|
||||
];
|
||||
}
|
||||
}
|
||||
return $matrix;
|
||||
}
|
||||
public static function moduleForPath(string $path): ?string
|
||||
{
|
||||
if ($path === '/') {
|
||||
return 'dashboard';
|
||||
}
|
||||
if (str_starts_with($path, '/animal/documents')) {
|
||||
return 'medical';
|
||||
}
|
||||
if (
|
||||
str_starts_with($path, '/animals') ||
|
||||
str_starts_with($path, '/animal') ||
|
||||
str_starts_with($path, '/litter') ||
|
||||
str_starts_with($path, '/bonded')
|
||||
) {
|
||||
foreach (
|
||||
[
|
||||
'/animal/add-medical',
|
||||
'/animal/medical-status',
|
||||
'/animal/add-treatment',
|
||||
'/animal/add-vaccine',
|
||||
'/animal/death',
|
||||
'/animal/prescription',
|
||||
'/animal/lab-report',
|
||||
'/animal/medical-document',
|
||||
'/animal/surgery',
|
||||
]
|
||||
as $medical
|
||||
) {
|
||||
if (str_starts_with($path, $medical)) {
|
||||
return 'medical';
|
||||
}
|
||||
}
|
||||
return 'animals';
|
||||
}
|
||||
if (str_starts_with($path, '/dashboard')) {
|
||||
return 'dashboard';
|
||||
}
|
||||
if (str_starts_with($path, '/care-round')) {
|
||||
return 'care';
|
||||
}
|
||||
if (str_starts_with($path, '/directory')) {
|
||||
return 'directory';
|
||||
}
|
||||
if (str_starts_with($path, '/agenda')) {
|
||||
return 'agenda';
|
||||
}
|
||||
if (str_starts_with($path, '/statistics')) {
|
||||
return 'statistics';
|
||||
}
|
||||
if (str_starts_with($path, '/accounting')) {
|
||||
return 'accounting';
|
||||
}
|
||||
if (str_starts_with($path, '/admin/grants')) {
|
||||
return 'grants';
|
||||
}
|
||||
if (str_starts_with($path, '/admin') || str_starts_with($path, '/documents')) {
|
||||
return 'administrative';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
public static function enforce(string $path, string $method): void
|
||||
{
|
||||
if (
|
||||
str_starts_with($path, '/settings') ||
|
||||
str_starts_with($path, '/dev/') ||
|
||||
in_array($path, ['/logout', '/evolutions'], true)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
$module = self::moduleForPath($path);
|
||||
if (!$module) {
|
||||
return;
|
||||
}
|
||||
$action = strtoupper($method) === 'POST' || self::isEditPage($path) ? 'edit' : 'view';
|
||||
$allowed = self::can($module, $action) && ($module !== 'medical' || self::can('animals', 'view'));
|
||||
if (!$allowed) {
|
||||
AuditService::log('authorization_denied', $path, 'Accès refusé au module ' . $module, null, null, [
|
||||
'module' => $module,
|
||||
'requested_action' => $action,
|
||||
]);
|
||||
http_response_code(403);
|
||||
if (function_exists('render')) {
|
||||
render('access_denied.php', [
|
||||
'title' => t('access.denied'),
|
||||
'deniedAction' => $action,
|
||||
'deniedModule' => self::moduleLabel($module),
|
||||
]);
|
||||
} else {
|
||||
echo t('access.denied');
|
||||
}
|
||||
exit();
|
||||
}
|
||||
}
|
||||
public static function firstAllowedPath(): string
|
||||
{
|
||||
foreach (
|
||||
[
|
||||
'dashboard' => '/dashboard',
|
||||
'animals' => '/animals',
|
||||
'care' => '/care-round',
|
||||
'directory' => '/directory',
|
||||
'agenda' => '/agenda',
|
||||
'statistics' => '/statistics',
|
||||
'accounting' => '/accounting',
|
||||
'administrative' => '/admin/register',
|
||||
'grants' => '/admin/grants',
|
||||
]
|
||||
as $module => $path
|
||||
) {
|
||||
if (self::can($module)) {
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
return '/evolutions';
|
||||
}
|
||||
public static function canOpenPath(string $path): bool
|
||||
{
|
||||
$module = self::moduleForPath($path);
|
||||
return $module === null ||
|
||||
(self::can($module, self::isEditPage($path) ? 'edit' : 'view') &&
|
||||
($module !== 'medical' || self::can('animals')));
|
||||
}
|
||||
public static function currentPermissions(): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (self::MODULES as $module => $unused) {
|
||||
$out[$module] = ['view' => self::can($module), 'edit' => self::can($module, 'edit')];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
private static function isEditPage(string $path): bool
|
||||
{
|
||||
return in_array(
|
||||
$path,
|
||||
[
|
||||
'/animal/new',
|
||||
'/animal/edit',
|
||||
'/animal/death',
|
||||
'/animal/surgery/new',
|
||||
'/litter/new',
|
||||
'/litter/add-existing',
|
||||
'/litter/add-existing/save',
|
||||
'/bonded/new',
|
||||
'/directory/new',
|
||||
'/directory/edit',
|
||||
'/care-round/setup',
|
||||
],
|
||||
true,
|
||||
);
|
||||
}
|
||||
public static function save(array $input, ?int $userId): void
|
||||
{
|
||||
$db = DB::pdo();
|
||||
$stmt = $db->prepare(
|
||||
"INSERT INTO role_permissions(role,module,can_view,can_edit,updated_by,updated_at) VALUES(:role,:module,:view,:edit,:user,datetime('now')) ON CONFLICT(role,module) DO UPDATE SET can_view=excluded.can_view,can_edit=excluded.can_edit,updated_by=excluded.updated_by,updated_at=excluded.updated_at",
|
||||
);
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
foreach (self::ROLES as $role => $unused) {
|
||||
if ($role === 'admin') {
|
||||
continue;
|
||||
}
|
||||
foreach (self::MODULES as $module => $unusedModule) {
|
||||
$view = isset($input[$role][$module]['view']) ? 1 : 0;
|
||||
if ($module === 'medical' && !isset($input[$role]['animals']['view'])) {
|
||||
$view = 0;
|
||||
}
|
||||
$edit = $view && isset($input[$role][$module]['edit']) ? 1 : 0;
|
||||
$stmt->execute([
|
||||
':role' => $role,
|
||||
':module' => $module,
|
||||
':view' => $view,
|
||||
':edit' => $edit,
|
||||
':user' => $userId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
$db->commit();
|
||||
self::$cache = [];
|
||||
} catch (Throwable $e) {
|
||||
$db->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
78
app/Services/PricingService.php
Normal file
78
app/Services/PricingService.php
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
final class PricingService
|
||||
{
|
||||
public const CATEGORIES = [
|
||||
'consultation' => 'Consultation',
|
||||
'surgery' => 'Chirurgie',
|
||||
'identification' => 'Identification',
|
||||
'test' => 'Test / analyse',
|
||||
'vaccine' => 'Vaccin',
|
||||
'euthanasia' => 'Euthanasie',
|
||||
'cremation' => 'Crémation',
|
||||
'medication' => 'Médicament',
|
||||
'dewormer' => 'Vermifuge',
|
||||
'other' => 'Autre',
|
||||
];
|
||||
public const GROUPS = [
|
||||
'act' => 'Acte',
|
||||
'surgery_discount' => 'Actes chirurgicaux',
|
||||
'medication' => 'Médicament hors antibiotique',
|
||||
'antibiotic' => 'Antibiotique',
|
||||
'ape_api' => 'APE / API',
|
||||
'analysis' => 'Analyse',
|
||||
'fixed' => 'Tarif fixe',
|
||||
'none' => 'Sans remise',
|
||||
];
|
||||
public static function categoryLabel(string $key): string
|
||||
{
|
||||
return t('pricing.category.' . $key, [], self::CATEGORIES[$key] ?? $key);
|
||||
}
|
||||
public static function groupLabel(string $key): string
|
||||
{
|
||||
return t('pricing.group.' . $key, [], self::GROUPS[$key] ?? $key);
|
||||
}
|
||||
public static function cents(string $v): int
|
||||
{
|
||||
return max(0, (int) round((float) str_replace(',', '.', trim($v)) * 100));
|
||||
}
|
||||
public static function tariffs(bool $active = false): array
|
||||
{
|
||||
$w = $active ? 'WHERE ct.active=1 AND dc.deleted_at IS NULL' : '';
|
||||
return DB::pdo()
|
||||
->query(
|
||||
"SELECT ct.*,dc.name clinic_name,COALESCE(dr.percent,0) discount_percent,ROUND(ct.amount_cents*(1-COALESCE(dr.percent,0)/100.0)) effective_cents FROM clinic_tariffs ct JOIN directory_contacts dc ON dc.id=ct.clinic_contact_id LEFT JOIN clinic_discount_rules dr ON dr.clinic_contact_id=ct.clinic_contact_id AND dr.discount_group=ct.discount_group $w ORDER BY dc.name COLLATE NOCASE,ct.active DESC,ct.label COLLATE NOCASE",
|
||||
)
|
||||
->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
public static function record(PDO $db, int $animal, int $tariff, float $qty, string $date, string $notes = ''): int
|
||||
{
|
||||
$s = $db->prepare(
|
||||
'SELECT ct.*,COALESCE(dr.percent,0) discount FROM clinic_tariffs ct LEFT JOIN clinic_discount_rules dr ON dr.clinic_contact_id=ct.clinic_contact_id AND dr.discount_group=ct.discount_group WHERE ct.id=? AND ct.active=1',
|
||||
);
|
||||
$s->execute([$tariff]);
|
||||
$t = $s->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$t) {
|
||||
throw new RuntimeException(t('error.tariff_not_found'));
|
||||
}
|
||||
$qty = max(0.01, $qty);
|
||||
$total = (int) round($t['amount_cents'] * $qty * (1 - (float) $t['discount'] / 100));
|
||||
$db->prepare(
|
||||
'INSERT INTO animal_expenses(animal_id,clinic_contact_id,tariff_id,label,occurred_on,quantity,catalog_unit_cents,discount_percent,total_cents,notes,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
|
||||
)->execute([
|
||||
$animal,
|
||||
$t['clinic_contact_id'],
|
||||
$tariff,
|
||||
$t['label'],
|
||||
$date,
|
||||
$qty,
|
||||
$t['amount_cents'],
|
||||
$t['discount'],
|
||||
$total,
|
||||
$notes ?: null,
|
||||
Auth::id(),
|
||||
]);
|
||||
return $total;
|
||||
}
|
||||
}
|
||||
188
app/Services/PrivacyService.php
Normal file
188
app/Services/PrivacyService.php
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class PrivacyService
|
||||
{
|
||||
public static function logAccess(
|
||||
string $type,
|
||||
?int $id,
|
||||
?int $animalId,
|
||||
string $name,
|
||||
string $action = 'view',
|
||||
): void {
|
||||
DB::pdo()
|
||||
->prepare(
|
||||
'INSERT INTO private_file_access_log(user_id,resource_type,resource_id,animal_id,action,original_name,ip_address,user_agent) VALUES(?,?,?,?,?,?,?,?)',
|
||||
)
|
||||
->execute([
|
||||
Auth::id(),
|
||||
$type,
|
||||
$id,
|
||||
$animalId,
|
||||
$action,
|
||||
mb_substr($name, 0, 240),
|
||||
$_SERVER['REMOTE_ADDR'] ?? null,
|
||||
mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 500) ?: null,
|
||||
]);
|
||||
}
|
||||
public static function accessLog(int $limit = 500): array
|
||||
{
|
||||
return DB::pdo()
|
||||
->query(
|
||||
'SELECT l.*,COALESCE(u.display_name,u.username) actor,a.name animal_name FROM private_file_access_log l LEFT JOIN users u ON u.id=l.user_id LEFT JOIN animals a ON a.id=l.animal_id ORDER BY l.id DESC LIMIT ' .
|
||||
max(1, min(2000, $limit)),
|
||||
)
|
||||
->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
public static function retentionReview(int $years): array
|
||||
{
|
||||
$rows = DB::pdo()
|
||||
->query(
|
||||
"SELECT dc.id,dc.name,dc.email,dc.phone,dc.updated_at,group_concat(DISTINCT r.role) roles,MAX(COALESCE(ad.adoption_date,ap.event_date,dc.updated_at)) last_activity,(SELECT COUNT(*) FROM animal_placements active JOIN animals a ON a.id=active.animal_id WHERE active.contact_id=dc.id AND active.event_type='foster' AND a.deleted_at IS NULL AND a.archived_at IS NULL AND lower(a.status)='fa') active_links FROM directory_contacts dc LEFT JOIN directory_contact_roles r ON r.contact_id=dc.id LEFT JOIN adoptions ad ON ad.adopter_contact_id=dc.id LEFT JOIN animal_placements ap ON ap.contact_id=dc.id WHERE dc.deleted_at IS NULL AND dc.kind='person' AND dc.anonymized_at IS NULL GROUP BY dc.id ORDER BY date(last_activity),dc.name COLLATE NOCASE",
|
||||
)
|
||||
->fetchAll(PDO::FETCH_ASSOC);
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$roles = array_filter(explode(',', (string) $row['roles']));
|
||||
$limits = [];
|
||||
foreach ($roles as $role) {
|
||||
$limits[] = match ($role) {
|
||||
'adoptant' => AppSettings::int('privacy_retention_adopter_years', 1, 30),
|
||||
'fa' => AppSettings::int('privacy_retention_foster_years', 1, 30),
|
||||
'benevole' => AppSettings::int('privacy_retention_volunteer_years', 1, 30),
|
||||
default => $years,
|
||||
};
|
||||
}
|
||||
$limit = $limits ? max($limits) : $years;
|
||||
$last = strtotime((string) $row['last_activity']) ?: time();
|
||||
if ($last > strtotime('-' . $limit . ' years')) {
|
||||
continue;
|
||||
}
|
||||
$row['policy_years'] = $limit;
|
||||
$row['blocked'] = (int) $row['active_links'] > 0;
|
||||
$out[] = $row;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
public static function exportContact(int $id): string
|
||||
{
|
||||
$db = DB::pdo();
|
||||
$s = $db->prepare(
|
||||
"SELECT dc.*,group_concat(r.role,',') roles,org.name organization_name FROM directory_contacts dc LEFT JOIN directory_contact_roles r ON r.contact_id=dc.id LEFT JOIN directory_contacts org ON org.id=dc.organization_id WHERE dc.id=? AND dc.deleted_at IS NULL GROUP BY dc.id",
|
||||
);
|
||||
$s->execute([$id]);
|
||||
$contact = $s->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$contact) {
|
||||
throw new RuntimeException(t('privacy.contact_not_found'));
|
||||
}
|
||||
$queries = [
|
||||
'adoptions' =>
|
||||
'SELECT a.internal_code,a.name animal,ad.adoption_date,ad.notes FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE ad.adopter_contact_id=? ORDER BY ad.adoption_date',
|
||||
'placements' =>
|
||||
'SELECT a.internal_code,a.name animal,ap.event_type,ap.event_date,ap.reason,ap.notes FROM animal_placements ap JOIN animals a ON a.id=ap.animal_id WHERE ap.contact_id=? ORDER BY ap.event_date,ap.id',
|
||||
'deposited_animals' =>
|
||||
'SELECT internal_code,name,intake_date,intake_type,intake_reason,intake_circumstances FROM animals WHERE depositor_contact_id=? ORDER BY intake_date',
|
||||
];
|
||||
$payload = ['generated_at' => new DateTimeImmutable()->format(DATE_ATOM), 'contact' => $contact];
|
||||
foreach ($queries as $key => $sql) {
|
||||
$q = $db->prepare($sql);
|
||||
$q->execute([$id]);
|
||||
$payload[$key] = $q->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'globinours-contact-');
|
||||
if ($tmp === false) {
|
||||
throw new RuntimeException(t('privacy.export_failed'));
|
||||
}
|
||||
@unlink($tmp);
|
||||
$path = $tmp . '.zip';
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
throw new RuntimeException(t('privacy.export_failed'));
|
||||
}
|
||||
$zip->addFromString(
|
||||
'contact.json',
|
||||
json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
);
|
||||
foreach (['adoptions', 'placements', 'deposited_animals'] as $key) {
|
||||
$zip->addFromString($key . '.csv', self::csv($payload[$key]));
|
||||
}
|
||||
$zip->addFromString(
|
||||
'README.txt',
|
||||
"Export de portabilité Globinours\nGénéré le " .
|
||||
date('d/m/Y H:i') .
|
||||
"\nDonnées à conserver dans un emplacement sécurisé.\n",
|
||||
);
|
||||
$zip->close();
|
||||
return $path;
|
||||
}
|
||||
public static function anonymize(int $id, string $reason): void
|
||||
{
|
||||
$reason = trim($reason);
|
||||
if ($reason === '') {
|
||||
throw new RuntimeException(t('privacy.reason_required'));
|
||||
}
|
||||
$db = DB::pdo();
|
||||
$s = $db->prepare(
|
||||
"SELECT * FROM directory_contacts WHERE id=? AND deleted_at IS NULL AND kind='person' AND anonymized_at IS NULL",
|
||||
);
|
||||
$s->execute([$id]);
|
||||
$contact = $s->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$contact) {
|
||||
throw new RuntimeException(t('privacy.contact_not_found'));
|
||||
}
|
||||
$backup = BackupService::create('avant-anonymisation-contact-' . $id);
|
||||
$label = t('privacy.anonymous_contact') . ' #' . $id;
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
$db->prepare(
|
||||
"UPDATE directory_contacts SET name=?,phone=NULL,email=NULL,address=NULL,postal_code=NULL,city=NULL,country='France',organization_id=NULL,notes=NULL,anonymized_at=datetime('now'),anonymization_reason=?,updated_at=datetime('now') WHERE id=?",
|
||||
)->execute([$label, $reason, $id]);
|
||||
$db->prepare(
|
||||
'UPDATE adoptions SET adopter_name=?,adopter_phone=NULL,adopter_email=NULL,adopter_address=NULL,adopter_postal_code=NULL,adopter_city=NULL WHERE adopter_contact_id=?',
|
||||
)->execute([$label, $id]);
|
||||
foreach ([(string) $contact['name']] as $old) {
|
||||
$db->prepare(
|
||||
'UPDATE animal_movements SET contact_name=? WHERE lower(trim(contact_name))=lower(trim(?))',
|
||||
)->execute([$label, $old]);
|
||||
}
|
||||
$db->commit();
|
||||
AuditService::log(
|
||||
'contact_anonymized',
|
||||
'/settings/privacy/anonymize',
|
||||
'Contact anonymisé',
|
||||
'contact',
|
||||
$id,
|
||||
['reason' => $reason, 'backup' => $backup['name'] ?? null],
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
if ($db->inTransaction()) {
|
||||
$db->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
public static function maintenance(): array
|
||||
{
|
||||
$months = AppSettings::int('privacy_access_log_months', 1, 120);
|
||||
$s = DB::pdo()->prepare("DELETE FROM private_file_access_log WHERE datetime(accessed_at)<datetime('now',?)");
|
||||
$s->execute(['-' . $months . ' months']);
|
||||
return ['access_logs_removed' => $s->rowCount(), 'retention_months' => $months];
|
||||
}
|
||||
private static function csv(array $rows): string
|
||||
{
|
||||
if (!$rows) {
|
||||
return "\xEF\xBB\xBF";
|
||||
}
|
||||
$stream = fopen('php://temp', 'r+');
|
||||
fwrite($stream, "\xEF\xBB\xBF");
|
||||
fputcsv($stream, array_keys($rows[0]), ';');
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($stream, array_values($row), ';');
|
||||
}
|
||||
rewind($stream);
|
||||
$out = stream_get_contents($stream);
|
||||
fclose($stream);
|
||||
return (string) $out;
|
||||
}
|
||||
}
|
||||
148
app/Services/PrivateMediaService.php
Normal file
148
app/Services/PrivateMediaService.php
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class PrivateMediaService
|
||||
{
|
||||
public static function root(): string
|
||||
{
|
||||
return dirname(__DIR__, 2) . '/data/private-media';
|
||||
}
|
||||
public static function animalDir(int $animalId): string
|
||||
{
|
||||
return self::root() . '/animals/' . $animalId;
|
||||
}
|
||||
public static function medicalDir(int $animalId): string
|
||||
{
|
||||
return self::animalDir($animalId) . '/meds';
|
||||
}
|
||||
public static function publicDir(int $animalId): string
|
||||
{
|
||||
return dirname(__DIR__, 2) . '/public/media/animals/' . $animalId;
|
||||
}
|
||||
|
||||
public static function ensure(string $dir): void
|
||||
{
|
||||
if (!is_dir($dir) && !mkdir($dir, 0700, true) && !is_dir($dir)) {
|
||||
throw new RuntimeException(t('service.image.directory_failed'));
|
||||
}
|
||||
@chmod($dir, 0700);
|
||||
}
|
||||
|
||||
public static function moveVisibility(int $animalId, string $filename, bool $toPublic): void
|
||||
{
|
||||
$filename = basename($filename);
|
||||
if ($filename === '' || $filename === '.' || $filename === '..') {
|
||||
throw new RuntimeException(t('service.private_media.invalid_name'));
|
||||
}
|
||||
$from = $toPublic ? self::animalDir($animalId) : self::publicDir($animalId);
|
||||
$to = $toPublic ? self::publicDir($animalId) : self::animalDir($animalId);
|
||||
$source = $from . '/' . $filename;
|
||||
$target = $to . '/' . $filename;
|
||||
if (!is_file($source)) {
|
||||
if (is_file($target)) {
|
||||
return;
|
||||
}
|
||||
throw new RuntimeException(t('service.private_media.not_found'));
|
||||
}
|
||||
self::ensure($to);
|
||||
if (!rename($source, $target)) {
|
||||
throw new RuntimeException(t('service.private_media.move_failed'));
|
||||
}
|
||||
@chmod($target, $toPublic ? 0644 : 0600);
|
||||
}
|
||||
|
||||
public static function migrateLegacy(): void
|
||||
{
|
||||
$db = DB::pdo();
|
||||
$private = $db
|
||||
->query('SELECT animal_id,filename FROM animal_photos WHERE is_public=0')
|
||||
->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($private as $row) {
|
||||
self::moveIfPresent(
|
||||
self::publicDir((int) $row['animal_id']) . '/' . basename((string) $row['filename']),
|
||||
self::animalDir((int) $row['animal_id']) . '/' . basename((string) $row['filename']),
|
||||
);
|
||||
}
|
||||
$medical = $db->query('SELECT mp.animal_id,mp.filename FROM medical_photos mp')->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($medical as $row) {
|
||||
self::moveIfPresent(
|
||||
self::publicDir((int) $row['animal_id']) . '/meds/' . basename((string) $row['filename']),
|
||||
self::medicalDir((int) $row['animal_id']) . '/' . basename((string) $row['filename']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static function moveIfPresent(string $source, string $target): void
|
||||
{
|
||||
if (!is_file($source) || is_file($target)) {
|
||||
return;
|
||||
}
|
||||
self::ensure(dirname($target));
|
||||
if (rename($source, $target)) {
|
||||
@chmod($target, 0600);
|
||||
}
|
||||
}
|
||||
|
||||
public static function send(): void
|
||||
{
|
||||
$kind = (string) ($_GET['kind'] ?? 'photo');
|
||||
$id = (int) ($_GET['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
http_response_code(404);
|
||||
return;
|
||||
}
|
||||
$db = DB::pdo();
|
||||
if ($kind === 'medical') {
|
||||
if (!PermissionService::can('medical', 'view')) {
|
||||
http_response_code(403);
|
||||
return;
|
||||
}
|
||||
$s = $db->prepare(
|
||||
'SELECT mp.id,mp.animal_id,mp.filename FROM medical_photos mp JOIN animals a ON a.id=mp.animal_id WHERE mp.id=? AND a.deleted_at IS NULL',
|
||||
);
|
||||
$s->execute([$id]);
|
||||
$row = $s->fetch(PDO::FETCH_ASSOC);
|
||||
$path = $row ? self::medicalDir((int) $row['animal_id']) . '/' . basename((string) $row['filename']) : '';
|
||||
} else {
|
||||
if (!PermissionService::can('animals', 'view')) {
|
||||
http_response_code(403);
|
||||
return;
|
||||
}
|
||||
$s = $db->prepare(
|
||||
'SELECT ap.id,ap.animal_id,ap.filename,ap.care_round_id FROM animal_photos ap JOIN animals a ON a.id=ap.animal_id WHERE ap.id=? AND ap.is_public=0 AND a.deleted_at IS NULL',
|
||||
);
|
||||
$s->execute([$id]);
|
||||
$row = $s->fetch(PDO::FETCH_ASSOC);
|
||||
if ($row && !empty($row['care_round_id']) && !PermissionService::can('medical', 'view')) {
|
||||
http_response_code(403);
|
||||
return;
|
||||
}
|
||||
$path = $row ? self::animalDir((int) $row['animal_id']) . '/' . basename((string) $row['filename']) : '';
|
||||
}
|
||||
if (empty($row) || !is_file($path)) {
|
||||
http_response_code(404);
|
||||
return;
|
||||
}
|
||||
$mime = new finfo(FILEINFO_MIME_TYPE)->file($path) ?: 'application/octet-stream';
|
||||
PrivacyService::logAccess(
|
||||
'private_' . $kind,
|
||||
$id,
|
||||
(int) $row['animal_id'],
|
||||
basename((string) $row['filename']),
|
||||
);
|
||||
AuditService::log(
|
||||
'private_file_viewed',
|
||||
'/private-media',
|
||||
'Média privé consulté',
|
||||
'animal',
|
||||
(int) $row['animal_id'],
|
||||
['kind' => $kind, 'media_id' => $id],
|
||||
);
|
||||
header('Content-Type: ' . $mime);
|
||||
header('Content-Length: ' . filesize($path));
|
||||
header('Cache-Control: private,no-store');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
readfile($path);
|
||||
}
|
||||
}
|
||||
97
app/Services/SecurityService.php
Normal file
97
app/Services/SecurityService.php
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class SecurityService
|
||||
{
|
||||
public static function publicError(Throwable $e): string
|
||||
{
|
||||
$reference = strtoupper(substr(bin2hex(random_bytes(6)), 0, 10));
|
||||
error_log(
|
||||
'[Globinours ' .
|
||||
$reference .
|
||||
'] ' .
|
||||
get_class($e) .
|
||||
': ' .
|
||||
$e->getMessage() .
|
||||
' in ' .
|
||||
$e->getFile() .
|
||||
':' .
|
||||
$e->getLine(),
|
||||
);
|
||||
return t('error.unexpected_reference', ['reference' => $reference]);
|
||||
}
|
||||
public static function hardenRuntimeFiles(): void
|
||||
{
|
||||
$root = dirname(__DIR__, 2);
|
||||
$marker = $root . '/storage/.security-hardened';
|
||||
if (is_file($marker) && filemtime($marker) > time() - 86400) {
|
||||
return;
|
||||
}
|
||||
foreach (
|
||||
[
|
||||
$root . '/data',
|
||||
$root . '/storage',
|
||||
$root . '/data/private-media',
|
||||
$root . '/data/medical-documents',
|
||||
$root . '/data/grants',
|
||||
$root . '/storage/backups',
|
||||
$root . '/storage/logs',
|
||||
$root . '/storage/asm3-imports',
|
||||
]
|
||||
as $dir
|
||||
) {
|
||||
if (is_dir($dir)) {
|
||||
@chmod($dir, 0700);
|
||||
}
|
||||
}
|
||||
foreach (
|
||||
[
|
||||
$root . '/.env',
|
||||
$root . '/data/refuge.sqlite',
|
||||
$root . '/data/refuge.sqlite-wal',
|
||||
$root . '/data/refuge.sqlite-shm',
|
||||
]
|
||||
as $file
|
||||
) {
|
||||
if (is_file($file)) {
|
||||
@chmod($file, 0600);
|
||||
}
|
||||
}
|
||||
foreach (
|
||||
[
|
||||
'storage/backups/*',
|
||||
'storage/logs/*',
|
||||
'storage/reports/*',
|
||||
'data/private-media/*',
|
||||
'data/medical-documents/*',
|
||||
'data/grants/*',
|
||||
]
|
||||
as $pattern
|
||||
) {
|
||||
foreach (glob($root . '/' . $pattern) ?: [] as $file) {
|
||||
self::hardenTree($file);
|
||||
}
|
||||
}
|
||||
@touch($marker);
|
||||
@chmod($marker, 0600);
|
||||
}
|
||||
private static function hardenTree(string $path): void
|
||||
{
|
||||
if (is_file($path)) {
|
||||
@chmod($path, 0600);
|
||||
return;
|
||||
}
|
||||
if (!is_dir($path)) {
|
||||
return;
|
||||
}
|
||||
@chmod($path, 0700);
|
||||
$it = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST,
|
||||
);
|
||||
foreach ($it as $item) {
|
||||
@chmod($item->getPathname(), $item->isDir() ? 0700 : 0600);
|
||||
}
|
||||
}
|
||||
}
|
||||
59
app/Services/ShelterRoomService.php
Normal file
59
app/Services/ShelterRoomService.php
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class ShelterRoomService
|
||||
{
|
||||
public static function all(bool $activeOnly = false): array
|
||||
{
|
||||
$sql =
|
||||
'SELECT * FROM shelter_rooms' .
|
||||
($activeOnly ? ' WHERE active=1' : '') .
|
||||
' ORDER BY sort_order,name COLLATE NOCASE';
|
||||
$rooms = DB::pdo()->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
||||
$boxes = DB::pdo()
|
||||
->query('SELECT * FROM shelter_boxes ORDER BY sort_order,name COLLATE NOCASE')
|
||||
->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($rooms as &$room) {
|
||||
$room['boxes'] = array_values(
|
||||
array_filter(
|
||||
$boxes,
|
||||
static fn($box) => (int) $box['room_id'] === (int) $room['id'] &&
|
||||
(!$activeOnly || (int) $box['active'] === 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
return $rooms;
|
||||
}
|
||||
|
||||
public static function byCode(bool $activeOnly = true): array
|
||||
{
|
||||
$result = [];
|
||||
foreach (self::all($activeOnly) as $room) {
|
||||
$result[(string) $room['code']] = $room;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
public static function resolveAnimal(array $animal, array $rooms): string
|
||||
{
|
||||
$stored = (string) ($animal['refuge_room'] ?? '');
|
||||
if ($stored !== '' && isset($rooms[$stored])) {
|
||||
return $stored;
|
||||
}
|
||||
$status = (string) ($animal['status'] ?? 'refuge');
|
||||
foreach ($rooms as $code => $room) {
|
||||
if ((string) $room['status_code'] === $status) {
|
||||
return (string) $code;
|
||||
}
|
||||
}
|
||||
return array_key_first($rooms) ?? '';
|
||||
}
|
||||
|
||||
public static function normalizeCode(string $value): string
|
||||
{
|
||||
$value = mb_strtolower(trim($value));
|
||||
$value = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value) ?: $value;
|
||||
return trim(preg_replace('/[^a-z0-9]+/', '-', $value) ?? '', '-');
|
||||
}
|
||||
}
|
||||
79
app/Services/SpeciesService.php
Normal file
79
app/Services/SpeciesService.php
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class SpeciesService
|
||||
{
|
||||
private static ?array $labels = null;
|
||||
public const CATEGORIES = [
|
||||
'animaux_compagnie' => 'Animaux de compagnie',
|
||||
'nac' => 'NAC',
|
||||
'animal_ferme' => 'Animaux de ferme',
|
||||
'faune_sauvage' => 'Faune sauvage',
|
||||
'autre' => 'Autre',
|
||||
];
|
||||
public static function categoryLabel(string $key): string
|
||||
{
|
||||
return t('species.category.' . $key, [], self::CATEGORIES[$key] ?? $key);
|
||||
}
|
||||
|
||||
public static function all(bool $activeOnly = false): array
|
||||
{
|
||||
$sql =
|
||||
'SELECT s.*,(SELECT COUNT(*) FROM animals a WHERE lower(trim(a.species))=lower(s.code)) animal_count FROM ref_species s';
|
||||
if ($activeOnly) {
|
||||
$sql .= ' WHERE s.active=1';
|
||||
}
|
||||
$sql .= ' ORDER BY s.sort_order,s.name COLLATE NOCASE';
|
||||
return DB::pdo()->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public static function activeWithCurrent(?string $current): array
|
||||
{
|
||||
$rows = self::all(true);
|
||||
$current = self::normalize((string) $current);
|
||||
if ($current !== '' && !array_filter($rows, static fn(array $r): bool => $r['code'] === $current)) {
|
||||
$stmt = DB::pdo()->prepare('SELECT * FROM ref_species WHERE code=:code');
|
||||
$stmt->execute([':code' => $current]);
|
||||
if ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
$rows[] = $row;
|
||||
}
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public static function normalize(string $value): string
|
||||
{
|
||||
$value = mb_strtolower(trim($value));
|
||||
return match ($value) {
|
||||
'cat' => 'chat',
|
||||
'dog' => 'chien',
|
||||
default => $value,
|
||||
};
|
||||
}
|
||||
|
||||
public static function exists(string $code, bool $activeOnly = true): bool
|
||||
{
|
||||
$sql = 'SELECT 1 FROM ref_species WHERE code=:code' . ($activeOnly ? ' AND active=1' : '');
|
||||
$stmt = DB::pdo()->prepare($sql);
|
||||
$stmt->execute([':code' => self::normalize($code)]);
|
||||
return (bool) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
public static function isCat(?string $code): bool
|
||||
{
|
||||
return self::normalize((string) $code) === 'chat';
|
||||
}
|
||||
|
||||
public static function label(?string $code): string
|
||||
{
|
||||
$code = self::normalize((string) $code);
|
||||
if (self::$labels === null) {
|
||||
self::$labels = [];
|
||||
foreach (self::all(false) as $row) {
|
||||
self::$labels[$row['code']] = $row['name'];
|
||||
}
|
||||
}
|
||||
return self::$labels[$code] ?? ($code !== '' ? ucfirst(str_replace('-', ' ', $code)) : 'Espèce inconnue');
|
||||
}
|
||||
}
|
||||
705
app/Services/StatisticsService.php
Normal file
705
app/Services/StatisticsService.php
Normal file
|
|
@ -0,0 +1,705 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class StatisticsService
|
||||
{
|
||||
public static function costs(?int $year, ?array $ids = null): array
|
||||
{
|
||||
$db = DB::pdo();
|
||||
$scopeIds = self::costScopeIds($year, $ids);
|
||||
$empty = [
|
||||
'total_cents' => 0,
|
||||
'animals' => 0,
|
||||
'scope_animals' => count($scopeIds),
|
||||
'average_all_cents' => 0,
|
||||
'average_expense_cents' => 0,
|
||||
'median_expense_cents' => 0,
|
||||
'by_clinic' => [],
|
||||
'by_category' => [],
|
||||
];
|
||||
if (!$scopeIds) {
|
||||
return $empty;
|
||||
}
|
||||
$where = ['a.deleted_at IS NULL', 'ae.animal_id IN(' . implode(',', array_map('intval', $scopeIds)) . ')'];
|
||||
$params = [];
|
||||
if ($year !== null) {
|
||||
$where[] = 'date(ae.occurred_on) BETWEEN :start AND :end';
|
||||
$params = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)];
|
||||
}
|
||||
$base =
|
||||
' FROM animal_expenses ae JOIN animals a ON a.id=ae.animal_id LEFT JOIN directory_contacts dc ON dc.id=ae.clinic_contact_id LEFT JOIN clinic_tariffs ct ON ct.id=ae.tariff_id WHERE ' .
|
||||
implode(' AND ', $where);
|
||||
$map = static function (string $sql) use ($db, $params): array {
|
||||
$s = $db->prepare($sql);
|
||||
$s->execute($params);
|
||||
return $s->fetchAll(PDO::FETCH_ASSOC);
|
||||
};
|
||||
$perAnimal = $map(
|
||||
'SELECT ae.animal_id,SUM(ae.total_cents) total_cents' .
|
||||
$base .
|
||||
' GROUP BY ae.animal_id ORDER BY total_cents',
|
||||
);
|
||||
$totals = array_map(static fn($row) => (int) $row['total_cents'], $perAnimal);
|
||||
$total = array_sum($totals);
|
||||
$animals = count($totals);
|
||||
return [
|
||||
'total_cents' => $total,
|
||||
'animals' => $animals,
|
||||
'scope_animals' => count($scopeIds),
|
||||
'average_all_cents' => count($scopeIds) ? (int) round($total / count($scopeIds)) : 0,
|
||||
'average_expense_cents' => $animals ? (int) round($total / $animals) : 0,
|
||||
'median_expense_cents' => self::median($totals),
|
||||
'by_clinic' => $map(
|
||||
"SELECT dc.id contact_id,COALESCE(dc.name,'—') label,COUNT(DISTINCT ae.animal_id) animals,SUM(ae.total_cents) total_cents,ROUND(SUM(ae.total_cents)*1.0/COUNT(DISTINCT ae.animal_id)) average_cents" .
|
||||
$base .
|
||||
' GROUP BY dc.id,dc.name ORDER BY total_cents DESC',
|
||||
),
|
||||
'by_category' => $map(
|
||||
"SELECT COALESCE(ct.category,'other') label,COUNT(DISTINCT ae.animal_id) animals,SUM(ae.total_cents) total_cents,ROUND(SUM(ae.total_cents)*1.0/COUNT(DISTINCT ae.animal_id)) average_cents" .
|
||||
$base .
|
||||
' GROUP BY ct.category ORDER BY total_cents DESC',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
public static function costsByMunicipality(?int $year): array
|
||||
{
|
||||
$db = DB::pdo();
|
||||
$scopeIds = self::costScopeIds($year, null);
|
||||
if (!$scopeIds) {
|
||||
return [];
|
||||
}
|
||||
$animals = $db
|
||||
->query('SELECT id,rescue_location_name,rescue_address FROM animals WHERE deleted_at IS NULL')
|
||||
->fetchAll(PDO::FETCH_ASSOC);
|
||||
$cities = [];
|
||||
$animalCity = [];
|
||||
foreach ($animals as $row) {
|
||||
$id = (int) $row['id'];
|
||||
if (!in_array($id, $scopeIds, true)) {
|
||||
continue;
|
||||
}
|
||||
$city = GrantService::municipality((string) ($row['rescue_location_name'] ?: $row['rescue_address']));
|
||||
if ($city === 'Non renseignée') {
|
||||
continue;
|
||||
}
|
||||
$animalCity[$id] = $city;
|
||||
$cities[$city]['animal_ids'][$id] = true;
|
||||
}
|
||||
if (!$animalCity) {
|
||||
return [];
|
||||
}
|
||||
$params = [];
|
||||
$dateSql = '';
|
||||
if ($year !== null) {
|
||||
$dateSql = ' AND date(occurred_on) BETWEEN :start AND :end';
|
||||
$params = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)];
|
||||
}
|
||||
$s = $db->prepare(
|
||||
'SELECT animal_id,SUM(total_cents) total_cents FROM animal_expenses WHERE animal_id IN(' .
|
||||
implode(',', array_keys($animalCity)) .
|
||||
')' .
|
||||
$dateSql .
|
||||
' GROUP BY animal_id',
|
||||
);
|
||||
$s->execute($params);
|
||||
foreach ($s->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$id = (int) $row['animal_id'];
|
||||
$city = $animalCity[$id];
|
||||
$cities[$city]['expense_totals'][$id] = (int) $row['total_cents'];
|
||||
}
|
||||
$out = [];
|
||||
foreach ($cities as $city => $data) {
|
||||
$totals = array_values($data['expense_totals'] ?? []);
|
||||
$total = array_sum($totals);
|
||||
$scope = count($data['animal_ids']);
|
||||
$with = count($totals);
|
||||
$out[] = [
|
||||
'municipality' => $city,
|
||||
'animals' => $scope,
|
||||
'animals_with_expense' => $with,
|
||||
'total_cents' => $total,
|
||||
'average_all_cents' => $scope ? (int) round($total / $scope) : 0,
|
||||
'average_expense_cents' => $with ? (int) round($total / $with) : 0,
|
||||
'median_expense_cents' => self::median($totals),
|
||||
];
|
||||
}
|
||||
usort(
|
||||
$out,
|
||||
static fn($a, $b) => $b['total_cents'] <=> $a['total_cents'] ?:
|
||||
strnatcasecmp($a['municipality'], $b['municipality']),
|
||||
);
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function costScopeIds(?int $year, ?array $ids): array
|
||||
{
|
||||
$db = DB::pdo();
|
||||
$limit = $ids === null ? '' : self::idSql($ids, 'a.id');
|
||||
if ($year === null) {
|
||||
$sql = 'SELECT a.id FROM animals a WHERE a.deleted_at IS NULL' . $limit;
|
||||
} else {
|
||||
$start = sprintf('%04d-01-01', $year);
|
||||
$end = sprintf('%04d-12-31', $year);
|
||||
$sql =
|
||||
'SELECT a.id FROM animals a WHERE a.deleted_at IS NULL' .
|
||||
$limit .
|
||||
" AND (date(a.intake_date) BETWEEN :start AND :end OR EXISTS(SELECT 1 FROM animal_movements m WHERE m.animal_id=a.id AND m.kind='entry' AND date(m.created_at) BETWEEN :start AND :end) OR EXISTS(SELECT 1 FROM animal_expenses ae WHERE ae.animal_id=a.id AND date(ae.occurred_on) BETWEEN :start AND :end))";
|
||||
}
|
||||
$s = $db->prepare($sql);
|
||||
$s->execute($year === null ? [] : [':start' => $start, ':end' => $end]);
|
||||
return array_map('intval', $s->fetchAll(PDO::FETCH_COLUMN));
|
||||
}
|
||||
|
||||
private static function median(array $values): int
|
||||
{
|
||||
if (!$values) {
|
||||
return 0;
|
||||
}
|
||||
sort($values, SORT_NUMERIC);
|
||||
$count = count($values);
|
||||
$middle = intdiv($count, 2);
|
||||
return $count % 2 ? (int) $values[$middle] : (int) round(($values[$middle - 1] + $values[$middle]) / 2);
|
||||
}
|
||||
private static function idSql(?array $ids, string $column = 'animal_id'): string
|
||||
{
|
||||
if ($ids === null) {
|
||||
return '';
|
||||
}
|
||||
if (!$ids) {
|
||||
return ' AND 1=0';
|
||||
}
|
||||
return ' AND ' . $column . ' IN (' . implode(',', array_map('intval', $ids)) . ')';
|
||||
}
|
||||
|
||||
public static function municipalities(): array
|
||||
{
|
||||
$rows = DB::pdo()
|
||||
->query('SELECT id,rescue_location_name,rescue_address FROM animals WHERE deleted_at IS NULL')
|
||||
->fetchAll(PDO::FETCH_ASSOC);
|
||||
$items = [];
|
||||
foreach ($rows as $row) {
|
||||
$city = GrantService::municipality((string) ($row['rescue_location_name'] ?: $row['rescue_address']));
|
||||
if ($city === 'Non renseignée') {
|
||||
continue;
|
||||
}
|
||||
$items[$city] = ($items[$city] ?? 0) + 1;
|
||||
}
|
||||
uksort($items, static fn($a, $b) => strnatcasecmp($a, $b));
|
||||
return $items;
|
||||
}
|
||||
|
||||
public static function animalIdsForMunicipality(string $municipality): array
|
||||
{
|
||||
if ($municipality === '') {
|
||||
return [];
|
||||
}
|
||||
$rows = DB::pdo()
|
||||
->query('SELECT id,rescue_location_name,rescue_address FROM animals WHERE deleted_at IS NULL')
|
||||
->fetchAll(PDO::FETCH_ASSOC);
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
if (
|
||||
GrantService::municipality((string) ($row['rescue_location_name'] ?: $row['rescue_address'])) ===
|
||||
$municipality
|
||||
) {
|
||||
$ids[] = (int) $row['id'];
|
||||
}
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
|
||||
public static function municipalityCoverage(): array
|
||||
{
|
||||
$row =
|
||||
DB::pdo()
|
||||
->query(
|
||||
"SELECT COUNT(*) total,SUM(CASE WHEN TRIM(COALESCE(rescue_location_name,''))<>'' OR TRIM(COALESCE(rescue_address,''))<>'' THEN 1 ELSE 0 END) known,SUM(CASE WHEN (TRIM(COALESCE(rescue_location_name,''))<>'' OR TRIM(COALESCE(rescue_address,''))<>'') AND COALESCE(rescue_location_confidence,'')<>'inferred_family' THEN 1 ELSE 0 END) documented,SUM(CASE WHEN rescue_location_confidence='inferred_family' THEN 1 ELSE 0 END) inferred,SUM(CASE WHEN intake_type='born_in_care' OR intake_reason='birth' THEN 1 ELSE 0 END) born_in_care,SUM(CASE WHEN TRIM(COALESCE(rescue_location_name,''))='' AND TRIM(COALESCE(rescue_address,''))='' AND intake_type<>'born_in_care' AND intake_reason<>'birth' THEN 1 ELSE 0 END) unknown FROM animals WHERE deleted_at IS NULL",
|
||||
)
|
||||
->fetch(PDO::FETCH_ASSOC) ?:
|
||||
[];
|
||||
return array_map('intval', $row);
|
||||
}
|
||||
|
||||
public static function annual(int $year, ?array $ids = null): array
|
||||
{
|
||||
if ($ids === null) {
|
||||
$stats = GrantService::statistics($year);
|
||||
} else {
|
||||
$db = DB::pdo();
|
||||
$p = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)];
|
||||
$in = self::idSql($ids);
|
||||
$scalar = static function (string $sql) use ($db, $p): float {
|
||||
$s = $db->prepare($sql);
|
||||
$s->execute($p);
|
||||
return (float) ($s->fetchColumn() ?: 0);
|
||||
};
|
||||
$stats = [];
|
||||
$stats['entries'] = $scalar(
|
||||
"SELECT COUNT(*) FROM (SELECT m.animal_id FROM animal_movements m JOIN animals a ON a.id=m.animal_id WHERE a.deleted_at IS NULL AND m.kind='entry' AND date(m.created_at) BETWEEN :start AND :end" .
|
||||
self::idSql($ids, 'm.animal_id') .
|
||||
' UNION SELECT a.id FROM animals a WHERE a.deleted_at IS NULL AND date(a.intake_date) BETWEEN :start AND :end' .
|
||||
self::idSql($ids, 'a.id') .
|
||||
')',
|
||||
);
|
||||
$stats['exits'] = $scalar(
|
||||
'SELECT (SELECT COUNT(*) FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE a.deleted_at IS NULL AND date(ad.adoption_date) BETWEEN :start AND :end' .
|
||||
self::idSql($ids, 'ad.animal_id') .
|
||||
")+(SELECT COUNT(*) FROM animal_deaths d JOIN animals a ON a.id=d.animal_id WHERE a.deleted_at IS NULL AND date(COALESCE(d.deceased_date,printf('%04d-01-01',d.death_year))) BETWEEN :start AND :end AND NOT EXISTS(SELECT 1 FROM adoptions ad WHERE ad.animal_id=d.animal_id)" .
|
||||
self::idSql($ids, 'd.animal_id') .
|
||||
')',
|
||||
);
|
||||
$stats['adoptions'] = $scalar(
|
||||
'SELECT COUNT(*) FROM adoptions WHERE date(adoption_date) BETWEEN :start AND :end' . $in,
|
||||
);
|
||||
$stats['deaths'] = $scalar(
|
||||
"SELECT COUNT(*) FROM animal_deaths WHERE date(COALESCE(deceased_date,printf('%04d-01-01',death_year))) BETWEEN :start AND :end" .
|
||||
$in,
|
||||
);
|
||||
$stats['births'] = $scalar(
|
||||
"SELECT COUNT(*) FROM litter_kittens lk JOIN litters l ON l.id=lk.litter_id JOIN animals a ON a.id=lk.animal_id WHERE a.deleted_at IS NULL AND date(COALESCE(NULLIF(l.birth_date,''),NULLIF(a.birth_date,''))) BETWEEN :start AND :end" .
|
||||
self::idSql($ids, 'lk.animal_id'),
|
||||
);
|
||||
$stats['vaccinations'] = $scalar(
|
||||
'SELECT COUNT(*) FROM vaccinations WHERE date(done_date) BETWEEN :start AND :end' . $in,
|
||||
);
|
||||
$stats['treatments'] = $scalar(
|
||||
'SELECT COUNT(*) FROM treatments WHERE date(start_date) BETWEEN :start AND :end' . $in,
|
||||
);
|
||||
$stats['avg'] = $scalar(
|
||||
"SELECT AVG(julianday(adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))) FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE date(adoption_date) BETWEEN :start AND :end AND date(adoption_date)>=date(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))" .
|
||||
self::idSql($ids, 'a.id'),
|
||||
);
|
||||
$stats['current'] =
|
||||
(float) ($db
|
||||
->query(
|
||||
"SELECT COUNT(*) FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND status NOT IN ('adopte','decede')" .
|
||||
self::idSql($ids, 'id'),
|
||||
)
|
||||
->fetchColumn() ?:
|
||||
0);
|
||||
}
|
||||
$db = DB::pdo();
|
||||
$range = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)];
|
||||
$filter = self::idSql($ids, 'a.id');
|
||||
$s = $db->prepare(
|
||||
"SELECT COUNT(*) FROM (SELECT m.animal_id FROM animal_movements m JOIN animals a ON a.id=m.animal_id WHERE a.deleted_at IS NULL AND m.kind='entry' AND date(m.created_at) BETWEEN :start AND :end" .
|
||||
$filter .
|
||||
' UNION SELECT a.id FROM animals a WHERE a.deleted_at IS NULL AND date(a.intake_date) BETWEEN :start AND :end' .
|
||||
$filter .
|
||||
" UNION SELECT a.id FROM animals a JOIN animal_deaths d ON d.animal_id=a.id WHERE a.deleted_at IS NULL AND NULLIF(TRIM(a.intake_date),'') IS NULL AND NOT EXISTS(SELECT 1 FROM animal_movements em WHERE em.animal_id=a.id AND em.kind='entry') AND date(COALESCE(d.deceased_date,printf('%04d-01-01',d.death_year))) BETWEEN :start AND :end" .
|
||||
$filter .
|
||||
')',
|
||||
);
|
||||
$s->execute($range);
|
||||
$stats['handled'] = (int) $s->fetchColumn();
|
||||
$s = $db->prepare(
|
||||
'SELECT COUNT(*) FROM medical_notes mn JOIN animals a ON a.id=mn.animal_id WHERE date(mn.noted_at) BETWEEN :start AND :end' .
|
||||
$filter,
|
||||
);
|
||||
$s->execute($range);
|
||||
$stats['medical_notes'] = (int) $s->fetchColumn();
|
||||
return $stats;
|
||||
}
|
||||
|
||||
public static function comparison(int $year, ?array $ids = null): array
|
||||
{
|
||||
$current = self::annual($year, $ids);
|
||||
$previous = self::annual($year - 1, $ids);
|
||||
$out = [];
|
||||
foreach (
|
||||
[
|
||||
'handled',
|
||||
'entries',
|
||||
'exits',
|
||||
'adoptions',
|
||||
'deaths',
|
||||
'births',
|
||||
'vaccinations',
|
||||
'treatments',
|
||||
'medical_notes',
|
||||
'avg',
|
||||
]
|
||||
as $key
|
||||
) {
|
||||
$a = (float) $current[$key];
|
||||
$b = (float) $previous[$key];
|
||||
$out[$key] = [
|
||||
'current' => $a,
|
||||
'previous' => $b,
|
||||
'difference' => $a - $b,
|
||||
'percent' => $b != 0 ? (($a - $b) / $b) * 100 : null,
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
public static function years(): array
|
||||
{
|
||||
$db = DB::pdo();
|
||||
$found = [];
|
||||
$queries = [
|
||||
'SELECT intake_date d FROM animals WHERE deleted_at IS NULL AND intake_date IS NOT NULL',
|
||||
"SELECT created_at d FROM animal_movements WHERE kind IN ('entry','exit')",
|
||||
'SELECT adoption_date d FROM adoptions',
|
||||
"SELECT COALESCE(deceased_date,printf('%04d-01-01',death_year)) d FROM animal_deaths WHERE deceased_date IS NOT NULL OR death_year IS NOT NULL",
|
||||
'SELECT birth_date d FROM litters WHERE birth_date IS NOT NULL',
|
||||
'SELECT done_date d FROM vaccinations',
|
||||
'SELECT start_date d FROM treatments',
|
||||
'SELECT noted_at d FROM medical_notes',
|
||||
'SELECT occurred_on d FROM animal_expenses',
|
||||
];
|
||||
foreach ($queries as $sql) {
|
||||
foreach ($db->query($sql)->fetchAll(PDO::FETCH_COLUMN) as $date) {
|
||||
$y = (int) substr((string) $date, 0, 4);
|
||||
if ($y >= 2000 && $y <= 2200) {
|
||||
$found[$y] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!$found) {
|
||||
$found[(int) date('Y')] = true;
|
||||
}
|
||||
$first = min(array_keys($found));
|
||||
$last = max((int) date('Y'), max(array_keys($found)));
|
||||
return range($first, $last);
|
||||
}
|
||||
|
||||
public static function timeline(?array $years = null, ?array $ids = null): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($years ?? self::years() as $year) {
|
||||
$out[(int) $year] = self::annual((int) $year, $ids);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
public static function all(?array $ids = null): array
|
||||
{
|
||||
$stats = array_fill_keys(
|
||||
['entries', 'exits', 'adoptions', 'deaths', 'births', 'vaccinations', 'treatments', 'medical_notes'],
|
||||
0.0,
|
||||
);
|
||||
foreach (self::timeline(null, $ids) as $annual) {
|
||||
foreach (array_keys($stats) as $key) {
|
||||
$stats[$key] += (float) $annual[$key];
|
||||
}
|
||||
}
|
||||
$db = DB::pdo();
|
||||
$stats['deaths'] =
|
||||
(float) ($db
|
||||
->query(
|
||||
'SELECT COUNT(*) FROM animal_deaths d JOIN animals a ON a.id=d.animal_id WHERE a.deleted_at IS NULL' .
|
||||
self::idSql($ids, 'd.animal_id'),
|
||||
)
|
||||
->fetchColumn() ?:
|
||||
0);
|
||||
if ($ids === null) {
|
||||
$stats['handled'] =
|
||||
(float) ($db->query('SELECT COUNT(*) FROM animals WHERE deleted_at IS NULL')->fetchColumn() ?: 0);
|
||||
$stats['avg'] =
|
||||
(float) ($db
|
||||
->query(
|
||||
"SELECT AVG(julianday(adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))) FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE adoption_date IS NOT NULL AND date(adoption_date)>=date(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))",
|
||||
)
|
||||
->fetchColumn() ?:
|
||||
0);
|
||||
} else {
|
||||
$stats['handled'] = (float) count($ids);
|
||||
$stats['avg'] =
|
||||
(float) ($db
|
||||
->query(
|
||||
"SELECT AVG(julianday(adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))) FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE adoption_date IS NOT NULL AND date(adoption_date)>=date(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))" .
|
||||
self::idSql($ids, 'a.id'),
|
||||
)
|
||||
->fetchColumn() ?:
|
||||
0);
|
||||
}
|
||||
$stats['entries'] = $stats['handled'];
|
||||
$stats['exits'] =
|
||||
(float) ($db
|
||||
->query(
|
||||
'SELECT (SELECT COUNT(*) FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE a.deleted_at IS NULL' .
|
||||
self::idSql($ids, 'ad.animal_id') .
|
||||
')+(SELECT COUNT(*) FROM animal_deaths d JOIN animals a ON a.id=d.animal_id WHERE a.deleted_at IS NULL AND NOT EXISTS(SELECT 1 FROM adoptions ad WHERE ad.animal_id=d.animal_id)' .
|
||||
self::idSql($ids, 'd.animal_id') .
|
||||
")+(SELECT COUNT(*) FROM animals a WHERE a.deleted_at IS NULL AND a.status='enfui' AND NOT EXISTS(SELECT 1 FROM adoptions ad WHERE ad.animal_id=a.id) AND NOT EXISTS(SELECT 1 FROM animal_deaths d WHERE d.animal_id=a.id)" .
|
||||
self::idSql($ids, 'a.id') .
|
||||
')',
|
||||
)
|
||||
->fetchColumn() ?:
|
||||
0);
|
||||
$stats['adoptions'] =
|
||||
(float) ($db
|
||||
->query(
|
||||
'SELECT COUNT(*) FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE a.deleted_at IS NULL' .
|
||||
self::idSql($ids, 'ad.animal_id'),
|
||||
)
|
||||
->fetchColumn() ?:
|
||||
0);
|
||||
$stats['births'] =
|
||||
(float) ($db
|
||||
->query(
|
||||
'SELECT COUNT(*) FROM litter_kittens lk JOIN animals a ON a.id=lk.animal_id WHERE a.deleted_at IS NULL' .
|
||||
self::idSql($ids, 'lk.animal_id'),
|
||||
)
|
||||
->fetchColumn() ?:
|
||||
0);
|
||||
$stats['vaccinations'] =
|
||||
(float) ($db
|
||||
->query(
|
||||
'SELECT COUNT(*) FROM vaccinations v JOIN animals a ON a.id=v.animal_id WHERE a.deleted_at IS NULL' .
|
||||
self::idSql($ids, 'v.animal_id'),
|
||||
)
|
||||
->fetchColumn() ?:
|
||||
0);
|
||||
$stats['medical_notes'] =
|
||||
(float) ($db
|
||||
->query(
|
||||
'SELECT COUNT(*) FROM medical_notes mn JOIN animals a ON a.id=mn.animal_id WHERE a.deleted_at IS NULL' .
|
||||
self::idSql($ids, 'mn.animal_id'),
|
||||
)
|
||||
->fetchColumn() ?:
|
||||
0);
|
||||
$stats['current'] = (float) (self::currentHealth($ids)['total'] ?? 0);
|
||||
return $stats;
|
||||
}
|
||||
|
||||
public static function undatedHandled(?array $ids = null): int
|
||||
{
|
||||
return (int) (DB::pdo()
|
||||
->query(
|
||||
"SELECT COUNT(*) FROM animals a WHERE a.deleted_at IS NULL AND (a.intake_date IS NULL OR TRIM(a.intake_date)='') AND NOT EXISTS(SELECT 1 FROM animal_movements m WHERE m.animal_id=a.id AND m.kind='entry')" .
|
||||
self::idSql($ids, 'a.id'),
|
||||
)
|
||||
->fetchColumn() ?:
|
||||
0);
|
||||
}
|
||||
|
||||
public static function undatedDeaths(?array $ids = null): int
|
||||
{
|
||||
return (int) (DB::pdo()
|
||||
->query(
|
||||
"SELECT COUNT(*) FROM animal_deaths d JOIN animals a ON a.id=d.animal_id WHERE a.deleted_at IS NULL AND (d.deceased_date IS NULL OR TRIM(d.deceased_date)='') AND d.death_year IS NULL" .
|
||||
self::idSql($ids, 'd.animal_id'),
|
||||
)
|
||||
->fetchColumn() ?:
|
||||
0);
|
||||
}
|
||||
|
||||
public static function undatedBirths(?array $ids = null): int
|
||||
{
|
||||
return (int) (DB::pdo()
|
||||
->query(
|
||||
"SELECT COUNT(*) FROM litter_kittens lk JOIN litters l ON l.id=lk.litter_id JOIN animals a ON a.id=lk.animal_id WHERE a.deleted_at IS NULL AND COALESCE(NULLIF(l.birth_date,''),NULLIF(a.birth_date,'')) IS NULL" .
|
||||
self::idSql($ids, 'lk.animal_id'),
|
||||
)
|
||||
->fetchColumn() ?:
|
||||
0);
|
||||
}
|
||||
|
||||
public static function undatedExits(?array $ids = null): int
|
||||
{
|
||||
return (int) (DB::pdo()
|
||||
->query(
|
||||
"SELECT (SELECT COUNT(*) FROM animal_deaths d JOIN animals a ON a.id=d.animal_id WHERE a.deleted_at IS NULL AND (d.deceased_date IS NULL OR TRIM(d.deceased_date)='') AND d.death_year IS NULL AND NOT EXISTS(SELECT 1 FROM adoptions ad WHERE ad.animal_id=d.animal_id)" .
|
||||
self::idSql($ids, 'd.animal_id') .
|
||||
")+(SELECT COUNT(*) FROM animals a WHERE a.deleted_at IS NULL AND a.status='enfui' AND NOT EXISTS(SELECT 1 FROM adoptions ad WHERE ad.animal_id=a.id) AND NOT EXISTS(SELECT 1 FROM animal_deaths d WHERE d.animal_id=a.id)" .
|
||||
self::idSql($ids, 'a.id') .
|
||||
')',
|
||||
)
|
||||
->fetchColumn() ?:
|
||||
0);
|
||||
}
|
||||
|
||||
public static function scopeCounts(?array $ids = null): array
|
||||
{
|
||||
$db = DB::pdo();
|
||||
return [
|
||||
'vaccinated_animals' => (int) $db
|
||||
->query(
|
||||
'SELECT COUNT(DISTINCT v.animal_id) FROM vaccinations v JOIN animals a ON a.id=v.animal_id WHERE a.deleted_at IS NULL' .
|
||||
self::idSql($ids, 'v.animal_id'),
|
||||
)
|
||||
->fetchColumn(),
|
||||
'medical_animals' => (int) $db
|
||||
->query(
|
||||
'SELECT COUNT(DISTINCT mn.animal_id) FROM medical_notes mn JOIN animals a ON a.id=mn.animal_id WHERE a.deleted_at IS NULL' .
|
||||
self::idSql($ids, 'mn.animal_id'),
|
||||
)
|
||||
->fetchColumn(),
|
||||
];
|
||||
}
|
||||
|
||||
private static function details(int $year, array $ids): array
|
||||
{
|
||||
$db = DB::pdo();
|
||||
$p = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)];
|
||||
$base =
|
||||
"WITH entry_sources AS (SELECT m.animal_id,date(m.created_at) entry_date FROM animal_movements m JOIN animals source_animal ON source_animal.id=m.animal_id WHERE source_animal.deleted_at IS NULL AND m.kind='entry' AND date(m.created_at) BETWEEN :start AND :end" .
|
||||
self::idSql($ids, 'm.animal_id') .
|
||||
' UNION SELECT id animal_id,date(intake_date) entry_date FROM animals WHERE deleted_at IS NULL AND date(intake_date) BETWEEN :start AND :end' .
|
||||
self::idSql($ids, 'id') .
|
||||
'), entered AS (SELECT animal_id,MIN(entry_date) entry_date FROM entry_sources GROUP BY animal_id) ';
|
||||
$map = static function (string $sql) use ($db, $p): array {
|
||||
$s = $db->prepare($sql);
|
||||
$s->execute($p);
|
||||
$out = [];
|
||||
foreach ($s->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||
$out[(string) $r['label']] = (int) $r['total'];
|
||||
}
|
||||
return $out;
|
||||
};
|
||||
$sex = $map(
|
||||
$base .
|
||||
"SELECT CASE a.sex WHEN 'F' THEN 'Femelles' WHEN 'M' THEN 'Mâles' ELSE 'Inconnu' END label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY a.sex ORDER BY total DESC",
|
||||
);
|
||||
$ages = $map(
|
||||
$base .
|
||||
"SELECT CASE WHEN a.birth_date IS NULL THEN 'Non renseigné' WHEN (julianday(e.entry_date)-julianday(a.birth_date))/365.25<1 THEN 'Moins d’un an' WHEN (julianday(e.entry_date)-julianday(a.birth_date))/365.25<3 THEN '1 à 2 ans' WHEN (julianday(e.entry_date)-julianday(a.birth_date))/365.25<8 THEN '3 à 7 ans' ELSE '8 ans et plus' END label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC",
|
||||
);
|
||||
$deathCauses = $map(
|
||||
"SELECT cause_code label,COUNT(*) total FROM animal_deaths WHERE date(COALESCE(deceased_date,printf('%04d-01-01',death_year))) BETWEEN :start AND :end" .
|
||||
$in .
|
||||
' GROUP BY cause_code ORDER BY total DESC',
|
||||
);
|
||||
$stays = $map(
|
||||
"SELECT CASE WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<30 THEN 'Moins d’un mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<90 THEN '1 à 3 mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<180 THEN '3 à 6 mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<365 THEN '6 à 12 mois' ELSE 'Plus d’un an' END label,COUNT(*) total FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE date(ad.adoption_date) BETWEEN :start AND :end AND date(ad.adoption_date)>=date(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))" .
|
||||
self::idSql($ids, 'a.id') .
|
||||
' GROUP BY label ORDER BY total DESC',
|
||||
);
|
||||
return compact('sex', 'ages', 'deathCauses', 'stays');
|
||||
}
|
||||
|
||||
public static function distributions(?int $year, ?array $ids = null, ?string $municipality = null): array
|
||||
{
|
||||
$db = DB::pdo();
|
||||
$p = $year === null ? [] : [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)];
|
||||
$rows = static function (string $sql, array $params = []) use ($db): array {
|
||||
$s = $db->prepare($sql);
|
||||
$s->execute($params);
|
||||
$out = [];
|
||||
foreach ($s->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||
$out[(string) $r['label']] = (int) $r['total'];
|
||||
}
|
||||
return $out;
|
||||
};
|
||||
$movementDate = $year === null ? '' : ' AND date(m.created_at) BETWEEN :start AND :end';
|
||||
$animalDate = $year === null ? '' : ' AND date(intake_date) BETWEEN :start AND :end';
|
||||
$datedAnimal = $year === null ? '' : " AND NULLIF(TRIM(intake_date),'') IS NOT NULL";
|
||||
$entered =
|
||||
"WITH entry_sources AS (SELECT m.animal_id,date(m.created_at) entry_date FROM animal_movements m JOIN animals source_animal ON source_animal.id=m.animal_id WHERE source_animal.deleted_at IS NULL AND m.kind='entry'" .
|
||||
$movementDate .
|
||||
self::idSql($ids, 'm.animal_id') .
|
||||
' UNION SELECT id animal_id,date(intake_date) entry_date FROM animals WHERE deleted_at IS NULL' .
|
||||
$datedAnimal .
|
||||
$animalDate .
|
||||
self::idSql($ids, 'id') .
|
||||
'), entered AS (SELECT animal_id,MIN(entry_date) entry_date FROM entry_sources GROUP BY animal_id) ';
|
||||
$colors = $rows(
|
||||
$entered .
|
||||
"SELECT COALESCE(NULLIF(TRIM(a.color),''),'Non renseignée') label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC,label",
|
||||
$p,
|
||||
);
|
||||
$breeds = $rows(
|
||||
$entered .
|
||||
"SELECT COALESCE(NULLIF(TRIM(a.breed),''),'Non renseignée') label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC,label",
|
||||
$p,
|
||||
);
|
||||
$species = $rows(
|
||||
$entered .
|
||||
"SELECT CASE lower(a.species) WHEN 'cat' THEN 'Chats' WHEN 'chat' THEN 'Chats' WHEN 'dog' THEN 'Chiens' WHEN 'chien' THEN 'Chiens' ELSE COALESCE(NULLIF(TRIM(a.species),''),'Inconnue') END label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC",
|
||||
$p,
|
||||
);
|
||||
$intakeTypes = $rows(
|
||||
$entered .
|
||||
"SELECT COALESCE(NULLIF(a.intake_type,''),'unknown') label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC",
|
||||
$p,
|
||||
);
|
||||
$intakeReasons = $rows(
|
||||
$entered .
|
||||
"SELECT CASE WHEN a.intake_owner_care_home=1 THEN 'owner_care_home' ELSE COALESCE(NULLIF(a.intake_reason,''),'unknown') END label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC",
|
||||
$p,
|
||||
);
|
||||
if ($year === null) {
|
||||
$animalFilter = self::idSql($ids, 'a.id');
|
||||
$details = [];
|
||||
$details['sex'] = $rows(
|
||||
"SELECT CASE a.sex WHEN 'F' THEN 'Femelles' WHEN 'M' THEN 'Mâles' ELSE 'Inconnu' END label,COUNT(*) total FROM animals a WHERE a.deleted_at IS NULL" .
|
||||
$animalFilter .
|
||||
' GROUP BY a.sex ORDER BY total DESC',
|
||||
);
|
||||
$details['ages'] = $rows(
|
||||
"SELECT CASE WHEN a.birth_date IS NULL OR NULLIF(TRIM(a.intake_date),'') IS NULL THEN 'Non renseigné' WHEN (julianday(a.intake_date)-julianday(a.birth_date))/365.25<1 THEN 'Moins d’un an' WHEN (julianday(a.intake_date)-julianday(a.birth_date))/365.25<3 THEN '1 à 2 ans' WHEN (julianday(a.intake_date)-julianday(a.birth_date))/365.25<8 THEN '3 à 7 ans' ELSE '8 ans et plus' END label,COUNT(*) total FROM animals a WHERE a.deleted_at IS NULL" .
|
||||
$animalFilter .
|
||||
' GROUP BY label ORDER BY total DESC',
|
||||
);
|
||||
$details['deathCauses'] = $rows(
|
||||
"SELECT COALESCE(NULLIF(d.cause_code,''),'unknown') label,COUNT(*) total FROM animal_deaths d JOIN animals a ON a.id=d.animal_id WHERE a.deleted_at IS NULL" .
|
||||
self::idSql($ids, 'd.animal_id') .
|
||||
' GROUP BY label ORDER BY total DESC',
|
||||
);
|
||||
$details['stays'] = $rows(
|
||||
"SELECT CASE WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<30 THEN 'Moins d’un mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<90 THEN '1 à 3 mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<180 THEN '3 à 6 mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<365 THEN '6 à 12 mois' ELSE 'Plus d’un an' END label,COUNT(*) total FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE a.deleted_at IS NULL AND ad.adoption_date IS NOT NULL AND date(ad.adoption_date)>=date(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))" .
|
||||
$animalFilter .
|
||||
' GROUP BY label ORDER BY total DESC',
|
||||
);
|
||||
$origins = $municipality !== null ? [$municipality => (int) array_sum($species)] : self::municipalities();
|
||||
} elseif ($ids !== null) {
|
||||
$details = self::details($year, $ids);
|
||||
$origins = $municipality !== null ? [$municipality => (int) array_sum($species)] : [];
|
||||
} else {
|
||||
$details = GrantService::detailedStatistics($year);
|
||||
$origins = $details['origins'];
|
||||
}
|
||||
foreach ($details as &$items) {
|
||||
arsort($items, SORT_NUMERIC);
|
||||
}
|
||||
unset($items);
|
||||
$filter = self::idSql($ids, 'id');
|
||||
$currentStatus = $rows(
|
||||
"SELECT CASE status WHEN 'refuge' THEN 'Refuge' WHEN 'fa' THEN 'Famille d’accueil' WHEN 'fa_permanente' THEN 'Accueil permanent' WHEN 'chat_libre' THEN 'Chats libres suivis' WHEN 'quarantaine' THEN 'Quarantaine' WHEN 'soin' THEN 'Infirmerie' WHEN 'reserve' THEN 'Réservé' ELSE status END label,COUNT(*) total FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND lower(status) NOT IN ('adopte','adopté','decede','décédé')" .
|
||||
$filter .
|
||||
' GROUP BY status ORDER BY total DESC',
|
||||
);
|
||||
$freeCatSites = $rows(
|
||||
"SELECT COALESCE(NULLIF(TRIM(free_cat_site_name),''),NULLIF(TRIM(free_cat_city),''),'Site non renseigné') label,COUNT(*) total FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND status='chat_libre'" .
|
||||
$filter .
|
||||
' GROUP BY label ORDER BY total DESC,label',
|
||||
);
|
||||
$releaseSql =
|
||||
$year === null
|
||||
? "SELECT substr(free_cat_released_at,1,4) label,COUNT(*) total FROM animals WHERE deleted_at IS NULL AND free_cat_released_at IS NOT NULL AND trim(free_cat_released_at)<>''" .
|
||||
$filter .
|
||||
' GROUP BY label ORDER BY label DESC'
|
||||
: "SELECT COALESCE(NULLIF(TRIM(free_cat_site_name),''),NULLIF(TRIM(free_cat_city),''),'Site non renseigné') label,COUNT(*) total FROM animals WHERE deleted_at IS NULL AND date(free_cat_released_at) BETWEEN :start AND :end" .
|
||||
$filter .
|
||||
' GROUP BY label ORDER BY total DESC,label';
|
||||
$freeCatReleases = $rows($releaseSql, $p);
|
||||
return [
|
||||
'colors' => $colors,
|
||||
'breeds' => $breeds,
|
||||
'species' => $species,
|
||||
'intakeTypes' => $intakeTypes,
|
||||
'intakeReasons' => $intakeReasons,
|
||||
'sex' => $details['sex'],
|
||||
'ages' => $details['ages'],
|
||||
'origins' => $origins,
|
||||
'deathCauses' => $details['deathCauses'],
|
||||
'stays' => $details['stays'],
|
||||
'currentStatus' => $currentStatus,
|
||||
'freeCatSites' => $freeCatSites,
|
||||
'freeCatReleases' => $freeCatReleases,
|
||||
];
|
||||
}
|
||||
|
||||
public static function currentHealth(?array $ids = null): array
|
||||
{
|
||||
$sql =
|
||||
"SELECT COUNT(*) total,SUM(CASE WHEN status='chat_libre' THEN 1 ELSE 0 END) free_cats,SUM(CASE WHEN sterilization_status='yes' THEN 1 ELSE 0 END) sterilized,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) sterilization_unknown,SUM(CASE WHEN COALESCE(NULLIF(TRIM(chip_id),''),NULLIF(TRIM(chip),'')) IS NOT NULL THEN 1 ELSE 0 END) chipped,SUM(CASE WHEN fiv_status='positive' THEN 1 ELSE 0 END) fiv_positive,SUM(CASE WHEN felv_status='positive' THEN 1 ELSE 0 END) felv_positive FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND lower(status) NOT IN ('adopte','adopté','decede','décédé')" .
|
||||
self::idSql($ids, 'id');
|
||||
$row = DB::pdo()->query($sql)->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
return array_map('intval', $row);
|
||||
}
|
||||
}
|
||||
207
app/Services/SystemHealthService.php
Normal file
207
app/Services/SystemHealthService.php
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class SystemHealthService
|
||||
{
|
||||
public static function report(): array
|
||||
{
|
||||
$checks = [];
|
||||
$root = dirname(__DIR__, 2);
|
||||
$db = DB::pdo();
|
||||
foreach (InstallationService::runtimeChecks() as $c) {
|
||||
$checks[] = self::check(
|
||||
$c['ok'] ? 'ok' : ($c['level'] === 'required' ? 'error' : 'warning'),
|
||||
'environment',
|
||||
$c['label'],
|
||||
$c['detail'],
|
||||
);
|
||||
}
|
||||
try {
|
||||
$integrity = (string) $db->query('PRAGMA integrity_check')->fetchColumn();
|
||||
$checks[] = self::check(
|
||||
$integrity === 'ok' ? 'ok' : 'error',
|
||||
'database',
|
||||
t('health.sqlite_integrity'),
|
||||
$integrity === 'ok' ? t('health.sqlite_ok') : $integrity,
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
$checks[] = self::check('error', 'database', t('health.sqlite_integrity'), $e->getMessage());
|
||||
}
|
||||
try {
|
||||
$fk = $db->query('PRAGMA foreign_key_check')->fetchAll(PDO::FETCH_ASSOC);
|
||||
$checks[] = self::check(
|
||||
!$fk ? 'ok' : 'error',
|
||||
'database',
|
||||
t('health.foreign_keys'),
|
||||
!$fk ? t('health.no_fk_error') : t('health.fk_errors', ['count' => count($fk)]),
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
$checks[] = self::check('error', 'database', t('health.foreign_keys'), $e->getMessage());
|
||||
}
|
||||
$migrationFiles = array_map('basename', glob($root . '/migrations/*.sql') ?: []);
|
||||
$applied = $db->query('SELECT name FROM _migrations')->fetchAll(PDO::FETCH_COLUMN);
|
||||
$pending = array_values(array_diff($migrationFiles, $applied));
|
||||
$checks[] = self::check(
|
||||
!$pending ? 'ok' : 'warning',
|
||||
'database',
|
||||
t('health.migrations'),
|
||||
!$pending
|
||||
? t('health.migrations_ok', ['count' => count($applied)])
|
||||
: t('health.migrations_pending', ['count' => count($pending)]),
|
||||
);
|
||||
$dbFile = $root . '/data/refuge.sqlite';
|
||||
$checks[] = self::check(
|
||||
is_file($dbFile) && is_readable($dbFile) ? 'ok' : 'error',
|
||||
'storage',
|
||||
t('health.database_file'),
|
||||
is_file($dbFile) ? self::bytes((int) filesize($dbFile)) : t('health.file_missing'),
|
||||
);
|
||||
$free = @disk_free_space($root);
|
||||
$status =
|
||||
$free === false
|
||||
? 'warning'
|
||||
: ($free >= 1_073_741_824
|
||||
? 'ok'
|
||||
: ($free >= 268_435_456
|
||||
? 'warning'
|
||||
: 'error'));
|
||||
$checks[] = self::check(
|
||||
$status,
|
||||
'storage',
|
||||
t('health.disk_space'),
|
||||
$free === false ? t('health.unknown') : self::bytes((int) $free),
|
||||
);
|
||||
$backups = BackupService::list();
|
||||
if (!$backups) {
|
||||
$checks[] = self::check('error', 'continuity', t('health.last_backup'), t('health.no_backup'));
|
||||
} else {
|
||||
$latest = $backups[0];
|
||||
$date = DateTimeImmutable::createFromFormat(
|
||||
'd/m/Y H:i:s',
|
||||
(string) $latest['date'],
|
||||
new DateTimeZone('Europe/Paris'),
|
||||
);
|
||||
$days = $date
|
||||
? (int) $date->diff(new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')))->format('%a')
|
||||
: 999;
|
||||
$checks[] = self::check(
|
||||
$days <= 7 ? 'ok' : ($days <= 30 ? 'warning' : 'error'),
|
||||
'continuity',
|
||||
t('health.last_backup'),
|
||||
t('health.backup_age', ['date' => $latest['date'], 'days' => $days]),
|
||||
);
|
||||
}
|
||||
[$missing, $orphans] = self::photoConsistency($db, $root);
|
||||
$checks[] = self::check(
|
||||
$missing === 0 ? 'ok' : 'warning',
|
||||
'files',
|
||||
t('health.missing_photos'),
|
||||
t('health.file_count', ['count' => $missing]),
|
||||
);
|
||||
$checks[] = self::check(
|
||||
$orphans === 0 ? 'ok' : 'warning',
|
||||
'files',
|
||||
t('health.orphan_photos'),
|
||||
t('health.file_count', ['count' => $orphans]),
|
||||
);
|
||||
$display = (string) ini_get('display_errors');
|
||||
$production = getenv('APP_ENV') !== 'development';
|
||||
$checks[] = self::check(
|
||||
!$production || !in_array(strtolower($display), ['1', 'on', 'yes', 'true'], true) ? 'ok' : 'warning',
|
||||
'security',
|
||||
t('health.display_errors'),
|
||||
$production ? t('health.production_value', ['value' => $display ?: 'Off']) : t('health.development_mode'),
|
||||
);
|
||||
$logErrors = filter_var(ini_get('log_errors'), FILTER_VALIDATE_BOOL);
|
||||
$errorLog = trim((string) ini_get('error_log'));
|
||||
$checks[] = self::check(
|
||||
$logErrors ? 'ok' : 'warning',
|
||||
'security',
|
||||
t('health.error_log'),
|
||||
$logErrors ? ($errorLog ?: t('health.system_log')) : t('health.logging_disabled'),
|
||||
);
|
||||
$applicationLog = $root . '/storage/logs/php-errors.log';
|
||||
$recent = self::recentLogLines($applicationLog, 7);
|
||||
$checks[] = self::check(
|
||||
$recent === 0 ? 'ok' : 'warning',
|
||||
'security',
|
||||
t('health.recent_errors'),
|
||||
$recent ? t('health.recent_error_count', ['count' => $recent]) : t('health.no_recent_error'),
|
||||
);
|
||||
$summary = ['ok' => 0, 'warning' => 0, 'error' => 0];
|
||||
foreach ($checks as $c) {
|
||||
$summary[$c['status']]++;
|
||||
}
|
||||
return [
|
||||
'checks' => $checks,
|
||||
'summary' => $summary,
|
||||
'generated_at' => new DateTimeImmutable('now', new DateTimeZone('Europe/Paris'))->format('d/m/Y H:i:s'),
|
||||
'php' => PHP_VERSION,
|
||||
'sqlite' => (string) $db->query('SELECT sqlite_version()')->fetchColumn(),
|
||||
];
|
||||
}
|
||||
private static function photoConsistency(PDO $db, string $root): array
|
||||
{
|
||||
$expected = [];
|
||||
$missing = 0;
|
||||
try {
|
||||
foreach ($db->query('SELECT animal_id,filename FROM animal_photos')->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$path =
|
||||
$root .
|
||||
'/public/media/animals/' .
|
||||
(int) $row['animal_id'] .
|
||||
'/' .
|
||||
basename((string) $row['filename']);
|
||||
$expected[$path] = true;
|
||||
if (!is_file($path)) {
|
||||
$missing++;
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
return [0, 0];
|
||||
}
|
||||
$orphans = 0;
|
||||
foreach (glob($root . '/public/media/animals/*/*') ?: [] as $file) {
|
||||
if (is_file($file) && !isset($expected[$file]) && !str_starts_with(basename($file), '.')) {
|
||||
$orphans++;
|
||||
}
|
||||
}
|
||||
return [$missing, $orphans];
|
||||
}
|
||||
private static function check(string $status, string $group, string $title, string $detail): array
|
||||
{
|
||||
return compact('status', 'group', 'title', 'detail');
|
||||
}
|
||||
private static function recentLogLines(string $path, int $days): int
|
||||
{
|
||||
if (!is_file($path) || filesize($path) === 0) {
|
||||
return 0;
|
||||
}
|
||||
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||
$dates = [];
|
||||
for ($i = 0; $i < $days; $i++) {
|
||||
$dates[] = new DateTimeImmutable("-$i days")->format('d-M-Y');
|
||||
}
|
||||
$count = 0;
|
||||
foreach (array_slice($lines, -1000) as $line) {
|
||||
foreach ($dates as $date) {
|
||||
if (str_contains($line, $date)) {
|
||||
$count++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
private static function bytes(int $bytes): string
|
||||
{
|
||||
foreach (['o', 'Ko', 'Mo', 'Go', 'To'] as $unit) {
|
||||
if ($bytes < 1024 || $unit === 'To') {
|
||||
return number_format($bytes, $unit === 'o' ? 0 : 1, ',', ' ') . ' ' . $unit;
|
||||
}
|
||||
$bytes = (int) round($bytes / 1024);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
144
app/Services/TrustedDeviceService.php
Normal file
144
app/Services/TrustedDeviceService.php
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class TrustedDeviceService
|
||||
{
|
||||
private const COOKIE = 'globinours_device';
|
||||
public static function create(int $userId, string $name = ''): void
|
||||
{
|
||||
$selector = bin2hex(random_bytes(9));
|
||||
$token = random_bytes(32);
|
||||
$expires = time() + 60 * 60 * 24 * 90;
|
||||
$name = trim($name) ?: self::deviceLabel();
|
||||
DB::pdo()
|
||||
->prepare(
|
||||
'INSERT INTO trusted_devices(user_id,selector,token_hash,device_name,user_agent,ip_address,expires_at) VALUES(?,?,?,?,?,?,?)',
|
||||
)
|
||||
->execute([
|
||||
$userId,
|
||||
$selector,
|
||||
hash('sha256', $token),
|
||||
mb_substr($name, 0, 100),
|
||||
mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 500) ?: null,
|
||||
$_SERVER['REMOTE_ADDR'] ?? null,
|
||||
date('Y-m-d H:i:s', $expires),
|
||||
]);
|
||||
self::cookie($selector . '.' . bin2hex($token), $expires);
|
||||
}
|
||||
public static function authenticate(): ?int
|
||||
{
|
||||
$raw = (string) ($_COOKIE[self::COOKIE] ?? '');
|
||||
if (!preg_match('/^([a-f0-9]{18})\.([a-f0-9]{64})$/', $raw, $m)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$s = DB::pdo()->prepare(
|
||||
"SELECT td.*,u.active FROM trusted_devices td JOIN users u ON u.id=td.user_id WHERE td.selector=? AND td.revoked_at IS NULL AND datetime(td.expires_at)>datetime('now') AND (td.last_used_at IS NULL OR datetime(td.last_used_at)>datetime('now','-30 days'))",
|
||||
);
|
||||
$s->execute([$m[1]]);
|
||||
$row = $s->fetch(PDO::FETCH_ASSOC);
|
||||
if (
|
||||
!$row ||
|
||||
!hash_equals((string) $row['token_hash'], hash('sha256', hex2bin($m[2]) ?: '')) ||
|
||||
(int) $row['active'] !== 1
|
||||
) {
|
||||
self::forget();
|
||||
return null;
|
||||
}
|
||||
$new = random_bytes(32);
|
||||
DB::pdo()
|
||||
->prepare(
|
||||
"UPDATE trusted_devices SET token_hash=?,last_used_at=datetime('now'),ip_address=? WHERE id=?",
|
||||
)
|
||||
->execute([hash('sha256', $new), $_SERVER['REMOTE_ADDR'] ?? null, $row['id']]);
|
||||
self::cookie($m[1] . '.' . bin2hex($new), strtotime((string) $row['expires_at']));
|
||||
return (int) $row['user_id'];
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
public static function currentSelector(): ?string
|
||||
{
|
||||
$raw = (string) ($_COOKIE[self::COOKIE] ?? '');
|
||||
return preg_match('/^([a-f0-9]{18})\./', $raw, $m) ? $m[1] : null;
|
||||
}
|
||||
public static function revokeCurrent(): void
|
||||
{
|
||||
$selector = self::currentSelector();
|
||||
if ($selector) {
|
||||
try {
|
||||
DB::pdo()
|
||||
->prepare("UPDATE trusted_devices SET revoked_at=datetime('now') WHERE selector=?")
|
||||
->execute([$selector]);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
self::forget();
|
||||
}
|
||||
public static function devices(?int $userId = null): array
|
||||
{
|
||||
$userId ??= Auth::id();
|
||||
$s = DB::pdo()->prepare(
|
||||
'SELECT * FROM trusted_devices WHERE user_id=? ORDER BY revoked_at IS NULL DESC,last_used_at DESC',
|
||||
);
|
||||
$s->execute([$userId]);
|
||||
return $s->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
public static function revoke(int $id, int $userId, bool $admin = false): void
|
||||
{
|
||||
$sql =
|
||||
'UPDATE trusted_devices SET revoked_at=datetime(\'now\') WHERE id=:id' .
|
||||
($admin ? '' : ' AND user_id=:uid');
|
||||
$params = [':id' => $id];
|
||||
if (!$admin) {
|
||||
$params[':uid'] = $userId;
|
||||
}
|
||||
DB::pdo()->prepare($sql)->execute($params);
|
||||
if (
|
||||
self::currentSelector() &&
|
||||
self::currentSelector() ===
|
||||
(string) DB::pdo()
|
||||
->query('SELECT selector FROM trusted_devices WHERE id=' . (int) $id)
|
||||
->fetchColumn()
|
||||
) {
|
||||
self::forget();
|
||||
}
|
||||
}
|
||||
private static function deviceLabel(): string
|
||||
{
|
||||
$ua = strtolower((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''));
|
||||
if (str_contains($ua, 'globinoursandroid')) {
|
||||
return 'Application Android';
|
||||
}
|
||||
if (str_contains($ua, 'android')) {
|
||||
return 'Smartphone ou tablette Android';
|
||||
}
|
||||
if (str_contains($ua, 'iphone') || str_contains($ua, 'ipad')) {
|
||||
return 'iPhone ou iPad';
|
||||
}
|
||||
return 'Navigateur web';
|
||||
}
|
||||
private static function cookie(string $value, int $expires): void
|
||||
{
|
||||
setcookie(self::COOKIE, $value, [
|
||||
'expires' => $expires,
|
||||
'path' => '/',
|
||||
'secure' => Auth::isSecureRequest(),
|
||||
'httponly' => true,
|
||||
'samesite' => 'Strict',
|
||||
]);
|
||||
$_COOKIE[self::COOKIE] = $value;
|
||||
}
|
||||
private static function forget(): void
|
||||
{
|
||||
setcookie(self::COOKIE, '', [
|
||||
'expires' => time() - 3600,
|
||||
'path' => '/',
|
||||
'secure' => Auth::isSecureRequest(),
|
||||
'httponly' => true,
|
||||
'samesite' => 'Strict',
|
||||
]);
|
||||
unset($_COOKIE[self::COOKIE]);
|
||||
}
|
||||
}
|
||||
82
app/Views/_animal_show_activity.php
Normal file
82
app/Views/_animal_show_activity.php
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
// Sous-vue de la fiche animale ; les variables sont préparées par AnimalsController::show().
|
||||
?>
|
||||
<!-- ACTIVITÉ -->
|
||||
<div class="tab-pane" id="tab-activity">
|
||||
<?php /** @var array $activity */ ?>
|
||||
<?php if (empty($activity)): ?>
|
||||
<div class="text-muted"><?= h(t('animal.no_activity')) ?></div>
|
||||
<?php else: ?>
|
||||
|
||||
<?php
|
||||
$actMeta = [
|
||||
'history' => ['label' => t('animal.system'), 'iconBg' => 'bg-blue', 'icon' => 'history', 'badge' => 'bg-blue-lt'],
|
||||
'medical' => ['label' => t('animal.tab.medical'), 'iconBg' => 'bg-red', 'icon' => 'heart', 'badge' => 'bg-red-lt'],
|
||||
'vaccine' => ['label' => t('animal.vaccine'), 'iconBg' => 'bg-yellow', 'icon' => 'shield-check', 'badge' => 'bg-yellow-lt'],
|
||||
'deworming' => ['label' => t('animals.dewormer'), 'iconBg' => 'bg-lime', 'icon' => 'pill', 'badge' => 'bg-lime-lt'],
|
||||
'treatment' => ['label' => t('animal.treatment'), 'iconBg' => 'bg-orange', 'icon' => 'pill', 'badge' => 'bg-orange-lt'],
|
||||
'weight' => ['label' => t('common.weight'), 'iconBg' => 'bg-indigo', 'icon' => 'scale', 'badge' => 'bg-indigo-lt'],
|
||||
'care_round' => ['label' => t('animal.care_round'), 'iconBg' => 'bg-red', 'icon' => 'alert-triangle','badge' => 'bg-red-lt'],
|
||||
'death' => ['label' => t('status.decede'), 'iconBg' => 'bg-dark', 'icon' => 'rainbow', 'badge' => 'bg-dark-lt'],
|
||||
];
|
||||
?>
|
||||
|
||||
<ul class="list list-timeline" style="list-style-type:none;">
|
||||
<?php foreach ($activity as $a):
|
||||
$kind = (string)($a['kind'] ?? 'history');
|
||||
if (!$canViewMedical && in_array($kind, ['medical','vaccine','treatment','deworming','weight','care_round','death'], true)) {
|
||||
continue;
|
||||
}
|
||||
$meta = $actMeta[$kind] ?? ['label' => ucfirst($kind), 'iconBg' => 'bg-muted', 'icon' => 'circle', 'badge' => 'bg-muted-lt'];
|
||||
|
||||
$lbl = (string)($a['label'] ?? '');
|
||||
$details = (string)($a['details'] ?? '');
|
||||
$at = (string)($a['at'] ?? '');
|
||||
?>
|
||||
<li>
|
||||
<!-- icône timeline (gauche) -->
|
||||
<div class="list-timeline-icon <?= h($meta['iconBg']) ?>">
|
||||
<i class="ti ti-<?= h($meta['icon']) ?>"></i>
|
||||
</div>
|
||||
|
||||
<div class="list-timeline-content">
|
||||
<div class="d-flex align-items-start justify-content-between">
|
||||
|
||||
<div class="d-flex align-items-center gap-3 flex-grow-1">
|
||||
|
||||
<!-- BADGE FIXE aligné -->
|
||||
<div style="min-width:110px">
|
||||
<span class="badge <?= h($meta['badge']) ?>">
|
||||
<?= strtoupper(h($meta['label'])) ?>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- TITRE -->
|
||||
<div class="fw-bold">
|
||||
<?= h($lbl) ?>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- DATE -->
|
||||
<div class="text-muted small text-end" style="min-width:140px">
|
||||
<?= h($at) ?>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<?php //if ($details !== ''):?>
|
||||
<!-- <div class="mt-2 ms-3"> -->
|
||||
<?php //echo nl2br(h($details));?>
|
||||
<!-- </div>-->
|
||||
<?php //endif;?>
|
||||
|
||||
|
||||
</div>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
48
app/Views/_animal_show_documents.php
Normal file
48
app/Views/_animal_show_documents.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
// Sous-vue de la fiche animale ; les variables sont préparées par AnimalsController::show().
|
||||
?>
|
||||
<!-- DOCUMENTS -->
|
||||
<?php if ($canViewMedical): ?>
|
||||
<div class="tab-pane" id="tab-documents">
|
||||
<div class="card card-sm">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h3 class="card-title mb-1"><?= h(t('animal.adoption_contract')) ?></h3>
|
||||
<div class="text-muted small"><?= h(t('animal.generated_copy_help')) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3 align-items-center">
|
||||
<div class="col-md">
|
||||
<div class="fw-bold"><?= h(t('animal.prefilled_information')) ?></div>
|
||||
<div class="text-muted small"><?= h(t('animal.adoption_prefill')) ?></div>
|
||||
<?php if (!empty($adoption)): ?><div class="text-success small mt-1">✓
|
||||
<?= h(t('animal.linked_adopter', ['name' => $adoption['adopter_name'] ?? ''])) ?></div>
|
||||
<?php else: ?><div class="text-warning small mt-1">⚠ <?= h(t('animal.no_linked_adopter')) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="col-md-auto d-flex flex-wrap gap-2"><a class="btn btn-outline-primary"
|
||||
href="/animal/documents/adoption?animal_id=<?= (int)$animal['id'] ?>&format=odt"><?= h(t('animal.download_odt')) ?></a><a
|
||||
class="btn btn-danger"
|
||||
href="/animal/documents/adoption?animal_id=<?= (int)$animal['id'] ?>&format=pdf"
|
||||
target="_blank"><?= h(t('animal.view_print_pdf')) ?></a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card card-sm mt-3">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h3 class="card-title mb-1"><?= h(t('animal.abandon_found')) ?></h3>
|
||||
<div class="text-muted small"><?= h(t('animal.known_info_added')) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body d-flex flex-wrap align-items-center gap-2">
|
||||
<div class="me-auto text-muted small"><?= h(t('animal.identity_summary')) ?></div><a
|
||||
class="btn btn-outline-primary"
|
||||
href="/animal/documents/abandon?animal_id=<?= (int)$animal['id'] ?>&format=odt"><?= h(t('animal.download_odt')) ?></a><a
|
||||
class="btn btn-danger" href="/animal/documents/abandon?animal_id=<?= (int)$animal['id'] ?>&format=pdf"
|
||||
target="_blank"><?= h(t('animal.view_print_pdf')) ?></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
31
app/Views/_animal_show_history.php
Normal file
31
app/Views/_animal_show_history.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
// Sous-vue de la fiche animale ; les variables sont préparées par AnimalsController::show().
|
||||
?>
|
||||
<!-- HISTORIQUE -->
|
||||
<div class="tab-pane" id="tab-hist">
|
||||
<?php $visibleHistory = $canViewMedical ? $history : array_values(array_filter($history, static fn (array $row): bool => !in_array((string)($row['type'] ?? ''), ['medical','treatment','vaccine','deworming','death'], true))); ?>
|
||||
<?php if (empty($visibleHistory)): ?>
|
||||
<div class="text-muted"><?= h(t('animal.no_history_event')) ?></div>
|
||||
<?php else: ?>
|
||||
<ul class="list list-timeline">
|
||||
<?php foreach ($visibleHistory as $h):
|
||||
$style = $historyStyles[$h['type']] ?? ['bg' => 'bg-muted','icon' => 'circle'];
|
||||
?>
|
||||
<li>
|
||||
<div class="list-timeline-icon <?= $style['bg'] ?>">
|
||||
<i class="ti ti-<?= $style['icon'] ?>"></i>
|
||||
</div>
|
||||
<div class="list-timeline-content">
|
||||
<div class="fw-bold"><?= h($h['label']) ?></div>
|
||||
<div class="text-muted small"><?= h($h['created_at']) ?></div>
|
||||
<?php if (!empty($h['actor_name'])): ?><div class="text-muted small">
|
||||
<?= h(t('common.by', ['name' => $h['actor_name']])) ?></div><?php endif; ?>
|
||||
<?php if (!empty($h['details'])): ?>
|
||||
<div class="mt-1"><?= nl2br(h($h['details'])) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
459
app/Views/_animal_show_identity.php
Normal file
459
app/Views/_animal_show_identity.php
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
<?php
|
||||
// Sous-vue de la fiche animale ; les variables sont préparées par AnimalsController::show().
|
||||
?>
|
||||
<!-- IDENTITÉ -->
|
||||
<div class="tab-pane active show" id="tab-ident">
|
||||
|
||||
<?php if ($canViewMedical && !empty($death)): ?>
|
||||
<?php
|
||||
$deathCauseLabels = array_combine(array_keys(DeathController::CAUSES), array_map(static fn ($key) => DeathController::causeLabel($key), array_keys(DeathController::CAUSES)));
|
||||
$deathPlaceLabels = array_combine(array_keys(DeathController::PLACES), array_map(static fn ($key) => DeathController::placeLabel($key), array_keys(DeathController::PLACES)));
|
||||
$deathDispositionLabels = array_combine(array_keys(DeathController::DISPOSITIONS), array_map(static fn ($key) => DeathController::dispositionLabel($key), array_keys(DeathController::DISPOSITIONS)));
|
||||
$deathDate = DateTimeImmutable::createFromFormat('Y-m-d', (string)$death['deceased_date']);
|
||||
$deathDateLabel = !empty($death['death_year']) ? t('animal.year', ['year' => (int)$death['death_year']]) : ($deathDate ? $deathDate->format('d/m/Y') : t('animal.date_unknown'));
|
||||
?>
|
||||
<div class="card border-dark mb-3 overflow-hidden">
|
||||
<div class="card-status-start bg-dark"></div>
|
||||
<div class="card-header d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h3 class="card-title mb-1">🌈 <?= h(t('animal.death_recorded')) ?></h3>
|
||||
<div class="text-muted small"><?= h($deathDateLabel) ?> ·
|
||||
<?= h($deathCauseLabels[$death['cause_code']] ?? $death['cause_code']) ?></div>
|
||||
</div><a class="btn btn-outline-dark btn-sm"
|
||||
href="/animal/death?id=<?= (int)$animal['id'] ?>"><?= h(t('common.edit')) ?></a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<div class="subheader"><?= h(t('animal.statistics')) ?></div><span
|
||||
class="badge <?= !empty($death['occurred_in_care']) ? 'bg-dark' : 'bg-secondary-lt' ?>"><?= h(t(!empty($death['occurred_in_care']) ? 'animal.in_shelter_figures' : 'animal.outside_care')) ?></span>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="subheader"><?= h(t('death.place')) ?></div>
|
||||
<strong><?= h($deathPlaceLabels[$death['place_type']] ?? $death['place_type']) ?></strong><?php if ($death['place_details']): ?>
|
||||
<div class="small text-muted"><?= h($death['place_details']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="subheader"><?= h(t('death.care_body')) ?></div>
|
||||
<strong><?= h($deathDispositionLabels[$death['body_disposition']] ?? $death['body_disposition']) ?></strong><?php if ($death['crematorium_name']): ?>
|
||||
<div class="small text-muted"><?= h($death['crematorium_name']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="subheader"><?= h(t('animal.context')) ?></div>
|
||||
<?php if (!empty($death['euthanized'])): ?><span
|
||||
class="badge bg-purple-lt"><?= h(t('animal.euthanasia')) ?></span><?php else: ?><span
|
||||
class="text-muted"><?= h(t('animal.natural_death_short')) ?></span><?php endif; ?><?php if ($death['veterinarian_name']): ?>
|
||||
<div class="small text-muted"><?= h($death['veterinarian_name']) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<?php if ($death['cause_details'] || $death['notes']): ?><div class="col-12 small">
|
||||
<?php if ($death['cause_details']): ?><strong><?= h(t('animal.cause')) ?> :</strong>
|
||||
<?= nl2br(h($death['cause_details'])) ?><?php endif; ?><?php if ($death['notes']): ?><div
|
||||
class="mt-1"><strong><?= h(t('common.notes')) ?> :</strong> <?= nl2br(h($death['notes'])) ?>
|
||||
</div><?php endif; ?></div><?php endif; ?>
|
||||
<?php if (!empty($death['death_age_value'])): ?><div class="col-12 small">
|
||||
<strong><?= h(t('animal.death_age')) ?></strong> <?= (int)$death['death_age_value'] ?> <?= h(match($death['death_age_unit']) {
|
||||
'day' => t('animal.age_days'),'week' => t('animal.age_weeks'),'month' => t('animal.age_months'),'year' => t('animal.age_years'),default => ''
|
||||
}) ?></div><?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($bondedGroup)): ?>
|
||||
<div class="card border-purple mb-3">
|
||||
<div class="card-status-start bg-purple"></div>
|
||||
<div class="card-header d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h3 class="card-title mb-1">🔗 <?= h($bondedGroup['name'] ?: t('animal.bonded_cats')) ?></h3>
|
||||
<div class="text-muted small"><?= h(t('animal.bonded_help')) ?></div>
|
||||
</div>
|
||||
<form method="post" action="/bonded/dissolve"
|
||||
onsubmit="return confirm(<?= h(json_encode(t('animal.bonded_dissolve_confirm'), JSON_HEX_APOS | JSON_HEX_QUOT)) ?>);">
|
||||
<input type="hidden" name="group_id" value="<?= (int)$bondedGroup['id'] ?>">
|
||||
<input type="hidden" name="animal_id" value="<?= (int)$animal['id'] ?>">
|
||||
<button class="btn btn-outline-secondary btn-sm"
|
||||
type="submit"><?= h(t('animal.dissolve_group')) ?></button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<?php if (!empty($bondedGroup['notes'])): ?><p class="text-muted mb-3">
|
||||
<?= nl2br(h($bondedGroup['notes'])) ?></p><?php endif; ?>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<?php foreach ($bondedMembers as $member): ?>
|
||||
<a class="btn <?= (int)$member['id'] === (int)$animal['id'] ? 'btn-purple' : 'btn-outline-purple' ?>"
|
||||
href="/animal?id=<?= (int)$member['id'] ?>">
|
||||
<span class="coat-swatch me-1"
|
||||
style="<?= h(coat_swatch_style($member['color'] ?? '')) ?>"></span><?= h($member['name']) ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
$lifeProfileMeta = [
|
||||
'yes' => [t('common.yes'),'bg-green-lt text-green','✓'],
|
||||
'no' => [t('common.no'),'bg-red-lt text-red','✕'],
|
||||
'unknown' => [t('common.unknown'),'bg-secondary-lt text-secondary','—'],
|
||||
];
|
||||
?>
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h3 class="card-title mb-1"><?= h(t('animal.form.intake')) ?></h3>
|
||||
<div class="text-muted small"><?= h(t('animal.intake_origin_help')) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="subheader"><?= h(t('common.date')) ?></div>
|
||||
<strong><?= h(!empty($animal['intake_date']) ? date('d/m/Y', strtotime((string)$animal['intake_date'])) : '—') ?></strong>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="subheader"><?= h(t('animal.intake_mode')) ?></div>
|
||||
<strong><?= h($intakeTypes[$animal['intake_type'] ?? 'unknown'] ?? t('common.not_provided')) ?></strong>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="subheader"><?= h(t('animal.reason')) ?></div>
|
||||
<strong><?= h($intakeReasons[$animal['intake_reason'] ?? 'unknown'] ?? t('common.not_provided')) ?></strong>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="subheader"><?= h(t('animal.origin_city')) ?></div>
|
||||
<strong><?= h(($animal['rescue_location_name'] ?? '') ?: '—') ?></strong>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="subheader"><?= h(t('animal.form.depositor')) ?></div><?php if ($depositor): ?><a
|
||||
class="fw-bold"
|
||||
href="/directory/edit?id=<?= (int)$depositor['id'] ?>"><?= h($depositor['name']) ?></a><?php if (!empty($depositor['city'])): ?><span
|
||||
class="text-muted"> · <?= h($depositor['city']) ?></span><?php endif; ?><?php else: ?><span
|
||||
class="text-muted"><?= h(t('animal.form.no_depositor')) ?></span><?php endif; ?>
|
||||
</div>
|
||||
<?php if (!empty($animal['intake_circumstances'])): ?><div class="col-12">
|
||||
<div class="subheader"><?= h(t('animal.form.circumstances')) ?></div>
|
||||
<div><?= nl2br(h($animal['intake_circumstances'])) ?></div>
|
||||
</div><?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h3 class="card-title mb-1"><?= h(t('animal.form.life_profile')) ?></h3>
|
||||
<div class="text-muted small"><?= h(t('animal.life_profile_help')) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<?php foreach ([
|
||||
'compatibility_dogs' => ['🐶',t('animals.dogs')],
|
||||
'compatibility_cats' => ['🐱',t('animals.cats')],
|
||||
'compatibility_children' => ['🧒',t('animals.children')],
|
||||
'house_trained' => ['🧼',t('animals.cleanliness')],
|
||||
] as $field => $profile): [$profileLabel,$profileClass,$profileSymbol] = $lifeProfileMeta[$animal[$field] ?? 'unknown'] ?? $lifeProfileMeta['unknown']; ?>
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="border rounded p-3 h-100 d-flex align-items-center gap-3"><span class="fs-2"
|
||||
aria-hidden="true"><?= $profile[0] ?></span>
|
||||
<div>
|
||||
<div class="text-muted small"><?= h($profile[1]) ?></div><span
|
||||
class="badge <?= h($profileClass) ?>"><?= h($profileSymbol . ' ' . $profileLabel) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php require __DIR__ . '/_expense_summary.php'; ?>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title m-0"><?= h(t('common.notes')) ?></h3>
|
||||
</div>
|
||||
<div class="card-body"><?= $animal['notes'] ? nl2br(h($animal['notes'])) : '—' ?></div>
|
||||
</div>
|
||||
|
||||
|
||||
<?php
|
||||
$rescueAddr = trim((string)($animal['rescue_address'] ?? ''));
|
||||
$rescueLat = isset($animal['rescue_lat']) && $animal['rescue_lat'] !== '' ? (float)$animal['rescue_lat'] : null;
|
||||
$rescueLng = isset($animal['rescue_lng']) && $animal['rescue_lng'] !== '' ? (float)$animal['rescue_lng'] : null;
|
||||
|
||||
$currentAddr = trim((string)($animal['current_address'] ?? '5 RUE NOTRE DAME 29260 LESNEVEN'));
|
||||
$currentLat = isset($animal['current_lat']) && $animal['current_lat'] !== '' ? (float)$animal['current_lat'] : null;
|
||||
$currentLng = isset($animal['current_lng']) && $animal['current_lng'] !== '' ? (float)$animal['current_lng'] : null;
|
||||
|
||||
$hasRescue = ($rescueLat !== null && $rescueLng !== null);
|
||||
$hasCurrent = ($currentLat !== null && $currentLng !== null);
|
||||
?>
|
||||
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header d-flex align-items-center justify-content-between">
|
||||
<h3 class="card-title m-0"><?= h(t('animal.location')) ?></h3>
|
||||
<div class="d-flex gap-2">
|
||||
<span class="badge bg-azure-lt"><?= h(t('animal.rescue')) ?></span>
|
||||
<span class="badge bg-lime-lt"><?= h(t('animal.current')) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="text-muted mb-1"><?= h(t('animal.rescue_place')) ?></div>
|
||||
<div class="fw-bold"><?= $rescueAddr !== '' ? h($rescueAddr) : '—' ?></div>
|
||||
<?php if (!$hasRescue): ?>
|
||||
<div class="text-muted small mt-1"><?= h(t('animal.coordinates_missing')) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="text-muted mb-1"><?= h(t('animal.form.current_location')) ?></div>
|
||||
<div class="fw-bold"><?= h($currentAddr) ?></div>
|
||||
|
||||
<?php if (!$hasCurrent): ?>
|
||||
<div class="text-muted small mt-1"><?= h(t('animal.coordinates_missing')) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<?php if ($animal['status'] === 'adopte'): ?>
|
||||
|
||||
<div class="alert alert-success mt-3">
|
||||
<strong><?= h(t('animal.adopted_by')) ?> :</strong><br>
|
||||
<?= htmlspecialchars($adoption['adopter_name'] . ' - ' . $adoption['adopter_address'] . ' ' . $adoption['adopter_postal_code'] . ' ' . $adoption['adopter_city'] . ' - (' . $adoption['adopter_phone'] . ' - ' . $adoption['adopter_email'] . ')') ?>
|
||||
</div>
|
||||
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="mt-3" id="mapJourney"
|
||||
style="height:320px;border-radius:10px;overflow:hidden; border:1px solid #ccc"></div>
|
||||
|
||||
<?php
|
||||
function haversine_km($lat1, $lon1, $lat2, $lon2)
|
||||
{
|
||||
$earthRadius = 6371; // km
|
||||
|
||||
$dLat = deg2rad($lat2 - $lat1);
|
||||
$dLon = deg2rad($lon2 - $lon1);
|
||||
|
||||
$a = sin($dLat / 2) * sin($dLat / 2) +
|
||||
cos(deg2rad($lat1)) * cos(deg2rad($lat2)) *
|
||||
sin($dLon / 2) * sin($dLon / 2);
|
||||
|
||||
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
|
||||
return $earthRadius * $c;
|
||||
}
|
||||
|
||||
$distanceKm = null;
|
||||
|
||||
if ($hasRescue && $hasCurrent) {
|
||||
$distanceKm = haversine_km(
|
||||
$rescueLat,
|
||||
$rescueLng,
|
||||
$currentLat,
|
||||
$currentLng,
|
||||
);
|
||||
}
|
||||
?>
|
||||
|
||||
<?php if ($distanceKm !== null): ?>
|
||||
<div class="mt-3 text-muted small">
|
||||
<?= h(t('animal.distance_story', ['name' => $animal['name'],'distance' => number_format($distanceKm, 2)])) ?><br>
|
||||
<em><?= h(t('animal.distance_caption')) ?></em>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<?php
|
||||
/** @var array|null $icad Structure = ['cached'=>..., 'stale'=>bool] comme ton endpoint */
|
||||
$cache = $icad['cached'] ?? null;
|
||||
$data = $cache['data'] ?? null;
|
||||
$status = $cache['status'] ?? 'empty';
|
||||
$fetched = $cache['fetched_at'] ?? '';
|
||||
$icadUrl = $data['icad_url'] ?? null;
|
||||
|
||||
$badge = match ($status) {
|
||||
'ok' => 'bg-success-lt',
|
||||
'error' => 'bg-danger-lt',
|
||||
default => 'bg-muted-lt',
|
||||
};
|
||||
|
||||
$label = match ($status) {
|
||||
'ok' => 'i-CAD : OK',
|
||||
'error' => 'i-CAD : Erreur',
|
||||
default => t('animal.icad_not_checked'),
|
||||
};
|
||||
|
||||
$err = (string)($cache['error'] ?? '');
|
||||
?>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header d-flex align-items-center justify-content-between">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="badge <?= $badge ?>"><?= h($label) ?></span>
|
||||
<?php if ($fetched): ?>
|
||||
<span class="text-muted small"><?= h(t('animal.last_sync', ['date' => $fetched])) ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<?php if ($icadUrl): ?>
|
||||
<a class="btn btn-sm btn-outline-primary" href="<?= h($icadUrl) ?>" target="_blank" rel="noopener">
|
||||
<?= h(t('animal.open_icad')) ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" id="btn-icad-refresh"
|
||||
data-animal-id="<?= (int)$animal['id'] ?>">
|
||||
<?= h(t('common.refresh')) ?>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body" id="icad-card-body">
|
||||
<?php if ($status === 'error'): ?>
|
||||
<div class="alert alert-danger">
|
||||
<strong><?= h(t('animal.icad_error')) ?></strong><br>
|
||||
<?= h($err) ?>
|
||||
</div>
|
||||
<?php elseif ($status !== 'ok' || !$data): ?>
|
||||
<div class="text-muted"><?= h(t('animal.no_icad')) ?></div>
|
||||
<?php else: ?>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="datagrid">
|
||||
<div class="datagrid-item">
|
||||
<div class="datagrid-title"><?= h(t('animal.icad_name')) ?></div>
|
||||
<div class="datagrid-content"><?= h($data['name'] ?? '') ?></div>
|
||||
</div>
|
||||
<div class="datagrid-item">
|
||||
<div class="datagrid-title">Puce</div>
|
||||
<div class="datagrid-content"><?= h($data['insert'] ?? '') ?></div>
|
||||
</div>
|
||||
<div class="datagrid-item">
|
||||
<div class="datagrid-title"><?= h(t('animal.sex')) ?></div>
|
||||
<div class="datagrid-content"><?= h($data['sex'] ?? '') ?></div>
|
||||
</div>
|
||||
<div class="datagrid-item">
|
||||
<div class="datagrid-title"><?= h(t('animal.birth')) ?></div>
|
||||
<div class="datagrid-content"><?= h($data['birth_date'] ?? '') ?></div>
|
||||
</div>
|
||||
<div class="datagrid-item">
|
||||
<div class="datagrid-title"><?= h(t('animal.appearance')) ?></div>
|
||||
<div class="datagrid-content"><?= h($data['appearance'] ?? '') ?></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
$holder =
|
||||
$data['holder'] ??
|
||||
$data['owner'] ??
|
||||
$data['detenteur'] ??
|
||||
null;
|
||||
|
||||
// Si ton parse aplatit au lieu d'imbriquer :
|
||||
if (!$holder && (
|
||||
isset($data['holder_name']) || isset($data['holder_phone']) || isset($data['holder_email'])
|
||||
)) {
|
||||
$holder = [
|
||||
'name' => $data['holder_name'] ?? '',
|
||||
'phone' => $data['holder_phone'] ?? '',
|
||||
'email' => $data['holder_email'] ?? '',
|
||||
'type' => $data['holder_type'] ?? '',
|
||||
'address' => $data['holder_address'] ?? '',
|
||||
];
|
||||
}
|
||||
?>
|
||||
|
||||
<?php if (is_array($holder) && array_filter($holder, fn ($v) => trim((string)$v) !== '')): ?>
|
||||
<hr class="my-3">
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-12">
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<div class="fw-bold"><?= h(t('animal.holder')) ?></div>
|
||||
<span class="text-muted small"><span
|
||||
class="badge bg-danger-lt"><?= h(t('animal.do_not_share')) ?></span></span>
|
||||
</div>
|
||||
|
||||
<div class="datagrid">
|
||||
<?php if (!empty($holder['type'])): ?>
|
||||
<div class="datagrid-item">
|
||||
<div class="datagrid-title">Type</div>
|
||||
<div class="datagrid-content"><?= h($holder['type']) ?></div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($holder['name'])): ?>
|
||||
<div class="datagrid-item">
|
||||
<div class="datagrid-title"><?= h(t('common.name')) ?></div>
|
||||
<div class="datagrid-content"><?= h($holder['name']) ?></div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($holder['phone'])): ?>
|
||||
<div class="datagrid-item">
|
||||
<div class="datagrid-title"><?= h(t('common.phone')) ?></div>
|
||||
<div class="datagrid-content"><?= h($holder['phone']) ?></div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($holder['email'])): ?>
|
||||
<div class="datagrid-item">
|
||||
<div class="datagrid-title"><?= h(t('common.email')) ?></div>
|
||||
<div class="datagrid-content"><?= h($holder['email']) ?></div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($holder['address'])): ?>
|
||||
<div class="datagrid-item" style="grid-column: 1 / -1;">
|
||||
<div class="datagrid-title"><?= h(t('common.address')) ?></div>
|
||||
<div class="datagrid-content"><?= nl2br(h($holder['address'])) ?></div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="text-muted mb-2"><?= h(t('animal.icad_history')) ?></div>
|
||||
<?php $events = $data['events'] ?? []; ?>
|
||||
<?php if (empty($events)): ?>
|
||||
<div class="text-muted"><?= h(t('animal.no_event')) ?></div>
|
||||
<?php else: ?>
|
||||
<ul class="list list-timeline">
|
||||
<?php foreach ($events as $e): ?>
|
||||
<li>
|
||||
<div class="list-timeline-icon bg-blue-lt">
|
||||
<i class="ti ti-history"></i>
|
||||
</div>
|
||||
<div class="list-timeline-content">
|
||||
<div class="fw-bold"><?= h($e['label'] ?? '') ?></div>
|
||||
<div class="text-muted small">
|
||||
<?= h(t('common.date')) ?> : <?= h($e['date'] ?? '') ?> —
|
||||
<?= h(t('animal.recorded')) ?> : <?= h($e['recorded_at'] ?? '') ?>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
68
app/Views/_animal_show_litters.php
Normal file
68
app/Views/_animal_show_litters.php
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
// Sous-vue de la fiche animale ; les variables sont préparées par AnimalsController::show().
|
||||
?>
|
||||
<!-- PORTÉES -->
|
||||
<?php if ($isCat): ?><div class="tab-pane" id="tab-litters">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<div>
|
||||
<h3 class="mb-0"><?= h(t('animal.litters_parentage')) ?></h3>
|
||||
<div class="text-muted"><?= h(t('animal.litters_help')) ?></div>
|
||||
</div>
|
||||
<a class="btn btn-primary" href="/litter/new?animal_id=<?= (int)$animal['id'] ?>">+
|
||||
<?= h(t('animal.add_litter')) ?></a>
|
||||
</div>
|
||||
<?php if (empty($litters)): ?>
|
||||
<div class="empty">
|
||||
<div class="empty-description"><?= h(t('animal.no_litter')) ?></div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="row row-cards">
|
||||
<?php foreach ($litters as $litter): ?>
|
||||
<?php
|
||||
$litterDateLabel = t('animal.date_unknown');
|
||||
if (!empty($litter['birth_date'])) {
|
||||
$litterDate = DateTimeImmutable::createFromFormat('Y-m-d', (string)$litter['birth_date']);
|
||||
if ($litterDate) {
|
||||
$litterDateLabel = $litterDate->format('d/m/Y');
|
||||
}
|
||||
}
|
||||
?>
|
||||
<div class="col-12">
|
||||
<div class="card card-sm">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h4 class="card-title mb-1"><?= h(t('animal.litter_on', ['date' => $litterDateLabel])) ?></h4>
|
||||
<div class="text-muted small">
|
||||
<?= h(t('animal.mother')) ?> :
|
||||
<?= $litter['mother_id'] ? '<a href="/animal?id=' . (int)$litter['mother_id'] . '">' . h($litter['mother_name']) . '</a>' : h(t('animal.unknown_female')) ?>
|
||||
· <?= h(t('animal.father')) ?> :
|
||||
<?= $litter['father_id'] ? '<a href="/animal?id=' . (int)$litter['father_id'] . '">' . h($litter['father_name']) . '</a>' : h(t('animal.unknown_male')) ?>
|
||||
</div>
|
||||
</div>
|
||||
<a class="btn btn-outline-primary btn-sm ms-auto"
|
||||
href="/litter/add-existing?litter_id=<?= (int)$litter['id'] ?>&animal_id=<?= (int)$animal['id'] ?>">+
|
||||
<?= h(t('animal.add_existing_cat')) ?></a>
|
||||
</div>
|
||||
<div class="list-group list-group-flush">
|
||||
<?php foreach ($litter['kittens'] as $index => $kitten): ?>
|
||||
<a class="list-group-item list-group-item-action d-flex align-items-center gap-2"
|
||||
href="/animal?id=<?= (int)$kitten['id'] ?>">
|
||||
<span
|
||||
class="text-muted font-monospace"><?= $index === count($litter['kittens']) - 1 ? '└' : '├' ?></span>
|
||||
<span class="coat-swatch" style="<?= h(coat_swatch_style($kitten['color'] ?? '')) ?>"></span>
|
||||
<strong><?= h($kitten['name']) ?></strong>
|
||||
<?php if (!empty($kitten['archived_at'])): ?><span
|
||||
class="badge bg-secondary-lt"><?= h(t('animal.archived')) ?></span><?php endif; ?>
|
||||
<span
|
||||
class="text-muted"><?= h(($kitten['sex'] ?? 'U') === 'F' ? t('sex.female') : (($kitten['sex'] ?? 'U') === 'M' ? t('sex.male') : t('animal.sex_define'))) ?></span>
|
||||
<?php [$kittenStatusClass, $kittenStatusLabel] = status_badge((string)$kitten['status']); ?>
|
||||
<span class="badge <?= h($kittenStatusClass) ?> ms-auto"><?= h($kittenStatusLabel) ?></span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div><?php endif; ?>
|
||||
71
app/Views/_animal_show_locations.php
Normal file
71
app/Views/_animal_show_locations.php
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
// Sous-vue de la fiche animale ; les variables sont préparées par AnimalsController::show().
|
||||
?>
|
||||
<!-- EMPLACEMENTS -->
|
||||
<div class="tab-pane" id="tab-placements">
|
||||
<div class="d-flex justify-content-between align-items-start gap-2 mb-3">
|
||||
<div>
|
||||
<h3 class="mb-1"><?= h(t('animal.placement_journey')) ?></h3>
|
||||
<div class="text-muted"><?= h(t('animal.placement_journey_help')) ?></div>
|
||||
</div><button class="btn btn-primary btn-sm" data-bs-toggle="modal"
|
||||
data-bs-target="#modalPlacement"><?= h(t('animal.add_event')) ?></button>
|
||||
</div>
|
||||
<?php $placementLabels = ['reservation' => t('animal.reservation'),'foster' => t('animal.form.foster'),'adoption' => t('animal.final_adoption'),'return' => t('animal.return_shelter'),'cancellation' => t('animal.cancellation')];
|
||||
$placementColors = ['reservation' => 'purple','foster' => 'cyan','adoption' => 'green','return' => 'orange','cancellation' => 'secondary']; ?>
|
||||
<?php if (empty($placements)): ?><div class="text-muted"><?= h(t('animal.no_placement')) ?></div><?php else: ?><ul
|
||||
class="list list-timeline"><?php foreach ($placements as $placement): ?><li>
|
||||
<div class="list-timeline-icon bg-<?= h($placementColors[$placement['event_type']] ?? 'secondary') ?>"><i
|
||||
class="ti ti-home"></i></div>
|
||||
<div class="list-timeline-content">
|
||||
<div class="d-flex justify-content-between gap-2">
|
||||
<div><span
|
||||
class="badge bg-<?= h($placementColors[$placement['event_type']] ?? 'secondary') ?>-lt"><?= h($placementLabels[$placement['event_type']] ?? $placement['event_type']) ?></span><?php if (!empty($placement['contact_name'])): ?>
|
||||
<strong class="ms-2"><?= h($placement['contact_name']) ?></strong><?php endif; ?></div>
|
||||
<div class="text-muted small"><?= h(date('d/m/Y', strtotime((string)$placement['event_date']))) ?>
|
||||
</div>
|
||||
</div><?php if (!empty($placement['reason'])): ?><div class="mt-1"><strong><?= h(t('animal.reason')) ?>
|
||||
:</strong> <?= h($placement['reason']) ?></div>
|
||||
<?php endif; ?><?php if (!empty($placement['notes'])): ?><div class="text-muted">
|
||||
<?= nl2br(h($placement['notes'])) ?></div>
|
||||
<?php endif; ?><?php if (!empty($placement['actor_name'])): ?><div class="text-muted small">
|
||||
<?= h(t('common.by', ['name' => $placement['actor_name']])) ?></div><?php endif; ?>
|
||||
</div>
|
||||
</li><?php endforeach; ?></ul><?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane" id="tab-locations">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-start gap-2 mb-3">
|
||||
<div>
|
||||
<h3 class="mb-1"><?= h(t('animal.location_history')) ?></h3>
|
||||
<div class="text-muted"><?= h(t('animal.location_history_help')) ?></div>
|
||||
</div>
|
||||
<span class="badge bg-blue-lt"><?= h(tn('animal.step', 'animal.steps', count($locationHistory ?? []))) ?></span>
|
||||
</div>
|
||||
<?php if (empty($locationHistory)): ?>
|
||||
<div class="text-muted"><?= h(t('animal.no_location')) ?></div>
|
||||
<?php else: ?>
|
||||
<ul class="list list-timeline">
|
||||
<?php foreach ($locationHistory as $location):
|
||||
$fromLabel = LocationHistoryService::label($location, 'from');
|
||||
$toLabel = LocationHistoryService::label($location, 'to');
|
||||
$isInitial = empty($location['from_status']) && empty($location['from_refuge_room']) && empty($location['from_care_box_key']) && empty($location['from_address']);
|
||||
?>
|
||||
<li>
|
||||
<div class="list-timeline-icon bg-blue"><i class="ti ti-map-pin"></i></div>
|
||||
<div class="list-timeline-content">
|
||||
<div class="d-flex flex-wrap justify-content-between gap-2">
|
||||
<div class="fw-bold">
|
||||
<?= $isInitial ? h($toLabel) : h($fromLabel) . ' <span class="text-muted mx-1">→</span> ' . h($toLabel) ?>
|
||||
</div>
|
||||
<div class="text-muted small"><?= h($location['moved_at']) ?></div>
|
||||
</div>
|
||||
<?php if (!empty($location['reason'])): ?><div class="text-muted mt-1"><?= h($location['reason']) ?>
|
||||
</div><?php endif; ?>
|
||||
<?php if (!empty($location['actor_name'])): ?><div class="text-muted small mt-1">
|
||||
<?= h(t('common.by', ['name' => $location['actor_name']])) ?></div><?php endif; ?>
|
||||
</div>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
436
app/Views/_animal_show_medical.php
Normal file
436
app/Views/_animal_show_medical.php
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
<?php
|
||||
// Sous-vue de la fiche animale ; les variables sont préparées par AnimalsController::show().
|
||||
?>
|
||||
<!-- MÉDICAL -->
|
||||
<?php if ($canViewMedical): ?>
|
||||
<div class="tab-pane" id="tab-med">
|
||||
<div class="card mt-3">
|
||||
<div class="card-header d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h3 class="card-title m-0"><?= h(t('animal.medical_status_global')) ?></h3>
|
||||
<div class="text-muted small"><?= h(t('animal.current_health_help')) ?></div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-primary" type="button" data-bs-toggle="modal"
|
||||
data-bs-target="#modalMedicalStatus"><?= h(t('common.edit')) ?></button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<?php if ($isCat): ?><div class="col-12 col-md-3">
|
||||
<div class="subheader mb-1">FIV</div>
|
||||
<span class="badge <?= $fivClass ?>"><?= h($fivLabel) ?></span>
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<div class="subheader mb-1">FeLV</div>
|
||||
<span class="badge <?= $felvClass ?>"><?= h($felvLabel) ?></span>
|
||||
</div><?php endif; ?>
|
||||
<div class="col-12 <?= $isCat ? 'col-md-6' : '' ?>">
|
||||
<div class="subheader mb-1"><?= h(t('animal.conditions_short')) ?></div>
|
||||
<?php $activeConditions = array_values(array_filter($healthConditions ?? [], fn ($condition) => ($condition['status'] ?? '') === 'active')); ?>
|
||||
<?php if (!$activeConditions): ?>
|
||||
<span class="text-muted"><?= h(t('animal.no_active_condition')) ?></span>
|
||||
<?php else: ?>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<?php foreach ($activeConditions as $condition): ?>
|
||||
<span class="badge bg-red-lt"><?= h($condition['name']) ?></span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mt-0">
|
||||
<div class="col-12 col-xl-5">
|
||||
<div class="card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h3 class="card-title mb-0">📄 <?= h(t('medical_document.prescriptions')) ?></h3>
|
||||
<div class="text-muted small"><?= h(t('medical_document.private_documents')) ?></div>
|
||||
</div><button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal"
|
||||
data-bs-target="#modalPrescription"><?= h(t('common.add')) ?></button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<?php if (empty($prescriptions)): ?><div class="text-muted">
|
||||
<?= h(t('medical_document.no_prescription')) ?></div><?php else: ?><div class="divide-y">
|
||||
<?php foreach ($prescriptions as $p): $statusKey = 'medical_document.status_' . $p['status']; ?>
|
||||
<div class="py-2">
|
||||
<div class="d-flex justify-content-between gap-2">
|
||||
<div>
|
||||
<strong><?= h((new DateTimeImmutable($p['prescribed_on']))->format('d/m/Y')) ?></strong>
|
||||
<span
|
||||
class="badge <?= $p['status'] === 'active' ? 'bg-green-lt' : 'bg-secondary-lt' ?>"><?= h(t($statusKey)) ?></span>
|
||||
</div><a class="btn btn-sm btn-ghost-primary" target="_blank"
|
||||
href="/animal/medical-document?kind=prescription&id=<?= (int)$p['id'] ?>"><i
|
||||
class="ti ti-file-search"></i> <?= h(t('common.open')) ?></a>
|
||||
</div><?php if (!empty($p['clinic_name']) || !empty($p['veterinarian_name'])): ?><div
|
||||
class="text-muted small">
|
||||
<?= h(implode(' · ', array_filter([$p['clinic_name'],$p['veterinarian_name']]))) ?>
|
||||
</div><?php endif; ?><?php if (!empty($prescriptionTreatments[$p['id']])): ?><div
|
||||
class="mt-1 d-flex flex-wrap gap-1">
|
||||
<?php foreach ($prescriptionTreatments[$p['id']] as $linked): ?><span
|
||||
class="badge bg-orange-lt">💊
|
||||
<?= h($linked['medication_name'] ?: t('animal.treatment')) ?></span><?php endforeach; ?>
|
||||
</div><?php endif; ?><?php if (!empty($p['notes'])): ?><div class="small mt-1">
|
||||
<?= nl2br(h($p['notes'])) ?></div><?php endif; ?>
|
||||
</div><?php endforeach; ?></div><?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-xl-7">
|
||||
<div class="card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h3 class="card-title mb-0">🧪 <?= h(t('medical_document.lab_results')) ?></h3>
|
||||
<div class="text-muted small"><?= h(t('medical_document.lab_help')) ?></div>
|
||||
</div><button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal"
|
||||
data-bs-target="#modalLabReport"><?= h(t('common.add')) ?></button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<?php if (empty($labReports)): ?><div class="text-muted"><?= h(t('medical_document.no_analysis')) ?>
|
||||
</div><?php else: ?><div class="accordion" id="labReportsAccordion">
|
||||
<?php foreach ($labReports as $i => $report): $results = $labResultsByReport[$report['id']] ?? []; ?>
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header"><button class="accordion-button <?= $i ? 'collapsed' : '' ?>"
|
||||
type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#lab-report-<?= (int)$report['id'] ?>"><span
|
||||
class="me-2"><?= h((new DateTimeImmutable($report['sampled_on']))->format('d/m/Y')) ?></span><span
|
||||
class="text-muted"><?= h($report['report_type'] ?: t('medical_document.analysis')) ?></span></button>
|
||||
</h2>
|
||||
<div id="lab-report-<?= (int)$report['id'] ?>"
|
||||
class="accordion-collapse collapse <?= !$i ? 'show' : '' ?>"
|
||||
data-bs-parent="#labReportsAccordion">
|
||||
<div class="accordion-body"><?php if ($report['stored_name']): ?><a
|
||||
class="btn btn-sm btn-outline-primary mb-2" target="_blank"
|
||||
href="/animal/medical-document?kind=analysis&id=<?= (int)$report['id'] ?>"><i
|
||||
class="ti ti-paperclip"></i>
|
||||
<?= h(t('medical_document.open_report')) ?></a><?php endif; ?><?php if ($results): ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-vcenter mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?= h(t('medical_document.parameter')) ?></th>
|
||||
<th><?= h(t('medical_document.value')) ?></th>
|
||||
<th><?= h(t('medical_document.reference')) ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><?php foreach ($results as $result): $hasReference = $result['reference_min'] !== null || $result['reference_max'] !== null;
|
||||
$low = $result['reference_min'] !== null && (float)$result['value'] < (float)$result['reference_min'];
|
||||
$high = $result['reference_max'] !== null && (float)$result['value'] > (float)$result['reference_max']; ?><tr>
|
||||
<td><?= h($result['parameter_name']) ?></td>
|
||||
<td><strong
|
||||
class="<?= !$hasReference ? '' : ($low || $high ? 'text-danger' : 'text-success') ?>"><?= h((string)$result['value'] . ' ' . ($result['unit'] ?? '')) ?></strong>
|
||||
</td>
|
||||
<td><?= $hasReference ? h(($result['reference_min'] ?? '…') . ' – ' . ($result['reference_max'] ?? '…') . ' ' . ($result['unit'] ?? '')) : '—' ?>
|
||||
</td>
|
||||
</tr><?php endforeach; ?></tbody>
|
||||
</table>
|
||||
</div><?php endif; ?><div class="text-muted small mt-2">
|
||||
<?= h(implode(' · ', array_filter([$report['laboratory_name'],$report['clinic_name'],$report['veterinarian_name']]))) ?>
|
||||
</div><?php if ($report['notes']): ?><div class="small mt-1">
|
||||
<?= nl2br(h($report['notes'])) ?></div><?php endif; ?></div>
|
||||
</div>
|
||||
</div><?php endforeach; ?></div><?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($labSeries)): ?><div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h3 class="card-title mb-0"><?= h(t('medical_document.evolution')) ?></h3>
|
||||
<div class="text-muted small"><?= h(t('medical_document.graph_help')) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3"><?php foreach (array_values($labSeries) as $i => $series): ?><div
|
||||
class="col-12 col-xl-6">
|
||||
<div class="border rounded p-3">
|
||||
<h4 class="mb-2">
|
||||
<?= h($series['name']) ?><?= $series['unit'] ? ' <span class="text-muted fw-normal">(' . h($series['unit']) . ')</span>' : '' ?>
|
||||
</h4>
|
||||
<div style="height:230px"><canvas class="lab-chart"
|
||||
data-series="<?= h(json_encode($series, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) ?>"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div><?php endforeach; ?></div>
|
||||
</div>
|
||||
</div><?php endif; ?>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h3 class="card-title m-0">🏥 Chirurgies</h3>
|
||||
<div class="text-muted small">Interventions planifiées et réalisées</div>
|
||||
</div><a class="btn btn-sm btn-outline-primary"
|
||||
href="/animal/surgery/new?animal_id=<?= (int)$animal['id'] ?>"><?= h(t('common.add')) ?></a>
|
||||
</div>
|
||||
<div class="card-body"><?php if (empty($surgeries)):?><span class="text-muted">Aucune chirurgie
|
||||
enregistrée.</span><?php else:?><div class="table-responsive">
|
||||
<table class="table table-sm table-vcenter mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Intervention</th>
|
||||
<th>Structure / vétérinaire</th>
|
||||
<th>Statut</th>
|
||||
<th class="text-end">Coût</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><?php foreach ($surgeries as $s):?><tr>
|
||||
<td><?= h(date('d/m/Y', strtotime($s['surgery_date']))) ?></td>
|
||||
<td><strong><?= h($s['procedure_name']) ?></strong><?php if ($s['notes']):?><div
|
||||
class="small text-muted"><?= h($s['notes']) ?></div><?php endif?></td>
|
||||
<td><?= h(implode(' · ', array_filter([$s['clinic_name'],$s['veterinarian_name']])) ?: '—') ?>
|
||||
</td>
|
||||
<td><span
|
||||
class="badge <?= $s['status'] === 'completed' ? 'bg-green-lt' : ($s['status'] === 'planned' ? 'bg-blue-lt' : 'bg-secondary-lt') ?>"><?= h(['completed' => 'Réalisée','planned' => 'Planifiée','cancelled' => 'Annulée'][$s['status']] ?? $s['status']) ?></span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<?= $s['amount_cents'] === null ? '—' : number_format($s['amount_cents'] / 100, 2, ',', ' ') . ' €' ?>
|
||||
</td>
|
||||
</tr><?php endforeach?></tbody>
|
||||
</table>
|
||||
</div><?php endif?></div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h3 class="card-title m-0"><?= h(t('animals.dewormer')) ?></h3>
|
||||
<div class="text-muted small"><?= h(t('animal.deworming_schedule')) ?></div>
|
||||
</div><button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal"
|
||||
data-bs-target="#modalDeworming"><?= h(t('common.add')) ?></button>
|
||||
</div>
|
||||
<div class="card-body"><?php if (!$dewormings): ?><span
|
||||
class="text-muted"><?= h(t('animal.no_deworming')) ?></span><?php else: ?><div class="mb-3">
|
||||
<strong><?= h(t('animal.latest_deworming', ['date' => (new DateTimeImmutable($lastDeworming['administered_on']))->format('d/m/Y')])) ?></strong><?php if ($lastDeworming['next_due_date']): $due = new DateTimeImmutable($lastDeworming['next_due_date']);
|
||||
$late = $due < new DateTimeImmutable('today'); ?><span
|
||||
class="badge ms-2 <?= $late ? 'bg-red-lt' : 'bg-blue-lt' ?>"><?= h(t('animal.next_date', ['date' => $due->format('d/m/Y')])) ?></span><?php endif; ?>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-vcenter mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?= h(t('common.date')) ?></th>
|
||||
<th><?= h(t('animal.product')) ?></th>
|
||||
<th><?= h(t('animal.dose')) ?></th>
|
||||
<th><?= h(t('common.weight')) ?></th>
|
||||
<th><?= h(t('animal.recorded_by')) ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><?php foreach ($dewormings as $d): ?><tr>
|
||||
<td><?= h((new DateTimeImmutable($d['administered_on']))->format('d/m/Y')) ?></td>
|
||||
<td><?= h($d['medication_name'] ?: t('common.not_provided')) ?></td>
|
||||
<td><?= h($d['dose_text'] ?: '—') ?></td>
|
||||
<td><?= $d['weight_kg'] !== null ? h(number_format((float)$d['weight_kg'], 2, ',', ' ')) . ' kg' : '—' ?>
|
||||
</td>
|
||||
<td><?= h($d['actor_name'] ?: '—') ?></td>
|
||||
</tr><?php endforeach; ?></tbody>
|
||||
</table>
|
||||
</div><?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title m-0"><?= h(t('animal.weight_evolution')) ?></h3>
|
||||
</div>
|
||||
|
||||
<?php if (!$weights): ?>
|
||||
<div class="card-body text-muted"><?= h(t('animal.no_weight')) ?></div>
|
||||
<?php else: ?>
|
||||
<div style="height:260px">
|
||||
<canvas id="weightChart"></canvas>
|
||||
</div>
|
||||
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<hr />
|
||||
<?php if (!$medical_notes): ?>
|
||||
<div class="text-muted"><?= h(t('animal.no_medical_note')) ?></div>
|
||||
<?php else: ?>
|
||||
<div class="divide-y">
|
||||
<?php foreach ($medical_notes as $n): ?>
|
||||
<div class="py-3">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<?php
|
||||
$kind = (string)$n['kind'];
|
||||
$badge = match ($kind) {
|
||||
'consult' => 'bg-blue-lt',
|
||||
'controle' => 'bg-green-lt',
|
||||
'symptome' => 'bg-yellow-lt',
|
||||
'diagnostic' => 'bg-purple-lt',
|
||||
'acte' => 'bg-orange-lt',
|
||||
'hosp' => 'bg-red-lt',
|
||||
default => 'bg-secondary-lt',
|
||||
};
|
||||
|
||||
$stmtPhotos = DB::pdo()->prepare('
|
||||
SELECT *
|
||||
FROM medical_photos
|
||||
WHERE medical_note_id = ?
|
||||
ORDER BY created_at ASC
|
||||
');
|
||||
$stmtPhotos->execute([(int)$n['id']]);
|
||||
$notePhotos = $stmtPhotos->fetchAll(PDO::FETCH_ASSOC);
|
||||
?>
|
||||
<span class="badge <?= $badge ?>"><?= ucfirst(h($kind)) ?></span>
|
||||
|
||||
<?php if (!empty($n['reason'])): ?>
|
||||
<strong class="ms-2"><?= h($n['reason']) ?></strong>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="text-muted"><?= h($n['noted_at']) ?></div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mt-2">
|
||||
<?php if (!empty($n['weight_kg']) || !empty($n['temperature_c'])): ?>
|
||||
<div class="text-muted">
|
||||
<?= !empty($n['weight_kg']) ? (h(t('pdf.weight')) . ': <strong>' . h($n['weight_kg']) . ' kg</strong>') : '' ?>
|
||||
<?= (!empty($n['weight_kg']) && !empty($n['temperature_c'])) ? ' • ' : '' ?>
|
||||
<?= !empty($n['temperature_c']) ? ('Temp: <strong>' . h($n['temperature_c']) . ' °C</strong>') : '' ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($n['clinic_name']) || !empty($n['vet_name'])): ?>
|
||||
<div class="text-muted">
|
||||
<?= !empty($n['clinic_name']) ? h($n['clinic_name']) : '' ?>
|
||||
<?= (!empty($n['clinic_name']) && !empty($n['vet_name'])) ? ' • ' : '' ?>
|
||||
<?= !empty($n['vet_name']) ? h($n['vet_name']) : '' ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
|
||||
<?php if (!empty($n['symptoms'])): ?>
|
||||
<div><span class="text-muted"><?= h(t('animal.symptoms')) ?> :</span> <?= nl2br(h($n['symptoms'])) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($n['exam'])): ?>
|
||||
<div><span class="text-muted"><?= h(t('animal.exam')) ?> :</span> <?= nl2br(h($n['exam'])) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($n['diagnosis'])): ?>
|
||||
<div><span class="text-muted"><?= h(t('animal.diagnosis')) ?> :</span> <?= nl2br(h($n['diagnosis'])) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($n['plan'])): ?>
|
||||
<div><span class="text-muted"><?= h(t('animal.plan')) ?> :</span> <?= nl2br(h($n['plan'])) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($notePhotos)): ?>
|
||||
<div class="mt-3">
|
||||
<div class="text-muted mb-1"><?= h(t('animal.photos')) ?> :</div>
|
||||
|
||||
<div class="row row-cards">
|
||||
<?php foreach ($notePhotos as $p): ?>
|
||||
<?php
|
||||
$src = '/private-media?kind=medical&id=' . (int)$p['id'];
|
||||
?>
|
||||
<div class="col-6 col-sm-4 col-md-3 col-lg-2">
|
||||
<a href="<?= $src ?>" target="_blank" class="d-block">
|
||||
<img src="<?= $src ?>" class="img-fluid rounded border"
|
||||
style="height:140px;object-fit:cover;">
|
||||
</a>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- TRAITEMENTS -->
|
||||
<div class="tab-pane" id="tab-trt">
|
||||
<?php if (!$treatments): ?>
|
||||
<div class="text-muted"><?= h(t('animal.no_treatment')) ?></div>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?= h(t('animal.status')) ?></th>
|
||||
<th><?= h(t('animal.medication')) ?></th>
|
||||
<th><?= h(t('animal.route')) ?></th>
|
||||
<th><?= h(t('animal.dosage')) ?></th>
|
||||
<th><?= h(t('animal.start')) ?></th>
|
||||
<th><?= h(t('dashboard.end')) ?></th>
|
||||
<th><?= h(t('common.notes')) ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($treatments as $t): ?>
|
||||
<?php
|
||||
$isFinished = !empty($t['end_date'])
|
||||
&& $t['end_date'] < date('Y-m-d');
|
||||
?>
|
||||
<tr class="<?= $isFinished ? 'table-secondary text-muted' : '' ?>">
|
||||
<td>
|
||||
<span class="badge <?= $isFinished ? 'bg-grey-lt' : 'bg-green-lt' ?>">
|
||||
<?= h(t($isFinished ? 'animal.finished' : 'animal.ongoing')) ?>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<strong><?= h($t['medication_name']) ?></strong>
|
||||
<?php if (!empty($t['molecule'])): ?>
|
||||
<div class="text-muted">
|
||||
<?= h($t['molecule']) ?><?= !empty($t['form']) ? (' • ' . h($t['form'])) : '' ?></div>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?= $t['route'] ? h($t['route']) : '—' ?></td>
|
||||
<td><?= h($t['dose_text']) ?></td>
|
||||
<td><?= h($t['start_date']) ?></td>
|
||||
<td><?= $t['end_date'] ? h($t['end_date']) : '—' ?></td>
|
||||
<td><?= $t['notes'] ? h($t['notes']) : '—' ?></td>
|
||||
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- VACCINS -->
|
||||
<div class="tab-pane" id="tab-vac">
|
||||
<?php if (!$vaccinations): ?>
|
||||
<div class="text-muted"><?= h(t('animal.no_vaccine_recorded')) ?></div>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-vcenter">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?= h(t('animal.vaccine')) ?></th>
|
||||
<th><?= h(t('animal.done_on')) ?></th>
|
||||
<th><?= h(t('animal.reminder_due')) ?></th>
|
||||
<th><?= h(t('animal.batch_traceability')) ?></th>
|
||||
<th><?= h(t('animal.administered_by')) ?></th>
|
||||
<th><?= h(t('common.notes')) ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($vaccinations as $v): ?>
|
||||
<tr>
|
||||
<td><strong><?= h($v['vaccine_name']) ?></strong></td>
|
||||
<td><?= h($v['done_date']) ?></td>
|
||||
<td><?= $v['due_date'] ? h($v['due_date']) : '—' ?></td>
|
||||
<td><?php if (!empty($v['manufacturer'])): ?><strong><?= h($v['manufacturer']) ?></strong><br><?php endif; ?><?= $v['lot'] ? h(t('animal.batch')) . ' ' . h($v['lot']) : '—' ?><?php if (!empty($v['batch_expires_on'])): ?>
|
||||
<div class="text-muted small">
|
||||
<?= h(t('animal.expires_on', ['date' => date('d/m/Y', strtotime((string)$v['batch_expires_on']))])) ?>
|
||||
</div><?php endif; ?></td>
|
||||
<td><?= !empty($v['administrator_name']) ? h($v['administrator_name']) : '—' ?></td>
|
||||
<td><?= $v['notes'] ? h($v['notes']) : '—' ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
1105
app/Views/_animal_show_modals.php
Normal file
1105
app/Views/_animal_show_modals.php
Normal file
File diff suppressed because it is too large
Load diff
205
app/Views/_animal_show_photos.php
Normal file
205
app/Views/_animal_show_photos.php
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
<?php
|
||||
// Sous-vue de la fiche animale ; les variables sont préparées par AnimalsController::show().
|
||||
?>
|
||||
<!-- PHOTOS -->
|
||||
<div class="tab-pane" id="tab-photos">
|
||||
<?php /** @var array $photos */ ?>
|
||||
|
||||
<?php
|
||||
$publicPhotos = array_values(array_filter($photos ?? [], fn ($p) => (int)($p['is_public'] ?? 1) === 1));
|
||||
$privatePhotos = array_values(array_filter($photos ?? [], fn ($p) => (int)($p['is_public'] ?? 1) === 0));
|
||||
?>
|
||||
|
||||
<div class="row row-cards">
|
||||
|
||||
<!-- PUBLIC -->
|
||||
<div class="col-12">
|
||||
<div class="card border-success">
|
||||
<div class="card-header d-flex align-items-center justify-content-between">
|
||||
<div class="d-flex align-items-baseline gap-2">
|
||||
<h3 class="card-title m-0"><?= h(t('animal.public_photos')) ?></h3>
|
||||
<div class="text-muted small">(<?= h(t('animal.visible_online')) ?>)</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/animal/photos/upload" enctype="multipart/form-data"
|
||||
class="d-flex gap-2">
|
||||
<input type="hidden" name="animal_id" value="<?= (int)$animal['id'] ?>">
|
||||
<input type="hidden" name="is_public" value="1">
|
||||
<input type="hidden" name="redirect_hash" value="#tab-photos">
|
||||
<input class="form-control form-control-sm" type="file" name="photos[]" multiple
|
||||
accept="image/*" required>
|
||||
<button class="btn btn-sm btn-success"
|
||||
type="submit"><?= h(t('animal.upload_public')) ?></button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<?php if (empty($publicPhotos)): ?>
|
||||
<div class="text-muted"><?= h(t('animal.no_public_photo')) ?></div>
|
||||
<?php else: ?>
|
||||
<div class="row row-cards">
|
||||
<?php foreach ($publicPhotos as $p): ?>
|
||||
<?php
|
||||
$src = '/media/animals/' . (int)$animal['id'] . '/' . h($p['filename']);
|
||||
$isPrimary = (int)($p['is_primary'] ?? 0) === 1;
|
||||
?>
|
||||
<div class="col-6 col-sm-4 col-md-3 col-lg-2">
|
||||
<div class="card card-sm">
|
||||
<a href="<?= $src ?>" target="_blank" class="d-block">
|
||||
<img src="<?= $src ?>" class="card-img-top" style="height:140px;object-fit:cover"
|
||||
alt="">
|
||||
</a>
|
||||
|
||||
<div class="card-body p-2">
|
||||
<?= $isPrimary
|
||||
? '<span class="badge bg-primary-lt">' . h(t('animal.primary')) . '</span>'
|
||||
: '<span class="badge bg-success-lt">' . h(t('animal.public')) . '</span>' ?>
|
||||
|
||||
<?php if ($isPrimary): ?>
|
||||
<button type="submit" class="btn btn-sm btn-icon btn-light position-absolute"
|
||||
title="<?= h(t('animal.set_primary')) ?>"
|
||||
style="border-radius:999px; top:10px;right:10px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="18" height="18"
|
||||
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"
|
||||
fill="currentColor" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path fill="#edd180"
|
||||
d="M12 17.75l-5.878 3.09l1.122-6.545l-4.756-4.635l6.57-.955l2.942-5.955l2.942 5.955l6.57 .955l-4.756 4.635l1.122 6.545z" />
|
||||
</svg>
|
||||
</button>
|
||||
<?php else: ?>
|
||||
<form method="post" action="/animal/photos/primary" class="position-absolute"
|
||||
style="top:8px;right:8px;">
|
||||
<input type="hidden" name="animal_id" value="<?= (int)$animal['id'] ?>">
|
||||
<input type="hidden" name="photo_id" value="<?= (int)$p['id'] ?>">
|
||||
<input type="hidden" name="redirect_hash" value="#tab-photos">
|
||||
<button type="submit" class="btn btn-sm btn-icon btn-light"
|
||||
title="<?= h(t('animal.set_primary')) ?>"
|
||||
style="border-radius:999px;top:-2px; right:2px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="18" height="18"
|
||||
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"
|
||||
fill="currentColor" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path
|
||||
d="M12 17.75l-5.878 3.09l1.122-6.545l-4.756-4.635l6.57-.955l2.942-5.955l2.942 5.955l6.57 .955l-4.756 4.635l1.122 6.545z" />
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="d-flex gap-1 mt-2">
|
||||
|
||||
<form method="post" action="/animal/photos/toggle" class="flex-fill">
|
||||
<input type="hidden" name="animal_id" value="<?= (int)$animal['id'] ?>">
|
||||
<input type="hidden" name="photo_id" value="<?= (int)$p['id'] ?>">
|
||||
<input type="hidden" name="is_public" value="0">
|
||||
<input type="hidden" name="redirect_hash" value="#tab-photos">
|
||||
<button class="btn btn-sm btn-outline-warning w-100"
|
||||
type="submit"><?= h(t('animal.private')) ?></button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/animal/photos/delete"
|
||||
onsubmit="return confirm(<?= h(json_encode(t('animal.delete_photo_confirm'), JSON_HEX_APOS | JSON_HEX_QUOT)) ?>);"
|
||||
class="flex-fill">
|
||||
<input type="hidden" name="animal_id" value="<?= (int)$animal['id'] ?>">
|
||||
<input type="hidden" name="photo_id" value="<?= (int)$p['id'] ?>">
|
||||
<input type="hidden" name="redirect_hash" value="#tab-photos">
|
||||
<button class="btn btn-sm btn-outline-danger w-100"
|
||||
type="submit"><?= h(t('common.delete_short')) ?></button>
|
||||
</form>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PRIVE -->
|
||||
<div class="col-12">
|
||||
<div class="card border-danger">
|
||||
<div class="card-header d-flex align-items-center justify-content-between">
|
||||
<div class="d-flex align-items-baseline gap-2">
|
||||
<h3 class="card-title m-0"><?= h(t('animal.private_photos')) ?></h3>
|
||||
<div class="text-muted small">(<?= h(t('animal.not_visible_online')) ?>)</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/animal/photos/upload" enctype="multipart/form-data"
|
||||
class="d-flex gap-2">
|
||||
<input type="hidden" name="animal_id" value="<?= (int)$animal['id'] ?>">
|
||||
<input type="hidden" name="is_public" value="0">
|
||||
<input type="hidden" name="redirect_hash" value="#tab-photos">
|
||||
<input class="form-control form-control-sm" type="file" name="photos[]" multiple
|
||||
accept="image/*" required>
|
||||
<button class="btn btn-sm btn-danger"
|
||||
type="submit"><?= h(t('animal.upload_private')) ?></button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<?php if (empty($privatePhotos)): ?>
|
||||
<div class="text-muted"><?= h(t('animal.no_private_photo')) ?></div>
|
||||
<?php else: ?>
|
||||
<div class="row row-cards">
|
||||
<?php foreach ($privatePhotos as $p): ?>
|
||||
<?php $src = '/private-media?kind=photo&id=' . (int)$p['id']; ?>
|
||||
<div class="col-6 col-sm-4 col-md-3 col-lg-2">
|
||||
<div class="card card-sm bg-muted-lt">
|
||||
<a href="<?= $src ?>" target="_blank" class="d-block">
|
||||
<img src="<?= $src ?>" class="card-img-top"
|
||||
style="height:140px;object-fit:cover;filter:grayscale(20%);" alt="">
|
||||
</a>
|
||||
|
||||
<div class="card-body p-2">
|
||||
<span class="badge bg-danger-lt"><?= h(t('animal.private')) ?></span>
|
||||
<?php if (!empty($p['caption'])): ?><div class="small mt-2">
|
||||
<?= nl2br(h($p['caption'])) ?></div><?php endif; ?>
|
||||
<?php if (!empty($p['care_round_id'])): ?><div class="text-muted small mt-1">📋
|
||||
<?= h(t('animal.round_photo')) ?></div><?php endif; ?>
|
||||
|
||||
<div class="d-flex gap-1 mt-2">
|
||||
<form method="post" action="/animal/photos/toggle" class="flex-fill">
|
||||
<input type="hidden" name="animal_id" value="<?= (int)$animal['id'] ?>">
|
||||
<input type="hidden" name="photo_id" value="<?= (int)$p['id'] ?>">
|
||||
<input type="hidden" name="is_public" value="1">
|
||||
<input type="hidden" name="redirect_hash" value="#tab-photos">
|
||||
<button class="btn btn-sm btn-outline-success w-100"
|
||||
type="submit"><?= h(t('animal.public')) ?></button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/animal/photos/delete"
|
||||
onsubmit="return confirm(<?= h(json_encode(t('animal.delete_photo_confirm'), JSON_HEX_APOS | JSON_HEX_QUOT)) ?>);"
|
||||
class="flex-fill">
|
||||
<input type="hidden" name="animal_id" value="<?= (int)$animal['id'] ?>">
|
||||
<input type="hidden" name="photo_id" value="<?= (int)$p['id'] ?>">
|
||||
<input type="hidden" name="redirect_hash" value="#tab-photos">
|
||||
<button class="btn btn-sm btn-outline-danger w-100"
|
||||
type="submit"><?= h(t('common.delete_short')) ?></button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
67
app/Views/_deworming_modal.php
Normal file
67
app/Views/_deworming_modal.php
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<div class="modal modal-blur fade" id="modalDewormingCatalog" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<form class="modal-content" method="post" action="/animal/add-deworming">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><?= h(t('deworm.catalog_record')) ?></h5><button type="button" class="btn-close"
|
||||
data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body"><input type="hidden" name="animal_id" value="<?= (int)$animal['id'] ?>">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6"><label class="form-label"><?= h(t('common.date')) ?></label><input
|
||||
class="form-control" type="date" name="administered_on" value="<?= date('Y-m-d') ?>"
|
||||
required></div>
|
||||
<div class="col-md-6"><label class="form-label"><?= h(t('deworm.product')) ?></label><select
|
||||
class="form-select" name="dewormer_id">
|
||||
<option value=""><?= h(t('common.not_provided')) ?></option>
|
||||
<?php foreach (($dewormers ?? []) as $d): ?><option value="<?= (int)$d['id'] ?>">
|
||||
<?= h($d['name']) ?></option><?php endforeach; ?>
|
||||
</select></div>
|
||||
<div class="col-12"><label class="form-check"><input class="form-check-input" type="checkbox"
|
||||
name="create_dewormer" value="1" id="dewormerNewToggle"><span
|
||||
class="form-check-label"><?= h(t('deworm.add_catalog')) ?></span></label></div>
|
||||
<div class="col-12 d-none" id="dewormerNewFields">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-5"><input class="form-control" name="new_dewormer_name"
|
||||
placeholder="<?= h(t('deworm.name')) ?>"></div>
|
||||
<div class="col-md-4"><input class="form-control" name="new_dewormer_ingredient"
|
||||
placeholder="<?= h(t('deworm.ingredient')) ?>"></div>
|
||||
<div class="col-md-3"><input class="form-control" name="new_dewormer_form"
|
||||
placeholder="<?= h(t('deworm.form')) ?>"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6"><label class="form-label"><?= h(t('animal.dose')) ?></label><input
|
||||
class="form-control" name="dose_text" placeholder="<?= h(t('deworm.dose_example')) ?>">
|
||||
</div>
|
||||
<div class="col-md-6"><label class="form-label"><?= h(t('common.weight')) ?></label>
|
||||
<div class="input-group"><input class="form-control" type="number" min="0" step="0.01"
|
||||
name="weight_kg"><span class="input-group-text">kg</span></div>
|
||||
</div>
|
||||
<div class="col-12"><label class="form-label"><?= h(t('deworm.next_due')) ?> <span
|
||||
class="text-muted"><?= h(t('common.optional_parentheses')) ?></span></label><input
|
||||
class="form-control" type="date" name="next_due_date">
|
||||
<div class="form-hint"><?= h(t('deworm.auto_age')) ?></div>
|
||||
</div>
|
||||
<?php if (!empty($litters)): ?><div class="col-12"><label class="form-check"><input
|
||||
class="form-check-input" type="checkbox" name="apply_litter" value="1"><span
|
||||
class="form-check-label"><?= h(t('deworm.apply_litter')) ?></span></label></div>
|
||||
<?php endif; ?>
|
||||
<div class="col-12"><label class="form-label"><?= h(t('animal.comment')) ?></label><textarea
|
||||
class="form-control" name="notes" rows="2"></textarea></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer"><button class="btn me-auto" type="button"
|
||||
data-bs-dismiss="modal"><?= h(t('common.cancel')) ?></button><button
|
||||
class="btn btn-primary"><?= h(t('common.save')) ?></button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('[data-bs-target="#modalDeworming"]').forEach(b => b.setAttribute(
|
||||
'data-bs-target', '#modalDewormingCatalog'));
|
||||
const t = document.getElementById('dewormerNewToggle'),
|
||||
p = document.getElementById('dewormerNewFields');
|
||||
t?.addEventListener('change', () => p?.classList.toggle('d-none', !t.checked));
|
||||
});
|
||||
|
||||
</script>
|
||||
29
app/Views/_directory_medical_fields.php
Normal file
29
app/Views/_directory_medical_fields.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php $directoryFieldId = $directoryFieldId ?? uniqid('medical_', false); ?>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label"><?= h(t('animal.veterinary_clinic')) ?></label>
|
||||
<select class="form-select directory-contact-select medical-clinic-select" name="clinic_contact_id"
|
||||
data-pair="<?= h($directoryFieldId) ?>" data-new-target="<?= h($directoryFieldId) ?>ClinicNew" required>
|
||||
<option value="">— <?= h(t('common.choose')) ?> —</option>
|
||||
<?php foreach ($directoryClinics as $contact): ?><option value="<?= (int)$contact['id'] ?>">
|
||||
<?= h($contact['name']) ?></option><?php endforeach; ?>
|
||||
<option value="new">+ <?= h(t('animal.create_clinic')) ?></option>
|
||||
</select>
|
||||
<input class="form-control mt-2 d-none" id="<?= h($directoryFieldId) ?>ClinicNew" name="new_clinic_name"
|
||||
placeholder="<?= h(t('animal.new_clinic_name')) ?>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label"><?= h(t('animal.form.veterinarian')) ?></label>
|
||||
<select class="form-select directory-contact-select medical-vet-select" name="vet_contact_id"
|
||||
data-pair="<?= h($directoryFieldId) ?>" data-new-target="<?= h($directoryFieldId) ?>VetNew" required>
|
||||
<option value="">— <?= h(t('common.choose')) ?> —</option>
|
||||
<?php foreach ($veterinarians as $contact): ?><option value="<?= (int)$contact['id'] ?>"
|
||||
data-organization="<?= (int)($contact['organization_id'] ?? 0) ?>"><?= h($contact['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
<option value="new">+ <?= h(t('animal.create_vet')) ?></option>
|
||||
</select>
|
||||
<input class="form-control mt-2 d-none" id="<?= h($directoryFieldId) ?>VetNew" name="new_vet_name"
|
||||
placeholder="<?= h(t('animal.new_vet_name')) ?>">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-hint"><?= h(t('animal.medical_pair_hint')) ?></div>
|
||||
</div>
|
||||
63
app/Views/_expense_modal.php
Normal file
63
app/Views/_expense_modal.php
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<?php if ($canViewMedical): ?>
|
||||
<div class="modal modal-blur fade" id="modalExpense" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
<form class="modal-content" method="post" action="/animal/add-expense">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h5 class="modal-title"><?= h(t('expense.add')) ?></h5>
|
||||
<div class="text-muted small"><?= h(t('expense.history_help')) ?></div>
|
||||
</div><button class="btn-close" type="button" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body"><input type="hidden" name="animal_id" value="<?= (int)$animal['id'] ?>">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8"><label
|
||||
class="form-label required"><?= h(t('expense.act_product')) ?></label><select
|
||||
class="form-select" name="tariff_id" id="expenseTariff" required>
|
||||
<option value="">— <?= h(t('expense.choose')) ?> —</option><?php $lastClinic = '';
|
||||
foreach ($expenseTariffs as $t):if ($lastClinic !== $t['clinic_name']):if ($lastClinic !== ''):?></optgroup><?php endif;
|
||||
$lastClinic = $t['clinic_name'];?><optgroup label="<?= h($lastClinic) ?>"><?php endif;?><option
|
||||
value="<?= (int)$t['id'] ?>" data-price="<?= (int)$t['effective_cents'] ?>"
|
||||
data-discount="<?= h((string)$t['discount_percent']) ?>"><?= h($t['label']) ?> ·
|
||||
<?= number_format($t['effective_cents'] / 100, 2, ',', ' ') ?> €</option><?php endforeach;
|
||||
if ($lastClinic !== ''):?></optgroup><?php endif;?>
|
||||
</select>
|
||||
<div class="form-hint" id="expenseHint"><?= h(t('expense.price_after_discount')) ?></div>
|
||||
</div>
|
||||
<div class="col-md-4"><label class="form-label"><?= h(t('common.date')) ?></label><input
|
||||
class="form-control" type="date" name="occurred_on" value="<?= date('Y-m-d') ?>" required>
|
||||
</div>
|
||||
<div class="col-md-4"><label class="form-label"><?= h(t('expense.quantity')) ?></label><input
|
||||
class="form-control" type="number" min="0.01" step="0.01" name="quantity" value="1"
|
||||
required></div>
|
||||
<div class="col-md-8"><label class="form-label"><?= h(t('expense.comment')) ?></label><input
|
||||
class="form-control" name="notes" placeholder="<?= h(t('expense.comment_placeholder')) ?>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer"><button class="btn me-auto" type="button"
|
||||
data-bs-dismiss="modal"><?= h(t('common.cancel')) ?></button><button
|
||||
class="btn btn-primary"><?= h(t('expense.save')) ?></button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const s = document.getElementById('expenseTariff'),
|
||||
h = document.getElementById('expenseHint'),
|
||||
template = < ? = json_encode(t('expense.unit_estimate'), JSON_UNESCAPED_UNICODE |
|
||||
JSON_UNESCAPED_SLASHES) ? > ,
|
||||
empty = < ? = json_encode(t('expense.price_after_discount'), JSON_UNESCAPED_UNICODE |
|
||||
JSON_UNESCAPED_SLASHES) ? > ;
|
||||
s?.addEventListener('change', () => {
|
||||
const o = s.selectedOptions[0],
|
||||
price = (Number(o?.dataset.price || 0) / 100).toLocaleString( < ? = json_encode(I18n::
|
||||
locale() === 'fr' ? 'fr-FR' : 'en-GB') ? > , {
|
||||
minimumFractionDigits: 2
|
||||
});
|
||||
h.textContent = o?.value ? template.replace(':price', price).replace(':discount', o.dataset
|
||||
.discount) : empty;
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
37
app/Views/_expense_summary.php
Normal file
37
app/Views/_expense_summary.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php if ($canViewMedical): ?>
|
||||
<div class="card mt-3">
|
||||
<div class="card-body d-flex flex-wrap align-items-center gap-3">
|
||||
<?php $expenseCount = count($expenses ?? []); ?><div class="me-auto">
|
||||
<div class="subheader"><?= h(t('expense.estimated_vet')) ?></div>
|
||||
<div class="text-muted small"><?= h(t('expense.recorded', ['count' => $expenseCount])) ?></div>
|
||||
</div>
|
||||
<div class="h2 mb-0"><?= number_format(($expenseTotal ?? 0) / 100, 2, ',', ' ') ?> €</div>
|
||||
<?php if (!empty($expenses)): ?><button class="btn btn-sm btn-outline-secondary" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#animalExpenseDetails"
|
||||
aria-expanded="false"><?= h(t('expense.details')) ?></button><?php endif; ?>
|
||||
</div>
|
||||
<?php if (!empty($expenses)): ?><div class="collapse" id="animalExpenseDetails">
|
||||
<div class="table-responsive border-top">
|
||||
<table class="table table-sm table-vcenter mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?= h(t('common.date')) ?></th>
|
||||
<th><?= h(t('expense.expense')) ?></th>
|
||||
<th><?= h(t('pricing.structure')) ?></th>
|
||||
<th><?= h(t('pricing.discount')) ?></th>
|
||||
<th class="text-end"><?= h(t('expense.total')) ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><?php foreach ($expenses as $e): ?><tr>
|
||||
<td><?= h(date('d/m/Y', strtotime($e['occurred_on']))) ?></td>
|
||||
<td><?= h($e['label']) ?><?php if ((float)$e['quantity'] !== 1.0): ?><span
|
||||
class="text-muted small"> × <?= h((string)$e['quantity']) ?></span><?php endif; ?></td>
|
||||
<td><?= h($e['clinic_name'] ?: '—') ?></td>
|
||||
<td><?= number_format((float)$e['discount_percent'], 2, ',', ' ') ?> %</td>
|
||||
<td class="text-end fw-bold"><?= number_format($e['total_cents'] / 100, 2, ',', ' ') ?> €</td>
|
||||
</tr><?php endforeach; ?></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div><?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
15
app/Views/_expense_tariff_field.php
Normal file
15
app/Views/_expense_tariff_field.php
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<div class="col-12"><label class="form-label"><?= h(t('animal.tariff_record')) ?> <span
|
||||
class="text-muted">(<?= h(mb_strtolower(t('common.optional'))) ?>)</span></label><select class="form-select"
|
||||
name="tariff_id">
|
||||
<option value=""><?= h(t('animal.no_expense')) ?></option><?php $expenseClinic = '';
|
||||
foreach (($expenseTariffs ?? []) as $t):if ($expenseClinic !== $t['clinic_name']):if ($expenseClinic !== ''):?>
|
||||
</optgroup><?php endif;
|
||||
$expenseClinic = $t['clinic_name'];?><optgroup label="<?= h($expenseClinic) ?>"><?php endif;?><option
|
||||
value="<?= (int)$t['id'] ?>"><?= h($t['label']) ?> ·
|
||||
<?= number_format($t['effective_cents'] / 100, 2, ',', ' ') ?> €
|
||||
<?= h(t('animal.net')) ?><?= (float)$t['discount_percent'] > 0 ? ' (−' . number_format((float)$t['discount_percent'], 0) . ' %)' : '' ?>
|
||||
</option><?php endforeach;
|
||||
if ($expenseClinic !== ''):?></optgroup><?php endif;?>
|
||||
</select>
|
||||
<div class="form-hint"><?= h(t('animal.expense_hint')) ?></div>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue