Publier Globinours 1.0.0-rc.3

This commit is contained in:
Alexandre NOEL 2026-09-03 12:39:15 +02:00
commit 9a2b4068da
325 changed files with 38230 additions and 20 deletions

View file

@ -0,0 +1,513 @@
<?php
declare(strict_types=1);
final class AccountingController
{
private static function dir(): string
{
$d = dirname(__DIR__, 2) . '/data/accounting-documents';
if (!is_dir($d)) {
mkdir($d, 0700, true);
}
return $d;
}
private static function vendors(PDO $db): array
{
return $db
->query(
"SELECT DISTINCT dc.id,dc.name 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 ('cabinet','crematorium','fourriere','structure_veterinaire') ORDER BY dc.name COLLATE NOCASE",
)
->fetchAll(PDO::FETCH_ASSOC);
}
public static function index(): void
{
$db = DB::pdo();
$year = (int) ($_GET['year'] ?? 0);
$where = ['ai.deleted_at IS NULL'];
$params = [];
if ($year) {
$where[] = "strftime('%Y',ai.invoice_date)=:year";
$params[':year'] = (string) $year;
}
$s = $db->prepare(
'SELECT ai.*,dc.name vendor_name FROM accounting_invoices ai LEFT JOIN directory_contacts dc ON dc.id=ai.vendor_contact_id WHERE ' .
implode(' AND ', $where) .
' ORDER BY date(ai.invoice_date) DESC,ai.id DESC',
);
$s->execute($params);
$rows = $s->fetchAll(PDO::FETCH_ASSOC);
$years = $db
->query(
"SELECT DISTINCT strftime('%Y',invoice_date) y FROM accounting_invoices WHERE deleted_at IS NULL ORDER BY y DESC",
)
->fetchAll(PDO::FETCH_COLUMN);
render('accounting.php', [
'title' => 'Comptabilité',
'invoices' => $rows,
'vendors' => self::vendors($db),
'years' => $years,
'year' => $year,
]);
}
public static function edit(): void
{
$id = (int) ($_GET['id'] ?? 0);
$db = DB::pdo();
$s = $db->prepare('SELECT * FROM accounting_invoices WHERE id=? AND deleted_at IS NULL');
$s->execute([$id]);
$invoice = $s->fetch(PDO::FETCH_ASSOC);
if (!$invoice) {
http_response_code(404);
return;
}
render('accounting_edit.php', [
'title' => 'Modifier la facture ' . $invoice['reference'],
'invoice' => $invoice,
'vendors' => self::vendors($db),
]);
}
public static function save(): void
{
$values = self::values();
$file = self::upload();
$db = DB::pdo();
$s = $db->prepare(
'INSERT INTO accounting_invoices(vendor_contact_id,reference,invoice_date,due_date,document_type,status,payment_method,paid_on,total_ht_cents,total_ttc_cents,amount_due_cents,notes,source,original_filename,stored_filename,mime_type,file_size,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
);
$s->execute([
...$values,
'Saisie manuelle',
$file['original'] ?? null,
$file['stored'] ?? null,
$file['mime'] ?? null,
$file['size'] ?? null,
Auth::id(),
]);
AuditService::log(
'accounting_invoice_created',
'/accounting/save',
'Facture ajoutée',
'accounting_invoice',
(int) $db->lastInsertId(),
['reference' => $values[1]],
);
header('Location: /accounting?year=0');
}
public static function update(): void
{
$id = (int) ($_POST['id'] ?? 0);
$db = DB::pdo();
$q = $db->prepare('SELECT * FROM accounting_invoices WHERE id=? AND deleted_at IS NULL');
$q->execute([$id]);
$old = $q->fetch(PDO::FETCH_ASSOC);
if (!$old) {
http_response_code(404);
return;
}
$values = self::values();
$file = self::upload();
$sql =
"UPDATE accounting_invoices SET vendor_contact_id=?,reference=?,invoice_date=?,due_date=?,document_type=?,status=?,payment_method=?,paid_on=?,total_ht_cents=?,total_ttc_cents=?,amount_due_cents=?,notes=?,updated_at=datetime('now')";
if ($file) {
$sql .= ',original_filename=?,stored_filename=?,mime_type=?,file_size=?';
array_push($values, $file['original'], $file['stored'], $file['mime'], $file['size']);
}
$sql .= ' WHERE id=? AND deleted_at IS NULL';
$values[] = $id;
$db->prepare($sql)->execute($values);
if ($file && !empty($old['stored_filename'])) {
@unlink(self::dir() . '/' . basename((string) $old['stored_filename']));
}
AuditService::log(
'accounting_invoice_updated',
'/accounting/update',
'Facture modifiée',
'accounting_invoice',
$id,
);
header('Location: /accounting?year=0');
}
public static function delete(): void
{
$id = (int) ($_POST['id'] ?? 0);
$db = DB::pdo();
$s = $db->prepare(
"UPDATE accounting_invoices SET deleted_at=datetime('now'),deleted_by=?,updated_at=datetime('now') WHERE id=? AND deleted_at IS NULL",
);
$s->execute([Auth::id(), $id]);
if ($s->rowCount()) {
AuditService::log(
'accounting_invoice_deleted',
'/accounting/delete',
'Facture mise à la corbeille',
'accounting_invoice',
$id,
);
}
header('Location: /accounting?year=0');
}
public static function document(): void
{
$id = (int) ($_GET['id'] ?? 0);
$s = DB::pdo()->prepare('SELECT * FROM accounting_invoices WHERE id=? AND deleted_at IS NULL');
$s->execute([$id]);
$r = $s->fetch(PDO::FETCH_ASSOC);
if (!$r || !$r['stored_filename']) {
http_response_code(404);
return;
}
$p = self::dir() . '/' . basename((string) $r['stored_filename']);
if (!is_file($p)) {
http_response_code(404);
return;
}
header('Content-Type: ' . $r['mime_type']);
header(
'Content-Disposition: inline; filename="' . str_replace('"', '', (string) $r['original_filename']) . '"',
);
header('Content-Length: ' . filesize($p));
readfile($p);
}
private static function values(): array
{
$ref = trim((string) ($_POST['reference'] ?? ''));
$date = (string) ($_POST['invoice_date'] ?? '');
$ttc = self::money($_POST['total_ttc'] ?? '');
if ($ref === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) || $ttc === null) {
http_response_code(400);
exit(t('accounting.required_fields'));
}
$status = (string) ($_POST['status'] ?? 'to_pay');
$due = self::money($_POST['amount_due'] ?? '');
if ($due === null) {
$due = $status === 'paid' ? 0 : $ttc;
}
return [
(int) ($_POST['vendor_contact_id'] ?? 0) ?: null,
$ref,
$date,
$_POST['due_date'] ?? '' ?: null,
(string) ($_POST['document_type'] ?? 'invoice'),
$status,
$_POST['payment_method'] ?? '' ?: null,
$_POST['paid_on'] ?? '' ?: null,
self::money($_POST['total_ht'] ?? ''),
$ttc,
$due,
trim((string) ($_POST['notes'] ?? '')) ?: null,
];
}
private static function money(mixed $v): ?int
{
$v = trim(str_replace([' ', '€', "\xc2\xa0"], ['', '', ''], (string) $v));
if ($v === '') {
return null;
}
$v = str_replace(',', '.', $v);
return is_numeric($v) ? (int) round((float) $v * 100) : null;
}
private static function upload(): array
{
if (empty($_FILES['document']['tmp_name'])) {
return [];
}
$f = $_FILES['document'];
if (($f['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || ($f['size'] ?? 0) > 20 * 1024 * 1024) {
throw new RuntimeException(t('accounting.document_invalid'));
}
$mime = new finfo(FILEINFO_MIME_TYPE)->file($f['tmp_name']);
$ext =
['application/pdf' => 'pdf', 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'][$mime] ??
null;
if (!$ext) {
throw new RuntimeException(t('accounting.document_format'));
}
$stored = bin2hex(random_bytes(20)) . '.' . $ext;
if (!move_uploaded_file($f['tmp_name'], self::dir() . '/' . $stored)) {
throw new RuntimeException(t('accounting.document_save_failed'));
}
return [
'original' => basename((string) $f['name']),
'stored' => $stored,
'mime' => $mime,
'size' => (int) $f['size'],
];
}
public static function dashboard(): void
{
$db = DB::pdo();
$year = (int) ($_GET['year'] ?? 0);
$where = ['ai.deleted_at IS NULL'];
$params = [];
if ($year) {
$where[] = "strftime('%Y',ai.invoice_date)=:year";
$params[':year'] = (string) $year;
}
$sql =
'SELECT ai.*,dc.name vendor_name FROM accounting_invoices ai LEFT JOIN directory_contacts dc ON dc.id=ai.vendor_contact_id WHERE ' .
implode(' AND ', $where) .
' ORDER BY date(ai.invoice_date) DESC,ai.id DESC';
$s = $db->prepare($sql);
$s->execute($params);
$rows = $s->fetchAll(PDO::FETCH_ASSOC);
[$rows, $filters] = self::filterRows($rows);
$vendors = [];
$annual = [];
$statuses = [];
$methods = [];
$totals = ['billed' => 0, 'due' => 0, 'documents' => count($rows), 'overdue' => 0, 'to_pay' => 0];
foreach ($rows as $r) {
$ttc = (int) $r['total_ttc_cents'];
$due = (int) ($r['amount_due_cents'] ?? 0);
$totals['billed'] += $ttc;
$totals['due'] += $due;
if ($due > 0) {
$totals['to_pay']++;
}
if ($due > 0 && !empty($r['due_date']) && $r['due_date'] < date('Y-m-d')) {
$totals['overdue']++;
}
$vendorId = (int) ($r['vendor_contact_id'] ?? 0);
$vendorLabel = $r['vendor_name'] ?: 'Structure non renseignée';
$vendors[$vendorId] ??= [
'id' => $vendorId,
'label' => $vendorLabel,
'billed' => 0,
'due' => 0,
'documents' => 0,
'paid_documents' => 0,
'animals' => 0,
'animal_cost' => 0,
];
$vendors[$vendorId]['billed'] += $ttc;
$vendors[$vendorId]['due'] += $due;
$vendors[$vendorId]['documents']++;
if ($r['status'] === 'paid') {
$vendors[$vendorId]['paid_documents']++;
}
$y = substr((string) $r['invoice_date'], 0, 4);
$annual[$y] ??= ['year' => $y, 'billed' => 0, 'due' => 0, 'documents' => 0];
$annual[$y]['billed'] += $ttc;
$annual[$y]['due'] += $due;
$annual[$y]['documents']++;
$statuses[$r['status']] = ($statuses[$r['status']] ?? 0) + 1;
$method = $r['payment_method'] ?: 'unknown';
$methods[$method] = ($methods[$method] ?? 0) + 1;
}
$expenseWhere = $year ? " AND strftime('%Y',ae.occurred_on)=:expense_year" : '';
$expense = $db->prepare(
"SELECT ae.clinic_contact_id,COUNT(DISTINCT ae.animal_id) animals,SUM(ae.total_cents) total FROM animal_expenses ae WHERE ae.clinic_contact_id IS NOT NULL $expenseWhere GROUP BY ae.clinic_contact_id",
);
$expense->execute($year ? [':expense_year' => (string) $year] : []);
foreach ($expense->fetchAll(PDO::FETCH_ASSOC) as $e) {
if (isset($vendors[(int) $e['clinic_contact_id']])) {
$vendors[(int) $e['clinic_contact_id']]['animals'] = (int) $e['animals'];
$vendors[(int) $e['clinic_contact_id']]['animal_cost'] = (int) $e['total'];
}
}
usort($vendors, fn($a, $b) => $b['billed'] <=> $a['billed']);
ksort($annual);
$years = $db
->query(
"SELECT DISTINCT strftime('%Y',invoice_date) y FROM accounting_invoices WHERE deleted_at IS NULL ORDER BY y DESC",
)
->fetchAll(PDO::FETCH_COLUMN);
render('accounting.php', [
'title' => 'Comptabilité',
'invoices' => $rows,
'vendors' => self::vendors($db),
'years' => $years,
'year' => $year,
'accountingFilters' => $filters,
'accountingTotals' => $totals,
'vendorStats' => $vendors,
'annualStats' => array_values($annual),
'statusStats' => $statuses,
'methodStats' => $methods,
]);
}
public static function exportCsv(): void
{
self::export(false);
}
private static function export(bool $unused): void
{
$db = DB::pdo();
$year = (int) ($_GET['year'] ?? 0);
$where = ['ai.deleted_at IS NULL'];
$params = [];
if ($year) {
$where[] = "strftime('%Y',ai.invoice_date)=:year";
$params[':year'] = (string) $year;
}
$s = $db->prepare(
'SELECT ai.*,dc.name vendor_name FROM accounting_invoices ai LEFT JOIN directory_contacts dc ON dc.id=ai.vendor_contact_id WHERE ' .
implode(' AND ', $where) .
' ORDER BY date(ai.invoice_date),ai.id',
);
$s->execute($params);
header('Content-Type: text/csv; charset=UTF-8');
header(
'Content-Disposition: attachment; filename="globinours-comptabilite-' .
($year ?: 'toutes-annees') .
'.csv"',
);
echo "\xEF\xBB\xBF";
$out = fopen('php://output', 'wb');
fputcsv(
$out,
[
'Date',
'Référence',
'Type',
'Structure',
'HT €',
'TTC €',
'Statut',
'Reste dû €',
'Règlement',
'Échéance',
'Notes',
],
';',
);
$safe = static function ($v) {
$v = (string) $v;
return preg_match('/^[=+\-@]/', $v) ? "'" . $v : $v;
};
foreach ($s->fetchAll(PDO::FETCH_ASSOC) as $r) {
fputcsv(
$out,
[
$r['invoice_date'],
$safe($r['reference']),
$r['document_type'],
$safe($r['vendor_name'] ?? ''),
$r['total_ht_cents'] === null ? '' : number_format($r['total_ht_cents'] / 100, 2, '.', ''),
number_format($r['total_ttc_cents'] / 100, 2, '.', ''),
$r['status'],
number_format(($r['amount_due_cents'] ?? 0) / 100, 2, '.', ''),
$r['payment_method'],
$r['due_date'],
$safe($r['notes'] ?? ''),
],
';',
);
}
fclose($out);
}
private static function filterRows(array $rows): array
{
$filters = [
'q' => trim((string) ($_GET['q'] ?? '')),
'vendor_id' => (int) ($_GET['vendor_id'] ?? 0),
'status' => (string) ($_GET['status'] ?? ''),
'type' => (string) ($_GET['type'] ?? ''),
'payment_method' => (string) ($_GET['payment_method'] ?? ''),
];
$rows = array_values(
array_filter($rows, static function ($r) use ($filters) {
if (
$filters['q'] !== '' &&
!str_contains(
mb_strtolower(
implode(' ', [$r['reference'] ?? '', $r['vendor_name'] ?? '', $r['notes'] ?? '']),
),
mb_strtolower($filters['q']),
)
) {
return false;
}
if ($filters['vendor_id'] > 0 && (int) $r['vendor_contact_id'] !== $filters['vendor_id']) {
return false;
}
if ($filters['status'] !== '' && $r['status'] !== $filters['status']) {
return false;
}
if ($filters['type'] !== '' && $r['document_type'] !== $filters['type']) {
return false;
}
if ($filters['payment_method'] === 'unknown' && !empty($r['payment_method'])) {
return false;
}
if (
$filters['payment_method'] !== '' &&
$filters['payment_method'] !== 'unknown' &&
$r['payment_method'] !== $filters['payment_method']
) {
return false;
}
return true;
}),
);
return [$rows, $filters];
}
public static function exportFilteredCsv(): void
{
$db = DB::pdo();
$year = (int) ($_GET['year'] ?? 0);
$s = $db->prepare(
'SELECT ai.*,dc.name vendor_name FROM accounting_invoices ai LEFT JOIN directory_contacts dc ON dc.id=ai.vendor_contact_id WHERE ai.deleted_at IS NULL' .
($year ? " AND strftime('%Y',ai.invoice_date)=:year" : '') .
' ORDER BY date(ai.invoice_date),ai.id',
);
$s->execute($year ? [':year' => (string) $year] : []);
[$rows] = self::filterRows($s->fetchAll(PDO::FETCH_ASSOC));
header('Content-Type: text/csv; charset=UTF-8');
header(
'Content-Disposition: attachment; filename="globinours-comptabilite-' .
($year ?: 'toutes-annees') .
'.csv"',
);
echo "\xEF\xBB\xBF";
$out = fopen('php://output', 'wb');
fputcsv(
$out,
[
'Date',
'Référence',
'Type',
'Structure',
'HT €',
'TTC €',
'Statut',
'Reste dû €',
'Règlement',
'Échéance',
'Notes',
],
';',
);
$safe = static fn($v) => preg_match('/^[=+\-@]/', (string) $v) ? "'" . $v : (string) $v;
foreach ($rows as $r) {
fputcsv(
$out,
[
$r['invoice_date'],
$safe($r['reference']),
$r['document_type'],
$safe($r['vendor_name'] ?? ''),
$r['total_ht_cents'] === null ? '' : number_format($r['total_ht_cents'] / 100, 2, '.', ''),
number_format($r['total_ttc_cents'] / 100, 2, '.', ''),
$r['status'],
number_format(($r['amount_due_cents'] ?? 0) / 100, 2, '.', ''),
$r['payment_method'],
$r['due_date'],
$safe($r['notes'] ?? ''),
],
';',
);
}
fclose($out);
}
public static function editByReference(): void
{
$ref = trim((string) ($_GET['reference'] ?? ''));
$s = DB::pdo()->prepare(
'SELECT id FROM accounting_invoices WHERE reference=? AND deleted_at IS NULL ORDER BY id DESC LIMIT 1',
);
$s->execute([$ref]);
$_GET['id'] = (int) $s->fetchColumn();
self::edit();
}
}

View file

@ -0,0 +1,101 @@
<?php
class AdminController
{
public static function exports(): void
{
self::adminOnly();
render('admin_exports.php', ['title' => t('admin.exports_title'), 'datasets' => ExportService::catalog()]);
}
public static function downloadExport(): void
{
self::adminOnly();
$type = (string) ($_GET['type'] ?? '');
try {
AuditService::log(
'data_export',
'/admin/exports/download',
$type === 'all' ? 'Export complet des données' : 'Export CSV : ' . $type,
);
if ($type === 'all') {
ExportService::downloadZip();
} else {
ExportService::downloadCsv($type);
}
} catch (InvalidArgumentException) {
http_response_code(404);
echo h(t('admin.unknown_export'));
} catch (Throwable $e) {
error_log('Échec export ' . $type . ' : ' . $e->getMessage());
http_response_code(500);
echo h(t('admin.export_failed'));
}
}
public static function register(): void
{
[$rows, $year, $q, $species, $sort, $dir] = self::registerData();
render(
'admin_register.php',
compact('rows', 'year', 'q', 'species', 'sort', 'dir') + ['title' => t('admin.title')],
);
}
public static function registerPdf(): void
{
[$rows, $year, $q, $species, $sort, $dir] = self::registerData();
$generatedAt = new DateTimeImmutable('now', new DateTimeZone('Europe/Paris'));
header('Content-Type: text/html; charset=UTF-8');
require __DIR__ . '/../Views/admin_register_print.php';
}
private static function registerData(): array
{
$pdo = DB::pdo();
$year = isset($_GET['year']) ? (int) $_GET['year'] : (int) date('Y');
$q = trim((string) ($_GET['q'] ?? ''));
$species = trim((string) ($_GET['species'] ?? ''));
$allowedSort = [
'date' => 'm.created_at',
'name' => 'a.name',
'species' => 'a.species',
'sex' => 'a.sex',
'type' => 'm.kind',
];
$sort = (string) ($_GET['sort'] ?? 'date');
if (!isset($allowedSort[$sort])) {
$sort = 'date';
}
$dir = strtolower((string) ($_GET['dir'] ?? 'asc')) === 'desc' ? 'desc' : 'asc';
$sql = "SELECT m.*, a.id AS animal_id, a.internal_code, a.name, a.sex, a.species, a.birth_date, a.chip_id,
d.cause_code death_cause_code,d.cause_details death_cause_details,d.euthanized death_euthanized,d.body_disposition death_disposition,d.cremation_date,
vet.name death_veterinarian,crem.name death_crematorium
FROM animal_movements m JOIN animals a ON a.id=m.animal_id
LEFT JOIN animal_deaths d ON d.animal_id=m.animal_id AND m.kind='exit' AND lower(m.lieu) IN ('décès','deces','décès de l''animal','deces de l''animal')
LEFT JOIN directory_contacts vet ON vet.id=d.veterinarian_contact_id
LEFT JOIN directory_contacts crem ON crem.id=d.crematorium_contact_id
WHERE a.deleted_at IS NULL AND a.archived_at IS NULL
AND m.created_at BETWEEN :ys AND :ye";
$params = [':ys' => sprintf('%04d-01-01 00:00:00', $year), ':ye' => sprintf('%04d-12-31 23:59:59', $year)];
if ($q !== '') {
$sql .= ' AND (a.name LIKE :q OR a.internal_code LIKE :q OR a.chip_id LIKE :q)';
$params[':q'] = "%$q%";
}
if ($species !== '') {
$sql .= ' AND lower(a.species)=:species';
$params[':species'] = strtolower($species);
}
$sql .= ' ORDER BY ' . $allowedSort[$sort] . ' ' . strtoupper($dir);
$statement = $pdo->prepare($sql);
$statement->execute($params);
return [$statement->fetchAll(PDO::FETCH_ASSOC), $year, $q, $species, $sort, $dir];
}
private static function adminOnly(): void
{
if (!PermissionService::can('administrative', 'view')) {
http_response_code(403);
echo h(t('admin.forbidden'));
exit();
}
}
}

View file

@ -0,0 +1,70 @@
<?php
require_once __DIR__ . '/../Services/AdoptionService.php';
class AdoptionsController
{
public static function store(): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /animals');
exit();
}
$animalId = (int) ($_POST['animal_id'] ?? 0);
$name = trim($_POST['adopter_name'] ?? '');
if (!$animalId || $name === '') {
header('Location: /animal/show?id=' . $animalId);
exit();
}
$data = [
'animal_id' => $animalId,
'adopter_name' => $name,
'adopter_phone' => trim($_POST['adopter_phone'] ?? ''),
'adopter_email' => trim($_POST['adopter_email'] ?? ''),
'adopter_address' => trim($_POST['adopter_address'] ?? ''),
'adopter_postal_code' => trim($_POST['adopter_postal_code'] ?? ''),
'adopter_city' => trim($_POST['adopter_city'] ?? ''),
'adoption_date' => $_POST['adoption_date'] ?? date('Y-m-d'),
'notes' => trim($_POST['notes'] ?? ''),
];
$db = DB::pdo();
$contactId = (int) ($_POST['adopter_contact_id'] ?? 0);
$contactData = [
':name' => $data['adopter_name'],
':phone' => $data['adopter_phone'] ?: null,
':email' => $data['adopter_email'] ?: null,
':address' => $data['adopter_address'] ?: null,
':postal' => $data['adopter_postal_code'] ?: null,
':city' => $data['adopter_city'] ?: null,
];
if ($contactId > 0) {
$contactData[':id'] = $contactId;
$db->prepare(
"UPDATE directory_contacts SET name=:name,phone=:phone,email=:email,address=:address,postal_code=:postal,city=:city,updated_at=datetime('now') WHERE id=:id AND deleted_at IS NULL",
)->execute($contactData);
} else {
$db->prepare(
"INSERT INTO directory_contacts(kind,name,phone,email,address,postal_code,city) VALUES('person',:name,:phone,:email,:address,:postal,:city)",
)->execute($contactData);
$contactId = (int) $db->lastInsertId();
}
$db->prepare("INSERT OR IGNORE INTO directory_contact_roles(contact_id,role) VALUES(:id,'adoptant')")->execute([
':id' => $contactId,
]);
$data['adopter_contact_id'] = $contactId;
try {
AdoptionService::adopt($data);
header('Location: /animal?id=' . $animalId);
exit();
} catch (Throwable $e) {
error_log('Échec adoption animal #' . $animalId . ' : ' . $e->getMessage());
http_response_code(500);
echo h(t('error.adoption_failed'));
}
}
}

View file

@ -0,0 +1,267 @@
<?php
declare(strict_types=1);
final class AgendaController
{
public static function index(): void
{
$month = (string) ($_GET['month'] ?? date('Y-m'));
if (!preg_match('/^\d{4}-\d{2}$/', $month)) {
$month = date('Y-m');
}
$cursor =
DateTimeImmutable::createFromFormat('!Y-m', $month) ?: new DateTimeImmutable('first day of this month');
$date = (string) ($_GET['date'] ?? date('Y-m-d'));
$weekCursor = self::date($date) ?: new DateTimeImmutable('today');
$db = DB::pdo();
$user = Auth::user();
$requestedView = (string) ($_GET['view'] ?? '');
$viewMode = in_array($requestedView, ['month', 'week'], true)
? $requestedView
: (string) ($_SESSION['agenda_view'] ?? ($user['agenda_view'] ?? 'month'));
if (!in_array($viewMode, ['month', 'week'], true)) {
$viewMode = 'month';
}
if ($requestedView !== '') {
$_SESSION['agenda_view'] = $viewMode;
}
$filters = self::filters();
$items = self::filtered(
$viewMode === 'week' ? AgendaService::week($weekCursor) : AgendaService::month($cursor),
$filters,
(int) $user['id'],
);
$upcoming = self::filtered(AgendaService::upcoming(), $filters, (int) $user['id']);
$feed = $db->prepare('SELECT agenda_feed_token_hash,agenda_feed_created_at FROM users WHERE id=?');
$feed->execute([(int) $user['id']]);
$feedState = $feed->fetch(PDO::FETCH_ASSOC) ?: [];
$token = (string) ($_SESSION['agenda_feed_token_once'] ?? '');
unset($_SESSION['agenda_feed_token_once']);
$volunteers = $db
->query(
"SELECT DISTINCT dc.id,dc.name FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id AND r.role='benevole' WHERE dc.deleted_at IS NULL ORDER BY dc.name COLLATE NOCASE",
)
->fetchAll(PDO::FETCH_ASSOC);
$edit = (int) ($_GET['edit'] ?? 0);
render('agenda.php', [
'title' => t('agenda.title'),
'cursor' => $cursor,
'weekCursor' => $weekCursor,
'viewMode' => $viewMode,
'items' => $items,
'upcoming' => $upcoming,
'filters' => $filters,
'filterSuffix' => self::filterSuffix($filters),
'feedEnabled' => !empty($feedState['agenda_feed_token_hash']),
'feedCreatedAt' => $feedState['agenda_feed_created_at'] ?? null,
'feedUrl' => $token !== '' ? CalendarFeedService::absoluteUrl('/agenda/feed.ics?token=' . $token) : '',
'editItem' => $edit ? AgendaService::find($edit) : null,
'animals' => $db
->query(
'SELECT id,name FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL ORDER BY name COLLATE NOCASE',
)
->fetchAll(),
'contacts' => $db
->query('SELECT id,name FROM directory_contacts WHERE deleted_at IS NULL ORDER BY name COLLATE NOCASE')
->fetchAll(),
'volunteers' => $volunteers,
'users' => $db
->query(
"SELECT id,COALESCE(NULLIF(display_name,''),username) name FROM users WHERE active=1 ORDER BY name COLLATE NOCASE",
)
->fetchAll(),
'status' => (string) ($_GET['status'] ?? ''),
'error' => (string) ($_GET['error'] ?? ''),
]);
}
public static function save(): void
{
try {
$id = (int) ($_POST['id'] ?? 0);
$saved = AgendaService::save($_POST, $id ?: null);
$conflicts = AgendaService::conflicts($saved);
AuditService::log(
$id ? 'agenda_updated' : 'agenda_created',
'/agenda/save',
$id ? 'Événement modifié' : 'Événement créé',
'agenda',
$saved,
['conflicts' => count($conflicts)],
);
header('Location: /agenda?status=' . ($conflicts ? 'conflict' : 'saved'));
} catch (Throwable $e) {
header('Location: /agenda?error=' . rawurlencode(SecurityService::publicError($e)));
}
exit();
}
public static function status(): void
{
try {
$id = (int) ($_POST['id'] ?? 0);
AgendaService::setStatus($id, (string) ($_POST['status'] ?? ''));
AuditService::log('agenda_status', '/agenda/status', 'État de lagenda modifié', 'agenda', $id, [
'status' => $_POST['status'] ?? '',
]);
header('Location: /agenda?status=updated');
} catch (Throwable $e) {
header('Location: /agenda?error=' . rawurlencode(SecurityService::publicError($e)));
}
exit();
}
public static function export(): void
{
$view = (string) ($_GET['view'] ?? 'month');
if ($view === 'week') {
$date = self::date((string) ($_GET['date'] ?? '')) ?: new DateTimeImmutable('today');
$items = AgendaService::week($date);
$name = 'agenda-semaine-' . $date->modify('monday this week')->format('Y-m-d') . '.ics';
} else {
$month = (string) ($_GET['month'] ?? date('Y-m'));
$date = preg_match('/^\d{4}-\d{2}$/', $month) ? DateTimeImmutable::createFromFormat('!Y-m', $month) : false;
$date = $date ?: new DateTimeImmutable('first day of this month');
$items = AgendaService::month($date);
$name = 'agenda-' . $date->format('Y-m') . '.ics';
}
$items = self::filtered($items, self::filters(), (int) Auth::id());
self::output(
CalendarFeedService::render($items, AppSettings::get('association_name') . ' — ' . t('agenda.title')),
$name,
);
}
public static function feed(): void
{
$user = CalendarFeedService::userForToken((string) ($_GET['token'] ?? ''));
if (!$user) {
http_response_code(404);
header('Content-Type: text/plain; charset=utf-8');
echo t('agenda.feed_invalid');
return;
}
$items = AgendaService::range(new DateTimeImmutable('-90 days'), new DateTimeImmutable('+365 days 23:59:59'));
self::output(
CalendarFeedService::render($items, AppSettings::get('association_name') . ' — ' . t('agenda.title')),
'globinours-agenda.ics',
false,
);
}
public static function regenerateFeed(): void
{
self::feedPost();
$_SESSION['agenda_feed_token_once'] = CalendarFeedService::generateToken((int) Auth::id());
AuditService::log('agenda_feed_regenerated', '/agenda/feed/regenerate', 'Lien privé dagenda régénéré');
header('Location: /agenda?status=feed');
exit();
}
public static function revokeFeed(): void
{
self::feedPost();
CalendarFeedService::revoke((int) Auth::id());
AuditService::log('agenda_feed_revoked', '/agenda/feed/revoke', 'Lien privé dagenda révoqué');
header('Location: /agenda?status=feed-revoked');
exit();
}
private static function filters(): array
{
$types = [...AgendaService::TYPES, 'vaccine', 'deworming', 'treatment_start', 'treatment_end'];
$scope = ($_GET['scope'] ?? 'all') === 'mine' ? 'mine' : 'all';
$type = in_array($t = (string) ($_GET['category'] ?? ''), $types, true) ? $t : '';
return [
'scope' => $scope,
'assigned_user_id' => max(0, (int) ($_GET['assigned_user_id'] ?? 0)),
'volunteer_contact_id' => max(0, (int) ($_GET['volunteer_contact_id'] ?? 0)),
'category' => $type,
'show_medical' => (string) ($_GET['show_medical'] ?? '1') !== '0',
'q' => mb_substr(trim((string) ($_GET['q'] ?? '')), 0, 100),
];
}
private static function filtered(array $items, array $f, int $userId): array
{
return array_values(
array_filter($items, static function ($row) use ($f, $userId): bool {
$source = (string) ($row['source_type'] ?? 'agenda');
if ($f['scope'] === 'mine' && (int) ($row['assigned_user_id'] ?? 0) !== $userId) {
return false;
}
if ($f['assigned_user_id'] && (int) ($row['assigned_user_id'] ?? 0) !== $f['assigned_user_id']) {
return false;
}
if (
$f['volunteer_contact_id'] &&
!in_array(
$f['volunteer_contact_id'],
array_map('intval', array_filter(explode(',', (string) ($row['volunteer_ids'] ?? '')))),
true,
)
) {
return false;
}
if ($f['category'] !== '' && (string) $row['item_type'] !== $f['category']) {
return false;
}
if (!$f['show_medical'] && $source !== 'agenda') {
return false;
}
if (
$f['q'] !== '' &&
!str_contains(
mb_strtolower(
implode(' ', [
(string) ($row['title'] ?? ''),
(string) ($row['animal_name'] ?? ''),
(string) ($row['location'] ?? ''),
(string) ($row['assigned_name'] ?? ''),
(string) ($row['volunteer_names'] ?? ''),
]),
),
mb_strtolower($f['q']),
)
) {
return false;
}
return true;
}),
);
}
private static function filterSuffix(array $f): string
{
$query = http_build_query(
[
'scope' => $f['scope'],
'assigned_user_id' => $f['assigned_user_id'] ?: null,
'volunteer_contact_id' => $f['volunteer_contact_id'] ?: null,
'category' => $f['category'] ?: null,
'show_medical' => $f['show_medical'] ? '1' : '0',
'q' => $f['q'] ?: null,
],
'',
'&',
PHP_QUERY_RFC3986,
);
return $query !== '' ? '&amp;' . str_replace('&', '&amp;', $query) : '';
}
private static function feedPost(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST' || !Auth::validCsrf($_POST['csrf'] ?? null)) {
http_response_code(419);
exit();
}
if (!Auth::user() || !PermissionService::can('agenda')) {
http_response_code(403);
exit();
}
}
private static function output(string $content, string $filename, bool $attachment = true): void
{
header('Content-Type: text/calendar; charset=utf-8');
header('Content-Disposition: ' . ($attachment ? 'attachment' : 'inline') . '; filename="' . $filename . '"');
header('Cache-Control: private, no-store');
echo $content;
exit();
}
private static function date(string $value): ?DateTimeImmutable
{
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $value);
return $date && $date->format('Y-m-d') === $value ? $date : null;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
trait AnimalLifecycleActions
{
public static function delete(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$id = (int) ($_POST['id'] ?? 0);
if ($id <= 0) {
http_response_code(400);
return;
}
$db = DB::pdo();
$reason = trim((string) ($_POST['reason'] ?? ''));
$db->prepare(
"
UPDATE animals
SET deleted_at = datetime('now'),
archived_at = NULL,
deletion_reason = :reason,
updated_at = datetime('now')
WHERE id = :id
",
)->execute([':id' => $id, ':reason' => $reason !== '' ? $reason : null]);
self::logHistory($id, 'delete', 'Animal placé dans la corbeille', $reason);
header('Location: /animals');
exit();
}
public static function archive(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$id = (int) ($_POST['id'] ?? 0);
if ($id <= 0) {
http_response_code(400);
return;
}
$db = DB::pdo();
$db->prepare(
"
UPDATE animals SET archived_at = datetime('now'), deleted_at = NULL,
deletion_reason = NULL, updated_at = datetime('now')
WHERE id = :id
",
)->execute([':id' => $id]);
self::logHistory($id, 'update', 'Animal archivé');
header('Location: /animals');
exit();
}
public static function restore(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$id = (int) ($_POST['id'] ?? 0);
if ($id <= 0) {
http_response_code(400);
return;
}
$db = DB::pdo();
$db->prepare(
"
UPDATE animals SET archived_at = NULL, deleted_at = NULL,
deletion_reason = NULL, updated_at = datetime('now')
WHERE id = :id
",
)->execute([':id' => $id]);
self::logHistory($id, 'update', 'Animal restauré');
header('Location: /animals');
exit();
}
}

