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