Globinours/app/Controllers/AnimalBrowseActions.php

1017 lines
45 KiB
PHP

<?php
declare(strict_types=1);
trait AnimalBrowseActions
{
public static function index(): void
{
$db = DB::pdo();
$q = trim((string) ($_GET['q'] ?? ''));
$status = trim((string) ($_GET['status'] ?? 'current'));
$quality = trim((string) ($_GET['quality'] ?? ''));
$availability = trim((string) ($_GET['availability'] ?? ''));
$species = SpeciesService::normalize((string) ($_GET['species'] ?? ''));
$sort = (string) ($_GET['sort'] ?? 'updated_at');
$dir = strtolower((string) ($_GET['dir'] ?? 'desc')) === 'asc' ? 'ASC' : 'DESC';
$requestedView = (string) ($_GET['view'] ?? '');
$user = Auth::user();
$viewMode = in_array($requestedView, ['table', 'mosaic'], true)
? $requestedView
: (string) ($user['animal_list_view'] ?? 'table');
if (!in_array($viewMode, ['table', 'mosaic'], true)) {
$viewMode = 'table';
}
if ($user && $requestedView !== '' && $viewMode !== ($user['animal_list_view'] ?? 'table')) {
$db->prepare('UPDATE users SET animal_list_view=:view,updated_at=datetime(\'now\') WHERE id=:id')->execute([
':view' => $viewMode,
':id' => $user['id'],
]);
}
$allowed = [
'name' => 'a.name',
'sex' => 'a.sex',
'species' => 'a.species',
'age' => 'a.birth_date',
'status' => 'a.status',
'compatibility_dogs' => 'a.compatibility_dogs',
'compatibility_cats' => 'a.compatibility_cats',
'compatibility_children' => 'a.compatibility_children',
'house_trained' => 'a.house_trained',
'updated_at' => 'a.updated_at',
'weight' => 'wt.last_weight',
];
$orderBy = $allowed[$sort] ?? 'a.updated_at';
$where = ['a.deleted_at IS NULL', 'a.archived_at IS NULL'];
$params = [];
if (in_array($availability, ['available', 'not_available', 'unknown'], true)) {
$where[] = 'a.adoption_availability=:availability';
$params[':availability'] = $availability;
} else {
$availability = '';
}
if ($species !== '' && SpeciesService::exists($species, false)) {
$where[] = 'lower(trim(a.species))=:species';
$params[':species'] = $species;
} else {
$species = '';
}
if ($q !== '') {
$where[] = '(a.name LIKE :q OR a.internal_code LIKE :q OR a.chip_id LIKE :q)';
$params[':q'] = '%' . $q . '%';
}
if ($status === 'current') {
$where[] = "lower(a.status) NOT IN ('adopte','adopté','decede','décédé','enfui','fugue')";
} elseif ($status !== '') {
$where[] = 'a.status = :status';
$params[':status'] = $status;
}
$qualityFilters = [
'birth_date' => [t('quality.filter.birth'), "a.birth_date IS NULL OR trim(a.birth_date)=''"],
'sex' => [t('quality.filter.sex'), "a.sex IS NULL OR trim(a.sex)='' OR a.sex='U'"],
'color' => [t('quality.filter.color'), "a.color IS NULL OR trim(a.color)=''"],
'breed' => [t('quality.filter.breed'), "a.breed IS NULL OR trim(a.breed)=''"],
'origin' => [
t('quality.filter.origin'),
"(a.rescue_location_name IS NULL OR trim(a.rescue_location_name)='') AND (a.rescue_address IS NULL OR trim(a.rescue_address)='')",
],
'chip' => [
t('quality.filter.chip'),
"(a.chip_id IS NULL OR trim(a.chip_id)='') AND (a.chip IS NULL OR trim(a.chip)='')",
],
'identification_registration' => [
t('quality.filter.registration'),
"COALESCE(NULLIF(a.identification_registration_status,''),'unknown')='unknown'",
],
'adoption_availability' => [
t('quality.filter.adoption'),
"COALESCE(NULLIF(a.adoption_availability,''),'unknown')='unknown'",
],
'sterilized' => [t('quality.filter.unsterilized'), "a.sterilization_status='no'"],
'sterilization_unknown' => [
t('quality.filter.sterilization'),
"COALESCE(NULLIF(a.sterilization_status,''),'unknown')='unknown'",
],
];
if (isset($qualityFilters[$quality])) {
$where[] = '(' . $qualityFilters[$quality][1] . ')';
} else {
$quality = '';
}
$sql = "
SELECT
a.*,
ms.last_med_at,
ms.ongoing_treatments,
ms.next_vaccine_due,
wt.last_weight,
wt.prev_weight,
p.filename AS primary_photo,
(
SELECT dc.name
FROM animal_placements ap
JOIN directory_contacts dc ON dc.id=ap.contact_id AND dc.deleted_at IS NULL
WHERE ap.animal_id=a.id AND ap.event_type='foster'
ORDER BY date(ap.event_date) DESC,ap.id DESC
LIMIT 1
) AS foster_contact_name,
(
SELECT rv.name
FROM vaccinations v
JOIN ref_vaccines rv ON rv.id = v.vaccine_id
WHERE v.animal_id = a.id
AND v.due_date IS NOT NULL
AND date(v.due_date) >= date('now')
ORDER BY date(v.due_date) ASC, v.id DESC
LIMIT 1
) AS next_vaccine_name,
(
SELECT group_concat(m.name, ', ')
FROM treatments t
JOIN ref_medications m ON m.id = t.medication_id
WHERE t.animal_id = a.id AND t.ongoing = 1
) AS ongoing_treatment_names,
(
SELECT COALESCE(
NULLIF(trim(mn.diagnosis), ''),
NULLIF(trim(mn.reason), ''),
NULLIF(trim(mn.symptoms), '')
)
FROM medical_notes mn
WHERE mn.animal_id = a.id
ORDER BY datetime(mn.noted_at) DESC, mn.id DESC
LIMIT 1
) AS latest_medical_point,
(SELECT d.administered_on FROM dewormings d WHERE d.animal_id=a.id ORDER BY date(d.administered_on) DESC,d.id DESC LIMIT 1) AS last_deworming_date,
(SELECT d.next_due_date FROM dewormings d WHERE d.animal_id=a.id ORDER BY date(d.administered_on) DESC,d.id DESC LIMIT 1) AS next_deworming_due
FROM animals a
LEFT JOIN v_animals_med_summary ms ON ms.animal_id = a.id
LEFT JOIN v_animals_weight_trend wt ON wt.animal_id = a.id
LEFT JOIN animal_photos p
ON p.animal_id = a.id
AND p.is_primary = 1
";
$sql .= ' WHERE ' . implode(' AND ', $where);
if ($sort === 'age') {
$birthDirection = $dir === 'ASC' ? 'DESC' : 'ASC';
$sql .= " ORDER BY (a.birth_date IS NULL OR trim(a.birth_date) = '') ASC, date(a.birth_date) $birthDirection, a.id DESC LIMIT 500";
} elseif (
in_array(
$sort,
['compatibility_dogs', 'compatibility_cats', 'compatibility_children', 'house_trained'],
true,
)
) {
$profileColumn = $allowed[$sort];
$profileDirection = $dir === 'ASC' ? 'ASC' : 'DESC';
$sql .= " ORDER BY CASE WHEN COALESCE($profileColumn,'unknown')='unknown' THEN 1 ELSE 0 END ASC, CASE WHEN $profileColumn='yes' THEN 0 ELSE 1 END $profileDirection, a.name COLLATE NOCASE ASC, a.id DESC LIMIT 500";
} else {
$sql .= " ORDER BY $orderBy $dir, a.id DESC LIMIT 500";
}
$stmt = $db->prepare($sql);
$stmt->execute($params);
$animals = $stmt->fetchAll();
// Regroupe visuellement les chatons sous un parent présent dans le résultat.
// La mère est prioritaire quand les deux parents sont affichés afin de ne jamais dupliquer une fiche.
if ($animals) {
$rowsById = [];
foreach ($animals as $row) {
$rowsById[(int) $row['id']] = $row;
}
$relations = $db
->query(
'
SELECT lk.litter_id, lk.animal_id, lk.position, l.mother_id, l.father_id, l.birth_date
FROM litter_kittens lk
JOIN litters l ON l.id = lk.litter_id
ORDER BY l.id, lk.position
',
)
->fetchAll(PDO::FETCH_ASSOC);
$childrenByParent = [];
$groupedChildren = [];
$orphanGroups = [];
$orphanChildren = [];
$orphanGroupByChild = [];
foreach ($relations as $relation) {
$childId = (int) $relation['animal_id'];
if (!isset($rowsById[$childId])) {
continue;
}
$motherId = (int) ($relation['mother_id'] ?? 0);
$fatherId = (int) ($relation['father_id'] ?? 0);
$parentId = isset($rowsById[$motherId]) ? $motherId : (isset($rowsById[$fatherId]) ? $fatherId : 0);
if (!$parentId) {
$litterId = (int) $relation['litter_id'];
$orphanGroups[$litterId]['birth_date'] = $relation['birth_date'];
$orphanGroups[$litterId]['label'] =
!$motherId && !$fatherId ? 'Parents inconnus' : 'Parents indisponibles';
$orphanGroups[$litterId]['children'][] = $childId;
$orphanChildren[$childId] = true;
$orphanGroupByChild[$childId] = $litterId;
continue;
}
if (!$parentId || $parentId === $childId) {
continue;
}
$childrenByParent[$parentId][] = $childId;
$groupedChildren[$childId] = true;
}
$ordered = [];
$emitted = [];
$emit = function (int $id, string $branch = '') use (
&$emit,
&$ordered,
&$emitted,
&$rowsById,
&$childrenByParent,
): void {
if (isset($emitted[$id]) || !isset($rowsById[$id])) {
return;
}
$emitted[$id] = true;
$row = $rowsById[$id];
if ($branch !== '') {
$row['_litter_branch'] = $branch;
}
$ordered[] = $row;
$children = array_values(array_unique($childrenByParent[$id] ?? []));
foreach ($children as $index => $childId) {
$emit($childId, $index === count($children) - 1 ? '└' : '├');
}
};
$emittedOrphanGroups = [];
$emitOrphanGroup = function (int $litterId) use (
&$emittedOrphanGroups,
&$orphanGroups,
&$rowsById,
&$emitted,
&$emit,
): void {
if (isset($emittedOrphanGroups[$litterId]) || !isset($orphanGroups[$litterId])) {
return;
}
$emittedOrphanGroups[$litterId] = true;
$group = $orphanGroups[$litterId];
$children = array_values(array_unique($group['children'] ?? []));
$visibleChildren = array_values(
array_filter(
$children,
fn(int $childId): bool => isset($rowsById[$childId]) && !isset($emitted[$childId]),
),
);
$dateLabel = t('common.unknown_date');
if (!empty($group['birth_date'])) {
$date = DateTimeImmutable::createFromFormat('Y-m-d', (string) $group['birth_date']);
if ($date) {
$dateLabel = $date->format('d/m/Y');
}
}
foreach ($visibleChildren as $index => $childId) {
if (!isset($rowsById[$childId]) || isset($emitted[$childId])) {
continue;
}
$rowsById[$childId]['_litter_branch'] = $index === count($visibleChildren) - 1 ? '└' : '├';
if ($index === 0) {
$rowsById[$childId]['_litter_group'] = [
'label' => (string) ($group['label'] ?? t('litter.unknown_parent')),
'details' => t('litter.group_details', [
'date' => $dateLabel,
'count' => count($visibleChildren),
]),
];
}
$emit($childId, $rowsById[$childId]['_litter_branch']);
}
};
foreach ($animals as $row) {
$rowId = (int) $row['id'];
if (isset($orphanGroupByChild[$rowId])) {
$emitOrphanGroup((int) $orphanGroupByChild[$rowId]);
} elseif (!isset($groupedChildren[$rowId])) {
$emit($rowId);
}
}
foreach ($animals as $row) {
$emit((int) $row['id']);
}
$animals = $ordered;
// Les groupes inséparables sont prioritaires sur la présentation par portée.
$bondedRows = $db
->query(
'
SELECT bg.id AS group_id, bg.name AS group_name, bg.notes AS group_notes,
bgm.animal_id, bgm.position
FROM bonded_groups bg
JOIN bonded_group_members bgm ON bgm.group_id = bg.id
WHERE bg.active = 1
ORDER BY bg.id, bgm.position
',
)
->fetchAll(PDO::FETCH_ASSOC);
$bondedByAnimal = [];
$bondedMeta = [];
foreach ($bondedRows as $bondedRow) {
$animalId = (int) $bondedRow['animal_id'];
if (!isset($rowsById[$animalId])) {
continue;
}
$groupId = (int) $bondedRow['group_id'];
$bondedByAnimal[$animalId] = $groupId;
$bondedMeta[$groupId] = ['name' => $bondedRow['group_name'], 'notes' => $bondedRow['group_notes']];
}
if ($bondedByAnimal) {
$orderedById = [];
foreach ($animals as $row) {
$orderedById[(int) $row['id']] = $row;
}
$bondedOutput = [];
$emittedGroups = [];
foreach ($animals as $row) {
$animalId = (int) $row['id'];
$groupId = $bondedByAnimal[$animalId] ?? 0;
if (!$groupId) {
$bondedOutput[] = $row;
continue;
}
if (isset($emittedGroups[$groupId])) {
continue;
}
$emittedGroups[$groupId] = true;
$memberIds = [];
foreach ($animals as $candidate) {
$candidateId = (int) $candidate['id'];
if (($bondedByAnimal[$candidateId] ?? 0) === $groupId) {
$memberIds[] = $candidateId;
}
}
foreach ($memberIds as $index => $memberId) {
$member = $orderedById[$memberId];
unset($member['_litter_branch'], $member['_litter_group']);
$member['_bonded_branch'] = $index === count($memberIds) - 1 ? '└' : '├';
if ($index === 0) {
$name = trim((string) ($bondedMeta[$groupId]['name'] ?? ''));
$member['_bonded_group'] = [
'label' => $name !== '' ? $name : t('bonded.default_name'),
'details' => t('bonded.group_details', [
'count' => count($memberIds),
'state' =>
count($memberIds) > 1 ? t('bonded.adopt_together') : t('bonded.incomplete'),
]),
'notes' => trim((string) ($bondedMeta[$groupId]['notes'] ?? '')),
];
}
$bondedOutput[] = $member;
}
}
$animals = $bondedOutput;
}
}
$statuses = [
'current' => t('animals.current_care'),
'' => t('animals.all_records'),
'refuge' => t('status.refuge'),
'fa' => t('status.fa'),
'fa_permanente' => t('status.fa_permanente'),
'chat_libre' => t('status.chat_libre'),
'quarantaine' => t('status.quarantaine'),
'soin' => t('status.soin'),
'isolation' => t('status.isolation'),
'hospitalise' => t('status.hospitalise'),
'adopte' => t('status.adopte'),
'decede' => t('status.decede'),
'enfui' => t('status.enfui'),
];
render('animals_list.php', [
'title' => t('animals.title'),
'animals' => $animals,
'q' => $q,
'status' => $status,
'statuses' => $statuses,
'sort' => $sort,
'dir' => $dir,
'quality' => $quality,
'qualityLabel' => $quality !== '' ? $qualityFilters[$quality][0] : '',
'availability' => $availability,
'viewMode' => $viewMode,
'species' => $species,
'speciesOptions' => SpeciesService::all(false),
]);
}
public static function show(): void
{
$db = DB::pdo();
$id = (int) ($_GET['id'] ?? 0);
if ($id <= 0) {
http_response_code(400);
echo h(t('error.missing_id'));
return;
}
// Une fiche archivée reste consultable : seule la corbeille masque la fiche normale.
$stmt = $db->prepare('SELECT * FROM animals WHERE id = :id AND deleted_at IS NULL LIMIT 1');
$stmt->execute([':id' => $id]);
$animal = $stmt->fetch();
if (!$animal) {
http_response_code(404);
echo h(t('error.animal_not_found'));
return;
}
if (!empty($animal['intake_owner_care_home'])) {
$animal['intake_reason'] = 'owner_care_home';
}
$depositor = null;
if (!empty($animal['depositor_contact_id'])) {
$s = $db->prepare(
'SELECT id,name,phone,email,city FROM directory_contacts WHERE id=:id AND deleted_at IS NULL',
);
$s->execute([':id' => $animal['depositor_contact_id']]);
$depositor = $s->fetch(PDO::FETCH_ASSOC) ?: null;
}
$freeCatCaretaker = null;
if (!empty($animal['free_cat_caretaker_contact_id'])) {
$s = $db->prepare(
'SELECT id,name,phone,email,city FROM directory_contacts WHERE id=:id AND deleted_at IS NULL',
);
$s->execute([':id' => $animal['free_cat_caretaker_contact_id']]);
$freeCatCaretaker = $s->fetch(PDO::FETCH_ASSOC) ?: null;
}
$sterilizationContacts = [];
foreach (
[
'vet' => $animal['sterilization_vet_contact_id'] ?? null,
'clinic' => $animal['sterilization_clinic_contact_id'] ?? null,
]
as $key => $contactId
) {
if ($contactId) {
$s = $db->prepare('SELECT id,name FROM directory_contacts WHERE id=:id AND deleted_at IS NULL');
$s->execute([':id' => $contactId]);
$sterilizationContacts[$key] = $s->fetch(PDO::FETCH_ASSOC) ?: null;
}
}
// Adoptions
$stmt = $db->prepare('
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
LIMIT 1
');
$stmt->execute([':id' => $id]);
$adoption = $stmt->fetch(PDO::FETCH_ASSOC);
$placementStmt = $db->prepare(
'SELECT ap.*,dc.name contact_name,dc.city contact_city,COALESCE(u.display_name,u.username) actor_name FROM animal_placements ap LEFT JOIN directory_contacts dc ON dc.id=ap.contact_id LEFT JOIN users u ON u.id=ap.created_by WHERE ap.animal_id=:id ORDER BY date(ap.event_date) DESC,ap.id DESC',
);
$placementStmt->execute([':id' => $id]);
$placements = $placementStmt->fetchAll(PDO::FETCH_ASSOC);
$placementContacts = $db
->query(
"SELECT DISTINCT dc.id,dc.name,dc.city FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id WHERE dc.deleted_at IS NULL AND r.role IN ('fa','adoptant') ORDER BY dc.name COLLATE NOCASE",
)
->fetchAll(PDO::FETCH_ASSOC);
// Notes médicales (timeline)
$stmt = $db->prepare('
SELECT mn.*, COALESCE(dc.name, c.name) AS clinic_name
FROM medical_notes mn
LEFT JOIN clinics c ON c.id = mn.clinic_id
LEFT JOIN directory_contacts dc ON dc.id = mn.clinic_contact_id
WHERE mn.animal_id = :id
ORDER BY mn.noted_at DESC
LIMIT 50
');
$stmt->execute([':id' => $id]);
$medical_notes = $stmt->fetchAll();
$prescriptionStmt = $db->prepare(
'SELECT p.*,vet.name veterinarian_name,clinic.name clinic_name,COALESCE(u.display_name,u.username) actor_name FROM medical_prescriptions p LEFT JOIN directory_contacts vet ON vet.id=p.veterinarian_contact_id LEFT JOIN directory_contacts clinic ON clinic.id=p.clinic_contact_id LEFT JOIN users u ON u.id=p.created_by WHERE p.animal_id=:id ORDER BY date(p.prescribed_on) DESC,p.id DESC',
);
$prescriptionStmt->execute([':id' => $id]);
$prescriptions = $prescriptionStmt->fetchAll(PDO::FETCH_ASSOC);
$prescriptionTreatmentStmt = $db->prepare(
'SELECT pt.prescription_id,t.id,m.name medication_name FROM prescription_treatments pt JOIN treatments t ON t.id=pt.treatment_id LEFT JOIN ref_medications m ON m.id=t.medication_id WHERE t.animal_id=:id ORDER BY m.name COLLATE NOCASE',
);
$prescriptionTreatmentStmt->execute([':id' => $id]);
$prescriptionTreatments = [];
foreach ($prescriptionTreatmentStmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$prescriptionTreatments[(int) $row['prescription_id']][] = $row;
}
$labStmt = $db->prepare(
'SELECT r.*,vet.name veterinarian_name,clinic.name clinic_name,COALESCE(u.display_name,u.username) actor_name FROM lab_reports r LEFT JOIN directory_contacts vet ON vet.id=r.veterinarian_contact_id LEFT JOIN directory_contacts clinic ON clinic.id=r.clinic_contact_id LEFT JOIN users u ON u.id=r.created_by WHERE r.animal_id=:id ORDER BY date(r.sampled_on) DESC,r.id DESC',
);
$labStmt->execute([':id' => $id]);
$labReports = $labStmt->fetchAll(PDO::FETCH_ASSOC);
$labResultStmt = $db->prepare(
'SELECT lr.*,r.sampled_on FROM lab_results lr JOIN lab_reports r ON r.id=lr.report_id WHERE r.animal_id=:id ORDER BY date(r.sampled_on),lr.position,lr.id',
);
$labResultStmt->execute([':id' => $id]);
$labResultsByReport = [];
$labSeries = [];
foreach ($labResultStmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$labResultsByReport[(int) $row['report_id']][] = $row;
$seriesKey =
mb_strtolower(trim((string) $row['parameter_name'])) .
'|' .
mb_strtolower(trim((string) ($row['unit'] ?? '')));
if (!isset($labSeries[$seriesKey])) {
$labSeries[$seriesKey] = [
'name' => $row['parameter_name'],
'unit' => $row['unit'] ?? '',
'points' => [],
];
}
$labSeries[$seriesKey]['points'][] = [
'date' => $row['sampled_on'],
'value' => (float) $row['value'],
'min' => $row['reference_min'] !== null ? (float) $row['reference_min'] : null,
'max' => $row['reference_max'] !== null ? (float) $row['reference_max'] : null,
];
}
// Traitements en cours
$stmt = $db->prepare('
SELECT t.*, m.name AS medication_name, m.molecule, m.form, COALESCE(dc.name, c.name) AS clinic_name
FROM treatments t
JOIN ref_medications m ON m.id = t.medication_id
LEFT JOIN clinics c ON c.id = t.clinic_id
LEFT JOIN directory_contacts dc ON dc.id = t.clinic_contact_id
WHERE t.animal_id = :id AND t.ongoing = 1
ORDER BY t.start_date DESC, t.id DESC
');
$stmt->execute([':id' => $id]);
$treatments = $stmt->fetchAll();
// Vaccins
$stmt = $db->prepare('
SELECT v.*, rv.name AS vaccine_name, COALESCE(dc.name, c.name) AS clinic_name,COALESCE(v.administered_by_name,au.display_name,au.username) AS administrator_name
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
');
$stmt->execute([':id' => $id]);
$vaccinations = $stmt->fetchAll();
$stmt = $db->prepare(
'SELECT d.*,m.name medication_name,COALESCE(u.display_name,u.username) actor_name FROM dewormings d LEFT JOIN ref_dewormers m ON m.id=d.dewormer_id LEFT JOIN users u ON u.id=d.created_by WHERE d.animal_id=:id ORDER BY date(d.administered_on) DESC,d.id DESC',
);
$stmt->execute([':id' => $id]);
$dewormings = $stmt->fetchAll(PDO::FETCH_ASSOC);
$lastDeworming = $dewormings[0] ?? null;
$medications = $db->query('SELECT id, name FROM ref_medications ORDER BY name COLLATE NOCASE, id')->fetchAll();
$dewormers = $db
->query('SELECT id,name FROM ref_dewormers WHERE active=1 ORDER BY name COLLATE NOCASE,id')
->fetchAll(PDO::FETCH_ASSOC);
$expenseTariffs = PricingService::tariffs(true);
$expenseStmt = $db->prepare(
'SELECT ae.*,dc.name clinic_name,COALESCE(u.display_name,u.username) actor_name FROM animal_expenses ae LEFT JOIN directory_contacts dc ON dc.id=ae.clinic_contact_id LEFT JOIN users u ON u.id=ae.created_by WHERE ae.animal_id=:id ORDER BY date(ae.occurred_on) DESC,ae.id DESC',
);
$expenseStmt->execute([':id' => $id]);
$expenses = $expenseStmt->fetchAll(PDO::FETCH_ASSOC);
$expenseTotal = array_sum(array_column($expenses, 'total_cents'));
$surgeryStmt = $db->prepare(
'SELECT s.*,c.name clinic_name,v.name veterinarian_name FROM animal_surgeries s LEFT JOIN directory_contacts c ON c.id=s.clinic_contact_id LEFT JOIN directory_contacts v ON v.id=s.veterinarian_contact_id WHERE s.animal_id=:id ORDER BY date(s.surgery_date) DESC,s.id DESC',
);
$surgeryStmt->execute([':id' => $id]);
$surgeries = $surgeryStmt->fetchAll(PDO::FETCH_ASSOC);
$ref_med_administration = $db
->query('SELECT id, name, accro FROM ref_med_administration ORDER BY id')
->fetchAll();
$vaccines = $db->query('SELECT id, name FROM ref_vaccines ORDER BY id')->fetchAll();
$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)',
)
->fetchAll(PDO::FETCH_ASSOC);
$activeUsers = $db
->query(
"SELECT id,COALESCE(NULLIF(display_name,''),username) name FROM users WHERE active=1 ORDER BY name COLLATE NOCASE",
)
->fetchAll(PDO::FETCH_ASSOC);
$clinics = $db->query('SELECT id, name FROM clinics ORDER BY name')->fetchAll();
$directoryClinics = $db
->query(
"
SELECT dc.id, dc.name FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id AND r.role='cabinet'
WHERE dc.deleted_at IS NULL ORDER BY dc.name COLLATE NOCASE
",
)
->fetchAll();
$veterinarians = $db
->query(
"
SELECT dc.id, dc.name, dc.organization_id FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id AND r.role='veterinaire'
WHERE dc.deleted_at IS NULL ORDER BY dc.name COLLATE NOCASE
",
)
->fetchAll();
$adopters = $db
->query(
"
SELECT dc.* FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id AND r.role='adoptant'
WHERE dc.deleted_at IS NULL ORDER BY dc.name COLLATE NOCASE
",
)
->fetchAll();
$deathStmt = $db->prepare(
'SELECT ad.*,vet.name veterinarian_name,crem.name crematorium_name FROM animal_deaths ad LEFT JOIN directory_contacts vet ON vet.id=ad.veterinarian_contact_id LEFT JOIN directory_contacts crem ON crem.id=ad.crematorium_contact_id WHERE ad.animal_id=:id',
);
$deathStmt->execute([':id' => $id]);
$death = $deathStmt->fetch(PDO::FETCH_ASSOC) ?: null;
// Poids
$weightsStmt = $db->prepare("
SELECT measured_at, value
FROM measurements
WHERE animal_id = :id AND type = 'weight'
ORDER BY datetime(measured_at) ASC
");
$weightsStmt->execute([':id' => $id]);
$weights = $weightsStmt->fetchAll();
$stmt = $db->prepare("
SELECT value
FROM measurements
WHERE animal_id = :id AND type='weight'
ORDER BY id DESC
LIMIT 1
");
$stmt->execute([':id' => $id]);
$lastWeight = $stmt->fetchColumn();
$stmt = $db->prepare("
SELECT value
FROM measurements
WHERE animal_id = :id AND type='weight'
AND value != (
SELECT value FROM measurements
WHERE animal_id = :id AND type='weight'
ORDER BY id DESC LIMIT 1
)
ORDER BY id DESC
LIMIT 1
");
$stmt->execute([':id' => $id]);
$prevWeight = $stmt->fetchColumn();
// PHOTOS : on récupère TOUT (public + privé)
$stmt = $db->prepare('
SELECT id, filename, is_primary, is_public, caption, care_round_id, created_at
FROM animal_photos
WHERE animal_id = :id
ORDER BY is_public DESC, is_primary DESC, id DESC
');
$stmt->execute([':id' => $id]);
$photos = $stmt->fetchAll(PDO::FETCH_ASSOC);
// PHOTO PRINCIPALE : seulement une publique
$stmt = $db->prepare('
SELECT filename
FROM animal_photos
WHERE animal_id = :id AND is_primary = 1 AND is_public = 1
ORDER BY id DESC
LIMIT 1
');
$stmt->execute([':id' => $id]);
$primary_photo = $stmt->fetchColumn() ?: null;
// Historique
$h = $db->prepare('
SELECT ah.type,ah.label,ah.details,ah.created_at,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
');
$h->execute([':id' => $id]);
$history = $h->fetchAll(PDO::FETCH_ASSOC);
$locationStmt = $db->prepare('SELECT alh.*,COALESCE(u.display_name,u.username) actor_name
FROM animal_location_history alh LEFT JOIN users u ON u.id=alh.user_id
WHERE alh.animal_id=:id ORDER BY datetime(alh.moved_at) DESC,alh.id DESC');
$locationStmt->execute([':id' => $id]);
$locationHistory = $locationStmt->fetchAll(PDO::FETCH_ASSOC);
$conditionsStmt = $db->prepare("
SELECT id, name, status, diagnosed_at, resolved_at, notes
FROM animal_health_conditions
WHERE animal_id = :id
ORDER BY status = 'active' DESC, datetime(COALESCE(diagnosed_at, created_at)) DESC, id DESC
");
$conditionsStmt->execute([':id' => $id]);
$healthConditions = $conditionsStmt->fetchAll(PDO::FETCH_ASSOC);
// --- ACTIVITY (timeline unifiée) ---
$activityStmt = $db->prepare("
SELECT kind, label, details, at FROM (
-- Historique interne
SELECT
CASE WHEN type='care_round' THEN 'care_round' WHEN type='death' THEN 'death' ELSE 'history' END AS kind,
COALESCE(label,'') AS label,
COALESCE(details,'') || CASE WHEN user_id IS NOT NULL THEN '\n\nPar : ' || COALESCE((SELECT display_name FROM users WHERE id=animal_history.user_id),(SELECT username FROM users WHERE id=animal_history.user_id),'Compte inconnu') ELSE '' END AS details,
datetime(created_at) AS at
FROM animal_history
WHERE animal_id = :id
UNION ALL
-- Notes médicales
SELECT
'medical' AS kind,
CASE
WHEN COALESCE(reason,'') != '' THEN 'Note médicale : ' || reason
ELSE 'Note médicale'
END AS label,
TRIM(
COALESCE(symptoms,'') ||
CASE WHEN COALESCE(symptoms,'')!='' AND COALESCE(diagnosis,'')!='' THEN '\n\n' ELSE '' END ||
CASE WHEN COALESCE(diagnosis,'')!='' THEN 'Diagnostic : ' || diagnosis ELSE '' END ||
CASE WHEN COALESCE(plan,'')!='' THEN '\n\nPlan : ' || plan ELSE '' END
) AS details,
datetime(noted_at) AS at
FROM medical_notes
WHERE animal_id = :id
UNION ALL
-- Vaccins
SELECT
'vaccine' AS kind,
'Vaccin : ' || COALESCE(rv.name,'(inconnu)') AS label,
TRIM(
'Fait le : ' || COALESCE(v.done_date,'?') ||
CASE WHEN v.due_date IS NOT NULL THEN '\nRappel : ' || v.due_date ELSE '' END ||
CASE WHEN COALESCE(v.lot,'')!='' THEN '\nLot : ' || v.lot ELSE '' END ||
CASE WHEN COALESCE(v.manufacturer,'')!='' THEN '\nFabricant : ' || v.manufacturer ELSE '' END ||
CASE WHEN COALESCE(v.batch_expires_on,'')!='' THEN '\nExpiration du lot : ' || v.batch_expires_on ELSE '' END ||
CASE WHEN COALESCE(v.administered_by_name,'')!='' THEN '\nAdministré par : ' || v.administered_by_name WHEN v.administered_by_user_id IS NOT NULL THEN '\nAdministré par : ' || COALESCE((SELECT display_name FROM users WHERE id=v.administered_by_user_id),(SELECT username FROM users WHERE id=v.administered_by_user_id),'Compte inconnu') ELSE '' END ||
CASE WHEN COALESCE(v.notes,'')!='' THEN '\nNotes : ' || v.notes ELSE '' END
) AS details,
datetime(v.done_date || ' 12:00:00') AS at
FROM vaccinations v
LEFT JOIN ref_vaccines rv ON rv.id = v.vaccine_id
WHERE v.animal_id = :id
UNION ALL
-- Traitements (création/MAJ)
SELECT
'treatment' AS kind,
'Traitement : ' || COALESCE(m.name,'(médicament)') AS label,
TRIM(
'Dose : ' || COALESCE(t.dose_text,'?') ||
CASE WHEN COALESCE(t.route,'')!='' THEN '\nVoie : ' || t.route ELSE '' END ||
CASE WHEN COALESCE(t.start_date,'')!='' THEN '\nDébut : ' || t.start_date ELSE '' END ||
CASE WHEN COALESCE(t.end_date,'')!='' THEN '\nFin : ' || t.end_date ELSE '' END ||
CASE WHEN t.ongoing=1 THEN '\nÉtat : en cours' ELSE '\nÉtat : terminé' END ||
CASE WHEN COALESCE(t.notes,'')!='' THEN '\nNotes : ' || t.notes ELSE '' END
) AS details,
datetime(COALESCE(t.start_date, datetime('now')) || ' 12:00:00') AS at
FROM treatments t
LEFT JOIN ref_medications m ON m.id = t.medication_id
WHERE t.animal_id = :id
UNION ALL
-- Poids (measurements)
SELECT
'weight' AS kind,
'Poids : ' || printf('%.2f', value) || ' ' || COALESCE(unit,'kg') AS label,
COALESCE(notes,'') AS details,
datetime(measured_at) AS at
FROM measurements
WHERE animal_id = :id AND type='weight'
)
ORDER BY at DESC
LIMIT 200
");
$activityStmt->execute([':id' => $id]);
$activity = $activityStmt->fetchAll(PDO::FETCH_ASSOC);
// --- i-CAD (cache local) ---
require_once __DIR__ . '/../Services/IcadService.php'; // adapte si ton chemin diffère
$icadSvc = new IcadService($db);
$icadCached = $icadSvc->getCachedByAnimal($id);
$icad = [
'cached' => $icadCached,
'stale' => $icadSvc->isStale($icadCached, 86400),
];
$littersStmt = $db->prepare('
SELECT l.*, mother.name AS mother_name, father.name AS father_name
FROM litters l
LEFT JOIN animals mother ON mother.id = l.mother_id
LEFT JOIN animals father ON father.id = l.father_id
WHERE l.mother_id = :id OR l.father_id = :id
OR EXISTS (SELECT 1 FROM litter_kittens lk WHERE lk.litter_id = l.id AND lk.animal_id = :id)
ORDER BY COALESCE(l.birth_date, l.created_at) DESC, l.id DESC
');
$littersStmt->execute([':id' => $id]);
$litters = $littersStmt->fetchAll(PDO::FETCH_ASSOC);
$kittenStmt = $db->prepare('
SELECT a.id, a.name, a.sex, a.color, a.status, a.archived_at
FROM litter_kittens lk JOIN animals a ON a.id = lk.animal_id
WHERE lk.litter_id = :litter_id AND a.deleted_at IS NULL
ORDER BY lk.position, a.id
');
foreach ($litters as &$litter) {
$kittenStmt->execute([':litter_id' => $litter['id']]);
$litter['kittens'] = $kittenStmt->fetchAll(PDO::FETCH_ASSOC);
}
unset($litter);
$bondedStmt = $db->prepare('
SELECT bg.id, bg.name, bg.notes
FROM bonded_groups bg
JOIN bonded_group_members bgm ON bgm.group_id = bg.id
WHERE bg.active = 1 AND bgm.animal_id = :id
LIMIT 1
');
$bondedStmt->execute([':id' => $id]);
$bondedGroup = $bondedStmt->fetch(PDO::FETCH_ASSOC) ?: null;
$bondedMembers = [];
if ($bondedGroup) {
$stmt = $db->prepare('
SELECT a.id, a.name, a.color, a.sex, a.status
FROM bonded_group_members bgm JOIN animals a ON a.id = bgm.animal_id
WHERE bgm.group_id = :group_id AND a.deleted_at IS NULL AND a.archived_at IS NULL
ORDER BY bgm.position, a.name COLLATE NOCASE
');
$stmt->execute([':group_id' => $bondedGroup['id']]);
$bondedMembers = $stmt->fetchAll(PDO::FETCH_ASSOC);
}
render('animal_show.php', [
'title' => 'Fiche de ' . $animal['name'],
'pageDescription' => 'Dossier et suivi de ' . $animal['name'] . ' dans Globinours.',
'animal' => $animal,
'depositor' => $depositor,
'intakeTypes' => self::intakeTypes(),
'intakeReasons' => self::intakeReasons(),
'freeCatCaretaker' => $freeCatCaretaker,
'sterilizationContacts' => $sterilizationContacts,
'medical_notes' => $medical_notes,
'prescriptions' => $prescriptions,
'prescriptionTreatments' => $prescriptionTreatments,
'labReports' => $labReports,
'labResultsByReport' => $labResultsByReport,
'labSeries' => $labSeries,
'labReferenceSets' => LabReferenceService::forAnimal((string) ($animal['species'] ?? '')),
'treatments' => $treatments,
'vaccinations' => $vaccinations,
'dewormings' => $dewormings,
'lastDeworming' => $lastDeworming,
'medications' => $medications,
'dewormers' => $dewormers,
'expenseTariffs' => $expenseTariffs,
'expenses' => $expenses,
'expenseTotal' => $expenseTotal,
'surgeries' => $surgeries,
'ref_med_administration' => $ref_med_administration,
'vaccines' => $vaccines,
'inventoryBatches' => $inventoryBatches,
'activeUsers' => $activeUsers,
'clinics' => $clinics,
'directoryClinics' => $directoryClinics,
'veterinarians' => $veterinarians,
'adopters' => $adopters,
'death' => $death,
'weights' => $weights,
'lastWeight' => $lastWeight,
'prevWeight' => $prevWeight,
'photos' => $photos,
'primary_photo' => $primary_photo,
'history' => $history,
'locationHistory' => $locationHistory,
'healthConditions' => $healthConditions,
'activity' => $activity,
'adoption' => $adoption,
'placements' => $placements,
'placementContacts' => $placementContacts,
'litters' => $litters,
'bondedGroup' => $bondedGroup,
'bondedMembers' => $bondedMembers,
'icad' => $icad,
]);
}
public static function inactiveList(string $mode): void
{
if (!in_array($mode, ['archived', 'trash'], true)) {
http_response_code(404);
return;
}
$db = DB::pdo();
$column = $mode === 'archived' ? 'archived_at' : 'deleted_at';
$q = trim((string) ($_GET['q'] ?? ''));
$status = trim((string) ($_GET['status'] ?? ''));
$sort = (string) ($_GET['sort'] ?? 'removed_at');
$dir = strtolower((string) ($_GET['dir'] ?? 'desc')) === 'asc' ? 'ASC' : 'DESC';
$allowed = [
'name' => 'a.name COLLATE NOCASE',
'sex' => 'a.sex',
'age' => 'a.birth_date',
'status' => 'a.status',
'compatibility_dogs' => 'a.compatibility_dogs',
'compatibility_cats' => 'a.compatibility_cats',
'house_trained' => 'a.house_trained',
'weight' => 'wt.last_weight',
'rescue_location_name' =>
"COALESCE(NULLIF(TRIM(a.rescue_location_name),''),a.rescue_address) COLLATE NOCASE",
'removed_at' => 'a.' . $column,
];
$where = ["a.$column IS NOT NULL"];
$params = [];
if ($q !== '') {
$where[] =
'(a.name LIKE :q OR a.internal_code LIKE :q OR a.chip_id LIKE :q OR a.rescue_location_name LIKE :q)';
$params[':q'] = '%' . $q . '%';
}
if ($status !== '') {
$where[] = 'a.status=:status';
$params[':status'] = $status;
}
$orderBy = $allowed[$sort] ?? $allowed['removed_at'];
$ageDirection = $dir === 'ASC' ? 'DESC' : 'ASC';
$orderSql =
$sort === 'age'
? "(a.birth_date IS NULL OR trim(a.birth_date)='') ASC, date(a.birth_date) $ageDirection, a.id DESC"
: "$orderBy $dir, a.id DESC";
$stmt = $db->prepare(
'
SELECT a.*, p.filename AS primary_photo, wt.last_weight, wt.prev_weight
FROM animals a
LEFT JOIN animal_photos p ON p.animal_id = a.id AND p.is_primary = 1
LEFT JOIN v_animals_weight_trend wt ON wt.animal_id=a.id
WHERE ' .
implode(' AND ', $where) .
"
ORDER BY $orderSql
LIMIT 500
",
);
$stmt->execute($params);
$statuses = ['' => t('animals.all_statuses')];
foreach (
[
'refuge',
'fa',
'fa_permanente',
'chat_libre',
'quarantaine',
'soin',
'isolation',
'hospitalise',
'adopte',
'decede',
'enfui',
]
as $statusCode
) {
$statuses[$statusCode] = t('status.' . $statusCode);
}
render('animals_inactive.php', [
'title' => $mode === 'archived' ? t('animals.archived') : t('animals.trash'),
'pageDescription' => $mode === 'archived' ? t('inactive.archive_help') : t('inactive.trash_help'),
'animals' => $stmt->fetchAll(PDO::FETCH_ASSOC),
'mode' => $mode,
'q' => $q,
'status' => $status,
'statuses' => $statuses,
'sort' => $sort,
'dir' => $dir,
]);
}
private static function redirectToAnimal(int $id): void
{
header('Location: /animal?id=' . $id);
exit();
}
}