View file

@ -0,0 +1,706 @@
<?php
declare(strict_types=1);
trait AnimalMedicalActions
{
public static function updateMedicalStatus(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$animalId = (int) ($_POST['animal_id'] ?? 0);
if ($animalId <= 0) {
http_response_code(400);
return;
}
$allowedTestStatuses = ['unknown', 'neg', 'pos', 'doubtful'];
$fivStatus = (string) ($_POST['fiv_status'] ?? 'unknown');
$felvStatus = (string) ($_POST['felv_status'] ?? 'unknown');
if (!in_array($fivStatus, $allowedTestStatuses, true)) {
$fivStatus = 'unknown';
}
if (!in_array($felvStatus, $allowedTestStatuses, true)) {
$felvStatus = 'unknown';
}
$testDate = trim((string) ($_POST['test_date'] ?? date('Y-m-d')));
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $testDate)) {
$testDate = date('Y-m-d');
}
$testNotes = trim((string) ($_POST['test_notes'] ?? ''));
$conditions = is_array($_POST['conditions'] ?? null) ? $_POST['conditions'] : [];
$db = DB::pdo();
$animalStmt = $db->prepare('SELECT fiv, felv, fiv_status, felv_status FROM animals WHERE id = :id');
$animalStmt->execute([':id' => $animalId]);
$old = $animalStmt->fetch(PDO::FETCH_ASSOC);
if (!$old) {
http_response_code(404);
return;
}
$oldFivStatus = (string) ($old['fiv_status'] ?: ((int) $old['fiv'] === 1 ? 'pos' : 'neg'));
$oldFelvStatus = (string) ($old['felv_status'] ?: ((int) $old['felv'] === 1 ? 'pos' : 'neg'));
$db->beginTransaction();
try {
$db->prepare(
"
UPDATE animals
SET fiv_status = :fiv_status,
felv_status = :felv_status,
fiv = :fiv,
felv = :felv,
updated_at = datetime('now')
WHERE id = :id
",
)->execute([
':fiv_status' => $fivStatus,
':felv_status' => $felvStatus,
':fiv' => $fivStatus === 'pos' ? 1 : 0,
':felv' => $felvStatus === 'pos' ? 1 : 0,
':id' => $animalId,
]);
$noteStmt = $db->prepare("
INSERT INTO medical_notes
(animal_id, noted_at, kind, reason, diagnosis, plan, created_at)
VALUES
(:animal_id, :noted_at, 'diagnostic', :reason, :diagnosis, :plan, datetime('now'))
");
foreach (
[
['label' => 'FIV', 'old' => $oldFivStatus, 'new' => $fivStatus],
['label' => 'FeLV', 'old' => $oldFelvStatus, 'new' => $felvStatus],
]
as $test
) {
if ($test['new'] === 'pos' && $test['old'] !== 'pos') {
$diagnosis = $test['label'] . ' positif';
$noteStmt->execute([
':animal_id' => $animalId,
':noted_at' => $testDate . ' 12:00:00',
':reason' => 'Résultat de test ' . $test['label'],
':diagnosis' => $diagnosis,
':plan' => $testNotes !== '' ? $testNotes : null,
]);
self::logHistory(
$animalId,
'medical',
$diagnosis,
'Test positif le ' . $testDate . ($testNotes !== '' ? "\n" . $testNotes : ''),
);
}
}
$conditionSelect = $db->prepare(
'SELECT status FROM animal_health_conditions WHERE id = :id AND animal_id = :animal_id',
);
$conditionUpdate = $db->prepare("
UPDATE animal_health_conditions
SET name = :name, status = :status, diagnosed_at = :diagnosed_at,
resolved_at = :resolved_at, notes = :notes, updated_at = datetime('now')
WHERE id = :id AND animal_id = :animal_id
");
$conditionInsert = $db->prepare('
INSERT INTO animal_health_conditions
(animal_id, name, status, diagnosed_at, resolved_at, notes)
VALUES
(:animal_id, :name, :status, :diagnosed_at, :resolved_at, :notes)
');
foreach ($conditions as $key => $condition) {
if (!is_array($condition)) {
continue;
}
$name = trim((string) ($condition['name'] ?? ''));
if ($name === '') {
continue;
}
$status = ($condition['status'] ?? 'active') === 'resolved' ? 'resolved' : 'active';
$diagnosedAt = trim((string) ($condition['diagnosed_at'] ?? '')) ?: null;
$notes = trim((string) ($condition['notes'] ?? '')) ?: null;
$conditionId = ctype_digit((string) $key) ? (int) $key : 0;
$previousStatus = null;
if ($conditionId > 0) {
$conditionSelect->execute([':id' => $conditionId, ':animal_id' => $animalId]);
$previousStatus = $conditionSelect->fetchColumn();
if ($previousStatus === false) {
continue;
}
$conditionUpdate->execute([
':name' => $name,
':status' => $status,
':diagnosed_at' => $diagnosedAt,
':resolved_at' => $status === 'resolved' ? date('Y-m-d') : null,
':notes' => $notes,
':id' => $conditionId,
':animal_id' => $animalId,
]);
} else {
$conditionInsert->execute([
':animal_id' => $animalId,
':name' => $name,
':status' => $status,
':diagnosed_at' => $diagnosedAt,
':resolved_at' => $status === 'resolved' ? date('Y-m-d') : null,
':notes' => $notes,
]);
}
if ($status === 'active' && $previousStatus !== 'active') {
$noteStmt->execute([
':animal_id' => $animalId,
':noted_at' => ($diagnosedAt ?: date('Y-m-d')) . ' 12:00:00',
':reason' => 'Statut médical global',
':diagnosis' => $name,
':plan' => $notes,
]);
self::logHistory(
$animalId,
'medical',
'Pathologie / vigilance ajoutée',
$name . ($notes ? "\n" . $notes : ''),
);
}
}
$changes = [];
if ($oldFivStatus !== $fivStatus) {
$changes[] = 'FIV : ' . $oldFivStatus . ' → ' . $fivStatus;
}
if ($oldFelvStatus !== $felvStatus) {
$changes[] = 'FeLV : ' . $oldFelvStatus . ' → ' . $felvStatus;
}
if ($changes) {
self::logHistory($animalId, 'medical', 'Statut médical mis à jour', implode("\n", $changes));
}
$db->commit();
} catch (Throwable $e) {
$db->rollBack();
throw $e;
}
header('Location: /animal?id=' . $animalId . '#tab-med');
exit();
}
public static function addMedical(): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
return;
}
$animalId = (int) ($_POST['animal_id'] ?? 0);
if ($animalId <= 0) {
http_response_code(400);
return;
}
$kind = trim($_POST['kind'] ?? 'consult');
$reason = trim($_POST['reason'] ?? '');
$symptoms = trim($_POST['symptoms'] ?? '');
$exam = trim($_POST['exam'] ?? '');
$diagnosis = trim($_POST['diagnosis'] ?? '');
$plan = trim($_POST['plan'] ?? '');
$markSterilized = !empty($_POST['mark_sterilized']);
if ($markSterilized) {
$kind = 'acte';
if ($reason === '') {
$reason = 'Stérilisation';
}
}
$temperature = $_POST['temperature_c'] ?? null;
$weight = $_POST['weight_kg'] ?? null;
$clinicId = (int) ($_POST['clinic_id'] ?? 0);
$clinicId = $clinicId > 0 ? $clinicId : null;
$db = DB::pdo();
$clinicContact = self::resolveDirectoryContact(
$db,
(int) ($_POST['clinic_contact_id'] ?? 0),
(string) ($_POST['new_clinic_name'] ?? ''),
'organization',
'cabinet',
);
$vetContact = self::resolveDirectoryContact(
$db,
(int) ($_POST['vet_contact_id'] ?? 0),
(string) ($_POST['new_vet_name'] ?? ''),
'person',
'veterinaire',
);
self::normalizeVeterinaryClinic($db, $vetContact, $clinicContact);
if (!$vetContact || !$clinicContact) {
http_response_code(400);
echo h(t('error.vet_clinic_required'));
return;
}
$vetName = $vetContact['name'] ?? null;
// 1⃣ Insertion note médicale
$stmt = $db->prepare('
INSERT INTO medical_notes
(animal_id, kind, reason, symptoms, exam, diagnosis, plan, temperature_c, weight_kg, clinic_id, vet_name, clinic_contact_id, vet_contact_id, created_by, created_at)
VALUES
(:aid, :kind, :reason, :symptoms, :exam, :diagnosis, :plan, :temp, :weight, :clinic, :vet, :clinic_contact, :vet_contact, :created_by, :created)
');
$stmt->execute([
':aid' => $animalId,
':kind' => $kind,
':reason' => $reason,
':symptoms' => $symptoms,
':exam' => $exam,
':diagnosis' => $diagnosis,
':plan' => $plan,
':temp' => $temperature !== '' ? (float) $temperature : null,
':weight' => $weight !== '' ? (float) $weight : null,
':clinic' => $clinicId,
':vet' => $vetName,
':clinic_contact' => $clinicContact['id'] ?? null,
':vet_contact' => $vetContact['id'] ?? null,
':created_by' => Auth::id(),
':created' => self::now(),
]);
// 👉 RÉCUPÉRATION ID NOTE
$medicalNoteId = $db->lastInsertId();
if ((int) ($_POST['tariff_id'] ?? 0) > 0) {
PricingService::record(
$db,
$animalId,
(int) $_POST['tariff_id'],
1,
substr(self::now(), 0, 10),
'Ajoutée avec la note médicale',
);
}
// ==========================
// 2⃣ Upload photos médicales
// ==========================
if (!empty($_FILES['medical_photos']['name'][0])) {
$uploadDir = PrivateMediaService::medicalDir($animalId);
if (!is_dir($uploadDir)) {
PrivateMediaService::ensure($uploadDir);
}
foreach ($_FILES['medical_photos']['tmp_name'] as $key => $tmpName) {
if ($_FILES['medical_photos']['error'][$key] !== UPLOAD_ERR_OK) {
continue;
}
if ($_FILES['medical_photos']['size'][$key] > 20 * 1024 * 1024) {
continue;
}
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $tmpName);
$allowedTypes = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
];
if (!isset($allowedTypes[$mime])) {
continue;
}
try {
$stored = ImageService::storeUploaded($tmpName, $uploadDir, 'med', 'medical');
$safeName = $stored['filename'];
@chmod($stored['path'], 0600);
$stmtPhoto = $db->prepare('
INSERT INTO medical_photos (animal_id, medical_note_id, filename)
VALUES (?, ?, ?)
');
$stmtPhoto->execute([$animalId, $medicalNoteId, $safeName]);
} catch (Throwable) {
continue;
}
}
}
// 3⃣ Poids → measurements (graph)
if ($weight !== '' && is_numeric($weight)) {
$db->prepare(
"
INSERT INTO measurements (animal_id, measured_at, type, value, unit, notes, created_by)
VALUES (:aid, :at, 'weight', :v, 'kg', 'from medical note', :created_by)
",
)->execute([
':aid' => $animalId,
':at' => self::now(),
':v' => (float) $weight,
':created_by' => Auth::id(),
]);
}
// 4⃣ Historique
self::logHistory($animalId, 'medical', 'Note médicale ajoutée', $reason ?: null);
// 5⃣ Touch animal
if ($markSterilized) {
$db->prepare(
"UPDATE animals SET sterilized=1,sterilization_status='yes',sterilization_date=COALESCE(sterilization_date,date(:date)),sterilization_vet_contact_id=COALESCE(:vet,sterilization_vet_contact_id),sterilization_clinic_contact_id=COALESCE(:clinic,sterilization_clinic_contact_id) WHERE id=:id",
)->execute([
':date' => self::now(),
':vet' => $vetContact['id'] ?? null,
':clinic' => $clinicContact['id'] ?? null,
':id' => $animalId,
]);
}
$db->prepare(
'
UPDATE animals SET updated_at = :u WHERE id = :id
',
)->execute([
':u' => self::now(),
':id' => $animalId,
]);
self::redirectToAnimal($animalId);
}
public static function addTreatment(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
echo h(t('error.method_not_allowed'));
return;
}
$id = (int) ($_POST['animal_id'] ?? 0);
$medId = (int) ($_POST['medication_id'] ?? 0);
$createMedication = !empty($_POST['create_medication']);
if ($id <= 0 || (!$createMedication && $medId <= 0)) {
http_response_code(400);
echo h(t('error.bad_request'));
return;
}
$route = trim((string) ($_POST['route'] ?? ''));
$dose = trim((string) ($_POST['dose_text'] ?? ''));
$start = trim((string) ($_POST['start_date'] ?? date('Y-m-d')));
$end = trim((string) ($_POST['end_date'] ?? ''));
$notes = trim((string) ($_POST['notes'] ?? ''));
$giveMorning = !empty($_POST['give_morning']) ? 1 : 0;
$giveEvening = !empty($_POST['give_evening']) ? 1 : 0;
$giveAsNeeded = !empty($_POST['give_as_needed']) ? 1 : 0;
$clinic_id = (int) ($_POST['clinic_id'] ?? 0);
$clinic_id = $clinic_id > 0 ? $clinic_id : null;
$vet_name = trim((string) ($_POST['vet_name'] ?? ''));
$vet_name = $vet_name !== '' ? mb_strtoupper($vet_name, 'UTF-8') : null;
if ($dose === '') {
http_response_code(400);
echo h(t('error.dose_required'));
return;
}
$db = DB::pdo();
if ($createMedication) {
$medicationName = trim((string) ($_POST['new_medication_name'] ?? ''));
$medicationMolecule = trim((string) ($_POST['new_medication_molecule'] ?? ''));
$medicationForm = trim((string) ($_POST['new_medication_form'] ?? ''));
if ($medicationName === '') {
http_response_code(400);
echo h(t('error.medication_name_required'));
return;
}
$existing = $db->prepare('SELECT id FROM ref_medications WHERE name = :name COLLATE NOCASE LIMIT 1');
$existing->execute([':name' => $medicationName]);
$medId = (int) ($existing->fetchColumn() ?: 0);
if ($medId <= 0) {
$insertMedication = $db->prepare(
'INSERT INTO ref_medications(name, molecule, form) VALUES(:name,:molecule,:form)',
);
$insertMedication->execute([
':name' => mb_substr($medicationName, 0, 150),
':molecule' => $medicationMolecule !== '' ? mb_substr($medicationMolecule, 0, 150) : null,
':form' => $medicationForm !== '' ? mb_substr($medicationForm, 0, 100) : null,
]);
$medId = (int) $db->lastInsertId();
}
}
$clinicContact = self::resolveDirectoryContact(
$db,
(int) ($_POST['clinic_contact_id'] ?? 0),
(string) ($_POST['new_clinic_name'] ?? ''),
'organization',
'cabinet',
);
$vetContact = self::resolveDirectoryContact(
$db,
(int) ($_POST['vet_contact_id'] ?? 0),
(string) ($_POST['new_vet_name'] ?? ''),
'person',
'veterinaire',
);
self::normalizeVeterinaryClinic($db, $vetContact, $clinicContact);
if (!$vetContact || !$clinicContact) {
http_response_code(400);
echo h(t('error.vet_clinic_required'));
return;
}
$vet_name = $vetContact['name'] ?? $vet_name;
$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,
':start' => $start,
':end' => $end !== '' ? $end : 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,
':give_morning' => $giveMorning,
':give_evening' => $giveEvening,
':give_as_needed' => $giveAsNeeded,
':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',
'Traitement ajouté',
"Médicament #$medId\nDose : $dose\nDébut : $start" .
($end !== '' ? "\nFin : $end" : '') .
($notes !== '' ? "\nNotes : $notes" : ''),
);
$db->prepare("UPDATE animals SET updated_at = datetime('now') WHERE id = :id")->execute([':id' => $id]);
self::redirectToAnimal($id);
}
public static function addExpense(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
return;
}
$animal = (int) ($_POST['animal_id'] ?? 0);
$tariff = (int) ($_POST['tariff_id'] ?? 0);
$qty = (float) str_replace(',', '.', (string) ($_POST['quantity'] ?? 1));
$date = (string) ($_POST['occurred_on'] ?? date('Y-m-d'));
if (!$animal || !$tariff || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
http_response_code(400);
echo h(t('error.invalid_expense'));
return;
}
$db = DB::pdo();
try {
$total = PricingService::record($db, $animal, $tariff, $qty, $date, trim((string) ($_POST['notes'] ?? '')));
self::logHistory(
$animal,
'medical',
'Dépense vétérinaire ajoutée',
number_format($total / 100, 2, ',', ' ') . ' €',
);
AuditService::log(
'animal_expense_added',
'/animal/add-expense',
'Dépense vétérinaire enregistrée',
'animal',
$animal,
['total_cents' => $total],
);
self::redirectToAnimal($animal);
} catch (Throwable $e) {
http_response_code(400);
echo h($e->getMessage());
}
}
public static function addVaccine(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
echo h(t('error.method_not_allowed'));
return;
}
$db = DB::pdo();
$id = (int) ($_POST['animal_id'] ?? 0);
$vacId = (int) ($_POST['vaccine_id'] ?? 0);
if ($id <= 0 || $vacId <= 0) {
http_response_code(400);
echo h(t('error.bad_request'));
return;
}
$done = trim((string) ($_POST['done_date'] ?? date('Y-m-d')));
$due = trim((string) ($_POST['due_date'] ?? ''));
// Si due_date est fourni, on respecte.
// Sinon auto selon protocole.
if ($due === '') {
// Birthdate (pour l'âge)
$stmt = $db->prepare('SELECT birth_date FROM animals WHERE id = :id');
$stmt->execute([':id' => $id]);
$birth = (string) ($stmt->fetchColumn() ?: '');
$isUnderOne = false;
if ($birth !== '') {
$birthDt = new DateTime($birth);
$doneDt = new DateTime($done);
$ageDays = (int) $birthDt->diff($doneDt)->format('%r%a');
if ($ageDays >= 0) {
$isUnderOne = $ageDays < 365;
}
}
// Protocole vaccin (family TC / L)
$stmt = $db->prepare('SELECT family, name FROM ref_vaccines WHERE id = :vid');
$stmt->execute([':vid' => $vacId]);
$vac = $stmt->fetch() ?: [];
$family = strtoupper((string) ($vac['family'] ?? ''));
// Détermine si c'est une primo pour la famille L (Leucose)
$isPrimoL = false;
if ($family === 'L') {
$stmt = $db->prepare("
SELECT COUNT(*)
FROM vaccinations v
JOIN ref_vaccines rv ON rv.id = v.vaccine_id
WHERE v.animal_id = :aid AND upper(coalesce(rv.family,'')) = 'L'
");
$stmt->execute([':aid' => $id]);
$countL = (int) $stmt->fetchColumn();
$isPrimoL = $countL === 0;
}
// Règles
if ($family === 'L') {
$due = $isPrimoL
? date('Y-m-d', strtotime($done . ' +1 month'))
: date('Y-m-d', strtotime($done . ' +1 year'));
} else {
// TC (ou inconnu => TC)
$due = $isUnderOne
? date('Y-m-d', strtotime($done . ' +1 month'))
: date('Y-m-d', strtotime($done . ' +1 year'));
}
}
$lot = trim((string) ($_POST['lot'] ?? ''));
$manufacturer = trim((string) ($_POST['manufacturer'] ?? ''));
$batchExpiresOn = trim((string) ($_POST['batch_expires_on'] ?? ''));
if ($batchExpiresOn !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $batchExpiresOn)) {
http_response_code(400);
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;
$vet_name = trim((string) ($_POST['vet_name'] ?? ''));
$vet_name = $vet_name !== '' ? mb_strtoupper($vet_name, 'UTF-8') : null;
$clinicContact = self::resolveDirectoryContact(
$db,
(int) ($_POST['clinic_contact_id'] ?? 0),
(string) ($_POST['new_clinic_name'] ?? ''),
'organization',
'cabinet',
);
$vetContact = self::resolveDirectoryContact(
$db,
(int) ($_POST['vet_contact_id'] ?? 0),
(string) ($_POST['new_vet_name'] ?? ''),
'person',
'veterinaire',
);
self::normalizeVeterinaryClinic($db, $vetContact, $clinicContact);
if (!$vetContact || !$clinicContact) {
http_response_code(400);
echo h(t('error.vet_clinic_required'));
return;
}
$vet_name = $vetContact['name'] ?? $vet_name;
$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,
':due' => $due !== '' ? $due : null,
':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(),
]);
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',
'Vaccin ajouté',
"Vaccin #$vacId\nFait le : $done" .
($due !== '' ? "\nRappel : $due" : '') .
($manufacturer !== '' ? "\nFabricant : $manufacturer" : '') .
($lot !== '' ? "\nLot : $lot" : '') .
($batchExpiresOn !== '' ? "\nExpiration du lot : $batchExpiresOn" : '') .
($administratorName !== '' || $administratorUserId
? "\nAdministré par : " .
($administratorName !== '' ? $administratorName : 'utilisateur #' . $administratorUserId)
: ''),
);
$db->prepare("UPDATE animals SET updated_at = datetime('now') WHERE id = :id")->execute([':id' => $id]);
self::redirectToAnimal($id);
}
}

View file

