diff --git a/CHANGELOG.md b/CHANGELOG.md index 85b35b6..f601345 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,142 @@ 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.35] — 2026-10-12 + +### Markdown enrichi sur le site public + +- ajout des tableaux Markdown, avec alignement des colonnes et défilement adapté aux mobiles ; +- prise en charge des listes numérotées et à cocher, citations, séparateurs, blocs de code, code en ligne et texte barré ; +- ajout d’un bouton insérant un modèle de tableau dans l’éditeur ; +- maintien de l’échappement du HTML brut, des protocoles de liens autorisés et des images limitées à la médiathèque publique ; +- tests de non-régression couvrant le rendu enrichi et les tentatives d’injection. + +## [1.0.0-rc.34] — 2026-10-12 + +### Pages libres et navigation du site public + +- ajout de pages publiques personnalisées en nombre libre depuis les paramètres ; +- publication automatique dans le menu avec une adresse lisible et compatible avec les deux modes de routage ; +- réorganisation par glisser-déposer, complétée par des boutons monter/descendre utilisables sur mobile et au clavier ; +- présentation repliable des pages Accueil, Présentation, Dons, Contact et Mentions légales afin d’alléger l’écran ; +- suppression des troncatures applicatives sur les textes du site public et redimensionnement automatique des zones de saisie ; +- ajout, modification, masquage et suppression contrôlée des pages personnalisées. + +## [1.0.0-rc.33] — 2026-10-11 + +### Réseaux sociaux dans le menu public + +- ajout des icônes des réseaux configurés immédiatement après le lien Contact ; +- masquage automatique des réseaux sans adresse renseignée ; +- ouverture sécurisée dans un nouvel onglet et libellés accessibles aux lecteurs d’écran ; +- réutilisation d’un catalogue unique entre le menu et la page Contact. + +## [1.0.0-rc.32] — 2026-10-02 + +### Statistiques par mois et par période + +- ajout des modes « Année », « Mois » et « Période personnalisée » en complément de la vue globale ; +- application de la période aux entrées, sorties, adoptions, décès, naissances, vaccinations, traitements et notes médicales ; +- filtrage cohérent des coûts vétérinaires, factures, structures et communes sur les mêmes dates ; +- comparaison automatique avec la période précédente de même durée, tout en conservant la comparaison année contre année ; +- conservation de la période lors de la navigation vers une commune ou une structure vétérinaire. + +## [1.0.0-rc.31] — 2026-10-01 + +### Navigation et synchronisation des dossiers animaux + +- correction du lien ouvrant la fiche d’un adoptant depuis la fiche animale ; +- ajout d’une colonne « Actions » et d’un bouton « Modifier » explicite dans la liste des vaccins ; +- transformation du rafraîchissement i-CAD en formulaire utilisable même sans JavaScript ; +- contrôle des erreurs HTTP et maintien de l’actualisation dynamique de la fiche i-CAD ; +- validation réelle du connecteur avec une fiche i-CAD, complétée par des tests de non-régression. + +## [1.0.0-rc.30] — 2026-10-01 + +### Fiabilisation des dossiers et du suivi médical + +- correction de la fausse déclaration de décès de Lucci après vérification ASM3 et protection contre les fiches de décès résiduelles lors d’une correction de statut ; +- réparation de l’actualisation I-CAD après la suppression de l’ancienne adresse `/pro` ; +- exclusion uniforme des animaux adoptés dans les rappels et traitements du tableau de bord ; +- affichage de la date du test FIV/FeLV et possibilité d’en comptabiliser le tarif ; +- ajout direct d’un vaccin depuis sa liste, fabricants suggérés et tri alphabétique ; +- filtrage des tarifs par clinique pour les vaccins, tests et chirurgies ; +- propositions normalisées d’intitulés chirurgicaux et catalogue médical administrable ; +- lien direct entre l’adoptant et sa fiche d’annuaire, ainsi que de nouveaux rôles de professionnels animaliers ; +- amélioration du menu mobile, des zones de texte du site public et des longues listes de rappels ; +- ajout d’un script protégé permettant de réinitialiser automatiquement une instance de démonstration à heure fixe ; +- nouvelle icône Android issue du logo officiel de l’application. + +## [1.0.0-rc.29] — 2026-09-22 + +### Corrections du suivi médical et du site public + +- correction du formulaire d’édition des administrations de vermifuge / anti-puce ; +- modification des traitements déjà saisis et correction des intitulés de produits médicaux ; +- accès plus visible aux vermifuges dans l’onglet médical ; +- chargement du CSS personnalisé après les styles ordinaires du site public ; +- affichage de la date de naissance dans le résumé de la fiche animale. + +## [1.0.0-rc.28] — 2026-09-15 + +### Saisies médicales corrigibles + +- possibilité de modifier une vaccination ou une administration de vermifuge / anti-puce directement depuis la fiche animale ; +- préremplissage du formulaire et contrôle de l’appartenance de l’entrée à l’animal ; +- absence de nouveau déstockage, de nouvelle dépense ou d’application à la portée pendant une correction ; +- ajout d’une trace de correction dans l’historique ; +- harmonisation du libellé « Vermifuge / anti-puce » dans toute l’interface ; +- sélection de « Chat Européen » comme race initiale lors de la création d’une fiche de chat. + +## [1.0.0-rc.27] — 2026-09-15 + +### Recherche globale des animaux + +- ajout des numéros de puce et de tatouage actuels à la recherche, avec maintien du champ historique ; +- recherche insensible aux majuscules, minuscules et accents dans tous les groupes de résultats ; +- affichage du numéro d’identification dans le résultat animal correspondant ; +- ajout de tests couvrant notamment `Bréhat`, `Brehat`, `BREHAT`, `bReHaT` et un numéro de puce. + +## [1.0.0-rc.26] — 2026-09-15 + +### Données animales et suivi vétérinaire + +- suppression sûre de l’ancienne contrainte SQLite limitant les salles à « silver » et « twix » ; +- reconstruction transactionnelle de la table des animaux avec conservation de toutes les fiches, relations, vues et index ; +- ajout d’un test de sauvegarde réelle contrôlant le nombre d’animaux, l’intégrité SQLite et les clés étrangères ; +- suppression des avertissements `open_basedir` pendant la détection de l’outil d’optimisation des images. +- remplacement des anciens états I-CAD par « Inconnu », « Enregistré », « En attente de changement » et « Propriétaire changé » ; +- retrait du champ redondant « Administré par » lors de la saisie d’un vaccin ; +- affichage du cabinet vétérinaire dans le récapitulatif des vaccinations et son export. + +## [1.0.0-rc.25] — 2026-09-15 + +### Modification des portées + +- ajout d’un écran permettant de corriger les parents et la date de naissance d’une portée ; +- ajout de nouvelles fiches ou de chats existants à une portée déjà enregistrée ; +- accès direct à la modification de chaque chaton depuis la portée ; +- possibilité de détacher un chaton sans supprimer sa fiche ni aucune de ses données ; +- contrôles empêchant de rattacher un parent comme chaton de la même portée. + +## [1.0.0-rc.24] — 2026-09-15 + +### Permissions et bases historiques + +- correction de la routine de sécurité qui empêchait périodiquement Nginx de traverser le dossier de données YunoHost ; +- réparation quotidienne des droits des images publiques sans exposer la base ni les documents privés ; +- droits immédiatement adaptés lors de la création d’un dossier d’images publiques ; +- normalisation des fichiers servis par le Web après une mise à jour autonome ou un retour arrière. +- harmonisation des deux anciens schémas de l’historique afin de rétablir la création des portées sur les bases historiques. + +## [1.0.0-rc.23] — 2026-09-15 + +### Création des portées + +- retrait d’un bloc de téléversement résiduel qui interrompait systématiquement l’enregistrement d’une portée ; +- correction du message trompeur « Le téléversement a échoué » rencontré sans aucun fichier envoyé ; +- compatibilité des événements de portée, de soins, de décès, de chirurgie et de placement avec l’historique des bases existantes. + ## [1.0.0-rc.22] — 2026-09-15 ### Disponibilité après adoption diff --git a/HISTORIQUE.md b/HISTORIQUE.md index 16a2e1b..5b92140 100644 --- a/HISTORIQUE.md +++ b/HISTORIQUE.md @@ -10,6 +10,55 @@ L’import depuis Animal Shelter Manager a été conçu comme un parcours pruden 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.35` — Des contenus publics réellement structurés + +Le moteur Markdown léger apprend à présenter tableaux, listes numérotées ou à cocher, citations, code et séparateurs sans introduire de dépendance externe. Les tableaux restent lisibles sur smartphone et le HTML non fiable demeure neutralisé. + +## `1.0.0-rc.34` — Un site public librement structuré + +Les associations peuvent désormais créer autant de pages éditoriales que nécessaire, les publier dans la navigation et choisir leur ordre par glisser-déposer. L’écran d’édition adopte des panneaux repliables et les contenus longs ne sont plus tronqués par Globinours. + +## `1.0.0-rc.33` — Réseaux visibles dès la navigation + +Le site public reprend désormais dans son menu les réseaux sociaux renseignés par l’association. Les icônes restent discrètes, accessibles et cohérentes avec les liens déjà présentés sur la page Contact. + +## `1.0.0-rc.32` — Statistiques à la période souhaitée + +Les bilans ne sont plus limités à une année complète. Une association peut désormais analyser un mois précis ou choisir librement deux dates ; les volumes d’activité, profils, coûts vétérinaires et comparaisons utilisent tous le même intervalle. + +## `1.0.0-rc.31` — Parcours de correction plus fiables + +Les liens entre animaux et adoptants conduisent désormais directement à la bonne fiche d’annuaire. La modification des vaccins devient explicite et la synchronisation i-CAD ne dépend plus entièrement du JavaScript : un formulaire serveur assure le rafraîchissement même lorsque l’interface dynamique rencontre un problème. + +## `1.0.0-rc.30` — Corrections issues de la validation terrain + +Une passe croisée avec les sauvegardes ASM3 a corrigé une collision historique entre Gribouille et Lucci. Le suivi médical gagne une date de test FIV/FeLV, des catalogues administrables et une meilleure association entre tarifs et cliniques. La synchronisation I-CAD, la navigation mobile et l’identité Android ont également été adaptées aux conditions réelles d’utilisation. + +## `1.0.0-rc.29` — Suivi médical et présentation + +Les traitements et les intitulés des produits médicaux peuvent être corrigés après leur saisie. Le formulaire des vermifuges s’ouvre de nouveau correctement. La fiche animale montre sa date de naissance et le CSS personnalisé du site public prend priorité sur les styles standard. + +## `1.0.0-rc.28` — Corrections des saisies médicales + +Les vaccinations et administrations de vermifuges ou d’anti-puces peuvent être corrigées sans provoquer une seconde sortie de stock ni une nouvelle dépense. Les nouvelles fiches de chats proposent également la race Chat Européen par défaut. + +## `1.0.0-rc.27` — Recherche tolérante + +La recherche globale retrouve les animaux par leur numéro de puce ou de tatouage et rapproche les termes indépendamment de leur casse et de leurs accents. + +## `1.0.0-rc.26` — Salles réellement configurables + +Retrait de la dernière contrainte héritée qui limitait encore les fiches animales aux deux premières salles historiques. La migration conserve les animaux et toutes leurs relations tout en autorisant les salles définies dans la configuration. +Les états d’enregistrement I-CAD suivent désormais le changement de propriétaire, tandis que le suivi vaccinal s’appuie sans doublon sur le cabinet et le vétérinaire de l’annuaire. + +## `1.0.0-rc.25` — Portées modifiables + +Les portées ne sont plus figées après leur création : parents et date peuvent être corrigés, tandis que les chatons peuvent être ajoutés, modifiés ou détachés sans perte de leur fiche. + +## `1.0.0-rc.24` — Permissions durables + +Séparation plus stricte entre les données privées et les médias publics afin que les réparations de sécurité et les mises à jour conservent l’accès de Nginx aux images et aux ressources de l’interface. Les deux structures d’historique rencontrées sur les installations anciennes sont également réunifiées pour sécuriser les portées et les autres écritures métier. + ## `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. @@ -97,3 +146,7 @@ Ajout d’une température préremplie à 38,5 °C lors du contrôle de chaque a ## `1.0.0-rc.22` — Disponibilité après adoption Une adoption finalisée rend désormais automatiquement l’animal indisponible à l’adoption afin que son état administratif et sa visibilité restent cohérents. + +## `1.0.0-rc.23` — Fiabilité des portées + +Suppression d’un ancien bloc de téléversement qui bloquait la création des fratries et harmonisation de leur écriture dans l’historique des animaux. diff --git a/README.md b/README.md index fd19de9..64d813f 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ L’application est développée à partir de besoins rencontrés sur le terrain - placements, adoptions, retours, décès, archives et corbeille ; - agenda partagé, rappels médicaux et abonnement iCalendar privé ; - pharmacie, lots, péremptions et mouvements de stock ; -- factures vétérinaires, règlements, justificatifs et statistiques de coûts ; +- factures vétérinaires, règlements, justificatifs et statistiques de coûts par année, mois ou période libre ; - registre des entrées et sorties, exports CSV et dossiers de subvention ; - recherche globale avec contrôle des résultats selon le rôle ; - site public facultatif pour les animaux proposés à l’adoption ; @@ -200,7 +200,7 @@ L’option `--apply` ne doit être utilisée qu’après lecture des rapports. L ## État du projet -La candidate actuelle est `1.0.0-rc.22`. La branche 1.0 est gelée fonctionnellement : seuls les défauts bloquants découverts pendant les validations terrain sont corrigés avant `1.0.0`. +La candidate actuelle est `1.0.0-rc.35`. Le site public accepte des pages personnalisées réordonnables et un Markdown enrichi, notamment les tableaux responsives. La branche 1.0 reste en stabilisation avant `1.0.0`. Le contrôle de stabilisation est disponible dans [docs/AUDIT-1.0.md](docs/AUDIT-1.0.md). diff --git a/VERSION b/VERSION index 49eb7f4..63f99b9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0-rc.22 +1.0.0-rc.35 diff --git a/app/Controllers/AnimalBrowseActions.php b/app/Controllers/AnimalBrowseActions.php index 845e5f9..3d4c912 100644 --- a/app/Controllers/AnimalBrowseActions.php +++ b/app/Controllers/AnimalBrowseActions.php @@ -518,7 +518,7 @@ trait AnimalBrowseActions // Adoptions $stmt = $db->prepare(' - SELECT adopter_name, adopter_address, adopter_postal_code, adopter_city, adopter_country, adopter_phone, adopter_email, adoption_date + SELECT adopter_name, adopter_address, adopter_postal_code, adopter_city, adopter_country, adopter_phone, adopter_email, adoption_date, adopter_contact_id FROM adoptions WHERE animal_id = :id ORDER BY id DESC @@ -612,12 +612,11 @@ trait AnimalBrowseActions // Vaccins $stmt = $db->prepare(' - SELECT v.*, rv.name AS vaccine_name, COALESCE(dc.name, c.name) AS clinic_name,COALESCE(v.administered_by_name,au.display_name,au.username) AS administrator_name + SELECT v.*, rv.name AS vaccine_name, COALESCE(dc.name, c.name) AS clinic_name FROM vaccinations v JOIN ref_vaccines rv ON rv.id = v.vaccine_id LEFT JOIN clinics c ON c.id = v.clinic_id LEFT JOIN directory_contacts dc ON dc.id = v.clinic_contact_id - LEFT JOIN users au ON au.id=v.administered_by_user_id WHERE v.animal_id = :id ORDER BY v.done_date DESC, v.id DESC LIMIT 50 @@ -651,7 +650,10 @@ trait AnimalBrowseActions $ref_med_administration = $db ->query('SELECT id, name, accro FROM ref_med_administration ORDER BY id') ->fetchAll(); - $vaccines = $db->query('SELECT id, name FROM ref_vaccines ORDER BY id')->fetchAll(); + $vaccines = $db->query('SELECT id, name FROM ref_vaccines ORDER BY name COLLATE NOCASE,id')->fetchAll(); + $vaccineManufacturers = $db + ->query('SELECT id,name FROM ref_vaccine_manufacturers WHERE active=1 ORDER BY name COLLATE NOCASE,id') + ->fetchAll(PDO::FETCH_ASSOC); $inventoryBatches = $db ->query( 'SELECT b.id,b.batch_number,b.expires_on,b.quantity,p.name,p.unit,p.category,p.reference_id FROM inventory_batches b JOIN inventory_products p ON p.id=b.product_id WHERE b.quantity>0 AND p.active=1 ORDER BY p.category,p.name COLLATE NOCASE,date(b.expires_on)', @@ -833,7 +835,7 @@ trait AnimalBrowseActions CASE WHEN COALESCE(v.lot,'')!='' THEN '\nLot : ' || v.lot ELSE '' END || CASE WHEN COALESCE(v.manufacturer,'')!='' THEN '\nFabricant : ' || v.manufacturer ELSE '' END || CASE WHEN COALESCE(v.batch_expires_on,'')!='' THEN '\nExpiration du lot : ' || v.batch_expires_on ELSE '' END || - CASE WHEN COALESCE(v.administered_by_name,'')!='' THEN '\nAdministré par : ' || v.administered_by_name WHEN v.administered_by_user_id IS NOT NULL THEN '\nAdministré par : ' || COALESCE((SELECT display_name FROM users WHERE id=v.administered_by_user_id),(SELECT username FROM users WHERE id=v.administered_by_user_id),'Compte inconnu') ELSE '' END || + CASE WHEN v.clinic_contact_id IS NOT NULL THEN '\nCabinet vétérinaire : ' || COALESCE((SELECT name FROM directory_contacts WHERE id=v.clinic_contact_id),'Cabinet inconnu') WHEN v.clinic_id IS NOT NULL THEN '\nCabinet vétérinaire : ' || COALESCE((SELECT name FROM clinics WHERE id=v.clinic_id),'Cabinet inconnu') ELSE '' END || CASE WHEN COALESCE(v.notes,'')!='' THEN '\nNotes : ' || v.notes ELSE '' END ) AS details, datetime(v.done_date || ' 12:00:00') AS at @@ -959,6 +961,7 @@ trait AnimalBrowseActions 'surgeries' => $surgeries, 'ref_med_administration' => $ref_med_administration, 'vaccines' => $vaccines, + 'vaccineManufacturers' => $vaccineManufacturers, 'inventoryBatches' => $inventoryBatches, 'activeUsers' => $activeUsers, 'clinics' => $clinics, diff --git a/app/Controllers/AnimalMedicalActions.php b/app/Controllers/AnimalMedicalActions.php index 3cd6172..8564d5f 100644 --- a/app/Controllers/AnimalMedicalActions.php +++ b/app/Controllers/AnimalMedicalActions.php @@ -32,6 +32,8 @@ trait AnimalMedicalActions $testDate = date('Y-m-d'); } $testNotes = trim((string) ($_POST['test_notes'] ?? '')); + $testTariffId = (int) ($_POST['test_tariff_id'] ?? 0); + $testClinicId = (int) ($_POST['clinic_contact_id'] ?? 0); $conditions = is_array($_POST['conditions'] ?? null) ? $_POST['conditions'] : []; $db = DB::pdo(); @@ -53,6 +55,7 @@ trait AnimalMedicalActions UPDATE animals SET fiv_status = :fiv_status, felv_status = :felv_status, + fiv_felv_test_date = :test_date, fiv = :fiv, felv = :felv, updated_at = datetime('now') @@ -61,6 +64,7 @@ trait AnimalMedicalActions )->execute([ ':fiv_status' => $fivStatus, ':felv_status' => $felvStatus, + ':test_date' => $testDate, ':fiv' => $fivStatus === 'pos' ? 1 : 0, ':felv' => $felvStatus === 'pos' ? 1 : 0, ':id' => $animalId, @@ -98,6 +102,15 @@ trait AnimalMedicalActions } } + if ($testTariffId > 0) { + $tariffCheck = $db->prepare("SELECT 1 FROM clinic_tariffs WHERE id=? AND clinic_contact_id=? AND category='test' AND active=1"); + $tariffCheck->execute([$testTariffId, $testClinicId]); + if (!$testClinicId || !$tariffCheck->fetchColumn()) { + throw new RuntimeException(t('error.tariff_not_found')); + } + PricingService::record($db, $animalId, $testTariffId, 1, $testDate, $testNotes); + } + $conditionSelect = $db->prepare( 'SELECT status FROM animal_health_conditions WHERE id = :id AND animal_id = :animal_id', ); @@ -400,6 +413,7 @@ trait AnimalMedicalActions } $id = (int) ($_POST['animal_id'] ?? 0); + $entryId = (int) ($_POST['entry_id'] ?? 0); $medId = (int) ($_POST['medication_id'] ?? 0); $createMedication = !empty($_POST['create_medication']); if ($id <= 0 || (!$createMedication && $medId <= 0)) { @@ -429,6 +443,15 @@ trait AnimalMedicalActions } $db = DB::pdo(); + if ($entryId > 0) { + $existing = $db->prepare('SELECT 1 FROM treatments WHERE id=:entry AND animal_id=:animal'); + $existing->execute([':entry' => $entryId, ':animal' => $id]); + if (!$existing->fetchColumn()) { + http_response_code(404); + echo h(t('error.not_found')); + return; + } + } if ($createMedication) { $medicationName = trim((string) ($_POST['new_medication_name'] ?? '')); $medicationMolecule = trim((string) ($_POST['new_medication_molecule'] ?? '')); @@ -475,12 +498,7 @@ trait AnimalMedicalActions 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, + $values = [ ':mid' => $medId, ':route' => $route !== '' ? $route : null, ':dose' => $dose, @@ -494,16 +512,32 @@ trait AnimalMedicalActions ':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'); + ]; + if ($entryId > 0) { + $values[':entry'] = $entryId; + $db->prepare('UPDATE treatments SET medication_id=:mid,route=:route,dose_text=:dose,start_date=:start, + end_date=:end,notes=:notes,clinic_id=:clinic_id,vet_name=:vet_name, + clinic_contact_id=:clinic_contact_id,vet_contact_id=:vet_contact_id, + give_morning=:give_morning,give_evening=:give_evening,give_as_needed=:give_as_needed + WHERE id=:entry')->execute($values); + } else { + $values[':aid'] = $id; + $values[':created_by'] = Auth::id(); + $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)') + ->execute($values); + 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é', + $entryId > 0 ? 'Traitement corrigé' : 'Traitement ajouté', "Médicament #$medId\nDose : $dose\nDébut : $start" . ($end !== '' ? "\nFin : $end" : '') . ($notes !== '' ? "\nNotes : $notes" : ''), @@ -562,15 +596,30 @@ trait AnimalMedicalActions $db = DB::pdo(); $id = (int) ($_POST['animal_id'] ?? 0); + $entryId = (int) ($_POST['entry_id'] ?? 0); $vacId = (int) ($_POST['vaccine_id'] ?? 0); if ($id <= 0 || $vacId <= 0) { http_response_code(400); echo h(t('error.bad_request')); return; } + if ($entryId > 0) { + $stmt = $db->prepare('SELECT 1 FROM vaccinations WHERE id=:entry AND animal_id=:animal'); + $stmt->execute([':entry' => $entryId, ':animal' => $id]); + if (!$stmt->fetchColumn()) { + http_response_code(404); + echo h(t('error.not_found')); + return; + } + } $done = trim((string) ($_POST['done_date'] ?? date('Y-m-d'))); $due = trim((string) ($_POST['due_date'] ?? '')); + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $done) || ($due !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $due))) { + http_response_code(400); + echo h(t('error.bad_request')); + return; + } // Si due_date est fourni, on respecte. // Sinon auto selon protocole. if ($due === '') { @@ -602,9 +651,9 @@ trait AnimalMedicalActions 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' + WHERE v.animal_id = :aid AND v.id <> :entry AND upper(coalesce(rv.family,'')) = 'L' "); - $stmt->execute([':aid' => $id]); + $stmt->execute([':aid' => $id, ':entry' => $entryId]); $countL = (int) $stmt->fetchColumn(); $isPrimoL = $countL === 0; } @@ -630,15 +679,6 @@ trait AnimalMedicalActions 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; @@ -667,11 +707,7 @@ trait AnimalMedicalActions } $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([ + $values = [ ':aid' => $id, ':vid' => $vacId, ':done' => $done, @@ -679,38 +715,50 @@ trait AnimalMedicalActions ':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'); + ]; + if ($entryId > 0) { + unset($values[':aid'], $values[':created_by']); + $values[':entry'] = $entryId; + $stmt = $db->prepare(' + UPDATE vaccinations SET vaccine_id=:vid,done_date=:done,due_date=:due,lot=:lot, + manufacturer=:manufacturer,batch_expires_on=:batch_expiry,notes=:notes,clinic_id=:clinic_id, + vet_name=:vet_name,clinic_contact_id=:clinic_contact_id,vet_contact_id=:vet_contact_id + WHERE id=:entry + '); + $stmt->execute($values); + } else { + $stmt = $db->prepare(' + INSERT INTO vaccinations(animal_id, vaccine_id, done_date, due_date, lot,manufacturer,batch_expires_on, notes, clinic_id, vet_name, clinic_contact_id, vet_contact_id, created_by) + VALUES(:aid,:vid,:done,:due,:lot,:manufacturer,:batch_expiry,:notes,:clinic_id,:vet_name,:clinic_contact_id,:vet_contact_id,:created_by) + '); + $stmt->execute($values); + 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é', + $entryId > 0 ? 'Vaccin corrigé' : '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) - : ''), + "\nCabinet vétérinaire : " . ($clinicContact['name'] ?? ''), ); $db->prepare("UPDATE animals SET updated_at = datetime('now') WHERE id = :id")->execute([':id' => $id]); diff --git a/app/Controllers/AnimalRecordActions.php b/app/Controllers/AnimalRecordActions.php index 9935cb6..187e74e 100644 --- a/app/Controllers/AnimalRecordActions.php +++ b/app/Controllers/AnimalRecordActions.php @@ -182,6 +182,7 @@ trait AnimalRecordActions 'adoption_unavailability_reason' => '', 'website_published' => 0, 'website_description' => '', + 'breed' => 'Chat Européen', 'color' => '', 'hair_type' => '', 'birth_date' => '', @@ -376,7 +377,10 @@ trait AnimalRecordActions } $identificationDate = trim((string) ($_POST['identification_date'] ?? '')); $identificationRegistration = (string) ($_POST['identification_registration_status'] ?? 'unknown'); - if (!in_array($identificationRegistration, ['unknown', 'pending', 'registered'], true)) { + if ($identificationRegistration === 'pending') { + $identificationRegistration = 'change_pending'; + } + if (!in_array($identificationRegistration, ['unknown', 'registered', 'change_pending', 'owner_changed'], true)) { $identificationRegistration = 'unknown'; } if ($chip_id === '') { @@ -742,6 +746,18 @@ trait AnimalRecordActions ':current_lng' => $currentLng, ]); + // Un décès est une donnée structurée distincte du statut. Lors d'une correction + // explicite d'un animal déclaré à tort décédé, ne pas laisser une ancienne fiche + // de décès continuer à alimenter la bannière et les statistiques. + $oldWasDeceased = in_array(strtolower((string) ($old['status'] ?? '')), ['decede', 'décédé'], true); + $isNowDeceased = in_array(strtolower($status), ['decede', 'décédé'], true); + if ($oldWasDeceased && !$isNowDeceased) { + $db->prepare('DELETE FROM animal_deaths WHERE animal_id=:id')->execute([':id' => $id]); + $db->prepare( + "INSERT INTO animal_history(animal_id,action,type,label,details,user_id) VALUES(:id,'death_correction','update','Décès annulé','La fiche de décès a été retirée après correction du statut de l’animal.',:user)", + )->execute([':id' => $id, ':user' => Auth::id()]); + } + if ($old) { LocationHistoryService::record( $db, diff --git a/app/Controllers/AnimalRelationshipActions.php b/app/Controllers/AnimalRelationshipActions.php index e799555..43cc1d4 100644 --- a/app/Controllers/AnimalRelationshipActions.php +++ b/app/Controllers/AnimalRelationshipActions.php @@ -8,8 +8,10 @@ trait AnimalRelationshipActions { $db = DB::pdo(); $sourceAnimalId = (int) ($_GET['animal_id'] ?? 0); + $existingLitterId = (int) ($_GET['litter_id'] ?? 0); $motherId = 0; $fatherId = 0; + $birthDate = ''; if ($sourceAnimalId > 0) { $stmt = $db->prepare( @@ -30,6 +32,20 @@ trait AnimalRelationshipActions } } + if ($existingLitterId > 0) { + $stmt = $db->prepare('SELECT mother_id,father_id,birth_date FROM litters WHERE id=:id'); + $stmt->execute([':id' => $existingLitterId]); + $existingLitter = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$existingLitter) { + http_response_code(404); + echo h(t('litter.not_found')); + return; + } + $motherId = (int) ($existingLitter['mother_id'] ?? 0); + $fatherId = (int) ($existingLitter['father_id'] ?? 0); + $birthDate = (string) ($existingLitter['birth_date'] ?? ''); + } + $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 @@ -53,6 +69,8 @@ trait AnimalRelationshipActions 'sourceAnimalId' => $sourceAnimalId, 'motherId' => $motherId, 'fatherId' => $fatherId, + 'birthDate' => $birthDate, + 'existingLitterId' => $existingLitterId, 'mothers' => $mothers, 'fathers' => $fathers, 'existingKittens' => $existingKittens, @@ -66,6 +84,7 @@ trait AnimalRelationshipActions return; } $db = DB::pdo(); + $existingLitterId = (int) ($_POST['existing_litter_id'] ?? 0); $motherId = (int) ($_POST['mother_id'] ?? 0) ?: null; $fatherId = (int) ($_POST['father_id'] ?? 0) ?: null; $birthDate = trim((string) ($_POST['birth_date'] ?? '')) ?: null; @@ -139,20 +158,24 @@ trait AnimalRelationshipActions $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(); + if ($existingLitterId > 0) { + $stmt = $db->prepare('SELECT id,mother_id,father_id,birth_date FROM litters WHERE id=:id'); + $stmt->execute([':id' => $existingLitterId]); + $existingLitter = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$existingLitter) { + throw new RuntimeException(t('litter.not_found')); + } + $litterId = (int) $existingLitter['id']; + $motherId = (int) ($existingLitter['mother_id'] ?? 0) ?: null; + $fatherId = (int) ($existingLitter['father_id'] ?? 0) ?: null; + $birthDate = (string) ($existingLitter['birth_date'] ?? '') ?: null; + } else { + $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')) @@ -160,11 +183,14 @@ trait AnimalRelationshipActions $link = $db->prepare( 'INSERT INTO litter_kittens(litter_id, animal_id, position) VALUES(:litter, :animal, :position)', ); + $positionBase = $existingLitterId > 0 + ? (int) $db->query('SELECT COALESCE(MAX(position),0) FROM litter_kittens WHERE litter_id=' . $litterId)->fetchColumn() + : 0; foreach ($kittens as $index => $kitten) { if (!is_array($kitten)) { continue; } - $position = $index + 1; + $position = $positionBase + $index + 1; $name = trim((string) ($kitten['name'] ?? '')); if ($name === '') { $name = $baseName . '-Bébé-' . $position; @@ -205,7 +231,7 @@ trait AnimalRelationshipActions ); self::logHistory($animalId, 'create', 'Chaton ajouté avec sa portée', 'Portée n°' . $litterId); } - $position = count($kittens); + $position = $positionBase + 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); @@ -221,6 +247,113 @@ trait AnimalRelationshipActions } } + public static function litterEditForm(): void + { + $db = DB::pdo(); + $litterId = (int) ($_GET['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; + } + $parents = $db->query("SELECT id,name,sex FROM animals WHERE deleted_at IS NULL AND lower(species) IN ('chat','cat') ORDER BY name COLLATE NOCASE")->fetchAll(PDO::FETCH_ASSOC); + $stmt = $db->prepare('SELECT a.id,a.name,a.internal_code,a.sex,a.color,a.status,a.archived_at FROM litter_kittens lk JOIN animals a ON a.id=lk.animal_id WHERE lk.litter_id=:id ORDER BY lk.position,a.name COLLATE NOCASE'); + $stmt->execute([':id' => $litterId]); + render('litter_edit.php', [ + 'title' => t('litter.edit'), + 'pageDescription' => t('litter.edit_help'), + 'litter' => $litter, + 'mothers' => array_values(array_filter($parents, static fn(array $animal): bool => $animal['sex'] === 'F')), + 'fathers' => array_values(array_filter($parents, static fn(array $animal): bool => $animal['sex'] === 'M')), + 'kittens' => $stmt->fetchAll(PDO::FETCH_ASSOC), + 'sourceAnimalId' => $sourceAnimalId, + ]); + } + + public static function updateLitter(): 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); + $motherId = (int) ($_POST['mother_id'] ?? 0) ?: null; + $fatherId = (int) ($_POST['father_id'] ?? 0) ?: null; + $birthDate = trim((string) ($_POST['birth_date'] ?? '')) ?: null; + if ($litterId <= 0 || ($motherId !== null && $motherId === $fatherId)) { + http_response_code(400); + echo h(t('litter.invalid_parent')); + 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; + } + $ids = array_values(array_filter([$motherId, $fatherId])); + if ($ids) { + $placeholders = implode(',', array_fill(0, count($ids), '?')); + $stmt = $db->prepare("SELECT id,sex FROM animals WHERE id IN ($placeholders) AND deleted_at IS NULL AND lower(species) IN ('chat','cat')"); + $stmt->execute($ids); + $valid = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $animal) { + $valid[(int) $animal['id']] = $animal['sex']; + } + if (($motherId && ($valid[$motherId] ?? '') !== 'F') || ($fatherId && ($valid[$fatherId] ?? '') !== 'M')) { + http_response_code(400); + echo h(t('litter.invalid_parent')); + return; + } + $stmt = $db->prepare('SELECT COUNT(*) FROM litter_kittens WHERE litter_id=? AND animal_id IN (' . $placeholders . ')'); + $stmt->execute([$litterId, ...$ids]); + if ((int) $stmt->fetchColumn() > 0) { + http_response_code(400); + echo h(t('litter.parent_is_kitten')); + return; + } + } + $exists = $db->prepare('SELECT 1 FROM litters WHERE id=:id'); + $exists->execute([':id' => $litterId]); + if (!$exists->fetchColumn()) { + http_response_code(404); + return; + } + $db->prepare('UPDATE litters SET mother_id=:mother,father_id=:father,birth_date=:birth WHERE id=:id')->execute([':mother' => $motherId, ':father' => $fatherId, ':birth' => $birthDate, ':id' => $litterId]); + header('Location: /animal?id=' . ($sourceAnimalId ?: ($motherId ?: ($fatherId ?: 0))) . '#tab-litters'); + exit(); + } + + public static function detachLitterKitten(): void + { + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + http_response_code(405); + return; + } + $litterId = (int) ($_POST['litter_id'] ?? 0); + $animalId = (int) ($_POST['animal_id'] ?? 0); + $sourceAnimalId = (int) ($_POST['source_animal_id'] ?? 0); + $stmt = DB::pdo()->prepare('DELETE FROM litter_kittens WHERE litter_id=:litter AND animal_id=:animal'); + $stmt->execute([':litter' => $litterId, ':animal' => $animalId]); + if ($stmt->rowCount() > 0) { + self::logHistory($animalId, 'update', 'Retiré d’une portée', 'Ancienne portée n°' . $litterId); + } + $remaining = DB::pdo()->prepare('SELECT COUNT(*) FROM litter_kittens WHERE litter_id=:id'); + $remaining->execute([':id' => $litterId]); + if ((int) $remaining->fetchColumn() === 0) { + DB::pdo()->prepare('DELETE FROM litters WHERE id=:id')->execute([':id' => $litterId]); + header('Location: /animal?id=' . ($sourceAnimalId ?: $animalId) . '#tab-litters'); + exit(); + } + header('Location: /litter/edit?id=' . $litterId . '&animal_id=' . $sourceAnimalId); + exit(); + } + public static function litterAddExistingForm(): void { $db = DB::pdo(); diff --git a/app/Controllers/AnimalsController.php b/app/Controllers/AnimalsController.php index 58e5757..adaec7c 100644 --- a/app/Controllers/AnimalsController.php +++ b/app/Controllers/AnimalsController.php @@ -116,8 +116,8 @@ final class AnimalsController $db = DB::pdo(); $db->prepare( ' - INSERT INTO animal_history (animal_id, type, label, details, user_id) - VALUES (:aid, :type, :label, :details, :user) + INSERT INTO animal_history (animal_id, action, type, label, details, user_id) + VALUES (:aid, :type, :type, :label, :details, :user) ', )->execute([ ':aid' => $animalId, @@ -448,6 +448,7 @@ final class AnimalsController } $db = DB::pdo(); $id = (int) ($_POST['id'] ?? 0); + $wantsJson = str_contains(strtolower((string) ($_SERVER['HTTP_ACCEPT'] ?? '')), 'application/json'); if ($id <= 0) { http_response_code(400); echo h(t('error.bad_request')); @@ -468,6 +469,10 @@ final class AnimalsController $chipId = preg_replace('/\D+/', '', (string) ($animal['chip_id'] ?? '')); if ($chipId === '') { + if (!$wantsJson) { + header('Location: /animal?id=' . $id . '#tab-ident'); + return; + } header('Content-Type: application/json; charset=utf-8'); echo json_encode(['cached' => null, 'stale' => true, 'error' => 'chip_id vide'], JSON_UNESCAPED_UNICODE); return; @@ -489,6 +494,10 @@ final class AnimalsController $stale = $svc->isStale($cached, 86400); } + if (!$wantsJson) { + header('Location: /animal?id=' . $id . '#tab-ident'); + return; + } header('Content-Type: application/json; charset=utf-8'); echo json_encode(['cached' => $cached, 'stale' => $stale], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } diff --git a/app/Controllers/CareRoundsController.php b/app/Controllers/CareRoundsController.php index 5e899b1..1cbf058 100644 --- a/app/Controllers/CareRoundsController.php +++ b/app/Controllers/CareRoundsController.php @@ -295,7 +295,7 @@ final class CareRoundsController $roundDate = $roundInfo ? date('d/m/Y', strtotime((string) $roundInfo['round_date'])) : date('d/m/Y'); $roundPeriod = t('care.' . self::period($roundInfo['period'] ?? 'morning')); $history = $db->prepare( - "INSERT INTO animal_history(animal_id,type,label,details,user_id) VALUES(:animal,'care_round',:label,:details,:user)", + "INSERT INTO animal_history(animal_id,action,type,label,details,user_id) VALUES(:animal,'care_round','care_round',:label,:details,:user)", ); $history->execute([ ':animal' => $animalId, diff --git a/app/Controllers/DashboardController.php b/app/Controllers/DashboardController.php index 098b7b6..08ed945 100644 --- a/app/Controllers/DashboardController.php +++ b/app/Controllers/DashboardController.php @@ -119,7 +119,7 @@ final class DashboardController 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 lower(a.status) NOT IN ('decede','décédé','adopte','adopté') AND date(v.due_date) < date('now') ORDER BY date(v.due_date) ASC LIMIT 50 @@ -135,7 +135,7 @@ final class DashboardController 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 lower(a.status) NOT IN ('decede','décédé','adopte','adopté') AND date(v.due_date) >= date('now') AND date(v.due_date) <= date('now', '+7 day') ORDER BY date(v.due_date) ASC @@ -152,7 +152,7 @@ final class DashboardController 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 lower(a.status) NOT IN ('decede','décédé','adopte','adopté') 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 @@ -162,7 +162,7 @@ final class DashboardController $vaccines_30d = $stmt->fetchAll(); // Traitements en cours - $stmt = $db->prepare(' + $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 @@ -171,9 +171,10 @@ final class DashboardController 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 lower(a.status) NOT IN ('decede','décédé','adopte','adopté') ORDER BY date(t.start_date) DESC, a.name ASC LIMIT 50 - '); + "); $stmt->execute(); $treatments = $stmt->fetchAll(); @@ -185,6 +186,7 @@ final class DashboardController 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 lower(a.status) NOT IN ('decede','décédé','adopte','adopté') AND t.end_date IS NOT NULL AND date(t.end_date) < date('now') ORDER BY date(t.end_date) ASC diff --git a/app/Controllers/DeathController.php b/app/Controllers/DeathController.php index 4518524..dd7a2e7 100644 --- a/app/Controllers/DeathController.php +++ b/app/Controllers/DeathController.php @@ -175,7 +175,7 @@ final class DeathController $details .= '\nEuthanasie : oui'; } $db->prepare( - "INSERT INTO animal_history(animal_id,type,label,details,user_id) VALUES(:id,'death',:label,:details,:user)", + "INSERT INTO animal_history(animal_id,action,type,label,details,user_id) VALUES(:id,'death','death',:label,:details,:user)", )->execute([ ':id' => $id, ':label' => $isUpdate ? 'Informations de décès modifiées' : 'Décès enregistré', diff --git a/app/Controllers/DewormingController.php b/app/Controllers/DewormingController.php index dfe0e1a..fc83a4e 100644 --- a/app/Controllers/DewormingController.php +++ b/app/Controllers/DewormingController.php @@ -4,6 +4,53 @@ declare(strict_types=1); final class DewormingController { + public static function renameProduct(): void + { + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + http_response_code(405); + return; + } + if (!PermissionService::can('medical', 'edit')) { + http_response_code(403); + return; + } + $tables = ['dewormer' => 'ref_dewormers', 'medication' => 'ref_medications', 'vaccine' => 'ref_vaccines']; + $category = (string) ($_POST['category'] ?? ''); + $table = $tables[$category] ?? null; + $id = (int) ($_POST['product_id'] ?? 0); + $animalId = (int) ($_POST['animal_id'] ?? 0); + $name = trim((string) ($_POST['name'] ?? '')); + if ($table === null || $id <= 0 || $animalId <= 0 || $name === '' || mb_strlen($name) > 150) { + http_response_code(400); + echo h(t('error.bad_request')); + return; + } + $db = DB::pdo(); + $stmt = $db->prepare("SELECT name FROM $table WHERE id=:id"); + $stmt->execute([':id' => $id]); + $previous = $stmt->fetchColumn(); + if ($previous === false) { + http_response_code(404); + echo h(t('error.not_found')); + return; + } + $duplicate = $db->prepare("SELECT 1 FROM $table WHERE name=:name COLLATE NOCASE AND id<>:id"); + $duplicate->execute([':name' => $name, ':id' => $id]); + if ($duplicate->fetchColumn()) { + http_response_code(409); + echo h(t('catalog.name_exists')); + return; + } + $db->prepare("UPDATE $table SET name=:name WHERE id=:id")->execute([':name' => $name, ':id' => $id]); + AuditService::log('catalog_renamed', '/animal/product/rename', 'Intitulé de produit corrigé', 'animal', $animalId, [ + 'category' => $category, + 'product_id' => $id, + 'previous_name' => $previous, + 'new_name' => $name, + ]); + header('Location: /animal?id=' . $animalId . '#tab-med'); + } + public static function save(): void { if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { @@ -17,6 +64,7 @@ final class DewormingController } $db = DB::pdo(); $animalId = (int) ($_POST['animal_id'] ?? 0); + $entryId = (int) ($_POST['entry_id'] ?? 0); $date = trim((string) ($_POST['administered_on'] ?? '')); $dewormer = (int) ($_POST['dewormer_id'] ?? 0); if (!$animalId || !self::date($date)) { @@ -31,6 +79,15 @@ final class DewormingController echo h(t('error.animal_not_found')); return; } + if ($entryId > 0) { + $s = $db->prepare('SELECT 1 FROM dewormings WHERE id=? AND animal_id=?'); + $s->execute([$entryId, $animalId]); + if (!$s->fetchColumn()) { + http_response_code(404); + echo h(t('error.not_found')); + return; + } + } if (isset($_POST['create_dewormer'])) { $name = trim((string) ($_POST['new_dewormer_name'] ?? '')); if ($name === '') { @@ -59,7 +116,7 @@ final class DewormingController } $ids = [$animalId]; $litterId = null; - if (isset($_POST['apply_litter'])) { + if ($entryId === 0 && 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', ); @@ -85,8 +142,36 @@ final class DewormingController '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é',?,?)", + "INSERT INTO animal_history(animal_id,action,type,label,details,user_id) VALUES(?,'deworming','deworming','Vermifuge / anti-puce administré',?,?)", ); + if ($entryId > 0) { + $s = $db->prepare('SELECT birth_date FROM animals WHERE id=?'); + $s->execute([$animalId]); + $birthDate = $s->fetchColumn() ?: null; + $next = $manual ?: self::nextDue(is_string($birthDate) ? $birthDate : null, $date); + $db->prepare( + "UPDATE dewormings SET dewormer_id=?,administered_on=?,dose_text=?,weight_kg=?,next_due_date=?,notes=?,updated_at=datetime('now') WHERE id=? AND animal_id=?", + )->execute([ + $dewormer ?: null, + $date, + trim((string) ($_POST['dose_text'] ?? '')) ?: null, + ($_POST['weight_kg'] ?? '') !== '' ? (float) $_POST['weight_kg'] : null, + $next, + trim((string) ($_POST['notes'] ?? '')) ?: null, + $entryId, + $animalId, + ]); + $db->prepare( + "INSERT INTO animal_history(animal_id,action,type,label,details,user_id) VALUES(?,'deworming_updated','deworming','Vermifuge / anti-puce corrigé',?,?)", + )->execute([ + $animalId, + 'Administration : ' . self::fr($date) . ($next ? ' · Prochaine échéance : ' . self::fr($next) : ''), + Auth::id(), + ]); + $db->commit(); + header('Location: /animal?id=' . $animalId . '#tab-med'); + exit(); + } foreach ($ids as $id) { $s = $db->prepare('SELECT name,birth_date FROM animals WHERE id=? AND deleted_at IS NULL'); $s->execute([$id]); @@ -117,7 +202,7 @@ final class DewormingController (int) ($_POST['inventory_batch_id'] ?? 0), $perAnimal * count($ids), $animalId, - 'Vermifuge du ' . $date . ($litterId ? ' · portée #' . $litterId : ''), + 'Vermifuge / anti-puce du ' . $date . ($litterId ? ' · portée #' . $litterId : ''), ); $db->commit(); header('Location: /animal?id=' . $animalId . '#tab-med'); diff --git a/app/Controllers/DirectoryController.php b/app/Controllers/DirectoryController.php index b52389f..1c101c7 100644 --- a/app/Controllers/DirectoryController.php +++ b/app/Controllers/DirectoryController.php @@ -15,6 +15,10 @@ final class DirectoryController 'dirigeant' => t('role.dirigeant'), 'fa' => t('role.fa'), 'fourriere' => t('role.fourriere'), + 'pet_sitter' => t('role.pet_sitter'), + 'refuge_partenaire' => t('role.refuge_partenaire'), + 'toiletteur' => t('role.toiletteur'), + 'professionnel_autre' => t('role.professionnel_autre'), 'veterinaire' => t('role.veterinaire'), ]; } @@ -91,17 +95,39 @@ final class DirectoryController '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); + $periodRequested = array_key_exists('year', $_GET) || array_key_exists('period_type', $_GET); $periodRaw = (string) ($_GET['year'] ?? 'all'); $periodYear = ctype_digit($periodRaw) && (int) $periodRaw >= 2000 && (int) $periodRaw <= 2100 ? (int) $periodRaw : null; + $periodType = (string) ($_GET['period_type'] ?? ($periodYear ? 'year' : 'all')); + $periodStart = $periodEnd = null; + $periodQuery = ['period_type' => $periodType]; + if ($periodType === 'year' && $periodYear !== null) { + $periodStart = sprintf('%04d-01-01', $periodYear); + $periodEnd = sprintf('%04d-12-31', $periodYear); + $periodQuery['year'] = $periodYear; + } elseif ($periodType === 'month' && preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', (string) ($_GET['month'] ?? ''))) { + $periodQuery['month'] = (string) $_GET['month']; + $periodStart = $periodQuery['month'] . '-01'; + $periodEnd = (new DateTimeImmutable($periodStart))->modify('last day of this month')->format('Y-m-d'); + } elseif ($periodType === 'custom') { + $from = (string) ($_GET['date_from'] ?? ''); + $to = (string) ($_GET['date_to'] ?? ''); + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $from) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $to)) { + [$periodStart, $periodEnd] = $from <= $to ? [$from, $to] : [$to, $from]; + $periodQuery += ['date_from' => $periodStart,'date_to' => $periodEnd]; + } + } + $periodLabel = $periodStart && $periodEnd + ? (new DateTimeImmutable($periodStart))->format('d/m/Y') . ' – ' . (new DateTimeImmutable($periodEnd))->format('d/m/Y') + : t('statistics.all_years'); $periodMunicipality = trim((string) ($_GET['municipality'] ?? '')); $expenseWhere = ['ae.clinic_contact_id=:id', 'a.deleted_at IS NULL']; $expenseParams = [':id' => $id]; - if ($periodYear !== null) { + if ($periodStart !== null && $periodEnd !== 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); + $expenseParams[':expense_start'] = $periodStart; + $expenseParams[':expense_end'] = $periodEnd; } $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 ' . @@ -125,9 +151,10 @@ final class DirectoryController $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; + if ($periodStart !== null && $periodEnd !== null) { + $invoiceWhere[] = 'date(invoice_date) BETWEEN :invoice_start AND :invoice_end'; + $invoiceParams[':invoice_start'] = $periodStart; + $invoiceParams[':invoice_end'] = $periodEnd; } $invoiceStmt = $db->prepare( 'SELECT * FROM accounting_invoices WHERE ' . @@ -158,6 +185,8 @@ final class DirectoryController 'expenseCount' => $expenseCount, 'expensePeriodRequested' => $periodRequested, 'expensePeriodYear' => $periodYear, + 'expensePeriodLabel' => $periodLabel, + 'expensePeriodQuery' => $periodQuery, 'expensePeriodMunicipality' => $periodMunicipality, 'contactInvoices' => $contactInvoices, 'contactInvoiceTotal' => $contactInvoiceTotal, diff --git a/app/Controllers/MedicalDocumentsController.php b/app/Controllers/MedicalDocumentsController.php index 698af5b..a331e87 100644 --- a/app/Controllers/MedicalDocumentsController.php +++ b/app/Controllers/MedicalDocumentsController.php @@ -314,7 +314,7 @@ final class MedicalDocumentsController 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',?,?,?)", + "INSERT INTO animal_history(animal_id,action,type,label,details,user_id) VALUES(?,'medical','medical',?,?,?)", )->execute([$animalId, $label, $details, Auth::id()]); } private static function fr(string $date): string diff --git a/app/Controllers/PlacementsController.php b/app/Controllers/PlacementsController.php index 4366c64..4fa878a 100644 --- a/app/Controllers/PlacementsController.php +++ b/app/Controllers/PlacementsController.php @@ -126,7 +126,7 @@ final class PlacementsController $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)', + 'INSERT INTO animal_history(animal_id,action,type,label,details,user_id,created_at) VALUES(:animal,\'placement\',\'placement\',:label,:details,:user,:date)', )->execute([ ':animal' => $animalId, ':label' => self::TYPES[$type], diff --git a/app/Controllers/PublicSiteController.php b/app/Controllers/PublicSiteController.php index 111f590..9b80755 100644 --- a/app/Controllers/PublicSiteController.php +++ b/app/Controllers/PublicSiteController.php @@ -13,8 +13,8 @@ final class PublicSiteController if ($path === '/site' || str_starts_with($path, '/site/')) { return true; } - return self::usesRoot() && - in_array( + if (!self::usesRoot()) return false; + if (in_array( $path, [ '/', @@ -28,7 +28,9 @@ final class PublicSiteController '/logo-association', ], true, - ); + )) return true; + return (bool) preg_match('#^/[a-z0-9-]+$#', $path) + && PublicPageService::findBySlug(trim($path, '/')) !== null; } public static function url(string $page = 'home', array $query = []): string { @@ -43,8 +45,10 @@ final class PublicSiteController 'legal' => $root ? '/mentions-legales' : '/site/mentions-legales', 'privacy' => $root ? '/confidentialite' : '/site/confidentialite', 'logo' => $root ? '/logo-association' : '/site/logo', + 'custom' => ($root ? '/' : '/site/page/') . PublicPageService::normalizeSlug((string) ($query['slug'] ?? '')), default => $root ? '/' : '/site', }; + unset($query['slug']); return $path . ($query ? '?' . http_build_query($query) : ''); } public static function dispatch(string $path): void @@ -59,6 +63,10 @@ final class PublicSiteController self::logo(); return; } + if (preg_match('#^/site/page/([a-z0-9-]+)$#', $path, $match)) { + self::customPage($match[1]); + return; + } match ($path) { '/site', '/site/' => self::home(), '/site/animaux' => self::animals(), @@ -73,7 +81,7 @@ final class PublicSiteController } private static function normalize(string $path): string { - return match ($path) { + $normalized = match ($path) { '/' => '/site', '/animaux' => '/site/animaux', '/adoption' => '/site/animal', @@ -85,6 +93,11 @@ final class PublicSiteController '/logo-association' => '/site/logo', default => $path, }; + if ($normalized === $path && self::usesRoot() && preg_match('#^/([a-z0-9-]+)$#', $path, $match) + && PublicPageService::findBySlug($match[1]) !== null) { + return '/site/page/' . $match[1]; + } + return $normalized; } private static function home(): void { @@ -160,6 +173,20 @@ final class PublicSiteController 'kind' => 'about', ]); } + private static function customPage(string $slug): void + { + $page = PublicPageService::findBySlug($slug); + if (!$page) { + self::notFound(); + return; + } + self::render('public_site_text.php', [ + 'title' => $page['title'], + 'content' => $page['content'], + 'kind' => 'custom', + 'customPage' => $page, + ]); + } private static function legal(): void { self::render('public_site_text.php', [ @@ -262,6 +289,7 @@ final class PublicSiteController extract($vars, EXTR_SKIP); $settings = AppSettings::all(); $publicSpecies = self::species(); + $publicPages = PublicPageService::all(true); require dirname(__DIR__) . '/Views/public_site_layout.php'; } private static function notFound(): void diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php index 35d2ff4..f7d3e0d 100644 --- a/app/Controllers/SettingsController.php +++ b/app/Controllers/SettingsController.php @@ -759,6 +759,7 @@ final class SettingsController 'saved' => isset($_GET['saved']), 'error' => (string) ($_GET['error'] ?? ''), 'images' => $images, + 'customPages' => PublicPageService::all(), ]); } @@ -869,22 +870,30 @@ final class SettingsController 'public_site_routing' => $routing, 'public_site_about_enabled' => isset($_POST['public_site_about_enabled']) ? '1' : '0', ]; - foreach ( - [ - 'public_site_home_title' => 150, - 'public_site_name' => 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); + $navigation = json_decode((string) ($_POST['public_site_navigation_order'] ?? '[]'), true); + $navigation = is_array($navigation) ? array_values(array_unique(array_filter($navigation, static function ($item): bool { + if (!is_string($item)) return false; + return in_array($item, ['home','animals','about','donation','contact'], true) + || (bool) preg_match('/^custom:[a-z0-9-]{1,80}$/', $item); + }))) : []; + $submittedPageSlugs = []; + foreach ((array) ($_POST['pages'] ?? []) as $submittedPage) { + if (!is_array($submittedPage) || !empty($submittedPage['delete'])) continue; + $submittedSlug = PublicPageService::normalizeSlug((string) ($submittedPage['slug'] ?? $submittedPage['title'] ?? '')); + if ($submittedSlug !== '') $submittedPageSlugs[] = $submittedSlug; + } + $navigation = array_values(array_filter($navigation, static function (string $item) use ($submittedPageSlugs): bool { + return !str_starts_with($item, 'custom:') || in_array(substr($item, 7), $submittedPageSlugs, true); + })); + foreach (['home','animals','about','donation','contact'] as $required) { + if (!in_array($required, $navigation, true)) $navigation[] = $required; + } + $values['public_site_navigation_order'] = json_encode($navigation, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + foreach (['public_site_home_title','public_site_name','public_site_about_title','public_site_donation_title'] as $key) { + $values[$key] = mb_substr(trim((string) ($_POST[$key] ?? '')), 0, 150); + } + foreach (['public_site_home_text','public_site_about_text','public_site_donation_text','public_site_contact_text','public_site_legal_text','public_site_privacy_text'] as $key) { + $values[$key] = trim((string) ($_POST[$key] ?? '')); } $customCss = trim((string) ($_POST['public_site_custom_css'] ?? '')); if (strlen($customCss) > 30000 || preg_match('/(?:<\/style|@import|expression\s*\(|javascript\s*:|behavior\s*:|-moz-binding|url\s*\()/i', $customCss)) { @@ -914,9 +923,19 @@ final class SettingsController } $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'); + $db = DB::pdo(); + try { + $db->beginTransaction(); + AppSettings::save($values, Auth::id()); + PublicPageService::saveAll((array) ($_POST['pages'] ?? [])); + $db->commit(); + AuditService::log('public_site_saved', '/settings/public-site/save', 'Configuration du site public modifiée'); + header('Location: /settings/public-site?saved=1'); + } catch (Throwable $e) { + if ($db->inTransaction()) $db->rollBack(); + $message = $e instanceof InvalidArgumentException ? $e->getMessage() : SecurityService::publicError($e); + header('Location: /settings/public-site?error=' . rawurlencode($message)); + } exit(); } public static function audit(): void @@ -1227,6 +1246,68 @@ final class SettingsController header('X-Content-Type-Options: nosniff'); readfile($path); } + public static function catalogs(): void + { + self::admin(); + $db = DB::pdo(); + render('settings_catalogs.php', [ + 'title' => t('settings.catalogs'), + 'catalogs' => [ + 'medication' => $db->query('SELECT id,name,molecule extra FROM ref_medications ORDER BY name COLLATE NOCASE')->fetchAll(PDO::FETCH_ASSOC), + 'vaccine' => $db->query('SELECT id,name,code extra FROM ref_vaccines ORDER BY name COLLATE NOCASE')->fetchAll(PDO::FETCH_ASSOC), + 'dewormer' => $db->query('SELECT id,name,active_ingredient extra FROM ref_dewormers ORDER BY name COLLATE NOCASE')->fetchAll(PDO::FETCH_ASSOC), + 'manufacturer' => $db->query('SELECT id,name,NULL extra FROM ref_vaccine_manufacturers ORDER BY name COLLATE NOCASE')->fetchAll(PDO::FETCH_ASSOC), + ], + 'saved' => isset($_GET['saved']), + 'error' => (string) ($_GET['error'] ?? ''), + ]); + } + public static function saveCatalogItem(): void + { + self::admin(); self::postOnly(); + $type = (string) ($_POST['type'] ?? ''); + $id = (int) ($_POST['id'] ?? 0); + $name = trim((string) ($_POST['name'] ?? '')); + $extra = trim((string) ($_POST['extra'] ?? '')) ?: null; + $map = [ + 'medication' => ['ref_medications','molecule'], 'vaccine' => ['ref_vaccines','code'], + 'dewormer' => ['ref_dewormers','active_ingredient'], 'manufacturer' => ['ref_vaccine_manufacturers',null], + ]; + if ($name === '' || !isset($map[$type])) { + header('Location: /settings/catalogs?error=' . rawurlencode(t('catalog.invalid'))); exit(); + } + [$table,$extraColumn] = $map[$type]; + try { + if ($id) { + $sql = "UPDATE $table SET name=?" . ($extraColumn ? ",$extraColumn=?" : '') . ' WHERE id=?'; + DB::pdo()->prepare($sql)->execute($extraColumn ? [$name,$extra,$id] : [$name,$id]); + } else { + $sql = "INSERT INTO $table(name" . ($extraColumn ? ",$extraColumn" : '') . ') VALUES(?' . ($extraColumn ? ',?' : '') . ')'; + DB::pdo()->prepare($sql)->execute($extraColumn ? [$name,$extra] : [$name]); + $id = (int) DB::pdo()->lastInsertId(); + } + AuditService::log('catalog_saved', '/settings/catalogs/save', 'Catalogue modifié : ' . $name, $type, $id); + header('Location: /settings/catalogs?saved=1'); + } catch (Throwable $e) { + header('Location: /settings/catalogs?error=' . rawurlencode(SecurityService::publicError($e))); + } + exit(); + } + public static function deleteCatalogItem(): void + { + self::admin(); self::postOnly(); + $type = (string) ($_POST['type'] ?? ''); $id = (int) ($_POST['id'] ?? 0); + $tables = ['medication'=>'ref_medications','vaccine'=>'ref_vaccines','dewormer'=>'ref_dewormers','manufacturer'=>'ref_vaccine_manufacturers']; + if (!$id || !isset($tables[$type])) { http_response_code(400); return; } + try { + DB::pdo()->prepare('DELETE FROM ' . $tables[$type] . ' WHERE id=?')->execute([$id]); + AuditService::log('catalog_deleted', '/settings/catalogs/delete', 'Élément de catalogue supprimé', $type, $id); + header('Location: /settings/catalogs?saved=1'); + } catch (Throwable) { + header('Location: /settings/catalogs?error=' . rawurlencode(t('catalog.in_use'))); + } + exit(); + } private static function postOnly(): void { if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { diff --git a/app/Controllers/StatisticsController.php b/app/Controllers/StatisticsController.php index 9d1e906..e1f067d 100644 --- a/app/Controllers/StatisticsController.php +++ b/app/Controllers/StatisticsController.php @@ -6,37 +6,95 @@ 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(); + $requestedYear = (string) ($_GET['year'] ?? date('Y')); + $periodType = (string) ($_GET['period_type'] ?? ($requestedYear === 'all' ? 'all' : 'year')); + if (!in_array($periodType, ['all','year','month','custom'], true)) { + $periodType = 'year'; + } + $year = max(2000, min(2200, (int) ($requestedYear === 'all' ? date('Y') : $requestedYear))); + $month = preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', (string) ($_GET['month'] ?? '')) + ? (string) $_GET['month'] + : date('Y-m'); + $validDate = static function (string $value): bool { + $date = DateTimeImmutable::createFromFormat('!Y-m-d', $value); + return $date !== false && $date->format('Y-m-d') === $value; + }; + $dateFrom = $validDate((string) ($_GET['date_from'] ?? '')) ? (string) $_GET['date_from'] : date('Y-01-01'); + $dateTo = $validDate((string) ($_GET['date_to'] ?? '')) ? (string) $_GET['date_to'] : date('Y-m-d'); + if ($dateFrom > $dateTo) { + [$dateFrom, $dateTo] = [$dateTo, $dateFrom]; + } + $start = $end = null; + if ($periodType === 'year') { + $start = sprintf('%04d-01-01', $year); + $end = sprintf('%04d-12-31', $year); + } elseif ($periodType === 'month') { + $start = $month . '-01'; + $end = (new DateTimeImmutable($start))->modify('last day of this month')->format('Y-m-d'); + $year = (int) substr($month, 0, 4); + } elseif ($periodType === 'custom') { + $start = $dateFrom; + $end = $dateTo; + $year = (int) substr($dateFrom, 0, 4); + } + $all = $periodType === 'all'; + $formatDate = static fn(string $date): string => (new DateTimeImmutable($date))->format('d/m/Y'); + $periodLabel = match ($periodType) { + 'all' => t('statistics.all_years'), + 'month' => substr($month, 5, 2) . '/' . substr($month, 0, 4), + 'custom' => $formatDate($dateFrom) . ' – ' . $formatDate($dateTo), + default => (string) $year, + }; + $periodQuery = ['period_type' => $periodType]; + if ($periodType === 'year') $periodQuery['year'] = $year; + if ($periodType === 'month') $periodQuery['month'] = $month; + if ($periodType === 'custom') $periodQuery += ['date_from' => $dateFrom,'date_to' => $dateTo]; $municipalities = StatisticsService::municipalities(); $municipality = trim((string) ($_GET['municipality'] ?? '')); if ($municipality !== '' && !isset($municipalities[$municipality])) { $municipality = ''; } $ids = $municipality !== '' ? StatisticsService::animalIdsForMunicipality($municipality) : null; + if ($all) { + $comparison = []; + $previousPeriodLabel = ''; + } elseif ($periodType === 'year') { + $comparison = StatisticsService::comparison($year, $ids); + $previousPeriodLabel = (string) ($year - 1); + } else { + $comparisonResult = StatisticsService::comparisonPeriod($start, $end, $ids); + $comparison = $comparisonResult['rows']; + $previousPeriodLabel = $formatDate($comparisonResult['previous_start']) . ' – ' . $formatDate($comparisonResult['previous_end']); + } render('statistics.php', [ 'title' => t('statistics.title'), 'pageDescription' => t('statistics.description'), 'year' => $year, 'allYears' => $all, + 'periodType' => $periodType, + 'month' => $month, + 'dateFrom' => $dateFrom, + 'dateTo' => $dateTo, + 'periodLabel' => $periodLabel, + 'previousPeriodLabel' => $previousPeriodLabel, + 'periodQuery' => $periodQuery, 'years' => $years, 'municipality' => $municipality, 'municipalities' => $municipalities, 'municipalityCoverage' => StatisticsService::municipalityCoverage(), - 'stats' => $all ? StatisticsService::all($ids) : StatisticsService::annual($year, $ids), - 'comparison' => $all ? [] : StatisticsService::comparison($year, $ids), + 'stats' => $all ? StatisticsService::all($ids) : StatisticsService::period($start, $end, $ids), + 'comparison' => $comparison, '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), + 'distributions' => StatisticsService::distributions($all ? null : $year, $ids, $municipality ?: null, $start, $end), 'health' => StatisticsService::currentHealth($ids), - 'costs' => StatisticsService::costs($year, $ids), - 'municipalityCosts' => StatisticsService::costsByMunicipality($year), + 'costs' => StatisticsService::costs($all ? null : $year, $ids, $start, $end), + 'municipalityCosts' => StatisticsService::costsByMunicipality($all ? null : $year, $start, $end), ]); } } diff --git a/app/Controllers/SurgeriesController.php b/app/Controllers/SurgeriesController.php index 01fab0d..bc5c383 100644 --- a/app/Controllers/SurgeriesController.php +++ b/app/Controllers/SurgeriesController.php @@ -56,6 +56,16 @@ final class SurgeriesController } $db = DB::pdo(); $tariff = (int) ($_POST['tariff_id'] ?? 0) ?: null; + $clinic = (int) ($_POST['clinic_contact_id'] ?? 0) ?: null; + if ($tariff) { + $validTariff = $db->prepare('SELECT 1 FROM clinic_tariffs WHERE id=? AND clinic_contact_id=? AND active=1'); + $validTariff->execute([$tariff, $clinic]); + if (!$clinic || !$validTariff->fetchColumn()) { + http_response_code(400); + echo h(t('surgery.invalid_data')); + return; + } + } $amountRaw = str_replace(',', '.', trim((string) ($_POST['amount'] ?? ''))); $amount = $amountRaw === '' ? null : (int) round(max(0, (float) $amountRaw) * 100); if ($amount === null && $tariff) { @@ -77,7 +87,7 @@ final class SurgeriesController $name, $date, $status, - (int) ($_POST['clinic_contact_id'] ?? 0) ?: null, + $clinic, (int) ($_POST['veterinarian_contact_id'] ?? 0) ?: null, $tariff, $amount, @@ -92,7 +102,7 @@ final class SurgeriesController '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, + $clinic, $tariff, $id, $name, @@ -104,7 +114,7 @@ final class SurgeriesController ]); } $db->prepare( - "INSERT INTO animal_history(animal_id,type,label,details,user_id) VALUES(?,'medical','Chirurgie enregistrée',?,?)", + "INSERT INTO animal_history(animal_id,action,type,label,details,user_id) VALUES(?,'medical','medical','Chirurgie enregistrée',?,?)", )->execute([$animal, $name . ' — ' . $date, Auth::id()]); $db->commit(); header('Location: /animal?id=' . $animal . '#tab-med'); diff --git a/app/Routes/web.php b/app/Routes/web.php index 552b61c..ac1a2bf 100644 --- a/app/Routes/web.php +++ b/app/Routes/web.php @@ -88,6 +88,9 @@ switch ($path) { case '/animal/add-deworming': DewormingController::save(); break; + case '/animal/product/rename': + DewormingController::renameProduct(); + break; case '/animal/add-vaccine': AnimalsController::addVaccine(); @@ -189,6 +192,15 @@ switch ($path) { case '/litter/save': AnimalsController::saveLitter(); break; + case '/litter/edit': + AnimalsController::litterEditForm(); + break; + case '/litter/update': + AnimalsController::updateLitter(); + break; + case '/litter/kitten/detach': + AnimalsController::detachLitterKitten(); + break; case '/litter/add-existing': AnimalsController::litterAddExistingForm(); break; @@ -326,6 +338,15 @@ switch ($path) { case '/settings/pricing': SettingsController::pricing(); break; + case '/settings/catalogs': + SettingsController::catalogs(); + break; + case '/settings/catalogs/save': + SettingsController::saveCatalogItem(); + break; + case '/settings/catalogs/delete': + SettingsController::deleteCatalogItem(); + break; case '/settings/pricing/tariff': SettingsController::saveTariff(); break; diff --git a/app/Services/AppSettings.php b/app/Services/AppSettings.php index d704200..f0c15de 100644 --- a/app/Services/AppSettings.php +++ b/app/Services/AppSettings.php @@ -62,6 +62,7 @@ final class AppSettings 'update_last_error' => '', 'public_site_legal_text' => '', 'public_site_privacy_text' => '', + 'public_site_navigation_order' => '["home","animals","about","donation","contact"]', ]; private static ?array $cache = null; public static function all(): array diff --git a/app/Services/Asm3MigrationPlanner.php b/app/Services/Asm3MigrationPlanner.php index 90edbc2..b3233f4 100644 --- a/app/Services/Asm3MigrationPlanner.php +++ b/app/Services/Asm3MigrationPlanner.php @@ -152,7 +152,7 @@ final class Asm3MigrationPlanner 'identification_date' => $date($chip !== '' ? $r['IDENTICHIPDATE'] ?? null : $r['TATTOODATE'] ?? null), 'identification_registration_status' => match ((string) ($r['IDENTICHIPSTATUS'] ?? '')) { '1' => 'registered', - '0' => 'pending', + '0' => 'change_pending', default => 'unknown', }, 'sterilization_status' => $yes($r['NEUTERED'] ?? 0) ? 'yes' : 'unknown', diff --git a/app/Services/DB.php b/app/Services/DB.php index 9995146..65c648a 100644 --- a/app/Services/DB.php +++ b/app/Services/DB.php @@ -29,6 +29,17 @@ final class DB $pdo->exec('PRAGMA busy_timeout = 3000;'); @chmod($dbPath, 0600); $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + $pdo->sqliteCreateFunction( + 'search_fold', + static function (mixed $value): string { + $text = mb_strtolower(trim((string) $value), 'UTF-8'); + $text = strtr($text, ['œ' => 'oe', 'æ' => 'ae', 'ø' => 'o', 'ß' => 'ss']); + $ascii = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $text); + return $ascii === false ? $text : strtolower($ascii); + }, + 1, + PDO::SQLITE_DETERMINISTIC, + ); self::$pdo = $pdo; return $pdo; diff --git a/app/Services/ExportService.php b/app/Services/ExportService.php index df454b8..1ad5d5b 100644 --- a/app/Services/ExportService.php +++ b/app/Services/ExportService.php @@ -38,7 +38,7 @@ final class ExportService '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', + '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,clinic.name 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 cu ON cu.id=v.created_by ORDER BY v.done_date,v.id', ], 'deaths' => [ 'Décès', diff --git a/app/Services/GlobalSearchService.php b/app/Services/GlobalSearchService.php index 84a847a..5230094 100644 --- a/app/Services/GlobalSearchService.php +++ b/app/Services/GlobalSearchService.php @@ -11,17 +11,21 @@ final class GlobalSearchService return []; } $db = DB::pdo(); - $like = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $query) . '%'; + $foldedQuery = self::fold($query); + $like = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $foldedQuery) . '%'; $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", + "SELECT id,name,internal_code,status,species,COALESCE(NULLIF(chip_id,''),NULLIF(chip,'')) identification_number FROM animals WHERE deleted_at IS NULL AND (search_fold(name) LIKE :q ESCAPE '\\' OR search_fold(internal_code) LIKE :q ESCAPE '\\' OR search_fold(COALESCE(chip_id,'')) LIKE :q ESCAPE '\\' OR search_fold(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']])), + 'detail' => implode( + ' · ', + array_filter([$r['internal_code'], $r['identification_number'], $r['species'], $r['status']]), + ), 'url' => '/animal?id=' . (int) $r['id'], ], ); @@ -29,7 +33,7 @@ final class GlobalSearchService 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", + "SELECT id,name,kind,phone,email,city FROM directory_contacts WHERE deleted_at IS NULL AND (search_fold(name) LIKE :q ESCAPE '\\' OR search_fold(COALESCE(phone,'')) LIKE :q ESCAPE '\\' OR search_fold(COALESCE(email,'')) LIKE :q ESCAPE '\\' OR search_fold(COALESCE(city,'')) LIKE :q ESCAPE '\\') ORDER BY name COLLATE NOCASE LIMIT :lim", $like, $limit, static fn($r) => [ @@ -42,7 +46,7 @@ final class GlobalSearchService 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", + "SELECT id,title,starts_at,location,status FROM agenda_items WHERE search_fold(title) LIKE :q ESCAPE '\\' OR search_fold(COALESCE(description,'')) LIKE :q ESCAPE '\\' OR search_fold(COALESCE(location,'')) LIKE :q ESCAPE '\\' ORDER BY starts_at DESC LIMIT :lim", $like, $limit, static fn($r) => [ @@ -58,7 +62,7 @@ final class GlobalSearchService 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", + "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 (search_fold(ai.reference) LIKE :q ESCAPE '\\' OR search_fold(COALESCE(ai.notes,'')) LIKE :q ESCAPE '\\' OR search_fold(COALESCE(dc.name,'')) LIKE :q ESCAPE '\\') ORDER BY ai.invoice_date DESC LIMIT :lim", $like, $limit, static fn($r) => [ @@ -71,6 +75,14 @@ final class GlobalSearchService return array_filter($out); } + private static function fold(string $value): string + { + $value = mb_strtolower(trim($value), 'UTF-8'); + $value = strtr($value, ['œ' => 'oe', 'æ' => 'ae', 'ø' => 'o', 'ß' => 'ss']); + $ascii = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value); + return $ascii === false ? $value : strtolower($ascii); + } + private static function rows(PDO $db, string $sql, string $like, int $limit, callable $map): array { $s = $db->prepare($sql); diff --git a/app/Services/IcadService.php b/app/Services/IcadService.php index ef381d3..eda60ea 100644 --- a/app/Services/IcadService.php +++ b/app/Services/IcadService.php @@ -112,8 +112,19 @@ final class IcadService ]); $this->assertSuccess($r2, t('service.icad.login_failed')); + // Le point d'entrée renvoie désormais du JSON et fournit l'adresse réelle + // de l'espace connecté. L'ancienne URL fixe /pro répond en 404 depuis 2026. + $loginResponse = json_decode(trim((string) $r2['body']), true); + if (is_array($loginResponse) && trim((string) ($loginResponse['error'] ?? '')) !== '') { + throw new RuntimeException(t('service.icad.login_failed')); + } + $accountPath = is_array($loginResponse) ? trim((string) ($loginResponse['address'] ?? '')) : ''; + if ($accountPath === '' || !str_starts_with($accountPath, '/')) { + $accountPath = '/acteur'; + } + // 3) Vérif session - $r2b = $this->req('https://www.i-cad.fr/pro', $cookie); + $r2b = $this->req('https://www.i-cad.fr' . $accountPath, $cookie); $this->assertSuccess($r2b, t('service.icad.login_failed')); if (stripos($r2b['body'], 'Se connecter') !== false || str_contains((string)$r2b['effective_url'], '/login')) { throw new RuntimeException(t('service.icad.login_failed')); diff --git a/app/Services/ImageService.php b/app/Services/ImageService.php index e6be26d..89682d6 100644 --- a/app/Services/ImageService.php +++ b/app/Services/ImageService.php @@ -68,6 +68,7 @@ final class ImageService if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) { throw new RuntimeException(t('service.image.directory_failed')); } + self::preparePublicDirectory($directory); $prefix = preg_replace('/[^a-z0-9_-]+/i', '_', trim($prefix)) ?: 'photo'; $useWebp = $profile !== 'logo'; $extension = $useWebp ? 'webp' : $source['extension']; @@ -130,10 +131,31 @@ final class ImageService ['/usr/bin/magick', '/usr/local/bin/magick', '/usr/bin/convert', '/usr/local/bin/convert'] as $binary ) { - if (is_executable($binary)) { + if (@is_executable($binary)) { return $binary; } } return null; } + + private static function preparePublicDirectory(string $directory): void + { + $mediaRoot = dirname(__DIR__, 2) . '/public/media'; + $normalized = rtrim(str_replace('\\', '/', $directory), '/'); + $normalizedRoot = rtrim(str_replace('\\', '/', $mediaRoot), '/'); + if ($normalized !== $normalizedRoot && !str_starts_with($normalized, $normalizedRoot . '/')) { + return; + } + + @chmod(dirname(__DIR__, 2) . '/data', 0710); + $relative = ltrim(substr($normalized, strlen($normalizedRoot)), '/'); + $current = $normalizedRoot; + @chmod($current, 0755); + foreach (array_filter(explode('/', $relative), 'strlen') as $segment) { + $current .= '/' . $segment; + if (is_dir($current)) { + @chmod($current, 0755); + } + } + } } diff --git a/app/Services/Migrations.php b/app/Services/Migrations.php index a76a1ff..860a0c6 100644 --- a/app/Services/Migrations.php +++ b/app/Services/Migrations.php @@ -65,13 +65,47 @@ final class Migrations } } - $relaxesForeignKeys = $name === '093_room_rules_and_care_periods.sql'; + // Deux anciens schémas de l’historique ont circulé : l’un avec + // action/field/values, l’autre avec type/label/details. Le code + // courant accepte les données des deux et a besoin de leur union. + if ($name === '096_animal_history_compatibility.sql') { + $columns = array_column( + $db->query('PRAGMA table_info(animal_history)')->fetchAll(PDO::FETCH_ASSOC), + 'name', + ); + $definitions = [ + 'action' => "TEXT NOT NULL DEFAULT 'history'", + 'field' => 'TEXT', + 'old_value' => 'TEXT', + 'new_value' => 'TEXT', + 'meta' => 'TEXT', + 'type' => "TEXT NOT NULL DEFAULT 'history'", + 'label' => "TEXT NOT NULL DEFAULT ''", + 'details' => 'TEXT', + 'user_id' => 'INTEGER', + ]; + foreach ($definitions as $column => $definition) { + if (!in_array($column, $columns, true)) { + $db->exec("ALTER TABLE animal_history ADD COLUMN $column $definition"); + } + } + } + + $relaxesForeignKeys = in_array( + $name, + ['093_room_rules_and_care_periods.sql', '097_remove_legacy_room_constraint.sql'], + true, + ); if ($relaxesForeignKeys) { $db->exec('PRAGMA foreign_keys = OFF'); } $db->beginTransaction(); try { - $db->exec($sql); + if ($name === '097_remove_legacy_room_constraint.sql') { + self::modernizeAnimalConstraints($db); + } else { + $db->exec($sql); + } $ins = $db->prepare("INSERT INTO _migrations(name, applied_at) VALUES(:n, datetime('now'))"); $ins->execute([':n' => $name]); if ($relaxesForeignKeys && $db->query('PRAGMA foreign_key_check')->fetchColumn() !== false) { @@ -90,4 +124,62 @@ final class Migrations } } } + + private static function modernizeAnimalConstraints(PDO $db): void + { + $createSql = (string) $db + ->query("SELECT sql FROM sqlite_master WHERE type='table' AND name='animals'") + ->fetchColumn(); + $constraint = "/refuge_room\\s+TEXT\\s+CHECK\\s*\\(\\s*refuge_room\\s+IN\\s*\\(\\s*'silver'\\s*,\\s*'twix'\\s*\\)\\s+OR\\s+refuge_room\\s+IS\\s+NULL\\s*\\)/i"; + $registrationConstraint = "/identification_registration_status\\s+TEXT\\s+NOT\\s+NULL\\s+DEFAULT\\s+'unknown'\\s+CHECK\\s*\\(\\s*identification_registration_status\\s+IN\\s*\\(\\s*'unknown'\\s*,\\s*'pending'\\s*,\\s*'registered'\\s*\\)\\s*\\)/i"; + if ( + $createSql === '' + || (!preg_match($constraint, $createSql) && !preg_match($registrationConstraint, $createSql)) + ) { + return; + } + + $newSql = preg_replace( + '/^CREATE TABLE\\s+(?:IF NOT EXISTS\\s+)?["`\[]?animals["`\]]?/i', + 'CREATE TABLE animals_room_unrestricted', + $createSql, + 1, + ); + $newSql = preg_replace($constraint, 'refuge_room TEXT', (string) $newSql, 1); + $newSql = preg_replace( + $registrationConstraint, + "identification_registration_status TEXT NOT NULL DEFAULT 'unknown' " . + "CHECK(identification_registration_status IN ('unknown','registered','change_pending','owner_changed','pending'))", + (string) $newSql, + 1, + ); + if (!$newSql || preg_match($constraint, $newSql)) { + throw new RuntimeException('Unable to remove the legacy shelter-room constraint.'); + } + + $objects = $db + ->query("SELECT sql FROM sqlite_master WHERE tbl_name='animals' AND type IN ('index','trigger') AND sql IS NOT NULL") + ->fetchAll(PDO::FETCH_COLUMN); + $views = $db + ->query("SELECT name,sql FROM sqlite_master WHERE type='view' AND sql IS NOT NULL ORDER BY rowid") + ->fetchAll(PDO::FETCH_ASSOC); + $columns = array_column($db->query('PRAGMA table_info(animals)')->fetchAll(PDO::FETCH_ASSOC), 'name'); + $quotedColumns = implode(',', array_map(static fn(string $column): string => '"' . str_replace('"', '""', $column) . '"', $columns)); + + foreach ($views as $view) { + $viewName = '"' . str_replace('"', '""', (string) $view['name']) . '"'; + $db->exec('DROP VIEW ' . $viewName); + } + $db->exec($newSql); + $db->exec("INSERT INTO animals_room_unrestricted ($quotedColumns) SELECT $quotedColumns FROM animals"); + $db->exec("UPDATE animals_room_unrestricted SET identification_registration_status='change_pending' WHERE identification_registration_status='pending'"); + $db->exec('DROP TABLE animals'); + $db->exec('ALTER TABLE animals_room_unrestricted RENAME TO animals'); + foreach ($objects as $objectSql) { + $db->exec((string) $objectSql); + } + foreach ($views as $view) { + $db->exec((string) $view['sql']); + } + } } diff --git a/app/Services/NotificationService.php b/app/Services/NotificationService.php index 6e56133..3684bcb 100644 --- a/app/Services/NotificationService.php +++ b/app/Services/NotificationService.php @@ -20,14 +20,14 @@ final class NotificationService '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(d.administered_on))", + "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 lower(a.status) NOT IN ('decede','décédé','adopte','adopté') 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', ], ]; diff --git a/app/Services/PermissionService.php b/app/Services/PermissionService.php index a7b8959..a03c5a1 100644 --- a/app/Services/PermissionService.php +++ b/app/Services/PermissionService.php @@ -91,6 +91,8 @@ final class PermissionService '/animal/medical-status', '/animal/add-treatment', '/animal/add-vaccine', + '/animal/add-deworming', + '/animal/product/rename', '/animal/death', '/animal/prescription', '/animal/lab-report', @@ -211,6 +213,9 @@ final class PermissionService '/animal/death', '/animal/surgery/new', '/litter/new', + '/litter/edit', + '/litter/update', + '/litter/kitten/detach', '/litter/add-existing', '/litter/add-existing/save', '/bonded/new', diff --git a/app/Services/PricingService.php b/app/Services/PricingService.php index 9b41b5b..e773377 100644 --- a/app/Services/PricingService.php +++ b/app/Services/PricingService.php @@ -12,7 +12,7 @@ final class PricingService 'euthanasia' => 'Euthanasie', 'cremation' => 'Crémation', 'medication' => 'Médicament', - 'dewormer' => 'Vermifuge', + 'dewormer' => 'Vermifuge / anti-puce', 'other' => 'Autre', ]; public const GROUPS = [ diff --git a/app/Services/PublicContentFormatter.php b/app/Services/PublicContentFormatter.php index ae4bb1a..0736b4a 100644 --- a/app/Services/PublicContentFormatter.php +++ b/app/Services/PublicContentFormatter.php @@ -2,77 +2,143 @@ declare(strict_types=1); +/** Lightweight Markdown renderer. Raw HTML is escaped and images stay local. */ final class PublicContentFormatter { public static function render(string $text): string { $lines = preg_split('/\R/u', trim($text)) ?: []; $html = []; - $listOpen = false; + $count = count($lines); + for ($i = 0; $i < $count;) { + $line = rtrim($lines[$i]); + if (trim($line) === '') { $i++; continue; } - foreach ($lines as $line) { - $line = trim($line); - if ($line === '') { - if ($listOpen) { - $html[] = ''; - $listOpen = false; + if (preg_match('/^\s*```\s*([A-Za-z0-9_-]*)\s*$/u', $line, $fence)) { + $language = strtolower($fence[1]); + $code = []; + for ($i++; $i < $count && !preg_match('/^\s*```\s*$/u', $lines[$i]); $i++) $code[] = $lines[$i]; + if ($i < $count) $i++; + $class = $language !== '' ? ' class="language-' . htmlspecialchars($language, ENT_QUOTES, 'UTF-8') . '"' : ''; + $html[] = '
' . htmlspecialchars(implode("\n", $code), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; + continue; + } + + if ($i + 1 < $count && self::isTableRow($line) && self::isTableSeparator($lines[$i + 1])) { + $headers = self::tableCells($line); + $alignments = array_map([self::class, 'tableAlignment'], self::tableCells($lines[$i + 1])); + $rows = []; + for ($i += 2; $i < $count && self::isTableRow($lines[$i]) && trim($lines[$i]) !== ''; $i++) $rows[] = self::tableCells($lines[$i]); + $table = '
'; + foreach ($headers as $column => $header) { + $align = $alignments[$column] ?? ''; + $table .= '' . self::inline($header) . ''; } - continue; - } - if (preg_match('/^[-*]\s+(.+)$/u', $line, $match)) { - if (!$listOpen) { - $html[] = '
    '; - $listOpen = true; + $table .= '
