Globinours/app/Controllers/AnimalsController.php

602 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
require_once __DIR__ . '/AnimalMedicalActions.php';
require_once __DIR__ . '/AnimalPhotoActions.php';
require_once __DIR__ . '/AnimalRelationshipActions.php';
require_once __DIR__ . '/AnimalLifecycleActions.php';
require_once __DIR__ . '/AnimalBrowseActions.php';
require_once __DIR__ . '/AnimalRecordActions.php';
final class AnimalsController
{
use AnimalBrowseActions;
use AnimalMedicalActions;
use AnimalPhotoActions;
use AnimalRecordActions;
use AnimalLifecycleActions;
private const INTAKE_TYPES = [
'unknown' => 'Non renseigné',
'found' => 'Trouvé',
'owner_surrender' => 'Cession par le propriétaire',
'transfer' => 'Transfert dune autre structure',
'born_in_care' => 'Né sous la responsabilité du refuge',
'return' => 'Retour après placement',
'seizure' => 'Saisie ou réquisition',
'other' => 'Autre mode dentré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();
}
}