@ -0,0 +1,296 @@
<?php
declare(strict_types=1);
trait AnimalPhotoActions
{
public static function uploadPhoto(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
echo h(t('error.method_not_allowed'));
return;
}
$id = (int) ($_POST['animal_id'] ?? 0);
if ($id <= 0) {
http_response_code(400);
echo h(t('error.bad_request'));
return;
}
if (!isset($_FILES['photo']) || $_FILES['photo']['error'] !== UPLOAD_ERR_OK) {
http_response_code(400);
echo h(t('error.upload_failed'));
return;
}
$db = DB::pdo();
$tmp = $_FILES['photo']['tmp_name'];
$orig = (string) ($_FILES['photo']['name'] ?? 'photo');
$size = (int) ($_FILES['photo']['size'] ?? 0);
$mime = (string) ($_FILES['photo']['type'] ?? '');
$base = preg_replace('/[^a-zA-Z0-9._-]+/', '_', pathinfo($orig, PATHINFO_FILENAME));
$base = $base ?: 'photo';
$dir = __DIR__ . '/../../public/media/animals/' . $id;
try {
$stored = ImageService::storeUploaded($tmp, $dir, $base, 'photo');
$filename = $stored['filename'];
$mime = $stored['mime'];
$size = $stored['size'];
} catch (Throwable $e) {
http_response_code(400);
echo h($e->getMessage());
return;
}
// si aucune photo primaire, celle-ci devient primaire
$stmt = $db->prepare('SELECT COUNT(*) FROM animal_photos WHERE animal_id=:id AND is_primary=1');
$stmt->execute([':id' => $id]);
$hasPrimary = (int) $stmt->fetchColumn() > 0;
$stmt = $db->prepare('
INSERT INTO animal_photos(animal_id, filename, original_name, mime, size_bytes, is_primary)
VALUES(:aid,:fn,:orig,:mime,:sz,:prim)
');
$stmt->execute([
':aid' => $id,
':fn' => $filename,
':orig' => $orig,
':mime' => $mime,
':sz' => $size,
':prim' => $hasPrimary ? 0 : 1,
]);
header('Location: /animal?id=' . $id);
exit();
}
public static function uploadPhotos(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$animalId = (int) ($_POST['animal_id'] ?? 0);
if ($animalId <= 0) {
http_response_code(400);
echo h(t('error.bad_request'));
return;
}
if (empty($_FILES['photos'])) {
http_response_code(400);
echo h(t('error.no_files'));
return;
}
$isPublic = (int) ($_POST['is_public'] ?? 1 ? 1 : 0);
$redirectHash = trim((string) ($_POST['redirect_hash'] ?? ''));
$db = DB::pdo();
self::ensureAnimalMediaDir($animalId);
$files = $_FILES['photos'];
$count = is_array($files['name']) ? count($files['name']) : 1;
// Y a-t-il déjà une photo principale ?
$hasPrimary = (bool) $db
->query(
'
SELECT 1 FROM animal_photos
WHERE animal_id=' .
(int) $animalId .
'
AND is_primary=1
LIMIT 1
',
)
->fetchColumn();
for ($i = 0; $i < $count; $i++) {
$name = is_array($files['name']) ? $files['name'][$i] : $files['name'];
$tmp = is_array($files['tmp_name']) ? $files['tmp_name'][$i] : $files['tmp_name'];
$err = is_array($files['error']) ? $files['error'][$i] : $files['error'];
if ($err !== UPLOAD_ERR_OK) {
continue;
}
if (!is_uploaded_file($tmp)) {
continue;
}
if (!self::isAllowedImage($tmp)) {
continue;
}
$base = self::safeFilename(pathinfo($name, PATHINFO_FILENAME));
$targetDir = $isPublic === 1 ? self::animalMediaDir($animalId) : PrivateMediaService::animalDir($animalId);
if ($isPublic === 0) {
PrivateMediaService::ensure($targetDir);
}
try {
$stored = ImageService::storeUploaded($tmp, $targetDir, $base, 'photo');
$final = $stored['filename'];
if ($isPublic === 0) {
@chmod($stored['path'], 0600);
}
} catch (Throwable) {
continue;
}
// RÈGLE : jamais une privée en principale
$isPrimary = !$hasPrimary && $isPublic === 1 ? 1 : 0;
if ($isPrimary === 1) {
$hasPrimary = true;
}
$st = $db->prepare('
INSERT INTO animal_photos(animal_id, filename, is_primary, is_public)
VALUES(:aid,:fn,:p,:pub)
');
$st->execute([
':aid' => $animalId,
':fn' => $final,
':p' => $isPrimary,
':pub' => $isPublic,
]);
}
$db->prepare(
"
UPDATE animals
SET updated_at=datetime('now')
WHERE id=:id
",
)->execute([':id' => $animalId]);
// Retour direct sur onglet photos
$hash = $redirectHash !== '' && $redirectHash[0] === '#' ? $redirectHash : '';
header('Location: /animal?id=' . $animalId . $hash);
exit();
}
public static function setPrimaryPhoto(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$animalId = (int) ($_POST['animal_id'] ?? 0);
$photoId = (int) ($_POST['photo_id'] ?? 0);
if ($animalId <= 0 || $photoId <= 0) {
http_response_code(400);
return;
}
$db = DB::pdo();
$stmt = $db->prepare('SELECT filename,is_public FROM animal_photos WHERE id=:pid AND animal_id=:aid');
$stmt->execute([':pid' => $photoId, ':aid' => $animalId]);
$photo = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$photo) {
http_response_code(404);
echo h(t('error.photo_not_found'));
return;
}
$moved = false;
try {
if ((int) $photo['is_public'] !== 1) {
PrivateMediaService::moveVisibility($animalId, (string) $photo['filename'], true);
$moved = true;
}
$db->beginTransaction();
$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,is_public=1 WHERE id=:pid AND animal_id=:aid')->execute(
[':pid' => $photoId, ':aid' => $animalId],
);
$db->commit();
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
if ($moved) {
try {
PrivateMediaService::moveVisibility($animalId, (string) $photo['filename'], false);
} catch (Throwable) {
}
}
throw $e;
}
// Historique si tu veux
if (method_exists(__CLASS__, 'logHistory')) {
self::logHistory($animalId, 'photo', 'Photo principale modifiée');
}
header('Location: /animal?id=' . $animalId);
exit();
}
public static function deletePhoto(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$animalId = (int) ($_POST['animal_id'] ?? 0);
$photoId = (int) ($_POST['photo_id'] ?? 0);
if ($animalId <= 0 || $photoId <= 0) {
http_response_code(400);
return;
}
$db = DB::pdo();
$st = $db->prepare(
'SELECT filename, is_primary, is_public FROM animal_photos WHERE id=:pid AND animal_id=:aid',
);
$st->execute([':pid' => $photoId, ':aid' => $animalId]);
$row = $st->fetch(PDO::FETCH_ASSOC);
if (!$row) {
header('Location: /animal?id=' . $animalId);
exit();
}
$filename = (string) $row['filename'];
$wasPrimary = (int) $row['is_primary'] === 1;
$db->prepare('DELETE FROM animal_photos WHERE id=:pid AND animal_id=:aid')->execute([
':pid' => $photoId,
':aid' => $animalId,
]);
$path =
((int) ($row['is_public'] ?? 1) === 1
? self::animalMediaDir($animalId)
: PrivateMediaService::animalDir($animalId)) .
'/' .
$filename;
if (is_file($path)) {
@unlink($path);
}
// Si on a supprimé la principale, on met la plus récente restante en principale
if ($wasPrimary) {
$pid = $db
->query('SELECT id FROM animal_photos WHERE animal_id=' . (int) $animalId . ' ORDER BY id DESC LIMIT 1')
->fetchColumn();
if ($pid) {
$db->prepare('UPDATE animal_photos SET is_primary=1 WHERE id=:pid')->execute([':pid' => (int) $pid]);
}
}
$db->prepare("UPDATE animals SET updated_at=datetime('now') WHERE id=:id")->execute([':id' => $animalId]);
if (method_exists(__CLASS__, 'logHistory')) {
self::logHistory($animalId, 'photo', 'Photo supprimée');
}
header('Location: /animal?id=' . $animalId);
exit();
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,443 @@
<?php
declare(strict_types=1);
trait AnimalRelationshipActions
{
public static function litterForm(): void
{
$db = DB::pdo();
$sourceAnimalId = (int) ($_GET['animal_id'] ?? 0);
$motherId = 0;
$fatherId = 0;
if ($sourceAnimalId > 0) {
$stmt = $db->prepare(
'SELECT id, sex FROM animals WHERE id = :id AND deleted_at IS NULL AND archived_at IS NULL',
);
$stmt->execute([':id' => $sourceAnimalId]);
$source = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$source) {
http_response_code(404);
echo h(t('error.animal_not_found'));
return;
}
if (($source['sex'] ?? '') === 'F') {
$motherId = $sourceAnimalId;
}
if (($source['sex'] ?? '') === 'M') {
$fatherId = $sourceAnimalId;
}
}
$eligibleSql = "
SELECT id, name FROM animals
WHERE deleted_at IS NULL AND archived_at IS NULL AND lower(species) IN ('chat', 'cat') AND sex = :sex
AND lower(status) NOT IN ('adopte', 'adopté', 'decede', 'décédé')
ORDER BY name COLLATE NOCASE
";
$stmt = $db->prepare($eligibleSql);
$stmt->execute([':sex' => 'F']);
$mothers = $stmt->fetchAll(PDO::FETCH_ASSOC);
$stmt->execute([':sex' => 'M']);
$fathers = $stmt->fetchAll(PDO::FETCH_ASSOC);
$existingKittens = $db
->query(
"SELECT a.id,a.name,a.internal_code,a.color,a.sex,a.status,a.archived_at FROM animals a WHERE a.deleted_at IS NULL AND lower(a.species) IN ('chat','cat') AND NOT EXISTS (SELECT 1 FROM litter_kittens lk WHERE lk.animal_id=a.id) ORDER BY CASE WHEN a.archived_at IS NULL THEN 0 ELSE 1 END,a.name COLLATE NOCASE,a.id",
)
->fetchAll(PDO::FETCH_ASSOC);
render('litter_form.php', [
'title' => t('litter.create'),
'pageDescription' => 'Création groupée des fiches dune portée de chatons.',
'sourceAnimalId' => $sourceAnimalId,
'motherId' => $motherId,
'fatherId' => $fatherId,
'mothers' => $mothers,
'fathers' => $fathers,
'existingKittens' => $existingKittens,
]);
}
public static function saveLitter(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$db = DB::pdo();
$motherId = (int) ($_POST['mother_id'] ?? 0) ?: null;
$fatherId = (int) ($_POST['father_id'] ?? 0) ?: null;
$birthDate = trim((string) ($_POST['birth_date'] ?? '')) ?: null;
$kittenMode = in_array((string) ($_POST['kitten_mode'] ?? 'new'), ['new', 'existing'], true)
? (string) $_POST['kitten_mode']
: 'new';
if ($birthDate !== null && preg_match('/^(\d{2})\/(\d{2})\/(\d{4})$/', $birthDate, $dateParts)) {
$birthDate = $dateParts[3] . '-' . $dateParts[2] . '-' . $dateParts[1];
}
$kittens = is_array($_POST['kittens'] ?? null) ? array_values($_POST['kittens']) : [];
$existingKittenIds = is_array($_POST['existing_kitten_ids'] ?? null)
? array_values(array_unique(array_filter(array_map('intval', $_POST['existing_kitten_ids']))))
: [];
if ($kittenMode === 'new') {
$existingKittenIds = [];
} else {
$kittens = [];
}
if (!$kittens && !$existingKittenIds) {
http_response_code(400);
echo h(t('litter.need_kitten'));
return;
}
if ($motherId !== null && $fatherId !== null && $motherId === $fatherId) {
http_response_code(400);
echo h(t('litter.different_parents'));
return;
}
if ($birthDate !== null && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $birthDate)) {
http_response_code(400);
echo h(t('error.invalid_birth_date'));
return;
}
$parentIds = array_values(array_filter([$motherId, $fatherId]));
if ($parentIds) {
$placeholders = implode(',', array_fill(0, count($parentIds), '?'));
$stmt = $db->prepare(
"SELECT id, name, sex FROM animals WHERE id IN ($placeholders) AND deleted_at IS NULL AND archived_at IS NULL AND lower(species) IN ('chat','cat') AND lower(status) NOT IN ('adopte','adopté','decede','décédé')",
);
$stmt->execute($parentIds);
$parents = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $parent) {
$parents[(int) $parent['id']] = $parent;
}
if (
($motherId && ($parents[$motherId]['sex'] ?? '') !== 'F') ||
($fatherId && ($parents[$fatherId]['sex'] ?? '') !== 'M')
) {
http_response_code(400);
echo h(t('litter.invalid_parent'));
return;
}
} else {
$parents = [];
}
if ($existingKittenIds) {
$placeholders = implode(',', array_fill(0, count($existingKittenIds), '?'));
$stmt = $db->prepare(
"SELECT id FROM animals WHERE id IN ($placeholders) AND deleted_at IS NULL AND lower(species) IN ('chat','cat') AND id NOT IN (?,?) AND NOT EXISTS (SELECT 1 FROM litter_kittens lk WHERE lk.animal_id=animals.id)",
);
$stmt->execute([...$existingKittenIds, (int) ($motherId ?? 0), (int) ($fatherId ?? 0)]);
if (count($stmt->fetchAll(PDO::FETCH_ASSOC)) !== count($existingKittenIds)) {
http_response_code(400);
echo h(t('litter.invalid_selected'));
return;
}
}
$baseName = trim((string) ($parents[$motherId]['name'] ?? ($parents[$fatherId]['name'] ?? 'Portée')));
$allowedSexes = ['F', 'M', 'U'];
try {
PrivateMediaService::moveVisibility($animalId, (string) $photo['filename'], $isPublic === 1);
} catch (Throwable $e) {
http_response_code(500);
echo h(t('error.upload_failed'));
return;
}
$db->beginTransaction();
try {
$stmt = $db->prepare(
'INSERT INTO litters(mother_id, father_id, birth_date) VALUES(:mother, :father, :birth)',
);
$stmt->execute([':mother' => $motherId, ':father' => $fatherId, ':birth' => $birthDate]);
$litterId = (int) $db->lastInsertId();
$insertAnimal = $db->prepare("
INSERT INTO animals(name, internal_code, status, species, sex, breed, color, birth_date, birth_is_estimated,intake_date,intake_type,intake_reason, created_at, updated_at)
VALUES(:name, :code, 'refuge', 'chat', :sex, 'Chat Européen', :color, :birth, 0,:birth,'born_in_care','birth', datetime('now'), datetime('now'))
");
$link = $db->prepare(
'INSERT INTO litter_kittens(litter_id, animal_id, position) VALUES(:litter, :animal, :position)',
);
foreach ($kittens as $index => $kitten) {
if (!is_array($kitten)) {
continue;
}
$position = $index + 1;
$name = trim((string) ($kitten['name'] ?? ''));
if ($name === '') {
$name = $baseName . '-Bébé-' . $position;
}
$sex = (string) ($kitten['sex'] ?? 'U');
if (!in_array($sex, $allowedSexes, true)) {
$sex = 'U';
}
$color = trim((string) ($kitten['color'] ?? '')) ?: null;
$insertAnimal->execute([
':name' => $name,
':code' => Ids::nextAnimalCode($name),
':sex' => $sex,
':color' => $color,
':birth' => $birthDate,
]);
$animalId = (int) $db->lastInsertId();
$link->execute([':litter' => $litterId, ':animal' => $animalId, ':position' => $position]);
self::syncIntakeMovement(
$db,
$animalId,
$birthDate,
'born_in_care',
'birth',
null,
'',
'',
'Naissance enregistrée avec la portée n°' . $litterId,
);
LocationHistoryService::record(
$db,
$animalId,
null,
['status' => 'refuge', 'refuge_room' => null, 'care_box_key' => null, 'current_address' => null],
'litter_creation',
'Emplacement lors de la création de la portée',
$birthDate ? $birthDate . ' 12:00:00' : null,
);
self::logHistory($animalId, 'create', 'Chaton ajouté avec sa portée', 'Portée n°' . $litterId);
}
$position = count($kittens);
foreach ($existingKittenIds as $animalId) {
$link->execute([':litter' => $litterId, ':animal' => $animalId, ':position' => ++$position]);
self::logHistory($animalId, 'update', 'Rattaché à une nouvelle portée', 'Portée n°' . $litterId);
}
$db->commit();
$targetId = (int) ($_POST['source_animal_id'] ?? 0) ?: ($motherId ?: ($fatherId ?: 0));
header('Location: ' . ($targetId ? '/animal?id=' . $targetId . '#tab-litters' : '/animals'));
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
}
public static function litterAddExistingForm(): void
{
$db = DB::pdo();
$litterId = (int) ($_GET['litter_id'] ?? 0);
$sourceAnimalId = (int) ($_GET['animal_id'] ?? 0);
$stmt = $db->prepare(
'SELECT l.*,m.name mother_name,f.name father_name FROM litters l LEFT JOIN animals m ON m.id=l.mother_id LEFT JOIN animals f ON f.id=l.father_id WHERE l.id=:id',
);
$stmt->execute([':id' => $litterId]);
$litter = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$litter) {
http_response_code(404);
echo h(t('litter.not_found'));
return;
}
$stmt = $db->prepare("
SELECT a.id,a.name,a.internal_code,a.color,a.sex,a.status,a.archived_at
FROM animals a
WHERE a.deleted_at IS NULL AND lower(a.species) IN ('chat','cat')
AND a.id NOT IN (COALESCE(:mother,0),COALESCE(:father,0))
AND NOT EXISTS (SELECT 1 FROM litter_kittens lk WHERE lk.animal_id=a.id)
ORDER BY CASE WHEN a.archived_at IS NULL THEN 0 ELSE 1 END,a.name COLLATE NOCASE,a.id
");
$stmt->execute([':mother' => $litter['mother_id'], ':father' => $litter['father_id']]);
render('litter_add_existing.php', [
'title' => t('litter.add_title'),
'pageDescription' => 'Rattacher des fiches animales existantes à une portée.',
'litter' => $litter,
'animals' => $stmt->fetchAll(PDO::FETCH_ASSOC),
'sourceAnimalId' => $sourceAnimalId,
]);
}
public static function saveExistingLitterKittens(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$db = DB::pdo();
$litterId = (int) ($_POST['litter_id'] ?? 0);
$sourceAnimalId = (int) ($_POST['source_animal_id'] ?? 0);
$ids = is_array($_POST['animal_ids'] ?? null)
? array_values(array_unique(array_filter(array_map('intval', $_POST['animal_ids']))))
: [];
if ($litterId <= 0 || !$ids) {
http_response_code(400);
echo h(t('litter.select_one'));
return;
}
$litterStmt = $db->prepare('SELECT id,mother_id,father_id FROM litters WHERE id=:id');
$litterStmt->execute([':id' => $litterId]);
$litter = $litterStmt->fetch(PDO::FETCH_ASSOC);
if (!$litter) {
http_response_code(404);
echo h(t('litter.not_found'));
return;
}
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $db->prepare(
"SELECT id FROM animals WHERE id IN ($placeholders) AND deleted_at IS NULL AND lower(species) IN ('chat','cat') AND id NOT IN (?,?) AND NOT EXISTS (SELECT 1 FROM litter_kittens lk WHERE lk.animal_id=animals.id)",
);
$stmt->execute([...$ids, (int) ($litter['mother_id'] ?? 0), (int) ($litter['father_id'] ?? 0)]);
if (count($stmt->fetchAll(PDO::FETCH_ASSOC)) !== count($ids)) {
http_response_code(400);
echo h(t('litter.invalid_existing'));
return;
}
$db->beginTransaction();
try {
$position = (int) $db
->query('SELECT COALESCE(MAX(position),0) FROM litter_kittens WHERE litter_id=' . $litterId)
->fetchColumn();
$link = $db->prepare(
'INSERT INTO litter_kittens(litter_id,animal_id,position) VALUES(:litter,:animal,:position)',
);
foreach ($ids as $animalId) {
$link->execute([':litter' => $litterId, ':animal' => $animalId, ':position' => ++$position]);
self::logHistory($animalId, 'update', 'Rattaché à une portée existante', 'Portée n°' . $litterId);
}
$db->commit();
header(
'Location: /animal?id=' .
($sourceAnimalId ?: ($litter['mother_id'] ?: ($litter['father_id'] ?: $ids[0]))) .
'#tab-litters',
);
exit();
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
}
public static function bondedForm(): void
{
$db = DB::pdo();
$sourceAnimalId = (int) ($_GET['animal_id'] ?? 0);
if ($sourceAnimalId <= 0) {
http_response_code(400);
return;
}
$stmt = $db->prepare("
SELECT a.id, a.name, a.color, a.species, a.status
FROM animals a
WHERE a.deleted_at IS NULL AND a.archived_at IS NULL
AND lower(a.species) IN ('chat', 'cat')
AND lower(a.status) NOT IN ('adopte', 'adopté', 'decede', 'décédé')
AND (a.id = :source OR NOT EXISTS (
SELECT 1 FROM bonded_group_members bgm
JOIN bonded_groups bg ON bg.id = bgm.group_id AND bg.active = 1
WHERE bgm.animal_id = a.id
))
ORDER BY a.name COLLATE NOCASE
");
$stmt->execute([':source' => $sourceAnimalId]);
$animals = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (!array_filter($animals, fn(array $animal): bool => (int) $animal['id'] === $sourceAnimalId)) {
http_response_code(404);
echo h(t('bonded.unavailable'));
return;
}
render('bonded_form.php', [
'title' => t('bonded.create'),
'pageDescription' => 'Associer plusieurs chats qui doivent rester ensemble.',
'sourceAnimalId' => $sourceAnimalId,
'animals' => $animals,
]);
}
public static function saveBondedGroup(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$ids = is_array($_POST['animal_ids'] ?? null)
? array_values(array_unique(array_filter(array_map('intval', $_POST['animal_ids']))))
: [];
if (count($ids) < 2) {
http_response_code(400);
echo h(t('bonded.need_two'));
return;
}
$db = DB::pdo();
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $db->prepare("
SELECT a.id FROM animals a
WHERE a.id IN ($placeholders) AND a.deleted_at IS NULL AND a.archived_at IS NULL
AND lower(a.species) IN ('chat', 'cat')
AND lower(a.status) NOT IN ('adopte', 'adopté', 'decede', 'décédé')
AND NOT EXISTS (
SELECT 1 FROM bonded_group_members bgm JOIN bonded_groups bg ON bg.id=bgm.group_id
WHERE bgm.animal_id=a.id AND bg.active=1
)
");
$stmt->execute($ids);
if (count($stmt->fetchAll()) !== count($ids)) {
http_response_code(400);
echo h(t('bonded.invalid_member'));
return;
}
$name = trim((string) ($_POST['name'] ?? '')) ?: null;
$notes = trim((string) ($_POST['notes'] ?? '')) ?: null;
$db->beginTransaction();
try {
$stmt = $db->prepare('INSERT INTO bonded_groups(name, notes) VALUES(:name, :notes)');
$stmt->execute([':name' => $name, ':notes' => $notes]);
$groupId = (int) $db->lastInsertId();
$link = $db->prepare(
'INSERT INTO bonded_group_members(group_id, animal_id, position) VALUES(:group_id, :animal_id, :position)',
);
foreach ($ids as $index => $animalId) {
$link->execute([':group_id' => $groupId, ':animal_id' => $animalId, ':position' => $index + 1]);
self::logHistory(
$animalId,
'update',
'Ajouté à un groupe inséparable',
$name ?: 'Groupe n°' . $groupId,
);
}
$db->commit();
header('Location: /animal?id=' . $ids[0]);
exit();
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
}
public static function dissolveBondedGroup(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$groupId = (int) ($_POST['group_id'] ?? 0);
$animalId = (int) ($_POST['animal_id'] ?? 0);
if ($groupId <= 0) {
http_response_code(400);
return;
}
$db = DB::pdo();
$members = $db->prepare('SELECT animal_id FROM bonded_group_members WHERE group_id=:id');
$members->execute([':id' => $groupId]);
$memberIds = array_map('intval', array_column($members->fetchAll(PDO::FETCH_ASSOC), 'animal_id'));
$stmt = $db->prepare(
"UPDATE bonded_groups SET active=0, dissolved_at=datetime('now') WHERE id=:id AND active=1",
);
$stmt->execute([':id' => $groupId]);
foreach ($memberIds as $memberId) {
self::logHistory($memberId, 'update', 'Groupe inséparable dissous');
}
header('Location: ' . ($animalId > 0 ? '/animal?id=' . $animalId : '/animals'));
exit();
}
}

View file

@ -0,0 +1,602 @@
<?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();
}
}

View file

@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
final class Asm3ImportController
{
public static function index(): void
{
self::admin();
$jobs = DB::pdo()
->query(
'SELECT j.*,COALESCE(u.display_name,u.username) actor FROM asm3_import_jobs j LEFT JOIN users u ON u.id=j.created_by ORDER BY j.id DESC LIMIT 20',
)
->fetchAll(PDO::FETCH_ASSOC);
render('settings_asm3.php', [
'title' => t('asm3.title'),
'jobs' => $jobs,
'error' => (string) ($_GET['error'] ?? ''),
'success' => (string) ($_GET['success'] ?? ''),
]);
}
public static function upload(): void
{
self::admin();
$f = $_FILES['dump'] ?? null;
if (!is_array($f)) {
self::fail(
self::postTooLarge()
? t('asm3.post_too_large', ['limit' => ini_get('post_max_size')])
: t('asm3.select_dump'),
);
}
$uploadError = (int) ($f['error'] ?? UPLOAD_ERR_NO_FILE);
if ($uploadError !== UPLOAD_ERR_OK) {
self::fail(self::uploadErrorMessage($uploadError));
}
if (!is_uploaded_file((string) $f['tmp_name'])) {
self::fail(t('asm3.upload_unverified'));
}
if ((int) $f['size'] > 250_000_000) {
self::fail(t('asm3.over_250'));
}
$name = (string) $f['name'];
$extension = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if (!in_array($extension, ['sql', 'pgsql'], true)) {
self::fail(t('asm3.extension'));
}
$dir = dirname(__DIR__, 2) . '/storage/asm3-imports';
if (!is_dir($dir) && !mkdir($dir, 0700, true) && !is_dir($dir)) {
self::fail(t('asm3.storage_unavailable'));
}
$stored = bin2hex(random_bytes(16)) . '.' . $extension;
$path = $dir . '/' . $stored;
if (!move_uploaded_file((string) $f['tmp_name'], $path)) {
self::fail(t('asm3.storage_failed'));
}
chmod($path, 0600);
try {
$analysis = Asm3Analyzer::analyze($path);
if (($analysis['tables']['animal'] ?? 0) < 1) {
throw new RuntimeException(t('asm3.no_animal'));
}
$animals = Asm3MigrationPlanner::plan($path, DB::pdo());
$contacts = Asm3ContactPlanner::plan($path, DB::pdo());
$report = [
'analysis' => $analysis,
'animals' => $animals['summary'],
'contacts' => $contacts['summary'],
'movements' => Asm3MovementImporter::run($path, DB::pdo(), false)['summary'],
'clinical' => Asm3ClinicalImporter::run($path, DB::pdo(), false),
'litters' => Asm3LitterImporter::run($path, DB::pdo(), false),
'media' => [
'metadata' => $analysis['tables']['media'] ?? 0,
'importable' => 0,
'reason' => 'Le dump SQL ne contient pas les fichiers binaires ASM3.',
],
];
$db = DB::pdo();
$db->prepare(
'INSERT INTO asm3_import_jobs(original_name,stored_name,source_sha256,analysis_json,created_by) VALUES(:original,:stored,:hash,:analysis,:user)',
)->execute([
':original' => basename($name),
':stored' => $stored,
':hash' => hash_file('sha256', $path),
':analysis' => json_encode($report, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
':user' => Auth::id(),
]);
AuditService::log('asm3_analyzed', '/settings/asm3', 'Dump ASM3 analysé : ' . basename($name));
header('Location: /settings/asm3?success=analyzed');
exit();
} catch (Throwable $e) {
@unlink($path);
self::fail($e->getMessage());
}
}
public static function apply(): void
{
self::admin();
$id = (int) ($_POST['id'] ?? 0);
$mode = ($_POST['mode'] ?? 'merge') === 'replace' ? 'replace' : 'merge';
$expected = t($mode === 'replace' ? 'asm3.confirm_replace_phrase' : 'asm3.confirm_import_phrase');
if (trim((string) ($_POST['confirmation'] ?? '')) !== $expected) {
self::fail(t('asm3.confirm_required', ['phrase' => $expected]));
}
$db = DB::pdo();
$q = $db->prepare("SELECT * FROM asm3_import_jobs WHERE id=:id AND status='analyzed'");
$q->execute([':id' => $id]);
$job = $q->fetch(PDO::FETCH_ASSOC);
$q->closeCursor();
unset($q);
if (!$job) {
self::fail(t('asm3.analysis_unavailable'));
}
$path = dirname(__DIR__, 2) . '/storage/asm3-imports/' . basename($job['stored_name']);
if (!is_file($path) || !hash_equals($job['source_sha256'], hash_file('sha256', $path))) {
self::fail(t('asm3.dump_altered'));
}
try {
$backup = BackupService::create('pre-asm3-import');
$db->beginTransaction();
$removed = $mode === 'replace' ? Asm3ResetService::clear($db) : null;
$results = [
'mode' => $mode,
'backup' => $backup['name'],
'removed' => $removed,
'animals' => Asm3AnimalImporter::run($path, $db, true, false),
'contacts' => Asm3ContactImporter::run($path, $db, true, false),
'movements' => Asm3MovementImporter::run($path, $db, true, false),
'clinical' => Asm3ClinicalImporter::run($path, $db, true, false),
'litters' => Asm3LitterImporter::run($path, $db, true, false),
];
$db->prepare(
"UPDATE asm3_import_jobs SET status='completed',result_json=:result,completed_at=datetime('now') WHERE id=:id",
)->execute([
':result' => json_encode($results, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
':id' => $id,
]);
$db->commit();
if ($mode === 'replace') {
Asm3ResetService::clearMedia();
}
AuditService::log(
'asm3_imported',
'/settings/asm3/apply',
'Migration ASM3 terminée en mode ' . $mode . ' depuis ' . $job['original_name'],
);
header('Location: /settings/asm3?success=imported');
exit();
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
$db->prepare(
"UPDATE asm3_import_jobs SET status='failed',result_json=:result,completed_at=datetime('now') WHERE id=:id",
)->execute([
':result' => json_encode(['error' => $e->getMessage(), 'mode' => $mode], JSON_UNESCAPED_UNICODE),
':id' => $id,
]);
self::fail(t('asm3.import_failed', ['error' => $e->getMessage()]));
}
}
private static function uploadErrorMessage(int $error): string
{
return match ($error) {
UPLOAD_ERR_INI_SIZE => t('asm3.upload_ini', ['limit' => ini_get('upload_max_filesize')]),
UPLOAD_ERR_FORM_SIZE => t('asm3.upload_form'),
UPLOAD_ERR_PARTIAL => t('asm3.upload_partial'),
UPLOAD_ERR_NO_FILE => t('asm3.select_dump'),
UPLOAD_ERR_NO_TMP_DIR => t('asm3.tmp_unavailable'),
UPLOAD_ERR_CANT_WRITE => t('asm3.write_failed'),
UPLOAD_ERR_EXTENSION => t('asm3.extension_stopped'),
default => t('asm3.upload_failed', ['code' => $error]),
};
}
private static function postTooLarge(): bool
{
$length = (int) ($_SERVER['CONTENT_LENGTH'] ?? 0);
$limit = self::iniBytes((string) ini_get('post_max_size'));
return $length > 0 && $limit > 0 && $length > $limit;
}
private static function iniBytes(string $value): int
{
$value = trim($value);
if ($value === '') {
return 0;
}
$number = (float) $value;
return (int) round(
$number *
match (strtolower(substr($value, -1))) {
'g' => 1024 ** 3,
'm' => 1024 ** 2,
'k' => 1024,
default => 1,
},
);
}
private static function fail(string $message): never
{
header('Location: /settings/asm3?error=' . rawurlencode($message));
exit();
}
private static function admin(): void
{
if (!Auth::is('admin')) {
http_response_code(403);
exit();
}
}
}

View file

@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
final class AuthController
{
public static function logo(): void
{
header('Content-Type: image/png');
header('Cache-Control: public,max-age=86400');
readfile(__DIR__ . '/../../resources/branding/Logo_Globinours_maxi.png');
exit();
}
public static function pwaIcon(int $size): void
{
$size = in_array($size, [192, 512], true) ? $size : 192;
$source = __DIR__ . '/../../resources/branding/Logo_Globinours_maxi.png';
if (!extension_loaded('gd') || !is_file($source)) {
self::logo();
return;
}
$src = imagecreatefrompng($source);
if (!$src) {
self::logo();
return;
}
$dst = imagecreatetruecolor($size, $size);
imagesavealpha($dst, true);
$transparent = imagecolorallocatealpha($dst, 0, 0, 0, 127);
imagefill($dst, 0, 0, $transparent);
$sw = imagesx($src);
$sh = imagesy($src);
$ratio = min(($size * 0.88) / $sw, ($size * 0.88) / $sh);
$w = (int) round($sw * $ratio);
$h = (int) round($sh * $ratio);
imagecopyresampled($dst, $src, (int) (($size - $w) / 2), (int) (($size - $h) / 2), 0, 0, $w, $h, $sw, $sh);
header('Content-Type: image/png');
header('Cache-Control: public,max-age=604800,immutable');
imagepng($dst);
imagedestroy($src);
imagedestroy($dst);
exit();
}
public static function setup(): void
{
if (Auth::countUsers() > 0) {
header('Location: /login');
exit();
}
$error = null;
$checks = InstallationService::runtimeChecks();
$canInstall = InstallationService::canInstall($checks);
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
if (!Auth::validCsrf($_POST['csrf'] ?? null)) {
$error = t('auth.session_expired');
} elseif (!$canInstall) {
$error = t('install.requirements_block');
} else {
$username = trim((string) ($_POST['username'] ?? ''));
$display = trim((string) ($_POST['display_name'] ?? ''));
$password = (string) ($_POST['password'] ?? '');
$confirm = (string) ($_POST['password_confirm'] ?? '');
$association = trim((string) ($_POST['association_name'] ?? ''));
$email = trim((string) ($_POST['association_email'] ?? ''));
$language = (string) ($_POST['app_language'] ?? 'fr');
$routing = (string) ($_POST['public_site_routing'] ?? 'integrated');
if ($association === '') {
$error = t('install.association_required');
} elseif ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$error = t('settings.invalid_email');
} elseif (!preg_match('/^[a-zA-Z0-9._-]{3,40}$/', $username)) {
$error = t('auth.invalid_username');
} elseif ($display === '') {
$error = t('auth.display_required');
} elseif (strlen($password) < 10) {
$error = t('auth.password_min', ['count' => 10]);
} elseif ($password !== $confirm) {
$error = t('auth.password_mismatch');
} else {
$db = DB::pdo();
try {
$db->beginTransaction();
$db->prepare(
"INSERT INTO users(username,password_hash,display_name,role) VALUES(:u,:p,:d,'admin')",
)->execute([
':u' => $username,
':p' => password_hash($password, PASSWORD_DEFAULT),
':d' => $display,
]);
AppSettings::save(
[
'association_name' => $association,
'association_address' => trim((string) ($_POST['association_address'] ?? '')),
'association_postal_code' => trim((string) ($_POST['association_postal_code'] ?? '')),
'association_city' => trim((string) ($_POST['association_city'] ?? '')),
'association_phone' => trim((string) ($_POST['association_phone'] ?? '')),
'association_email' => $email,
'association_siret' => trim((string) ($_POST['association_siret'] ?? '')),
'association_rna' => trim((string) ($_POST['association_rna'] ?? '')),
'app_language' => isset(I18n::LOCALES[$language]) ? $language : 'fr',
'public_site_routing' => in_array($routing, ['integrated', 'root'], true)
? $routing
: 'integrated',
],
(int) $db->lastInsertId(),
);
$db->commit();
Auth::login($username, $password);
AuditService::log(
'setup',
'/setup',
'Installation initiale et création du premier administrateur',
'user',
Auth::id(),
);
header('Location: /settings/health?installed=1');
exit();
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
$error = SecurityService::publicError($e);
}
}
}
}
self::view(t('auth.first_setup'), 'setup', $error, [
'checks' => $checks,
'canInstall' => $canInstall,
'publicPath' => InstallationService::publicPath(),
]);
}
public static function setupUnavailable(array $checks): void
{
self::view(t('auth.first_setup'), 'setup', $error = t('install.runtime_missing'), [
'checks' => $checks,
'canInstall' => false,
'publicPath' => InstallationService::publicPath(),
]);
}
public static function login(): void
{
if (Auth::countUsers() === 0) {
header('Location: /setup');
exit();
}
if (Auth::user()) {
header('Location: ' . PermissionService::firstAllowedPath());
exit();
}
$error = null;
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
if (!Auth::validCsrf($_POST['csrf'] ?? null)) {
$error = t('auth.session_expired');
} elseif (
Auth::login(
(string) ($_POST['username'] ?? ''),
(string) ($_POST['password'] ?? ''),
isset($_POST['remember_device']),
(string) ($_POST['device_name'] ?? ''),
)
) {
AuditService::log('login', '/login', 'Connexion réussie');
$next = (string) ($_POST['next'] ?? '');
if (
!str_starts_with($next, '/') ||
str_starts_with($next, '//') ||
!PermissionService::canOpenPath((string) (parse_url($next, PHP_URL_PATH) ?: '/'))
) {
$next = PermissionService::firstAllowedPath();
}
header('Location: ' . $next);
exit();
} else {
AuditService::log('login_failed', '/login', 'Échec de connexion', null, null, [
'username_hash' => hash('sha256', mb_strtolower(trim((string) ($_POST['username'] ?? '')))),
]);
$error = t('auth.invalid_credentials');
}
}
self::view(t('auth.login'), 'login', $error);
}
public static function logout(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST' || !Auth::validCsrf($_POST['csrf'] ?? null)) {
http_response_code(400);
return;
}
AuditService::log('logout', '/logout', 'Déconnexion');
Auth::logout();
header('Location: /login');
exit();
}
private static function view(string $title, string $mode, ?string $error, array $vars = []): void
{
extract($vars, EXTR_SKIP);
require __DIR__ . '/../Views/auth.php';
}
}

