diff --git a/CHANGELOG.md b/CHANGELOG.md index f601345..839e322 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,114 +2,6 @@ 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 diff --git a/HISTORIQUE.md b/HISTORIQUE.md index 5b92140..9cb5fa6 100644 --- a/HISTORIQUE.md +++ b/HISTORIQUE.md @@ -10,47 +10,6 @@ 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. diff --git a/README.md b/README.md index 64d813f..0da8de5 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 par année, mois ou période libre ; +- factures vétérinaires, règlements, justificatifs et statistiques de coûts ; - 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.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`. +La candidate actuelle est `1.0.0-rc.25`. 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`. 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 63f99b9..07923b2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0-rc.35 +1.0.0-rc.25 diff --git a/app/Controllers/AnimalBrowseActions.php b/app/Controllers/AnimalBrowseActions.php index 3d4c912..845e5f9 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, adopter_contact_id + SELECT adopter_name, adopter_address, adopter_postal_code, adopter_city, adopter_country, adopter_phone, adopter_email, adoption_date FROM adoptions WHERE animal_id = :id ORDER BY id DESC @@ -612,11 +612,12 @@ trait AnimalBrowseActions // Vaccins $stmt = $db->prepare(' - SELECT v.*, rv.name AS vaccine_name, COALESCE(dc.name, c.name) AS clinic_name + 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 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 @@ -650,10 +651,7 @@ 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 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); + $vaccines = $db->query('SELECT id, name FROM ref_vaccines ORDER BY id')->fetchAll(); $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)', @@ -835,7 +833,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 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.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 COALESCE(v.notes,'')!='' THEN '\nNotes : ' || v.notes ELSE '' END ) AS details, datetime(v.done_date || ' 12:00:00') AS at @@ -961,7 +959,6 @@ 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 8564d5f..3cd6172 100644 --- a/app/Controllers/AnimalMedicalActions.php +++ b/app/Controllers/AnimalMedicalActions.php @@ -32,8 +32,6 @@ 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(); @@ -55,7 +53,6 @@ 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') @@ -64,7 +61,6 @@ trait AnimalMedicalActions )->execute([ ':fiv_status' => $fivStatus, ':felv_status' => $felvStatus, - ':test_date' => $testDate, ':fiv' => $fivStatus === 'pos' ? 1 : 0, ':felv' => $felvStatus === 'pos' ? 1 : 0, ':id' => $animalId, @@ -102,15 +98,6 @@ 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', ); @@ -413,7 +400,6 @@ 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)) { @@ -443,15 +429,6 @@ 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'] ?? '')); @@ -498,7 +475,12 @@ trait AnimalMedicalActions return; } $vet_name = $vetContact['name'] ?? $vet_name; - $values = [ + $stmt = $db->prepare(' + INSERT INTO treatments(animal_id, medication_id, route, dose_text, start_date, end_date, ongoing, notes, clinic_id, vet_name, clinic_contact_id, vet_contact_id, give_morning, give_evening, give_as_needed, created_by) + VALUES(:aid,:mid,:route,:dose,:start,:end,1,:notes,:clinic_id,:vet_name,:clinic_contact_id,:vet_contact_id,:give_morning,:give_evening,:give_as_needed,:created_by) + '); + $stmt->execute([ + ':aid' => $id, ':mid' => $medId, ':route' => $route !== '' ? $route : null, ':dose' => $dose, @@ -512,32 +494,16 @@ trait AnimalMedicalActions ':give_morning' => $giveMorning, ':give_evening' => $giveEvening, ':give_as_needed' => $giveAsNeeded, - ]; - 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'); - } + ':created_by' => Auth::id(), + ]); + if ((int) ($_POST['tariff_id'] ?? 0) > 0) { + PricingService::record($db, $id, (int) $_POST['tariff_id'], 1, $start, 'Ajoutée avec le traitement'); } self::logHistory( $id, 'treatment', - $entryId > 0 ? 'Traitement corrigé' : 'Traitement ajouté', + 'Traitement ajouté', "Médicament #$medId\nDose : $dose\nDébut : $start" . ($end !== '' ? "\nFin : $end" : '') . ($notes !== '' ? "\nNotes : $notes" : ''), @@ -596,30 +562,15 @@ 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 === '') { @@ -651,9 +602,9 @@ trait AnimalMedicalActions SELECT COUNT(*) FROM vaccinations v JOIN ref_vaccines rv ON rv.id = v.vaccine_id - WHERE v.animal_id = :aid AND v.id <> :entry AND upper(coalesce(rv.family,'')) = 'L' + WHERE v.animal_id = :aid AND upper(coalesce(rv.family,'')) = 'L' "); - $stmt->execute([':aid' => $id, ':entry' => $entryId]); + $stmt->execute([':aid' => $id]); $countL = (int) $stmt->fetchColumn(); $isPrimoL = $countL === 0; } @@ -679,6 +630,15 @@ 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; @@ -707,7 +667,11 @@ trait AnimalMedicalActions } $vet_name = $vetContact['name'] ?? $vet_name; - $values = [ + $stmt = $db->prepare(' + INSERT INTO vaccinations(animal_id, vaccine_id, done_date, due_date, lot,manufacturer,batch_expires_on,administered_by_user_id,administered_by_name, notes, clinic_id, vet_name, clinic_contact_id, vet_contact_id, created_by) + VALUES(:aid,:vid,:done,:due,:lot,:manufacturer,:batch_expiry,:administrator_user,:administrator_name,:notes,:clinic_id,:vet_name,:clinic_contact_id,:vet_contact_id,:created_by) + '); + $stmt->execute([ ':aid' => $id, ':vid' => $vacId, ':done' => $done, @@ -715,50 +679,38 @@ 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(), - ]; - 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'); - } + ]); + 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', - $entryId > 0 ? 'Vaccin corrigé' : 'Vaccin ajouté', + 'Vaccin ajouté', "Vaccin #$vacId\nFait le : $done" . ($due !== '' ? "\nRappel : $due" : '') . ($manufacturer !== '' ? "\nFabricant : $manufacturer" : '') . ($lot !== '' ? "\nLot : $lot" : '') . ($batchExpiresOn !== '' ? "\nExpiration du lot : $batchExpiresOn" : '') . - "\nCabinet vétérinaire : " . ($clinicContact['name'] ?? ''), + ($administratorName !== '' || $administratorUserId + ? "\nAdministré par : " . + ($administratorName !== '' ? $administratorName : 'utilisateur #' . $administratorUserId) + : ''), ); $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 187e74e..9935cb6 100644 --- a/app/Controllers/AnimalRecordActions.php +++ b/app/Controllers/AnimalRecordActions.php @@ -182,7 +182,6 @@ trait AnimalRecordActions 'adoption_unavailability_reason' => '', 'website_published' => 0, 'website_description' => '', - 'breed' => 'Chat Européen', 'color' => '', 'hair_type' => '', 'birth_date' => '', @@ -377,10 +376,7 @@ trait AnimalRecordActions } $identificationDate = trim((string) ($_POST['identification_date'] ?? '')); $identificationRegistration = (string) ($_POST['identification_registration_status'] ?? 'unknown'); - if ($identificationRegistration === 'pending') { - $identificationRegistration = 'change_pending'; - } - if (!in_array($identificationRegistration, ['unknown', 'registered', 'change_pending', 'owner_changed'], true)) { + if (!in_array($identificationRegistration, ['unknown', 'pending', 'registered'], true)) { $identificationRegistration = 'unknown'; } if ($chip_id === '') { @@ -746,18 +742,6 @@ 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/AnimalsController.php b/app/Controllers/AnimalsController.php index adaec7c..209c6dd 100644 --- a/app/Controllers/AnimalsController.php +++ b/app/Controllers/AnimalsController.php @@ -448,7 +448,6 @@ 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')); @@ -469,10 +468,6 @@ 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; @@ -494,10 +489,6 @@ 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/DashboardController.php b/app/Controllers/DashboardController.php index 08ed945..098b7b6 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é','adopte','adopté') + AND lower(a.status) NOT IN ('decede','décédé') AND date(v.due_date) < date('now') ORDER BY date(v.due_date) ASC LIMIT 50 @@ -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é','adopte','adopté') + AND lower(a.status) NOT IN ('decede','décédé') AND date(v.due_date) >= date('now') AND date(v.due_date) <= date('now', '+7 day') ORDER BY date(v.due_date) ASC @@ -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é','adopte','adopté') + AND lower(a.status) NOT IN ('decede','décédé') AND date(v.due_date) > date('now', '+7 day') AND date(v.due_date) <= date('now', '+30 day') ORDER BY date(v.due_date) ASC @@ -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,10 +171,9 @@ 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(); @@ -186,7 +185,6 @@ 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/DewormingController.php b/app/Controllers/DewormingController.php index fc83a4e..3ce8082 100644 --- a/app/Controllers/DewormingController.php +++ b/app/Controllers/DewormingController.php @@ -4,53 +4,6 @@ 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') { @@ -64,7 +17,6 @@ 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)) { @@ -79,15 +31,6 @@ 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 === '') { @@ -116,7 +59,7 @@ final class DewormingController } $ids = [$animalId]; $litterId = null; - if ($entryId === 0 && isset($_POST['apply_litter'])) { + if (isset($_POST['apply_litter'])) { $s = $db->prepare( 'SELECT l.id FROM litters l LEFT JOIN litter_kittens lk ON lk.litter_id=l.id WHERE l.mother_id=? OR lk.animal_id=? ORDER BY l.id DESC LIMIT 1', ); @@ -142,36 +85,8 @@ 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,action,type,label,details,user_id) VALUES(?,'deworming','deworming','Vermifuge / anti-puce administré',?,?)", + "INSERT INTO animal_history(animal_id,action,type,label,details,user_id) VALUES(?,'deworming','deworming','Vermifuge 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]); @@ -202,7 +117,7 @@ final class DewormingController (int) ($_POST['inventory_batch_id'] ?? 0), $perAnimal * count($ids), $animalId, - 'Vermifuge / anti-puce du ' . $date . ($litterId ? ' · portée #' . $litterId : ''), + 'Vermifuge 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 1c101c7..b52389f 100644 --- a/app/Controllers/DirectoryController.php +++ b/app/Controllers/DirectoryController.php @@ -15,10 +15,6 @@ 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'), ]; } @@ -95,39 +91,17 @@ 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) || array_key_exists('period_type', $_GET); + $periodRequested = array_key_exists('year', $_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 ($periodStart !== null && $periodEnd !== null) { + if ($periodYear !== null) { $expenseWhere[] = 'date(ae.occurred_on) BETWEEN :expense_start AND :expense_end'; - $expenseParams[':expense_start'] = $periodStart; - $expenseParams[':expense_end'] = $periodEnd; + $expenseParams[':expense_start'] = sprintf('%04d-01-01', $periodYear); + $expenseParams[':expense_end'] = sprintf('%04d-12-31', $periodYear); } $expenses = $db->prepare( 'SELECT ae.*,a.name animal_name,a.internal_code,a.rescue_location_name,a.rescue_address FROM animal_expenses ae JOIN animals a ON a.id=ae.animal_id WHERE ' . @@ -151,10 +125,9 @@ final class DirectoryController $expenseRows = array_slice($expenseRows, 0, 250); $invoiceWhere = ['vendor_contact_id=:vendor', 'deleted_at IS NULL']; $invoiceParams = [':vendor' => $id]; - if ($periodStart !== null && $periodEnd !== null) { - $invoiceWhere[] = 'date(invoice_date) BETWEEN :invoice_start AND :invoice_end'; - $invoiceParams[':invoice_start'] = $periodStart; - $invoiceParams[':invoice_end'] = $periodEnd; + if ($periodYear !== null) { + $invoiceWhere[] = "strftime('%Y',invoice_date)=:invoice_year"; + $invoiceParams[':invoice_year'] = (string) $periodYear; } $invoiceStmt = $db->prepare( 'SELECT * FROM accounting_invoices WHERE ' . @@ -185,8 +158,6 @@ final class DirectoryController 'expenseCount' => $expenseCount, 'expensePeriodRequested' => $periodRequested, 'expensePeriodYear' => $periodYear, - 'expensePeriodLabel' => $periodLabel, - 'expensePeriodQuery' => $periodQuery, 'expensePeriodMunicipality' => $periodMunicipality, 'contactInvoices' => $contactInvoices, 'contactInvoiceTotal' => $contactInvoiceTotal, diff --git a/app/Controllers/PublicSiteController.php b/app/Controllers/PublicSiteController.php index 9b80755..111f590 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; } - if (!self::usesRoot()) return false; - if (in_array( + return self::usesRoot() && + in_array( $path, [ '/', @@ -28,9 +28,7 @@ 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 { @@ -45,10 +43,8 @@ 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 @@ -63,10 +59,6 @@ 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(), @@ -81,7 +73,7 @@ final class PublicSiteController } private static function normalize(string $path): string { - $normalized = match ($path) { + return match ($path) { '/' => '/site', '/animaux' => '/site/animaux', '/adoption' => '/site/animal', @@ -93,11 +85,6 @@ 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 { @@ -173,20 +160,6 @@ 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', [ @@ -289,7 +262,6 @@ 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 f7d3e0d..35d2ff4 100644 --- a/app/Controllers/SettingsController.php +++ b/app/Controllers/SettingsController.php @@ -759,7 +759,6 @@ final class SettingsController 'saved' => isset($_GET['saved']), 'error' => (string) ($_GET['error'] ?? ''), 'images' => $images, - 'customPages' => PublicPageService::all(), ]); } @@ -870,30 +869,22 @@ final class SettingsController 'public_site_routing' => $routing, 'public_site_about_enabled' => isset($_POST['public_site_about_enabled']) ? '1' : '0', ]; - $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] ?? '')); + 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); } $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)) { @@ -923,19 +914,9 @@ final class SettingsController } $values[$key] = $url; } - $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)); - } + AppSettings::save($values, Auth::id()); + AuditService::log('public_site_saved', '/settings/public-site/save', 'Configuration du site public modifiée'); + header('Location: /settings/public-site?saved=1'); exit(); } public static function audit(): void @@ -1246,68 +1227,6 @@ 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 e1f067d..9d1e906 100644 --- a/app/Controllers/StatisticsController.php +++ b/app/Controllers/StatisticsController.php @@ -6,95 +6,37 @@ 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::period($start, $end, $ids), - 'comparison' => $comparison, + 'stats' => $all ? StatisticsService::all($ids) : StatisticsService::annual($year, $ids), + 'comparison' => $all ? [] : StatisticsService::comparison($year, $ids), 'timeline' => StatisticsService::timeline($years, $ids), 'undatedHandled' => StatisticsService::undatedHandled($ids), 'undatedDeaths' => StatisticsService::undatedDeaths($ids), 'undatedBirths' => StatisticsService::undatedBirths($ids), 'undatedExits' => StatisticsService::undatedExits($ids), 'scopeCounts' => StatisticsService::scopeCounts($ids), - 'distributions' => StatisticsService::distributions($all ? null : $year, $ids, $municipality ?: null, $start, $end), + 'distributions' => StatisticsService::distributions($year, $ids, $municipality ?: null), 'health' => StatisticsService::currentHealth($ids), - 'costs' => StatisticsService::costs($all ? null : $year, $ids, $start, $end), - 'municipalityCosts' => StatisticsService::costsByMunicipality($all ? null : $year, $start, $end), + 'costs' => StatisticsService::costs($year, $ids), + 'municipalityCosts' => StatisticsService::costsByMunicipality($year), ]); } } diff --git a/app/Controllers/SurgeriesController.php b/app/Controllers/SurgeriesController.php index bc5c383..a0739d8 100644 --- a/app/Controllers/SurgeriesController.php +++ b/app/Controllers/SurgeriesController.php @@ -56,16 +56,6 @@ 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) { @@ -87,7 +77,7 @@ final class SurgeriesController $name, $date, $status, - $clinic, + (int) ($_POST['clinic_contact_id'] ?? 0) ?: null, (int) ($_POST['veterinarian_contact_id'] ?? 0) ?: null, $tariff, $amount, @@ -102,7 +92,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, - $clinic, + (int) ($_POST['clinic_contact_id'] ?? 0) ?: null, $tariff, $id, $name, diff --git a/app/Routes/web.php b/app/Routes/web.php index ac1a2bf..9a12b0e 100644 --- a/app/Routes/web.php +++ b/app/Routes/web.php @@ -88,9 +88,6 @@ switch ($path) { case '/animal/add-deworming': DewormingController::save(); break; - case '/animal/product/rename': - DewormingController::renameProduct(); - break; case '/animal/add-vaccine': AnimalsController::addVaccine(); @@ -338,15 +335,6 @@ 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 f0c15de..d704200 100644 --- a/app/Services/AppSettings.php +++ b/app/Services/AppSettings.php @@ -62,7 +62,6 @@ 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 b3233f4..90edbc2 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' => 'change_pending', + '0' => 'pending', default => 'unknown', }, 'sterilization_status' => $yes($r['NEUTERED'] ?? 0) ? 'yes' : 'unknown', diff --git a/app/Services/DB.php b/app/Services/DB.php index 65c648a..9995146 100644 --- a/app/Services/DB.php +++ b/app/Services/DB.php @@ -29,17 +29,6 @@ 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 1ad5d5b..df454b8 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,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', + 'SELECT v.id,a.internal_code code_animal,a.name animal,rv.name vaccin,v.done_date date_realisation,v.due_date prochain_rappel,v.manufacturer fabricant,v.lot,v.batch_expires_on expiration_lot,COALESCE(v.administered_by_name,au.display_name,au.username) administre_par,COALESCE(cu.display_name,cu.username) saisi_par,vet.name veterinaire,clinic.name cabinet,v.notes FROM vaccinations v JOIN animals a ON a.id=v.animal_id JOIN ref_vaccines rv ON rv.id=v.vaccine_id LEFT JOIN directory_contacts vet ON vet.id=v.vet_contact_id LEFT JOIN directory_contacts clinic ON clinic.id=v.clinic_contact_id LEFT JOIN users au ON au.id=v.administered_by_user_id LEFT JOIN users cu ON cu.id=v.created_by ORDER BY v.done_date,v.id', ], 'deaths' => [ 'Décès', diff --git a/app/Services/GlobalSearchService.php b/app/Services/GlobalSearchService.php index 5230094..84a847a 100644 --- a/app/Services/GlobalSearchService.php +++ b/app/Services/GlobalSearchService.php @@ -11,21 +11,17 @@ final class GlobalSearchService return []; } $db = DB::pdo(); - $foldedQuery = self::fold($query); - $like = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $foldedQuery) . '%'; + $like = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $query) . '%'; $out = []; if (PermissionService::can('animals')) { $out['animals'] = self::rows( $db, - "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", + "SELECT id,name,internal_code,status,species FROM animals WHERE deleted_at IS NULL AND (name LIKE :q ESCAPE '\\' OR internal_code LIKE :q ESCAPE '\\' OR COALESCE(chip,'') LIKE :q ESCAPE '\\') ORDER BY name COLLATE NOCASE LIMIT :lim", $like, $limit, static fn($r) => [ 'title' => $r['name'], - 'detail' => implode( - ' · ', - array_filter([$r['internal_code'], $r['identification_number'], $r['species'], $r['status']]), - ), + 'detail' => implode(' · ', array_filter([$r['internal_code'], $r['species'], $r['status']])), 'url' => '/animal?id=' . (int) $r['id'], ], ); @@ -33,7 +29,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 (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", + "SELECT id,name,kind,phone,email,city FROM directory_contacts WHERE deleted_at IS NULL AND (name LIKE :q ESCAPE '\\' OR COALESCE(phone,'') LIKE :q ESCAPE '\\' OR COALESCE(email,'') LIKE :q ESCAPE '\\' OR COALESCE(city,'') LIKE :q ESCAPE '\\') ORDER BY name COLLATE NOCASE LIMIT :lim", $like, $limit, static fn($r) => [ @@ -46,7 +42,7 @@ final class GlobalSearchService if (PermissionService::can('agenda')) { $out['agenda'] = self::rows( $db, - "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", + "SELECT id,title,starts_at,location,status FROM agenda_items WHERE title LIKE :q ESCAPE '\\' OR COALESCE(description,'') LIKE :q ESCAPE '\\' OR COALESCE(location,'') LIKE :q ESCAPE '\\' ORDER BY starts_at DESC LIMIT :lim", $like, $limit, static fn($r) => [ @@ -62,7 +58,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 (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", + "SELECT ai.id,ai.reference,ai.invoice_date,ai.status,dc.name vendor FROM accounting_invoices ai LEFT JOIN directory_contacts dc ON dc.id=ai.vendor_contact_id WHERE ai.deleted_at IS NULL AND (ai.reference LIKE :q ESCAPE '\\' OR COALESCE(ai.notes,'') LIKE :q ESCAPE '\\' OR COALESCE(dc.name,'') LIKE :q ESCAPE '\\') ORDER BY ai.invoice_date DESC LIMIT :lim", $like, $limit, static fn($r) => [ @@ -75,14 +71,6 @@ 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 eda60ea..ef381d3 100644 --- a/app/Services/IcadService.php +++ b/app/Services/IcadService.php @@ -112,19 +112,8 @@ 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' . $accountPath, $cookie); + $r2b = $this->req('https://www.i-cad.fr/pro', $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 89682d6..d214ef3 100644 --- a/app/Services/ImageService.php +++ b/app/Services/ImageService.php @@ -131,7 +131,7 @@ 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; } } diff --git a/app/Services/Migrations.php b/app/Services/Migrations.php index 860a0c6..1476068 100644 --- a/app/Services/Migrations.php +++ b/app/Services/Migrations.php @@ -91,21 +91,13 @@ final class Migrations } } - $relaxesForeignKeys = in_array( - $name, - ['093_room_rules_and_care_periods.sql', '097_remove_legacy_room_constraint.sql'], - true, - ); + $relaxesForeignKeys = $name === '093_room_rules_and_care_periods.sql'; if ($relaxesForeignKeys) { $db->exec('PRAGMA foreign_keys = OFF'); } $db->beginTransaction(); try { - if ($name === '097_remove_legacy_room_constraint.sql') { - self::modernizeAnimalConstraints($db); - } else { - $db->exec($sql); - } + $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) { @@ -124,62 +116,4 @@ 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 3684bcb..6e56133 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 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 a03c5a1..9de5203 100644 --- a/app/Services/PermissionService.php +++ b/app/Services/PermissionService.php @@ -91,8 +91,6 @@ 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', diff --git a/app/Services/PricingService.php b/app/Services/PricingService.php index e773377..9b41b5b 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 / anti-puce', + 'dewormer' => 'Vermifuge', 'other' => 'Autre', ]; public const GROUPS = [ diff --git a/app/Services/PublicContentFormatter.php b/app/Services/PublicContentFormatter.php index 0736b4a..ae4bb1a 100644 --- a/app/Services/PublicContentFormatter.php +++ b/app/Services/PublicContentFormatter.php @@ -2,143 +2,77 @@ 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 = []; - $count = count($lines); - for ($i = 0; $i < $count;) { - $line = rtrim($lines[$i]); - if (trim($line) === '') { $i++; continue; } + $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) . ''; + foreach ($lines as $line) { + $line = trim($line); + if ($line === '') { + if ($listOpen) { + $html[] = ''; + $listOpen = false; } - $table .= ''; - foreach ($rows as $row) { - $table .= ''; - foreach ($headers as $column => $_) { - $align = $alignments[$column] ?? ''; - $table .= '' . self::inline($row[$column] ?? '') . ''; - } - $table .= ''; + continue; + } + if (preg_match('/^[-*]\s+(.+)$/u', $line, $match)) { + if (!$listOpen) { + $html[] = '
'; + $html[] = '
  • ' . self::inline($match[1]) . '
  • '; continue; } - - 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++; + if ($listOpen) { + $html[] = ''; + $listOpen = false; + } + if (preg_match('/^(#{1,3})\s+(.+)$/u', $line, $match)) { + $level = strlen($match[1]) + 1; + $html[] = '' . self::inline($match[2]) . ''; continue; } - 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)) . '

    '; + $html[] = '

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

    '; + } + if ($listOpen) { + $html[] = ''; } 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 { - $tokens = []; - $reserve = static function (string $html) use (&$tokens): string { - $token = "\x1A" . count($tokens) . "\x1A"; - $tokens[] = $html; + $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') . ''; 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; - $text = preg_replace('/(?$1', $text) ?? $text; - foreach ($tokens as $index => $html) $text = str_replace("\x1A" . $index . "\x1A", $html, $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); + } return $text; } } diff --git a/app/Services/PublicPageService.php b/app/Services/PublicPageService.php deleted file mode 100644 index 9f0ff2b..0000000 --- a/app/Services/PublicPageService.php +++ /dev/null @@ -1,81 +0,0 @@ -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/StatisticsService.php b/app/Services/StatisticsService.php index 7975fe8..724081d 100644 --- a/app/Services/StatisticsService.php +++ b/app/Services/StatisticsService.php @@ -4,14 +4,10 @@ declare(strict_types=1); final class StatisticsService { - public static function costs(?int $year, ?array $ids = null, ?string $start = null, ?string $end = null): array + public static function costs(?int $year, ?array $ids = null): array { $db = DB::pdo(); - if ($start === null && $year !== null) { - $start = sprintf('%04d-01-01', $year); - $end = sprintf('%04d-12-31', $year); - } - $scopeIds = self::costScopeIds($year, $ids, $start, $end); + $scopeIds = self::costScopeIds($year, $ids); $empty = [ 'total_cents' => 0, 'animals' => 0, @@ -27,9 +23,9 @@ final class StatisticsService } $where = ['a.deleted_at IS NULL', 'ae.animal_id IN(' . implode(',', array_map('intval', $scopeIds)) . ')']; $params = []; - if ($start !== null && $end !== null) { + if ($year !== null) { $where[] = 'date(ae.occurred_on) BETWEEN :start AND :end'; - $params = [':start' => $start, ':end' => $end]; + $params = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)]; } $base = ' FROM animal_expenses ae JOIN animals a ON a.id=ae.animal_id LEFT JOIN directory_contacts dc ON dc.id=ae.clinic_contact_id LEFT JOIN clinic_tariffs ct ON ct.id=ae.tariff_id WHERE ' . @@ -67,14 +63,10 @@ final class StatisticsService ]; } - public static function costsByMunicipality(?int $year, ?string $start = null, ?string $end = null): array + public static function costsByMunicipality(?int $year): array { $db = DB::pdo(); - if ($start === null && $year !== null) { - $start = sprintf('%04d-01-01', $year); - $end = sprintf('%04d-12-31', $year); - } - $scopeIds = self::costScopeIds($year, null, $start, $end); + $scopeIds = self::costScopeIds($year, null); if (!$scopeIds) { return []; } @@ -100,9 +92,9 @@ final class StatisticsService } $params = []; $dateSql = ''; - if ($start !== null && $end !== null) { + if ($year !== null) { $dateSql = ' AND date(occurred_on) BETWEEN :start AND :end'; - $params = [':start' => $start, ':end' => $end]; + $params = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)]; } $s = $db->prepare( 'SELECT animal_id,SUM(total_cents) total_cents FROM animal_expenses WHERE animal_id IN(' . @@ -141,24 +133,22 @@ final class StatisticsService return $out; } - private static function costScopeIds(?int $year, ?array $ids, ?string $start = null, ?string $end = null): array + private static function costScopeIds(?int $year, ?array $ids): array { $db = DB::pdo(); $limit = $ids === null ? '' : self::idSql($ids, 'a.id'); - if ($start === null && $year !== null) { - $start = sprintf('%04d-01-01', $year); - $end = sprintf('%04d-12-31', $year); - } - if ($start === null || $end === null) { + if ($year === null) { $sql = 'SELECT a.id FROM animals a WHERE a.deleted_at IS NULL' . $limit; } else { + $start = sprintf('%04d-01-01', $year); + $end = sprintf('%04d-12-31', $year); $sql = 'SELECT a.id FROM animals a WHERE a.deleted_at IS NULL' . $limit . " AND (date(a.intake_date) BETWEEN :start AND :end OR EXISTS(SELECT 1 FROM animal_movements m WHERE m.animal_id=a.id AND m.kind='entry' AND date(m.created_at) BETWEEN :start AND :end) OR EXISTS(SELECT 1 FROM animal_expenses ae WHERE ae.animal_id=a.id AND date(ae.occurred_on) BETWEEN :start AND :end))"; } $s = $db->prepare($sql); - $s->execute($start === null || $end === null ? [] : [':start' => $start, ':end' => $end]); + $s->execute($year === null ? [] : [':start' => $start, ':end' => $end]); return array_map('intval', $s->fetchAll(PDO::FETCH_COLUMN)); } @@ -234,49 +224,64 @@ final class StatisticsService public static function annual(int $year, ?array $ids = null): array { - 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 - { + if ($ids === null) { + $stats = GrantService::statistics($year); + } else { + $db = DB::pdo(); + $p = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)]; + $in = self::idSql($ids); + $scalar = static function (string $sql) use ($db, $p): float { + $s = $db->prepare($sql); + $s->execute($p); + return (float) ($s->fetchColumn() ?: 0); + }; + $stats = []; + $stats['entries'] = $scalar( + "SELECT COUNT(*) FROM (SELECT m.animal_id FROM animal_movements m JOIN animals a ON a.id=m.animal_id WHERE a.deleted_at IS NULL AND m.kind='entry' AND date(m.created_at) BETWEEN :start AND :end" . + self::idSql($ids, 'm.animal_id') . + ' UNION SELECT a.id FROM animals a WHERE a.deleted_at IS NULL AND date(a.intake_date) BETWEEN :start AND :end' . + self::idSql($ids, 'a.id') . + ')', + ); + $stats['exits'] = $scalar( + 'SELECT (SELECT COUNT(*) FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE a.deleted_at IS NULL AND date(ad.adoption_date) BETWEEN :start AND :end' . + self::idSql($ids, 'ad.animal_id') . + ")+(SELECT COUNT(*) FROM animal_deaths d JOIN animals a ON a.id=d.animal_id WHERE a.deleted_at IS NULL AND date(COALESCE(d.deceased_date,printf('%04d-01-01',d.death_year))) BETWEEN :start AND :end AND NOT EXISTS(SELECT 1 FROM adoptions ad WHERE ad.animal_id=d.animal_id)" . + self::idSql($ids, 'd.animal_id') . + ')', + ); + $stats['adoptions'] = $scalar( + 'SELECT COUNT(*) FROM adoptions WHERE date(adoption_date) BETWEEN :start AND :end' . $in, + ); + $stats['deaths'] = $scalar( + "SELECT COUNT(*) FROM animal_deaths WHERE date(COALESCE(deceased_date,printf('%04d-01-01',death_year))) BETWEEN :start AND :end" . + $in, + ); + $stats['births'] = $scalar( + "SELECT COUNT(*) FROM litter_kittens lk JOIN litters l ON l.id=lk.litter_id JOIN animals a ON a.id=lk.animal_id WHERE a.deleted_at IS NULL AND date(COALESCE(NULLIF(l.birth_date,''),NULLIF(a.birth_date,''))) BETWEEN :start AND :end" . + self::idSql($ids, 'lk.animal_id'), + ); + $stats['vaccinations'] = $scalar( + 'SELECT COUNT(*) FROM vaccinations WHERE date(done_date) BETWEEN :start AND :end' . $in, + ); + $stats['treatments'] = $scalar( + 'SELECT COUNT(*) FROM treatments WHERE date(start_date) BETWEEN :start AND :end' . $in, + ); + $stats['avg'] = $scalar( + "SELECT AVG(julianday(adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))) FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE date(adoption_date) BETWEEN :start AND :end AND date(adoption_date)>=date(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))" . + self::idSql($ids, 'a.id'), + ); + $stats['current'] = + (float) ($db + ->query( + "SELECT COUNT(*) FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND status NOT IN ('adopte','decede')" . + self::idSql($ids, 'id'), + ) + ->fetchColumn() ?: + 0); + } $db = DB::pdo(); - $range = [':start' => $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); + $range = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)]; $filter = self::idSql($ids, 'a.id'); $s = $db->prepare( "SELECT COUNT(*) FROM (SELECT m.animal_id FROM animal_movements m JOIN animals a ON a.id=m.animal_id WHERE a.deleted_at IS NULL AND m.kind='entry' AND date(m.created_at) BETWEEN :start AND :end" . @@ -298,24 +303,6 @@ 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); @@ -545,10 +532,10 @@ final class StatisticsService ]; } - private static function detailsRange(string $start, string $end, ?array $ids): array + private static function details(int $year, array $ids): array { $db = DB::pdo(); - $p = [':start' => $start, ':end' => $end]; + $p = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)]; $base = "WITH entry_sources AS (SELECT m.animal_id,date(m.created_at) entry_date FROM animal_movements m JOIN animals source_animal ON source_animal.id=m.animal_id WHERE source_animal.deleted_at IS NULL AND m.kind='entry' AND date(m.created_at) BETWEEN :start AND :end" . self::idSql($ids, 'm.animal_id') . @@ -574,7 +561,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" . - self::idSql($ids) . + $in . ' GROUP BY cause_code ORDER BY total DESC', ); $stays = $map( @@ -585,33 +572,10 @@ final class StatisticsService return compact('sex', 'ages', 'deathCauses', 'stays'); } - private static function originsForPeriod(string $start, string $end, ?array $ids): array + public static function distributions(?int $year, ?array $ids = null, ?string $municipality = null): array { $db = DB::pdo(); - $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] : []; + $p = $year === null ? [] : [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)]; $rows = static function (string $sql, array $params = []) use ($db): array { $s = $db->prepare($sql); $s->execute($params); @@ -621,9 +585,9 @@ final class StatisticsService } return $out; }; - $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" : ''; + $movementDate = $year === null ? '' : ' AND date(m.created_at) BETWEEN :start AND :end'; + $animalDate = $year === null ? '' : ' AND date(intake_date) BETWEEN :start AND :end'; + $datedAnimal = $year === null ? '' : " AND NULLIF(TRIM(intake_date),'') IS NOT NULL"; $entered = "WITH entry_sources AS (SELECT m.animal_id,date(m.created_at) entry_date FROM animal_movements m JOIN animals source_animal ON source_animal.id=m.animal_id WHERE source_animal.deleted_at IS NULL AND m.kind='entry'" . $movementDate . @@ -663,7 +627,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 (!$bounded) { + if ($year === null) { $animalFilter = self::idSql($ids, 'a.id'); $details = []; $details['sex'] = $rows( @@ -687,11 +651,12 @@ 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 = self::detailsRange($start, $end, $ids); - $origins = $municipality !== null - ? [$municipality => (int) array_sum($species)] - : self::originsForPeriod($start, $end, $ids); + $details = GrantService::detailedStatistics($year); + $origins = $details['origins']; } foreach ($details as &$items) { arsort($items, SORT_NUMERIC); @@ -709,7 +674,7 @@ final class StatisticsService ' GROUP BY label ORDER BY total DESC,label', ); $releaseSql = - !$bounded + $year === null ? "SELECT substr(free_cat_released_at,1,4) label,COUNT(*) total FROM animals WHERE deleted_at IS NULL AND free_cat_released_at IS NOT NULL AND trim(free_cat_released_at)<>''" . $filter . ' GROUP BY label ORDER BY label DESC' diff --git a/app/Views/_animal_show_identity.php b/app/Views/_animal_show_identity.php index 6d9fda0..3d9488c 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,15 +221,11 @@ $hasCurrent = ($currentLat !== null && $currentLng !== null);
    - +
    :
    - -
    +
    @@ -316,14 +312,10 @@ $err = (string)($cache['error'] ?? ''); -
    - - - - -
    + diff --git a/app/Views/_animal_show_medical.php b/app/Views/_animal_show_medical.php index 064d39f..75a4211 100644 --- a/app/Views/_animal_show_medical.php +++ b/app/Views/_animal_show_medical.php @@ -22,10 +22,6 @@
    FeLV
    -
    -
    -
    -
    @@ -202,7 +198,7 @@
    -

    🪱

    +

    @@ -222,7 +218,6 @@ - @@ -232,16 +227,6 @@ -
    @@ -377,7 +362,6 @@ ORDER BY created_at ASC - @@ -404,19 +388,7 @@ ORDER BY created_at ASC - + @@ -427,9 +399,6 @@ ORDER BY created_at ASC
    -
    - -
    @@ -441,9 +410,8 @@ ORDER BY created_at ASC - + - @@ -456,17 +424,8 @@ 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 a01e550..972364f 100644 --- a/app/Views/_animal_show_modals.php +++ b/app/Views/_animal_show_modals.php @@ -22,7 +22,6 @@ $currentTime = date('H:i'); -
    @@ -286,7 +264,7 @@ if ($grantAlertTotal > 0): ?>
    🚨
    - +
    @@ -312,7 +290,7 @@ if ($grantAlertTotal > 0): ?>
    ⏳
    -
    +
    @@ -337,7 +315,7 @@ if ($grantAlertTotal > 0): ?>
    📅
    -
    +
    diff --git a/app/Views/directory_show.php b/app/Views/directory_show.php index 1886f02..50bb429 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 cbb4341..7883136 100644 --- a/app/Views/evolutions.php +++ b/app/Views/evolutions.php @@ -31,10 +31,10 @@
    - 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é.

    + 1.0.0-rc.25 +

    Portées modifiables et fiables

    +

    La création d’une fratrie ne dépend plus d’un ancien traitement de photo et + son historique reste compatible avec les bases existantes.

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

    Prochaines étapes

    -

    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.

    +

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

    diff --git a/app/Views/public_site_contact.php b/app/Views/public_site_contact.php index 2678fc8..f2a29a9 100644 --- a/app/Views/public_site_contact.php +++ b/app/Views/public_site_contact.php @@ -14,7 +14,12 @@ $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 deleted file mode 100644 index bcdae9b..0000000 --- a/app/Views/settings_catalogs.php +++ /dev/null @@ -1,17 +0,0 @@ -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 49f5ea9..ba4a8bb 100644 --- a/app/Views/settings_nav.php +++ b/app/Views/settings_nav.php @@ -8,7 +8,6 @@ $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 134a571..d36c089 100644 --- a/app/Views/settings_public_site.php +++ b/app/Views/settings_public_site.php @@ -28,7 +28,6 @@
    -
    @@ -79,17 +78,10 @@
    -
    -

    - -
    -
    -
    - - +
    +

    🏠

    - -
    +
    @@ -97,14 +89,11 @@ 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 59efc92..bad7f2b 100644 --- a/app/Views/statistics.php +++ b/app/Views/statistics.php @@ -54,19 +54,12 @@ $groups = [
    -
    -
    + + >
    -
    -
    -
    @@ -85,7 +78,7 @@ $groups = [
    · - (int)$stats['handled']])) ?> (int)$stats['handled']])) ?>
    @@ -111,7 +104,7 @@ $groups = [
    - $periodLabel])) ?> + (int)$year]) ?>
    @@ -145,7 +138,7 @@ $groups = [

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

    +

    $year - 1])) ?>

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

    $periodLabel])) ?> + class="text-muted"> (int)$year])) ?>
    $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($periodQuery + ['municipality' => $label]); + $cityUrl = '/statistics?' . http_build_query(['year' => $allYears ? 'all' : $year,'municipality' => $label]); $filterValue = $linkType === 'sex' ? match ((string)$label) { 'Femelles' => 'F', 'Mâles' => 'M', default => '' } : (string)$label; @@ -346,18 +339,6 @@ if (empty($costs['by_clinic'])):?>
    - diff --git a/docs/07-statistiques-et-administratif.md b/docs/07-statistiques-et-administratif.md index 1ce6af7..7e53cc4 100644 --- a/docs/07-statistiques-et-administratif.md +++ b/docs/07-statistiques-et-administratif.md @@ -10,7 +10,7 @@ ## Statistiques -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. +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. 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 période 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 année 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 633086c..7af8c24 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. Chaque rubrique peut être repliée pour conserver un écran lisible. +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. Deux modes existent : @@ -32,11 +32,7 @@ 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 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 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 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 35cd2b3..fc7a2eb 100644 --- a/docs/11-sauvegardes-et-maintenance.md +++ b/docs/11-sauvegardes-et-maintenance.md @@ -19,20 +19,6 @@ 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/097_remove_legacy_room_constraint.sql b/migrations/097_remove_legacy_room_constraint.sql deleted file mode 100644 index 0291a07..0000000 --- a/migrations/097_remove_legacy_room_constraint.sql +++ /dev/null @@ -1,3 +0,0 @@ --- 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 deleted file mode 100644 index a1ae4ad..0000000 --- a/migrations/098_medical_catalogs_and_test_date.sql +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index d5934e6..0000000 --- a/migrations/099_public_custom_pages.sql +++ /dev/null @@ -1,13 +0,0 @@ -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 bc7e315..8a7ebe3 100644 --- a/public/assets/mobile.css +++ b/public/assets/mobile.css @@ -49,14 +49,11 @@ img { } .topbar-menu > .navbar-nav { - display: flex; - flex-direction: column; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); width: 100%; - gap: .35rem; + gap: .5rem; padding-top: .5rem; - max-height: calc(100dvh - 92px); - overflow-y: auto; - overscroll-behavior: contain; } .topbar-menu .btn { @@ -81,11 +78,6 @@ 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 fce0847..42440ad 100644 --- a/public/index.php +++ b/public/index.php @@ -16,7 +16,6 @@ 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 f0efb00..b947b62 100644 --- a/resources/lang/en.php +++ b/resources/lang/en.php @@ -210,71 +210,4 @@ return [ '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 a7418d6..12f4b89 100644 --- a/resources/lang/fr.php +++ b/resources/lang/fr.php @@ -210,71 +210,4 @@ return [ '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 deleted file mode 100644 index 7db7d0e..0000000 --- a/scripts/reset-demo.php +++ /dev/null @@ -1,28 +0,0 @@ -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 7afdb7b..55b5a7d 100644 --- a/tests/run.php +++ b/tests/run.php @@ -34,7 +34,6 @@ foreach ( 'SpeciesService', 'LabReferenceService', 'PublicContentFormatter', - 'PublicPageService', 'ShelterRoomService', 'GlobalSearchService', 'PrivateMediaService', @@ -154,10 +153,6 @@ 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]); @@ -172,62 +167,6 @@ 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'); @@ -296,44 +235,6 @@ try { $assert(str_contains($image, ' Important\n\n`\n```"); - $assert(!str_contains($fenced, '