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