View file

@ -0,0 +1,463 @@
<?php
declare(strict_types=1);
final class CareRoundsController
{
public static function index(): void
{
$db = DB::pdo();
$period = ($_GET['period'] ?? 'morning') === 'evening' ? 'evening' : 'morning';
$date = preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) ($_GET['date'] ?? ''))
? (string) $_GET['date']
: date('Y-m-d');
$stmt = $db->prepare('SELECT * FROM care_rounds WHERE round_date=:date AND period=:period');
$stmt->execute([':date' => $date, ':period' => $period]);
$round = $stmt->fetch();
if (!$round) {
$round = ['id' => 0, 'round_date' => $date, 'period' => $period, 'performed_by_user_id' => null];
}
$layouts = [];
foreach ($db->query('SELECT * FROM care_room_layouts')->fetchAll() as $l) {
$layouts[$l['room']] = $l;
}
$animals = $db
->query(
"
SELECT a.*, p.filename AS primary_photo
FROM animals a LEFT JOIN animal_photos p ON p.animal_id=a.id AND p.is_primary=1
WHERE a.deleted_at IS NULL AND a.archived_at IS NULL
AND lower(a.status) IN ('refuge','soin','quarantaine')
ORDER BY a.name COLLATE NOCASE
",
)
->fetchAll();
$observations = [];
$administrations = [];
if ((int) $round['id'] > 0) {
$stmt = $db->prepare('SELECT * FROM care_round_observations WHERE round_id=:id');
$stmt->execute([':id' => $round['id']]);
foreach ($stmt->fetchAll() as $o) {
$observations[(int) $o['animal_id']] = $o;
}
$stmt = $db->prepare('SELECT treatment_id,status FROM treatment_administrations WHERE round_id=:id');
$stmt->execute([':id' => $round['id']]);
foreach ($stmt->fetchAll() as $a) {
$administrations[(int) $a['treatment_id']] = $a['status'];
}
}
$treatments = [];
$stmt = $db->prepare("
SELECT t.*,m.name AS medication_name FROM treatments t JOIN ref_medications m ON m.id=t.medication_id
WHERE t.ongoing=1 AND ((:period='morning' AND t.give_morning=1) OR (:period='evening' AND t.give_evening=1))
ORDER BY m.name
");
$stmt->execute([':period' => $period]);
foreach ($stmt->fetchAll() as $t) {
$treatments[(int) $t['animal_id']][] = $t;
}
$unconfigured = [];
$rows = $db
->query(
'SELECT t.id,t.animal_id,t.dose_text,m.name medication_name FROM treatments t JOIN ref_medications m ON m.id=t.medication_id WHERE t.ongoing=1 AND t.give_morning=0 AND t.give_evening=0 AND t.give_as_needed=0 ORDER BY m.name',
)
->fetchAll();
foreach ($rows as $r) {
$unconfigured[(int) $r['animal_id']][] = $r;
}
render('care_round.php', [
'title' => t('care.title'),
'pageDescription' => t('care.description'),
'period' => $period,
'date' => $date,
'round' => $round,
'layouts' => $layouts,
'rooms' => ShelterRoomService::all(true),
'animals' => $animals,
'treatmentsByAnimal' => $treatments,
'unconfiguredTreatments' => $unconfigured,
'observations' => $observations,
'administrations' => $administrations,
]);
}
public static function saveObservation(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$db = DB::pdo();
$roundId = self::ensureRound($db);
$animalId = (int) ($_POST['animal_id'] ?? 0);
if (!$roundId || !$animalId) {
http_response_code(400);
return;
}
$animalCheck = $db->prepare(
"SELECT 1 FROM animals WHERE id=? AND deleted_at IS NULL AND archived_at IS NULL AND lower(status) IN ('refuge','soin','quarantaine')",
);
$animalCheck->execute([$animalId]);
if (!$animalCheck->fetchColumn()) {
http_response_code(404);
return;
}
$allowed = [
'food' => ['normal', 'little', 'refused'],
'water' => ['normal', 'renewed', 'abnormal'],
'urine' => ['normal', 'absent', 'abnormal'],
'stool' => ['normal', 'absent', 'abnormal'],
'general_state' => ['good', 'watch', 'alert'],
];
$values = [];
foreach ($allowed as $field => $opts) {
$value = (string) ($_POST[$field] ?? $opts[0]);
$values[$field] = in_array($value, $opts, true) ? $value : $opts[0];
}
$db->prepare(
'UPDATE care_rounds SET performed_by_user_id=COALESCE(performed_by_user_id,:user) WHERE id=:id',
)->execute([':user' => Auth::id(), ':id' => $roundId]);
$comment = trim((string) ($_POST['comment'] ?? ''));
$photoComment = trim((string) ($_POST['photo_comment'] ?? ''));
$photo = $_FILES['care_photo'] ?? null;
$hasPhoto = is_array($photo) && ($photo['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE;
if ($hasPhoto) {
if (
($photo['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK ||
!is_uploaded_file((string) $photo['tmp_name']) ||
($photo['size'] ?? 0) > 20 * 1024 * 1024 ||
$photoComment === ''
) {
http_response_code(400);
echo h(t('care.invalid_photo'));
return;
}
$imageInfo = @getimagesize((string) $photo['tmp_name']);
$mime = (string) ($imageInfo['mime'] ?? '');
$extensions = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif'];
if (!isset($extensions[$mime])) {
http_response_code(400);
echo h(t('care.invalid_photo_format'));
return;
}
}
$admins = is_array($_POST['treatments'] ?? null) ? $_POST['treatments'] : [];
$skippedIds = [];
foreach ($admins as $treatmentId => $status) {
if ($status === 'skipped') {
$skippedIds[] = (int) $treatmentId;
}
}
$alerts = [];
if ($values['food'] === 'little') {
$alerts[] = 'A peu mangé';
} elseif ($values['food'] === 'refused') {
$alerts[] = 'A refusé de manger';
}
if ($values['water'] === 'abnormal') {
$alerts[] = 'Eau anormale';
}
if ($values['urine'] === 'absent') {
$alerts[] = 'Urines absentes';
} elseif ($values['urine'] === 'abnormal') {
$alerts[] = 'Urines anormales';
}
if ($values['stool'] === 'absent') {
$alerts[] = 'Selles absentes';
} elseif ($values['stool'] === 'abnormal') {
$alerts[] = 'Selles anormales';
}
if ($values['general_state'] === 'watch') {
$alerts[] = 'État général à surveiller';
} elseif ($values['general_state'] === 'alert') {
$alerts[] = 'Alerte sur létat général';
}
if ($comment !== '') {
$alerts[] = 'Commentaire : ' . $comment;
}
foreach ($skippedIds as $treatmentId) {
$nameStmt = $db->prepare(
'SELECT m.name FROM treatments t LEFT JOIN ref_medications m ON m.id=t.medication_id WHERE t.id=:id AND t.animal_id=:animal',
);
$nameStmt->execute([':id' => $treatmentId, ':animal' => $animalId]);
$alerts[] = 'Traitement non donné : ' . ($nameStmt->fetchColumn() ?: 'traitement #' . $treatmentId);
}
$signature = $alerts
? hash('sha256', json_encode([$values, $comment, $skippedIds], JSON_UNESCAPED_UNICODE))
: null;
$previousStmt = $db->prepare(
'SELECT alert_signature FROM care_round_observations WHERE round_id=:round AND animal_id=:animal',
);
$previousStmt->execute([':round' => $roundId, ':animal' => $animalId]);
$previousSignature = $previousStmt->fetchColumn() ?: null;
$savedPhotoPath = null;
$db->beginTransaction();
try {
$stmt = $db->prepare(
"INSERT INTO care_round_observations(round_id,animal_id,food,water,urine,stool,general_state,comment,alert_signature) VALUES(:round,:animal,:food,:water,:urine,:stool,:general,:comment,:signature) ON CONFLICT(round_id,animal_id) DO UPDATE SET food=excluded.food,water=excluded.water,urine=excluded.urine,stool=excluded.stool,general_state=excluded.general_state,comment=excluded.comment,alert_signature=excluded.alert_signature,checked_at=datetime('now')",
);
$stmt->execute([
':round' => $roundId,
':animal' => $animalId,
':food' => $values['food'],
':water' => $values['water'],
':urine' => $values['urine'],
':stool' => $values['stool'],
':general' => $values['general_state'],
':comment' => $comment ?: null,
':signature' => $signature,
]);
$ins = $db->prepare(
"INSERT INTO treatment_administrations(round_id,treatment_id,animal_id,status,comment) VALUES(:round,:treatment,:animal,:status,:comment) ON CONFLICT(round_id,treatment_id) DO UPDATE SET status=excluded.status,comment=excluded.comment,administered_at=datetime('now')",
);
$treatmentCheck = $db->prepare('SELECT 1 FROM treatments WHERE id=? AND animal_id=? AND ongoing=1');
foreach ($admins as $treatmentId => $status) {
if (!in_array($status, ['given', 'skipped'], true)) {
continue;
}
$treatmentCheck->execute([(int) $treatmentId, $animalId]);
if (!$treatmentCheck->fetchColumn()) {
continue;
}
$ins->execute([
':round' => $roundId,
':treatment' => (int) $treatmentId,
':animal' => $animalId,
':status' => $status,
':comment' => null,
]);
}
if ($hasPhoto) {
$dir = PrivateMediaService::animalDir($animalId);
PrivateMediaService::ensure($dir);
$stored = ImageService::storeUploaded((string) $photo['tmp_name'], $dir, 'tournee', 'medical');
$filename = $stored['filename'];
$savedPhotoPath = $stored['path'];
$mime = $stored['mime'];
@chmod($savedPhotoPath, 0600);
$photoStmt = $db->prepare(
'INSERT INTO animal_photos(animal_id,filename,original_name,mime,size_bytes,is_primary,is_public,caption,care_round_id) VALUES(:animal,:filename,:original,:mime,:size,0,0,:caption,:round)',
);
$photoStmt->execute([
':animal' => $animalId,
':filename' => $filename,
':original' => (string) ($photo['name'] ?? 'photo'),
':mime' => $mime,
':size' => $stored['size'],
':caption' => $photoComment,
':round' => $roundId,
]);
$alerts[] = 'Photo privée ajoutée : ' . $photoComment;
}
if (($signature !== null && $signature !== $previousSignature) || $hasPhoto) {
$roundStmt = $db->prepare('SELECT round_date,period FROM care_rounds WHERE id=:id');
$roundStmt->execute([':id' => $roundId]);
$roundInfo = $roundStmt->fetch();
$roundDate = $roundInfo ? date('d/m/Y', strtotime((string) $roundInfo['round_date'])) : date('d/m/Y');
$roundPeriod = ($roundInfo['period'] ?? 'morning') === 'evening' ? 'soir' : 'matin';
$history = $db->prepare(
"INSERT INTO animal_history(animal_id,type,label,details,user_id) VALUES(:animal,'care_round',:label,:details,:user)",
);
$history->execute([
':animal' => $animalId,
':label' => 'Anomalie durant la tournée du ' . $roundPeriod . ' — ' . $roundDate,
':details' => implode("\n", $alerts),
':user' => Auth::id(),
]);
}
$db->prepare("UPDATE animals SET updated_at=datetime('now') WHERE id=:id")->execute([':id' => $animalId]);
$db->commit();
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
if ($savedPhotoPath && is_file($savedPhotoPath)) {
@unlink($savedPhotoPath);
}
throw $e;
}
$period = ($_POST['period'] ?? 'morning') === 'evening' ? 'evening' : 'morning';
$date = (string) ($_POST['date'] ?? date('Y-m-d'));
header('Location: /care-round?period=' . $period . '&date=' . rawurlencode($date) . '#animal-' . $animalId);
exit();
}
public static function saveRoom(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$db = DB::pdo();
$roundId = self::ensureRound($db);
$room = (string) ($_POST['room'] ?? '');
$rooms = ShelterRoomService::byCode(true);
if (!$roundId || !isset($rooms[$room]) || empty($rooms[$room]['bulk_validation'])) {
http_response_code(400);
return;
}
$animals = $db
->query(
"SELECT id,status,refuge_room FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND lower(status) IN ('refuge','soin','quarantaine')",
)
->fetchAll(PDO::FETCH_ASSOC);
$stmt = $db->prepare(
"INSERT OR IGNORE INTO care_round_observations(round_id,animal_id,food,water,urine,stool,general_state) VALUES(:round,:animal,'normal','normal','normal','normal','good')",
);
foreach ($animals as $animal) {
if (ShelterRoomService::resolveAnimal($animal, $rooms) === $room) {
$stmt->execute([':round' => $roundId, ':animal' => $animal['id']]);
}
}
$period = ($_POST['period'] ?? 'morning') === 'evening' ? 'evening' : 'morning';
$date = preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) ($_POST['date'] ?? ''))
? (string) $_POST['date']
: date('Y-m-d');
header('Location: /care-round?period=' . $period . '&date=' . rawurlencode($date) . '#room-' . $room);
exit();
}
public static function setup(): void
{
$db = DB::pdo();
$layouts = [];
foreach ($db->query('SELECT * FROM care_room_layouts')->fetchAll() as $l) {
$layouts[$l['room']] = $l;
}
$animals = $db
->query(
"SELECT id,name,status,refuge_room,care_box_key,current_address FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND lower(status) IN ('refuge','soin','quarantaine') ORDER BY name COLLATE NOCASE",
)
->fetchAll();
render('care_setup.php', [
'title' => t('care.setup_title'),
'layouts' => $layouts,
'rooms' => ShelterRoomService::all(true),
'animals' => $animals,
]);
}
public static function saveSetup(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$db = DB::pdo();
$topOpts = ['split', 'left2', 'right2', 'merged'];
$bottomOpts = ['split', 'merged'];
foreach (['care', 'quarantine'] as $room) {
$top = (string) ($_POST[$room . '_top'] ?? 'split');
$bottom = (string) ($_POST[$room . '_bottom'] ?? 'split');
if (!in_array($top, $topOpts, true)) {
$top = 'split';
}
if (!in_array($bottom, $bottomOpts, true)) {
$bottom = 'split';
}
$db->prepare(
"UPDATE care_room_layouts SET top_layout=:top,bottom_layout=:bottom,updated_at=datetime('now') WHERE room=:room",
)->execute([':top' => $top, ':bottom' => $bottom, ':room' => $room]);
}
$assignments = is_array($_POST['box'] ?? null) ? $_POST['box'] : [];
$roomAssignments = is_array($_POST['room'] ?? null) ? $_POST['room'] : [];
$rooms = ShelterRoomService::byCode(true);
$locationReason = trim((string) ($_POST['location_change_reason'] ?? ''));
$select = $db->prepare(
'SELECT status,refuge_room,care_box_key,current_address FROM animals WHERE id=:id AND deleted_at IS NULL AND archived_at IS NULL',
);
$stmt = $db->prepare(
"UPDATE animals SET status=:status,refuge_room=:room,care_box_key=:box,updated_at=datetime('now') WHERE id=:id AND deleted_at IS NULL AND archived_at IS NULL",
);
foreach ($roomAssignments as $id => $roomCode) {
$animalId = (int) $id;
$select->execute([':id' => $animalId]);
$before = $select->fetch(PDO::FETCH_ASSOC);
if (!$before) {
continue;
}
$roomCode = (string) $roomCode;
if (!isset($rooms[$roomCode])) {
continue;
}
$room = $rooms[$roomCode];
$allowedBoxes = array_column(
array_filter($room['boxes'], static fn($box) => (int) $box['active'] === 1),
'code',
);
if (in_array($roomCode, ['care', 'quarantine'], true)) {
$allowedBoxes = array_values(
array_unique(array_merge($allowedBoxes, ['top_ab', 'top_bc', 'top_all', 'bottom_all'])),
);
}
$newBox = trim((string) ($assignments[$id] ?? ''));
if ($room['room_type'] !== 'boxes' || !in_array($newBox, $allowedBoxes, true)) {
$newBox = null;
}
$status = (string) $room['status_code'];
$stmt->execute([':status' => $status, ':room' => $roomCode, ':box' => $newBox, ':id' => $animalId]);
LocationHistoryService::record(
$db,
$animalId,
$before,
array_merge($before, ['status' => $status, 'refuge_room' => $roomCode, 'care_box_key' => $newBox]),
'care_setup',
$locationReason ?: 'Attribution de la salle ou du box depuis la tournée',
);
}
header('Location: /care-round');
exit();
}
public static function saveTreatmentSchedule(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$id = (int) ($_POST['treatment_id'] ?? 0);
$schedule = (string) ($_POST['schedule'] ?? '');
$values = match ($schedule) {
'morning' => [1, 0, 0],
'evening' => [0, 1, 0],
'both' => [1, 1, 0],
'needed' => [0, 0, 1],
default => null,
};
if (!$id || !$values) {
http_response_code(400);
return;
}
DB::pdo()
->prepare('UPDATE treatments SET give_morning=?,give_evening=?,give_as_needed=? WHERE id=?')
->execute([$values[0], $values[1], $values[2], $id]);
$period = ($_POST['period'] ?? 'morning') === 'evening' ? 'evening' : 'morning';
header('Location: /care-round?period=' . $period);
exit();
}
private static function ensureRound(PDO $db): int
{
$date = preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) ($_POST['date'] ?? ''))
? (string) $_POST['date']
: date('Y-m-d');
$period = ($_POST['period'] ?? 'morning') === 'evening' ? 'evening' : 'morning';
$requested = (int) ($_POST['round_id'] ?? 0);
if ($requested) {
$s = $db->prepare('SELECT id FROM care_rounds WHERE id=? AND round_date=? AND period=?');
$s->execute([$requested, $date, $period]);
if ($s->fetchColumn()) {
return $requested;
}
}
$db->prepare(
'INSERT OR IGNORE INTO care_rounds(round_date,period,performed_by_user_id) VALUES(?,?,?)',
)->execute([$date, $period, Auth::id()]);
$s = $db->prepare('SELECT id FROM care_rounds WHERE round_date=? AND period=?');
$s->execute([$date, $period]);
return (int) $s->fetchColumn();
}
}

View file