'; + foreach ($rows as $row) { + $table .= ''; + foreach ($headers as $column => $_) { + $align = $alignments[$column] ?? ''; + $table .= '' . self::inline($row[$column] ?? '') . ''; + } + $table .= ''; } - $html[] = '
  • ' . self::inline($match[1]) . '
  • '; + $html[] = $table . '
    '; continue; } - if ($listOpen) { - $html[] = ''; - $listOpen = false; - } - if (preg_match('/^(#{1,3})\s+(.+)$/u', $line, $match)) { - $level = strlen($match[1]) + 1; - $html[] = '' . self::inline($match[2]) . ''; + + if (preg_match('/^\s*([-*_])(?:\s*\1){2,}\s*$/u', $line)) { $html[] = '
    '; $i++; continue; } + if (preg_match('/^(#{1,6})\s+(.+?)\s*#*$/u', trim($line), $heading)) { + $level = min(6, strlen($heading[1]) + 1); + $html[] = '' . self::inline($heading[2]) . ''; + $i++; continue; } - $html[] = '

    ' . self::inline($line) . '

    '; - } - if ($listOpen) { - $html[] = ''; + if (preg_match('/^\s*>\s?(.*)$/u', $line)) { + $quote = []; + while ($i < $count && preg_match('/^\s*>\s?(.*)$/u', $lines[$i], $match)) { $quote[] = $match[1]; $i++; } + $html[] = '
    ' . self::render(implode("\n", $quote)) . '
    '; + continue; + } + if (preg_match('/^\s*(?:[-+*]|\d+[.)])\s+(.+)$/u', $line)) { + $ordered = (bool) preg_match('/^\s*\d+[.)]\s+/u', $line); + $tag = $ordered ? 'ol' : 'ul'; + $list = '<' . $tag . '>'; + while ($i < $count) { + $pattern = $ordered ? '/^\s*\d+[.)]\s+(.+)$/u' : '/^\s*[-+*]\s+(.+)$/u'; + if (!preg_match($pattern, $lines[$i], $item)) break; + $value = $item[1]; + $taskClass = ''; + if (preg_match('/^\[([ xX])\]\s+(.+)$/u', $value, $task)) { + $taskClass = ' class="task-list-item"'; + $value = ' ' . self::inline($task[2]); + } else $value = self::inline($value); + $list .= '' . $value . ''; + $i++; + } + $html[] = $list . ''; + continue; + } + + $paragraph = [trim($line)]; + for ($i++; $i < $count && trim($lines[$i]) !== '' && !self::startsBlock($lines, $i); $i++) $paragraph[] = trim($lines[$i]); + $html[] = '

    ' . self::inline(implode(' ', $paragraph)) . '

    '; } return implode("\n", $html); } + private static function startsBlock(array $lines, int $index): bool + { + $line = rtrim((string) ($lines[$index] ?? '')); + return preg_match('/^\s*(?:```|#{1,6}\s+|>\s?|[-+*]\s+|\d+[.)]\s+)/u', $line) === 1 + || preg_match('/^\s*([-*_])(?:\s*\1){2,}\s*$/u', $line) === 1 + || ($index + 1 < count($lines) && self::isTableRow($line) && self::isTableSeparator($lines[$index + 1])); + } + + private static function isTableRow(string $line): bool { return str_contains(trim($line), '|'); } + private static function isTableSeparator(string $line): bool + { + $cells = self::tableCells($line); + return $cells !== [] && count(array_filter($cells, static fn (string $cell): bool => preg_match('/^:?-{3,}:?$/', trim($cell)) === 1)) === count($cells); + } + private static function tableCells(string $line): array + { + $line = trim($line); + if (str_starts_with($line, '|')) $line = substr($line, 1); + if (str_ends_with($line, '|') && !str_ends_with($line, '\\|')) $line = substr($line, 0, -1); + $marker = "\x1D"; + return array_map(static fn (string $cell): string => str_replace($marker, '|', trim($cell)), explode('|', str_replace('\\|', $marker, $line))); + } + private static function tableAlignment(string $separator): string + { + $separator = trim($separator); + if (str_starts_with($separator, ':') && str_ends_with($separator, ':')) return 'center'; + if (str_ends_with($separator, ':')) return 'right'; + return str_starts_with($separator, ':') ? 'left' : ''; + } + private static function inline(string $text): string { - $images = []; - $text = preg_replace_callback('/!\[([^\]]*)\]\((\/media\/site\/[A-Za-z0-9._%-]+)\)/u', static function (array $match) use (&$images): string { - $token = "\x1B" . count($images) . "\x1B"; - $images[] = '' .
-                htmlspecialchars($match[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''; - return $token; - }, $text) ?? $text; - $links = []; - $text = preg_replace_callback('/\[([^\]]+)\]\((https:\/\/[^\s)]+)\)/u', static function (array $match) use (&$links): string { - if (!filter_var($match[2], FILTER_VALIDATE_URL)) { - return $match[0]; - } - $token = "\x1A" . count($links) . "\x1A"; - $links[] = '' . htmlspecialchars($match[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''; + $tokens = []; + $reserve = static function (string $html) use (&$tokens): string { + $token = "\x1A" . count($tokens) . "\x1A"; + $tokens[] = $html; return $token; + }; + $text = preg_replace_callback('/`([^`]+)`/u', static fn (array $match): string => $reserve('' . htmlspecialchars($match[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''), $text) ?? $text; + $text = preg_replace_callback('/!\[([^\]]*)\]\((\/media\/site\/[A-Za-z0-9._%-]+)(?:\s+["\']([^"\']*)["\'])?\)/u', static fn (array $match): string => $reserve('' . htmlspecialchars($match[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''), $text) ?? $text; + $text = preg_replace_callback('/\[([^\]]+)\]\((https:\/\/[^\s)]+|\/[^\s)]*)\)/u', static function (array $match) use ($reserve): string { + $url = $match[2]; + if (str_starts_with($url, 'https://') && !filter_var($url, FILTER_VALIDATE_URL)) return $match[0]; + return $reserve('' . htmlspecialchars($match[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''); }, $text) ?? $text; $text = htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $text = preg_replace('/~~(.+?)~~/u', '$1', $text) ?? $text; $text = preg_replace('/\*\*(.+?)\*\*/u', '$1', $text) ?? $text; + $text = preg_replace('/__(.+?)__/u', '$1', $text) ?? $text; $text = preg_replace('/(?$1', $text) ?? $text; - foreach ($links as $index => $link) { - $text = str_replace("\x1A" . $index . "\x1A", $link, $text); - } - foreach ($images as $index => $image) { - $text = str_replace("\x1B" . $index . "\x1B", $image, $text); - } + $text = preg_replace('/(?$1', $text) ?? $text; + foreach ($tokens as $index => $html) $text = str_replace("\x1A" . $index . "\x1A", $html, $text); return $text; } } diff --git a/app/Services/PublicPageService.php b/app/Services/PublicPageService.php new file mode 100644 index 0000000..9f0ff2b --- /dev/null +++ b/app/Services/PublicPageService.php @@ -0,0 +1,81 @@ +query($sql)->fetchAll(PDO::FETCH_ASSOC); + } + + public static function findBySlug(string $slug, bool $enabledOnly = true): ?array + { + $slug = self::normalizeSlug($slug); + if ($slug === '') return null; + $stmt = DB::pdo()->prepare( + 'SELECT * FROM public_site_pages WHERE slug=:slug' . ($enabledOnly ? ' AND enabled=1' : '') . ' LIMIT 1', + ); + $stmt->execute([':slug' => $slug]); + return $stmt->fetch(PDO::FETCH_ASSOC) ?: null; + } + + public static function saveAll(array $pages): void + { + $db = DB::pdo(); + $pages = array_values(array_filter($pages, 'is_array')); + usort($pages, static fn (array $a, array $b): int => (int) ($a['position'] ?? 0) <=> (int) ($b['position'] ?? 0)); + $seenSlugs = []; + $position = 0; + foreach ($pages as $page) { + if (!is_array($page)) continue; + $id = max(0, (int) ($page['id'] ?? 0)); + if (!empty($page['delete'])) { + if ($id > 0) $db->prepare('DELETE FROM public_site_pages WHERE id=?')->execute([$id]); + continue; + } + $title = mb_substr(trim((string) ($page['title'] ?? '')), 0, 150); + $slug = self::normalizeSlug((string) ($page['slug'] ?? '')); + if ($slug === '') $slug = self::normalizeSlug($title); + if ($title === '' && $slug === '' && trim((string) ($page['content'] ?? '')) === '') continue; + if ($title === '' || $slug === '' || in_array($slug, self::RESERVED, true)) { + throw new InvalidArgumentException(t('public_site.page_invalid')); + } + if (isset($seenSlugs[$slug])) throw new InvalidArgumentException(t('public_site.page_slug_duplicate')); + $seenSlugs[$slug] = true; + $duplicate = $db->prepare('SELECT id FROM public_site_pages WHERE slug=:slug AND id<>:id LIMIT 1'); + $duplicate->execute([':slug' => $slug, ':id' => $id]); + if ($duplicate->fetchColumn()) throw new InvalidArgumentException(t('public_site.page_slug_duplicate')); + $content = trim((string) ($page['content'] ?? '')); + $enabled = !empty($page['enabled']) ? 1 : 0; + if ($id > 0) { + $check = $db->prepare('SELECT 1 FROM public_site_pages WHERE id=?'); + $check->execute([$id]); + if (!$check->fetchColumn()) throw new InvalidArgumentException(t('public_site.page_invalid')); + $stmt = $db->prepare('UPDATE public_site_pages SET slug=:slug,title=:title,content=:content,enabled=:enabled,sort_order=:sort,updated_at=CURRENT_TIMESTAMP WHERE id=:id'); + $stmt->execute([':slug'=>$slug,':title'=>$title,':content'=>$content,':enabled'=>$enabled,':sort'=>$position,':id'=>$id]); + } else { + $stmt = $db->prepare('INSERT INTO public_site_pages(slug,title,content,enabled,sort_order) VALUES(:slug,:title,:content,:enabled,:sort)'); + $stmt->execute([':slug'=>$slug,':title'=>$title,':content'=>$content,':enabled'=>$enabled,':sort'=>$position]); + } + $position++; + } + } + + public static function normalizeSlug(string $value): string + { + $value = mb_strtolower(trim($value), 'UTF-8'); + $ascii = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value); + $value = $ascii === false ? $value : $ascii; + $value = preg_replace('/[^a-z0-9]+/', '-', strtolower($value)) ?? ''; + return mb_substr(trim($value, '-'), 0, 80); + } +} diff --git a/app/Services/SecurityService.php b/app/Services/SecurityService.php index 69c0ce5..a7e47c7 100644 --- a/app/Services/SecurityService.php +++ b/app/Services/SecurityService.php @@ -24,13 +24,12 @@ final class SecurityService public static function hardenRuntimeFiles(): void { $root = dirname(__DIR__, 2); - $marker = $root . '/storage/.security-hardened'; + $marker = $root . '/storage/.security-hardened-v2'; if (is_file($marker) && filemtime($marker) > time() - 86400) { return; } foreach ( [ - $root . '/data', $root . '/storage', $root . '/data/private-media', $root . '/data/medical-documents', @@ -45,6 +44,13 @@ final class SecurityService @chmod($dir, 0700); } } + // `public/media` may be a symlink into `data` (notably on YunoHost). + // Keep the data root traversable without making its private contents + // listable, then restore web-safe permissions only below public media. + if (is_dir($root . '/data')) { + @chmod($root . '/data', 0710); + } + self::hardenPublicTree($root . '/public/media'); foreach ( [ $root . '/.env', @@ -94,4 +100,19 @@ final class SecurityService @chmod($item->getPathname(), $item->isDir() ? 0700 : 0600); } } + + private static function hardenPublicTree(string $path): void + { + if (!is_dir($path)) { + return; + } + @chmod($path, 0755); + $it = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST, + ); + foreach ($it as $item) { + @chmod($item->getPathname(), $item->isDir() ? 0755 : 0644); + } + } } diff --git a/app/Services/StatisticsService.php b/app/Services/StatisticsService.php index 724081d..7975fe8 100644 --- a/app/Services/StatisticsService.php +++ b/app/Services/StatisticsService.php @@ -4,10 +4,14 @@ declare(strict_types=1); final class StatisticsService { - public static function costs(?int $year, ?array $ids = null): array + public static function costs(?int $year, ?array $ids = null, ?string $start = null, ?string $end = null): array { $db = DB::pdo(); - $scopeIds = self::costScopeIds($year, $ids); + if ($start === null && $year !== null) { + $start = sprintf('%04d-01-01', $year); + $end = sprintf('%04d-12-31', $year); + } + $scopeIds = self::costScopeIds($year, $ids, $start, $end); $empty = [ 'total_cents' => 0, 'animals' => 0, @@ -23,9 +27,9 @@ final class StatisticsService } $where = ['a.deleted_at IS NULL', 'ae.animal_id IN(' . implode(',', array_map('intval', $scopeIds)) . ')']; $params = []; - if ($year !== null) { + if ($start !== null && $end !== null) { $where[] = 'date(ae.occurred_on) BETWEEN :start AND :end'; - $params = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)]; + $params = [':start' => $start, ':end' => $end]; } $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 ' . @@ -63,10 +67,14 @@ final class StatisticsService ]; } - public static function costsByMunicipality(?int $year): array + public static function costsByMunicipality(?int $year, ?string $start = null, ?string $end = null): array { $db = DB::pdo(); - $scopeIds = self::costScopeIds($year, null); + if ($start === null && $year !== null) { + $start = sprintf('%04d-01-01', $year); + $end = sprintf('%04d-12-31', $year); + } + $scopeIds = self::costScopeIds($year, null, $start, $end); if (!$scopeIds) { return []; } @@ -92,9 +100,9 @@ final class StatisticsService } $params = []; $dateSql = ''; - if ($year !== null) { + if ($start !== null && $end !== null) { $dateSql = ' AND date(occurred_on) BETWEEN :start AND :end'; - $params = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)]; + $params = [':start' => $start, ':end' => $end]; } $s = $db->prepare( 'SELECT animal_id,SUM(total_cents) total_cents FROM animal_expenses WHERE animal_id IN(' . @@ -133,22 +141,24 @@ final class StatisticsService return $out; } - private static function costScopeIds(?int $year, ?array $ids): array + private static function costScopeIds(?int $year, ?array $ids, ?string $start = null, ?string $end = null): 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 { + if ($start === null && $year !== null) { $start = sprintf('%04d-01-01', $year); $end = sprintf('%04d-12-31', $year); + } + if ($start === null || $end === null) { + $sql = 'SELECT a.id FROM animals a WHERE a.deleted_at IS NULL' . $limit; + } else { $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]); + $s->execute($start === null || $end === null ? [] : [':start' => $start, ':end' => $end]); return array_map('intval', $s->fetchAll(PDO::FETCH_COLUMN)); } @@ -224,64 +234,49 @@ final class StatisticsService 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); - } + return self::period(sprintf('%04d-01-01', $year), sprintf('%04d-12-31', $year), $ids); + } + + public static function period(string $start, string $end, ?array $ids = null): array + { $db = DB::pdo(); - $range = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)]; + $range = [':start' => $start, ':end' => $end]; + $scalar = static function (string $sql) use ($db, $range): float { + $s = $db->prepare($sql); + $s->execute($range); + return (float) ($s->fetchColumn() ?: 0); + }; + $in = self::idSql($ids); + $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); $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" . @@ -303,6 +298,24 @@ final class StatisticsService return $stats; } + public static function comparisonPeriod(string $start, string $end, ?array $ids = null): array + { + $from = new DateTimeImmutable($start); + $to = new DateTimeImmutable($end); + $days = (int) $from->diff($to)->format('%a') + 1; + $previousEnd = $from->modify('-1 day'); + $previousStart = $previousEnd->modify('-' . ($days - 1) . ' days'); + $current = self::period($start, $end, $ids); + $previous = self::period($previousStart->format('Y-m-d'), $previousEnd->format('Y-m-d'), $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 ['rows' => $out,'previous_start' => $previousStart->format('Y-m-d'),'previous_end' => $previousEnd->format('Y-m-d')]; + } + public static function comparison(int $year, ?array $ids = null): array { $current = self::annual($year, $ids); @@ -532,10 +545,10 @@ final class StatisticsService ]; } - private static function details(int $year, array $ids): array + private static function detailsRange(string $start, string $end, ?array $ids): array { $db = DB::pdo(); - $p = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)]; + $p = [':start' => $start, ':end' => $end]; $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') . @@ -561,7 +574,7 @@ final class StatisticsService ); $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 . + self::idSql($ids) . ' GROUP BY cause_code ORDER BY total DESC', ); $stays = $map( @@ -572,10 +585,33 @@ final class StatisticsService return compact('sex', 'ages', 'deathCauses', 'stays'); } - public static function distributions(?int $year, ?array $ids = null, ?string $municipality = null): array + private static function originsForPeriod(string $start, string $end, ?array $ids): array { $db = DB::pdo(); - $p = $year === null ? [] : [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)]; + $sql = + "SELECT DISTINCT a.id,a.rescue_location_name,a.rescue_address FROM animals a WHERE a.deleted_at IS NULL" . + self::idSql($ids, 'a.id') . + " 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))"; + $stmt = $db->prepare($sql); + $stmt->execute([':start' => $start, ':end' => $end]); + $origins = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $city = GrantService::municipality((string) ($row['rescue_location_name'] ?: $row['rescue_address'])); + $origins[$city] = ($origins[$city] ?? 0) + 1; + } + arsort($origins, SORT_NUMERIC); + return $origins; + } + + public static function distributions(?int $year, ?array $ids = null, ?string $municipality = null, ?string $start = null, ?string $end = null): array + { + $db = DB::pdo(); + if ($start === null && $year !== null) { + $start = sprintf('%04d-01-01', $year); + $end = sprintf('%04d-12-31', $year); + } + $bounded = $start !== null && $end !== null; + $p = $bounded ? [':start' => $start, ':end' => $end] : []; $rows = static function (string $sql, array $params = []) use ($db): array { $s = $db->prepare($sql); $s->execute($params); @@ -585,9 +621,9 @@ final class StatisticsService } 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"; + $movementDate = $bounded ? ' AND date(m.created_at) BETWEEN :start AND :end' : ''; + $animalDate = $bounded ? ' AND date(intake_date) BETWEEN :start AND :end' : ''; + $datedAnimal = $bounded ? " 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 . @@ -627,7 +663,7 @@ final class StatisticsService "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) { + if (!$bounded) { $animalFilter = self::idSql($ids, 'a.id'); $details = []; $details['sex'] = $rows( @@ -651,12 +687,11 @@ final class StatisticsService ' 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']; + $details = self::detailsRange($start, $end, $ids); + $origins = $municipality !== null + ? [$municipality => (int) array_sum($species)] + : self::originsForPeriod($start, $end, $ids); } foreach ($details as &$items) { arsort($items, SORT_NUMERIC); @@ -674,7 +709,7 @@ final class StatisticsService ' GROUP BY label ORDER BY total DESC,label', ); $releaseSql = - $year === null + !$bounded ? "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' diff --git a/app/Services/UpdateService.php b/app/Services/UpdateService.php index fa7f728..35b6b39 100644 --- a/app/Services/UpdateService.php +++ b/app/Services/UpdateService.php @@ -457,6 +457,7 @@ final class UpdateService { self::clearManagedCode($root); self::copyManagedCode($source, $root); + self::normalizePublicCodePermissions($root . '/public'); foreach (['data', 'storage', 'public/media'] as $path) { self::ensureDirectory($root . '/' . $path, 0770); } @@ -466,6 +467,7 @@ final class UpdateService { self::clearManagedCode($root); self::copyManagedCode($snapshot, $root); + self::normalizePublicCodePermissions($root . '/public'); } private static function copyManagedCode(string $source, string $target): void @@ -558,6 +560,24 @@ final class UpdateService } } + private static function normalizePublicCodePermissions(string $publicRoot): void + { + if (!is_dir($publicRoot)) { + return; + } + @chmod($publicRoot, 0755); + $it = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($publicRoot, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST, + ); + foreach ($it as $item) { + if ($item->isLink()) { + continue; + } + @chmod($item->getPathname(), $item->isDir() ? 0755 : 0644); + } + } + private static function removeTree(string $path): void { if (is_link($path) || is_file($path)) { diff --git a/app/Views/_animal_show_identity.php b/app/Views/_animal_show_identity.php index 3d9488c..6d9fda0 100644 --- a/app/Views/_animal_show_identity.php +++ b/app/Views/_animal_show_identity.php @@ -4,7 +4,7 @@
    - + 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))); @@ -221,11 +221,15 @@ $hasCurrent = ($currentLat !== null && $currentLng !== null);
    - +
    :
    - + +
    @@ -312,10 +316,14 @@ $err = (string)($cache['error'] ?? ''); - +
    + + + + +
    diff --git a/app/Views/_animal_show_litters.php b/app/Views/_animal_show_litters.php index 82414cc..e611923 100644 --- a/app/Views/_animal_show_litters.php +++ b/app/Views/_animal_show_litters.php @@ -39,7 +39,8 @@ ' . h($litter['father_name']) . '' : h(t('animal.unknown_male')) ?> - ✏️ + + diff --git a/app/Views/_animal_show_medical.php b/app/Views/_animal_show_medical.php index 75a4211..064d39f 100644 --- a/app/Views/_animal_show_medical.php +++ b/app/Views/_animal_show_medical.php @@ -22,6 +22,10 @@
    FeLV
    +
    +
    +
    +
    @@ -198,7 +202,7 @@
    -

    +

    🪱

    @@ -218,6 +222,7 @@ + @@ -227,6 +232,16 @@ +
    @@ -362,6 +377,7 @@ ORDER BY created_at ASC + @@ -388,7 +404,19 @@ ORDER BY created_at ASC - + @@ -399,6 +427,9 @@ ORDER BY created_at ASC
    +
    + +
    @@ -410,8 +441,9 @@ ORDER BY created_at ASC - + + @@ -424,8 +456,17 @@ ORDER BY created_at ASC
    date('d/m/Y', strtotime((string)$v['batch_expires_on']))])) ?>
    - + + diff --git a/app/Views/_animal_show_modals.php b/app/Views/_animal_show_modals.php index 972364f..a01e550 100644 --- a/app/Views/_animal_show_modals.php +++ b/app/Views/_animal_show_modals.php @@ -22,6 +22,7 @@ $currentTime = date('H:i'); +
    @@ -264,7 +286,7 @@ if ($grantAlertTotal > 0): ?>
    🚨
    - +
    @@ -290,7 +312,7 @@ if ($grantAlertTotal > 0): ?>
    ⏳
    -
    +
    @@ -315,7 +337,7 @@ if ($grantAlertTotal > 0): ?>
    📅
    -
    +
    diff --git a/app/Views/directory_show.php b/app/Views/directory_show.php index 50bb429..1886f02 100644 --- a/app/Views/directory_show.php +++ b/app/Views/directory_show.php @@ -30,7 +30,7 @@ $animalRows = static function (array$rows, string$title, string$color, string$ic @@ -78,7 +78,7 @@ if ($address): ?>
    📍
    · - + ·
    (int)$expenseCount])) ?> diff --git a/app/Views/evolutions.php b/app/Views/evolutions.php index 5ed43da..cbb4341 100644 --- a/app/Views/evolutions.php +++ b/app/Views/evolutions.php @@ -31,10 +31,10 @@
    - 1.0.0-rc.22 -

    Disponibilité après adoption

    -

    Une adoption finalisée rend automatiquement l’animal indisponible afin de - conserver un état administratif cohérent.

    + 1.0.0-rc.35 +

    Des pages publiques riches et lisibles

    +

    Les pages personnalisées accueillent maintenant tableaux responsives, listes, + citations et code tout en conservant un rendu strictement sécurisé.

    @@ -43,8 +43,8 @@
    Après la 1.0

    Prochaines étapes

    -

    Tests HTTP plus complets, validation des paquets d’installation et amélioration - continue des parcours.

    +

    Réinitialisation planifiée des démonstrations, normalisation assistée des + contacts, validation des paquets YunoHost, ZimaOS et CasaOS, puis amélioration continue des parcours.

    diff --git a/app/Views/layout.php b/app/Views/layout.php index 098b27e..5bf40e1 100644 --- a/app/Views/layout.php +++ b/app/Views/layout.php @@ -271,7 +271,7 @@ if ($pageDescription === '') { if (path.startsWith('/admin') || path.startsWith('/documents')) return 'administrative'; return null; }; - const permissionEditPages = new Set(['/animal/new', '/animal/edit', '/animal/death', '/litter/new', + const permissionEditPages = new Set(['/animal/new', '/animal/edit', '/animal/death', '/litter/new', '/litter/edit', '/litter/update', '/litter/kitten/detach', '/litter/add-existing', '/litter/add-existing/save', '/bonded/new', '/directory/new', '/directory/edit', '/care-round/setup' ]); diff --git a/app/Views/litter_edit.php b/app/Views/litter_edit.php new file mode 100644 index 0000000..d9e5dbb --- /dev/null +++ b/app/Views/litter_edit.php @@ -0,0 +1,13 @@ + +
    + +

    +
    +
    +
    +
    +
    + +
    +
    +
    diff --git a/app/Views/litter_form.php b/app/Views/litter_form.php index babc8e5..19bda54 100644 --- a/app/Views/litter_form.php +++ b/app/Views/litter_form.php @@ -8,6 +8,7 @@ $prefillCount = max(0, min(20, (int)($_GET['count'] ?? 4))); ?>
    +
    diff --git a/app/Views/public_site_contact.php b/app/Views/public_site_contact.php index f2a29a9..2678fc8 100644 --- a/app/Views/public_site_contact.php +++ b/app/Views/public_site_contact.php @@ -14,12 +14,7 @@ $obfuscate = static fn (string$value): string => implode('', array_reverse(mb_st class="contact-obfuscated">
    diff --git a/app/Views/settings_catalogs.php b/app/Views/settings_catalogs.php new file mode 100644 index 0000000..bcdae9b --- /dev/null +++ b/app/Views/settings_catalogs.php @@ -0,0 +1,17 @@ +t('animal.medication'),'vaccine'=>t('animal.vaccine'),'dewormer'=>t('animals.dewormer'),'manufacturer'=>t('animal.manufacturer')]; +$extras = ['medication'=>t('animal.molecule'),'vaccine'=>t('catalog.code'),'dewormer'=>t('catalog.active_ingredient'),'manufacturer'=>'']; ?> +
    +
    +

    +
    + $rows): ?>
    +

    + +
    +
    +
    +
    +
    +
    +
    diff --git a/app/Views/settings_nav.php b/app/Views/settings_nav.php index ba4a8bb..49f5ea9 100644 --- a/app/Views/settings_nav.php +++ b/app/Views/settings_nav.php @@ -8,6 +8,7 @@ $settingsSection = match(true) { str_starts_with($settingsPath, '/settings/rooms') => 'rooms', str_starts_with($settingsPath, '/settings/pricing') => 'pricing', str_starts_with($settingsPath, '/settings/inventory') => 'inventory', + str_starts_with($settingsPath, '/settings/catalogs') => 'catalogs', str_starts_with($settingsPath, '/settings/lab-references') => 'lab-references', str_starts_with($settingsPath, '/settings/public-site') => 'public-site', str_starts_with($settingsPath, '/settings/backups') => 'backups', diff --git a/app/Views/settings_public_site.php b/app/Views/settings_public_site.php index d36c089..134a571 100644 --- a/app/Views/settings_public_site.php +++ b/app/Views/settings_public_site.php @@ -28,6 +28,7 @@
    +
    @@ -78,10 +79,17 @@
    -
    -
    +
    +

    + +
    +
    +
    + +

    🏠

    -
    + +
    @@ -89,11 +97,14 @@ class="form-control public-rich-text" name="public_site_home_text" rows="4">
    -
    -
    -
    + +

    🐾

    +
    + +

    📖

    -
    + +
    -
    -
    -
    + +
    + +

    💝

    -
    + +
    -
    -
    -
    + +
    + +

    ✉️

    -
    + +
    + + $page): ?> +
    + + +

    + + + + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    +
    -
    -
    + +
    +

    ⚖️

    -
    + +
    -
    +
    + diff --git a/app/Views/statistics.php b/app/Views/statistics.php index bad7f2b..59efc92 100644 --- a/app/Views/statistics.php +++ b/app/Views/statistics.php @@ -54,12 +54,19 @@ $groups = [
    -
    + + + +
    +
    +
    +
    +
    @@ -78,7 +85,7 @@ $groups = [
    · - (int)$stats['handled']])) ?> (int)$stats['handled']])) ?>
    @@ -104,7 +111,7 @@ $groups = [
    - (int)$year]) ?> + $periodLabel])) ?>
    @@ -138,7 +145,7 @@ $groups = [

    - (int)$r['contact_id'],'year' => $allYears ? 'all' : $year,'municipality' => $municipality]) : null; ?> + (int)$r['contact_id'],'municipality' => $municipality] + $periodQuery) : null; ?>
    - $allYears ? 'all' : $year,'municipality' => $row['municipality']]); ?> + $row['municipality']]); ?> @@ -245,7 +252,7 @@ if (empty($costs['by_clinic'])):?>
    -

    $year - 1])) ?>

    +

    @@ -254,8 +261,8 @@ if (empty($costs['by_clinic'])):?> - - + + @@ -297,7 +304,7 @@ if (empty($costs['by_clinic'])):?>

    (int)$year])) ?> + class="text-muted"> $periodLabel])) ?>
    $total): $display = $labels[$label] ?? $distributionLabels[$label] ?? $label; $width = $maximum > 0 ? max(4, round($total / $maximum * 100)) : 0; $pct = $sum > 0 ? round($total / $sum * 100) : 0; - $cityUrl = '/statistics?' . http_build_query(['year' => $allYears ? 'all' : $year,'municipality' => $label]); + $cityUrl = '/statistics?' . http_build_query($periodQuery + ['municipality' => $label]); $filterValue = $linkType === 'sex' ? match ((string)$label) { 'Femelles' => 'F', 'Mâles' => 'M', default => '' } : (string)$label; @@ -339,6 +346,18 @@ if (empty($costs['by_clinic'])):?>
    + diff --git a/docs/07-statistiques-et-administratif.md b/docs/07-statistiques-et-administratif.md index 7e53cc4..1ce6af7 100644 --- a/docs/07-statistiques-et-administratif.md +++ b/docs/07-statistiques-et-administratif.md @@ -10,7 +10,7 @@ ## Statistiques -Sélectionnez une année ou **Toutes les années**. Une année affiche sa comparaison avec l’année précédente. La vue globale produit une évolution sur toutes les années sans inventer de date pour les événements historiques imprécis. +Choisissez **Toutes les années**, une **Année**, un **Mois** ou une **Période personnalisée** comprise entre deux dates. Une année est comparée à l’année précédente ; un mois ou une période libre est comparé à l’intervalle immédiatement précédent de même durée. La vue globale produit une évolution sur toutes les années sans inventer de date pour les événements historiques imprécis. Les indicateurs couvrent notamment : animaux distincts pris en charge, entrées, sorties, adoptions, décès, naissances, activité médicale, vaccinations, traitements, sexes, âges, espèces, races, robes, origines et délai d’adoption. @@ -24,7 +24,7 @@ Les anciens dossiers sans commune fiable restent dans les totaux généraux, mai ## Analyser les coûts -Les dépenses peuvent être réparties par structure et par catégorie. Le tableau indique total, animaux concernés, moyenne et médiane. Cliquez sur une structure pour ouvrir sa fiche avec la même année et la même commune : le détail permet de vérifier les actes avant de présenter un montant à un partenaire. +Les dépenses peuvent être réparties par structure et par catégorie. Le tableau indique total, animaux concernés, moyenne et médiane. Cliquez sur une structure pour ouvrir sa fiche avec la même période et la même commune : le détail permet de vérifier les actes avant de présenter un montant à un partenaire. Le coût moyen sur tous les animaux du périmètre et celui sur les seuls animaux ayant généré des frais répondent à deux questions différentes ; conservez leurs libellés dans toute communication de subvention. diff --git a/docs/08-documents-et-site-public.md b/docs/08-documents-et-site-public.md index 7af8c24..633086c 100644 --- a/docs/08-documents-et-site-public.md +++ b/docs/08-documents-et-site-public.md @@ -14,7 +14,7 @@ Le fichier ODT reste modifiable dans une suite bureautique. La version PDF/HTML ## Activer le site public -Dans **Paramètres → Site public**, activez le site, choisissez son thème et complétez les contenus : accueil, présentation libre, dons, contact et mentions légales. Trois thèmes légers sont fournis. +Dans **Paramètres → Site public**, activez le site, choisissez son thème et complétez les contenus : accueil, présentation libre, dons, contact et mentions légales. Trois thèmes légers sont fournis. Chaque rubrique peut être repliée pour conserver un écran lisible. Deux modes existent : @@ -32,7 +32,11 @@ Au moins une photo doit être publique pour illustrer correctement la fiche. Les ## Pages publiques -Le menu présente directement les espèces publiées, puis les pages de texte, dons et contact. Le pied de page utilise l’adresse complète, le RNA et le SIRET configurés. La page Contact affiche une carte positionnée sur l’adresse de l’association ; la bibliothèque cartographique est locale mais les tuiles OpenStreetMap nécessitent Internet. +Le menu présente directement les espèces publiées, puis les pages de texte, dons et contact. Le bouton **Ajouter une page** permet de créer autant de pages libres que nécessaire. Renseignez un titre, une adresse courte et le contenu, puis réorganisez les pages par glisser-déposer. Les flèches monter/descendre offrent la même fonction sur écran tactile ou au clavier. Une page peut être masquée sans être supprimée. + +Les zones de texte acceptent les contenus longs et grandissent avec leur contenu. Le pied de page utilise l’adresse complète, le RNA et le SIRET configurés. La page Contact affiche une carte positionnée sur l’adresse de l’association ; la bibliothèque cartographique est locale mais les tuiles OpenStreetMap nécessitent Internet. + +Le contenu accepte les principaux éléments Markdown utiles à une page publique : titres, paragraphes, gras, italique, texte barré, liens HTTPS ou internes, images de la médiathèque, listes simples, numérotées ou à cocher, citations, séparateurs, code et tableaux avec alignement. Le bouton **Tableau** insère une structure prête à compléter. Les tableaux deviennent défilables horizontalement sur les petits écrans. Le HTML brut reste volontairement neutralisé pour protéger le site. Le téléphone et le courriel bénéficient d’une obfuscation CSS légère contre les robots simples. Cela ne remplace ni une politique antispam ni une adresse dédiée au public. diff --git a/docs/11-sauvegardes-et-maintenance.md b/docs/11-sauvegardes-et-maintenance.md index fc7a2eb..35cd2b3 100644 --- a/docs/11-sauvegardes-et-maintenance.md +++ b/docs/11-sauvegardes-et-maintenance.md @@ -19,6 +19,20 @@ La restauration remplace des données actuelles par le contenu de l’archive. A Globinours crée une sauvegarde d’urgence lors des opérations sensibles prévues, mais cette protection ne dispense pas d’une copie externe. +## Réinitialiser automatiquement une démonstration + +La page **Paramètres → Données** permet de recharger manuellement le jeu fictif après création d’une sauvegarde. Une instance de démonstration publique peut aussi être remise à zéro par une tâche planifiée : + +```bash +php scripts/reset-demo.php --confirm-demo-reset +``` + +Le script crée systématiquement une sauvegarde, vide uniquement les données métier puis réinstalle les données et médias fictifs. Il ne doit jamais être planifié sur une instance de production. Exemple quotidien à 03 h 30 avec cron : + +```cron +30 3 * * * cd /chemin/vers/globinours && /usr/bin/php scripts/reset-demo.php --confirm-demo-reset +``` + ## Importer Animal Shelter Manager 3 Dans **Paramètres → Migration ASM3**, déposez un dump SQL ou PostgreSQL. L’assistant commence par analyser le format, les volumes et ambiguïtés. Lisez le rapport avant l’import. diff --git a/migrations/096_animal_history_compatibility.sql b/migrations/096_animal_history_compatibility.sql new file mode 100644 index 0000000..4857199 --- /dev/null +++ b/migrations/096_animal_history_compatibility.sql @@ -0,0 +1,3 @@ +-- La structure est harmonisée conditionnellement par Migrations::run afin de +-- prendre en charge les deux schémas historiques sans recréer la table. +SELECT 1; diff --git a/migrations/097_remove_legacy_room_constraint.sql b/migrations/097_remove_legacy_room_constraint.sql new file mode 100644 index 0000000..0291a07 --- /dev/null +++ b/migrations/097_remove_legacy_room_constraint.sql @@ -0,0 +1,3 @@ +-- La reconstruction conditionnelle est réalisée par Migrations::run afin de +-- préserver toutes les colonnes des bases historiques. +SELECT 1; diff --git a/migrations/098_medical_catalogs_and_test_date.sql b/migrations/098_medical_catalogs_and_test_date.sql new file mode 100644 index 0000000..a1ae4ad --- /dev/null +++ b/migrations/098_medical_catalogs_and_test_date.sql @@ -0,0 +1,17 @@ +ALTER TABLE animals ADD COLUMN fiv_felv_test_date TEXT; + +CREATE TABLE IF NOT EXISTS ref_vaccine_manufacturers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL COLLATE NOCASE UNIQUE, + active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +INSERT OR IGNORE INTO ref_vaccine_manufacturers(name) VALUES +('Boehringer Ingelheim'), +('Ceva Santé Animale'), +('Elanco'), +('MSD Santé Animale'), +('Virbac'), +('Zoetis'); diff --git a/migrations/099_public_custom_pages.sql b/migrations/099_public_custom_pages.sql new file mode 100644 index 0000000..d5934e6 --- /dev/null +++ b/migrations/099_public_custom_pages.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS public_site_pages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE COLLATE NOCASE, + title TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0,1)), + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_public_site_pages_navigation +ON public_site_pages(enabled, sort_order, title); diff --git a/public/assets/mobile.css b/public/assets/mobile.css index 8a7ebe3..bc7e315 100644 --- a/public/assets/mobile.css +++ b/public/assets/mobile.css @@ -49,11 +49,14 @@ img { } .topbar-menu > .navbar-nav { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + display: flex; + flex-direction: column; width: 100%; - gap: .5rem; + gap: .35rem; padding-top: .5rem; + max-height: calc(100dvh - 92px); + overflow-y: auto; + overscroll-behavior: contain; } .topbar-menu .btn { @@ -78,6 +81,11 @@ img { margin: 0 !important; } + .topbar-menu .search-trigger-wrap, + .topbar-menu .settings-dropdown { + width: 100%; + } + .page-header { padding-top: .5rem; } diff --git a/public/index.php b/public/index.php index 42440ad..fce0847 100644 --- a/public/index.php +++ b/public/index.php @@ -16,6 +16,7 @@ require_once __DIR__ . '/../app/Services/BackupService.php'; require_once __DIR__ . '/../app/Services/UpdateService.php'; require_once __DIR__ . '/../app/Services/AppSettings.php'; require_once __DIR__ . '/../app/Services/PublicContentFormatter.php'; +require_once __DIR__ . '/../app/Services/PublicPageService.php'; require_once __DIR__ . '/../app/Services/InstallationService.php'; require_once __DIR__ . '/../app/Services/SystemHealthService.php'; require_once __DIR__ . '/../app/Services/NotificationService.php'; diff --git a/resources/lang/en.php b/resources/lang/en.php index f00c501..f0efb00 100644 --- a/resources/lang/en.php +++ b/resources/lang/en.php @@ -204,4 +204,77 @@ return [ 'care.temperature'=>'Temperature', 'care.invalid_temperature'=>'Temperature must be between 30 and 45 °C.', 'chart.temperature'=>'Temperature (°C)', + 'litter.edit'=>'Edit litter', + 'litter.edit_help'=>'Correct the parents, birth date or linked kittens.', + 'litter.kittens'=>'Kittens in this litter', + 'litter.detach'=>'Detach', + 'litter.detach_confirm'=>'Remove this cat from the litter? Its record and data will be preserved.', + 'litter.parent_is_kitten'=>'A parent cannot also be a kitten in this litter.', + 'animals.dewormer'=>'Deworming / flea treatment', + 'animal.deworming_record'=>'Record deworming / flea treatment', + 'animal.no_deworming'=>'No deworming / flea treatment recorded.', + 'animal.latest_deworming'=>'Latest deworming / flea treatment: :date', + 'pricing.category.dewormer'=>'Deworming / flea treatment', + 'deworm.catalog_record'=>'Record deworming / flea treatment', + 'deworm.product'=>'Deworming / flea treatment', + 'deworm.add_catalog'=>'Add a new deworming / flea treatment product to the catalogue', + 'deworm.name'=>'Deworming / flea treatment product name', + 'deworm.invalid_date'=>'Invalid deworming / flea treatment date.', + 'deworm.name_required'=>'The deworming / flea treatment product name is required.', + 'notification.dewormings_due'=>'Deworming / flea treatment due or within 7 days', + 'inventory.intro'=>'Track medicines, deworming / flea treatments and vaccines separately, including batches, expiry dates, costs and movements.', + 'inventory.dewormers'=>'Deworming / flea treatments', + 'inventory.dewormer'=>'Deworming / flea treatment', + 'agenda.deworming'=>'Deworming / flea treatment', + 'agenda.deworming_reminder'=>'Deworming / flea treatment: :product', + 'agenda.type_deworming'=>'Next deworming / flea treatment', + 'catalog.rename'=>'Correct a product name', + 'catalog.rename_hint'=>'This correction renames the product in every record that uses it. It does not change recorded administrations.', + 'catalog.name_exists'=>'This name already exists in this catalogue.', + 'animal.form.change_pending'=>'Ownership change pending', + 'animal.form.owner_changed'=>'Owner changed', + 'animal.registration_change_pending'=>'I-CAD: ownership change pending', + 'animal.registration_owner_changed'=>'I-CAD: owner changed', + 'animal.veterinary_clinic'=>'Veterinary clinic', + 'animal.add_vaccine'=>'Add a vaccine', + 'role.pet_sitter'=>'Pet sitter', + 'role.refuge_partenaire'=>'Partner shelter or charity', + 'role.toiletteur'=>'Groomer', + 'role.professionnel_autre'=>'Other animal professional', + 'common.show_more'=>'Show more', + 'common.show_less'=>'Show less', + 'settings.catalogs'=>'Medical catalogues', + 'catalog.invalid'=>'Invalid catalogue item.', + 'catalog.in_use'=>'This item is already in use and cannot be deleted. Rename it if needed.', + 'catalog.saved'=>'Catalogue saved.', + 'catalog.admin_help'=>'Add, correct or delete the labels offered in medical records.', + 'catalog.code'=>'Code', + 'catalog.active_ingredient'=>'Active ingredient', + 'catalog.delete_confirm'=>'Delete this label?', + 'statistics.by_year'=>'Year', + 'statistics.by_month'=>'Month', + 'statistics.custom_period'=>'Custom period', + 'statistics.month'=>'Month', + 'statistics.from'=>'From', + 'statistics.to'=>'To', + 'statistics.in_period'=>'during :period', + 'statistics.previous_period_comparison'=>'Comparison with the previous period', + 'statistics.profiles_help_period'=>'Profiles cover animals admitted during :period, unless stated otherwise.', + 'public_site.custom_pages'=>'Custom pages', + 'public_site.site_pages'=>'Pages and menu order', + 'public_site.site_pages_help'=>'Expand a page to edit it and drag the panels to choose their order in the public menu.', + 'public_site.custom_pages_help'=>'Add as many pages as needed and reorder them with drag and drop or the arrow buttons.', + 'public_site.add_page'=>'Add a page', + 'public_site.new_page'=>'New page', + 'public_site.page_title'=>'Page title', + 'public_site.page_slug'=>'Page address', + 'public_site.page_slug_help'=>'Letters, numbers and hyphens; it is used in the public URL.', + 'public_site.page_published'=>'Show this page on the public website', + 'public_site.delete_page'=>'Delete this page', + 'public_site.drag_page'=>'Drag to reorder', + 'public_site.move_up'=>'Move page up', + 'public_site.move_down'=>'Move page down', + 'public_site.page_invalid'=>'A custom page title or address is invalid or reserved.', + 'public_site.page_slug_duplicate'=>'Two custom pages cannot use the same address.', + 'public_site.markdown_table'=>'Table', ]; diff --git a/resources/lang/fr.php b/resources/lang/fr.php index 375c96c..a7418d6 100644 --- a/resources/lang/fr.php +++ b/resources/lang/fr.php @@ -204,4 +204,77 @@ return [ 'care.temperature'=>'Température', 'care.invalid_temperature'=>'La température doit être comprise entre 30 et 45 °C.', 'chart.temperature'=>'Température (°C)', + 'litter.edit'=>'Modifier la portée', + 'litter.edit_help'=>'Corriger les parents, la date ou les chatons rattachés.', + 'litter.kittens'=>'Chatons de la portée', + 'litter.detach'=>'Détacher', + 'litter.detach_confirm'=>'Retirer ce chat de la portée ? Sa fiche et ses données seront conservées.', + 'litter.parent_is_kitten'=>'Un parent ne peut pas être également chaton de cette portée.', + 'animals.dewormer'=>'Vermifuge / anti-puce', + 'animal.deworming_record'=>'Enregistrer un vermifuge / anti-puce', + 'animal.no_deworming'=>'Aucun vermifuge / anti-puce enregistré.', + 'animal.latest_deworming'=>'Dernier vermifuge / anti-puce : :date', + 'pricing.category.dewormer'=>'Vermifuge / anti-puce', + 'deworm.catalog_record'=>'Enregistrer un vermifuge / anti-puce', + 'deworm.product'=>'Vermifuge / anti-puce', + 'deworm.add_catalog'=>'Ajouter un vermifuge / anti-puce au référentiel', + 'deworm.name'=>'Nom du vermifuge / anti-puce', + 'deworm.invalid_date'=>'Date de vermifuge / anti-puce invalide.', + 'deworm.name_required'=>'Le nom du vermifuge / anti-puce est obligatoire.', + 'notification.dewormings_due'=>'Vermifuges / anti-puces échus ou sous 7 jours', + 'inventory.intro'=>'Suivez séparément médicaments, vermifuges / anti-puces et vaccins, leurs lots, péremptions, coûts et mouvements.', + 'inventory.dewormers'=>'Vermifuges / anti-puces', + 'inventory.dewormer'=>'Vermifuge / anti-puce', + 'agenda.deworming'=>'Vermifuge / anti-puce', + 'agenda.deworming_reminder'=>'Vermifuge / anti-puce : :product', + 'agenda.type_deworming'=>'Prochain vermifuge / anti-puce', + 'catalog.rename'=>'Corriger le nom d’un produit', + 'catalog.rename_hint'=>'Cette correction renomme le produit dans toutes les fiches qui l’utilisent. Elle ne modifie pas les administrations enregistrées.', + 'catalog.name_exists'=>'Ce nom existe déjà dans ce catalogue.', + 'animal.form.change_pending'=>'En attente de changement', + 'animal.form.owner_changed'=>'Propriétaire changé', + 'animal.registration_change_pending'=>'I-CAD : en attente de changement', + 'animal.registration_owner_changed'=>'I-CAD : propriétaire changé', + 'animal.veterinary_clinic'=>'Clinique vétérinaire', + 'animal.add_vaccine'=>'Ajouter un vaccin', + 'role.pet_sitter'=>'Pet-sitter', + 'role.refuge_partenaire'=>'Refuge ou association partenaire', + 'role.toiletteur'=>'Toiletteur', + 'role.professionnel_autre'=>'Autre professionnel animalier', + 'common.show_more'=>'Afficher plus', + 'common.show_less'=>'Afficher moins', + 'settings.catalogs'=>'Catalogues médicaux', + 'catalog.invalid'=>'Élément de catalogue invalide.', + 'catalog.in_use'=>'Cet élément est déjà utilisé et ne peut pas être supprimé. Renommez-le si nécessaire.', + 'catalog.saved'=>'Catalogue enregistré.', + 'catalog.admin_help'=>'Ajoutez, corrigez ou supprimez les intitulés proposés dans les fiches médicales.', + 'catalog.code'=>'Code', + 'catalog.active_ingredient'=>'Principe actif', + 'catalog.delete_confirm'=>'Supprimer cet intitulé ?', + 'statistics.by_year'=>'Année', + 'statistics.by_month'=>'Mois', + 'statistics.custom_period'=>'Période personnalisée', + 'statistics.month'=>'Mois', + 'statistics.from'=>'Du', + 'statistics.to'=>'Au', + 'statistics.in_period'=>'sur :period', + 'statistics.previous_period_comparison'=>'Comparaison avec la période précédente', + 'statistics.profiles_help_period'=>'Les profils concernent les animaux entrés sur la période :period, sauf mention contraire.', + 'public_site.custom_pages'=>'Pages personnalisées', + 'public_site.site_pages'=>'Pages et ordre du menu', + 'public_site.site_pages_help'=>'Dépliez une page pour la modifier et glissez les blocs pour choisir leur ordre dans le menu public.', + 'public_site.custom_pages_help'=>'Ajoutez autant de pages que nécessaire et réorganisez-les par glisser-déposer ou avec les flèches.', + 'public_site.add_page'=>'Ajouter une page', + 'public_site.new_page'=>'Nouvelle page', + 'public_site.page_title'=>'Titre de la page', + 'public_site.page_slug'=>'Adresse de la page', + 'public_site.page_slug_help'=>'Lettres, chiffres et tirets ; elle sera utilisée dans l’adresse publique.', + 'public_site.page_published'=>'Afficher cette page sur le site public', + 'public_site.delete_page'=>'Supprimer cette page', + 'public_site.drag_page'=>'Glisser pour réorganiser', + 'public_site.move_up'=>'Monter la page', + 'public_site.move_down'=>'Descendre la page', + 'public_site.page_invalid'=>'Le titre ou l’adresse d’une page personnalisée est invalide ou réservé.', + 'public_site.page_slug_duplicate'=>'Deux pages personnalisées ne peuvent pas utiliser la même adresse.', + 'public_site.markdown_table'=>'Tableau', ]; diff --git a/scripts/reset-demo.php b/scripts/reset-demo.php new file mode 100644 index 0000000..7db7d0e --- /dev/null +++ b/scripts/reset-demo.php @@ -0,0 +1,28 @@ +beginTransaction(); + $removed = DemoDataService::reset($db); + DemoDataService::seed($db, null); + $db->commit(); + DemoDataService::clearFiles(); + DemoDataService::installFiles($db); + fwrite(STDOUT, json_encode(['ok'=>true,'backup'=>$backup['name'],'removed'=>$removed], JSON_UNESCAPED_UNICODE) . PHP_EOL); +} catch (Throwable $e) { + if ($db->inTransaction()) $db->rollBack(); + fwrite(STDERR, json_encode(['ok'=>false,'error'=>$e->getMessage()], JSON_UNESCAPED_UNICODE) . PHP_EOL); + exit(1); +} diff --git a/tests/run.php b/tests/run.php index 56a6761..7afdb7b 100644 --- a/tests/run.php +++ b/tests/run.php @@ -34,6 +34,7 @@ foreach ( 'SpeciesService', 'LabReferenceService', 'PublicContentFormatter', + 'PublicPageService', 'ShelterRoomService', 'GlobalSearchService', 'PrivateMediaService', @@ -78,6 +79,35 @@ try { ); $assert(in_array('temperature_c', $columns, true), 'Colonne temperature_c absente'); }); + $test('La création d’une portée ne dépend pas d’un téléversement', function () use ($assert): void { + $relationships = file_get_contents(__DIR__ . '/../app/Controllers/AnimalRelationshipActions.php'); + $animals = file_get_contents(__DIR__ . '/../app/Controllers/AnimalsController.php'); + $assert(!str_contains($relationships, 'PrivateMediaService::moveVisibility'), 'Bloc de photo résiduel dans les portées'); + $assert( + str_contains($animals, 'animal_id, action, type, label, details, user_id'), + 'L’historique des portées ne renseigne pas son action', + ); + }); + $test('Une portée peut être corrigée sans supprimer ses animaux', function () use ($assert): void { + $routes = file_get_contents(__DIR__ . '/../app/Routes/web.php'); + $controller = file_get_contents(__DIR__ . '/../app/Controllers/AnimalRelationshipActions.php'); + $view = file_get_contents(__DIR__ . '/../app/Views/litter_edit.php'); + foreach (['/litter/edit', '/litter/update', '/litter/kitten/detach'] as $route) { + $assert(str_contains($routes, $route), 'Route de portée absente : ' . $route); + } + $assert(str_contains($controller, 'DELETE FROM litter_kittens'), 'Le détachement du chaton est absent'); + $assert(!str_contains($controller, 'DELETE FROM animals'), 'Le détachement supprimerait une fiche animale'); + $assert(str_contains($view, '/animal/edit?id='), 'La fiche du chaton n’est pas modifiable depuis la portée'); + }); + $test('Les médias publics restent accessibles au serveur web', function () use ($assert): void { + $security = file_get_contents(__DIR__ . '/../app/Services/SecurityService.php'); + $images = file_get_contents(__DIR__ . '/../app/Services/ImageService.php'); + $updates = file_get_contents(__DIR__ . '/../app/Services/UpdateService.php'); + $assert(str_contains($security, "@chmod(\$root . '/data', 0710)"), 'La racine des données ne reste pas traversable'); + $assert(str_contains($security, "hardenPublicTree(\$root . '/public/media')"), 'Les médias publics ne sont pas réparés'); + $assert(str_contains($images, 'preparePublicDirectory($directory)'), 'Les nouveaux dossiers publics ne sont pas préparés'); + $assert(str_contains($updates, "normalizePublicCodePermissions(\$root . '/public')"), 'Le code public n’est pas normalisé après mise à jour'); + }); $test("Les voies d’administration des médicaments sont initialisées", function () use ($db, $assert): void { $routes = $db ->query('SELECT accro FROM ref_med_administration ORDER BY id') @@ -88,7 +118,7 @@ try { $db->query('PRAGMA table_info(animal_history)')->fetchAll(PDO::FETCH_ASSOC), 'name', ); - foreach (['type', 'label', 'details'] as $column) { + foreach (['action', 'field', 'old_value', 'new_value', 'meta', 'type', 'label', 'details', 'user_id'] as $column) { $assert(in_array($column, $historyColumns, true), 'Colonne historique absente : ' . $column); } $assert( @@ -124,6 +154,10 @@ try { 'Les cinq box initiaux ne sont pas disponibles', ); }); + $test('Une nouvelle fiche de chat propose la race Chat Européen', function () use ($assert): void { + $controller = (string) file_get_contents(dirname(__DIR__) . '/app/Controllers/AnimalRecordActions.php'); + $assert(str_contains($controller, "'breed' => 'Chat Européen'")); + }); $test('Les tournées acceptent quatre créneaux configurables', function () use ($db, $assert): void { foreach (ShelterRoomService::PERIODS as $period) { $db->prepare('INSERT INTO care_rounds(round_date,period) VALUES(?,?)')->execute(['2099-01-01', $period]); @@ -138,6 +172,62 @@ try { $assert(!ShelterRoomService::accepts($room, ['species' => 'chat', 'sex' => 'F', 'fiv' => 1])); $assert(!ShelterRoomService::accepts($room, ['species' => 'chat', 'sex' => 'M', 'fiv' => 0])); }); + $test('Une salle configurable peut être enregistrée sur un animal', function () use ($db, $assert): void { + $schema = (string) $db->query("SELECT sql FROM sqlite_master WHERE type='table' AND name='animals'")->fetchColumn(); + $assert(!str_contains($schema, "refuge_room IN ('silver', 'twix')"), 'Ancienne contrainte de salle encore active'); + $code = 'TEST-ROOM-' . bin2hex(random_bytes(3)); + $db->prepare("INSERT INTO animals(name,internal_code,refuge_room) VALUES('Test salle',:code,'nouvelle-salle')")->execute([':code' => $code]); + $id = (int) $db->lastInsertId(); + $assert((string) $db->query('SELECT refuge_room FROM animals WHERE id=' . $id)->fetchColumn() === 'nouvelle-salle'); + $db->prepare('DELETE FROM animals WHERE id=:id')->execute([':id' => $id]); + }); + $test('Les quatre états I-CAD sont acceptés', function () use ($db, $assert): void { + $code = 'TEST-ICAD-' . bin2hex(random_bytes(3)); + $db->prepare("INSERT INTO animals(name,internal_code,identification_registration_status) VALUES('Test I-CAD',:code,'unknown')") + ->execute([':code' => $code]); + $id = (int) $db->lastInsertId(); + foreach (['unknown', 'registered', 'change_pending', 'owner_changed'] as $status) { + $db->prepare('UPDATE animals SET identification_registration_status=:status WHERE id=:id') + ->execute([':status' => $status, ':id' => $id]); + $assert( + (string) $db->query('SELECT identification_registration_status FROM animals WHERE id=' . $id)->fetchColumn() === $status, + 'État I-CAD refusé : ' . $status, + ); + } + $db->prepare('DELETE FROM animals WHERE id=:id')->execute([':id' => $id]); + }); + $test('La vaccination repose sur le cabinet vétérinaire', function () use ($assert): void { + $modal = (string) file_get_contents(dirname(__DIR__) . '/app/Views/_animal_show_modals.php'); + $summary = (string) file_get_contents(dirname(__DIR__) . '/app/Views/_animal_show_medical.php'); + $assert(!str_contains($modal, 'name="administered_by_user_id"')); + $assert(!str_contains($modal, 'name="administered_by_name"')); + $assert(str_contains($summary, "\$v['clinic_name']")); + }); + $test('Vaccins et vermifuges peuvent être corrigés sans nouveau déstockage', function () use ($assert): void { + $modals = (string) file_get_contents(dirname(__DIR__) . '/app/Views/_animal_show_modals.php'); + $medical = (string) file_get_contents(dirname(__DIR__) . '/app/Views/_animal_show_medical.php'); + $vaccines = (string) file_get_contents(dirname(__DIR__) . '/app/Controllers/AnimalMedicalActions.php'); + $dewormings = (string) file_get_contents(dirname(__DIR__) . '/app/Controllers/DewormingController.php'); + $assert(substr_count($modals, 'name="entry_id"') >= 2); + $assert(str_contains($medical, 'edit-vaccine')); + $assert(str_contains($medical, 'edit-deworming')); + $assert(str_contains($vaccines, 'if ($entryId > 0)')); + $assert(str_contains($dewormings, 'if ($entryId > 0)')); + $assert(strpos($vaccines, 'if ($entryId > 0)') < strpos($vaccines, 'InventoryService::consumeBatch')); + $assert(strpos($dewormings, 'if ($entryId > 0)') < strpos($dewormings, 'InventoryService::consumeBatch')); + }); + $test('La liste des vaccins affiche une action de modification explicite', function () use ($assert): void { + $medical = file_get_contents(__DIR__ . '/../app/Views/_animal_show_medical.php'); + $assert(str_contains($medical, "h(t('common.actions'))"), 'L’en-tête Actions manque aux vaccins'); + $assert( + str_contains($medical, 'btn btn-sm btn-outline-primary edit-vaccine'), + 'Le bouton Modifier des vaccins est absent', + ); + $assert( + !preg_match('/class="[^"]*edit-vaccine[^"]*"[\s\S]*?>✏️<\/button>/', $medical), + 'Le crayon est encore utilisé pour modifier un vaccin', + ); + }); $test('Les paramètres sont enregistrés et relus', function () use ($assert): void { AppSettings::save(['association_name' => 'Refuge Test', 'app_language' => 'en'], null); $assert(AppSettings::get('association_name') === 'Refuge Test'); @@ -206,6 +296,44 @@ try { $assert(str_contains($image, ' Important\n\n`\n```"); + $assert(!str_contains($fenced, '