@ -0,0 +1,300 @@
<?php
declare(strict_types=1);
final class DashboardController
{
public static function index(): void
{
$db = DB::pdo();
// Complète les anciennes quarantaines dont la date dentrée était absente.
$db->exec("
UPDATE animals
SET intake_date = date(updated_at)
WHERE lower(trim(status))='quarantaine'
AND deleted_at IS NULL AND archived_at IS NULL
AND (intake_date IS NULL OR trim(intake_date)='')
AND updated_at IS NOT NULL AND trim(updated_at)<>''
");
$quarantineDays = AppSettings::int('quarantine_days', 1, 90);
$stmt = $db->prepare("
UPDATE animals
SET quarantine_until = date(intake_date, '+' || :days || ' day')
WHERE lower(trim(status))='quarantaine'
AND deleted_at IS NULL AND archived_at IS NULL
AND (quarantine_until IS NULL OR trim(quarantine_until)='')
AND intake_date IS NOT NULL AND trim(intake_date)<>''
");
$stmt->execute([':days' => $quarantineDays]);
// Stats par statut (avec tolérance accents)
$stats = $db
->query(
"
SELECT
SUM(CASE WHEN lower(status) NOT IN ('adopte','adopté','decede','décédé') THEN 1 ELSE 0 END) AS total,
SUM(CASE WHEN lower(status) NOT IN ('adopte','adopté','decede','décédé') AND birth_date IS NOT NULL AND trim(birth_date) != '' AND date(birth_date) > date('now','-12 months') THEN 1 ELSE 0 END) AS kittens,
SUM(CASE WHEN lower(status) NOT IN ('adopte','adopté','decede','décédé') AND birth_date IS NOT NULL AND trim(birth_date) != '' AND date(birth_date) <= date('now','-12 months') THEN 1 ELSE 0 END) AS adults,
SUM(CASE WHEN lower(status) NOT IN ('adopte','adopté','decede','décédé') AND (birth_date IS NULL OR trim(birth_date) = '') THEN 1 ELSE 0 END) AS age_unknown,
SUM(CASE WHEN status='refuge' THEN 1 ELSE 0 END) AS refuge,
SUM(CASE WHEN status='refuge' AND refuge_room='silver' THEN 1 ELSE 0 END) AS refuge_silver,
SUM(CASE WHEN status='refuge' AND refuge_room='twix' THEN 1 ELSE 0 END) AS refuge_twix,
SUM(CASE WHEN status='refuge' AND (refuge_room IS NULL OR trim(refuge_room)='') THEN 1 ELSE 0 END) AS refuge_unassigned,
SUM(CASE WHEN status='fa' THEN 1 ELSE 0 END) AS fa,
SUM(CASE WHEN status='fa_permanente' THEN 1 ELSE 0 END) AS fa_permanente,
SUM(CASE WHEN status='quarantaine' THEN 1 ELSE 0 END) AS quarantaine,
SUM(CASE WHEN status='soin' THEN 1 ELSE 0 END) AS soin,
SUM(CASE WHEN status='isolation' THEN 1 ELSE 0 END) AS isolation,
SUM(CASE WHEN status='hospitalise' OR status='hospitalisé' THEN 1 ELSE 0 END) AS hospitalise,
SUM(CASE WHEN status='reserve' OR status='réservé' THEN 1 ELSE 0 END) AS reserve,
SUM(CASE WHEN status='adopte' OR status='adopté' THEN 1 ELSE 0 END) AS adopte,
SUM(CASE WHEN status='decede' OR status='décédé' THEN 1 ELSE 0 END) AS dece,
SUM(CASE WHEN status='enfui' THEN 1 ELSE 0 END) AS enfui
FROM animals
WHERE deleted_at IS NULL AND archived_at IS NULL
",
)
->fetch();
$shelterRoomCounts = [];
$configuredRooms = ShelterRoomService::byCode(true);
$roomAnimals = $db
->query(
"SELECT status,refuge_room FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND status IN ('refuge','soin','quarantaine')",
)
->fetchAll(PDO::FETCH_ASSOC);
foreach ($configuredRooms as $code => $room) {
$shelterRoomCounts[$code] = [
'name' => $room['name'],
'color' => $room['color'],
'status' => $room['status_code'],
'count' => 0,
];
}
foreach ($roomAnimals as $roomAnimal) {
$code = ShelterRoomService::resolveAnimal($roomAnimal, $configuredRooms);
if (isset($shelterRoomCounts[$code])) {
$shelterRoomCounts[$code]['count']++;
}
}
$quality =
$db
->query(
"SELECT COUNT(*) total,
SUM(CASE WHEN birth_date IS NULL OR trim(birth_date)='' THEN 1 ELSE 0 END) missing_birth_date,
SUM(CASE WHEN sex IS NULL OR trim(sex)='' OR sex='U' THEN 1 ELSE 0 END) missing_sex,
SUM(CASE WHEN color IS NULL OR trim(color)='' THEN 1 ELSE 0 END) missing_color,
SUM(CASE WHEN breed IS NULL OR trim(breed)='' THEN 1 ELSE 0 END) missing_breed,
SUM(CASE WHEN (rescue_location_name IS NULL OR trim(rescue_location_name)='') AND (rescue_address IS NULL OR trim(rescue_address)='') THEN 1 ELSE 0 END) missing_origin,
SUM(CASE WHEN (chip_id IS NULL OR trim(chip_id)='') AND (chip IS NULL OR trim(chip)='') THEN 1 ELSE 0 END) missing_chip,
SUM(CASE WHEN COALESCE(NULLIF(identification_registration_status,''),'unknown')='unknown' THEN 1 ELSE 0 END) unknown_identification_registration,
SUM(CASE WHEN COALESCE(NULLIF(adoption_availability,''),'unknown')='unknown' THEN 1 ELSE 0 END) unknown_adoption_availability,
SUM(CASE WHEN sterilization_status='no' THEN 1 ELSE 0 END) unsterilized,
SUM(CASE WHEN COALESCE(NULLIF(sterilization_status,''),'unknown')='unknown' THEN 1 ELSE 0 END) unknown_sterilization,
SUM(CASE WHEN birth_date IS NULL OR trim(birth_date)='' OR sex IS NULL OR trim(sex)='' OR sex='U' OR color IS NULL OR trim(color)='' OR breed IS NULL OR trim(breed)='' OR ((rescue_location_name IS NULL OR trim(rescue_location_name)='') AND (rescue_address IS NULL OR trim(rescue_address)='')) OR ((chip_id IS NULL OR trim(chip_id)='') AND (chip IS NULL OR trim(chip)='')) OR COALESCE(NULLIF(identification_registration_status,''),'unknown')='unknown' OR COALESCE(NULLIF(adoption_availability,''),'unknown')='unknown' OR COALESCE(NULLIF(sterilization_status,''),'unknown')='unknown' THEN 1 ELSE 0 END) incomplete
FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND lower(status) NOT IN ('adopte','adopté','decede','décédé','enfui','fugue')",
)
->fetch(PDO::FETCH_ASSOC) ?:
[];
// Activité
$activity = $db
->query(
"
SELECT
(SELECT COUNT(*) FROM medical_notes mn JOIN animals a ON a.id=mn.animal_id WHERE a.deleted_at IS NULL AND a.archived_at IS NULL AND date(mn.noted_at) >= date('now','-7 day')) AS notes_7d,
(SELECT COUNT(*) FROM vaccinations v JOIN animals a ON a.id=v.animal_id WHERE a.deleted_at IS NULL AND a.archived_at IS NULL AND v.done_date IS NOT NULL AND date(v.done_date) >= date('now','-30 day')) AS vaccines_30d,
(SELECT COUNT(*) FROM treatments t JOIN animals a ON a.id=t.animal_id WHERE a.deleted_at IS NULL AND a.archived_at IS NULL AND t.start_date IS NOT NULL AND date(t.start_date) >= date('now','-30 day')) AS treatments_30d
",
)
->fetch();
// Vaccins en retard
$stmt = $db->prepare("
SELECT a.id, a.name, a.internal_code AS code, rv.name AS vaccine_name, v.due_date
FROM vaccinations v
JOIN animals a ON a.id = v.animal_id
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é')
AND date(v.due_date) < date('now')
ORDER BY date(v.due_date) ASC
LIMIT 50
");
$stmt->execute();
$vaccines_overdue = $stmt->fetchAll();
// Vaccins ≤ 7 jours
$stmt = $db->prepare("
SELECT a.id, a.name, a.internal_code AS code, rv.name AS vaccine_name, v.due_date
FROM vaccinations v
JOIN animals a ON a.id = v.animal_id
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é')
AND date(v.due_date) >= date('now')
AND date(v.due_date) <= date('now', '+7 day')
ORDER BY date(v.due_date) ASC
LIMIT 50
");
$stmt->execute();
$vaccines_7d = $stmt->fetchAll();
// Vaccins ≤ 30 jours
$stmt = $db->prepare("
SELECT a.id, a.name, a.internal_code AS code, rv.name AS vaccine_name, v.due_date
FROM vaccinations v
JOIN animals a ON a.id = v.animal_id
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é')
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
LIMIT 50
");
$stmt->execute();
$vaccines_30d = $stmt->fetchAll();
// Traitements en cours
$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
FROM treatments t
JOIN animals a ON a.id = t.animal_id
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
ORDER BY date(t.start_date) DESC, a.name ASC
LIMIT 50
');
$stmt->execute();
$treatments = $stmt->fetchAll();
// Traitements à clôturer (fin dépassée mais ongoing=1)
$stmt = $db->prepare("
SELECT a.id, a.name, a.internal_code AS code, m.name AS medication_name, t.end_date
FROM treatments t
JOIN animals a ON a.id = t.animal_id
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 t.end_date IS NOT NULL
AND date(t.end_date) < date('now')
ORDER BY date(t.end_date) ASC
LIMIT 50
");
$stmt->execute();
$treatments_overdue = $stmt->fetchAll();
// Dernières notes médicales
$stmt = $db->prepare('
SELECT mn.noted_at, mn.kind, mn.reason,
a.id AS animal_id, a.name, a.internal_code AS code
FROM medical_notes mn
JOIN animals a ON a.id = mn.animal_id
WHERE a.deleted_at IS NULL AND a.archived_at IS NULL
ORDER BY datetime(mn.noted_at) DESC, mn.id DESC
LIMIT 30
');
$stmt->execute();
$recent_notes = $stmt->fetchAll();
// Quarantaine : liste + J-... (si dates renseignées)
$stmt = $db->prepare("
SELECT
id, name, internal_code AS code,
intake_date,
quarantine_until,
CASE
WHEN quarantine_until IS NULL OR quarantine_until = '' THEN NULL
ELSE CAST(julianday(date(quarantine_until)) - julianday(date('now','localtime')) AS INT)
END AS days_left
FROM animals
WHERE lower(trim(status))='quarantaine'
AND deleted_at IS NULL AND archived_at IS NULL
ORDER BY
CASE
WHEN quarantine_until IS NULL OR quarantine_until = '' THEN 9999
ELSE days_left
END ASC,
name ASC
LIMIT 100
");
$stmt->execute();
$quarantine = $stmt->fetchAll();
$stmt = $db->prepare('
SELECT
a.id, a.name, a.internal_code AS code,
wa.last_weight, wa.w30_weight, wa.w30_date,
ROUND(((wa.last_weight - wa.w30_weight) / wa.w30_weight) * 100, 1) AS pct
FROM v_animals_weight_alerts wa
JOIN animals a ON a.id = wa.animal_id
WHERE wa.last_weight IS NOT NULL
AND a.deleted_at IS NULL AND a.archived_at IS NULL
AND wa.w30_weight IS NOT NULL
AND wa.w30_weight > 0
AND ABS((wa.last_weight - wa.w30_weight) / wa.w30_weight) >= 0.15
ORDER BY ABS((wa.last_weight - wa.w30_weight) / wa.w30_weight) DESC
LIMIT 50
');
$stmt->execute();
$weight_alerts = $stmt->fetchAll();
$careToday = [];
$rows = $db
->query(
"SELECT periods.period,
(SELECT COUNT(*) FROM animals a WHERE a.deleted_at IS NULL AND a.archived_at IS NULL AND lower(a.status) IN ('refuge','soin','quarantaine')) expected,
COUNT(o.id) checked,
SUM(CASE WHEN o.alert_signature IS NOT NULL THEN 1 ELSE 0 END) alerts
FROM (SELECT 'morning' period UNION ALL SELECT 'evening') periods
LEFT JOIN care_rounds r ON r.round_date=date('now','localtime') AND r.period=periods.period
LEFT JOIN care_round_observations o ON o.round_id=r.id
GROUP BY periods.period ORDER BY periods.period DESC",
)
->fetchAll();
foreach ($rows as $row) {
$careToday[$row['period']] = $row;
}
$stmt = $db->query(
"SELECT a.id,a.name,r.period,o.comment FROM care_round_observations o JOIN care_rounds r ON r.id=o.round_id JOIN animals a ON a.id=o.animal_id WHERE r.round_date=date('now','localtime') AND o.alert_signature IS NOT NULL AND a.deleted_at IS NULL ORDER BY o.checked_at DESC LIMIT 10",
);
$careAlertsToday = $stmt->fetchAll();
$grantAlerts = PermissionService::can('grants') ? GrantService::alerts((int) date('Y')) : [];
$agendaDue = PermissionService::can('agenda') ? AgendaService::due() : [];
render('dashboard.php', [
'title' => 'Dashboard',
'stats' => $stats,
'shelterRoomCounts' => $shelterRoomCounts,
'quality' => $quality,
'activity' => $activity,
'vaccines_overdue' => $vaccines_overdue,
'vaccines_7d' => $vaccines_7d,
'vaccines_30d' => $vaccines_30d,
'treatments' => $treatments,
'treatments_overdue' => $treatments_overdue,
'recent_notes' => $recent_notes,
'quarantine' => $quarantine,
'weight_alerts' => $weight_alerts,
'careToday' => $careToday,
'careAlertsToday' => $careAlertsToday,
'grantAlerts' => $grantAlerts,
'agendaDue' => $agendaDue,
]);
}
}

View file

@ -0,0 +1,305 @@
<?php
declare(strict_types=1);
final class DeathController
{
public const CAUSES = [
'disease' => 'Maladie',
'accident' => 'Accident',
'old_age' => 'Vieillesse',
'congenital' => 'Malformation ou affection congénitale',
'surgery_complication' => 'Complication médicale ou chirurgicale',
'poisoning' => 'Empoisonnement',
'unknown' => 'Cause inconnue',
'other' => 'Autre cause',
];
public const PLACES = [
'refuge' => 'Au refuge',
'fa' => 'En famille daccueil',
'veterinaire' => 'Chez le vétérinaire',
'exterieur' => 'À lextérieur',
'inconnu' => 'Lieu inconnu',
'autre' => 'Autre lieu',
];
public const DISPOSITIONS = [
'pending' => 'À organiser',
'collective_cremation' => 'Crémation collective',
'individual_cremation' => 'Crémation individuelle',
'returned_family' => 'Corps ou cendres remis à la famille',
'burial' => 'Inhumation',
'other' => 'Autre prise en charge',
];
public static function causeLabel(string $key): string
{
return t('death.cause.' . $key, [], self::CAUSES[$key] ?? $key);
}
public static function placeLabel(string $key): string
{
return t('death.place.' . $key, [], self::PLACES[$key] ?? $key);
}
public static function dispositionLabel(string $key): string
{
return t('death.disposition.' . $key, [], self::DISPOSITIONS[$key] ?? $key);
}
public static function form(): void
{
$db = DB::pdo();
$id = (int) ($_GET['id'] ?? 0);
$animal = self::animal($db, $id);
$stmt = $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',
);
$stmt->execute([':id' => $id]);
$death = $stmt->fetch(PDO::FETCH_ASSOC) ?: [
'animal_id' => $id,
'deceased_date' => date('Y-m-d'),
'cause_code' => 'disease',
'cause_details' => '',
'occurred_in_care' => !in_array($animal['status'], ['adopte', 'adopté'], true) ? 1 : 0,
'place_type' => 'refuge',
'place_details' => '',
'euthanized' => 0,
'veterinarian_contact_id' => '',
'crematorium_contact_id' => '',
'body_disposition' => 'pending',
'cremation_date' => '',
'notes' => '',
];
$veterinarians = self::contacts($db, 'veterinaire');
$crematoriums = self::contacts($db, 'crematorium');
render('animal_death_form.php', [
'title' => t('death.title', ['name' => $animal['name']]),
'animal' => $animal,
'death' => $death,
'causes' => self::CAUSES,
'places' => self::PLACES,
'dispositions' => self::DISPOSITIONS,
'veterinarians' => $veterinarians,
'crematoriums' => $crematoriums,
]);
}
public static function save(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$db = DB::pdo();
$id = (int) ($_POST['animal_id'] ?? 0);
$animal = self::animal($db, $id);
$date = trim((string) ($_POST['deceased_date'] ?? ''));
$cause = (string) ($_POST['cause_code'] ?? '');
$place = (string) ($_POST['place_type'] ?? '');
$disposition = (string) ($_POST['body_disposition'] ?? 'pending');
if (
!self::validDate($date) ||
$date > date('Y-m-d') ||
!isset(self::CAUSES[$cause]) ||
!isset(self::PLACES[$place]) ||
!isset(self::DISPOSITIONS[$disposition])
) {
http_response_code(400);
echo h(t('death.invalid'));
return;
}
if ($cause === 'other' && trim((string) ($_POST['cause_details'] ?? '')) === '') {
http_response_code(400);
echo h(t('death.cause_required'));
return;
}
$vet = self::validContact($db, (int) ($_POST['veterinarian_contact_id'] ?? 0), 'veterinaire');
$crem = self::validContact($db, (int) ($_POST['crematorium_contact_id'] ?? 0), 'crematorium');
$cremationDate = trim((string) ($_POST['cremation_date'] ?? ''));
if ($cremationDate !== '' && (!self::validDate($cremationDate) || $cremationDate < $date)) {
http_response_code(400);
echo h(t('death.cremation_after'));
return;
}
$check = $db->prepare('SELECT 1 FROM animal_deaths WHERE animal_id=:id');
$check->execute([':id' => $id]);
$isUpdate = (bool) $check->fetchColumn();
$beforeLocation = LocationHistoryService::snapshot($animal);
$params = [
':animal' => $id,
':date' => $date,
':cause' => $cause,
':details' => trim((string) ($_POST['cause_details'] ?? '')) ?: null,
':care' => isset($_POST['occurred_in_care']) ? 1 : 0,
':place' => $place,
':place_details' => trim((string) ($_POST['place_details'] ?? '')) ?: null,
':euthanized' => isset($_POST['euthanized']) ? 1 : 0,
':vet' => $vet,
':crem' => $crem,
':disposition' => $disposition,
':cremation_date' => $cremationDate ?: null,
':notes' => trim((string) ($_POST['notes'] ?? '')) ?: null,
':user' => Auth::id(),
];
$db->beginTransaction();
try {
$db->prepare(
"INSERT INTO animal_deaths(animal_id,deceased_date,cause_code,cause_details,occurred_in_care,place_type,place_details,euthanized,veterinarian_contact_id,crematorium_contact_id,body_disposition,cremation_date,notes,created_by,updated_by) VALUES(:animal,:date,:cause,:details,:care,:place,:place_details,:euthanized,:vet,:crem,:disposition,:cremation_date,:notes,:user,:user) ON CONFLICT(animal_id) DO UPDATE SET deceased_date=excluded.deceased_date,cause_code=excluded.cause_code,cause_details=excluded.cause_details,occurred_in_care=excluded.occurred_in_care,place_type=excluded.place_type,place_details=excluded.place_details,euthanized=excluded.euthanized,veterinarian_contact_id=excluded.veterinarian_contact_id,crematorium_contact_id=excluded.crematorium_contact_id,body_disposition=excluded.body_disposition,cremation_date=excluded.cremation_date,notes=excluded.notes,updated_by=excluded.updated_by,updated_at=datetime('now')",
)->execute($params);
$db->prepare(
"UPDATE animals SET status='decede',refuge_room=NULL,care_box_key=NULL,updated_at=datetime('now') WHERE id=:id",
)->execute([':id' => $id]);
LocationHistoryService::record(
$db,
$id,
$beforeLocation,
[
'status' => 'decede',
'refuge_room' => null,
'care_box_key' => null,
'current_address' => $animal['current_address'] ?? null,
],
'death',
'Décès enregistré',
$date . ' 12:00:00',
);
$db->prepare(
'UPDATE treatments SET ongoing=0,end_date=COALESCE(end_date,:date) WHERE animal_id=:id AND ongoing=1',
)->execute([':date' => $date, ':id' => $id]);
$details =
'Date : ' .
self::frDate($date) .
'\nCause : ' .
self::CAUSES[$cause] .
'\nContexte : ' .
($params[':care'] ? 'Sous la responsabilité du refuge' : 'Après la prise en charge du refuge');
if ($params[':euthanized']) {
$details .= '\nEuthanasie : oui';
}
$db->prepare(
"INSERT INTO animal_history(animal_id,type,label,details,user_id) VALUES(:id,'death',:label,:details,:user)",
)->execute([
':id' => $id,
':label' => $isUpdate ? 'Informations de décès modifiées' : 'Décès enregistré',
':details' => $details,
':user' => Auth::id(),
]);
self::syncMovement(
$db,
$id,
$date,
$cause,
$params[':details'],
$place,
$params[':place_details'],
$params[':euthanized'] === 1,
$vet,
$crem,
$disposition,
);
$db->commit();
header('Location: /animal?id=' . $id);
exit();
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
}
private static function animal(PDO $db, int $id): array
{
$s = $db->prepare('SELECT * FROM animals WHERE id=:id AND deleted_at IS NULL');
$s->execute([':id' => $id]);
$a = $s->fetch(PDO::FETCH_ASSOC);
if (!$a) {
http_response_code(404);
echo h(t('error.animal_not_found'));
exit();
}
return $a;
}
private static function contacts(PDO $db, string $role): array
{
$s = $db->prepare(
'SELECT dc.id,dc.name,dc.city FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id WHERE r.role=:role AND dc.deleted_at IS NULL ORDER BY dc.name COLLATE NOCASE',
);
$s->execute([':role' => $role]);
return $s->fetchAll(PDO::FETCH_ASSOC);
}
private static function validContact(PDO $db, int $id, string $role): ?int
{
if (!$id) {
return null;
}
$s = $db->prepare(
'SELECT 1 FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id WHERE dc.id=:id AND r.role=:role AND dc.deleted_at IS NULL',
);
$s->execute([':id' => $id, ':role' => $role]);
return $s->fetchColumn() ? $id : null;
}
private static function syncMovement(
PDO $db,
int $animalId,
string $date,
string $cause,
?string $causeDetails,
string $place,
?string $placeDetails,
bool $euthanized,
?int $vetId,
?int $cremId,
string $disposition,
): void {
$names = [];
if ($vetId || $cremId) {
$s = $db->prepare('SELECT id,name FROM directory_contacts WHERE id IN (:vet,:crem)');
$s->execute([':vet' => $vetId ?? 0, ':crem' => $cremId ?? 0]);
foreach ($s->fetchAll(PDO::FETCH_ASSOC) as $row) {
$names[(int) $row['id']] = $row['name'];
}
}
$noteParts = ['Cause : ' . self::CAUSES[$cause] . ($causeDetails ? ' — ' . $causeDetails : '')];
if ($euthanized) {
$noteParts[] = 'Euthanasie : oui';
}
if ($cremId && isset($names[$cremId])) {
$noteParts[] = 'Crématorium : ' . $names[$cremId];
}
$noteParts[] = 'Prise en charge : ' . self::DISPOSITIONS[$disposition];
$s = $db->prepare(
"SELECT id FROM animal_movements WHERE animal_id=:animal AND kind='exit' AND lower(lieu) IN ('décès','deces','décès de l''animal','deces de l''animal') ORDER BY id DESC LIMIT 1",
);
$s->execute([':animal' => $animalId]);
$movementId = (int) $s->fetchColumn();
$values = [
':animal' => $animalId,
':place' => self::PLACES[$place] . ($placeDetails ? ' — ' . $placeDetails : ''),
':contact' => $vetId && isset($names[$vetId]) ? $names[$vetId] : null,
':note' => implode("\n", $noteParts),
':at' => $date . ' 12:00:00',
];
if ($movementId) {
$values[':movement'] = $movementId;
$db->prepare(
"UPDATE animal_movements SET place=:place,lieu='Décès de l''animal',contact_name=:contact,note=:note,created_at=:at WHERE id=:movement AND animal_id=:animal",
)->execute($values);
} else {
$db->prepare(
"INSERT INTO animal_movements(animal_id,kind,place,lieu,contact_name,note,created_at) VALUES(:animal,'exit',:place,'Décès de l''animal',:contact,:note,:at)",
)->execute($values);
}
}
private static function validDate(string $date): bool
{
$d = DateTimeImmutable::createFromFormat('!Y-m-d', $date);
$errors = DateTimeImmutable::getLastErrors();
return $d !== false &&
($errors === false || ((int) $errors['warning_count'] === 0 && (int) $errors['error_count'] === 0)) &&
$d->format('Y-m-d') === $date;
}
private static function frDate(string $date): string
{
$d = DateTimeImmutable::createFromFormat('Y-m-d', $date);
return $d ? $d->format('d/m/Y') : $date;
}
}

View file

@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
final class DevController
{
public static function backfillWeights(): void
{
header('Content-Type: text/plain; charset=utf-8');
// sécurité : local only
$addr = $_SERVER['REMOTE_ADDR'] ?? '';
if ($addr !== '127.0.0.1' && $addr !== '::1') {
http_response_code(403);
echo "Forbidden\n";
return;
}
$db = DB::pdo();
$db->beginTransaction();
try {
// Copie chaque note médicale ayant un poids -> measurements (sans dupliquer le même jour)
$sql = "
INSERT INTO measurements(animal_id, measured_at, type, value, unit, notes)
SELECT mn.animal_id, mn.noted_at, 'weight', mn.weight_kg, 'kg', 'backfill from medical_notes'
FROM medical_notes mn
WHERE mn.weight_kg IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM measurements m
WHERE m.animal_id = mn.animal_id
AND m.type = 'weight'
AND date(m.measured_at) = date(mn.noted_at)
)
";
$count = $db->exec($sql);
$db->commit();
echo 'Backfill OK. Inserted rows: ' . (int) $count . "\n";
echo "Retourne sur /animal?id=...\n";
} catch (Throwable $e) {
$db->rollBack();
http_response_code(500);
echo 'Backfill FAILED: ' . $e->getMessage() . "\n";
}
}
public static function backup(): void
{
header('Content-Type: text/plain; charset=utf-8');
$addr = $_SERVER['REMOTE_ADDR'] ?? '';
if ($addr !== '127.0.0.1' && $addr !== '::1') {
http_response_code(403);
echo "Forbidden\n";
return;
}
$src = __DIR__ . '/../../data/refuge.sqlite';
$dir = __DIR__ . '/../../storage/backups';
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
$dst = $dir . '/refuge-' . date('Ymd-His') . '.sqlite';
// Copie cohérente SQLite
$pdo = DB::pdo();
$pdo->exec('VACUUM INTO ' . $pdo->quote($dst));
echo 'Backup OK: ' . basename($dst) . "\n";
}
public static function backfillMovements(): void
{
$pdo = DB::pdo();
$animals = $pdo->query('SELECT * FROM animals')->fetchAll(PDO::FETCH_ASSOC);
$pdo->beginTransaction();
try {
foreach ($animals as $a) {
// Vérifie si déjà migré
$check = $pdo->prepare('SELECT COUNT(*) FROM animal_movements WHERE animal_id = ?');
$check->execute([$a['id']]);
if ($check->fetchColumn() > 0) {
continue;
}
// ENTRY
$stmt = $pdo->prepare("
INSERT INTO animal_movements
(animal_id, kind, moved_at, provenance, place, note)
VALUES (?, 'entry', ?, ?, ?, ?)
");
$stmt->execute([
$a['id'],
$a['created_at'] ?? date('Y-m-d H:i:s'),
'Migration ancienne donnée',
$a['rescue_address'] ?? '',
'Migration automatique',
]);
// EXIT (si statut final)
if (in_array($a['status'], ['adopted', 'deceased', 'transferred', 'fugue'])) {
$stmt = $pdo->prepare("
INSERT INTO animal_movements
(animal_id, kind, moved_at, exit_type, place, note)
VALUES (?, 'exit', ?, ?, ?, ?)
");
$stmt->execute([
$a['id'],
$a['updated_at'] ?? date('Y-m-d H:i:s'),
$a['status'],
$a['current_address'] ?? '',
'Migration automatique',
]);
}
}
$pdo->commit();
echo 'Backfill OK.';
} catch (Throwable $e) {
$pdo->rollBack();
echo 'Erreur: ' . $e->getMessage();
}
}
}

View file

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
final class DevicesController
{
public static function index(): void
{
render('account_devices.php', [
'title' => t('devices.title'),
'devices' => TrustedDeviceService::devices(),
'currentSelector' => TrustedDeviceService::currentSelector(),
'saved' => isset($_GET['saved']),
]);
}
public static function revoke(): void
{
self::postOnly();
$id = (int) ($_POST['id'] ?? 0);
TrustedDeviceService::revoke($id, (int) Auth::id());
AuditService::log(
'trusted_device_revoked',
'/account/devices/revoke',
'Appareil de confiance révoqué',
'trusted_device',
$id,
);
header('Location: /account/devices?saved=1');
exit();
}
public static function revokeAll(): void
{
self::postOnly();
DB::pdo()
->prepare("UPDATE trusted_devices SET revoked_at=datetime('now') WHERE user_id=? AND revoked_at IS NULL")
->execute([Auth::id()]);
AuditService::log(
'trusted_devices_revoked',
'/account/devices/revoke-all',
'Tous les appareils de confiance ont été révoqués',
);
Auth::logout();
header('Location: /login');
exit();
}
private static function postOnly(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
header('Allow: POST');
exit();
}
if (!Auth::validCsrf($_POST['csrf'] ?? null)) {
http_response_code(419);
echo h(t('auth.session_expired'));
exit();
}
}
}

View file

@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
final class DewormingController
{
public static function save(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
if (!PermissionService::can('medical')) {
http_response_code(403);
echo h(t('error.access_denied'));
return;
}
$db = DB::pdo();
$animalId = (int) ($_POST['animal_id'] ?? 0);
$date = trim((string) ($_POST['administered_on'] ?? ''));
$dewormer = (int) ($_POST['dewormer_id'] ?? 0);
if (!$animalId || !self::date($date)) {
http_response_code(400);
echo h(t('deworm.invalid_date'));
return;
}
$s = $db->prepare('SELECT 1 FROM animals WHERE id=? AND deleted_at IS NULL');
$s->execute([$animalId]);
if (!$s->fetchColumn()) {
http_response_code(404);
echo h(t('error.animal_not_found'));
return;
}
if (isset($_POST['create_dewormer'])) {
$name = trim((string) ($_POST['new_dewormer_name'] ?? ''));
if ($name === '') {
http_response_code(400);
echo h(t('deworm.name_required'));
return;
}
$s = $db->prepare('SELECT id FROM ref_dewormers WHERE name=? COLLATE NOCASE');
$s->execute([$name]);
$dewormer = (int) $s->fetchColumn();
if (!$dewormer) {
$db->prepare('INSERT INTO ref_dewormers(name,active_ingredient,form) VALUES(?,?,?)')->execute([
mb_substr($name, 0, 150),
trim((string) ($_POST['new_dewormer_ingredient'] ?? '')) ?: null,
trim((string) ($_POST['new_dewormer_form'] ?? '')) ?: null,
]);
$dewormer = (int) $db->lastInsertId();
}
}
if ($dewormer) {
$s = $db->prepare('SELECT 1 FROM ref_dewormers WHERE id=? AND active=1');
$s->execute([$dewormer]);
if (!$s->fetchColumn()) {
$dewormer = 0;
}
}
$ids = [$animalId];
$litterId = null;
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',
);
$s->execute([$animalId, $animalId]);
$litterId = (int) $s->fetchColumn() ?: null;
if ($litterId) {
$s = $db->prepare(
'SELECT animal_id FROM litter_kittens WHERE litter_id=? UNION SELECT mother_id FROM litters WHERE id=? AND mother_id IS NOT NULL',
);
$s->execute([$litterId, $litterId]);
$ids = array_values(array_unique(array_map('intval', $s->fetchAll(PDO::FETCH_COLUMN))));
}
}
$manual = trim((string) ($_POST['next_due_date'] ?? ''));
if ($manual !== '' && !self::date($manual)) {
http_response_code(400);
echo h(t('deworm.invalid_next_due'));
return;
}
$db->beginTransaction();
try {
$insert = $db->prepare(
'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,type,label,details,user_id) VALUES(?,'deworming','Vermifuge administré',?,?)",
);
foreach ($ids as $id) {
$s = $db->prepare('SELECT name,birth_date FROM animals WHERE id=? AND deleted_at IS NULL');
$s->execute([$id]);
$animal = $s->fetch();
if (!$animal) {
continue;
}
$next = $manual ?: self::nextDue($animal['birth_date'] ?? null, $date);
$insert->execute([
$id,
$dewormer ?: null,
$date,
trim((string) ($_POST['dose_text'] ?? '')) ?: null,
($_POST['weight_kg'] ?? '') !== '' ? (float) $_POST['weight_kg'] : null,
$next,
trim((string) ($_POST['notes'] ?? '')) ?: null,
$litterId,
Auth::id(),
]);
$history->execute([
$id,
'Administration : ' . self::fr($date) . ($next ? ' · Prochaine échéance : ' . self::fr($next) : ''),
Auth::id(),
]);
}
$perAnimal = (float) str_replace(',', '.', (string) ($_POST['inventory_quantity'] ?? 0));
InventoryService::consumeBatch(
(int) ($_POST['inventory_batch_id'] ?? 0),
$perAnimal * count($ids),
$animalId,
'Vermifuge du ' . $date . ($litterId ? ' · portée #' . $litterId : ''),
);
$db->commit();
header('Location: /animal?id=' . $animalId . '#tab-med');
exit();
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
}
public static function nextDue(?string $birth, string $given): string
{
$admin = new DateTimeImmutable($given);
if ($birth && ($born = DateTimeImmutable::createFromFormat('!Y-m-d', $birth))) {
$targets = [
$born->modify('+3 weeks'),
$born->modify('+5 weeks'),
$born->modify('+7 weeks'),
$born->modify('+3 months'),
$born->modify('+4 months'),
$born->modify('+5 months'),
$born->modify('+6 months'),
];
foreach ($targets as $target) {
if ($target > $admin) {
return $target->format('Y-m-d');
}
}
}
return $admin->modify('+3 months')->format('Y-m-d');
}
private static function date(string $v): bool
{
$d = DateTimeImmutable::createFromFormat('!Y-m-d', $v);
return $d && $d->format('Y-m-d') === $v;
}
private static function fr(string $v): string
{
$d = DateTimeImmutable::createFromFormat('!Y-m-d', $v);
return $d ? $d->format('d/m/Y') : $v;
}
}

View file

@ -0,0 +1,279 @@
<?php
declare(strict_types=1);
final class DirectoryController
{
private static function roles(): array
{
return [
'adoptant' => t('role.adoptant'),
'benevole' => t('role.benevole'),
'cabinet' => t('role.cabinet'),
'crematorium' => t('role.crematorium'),
'deposant' => t('role.deposant'),
'dirigeant' => t('role.dirigeant'),
'fa' => t('role.fa'),
'fourriere' => t('role.fourriere'),
'veterinaire' => t('role.veterinaire'),
];
}
public static function index(): void
{
$db = DB::pdo();
$q = trim((string) ($_GET['q'] ?? ''));
$role = trim((string) ($_GET['role'] ?? ''));
$where = ['dc.deleted_at IS NULL'];
$params = [];
if ($q !== '') {
$where[] = '(dc.name LIKE :q OR dc.phone LIKE :q OR dc.email LIKE :q OR dc.city LIKE :q)';
$params[':q'] = '%' . $q . '%';
}
$roles = self::roles();
if (isset($roles[$role])) {
$where[] = 'EXISTS(SELECT 1 FROM directory_contact_roles f WHERE f.contact_id=dc.id AND f.role=:role)';
$params[':role'] = $role;
}
$stmt = $db->prepare(
"
SELECT dc.*, org.name AS organization_name, group_concat(dcr.role, ',') AS roles,
(SELECT COUNT(*) FROM (
SELECT animal_id FROM adoptions ad WHERE ad.adopter_contact_id=dc.id OR lower(trim(ad.adopter_name))=lower(trim(dc.name))
UNION SELECT animal_id FROM animal_placements ap WHERE ap.contact_id=dc.id AND ap.event_type='adoption'
UNION SELECT animal_id FROM animal_movements am WHERE am.kind='exit' AND lower(trim(am.contact_name))=lower(trim(dc.name)) AND lower(COALESCE(am.lieu,'')) LIKE 'adopt%'
)) AS adoption_count,
(SELECT COALESCE(SUM(ae.total_cents),0) FROM animal_expenses ae WHERE ae.clinic_contact_id=dc.id) AS expense_total_cents
FROM directory_contacts dc
LEFT JOIN directory_contacts org ON org.id=dc.organization_id
LEFT JOIN directory_contact_roles dcr ON dcr.contact_id=dc.id
WHERE " .
implode(' AND ', $where) .
'
GROUP BY dc.id ORDER BY dc.name COLLATE NOCASE
',
);
$stmt->execute($params);
render('directory_list.php', [
'title' => t('directory.title'),
'contacts' => $stmt->fetchAll(),
'roles' => $roles,
'q' => $q,
'role' => $role,
]);
}
public static function show(): void
{
$db = DB::pdo();
$id = (int) ($_GET['id'] ?? 0);
$stmt = $db->prepare(
"SELECT dc.*,org.name organization_name,group_concat(r.role,',') roles FROM directory_contacts dc LEFT JOIN directory_contacts org ON org.id=dc.organization_id LEFT JOIN directory_contact_roles r ON r.contact_id=dc.id WHERE dc.id=:id AND dc.deleted_at IS NULL GROUP BY dc.id",
);
$stmt->execute([':id' => $id]);
$contact = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$contact) {
http_response_code(404);
echo h(t('directory.not_found'));
return;
}
$adoptions = $db->prepare("SELECT a.id,a.name,a.internal_code,a.species,a.status,x.event_date FROM animals a JOIN (SELECT animal_id,MAX(event_date) event_date FROM (
SELECT animal_id,adoption_date event_date FROM adoptions WHERE adopter_contact_id=:id1 OR lower(trim(adopter_name))=lower(trim(:name1))
UNION ALL SELECT animal_id,event_date FROM animal_placements WHERE contact_id=:id2 AND event_type='adoption'
UNION ALL SELECT animal_id,created_at event_date FROM animal_movements WHERE kind='exit' AND lower(trim(contact_name))=lower(trim(:name2)) AND lower(COALESCE(lieu,'')) LIKE 'adopt%'
) GROUP BY animal_id) x ON x.animal_id=a.id ORDER BY date(x.event_date) DESC,a.name COLLATE NOCASE");
$adoptions->execute([':id1' => $id, ':name1' => $contact['name'], ':id2' => $id, ':name2' => $contact['name']]);
$fosters = $db->prepare(
"SELECT a.id,a.name,a.internal_code,a.species,a.status,MAX(ap.event_date) event_date FROM animal_placements ap JOIN animals a ON a.id=ap.animal_id WHERE ap.contact_id=:id AND ap.event_type='foster' GROUP BY a.id ORDER BY date(event_date) DESC,a.name COLLATE NOCASE",
);
$fosters->execute([':id' => $id]);
$deposited = $db->prepare(
'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);
$periodRaw = (string) ($_GET['year'] ?? 'all');
$periodYear =
ctype_digit($periodRaw) && (int) $periodRaw >= 2000 && (int) $periodRaw <= 2100 ? (int) $periodRaw : null;
$periodMunicipality = trim((string) ($_GET['municipality'] ?? ''));
$expenseWhere = ['ae.clinic_contact_id=:id', 'a.deleted_at IS NULL'];
$expenseParams = [':id' => $id];
if ($periodYear !== null) {
$expenseWhere[] = 'date(ae.occurred_on) BETWEEN :expense_start AND :expense_end';
$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 ' .
implode(' AND ', $expenseWhere) .
' ORDER BY date(ae.occurred_on) DESC,ae.id DESC',
);
$expenses->execute($expenseParams);
$expenseRows = $expenses->fetchAll(PDO::FETCH_ASSOC);
if ($periodMunicipality !== '') {
$expenseRows = array_values(
array_filter(
$expenseRows,
static fn($row) => GrantService::municipality(
(string) ($row['rescue_location_name'] ?: $row['rescue_address']),
) === $periodMunicipality,
),
);
}
$expenseTotal = array_sum(array_map(static fn($row) => (int) $row['total_cents'], $expenseRows));
$expenseCount = count($expenseRows);
$expenseRows = array_slice($expenseRows, 0, 250);
$invoiceWhere = ['vendor_contact_id=:vendor', 'deleted_at IS NULL'];
$invoiceParams = [':vendor' => $id];
if ($periodYear !== null) {
$invoiceWhere[] = "strftime('%Y',invoice_date)=:invoice_year";
$invoiceParams[':invoice_year'] = (string) $periodYear;
}
$invoiceStmt = $db->prepare(
'SELECT * FROM accounting_invoices WHERE ' .
implode(' AND ', $invoiceWhere) .
' ORDER BY date(invoice_date) DESC,id DESC',
);
$invoiceStmt->execute($invoiceParams);
$contactInvoices = $invoiceStmt->fetchAll(PDO::FETCH_ASSOC);
$contactInvoiceTotal = array_sum(array_column($contactInvoices, 'total_ttc_cents'));
$contactInvoiceDue = array_sum(array_column($contactInvoices, 'amount_due_cents'));
$linkedPeople = [];
if ($contact['kind'] === 'organization') {
$s = $db->prepare(
"SELECT dc.id,dc.name,dc.phone,dc.email,group_concat(r.role,',') roles FROM directory_contacts dc LEFT JOIN directory_contact_roles r ON r.contact_id=dc.id WHERE dc.organization_id=:id AND dc.deleted_at IS NULL GROUP BY dc.id ORDER BY dc.name COLLATE NOCASE",
);
$s->execute([':id' => $id]);
$linkedPeople = $s->fetchAll(PDO::FETCH_ASSOC);
}
render('directory_show_accounting.php', [
'title' => $contact['name'],
'contact' => $contact,
'roles' => self::roles(),
'adoptions' => $adoptions->fetchAll(PDO::FETCH_ASSOC),
'fosters' => $fosters->fetchAll(PDO::FETCH_ASSOC),
'deposited' => $deposited->fetchAll(PDO::FETCH_ASSOC),
'expenses' => $expenseRows,
'expenseTotal' => $expenseTotal,
'expenseCount' => $expenseCount,
'expensePeriodRequested' => $periodRequested,
'expensePeriodYear' => $periodYear,
'expensePeriodMunicipality' => $periodMunicipality,
'contactInvoices' => $contactInvoices,
'contactInvoiceTotal' => $contactInvoiceTotal,
'contactInvoiceDue' => $contactInvoiceDue,
'linkedPeople' => $linkedPeople,
]);
}
public static function form(): void
{
$db = DB::pdo();
$id = (int) ($_GET['id'] ?? 0);
$contact = [
'id' => 0,
'kind' => 'person',
'name' => '',
'phone' => '',
'email' => '',
'address' => '',
'postal_code' => '',
'city' => '',
'country' => 'France',
'organization_id' => '',
'notes' => '',
];
$selected = [];
if ($id) {
$stmt = $db->prepare('SELECT * FROM directory_contacts WHERE id=:id AND deleted_at IS NULL');
$stmt->execute([':id' => $id]);
$contact = $stmt->fetch() ?: $contact;
$stmt = $db->prepare('SELECT role FROM directory_contact_roles WHERE contact_id=:id');
$stmt->execute([':id' => $id]);
$selected = $stmt->fetchAll(PDO::FETCH_COLUMN);
}
$organizations = $db
->query(
"SELECT id,name FROM directory_contacts WHERE kind='organization' AND deleted_at IS NULL ORDER BY name COLLATE NOCASE",
)
->fetchAll();
render('directory_form.php', [
'title' => $id ? t('directory.edit') : t('directory.add'),
'contact' => $contact,
'selectedRoles' => $selected,
'roles' => self::roles(),
'organizations' => $organizations,
]);
}
public static function save(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$db = DB::pdo();
$id = (int) ($_POST['id'] ?? 0);
$name = trim((string) ($_POST['name'] ?? ''));
if ($name === '') {
http_response_code(400);
echo h(t('directory.name_required'));
return;
}
$kind = ($_POST['kind'] ?? 'person') === 'organization' ? 'organization' : 'person';
$roles = array_values(
array_intersect(array_keys(self::roles()), is_array($_POST['roles'] ?? null) ? $_POST['roles'] : []),
);
$organizationId = $kind === 'person' ? ((int) ($_POST['organization_id'] ?? 0) ?: null) : null;
if ($organizationId !== null) {
$check = $db->prepare(
"SELECT 1 FROM directory_contacts WHERE id=:id AND kind='organization' AND deleted_at IS NULL",
);
$check->execute([':id' => $organizationId]);
if (!$check->fetchColumn() || $organizationId === $id) {
http_response_code(400);
echo h(t('directory.invalid_organization'));
return;
}
}
$data = [
':kind' => $kind,
':name' => $name,
':phone' => trim((string) ($_POST['phone'] ?? '')) ?: null,
':email' => trim((string) ($_POST['email'] ?? '')) ?: null,
':address' => trim((string) ($_POST['address'] ?? '')) ?: null,
':postal' => trim((string) ($_POST['postal_code'] ?? '')) ?: null,
':city' => trim((string) ($_POST['city'] ?? '')) ?: null,
':country' => trim((string) ($_POST['country'] ?? 'France')) ?: 'France',
':org' => $organizationId,
':notes' => trim((string) ($_POST['notes'] ?? '')) ?: null,
];
$db->beginTransaction();
try {
if ($id) {
$data[':id'] = $id;
$db->prepare(
"UPDATE directory_contacts SET kind=:kind,name=:name,phone=:phone,email=:email,address=:address,postal_code=:postal,city=:city,country=:country,organization_id=:org,notes=:notes,updated_at=datetime('now') WHERE id=:id",
)->execute($data);
} else {
$db->prepare(
'INSERT INTO directory_contacts(kind,name,phone,email,address,postal_code,city,country,organization_id,notes) VALUES(:kind,:name,:phone,:email,:address,:postal,:city,:country,:org,:notes)',
)->execute($data);
$id = (int) $db->lastInsertId();
}
$db->prepare('DELETE FROM directory_contact_roles WHERE contact_id=:id')->execute([':id' => $id]);
$ins = $db->prepare('INSERT INTO directory_contact_roles(contact_id,role) VALUES(:id,:role)');
foreach ($roles as $r) {
$ins->execute([':id' => $id, ':role' => $r]);
}
$db->commit();
header('Location: /directory');
exit();
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
}
}

View file

@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
final class DocumentsController
{
public static function blankAdoption(): void
{
$format = ($_GET['format'] ?? 'odt') === 'pdf' ? 'pdf' : 'odt';
$path = DocumentsService::adoptionTemplate();
$generated = null;
if ($format === 'pdf') {
$dir = sys_get_temp_dir() . '/globinours-doc-' . bin2hex(random_bytes(8));
mkdir($dir, 0775, true);
$odt = $dir . '/contrat-adoption-vierge.odt';
copy($path, $odt);
$path = DocumentsService::toPdf($odt);
$generated = $path;
}
self::send($path, 'contrat-adoption-vierge.' . $format, $format, $generated);
}
public static function blank(): void
{
$type = (string) ($_GET['type'] ?? '');
$names = [
'adoption' => 'contrat-adoption',
'abandon' => 'abandon-trouvaille',
'benevole' => 'contrat-benevole',
'fa' => 'proposition-fa',
'pre_adoption' => 'certificat-visite-pre-adoption',
];
if (!isset($names[$type])) {
http_response_code(404);
return;
}
$format = ($_GET['format'] ?? 'odt') === 'pdf' ? 'pdf' : 'odt';
$source = DocumentsService::template($type);
$dir = sys_get_temp_dir() . '/globinours-doc-' . bin2hex(random_bytes(8));
mkdir($dir, 0775, true);
$odt = $dir . '/' . $names[$type] . '-vierge.odt';
copy($source, $odt);
$path = $format === 'pdf' ? DocumentsService::toPdf($odt) : $odt;
self::send($path, $names[$type] . '-vierge.' . $format, $format, $path);
}
public static function adoption(): void
{
$id = (int) ($_GET['animal_id'] ?? 0);
if ($id <= 0) {
http_response_code(400);
return;
}
$db = DB::pdo();
$stmt = $db->prepare('SELECT * FROM animals WHERE id=:id AND deleted_at IS NULL');
$stmt->execute([':id' => $id]);
$animal = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$animal) {
http_response_code(404);
return;
}
$stmt = $db->prepare('SELECT * FROM adoptions WHERE animal_id=:id ORDER BY id DESC LIMIT 1');
$stmt->execute([':id' => $id]);
$adoption = $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
$stmt = $db->prepare(
'SELECT done_date,due_date FROM vaccinations WHERE animal_id=:id ORDER BY done_date DESC,id DESC LIMIT 1',
);
$stmt->execute([':id' => $id]);
$vaccine = $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
$format = ($_GET['format'] ?? 'pdf') === 'odt' ? 'odt' : 'pdf';
$odt = DocumentsService::generateAdoption($animal, $adoption, $vaccine);
$path = $format === 'pdf' ? DocumentsService::toPdf($odt) : $odt;
self::send(
$path,
'contrat-adoption-' . self::safeName((string) $animal['name']) . '.' . $format,
$format,
$path,
);
}
public static function abandon(): void
{
$id = (int) ($_GET['animal_id'] ?? 0);
$db = DB::pdo();
$stmt = $db->prepare('SELECT * FROM animals WHERE id=:id AND deleted_at IS NULL');
$stmt->execute([':id' => $id]);
$animal = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$animal) {
http_response_code(404);
return;
}
$format = ($_GET['format'] ?? 'pdf') === 'odt' ? 'odt' : 'pdf';
$odt = DocumentsService::generateAbandon($animal);
$path = $format === 'pdf' ? DocumentsService::toPdf($odt) : $odt;
self::send(
$path,
'abandon-trouvaille-' . self::safeName((string) $animal['name']) . '.' . $format,
$format,
$path,
);
}
public static function contact(): void
{
$id = (int) ($_GET['contact_id'] ?? 0);
$type = (string) ($_GET['type'] ?? '');
$role = $type === 'benevole' ? 'benevole' : ($type === 'fa' ? 'fa' : null);
if (!$role) {
http_response_code(400);
return;
}
$db = DB::pdo();
$stmt = $db->prepare(
'SELECT dc.* FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id AND r.role=:role WHERE dc.id=:id AND dc.deleted_at IS NULL',
);
$stmt->execute([':role' => $role, ':id' => $id]);
$contact = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$contact) {
http_response_code(404);
return;
}
$format = ($_GET['format'] ?? 'pdf') === 'odt' ? 'odt' : 'pdf';
$odt = DocumentsService::generateContact($type, $contact);
$path = $format === 'pdf' ? DocumentsService::toPdf($odt) : $odt;
self::send($path, $type . '-' . self::safeName((string) $contact['name']) . '.' . $format, $format, $path);
}
private static function send(string $path, string $filename, string $format, ?string $cleanup): void
{
if (!is_file($path)) {
http_response_code(500);
return;
}
header('Content-Type: ' . ($format === 'pdf' ? 'application/pdf' : 'application/vnd.oasis.opendocument.text'));
header('Content-Length: ' . filesize($path));
header(
'Content-Disposition: ' . ($format === 'pdf' ? 'inline' : 'attachment') . '; filename="' . $filename . '"',
);
readfile($path);
if ($cleanup) {
DocumentsService::cleanup($cleanup);
}
exit();
}
private static function safeName(string $name): string
{
$name = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $name) ?: 'chat';
return strtolower(trim(preg_replace('/[^a-zA-Z0-9]+/', '-', $name), '-')) ?: 'chat';
}
}

View file

@ -0,0 +1,240 @@
<?php
declare(strict_types=1);
final class GrantsController
{
private static function admin(): void
{
$action = ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST' ? 'edit' : 'view';
if (!PermissionService::can('grants', $action)) {
http_response_code(403);
echo h(t('grant.forbidden'));
exit();
}
}
private static function year(): int
{
return max(2000, min(2200, (int) ($_REQUEST['year'] ?? date('Y'))));
}
public static function index(): void
{
self::admin();
$year = self::year();
$applications = GrantService::applications($year);
render('admin_grants.php', [
'title' => t('grant.title'),
'year' => $year,
'annual' => GrantService::ensureYear($year),
'stats' => GrantService::statistics($year),
'details' => GrantService::detailedStatistics($year),
'comparison' => GrantService::comparison($year),
'applications' => $applications,
'checklist' => GrantService::checklist($year),
'documents' => GrantService::documents($year),
'saved' => isset($_GET['saved']),
'error' => (string) ($_GET['error'] ?? ''),
]);
}
public static function saveAnnual(): void
{
self::admin();
$year = self::year();
GrantService::ensureYear($year);
$ints = ['members_count', 'volunteers_count', 'employees_count'];
$nums = [
'employees_fte',
'treasury_amount',
'actual_expenses',
'actual_income',
'forecast_expenses',
'forecast_income',
'voluntary_contributions',
];
$texts = [
'annual_report',
'completed_actions',
'planned_actions',
'beneficiaries',
'officers_notes',
'in_kind_support',
];
$values = [
':year' => $year,
':last_ag_date' => self::date($_POST['last_ag_date'] ?? null),
':uid' => Auth::id(),
];
foreach ($ints as $f) {
$values[':' . $f] = max(0, (int) ($_POST[$f] ?? 0));
}
foreach ($nums as $f) {
$values[':' . $f] = max(0, self::amount($_POST[$f] ?? 0));
}
foreach ($texts as $f) {
$values[':' . $f] = trim((string) ($_POST[$f] ?? ''));
}
$sets = [];
foreach (array_merge($ints, $nums, $texts, ['last_ag_date']) as $f) {
$sets[] = "$f=:$f";
}
$sql =
'UPDATE grant_years SET ' .
implode(',', $sets) .
',updated_by=:uid,updated_at=datetime(\'now\') WHERE year=:year';
DB::pdo()->prepare($sql)->execute($values);
AuditService::log('grant_year_saved', '/admin/grants/save', 'Bilan annuel ' . $year . ' mis à jour');
header('Location: /admin/grants?year=' . $year . '&saved=1');
}
public static function saveApplication(): void
{
self::admin();
$year = self::year();
GrantService::ensureYear($year);
$id = (int) ($_POST['application_id'] ?? 0);
$existing = [];
if ($id) {
$q = DB::pdo()->prepare('SELECT * FROM grant_applications WHERE id=? AND year=?');
$q->execute([$id, $year]);
$existing = $q->fetch() ?: [];
}
$name = trim((string) ($_POST['organization_name'] ?? ($existing['organization_name'] ?? '')));
if ($name === '') {
header('Location: /admin/grants?year=' . $year . '&error=organisme');
return;
}
$status = (string) ($_POST['status'] ?? ($existing['status'] ?? 'draft'));
if (!isset(GrantService::STATUSES[$status])) {
$status = 'draft';
}
$awardedRaw = $_POST['awarded_amount'] ?? ($existing['awarded_amount'] ?? '');
$params = [
':year' => $year,
':name' => $name,
':deadline' => self::date($_POST['deadline'] ?? ($existing['deadline'] ?? null)),
':requested' => self::amount($_POST['requested_amount'] ?? ($existing['requested_amount'] ?? 0)),
':previous' => self::amount($_POST['previous_amount'] ?? ($existing['previous_amount'] ?? 0)),
':awarded' => trim((string) $awardedRaw) === '' ? null : self::amount($awardedRaw),
':status' => $status,
':notes' => trim((string) ($_POST['notes'] ?? ($existing['notes'] ?? ''))),
':uid' => Auth::id(),
];
if ($id) {
$params[':id'] = $id;
DB::pdo()
->prepare(
'UPDATE grant_applications SET organization_name=:name,deadline=:deadline,requested_amount=:requested,previous_amount=:previous,awarded_amount=:awarded,status=:status,notes=:notes,updated_by=:uid,updated_at=datetime(\'now\') WHERE id=:id AND year=:year',
)
->execute($params);
} else {
DB::pdo()
->prepare(
'INSERT INTO grant_applications(year,organization_name,deadline,requested_amount,previous_amount,awarded_amount,status,notes,created_by,updated_by) VALUES(:year,:name,:deadline,:requested,:previous,:awarded,:status,:notes,:uid,:uid)',
)
->execute($params);
}
header('Location: /admin/grants?year=' . $year . '&saved=1');
}
public static function upload(): void
{
self::admin();
$year = self::year();
GrantService::ensureYear($year);
$f = $_FILES['document'] ?? null;
if (!$f || ($f['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || ($f['size'] ?? 0) > 20_000_000) {
header('Location: /admin/grants?year=' . $year . '&error=document');
return;
}
$mime = new finfo(FILEINFO_MIME_TYPE)->file($f['tmp_name']) ?: 'application/octet-stream';
$allowed = ['application/pdf' => 'pdf', 'application/vnd.oasis.opendocument.text' => 'odt'];
if (!isset($allowed[$mime])) {
header('Location: /admin/grants?year=' . $year . '&error=format');
return;
}
$dir = dirname(__DIR__, 2) . '/data/grants';
if (!is_dir($dir)) {
mkdir($dir, 0770, true);
}
$stored = bin2hex(random_bytes(18)) . '.' . $allowed[$mime];
if (!move_uploaded_file($f['tmp_name'], $dir . '/' . $stored)) {
throw new RuntimeException(t('error.document_storage'));
}
$category = (string) ($_POST['category'] ?? 'other');
if (!isset(GrantService::CATEGORIES[$category])) {
$category = 'other';
}
$global = isset($_POST['global_document']);
$applicationId = (int) ($_POST['application_id'] ?? 0);
if ($applicationId) {
$check = DB::pdo()->prepare('SELECT 1 FROM grant_applications WHERE id=? AND year=?');
$check->execute([$applicationId, $year]);
if (!$check->fetchColumn()) {
$applicationId = 0;
}
}
if ($category === 'official_form' && !$applicationId) {
@unlink($dir . '/' . $stored);
header('Location: /admin/grants?year=' . $year . '&error=organisme');
return;
}
DB::pdo()
->prepare(
'INSERT INTO grant_documents(year,application_id,category,title,stored_name,original_name,mime_type,size_bytes,valid_until,notes,uploaded_by) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
)
->execute([
$global ? null : $year,
$applicationId ?: null,
$category,
trim((string) ($_POST['title'] ?? '')) ?: pathinfo((string) $f['name'], PATHINFO_FILENAME),
$stored,
basename((string) $f['name']),
$mime,
(int) $f['size'],
self::date($_POST['valid_until'] ?? null),
trim((string) ($_POST['notes'] ?? '')),
Auth::id(),
]);
header('Location: /admin/grants?year=' . $year . '&saved=1');
}
public static function download(): void
{
self::admin();
$s = DB::pdo()->prepare('SELECT * FROM grant_documents WHERE id=?');
$s->execute([(int) ($_GET['id'] ?? 0)]);
$d = $s->fetch();
$path = dirname(__DIR__, 2) . '/data/grants/' . ($d['stored_name'] ?? '');
if (!$d || !is_file($path)) {
http_response_code(404);
echo h(t('grant.document_not_found'));
return;
}
PrivacyService::logAccess('grant_document', (int) $d['id'], null, (string) $d['original_name']);
header('Content-Type: ' . $d['mime_type']);
header('Content-Disposition: inline; filename="' . rawurlencode($d['original_name']) . '"');
header('Content-Length: ' . filesize($path));
header('Cache-Control: private,no-store');
readfile($path);
}
public static function print(): void
{
self::admin();
$year = self::year();
$annual = GrantService::ensureYear($year);
$stats = GrantService::statistics($year);
$details = GrantService::detailedStatistics($year);
$comparison = GrantService::comparison($year);
$applications = GrantService::applications($year);
$documents = GrantService::documents($year);
$generatedAt = new DateTimeImmutable('now', new DateTimeZone('Europe/Paris'));
header('Content-Type: text/html; charset=UTF-8');
require __DIR__ . '/../Views/admin_grants_print.php';
}
private static function date(mixed $v): ?string
{
$v = trim((string) $v);
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $v) ? $v : null;
}
private static function amount(mixed $v): float
{
return round((float) str_replace([' ', ' ', ','], ['', '', '.'], (string) $v), 2);
}
}

View file

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
final class InventoryController
{
private static function admin(): void
{
if (!Auth::is('admin')) {
http_response_code(403);
echo h(t('settings.admin_only'));
exit();
}
}
public static function index(): void
{
self::admin();
$db = DB::pdo();
render('settings_inventory.php', [
'title' => t('inventory.title'),
'products' => InventoryService::products(),
'batches' => InventoryService::batches(),
'movements' => InventoryService::movements(),
'alerts' => InventoryService::alerts(),
'references' => [
'medication' => $db
->query('SELECT id,name FROM ref_medications ORDER BY name COLLATE NOCASE')
->fetchAll(),
'dewormer' => $db->query('SELECT id,name FROM ref_dewormers ORDER BY name COLLATE NOCASE')->fetchAll(),
'vaccine' => $db->query('SELECT id,name FROM ref_vaccines ORDER BY name COLLATE NOCASE')->fetchAll(),
],
'suppliers' => $db
->query(
"SELECT DISTINCT dc.id,dc.name FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id WHERE dc.deleted_at IS NULL AND dc.kind='organization' AND r.role IN ('cabinet','fourriere') ORDER BY dc.name COLLATE NOCASE",
)
->fetchAll(),
'animals' => $db
->query(
'SELECT id,name FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL ORDER BY name COLLATE NOCASE',
)
->fetchAll(),
'status' => (string) ($_GET['status'] ?? ''),
'error' => (string) ($_GET['error'] ?? ''),
]);
}
public static function product(): void
{
self::admin();
self::run(fn() => InventoryService::saveProduct($_POST), 'product');
}
public static function receive(): void
{
self::admin();
self::run(fn() => InventoryService::receive($_POST), 'received');
}
public static function move(): void
{
self::admin();
self::run(fn() => InventoryService::move($_POST), 'moved');
}
private static function run(callable $fn, string $status): void
{
try {
$fn();
header('Location: /settings/inventory?status=' . $status);
} catch (RuntimeException $e) {
header('Location: /settings/inventory?error=' . rawurlencode($e->getMessage()));
} catch (Throwable $e) {
header('Location: /settings/inventory?error=' . rawurlencode(SecurityService::publicError($e)));
}
exit();
}
}

View file

@ -0,0 +1,334 @@
<?php
declare(strict_types=1);
final class MedicalDocumentsController
{
private const MAX_BYTES = 20 * 1024 * 1024;
private const TYPES = [
'application/pdf' => 'pdf',
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
];
public static function savePrescription(): void
{
$animalId = self::animalId();
$date = self::date((string) ($_POST['prescribed_on'] ?? ''));
if (!$animalId || !$date) {
self::fail(t('medical_document.invalid_data'));
return;
}
try {
$file = self::storeUpload('document', $animalId, 'prescription', true);
} catch (Throwable $e) {
self::fail($e->getMessage());
return;
}
$db = DB::pdo();
try {
$db->beginTransaction();
$stmt = $db->prepare(
'INSERT INTO medical_prescriptions(animal_id,prescribed_on,veterinarian_contact_id,clinic_contact_id,status,notes,original_name,stored_name,mime_type,size_bytes,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
);
$status = in_array($_POST['status'] ?? '', ['active', 'finished', 'replaced'], true)
? $_POST['status']
: 'active';
$stmt->execute([
$animalId,
$date,
self::contact('veterinarian_contact_id'),
self::contact('clinic_contact_id'),
$status,
self::text('notes'),
$file['original'],
$file['stored'],
$file['mime'],
$file['size'],
Auth::id(),
]);
$id = (int) $db->lastInsertId();
$link = $db->prepare(
'INSERT OR IGNORE INTO prescription_treatments(prescription_id,treatment_id) SELECT ?,id FROM treatments WHERE id=? AND animal_id=?',
);
foreach ((array) ($_POST['treatment_ids'] ?? []) as $treatmentId) {
$link->execute([$id, (int) $treatmentId, $animalId]);
}
self::history($db, $animalId, 'Ordonnance ajoutée', self::fr($date));
$db->commit();
AuditService::log(
'prescription_added',
'/animal/prescription/save',
t('medical_document.prescription_added'),
'animal',
$animalId,
['prescription_id' => $id],
);
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
self::remove($animalId, $file['stored']);
throw $e;
}
self::back($animalId);
}
public static function saveLabReport(): void
{
$animalId = self::animalId();
$date = self::date((string) ($_POST['sampled_on'] ?? ''));
$names = (array) ($_POST['parameter_name'] ?? []);
$values = (array) ($_POST['value'] ?? []);
if (!$animalId || !$date) {
self::fail(t('medical_document.invalid_data'));
return;
}
try {
$file = self::storeUpload('document', $animalId, 'analysis', false);
} catch (Throwable $e) {
self::fail($e->getMessage());
return;
}
$rows = [];
foreach ($names as $i => $name) {
$name = trim((string) $name);
$raw = str_replace(',', '.', trim((string) ($values[$i] ?? '')));
if ($name === '' && $raw === '') {
continue;
}
if ($name === '' || !is_numeric($raw)) {
if ($file) {
self::remove($animalId, $file['stored']);
}
self::fail(t('medical_document.invalid_result'));
return;
}
$min = self::numberOrNull(($_POST['reference_min'] ?? [])[$i] ?? null);
$max = self::numberOrNull(($_POST['reference_max'] ?? [])[$i] ?? null);
if ($min !== null && $max !== null && $min > $max) {
if ($file) {
self::remove($animalId, $file['stored']);
}
self::fail(t('medical_document.invalid_result'));
return;
}
$rows[] = [
'name' => mb_substr($name, 0, 120),
'value' => (float) $raw,
'unit' => self::arrayText('unit', $i, 30),
'min' => $min,
'max' => $max,
'notes' => self::arrayText('result_notes', $i, 255),
];
}
if (!$rows && !$file) {
self::fail(t('medical_document.result_or_document_required'));
return;
}
$db = DB::pdo();
try {
$db->beginTransaction();
$stmt = $db->prepare(
'INSERT INTO lab_reports(animal_id,sampled_on,report_type,laboratory_name,veterinarian_contact_id,clinic_contact_id,notes,original_name,stored_name,mime_type,size_bytes,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)',
);
$stmt->execute([
$animalId,
$date,
self::text('report_type', 120),
self::text('laboratory_name', 150),
self::contact('veterinarian_contact_id'),
self::contact('clinic_contact_id'),
self::text('notes'),
$file['original'] ?? null,
$file['stored'] ?? null,
$file['mime'] ?? null,
$file['size'] ?? 0,
Auth::id(),
]);
$reportId = (int) $db->lastInsertId();
$insert = $db->prepare(
'INSERT INTO lab_results(report_id,parameter_name,value,unit,reference_min,reference_max,notes,position) VALUES(?,?,?,?,?,?,?,?)',
);
foreach ($rows as $i => $row) {
$insert->execute([
$reportId,
$row['name'],
$row['value'],
$row['unit'],
$row['min'],
$row['max'],
$row['notes'],
$i,
]);
}
self::history(
$db,
$animalId,
'Résultats danalyse ajoutés',
self::fr($date) . ($rows ? ' · ' . count($rows) . ' paramètre(s)' : ''),
);
$db->commit();
AuditService::log(
'lab_report_added',
'/animal/lab-report/save',
t('medical_document.analysis_added'),
'animal',
$animalId,
['report_id' => $reportId, 'results' => count($rows)],
);
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
if ($file) {
self::remove($animalId, $file['stored']);
}
throw $e;
}
self::back($animalId);
}
public static function download(): void
{
$id = (int) ($_GET['id'] ?? 0);
$kind = (string) ($_GET['kind'] ?? '');
$table = $kind === 'prescription' ? 'medical_prescriptions' : ($kind === 'analysis' ? 'lab_reports' : '');
if (!$id || !$table) {
http_response_code(404);
return;
}
$stmt = DB::pdo()->prepare(
"SELECT d.animal_id,d.original_name,d.stored_name,d.mime_type FROM $table d JOIN animals a ON a.id=d.animal_id WHERE d.id=? AND a.deleted_at IS NULL",
);
$stmt->execute([$id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row || !$row['stored_name']) {
http_response_code(404);
return;
}
$path = self::directory((int) $row['animal_id']) . '/' . basename((string) $row['stored_name']);
if (!is_file($path)) {
http_response_code(404);
return;
}
PrivacyService::logAccess('medical_' . $kind, $id, (int) $row['animal_id'], (string) $row['original_name']);
AuditService::log(
'private_file_viewed',
'/animal/medical-document',
'Document médical privé consulté',
'animal',
(int) $row['animal_id'],
['kind' => $kind, 'document_id' => $id],
);
header('Content-Type: ' . ($row['mime_type'] ?: 'application/octet-stream'));
header('Content-Length: ' . filesize($path));
header("Content-Disposition: inline; filename*=UTF-8''" . rawurlencode((string) $row['original_name']));
header('Cache-Control: private, no-store');
readfile($path);
}
private static function animalId(): int
{
$id = (int) ($_POST['animal_id'] ?? 0);
if (!$id) {
return 0;
}
$s = DB::pdo()->prepare('SELECT 1 FROM animals WHERE id=? AND deleted_at IS NULL');
$s->execute([$id]);
return $s->fetchColumn() ? $id : 0;
}
private static function date(string $v): ?string
{
$d = DateTimeImmutable::createFromFormat('!Y-m-d', $v);
return $d && $d->format('Y-m-d') === $v ? $v : null;
}
private static function contact(string $key): ?int
{
$id = (int) ($_POST[$key] ?? 0);
if ($id <= 0) {
return null;
}
$role = $key === 'veterinarian_contact_id' ? 'veterinaire' : 'cabinet';
$s = DB::pdo()->prepare(
'SELECT 1 FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id WHERE dc.id=? AND dc.deleted_at IS NULL AND r.role=?',
);
$s->execute([$id, $role]);
return $s->fetchColumn() ? $id : null;
}
private static function text(string $key, int $max = 2000): ?string
{
$v = trim((string) ($_POST[$key] ?? ''));
return $v === '' ? null : mb_substr($v, 0, $max);
}
private static function arrayText(string $key, int $i, int $max): ?string
{
$v = trim((string) (($_POST[$key] ?? [])[$i] ?? ''));
return $v === '' ? null : mb_substr($v, 0, $max);
}
private static function numberOrNull(mixed $v): ?float
{
$v = str_replace(',', '.', trim((string) $v));
return $v !== '' && is_numeric($v) ? (float) $v : null;
}
private static function directory(int $animalId): string
{
return dirname(__DIR__, 2) . '/data/medical-documents/' . $animalId;
}
private static function storeUpload(string $key, int $animalId, string $prefix, bool $required): ?array
{
$f = $_FILES[$key] ?? null;
if (!$f || ($f['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
if ($required) {
throw new RuntimeException(t('medical_document.file_required'));
}
return null;
}
if (($f['error'] ?? 1) !== UPLOAD_ERR_OK || ($f['size'] ?? 0) <= 0 || $f['size'] > self::MAX_BYTES) {
throw new RuntimeException(t('medical_document.file_invalid'));
}
$mime = new finfo(FILEINFO_MIME_TYPE)->file($f['tmp_name']);
if (!isset(self::TYPES[$mime])) {
throw new RuntimeException(t('medical_document.file_type'));
}
$dir = self::directory($animalId);
if (!is_dir($dir) && !mkdir($dir, 0770, true) && !is_dir($dir)) {
throw new RuntimeException(t('medical_document.storage_failed'));
}
$stored = $prefix . '-' . bin2hex(random_bytes(16)) . '.' . self::TYPES[$mime];
if (!move_uploaded_file($f['tmp_name'], $dir . '/' . $stored)) {
throw new RuntimeException(t('medical_document.storage_failed'));
}
return [
'original' => mb_substr(basename((string) $f['name']), 0, 240),
'stored' => $stored,
'mime' => $mime,
'size' => (int) $f['size'],
];
}
private static function remove(int $animalId, string $name): void
{
@unlink(self::directory($animalId) . '/' . basename($name));
}
private static function history(PDO $db, int $animalId, string $label, string $details): void
{
$db->prepare(
"INSERT INTO animal_history(animal_id,type,label,details,user_id) VALUES(?,'medical',?,?,?)",
)->execute([$animalId, $label, $details, Auth::id()]);
}
private static function fr(string $date): string
{
return new DateTimeImmutable($date)->format('d/m/Y');
}
private static function fail(string $message): void
{
http_response_code(400);
echo h($message);
}
private static function back(int $id): never
{
header('Location: /animal?id=' . $id . '#tab-med');
exit();
}
}

View file

@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
final class PlacementsController
{
private const TYPES = [
'reservation' => 'Réservation',
'foster' => 'Placement en famille daccueil',
'return' => 'Retour au refuge',
'cancellation' => 'Annulation',
];
public static function save(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$db = DB::pdo();
$animalId = (int) ($_POST['animal_id'] ?? 0);
$type = (string) ($_POST['event_type'] ?? '');
$date = (string) ($_POST['event_date'] ?? '');
if (!$animalId || !isset(self::TYPES[$type]) || !self::validDate($date)) {
http_response_code(400);
echo h(t('error.invalid_placement'));
return;
}
$reason = trim((string) ($_POST['reason'] ?? ''));
if (in_array($type, ['return', 'cancellation'], true) && $reason === '') {
http_response_code(400);
echo h(t('error.placement_reason_required'));
return;
}
$s = $db->prepare('SELECT * FROM animals WHERE id=:id AND deleted_at IS NULL AND archived_at IS NULL');
$s->execute([':id' => $animalId]);
$animal = $s->fetch(PDO::FETCH_ASSOC);
if (!$animal) {
http_response_code(404);
return;
}
$contactId = (int) ($_POST['contact_id'] ?? 0) ?: null;
$contact = null;
if ($contactId) {
$s = $db->prepare('SELECT * FROM directory_contacts WHERE id=:id AND deleted_at IS NULL');
$s->execute([':id' => $contactId]);
$contact = $s->fetch(PDO::FETCH_ASSOC) ?: null;
if (!$contact) {
$contactId = null;
}
}
$status = match ($type) {
'reservation' => 'reserve',
'foster' => 'fa',
'return' => 'refuge',
'cancellation' => in_array($animal['status'], ['reserve', 'réservé'], true) ? 'refuge' : $animal['status'],
};
$currentAddress = (string) ($animal['current_address'] ?? '');
if ($type === 'foster' && $contact) {
$currentAddress = implode(
' ',
array_filter([$contact['address'] ?? '', $contact['postal_code'] ?? '', $contact['city'] ?? '']),
);
}
if ($type === 'return') {
$currentAddress = implode(
' ',
array_filter([
AppSettings::get('association_address'),
AppSettings::get('association_postal_code'),
AppSettings::get('association_city'),
]),
);
}
$notes = trim((string) ($_POST['notes'] ?? ''));
$db->beginTransaction();
try {
$db->prepare(
'INSERT INTO animal_placements(animal_id,event_type,event_date,contact_id,reason,notes,created_by) VALUES(:animal,:type,:date,:contact,:reason,:notes,:user)',
)->execute([
':animal' => $animalId,
':type' => $type,
':date' => $date,
':contact' => $contactId,
':reason' => $reason ?: null,
':notes' => $notes ?: null,
':user' => Auth::id(),
]);
$db->prepare(
'UPDATE animals SET status=:status,refuge_room=NULL,care_box_key=NULL,current_address=:address,current_lat=NULL,current_lng=NULL,updated_at=datetime(\'now\') WHERE id=:id',
)->execute([':status' => $status, ':address' => $currentAddress ?: null, ':id' => $animalId]);
$after = $animal;
$after['status'] = $status;
$after['refuge_room'] = null;
$after['care_box_key'] = null;
$after['current_address'] = $currentAddress;
LocationHistoryService::record(
$db,
$animalId,
$animal,
$after,
'placement',
self::TYPES[$type] . ($reason !== '' ? ' — ' . $reason : ''),
$date . ' 12:00:00',
);
$details = [];
if ($reason !== '') {
$details[] = 'Motif : ' . $reason;
}
if ($notes !== '') {
$details[] = 'Notes : ' . $notes;
}
$db->prepare(
'INSERT INTO animal_history(animal_id,type,label,details,user_id,created_at) VALUES(:animal,\'placement\',:label,:details,:user,:date)',
)->execute([
':animal' => $animalId,
':label' => self::TYPES[$type],
':details' => $details ? implode("\n", $details) : null,
':user' => Auth::id(),
':date' => $date . ' 12:00:00',
]);
if ($type === 'foster' || $type === 'return') {
self::movement($db, $animalId, $type, $contactId, $reason, $notes, $date);
}
$db->commit();
header('Location: /animal?id=' . $animalId . '#tab-placements');
exit();
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
}
private static function movement(
PDO $db,
int $animalId,
string $type,
?int $contactId,
string $reason,
string $notes,
string $date,
): void {
$contact = null;
if ($contactId) {
$s = $db->prepare('SELECT name,phone,email,city FROM directory_contacts WHERE id=:id');
$s->execute([':id' => $contactId]);
$contact = $s->fetch(PDO::FETCH_ASSOC) ?: null;
}
$db->prepare(
'INSERT INTO animal_movements(animal_id,kind,place,lieu,contact_name,contact_phone,contact_email,note,created_at) VALUES(:animal,:kind,:place,:lieu,:name,:phone,:email,:note,:date)',
)->execute([
':animal' => $animalId,
':kind' => $type === 'return' ? 'entry' : 'exit',
':place' => $contact['city'] ?? null,
':lieu' => $type === 'return' ? 'Retour de placement' : 'Placement en FA',
':name' => $contact['name'] ?? null,
':phone' => $contact['phone'] ?? null,
':email' => $contact['email'] ?? null,
':note' =>
trim(($reason !== '' ? 'Motif : ' . $reason : '') . ($notes !== '' ? "\nNotes : " . $notes : '')) ?:
null,
':date' => $date . ' 12:00:00',
]);
}
private static function validDate(string $date): bool
{
$d = DateTimeImmutable::createFromFormat('!Y-m-d', $date);
return $d !== false && $d->format('Y-m-d') === $date;
}
}

View file

@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
final class PrivacyController
{
private static function admin(): void
{
if (!Auth::is('admin')) {
http_response_code(403);
echo h(t('settings.admin_only'));
exit();
}
}
public static function index(): void
{
self::admin();
$settings = AppSettings::all();
$years = max(1, (int) ($settings['privacy_retention_years'] ?? 5));
render('settings_privacy.php', [
'title' => t('privacy.title'),
'settings' => $settings,
'review' => PrivacyService::retentionReview($years),
'accesses' => PrivacyService::accessLog(),
'saved' => isset($_GET['saved']),
'error' => (string) ($_GET['error'] ?? ''),
]);
}
public static function save(): void
{
self::admin();
self::postOnly();
$values = [];
foreach (
[
'privacy_retention_years' => 5,
'privacy_retention_adopter_years' => 5,
'privacy_retention_foster_years' => 5,
'privacy_retention_volunteer_years' => 5,
]
as $key => $default
) {
$values[$key] = (string) max(1, min(30, (int) ($_POST[$key] ?? $default)));
}
$values['privacy_access_log_months'] = (string) max(
1,
min(120, (int) ($_POST['privacy_access_log_months'] ?? 24)),
);
AppSettings::save($values, Auth::id());
AuditService::log(
'privacy_settings_saved',
'/settings/privacy/save',
'Politique de conservation modifiée',
null,
null,
$values,
);
header('Location: /settings/privacy?saved=1');
exit();
}
public static function export(): void
{
self::admin();
$id = (int) ($_GET['id'] ?? 0);
try {
$path = PrivacyService::exportContact($id);
PrivacyService::logAccess('contact_export', $id, null, 'contact-' . $id . '.zip', 'download');
AuditService::log(
'contact_exported',
'/settings/privacy/export',
'Export RGPD dun contact',
'contact',
$id,
);
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="contact-' . $id . '-globinours.zip"');
header('Content-Length: ' . filesize($path));
header('Cache-Control: private,no-store');
readfile($path);
@unlink($path);
} catch (RuntimeException $e) {
http_response_code(404);
echo h($e->getMessage());
} catch (Throwable $e) {
http_response_code(500);
echo h(SecurityService::publicError($e));
}
}
public static function anonymize(): void
{
self::admin();
self::postOnly();
$id = (int) ($_POST['id'] ?? 0);
try {
if (!isset($_POST['confirm_reviewed'])) {
throw new RuntimeException(t('privacy.review_required'));
}
PrivacyService::anonymize($id, (string) ($_POST['reason'] ?? ''));
header('Location: /settings/privacy?saved=1');
} catch (Throwable $e) {
header('Location: /settings/privacy?error=' . rawurlencode($e->getMessage()));
}
exit();
}
private static function postOnly(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
header('Allow: POST');
exit();
}
}
}

View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
final class ProjectController
{
public static function evolutions(): void
{
render('evolutions.php', [
'title' => t('footer.evolutions'),
'pageDescription' => t('project.evolutions_description'),
]);
}
}

View file

@ -0,0 +1,272 @@
<?php
declare(strict_types=1);
final class PublicSiteController
{
public static function usesRoot(): bool
{
return AppSettings::get('public_site_routing') === 'root';
}
public static function handles(string $path): bool
{
if ($path === '/site' || str_starts_with($path, '/site/')) {
return true;
}
return self::usesRoot() &&
in_array(
$path,
[
'/',
'/animaux',
'/adoption',
'/presentation',
'/dons',
'/contact',
'/mentions-legales',
'/confidentialite',
'/logo-association',
],
true,
);
}
public static function url(string $page = 'home', array $query = []): string
{
$root = self::usesRoot();
$path = match ($page) {
'home' => $root ? '/' : '/site',
'animals' => $root ? '/animaux' : '/site/animaux',
'animal' => $root ? '/adoption' : '/site/animal',
'about' => $root ? '/presentation' : '/site/presentation',
'donation' => $root ? '/dons' : '/site/dons',
'contact' => $root ? '/contact' : '/site/contact',
'legal' => $root ? '/mentions-legales' : '/site/mentions-legales',
'privacy' => $root ? '/confidentialite' : '/site/confidentialite',
'logo' => $root ? '/logo-association' : '/site/logo',
default => $root ? '/' : '/site',
};
return $path . ($query ? '?' . http_build_query($query) : '');
}
public static function dispatch(string $path): void
{
$path = self::normalize($path);
if (AppSettings::get('public_site_enabled') !== '1') {
http_response_code(404);
self::render('public_site_unavailable.php', ['title' => t('public_site.unavailable')]);
return;
}
if ($path === '/site/logo') {
self::logo();
return;
}
match ($path) {
'/site', '/site/' => self::home(),
'/site/animaux' => self::animals(),
'/site/animal' => self::animal(),
'/site/dons' => self::donation(),
'/site/contact' => self::contact(),
'/site/presentation' => self::about(),
'/site/mentions-legales' => self::legal(),
'/site/confidentialite' => self::privacy(),
default => self::notFound(),
};
}
private static function normalize(string $path): string
{
return match ($path) {
'/' => '/site',
'/animaux' => '/site/animaux',
'/adoption' => '/site/animal',
'/presentation' => '/site/presentation',
'/dons' => '/site/dons',
'/contact' => '/site/contact',
'/mentions-legales' => '/site/mentions-legales',
'/confidentialite' => '/site/confidentialite',
'/logo-association' => '/site/logo',
default => $path,
};
}
private static function home(): void
{
$db = DB::pdo();
$animals = self::animalQuery('', 8);
$counts = $db
->query(
"SELECT species,COUNT(*) count FROM animals WHERE website_published=1 AND adoption_availability='available' AND archived_at IS NULL AND deleted_at IS NULL AND status NOT IN ('adopte','decede') GROUP BY species ORDER BY species",
)
->fetchAll(PDO::FETCH_ASSOC);
self::render('public_site_home.php', [
'title' => AppSettings::get('public_site_home_title'),
'animals' => $animals,
'speciesCounts' => $counts,
]);
}
private static function animals(): void
{
$species = SpeciesService::normalize((string) ($_GET['espece'] ?? ''));
if ($species !== '' && !SpeciesService::exists($species, false)) {
$species = '';
}
self::render('public_site_animals.php', [
'title' => t('public_site.animals'),
'animals' => self::animalQuery($species, 200),
'selectedSpecies' => $species,
'publicSpecies' => self::species(),
]);
}
private static function animal(): void
{
$id = (int) ($_GET['id'] ?? 0);
$s = DB::pdo()->prepare(
"SELECT a.*,s.name species_name,s.icon species_icon,(SELECT filename FROM animal_photos p WHERE p.animal_id=a.id AND p.is_public=1 ORDER BY p.is_primary DESC,p.id DESC LIMIT 1) primary_photo FROM animals a LEFT JOIN ref_species s ON s.code=a.species WHERE a.id=? AND a.website_published=1 AND a.adoption_availability='available' AND a.archived_at IS NULL AND a.deleted_at IS NULL AND a.status NOT IN ('adopte','decede')",
);
$s->execute([$id]);
$animal = $s->fetch(PDO::FETCH_ASSOC);
if (!$animal) {
self::notFound();
return;
}
$p = DB::pdo()->prepare(
'SELECT filename,caption FROM animal_photos WHERE animal_id=? AND is_public=1 ORDER BY is_primary DESC,id DESC',
);
$p->execute([$id]);
self::render('public_site_animal.php', [
'title' => $animal['name'],
'animal' => $animal,
'photos' => $p->fetchAll(PDO::FETCH_ASSOC),
]);
}
private static function donation(): void
{
self::render('public_site_text.php', [
'title' => AppSettings::get('public_site_donation_title'),
'content' => AppSettings::get('public_site_donation_text'),
'kind' => 'donation',
]);
}
private static function contact(): void
{
self::render('public_site_contact.php', ['title' => t('public_site.contact')]);
}
private static function about(): void
{
if (AppSettings::get('public_site_about_enabled') !== '1') {
self::notFound();
return;
}
self::render('public_site_text.php', [
'title' => AppSettings::get('public_site_about_title'),
'content' => AppSettings::get('public_site_about_text'),
'kind' => 'about',
]);
}
private static function legal(): void
{
self::render('public_site_text.php', [
'title' => t('public_site.legal'),
'content' => self::legalText(),
'kind' => 'legal',
]);
}
private static function privacy(): void
{
self::render('public_site_text.php', [
'title' => t('public_site.privacy'),
'content' => self::privacyText(),
'kind' => 'privacy',
]);
}
public static function legalText(?array $settings = null): string
{
$settings ??= AppSettings::all();
$custom = trim((string) ($settings['public_site_legal_text'] ?? ''));
if ($custom !== '') {
return $custom;
}
$address =
implode(
', ',
array_filter([
$settings['association_address'] ?? '',
trim(($settings['association_postal_code'] ?? '') . ' ' . ($settings['association_city'] ?? '')),
]),
) ?:
t('public_site.to_complete');
return t('public_site.default_legal', [
'association' => $settings['association_name'] ?: t('public_site.to_complete'),
'address' => $address,
'email' => $settings['association_email'] ?: t('public_site.to_complete'),
'rna' => $settings['association_rna'] ?: t('public_site.to_complete'),
'siret' => $settings['association_siret'] ?: t('public_site.to_complete'),
]);
}
public static function privacyText(?array $settings = null): string
{
$settings ??= AppSettings::all();
$custom = trim((string) ($settings['public_site_privacy_text'] ?? ''));
if ($custom !== '') {
return $custom;
}
$address =
implode(
', ',
array_filter([
$settings['association_address'] ?? '',
trim(($settings['association_postal_code'] ?? '') . ' ' . ($settings['association_city'] ?? '')),
]),
) ?:
t('public_site.to_complete');
return t('public_site.default_privacy', [
'association' => $settings['association_name'] ?: t('public_site.to_complete'),
'address' => $address,
'email' => $settings['association_email'] ?: t('public_site.to_complete'),
]);
}
private static function logo(): void
{
$path = AssociationBrand::path();
if (!$path) {
http_response_code(404);
return;
}
$mime = new finfo(FILEINFO_MIME_TYPE)->file($path) ?: 'application/octet-stream';
header('Content-Type: ' . $mime);
header('Content-Length: ' . filesize($path));
header('Cache-Control: public,max-age=3600');
readfile($path);
}
private static function animalQuery(string $species, int $limit): array
{
$sql =
"SELECT a.id,a.name,a.species,a.sex,a.birth_date,a.birth_is_estimated,a.birth_estimated_months,a.breed,a.color,a.sterilization_status,a.compatibility_dogs,a.compatibility_cats,a.compatibility_children,a.house_trained,a.website_description,s.name species_name,s.icon species_icon,(SELECT filename FROM animal_photos p WHERE p.animal_id=a.id AND p.is_public=1 ORDER BY p.is_primary DESC,p.id DESC LIMIT 1) primary_photo FROM animals a LEFT JOIN ref_species s ON s.code=a.species WHERE a.website_published=1 AND a.adoption_availability='available' AND a.archived_at IS NULL AND a.deleted_at IS NULL AND a.status NOT IN ('adopte','decede')";
$params = [];
if ($species !== '') {
$sql .= ' AND a.species=:species';
$params[':species'] = $species;
}
$sql .= ' ORDER BY a.updated_at DESC,a.name COLLATE NOCASE LIMIT ' . max(1, min(500, $limit));
$s = DB::pdo()->prepare($sql);
$s->execute($params);
return $s->fetchAll(PDO::FETCH_ASSOC);
}
private static function species(): array
{
return DB::pdo()
->query(
"SELECT s.code,s.name,s.icon,COUNT(a.id) count FROM ref_species s JOIN animals a ON a.species=s.code AND a.website_published=1 AND a.adoption_availability='available' AND a.archived_at IS NULL AND a.deleted_at IS NULL AND a.status NOT IN ('adopte','decede') GROUP BY s.id ORDER BY s.sort_order,s.name COLLATE NOCASE",
)
->fetchAll(PDO::FETCH_ASSOC);
}
private static function render(string $view, array $vars = []): void
{
extract($vars, EXTR_SKIP);
$settings = AppSettings::all();
$publicSpecies = self::species();
require dirname(__DIR__) . '/Views/public_site_layout.php';
}
private static function notFound(): void
{
http_response_code(404);
self::render('public_site_unavailable.php', ['title' => t('public_site.not_found')]);
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
final class SearchController
{
public static function index(): void
{
$q = mb_substr(trim((string) ($_GET['q'] ?? '')), 0, 120);
render('search.php', [
'title' => t('search.title'),
'searchQuery' => $q,
'searchResults' => GlobalSearchService::search($q, 20),
]);
}
public static function json(): void
{
$q = mb_substr(trim((string) ($_GET['q'] ?? '')), 0, 120);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: private, no-store');
echo json_encode(
['query' => $q, 'groups' => GlobalSearchService::search($q)],
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR,
);
}
}

View file

@ -0,0 +1,998 @@
<?php
declare(strict_types=1);
final class SettingsController
{
private const ROLES = PermissionService::ROLES;
public static function general(): void
{
self::admin();
render('settings_general.php', [
'title' => t('settings.general_title'),
'settings' => AppSettings::all(),
'saved' => isset($_GET['saved']),
'error' => (string) ($_GET['error'] ?? ''),
'associationLogo' => AssociationBrand::path(),
]);
}
public static function saveGeneral(): void
{
self::admin();
self::postOnly();
$values = [];
foreach (
[
'association_name',
'association_address',
'association_postal_code',
'association_city',
'association_phone',
'association_email',
'association_siret',
'association_rna',
]
as $key
) {
$values[$key] = trim((string) ($_POST[$key] ?? ''));
}
if ($values['association_name'] === '') {
http_response_code(400);
echo h(t('settings.association_required'));
return;
}
if ($values['association_email'] !== '' && !filter_var($values['association_email'], FILTER_VALIDATE_EMAIL)) {
http_response_code(400);
echo h(t('settings.invalid_email'));
return;
}
$values['quarantine_days'] = (string) max(1, min(90, (int) ($_POST['quarantine_days'] ?? 15)));
$values['backup_retention'] = (string) max(1, min(50, (int) ($_POST['backup_retention'] ?? 10)));
$values['app_language'] = isset(I18n::LOCALES[(string) ($_POST['app_language'] ?? 'fr')])
? (string) $_POST['app_language']
: 'fr';
try {
$fullAddress = implode(
', ',
array_filter([
$values['association_address'],
trim($values['association_postal_code'] . ' ' . $values['association_city']),
]),
);
if ($fullAddress === '') {
$values['association_latitude'] = '';
$values['association_longitude'] = '';
} elseif ($point = GeoService::geocode(DB::pdo(), $fullAddress)) {
$values['association_latitude'] = (string) $point['lat'];
$values['association_longitude'] = (string) $point['lng'];
}
if (isset($_POST['delete_association_logo'])) {
AssociationBrand::delete();
} elseif (isset($_FILES['association_logo'])) {
AssociationBrand::store($_FILES['association_logo']);
}
AppSettings::save($values, Auth::id());
AuditService::log(
'settings_saved',
'/settings/general/save',
'Paramètres généraux et identité visuelle modifiés',
);
header('Location: /settings/general?saved=1');
} catch (Throwable $e) {
header('Location: /settings/general?error=' . rawurlencode(SecurityService::publicError($e)));
}
exit();
}
public static function associationLogo(): void
{
self::admin();
$path = AssociationBrand::path();
if (!$path) {
http_response_code(404);
return;
}
$mime = new finfo(FILEINFO_MIME_TYPE)->file($path) ?: 'application/octet-stream';
header('Content-Type: ' . $mime);
header('Content-Length: ' . filesize($path));
header('Cache-Control: private, max-age=3600');
header('X-Content-Type-Options: nosniff');
readfile($path);
}
public static function users(): void
{
self::admin();
$users = DB::pdo()
->query('SELECT * FROM users ORDER BY active DESC,display_name COLLATE NOCASE,username COLLATE NOCASE')
->fetchAll(PDO::FETCH_ASSOC);
render('settings_users.php', ['title' => t('settings.users_title'), 'users' => $users, 'roles' => self::ROLES]);
}
public static function saveUser(): void
{
self::admin();
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
http_response_code(405);
return;
}
$db = DB::pdo();
$id = (int) ($_POST['id'] ?? 0);
$username = trim((string) ($_POST['username'] ?? ''));
$display = trim((string) ($_POST['display_name'] ?? ''));
$role = (string) ($_POST['role'] ?? 'lecture');
$active = isset($_POST['active']) ? 1 : 0;
$password = (string) ($_POST['password'] ?? '');
if (!preg_match('/^[a-zA-Z0-9._-]{3,40}$/', $username) || $display === '' || !isset(self::ROLES[$role])) {
http_response_code(400);
echo h(t('settings.invalid_data'));
return;
}
if ($id === Auth::id() && (!$active || $role !== 'admin')) {
http_response_code(400);
echo h(t('settings.keep_admin'));
return;
}
if ($id) {
$params = [':u' => $username, ':d' => $display, ':r' => $role, ':a' => $active, ':id' => $id];
$sql = "UPDATE users SET username=:u,display_name=:d,role=:r,active=:a,updated_at=datetime('now')";
if ($password !== '') {
if (strlen($password) < 10) {
http_response_code(400);
echo h(t('settings.password_short'));
return;
}
$sql .= ',password_hash=:p';
$params[':p'] = password_hash($password, PASSWORD_DEFAULT);
}
$sql .= ' WHERE id=:id';
$db->prepare($sql)->execute($params);
$summary = 'Utilisateur modifié : ' . $display;
} else {
if (strlen($password) < 10) {
http_response_code(400);
echo h(t('settings.password_required'));
return;
}
$db->prepare(
'INSERT INTO users(username,password_hash,display_name,role,active) VALUES(:u,:p,:d,:r,:a)',
)->execute([
':u' => $username,
':p' => password_hash($password, PASSWORD_DEFAULT),
':d' => $display,
':r' => $role,
':a' => $active,
]);
$id = (int) $db->lastInsertId();
$summary = 'Utilisateur créé : ' . $display;
}
AuditService::log('user_saved', '/settings/users/save', $summary, 'user', $id, [
'role' => $role,
'active' => $active,
]);
header('Location: /settings/users');
exit();
}
public static function permissions(): void
{
self::admin();
render('settings_permissions.php', [
'title' => t('settings.permissions_title'),
'roles' => PermissionService::ROLES,
'modules' => PermissionService::MODULES,
'matrix' => PermissionService::matrix(),
'saved' => isset($_GET['saved']),
]);
}
public static function savePermissions(): void
{
self::admin();
self::postOnly();
PermissionService::save((array) ($_POST['permissions'] ?? []), Auth::id());
AuditService::log('permissions_saved', '/settings/permissions/save', 'Permissions des rôles modifiées');
header('Location: /settings/permissions?saved=1');
exit();
}
public static function species(): void
{
self::admin();
render('settings_species.php', [
'title' => t('settings.species_title'),
'species' => SpeciesService::all(),
'categories' => SpeciesService::CATEGORIES,
'saved' => isset($_GET['saved']),
'error' => (string) ($_GET['error'] ?? ''),
]);
}
public static function rooms(): void
{
self::admin();
render('settings_rooms.php', [
'title' => t('rooms.title'),
'rooms' => ShelterRoomService::all(),
'saved' => isset($_GET['saved']),
'error' => (string) ($_GET['error'] ?? ''),
]);
}
public static function saveRooms(): void
{
self::admin();
self::postOnly();
$db = DB::pdo();
$submitted = (array) ($_POST['rooms'] ?? []);
try {
$db->beginTransaction();
foreach ($submitted as $id => $row) {
$id = (int) $id;
if (!$id) {
continue;
}
$name = trim((string) ($row['name'] ?? ''));
$type = (string) ($row['room_type'] ?? 'collective');
$status = (string) ($row['status_code'] ?? 'refuge');
$color = (string) ($row['color'] ?? '#6c757d');
if (
$name === '' ||
!in_array($type, ['collective', 'boxes'], true) ||
!in_array($status, ['refuge', 'soin', 'quarantaine'], true) ||
!preg_match('/^#[0-9a-fA-F]{6}$/', $color)
) {
throw new RuntimeException(t('rooms.invalid'));
}
$db->prepare(
"UPDATE shelter_rooms SET name=?,room_type=?,status_code=?,color=?,bulk_validation=?,active=?,sort_order=?,updated_at=datetime('now') WHERE id=?",
)->execute([
$name,
$type,
$status,
$color,
isset($row['bulk_validation']) ? 1 : 0,
isset($row['active']) ? 1 : 0,
max(0, (int) ($row['sort_order'] ?? 100)),
$id,
]);
self::syncBoxes($db, $id, (string) ($row['boxes'] ?? ''));
}
$new = (array) ($_POST['new_room'] ?? []);
$newName = trim((string) ($new['name'] ?? ''));
if ($newName !== '') {
$code = ShelterRoomService::normalizeCode((string) ($new['code'] ?? $newName));
if (strlen($code) < 2) {
throw new RuntimeException(t('rooms.invalid'));
}
$type = in_array($new['room_type'] ?? '', ['collective', 'boxes'], true)
? (string) $new['room_type']
: 'collective';
$status = in_array($new['status_code'] ?? '', ['refuge', 'soin', 'quarantaine'], true)
? (string) $new['status_code']
: 'refuge';
$color = preg_match('/^#[0-9a-fA-F]{6}$/', (string) ($new['color'] ?? ''))
? (string) $new['color']
: '#6c757d';
$db->prepare(
'INSERT INTO shelter_rooms(code,name,room_type,status_code,color,bulk_validation,sort_order) VALUES(?,?,?,?,?,?,?)',
)->execute([$code, $newName, $type, $status, $color, isset($new['bulk_validation']) ? 1 : 0, 100]);
self::syncBoxes($db, (int) $db->lastInsertId(), (string) ($new['boxes'] ?? ''));
}
$db->commit();
AuditService::log('rooms_saved', '/settings/rooms/save', 'Salles et box du refuge modifiés');
header('Location: /settings/rooms?saved=1');
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
header('Location: /settings/rooms?error=' . rawurlencode(SecurityService::publicError($e)));
}
exit();
}
private static function syncBoxes(PDO $db, int $roomId, string $lines): void
{
$names = array_values(
array_filter(array_map('trim', preg_split('/\R/u', $lines) ?: []), static fn($name) => $name !== ''),
);
$existing = $db->prepare('SELECT id,code FROM shelter_boxes WHERE room_id=? ORDER BY sort_order,id');
$existing->execute([$roomId]);
$known = $existing->fetchAll(PDO::FETCH_ASSOC);
$kept = [];
foreach ($names as $index => $name) {
$position = ($index + 1) * 10;
if (isset($known[$index])) {
$id = (int) $known[$index]['id'];
$kept[] = $id;
$db->prepare(
"UPDATE shelter_boxes SET name=?,active=1,sort_order=?,updated_at=datetime('now') WHERE id=?",
)->execute([$name, $position, $id]);
continue;
}
$code = ShelterRoomService::normalizeCode($name) ?: 'box-' . ($index + 1);
$base = $code;
$suffix = 2;
$exists = $db->prepare('SELECT 1 FROM shelter_boxes WHERE room_id=? AND code=?');
while (true) {
$exists->execute([$roomId, $code]);
if (!$exists->fetchColumn()) {
break;
}
$code = $base . '-' . $suffix++;
}
$db->prepare('INSERT INTO shelter_boxes(room_id,code,name,sort_order) VALUES(?,?,?,?)')->execute([
$roomId,
$code,
$name,
$position,
]);
$kept[] = (int) $db->lastInsertId();
}
foreach ($known as $box) {
if (!in_array((int) $box['id'], $kept, true)) {
$db->prepare("UPDATE shelter_boxes SET active=0,updated_at=datetime('now') WHERE id=?")->execute([
(int) $box['id'],
]);
}
}
}
public static function saveSpecies(): void
{
self::admin();
self::postOnly();
$db = DB::pdo();
$id = (int) ($_POST['id'] ?? 0);
$name = trim((string) ($_POST['name'] ?? ''));
$code = SpeciesService::normalize((string) ($_POST['code'] ?? ''));
$category = (string) ($_POST['category'] ?? 'autre');
$icon = trim((string) ($_POST['icon'] ?? '')) ?: '🐾';
$color = trim((string) ($_POST['color'] ?? '#6c757d'));
$active = isset($_POST['active']) ? 1 : 0;
$sort = max(0, min(999, (int) ($_POST['sort_order'] ?? 100)));
if (
$name === '' ||
!preg_match('/^[a-z0-9][a-z0-9-]{1,49}$/', $code) ||
!isset(SpeciesService::CATEGORIES[$category]) ||
!preg_match('/^#[0-9a-fA-F]{6}$/', $color)
) {
header('Location: /settings/species?error=' . rawurlencode(t('settings.species_invalid')));
exit();
}
try {
if ($id > 0) {
$old = $db->prepare('SELECT code,is_system FROM ref_species WHERE id=:id');
$old->execute([':id' => $id]);
$oldRow = $old->fetch(PDO::FETCH_ASSOC);
if (!$oldRow) {
throw new RuntimeException(t('settings.species_not_found'));
}
$oldCode = (string) $oldRow['code'];
if ((int) $oldRow['is_system'] === 1) {
$code = $oldCode;
}
$db->beginTransaction();
$db->prepare(
'UPDATE ref_species SET code=:code,name=:name,category=:category,icon=:icon,color=:color,active=:active,sort_order=:sort,updated_at=datetime(\'now\') WHERE id=:id',
)->execute([
':code' => $code,
':name' => $name,
':category' => $category,
':icon' => $icon,
':color' => $color,
':active' => $active,
':sort' => $sort,
':id' => $id,
]);
if ($oldCode !== $code) {
$db->prepare('UPDATE animals SET species=:new WHERE lower(trim(species))=lower(:old)')->execute([
':new' => $code,
':old' => $oldCode,
]);
}
$db->commit();
} else {
$db->prepare(
'INSERT INTO ref_species(code,name,category,icon,color,active,sort_order) VALUES(:code,:name,:category,:icon,:color,:active,:sort)',
)->execute([
':code' => $code,
':name' => $name,
':category' => $category,
':icon' => $icon,
':color' => $color,
':active' => $active,
':sort' => $sort,
]);
}
AuditService::log(
'species_saved',
'/settings/species/save',
'Espèce enregistrée : ' . $name,
'species',
$id ?: ((int) $db->lastInsertId()),
);
header('Location: /settings/species?saved=1');
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
header('Location: /settings/species?error=' . rawurlencode(SecurityService::publicError($e)));
}
exit();
}
public static function pricing(): void
{
self::admin();
$db = DB::pdo();
$clinics = $db
->query(
"SELECT DISTINCT dc.id,dc.name FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id AND r.role IN ('cabinet','crematorium','fourriere') WHERE dc.kind='organization' AND dc.deleted_at IS NULL ORDER BY dc.name COLLATE NOCASE",
)
->fetchAll(PDO::FETCH_ASSOC);
$discounts = [];
foreach ($db->query('SELECT * FROM clinic_discount_rules')->fetchAll(PDO::FETCH_ASSOC) as $r) {
$discounts[(int) $r['clinic_contact_id']][(string) $r['discount_group']] = $r;
}
render('settings_pricing.php', [
'title' => t('pricing.title'),
'tariffs' => PricingService::tariffs(),
'clinics' => $clinics,
'discounts' => $discounts,
'categories' => PricingService::CATEGORIES,
'groups' => PricingService::GROUPS,
'medications' => $db
->query('SELECT id,name FROM ref_medications ORDER BY name COLLATE NOCASE')
->fetchAll(PDO::FETCH_ASSOC),
'vaccines' => $db
->query('SELECT id,name FROM ref_vaccines ORDER BY name COLLATE NOCASE')
->fetchAll(PDO::FETCH_ASSOC),
'dewormers' => $db
->query('SELECT id,name FROM ref_dewormers ORDER BY name COLLATE NOCASE')
->fetchAll(PDO::FETCH_ASSOC),
'settings' => AppSettings::all(),
'saved' => isset($_GET['saved']),
'error' => (string) ($_GET['error'] ?? ''),
]);
}
public static function saveTariff(): void
{
self::admin();
self::postOnly();
$db = DB::pdo();
$id = (int) ($_POST['id'] ?? 0);
$clinic = (int) ($_POST['clinic_contact_id'] ?? 0);
$label = trim((string) ($_POST['label'] ?? ''));
$category = (string) ($_POST['category'] ?? 'other');
$group = (string) ($_POST['discount_group'] ?? 'act');
$unit = trim((string) ($_POST['unit_label'] ?? 'acte')) ?: 'acte';
$amount = PricingService::cents((string) ($_POST['amount'] ?? '0'));
$active = isset($_POST['active']) ? 1 : 0;
$eligible = $db->prepare(
"SELECT 1 FROM directory_contacts dc WHERE dc.id=:id AND dc.kind='organization' AND dc.deleted_at IS NULL AND EXISTS(SELECT 1 FROM directory_contact_roles r WHERE r.contact_id=dc.id AND r.role IN ('cabinet','crematorium','fourriere'))",
);
$eligible->execute([':id' => $clinic]);
if (
!$clinic ||
!$eligible->fetchColumn() ||
$label === '' ||
!isset(PricingService::CATEGORIES[$category]) ||
!isset(PricingService::GROUPS[$group])
) {
header('Location: /settings/pricing?error=' . rawurlencode(t('pricing.invalid_structure')));
exit();
}
$params = [
$clinic,
mb_substr($label, 0, 180),
$category,
$group,
mb_substr($unit, 0, 50),
(int) ($_POST['medication_id'] ?? 0) ?: null,
(int) ($_POST['vaccine_id'] ?? 0) ?: null,
(int) ($_POST['dewormer_id'] ?? 0) ?: null,
$amount,
$active,
trim((string) ($_POST['notes'] ?? '')) ?: null,
];
try {
if ($id) {
$params[] = $id;
$db->prepare(
"UPDATE clinic_tariffs SET clinic_contact_id=?,label=?,category=?,discount_group=?,unit_label=?,medication_id=?,vaccine_id=?,dewormer_id=?,amount_cents=?,active=?,notes=?,updated_at=datetime('now') WHERE id=?",
)->execute($params);
} else {
$db->prepare(
'INSERT INTO clinic_tariffs(clinic_contact_id,label,category,discount_group,unit_label,medication_id,vaccine_id,dewormer_id,amount_cents,active,notes) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
)->execute($params);
$id = (int) $db->lastInsertId();
}
AuditService::log(
'tariff_saved',
'/settings/pricing/tariff',
'Tarif enregistré : ' . $label,
'clinic_tariff',
$id,
);
header('Location: /settings/pricing?saved=1');
} catch (Throwable $e) {
header('Location: /settings/pricing?error=' . rawurlencode(SecurityService::publicError($e)));
}
exit();
}
public static function saveDiscount(): void
{
self::admin();
self::postOnly();
$clinic = (int) ($_POST['clinic_contact_id'] ?? 0);
$submitted = (array) ($_POST['discounts'] ?? []);
$db = DB::pdo();
$eligible = $db->prepare(
"SELECT 1 FROM directory_contacts dc WHERE dc.id=:id AND dc.kind='organization' AND dc.deleted_at IS NULL AND EXISTS(SELECT 1 FROM directory_contact_roles r WHERE r.contact_id=dc.id AND r.role IN ('cabinet','crematorium','fourriere'))",
);
$eligible->execute([':id' => $clinic]);
if (!$clinic || !$eligible->fetchColumn()) {
http_response_code(400);
echo h(t('pricing.invalid_discount_structure'));
return;
}
$stmt = $db->prepare(
'INSERT INTO clinic_discount_rules(clinic_contact_id,discount_group,label,percent) VALUES(?,?,?,?) ON CONFLICT(clinic_contact_id,discount_group) DO UPDATE SET label=excluded.label,percent=excluded.percent',
);
$saved = [];
try {
$db->beginTransaction();
foreach (PricingService::GROUPS as $group => $label) {
if ($group === 'none' || !array_key_exists($group, $submitted)) {
continue;
}
$percent = max(0, min(100, (float) str_replace(',', '.', (string) $submitted[$group])));
$stmt->execute([$clinic, $group, $label, $percent]);
$saved[$group] = $percent;
}
$db->commit();
AuditService::log(
'discount_saved',
'/settings/pricing/discount',
'Remises tarifaires modifiées',
'clinic',
$clinic,
$saved,
);
header('Location: /settings/pricing?saved=1');
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
header('Location: /settings/pricing?error=' . rawurlencode(SecurityService::publicError($e)));
}
exit();
}
public static function saveAdoptionFees(): void
{
self::admin();
self::postOnly();
AppSettings::save(
[
'adoption_fee_sterilized' => (string) max(
0,
min(2000, (int) ($_POST['adoption_fee_sterilized'] ?? 220)),
),
'adoption_fee_unsterilized' => (string) max(
0,
min(2000, (int) ($_POST['adoption_fee_unsterilized'] ?? 200)),
),
],
Auth::id(),
);
AuditService::log('adoption_fees_saved', '/settings/pricing/adoption', 'Tarifs dadoption modifiés');
header('Location: /settings/pricing?saved=1');
exit();
}
public static function labReferences(): void
{
self::admin();
$rows = LabReferenceService::all();
$groups = [];
foreach ($rows as $row) {
$groups[$row['analyzer_name']][$row['species_code']][] = $row;
}
render('settings_lab_references.php', [
'title' => t('lab_reference.title'),
'groups' => $groups,
'saved' => isset($_GET['saved']),
'error' => (string) ($_GET['error'] ?? ''),
]);
}
public static function saveLabReferences(): void
{
self::admin();
self::postOnly();
$db = DB::pdo();
$input = (array) ($_POST['ranges'] ?? []);
$stmt = $db->prepare(
"UPDATE lab_reference_ranges SET parameter_name=?,unit=?,reference_min=?,reference_max=?,source_note=?,active=?,updated_by=?,updated_at=datetime('now') WHERE id=?",
);
try {
$db->beginTransaction();
foreach ($input as $id => $row) {
$id = (int) $id;
$name = trim((string) ($row['name'] ?? ''));
$unit = trim((string) ($row['unit'] ?? ''));
$min = self::nullableNumber($row['min'] ?? '');
$max = self::nullableNumber($row['max'] ?? '');
if (!$id || $name === '' || $unit === '' || ($min !== null && $max !== null && $min > $max)) {
throw new RuntimeException(t('lab_reference.invalid'));
}
$stmt->execute([
mb_substr($name, 0, 120),
mb_substr($unit, 0, 30),
$min,
$max,
mb_substr(trim((string) ($row['source'] ?? '')), 0, 255) ?: null,
isset($row['active']) ? 1 : 0,
Auth::id(),
$id,
]);
}
$db->commit();
AuditService::log(
'lab_references_saved',
'/settings/lab-references/save',
'Intervalles de référence biologiques modifiés',
);
header('Location: /settings/lab-references?saved=1');
} catch (RuntimeException $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
header('Location: /settings/lab-references?error=' . rawurlencode($e->getMessage()));
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
header('Location: /settings/lab-references?error=' . rawurlencode(SecurityService::publicError($e)));
}
exit();
}
public static function publicSite(): void
{
self::admin();
render('settings_public_site.php', [
'title' => t('public_site.settings_title'),
'settings' => AppSettings::all(),
'saved' => isset($_GET['saved']),
'error' => (string) ($_GET['error'] ?? ''),
]);
}
public static function health(): void
{
self::admin();
render('settings_health.php', [
'title' => t('health.page_title'),
'report' => SystemHealthService::report(),
'installed' => isset($_GET['installed']),
]);
}
public static function notifications(): void
{
self::admin();
render('settings_notifications.php', [
'title' => t('notification.page_title'),
'settings' => AppSettings::all(),
'saved' => isset($_GET['saved']),
'sent' => (string) ($_GET['sent'] ?? ''),
]);
}
public static function saveNotifications(): void
{
self::admin();
self::postOnly();
$email = trim((string) ($_POST['notification_email_recipient'] ?? ''));
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
header('Location: /settings/notifications?sent=invalid');
exit();
}
$frequency = in_array((string) ($_POST['notification_email_frequency'] ?? ''), ['daily', 'weekly'], true)
? (string) $_POST['notification_email_frequency']
: 'weekly';
AppSettings::save(
[
'notification_email_enabled' => isset($_POST['notification_email_enabled']) ? '1' : '0',
'notification_email_frequency' => $frequency,
'notification_email_recipient' => $email,
],
Auth::id(),
);
AuditService::log('notifications_saved', '/settings/notifications', 'Configuration des notifications modifiée');
header('Location: /settings/notifications?saved=1');
exit();
}
public static function sendNotifications(): void
{
self::admin();
self::postOnly();
$result = NotificationService::send(true);
AuditService::log(
'notification_digest',
'/settings/notifications/send',
'Envoi manuel du résumé : ' . $result['reason'],
);
header('Location: /settings/notifications?sent=' . rawurlencode((string) $result['reason']));
exit();
}
public static function savePublicSite(): void
{
self::admin();
self::postOnly();
$theme = (string) ($_POST['public_site_theme'] ?? 'warm');
if (!in_array($theme, ['warm', 'nature', 'minimal'], true)) {
$theme = 'warm';
}
$routing = (string) ($_POST['public_site_routing'] ?? 'integrated');
if (!in_array($routing, ['integrated', 'root'], true)) {
$routing = 'integrated';
}
$values = [
'public_site_enabled' => isset($_POST['public_site_enabled']) ? '1' : '0',
'public_site_theme' => $theme,
'public_site_routing' => $routing,
'public_site_about_enabled' => isset($_POST['public_site_about_enabled']) ? '1' : '0',
];
foreach (
[
'public_site_home_title' => 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);
}
foreach (['public_site_donation_url', 'public_site_facebook_url', 'public_site_instagram_url'] as $key) {
$url = trim((string) ($_POST[$key] ?? ''));
$scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
if ($url !== '' && (!filter_var($url, FILTER_VALIDATE_URL) || $scheme !== 'https')) {
header('Location: /settings/public-site?error=' . rawurlencode(t('public_site.invalid_url')));
exit();
}
$values[$key] = $url;
}
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
{
self::admin();
$rows = DB::pdo()
->query(
'SELECT al.*,COALESCE(u.display_name,u.username) actor FROM audit_log al LEFT JOIN users u ON u.id=al.user_id ORDER BY al.id DESC LIMIT 500',
)
->fetchAll(PDO::FETCH_ASSOC);
render('settings_audit.php', ['title' => t('audit.title'), 'rows' => $rows]);
}
public static function dataTools(): void
{
self::admin();
$db = DB::pdo();
render('settings_data.php', [
'title' => t('data.title'),
'counts' => [
'animals' => (int) $db->query('SELECT COUNT(*) FROM animals')->fetchColumn(),
'contacts' => (int) $db->query('SELECT COUNT(*) FROM directory_contacts')->fetchColumn(),
],
'status' => (string) ($_GET['status'] ?? ''),
'backup' => (string) ($_GET['backup'] ?? ''),
'error' => (string) ($_GET['error'] ?? ''),
]);
}
public static function resetData(): void
{
self::dataOperation(false);
}
public static function loadDemoData(): void
{
self::dataOperation(true);
}
private static function dataOperation(bool $demo): void
{
self::admin();
self::postOnly();
$expected = t($demo ? 'data.confirm_demo_phrase' : 'data.confirm_reset_phrase');
if (trim((string) ($_POST['confirmation'] ?? '')) !== $expected) {
header(
'Location: /settings/data?error=' . rawurlencode(t('data.confirm_required', ['phrase' => $expected])),
);
exit();
}
$db = DB::pdo();
try {
$backup = BackupService::create($demo ? 'pre-demo-data' : 'pre-data-reset');
$db->beginTransaction();
$removed = DemoDataService::reset($db);
$demo ? DemoDataService::seed($db, Auth::id()) : null;
$db->commit();
DemoDataService::clearFiles();
if ($demo) {
DemoDataService::installFiles($db);
}
AuditService::log(
$demo ? 'demo_data_loaded' : 'business_data_reset',
'/settings/data',
$demo ? 'Base fictive de démonstration chargée' : 'Données métier remises à zéro',
null,
null,
[
'removed_animals' => $removed['animals'],
'removed_contacts' => $removed['contacts'],
'backup' => $backup['name'],
],
);
header(
'Location: /settings/data?status=' .
($demo ? 'demo' : 'reset') .
'&backup=' .
rawurlencode($backup['name']),
);
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
header('Location: /settings/data?error=' . rawurlencode(SecurityService::publicError($e)));
}
exit();
}
public static function backups(): void
{
self::admin();
render('settings_backups.php', [
'title' => t('backups.title'),
'backups' => BackupService::list(),
'status' => (string) ($_GET['status'] ?? ''),
'error' => (string) ($_GET['error'] ?? ''),
]);
}
public static function createBackup(): void
{
self::admin();
self::postOnly();
try {
$backup = BackupService::create();
AuditService::log('backup_created', '/settings/backups/create', 'Sauvegarde créée : ' . $backup['name']);
header('Location: /settings/backups?status=created');
} catch (Throwable $e) {
header('Location: /settings/backups?error=' . rawurlencode(SecurityService::publicError($e)));
}
exit();
}
public static function saveBackupSchedule(): void
{
self::admin();
self::postOnly();
$frequency = (string) ($_POST['backup_schedule_frequency'] ?? 'daily');
if (!in_array($frequency, ['daily', 'weekly'], true)) {
$frequency = 'daily';
}
AppSettings::save(
[
'backup_schedule_enabled' => isset($_POST['backup_schedule_enabled']) ? '1' : '0',
'backup_schedule_frequency' => $frequency,
],
Auth::id(),
);
AuditService::log(
'backup_schedule_saved',
'/settings/backups/schedule',
'Planification des sauvegardes modifiée',
);
header('Location: /settings/backups?status=schedule');
exit();
}
public static function verifyBackup(): void
{
self::admin();
self::postOnly();
try {
$result = BackupService::verify((string) ($_POST['file'] ?? ''));
AppSettings::save(['backup_last_verified_at' => date('Y-m-d H:i:s')], Auth::id());
AuditService::log(
'backup_verified',
'/settings/backups/verify',
'Sauvegarde vérifiée : ' . $result['name'],
);
header('Location: /settings/backups?status=verified');
} catch (RuntimeException $e) {
header('Location: /settings/backups?error=' . rawurlencode($e->getMessage()));
} catch (Throwable $e) {
header('Location: /settings/backups?error=' . rawurlencode(SecurityService::publicError($e)));
}
exit();
}
public static function downloadBackup(): void
{
self::admin();
$path = BackupService::path((string) ($_GET['file'] ?? ''));
if (!$path) {
http_response_code(404);
echo h(t('backups.not_found'));
return;
}
PrivacyService::logAccess('backup', null, null, basename($path));
AuditService::log('private_file_viewed', '/settings/backups/download', 'Sauvegarde privée téléchargée');
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="' . basename($path) . '"');
header('Content-Length: ' . filesize($path));
header('Cache-Control: private,no-store');
header('X-Content-Type-Options: nosniff');
readfile($path);
}
public static function deleteBackup(): void
{
self::admin();
self::postOnly();
$name = (string) ($_POST['file'] ?? '');
try {
BackupService::delete($name);
AuditService::log('backup_deleted', '/settings/backups/delete', 'Sauvegarde supprimée : ' . $name);
header('Location: /settings/backups?status=deleted');
} catch (RuntimeException $e) {
header('Location: /settings/backups?error=' . rawurlencode($e->getMessage()));
} catch (Throwable $e) {
header('Location: /settings/backups?error=' . rawurlencode(SecurityService::publicError($e)));
}
exit();
}
public static function restoreBackup(): void
{
self::admin();
self::postOnly();
$upload = $_FILES['backup'] ?? null;
if (
!is_array($upload) ||
($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK ||
!is_uploaded_file((string) $upload['tmp_name'])
) {
header('Location: /settings/backups?error=' . rawurlencode(t('backups.invalid_file')));
exit();
}
if ((int) ($upload['size'] ?? 0) > 1_000_000_000) {
header('Location: /settings/backups?error=' . rawurlencode(t('backups.too_large')));
exit();
}
try {
BackupService::restore((string) $upload['tmp_name']);
Auth::logout();
header('Location: /login?restored=1');
} catch (RuntimeException $e) {
header('Location: /settings/backups?error=' . rawurlencode($e->getMessage()));
} catch (Throwable $e) {
header('Location: /settings/backups?error=' . rawurlencode(SecurityService::publicError($e)));
}
exit();
}
private static function postOnly(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
header('Allow: POST');
exit();
}
}
private static function nullableNumber(mixed $value): ?float
{
$value = str_replace(',', '.', trim((string) $value));
if ($value === '') {
return null;
}
if (!is_numeric($value)) {
throw new RuntimeException(t('lab_reference.invalid'));
}
return (float) $value;
}
private static function admin(): void
{
if (!Auth::is('admin')) {
http_response_code(403);
echo h(t('settings.admin_only'));
exit();
}
}
}

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
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();
$municipalities = StatisticsService::municipalities();
$municipality = trim((string) ($_GET['municipality'] ?? ''));
if ($municipality !== '' && !isset($municipalities[$municipality])) {
$municipality = '';
}
$ids = $municipality !== '' ? StatisticsService::animalIdsForMunicipality($municipality) : null;
render('statistics.php', [
'title' => t('statistics.title'),
'pageDescription' => t('statistics.description'),
'year' => $year,
'allYears' => $all,
'years' => $years,
'municipality' => $municipality,
'municipalities' => $municipalities,
'municipalityCoverage' => StatisticsService::municipalityCoverage(),
'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($year, $ids, $municipality ?: null),
'health' => StatisticsService::currentHealth($ids),
'costs' => StatisticsService::costs($year, $ids),
'municipalityCosts' => StatisticsService::costsByMunicipality($year),
]);
}
}

View file

@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
final class SurgeriesController
{
public static function form(): void
{
$id = (int) ($_GET['animal_id'] ?? 0);
$db = DB::pdo();
$s = $db->prepare('SELECT id,name,species FROM animals WHERE id=? AND deleted_at IS NULL');
$s->execute([$id]);
$animal = $s->fetch(PDO::FETCH_ASSOC);
if (!$animal) {
http_response_code(404);
return;
}
$clinics = $db
->query(
"SELECT DISTINCT dc.id,dc.name 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 ('cabinet','structure_veterinaire') ORDER BY dc.name COLLATE NOCASE",
)
->fetchAll(PDO::FETCH_ASSOC);
$vets = $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(PDO::FETCH_ASSOC);
$tariffs = $db
->query(
"SELECT ct.id,ct.clinic_contact_id,ct.label,ct.amount_cents,COALESCE(dr.percent,0) discount FROM clinic_tariffs ct LEFT JOIN clinic_discount_rules dr ON dr.clinic_contact_id=ct.clinic_contact_id AND dr.discount_group=ct.discount_group WHERE ct.active=1 AND ct.category IN ('surgery','dental','imaging') ORDER BY ct.label COLLATE NOCASE",
)
->fetchAll(PDO::FETCH_ASSOC);
render('surgery_form.php', [
'title' => 'Chirurgie de ' . $animal['name'],
'animal' => $animal,
'clinics' => $clinics,
'vets' => $vets,
'tariffs' => $tariffs,
]);
}
public static function save(): void
{
$animal = (int) ($_POST['animal_id'] ?? 0);
$name = trim((string) ($_POST['procedure_name'] ?? ''));
$date = (string) ($_POST['surgery_date'] ?? '');
$category = (string) ($_POST['category'] ?? 'other');
$status = (string) ($_POST['status'] ?? 'completed');
if (
!$animal ||
$name === '' ||
!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) ||
!in_array($status, ['planned', 'completed', 'cancelled'], true)
) {
http_response_code(400);
echo h(t('surgery.invalid_data'));
return;
}
$db = DB::pdo();
$tariff = (int) ($_POST['tariff_id'] ?? 0) ?: null;
$amountRaw = str_replace(',', '.', trim((string) ($_POST['amount'] ?? '')));
$amount = $amountRaw === '' ? null : (int) round(max(0, (float) $amountRaw) * 100);
if ($amount === null && $tariff) {
$q = $db->prepare(
'SELECT amount_cents*(1-COALESCE((SELECT percent FROM clinic_discount_rules WHERE clinic_contact_id=ct.clinic_contact_id AND discount_group=ct.discount_group),0)/100.0) FROM clinic_tariffs ct WHERE id=?',
);
$q->execute([$tariff]);
$v = $q->fetchColumn();
$amount = $v === false ? null : (int) round((float) $v);
}
$db->beginTransaction();
try {
$s = $db->prepare(
'INSERT INTO animal_surgeries(animal_id,category,procedure_name,surgery_date,status,clinic_contact_id,veterinarian_contact_id,tariff_id,amount_cents,anesthesia,notes,follow_up,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)',
);
$s->execute([
$animal,
$category,
$name,
$date,
$status,
(int) ($_POST['clinic_contact_id'] ?? 0) ?: null,
(int) ($_POST['veterinarian_contact_id'] ?? 0) ?: null,
$tariff,
$amount,
trim((string) ($_POST['anesthesia'] ?? '')) ?: null,
trim((string) ($_POST['notes'] ?? '')) ?: null,
trim((string) ($_POST['follow_up'] ?? '')) ?: null,
Auth::id(),
]);
$id = (int) $db->lastInsertId();
if ($status === 'completed' && $amount !== null) {
$db->prepare(
'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,
(int) ($_POST['clinic_contact_id'] ?? 0) ?: null,
$tariff,
$id,
$name,
$date,
$amount,
$amount,
trim((string) ($_POST['notes'] ?? '')) ?: null,
Auth::id(),
]);
}
$db->prepare(
"INSERT INTO animal_history(animal_id,type,label,details,user_id) VALUES(?,'medical','Chirurgie enregistrée',?,?)",
)->execute([$animal, $name . ' — ' . $date, Auth::id()]);
$db->commit();
header('Location: /animal?id=' . $animal . '#tab-med');
} catch (Throwable $e) {
$db->rollBack();
throw $e;
}
}
}