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

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 d’entré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 d’accueil',
'veterinaire' => 'Chez le vétérinaire',
'exterieur' => 'À l’exté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 d’analyse 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 d’accueil',
'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 d’un 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 d’adoption 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;
}
}
}

441
app/Routes/web.php Normal file
View file

@ -0,0 +1,441 @@
<?php
declare(strict_types=1);
switch ($path) {
case '/search':
SearchController::index();
break;
case '/search.json':
SearchController::json();
break;
case '/accounting':
AccountingController::dashboard();
break;
case '/accounting/export.csv':
AccountingController::exportFilteredCsv();
break;
case '/accounting/edit':
AccountingController::edit();
break;
case '/accounting/edit-by-reference':
AccountingController::editByReference();
break;
case '/accounting/save':
AccountingController::save();
break;
case '/accounting/update':
AccountingController::update();
break;
case '/accounting/delete':
AccountingController::delete();
break;
case '/accounting/document':
AccountingController::document();
break;
case '/agenda':
AgendaController::index();
break;
case '/agenda/save':
AgendaController::save();
break;
case '/agenda/status':
AgendaController::status();
break;
case '/agenda/export.ics':
AgendaController::export();
break;
case '/':
DashboardController::index();
break;
case '/animals':
AnimalsController::index();
break;
case '/animals/archived':
AnimalsController::inactiveList('archived');
break;
case '/animals/trash':
AnimalsController::inactiveList('trash');
break;
case '/animal':
AnimalsController::show();
break;
case '/animal/add-medical':
AnimalsController::addMedical();
break;
case '/animal/prescription/save':
MedicalDocumentsController::savePrescription();
break;
case '/animal/lab-report/save':
MedicalDocumentsController::saveLabReport();
break;
case '/animal/medical-document':
MedicalDocumentsController::download();
break;
case '/private-media':
PrivateMediaService::send();
break;
case '/animal/medical-status':
AnimalsController::updateMedicalStatus();
break;
case '/animal/add-treatment':
AnimalsController::addTreatment();
break;
case '/animal/add-deworming':
DewormingController::save();
break;
case '/animal/add-vaccine':
AnimalsController::addVaccine();
break;
case '/animal/add-expense':
AnimalsController::addExpense();
break;
case '/animal/surgery/new':
SurgeriesController::form();
break;
case '/animal/surgery/save':
SurgeriesController::save();
break;
case '/dev/backfill-weights':
DevController::backfillWeights();
break;
case '/animal/upload-photos':
AnimalsController::uploadPhotos();
break;
case '/animal/set-primary-photo':
AnimalsController::setPrimaryPhoto();
break;
case '/dev/backup':
DevController::backup();
break;
case '/dev/backfill-movements':
DevController::backfillMovements();
break;
case '/animal/pdf':
AnimalsController::pdf();
break;
case '/dashboard':
DashboardController::index();
break;
case '/statistics':
StatisticsController::index();
break;
case '/evolutions':
ProjectController::evolutions();
break;
case '/directory':
DirectoryController::index();
break;
case '/directory/view':
DirectoryController::show();
break;
case '/directory/new':
case '/directory/edit':
DirectoryController::form();
break;
case '/directory/save':
DirectoryController::save();
break;
case '/care-round':
CareRoundsController::index();
break;
case '/care-round/save':
CareRoundsController::saveObservation();
break;
case '/care-round/save-room':
CareRoundsController::saveRoom();
break;
case '/care-round/setup':
CareRoundsController::setup();
break;
case '/care-round/setup/save':
CareRoundsController::saveSetup();
break;
case '/care-round/treatment-schedule':
CareRoundsController::saveTreatmentSchedule();
break;
case '/animal/new':
AnimalsController::form();
break;
case '/animal/edit':
AnimalsController::form();
break;
case '/animal/save':
AnimalsController::save();
break;
case '/animal/death':
DeathController::form();
break;
case '/animal/death/save':
DeathController::save();
break;
case '/litter/new':
AnimalsController::litterForm();
break;
case '/litter/save':
AnimalsController::saveLitter();
break;
case '/litter/add-existing':
AnimalsController::litterAddExistingForm();
break;
case '/litter/add-existing/save':
AnimalsController::saveExistingLitterKittens();
break;
case '/bonded/new':
AnimalsController::bondedForm();
break;
case '/bonded/save':
AnimalsController::saveBondedGroup();
break;
case '/bonded/dissolve':
AnimalsController::dissolveBondedGroup();
break;
case '/animal/history':
AnimalsController::history();
break;
case '/animal/delete':
AnimalsController::delete();
break;
case '/animal/archive':
AnimalsController::archive();
break;
case '/animal/restore':
AnimalsController::restore();
break;
case '/animal/photos/upload':
AnimalsController::uploadPhotos();
break;
case '/animal/photos/primary':
AnimalsController::setPrimaryPhoto();
break;
case '/animal/photos/delete':
AnimalsController::deletePhoto();
break;
case '/animal/photos/toggle':
AnimalsController::togglePhotoVisibility();
break;
case '/animal/icad':
AnimalsController::icad();
break;
case '/animal/geocode':
AnimalsController::geocode();
break;
case '/animal/adopt':
AdoptionsController::store();
break;
case '/animal/placement/save':
PlacementsController::save();
break;
//===== ADMIN
case '/admin/register':
AdminController::register();
break;
case '/admin/register/pdf':
AdminController::registerPdf();
break;
case '/admin/exports':
AdminController::exports();
break;
case '/admin/exports/download':
AdminController::downloadExport();
break;
case '/admin/grants':
GrantsController::index();
break;
case '/admin/grants/save':
GrantsController::saveAnnual();
break;
case '/admin/grants/application':
GrantsController::saveApplication();
break;
case '/admin/grants/document':
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
GrantsController::upload();
} else {
GrantsController::download();
}
break;
case '/admin/grants/print':
GrantsController::print();
break;
case '/documents/adoption/blank':
DocumentsController::blankAdoption();
break;
case '/documents/blank':
DocumentsController::blank();
break;
case '/animal/documents/adoption':
DocumentsController::adoption();
break;
case '/animal/documents/abandon':
DocumentsController::abandon();
break;
case '/directory/documents':
DocumentsController::contact();
break;
case '/settings':
case '/settings/general':
SettingsController::general();
break;
case '/settings/association-logo':
SettingsController::associationLogo();
break;
case '/settings/users':
SettingsController::users();
break;
case '/settings/permissions':
SettingsController::permissions();
break;
case '/settings/species':
SettingsController::species();
break;
case '/settings/species/save':
SettingsController::saveSpecies();
break;
case '/settings/rooms':
SettingsController::rooms();
break;
case '/settings/rooms/save':
SettingsController::saveRooms();
break;
case '/settings/pricing':
SettingsController::pricing();
break;
case '/settings/pricing/tariff':
SettingsController::saveTariff();
break;
case '/settings/pricing/discount':
SettingsController::saveDiscount();
break;
case '/settings/pricing/adoption':
SettingsController::saveAdoptionFees();
break;
case '/settings/lab-references':
SettingsController::labReferences();
break;
case '/settings/lab-references/save':
SettingsController::saveLabReferences();
break;
case '/settings/public-site':
SettingsController::publicSite();
break;
case '/settings/public-site/save':
SettingsController::savePublicSite();
break;
case '/settings/privacy':
PrivacyController::index();
break;
case '/settings/privacy/save':
PrivacyController::save();
break;
case '/settings/privacy/export':
PrivacyController::export();
break;
case '/settings/privacy/anonymize':
PrivacyController::anonymize();
break;
case '/settings/inventory':
InventoryController::index();
break;
case '/settings/inventory/product':
InventoryController::product();
break;
case '/settings/inventory/receive':
InventoryController::receive();
break;
case '/settings/inventory/move':
InventoryController::move();
break;
case '/settings/health':
SettingsController::health();
break;
case '/settings/notifications':
SettingsController::notifications();
break;
case '/settings/notifications/save':
SettingsController::saveNotifications();
break;
case '/settings/notifications/send':
SettingsController::sendNotifications();
break;
case '/settings/general/save':
SettingsController::saveGeneral();
break;
case '/settings/users/save':
SettingsController::saveUser();
break;
case '/settings/permissions/save':
SettingsController::savePermissions();
break;
case '/settings/audit':
SettingsController::audit();
break;
case '/settings/data':
SettingsController::dataTools();
break;
case '/settings/data/reset':
SettingsController::resetData();
break;
case '/settings/data/demo':
SettingsController::loadDemoData();
break;
case '/settings/backups':
SettingsController::backups();
break;
case '/settings/backups/create':
SettingsController::createBackup();
break;
case '/settings/backups/schedule':
SettingsController::saveBackupSchedule();
break;
case '/settings/backups/verify':
SettingsController::verifyBackup();
break;
case '/settings/backups/download':
SettingsController::downloadBackup();
break;
case '/settings/backups/delete':
SettingsController::deleteBackup();
break;
case '/settings/backups/restore':
SettingsController::restoreBackup();
break;
case '/settings/asm3':
Asm3ImportController::index();
break;
case '/settings/asm3/upload':
Asm3ImportController::upload();
break;
case '/settings/asm3/apply':
Asm3ImportController::apply();
break;
default:
http_response_code(404);
echo '404';
}

View file

@ -0,0 +1,192 @@
<?php
require_once __DIR__ . '/DB.php';
require_once __DIR__ . '/GeocodeService.php';
class AdoptionService
{
public static function adopt(array $data): void
{
$db = DB::pdo();
$db->beginTransaction();
try {
$animalId = (int) $data['animal_id'];
$date = $data['adoption_date'] ?? date('Y-m-d');
$beforeStmt = $db->prepare(
'SELECT status,refuge_room,care_box_key,current_address FROM animals WHERE id=:id',
);
$beforeStmt->execute([':id' => $animalId]);
$beforeLocation = $beforeStmt->fetch(PDO::FETCH_ASSOC) ?: [];
// 1. Enregistrement de l’adoption
$stmt = $db->prepare('
INSERT INTO adoptions (
animal_id,
adopter_name,
adopter_phone,
adopter_email,
adopter_address,
adopter_postal_code,
adopter_city,
adoption_date,
notes,
adopter_contact_id,
created_at
) VALUES (
:animal_id,
:name,
:phone,
:email,
:address,
:postal,
:city,
:date,
:notes,
:contact_id,
CURRENT_TIMESTAMP
)
');
$stmt->execute([
':animal_id' => $animalId,
':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,
':date' => $date,
':notes' => $data['notes'] ?: null,
':contact_id' => $data['adopter_contact_id'] ?? null,
]);
$adoptionId = (int) $db->lastInsertId();
$db->prepare(
"INSERT INTO animal_placements(animal_id,event_type,event_date,contact_id,reason,notes,adoption_id,created_by) VALUES(:animal,'adoption',:date,:contact,'Adoption définitive',:notes,:adoption,:user)",
)->execute([
':animal' => $animalId,
':date' => $date,
':contact' => $data['adopter_contact_id'] ?? null,
':notes' => $data['notes'] ?: null,
':adoption' => $adoptionId,
':user' => Auth::id(),
]);
// 2. Mise à jour du statut de l’animal
$currentAddress = implode(
' ',
array_filter([
$data['adopter_address'] ?? '',
$data['adopter_postal_code'] ?? '',
$data['adopter_city'] ?? '',
]),
);
$lat = null;
$lng = null;
if (!empty($currentAddress)) {
$coords = GeocodeService::geocode($currentAddress);
if ($coords) {
$lat = $coords['lat'] ?? null;
$lng = $coords['lng'] ?? null;
}
}
$db->prepare(
"
UPDATE animals
SET
status = 'adopte',
refuge_room = NULL,
care_box_key = NULL,
updated_at = CURRENT_TIMESTAMP,
current_address = :currentAddress,
current_lat = :lat,
current_lng = :lng
WHERE id = :id
",
)->execute([':id' => $animalId, ':currentAddress' => $currentAddress, ':lat' => $lat, ':lng' => $lng]);
LocationHistoryService::record(
$db,
$animalId,
$beforeLocation,
[
'status' => 'adopte',
'refuge_room' => null,
'care_box_key' => null,
'current_address' => $currentAddress,
],
'adoption',
'Adoption finalisée',
$date . ' 12:00:00',
);
// 3. Ajout dans les mouvements administratifs (sortie)
$stmt = $db->prepare("
INSERT INTO animal_movements (
animal_id,
kind,
place,
lieu,
contact_name,
contact_phone,
contact_email,
note,
created_at
) VALUES (
:animal_id,
'exit',
:place,
'Adoption',
:contact_name,
:contact_phone,
:contact_email,
:note,
CURRENT_TIMESTAMP
)
");
$stmt->execute([
':animal_id' => $animalId,
':place' => $data['adopter_address'] ?: null,
':contact_name' => $data['adopter_name'] ?: null,
':contact_phone' => $data['adopter_phone'] ?: null,
':contact_email' => $data['adopter_email'] ?: null,
':note' => 'Adoption finalisée',
]);
// 4. Ajout dans l’historique
$stmt = $db->prepare("
INSERT INTO animal_history (
animal_id,
type,
label,
details,
user_id,
created_at
) VALUES (
:animal_id,
'adoption',
'Adoption finalisée',
:details,
:user_id,
CURRENT_TIMESTAMP
)
");
$stmt->execute([
':animal_id' => $animalId,
':details' => 'Par ' . $data['adopter_name'] . ' - Note(s) : ' . $data['notes'],
':user_id' => Auth::id(),
]);
$db->commit();
} catch (Exception $e) {
$db->rollBack();
throw $e;
}
}
}

View file

@ -0,0 +1,351 @@
<?php
declare(strict_types=1);
final class AgendaService
{
public const TYPES = ['appointment', 'veterinary', 'transport', 'pre_adoption', 'task', 'other'];
public static function save(array $data, ?int $id = null): int
{
$type = (string) ($data['item_type'] ?? 'task');
$title = trim((string) ($data['title'] ?? ''));
$date = trim((string) ($data['date'] ?? ''));
$time = trim((string) ($data['time'] ?? ''));
$allDay = isset($data['all_day']);
if (!in_array($type, self::TYPES, true) || $title === '' || !self::date($date)) {
throw new RuntimeException(t('agenda.invalid'));
}
if (!$allDay && !preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $time)) {
throw new RuntimeException(t('agenda.invalid_time'));
}
$start = $date . ' ' . ($allDay ? '00:00:00' : $time . ':00');
$endDate = trim((string) ($data['end_date'] ?? ''));
$endTime = trim((string) ($data['end_time'] ?? ''));
$end = null;
if ($endDate !== '' || $endTime !== '') {
if (!self::date($endDate) || !preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $endTime)) {
throw new RuntimeException(t('agenda.invalid_end'));
}
$end = $endDate . ' ' . $endTime . ':00';
if ($end < $start) {
throw new RuntimeException(t('agenda.end_before_start'));
}
}
$repeat = (string) ($data['repeat_rule'] ?? 'none');
if (!in_array($repeat, ['none', 'daily', 'weekly', 'monthly'], true)) {
$repeat = 'none';
}
$until = trim((string) ($data['repeat_until'] ?? ''));
if ($until !== '' && !self::date($until)) {
throw new RuntimeException(t('agenda.invalid_repeat'));
}
$values = [
':type' => $type,
':title' => mb_substr($title, 0, 180),
':description' => trim((string) ($data['description'] ?? '')) ?: null,
':start' => $start,
':end' => $end,
':all_day' => $allDay ? 1 : 0,
':location' => trim((string) ($data['location'] ?? '')) ?: null,
':priority' => in_array(
$p = (string) ($data['priority'] ?? 'normal'),
['low', 'normal', 'high', 'urgent'],
true,
)
? $p
: 'normal',
':assigned' => (int) ($data['assigned_user_id'] ?? 0) ?: null,
':animal' => (int) ($data['animal_id'] ?? 0) ?: null,
':contact' => (int) ($data['contact_id'] ?? 0) ?: null,
':reminder' => ($data['reminder_minutes'] ?? '') === '' ? null : max(0, (int) $data['reminder_minutes']),
':repeat' => $repeat,
':until' => $until ?: null,
':user' => Auth::id(),
];
$db = DB::pdo();
$db->beginTransaction();
try {
if ($id) {
$values[':id'] = $id;
$db->prepare(
"UPDATE agenda_items SET item_type=:type,title=:title,description=:description,starts_at=:start,ends_at=:end,all_day=:all_day,location=:location,priority=:priority,assigned_user_id=:assigned,animal_id=:animal,contact_id=:contact,reminder_minutes=:reminder,repeat_rule=:repeat,repeat_until=:until,updated_at=datetime('now') WHERE id=:id",
)->execute($values);
} else {
$db->prepare(
'INSERT INTO agenda_items(item_type,title,description,starts_at,ends_at,all_day,location,priority,assigned_user_id,animal_id,contact_id,reminder_minutes,repeat_rule,repeat_until,created_by) VALUES(:type,:title,:description,:start,:end,:all_day,:location,:priority,:assigned,:animal,:contact,:reminder,:repeat,:until,:user)',
)->execute($values);
$id = (int) $db->lastInsertId();
}
self::saveVolunteers($id, $data['volunteer_contact_ids'] ?? []);
$db->commit();
return $id;
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
}
public static function find(int $id): ?array
{
$s = DB::pdo()->prepare(
"SELECT ai.*,(SELECT group_concat(contact_id,',') FROM agenda_volunteers WHERE agenda_item_id=ai.id) volunteer_ids FROM agenda_items ai WHERE ai.id=?",
);
$s->execute([$id]);
return $s->fetch(PDO::FETCH_ASSOC) ?: null;
}
public static function setStatus(int $id, string $status): void
{
if (!in_array($status, ['planned', 'completed', 'cancelled'], true)) {
throw new RuntimeException(t('agenda.invalid'));
}
DB::pdo()
->prepare(
"UPDATE agenda_items SET status=?,completed_at=CASE WHEN ?='completed' THEN datetime('now') ELSE NULL END,completed_by=CASE WHEN ?='completed' THEN ? ELSE NULL END,updated_at=datetime('now') WHERE id=?",
)
->execute([$status, $status, $status, Auth::id(), $id]);
}
public static function conflicts(int $id): array
{
$db = DB::pdo();
$s = $db->prepare(
"SELECT DISTINCT other.id,other.title,other.starts_at FROM agenda_items current JOIN agenda_items other ON other.id<>current.id AND other.status='planned' AND datetime(other.starts_at)<datetime(COALESCE(current.ends_at,CASE WHEN current.all_day=1 THEN datetime(current.starts_at,'+1 day') ELSE datetime(current.starts_at,'+1 hour') END)) AND datetime(COALESCE(other.ends_at,CASE WHEN other.all_day=1 THEN datetime(other.starts_at,'+1 day') ELSE datetime(other.starts_at,'+1 hour') END))>datetime(current.starts_at) WHERE current.id=:id AND current.status='planned' AND ((current.assigned_user_id IS NOT NULL AND other.assigned_user_id=current.assigned_user_id) OR EXISTS(SELECT 1 FROM agenda_volunteers mine JOIN agenda_volunteers theirs ON theirs.contact_id=mine.contact_id AND theirs.agenda_item_id=other.id WHERE mine.agenda_item_id=current.id)) ORDER BY datetime(other.starts_at)",
);
$s->execute([':id' => $id]);
return $s->fetchAll(PDO::FETCH_ASSOC);
}
public static function month(DateTimeImmutable $month): array
{
return self::between(
$month->modify('first day of this month')->setTime(0, 0),
$month->modify('last day of this month')->setTime(23, 59, 59),
);
}
public static function week(DateTimeImmutable $date): array
{
$start = $date->modify('monday this week')->setTime(0, 0);
return self::between($start, $start->modify('+6 days')->setTime(23, 59, 59));
}
public static function range(DateTimeImmutable $from, DateTimeImmutable $to): array
{
return self::between($from, $to);
}
public static function upcoming(int $days = 30): array
{
return self::between(new DateTimeImmutable('today'), new DateTimeImmutable('+' . $days . ' days 23:59:59'));
}
public static function due(): array
{
$db = DB::pdo();
$overdue = $db
->query(
"SELECT ai.*,ai.starts_at instance_starts_at,a.name animal_name,COALESCE(u.display_name,u.username) assigned_name FROM agenda_items ai LEFT JOIN animals a ON a.id=ai.animal_id LEFT JOIN users u ON u.id=ai.assigned_user_id WHERE ai.status='planned' AND ai.repeat_rule='none' AND datetime(ai.starts_at)<datetime('now') ORDER BY datetime(ai.starts_at) LIMIT 25",
)
->fetchAll(PDO::FETCH_ASSOC);
$soon = array_values(
array_filter(
self::upcoming(2),
static fn($row) => $row['status'] === 'planned' &&
strtotime($row['instance_starts_at']) <= strtotime('+1 day'),
),
);
$seen = [];
$out = [];
foreach ([...$overdue, ...$soon] as $row) {
$key = (int) $row['id'] . '|' . ($row['instance_starts_at'] ?? $row['starts_at']);
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$out[] = $row;
}
usort(
$out,
static fn($a, $b) => strcmp(
(string) ($a['instance_starts_at'] ?? $a['starts_at']),
(string) ($b['instance_starts_at'] ?? $b['starts_at']),
),
);
return array_slice($out, 0, 25);
}
private static function between(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$db = DB::pdo();
$s = $db->prepare(
"SELECT ai.*,a.name animal_name,dc.name contact_name,COALESCE(u.display_name,u.username) assigned_name,(SELECT group_concat(vdc.name,', ') FROM agenda_volunteers av JOIN directory_contacts vdc ON vdc.id=av.contact_id WHERE av.agenda_item_id=ai.id) volunteer_names,(SELECT group_concat(av.contact_id,',') FROM agenda_volunteers av WHERE av.agenda_item_id=ai.id) volunteer_ids FROM agenda_items ai LEFT JOIN animals a ON a.id=ai.animal_id LEFT JOIN directory_contacts dc ON dc.id=ai.contact_id LEFT JOIN users u ON u.id=ai.assigned_user_id WHERE date(ai.starts_at)<=:to AND (ai.repeat_rule<>'none' OR datetime(ai.starts_at)>=:from) AND ai.status<>'cancelled' ORDER BY datetime(ai.starts_at),ai.priority DESC",
);
$s->execute([':from' => $from->format('Y-m-d H:i:s'), ':to' => $to->format('Y-m-d')]);
$out = [];
foreach ($s->fetchAll(PDO::FETCH_ASSOC) as $row) {
$start = new DateTimeImmutable($row['starts_at']);
$end = $row['ends_at'] ? new DateTimeImmutable($row['ends_at']) : null;
$step = match ($row['repeat_rule']) {
'daily' => '+1 day',
'weekly' => '+1 week',
'monthly' => '+1 month',
default => null,
};
while ($start < $from && $step) {
$start = $start->modify($step);
}
do {
if ($start > $to) {
break;
}
if (!$row['repeat_until'] || $start->format('Y-m-d') <= $row['repeat_until']) {
$instance = $row;
$instance['instance_starts_at'] = $start->format('Y-m-d H:i:s');
$instance['instance_ends_at'] = $end
? $end
->modify(
$start->getTimestamp() -
new DateTimeImmutable($row['starts_at'])->getTimestamp() .
' seconds',
)
->format('Y-m-d H:i:s')
: null;
$instance['source_type'] = 'agenda';
if (!empty($instance['volunteer_names'])) {
$instance['title'] .= ' · 👥 ' . $instance['volunteer_names'];
}
$out[] = $instance;
}
if (!$step) {
break;
}
$start = $start->modify($step);
} while (!$row['repeat_until'] || $start->format('Y-m-d') <= $row['repeat_until']);
}
$vaccines = $db->prepare(
"SELECT v.id,v.due_date,rv.name vaccine_name,a.id animal_id,a.name animal_name FROM vaccinations v JOIN ref_vaccines rv ON rv.id=v.vaccine_id JOIN animals a ON a.id=v.animal_id WHERE v.due_date IS NOT NULL AND date(v.due_date) BETWEEN :from AND :to AND a.deleted_at IS NULL AND a.archived_at IS NULL AND lower(a.status) NOT IN ('adopte','adopté','decede','décédé') AND NOT EXISTS(SELECT 1 FROM vaccinations newer WHERE newer.animal_id=v.animal_id AND newer.vaccine_id=v.vaccine_id AND date(newer.done_date)>date(v.done_date)) ORDER BY date(v.due_date),a.name COLLATE NOCASE",
);
$vaccines->execute([':from' => $from->format('Y-m-d'), ':to' => $to->format('Y-m-d')]);
foreach ($vaccines->fetchAll(PDO::FETCH_ASSOC) as $v) {
$out[] = self::reminder(
-(100000000 + (int) $v['id']),
(int) $v['id'],
'vaccine',
'vaccine',
t('agenda.vaccine_reminder', ['vaccine' => $v['vaccine_name']]),
$v['due_date'],
(int) $v['animal_id'],
$v['animal_name'],
'high',
);
}
$dewormings = $db->prepare(
"SELECT d.id,d.next_due_date,COALESCE(rd.name,'') dewormer_name,a.id animal_id,a.name animal_name FROM dewormings d LEFT JOIN ref_dewormers rd ON rd.id=d.dewormer_id JOIN animals a ON a.id=d.animal_id WHERE d.next_due_date IS NOT NULL AND date(d.next_due_date) BETWEEN :from AND :to AND a.deleted_at IS NULL AND a.archived_at IS NULL AND lower(a.status) NOT IN ('adopte','adopté','decede','décédé') AND NOT EXISTS(SELECT 1 FROM dewormings newer WHERE newer.animal_id=d.animal_id AND (date(newer.administered_on)>date(d.administered_on) OR (date(newer.administered_on)=date(d.administered_on) AND newer.id>d.id))) ORDER BY date(d.next_due_date),a.name COLLATE NOCASE",
);
$dewormings->execute([':from' => $from->format('Y-m-d'), ':to' => $to->format('Y-m-d')]);
foreach ($dewormings->fetchAll(PDO::FETCH_ASSOC) as $d) {
$out[] = self::reminder(
-(200000000 + (int) $d['id']),
(int) $d['id'],
'deworming',
'deworming',
t('agenda.deworming_reminder', ['product' => $d['dewormer_name'] ?: t('agenda.deworming')]),
$d['next_due_date'],
(int) $d['animal_id'],
$d['animal_name'],
'high',
);
}
$treatments = $db->prepare(
"SELECT t.id,t.start_date,t.end_date,m.name medication_name,a.id animal_id,a.name animal_name FROM treatments t JOIN ref_medications m ON m.id=t.medication_id JOIN animals a ON a.id=t.animal_id WHERE t.ongoing=1 AND (date(t.start_date) BETWEEN :from AND :to OR (t.end_date IS NOT NULL AND date(t.end_date) BETWEEN :from AND :to)) AND a.deleted_at IS NULL AND a.archived_at IS NULL AND lower(a.status) NOT IN ('adopte','adopté','decede','décédé') ORDER BY date(t.start_date),a.name COLLATE NOCASE",
);
$treatments->execute([':from' => $from->format('Y-m-d'), ':to' => $to->format('Y-m-d')]);
foreach ($treatments->fetchAll(PDO::FETCH_ASSOC) as $t) {
if ($t['start_date'] >= $from->format('Y-m-d') && $t['start_date'] <= $to->format('Y-m-d')) {
$out[] = self::reminder(
-(300000000 + (int) $t['id']),
(int) $t['id'],
'treatment_start',
'treatment_start',
t('agenda.treatment_start', ['medication' => $t['medication_name']]),
$t['start_date'],
(int) $t['animal_id'],
$t['animal_name'],
'high',
);
}
if ($t['end_date'] && $t['end_date'] >= $from->format('Y-m-d') && $t['end_date'] <= $to->format('Y-m-d')) {
$out[] = self::reminder(
-(400000000 + (int) $t['id']),
(int) $t['id'],
'treatment_end',
'treatment_end',
t('agenda.treatment_end', ['medication' => $t['medication_name']]),
$t['end_date'],
(int) $t['animal_id'],
$t['animal_name'],
'high',
);
}
}
usort($out, static fn($a, $b) => strcmp($a['instance_starts_at'], $b['instance_starts_at']));
return $out;
}
private static function reminder(
int $id,
int $sourceId,
string $sourceType,
string $itemType,
string $title,
string $date,
int $animalId,
string $animalName,
string $priority,
): array {
return [
'id' => $id,
'source_id' => $sourceId,
'source_type' => $sourceType,
'item_type' => $itemType,
'title' => $title,
'description' => null,
'starts_at' => $date . ' 00:00:00',
'instance_starts_at' => $date . ' 00:00:00',
'instance_ends_at' => null,
'all_day' => 1,
'location' => null,
'status' => 'planned',
'priority' => $priority,
'assigned_user_id' => null,
'assigned_name' => null,
'volunteer_names' => null,
'volunteer_ids' => null,
'animal_id' => $animalId,
'animal_name' => $animalName,
'contact_id' => null,
'contact_name' => null,
'reminder_minutes' => null,
'repeat_rule' => 'none',
'repeat_until' => null,
];
}
private static function saveVolunteers(int $itemId, mixed $input): void
{
$ids = is_array($input) ? array_values(array_unique(array_filter(array_map('intval', $input)))) : [];
$db = DB::pdo();
$db->prepare('DELETE FROM agenda_volunteers WHERE agenda_item_id=?')->execute([$itemId]);
if (!$ids) {
return;
}
$check = $db->prepare(
"SELECT 1 FROM directory_contacts dc JOIN directory_contact_roles r ON r.contact_id=dc.id AND r.role='benevole' WHERE dc.id=? AND dc.deleted_at IS NULL",
);
$insert = $db->prepare('INSERT INTO agenda_volunteers(agenda_item_id,contact_id) VALUES(?,?)');
foreach ($ids as $id) {
$check->execute([$id]);
if ($check->fetchColumn()) {
$insert->execute([$itemId, $id]);
}
}
}
private static function date(string $v): bool
{
$d = DateTimeImmutable::createFromFormat('!Y-m-d', $v);
return $d && $d->format('Y-m-d') === $v;
}
}

View file

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
final class AppSettings
{
private const DEFAULTS = [
'association_name' => 'Association',
'association_address' => '',
'association_postal_code' => '',
'association_city' => '',
'association_phone' => '',
'association_email' => '',
'association_siret' => '',
'association_rna' => '',
'association_latitude' => '',
'association_longitude' => '',
'adoption_fee_sterilized' => '220',
'adoption_fee_unsterilized' => '200',
'quarantine_days' => '15',
'backup_retention' => '10',
'app_language' => 'fr',
'public_site_enabled' => '0',
'public_site_theme' => 'warm',
'public_site_routing' => 'integrated',
'public_site_home_title' => 'Ils attendent une famille',
'public_site_home_text' => 'Découvrez les animaux actuellement proposés à l’adoption par notre association.',
'public_site_about_enabled' => '1',
'public_site_about_title' => 'Notre association',
'public_site_about_text' => 'Présentez ici votre association, son histoire et ses missions.',
'public_site_donation_title' => 'Nous soutenir',
'public_site_donation_text' =>
'Chaque don contribue directement aux soins, à l’alimentation et à la protection des animaux.',
'public_site_donation_url' => '',
'public_site_contact_text' => 'Une question sur une adoption ou sur l’association ? Contactez-nous.',
'public_site_facebook_url' => '',
'public_site_instagram_url' => '',
'notification_email_enabled' => '0',
'notification_email_frequency' => 'weekly',
'notification_email_recipient' => '',
'notification_last_sent_at' => '',
'privacy_retention_years' => '5',
'privacy_retention_adopter_years' => '5',
'privacy_retention_foster_years' => '5',
'privacy_retention_volunteer_years' => '5',
'privacy_access_log_months' => '24',
'backup_schedule_enabled' => '0',
'backup_schedule_frequency' => 'daily',
'backup_last_run_at' => '',
'backup_last_verified_at' => '',
'public_site_legal_text' => '',
'public_site_privacy_text' => '',
];
private static ?array $cache = null;
public static function all(): array
{
if (self::$cache !== null) {
return self::$cache;
}
$values = self::DEFAULTS;
try {
foreach (
DB::pdo()->query('SELECT setting_key,setting_value FROM app_settings')->fetchAll(PDO::FETCH_ASSOC)
as $row
) {
if (array_key_exists($row['setting_key'], $values)) {
$values[$row['setting_key']] = (string) $row['setting_value'];
}
}
} catch (Throwable) {
}
return self::$cache = $values;
}
public static function get(string $key): string
{
return (string) (self::all()[$key] ?? '');
}
public static function int(string $key, int $min = 0, int $max = PHP_INT_MAX): int
{
return max($min, min($max, (int) self::get($key)));
}
public static function save(array $values, ?int $userId): void
{
$allowed = array_keys(self::DEFAULTS);
$stmt = DB::pdo()->prepare(
"INSERT INTO app_settings(setting_key,setting_value,updated_by,updated_at) VALUES(:key,:value,:user,datetime('now')) ON CONFLICT(setting_key) DO UPDATE SET setting_value=excluded.setting_value,updated_by=excluded.updated_by,updated_at=datetime('now')",
);
foreach ($allowed as $key) {
if (array_key_exists($key, $values)) {
$stmt->execute([':key' => $key, ':value' => (string) $values[$key], ':user' => $userId]);
}
}
self::$cache = null;
}
}

View file

@ -0,0 +1,490 @@
<?php
declare(strict_types=1);
final class Asm3Analyzer
{
private const DOMAINS = [
'animal' => ['Animaux', 'animals', 'ready'],
'owner' => ['Contacts et structures', 'directory_contacts', 'planned'],
'adoption' => ['Mouvements, FA et adoptions', 'animal_placements / adoptions', 'planned'],
'animalvaccination' => ['Vaccinations', 'vaccinations', 'planned'],
'medical' => ['Traitements et actes médicaux', 'medical_notes / treatments', 'planned'],
'animalmedical' => ['Traitements et actes médicaux', 'medical_notes / treatments', 'planned'],
'media' => ['Médias', 'animal_photos / medical_photos', 'planned'],
'animallitter' => ['Portées', 'litters', 'planned'],
'animalcontrol' => ['Incidents et contrôles', 'non pris en charge', 'review'],
'animaltest' => ['Tests médicaux', 'medical_notes', 'planned'],
];
public static function analyze(string $path): array
{
if (!is_file($path) || !is_readable($path)) {
throw new RuntimeException(t('asm3.unreadable'));
}
if (self::isCopyDump($path)) {
return self::analyzeCopy($path);
}
$handle = fopen($path, 'rb');
if (!$handle) {
throw new RuntimeException(t('asm3.open_failed'));
}
$counts = [];
$columns = [];
$animalStats = [
'active' => 0,
'archived' => 0,
'adoptable' => 0,
'not_adoptable' => 0,
'deceased' => 0,
'microchip' => 0,
'tattoo' => 0,
'neutered' => 0,
'missing_name' => 0,
'missing_birth_date' => 0,
'species' => [],
];
$statement = '';
$quoted = false;
$bytes = 0;
try {
while (($chunk = fgets($handle)) !== false) {
$bytes += strlen($chunk);
$statement .= $chunk;
$length = strlen($statement);
$quoted = false;
for ($i = 0; $i < $length; $i++) {
if ($statement[$i] !== "'") {
continue;
}
if ($quoted && $i + 1 < $length && $statement[$i + 1] === "'") {
$i++;
continue;
}
$quoted = !$quoted;
}
if ($quoted || !preg_match('/;\s*$/s', $statement)) {
continue;
}
self::consume($statement, $counts, $columns, $animalStats);
$statement = '';
}
if (trim($statement) !== '') {
self::consume($statement, $counts, $columns, $animalStats);
}
} finally {
fclose($handle);
}
arsort($counts);
$domains = [];
foreach (self::DOMAINS as $table => [$label, $target, $status]) {
$domains[] = [
'table' => $table,
'label' => $label,
'target' => $target,
'status' => $status,
'rows' => $counts[$table] ?? 0,
'present' => isset($counts[$table]),
];
}
$warnings = [];
if (!isset($counts['animal'])) {
$warnings[] = t('asm3.animal_table_missing');
}
if (($counts['animal'] ?? 0) > 0 && $animalStats['missing_name'] > 0) {
$warnings[] = t('asm3.animals_without_name', ['count' => $animalStats['missing_name']]);
}
if (($counts['animal'] ?? 0) > 0 && $animalStats['missing_birth_date'] > 0) {
$warnings[] = t('asm3.animals_without_birth', ['count' => $animalStats['missing_birth_date']]);
}
$unsupported = [];
foreach ($counts as $table => $count) {
if (!isset(self::DOMAINS[$table]) && $count > 0) {
$unsupported[$table] = $count;
}
}
return [
'format' => 'ASM3 SQL INSERT',
'source' => basename($path),
'size_bytes' => filesize($path),
'read_bytes' => $bytes,
'generated_at' => date(DATE_ATOM),
'tables' => $counts,
'columns' => $columns,
'domains' => $domains,
'animals' => $animalStats,
'warnings' => $warnings,
'unmapped_tables' => $unsupported,
'read_only' => true,
];
}
public static function rows(string $path, array $wantedTables): array
{
if (self::isCopyDump($path)) {
return self::copyRows($path, $wantedTables);
}
$wanted = array_fill_keys(array_map('strtolower', $wantedTables), true);
$rows = [];
$columns = [];
$handle = fopen($path, 'rb');
if (!$handle) {
throw new RuntimeException(t('asm3.open_failed'));
}
$statement = '';
try {
while (($chunk = fgets($handle)) !== false) {
$statement .= $chunk;
$quoted = false;
$length = strlen($statement);
for ($i = 0; $i < $length; $i++) {
if ($statement[$i] !== "'") {
continue;
}
if ($quoted && $i + 1 < $length && $statement[$i + 1] === "'") {
$i++;
continue;
}
$quoted = !$quoted;
}
if ($quoted || !preg_match('/;\s*$/s', $statement)) {
continue;
}
if (
preg_match(
'/^\s*INSERT\s+INTO\s+[`"]?([a-zA-Z0-9_]+)[`"]?\s*\((.*?)\)\s*VALUES\s*\((.*)\)\s*;\s*$/is',
$statement,
$m,
)
) {
$table = strtolower($m[1]);
if (isset($wanted[$table])) {
$cols = $columns[$table] ??= array_map(
static fn($v) => strtoupper(trim($v, " \t\r\n`\"")),
explode(',', $m[2]),
);
$values = self::values($m[3]);
$row = [];
foreach ($cols as $i => $column) {
$row[$column] = $values[$i] ?? null;
}
$rows[$table][] = $row;
}
}
$statement = '';
}
} finally {
fclose($handle);
}
return $rows;
}
private static function consume(string $sql, array &$counts, array &$columns, array &$animalStats): void
{
if (
!preg_match(
'/^\s*INSERT\s+INTO\s+[`"]?([a-zA-Z0-9_]+)[`"]?\s*\((.*?)\)\s*VALUES\s*\((.*)\)\s*;\s*$/is',
$sql,
$m,
)
) {
return;
}
$table = strtolower($m[1]);
$counts[$table] = ($counts[$table] ?? 0) + 1;
if (!isset($columns[$table])) {
$columns[$table] = array_map(static fn($v) => strtoupper(trim($v, " \t\r\n`\"")), explode(',', $m[2]));
}
if ($table !== 'animal') {
return;
}
$values = self::values($m[3]);
$row = [];
foreach ($columns[$table] as $i => $column) {
$row[$column] = $values[$i] ?? null;
}
$truthy = static fn($v): bool => (string) $v === '1';
$blank = static fn($v): bool => $v === null || trim((string) $v) === '';
$animalStats[$truthy($row['ARCHIVED'] ?? 0) ? 'archived' : 'active']++;
$animalStats[$truthy($row['ADOPTABLE'] ?? 0) ? 'adoptable' : 'not_adoptable']++;
if (!$blank($row['DECEASEDDATE'] ?? null) || $truthy($row['PUTTOSLEEP'] ?? 0)) {
$animalStats['deceased']++;
}
if (!$blank($row['IDENTICHIPNUMBER'] ?? null) || $truthy($row['IDENTICHIPPED'] ?? 0)) {
$animalStats['microchip']++;
}
if (!$blank($row['TATTOONUMBER'] ?? null) || $truthy($row['TATTOO'] ?? 0)) {
$animalStats['tattoo']++;
}
if ($truthy($row['NEUTERED'] ?? 0)) {
$animalStats['neutered']++;
}
if ($blank($row['ANIMALNAME'] ?? null)) {
$animalStats['missing_name']++;
}
if ($blank($row['DATEOFBIRTH'] ?? null)) {
$animalStats['missing_birth_date']++;
}
$species = (string) ($row['SPECIESID'] ?? 'unknown');
$animalStats['species'][$species] = ($animalStats['species'][$species] ?? 0) + 1;
}
private static function isCopyDump(string $path): bool
{
$handle = @fopen($path, 'rb');
if (!$handle) {
return false;
}
$sample = (string) fread($handle, 262144);
fclose($handle);
return preg_match('/^COPY\s+(?:public\.)?[a-zA-Z0-9_]+\s*\(/mi', $sample) === 1;
}
private static function analyzeCopy(string $path): array
{
$counts = [];
$columns = [];
$animalStats = [
'active' => 0,
'archived' => 0,
'adoptable' => 0,
'not_adoptable' => 0,
'deceased' => 0,
'microchip' => 0,
'tattoo' => 0,
'neutered' => 0,
'missing_name' => 0,
'missing_birth_date' => 0,
'species' => [],
];
$handle = fopen($path, 'rb');
if (!$handle) {
throw new RuntimeException(t('asm3.open_failed'));
}
$table = null;
$tableColumns = [];
$bytes = 0;
try {
while (($line = fgets($handle)) !== false) {
$bytes += strlen($line);
$line = rtrim($line, "\r\n");
if ($table === null) {
if (
preg_match(
'/^COPY\s+(?:public\.)?[`"]?([a-zA-Z0-9_]+)[`"]?\s*\((.*?)\)\s+FROM\s+stdin;$/i',
$line,
$m,
)
) {
$table = strtolower($m[1]);
$tableColumns = array_map(static fn($v) => strtoupper(trim($v, " \t`\"")), explode(',', $m[2]));
$columns[$table] = $tableColumns;
$counts[$table] ??= 0;
}
continue;
}
if ($line === '\\.') {
$table = null;
$tableColumns = [];
continue;
}
$counts[$table]++;
if ($table === 'animal') {
self::accumulateAnimalStats(self::copyRow($line, $tableColumns), $animalStats);
}
}
} finally {
fclose($handle);
}
arsort($counts);
$domains = [];
foreach (self::DOMAINS as $name => [$label, $target, $status]) {
$domains[] = [
'table' => $name,
'label' => $label,
'target' => $target,
'status' => $status,
'rows' => $counts[$name] ?? 0,
'present' => isset($counts[$name]),
];
}
$warnings = [];
if (!isset($counts['animal'])) {
$warnings[] = t('asm3.animal_table_missing');
}
if (($counts['animal'] ?? 0) > 0 && $animalStats['missing_name'] > 0) {
$warnings[] = t('asm3.animals_without_name', ['count' => $animalStats['missing_name']]);
}
if (($counts['animal'] ?? 0) > 0 && $animalStats['missing_birth_date'] > 0) {
$warnings[] = t('asm3.animals_without_birth', ['count' => $animalStats['missing_birth_date']]);
}
$unsupported = [];
foreach ($counts as $name => $count) {
if (!isset(self::DOMAINS[$name]) && $count > 0) {
$unsupported[$name] = $count;
}
}
return [
'format' => 'ASM3 PostgreSQL COPY',
'source' => basename($path),
'size_bytes' => filesize($path),
'read_bytes' => $bytes,
'generated_at' => date(DATE_ATOM),
'tables' => $counts,
'columns' => $columns,
'domains' => $domains,
'animals' => $animalStats,
'warnings' => $warnings,
'unmapped_tables' => $unsupported,
'read_only' => true,
];
}
private static function copyRows(string $path, array $wantedTables): array
{
$wanted = array_fill_keys(array_map('strtolower', $wantedTables), true);
$rows = [];
$handle = fopen($path, 'rb');
if (!$handle) {
throw new RuntimeException(t('asm3.open_failed'));
}
$table = null;
$columns = [];
$capture = false;
try {
while (($line = fgets($handle)) !== false) {
$line = rtrim($line, "\r\n");
if ($table === null) {
if (
preg_match(
'/^COPY\s+(?:public\.)?[`"]?([a-zA-Z0-9_]+)[`"]?\s*\((.*?)\)\s+FROM\s+stdin;$/i',
$line,
$m,
)
) {
$table = strtolower($m[1]);
$capture = isset($wanted[$table]);
$columns = $capture
? array_map(static fn($v) => strtoupper(trim($v, " \t`\"")), explode(',', $m[2]))
: [];
}
continue;
}
if ($line === '\\.') {
$table = null;
$columns = [];
$capture = false;
continue;
}
if ($capture) {
$rows[$table][] = self::copyRow($line, $columns);
}
}
} finally {
fclose($handle);
}
return $rows;
}
private static function copyRow(string $line, array $columns): array
{
$values = explode("\t", $line);
$row = [];
foreach ($columns as $i => $column) {
$row[$column] = self::copyScalar($values[$i] ?? '\\N');
}
return $row;
}
private static function copyScalar(string $value): ?string
{
if ($value === '\\N') {
return null;
}
return preg_replace_callback(
'/\\\\([0-7]{1,3}|.)/s',
static function (array $m): string {
$escape = $m[1];
if (ctype_digit($escape)) {
return chr(octdec($escape));
}
return match ($escape) {
'b' => "\x08",
'f' => "\x0c",
'n' => "\n",
'r' => "\r",
't' => "\t",
'v' => "\x0b",
'\\' => '\\',
default => $escape,
};
},
$value,
) ?? $value;
}
private static function accumulateAnimalStats(array $row, array &$animalStats): void
{
$truthy = static fn($v): bool => (string) $v === '1';
$blank = static fn($v): bool => $v === null || trim((string) $v) === '';
$animalStats[$truthy($row['ARCHIVED'] ?? 0) ? 'archived' : 'active']++;
$animalStats[$truthy($row['ADOPTABLE'] ?? 0) ? 'adoptable' : 'not_adoptable']++;
if (!$blank($row['DECEASEDDATE'] ?? null) || $truthy($row['PUTTOSLEEP'] ?? 0)) {
$animalStats['deceased']++;
}
if (!$blank($row['IDENTICHIPNUMBER'] ?? null) || $truthy($row['IDENTICHIPPED'] ?? 0)) {
$animalStats['microchip']++;
}
if (!$blank($row['TATTOONUMBER'] ?? null) || $truthy($row['TATTOO'] ?? 0)) {
$animalStats['tattoo']++;
}
if ($truthy($row['NEUTERED'] ?? 0)) {
$animalStats['neutered']++;
}
if ($blank($row['ANIMALNAME'] ?? null)) {
$animalStats['missing_name']++;
}
if ($blank($row['DATEOFBIRTH'] ?? null)) {
$animalStats['missing_birth_date']++;
}
$species = (string) ($row['SPECIESID'] ?? 'unknown');
$animalStats['species'][$species] = ($animalStats['species'][$species] ?? 0) + 1;
}
private static function values(string $input): array
{
$values = [];
$value = '';
$quoted = false;
$length = strlen($input);
for ($i = 0; $i < $length; $i++) {
$char = $input[$i];
if ($char === "'") {
if ($quoted && $i + 1 < $length && $input[$i + 1] === "'") {
$value .= "'";
$i++;
continue;
}
$quoted = !$quoted;
continue;
}
if ($char === ',' && !$quoted) {
$values[] = self::scalar($value);
$value = '';
continue;
}
$value .= $char;
}
$values[] = self::scalar($value);
return $values;
}
private static function scalar(string $value): mixed
{
$value = trim($value);
if (strcasecmp($value, 'null') === 0) {
return null;
}
return $value;
}
}

View file

@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
final class Asm3AnimalImporter
{
public static function run(string $path, PDO $db, bool $apply = false, bool $createBackup = true): array
{
$plan = Asm3MigrationPlanner::plan($path, $db);
$hash = hash_file('sha256', $path);
if ($hash === false) {
throw new RuntimeException(t('asm3.hash_failed'));
}
$result = [
'mode' => $apply ? 'apply' : 'dry-run',
'source' => basename($path),
'source_sha256' => $hash,
'planned' => $plan['summary'],
'created' => 0,
'linked_matches' => 0,
'skipped_existing_import' => 0,
'skipped_match' => 0,
'skipped_review' => $plan['summary']['review'],
'backup' => null,
'read_only' => !$apply,
];
if (!$apply) {
$result['would_create'] = $plan['summary']['create'];
return $result;
}
if ($createBackup) {
$backup = BackupService::create('pre-asm3-import');
$result['backup'] = $backup['name'] ?? null;
}
$ownsTransaction = !$db->inTransaction();
if ($ownsTransaction) {
$db->beginTransaction();
}
try {
$run = $db->prepare(
"INSERT INTO asm3_import_runs(source_name,source_sha256,status,summary_json,backup_name) VALUES(:name,:hash,'completed','{}',:backup)",
);
$run->execute([':name' => basename($path), ':hash' => $hash, ':backup' => $result['backup']]);
$runId = (int) $db->lastInsertId();
foreach ($plan['items'] as $item) {
if ($item['action'] === 'review') {
continue;
}
$check = $db->prepare(
"SELECT target_id FROM asm3_import_links WHERE source_sha256=:hash AND entity_type='animal' AND source_id=:source",
);
$check->execute([':hash' => $hash, ':source' => $item['asm3_id']]);
if ($check->fetchColumn()) {
$result['skipped_existing_import']++;
continue;
}
if ($item['action'] === 'match') {
$target = (int) ($item['globinours_candidates'][0]['id'] ?? 0);
if ($target <= 0) {
$result['skipped_match']++;
continue;
}
$result['linked_matches']++;
} else {
$target = self::insert($db, $item['mapped'], $item['asm3_id']);
$result['created']++;
}
$db->prepare(
"INSERT INTO asm3_import_links(source_sha256,entity_type,source_id,target_id,import_run_id) VALUES(:hash,'animal',:source,:target,:run)",
)->execute([':hash' => $hash, ':source' => $item['asm3_id'], ':target' => $target, ':run' => $runId]);
}
$db->prepare('UPDATE asm3_import_runs SET summary_json=:summary WHERE id=:id')->execute([
':summary' => json_encode($result, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
':id' => $runId,
]);
if ($ownsTransaction) {
$db->commit();
}
} catch (Throwable $e) {
if ($ownsTransaction && $db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
return $result;
}
private static function insert(PDO $db, array $a, int $sourceId): int
{
$code = trim((string) $a['internal_code']);
if ($code === '') {
$code = 'ASM3-' . $sourceId;
}
$exists = $db->prepare('SELECT 1 FROM animals WHERE internal_code=:code');
$exists->execute([':code' => $code]);
if ($exists->fetchColumn()) {
$code .= '-ASM3-' . $sourceId;
}
$stmt = $db->prepare(
"INSERT INTO animals(internal_code,name,species,sex,birth_date,birth_is_estimated,chip_id,status,sterilized,sterilization_status,sterilization_date,color,breed,notes,intake_date,archived_at,identification_type,identification_date,identification_registration_status,adoption_availability,adoption_available_from,adoption_unavailability_reason,compatibility_dogs,compatibility_cats,compatibility_children,house_trained,created_at,updated_at) VALUES(:code,:name,:species,:sex,:birth,:estimated,:chip,:status,:sterilized,:sterilization_status,:sterilization_date,:color,:breed,:notes,:intake,:archived,:identification_type,:identification_date,:registration,:availability,:available_from,:unavailable_reason,:dogs,:cats,:children,:trained,datetime('now'),datetime('now'))",
);
$stmt->execute([
':code' => $code,
':name' => $a['name'] ?: 'Animal ASM3 ' . $sourceId,
':species' => $a['species'] ?: 'inconnu',
':sex' => $a['sex'],
':birth' => $a['birth_date'],
':estimated' => $a['birth_is_estimated'],
':chip' => $a['chip_id'] ?: null,
':status' => $a['status'],
':sterilized' => $a['sterilization_status'] === 'yes' ? 1 : 0,
':sterilization_status' => $a['sterilization_status'],
':sterilization_date' => $a['sterilization_date'],
':color' => $a['color'],
':breed' => $a['breed'] ?: null,
':notes' => $a['notes'] ?: null,
':intake' => $a['intake_date'],
':archived' => $a['archived'] ? date('Y-m-d H:i:s') : null,
':identification_type' => $a['identification_type'],
':identification_date' => $a['identification_date'],
':registration' => $a['identification_registration_status'],
':availability' => $a['adoption_availability'],
':available_from' => $a['adoption_available_from'],
':unavailable_reason' => $a['adoption_unavailability_reason'] ?: null,
':dogs' => $a['compatibility_dogs'],
':cats' => $a['compatibility_cats'],
':children' => $a['compatibility_children'],
':trained' => $a['house_trained'],
]);
$id = (int) $db->lastInsertId();
if ($a['deceased_date']) {
$db->prepare(
"INSERT INTO animal_deaths(animal_id,deceased_date,cause_code,cause_details,place_type,body_disposition,notes) VALUES(:animal,:date,'unknown',NULL,'inconnu','pending',:notes)",
)->execute([':animal' => $id, ':date' => $a['deceased_date'], ':notes' => 'Importé depuis ASM3']);
}
return $id;
}
}

View file

@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
final class Asm3ClinicalImporter
{
public static function run(string $path, PDO $db, bool $apply = false, bool $createBackup = true): array
{
$hash = hash_file('sha256', $path);
$src = Asm3Analyzer::rows($path, ['animalvaccination', 'vaccinationtype', 'log', 'logtype']);
$links = self::animalLinks($db, $hash);
$types = [];
foreach ($src['vaccinationtype'] ?? [] as $r) {
$types[(int) $r['ID']] = trim((string) $r['VACCINATIONTYPE']);
}
$logTypes = [];
foreach ($src['logtype'] ?? [] as $r) {
$logTypes[(int) $r['ID']] = trim((string) $r['LOGTYPENAME']);
}
$result = [
'mode' => $apply ? 'apply' : 'dry-run',
'read_only' => !$apply,
'vaccinations' => 0,
'medical_notes' => 0,
'weights' => 0,
'unresolved' => 0,
'already_imported' => 0,
'backup' => null,
];
foreach ($src['animalvaccination'] ?? [] as $r) {
$result['vaccinations']++;
if (!isset($links[(int) $r['ANIMALID']])) {
$result['unresolved']++;
}
}
foreach ($src['log'] ?? [] as $r) {
if ((int) ($r['LINKTYPE'] ?? -1) === 0) {
if (!isset($links[(int) $r['LINKID']])) {
$result['unresolved']++;
}
$name = mb_strtolower($logTypes[(int) $r['LOGTYPEID']] ?? '');
if ($name === 'poids') {
$result['weights']++;
} else {
$result['medical_notes']++;
}
}
}
if (!$apply) {
return $result;
}
if ($createBackup) {
$b = BackupService::create('pre-asm3-import');
$result['backup'] = $b['name'] ?? null;
}
$ownsTransaction = !$db->inTransaction();
if ($ownsTransaction) {
$db->beginTransaction();
}
try {
$runId = self::runId($db, $path, $hash, $result['backup'], 'médical');
foreach ($src['animalvaccination'] ?? [] as $r) {
$animal = $links[(int) $r['ANIMALID']] ?? 0;
if (!$animal) {
continue;
}
$source = (int) $r['ID'];
if (self::seen($db, $hash, 'vaccination', $source)) {
$result['already_imported']++;
continue;
}
$name = $types[(int) $r['VACCINATIONID']] ?? 'Vaccin ASM3 ' . (int) $r['VACCINATIONID'];
$db->prepare('INSERT OR IGNORE INTO ref_vaccines(name) VALUES(:name)')->execute([':name' => $name]);
$q = $db->prepare('SELECT id FROM ref_vaccines WHERE name=:name');
$q->execute([':name' => $name]);
$vaccine = (int) $q->fetchColumn();
$db->prepare(
'INSERT INTO vaccinations(animal_id,vaccine_id,done_date,due_date,lot,notes,manufacturer,batch_expires_on,administered_by_name) VALUES(:animal,:vaccine,:done,:due,:lot,:notes,:manufacturer,:expires,:by)',
)->execute([
':animal' => $animal,
':vaccine' => $vaccine,
':done' =>
self::date($r['DATEOFVACCINATION'] ?? null) ?:
self::date($r['DATEREQUIRED'] ?? null) ?:
date('Y-m-d'),
':due' => self::date($r['DATEREQUIRED'] ?? null),
':lot' => trim((string) $r['BATCHNUMBER']) ?: null,
':notes' => trim((string) $r['COMMENTS']) ?: null,
':manufacturer' => trim((string) $r['MANUFACTURER']) ?: null,
':expires' => self::date($r['BATCHEXPIRYDATE'] ?? null),
':by' => trim((string) $r['GIVENBY']) ?: null,
]);
self::link($db, $hash, 'vaccination', $source, (int) $db->lastInsertId(), $runId);
}
foreach ($src['log'] ?? [] as $r) {
if ((int) ($r['LINKTYPE'] ?? -1) !== 0) {
continue;
}
$animal = $links[(int) $r['LINKID']] ?? 0;
if (!$animal) {
continue;
}
$source = (int) $r['ID'];
$name = $logTypes[(int) $r['LOGTYPEID']] ?? 'Historique';
$entity = mb_strtolower($name) === 'poids' ? 'measurement' : 'medical-log';
if (self::seen($db, $hash, $entity, $source)) {
continue;
}
$text = trim((string) $r['COMMENTS']);
$date = self::date($r['DATE'] ?? null) ?: date('Y-m-d');
if ($entity === 'measurement' && preg_match('/([0-9]+(?:[.,][0-9]+)?)/', $text, $m)) {
$value = (float) str_replace(',', '.', $m[1]);
$db->prepare(
"INSERT INTO measurements(animal_id,measured_at,type,value,unit,notes) VALUES(:animal,:date,'weight',:value,'kg',:notes)",
)->execute([':animal' => $animal, ':date' => $date, ':value' => $value, ':notes' => $text]);
} else {
$db->prepare(
"INSERT INTO medical_notes(animal_id,noted_at,kind,reason,plan) VALUES(:animal,:date,'autre',:reason,:plan)",
)->execute([':animal' => $animal, ':date' => $date, ':reason' => $name, ':plan' => $text ?: null]);
}
self::link($db, $hash, $entity, $source, (int) $db->lastInsertId(), $runId);
}
if ($ownsTransaction) {
$db->commit();
}
} catch (Throwable $e) {
if ($ownsTransaction && $db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
return $result;
}
private static function animalLinks(PDO $db, string $hash): array
{
$s = $db->prepare(
"SELECT source_id,target_id FROM asm3_import_links WHERE source_sha256=:h AND entity_type='animal'",
);
$s->execute([':h' => $hash]);
$o = [];
foreach ($s as $r) {
$o[(int) $r['source_id']] = (int) $r['target_id'];
}
return $o;
}
private static function runId(PDO $db, string $p, string $h, ?string $b, string $label): int
{
$db->prepare(
"INSERT INTO asm3_import_runs(source_name,source_sha256,status,summary_json,backup_name) VALUES(:n,:h,'completed','{}',:b)",
)->execute([':n' => basename($p) . ' · ' . $label, ':h' => $h, ':b' => $b]);
return (int) $db->lastInsertId();
}
private static function seen(PDO $db, string $h, string $e, int $s): bool
{
$q = $db->prepare('SELECT 1 FROM asm3_import_links WHERE source_sha256=:h AND entity_type=:e AND source_id=:s');
$q->execute([':h' => $h, ':e' => $e, ':s' => $s]);
return (bool) $q->fetchColumn();
}
private static function link(PDO $db, string $h, string $e, int $s, int $t, int $r): void
{
$db->prepare(
'INSERT INTO asm3_import_links(source_sha256,entity_type,source_id,target_id,import_run_id) VALUES(:h,:e,:s,:t,:r)',
)->execute([':h' => $h, ':e' => $e, ':s' => $s, ':t' => $t, ':r' => $r]);
}
private static function date(mixed $v): ?string
{
return $v && preg_match('/^\d{4}-\d{2}-\d{2}/', (string) $v, $m) ? $m[0] : null;
}
}

View file

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
final class Asm3ContactImporter
{
public static function run(string $path, PDO $db, bool $apply = false, bool $createBackup = true): array
{
$plan = Asm3ContactPlanner::plan($path, $db);
$hash = hash_file('sha256', $path);
if ($hash === false) {
throw new RuntimeException(t('asm3.hash_unavailable'));
}
$result = [
'mode' => $apply ? 'apply' : 'dry-run',
'entity' => 'contacts',
'planned' => $plan['summary'],
'created' => 0,
'linked_matches' => 0,
'skipped_match' => 0,
'skipped_review' => $plan['summary']['review'],
'backup' => null,
'read_only' => !$apply,
'would_create' => $plan['summary']['create'],
];
if (!$apply) {
return $result;
}
if ($createBackup) {
$backup = BackupService::create('pre-asm3-import');
$result['backup'] = $backup['name'] ?? null;
}
$ownsTransaction = !$db->inTransaction();
if ($ownsTransaction) {
$db->beginTransaction();
}
try {
$db->prepare(
"INSERT INTO asm3_import_runs(source_name,source_sha256,status,summary_json,backup_name) VALUES(:name,:hash,'completed','{}',:backup)",
)->execute([':name' => basename($path) . ' · contacts', ':hash' => $hash, ':backup' => $result['backup']]);
$runId = (int) $db->lastInsertId();
foreach ($plan['items'] as $item) {
if ($item['action'] === 'review') {
continue;
}
$check = $db->prepare(
"SELECT target_id FROM asm3_import_links WHERE source_sha256=:hash AND entity_type='contact' AND source_id=:source",
);
$check->execute([':hash' => $hash, ':source' => $item['asm3_id']]);
if ($check->fetchColumn()) {
continue;
}
$c = $item['mapped'];
if ($item['action'] === 'match') {
$target = (int) ($item['globinours_candidates'][0]['id'] ?? 0);
if ($target <= 0) {
$result['skipped_match']++;
continue;
}
$result['linked_matches']++;
} else {
$db->prepare(
'INSERT INTO directory_contacts(kind,name,phone,email,address,postal_code,city,country,notes) VALUES(:kind,:name,:phone,:email,:address,:postal,:city,:country,:notes)',
)->execute([
':kind' => $c['kind'],
':name' => $c['name'],
':phone' => $c['phone'],
':email' => $c['email'],
':address' => $c['address'],
':postal' => $c['postal_code'],
':city' => $c['city'],
':country' => $c['country'],
':notes' => $c['notes'],
]);
$target = (int) $db->lastInsertId();
$result['created']++;
}
$role = $db->prepare(
'INSERT OR IGNORE INTO directory_contact_roles(contact_id,role) VALUES(:contact,:role)',
);
foreach ($c['roles'] as $value) {
$role->execute([':contact' => $target, ':role' => $value]);
}
$db->prepare(
"INSERT INTO asm3_import_links(source_sha256,entity_type,source_id,target_id,import_run_id) VALUES(:hash,'contact',:source,:target,:run)",
)->execute([':hash' => $hash, ':source' => $item['asm3_id'], ':target' => $target, ':run' => $runId]);
}
$db->prepare('UPDATE asm3_import_runs SET summary_json=:summary WHERE id=:id')->execute([
':summary' => json_encode($result, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
':id' => $runId,
]);
if ($ownsTransaction) {
$db->commit();
}
} catch (Throwable $e) {
if ($ownsTransaction && $db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
return $result;
}
}

View file

@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
final class Asm3ContactPlanner
{
public static function plan(string $path, PDO $db): array
{
$source = Asm3Analyzer::rows($path, ['owner', 'animal', 'adoption', 'animalvaccination']);
$adopters = [];
$depositors = [];
$vets = [];
foreach ($source['adoption'] ?? [] as $r) {
if ((int) ($r['OWNERID'] ?? 0) > 0) {
$adopters[(int) $r['OWNERID']] = true;
}
}
foreach ($source['animal'] ?? [] as $r) {
foreach (['BROUGHTINBYOWNERID', 'ORIGINALOWNERID'] as $field) {
if ((int) ($r[$field] ?? 0) > 0) {
$depositors[(int) $r[$field]] = true;
}
}
foreach (['CURRENTVETID', 'OWNERSVETID'] as $field) {
if ((int) ($r[$field] ?? 0) > 0) {
$vets[(int) $r[$field]] = true;
}
}
}
foreach ($source['animalvaccination'] ?? [] as $r) {
if ((int) ($r['ADMINISTERINGVETID'] ?? 0) > 0) {
$vets[(int) $r['ADMINISTERINGVETID']] = true;
}
}
$existing = $db
->query('SELECT id,kind,name,phone,email,city FROM directory_contacts WHERE deleted_at IS NULL')
->fetchAll(PDO::FETCH_ASSOC);
$items = [];
$summary = ['create' => 0, 'match' => 0, 'review' => 0];
foreach ($source['owner'] ?? [] as $row) {
$mapped = self::map(
$row,
isset($adopters[(int) $row['ID']]),
isset($depositors[(int) $row['ID']]),
isset($vets[(int) $row['ID']]),
);
$strong = [];
$weak = [];
$reasons = [];
foreach ($existing as $contact) {
$email = self::text($mapped['email']);
$phone = self::phone($mapped['phone']);
$sameEmail = $email !== '' && $email === self::text($contact['email']);
$samePhone = $phone !== '' && $phone === self::phone($contact['phone']);
$sameIdentity =
self::text($mapped['name']) === self::text($contact['name']) &&
self::text($mapped['city']) === self::text($contact['city']);
if ($sameEmail || $samePhone || $sameIdentity) {
$strong[(int) $contact['id']] = $contact;
if ($sameEmail) {
$reasons[] = 'même adresse e-mail';
}
if ($samePhone) {
$reasons[] = 'même téléphone';
}
if ($sameIdentity) {
$reasons[] = 'même nom et ville';
}
} elseif (
self::text($mapped['name']) !== '' &&
self::text($mapped['name']) === self::text($contact['name'])
) {
$weak[(int) $contact['id']] = $contact;
}
}
if (count($strong) === 1) {
$action = 'match';
$candidates = $strong;
} elseif (count($strong) > 1) {
$action = 'review';
$candidates = $strong;
} elseif ($weak) {
$action = 'review';
$candidates = $weak;
$reasons[] = 'même nom uniquement';
} else {
$action = 'create';
$candidates = [];
}
$summary[$action]++;
$items[] = [
'asm3_id' => (int) $row['ID'],
'action' => $action,
'match_reasons' => array_values(array_unique($reasons)),
'globinours_candidates' => array_values(
array_map(
static fn($c) => ['id' => (int) $c['id'], 'name' => $c['name'], 'city' => $c['city']],
$candidates,
),
),
'mapped' => $mapped,
];
}
return ['source' => basename($path), 'read_only' => true, 'summary' => $summary, 'items' => $items];
}
private static function map(array $r, bool $adopter, bool $depositor, bool $vetReferenced): array
{
$yes = static fn($v) => (string) $v === '1';
$name = trim(
(string) ($r['OWNERNAME'] ?? '' ?:
trim((string) ($r['OWNERFORENAMES'] ?? '') . ' ' . (string) ($r['OWNERSURNAME'] ?? ''))),
);
$organization =
(bool) preg_match('/\b(cabinet|clinique|cr[eé]matorium|association|refuge|fourri[eè]re)\b/iu', $name) ||
$yes($r['ISSHELTER'] ?? 0) ||
$yes($r['ISSUPPLIER'] ?? 0);
$roles = [];
if ($adopter || $yes($r['ISADOPTER'] ?? 0)) {
$roles[] = 'adoptant';
}
if ($yes($r['ISFOSTERER'] ?? 0)) {
$roles[] = 'fa';
}
if ($yes($r['ISVOLUNTEER'] ?? 0)) {
$roles[] = 'benevole';
}
if ($depositor) {
$roles[] = 'deposant';
}
if ($vetReferenced || $yes($r['ISVET'] ?? 0)) {
$roles[] = $organization ? 'cabinet' : 'veterinaire';
}
if (preg_match('/cr[eé]matorium/iu', $name)) {
$roles[] = 'crematorium';
}
$phones = array_values(
array_filter([
trim((string) ($r['MOBILETELEPHONE'] ?? '')),
trim((string) ($r['HOMETELEPHONE'] ?? '')),
trim((string) ($r['WORKTELEPHONE'] ?? '')),
]),
);
return [
'kind' => $organization ? 'organization' : 'person',
'name' => $name ?: 'Contact ASM3 ' . (int) $r['ID'],
'phone' => $phones[0] ?? null,
'email' => trim((string) ($r['EMAILADDRESS'] ?? '')) ?: null,
'address' => trim((string) ($r['OWNERADDRESS'] ?? '')) ?: null,
'postal_code' => trim((string) ($r['OWNERPOSTCODE'] ?? '')) ?: null,
'city' => trim((string) ($r['OWNERTOWN'] ?? '')) ?: null,
'country' => trim((string) ($r['OWNERCOUNTRY'] ?? '')) ?: 'France',
'notes' => trim((string) ($r['COMMENTS'] ?? '')) ?: null,
'roles' => array_values(array_unique($roles)),
];
}
private static function text(mixed $v): string
{
return mb_strtolower(trim((string) $v));
}
private static function phone(mixed $v): string
{
return (string) preg_replace('/\D+/', '', (string) $v);
}
}

View file

@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
final class Asm3IntakeRecovery
{
private const CITIES = [
'Betton' => ['betton', 'beton'],
'Brignogan-Plages' => ['brignogan'],
'Fougères' => ['fougère', 'fougeres'],
'Gouesnou' => ['gouesnou'],
'Goulven' => ['goulven'],
'Guissény' => ['guissény', 'guisseny'],
'Kernouës' => ['kernouës', 'kernoues'],
'Kerlouan' => ['kerlouan'],
'Landerneau' => ['landerneau'],
'Lanhouarneau' => ['lanhouarneau'],
'Landivisiau' => ['landivisiau'],
'Le Drennec' => ['le drennec'],
'Le Folgoët' => ['le folgoët', 'le folgoet', 'du folgoët', 'du folgoet'],
'Lesneven' => ['lesneven', 'lesnevenet'],
'Perros-Guirec' => ['perros guirec', 'perros guierrec'],
'Ploudaniel' => ['ploudaniel'],
'Plouescat' => ['plouescat'],
'Plouguerneau' => ['plouguerneau'],
'Plouider' => ['plouider'],
'Plounéour-Brignogan-Plages' => [
'plounéour trez',
'plouneour trez',
'plounéour brignogan plage',
'plouneour brignogan plage',
],
'Plounévez-Lochrist' => ['plounevez lochrist', 'plounévez lochrist'],
'Plouzané' => ['plouzané', 'plouzane'],
'Saint-Frégant' => ['saint frégant', 'saint fregant'],
'Saint-Méen' => ['saint méen', 'saint meen'],
'Saint-Pol-de-Léon' => ['saint pol de leon', 'saint-pol-de-léon'],
];
public static function reason(array $row): string
{
return trim((string) ($row['REASONFORENTRY'] ?? ''));
}
public static function municipality(string $reason): ?string
{
$text = self::plain($reason);
if ($text === '') {
return null;
}
$found = [];
foreach (self::CITIES as $city => $aliases) {
foreach ($aliases as $alias) {
if (str_contains($text, self::plain($alias))) {
$found[$city] = true;
break;
}
}
}
if (count($found) !== 1) {
return null;
}
$city = (string) array_key_first($found);
$words =
'trouv|errant|recueill|recup|ramass|vient|arriv|fourriere|mairie|police municipale|jardin|terrain|ferme|hangar|grange|ne dans|abandonn|signal';
if (preg_match('/^\s*' . self::cityPattern($city) . '\b/u', $text)) {
return $city;
}
if (preg_match('/(?:' . $words . ').{0,100}' . self::cityPattern($city) . '/u', $text)) {
return $city;
}
if (preg_match('/' . self::cityPattern($city) . '.{0,55}(?:' . $words . ')/u', $text)) {
return $city;
}
return null;
}
private static function cityPattern(string $city): string
{
$parts = [];
foreach (self::CITIES[$city] as $alias) {
$parts[] = preg_quote(self::plain($alias), '/');
}
return '(?:' . implode('|', $parts) . ')';
}
private static function plain(string $value): string
{
$value = mb_strtolower(trim($value));
$v = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value);
return preg_replace('/\s+/u', ' ', is_string($v) ? $v : $value) ?? $value;
}
}

View file

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
final class Asm3LitterImporter
{
public static function run(string $path, PDO $db, bool $apply = false, bool $createBackup = true): array
{
$hash = hash_file('sha256', $path);
$src = Asm3Analyzer::rows($path, ['animallitter', 'animal']);
$links = [];
$q = $db->prepare(
"SELECT source_id,target_id FROM asm3_import_links WHERE source_sha256=:h AND entity_type='animal'",
);
$q->execute([':h' => $hash]);
foreach ($q as $r) {
$links[(int) $r['source_id']] = (int) $r['target_id'];
}
$animals = [];
foreach ($src['animal'] ?? [] as $a) {
$animals[(string) $a['ACCEPTANCENUMBER']][] = (int) $a['ID'];
}
$result = [
'mode' => $apply ? 'apply' : 'dry-run',
'read_only' => !$apply,
'litters' => count($src['animallitter'] ?? []),
'would_import' => 0,
'unresolved' => 0,
'created' => 0,
'kittens_linked' => 0,
'backup' => null,
];
foreach ($src['animallitter'] ?? [] as $l) {
$parent = (int) $l['PARENTANIMALID'];
$kids = array_filter($animals[(string) $l['ACCEPTANCENUMBER']] ?? [], fn($id) => $id !== $parent);
if (!isset($links[$parent]) || array_filter($kids, fn($id) => !isset($links[$id]))) {
$result['unresolved']++;
continue;
}
$result['would_import']++;
}
if (!$apply) {
return $result;
}
if ($createBackup) {
$b = BackupService::create('pre-asm3-import');
$result['backup'] = $b['name'] ?? null;
}
$owns = !$db->inTransaction();
if ($owns) {
$db->beginTransaction();
}
try {
$db->prepare(
"INSERT INTO asm3_import_runs(source_name,source_sha256,status,summary_json,backup_name) VALUES(:n,:h,'completed','{}',:b)",
)->execute([':n' => basename($path) . ' · portées', ':h' => $hash, ':b' => $result['backup']]);
$run = (int) $db->lastInsertId();
foreach ($src['animallitter'] ?? [] as $l) {
$source = (int) $l['ID'];
$seen = $db->prepare(
"SELECT 1 FROM asm3_import_links WHERE source_sha256=:h AND entity_type='litter' AND source_id=:s",
);
$seen->execute([':h' => $hash, ':s' => $source]);
if ($seen->fetchColumn()) {
continue;
}
$parent = (int) $l['PARENTANIMALID'];
$kids = array_values(
array_filter($animals[(string) $l['ACCEPTANCENUMBER']] ?? [], fn($id) => $id !== $parent),
);
if (!isset($links[$parent]) || array_filter($kids, fn($id) => !isset($links[$id]))) {
continue;
}
$db->prepare('INSERT INTO litters(mother_id,birth_date,notes) VALUES(:parent,:date,:notes)')->execute([
':parent' => $links[$parent],
':date' => substr((string) $l['DATE'], 0, 10),
':notes' => trim((string) $l['COMMENTS']) ?: null,
]);
$target = (int) $db->lastInsertId();
$ins = $db->prepare(
'INSERT OR IGNORE INTO litter_kittens(litter_id,animal_id,position) VALUES(:litter,:animal,:position)',
);
foreach ($kids as $i => $kid) {
$ins->execute([':litter' => $target, ':animal' => $links[$kid], ':position' => $i + 1]);
$result['kittens_linked']++;
}
$db->prepare(
"INSERT INTO asm3_import_links(source_sha256,entity_type,source_id,target_id,import_run_id) VALUES(:h,'litter',:s,:t,:r)",
)->execute([':h' => $hash, ':s' => $source, ':t' => $target, ':r' => $run]);
$result['created']++;
}
if ($owns) {
$db->commit();
}
} catch (Throwable $e) {
if ($owns && $db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
return $result;
}
}

View file

@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
final class Asm3MigrationPlanner
{
public static function plan(string $path, PDO $db): array
{
$source = Asm3Analyzer::rows($path, ['animal', 'species', 'basecolour', 'lksex']);
$species = self::lookup($source['species'] ?? [], 'ID', 'SPECIESNAME');
$colors = self::lookup($source['basecolour'] ?? [], 'ID', 'BASECOLOUR');
$sexes = self::lookup($source['lksex'] ?? [], 'ID', 'SEX');
$existing = $db
->query('SELECT id,name,internal_code,chip_id,birth_date FROM animals')
->fetchAll(PDO::FETCH_ASSOC);
$byChip = [];
$byCode = [];
$byIdentity = [];
$byName = [];
foreach ($existing as $animal) {
$chip = self::identifier($animal['chip_id'] ?? '');
if ($chip !== '') {
$byChip[$chip][] = $animal;
}
$code = self::text($animal['internal_code'] ?? '');
if ($code !== '') {
$byCode[$code][] = $animal;
}
$identity = self::identity($animal['name'] ?? '', $animal['birth_date'] ?? '');
if ($identity !== '') {
$byIdentity[$identity][] = $animal;
}
$name = self::text($animal['name'] ?? '');
if ($name !== '') {
$byName[$name][] = $animal;
}
}
$items = [];
$summary = ['create' => 0, 'match' => 0, 'review' => 0];
foreach ($source['animal'] ?? [] as $row) {
$mapped = self::mapAnimal($row, $species, $colors, $sexes);
$candidates = [];
$reasons = [];
$chip = self::identifier($mapped['chip_id']);
if ($chip !== '' && !empty($byChip[$chip])) {
foreach ($byChip[$chip] as $a) {
$candidates[(int) $a['id']] = $a;
}
$reasons[] = 'même identification';
}
// Les codes refuge peuvent être réutilisés après une remise à zéro d’ASM3.
// Un code seul n’est donc jamais une preuve suffisante entre deux sauvegardes historiques.
$code = self::text($mapped['internal_code']);
$identity = self::identity($mapped['name'], $mapped['birth_date']);
if ($identity !== '' && !empty($byIdentity[$identity])) {
foreach ($byIdentity[$identity] as $a) {
$candidates[(int) $a['id']] = $a;
}
$reasons[] = 'même nom et naissance';
}
$strongMatch = count($candidates) > 0;
if (!$strongMatch) {
$name = self::text($mapped['name']);
if ($name !== '' && !empty($byName[$name])) {
$weakByName = $byName[$name];
$knownDifferentBirths = $mapped['birth_date'] !== '' && $mapped['birth_date'] !== null;
foreach ($weakByName as $a) {
$knownDifferentBirths =
$knownDifferentBirths &&
!empty($a['birth_date']) &&
$a['birth_date'] !== $mapped['birth_date'];
}
if (!$knownDifferentBirths) {
foreach ($weakByName as $a) {
$candidates[(int) $a['id']] = $a;
}
$reasons[] = 'même nom uniquement';
}
}
}
// Un code isolé et réutilisé ne doit ni fusionner, ni bloquer la création.
// Asm3AnimalImporter suffixera le code technique si nécessaire.
if (!$strongMatch && count($candidates) === 0) {
$action = 'create';
} elseif ($strongMatch && count($candidates) === 1) {
$action = 'match';
} else {
$action = 'review';
}
$summary[$action]++;
$items[] = [
'asm3_id' => (int) ($row['ID'] ?? 0),
'action' => $action,
'match_reasons' => array_values(array_unique($reasons)),
'globinours_candidates' => array_values(
array_map(
static fn($a) => ['id' => (int) $a['id'], 'name' => $a['name'], 'code' => $a['internal_code']],
$candidates,
),
),
'mapped' => $mapped,
];
}
return [
'source' => basename($path),
'generated_at' => date(DATE_ATOM),
'read_only' => true,
'summary' => $summary,
'items' => $items,
'lookups' => ['species' => $species, 'colors' => $colors, 'sexes' => $sexes],
];
}
private static function mapAnimal(array $r, array $species, array $colors, array $sexes): array
{
$date = static fn($v) => $v && preg_match('/^\d{4}-\d{2}-\d{2}/', (string) $v, $m) ? $m[0] : null;
$yes = static fn($v) => (string) $v === '1';
$tri = static fn($v) => match ((string) $v) {
'0' => 'no',
'1' => 'yes',
default => 'unknown',
};
$chip = trim((string) ($r['IDENTICHIPNUMBER'] ?? ''));
$tattoo = trim((string) ($r['TATTOONUMBER'] ?? ''));
$identifier = $chip !== '' ? $chip : $tattoo;
$deceased = $date($r['DECEASEDDATE'] ?? null) !== null || $yes($r['PUTTOSLEEP'] ?? 0);
$availability = $yes($r['ISNOTAVAILABLEFORADOPTION'] ?? 0)
? 'not_available'
: ($yes($r['ADOPTABLE'] ?? 0)
? 'available'
: 'unknown');
return [
'name' => trim((string) ($r['ANIMALNAME'] ?? '')),
'internal_code' => trim((string) ($r['SHELTERCODE'] ?? '' ?: $r['SHORTCODE'] ?? '')),
'species' => self::species($species[(string) ($r['SPECIESID'] ?? '')] ?? ''),
'sex' => match (mb_strtolower((string) ($sexes[(string) ($r['SEX'] ?? '')] ?? ''))) {
'mâle' => 'M',
'femelle' => 'F',
default => 'U',
},
'birth_date' => $date($r['DATEOFBIRTH'] ?? null),
'birth_is_estimated' => $yes($r['ESTIMATEDDOB'] ?? 0) ? 1 : 0,
'breed' => trim((string) ($r['BREEDNAME'] ?? '')),
'color' => $colors[(string) ($r['BASECOLOURID'] ?? '')] ?? null,
'notes' => trim((string) ($r['ANIMALCOMMENTS'] ?? '')),
'intake_date' => $date($r['MOSTRECENTENTRYDATE'] ?? null ?: $r['DATEBROUGHTIN'] ?? null),
'status' => $deceased ? 'decede' : ($yes($r['ISQUARANTINE'] ?? 0) ? 'quarantaine' : 'refuge'),
'archived' => $yes($r['ARCHIVED'] ?? 0),
'deceased_date' => $date($r['DECEASEDDATE'] ?? null),
'identification_type' => $chip !== '' ? 'microchip' : ($tattoo !== '' ? 'tattoo' : 'unknown'),
'chip_id' => $identifier,
'identification_date' => $date($chip !== '' ? $r['IDENTICHIPDATE'] ?? null : $r['TATTOODATE'] ?? null),
'identification_registration_status' => match ((string) ($r['IDENTICHIPSTATUS'] ?? '')) {
'1' => 'registered',
'0' => 'pending',
default => 'unknown',
},
'sterilization_status' => $yes($r['NEUTERED'] ?? 0) ? 'yes' : 'unknown',
'sterilization_date' => $date($r['NEUTEREDDATE'] ?? null),
'adoption_availability' => $availability,
'adoption_available_from' => $date($r['HOLDUNTILDATE'] ?? null),
'adoption_unavailability_reason' => trim((string) ($r['REASONNO'] ?? '')),
'compatibility_dogs' => $tri($r['ISGOODWITHDOGS'] ?? null),
'compatibility_cats' => $tri($r['ISGOODWITHCATS'] ?? null),
'compatibility_children' => $tri($r['ISGOODWITHCHILDREN'] ?? null),
'house_trained' => $tri($r['ISHOUSETRAINED'] ?? null),
];
}
private static function lookup(array $rows, string $key, string $value): array
{
$out = [];
foreach ($rows as $row) {
$out[(string) $row[$key]] = (string) $row[$value];
}
return $out;
}
private static function species(string $value): string
{
return match (mb_strtolower($value)) {
'chat' => 'chat',
'chien' => 'chien',
default => mb_strtolower($value) ?: 'inconnu',
};
}
private static function identifier(mixed $value): string
{
return strtoupper((string) preg_replace('/[^a-zA-Z0-9]/', '', (string) $value));
}
private static function text(mixed $value): string
{
return mb_strtolower(trim((string) $value));
}
private static function identity(mixed $name, mixed $birth): string
{
$name = self::text($name);
$birth = trim((string) $birth);
return $name !== '' && $birth !== '' ? $name . '|' . $birth : '';
}
}

View file

@ -0,0 +1,271 @@
<?php
declare(strict_types=1);
final class Asm3MovementImporter
{
public static function run(string $path, PDO $db, bool $apply = false, bool $createBackup = true): array
{
$hash = hash_file('sha256', $path);
if ($hash === false) {
throw new RuntimeException(t('asm3.hash_unavailable'));
}
$rows = Asm3Analyzer::rows($path, ['adoption'])['adoption'] ?? [];
$events = [];
$unsupported = 0;
foreach ($rows as $row) {
$source = (int) ($row['ID'] ?? 0);
$type = (int) ($row['MOVEMENTTYPE'] ?? 0);
$movementDate = self::date($row['MOVEMENTDATE'] ?? null);
$returnDate = self::date($row['RETURNDATE'] ?? null);
$reservationDate = self::date($row['RESERVATIONDATE'] ?? null);
$cancelDate = self::date($row['RESERVATIONCANCELLEDDATE'] ?? null);
if ($reservationDate) {
$events[] = self::event($row, $source, 'reservation', $reservationDate, 'Réservation ASM3');
}
if ($cancelDate) {
$events[] = self::event($row, $source, 'cancellation', $cancelDate, 'Annulation de réservation ASM3');
}
if ($movementDate && $type === 1) {
$events[] = self::event($row, $source, 'adoption', $movementDate, 'Adoption définitive ASM3');
} elseif ($movementDate && in_array($type, [2, 12], true)) {
$events[] = self::event(
$row,
$source,
'foster',
$movementDate,
$type === 12 || (string) ($row['ISPERMANENTFOSTER'] ?? 0) === '1'
? 'FA permanente ASM3'
: 'Famille d’accueil ASM3',
);
} elseif ($movementDate && !in_array($type, [0, 9, 10], true)) {
$unsupported++;
}
if ($returnDate && in_array($type, [1, 2, 12], true)) {
$events[] = self::event(
$row,
$source,
'return',
$returnDate,
trim((string) ($row['REASONFORRETURN'] ?? '')) ?: 'Retour enregistré dans ASM3',
);
}
}
$links = self::links($db, $hash);
$summary = [
'events' => count($events),
'adoptions' => 0,
'fosters' => 0,
'returns' => 0,
'reservations' => 0,
'cancellations' => 0,
'unresolved_animals' => 0,
'unresolved_contacts' => 0,
'unsupported_movements' => $unsupported,
'would_import' => 0,
'created' => 0,
'already_imported' => 0,
];
foreach ($events as $event) {
$summary[
$event['type'] === 'adoption'
? 'adoptions'
: ($event['type'] === 'foster'
? 'fosters'
: ($event['type'] === 'return'
? 'returns'
: ($event['type'] === 'reservation'
? 'reservations'
: 'cancellations')))
]++;
if (!isset($links['animal'][$event['animal_source_id']])) {
$summary['unresolved_animals']++;
continue;
}
if ($event['contact_source_id'] > 0 && !isset($links['contact'][$event['contact_source_id']])) {
$summary['unresolved_contacts']++;
}
$summary['would_import']++;
}
if (!$apply) {
return ['mode' => 'dry-run', 'read_only' => true, 'summary' => $summary];
}
if ($createBackup) {
$backup = BackupService::create('pre-asm3-import');
$backupName = $backup['name'] ?? null;
} else {
$backupName = null;
}
$ownsTransaction = !$db->inTransaction();
if ($ownsTransaction) {
$db->beginTransaction();
}
try {
$db->prepare(
"INSERT INTO asm3_import_runs(source_name,source_sha256,status,summary_json,backup_name) VALUES(:name,:hash,'completed','{}',:backup)",
)->execute([':name' => basename($path) . ' · mouvements', ':hash' => $hash, ':backup' => $backupName]);
$runId = (int) $db->lastInsertId();
$affected = [];
foreach ($events as $event) {
$animalId = $links['animal'][$event['animal_source_id']] ?? 0;
if (!$animalId) {
continue;
}
$affected[$animalId] = true;
$entity = 'placement-' . $event['type'];
$exists = $db->prepare(
'SELECT target_id FROM asm3_import_links WHERE source_sha256=:hash AND entity_type=:entity AND source_id=:source',
);
$exists->execute([':hash' => $hash, ':entity' => $entity, ':source' => $event['source_id']]);
if ($exists->fetchColumn()) {
$summary['already_imported']++;
continue;
}
$contactId = $links['contact'][$event['contact_source_id']] ?? null;
$adoptionId = null;
if ($event['type'] === 'adoption') {
$contact = self::contact($db, $contactId);
$db->prepare(
'INSERT INTO adoptions(animal_id,adopter_name,adopter_phone,adopter_email,adopter_address,adopter_postal_code,adopter_city,adopter_country,adoption_date,notes,adopter_contact_id) VALUES(:animal,:name,:phone,:email,:address,:postal,:city,:country,:date,:notes,:contact)',
)->execute([
':animal' => $animalId,
':name' => $contact['name'] ?? 'Adoptant ASM3 non retrouvé',
':phone' => $contact['phone'] ?? null,
':email' => $contact['email'] ?? null,
':address' => $contact['address'] ?? null,
':postal' => $contact['postal_code'] ?? null,
':city' => $contact['city'] ?? null,
':country' => $contact['country'] ?? 'France',
':date' => $event['date'],
':notes' => $event['notes'] ?: null,
':contact' => $contactId,
]);
$adoptionId = (int) $db->lastInsertId();
}
$db->prepare(
'INSERT INTO animal_placements(animal_id,event_type,event_date,contact_id,reason,notes,adoption_id) VALUES(:animal,:type,:date,:contact,:reason,:notes,:adoption)',
)->execute([
':animal' => $animalId,
':type' => $event['type'],
':date' => $event['date'],
':contact' => $contactId,
':reason' => $event['reason'],
':notes' => $event['notes'] ?: null,
':adoption' => $adoptionId,
]);
$placementId = (int) $db->lastInsertId();
$db->prepare(
'INSERT INTO asm3_import_links(source_sha256,entity_type,source_id,target_id,import_run_id) VALUES(:hash,:entity,:source,:target,:run)',
)->execute([
':hash' => $hash,
':entity' => $entity,
':source' => $event['source_id'],
':target' => $placementId,
':run' => $runId,
]);
$summary['created']++;
}
self::syncAnimalStates($db, array_keys($affected));
$db->prepare('UPDATE asm3_import_runs SET summary_json=:summary WHERE id=:id')->execute([
':summary' => json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
':id' => $runId,
]);
if ($ownsTransaction) {
$db->commit();
}
} catch (Throwable $e) {
if ($ownsTransaction && $db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
return ['mode' => 'apply', 'read_only' => false, 'backup' => $backupName, 'summary' => $summary];
}
private static function event(array $row, int $source, string $type, string $date, string $reason): array
{
return [
'source_id' => $source,
'type' => $type,
'date' => $date,
'animal_source_id' => (int) ($row['ANIMALID'] ?? 0),
'contact_source_id' => (int) ($row['OWNERID'] ?? 0),
'reason' => $reason,
'notes' => trim((string) ($row['COMMENTS'] ?? '')),
];
}
private static function date(mixed $value): ?string
{
return $value && preg_match('/^\d{4}-\d{2}-\d{2}/', (string) $value, $m) ? $m[0] : null;
}
private static function links(PDO $db, string $hash): array
{
$out = ['animal' => [], 'contact' => []];
$s = $db->prepare(
"SELECT entity_type,source_id,target_id FROM asm3_import_links WHERE source_sha256=:hash AND entity_type IN ('animal','contact')",
);
$s->execute([':hash' => $hash]);
foreach ($s as $r) {
$out[$r['entity_type']][(int) $r['source_id']] = (int) $r['target_id'];
}
return $out;
}
private static function contact(PDO $db, ?int $id): ?array
{
if (!$id) {
return null;
}
$s = $db->prepare('SELECT * FROM directory_contacts WHERE id=:id');
$s->execute([':id' => $id]);
return $s->fetch(PDO::FETCH_ASSOC) ?: null;
}
private static function syncAnimalStates(PDO $db, array $animalIds): void
{
$latest = $db->prepare(
'SELECT ap.*,dc.name contact_name,dc.address,dc.postal_code,dc.city FROM animal_placements ap LEFT JOIN directory_contacts dc ON dc.id=ap.contact_id WHERE ap.animal_id=:animal ORDER BY date(ap.event_date) DESC,ap.id DESC LIMIT 1',
);
foreach ($animalIds as $animalId) {
$death = $db->prepare('SELECT 1 FROM animal_deaths WHERE animal_id=?');
$death->execute([$animalId]);
if ($death->fetchColumn()) {
continue;
}
$latest->execute([':animal' => $animalId]);
$event = $latest->fetch(PDO::FETCH_ASSOC);
if (!$event) {
continue;
}
$address = implode(
' ',
array_filter([$event['address'] ?? null, $event['postal_code'] ?? null, $event['city'] ?? null]),
);
if ($event['event_type'] === 'adoption') {
$db->prepare(
"UPDATE animals SET status='adopte',is_archived=1,archived_at=:archived,current_address=:address,refuge_room=NULL,care_box_key=NULL,quarantine_until=NULL,updated_at=datetime('now') WHERE id=:id",
)->execute([
':archived' => $event['event_date'] . ' 12:00:00',
':address' =>
$address !== ''
? $address
: 'Adopté par ' . ($event['contact_name'] ?: 'un adoptant non renseigné'),
':id' => $animalId,
]);
} elseif ($event['event_type'] === 'foster') {
$db->prepare(
"UPDATE animals SET status=CASE WHEN lower(COALESCE(reason,'')) LIKE '%permanente%' THEN 'fa_permanente' ELSE 'fa' END,is_archived=0,archived_at=NULL,current_address=:address,refuge_room=NULL,care_box_key=NULL,updated_at=datetime('now') WHERE id=:id",
)->execute([
':address' =>
$address !== '' ? $address : 'Chez ' . ($event['contact_name'] ?: 'une famille d’accueil'),
':id' => $animalId,
]);
} elseif (in_array($event['event_type'], ['return', 'cancellation'], true)) {
$db->prepare(
"UPDATE animals SET status='refuge',is_archived=0,archived_at=NULL,current_address=NULL,updated_at=datetime('now') WHERE id=?",
)->execute([$animalId]);
} elseif ($event['event_type'] === 'reservation') {
$db->prepare(
"UPDATE animals SET status='reserve',is_archived=0,archived_at=NULL,updated_at=datetime('now') WHERE id=?",
)->execute([$animalId]);
}
}
}
}

View file

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
final class Asm3ResetService
{
public static function clear(PDO $db): array
{
$before = [
'animals' => (int) $db->query('SELECT COUNT(*) FROM animals')->fetchColumn(),
'contacts' => (int) $db->query('SELECT COUNT(*) FROM directory_contacts')->fetchColumn(),
];
$tables = [
'agenda_volunteers',
'agenda_items',
'prescription_treatments',
'lab_results',
'medical_prescriptions',
'lab_reports',
'treatment_administrations',
'care_round_observations',
'animal_photos',
'medical_photos',
'animal_health_conditions',
'vaccinations',
'treatments',
'medical_notes',
'measurements',
'animal_placements',
'adoptions',
'animal_movements',
'animal_deaths',
'animal_location_history',
'animal_history',
'litter_kittens',
'litters',
'bonded_group_members',
'bonded_groups',
'care_rounds',
'icad_cache',
'asm3_import_links',
'asm3_import_runs',
'directory_contact_roles',
'animals',
'directory_contacts',
];
foreach ($tables as $table) {
$db->exec('DELETE FROM ' . $table);
}
return $before;
}
public static function clearMedia(): void
{
foreach (
[dirname(__DIR__, 2) . '/public/media/animals', dirname(__DIR__, 2) . '/data/medical-documents']
as $root
) {
if (!is_dir($root)) {
continue;
}
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST,
);
foreach ($iterator as $file) {
$path = $file->getPathname();
if ($file->isLink()) {
unlink($path);
} elseif ($file->isDir()) {
rmdir($path);
} else {
unlink($path);
}
}
}
}
}

View file

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
final class AssociationBrand
{
private const MAX_BYTES = 5_000_000;
private const MIME_EXTENSIONS = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/webp' => 'webp'];
public static function path(): ?string
{
$files = glob(self::directory() . '/logo.*') ?: [];
foreach ($files as $file) {
if (is_file($file)) {
return $file;
}
}
return null;
}
public static function store(array $upload): void
{
$error = (int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE);
if ($error === UPLOAD_ERR_NO_FILE) {
return;
}
if ($error !== UPLOAD_ERR_OK || !is_uploaded_file((string) ($upload['tmp_name'] ?? ''))) {
throw new RuntimeException(t('service.logo.upload_failed'));
}
if ((int) ($upload['size'] ?? 0) > self::MAX_BYTES) {
throw new RuntimeException(t('service.logo.too_large'));
}
$tmp = (string) $upload['tmp_name'];
$mime = new finfo(FILEINFO_MIME_TYPE)->file($tmp);
$extension = self::MIME_EXTENSIONS[$mime] ?? null;
if (!$extension) {
throw new RuntimeException(t('service.logo.format'));
}
$dimensions = @getimagesize($tmp);
if (!$dimensions || $dimensions[0] * $dimensions[1] > 40_000_000) {
throw new RuntimeException(t('service.logo.dimensions'));
}
$dir = self::directory();
$stored = ImageService::storeUploaded($tmp, $dir, 'association', 'logo');
$target = $dir . '/logo.' . $stored['extension'];
foreach (glob($dir . '/logo.*') ?: [] as $old) {
if ($old !== $stored['path']) {
@unlink($old);
}
}
if (!rename($stored['path'], $target)) {
@unlink($stored['path']);
throw new RuntimeException(t('service.logo.finalize_failed'));
}
}
public static function delete(): void
{
foreach (glob(self::directory() . '/logo.*') ?: [] as $file) {
if (is_file($file)) {
@unlink($file);
}
}
}
private static function directory(): string
{
return dirname(__DIR__, 2) . '/data/association';
}
}

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
final class AuditService
{
public static function log(
string $action,
string $path,
?string $summary = null,
?string $entityType = null,
?int $entityId = null,
array $metadata = [],
): void {
$safe = [];
foreach ($metadata as $key => $value) {
if (in_array(strtolower((string) $key), ['password', 'password_confirm', 'csrf'], true)) {
continue;
}
if (is_scalar($value) || $value === null) {
$safe[$key] = $value;
}
}
DB::pdo()
->prepare(
'INSERT INTO audit_log(user_id,action,method,path,entity_type,entity_id,summary,metadata,ip_address) VALUES(:user,:action,:method,:path,:type,:entity,:summary,:meta,:ip)',
)
->execute([
':user' => Auth::id(),
':action' => $action,
':method' => $_SERVER['REQUEST_METHOD'] ?? 'CLI',
':path' => $path,
':type' => $entityType,
':entity' => $entityId,
':summary' => $summary,
':meta' => $safe ? json_encode($safe, JSON_UNESCAPED_UNICODE) : null,
':ip' => $_SERVER['REMOTE_ADDR'] ?? null,
]);
}
}

201
app/Services/Auth.php Normal file
View file

@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
final class Auth
{
private static ?array $user = null;
public static function start(): void
{
if (session_status() === PHP_SESSION_ACTIVE) {
return;
}
ini_set('session.use_strict_mode', '1');
ini_set('session.use_only_cookies', '1');
ini_set('session.cookie_httponly', '1');
session_name('globinours_session');
session_set_cookie_params([
'httponly' => true,
'secure' => self::isSecureRequest(),
'samesite' => 'Strict',
'path' => '/',
]);
session_start();
$now = time();
$idle = max(300, (int) (getenv('GLOBINOURS_SESSION_IDLE') ?: 1800));
$absolute = max($idle, (int) (getenv('GLOBINOURS_SESSION_ABSOLUTE') ?: 43200));
if (
!empty($_SESSION['user_id']) &&
((isset($_SESSION['last_activity']) && $now - (int) $_SESSION['last_activity'] > $idle) ||
(isset($_SESSION['authenticated_at']) && $now - (int) $_SESSION['authenticated_at'] > $absolute))
) {
self::logout(false);
session_regenerate_id(true);
}
if (
empty($_SESSION['user_id']) &&
class_exists(TrustedDeviceService::class) &&
($id = TrustedDeviceService::authenticate())
) {
session_regenerate_id(true);
$_SESSION['user_id'] = $id;
$_SESSION['authenticated_at'] = $now;
}
if (!empty($_SESSION['user_id'])) {
$_SESSION['last_activity'] = $now;
}
}
public static function countUsers(): int
{
return (int) DB::pdo()->query('SELECT COUNT(*) FROM users')->fetchColumn();
}
public static function user(): ?array
{
if (self::$user !== null) {
return self::$user;
}
$id = (int) ($_SESSION['user_id'] ?? 0);
if (!$id) {
return null;
}
$s = DB::pdo()->prepare(
'SELECT id,username,display_name,role,active,last_login_at,animal_list_view,agenda_view FROM users WHERE id=:id AND active=1',
);
$s->execute([':id' => $id]);
$u = $s->fetch(PDO::FETCH_ASSOC);
if (!$u) {
self::logout(false);
return null;
}
return self::$user = $u;
}
public static function id(): ?int
{
return ($u = self::user()) ? (int) $u['id'] : null;
}
public static function login(
string $username,
string $password,
bool $remember = false,
string $deviceName = '',
): bool {
$db = DB::pdo();
$username = trim($username);
$ip = mb_substr((string) ($_SERVER['REMOTE_ADDR'] ?? ''), 0, 64);
$db->exec("DELETE FROM login_attempts WHERE attempted_at<datetime('now','-7 days')");
$rate = $db->prepare(
"SELECT COUNT(*) FROM login_attempts WHERE succeeded=0 AND ip_address=? AND attempted_at>=datetime('now','-15 minutes')",
);
$rate->execute([$ip ?: null]);
if ((int) $rate->fetchColumn() >= 20) {
self::recordAttempt($username, $ip, false);
return false;
}
$s = $db->prepare('SELECT * FROM users WHERE lower(username)=lower(:u) LIMIT 1');
$s->execute([':u' => $username]);
$u = $s->fetch(PDO::FETCH_ASSOC);
$valid =
$u &&
(int) $u['active'] === 1 &&
(empty($u['locked_until']) || strtotime($u['locked_until']) <= time()) &&
password_verify($password, (string) $u['password_hash']);
if (!$u) {
password_verify($password, '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.');
}
if (!$valid) {
if ($u) {
$failed = (int) $u['failed_attempts'] + 1;
$lock = $failed >= 5 ? date('Y-m-d H:i:s', time() + 900) : null;
$db->prepare('UPDATE users SET failed_attempts=:f,locked_until=:l WHERE id=:id')->execute([
':f' => $failed,
':l' => $lock,
':id' => $u['id'],
]);
}
self::recordAttempt($username, $ip, false);
return false;
}
session_regenerate_id(true);
$_SESSION['user_id'] = (int) $u['id'];
$_SESSION['authenticated_at'] = time();
$_SESSION['last_activity'] = time();
self::$user = null;
$db->prepare(
"UPDATE users SET failed_attempts=0,locked_until=NULL,last_login_at=datetime('now') WHERE id=:id",
)->execute([':id' => $u['id']]);
self::recordAttempt($username, $ip, true);
if ($remember && class_exists(TrustedDeviceService::class)) {
TrustedDeviceService::create((int) $u['id'], $deviceName);
}
return true;
}
public static function logout(bool $destroy = true): void
{
if ($destroy && class_exists(TrustedDeviceService::class)) {
TrustedDeviceService::revokeCurrent();
}
self::$user = null;
unset($_SESSION['user_id']);
if ($destroy) {
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(
session_name(),
'',
time() - 42000,
$p['path'],
$p['domain'] ?? '',
(bool) $p['secure'],
(bool) $p['httponly'],
);
}
session_destroy();
}
}
public static function csrf(): string
{
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
return (string) $_SESSION['csrf'];
}
public static function validCsrf(?string $token): bool
{
return is_string($token) && hash_equals(self::csrf(), $token);
}
public static function is(string $role): bool
{
return (self::user()['role'] ?? '') === $role;
}
public static function canWrite(string $path): bool
{
$module = PermissionService::moduleForPath($path);
return $module === null || PermissionService::can($module, 'edit');
}
public static function isSecureRequest(): bool
{
if (!empty($_SERVER['HTTPS']) && strtolower((string) $_SERVER['HTTPS']) !== 'off') {
return true;
}
if (str_starts_with(strtolower((string) getenv('APP_URL')), 'https://')) {
return true;
}
$remote = (string) ($_SERVER['REMOTE_ADDR'] ?? '');
$trusted = array_filter(array_map('trim', explode(',', (string) getenv('GLOBINOURS_TRUSTED_PROXIES'))));
if (in_array($remote, ['127.0.0.1', '::1'], true) || in_array($remote, $trusted, true)) {
return strtolower(trim(explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''))[0] ?? '')) ===
'https';
}
return false;
}
private static function recordAttempt(string $username, string $ip, bool $success): void
{
try {
DB::pdo()
->prepare('INSERT INTO login_attempts(username_hash,ip_address,succeeded) VALUES(?,?,?)')
->execute([hash('sha256', mb_strtolower($username)), $ip ?: null, $success ? 1 : 0]);
} catch (Throwable) {
}
}
}

View file

@ -0,0 +1,467 @@
<?php
declare(strict_types=1);
final class BackupService
{
private const FORMAT_VERSION = 1;
public static function create(string $reason = 'manual'): array
{
if (!class_exists(ZipArchive::class)) {
throw new RuntimeException(t('service.backup.zip_missing'));
}
$root = dirname(__DIR__, 2);
$backupDir = $root . '/storage/backups';
if (!is_dir($backupDir) && !mkdir($backupDir, 0775, true) && !is_dir($backupDir)) {
throw new RuntimeException(t('service.backup.directory_failed'));
}
$stamp = new DateTimeImmutable('now', new DateTimeZone('Europe/Paris'))->format('Ymd-His');
$suffix = $reason === 'pre-restore' ? '-avant-restauration' : '';
$target = $backupDir . '/globinours-' . $stamp . $suffix . '.zip';
// Certains volumes synchronisés acceptent les ZIP mais SQLite ne peut pas
// y créer de base avec VACUUM INTO. Le snapshot transitoire appartient au
// répertoire temporaire système ; seule l'archive finale va dans backups/.
$snapshot = tempnam(sys_get_temp_dir(), 'globinours-snapshot-');
if ($snapshot === false) {
throw new RuntimeException(t('service.backup.prepare_failed'));
}
@unlink($snapshot); // VACUUM INTO exige que la destination n'existe pas.
$pdo = DB::pdo();
$pdo->exec('PRAGMA wal_checkpoint(FULL)');
$pdo->exec('VACUUM INTO ' . $pdo->quote($snapshot));
$zip = new ZipArchive();
if ($zip->open($target, ZipArchive::CREATE | ZipArchive::EXCL) !== true) {
@unlink($snapshot);
throw new RuntimeException(t('service.backup.create_failed'));
}
$files = [];
try {
self::addFile($zip, $snapshot, 'database/refuge.sqlite', $files);
if (getenv('GLOBINOURS_BACKUP_DATA_ONLY') !== '1') {
self::addTree($zip, $root . '/public/media', 'public/media', $files);
self::addTree($zip, $root . '/data/association', 'data/association', $files);
self::addTree($zip, $root . '/data/grants', 'data/grants', $files);
self::addTree($zip, $root . '/data/medical-documents', 'data/medical-documents', $files);
self::addTree($zip, $root . '/data/private-media', 'data/private-media', $files);
}
$manifest = [
'application' => 'Globinours',
'format_version' => self::FORMAT_VERSION,
'created_at' => new DateTimeImmutable('now', new DateTimeZone('Europe/Paris'))->format(
DateTimeInterface::ATOM,
),
'reason' => $reason,
'php_version' => PHP_VERSION,
'files' => $files,
];
$zip->addFromString(
'manifest.json',
json_encode(
$manifest,
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR,
),
);
} finally {
$zip->close();
@unlink($snapshot);
}
@chmod($target, 0600);
self::prune($backupDir);
return self::describe($target);
}
public static function list(): array
{
$dir = dirname(__DIR__, 2) . '/storage/backups';
$files = glob($dir . '/globinours-*.zip') ?: [];
usort($files, static fn(string $a, string $b): int => filemtime($b) <=> filemtime($a));
return array_map([self::class, 'describe'], $files);
}
public static function path(string $name): ?string
{
if (
$name !== basename($name) ||
!preg_match('/^globinours-[0-9]{8}-[0-9]{6}(?:-avant-restauration)?\.zip$/', $name)
) {
return null;
}
$path = dirname(__DIR__, 2) . '/storage/backups/' . $name;
return is_file($path) ? $path : null;
}
public static function delete(string $name): void
{
$path = self::path($name);
if (!$path) {
throw new RuntimeException(t('service.backup.not_found'));
}
if (!unlink($path)) {
throw new RuntimeException(t('service.backup.delete_failed'));
}
}
public static function verify(string $name): array
{
$path = self::path($name);
if (!$path) {
throw new RuntimeException(t('service.backup.not_found'));
}
$zip = new ZipArchive();
if ($zip->open($path) !== true) {
throw new RuntimeException(t('service.backup.unreadable'));
}
try {
$raw = $zip->getFromName('manifest.json');
if ($raw === false) {
throw new RuntimeException(t('service.backup.manifest_missing'));
}
$manifest = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
if (
($manifest['application'] ?? '') !== 'Globinours' ||
(int) ($manifest['format_version'] ?? 0) !== self::FORMAT_VERSION
) {
throw new RuntimeException(t('service.backup.incompatible'));
}
foreach ($manifest['files'] ?? [] as $file => $meta) {
$stream = $zip->getStream((string) $file);
if (!$stream) {
throw new RuntimeException(t('service.backup.file_altered', ['name' => $file]));
}
$context = hash_init('sha256');
hash_update_stream($context, $stream);
fclose($stream);
if (!hash_equals((string) ($meta['sha256'] ?? ''), hash_final($context))) {
throw new RuntimeException(t('service.backup.file_altered', ['name' => $file]));
}
}
$tmp = tempnam(sys_get_temp_dir(), 'globinours-verify-');
if ($tmp === false) {
throw new RuntimeException(t('service.backup.prepare_failed'));
}
$dbBytes = $zip->getFromName('database/refuge.sqlite');
if ($dbBytes === false) {
@unlink($tmp);
throw new RuntimeException(t('service.backup.database_missing'));
}
file_put_contents($tmp, $dbBytes);
self::validateDatabase($tmp);
@unlink($tmp);
return [
'valid' => true,
'name' => $name,
'created_at' => (string) ($manifest['created_at'] ?? ''),
'files' => count($manifest['files'] ?? []),
];
} finally {
$zip->close();
}
}
public static function runScheduled(bool $force = false): array
{
if (AppSettings::get('backup_schedule_enabled') !== '1' && !$force) {
return ['created' => false, 'reason' => 'disabled'];
}
$frequency = AppSettings::get('backup_schedule_frequency');
$last = AppSettings::get('backup_last_run_at');
$minimum = $frequency === 'weekly' ? 6 * 86400 : 20 * 3600;
if (!$force && $last !== '' && time() - (strtotime($last) ?: 0) < $minimum) {
return ['created' => false, 'reason' => 'not_due'];
}
$backup = self::create('scheduled');
$verification = self::verify($backup['name']);
AppSettings::save(
['backup_last_run_at' => date('Y-m-d H:i:s'), 'backup_last_verified_at' => date('Y-m-d H:i:s')],
null,
);
return ['created' => true, 'backup' => $backup, 'verification' => $verification];
}
public static function restore(string $uploadedPath): array
{
if (!is_file($uploadedPath)) {
throw new RuntimeException(t('service.backup.restore_missing'));
}
$root = dirname(__DIR__, 2);
$stage = $root . '/storage/restore-stage-' . bin2hex(random_bytes(6));
if (!mkdir($stage, 0700, true)) {
throw new RuntimeException(t('service.backup.prepare_failed'));
}
$zip = new ZipArchive();
if ($zip->open($uploadedPath) !== true) {
self::removeTree($stage);
throw new RuntimeException(t('service.backup.unreadable'));
}
try {
if ($zip->numFiles > 10000) {
throw new RuntimeException(t('service.backup.too_many_files'));
}
$total = 0;
$seen = [];
for ($i = 0; $i < $zip->numFiles; $i++) {
$stat = $zip->statIndex($i);
$name = (string) ($stat['name'] ?? '');
if (isset($seen[$name])) {
throw new RuntimeException(t('service.backup.unsafe_path'));
}
$seen[$name] = true;
$total += (int) ($stat['size'] ?? 0);
if ($total > 2_000_000_000) {
throw new RuntimeException(t('service.backup.too_large'));
}
if (
$name === '' ||
str_contains($name, "\0") ||
str_starts_with($name, '/') ||
preg_match('#(^|/)\.\.(/|$)#', $name)
) {
throw new RuntimeException(t('service.backup.unsafe_path'));
}
}
$manifestRaw = $zip->getFromName('manifest.json');
if ($manifestRaw === false) {
throw new RuntimeException(t('service.backup.manifest_missing'));
}
$manifest = json_decode($manifestRaw, true, 512, JSON_THROW_ON_ERROR);
if (
($manifest['application'] ?? '') !== 'Globinours' ||
(int) ($manifest['format_version'] ?? 0) !== self::FORMAT_VERSION
) {
throw new RuntimeException(t('service.backup.incompatible'));
}
$manifestFiles = array_keys(is_array($manifest['files'] ?? null) ? $manifest['files'] : []);
$allowed = array_fill_keys(array_merge(['manifest.json'], $manifestFiles), true);
for ($i = 0; $i < $zip->numFiles; $i++) {
$stat = $zip->statIndex($i);
$name = (string) ($stat['name'] ?? '');
if (str_ends_with($name, '/')) {
continue;
}
if (!isset($allowed[$name])) {
throw new RuntimeException(t('service.backup.manifest_path'));
}
if (method_exists($zip, 'getExternalAttributesIndex')) {
$opsys = 0;
$attr = 0;
if ($zip->getExternalAttributesIndex($i, $opsys, $attr) && (($attr >> 16) & 0170000) === 0120000) {
throw new RuntimeException(t('service.backup.unsafe_path'));
}
}
}
$written = 0;
foreach ($manifestFiles as $name) {
$source = $zip->getStream($name);
if (!$source) {
throw new RuntimeException(t('service.backup.extract_failed'));
}
$target = $stage . '/' . $name;
$dir = dirname($target);
if (!is_dir($dir) && !mkdir($dir, 0700, true) && !is_dir($dir)) {
fclose($source);
throw new RuntimeException(t('service.backup.extract_failed'));
}
$dest = fopen($target, 'xb');
if (!$dest) {
fclose($source);
throw new RuntimeException(t('service.backup.extract_failed'));
}
while (!feof($source)) {
$chunk = fread($source, 1048576);
if ($chunk === false) {
fclose($source);
fclose($dest);
throw new RuntimeException(t('service.backup.extract_failed'));
}
$written += strlen($chunk);
if ($written > 2_000_000_000) {
fclose($source);
fclose($dest);
throw new RuntimeException(t('service.backup.too_large'));
}
fwrite($dest, $chunk);
}
fclose($source);
fclose($dest);
@chmod($target, 0600);
}
} finally {
$zip->close();
}
try {
foreach ($manifest['files'] ?? [] as $name => $metadata) {
if (
!is_string($name) ||
!preg_match(
'#^(database/refuge\.sqlite|public/media/.+|data/grants/.+|data/medical-documents/.+|data/private-media/.+|data/documents/.+|data/association/logo\.(?:png|jpg|webp)|data/(?:Logo_Globinours_64\.png|Logo_Globinours_maxi\.png|favicon\.ico))$#',
$name,
)
) {
throw new RuntimeException(t('service.backup.manifest_path'));
}
$restored = $stage . '/' . $name;
$hash = (string) ($metadata['sha256'] ?? '');
if (!is_file($restored) || $hash === '' || !hash_equals($hash, hash_file('sha256', $restored))) {
throw new RuntimeException(t('service.backup.file_altered', ['name' => $name]));
}
}
$database = $stage . '/database/refuge.sqlite';
if (!is_file($database)) {
throw new RuntimeException(t('service.backup.database_missing'));
}
$expected = $manifest['files']['database/refuge.sqlite']['sha256'] ?? '';
if ($expected === '' || !hash_equals($expected, hash_file('sha256', $database))) {
throw new RuntimeException(t('service.backup.database_hash'));
}
self::validateDatabase($database);
$emergency = self::create('pre-restore');
self::replaceInstallation($stage, $root);
return ['emergency' => $emergency, 'created_at' => (string) ($manifest['created_at'] ?? '')];
} finally {
self::removeTree($stage);
}
}
private static function validateDatabase(string $path): void
{
$db = new SQLite3($path, SQLITE3_OPEN_READONLY);
try {
if ($db->querySingle('PRAGMA integrity_check') !== 'ok') {
throw new RuntimeException(t('service.backup.database_corrupt'));
}
foreach (['animals', 'users', 'animal_history'] as $table) {
$safe = SQLite3::escapeString($table);
if (!(int) $db->querySingle("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='$safe'")) {
throw new RuntimeException(t('service.backup.table_missing', ['table' => $table]));
}
}
} finally {
$db->close();
}
}
private static function replaceInstallation(string $stage, string $root): void
{
$rollback = $root . '/storage/restore-rollback-' . bin2hex(random_bytes(5));
mkdir($rollback, 0700, true);
$targets = ['public/media', 'data/association', 'data/grants', 'data/medical-documents', 'data/private-media'];
$moved = [];
try {
foreach ($targets as $relative) {
$live = $root . '/' . $relative;
$saved = $rollback . '/' . str_replace('/', '__', $relative);
$incoming = $stage . '/' . $relative;
if (is_dir($live) && !rename($live, $saved)) {
throw new RuntimeException(t('service.backup.secure_failed', ['path' => $relative]));
}
if (is_dir($saved)) {
$moved[$relative] = $saved;
}
if (is_dir($incoming) && !rename($incoming, $live)) {
throw new RuntimeException(t('service.backup.restore_path_failed', ['path' => $relative]));
}
if (!is_dir($live)) {
mkdir($live, 0775, true);
}
}
$pdo = DB::pdo();
$pdo->exec('PRAGMA wal_checkpoint(TRUNCATE)');
unset($pdo);
DB::close();
foreach (['-wal', '-shm'] as $suffix) {
@unlink($root . '/data/refuge.sqlite' . $suffix);
}
if (!rename($root . '/data/refuge.sqlite', $rollback . '/refuge.sqlite')) {
throw new RuntimeException(t('service.backup.secure_database_failed'));
}
if (!copy($stage . '/database/refuge.sqlite', $root . '/data/refuge.sqlite')) {
throw new RuntimeException(t('service.backup.restore_database_failed'));
}
@chmod($root . '/data/refuge.sqlite', 0600);
self::removeTree($rollback);
} catch (Throwable $e) {
DB::close();
if (is_file($rollback . '/refuge.sqlite')) {
@unlink($root . '/data/refuge.sqlite');
@rename($rollback . '/refuge.sqlite', $root . '/data/refuge.sqlite');
}
foreach ($targets as $relative) {
$live = $root . '/' . $relative;
$saved = $rollback . '/' . str_replace('/', '__', $relative);
if (is_dir($saved)) {
self::removeTree($live);
@rename($saved, $live);
}
}
self::removeTree($rollback);
throw $e;
}
}
private static function addTree(ZipArchive $zip, string $dir, string $prefix, array &$files): void
{
if (!is_dir($dir)) {
return;
}
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS));
foreach ($iterator as $file) {
if ($file->isFile() && !$file->isLink()) {
self::addFile(
$zip,
$file->getPathname(),
$prefix . '/' . substr($file->getPathname(), strlen($dir) + 1),
$files,
);
}
}
}
private static function addFile(ZipArchive $zip, string $source, string $name, array &$files): void
{
if (!is_file($source)) {
return;
}
$name = str_replace('\\', '/', $name);
if (!$zip->addFile($source, $name)) {
throw new RuntimeException(t('service.backup.add_failed', ['name' => $name]));
}
$files[$name] = ['size' => filesize($source), 'sha256' => hash_file('sha256', $source)];
}
private static function describe(string $path): array
{
$modified = new DateTimeImmutable('@' . (filemtime($path) ?: time()))->setTimezone(
new DateTimeZone('Europe/Paris'),
);
return ['name' => basename($path), 'size' => filesize($path) ?: 0, 'date' => $modified->format('d/m/Y H:i:s')];
}
private static function prune(string $dir): void
{
$files = glob($dir . '/globinours-*.zip') ?: [];
usort($files, static fn($a, $b) => filemtime($b) <=> filemtime($a));
$keep = class_exists(AppSettings::class) ? AppSettings::int('backup_retention', 1, 50) : 10;
foreach (array_slice($files, $keep) as $file) {
@unlink($file);
}
}
private static function removeTree(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST,
);
foreach ($it as $item) {
$item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
}
@rmdir($dir);
}
}

View file

@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
final class CalendarFeedService
{
public static function generateToken(int $userId): string
{
$token = bin2hex(random_bytes(32));
DB::pdo()
->prepare(
"UPDATE users SET agenda_feed_token_hash=:hash,agenda_feed_created_at=datetime('now'),updated_at=datetime('now') WHERE id=:id AND active=1",
)
->execute([':hash' => hash('sha256', $token), ':id' => $userId]);
return $token;
}
public static function revoke(int $userId): void
{
DB::pdo()
->prepare(
"UPDATE users SET agenda_feed_token_hash=NULL,agenda_feed_created_at=NULL,updated_at=datetime('now') WHERE id=?",
)
->execute([$userId]);
}
public static function userForToken(string $token): ?array
{
if (!preg_match('/^[a-f0-9]{64}$/', $token)) {
return null;
}
$s = DB::pdo()->prepare('SELECT id,role FROM users WHERE agenda_feed_token_hash=? AND active=1 LIMIT 1');
$s->execute([hash('sha256', $token)]);
$user = $s->fetch(PDO::FETCH_ASSOC) ?: null;
if (!$user || !PermissionService::can('agenda', 'view', (string) $user['role'])) {
return null;
}
return $user;
}
public static function absoluteUrl(string $path): string
{
$configured = rtrim((string) getenv('APP_URL'), '/');
if ($configured !== '' && preg_match('~^https?://~i', $configured)) {
return $configured . $path;
}
$proto = (string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '');
if (!in_array($proto, ['http', 'https'], true)) {
$proto = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' ? 'https' : 'http';
}
$host = (string) ($_SERVER['HTTP_X_FORWARDED_HOST'] ?? ($_SERVER['HTTP_HOST'] ?? 'localhost'));
$host = trim(explode(',', $host)[0]);
if (!preg_match('/^[a-z0-9.:-]+$/i', $host)) {
$host = 'localhost';
}
return $proto . '://' . $host . $path;
}
public static function render(array $items, string $name): string
{
$lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Globinours//Agenda refuge//FR',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'X-WR-CALNAME:' . self::escape($name),
'X-WR-TIMEZONE:Europe/Paris',
];
$stamp = gmdate('Ymd\THis\Z');
foreach ($items as $item) {
$source = (string) ($item['source_type'] ?? 'agenda');
$sourceId = (int) ($item['source_id'] ?? $item['id']);
$instance = (string) $item['instance_starts_at'];
$uid = $source . '-' . $sourceId . '-' . substr(hash('sha256', $instance), 0, 12) . '@globinours';
$lines[] = 'BEGIN:VEVENT';
$lines[] = 'UID:' . $uid;
$lines[] = 'DTSTAMP:' . $stamp;
if ((int) $item['all_day'] === 1) {
$start = new DateTimeImmutable(substr($instance, 0, 10));
$lines[] = 'DTSTART;VALUE=DATE:' . $start->format('Ymd');
$lines[] = 'DTEND;VALUE=DATE:' . $start->modify('+1 day')->format('Ymd');
} else {
$start = new DateTimeImmutable($instance, new DateTimeZone('Europe/Paris'));
$end = !empty($item['instance_ends_at'])
? new DateTimeImmutable($item['instance_ends_at'], new DateTimeZone('Europe/Paris'))
: $start->modify('+1 hour');
$lines[] = 'DTSTART;TZID=Europe/Paris:' . $start->format('Ymd\THis');
$lines[] = 'DTEND;TZID=Europe/Paris:' . $end->format('Ymd\THis');
}
$lines[] = 'SUMMARY:' . self::escape((string) $item['title']);
$description = trim((string) ($item['description'] ?? ''));
if (!empty($item['animal_name'])) {
$description = trim($description . "\n" . t('animal.animal') . ' : ' . $item['animal_name']);
}
if ($description !== '') {
$lines[] = 'DESCRIPTION:' . self::escape($description);
}
if (!empty($item['location'])) {
$lines[] = 'LOCATION:' . self::escape((string) $item['location']);
}
if (!empty($item['animal_id'])) {
$lines[] = 'URL:' . self::escape(self::absoluteUrl('/animal?id=' . (int) $item['animal_id']));
}
$lines[] = 'STATUS:' . (($item['status'] ?? 'planned') === 'completed' ? 'COMPLETED' : 'CONFIRMED');
$lines[] = 'END:VEVENT';
}
$lines[] = 'END:VCALENDAR';
return implode("\r\n", array_map([self::class, 'fold'], $lines)) . "\r\n";
}
private static function escape(string $value): string
{
return str_replace(['\\', "\r\n", "\n", "\r", ';', ','], ['\\\\', '\\n', '\\n', '\\n', '\\;', '\\,'], $value);
}
private static function fold(string $line): string
{
$out = '';
while (strlen($line) > 73) {
$cut = 73;
while ($cut > 0 && (ord($line[$cut]) & 0xc0) === 0x80) {
$cut--;
}
$out .= substr($line, 0, $cut) . "\r\n ";
$line = substr($line, $cut);
}
return $out . $line;
}
}

41
app/Services/DB.php Normal file
View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
final class DB
{
private static ?PDO $pdo = null;
public static function pdo(): PDO
{
if (self::$pdo instanceof PDO) {
return self::$pdo;
}
$dbPath = getenv('GLOBINOURS_DB_PATH') ?: __DIR__ . '/../../data/refuge.sqlite';
$dir = dirname($dbPath);
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
$pdo = new PDO('sqlite:' . $dbPath, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
$pdo->exec('PRAGMA foreign_keys = ON;');
$pdo->exec('PRAGMA journal_mode = WAL;');
$pdo->exec('PRAGMA busy_timeout = 3000;');
@chmod($dbPath, 0600);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
self::$pdo = $pdo;
return $pdo;
}
public static function close(): void
{
self::$pdo = null;
}
}

View file

@ -0,0 +1,536 @@
<?php
declare(strict_types=1);
final class DemoDataService
{
private const TABLES = [
'agenda_volunteers',
'agenda_items',
'prescription_treatments',
'lab_results',
'medical_prescriptions',
'lab_reports',
'treatment_administrations',
'care_round_observations',
'care_rounds',
'medical_photos',
'animal_photos',
'animal_expenses',
'animal_surgeries',
'animal_health_conditions',
'dewormings',
'vaccinations',
'treatments',
'medical_notes',
'measurements',
'animal_placements',
'adoptions',
'animal_movements',
'animal_deaths',
'animal_location_history',
'animal_history',
'litter_kittens',
'litters',
'bonded_group_members',
'bonded_groups',
'icad_cache',
'asm3_import_links',
'asm3_import_runs',
'asm3_import_jobs',
'directory_contact_roles',
'animals',
'directory_contacts',
'grant_documents',
'grant_applications',
'grant_years',
'care_room_layouts',
'clinics',
'geocode_cache',
'audit_log',
];
public static function reset(PDO $db): array
{
$before = [
'animals' => (int) $db->query('SELECT COUNT(*) FROM animals')->fetchColumn(),
'contacts' => (int) $db->query('SELECT COUNT(*) FROM directory_contacts')->fetchColumn(),
];
$sequence = $db->prepare('DELETE FROM sqlite_sequence WHERE name=?');
$exists = $db->prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?");
foreach (self::TABLES as $table) {
$exists->execute([$table]);
if (!$exists->fetchColumn()) {
continue;
}
$db->exec('DELETE FROM ' . $table);
$sequence->execute([$table]);
}
return $before;
}
public static function seed(PDO $db, ?int $userId): array
{
$contacts = [
'fa' => [
'person',
'Alice Martin',
'06 00 00 00 01',
'alice.demo@example.org',
'12 rue des Lilas',
'29260',
'Lesneven',
],
'adopter' => [
'person',
'Marc Durand',
'06 00 00 00 02',
'marc.demo@example.org',
'8 rue du Port',
'29200',
'Brest',
],
'depositor' => [
'person',
'Sophie Le Gall',
'06 00 00 00 03',
'sophie.demo@example.org',
'4 place du Marché',
'29000',
'Ville fictive',
],
'vet' => [
'person',
'Dr Camille Morel',
'02 00 00 00 04',
'veterinaire.demo@example.org',
'2 avenue des Chats',
'29260',
'Lesneven',
],
'clinic' => [
'organization',
'Clinique vétérinaire Démo',
'02 00 00 00 05',
'clinique.demo@example.org',
'2 avenue des Chats',
'29260',
'Lesneven',
],
'crematorium' => [
'organization',
'Crématorium animalier Démo',
'02 00 00 00 06',
'crematorium.demo@example.org',
'1 route du Souvenir',
'29400',
'Landivisiau',
],
];
$contactIds = [];
$insert = $db->prepare(
'INSERT INTO directory_contacts(kind,name,phone,email,address,postal_code,city,notes) VALUES(?,?,?,?,?,?,?,?)',
);
$role = $db->prepare('INSERT INTO directory_contact_roles(contact_id,role) VALUES(?,?)');
foreach ($contacts as $key => $c) {
$insert->execute([...$c, 'Donnée fictive de démonstration']);
$contactIds[$key] = (int) $db->lastInsertId();
$roleName = match ($key) {
'adopter' => 'adoptant',
'vet' => 'veterinaire',
'clinic' => 'cabinet',
'crematorium' => 'crematorium',
'depositor' => 'deposant',
default => 'fa',
};
$role->execute([$contactIds[$key], $roleName]);
}
$animals = [
[
'DEMO-001',
'Moka',
'F',
'2019-05-14',
'refuge',
'silver',
'Bicolore Noir / Blanc',
'yes',
'yes',
'yes',
'yes',
'yes',
'available',
],
[
'DEMO-002',
'Pixel',
'M',
'2021-09-03',
'refuge',
'twix',
'Roux',
'yes',
'yes',
'yes',
'yes',
'no',
'available',
],
[
'DEMO-003',
'Twix',
'M',
'2019-04-15',
'quarantaine',
null,
'Roux',
'yes',
'yes',
'yes',
'yes',
'yes',
'not_available',
],
[
'DEMO-004',
'Nox',
'M',
'2023-02-18',
'soin',
null,
'Noir',
'no',
'yes',
'unknown',
'yes',
'no',
'not_available',
],
[
'DEMO-005',
'Caramel',
'F',
'2020-07-21',
'fa',
null,
'Écaille de tortue',
'yes',
'yes',
'yes',
'yes',
'yes',
'available',
],
[
'DEMO-006',
'Plume',
'F',
'2022-04-08',
'refuge',
'silver',
'Tricolore / Calico',
'yes',
'yes',
'yes',
'yes',
'yes',
'available',
],
[
'DEMO-007',
'Plume-Bébé-1',
'F',
date('Y-m-d', strtotime('-4 months')),
'refuge',
'silver',
'Bicolore Gris / Blanc',
'unknown',
'yes',
'unknown',
'unknown',
'unknown',
'not_available',
],
[
'DEMO-008',
'Plume-Bébé-2',
'M',
date('Y-m-d', strtotime('-4 months')),
'refuge',
'silver',
'Gris',
'unknown',
'yes',
'unknown',
'unknown',
'unknown',
'not_available',
],
[
'DEMO-009',
'Plume-Bébé-3',
'U',
date('Y-m-d', strtotime('-4 months')),
'refuge',
'silver',
'Tigré / Tabby',
'unknown',
'yes',
'unknown',
'unknown',
'unknown',
'not_available',
],
[
'DEMO-010',
'Lune',
'F',
'2021-01-12',
'refuge',
'twix',
'Blanc',
'yes',
'no',
'yes',
'yes',
'yes',
'available',
],
[
'DEMO-011',
'Soleil',
'M',
'2021-01-12',
'refuge',
'twix',
'Roux',
'yes',
'no',
'yes',
'yes',
'yes',
'available',
],
[
'DEMO-012',
'Étoile',
'F',
'2012-06-01',
'decede',
null,
'Noir',
'yes',
'unknown',
'unknown',
'unknown',
'unknown',
'not_available',
],
];
$animalIds = [];
$animal = $db->prepare(
"INSERT INTO animals(internal_code,name,species,sex,birth_date,status,refuge_room,care_box_key,color,sterilized,sterilization_status,compatibility_cats,compatibility_dogs,compatibility_children,house_trained,adoption_availability,intake_date,intake_type,intake_reason,rescue_location_name,current_address,chip_id,identification_type,identification_registration_status,archived_at,notes) VALUES(:code,:name,'chat',:sex,:birth,:status,:room,:box,:color,:sterilized,:sterilization,:cats,:dogs,:children,:clean,:availability,date('now','-'||:days||' days'),'found','stray_found','Commune fictive','5 rue de la Démonstration, 29260 Lesneven',:chip,:identification,:registration,:archived,'Donnée fictive de démonstration')",
);
foreach ($animals as $i => $a) {
[
$code,
$name,
$sex,
$birth,
$status,
$room,
$color,
$sterilization,
$cats,
$dogs,
$children,
$clean,
$availability,
] = $a;
$sterilized = $sterilization === 'yes' ? 1 : 0;
$chip = $i < 6 ? '250269900' . str_pad((string) ($i + 1), 6, '0', STR_PAD_LEFT) : null;
$animal->execute([
':code' => $code,
':name' => $name,
':sex' => $sex,
':birth' => $birth,
':status' => $status,
':room' => $room,
':box' => $status === 'soin' ? 'care-top-1' : null,
':color' => $color,
':sterilized' => $sterilized,
':sterilization' => $sterilization,
':cats' => $cats,
':dogs' => $dogs,
':children' => $children,
':clean' => $clean,
':availability' => $availability,
':days' => 90 + $i * 12,
':chip' => $chip,
':identification' => $chip ? 'microchip' : 'unknown',
':registration' => $chip ? 'registered' : 'unknown',
':archived' => $status === 'decede' ? date('Y-m-d H:i:s') : null,
]);
$animalIds[$name] = (int) $db->lastInsertId();
}
$db->prepare('INSERT INTO litters(mother_id,birth_date,notes) VALUES(?,?,?)')->execute([
$animalIds['Plume'],
$animals[6][3],
'Portée fictive de démonstration',
]);
$litter = (int) $db->lastInsertId();
$link = $db->prepare('INSERT INTO litter_kittens(litter_id,animal_id,position) VALUES(?,?,?)');
foreach (['Plume-Bébé-1', 'Plume-Bébé-2', 'Plume-Bébé-3'] as $i => $name) {
$link->execute([$litter, $animalIds[$name], $i + 1]);
}
$db->prepare('INSERT INTO bonded_groups(name,notes) VALUES(?,?)')->execute([
'Duo Lune & Soleil',
'Chats fictifs à adopter ensemble',
]);
$group = (int) $db->lastInsertId();
$member = $db->prepare('INSERT INTO bonded_group_members(group_id,animal_id,position) VALUES(?,?,?)');
$member->execute([$group, $animalIds['Lune'], 1]);
$member->execute([$group, $animalIds['Soleil'], 2]);
$twix = $animalIds['Twix'];
$db->prepare(
"UPDATE animals SET breed='Européen',birth_is_estimated=0,fiv=0,felv=0,quarantine_until=date('now','+10 days'),intake_circumstances='Trouvé seul, bilan sanitaire programmé à l’arrivée.',depositor_contact_id=?,rescue_location_name='Commune fictive',current_address='Quarantaine — box supérieur gauche',identification_date=date('now','-2 years'),notes='Fiche vitrine entièrement fictive : elle illustre un dossier animal complet.' WHERE id=?",
)->execute([$contactIds['depositor'], $twix]);
$db->prepare(
"INSERT OR IGNORE INTO ref_medications(name,molecule,form,notes) VALUES('Doxybactin','doxycycline','comprimé','Référence de démonstration')",
)->execute();
$med = (int) $db->query("SELECT id FROM ref_medications WHERE name='Doxybactin'")->fetchColumn();
$db->prepare(
"INSERT INTO treatments(animal_id,medication_id,route,dose_text,start_date,end_date,ongoing,notes,created_by,give_morning,give_evening) VALUES(?,?,'PO','1/2 comprimé matin et soir',date('now','-2 days'),date('now','+5 days'),1,'Traitement fictif de démonstration',?,1,1)",
)->execute([$twix, $med, $userId]);
$db->prepare(
"INSERT INTO medical_notes(animal_id,noted_at,kind,reason,symptoms,exam,diagnosis,plan,weight_kg,temperature_c,created_by,vet_contact_id,clinic_contact_id) VALUES(?,datetime('now','-1 day'),'consult','Visite sanitaire d’entrée','Éternuements occasionnels','État général satisfaisant, auscultation sans anomalie','Coryza léger','Surveillance, traitement court et contrôle avant sortie de quarantaine',4.8,38.4,?,?,?)",
)->execute([$twix, $userId, $contactIds['vet'], $contactIds['clinic']]);
$weight = $db->prepare(
"INSERT INTO measurements(animal_id,type,value,unit,measured_at,notes,created_by) VALUES(?,'weight',?,'kg',datetime('now',?),'Mesure fictive',?)",
);
foreach ([[4.6, '-30 days'], [4.7, '-14 days'], [4.8, '-1 day']] as $w) {
$weight->execute([$twix, $w[0], $w[1], $userId]);
}
$db->prepare(
"INSERT OR IGNORE INTO ref_vaccines(name,notes) VALUES('TCL','Vaccin de démonstration')",
)->execute();
$vaccine = (int) $db->query("SELECT id FROM ref_vaccines WHERE name='TCL'")->fetchColumn();
$db->prepare(
"INSERT INTO vaccinations(animal_id,vaccine_id,done_date,due_date,lot,manufacturer,notes,created_by,vet_contact_id,clinic_contact_id) VALUES(?,?,date('now','-11 months'),date('now','+1 month'),'LOT-DEMO-1','Laboratoire Démo','Vaccination fictive à jour',?,?,?)",
)->execute([$twix, $vaccine, $userId, $contactIds['vet'], $contactIds['clinic']]);
$dewormer = (int) $db->query('SELECT id FROM ref_dewormers ORDER BY id LIMIT 1')->fetchColumn();
$db->prepare(
"INSERT INTO dewormings(animal_id,dewormer_id,administered_on,dose_text,weight_kg,next_due_date,notes,created_by) VALUES(?,?,date('now','-10 days'),'1 pipette',4.7,date('now','+80 days'),'Administration fictive',?)",
)->execute([$twix, $dewormer, $userId]);
$db->prepare(
"INSERT INTO animal_health_conditions(animal_id,name,status,diagnosed_at,resolved_at,notes) VALUES(?,'Gingivite légère','resolved',date('now','-6 months'),date('now','-5 months'),'Antécédent fictif résolu')",
)->execute([$twix]);
$db->prepare(
"INSERT INTO lab_reports(animal_id,sampled_on,report_type,laboratory_name,veterinarian_contact_id,clinic_contact_id,notes,created_by) VALUES(?,date('now','-1 day'),'Bilan complet','Laboratoire Démo',?,?,'Prise de sang fictive — valeurs dans les intervalles usuels',?)",
)->execute([$twix, $contactIds['vet'], $contactIds['clinic'], $userId]);
$report = (int) $db->lastInsertId();
$result = $db->prepare(
'INSERT INTO lab_results(report_id,parameter_name,value,unit,reference_min,reference_max,notes,position) VALUES(?,?,?,?,?,?,?,?)',
);
foreach (
[
['Leucocytes', 8.4, '10⁹/L', 3.5, 20.7, 10],
['Érythrocytes', 9.6, '10¹²/L', 7.7, 12.8, 20],
['Hémoglobine', 13.8, 'g/dL', 10, 17, 30],
['Hématocrite', 42.1, '%', 33.7, 55.4, 40],
['Plaquettes', 312, '10⁹/L', 125, 618, 50],
['Créatinine', 12, 'mg/L', 3, 21, 60],
['Urée', 0.42, 'g/L', 0.214, 0.642, 70],
]
as $r
) {
$result->execute([$report, $r[0], $r[1], $r[2], $r[3], $r[4], 'Résultat fictif', $r[5]]);
}
$db->prepare(
"INSERT INTO animal_photos(animal_id,filename,original_name,mime,size_bytes,is_primary,is_public,caption) VALUES(?,'twix.webp','twix.jpg','image/webp',0,1,1,'Twix — photographie de démonstration')",
)->execute([$twix]);
$db->prepare(
"INSERT INTO agenda_items(item_type,title,description,starts_at,ends_at,location,status,priority,animal_id,contact_id,reminder_minutes,created_by) VALUES('veterinary','Contrôle de sortie de quarantaine','Rendez-vous fictif : contrôle clinique et validation de la sortie.',datetime('now','+7 days','09:00'),datetime('now','+7 days','09:30'),'Clinique vétérinaire Démo','planned','high',?,?,60,?)",
)->execute([$twix, $contactIds['clinic'], $userId]);
$db->prepare(
"INSERT INTO animal_placements(animal_id,event_type,event_date,contact_id,reason,notes,created_by) VALUES(?,'foster',date('now','-30 days'),?,'Placement de démonstration','Donnée fictive',?)",
)->execute([$animalIds['Caramel'], $contactIds['fa'], $userId]);
$db->prepare(
"INSERT INTO animal_deaths(animal_id,deceased_date,cause_code,cause_details,occurred_in_care,place_type,euthanized,veterinarian_contact_id,crematorium_contact_id,body_disposition,created_by) VALUES(?,date('now','-60 days'),'old_age','Décès fictif',1,'veterinaire',1,?,?,'collective_cremation',?)",
)->execute([$animalIds['Étoile'], $contactIds['vet'], $contactIds['crematorium'], $userId]);
$history = $db->prepare(
"INSERT INTO animal_history(animal_id,action,field,new_value,user_id,created_at) VALUES(?,'created','demo','Donnée entièrement fictive',?,datetime('now'))",
);
foreach ($animalIds as $id) {
$history->execute([$id, $userId]);
}
return [
'animals' => count($animalIds),
'contacts' => count($contactIds),
'litters' => 1,
'bonded_groups' => 1,
'treatments' => 1,
'vaccinations' => 1,
'lab_reports' => 1,
'agenda_items' => 1,
'photos' => 1,
];
}
public static function clearFiles(): void
{
foreach (
[
dirname(__DIR__, 2) . '/public/media/animals',
dirname(__DIR__, 2) . '/data/grants',
dirname(__DIR__, 2) . '/data/medical-documents',
]
as $root
) {
if (!is_dir($root)) {
continue;
}
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST,
);
foreach ($it as $file) {
$path = $file->getPathname();
if ($file->isLink() || $file->isFile()) {
@unlink($path);
} elseif ($file->isDir()) {
@rmdir($path);
}
}
}
}
public static function installFiles(PDO $db): void
{
$animalId = (int) $db->query("SELECT id FROM animals WHERE internal_code='DEMO-003' LIMIT 1")->fetchColumn();
if ($animalId <= 0) {
return;
}
$source = dirname(__DIR__, 2) . '/resources/demo/twix.webp';
$directory = dirname(__DIR__, 2) . '/public/media/animals/' . $animalId;
$target = $directory . '/twix.webp';
if (!is_file($source)) {
throw new RuntimeException(t('service.image.invalid_upload'));
}
if (!is_dir($directory) && !mkdir($directory, 0755, true) && !is_dir($directory)) {
throw new RuntimeException(t('service.image.directory_failed'));
}
if (!copy($source, $target)) {
throw new RuntimeException(t('service.image.save_failed'));
}
@chmod($target, 0644);
$db->prepare("UPDATE animal_photos SET size_bytes=? WHERE animal_id=? AND filename='twix.webp'")->execute([
filesize($target),
$animalId,
]);
}
}

View file

@ -0,0 +1,537 @@
<?php
declare(strict_types=1);
final class DocumentsService
{
private const ADOPTION_TEMPLATE = __DIR__ . '/../../resources/documents/CONTRAT ADOPTION V1.5.odt';
private const TEMPLATES = [
'adoption' => self::ADOPTION_TEMPLATE,
'abandon' => __DIR__ . '/../../resources/documents/ABANDON V1.0.odt',
'benevole' => __DIR__ . '/../../resources/documents/CONTRAT BENEVOLE V2.0.odt',
'fa' => __DIR__ . '/../../resources/documents/PROPOSITION FA V1.1.odt',
'pre_adoption' => __DIR__ . '/../../resources/documents/CERTIFICAT VISITE PRE-ADOPTION V1.1.odt',
];
public static function adoptionTemplate(): string
{
if (!is_file(self::ADOPTION_TEMPLATE)) {
throw new RuntimeException(t('service.document.adoption_missing'));
}
return self::ADOPTION_TEMPLATE;
}
public static function template(string $type): string
{
$path = self::TEMPLATES[$type] ?? null;
if (!$path || !is_file($path)) {
throw new RuntimeException(t('service.document.template_missing'));
}
return $path;
}
public static function generateAbandon(array $animal): string
{
return self::transformTemplate('abandon', 'abandon-' . self::safeName((string) $animal['name']), function (
DOMXPath $xp,
) use ($animal): void {
$sex = match ($animal['sex'] ?? 'U') {
'M' => 'Mâle',
'F' => 'Femelle',
default => 'Non renseigné',
};
$birth = trim((string) ($animal['birth_date'] ?? ''));
$age = '—';
if ($birth !== '') {
$born = new DateTimeImmutable($birth);
$now = new DateTimeImmutable();
$diff = $born->diff($now);
$age = $diff->y > 0 ? $diff->y . ' an' . ($diff->y > 1 ? 's' : '') : $diff->m . ' mois';
}
$chip = trim((string) ($animal['chip'] ?: $animal['chip_id'] ?? ''));
self::replaceParagraph($xp, 'Nom de l’animal', [
['Nom de l’animal : ', 'label'],
[(string) $animal['name'], 'value'],
['tab'],
['Sexe : ', 'label'],
[$sex, 'value'],
]);
self::replaceParagraph($xp, 'Race :', [
['Race : ', 'label'],
[(string) ($animal['breed'] ?? '—'), 'value'],
['tab'],
['Âge : ', 'label'],
[$age, 'value'],
['break'],
['Vaccination : ', 'label'],
['À vérifier', 'value'],
['tab'],
['Vermifugation : ', 'label'],
['À vérifier', 'value'],
['break'],
['Numéro de tatouage ou puce électronique : ', 'label'],
[$chip !== '' ? $chip : '—', 'value'],
]);
});
}
public static function generateContact(string $type, array $contact): string
{
if (!in_array($type, ['benevole', 'fa'], true)) {
throw new InvalidArgumentException(t('service.document.invalid_type'));
}
return self::transformTemplate($type, $type . '-' . self::safeName((string) $contact['name']), function (
DOMXPath $xp,
) use ($type, $contact): void {
$name = (string) $contact['name'];
$address = (string) ($contact['address'] ?? '');
$postal = (string) ($contact['postal_code'] ?? '');
$city = (string) ($contact['city'] ?? '');
$phone = (string) ($contact['phone'] ?? '');
$email = (string) ($contact['email'] ?? '');
if ($type === 'benevole') {
self::replaceParagraph(
$xp,
'Nom :',
[['Nom : ', 'label'], [$name, 'value'], ['tab'], ['Prénom : ', 'label'], ['', 'value']],
0,
);
self::replaceParagraph($xp, 'Adresse :', [['Adresse : ', 'label'], [$address, 'value']], 0);
self::replaceParagraph(
$xp,
'Code Postal :',
[['Code Postal : ', 'label'], [$postal, 'value'], ['tab'], ['Ville : ', 'label'], [$city, 'value']],
0,
);
self::replaceParagraph(
$xp,
'Numéro de téléphone',
[
['Numéro de téléphone : ', 'label'],
[$phone, 'value'],
['tab'],
['Adresse e-mail : ', 'label'],
[$email, 'value'],
],
0,
);
} else {
self::replaceParagraph($xp, 'NOM / Prénom :', [['NOM / Prénom : ', 'label'], [$name, 'value']], 0);
self::replaceParagraph($xp, 'Adresse :', [['Adresse : ', 'label'], [$address, 'value']], 0);
self::replaceParagraph(
$xp,
'Code postal :',
[['Code postal : ', 'label'], [$postal, 'value'], ['tab'], ['Ville : ', 'label'], [$city, 'value']],
0,
);
self::replaceParagraph(
$xp,
'Téléphone :',
[
['Téléphone : ', 'label'],
[$phone, 'value'],
['tab'],
['Adresse mail : ', 'label'],
[$email, 'value'],
],
0,
);
}
});
}
public static function generateAdoption(array $animal, ?array $adoption, ?array $vaccine): string
{
$dir = self::tempDir();
$name = self::safeName((string) $animal['name']);
$target = $dir . '/contrat-adoption-' . $name . '.odt';
if (!copy(self::adoptionTemplate(), $target)) {
throw new RuntimeException(t('service.document.copy_failed'));
}
$zip = new ZipArchive();
if ($zip->open($target) !== true) {
throw new RuntimeException(t('service.document.open_failed'));
}
$xml = $zip->getFromName('content.xml');
if ($xml === false) {
$zip->close();
throw new RuntimeException(t('service.document.content_invalid'));
}
$dom = new DOMDocument();
$dom->preserveWhiteSpace = true;
if (!$dom->loadXML($xml)) {
$zip->close();
throw new RuntimeException(t('service.document.xml_invalid'));
}
$xp = new DOMXPath($dom);
$xp->registerNamespace('text', 'urn:oasis:names:tc:opendocument:xmlns:text:1.0');
$xp->registerNamespace('office', 'urn:oasis:names:tc:opendocument:xmlns:office:1.0');
self::addGeneratedFieldStyles($dom, $xp);
$date = static fn(?string $value): string => $value ? date('d/m/Y', strtotime($value)) : '';
$sex = match ($animal['sex'] ?? 'U') {
'M' => '☒ Mâle ☐ Femelle',
'F' => '☐ Mâle ☒ Femelle',
default => '☐ Mâle ☐ Femelle',
};
$origin = trim((string) ($animal['breed'] ?? ''));
$chip = trim((string) ($animal['chip'] ?: $animal['chip_id'] ?? ''));
$testStatus =
'FIV ' .
self::testLabel((string) ($animal['fiv_status'] ?? 'unknown')) .
' / FeLV ' .
self::testLabel((string) ($animal['felv_status'] ?? 'unknown'));
$signatureDate = $date($adoption['adoption_date'] ?? date('Y-m-d'));
self::setSpan($xp, 'T26', (string) $animal['name']);
self::setLabeledSpan($xp, 'T28', 'Sexe : ', $sex);
self::setSpan($xp, 'T31', ' ' . $date($animal['birth_date'] ?? null));
self::setSpan($xp, 'T33', 'Robe : ', null, 'GeneratedField');
self::setSpan($xp, 'T34', (string) ($animal['color'] ?? ''));
self::setSpan($xp, 'T41', $origin !== '' ? $origin : '—');
self::setSpan($xp, 'T42', '');
self::setLabeledSpan($xp, 'T45', 'Puce électronique N° : ', $chip !== '' ? $chip : '—');
self::setLabeledSpan(
$xp,
'T49',
' : ',
(int) ($animal['sterilized'] ?? 0) === 1
? '☒ Effectuée ☐ À effectuer avant le :'
: '☐ Effectuée ☒ À effectuer avant le :',
);
self::setLabeledSpan($xp, 'T53', 'Date du test FIV-FELV : ', $testStatus, 'tab');
self::setLabeledSpan($xp, 'T55', 'Date de la dernière vaccination : ', $date($vaccine['done_date'] ?? null));
self::setLabeledSpan($xp, 'T56', 'Rappel à effectuer le : ', $date($vaccine['due_date'] ?? null), 'tab');
self::setSpan($xp, 'T98', (string) ($adoption['adopter_name'] ?? ''));
self::setSpan($xp, 'T101', (string) ($adoption['adopter_address'] ?? ''));
self::setSpan($xp, 'T104', (string) ($adoption['adopter_postal_code'] ?? ''));
self::setSpan($xp, 'T107', (string) ($adoption['adopter_city'] ?? ''));
self::setSpan($xp, 'T110', '');
self::setSpan($xp, 'T113', (string) ($adoption['adopter_phone'] ?? ''));
self::setSpan($xp, 'T116', (string) ($adoption['adopter_email'] ?? ''));
$signatureCity = AppSettings::get('association_city') ?: '—';
self::setLabeledSpan($xp, 'T155', 'Fait à : ', $signatureCity, null, 'GeneratedSignature');
self::setLabeledSpan($xp, 'T156', 'Le : ', $signatureDate, 'tab', 'GeneratedSignature');
self::setLabeledSpan($xp, 'T247', 'Fait à : ', $signatureCity, null, 'GeneratedSignature');
self::setLabeledSpan($xp, 'T253', 'Le : ', $signatureDate, 'tab', 'GeneratedSignature');
self::setSpan($xp, 'T283', (string) $animal['name']);
self::setLabeledSpan($xp, 'T367', 'Fait à : ', $signatureCity, null, 'GeneratedSignature');
self::setLabeledSpan($xp, 'T373', 'Le : ', $signatureDate, 'tab', 'GeneratedSignature');
foreach (
[
'T25',
'T30',
'T33',
'T36',
'T40',
'T45',
'T48',
'T52',
'T53',
'T55',
'T56',
'T97',
'T100',
'T103',
'T106',
'T109',
'T112',
'T115',
'T281',
]
as $labelStyle
) {
self::applySpanStyle($xp, $labelStyle);
}
$updated = $dom->saveXML();
if ($updated === false || !$zip->addFromString('content.xml', $updated)) {
$zip->close();
throw new RuntimeException(t('service.document.update_contract_failed'));
}
$zip->close();
return $target;
}
public static function toPdf(string $odtPath): string
{
$dir = dirname($odtPath);
$profile = $dir . '/lo-profile';
mkdir($profile, 0775, true);
$cmd = [
'/usr/bin/env',
'-u',
'DISPLAY',
'SAL_USE_VCLPLUGIN=svp',
'/usr/bin/libreoffice',
'-env:UserInstallation=' . self::fileUri($profile),
'--headless',
'--nologo',
'--nodefault',
'--nolockcheck',
'--nofirststartwizard',
'--convert-to',
'pdf',
'--outdir',
$dir,
$odtPath,
];
$pipes = [];
$process = proc_open($cmd, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
if (!is_resource($process)) {
throw new RuntimeException(t('service.document.libreoffice_failed'));
}
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$code = proc_close($process);
$pdf = $dir . '/' . pathinfo($odtPath, PATHINFO_FILENAME) . '.pdf';
if ($code !== 0 || !is_file($pdf)) {
throw new RuntimeException(t('service.document.pdf_failed', ['details' => trim($stdout . ' ' . $stderr)]));
}
return $pdf;
}
public static function cleanup(string $path): void
{
$dir = dirname($path);
if (!str_starts_with($dir, sys_get_temp_dir() . '/globinours-doc-')) {
return;
}
$items = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST,
);
foreach ($items as $item) {
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
}
rmdir($dir);
}
private static function setSpan(
DOMXPath $xp,
string $style,
string $value,
?string $prefix = null,
string $generatedStyle = 'GeneratedValue',
): void {
$nodes = $xp->query("//text:span[@text:style-name='$style']");
if (!$nodes || $nodes->length === 0) {
return;
}
$node = $nodes->item(0);
while ($node->firstChild) {
$node->removeChild($node->firstChild);
}
$node->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:text:1.0', 'text:style-name', $generatedStyle);
if ($prefix === 'tab') {
$node->appendChild(
$node->ownerDocument->createElementNS('urn:oasis:names:tc:opendocument:xmlns:text:1.0', 'text:tab'),
);
}
$node->appendChild($node->ownerDocument->createTextNode($value));
}
private static function setLabeledSpan(
DOMXPath $xp,
string $style,
string $label,
string $value,
?string $prefix = null,
string $labelStyle = 'GeneratedField',
): void {
$nodes = $xp->query("//text:span[@text:style-name='$style']");
if (!$nodes || $nodes->length === 0) {
return;
}
$node = $nodes->item(0);
while ($node->firstChild) {
$node->removeChild($node->firstChild);
}
$node->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:text:1.0', 'text:style-name', $labelStyle);
if ($prefix === 'tab') {
$node->appendChild(
$node->ownerDocument->createElementNS('urn:oasis:names:tc:opendocument:xmlns:text:1.0', 'text:tab'),
);
}
$node->appendChild($node->ownerDocument->createTextNode($label));
$valueSpan = $node->ownerDocument->createElementNS(
'urn:oasis:names:tc:opendocument:xmlns:text:1.0',
'text:span',
);
$valueSpan->setAttributeNS(
'urn:oasis:names:tc:opendocument:xmlns:text:1.0',
'text:style-name',
'GeneratedValue',
);
$valueSpan->appendChild($node->ownerDocument->createTextNode($value));
$node->appendChild($valueSpan);
}
private static function applySpanStyle(DOMXPath $xp, string $style, string $generatedStyle = 'GeneratedField'): void
{
$nodes = $xp->query("//text:span[@text:style-name='$style']");
if (!$nodes || $nodes->length === 0) {
return;
}
$nodes
->item(0)
->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:text:1.0', 'text:style-name', $generatedStyle);
}
private static function addGeneratedFieldStyles(DOMDocument $dom, DOMXPath $xp): void
{
$automatic = $xp->query('//office:automatic-styles')->item(0);
if (!$automatic) {
return;
}
self::appendTextStyle($dom, $automatic, 'GeneratedField', '11pt');
self::appendTextStyle($dom, $automatic, 'GeneratedValue', '11pt', true);
self::appendTextStyle($dom, $automatic, 'GeneratedSignature', '11pt');
}
private static function appendTextStyle(
DOMDocument $dom,
DOMNode $automatic,
string $name,
string $size,
bool $bold = false,
): void {
$style = $dom->createElementNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:style');
$style->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:name', $name);
$style->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:family', 'text');
$props = $dom->createElementNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:text-properties');
$props->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:font-name', 'Open Sans');
$props->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:font-name-asian', 'Open Sans');
$props->setAttributeNS(
'urn:oasis:names:tc:opendocument:xmlns:style:1.0',
'style:font-name-complex',
'Open Sans',
);
$props->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0', 'fo:font-size', $size);
$weight = $bold ? 'bold' : 'normal';
$props->setAttributeNS(
'urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0',
'fo:font-weight',
$weight,
);
$props->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:font-size-asian', $size);
$props->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:font-size-complex', $size);
$props->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:font-weight-asian', $weight);
$props->setAttributeNS('urn:oasis:names:tc:opendocument:xmlns:style:1.0', 'style:font-weight-complex', $weight);
$style->appendChild($props);
$automatic->appendChild($style);
}
private static function transformTemplate(string $type, string $filename, callable $transform): string
{
$dir = self::tempDir();
$target = $dir . '/' . $filename . '.odt';
if (!copy(self::template($type), $target)) {
throw new RuntimeException(t('service.document.copy_failed'));
}
$zip = new ZipArchive();
if ($zip->open($target) !== true) {
throw new RuntimeException(t('service.document.open_failed'));
}
$xml = $zip->getFromName('content.xml');
if ($xml === false) {
$zip->close();
throw new RuntimeException(t('service.document.content_invalid'));
}
$dom = new DOMDocument();
$dom->preserveWhiteSpace = true;
if (!$dom->loadXML($xml)) {
$zip->close();
throw new RuntimeException(t('service.document.xml_invalid'));
}
$xp = new DOMXPath($dom);
$xp->registerNamespace('text', 'urn:oasis:names:tc:opendocument:xmlns:text:1.0');
$xp->registerNamespace('office', 'urn:oasis:names:tc:opendocument:xmlns:office:1.0');
self::addGeneratedFieldStyles($dom, $xp);
$transform($xp);
$updated = $dom->saveXML();
if ($updated === false || !$zip->addFromString('content.xml', $updated)) {
$zip->close();
throw new RuntimeException(t('service.document.update_failed'));
}
$zip->close();
return $target;
}
private static function replaceParagraph(DOMXPath $xp, string $startsWith, array $parts, int $occurrence = 0): void
{
$matches = [];
foreach ($xp->query('//text:p') as $paragraph) {
$plain = preg_replace('/\s+/u', ' ', str_replace("\u{00A0}", ' ', $paragraph->textContent));
if (str_starts_with(trim((string) $plain), $startsWith)) {
$matches[] = $paragraph;
}
}
$node = $matches[$occurrence] ?? null;
if (!$node) {
return;
}
while ($node->firstChild) {
$node->removeChild($node->firstChild);
}
foreach ($parts as $part) {
if ($part === ['tab']) {
$node->appendChild(
$node->ownerDocument->createElementNS('urn:oasis:names:tc:opendocument:xmlns:text:1.0', 'text:tab'),
);
continue;
}
if ($part === ['break']) {
$node->appendChild(
$node->ownerDocument->createElementNS(
'urn:oasis:names:tc:opendocument:xmlns:text:1.0',
'text:line-break',
),
);
continue;
}
[$text, $kind] = $part;
$span = $node->ownerDocument->createElementNS(
'urn:oasis:names:tc:opendocument:xmlns:text:1.0',
'text:span',
);
$span->setAttributeNS(
'urn:oasis:names:tc:opendocument:xmlns:text:1.0',
'text:style-name',
$kind === 'value' ? 'GeneratedValue' : 'GeneratedField',
);
$span->appendChild($node->ownerDocument->createTextNode($text));
$node->appendChild($span);
}
}
private static function testLabel(string $status): string
{
return match ($status) {
'neg' => 'négatif',
'pos' => 'positif',
default => 'non renseigné',
};
}
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';
}
private static function tempDir(): string
{
$dir = sys_get_temp_dir() . '/globinours-doc-' . bin2hex(random_bytes(8));
if (!mkdir($dir, 0775, true)) {
throw new RuntimeException(t('service.document.temp_failed'));
}
return $dir;
}
private static function fileUri(string $path): string
{
return 'file://' . str_replace('%2F', '/', rawurlencode($path));
}
}

View file

@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
final class ExportService
{
private const DATASETS = [
'animals' => [
'Animaux',
'animaux.csv',
"SELECT a.id,a.internal_code code_interne,a.name nom,a.species espece,a.sex sexe,a.birth_date date_naissance,a.birth_is_estimated naissance_estimee,a.chip_id numero_identification,a.identification_type type_identification,a.identification_date date_identification,a.identification_registration_status statut_enregistrement_icad,a.adoption_availability disponibilite_adoption,a.adoption_available_from disponible_a_partir_du,a.adoption_unavailability_reason motif_indisponibilite,a.breed race,a.color robe,a.hair_type type_poils,a.compatibility_dogs compatible_chiens,a.compatibility_cats compatible_chats,a.compatibility_children compatible_enfants,a.house_trained proprete,a.status statut,a.refuge_room salle_refuge,a.care_box_key box_soin,a.fiv_status statut_fiv,a.felv_status statut_felv,a.sterilization_status statut_sterilisation,a.sterilization_date date_sterilisation,sv.name veterinaire_sterilisation,sc.name cabinet_sterilisation,a.intake_date date_entree,a.intake_type mode_entree,CASE WHEN a.intake_owner_care_home=1 THEN 'owner_care_home' ELSE a.intake_reason END motif_entree,dep.name deposant,a.intake_circumstances circonstances_entree,a.quarantine_until fin_quarantaine,a.rescue_location_name commune_origine,a.rescue_address adresse_origine,a.notes,a.created_at cree_le,a.updated_at modifie_le,a.archived_at archive_le,a.deleted_at supprime_le,a.deletion_reason motif_suppression FROM animals a LEFT JOIN directory_contacts dep ON dep.id=a.depositor_contact_id LEFT JOIN directory_contacts sv ON sv.id=a.sterilization_vet_contact_id LEFT JOIN directory_contacts sc ON sc.id=a.sterilization_clinic_contact_id ORDER BY a.id",
],
'contacts' => [
'Annuaire',
'annuaire.csv',
'SELECT c.id,c.kind type,c.name nom,GROUP_CONCAT(DISTINCT r.role) roles,c.phone telephone,c.email,c.address adresse,c.postal_code code_postal,c.city ville,c.country pays,o.name structure_liee,c.notes,c.created_at cree_le,c.updated_at modifie_le,c.deleted_at supprime_le FROM directory_contacts c LEFT JOIN directory_contact_roles r ON r.contact_id=c.id LEFT JOIN directory_contacts o ON o.id=c.organization_id GROUP BY c.id ORDER BY c.name COLLATE NOCASE',
],
'adoptions' => [
'Adoptions',
'adoptions.csv',
'SELECT ad.id,a.internal_code code_animal,a.name animal,ad.adoption_date date_adoption,ad.adopter_name adoptant,ad.adopter_phone telephone,ad.adopter_email email,ad.adopter_address adresse,ad.adopter_postal_code code_postal,ad.adopter_city ville,ad.adopter_country pays,ad.notes,ad.created_at cree_le FROM adoptions ad JOIN animals a ON a.id=ad.animal_id ORDER BY ad.adoption_date,ad.id',
],
'placements' => [
'Parcours d’accueil et retours',
'placements-retours.csv',
'SELECT ap.id,a.internal_code code_animal,a.name animal,ap.event_type type_evenement,ap.event_date date_evenement,dc.name contact,dc.city ville,ap.reason motif,ap.notes,COALESCE(u.display_name,u.username) saisi_par,ap.created_at cree_le FROM animal_placements ap JOIN animals a ON a.id=ap.animal_id LEFT JOIN directory_contacts dc ON dc.id=ap.contact_id LEFT JOIN users u ON u.id=ap.created_by ORDER BY ap.event_date,ap.id',
],
'medical' => [
'Notes médicales',
'notes-medicales.csv',
'SELECT mn.id,a.internal_code code_animal,a.name animal,mn.noted_at date_note,mn.kind type,mn.reason motif,mn.symptoms symptomes,mn.exam examen,mn.diagnosis,mn.plan conduite,mn.temperature_c temperature,mn.weight_kg poids,vet.name veterinaire,clinic.name cabinet,COALESCE(u.display_name,u.username) saisi_par,mn.created_at cree_le FROM medical_notes mn JOIN animals a ON a.id=mn.animal_id LEFT JOIN directory_contacts vet ON vet.id=mn.vet_contact_id LEFT JOIN directory_contacts clinic ON clinic.id=mn.clinic_contact_id LEFT JOIN users u ON u.id=mn.created_by ORDER BY mn.noted_at,mn.id',
],
'treatments' => [
'Traitements',
'traitements.csv',
'SELECT t.id,a.internal_code code_animal,a.name animal,m.name medicament,t.dose_text dose,t.route voie,t.start_date debut,t.end_date fin,t.ongoing en_cours,t.give_morning matin,t.give_evening soir,t.give_as_needed si_besoin,vet.name veterinaire,clinic.name cabinet,t.notes FROM treatments t JOIN animals a ON a.id=t.animal_id JOIN ref_medications m ON m.id=t.medication_id LEFT JOIN directory_contacts vet ON vet.id=t.vet_contact_id LEFT JOIN directory_contacts clinic ON clinic.id=t.clinic_contact_id ORDER BY t.start_date,t.id',
],
'vaccinations' => [
'Vaccinations',
'vaccinations.csv',
'SELECT v.id,a.internal_code code_animal,a.name animal,rv.name vaccin,v.done_date date_realisation,v.due_date prochain_rappel,v.manufacturer fabricant,v.lot,v.batch_expires_on expiration_lot,COALESCE(v.administered_by_name,au.display_name,au.username) administre_par,COALESCE(cu.display_name,cu.username) saisi_par,vet.name veterinaire,clinic.name cabinet,v.notes FROM vaccinations v JOIN animals a ON a.id=v.animal_id JOIN ref_vaccines rv ON rv.id=v.vaccine_id LEFT JOIN directory_contacts vet ON vet.id=v.vet_contact_id LEFT JOIN directory_contacts clinic ON clinic.id=v.clinic_contact_id LEFT JOIN users au ON au.id=v.administered_by_user_id LEFT JOIN users cu ON cu.id=v.created_by ORDER BY v.done_date,v.id',
],
'deaths' => [
'Décès',
'deces.csv',
'SELECT d.animal_id,a.internal_code code_animal,a.name animal,d.deceased_date date_deces,d.date_precision precision_date,d.death_age_value age_deces_valeur,d.death_age_unit age_deces_unite,d.cause_code cause,d.cause_details details_cause,d.occurred_in_care sous_responsabilite,d.place_type type_lieu,d.place_details lieu,d.euthanized euthanasie,vet.name veterinaire,crem.name crematorium,d.body_disposition devenir_corps,d.cremation_date date_cremation,d.memorial_medal_color couleur_medaille,d.memorial_medal_count nombre_medailles,d.recovered_source source_recuperation,d.notes,COALESCE(u.display_name,u.username) enregistre_par,d.created_at cree_le,d.updated_at modifie_le FROM animal_deaths d JOIN animals a ON a.id=d.animal_id LEFT JOIN directory_contacts vet ON vet.id=d.veterinarian_contact_id LEFT JOIN directory_contacts crem ON crem.id=d.crematorium_contact_id LEFT JOIN users u ON u.id=d.created_by ORDER BY d.deceased_date,d.animal_id',
],
'measurements' => [
'Mesures',
'mesures.csv',
'SELECT me.id,a.internal_code code_animal,a.name animal,me.measured_at date_mesure,me.type,me.value valeur,me.unit unite,me.notes,COALESCE(u.display_name,u.username) saisi_par FROM measurements me JOIN animals a ON a.id=me.animal_id LEFT JOIN users u ON u.id=me.created_by ORDER BY me.measured_at,me.id',
],
'litters' => [
'Portées',
'portees.csv',
'SELECT l.id,m.internal_code code_mere,m.name mere,f.internal_code code_pere,f.name pere,l.birth_date date_naissance,GROUP_CONCAT(k.internal_code) codes_chatons,GROUP_CONCAT(k.name) chatons,l.notes,l.created_at cree_le FROM litters l LEFT JOIN animals m ON m.id=l.mother_id LEFT JOIN animals f ON f.id=l.father_id LEFT JOIN litter_kittens lk ON lk.litter_id=l.id LEFT JOIN animals k ON k.id=lk.animal_id GROUP BY l.id ORDER BY l.birth_date,l.id',
],
'bonded' => [
'Groupes inséparables',
'groupes-inseparables.csv',
'SELECT g.id,g.name nom_groupe,g.active actif,GROUP_CONCAT(a.internal_code) codes_animaux,GROUP_CONCAT(a.name) animaux,g.notes,g.created_at cree_le,g.dissolved_at dissous_le FROM bonded_groups g LEFT JOIN bonded_group_members gm ON gm.group_id=g.id LEFT JOIN animals a ON a.id=gm.animal_id GROUP BY g.id ORDER BY g.id',
],
'care_rounds' => [
'Tournées',
'tournees.csv',
'SELECT r.id,r.round_date date_tournee,r.period periode,a.internal_code code_animal,a.name animal,o.food alimentation,o.water eau,o.urine,o.stool selles,o.general_state etat_general,o.comment,o.checked_at valide_le,COALESCE(u.display_name,u.username,r.performed_by) effectue_par FROM care_rounds r LEFT JOIN care_round_observations o ON o.round_id=r.id LEFT JOIN animals a ON a.id=o.animal_id LEFT JOIN users u ON u.id=r.performed_by_user_id ORDER BY r.round_date,r.period,o.checked_at',
],
'administrations' => [
'Administrations de traitements',
'administrations-traitements.csv',
'SELECT ta.id,r.round_date date_tournee,r.period periode,a.internal_code code_animal,a.name animal,m.name medicament,ta.status statut,ta.comment,ta.administered_at administre_le FROM treatment_administrations ta JOIN care_rounds r ON r.id=ta.round_id JOIN animals a ON a.id=ta.animal_id JOIN treatments t ON t.id=ta.treatment_id JOIN ref_medications m ON m.id=t.medication_id ORDER BY r.round_date,r.period,ta.id',
],
'media_index' => [
'Index des médias',
'index-medias.csv',
"SELECT 'animal' type_media,p.id,a.internal_code code_animal,a.name animal,p.filename fichier,p.original_name nom_original,p.mime,p.size_bytes taille_octets,p.is_primary principale,p.is_public publique,p.caption legende,p.care_round_id tournee_id,NULL note_medicale_id,p.created_at ajoute_le FROM animal_photos p JOIN animals a ON a.id=p.animal_id UNION ALL SELECT 'medical',mp.id,a.internal_code,a.name,mp.filename,NULL,NULL,NULL,0,0,NULL,NULL,mp.medical_note_id,mp.created_at FROM medical_photos mp JOIN animals a ON a.id=mp.animal_id ORDER BY 14,2",
],
'movements' => [
'Mouvements',
'mouvements.csv',
'SELECT m.id,a.internal_code code_animal,a.name animal,m.kind type,m.place emplacement,m.lieu motif,m.contact_name contact,m.contact_phone telephone,m.contact_email email,m.note,m.created_at date_mouvement FROM animal_movements m JOIN animals a ON a.id=m.animal_id ORDER BY m.created_at,m.id',
],
'locations' => [
'Historique des lieux de vie',
'historique-emplacements.csv',
'SELECT lh.id,a.internal_code code_animal,a.name animal,lh.from_status ancien_statut,lh.from_refuge_room ancienne_salle,lh.from_care_box_key ancien_box,lh.from_address ancienne_adresse,lh.to_status nouveau_statut,lh.to_refuge_room nouvelle_salle,lh.to_care_box_key nouveau_box,lh.to_address nouvelle_adresse,lh.reason motif,lh.source origine_action,COALESCE(u.display_name,u.username) utilisateur,lh.moved_at date_deplacement FROM animal_location_history lh JOIN animals a ON a.id=lh.animal_id LEFT JOIN users u ON u.id=lh.user_id ORDER BY lh.moved_at,lh.id',
],
'history' => [
'Historique',
'historique.csv',
'SELECT h.id,a.internal_code code_animal,a.name animal,h.type,h.label,h.details,COALESCE(u.display_name,u.username) utilisateur,h.created_at date_action FROM animal_history h JOIN animals a ON a.id=h.animal_id LEFT JOIN users u ON u.id=h.user_id ORDER BY h.created_at,h.id',
],
];
public static function catalog(): array
{
foreach (self::DATASETS as $key => [$label, $file]) {
$out[$key] = ['label' => t('export.' . $key), 'filename' => $file];
}
return $out ?? [];
}
public static function downloadCsv(string $key): void
{
[$label, $filename] = self::definition($key);
header('Content-Type: text/csv; charset=UTF-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('X-Content-Type-Options: nosniff');
$stream = fopen('php://output', 'wb');
self::write($stream, $key);
fclose($stream);
}
public static function downloadZip(): void
{
if (!class_exists(ZipArchive::class)) {
throw new RuntimeException(t('service.export.zip_missing'));
}
$path = tempnam(sys_get_temp_dir(), 'globinours-export-');
$zip = new ZipArchive();
if ($zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
throw new RuntimeException(t('service.export.prepare_failed'));
}
try {
foreach (self::DATASETS as $key => [, $filename]) {
$stream = fopen('php://temp', 'w+b');
self::write($stream, $key);
rewind($stream);
$zip->addFromString($filename, stream_get_contents($stream));
fclose($stream);
}
$zip->addFromString(
'LISEZ-MOI.txt',
"Export de portabilité Globinours\r\nGénéré le " .
date('d/m/Y à H:i') .
"\r\nEncodage : UTF-8 avec séparateur point-virgule.\r\nLes comptes utilisateurs, mots de passe, médias et secrets ne sont pas inclus.\r\n",
);
} finally {
$zip->close();
}
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="globinours-export-' . date('Y-m-d-His') . '.zip"');
header('Content-Length: ' . filesize($path));
header('X-Content-Type-Options: nosniff');
readfile($path);
@unlink($path);
}
private static function write($stream, string $key): void
{
[, , $sql] = self::definition($key);
fwrite($stream, "\xEF\xBB\xBF");
$stmt = DB::pdo()->query($sql);
$headers = [];
for ($i = 0; $i < $stmt->columnCount(); $i++) {
$headers[] = (string) ($stmt->getColumnMeta($i)['name'] ?? 'colonne_' . $i);
}
fputcsv($stream, $headers, ';', '"', '\\', "\r\n");
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
fputcsv($stream, array_map([self::class, 'safeCell'], $row), ';', '"', '\\', "\r\n");
}
}
private static function safeCell(mixed $value): string
{
if (is_int($value) || is_float($value)) {
return (string) $value;
}
$value = $value === null ? '' : (string) $value;
if ($value !== '' && in_array($value[0], ['=', '+', '-', '@'], true)) {
$value = "'" . $value;
}
return $value;
}
private static function definition(string $key): array
{
if (!isset(self::DATASETS[$key])) {
throw new InvalidArgumentException(t('service.export.unknown_dataset'));
}
return self::DATASETS[$key];
}
}

View file

@ -0,0 +1,62 @@
<?php
final class GeoService
{
public static function geocode(PDO $db, string $query): ?array
{
$q = trim(preg_replace('~\s+~', ' ', $query));
if ($q === '') {
return null;
}
// Cache DB
$st = $db->prepare('SELECT lat,lng FROM geocode_cache WHERE q=:q LIMIT 1');
$st->execute([':q' => $q]);
$row = $st->fetch(PDO::FETCH_ASSOC);
if ($row && $row['lat'] !== null && $row['lng'] !== null) {
return ['lat' => (float) $row['lat'], 'lng' => (float) $row['lng']];
}
// Nominatim (OpenStreetMap) — respecter l'usage: User-Agent + requêtes raisonnables
$url = 'https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=' . rawurlencode($q);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_HTTPHEADER => ['Accept: application/json', 'User-Agent: Globinours/1.0 (contact: local-refuge)'],
]);
$body = curl_exec($ch);
if ($body === false) {
curl_close($ch);
return null;
}
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code < 200 || $code >= 300) {
return null;
}
$json = json_decode($body, true);
if (!is_array($json) || empty($json[0]['lat']) || empty($json[0]['lon'])) {
// Stocker "vide" en cache évite de spammer
$db->prepare(
"INSERT OR REPLACE INTO geocode_cache(q,lat,lng,raw_json,fetched_at) VALUES(:q,NULL,NULL,:raw,datetime('now'))",
)->execute([':q' => $q, ':raw' => $body]);
return null;
}
$lat = (float) $json[0]['lat'];
$lng = (float) $json[0]['lon'];
$db->prepare(
"INSERT OR REPLACE INTO geocode_cache(q,lat,lng,raw_json,fetched_at) VALUES(:q,:lat,:lng,:raw,datetime('now'))",
)->execute([':q' => $q, ':lat' => $lat, ':lng' => $lng, ':raw' => $body]);
return ['lat' => $lat, 'lng' => $lng];
}
}

View file

@ -0,0 +1,70 @@
<?php
final class GeocodeService
{
/**
* Géocodage via Nominatim (OpenStreetMap)
* Retour: ['lat'=>float, 'lng'=>float, 'display_name'=>string] ou null
*
* IMPORTANT:
* - Respecte le fair use : pas de spam (cache côté DB chez toi, et TTL)
* - User-Agent explicite
*/
public static function geocode(string $query): ?array
{
$query = trim($query);
if ($query === '') {
return null;
}
// Nominatim conseille d'envoyer un UA + un contact
$email = getenv('GEOCODER_EMAIL') ?: 'contact@localhost';
$ua = 'TwixRefuge/1.0 (geocoding; contact: ' . $email . ')';
$url =
'https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&addressdetails=0&q=' .
rawurlencode($query);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 12,
CURLOPT_CONNECTTIMEOUT => 6,
CURLOPT_HTTPHEADER => ['User-Agent: ' . $ua, 'Accept: application/json'],
CURLOPT_ENCODING => '', // gzip/br si dispo
]);
$body = curl_exec($ch);
if ($body === false) {
curl_close($ch);
return null;
}
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status < 200 || $status >= 300) {
return null;
}
$json = json_decode($body, true);
if (!is_array($json) || empty($json[0])) {
return null;
}
$item = $json[0];
$lat = isset($item['lat']) ? (float) $item['lat'] : null;
$lng = isset($item['lon']) ? (float) $item['lon'] : null;
if ($lat === null || $lng === null) {
return null;
}
return [
'lat' => $lat,
'lng' => $lng,
'display_name' => (string) ($item['display_name'] ?? ''),
];
}
}

View file

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
final class GlobalSearchService
{
public static function search(string $query, int $limit = 8): array
{
$query = trim(preg_replace('/\s+/u', ' ', $query) ?? '');
if (mb_strlen($query) < 2) {
return [];
}
$db = DB::pdo();
$like = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $query) . '%';
$out = [];
if (PermissionService::can('animals')) {
$out['animals'] = self::rows(
$db,
"SELECT id,name,internal_code,status,species FROM animals WHERE deleted_at IS NULL AND (name LIKE :q ESCAPE '\\' OR internal_code LIKE :q ESCAPE '\\' OR COALESCE(chip,'') LIKE :q ESCAPE '\\') ORDER BY name COLLATE NOCASE LIMIT :lim",
$like,
$limit,
static fn($r) => [
'title' => $r['name'],
'detail' => implode(' · ', array_filter([$r['internal_code'], $r['species'], $r['status']])),
'url' => '/animal?id=' . (int) $r['id'],
],
);
}
if (PermissionService::can('directory')) {
$out['directory'] = self::rows(
$db,
"SELECT id,name,kind,phone,email,city FROM directory_contacts WHERE deleted_at IS NULL AND (name LIKE :q ESCAPE '\\' OR COALESCE(phone,'') LIKE :q ESCAPE '\\' OR COALESCE(email,'') LIKE :q ESCAPE '\\' OR COALESCE(city,'') LIKE :q ESCAPE '\\') ORDER BY name COLLATE NOCASE LIMIT :lim",
$like,
$limit,
static fn($r) => [
'title' => $r['name'],
'detail' => implode(' · ', array_filter([$r['city'], $r['phone'], $r['email']])),
'url' => '/directory/view?id=' . (int) $r['id'],
],
);
}
if (PermissionService::can('agenda')) {
$out['agenda'] = self::rows(
$db,
"SELECT id,title,starts_at,location,status FROM agenda_items WHERE title LIKE :q ESCAPE '\\' OR COALESCE(description,'') LIKE :q ESCAPE '\\' OR COALESCE(location,'') LIKE :q ESCAPE '\\' ORDER BY starts_at DESC LIMIT :lim",
$like,
$limit,
static fn($r) => [
'title' => $r['title'],
'detail' => implode(
' · ',
array_filter([substr((string) $r['starts_at'], 0, 16), $r['location'], $r['status']]),
),
'url' => '/agenda?q=' . rawurlencode($r['title']),
],
);
}
if (PermissionService::can('accounting')) {
$out['accounting'] = self::rows(
$db,
"SELECT ai.id,ai.reference,ai.invoice_date,ai.status,dc.name vendor FROM accounting_invoices ai LEFT JOIN directory_contacts dc ON dc.id=ai.vendor_contact_id WHERE ai.deleted_at IS NULL AND (ai.reference LIKE :q ESCAPE '\\' OR COALESCE(ai.notes,'') LIKE :q ESCAPE '\\' OR COALESCE(dc.name,'') LIKE :q ESCAPE '\\') ORDER BY ai.invoice_date DESC LIMIT :lim",
$like,
$limit,
static fn($r) => [
'title' => $r['reference'],
'detail' => implode(' · ', array_filter([$r['vendor'], $r['invoice_date'], $r['status']])),
'url' => '/accounting/edit?id=' . (int) $r['id'],
],
);
}
return array_filter($out);
}
private static function rows(PDO $db, string $sql, string $like, int $limit, callable $map): array
{
$s = $db->prepare($sql);
$s->bindValue(':q', $like);
$s->bindValue(':lim', max(1, min(20, $limit)), PDO::PARAM_INT);
$s->execute();
return array_map($map, $s->fetchAll(PDO::FETCH_ASSOC));
}
}

View file

@ -0,0 +1,313 @@
<?php
declare(strict_types=1);
final class GrantService
{
public const STATUSES = [
'draft' => 'Brouillon',
'ready' => 'Prête',
'submitted' => 'Déposée',
'awarded' => 'Accordée',
'refused' => 'Refusée',
'withdrawn' => 'Abandonnée',
];
public const CATEGORIES = [
'official_form' => 'Formulaire officiel de l’organisme',
'statutes' => 'Statuts',
'prefecture' => 'Récépissé de préfecture',
'jo' => 'Publication au Journal officiel',
'rib' => 'RIB',
'insurance' => 'Attestation d’assurance',
'ag' => 'Procès-verbal d’assemblée générale',
'activity' => 'Rapport d’activité',
'accounts' => 'Comptes approuvés',
'budget' => 'Budget prévisionnel',
'other' => 'Autre',
];
public const REQUIRED = [
'official_form',
'statutes',
'prefecture',
'jo',
'rib',
'insurance',
'ag',
'activity',
'accounts',
'budget',
];
public static function statusLabel(string $key): string
{
return t('grant.status.' . $key, [], self::STATUSES[$key] ?? $key);
}
public static function categoryLabel(string $key): string
{
return t('grant.category.' . $key, [], self::CATEGORIES[$key] ?? $key);
}
public static function ensureYear(int $year): array
{
$db = DB::pdo();
$s = $db->prepare('INSERT OR IGNORE INTO grant_years(year) VALUES(?)');
$s->execute([$year]);
$s = $db->prepare('SELECT * FROM grant_years WHERE year=?');
$s->execute([$year]);
return $s->fetch() ?: [];
}
public static function statistics(int $year): array
{
$db = DB::pdo();
$start = sprintf('%04d-01-01', $year);
$end = sprintf('%04d-12-31', $year);
$scalar = static function (string $sql, array $params = []) use ($db): float {
$s = $db->prepare($sql);
$s->execute($params);
return (float) ($s->fetchColumn() ?: 0);
};
$p = [':start' => $start, ':end' => $end];
$entries = $scalar(
"SELECT COUNT(*) FROM (SELECT m.animal_id FROM animal_movements m JOIN animals a ON a.id=m.animal_id WHERE a.deleted_at IS NULL AND m.kind='entry' AND date(m.created_at) BETWEEN :start AND :end UNION SELECT a.id FROM animals a WHERE a.deleted_at IS NULL AND date(a.intake_date) BETWEEN :start AND :end)",
$p,
);
$exits = $scalar(
"SELECT (SELECT COUNT(*) FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE a.deleted_at IS NULL AND date(ad.adoption_date) BETWEEN :start AND :end)+(SELECT COUNT(*) FROM animal_deaths d JOIN animals a ON a.id=d.animal_id WHERE a.deleted_at IS NULL AND date(COALESCE(d.deceased_date,printf('%04d-01-01',d.death_year))) BETWEEN :start AND :end AND NOT EXISTS(SELECT 1 FROM adoptions ad WHERE ad.animal_id=d.animal_id))",
$p,
);
$adoptions = $scalar('SELECT COUNT(*) FROM adoptions WHERE date(adoption_date) BETWEEN :start AND :end', $p);
$deaths = $scalar(
"SELECT COUNT(*) FROM animal_deaths WHERE date(COALESCE(deceased_date,printf('%04d-01-01',death_year))) BETWEEN :start AND :end",
$p,
);
$births = $scalar(
"SELECT COUNT(*) FROM litter_kittens lk JOIN litters l ON l.id=lk.litter_id JOIN animals a ON a.id=lk.animal_id WHERE a.deleted_at IS NULL AND date(COALESCE(NULLIF(l.birth_date,''),NULLIF(a.birth_date,''))) BETWEEN :start AND :end",
$p,
);
$vaccinations = $scalar('SELECT COUNT(*) FROM vaccinations WHERE date(done_date) BETWEEN :start AND :end', $p);
$treatments = $scalar('SELECT COUNT(*) FROM treatments WHERE date(start_date) BETWEEN :start AND :end', $p);
$avg = $scalar(
"SELECT AVG(julianday(adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))) FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE date(adoption_date) BETWEEN :start AND :end AND date(adoption_date)>=date(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))",
$p,
);
$current = $scalar(
"SELECT COUNT(*) FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND status NOT IN ('adopte','decede')",
);
return compact(
'entries',
'exits',
'adoptions',
'deaths',
'births',
'vaccinations',
'treatments',
'avg',
'current',
);
}
public static function detailedStatistics(int $year): array
{
$db = DB::pdo();
$start = sprintf('%04d-01-01', $year);
$end = sprintf('%04d-12-31', $year);
$p = [':start' => $start, ':end' => $end];
$rows = static function (string $sql, array $params) use ($db): array {
$s = $db->prepare($sql);
$s->execute($params);
return $s->fetchAll(PDO::FETCH_ASSOC);
};
$map = static function (array $items, string $key = 'label'): array {
$out = [];
foreach ($items as $item) {
$out[(string) $item[$key]] = (int) $item['total'];
}
return $out;
};
$base =
"WITH entry_sources AS (SELECT m.animal_id,date(m.created_at) entry_date FROM animal_movements m JOIN animals source_animal ON source_animal.id=m.animal_id WHERE source_animal.deleted_at IS NULL AND m.kind='entry' AND date(m.created_at) BETWEEN :start AND :end UNION SELECT id animal_id,date(intake_date) entry_date FROM animals WHERE deleted_at IS NULL AND date(intake_date) BETWEEN :start AND :end), entered AS (SELECT animal_id,MIN(entry_date) entry_date FROM entry_sources GROUP BY animal_id) ";
$sex = $map(
$rows(
$base .
"SELECT CASE a.sex WHEN 'F' THEN 'Femelles' WHEN 'M' THEN 'Mâles' ELSE 'Inconnu' END label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY a.sex ORDER BY total DESC",
$p,
),
);
$ages = $map(
$rows(
$base .
"SELECT CASE WHEN a.birth_date IS NULL THEN 'Non renseigné' WHEN (julianday(e.entry_date)-julianday(a.birth_date))/365.25<1 THEN 'Moins d’un an' WHEN (julianday(e.entry_date)-julianday(a.birth_date))/365.25<3 THEN '1 à 2 ans' WHEN (julianday(e.entry_date)-julianday(a.birth_date))/365.25<8 THEN '3 à 7 ans' ELSE '8 ans et plus' END label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC",
$p,
),
);
$originRows = $rows(
$base . 'SELECT a.rescue_location_name,a.rescue_address FROM entered e JOIN animals a ON a.id=e.animal_id',
$p,
);
$origins = [];
foreach ($originRows as $originRow) {
$label = self::municipality((string) ($originRow['rescue_location_name'] ?: $originRow['rescue_address']));
$origins[$label] = ($origins[$label] ?? 0) + 1;
}
arsort($origins, SORT_NUMERIC);
$origins = array_slice($origins, 0, 12, true);
$deathCauses = $map(
$rows(
"SELECT cause_code label,COUNT(*) total FROM animal_deaths WHERE date(COALESCE(deceased_date,printf('%04d-01-01',death_year))) BETWEEN :start AND :end GROUP BY cause_code ORDER BY total DESC",
$p,
),
);
$stays = $map(
$rows(
"SELECT CASE WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<30 THEN 'Moins d’un mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<90 THEN '1 à 3 mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<180 THEN '3 à 6 mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<365 THEN '6 à 12 mois' ELSE 'Plus d’un an' END label,COUNT(*) total FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE date(ad.adoption_date) BETWEEN :start AND :end AND date(ad.adoption_date)>=date(COALESCE(NULLIF(a.intake_date,''),date(a.created_at))) GROUP BY label ORDER BY total DESC",
$p,
),
);
return compact('sex', 'ages', 'origins', 'deathCauses', 'stays');
}
public static function comparison(int $year): array
{
$current = self::statistics($year);
$previous = self::statistics($year - 1);
$out = [];
foreach (['entries', 'exits', 'adoptions', 'deaths', 'births', 'vaccinations', 'treatments', 'avg'] as $key) {
$a = (float) $current[$key];
$b = (float) $previous[$key];
$out[$key] = [
'current' => $a,
'previous' => $b,
'difference' => $a - $b,
'percent' => $b != 0 ? (($a - $b) / $b) * 100 : null,
];
}
return $out;
}
public static function municipality(string $value): string
{
$value = trim(preg_replace('/\s+/u', ' ', $value) ?? '');
if ($value === '') {
return 'Non renseignée';
}
if (preg_match('/\b\d{5}\s+([^,;]+)(?:[,;]|$)/u', $value, $match)) {
$value = trim($match[1]);
} elseif (str_contains($value, ',')) {
$parts = array_values(array_filter(array_map('trim', explode(',', $value))));
$value = (string) end($parts);
}
$value = preg_replace('/^(?:FRANCE|FR)\s*[-–—]?\s*/iu', '', $value) ?? $value;
return $value !== '' ? mb_convert_case($value, MB_CASE_TITLE, 'UTF-8') : 'Non renseignée';
}
public static function applications(int $year): array
{
$s = DB::pdo()->prepare(
'SELECT * FROM grant_applications WHERE year=? ORDER BY COALESCE(deadline,\'9999-12-31\'), organization_name',
);
$s->execute([$year]);
return $s->fetchAll();
}
public static function documents(int $year): array
{
$s = DB::pdo()->prepare(
'SELECT d.*,u.display_name uploader,ga.organization_name application_name FROM grant_documents d LEFT JOIN users u ON u.id=d.uploaded_by LEFT JOIN grant_applications ga ON ga.id=d.application_id WHERE d.year IS NULL OR d.year=? ORDER BY d.category,d.created_at DESC',
);
$s->execute([$year]);
return $s->fetchAll();
}
public static function checklist(int $year): array
{
$applications = self::applications($year);
$documents = self::documents($year);
$today = date('Y-m-d');
$soon = date('Y-m-d', strtotime('+30 days'));
$result = [];
foreach ($applications as $application) {
$items = [];
$valid = 0;
$warnings = 0;
foreach (self::REQUIRED as $category) {
$matches = array_values(
array_filter($documents, static function (array $document) use ($category, $application): bool {
if ($document['category'] !== $category) {
return false;
}
if ($category === 'official_form') {
return (int) ($document['application_id'] ?? 0) === (int) $application['id'];
}
return empty($document['application_id']) ||
(int) $document['application_id'] === (int) $application['id'];
}),
);
usort(
$matches,
static fn(array $a, array $b): int => strcmp((string) $b['created_at'], (string) $a['created_at']),
);
$document = $matches[0] ?? null;
$state = 'missing';
if ($document) {
$expiry = (string) ($document['valid_until'] ?? '');
if ($expiry !== '' && $expiry < $today) {
$state = 'expired';
} elseif ($expiry !== '' && $expiry <= $soon) {
$state = 'expiring';
} else {
$state = 'valid';
}
}
if ($state === 'valid') {
$valid++;
} elseif ($state === 'expiring') {
$valid++;
$warnings++;
} else {
$warnings++;
}
$items[$category] = ['state' => $state, 'document' => $document];
}
$total = count(self::REQUIRED);
$result[(int) $application['id']] = [
'items' => $items,
'valid' => $valid,
'total' => $total,
'warnings' => $warnings,
'complete' => $valid === $total,
];
}
return $result;
}
public static function alerts(int $year): array
{
$applications = self::applications($year);
$checklist = self::checklist($year);
$today = date('Y-m-d');
$soon = date('Y-m-d', strtotime('+30 days'));
$deadlines = 0;
$overdue = 0;
$incomplete = 0;
$documents = 0;
foreach ($applications as $application) {
if (in_array($application['status'], ['awarded', 'refused', 'withdrawn'], true)) {
continue;
}
$deadline = (string) ($application['deadline'] ?? '');
if ($deadline !== '' && $deadline < $today) {
$overdue++;
} elseif ($deadline !== '' && $deadline <= $soon) {
$deadlines++;
}
if (!($checklist[(int) $application['id']]['complete'] ?? false)) {
$incomplete++;
}
}
foreach (self::documents($year) as $document) {
$expiry = (string) ($document['valid_until'] ?? '');
if ($expiry !== '' && $expiry <= $soon) {
$documents++;
}
}
return compact('deadlines', 'overdue', 'incomplete', 'documents');
}
}

51
app/Services/I18n.php Normal file
View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
final class I18n
{
public const LOCALES = ['fr' => 'Français', 'en' => 'English'];
private static string $locale = 'fr';
private static array $catalogues = [];
public static function setLocale(string $locale): void
{
self::$locale = isset(self::LOCALES[$locale]) ? $locale : 'fr';
}
public static function locale(): string
{
return self::$locale;
}
public static function translate(string $key, array $replace = []): string
{
$value = self::catalogue(self::$locale)[$key] ?? (self::catalogue('fr')[$key] ?? $key);
foreach ($replace as $name => $replacement) {
$value = str_replace(':' . $name, (string) $replacement, $value);
}
return $value;
}
public static function choice(string $singular, string $plural, int|float $count, array $replace = []): string
{
return self::translate($count == 1 ? $singular : $plural, ['count' => $count] + $replace);
}
private static function catalogue(string $locale): array
{
if (isset(self::$catalogues[$locale])) {
return self::$catalogues[$locale];
}
$path = __DIR__ . '/../../resources/lang/' . $locale . '.php';
$catalogue = is_file($path) ? require $path : [];
return self::$catalogues[$locale] = is_array($catalogue) ? $catalogue : [];
}
}
if (!function_exists('t')) {
function t(string $key, array $replace = []): string
{
return I18n::translate($key, $replace);
}
}
if (!function_exists('tn')) {
function tn(string $singular, string $plural, int|float $count, array $replace = []): string
{
return I18n::choice($singular, $plural, $count, $replace);
}
}

View file

@ -0,0 +1,363 @@
<?php
final class IcadService
{
private PDO $db;
public function __construct(PDO $db)
{
$this->db = $db;
}
// ========= CACHE =========
public function getCachedByAnimal(int $animalId): ?array
{
$st = $this->db->prepare('SELECT * FROM icad_cache WHERE animal_id=:id LIMIT 1');
$st->execute([':id' => $animalId]);
$row = $st->fetch(PDO::FETCH_ASSOC);
if (!$row) {
return null;
}
$data = $row['data_json'] ? json_decode($row['data_json'], true) : null;
return [
'animal_id' => (int) $row['animal_id'],
'chip_id' => (string) ($row['chip_id'] ?? ''),
'status' => (string) ($row['status'] ?? 'empty'), // ok|error|empty
'error' => (string) ($row['error_msg'] ?? ''),
'fetched_at' => (string) ($row['fetched_at'] ?? ''),
'data' => is_array($data) ? $data : null,
];
}
public function isStale(?array $cache, int $ttlSeconds = 86400): bool
{
if (!$cache || empty($cache['fetched_at'])) {
return true;
}
// si en erreur → on retente
if (($cache['status'] ?? '') !== 'ok') {
return true;
}
$t = strtotime($cache['fetched_at']);
if ($t === false) {
return true;
}
return time() - $t > $ttlSeconds;
}
private function saveCache(int $animalId, string $chipId, string $status, ?string $err, ?array $data): void
{
$st = $this->db->prepare("
INSERT INTO icad_cache(animal_id, chip_id, data_json, fetched_at, status, error_msg)
VALUES(:aid,:chip,:json,datetime('now'),:st,:err)
ON CONFLICT(animal_id) DO UPDATE SET
chip_id = excluded.chip_id,
data_json = excluded.data_json,
fetched_at = excluded.fetched_at,
status = excluded.status,
error_msg = excluded.error_msg
");
$st->execute([
':aid' => $animalId,
':chip' => $chipId,
':json' => $data ? json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null,
':st' => $status,
':err' => $err,
]);
}
// ========= PUBLIC API =========
public function refreshByChip(int $animalId, string $chipId): array
{
$chipId = preg_replace('/\D+/', '', $chipId);
if ($chipId === '') {
throw new RuntimeException(t('service.icad.invalid_chip'));
}
$login = getenv('ICAD_LOGIN') ?: '';
$pass = getenv('ICAD_PASSWORD') ?: '';
if ($login === '' || $pass === '') {
throw new RuntimeException(t('service.icad.credentials_missing'));
}
$cookie = sys_get_temp_dir() . '/twix_icad_' . bin2hex(random_bytes(6)) . '.cookie';
try {
// 1) GET HOME => token _token du formulaire login ACTEUR
$r1 = $this->req('https://www.i-cad.fr/', $cookie);
$token = $this->extractToken($r1['body'], '_token'); // prend le 1er _token trouvé
// 2) POST login/check/acteur (XHR)
$post = http_build_query([
'_token' => $token,
'login' => $login,
'password' => $pass,
]);
$r2 = $this->req('https://www.i-cad.fr/login/check/acteur', $cookie, 'POST', $post, [
'Accept: */*',
'Content-Type: application/x-www-form-urlencoded; charset=UTF-8',
'X-Requested-With: XMLHttpRequest',
'Origin: https://www.i-cad.fr',
'Referer: https://www.i-cad.fr/',
]);
// 3) Vérif session
$r2b = $this->req('https://www.i-cad.fr/pro', $cookie);
if (stripos($r2b['body'], 'Se connecter') !== false) {
throw new RuntimeException(t('service.icad.login_failed'));
}
// 4) GET recherche. I-CAD a renommé le formulaire
// search_animal en animal_search en 2026 : on accepte les deux.
$r3 = $this->req('https://www.i-cad.fr/identification/recherche', $cookie);
[$searchForm, $searchToken] = $this->extractAnimalSearchForm($r3['body']);
// 5) POST recherche
$post2 = http_build_query([
$searchForm . '[insert]' => $chipId,
$searchForm . '[tatou]' => '',
$searchForm . '[espece]' => '',
$searchForm . '[sexe]' => '',
$searchForm . '[robe]' => '',
$searchForm . '[_token]' => $searchToken,
]);
$r4 = $this->req('https://www.i-cad.fr/identification/recherche', $cookie, 'POST', $post2, [
'Content-Type: application/x-www-form-urlencoded',
'Origin: https://www.i-cad.fr',
'Referer: https://www.i-cad.fr/identification/recherche',
]);
$animalPath = $this->extractAnimalPath($r4);
if (!$animalPath) {
throw new RuntimeException(t('service.icad.no_result'));
}
// 6) GET fiche animal
$r5 = $this->req('https://www.i-cad.fr' . $animalPath, $cookie);
$data = $this->parseAnimalHtml($r5['body'], $animalPath);
$this->saveCache($animalId, $chipId, 'ok', null, $data);
return $data;
} catch (Throwable $e) {
$this->saveCache($animalId, $chipId, 'error', $e->getMessage(), null);
throw $e;
} finally {
@unlink($cookie);
}
}
// ========= HTTP =========
private function req(
string $url,
string $cookieFile,
string $method = 'GET',
?string $body = null,
array $headers = [],
): array {
$ch = curl_init($url);
$defaultHeaders = [
'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language: fr,en-US;q=0.9,en;q=0.8',
'Connection: keep-alive',
'DNT: 1',
'Sec-GPC: 1',
];
$allHeaders = array_merge($defaultHeaders, $headers);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 10,
CURLOPT_HEADER => true,
CURLOPT_COOKIEJAR => $cookieFile,
CURLOPT_COOKIEFILE => $cookieFile,
CURLOPT_USERAGENT => 'Mozilla/5.0 (X11; Linux x86_64; rv:147.0) Gecko/20100101 Firefox/147.0',
CURLOPT_TIMEOUT => 25,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_ENCODING => '', // gzip/br si dispo
CURLOPT_HTTPHEADER => $allHeaders,
]);
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body ?? '');
}
$raw = curl_exec($ch);
if ($raw === false) {
$err = curl_error($ch);
curl_close($ch);
throw new RuntimeException(t('service.icad.network_error', ['error' => $err]));
}
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$hs = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$effUrl = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
curl_close($ch);
return [
'status' => $status,
'headers' => substr($raw, 0, $hs),
'body' => substr($raw, $hs),
'effective_url' => $effUrl,
];
}
// ========= TOKENS / PARSING =========
private function extractToken(string $html, string $fieldName): string
{
$field = preg_quote($fieldName, '/');
// 1) cas standard: name="X" value="Y"
$re = '/<input\b[^>]*\bname=(["\'])' . $field . '\1[^>]*\bvalue=(["\'])(.*?)\2[^>]*>/is';
if (preg_match($re, $html, $m)) {
return html_entity_decode($m[3], ENT_QUOTES);
}
// 2) fallback: value avant name
$re2 = '/<input\b[^>]*\bvalue=(["\'])(.*?)\1[^>]*\bname=(["\'])' . $field . '\3[^>]*>/is';
if (preg_match($re2, $html, $m)) {
return html_entity_decode($m[2], ENT_QUOTES);
}
// 3) si fieldName == _token : on prend le 1er _token trouvé
if ($fieldName === '_token') {
if (preg_match('/<input\b[^>]*\bname=(["\'])_token\1[^>]*\bvalue=(["\'])(.*?)\2[^>]*>/is', $html, $m)) {
return html_entity_decode($m[3], ENT_QUOTES);
}
}
throw new RuntimeException(t('service.icad.token_missing', ['field' => $fieldName]));
}
private function extractAnimalSearchForm(string $html): array
{
foreach (['animal_search', 'search_animal'] as $formName) {
try {
return [$formName, $this->extractToken($html, $formName . '[_token]')];
} catch (RuntimeException $e) {
// Essayer le nom historique suivant.
}
}
throw new RuntimeException(t('service.icad.form_missing'));
}
private function extractAnimalPath(array $resp): ?string
{
// souvent une redirection finale /animal/XXXX dans effective_url
$p = parse_url($resp['effective_url'] ?? '', PHP_URL_PATH);
if (is_string($p) && preg_match('~^/animal/\d+~', $p)) {
return $p;
}
// fallback HTML
if (preg_match('~href=["\'](/animal/\d+)["\']~', $resp['body'], $m)) {
return $m[1];
}
if (preg_match('~(/animal/\d+)~', $resp['body'], $m)) {
return $m[1];
}
return null;
}
private function parseAnimalHtml(string $html, string $animalPath): array
{
libxml_use_internal_errors(true);
$dom = new DOMDocument();
$dom->loadHTML($html);
$xp = new DOMXPath($dom);
$txt = function (string $xpath) use ($xp): string {
$n = $xp->query($xpath);
if (!$n || $n->length === 0) {
return '';
}
return trim(preg_replace('~\s+~', ' ', $n->item(0)->textContent));
};
$name = $txt('//*[@id="animal_informations"]//*[contains(@class,"title")]//span[contains(@class,"emphasis")]');
$insertRaw = $txt('//*[@id="animal_informations"]//*[contains(@class,"id")]//span[contains(@class,"number")]');
$insert = preg_replace('~\s+~', '', $insertRaw);
// infos label/value
$rows = $xp->query('//*[@id="animal_informations"]//*[contains(@class,"table-row")]');
$map = [];
foreach ($rows as $row) {
$ln = $xp->query('.//*[contains(@class,"label")]', $row)->item(0);
$vn = $xp->query('.//*[contains(@class,"value")]', $row)->item(0);
if (!$ln || !$vn) {
continue;
}
$label = trim(preg_replace('~\s+~', ' ', $ln->textContent));
$value = trim(preg_replace('~\s+~', ' ', $vn->textContent));
if ($label !== '') {
$map[$label] = $value;
}
}
// events
$events = [];
$trs = $xp->query('//*[contains(@class,"event-row")]');
foreach ($trs as $tr) {
$tds = $xp->query('./td', $tr);
if (!$tds || $tds->length < 3) {
continue;
}
$events[] = [
'date' => trim($tds->item(0)->textContent),
'label' => trim(preg_replace('~\s+~', ' ', $tds->item(1)->textContent)),
'recorded_at' => trim($tds->item(2)->textContent),
];
}
// Coordonnées du détenteur.
$ownerName = $txt('//*[@id="down_right_block"]//*[contains(@class,"coords")]//*[contains(@class,"name")]');
$ownerEmail = $txt('//*[@id="down_right_block"]//*[contains(@class,"coords")]//*[contains(@class,"address")]');
$ownerPhone = $txt(
'//*[@id="down_right_block"]//*[contains(@class,"coords")]//*[contains(@class,"phone_numbers")]',
);
$ownerPhone = preg_replace('~\s+~', ' ', $ownerPhone);
// Adresse postale: i-CAD ne la met pas toujours dans un bloc unique, mais on tente
$ownerPostal = $txt('//*[@id="down_right_block"]//*[contains(@class,"coords")]//*[contains(@class,"postal")]');
if ($ownerPostal === '') {
// fallback: parfois l’adresse est dans le même bloc coords mais pas taggée
$coordsText = $txt('//*[@id="down_right_block"]//*[contains(@class,"coords")]');
// on évite de tout recracher, juste si on détecte des numéros/rue
$ownerPostal = $coordsText;
}
return [
'icad_url' => 'https://www.i-cad.fr' . $animalPath,
'name' => $name,
'insert' => $insert,
'species' => $map['Espèce'] ?? '',
'sex' => $map['Sexe'] ?? '',
'birth_date' => $map['Né le'] ?? '',
'coat_type' => $map['Type de robe'] ?? '',
'appearance' => $map['Apparence raciale'] ?? '',
'events' => $events,
'owner' => [
'name' => $ownerName,
'phone' => $ownerPhone,
'email' => $ownerEmail,
'postal' => $ownerPostal,
],
];
}
}

50
app/Services/Ids.php Normal file
View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
final class Ids
{
public static function slugifyUpper(string $s): string
{
$s = trim($s);
if ($s === '') {
return 'SANS-NOM';
}
// translit: accents -> ascii (si dispo)
$t = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s);
if ($t !== false) {
$s = $t;
}
$s = strtoupper($s);
$s = preg_replace('/[^A-Z0-9]+/', '-', $s) ?? $s;
$s = trim($s, '-');
return $s !== '' ? $s : 'SANS-NOM';
}
public static function nextAnimalCode(string $name, ?int $year = null): string
{
$year = $year ?? (int) date('Y');
$slug = self::slugifyUpper($name);
$db = DB::pdo();
$stmt = $db->prepare(
'SELECT internal_code FROM animals WHERE internal_code LIKE :p ORDER BY internal_code DESC LIMIT 1',
);
$stmt->execute([':p' => $year . '-%']);
$last = (string) ($stmt->fetchColumn() ?: '');
$n = 1;
if ($last !== '') {
// format attendu: YYYY-NNN-...
$parts = explode('-', $last);
if (count($parts) >= 2 && ctype_digit($parts[1])) {
$n = (int) $parts[1] + 1;
}
}
return sprintf('%d-%03d-%s', $year, $n, $slug);
}
}

View file

@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
final class ImageService
{
private const ALLOWED = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif'];
public static function inspect(string $path, int $maxBytes = 20_000_000): array
{
if (!is_file($path) || filesize($path) > $maxBytes) {
throw new RuntimeException(t('service.image.missing_large'));
}
$info = @getimagesize($path);
$mime = (string) ($info['mime'] ?? '');
if (!$info || !isset(self::ALLOWED[$mime])) {
throw new RuntimeException(t('service.image.format'));
}
if ((int) $info[0] * (int) $info[1] > 60_000_000) {
throw new RuntimeException(t('service.image.dimensions'));
}
return [
'mime' => $mime,
'extension' => self::ALLOWED[$mime],
'width' => (int) $info[0],
'height' => (int) $info[1],
'size' => (int) filesize($path),
];
}
public static function storeUploaded(
string $tmp,
string $directory,
string $prefix = 'photo',
string $profile = 'photo',
): array {
if (!is_uploaded_file($tmp)) {
throw new RuntimeException(t('service.image.invalid_upload'));
}
return self::store($tmp, $directory, $prefix, $profile, true);
}
public static function storeLocal(
string $source,
string $directory,
string $prefix = 'photo',
string $profile = 'photo',
): array {
return self::store($source, $directory, $prefix, $profile, false);
}
private static function store(
string $tmp,
string $directory,
string $prefix,
string $profile,
bool $moveSource,
): array {
$source = self::inspect($tmp);
if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) {
throw new RuntimeException(t('service.image.directory_failed'));
}
$prefix = preg_replace('/[^a-z0-9_-]+/i', '_', trim($prefix)) ?: 'photo';
$useWebp = $profile !== 'logo';
$extension = $useWebp ? 'webp' : $source['extension'];
$filename = $prefix . '_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.' . $extension;
$target = rtrim($directory, '/') . '/' . $filename;
$stage = rtrim($directory, '/') . '/.' . $filename . '.part.' . $extension;
$optimized = false;
$binary = self::magickBinary();
if ($binary !== null) {
$max = $profile === 'logo' ? '1800x1800>' : '2560x2560>';
$quality = $profile === 'medical' ? '88' : '82';
$command = [$binary, $tmp . '[0]', '-auto-orient', '-strip', '-resize', $max];
if ($extension === 'png') {
$command = array_merge($command, ['-define', 'png:compression-level=9']);
} else {
$command = array_merge($command, ['-quality', (string) $quality]);
}
$command[] = $stage;
$process = proc_open($command, [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
if (is_resource($process)) {
fclose($pipes[0]);
stream_get_contents($pipes[1]);
fclose($pipes[1]);
stream_get_contents($pipes[2]);
fclose($pipes[2]);
$code = proc_close($process);
$optimized = $code === 0 && is_file($stage) && filesize($stage) > 0 && @getimagesize($stage) !== false;
}
}
if ($optimized) {
if (!rename($stage, $target)) {
@unlink($stage);
throw new RuntimeException(t('service.image.optimized_failed'));
}
} else {
if (is_file($stage)) {
@unlink($stage);
}
$extension = $source['extension'];
$filename = $prefix . '_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.' . $extension;
$target = rtrim($directory, '/') . '/' . $filename;
$saved = $moveSource ? move_uploaded_file($tmp, $target) : copy($tmp, $target);
if (!$saved) {
throw new RuntimeException(t('service.image.save_failed'));
}
}
@chmod($target, 0644);
$stored = self::inspect($target);
return array_merge($stored, [
'filename' => $filename,
'path' => $target,
'original_size' => $source['size'],
'optimized' => $optimized,
]);
}
private static function magickBinary(): ?string
{
foreach (
['/usr/bin/magick', '/usr/local/bin/magick', '/usr/bin/convert', '/usr/local/bin/convert']
as $binary
) {
if (is_executable($binary)) {
return $binary;
}
}
return null;
}
}

View file

@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
final class InstallationService
{
public static function runtimeChecks(bool $withFilesystem = true): array
{
$checks = [];
self::add($checks, 'php', version_compare(PHP_VERSION, '8.1.0', '>='), 'required', 'PHP 8.1+', PHP_VERSION);
self::add(
$checks,
'pdo',
extension_loaded('pdo'),
'required',
'PDO',
extension_loaded('pdo') ? 'Disponible' : 'Extension absente',
);
self::add(
$checks,
'sqlite',
extension_loaded('pdo_sqlite'),
'required',
'PDO SQLite',
extension_loaded('pdo_sqlite') ? 'Disponible' : 'Installez l’extension PHP SQLite',
);
foreach (
['mbstring' => 'mbstring', 'json' => 'JSON', 'fileinfo' => 'Fileinfo', 'session' => 'Sessions']
as $extension => $label
) {
self::add(
$checks,
$extension,
extension_loaded($extension),
'required',
$label,
extension_loaded($extension) ? 'Disponible' : 'Extension absente',
);
}
self::add(
$checks,
'zip',
class_exists('ZipArchive'),
'recommended',
'ZIP',
class_exists('ZipArchive')
? 'Sauvegardes ZIP disponibles'
: 'Extension ZIP absente : sauvegardes indisponibles',
);
self::add(
$checks,
'curl',
extension_loaded('curl'),
'recommended',
'cURL',
extension_loaded('curl')
? 'Connecteurs externes disponibles'
: 'Extension cURL absente : I-CAD et géocodage indisponibles',
);
self::add(
$checks,
'image',
extension_loaded('imagick') || extension_loaded('gd'),
'recommended',
'Traitement des images',
extension_loaded('imagick')
? 'ImageMagick disponible'
: (extension_loaded('gd')
? 'GD disponible'
: 'ImageMagick ou GD conseillé'),
);
if ($withFilesystem) {
foreach (self::directories() as $key => $path) {
if (!is_dir($path)) {
@mkdir($path, 0775, true);
}
self::add(
$checks,
'dir_' . $key,
is_dir($path) && is_writable($path),
'required',
'Écriture · ' . self::relative($path),
is_dir($path) && is_writable($path) ? 'Accessible en écriture' : 'Droits d’écriture insuffisants',
);
}
}
return $checks;
}
public static function blockingRuntimeIssues(): array
{
return array_values(
array_filter(
self::runtimeChecks(true),
static fn(array $c): bool => $c['level'] === 'required' && !$c['ok'],
),
);
}
public static function canInstall(array $checks): bool
{
foreach ($checks as $check) {
if ($check['level'] === 'required' && !$check['ok']) {
return false;
}
}
return true;
}
public static function publicPath(): string
{
return dirname(__DIR__, 2) . '/public';
}
public static function directories(): array
{
$root = dirname(__DIR__, 2);
return [
'data' => $root . '/data',
'storage' => $root . '/storage',
'logs' => $root . '/storage/logs',
'backups' => $root . '/storage/backups',
'media' => $root . '/public/media',
'association' => $root . '/data/association',
];
}
private static function add(
array &$checks,
string $key,
bool $ok,
string $level,
string $label,
string $detail,
): void {
$checks[] = ['key' => $key, 'ok' => $ok, 'level' => $level, 'label' => $label, 'detail' => $detail];
}
private static function relative(string $path): string
{
return ltrim(str_replace(dirname(__DIR__, 2), '', $path), '/');
}
}

View file

@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
final class InventoryService
{
public static function products(): array
{
return DB::pdo()
->query(
'SELECT p.*,COALESCE(SUM(b.quantity),0) stock,(SELECT MIN(expires_on) FROM inventory_batches x WHERE x.product_id=p.id AND x.quantity>0 AND x.expires_on IS NOT NULL) next_expiry FROM inventory_products p LEFT JOIN inventory_batches b ON b.product_id=p.id GROUP BY p.id ORDER BY p.category,p.name COLLATE NOCASE',
)
->fetchAll(PDO::FETCH_ASSOC);
}
public static function batches(): array
{
return DB::pdo()
->query(
'SELECT b.*,p.name product_name,p.unit,p.category,dc.name supplier_name FROM inventory_batches b JOIN inventory_products p ON p.id=b.product_id LEFT JOIN directory_contacts dc ON dc.id=b.supplier_contact_id ORDER BY b.quantity>0 DESC,date(b.expires_on),p.name COLLATE NOCASE',
)
->fetchAll(PDO::FETCH_ASSOC);
}
public static function movements(int $limit = 150): array
{
$limit = max(1, min(500, $limit));
return DB::pdo()
->query(
"SELECT m.*,p.name product_name,p.unit,b.batch_number,a.name animal_name,COALESCE(u.display_name,u.username) actor FROM inventory_movements m JOIN inventory_products p ON p.id=m.product_id LEFT JOIN inventory_batches b ON b.id=m.batch_id LEFT JOIN animals a ON a.id=m.animal_id LEFT JOIN users u ON u.id=m.created_by ORDER BY datetime(m.occurred_at) DESC,m.id DESC LIMIT $limit",
)
->fetchAll(PDO::FETCH_ASSOC);
}
public static function saveProduct(array $data): int
{
$category = (string) ($data['category'] ?? '');
$name = trim((string) ($data['name'] ?? ''));
$unit = trim((string) ($data['unit'] ?? ''));
if (!in_array($category, ['medication', 'dewormer', 'vaccine'], true) || $name === '' || $unit === '') {
throw new RuntimeException(t('inventory.invalid_product'));
}
$reference = (int) ($data['reference_id'] ?? 0) ?: null;
$db = DB::pdo();
if (!$reference) {
$table = match ($category) {
'medication' => 'ref_medications',
'dewormer' => 'ref_dewormers',
'vaccine' => 'ref_vaccines',
};
$lookup = $db->prepare("SELECT id FROM $table WHERE name=? COLLATE NOCASE LIMIT 1");
$lookup->execute([$name]);
$reference = (int) $lookup->fetchColumn() ?: null;
}
$minimum = max(0, (float) str_replace(',', '.', (string) ($data['minimum_quantity'] ?? 0)));
$s = $db->prepare(
'INSERT INTO inventory_products(category,reference_id,name,unit,minimum_quantity) VALUES(?,?,?,?,?)',
);
$s->execute([$category, $reference, mb_substr($name, 0, 150), mb_substr($unit, 0, 40), $minimum]);
return (int) $db->lastInsertId();
}
public static function receive(array $data): int
{
$product = (int) ($data['product_id'] ?? 0);
$quantity = (float) str_replace(',', '.', (string) ($data['quantity'] ?? 0));
if (!$product || $quantity <= 0) {
throw new RuntimeException(t('inventory.invalid_quantity'));
}
$expiry = trim((string) ($data['expires_on'] ?? ''));
if ($expiry !== '' && !self::date($expiry)) {
throw new RuntimeException(t('inventory.invalid_date'));
}
$cost = trim((string) ($data['unit_cost'] ?? ''));
$cents = $cost === '' ? null : (int) round((float) str_replace(',', '.', $cost) * 100);
$db = DB::pdo();
$db->beginTransaction();
try {
$s = $db->prepare(
'INSERT INTO inventory_batches(product_id,batch_number,expires_on,supplier_contact_id,unit_cost_cents,quantity,notes) VALUES(?,?,?,?,?,?,?)',
);
$s->execute([
$product,
trim((string) ($data['batch_number'] ?? '')) ?: null,
$expiry ?: null,
(int) ($data['supplier_contact_id'] ?? 0) ?: null,
$cents,
$quantity,
trim((string) ($data['notes'] ?? '')) ?: null,
]);
$batch = (int) $db->lastInsertId();
$db->prepare(
"INSERT INTO inventory_movements(product_id,batch_id,movement_type,quantity_delta,reason,created_by) VALUES(?,?,'entry',?,?,?)",
)->execute([$product, $batch, $quantity, t('inventory.receipt'), Auth::id()]);
$db->commit();
return $batch;
} catch (Throwable $e) {
$db->rollBack();
throw $e;
}
}
public static function move(array $data): void
{
$batch = (int) ($data['batch_id'] ?? 0);
$type = (string) ($data['movement_type'] ?? '');
$quantity = (float) str_replace(',', '.', (string) ($data['quantity'] ?? 0));
if (!$batch || !in_array($type, ['administration', 'loss', 'correction'], true) || $quantity <= 0) {
throw new RuntimeException(t('inventory.invalid_movement'));
}
$db = DB::pdo();
$s = $db->prepare(
'SELECT b.*,p.id product_id FROM inventory_batches b JOIN inventory_products p ON p.id=b.product_id WHERE b.id=?',
);
$s->execute([$batch]);
$row = $s->fetch(PDO::FETCH_ASSOC);
if (!$row) {
throw new RuntimeException(t('inventory.batch_not_found'));
}
$delta =
$type === 'correction'
? (float) str_replace(',', '.', (string) ($data['signed_quantity'] ?? 0))
: -$quantity;
if ($delta == 0 || (float) $row['quantity'] + $delta < 0) {
throw new RuntimeException(t('inventory.insufficient_stock'));
}
$db->beginTransaction();
try {
$db->prepare('UPDATE inventory_batches SET quantity=quantity+? WHERE id=?')->execute([$delta, $batch]);
$db->prepare(
'INSERT INTO inventory_movements(product_id,batch_id,movement_type,quantity_delta,animal_id,reason,created_by) VALUES(?,?,?,?,?,?,?)',
)->execute([
(int) $row['product_id'],
$batch,
$type,
$delta,
(int) ($data['animal_id'] ?? 0) ?: null,
trim((string) ($data['reason'] ?? '')) ?: null,
Auth::id(),
]);
$db->commit();
} catch (Throwable $e) {
$db->rollBack();
throw $e;
}
}
public static function alerts(): array
{
$low = [];
$expired = [];
foreach (self::products() as $p) {
if ((float) $p['stock'] <= (float) $p['minimum_quantity']) {
$low[] = $p;
}
}
foreach (self::batches() as $b) {
if (
(float) $b['quantity'] > 0 &&
$b['expires_on'] &&
$b['expires_on'] <= date('Y-m-d', strtotime('+30 days'))
) {
$expired[] = $b;
}
}
return compact('low', 'expired');
}
public static function consumeBatch(int $batchId, float $quantity, ?int $animalId, string $reason): void
{
if ($batchId <= 0 || $quantity <= 0) {
return;
}
$db = DB::pdo();
$s = $db->prepare(
'SELECT b.product_id,b.quantity,b.unit_cost_cents,b.supplier_contact_id,p.name FROM inventory_batches b JOIN inventory_products p ON p.id=b.product_id WHERE b.id=?',
);
$s->execute([$batchId]);
$batch = $s->fetch(PDO::FETCH_ASSOC);
if (!$batch) {
throw new RuntimeException(t('inventory.batch_not_found'));
}
if ((float) $batch['quantity'] < $quantity) {
throw new RuntimeException(t('inventory.insufficient_stock'));
}
$u = $db->prepare('UPDATE inventory_batches SET quantity=quantity-? WHERE id=? AND quantity>=?');
$u->execute([$quantity, $batchId, $quantity]);
if ($u->rowCount() !== 1) {
throw new RuntimeException(t('inventory.insufficient_stock'));
}
$movement = $db->prepare(
"INSERT INTO inventory_movements(product_id,batch_id,movement_type,quantity_delta,animal_id,reason,created_by) VALUES(?,?,'administration',?,?,?,?)",
);
$movement->execute([(int) $batch['product_id'], $batchId, -$quantity, $animalId, $reason, Auth::id()]);
$movementId = (int) $db->lastInsertId();
if ($animalId && !is_null($batch['unit_cost_cents'])) {
$unit = (int) $batch['unit_cost_cents'];
$db->prepare(
"INSERT INTO animal_expenses(animal_id,clinic_contact_id,source_type,source_id,label,occurred_on,quantity,catalog_unit_cents,discount_percent,total_cents,notes,created_by) VALUES(?,?, 'inventory',?,?,date('now'),?,?,0,?,?,?)",
)->execute([
$animalId,
(int) $batch['supplier_contact_id'] ?: null,
$movementId,
(string) $batch['name'],
$quantity,
$unit,
(int) round($unit * $quantity),
$reason,
Auth::id(),
]);
}
}
private static function date(string $v): bool
{
$d = DateTimeImmutable::createFromFormat('!Y-m-d', $v);
return $d && $d->format('Y-m-d') === $v;
}
}

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
final class LabReferenceService
{
public static function all(): array
{
return DB::pdo()
->query(
'SELECT * FROM lab_reference_ranges ORDER BY analyzer_name COLLATE NOCASE,species_code,sort_order,parameter_code COLLATE NOCASE',
)
->fetchAll(PDO::FETCH_ASSOC);
}
public static function forAnimal(string $species): array
{
$species = SpeciesService::normalize($species);
$s = DB::pdo()->prepare(
'SELECT analyzer_code,analyzer_name,parameter_code,parameter_name,unit,reference_min,reference_max FROM lab_reference_ranges WHERE species_code=? AND active=1 ORDER BY analyzer_name COLLATE NOCASE,sort_order,parameter_code COLLATE NOCASE',
);
$s->execute([$species]);
$out = [];
foreach ($s->fetchAll(PDO::FETCH_ASSOC) as $r) {
$key = $r['analyzer_code'];
$out[$key]['label'] = $r['analyzer_name'];
$out[$key]['parameters'][] = [
'code' => $r['parameter_code'],
'name' => $r['parameter_name'],
'unit' => $r['unit'],
'min' => $r['reference_min'],
'max' => $r['reference_max'],
];
}
return $out;
}
}

View file

@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
final class LocationHistoryService
{
private const TRACKED = ['status', 'refuge_room', 'care_box_key', 'current_address'];
public static function snapshot(array $animal): array
{
$snapshot = [];
foreach (self::TRACKED as $field) {
$value = trim((string) ($animal[$field] ?? ''));
$snapshot[$field] = $value === '' ? null : $value;
}
return $snapshot;
}
public static function record(
PDO $db,
int $animalId,
?array $before,
array $after,
string $source,
?string $reason = null,
?string $movedAt = null,
): bool {
$from = self::snapshot($before ?? []);
$to = self::snapshot($after);
if ($before !== null && $from === $to) {
return false;
}
$stmt = $db->prepare('INSERT INTO animal_location_history (
animal_id,from_status,from_refuge_room,from_care_box_key,from_address,
to_status,to_refuge_room,to_care_box_key,to_address,reason,source,moved_at,user_id
) VALUES (
:animal,:from_status,:from_room,:from_box,:from_address,
:to_status,:to_room,:to_box,:to_address,:reason,:source,:moved_at,:user
)');
$stmt->execute([
':animal' => $animalId,
':from_status' => $from['status'],
':from_room' => $from['refuge_room'],
':from_box' => $from['care_box_key'],
':from_address' => $from['current_address'],
':to_status' => $to['status'],
':to_room' => $to['refuge_room'],
':to_box' => $to['care_box_key'],
':to_address' => $to['current_address'],
':reason' => ($reason = trim((string) $reason)) !== '' ? $reason : null,
':source' => $source,
':moved_at' => $movedAt ?: date('Y-m-d H:i:s'),
':user' => Auth::id(),
]);
return true;
}
public static function label(array $row, string $prefix): string
{
$status = trim((string) ($row[$prefix . '_status'] ?? ''));
$room = trim((string) ($row[$prefix . '_refuge_room'] ?? ''));
$box = trim((string) ($row[$prefix . '_care_box_key'] ?? ''));
$address = trim((string) ($row[$prefix . '_address'] ?? ''));
$label = match ($status) {
'refuge' => $room !== '' ? 'Refuge · ' . self::roomName($room) : 'Refuge · Salle non attribuée',
'soin' => $room !== '' ? self::roomName($room) : 'Infirmerie',
'quarantaine' => $room !== '' ? self::roomName($room) : 'Salle de quarantaine',
'fa' => 'Famille d’accueil',
'fa_permanente' => 'FA permanente',
'reserve', 'réservé' => 'Réservé',
'adopte', 'adopté' => 'Adopté',
'hospitalise', 'hospitalisé' => 'Hospitalisé',
'isolation' => 'Isolation',
'decede', 'décédé' => 'Décédé',
'enfui', 'fugue' => 'Enfui',
'' => 'Emplacement antérieur inconnu',
default => ucfirst(str_replace('_', ' ', $status)),
};
if ($box !== '' && in_array($status, ['soin', 'quarantaine'], true)) {
$label .= ' · Box ' . self::boxLabel($box);
}
if (
$address !== '' &&
in_array($status, ['fa', 'fa_permanente', 'adopte', 'adopté', 'hospitalise', 'hospitalisé'], true)
) {
$label .= ' · ' . $address;
}
return $label;
}
private static function boxLabel(string $box): string
{
return match ($box) {
'top_a' => 'haut gauche',
'top_b' => 'haut centre',
'top_c' => 'haut droite',
'top_ab' => 'haut gauche double',
'top_bc' => 'haut droite double',
'top_all' => 'haut complet',
'bottom_a' => 'bas gauche',
'bottom_b' => 'bas droite',
'bottom_all' => 'bas complet',
default => str_replace('_', ' ', $box),
};
}
private static function roomName(string $code): string
{
static $rooms = null;
$rooms ??= ShelterRoomService::byCode(false);
return (string) ($rooms[$code]['name'] ?? $code);
}
}

View file

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
final class Migrations
{
public static function run(string $migrationsDir): void
{
$db = DB::pdo();
$db->exec('CREATE TABLE IF NOT EXISTS _migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL);');
$files = glob(rtrim($migrationsDir, '/') . '/*.sql') ?: [];
sort($files);
foreach ($files as $file) {
$name = basename($file);
$stmt = $db->prepare('SELECT 1 FROM _migrations WHERE name = :n');
$stmt->execute([':n' => $name]);
if ($stmt->fetchColumn()) {
continue;
}
$sql = file_get_contents($file);
if ($sql === false) {
throw new RuntimeException("Cannot read migration: $name");
}
// Les toutes premières installations utilisaient déjà deleted_at avant
// que cette colonne ne soit correctement inscrite dans une migration.
if ($name === '025_animal_archive_and_trash.sql') {
$columns = array_column($db->query('PRAGMA table_info(animals)')->fetchAll(PDO::FETCH_ASSOC), 'name');
if (!in_array('deleted_at', $columns, true)) {
$db->exec('ALTER TABLE animals ADD COLUMN deleted_at TEXT');
}
}
// Répare les bases créées par une candidate rc.3 incomplète, dans
// laquelle les médias privés étaient utilisés avant d'être migrés.
if ($name === '089_media_schema_repair.sql') {
$columns = array_column(
$db->query('PRAGMA table_info(animal_photos)')->fetchAll(PDO::FETCH_ASSOC),
'name',
);
if (!in_array('is_public', $columns, true)) {
$db->exec('ALTER TABLE animal_photos ADD COLUMN is_public INTEGER NOT NULL DEFAULT 1');
}
}
$db->beginTransaction();
try {
$db->exec($sql);
$ins = $db->prepare("INSERT INTO _migrations(name, applied_at) VALUES(:n, datetime('now'))");
$ins->execute([':n' => $name]);
$db->commit();
} catch (Throwable $e) {
$db->rollBack();
throw new RuntimeException('Migration ' . $name . ' : ' . $e->getMessage(), 0, $e);
}
}
}
}

View file

@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
final class NotificationService
{
public static function alerts(): array
{
$db = DB::pdo();
$alerts = [];
$queries = [
[
'vaccines',
'danger',
t('notification.vaccines_overdue'),
"SELECT COUNT(*) FROM vaccinations v JOIN animals a ON a.id=v.animal_id WHERE v.due_date IS NOT NULL AND date(v.due_date)<date('now') AND a.deleted_at IS NULL AND a.archived_at IS NULL AND lower(a.status) NOT IN ('decede','décédé','adopte','adopté')",
'/dashboard#vaccine-deadlines',
],
[
'treatments',
'danger',
t('notification.treatments_overdue'),
"SELECT COUNT(*) FROM treatments t JOIN animals a ON a.id=t.animal_id WHERE t.ongoing=1 AND t.end_date IS NOT NULL AND date(t.end_date)<date('now') AND a.deleted_at IS NULL AND a.archived_at IS NULL",
'/dashboard#active-treatments',
],
[
'dewormings',
'warning',
t('notification.dewormings_due'),
"SELECT COUNT(*) FROM dewormings d JOIN animals a ON a.id=d.animal_id WHERE d.next_due_date IS NOT NULL AND date(d.next_due_date)<=date('now','+7 day') AND a.deleted_at IS NULL AND a.archived_at IS NULL AND NOT EXISTS(SELECT 1 FROM dewormings newer WHERE newer.animal_id=d.animal_id AND date(newer.administered_on)>date(d.administered_on))",
'/dashboard',
],
];
foreach ($queries as [$key, $severity, $label, $sql, $url]) {
try {
$count = (int) $db->query($sql)->fetchColumn();
if ($count) {
$alerts[] = compact('key', 'severity', 'label', 'count', 'url');
}
} catch (Throwable) {
}
}
if (class_exists(InventoryService::class)) {
try {
$stock = InventoryService::alerts();
if ($stock['low']) {
$alerts[] = [
'key' => 'inventory-low',
'severity' => 'warning',
'label' => t('notification.inventory_low'),
'count' => count($stock['low']),
'url' => '/settings/inventory',
];
}
if ($stock['expired']) {
$alerts[] = [
'key' => 'inventory-expiry',
'severity' => 'danger',
'label' => t('notification.inventory_expiry'),
'count' => count($stock['expired']),
'url' => '/settings/inventory',
];
}
} catch (Throwable) {
}
}
if (class_exists(AgendaService::class)) {
try {
$due = AgendaService::due();
if ($due) {
$alerts[] = [
'key' => 'agenda',
'severity' => 'warning',
'label' => t('notification.agenda_due'),
'count' => count($due),
'url' => '/agenda',
];
}
} catch (Throwable) {
}
}
$backups = BackupService::list();
$days = 999;
if (
$backups &&
($date = DateTimeImmutable::createFromFormat(
'd/m/Y H:i:s',
(string) $backups[0]['date'],
new DateTimeZone('Europe/Paris'),
))
) {
$days = (int) $date->diff(new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')))->format('%a');
}
if ($days > 7) {
$alerts[] = [
'key' => 'backup',
'severity' => $days > 30 ? 'danger' : 'warning',
'label' => t('notification.backup_old'),
'count' => $days,
'url' => '/settings/backups',
];
}
return $alerts;
}
public static function send(bool $force = false): array
{
if (AppSettings::get('notification_email_enabled') !== '1' && !$force) {
return ['sent' => false, 'reason' => 'disabled'];
}
$recipient = AppSettings::get('notification_email_recipient') ?: AppSettings::get('association_email');
if (!filter_var($recipient, FILTER_VALIDATE_EMAIL)) {
return ['sent' => false, 'reason' => 'recipient'];
}
$frequency = AppSettings::get('notification_email_frequency');
$last = AppSettings::get('notification_last_sent_at');
if (!$force && $last !== '') {
$elapsed = time() - (strtotime($last) ?: 0);
$minimum = $frequency === 'daily' ? 20 * 3600 : 6 * 86400;
if ($elapsed < $minimum) {
return ['sent' => false, 'reason' => 'not_due'];
}
}
$alerts = self::alerts();
$association = AppSettings::get('association_name') ?: 'Globinours';
$subject = '[' . $association . '] ' . t('notification.email_subject');
$lines = [t('notification.email_intro', ['association' => $association]), ''];
if (!$alerts) {
$lines[] = t('notification.no_alert');
} else {
foreach ($alerts as $a) {
$lines[] = '- ' . $a['label'] . ' : ' . $a['count'];
}
}
$lines[] = '';
$lines[] = t('notification.email_footer');
$sent = @mail($recipient, $subject, implode("\n", $lines), 'Content-Type: text/plain; charset=UTF-8');
if ($sent) {
AppSettings::save(['notification_last_sent_at' => date('Y-m-d H:i:s')], Auth::id());
}
return ['sent' => $sent, 'reason' => $sent ? 'sent' : 'mail_failed', 'count' => count($alerts)];
}
}

View file

@ -0,0 +1,258 @@
<?php
declare(strict_types=1);
final class PermissionService
{
public const ROLES = [
'admin' => 'Administrateur',
'responsable' => 'Responsable',
'benevole' => 'Bénévole',
'lecture' => 'Lecture seule',
];
public const MODULES = [
'dashboard' => ['Dashboard', 'Priorités et situation quotidienne'],
'animals' => ['Animaux', 'Fiches, portées, liens, médias et adoptions'],
'medical' => ['Médical', 'Notes, traitements, vaccinations et décès'],
'care' => ['Tournées', 'Passages tablette et configuration des box'],
'directory' => ['Annuaire', 'Personnes, structures et coordonnées'],
'agenda' => ['Agenda', 'Rendez-vous, transports, visites et tâches partagées'],
'statistics' => ['Statistiques', 'Analyses annuelles et tendances'],
'administrative' => ['Administratif', 'Registre, documents et exports'],
'accounting' => ['Comptabilité', 'Factures, avoirs et règlements'],
'grants' => ['Subventions', 'Bilans, demandes et justificatifs'],
];
public static function roleLabel(string $key): string
{
return t('permission.role.' . $key, [], self::ROLES[$key] ?? $key);
}
public static function moduleLabel(string $key): string
{
return t('permission.module.' . $key, [], self::MODULES[$key][0] ?? $key);
}
public static function moduleDescription(string $key): string
{
return t('permission.module_help.' . $key, [], self::MODULES[$key][1] ?? '');
}
private static array $cache = [];
public static function can(string $module, string $action = 'view', ?string $role = null): bool
{
$role ??= (string) (Auth::user()['role'] ?? '');
if ($role === 'admin') {
return true;
}
if (!isset(self::MODULES[$module], self::ROLES[$role])) {
return false;
}
if (!isset(self::$cache[$role])) {
$s = DB::pdo()->prepare('SELECT module,can_view,can_edit FROM role_permissions WHERE role=?');
$s->execute([$role]);
self::$cache[$role] = [];
foreach ($s->fetchAll(PDO::FETCH_ASSOC) as $row) {
self::$cache[$role][$row['module']] = [
'view' => (bool) $row['can_view'],
'edit' => (bool) $row['can_edit'],
];
}
}
return (bool) (self::$cache[$role][$module][$action === 'edit' ? 'edit' : 'view'] ?? false);
}
public static function matrix(): array
{
$matrix = [];
foreach (array_keys(self::ROLES) as $role) {
foreach (array_keys(self::MODULES) as $module) {
$matrix[$role][$module] = [
'view' => self::can($module, 'view', $role),
'edit' => self::can($module, 'edit', $role),
];
}
}
return $matrix;
}
public static function moduleForPath(string $path): ?string
{
if ($path === '/') {
return 'dashboard';
}
if (str_starts_with($path, '/animal/documents')) {
return 'medical';
}
if (
str_starts_with($path, '/animals') ||
str_starts_with($path, '/animal') ||
str_starts_with($path, '/litter') ||
str_starts_with($path, '/bonded')
) {
foreach (
[
'/animal/add-medical',
'/animal/medical-status',
'/animal/add-treatment',
'/animal/add-vaccine',
'/animal/death',
'/animal/prescription',
'/animal/lab-report',
'/animal/medical-document',
'/animal/surgery',
]
as $medical
) {
if (str_starts_with($path, $medical)) {
return 'medical';
}
}
return 'animals';
}
if (str_starts_with($path, '/dashboard')) {
return 'dashboard';
}
if (str_starts_with($path, '/care-round')) {
return 'care';
}
if (str_starts_with($path, '/directory')) {
return 'directory';
}
if (str_starts_with($path, '/agenda')) {
return 'agenda';
}
if (str_starts_with($path, '/statistics')) {
return 'statistics';
}
if (str_starts_with($path, '/accounting')) {
return 'accounting';
}
if (str_starts_with($path, '/admin/grants')) {
return 'grants';
}
if (str_starts_with($path, '/admin') || str_starts_with($path, '/documents')) {
return 'administrative';
}
return null;
}
public static function enforce(string $path, string $method): void
{
if (
str_starts_with($path, '/settings') ||
str_starts_with($path, '/dev/') ||
in_array($path, ['/logout', '/evolutions'], true)
) {
return;
}
$module = self::moduleForPath($path);
if (!$module) {
return;
}
$action = strtoupper($method) === 'POST' || self::isEditPage($path) ? 'edit' : 'view';
$allowed = self::can($module, $action) && ($module !== 'medical' || self::can('animals', 'view'));
if (!$allowed) {
AuditService::log('authorization_denied', $path, 'Accès refusé au module ' . $module, null, null, [
'module' => $module,
'requested_action' => $action,
]);
http_response_code(403);
if (function_exists('render')) {
render('access_denied.php', [
'title' => t('access.denied'),
'deniedAction' => $action,
'deniedModule' => self::moduleLabel($module),
]);
} else {
echo t('access.denied');
}
exit();
}
}
public static function firstAllowedPath(): string
{
foreach (
[
'dashboard' => '/dashboard',
'animals' => '/animals',
'care' => '/care-round',
'directory' => '/directory',
'agenda' => '/agenda',
'statistics' => '/statistics',
'accounting' => '/accounting',
'administrative' => '/admin/register',
'grants' => '/admin/grants',
]
as $module => $path
) {
if (self::can($module)) {
return $path;
}
}
return '/evolutions';
}
public static function canOpenPath(string $path): bool
{
$module = self::moduleForPath($path);
return $module === null ||
(self::can($module, self::isEditPage($path) ? 'edit' : 'view') &&
($module !== 'medical' || self::can('animals')));
}
public static function currentPermissions(): array
{
$out = [];
foreach (self::MODULES as $module => $unused) {
$out[$module] = ['view' => self::can($module), 'edit' => self::can($module, 'edit')];
}
return $out;
}
private static function isEditPage(string $path): bool
{
return in_array(
$path,
[
'/animal/new',
'/animal/edit',
'/animal/death',
'/animal/surgery/new',
'/litter/new',
'/litter/add-existing',
'/litter/add-existing/save',
'/bonded/new',
'/directory/new',
'/directory/edit',
'/care-round/setup',
],
true,
);
}
public static function save(array $input, ?int $userId): void
{
$db = DB::pdo();
$stmt = $db->prepare(
"INSERT INTO role_permissions(role,module,can_view,can_edit,updated_by,updated_at) VALUES(:role,:module,:view,:edit,:user,datetime('now')) ON CONFLICT(role,module) DO UPDATE SET can_view=excluded.can_view,can_edit=excluded.can_edit,updated_by=excluded.updated_by,updated_at=excluded.updated_at",
);
$db->beginTransaction();
try {
foreach (self::ROLES as $role => $unused) {
if ($role === 'admin') {
continue;
}
foreach (self::MODULES as $module => $unusedModule) {
$view = isset($input[$role][$module]['view']) ? 1 : 0;
if ($module === 'medical' && !isset($input[$role]['animals']['view'])) {
$view = 0;
}
$edit = $view && isset($input[$role][$module]['edit']) ? 1 : 0;
$stmt->execute([
':role' => $role,
':module' => $module,
':view' => $view,
':edit' => $edit,
':user' => $userId,
]);
}
}
$db->commit();
self::$cache = [];
} catch (Throwable $e) {
$db->rollBack();
throw $e;
}
}
}

View file

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
final class PricingService
{
public const CATEGORIES = [
'consultation' => 'Consultation',
'surgery' => 'Chirurgie',
'identification' => 'Identification',
'test' => 'Test / analyse',
'vaccine' => 'Vaccin',
'euthanasia' => 'Euthanasie',
'cremation' => 'Crémation',
'medication' => 'Médicament',
'dewormer' => 'Vermifuge',
'other' => 'Autre',
];
public const GROUPS = [
'act' => 'Acte',
'surgery_discount' => 'Actes chirurgicaux',
'medication' => 'Médicament hors antibiotique',
'antibiotic' => 'Antibiotique',
'ape_api' => 'APE / API',
'analysis' => 'Analyse',
'fixed' => 'Tarif fixe',
'none' => 'Sans remise',
];
public static function categoryLabel(string $key): string
{
return t('pricing.category.' . $key, [], self::CATEGORIES[$key] ?? $key);
}
public static function groupLabel(string $key): string
{
return t('pricing.group.' . $key, [], self::GROUPS[$key] ?? $key);
}
public static function cents(string $v): int
{
return max(0, (int) round((float) str_replace(',', '.', trim($v)) * 100));
}
public static function tariffs(bool $active = false): array
{
$w = $active ? 'WHERE ct.active=1 AND dc.deleted_at IS NULL' : '';
return DB::pdo()
->query(
"SELECT ct.*,dc.name clinic_name,COALESCE(dr.percent,0) discount_percent,ROUND(ct.amount_cents*(1-COALESCE(dr.percent,0)/100.0)) effective_cents FROM clinic_tariffs ct JOIN directory_contacts dc ON dc.id=ct.clinic_contact_id LEFT JOIN clinic_discount_rules dr ON dr.clinic_contact_id=ct.clinic_contact_id AND dr.discount_group=ct.discount_group $w ORDER BY dc.name COLLATE NOCASE,ct.active DESC,ct.label COLLATE NOCASE",
)
->fetchAll(PDO::FETCH_ASSOC);
}
public static function record(PDO $db, int $animal, int $tariff, float $qty, string $date, string $notes = ''): int
{
$s = $db->prepare(
'SELECT ct.*,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.id=? AND ct.active=1',
);
$s->execute([$tariff]);
$t = $s->fetch(PDO::FETCH_ASSOC);
if (!$t) {
throw new RuntimeException(t('error.tariff_not_found'));
}
$qty = max(0.01, $qty);
$total = (int) round($t['amount_cents'] * $qty * (1 - (float) $t['discount'] / 100));
$db->prepare(
'INSERT INTO animal_expenses(animal_id,clinic_contact_id,tariff_id,label,occurred_on,quantity,catalog_unit_cents,discount_percent,total_cents,notes,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
)->execute([
$animal,
$t['clinic_contact_id'],
$tariff,
$t['label'],
$date,
$qty,
$t['amount_cents'],
$t['discount'],
$total,
$notes ?: null,
Auth::id(),
]);
return $total;
}
}

View file

@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
final class PrivacyService
{
public static function logAccess(
string $type,
?int $id,
?int $animalId,
string $name,
string $action = 'view',
): void {
DB::pdo()
->prepare(
'INSERT INTO private_file_access_log(user_id,resource_type,resource_id,animal_id,action,original_name,ip_address,user_agent) VALUES(?,?,?,?,?,?,?,?)',
)
->execute([
Auth::id(),
$type,
$id,
$animalId,
$action,
mb_substr($name, 0, 240),
$_SERVER['REMOTE_ADDR'] ?? null,
mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 500) ?: null,
]);
}
public static function accessLog(int $limit = 500): array
{
return DB::pdo()
->query(
'SELECT l.*,COALESCE(u.display_name,u.username) actor,a.name animal_name FROM private_file_access_log l LEFT JOIN users u ON u.id=l.user_id LEFT JOIN animals a ON a.id=l.animal_id ORDER BY l.id DESC LIMIT ' .
max(1, min(2000, $limit)),
)
->fetchAll(PDO::FETCH_ASSOC);
}
public static function retentionReview(int $years): array
{
$rows = DB::pdo()
->query(
"SELECT dc.id,dc.name,dc.email,dc.phone,dc.updated_at,group_concat(DISTINCT r.role) roles,MAX(COALESCE(ad.adoption_date,ap.event_date,dc.updated_at)) last_activity,(SELECT COUNT(*) FROM animal_placements active JOIN animals a ON a.id=active.animal_id WHERE active.contact_id=dc.id AND active.event_type='foster' AND a.deleted_at IS NULL AND a.archived_at IS NULL AND lower(a.status)='fa') active_links FROM directory_contacts dc LEFT JOIN directory_contact_roles r ON r.contact_id=dc.id LEFT JOIN adoptions ad ON ad.adopter_contact_id=dc.id LEFT JOIN animal_placements ap ON ap.contact_id=dc.id WHERE dc.deleted_at IS NULL AND dc.kind='person' AND dc.anonymized_at IS NULL GROUP BY dc.id ORDER BY date(last_activity),dc.name COLLATE NOCASE",
)
->fetchAll(PDO::FETCH_ASSOC);
$out = [];
foreach ($rows as $row) {
$roles = array_filter(explode(',', (string) $row['roles']));
$limits = [];
foreach ($roles as $role) {
$limits[] = match ($role) {
'adoptant' => AppSettings::int('privacy_retention_adopter_years', 1, 30),
'fa' => AppSettings::int('privacy_retention_foster_years', 1, 30),
'benevole' => AppSettings::int('privacy_retention_volunteer_years', 1, 30),
default => $years,
};
}
$limit = $limits ? max($limits) : $years;
$last = strtotime((string) $row['last_activity']) ?: time();
if ($last > strtotime('-' . $limit . ' years')) {
continue;
}
$row['policy_years'] = $limit;
$row['blocked'] = (int) $row['active_links'] > 0;
$out[] = $row;
}
return $out;
}
public static function exportContact(int $id): string
{
$db = DB::pdo();
$s = $db->prepare(
"SELECT dc.*,group_concat(r.role,',') roles,org.name organization_name FROM directory_contacts dc LEFT JOIN directory_contact_roles r ON r.contact_id=dc.id LEFT JOIN directory_contacts org ON org.id=dc.organization_id WHERE dc.id=? AND dc.deleted_at IS NULL GROUP BY dc.id",
);
$s->execute([$id]);
$contact = $s->fetch(PDO::FETCH_ASSOC);
if (!$contact) {
throw new RuntimeException(t('privacy.contact_not_found'));
}
$queries = [
'adoptions' =>
'SELECT a.internal_code,a.name animal,ad.adoption_date,ad.notes FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE ad.adopter_contact_id=? ORDER BY ad.adoption_date',
'placements' =>
'SELECT a.internal_code,a.name animal,ap.event_type,ap.event_date,ap.reason,ap.notes FROM animal_placements ap JOIN animals a ON a.id=ap.animal_id WHERE ap.contact_id=? ORDER BY ap.event_date,ap.id',
'deposited_animals' =>
'SELECT internal_code,name,intake_date,intake_type,intake_reason,intake_circumstances FROM animals WHERE depositor_contact_id=? ORDER BY intake_date',
];
$payload = ['generated_at' => new DateTimeImmutable()->format(DATE_ATOM), 'contact' => $contact];
foreach ($queries as $key => $sql) {
$q = $db->prepare($sql);
$q->execute([$id]);
$payload[$key] = $q->fetchAll(PDO::FETCH_ASSOC);
}
$tmp = tempnam(sys_get_temp_dir(), 'globinours-contact-');
if ($tmp === false) {
throw new RuntimeException(t('privacy.export_failed'));
}
@unlink($tmp);
$path = $tmp . '.zip';
$zip = new ZipArchive();
if ($zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
throw new RuntimeException(t('privacy.export_failed'));
}
$zip->addFromString(
'contact.json',
json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
);
foreach (['adoptions', 'placements', 'deposited_animals'] as $key) {
$zip->addFromString($key . '.csv', self::csv($payload[$key]));
}
$zip->addFromString(
'README.txt',
"Export de portabilité Globinours\nGénéré le " .
date('d/m/Y H:i') .
"\nDonnées à conserver dans un emplacement sécurisé.\n",
);
$zip->close();
return $path;
}
public static function anonymize(int $id, string $reason): void
{
$reason = trim($reason);
if ($reason === '') {
throw new RuntimeException(t('privacy.reason_required'));
}
$db = DB::pdo();
$s = $db->prepare(
"SELECT * FROM directory_contacts WHERE id=? AND deleted_at IS NULL AND kind='person' AND anonymized_at IS NULL",
);
$s->execute([$id]);
$contact = $s->fetch(PDO::FETCH_ASSOC);
if (!$contact) {
throw new RuntimeException(t('privacy.contact_not_found'));
}
$backup = BackupService::create('avant-anonymisation-contact-' . $id);
$label = t('privacy.anonymous_contact') . ' #' . $id;
$db->beginTransaction();
try {
$db->prepare(
"UPDATE directory_contacts SET name=?,phone=NULL,email=NULL,address=NULL,postal_code=NULL,city=NULL,country='France',organization_id=NULL,notes=NULL,anonymized_at=datetime('now'),anonymization_reason=?,updated_at=datetime('now') WHERE id=?",
)->execute([$label, $reason, $id]);
$db->prepare(
'UPDATE adoptions SET adopter_name=?,adopter_phone=NULL,adopter_email=NULL,adopter_address=NULL,adopter_postal_code=NULL,adopter_city=NULL WHERE adopter_contact_id=?',
)->execute([$label, $id]);
foreach ([(string) $contact['name']] as $old) {
$db->prepare(
'UPDATE animal_movements SET contact_name=? WHERE lower(trim(contact_name))=lower(trim(?))',
)->execute([$label, $old]);
}
$db->commit();
AuditService::log(
'contact_anonymized',
'/settings/privacy/anonymize',
'Contact anonymisé',
'contact',
$id,
['reason' => $reason, 'backup' => $backup['name'] ?? null],
);
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
}
public static function maintenance(): array
{
$months = AppSettings::int('privacy_access_log_months', 1, 120);
$s = DB::pdo()->prepare("DELETE FROM private_file_access_log WHERE datetime(accessed_at)<datetime('now',?)");
$s->execute(['-' . $months . ' months']);
return ['access_logs_removed' => $s->rowCount(), 'retention_months' => $months];
}
private static function csv(array $rows): string
{
if (!$rows) {
return "\xEF\xBB\xBF";
}
$stream = fopen('php://temp', 'r+');
fwrite($stream, "\xEF\xBB\xBF");
fputcsv($stream, array_keys($rows[0]), ';');
foreach ($rows as $row) {
fputcsv($stream, array_values($row), ';');
}
rewind($stream);
$out = stream_get_contents($stream);
fclose($stream);
return (string) $out;
}
}

View file

@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
final class PrivateMediaService
{
public static function root(): string
{
return dirname(__DIR__, 2) . '/data/private-media';
}
public static function animalDir(int $animalId): string
{
return self::root() . '/animals/' . $animalId;
}
public static function medicalDir(int $animalId): string
{
return self::animalDir($animalId) . '/meds';
}
public static function publicDir(int $animalId): string
{
return dirname(__DIR__, 2) . '/public/media/animals/' . $animalId;
}
public static function ensure(string $dir): void
{
if (!is_dir($dir) && !mkdir($dir, 0700, true) && !is_dir($dir)) {
throw new RuntimeException(t('service.image.directory_failed'));
}
@chmod($dir, 0700);
}
public static function moveVisibility(int $animalId, string $filename, bool $toPublic): void
{
$filename = basename($filename);
if ($filename === '' || $filename === '.' || $filename === '..') {
throw new RuntimeException(t('service.private_media.invalid_name'));
}
$from = $toPublic ? self::animalDir($animalId) : self::publicDir($animalId);
$to = $toPublic ? self::publicDir($animalId) : self::animalDir($animalId);
$source = $from . '/' . $filename;
$target = $to . '/' . $filename;
if (!is_file($source)) {
if (is_file($target)) {
return;
}
throw new RuntimeException(t('service.private_media.not_found'));
}
self::ensure($to);
if (!rename($source, $target)) {
throw new RuntimeException(t('service.private_media.move_failed'));
}
@chmod($target, $toPublic ? 0644 : 0600);
}
public static function migrateLegacy(): void
{
$db = DB::pdo();
$private = $db
->query('SELECT animal_id,filename FROM animal_photos WHERE is_public=0')
->fetchAll(PDO::FETCH_ASSOC);
foreach ($private as $row) {
self::moveIfPresent(
self::publicDir((int) $row['animal_id']) . '/' . basename((string) $row['filename']),
self::animalDir((int) $row['animal_id']) . '/' . basename((string) $row['filename']),
);
}
$medical = $db->query('SELECT mp.animal_id,mp.filename FROM medical_photos mp')->fetchAll(PDO::FETCH_ASSOC);
foreach ($medical as $row) {
self::moveIfPresent(
self::publicDir((int) $row['animal_id']) . '/meds/' . basename((string) $row['filename']),
self::medicalDir((int) $row['animal_id']) . '/' . basename((string) $row['filename']),
);
}
}
private static function moveIfPresent(string $source, string $target): void
{
if (!is_file($source) || is_file($target)) {
return;
}
self::ensure(dirname($target));
if (rename($source, $target)) {
@chmod($target, 0600);
}
}
public static function send(): void
{
$kind = (string) ($_GET['kind'] ?? 'photo');
$id = (int) ($_GET['id'] ?? 0);
if ($id <= 0) {
http_response_code(404);
return;
}
$db = DB::pdo();
if ($kind === 'medical') {
if (!PermissionService::can('medical', 'view')) {
http_response_code(403);
return;
}
$s = $db->prepare(
'SELECT mp.id,mp.animal_id,mp.filename FROM medical_photos mp JOIN animals a ON a.id=mp.animal_id WHERE mp.id=? AND a.deleted_at IS NULL',
);
$s->execute([$id]);
$row = $s->fetch(PDO::FETCH_ASSOC);
$path = $row ? self::medicalDir((int) $row['animal_id']) . '/' . basename((string) $row['filename']) : '';
} else {
if (!PermissionService::can('animals', 'view')) {
http_response_code(403);
return;
}
$s = $db->prepare(
'SELECT ap.id,ap.animal_id,ap.filename,ap.care_round_id FROM animal_photos ap JOIN animals a ON a.id=ap.animal_id WHERE ap.id=? AND ap.is_public=0 AND a.deleted_at IS NULL',
);
$s->execute([$id]);
$row = $s->fetch(PDO::FETCH_ASSOC);
if ($row && !empty($row['care_round_id']) && !PermissionService::can('medical', 'view')) {
http_response_code(403);
return;
}
$path = $row ? self::animalDir((int) $row['animal_id']) . '/' . basename((string) $row['filename']) : '';
}
if (empty($row) || !is_file($path)) {
http_response_code(404);
return;
}
$mime = new finfo(FILEINFO_MIME_TYPE)->file($path) ?: 'application/octet-stream';
PrivacyService::logAccess(
'private_' . $kind,
$id,
(int) $row['animal_id'],
basename((string) $row['filename']),
);
AuditService::log(
'private_file_viewed',
'/private-media',
'Média privé consulté',
'animal',
(int) $row['animal_id'],
['kind' => $kind, 'media_id' => $id],
);
header('Content-Type: ' . $mime);
header('Content-Length: ' . filesize($path));
header('Cache-Control: private,no-store');
header('X-Content-Type-Options: nosniff');
readfile($path);
}
}

View file

@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
final class SecurityService
{
public static function publicError(Throwable $e): string
{
$reference = strtoupper(substr(bin2hex(random_bytes(6)), 0, 10));
error_log(
'[Globinours ' .
$reference .
'] ' .
get_class($e) .
': ' .
$e->getMessage() .
' in ' .
$e->getFile() .
':' .
$e->getLine(),
);
return t('error.unexpected_reference', ['reference' => $reference]);
}
public static function hardenRuntimeFiles(): void
{
$root = dirname(__DIR__, 2);
$marker = $root . '/storage/.security-hardened';
if (is_file($marker) && filemtime($marker) > time() - 86400) {
return;
}
foreach (
[
$root . '/data',
$root . '/storage',
$root . '/data/private-media',
$root . '/data/medical-documents',
$root . '/data/grants',
$root . '/storage/backups',
$root . '/storage/logs',
$root . '/storage/asm3-imports',
]
as $dir
) {
if (is_dir($dir)) {
@chmod($dir, 0700);
}
}
foreach (
[
$root . '/.env',
$root . '/data/refuge.sqlite',
$root . '/data/refuge.sqlite-wal',
$root . '/data/refuge.sqlite-shm',
]
as $file
) {
if (is_file($file)) {
@chmod($file, 0600);
}
}
foreach (
[
'storage/backups/*',
'storage/logs/*',
'storage/reports/*',
'data/private-media/*',
'data/medical-documents/*',
'data/grants/*',
]
as $pattern
) {
foreach (glob($root . '/' . $pattern) ?: [] as $file) {
self::hardenTree($file);
}
}
@touch($marker);
@chmod($marker, 0600);
}
private static function hardenTree(string $path): void
{
if (is_file($path)) {
@chmod($path, 0600);
return;
}
if (!is_dir($path)) {
return;
}
@chmod($path, 0700);
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST,
);
foreach ($it as $item) {
@chmod($item->getPathname(), $item->isDir() ? 0700 : 0600);
}
}
}

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
final class ShelterRoomService
{
public static function all(bool $activeOnly = false): array
{
$sql =
'SELECT * FROM shelter_rooms' .
($activeOnly ? ' WHERE active=1' : '') .
' ORDER BY sort_order,name COLLATE NOCASE';
$rooms = DB::pdo()->query($sql)->fetchAll(PDO::FETCH_ASSOC);
$boxes = DB::pdo()
->query('SELECT * FROM shelter_boxes ORDER BY sort_order,name COLLATE NOCASE')
->fetchAll(PDO::FETCH_ASSOC);
foreach ($rooms as &$room) {
$room['boxes'] = array_values(
array_filter(
$boxes,
static fn($box) => (int) $box['room_id'] === (int) $room['id'] &&
(!$activeOnly || (int) $box['active'] === 1),
),
);
}
return $rooms;
}
public static function byCode(bool $activeOnly = true): array
{
$result = [];
foreach (self::all($activeOnly) as $room) {
$result[(string) $room['code']] = $room;
}
return $result;
}
public static function resolveAnimal(array $animal, array $rooms): string
{
$stored = (string) ($animal['refuge_room'] ?? '');
if ($stored !== '' && isset($rooms[$stored])) {
return $stored;
}
$status = (string) ($animal['status'] ?? 'refuge');
foreach ($rooms as $code => $room) {
if ((string) $room['status_code'] === $status) {
return (string) $code;
}
}
return array_key_first($rooms) ?? '';
}
public static function normalizeCode(string $value): string
{
$value = mb_strtolower(trim($value));
$value = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value) ?: $value;
return trim(preg_replace('/[^a-z0-9]+/', '-', $value) ?? '', '-');
}
}

View file

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
final class SpeciesService
{
private static ?array $labels = null;
public const CATEGORIES = [
'animaux_compagnie' => 'Animaux de compagnie',
'nac' => 'NAC',
'animal_ferme' => 'Animaux de ferme',
'faune_sauvage' => 'Faune sauvage',
'autre' => 'Autre',
];
public static function categoryLabel(string $key): string
{
return t('species.category.' . $key, [], self::CATEGORIES[$key] ?? $key);
}
public static function all(bool $activeOnly = false): array
{
$sql =
'SELECT s.*,(SELECT COUNT(*) FROM animals a WHERE lower(trim(a.species))=lower(s.code)) animal_count FROM ref_species s';
if ($activeOnly) {
$sql .= ' WHERE s.active=1';
}
$sql .= ' ORDER BY s.sort_order,s.name COLLATE NOCASE';
return DB::pdo()->query($sql)->fetchAll(PDO::FETCH_ASSOC);
}
public static function activeWithCurrent(?string $current): array
{
$rows = self::all(true);
$current = self::normalize((string) $current);
if ($current !== '' && !array_filter($rows, static fn(array $r): bool => $r['code'] === $current)) {
$stmt = DB::pdo()->prepare('SELECT * FROM ref_species WHERE code=:code');
$stmt->execute([':code' => $current]);
if ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$rows[] = $row;
}
}
return $rows;
}
public static function normalize(string $value): string
{
$value = mb_strtolower(trim($value));
return match ($value) {
'cat' => 'chat',
'dog' => 'chien',
default => $value,
};
}
public static function exists(string $code, bool $activeOnly = true): bool
{
$sql = 'SELECT 1 FROM ref_species WHERE code=:code' . ($activeOnly ? ' AND active=1' : '');
$stmt = DB::pdo()->prepare($sql);
$stmt->execute([':code' => self::normalize($code)]);
return (bool) $stmt->fetchColumn();
}
public static function isCat(?string $code): bool
{
return self::normalize((string) $code) === 'chat';
}
public static function label(?string $code): string
{
$code = self::normalize((string) $code);
if (self::$labels === null) {
self::$labels = [];
foreach (self::all(false) as $row) {
self::$labels[$row['code']] = $row['name'];
}
}
return self::$labels[$code] ?? ($code !== '' ? ucfirst(str_replace('-', ' ', $code)) : 'Espèce inconnue');
}
}

View file

@ -0,0 +1,705 @@
<?php
declare(strict_types=1);
final class StatisticsService
{
public static function costs(?int $year, ?array $ids = null): array
{
$db = DB::pdo();
$scopeIds = self::costScopeIds($year, $ids);
$empty = [
'total_cents' => 0,
'animals' => 0,
'scope_animals' => count($scopeIds),
'average_all_cents' => 0,
'average_expense_cents' => 0,
'median_expense_cents' => 0,
'by_clinic' => [],
'by_category' => [],
];
if (!$scopeIds) {
return $empty;
}
$where = ['a.deleted_at IS NULL', 'ae.animal_id IN(' . implode(',', array_map('intval', $scopeIds)) . ')'];
$params = [];
if ($year !== null) {
$where[] = 'date(ae.occurred_on) BETWEEN :start AND :end';
$params = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)];
}
$base =
' FROM animal_expenses ae JOIN animals a ON a.id=ae.animal_id LEFT JOIN directory_contacts dc ON dc.id=ae.clinic_contact_id LEFT JOIN clinic_tariffs ct ON ct.id=ae.tariff_id WHERE ' .
implode(' AND ', $where);
$map = static function (string $sql) use ($db, $params): array {
$s = $db->prepare($sql);
$s->execute($params);
return $s->fetchAll(PDO::FETCH_ASSOC);
};
$perAnimal = $map(
'SELECT ae.animal_id,SUM(ae.total_cents) total_cents' .
$base .
' GROUP BY ae.animal_id ORDER BY total_cents',
);
$totals = array_map(static fn($row) => (int) $row['total_cents'], $perAnimal);
$total = array_sum($totals);
$animals = count($totals);
return [
'total_cents' => $total,
'animals' => $animals,
'scope_animals' => count($scopeIds),
'average_all_cents' => count($scopeIds) ? (int) round($total / count($scopeIds)) : 0,
'average_expense_cents' => $animals ? (int) round($total / $animals) : 0,
'median_expense_cents' => self::median($totals),
'by_clinic' => $map(
"SELECT dc.id contact_id,COALESCE(dc.name,'—') label,COUNT(DISTINCT ae.animal_id) animals,SUM(ae.total_cents) total_cents,ROUND(SUM(ae.total_cents)*1.0/COUNT(DISTINCT ae.animal_id)) average_cents" .
$base .
' GROUP BY dc.id,dc.name ORDER BY total_cents DESC',
),
'by_category' => $map(
"SELECT COALESCE(ct.category,'other') label,COUNT(DISTINCT ae.animal_id) animals,SUM(ae.total_cents) total_cents,ROUND(SUM(ae.total_cents)*1.0/COUNT(DISTINCT ae.animal_id)) average_cents" .
$base .
' GROUP BY ct.category ORDER BY total_cents DESC',
),
];
}
public static function costsByMunicipality(?int $year): array
{
$db = DB::pdo();
$scopeIds = self::costScopeIds($year, null);
if (!$scopeIds) {
return [];
}
$animals = $db
->query('SELECT id,rescue_location_name,rescue_address FROM animals WHERE deleted_at IS NULL')
->fetchAll(PDO::FETCH_ASSOC);
$cities = [];
$animalCity = [];
foreach ($animals as $row) {
$id = (int) $row['id'];
if (!in_array($id, $scopeIds, true)) {
continue;
}
$city = GrantService::municipality((string) ($row['rescue_location_name'] ?: $row['rescue_address']));
if ($city === 'Non renseignée') {
continue;
}
$animalCity[$id] = $city;
$cities[$city]['animal_ids'][$id] = true;
}
if (!$animalCity) {
return [];
}
$params = [];
$dateSql = '';
if ($year !== null) {
$dateSql = ' AND date(occurred_on) BETWEEN :start AND :end';
$params = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)];
}
$s = $db->prepare(
'SELECT animal_id,SUM(total_cents) total_cents FROM animal_expenses WHERE animal_id IN(' .
implode(',', array_keys($animalCity)) .
')' .
$dateSql .
' GROUP BY animal_id',
);
$s->execute($params);
foreach ($s->fetchAll(PDO::FETCH_ASSOC) as $row) {
$id = (int) $row['animal_id'];
$city = $animalCity[$id];
$cities[$city]['expense_totals'][$id] = (int) $row['total_cents'];
}
$out = [];
foreach ($cities as $city => $data) {
$totals = array_values($data['expense_totals'] ?? []);
$total = array_sum($totals);
$scope = count($data['animal_ids']);
$with = count($totals);
$out[] = [
'municipality' => $city,
'animals' => $scope,
'animals_with_expense' => $with,
'total_cents' => $total,
'average_all_cents' => $scope ? (int) round($total / $scope) : 0,
'average_expense_cents' => $with ? (int) round($total / $with) : 0,
'median_expense_cents' => self::median($totals),
];
}
usort(
$out,
static fn($a, $b) => $b['total_cents'] <=> $a['total_cents'] ?:
strnatcasecmp($a['municipality'], $b['municipality']),
);
return $out;
}
private static function costScopeIds(?int $year, ?array $ids): array
{
$db = DB::pdo();
$limit = $ids === null ? '' : self::idSql($ids, 'a.id');
if ($year === null) {
$sql = 'SELECT a.id FROM animals a WHERE a.deleted_at IS NULL' . $limit;
} else {
$start = sprintf('%04d-01-01', $year);
$end = sprintf('%04d-12-31', $year);
$sql =
'SELECT a.id FROM animals a WHERE a.deleted_at IS NULL' .
$limit .
" AND (date(a.intake_date) BETWEEN :start AND :end OR EXISTS(SELECT 1 FROM animal_movements m WHERE m.animal_id=a.id AND m.kind='entry' AND date(m.created_at) BETWEEN :start AND :end) OR EXISTS(SELECT 1 FROM animal_expenses ae WHERE ae.animal_id=a.id AND date(ae.occurred_on) BETWEEN :start AND :end))";
}
$s = $db->prepare($sql);
$s->execute($year === null ? [] : [':start' => $start, ':end' => $end]);
return array_map('intval', $s->fetchAll(PDO::FETCH_COLUMN));
}
private static function median(array $values): int
{
if (!$values) {
return 0;
}
sort($values, SORT_NUMERIC);
$count = count($values);
$middle = intdiv($count, 2);
return $count % 2 ? (int) $values[$middle] : (int) round(($values[$middle - 1] + $values[$middle]) / 2);
}
private static function idSql(?array $ids, string $column = 'animal_id'): string
{
if ($ids === null) {
return '';
}
if (!$ids) {
return ' AND 1=0';
}
return ' AND ' . $column . ' IN (' . implode(',', array_map('intval', $ids)) . ')';
}
public static function municipalities(): array
{
$rows = DB::pdo()
->query('SELECT id,rescue_location_name,rescue_address FROM animals WHERE deleted_at IS NULL')
->fetchAll(PDO::FETCH_ASSOC);
$items = [];
foreach ($rows as $row) {
$city = GrantService::municipality((string) ($row['rescue_location_name'] ?: $row['rescue_address']));
if ($city === 'Non renseignée') {
continue;
}
$items[$city] = ($items[$city] ?? 0) + 1;
}
uksort($items, static fn($a, $b) => strnatcasecmp($a, $b));
return $items;
}
public static function animalIdsForMunicipality(string $municipality): array
{
if ($municipality === '') {
return [];
}
$rows = DB::pdo()
->query('SELECT id,rescue_location_name,rescue_address FROM animals WHERE deleted_at IS NULL')
->fetchAll(PDO::FETCH_ASSOC);
$ids = [];
foreach ($rows as $row) {
if (
GrantService::municipality((string) ($row['rescue_location_name'] ?: $row['rescue_address'])) ===
$municipality
) {
$ids[] = (int) $row['id'];
}
}
return $ids;
}
public static function municipalityCoverage(): array
{
$row =
DB::pdo()
->query(
"SELECT COUNT(*) total,SUM(CASE WHEN TRIM(COALESCE(rescue_location_name,''))<>'' OR TRIM(COALESCE(rescue_address,''))<>'' THEN 1 ELSE 0 END) known,SUM(CASE WHEN (TRIM(COALESCE(rescue_location_name,''))<>'' OR TRIM(COALESCE(rescue_address,''))<>'') AND COALESCE(rescue_location_confidence,'')<>'inferred_family' THEN 1 ELSE 0 END) documented,SUM(CASE WHEN rescue_location_confidence='inferred_family' THEN 1 ELSE 0 END) inferred,SUM(CASE WHEN intake_type='born_in_care' OR intake_reason='birth' THEN 1 ELSE 0 END) born_in_care,SUM(CASE WHEN TRIM(COALESCE(rescue_location_name,''))='' AND TRIM(COALESCE(rescue_address,''))='' AND intake_type<>'born_in_care' AND intake_reason<>'birth' THEN 1 ELSE 0 END) unknown FROM animals WHERE deleted_at IS NULL",
)
->fetch(PDO::FETCH_ASSOC) ?:
[];
return array_map('intval', $row);
}
public static function annual(int $year, ?array $ids = null): array
{
if ($ids === null) {
$stats = GrantService::statistics($year);
} else {
$db = DB::pdo();
$p = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)];
$in = self::idSql($ids);
$scalar = static function (string $sql) use ($db, $p): float {
$s = $db->prepare($sql);
$s->execute($p);
return (float) ($s->fetchColumn() ?: 0);
};
$stats = [];
$stats['entries'] = $scalar(
"SELECT COUNT(*) FROM (SELECT m.animal_id FROM animal_movements m JOIN animals a ON a.id=m.animal_id WHERE a.deleted_at IS NULL AND m.kind='entry' AND date(m.created_at) BETWEEN :start AND :end" .
self::idSql($ids, 'm.animal_id') .
' UNION SELECT a.id FROM animals a WHERE a.deleted_at IS NULL AND date(a.intake_date) BETWEEN :start AND :end' .
self::idSql($ids, 'a.id') .
')',
);
$stats['exits'] = $scalar(
'SELECT (SELECT COUNT(*) FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE a.deleted_at IS NULL AND date(ad.adoption_date) BETWEEN :start AND :end' .
self::idSql($ids, 'ad.animal_id') .
")+(SELECT COUNT(*) FROM animal_deaths d JOIN animals a ON a.id=d.animal_id WHERE a.deleted_at IS NULL AND date(COALESCE(d.deceased_date,printf('%04d-01-01',d.death_year))) BETWEEN :start AND :end AND NOT EXISTS(SELECT 1 FROM adoptions ad WHERE ad.animal_id=d.animal_id)" .
self::idSql($ids, 'd.animal_id') .
')',
);
$stats['adoptions'] = $scalar(
'SELECT COUNT(*) FROM adoptions WHERE date(adoption_date) BETWEEN :start AND :end' . $in,
);
$stats['deaths'] = $scalar(
"SELECT COUNT(*) FROM animal_deaths WHERE date(COALESCE(deceased_date,printf('%04d-01-01',death_year))) BETWEEN :start AND :end" .
$in,
);
$stats['births'] = $scalar(
"SELECT COUNT(*) FROM litter_kittens lk JOIN litters l ON l.id=lk.litter_id JOIN animals a ON a.id=lk.animal_id WHERE a.deleted_at IS NULL AND date(COALESCE(NULLIF(l.birth_date,''),NULLIF(a.birth_date,''))) BETWEEN :start AND :end" .
self::idSql($ids, 'lk.animal_id'),
);
$stats['vaccinations'] = $scalar(
'SELECT COUNT(*) FROM vaccinations WHERE date(done_date) BETWEEN :start AND :end' . $in,
);
$stats['treatments'] = $scalar(
'SELECT COUNT(*) FROM treatments WHERE date(start_date) BETWEEN :start AND :end' . $in,
);
$stats['avg'] = $scalar(
"SELECT AVG(julianday(adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))) FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE date(adoption_date) BETWEEN :start AND :end AND date(adoption_date)>=date(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))" .
self::idSql($ids, 'a.id'),
);
$stats['current'] =
(float) ($db
->query(
"SELECT COUNT(*) FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND status NOT IN ('adopte','decede')" .
self::idSql($ids, 'id'),
)
->fetchColumn() ?:
0);
}
$db = DB::pdo();
$range = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)];
$filter = self::idSql($ids, 'a.id');
$s = $db->prepare(
"SELECT COUNT(*) FROM (SELECT m.animal_id FROM animal_movements m JOIN animals a ON a.id=m.animal_id WHERE a.deleted_at IS NULL AND m.kind='entry' AND date(m.created_at) BETWEEN :start AND :end" .
$filter .
' UNION SELECT a.id FROM animals a WHERE a.deleted_at IS NULL AND date(a.intake_date) BETWEEN :start AND :end' .
$filter .
" UNION SELECT a.id FROM animals a JOIN animal_deaths d ON d.animal_id=a.id WHERE a.deleted_at IS NULL AND NULLIF(TRIM(a.intake_date),'') IS NULL AND NOT EXISTS(SELECT 1 FROM animal_movements em WHERE em.animal_id=a.id AND em.kind='entry') AND date(COALESCE(d.deceased_date,printf('%04d-01-01',d.death_year))) BETWEEN :start AND :end" .
$filter .
')',
);
$s->execute($range);
$stats['handled'] = (int) $s->fetchColumn();
$s = $db->prepare(
'SELECT COUNT(*) FROM medical_notes mn JOIN animals a ON a.id=mn.animal_id WHERE date(mn.noted_at) BETWEEN :start AND :end' .
$filter,
);
$s->execute($range);
$stats['medical_notes'] = (int) $s->fetchColumn();
return $stats;
}
public static function comparison(int $year, ?array $ids = null): array
{
$current = self::annual($year, $ids);
$previous = self::annual($year - 1, $ids);
$out = [];
foreach (
[
'handled',
'entries',
'exits',
'adoptions',
'deaths',
'births',
'vaccinations',
'treatments',
'medical_notes',
'avg',
]
as $key
) {
$a = (float) $current[$key];
$b = (float) $previous[$key];
$out[$key] = [
'current' => $a,
'previous' => $b,
'difference' => $a - $b,
'percent' => $b != 0 ? (($a - $b) / $b) * 100 : null,
];
}
return $out;
}
public static function years(): array
{
$db = DB::pdo();
$found = [];
$queries = [
'SELECT intake_date d FROM animals WHERE deleted_at IS NULL AND intake_date IS NOT NULL',
"SELECT created_at d FROM animal_movements WHERE kind IN ('entry','exit')",
'SELECT adoption_date d FROM adoptions',
"SELECT COALESCE(deceased_date,printf('%04d-01-01',death_year)) d FROM animal_deaths WHERE deceased_date IS NOT NULL OR death_year IS NOT NULL",
'SELECT birth_date d FROM litters WHERE birth_date IS NOT NULL',
'SELECT done_date d FROM vaccinations',
'SELECT start_date d FROM treatments',
'SELECT noted_at d FROM medical_notes',
'SELECT occurred_on d FROM animal_expenses',
];
foreach ($queries as $sql) {
foreach ($db->query($sql)->fetchAll(PDO::FETCH_COLUMN) as $date) {
$y = (int) substr((string) $date, 0, 4);
if ($y >= 2000 && $y <= 2200) {
$found[$y] = true;
}
}
}
if (!$found) {
$found[(int) date('Y')] = true;
}
$first = min(array_keys($found));
$last = max((int) date('Y'), max(array_keys($found)));
return range($first, $last);
}
public static function timeline(?array $years = null, ?array $ids = null): array
{
$out = [];
foreach ($years ?? self::years() as $year) {
$out[(int) $year] = self::annual((int) $year, $ids);
}
return $out;
}
public static function all(?array $ids = null): array
{
$stats = array_fill_keys(
['entries', 'exits', 'adoptions', 'deaths', 'births', 'vaccinations', 'treatments', 'medical_notes'],
0.0,
);
foreach (self::timeline(null, $ids) as $annual) {
foreach (array_keys($stats) as $key) {
$stats[$key] += (float) $annual[$key];
}
}
$db = DB::pdo();
$stats['deaths'] =
(float) ($db
->query(
'SELECT COUNT(*) FROM animal_deaths d JOIN animals a ON a.id=d.animal_id WHERE a.deleted_at IS NULL' .
self::idSql($ids, 'd.animal_id'),
)
->fetchColumn() ?:
0);
if ($ids === null) {
$stats['handled'] =
(float) ($db->query('SELECT COUNT(*) FROM animals WHERE deleted_at IS NULL')->fetchColumn() ?: 0);
$stats['avg'] =
(float) ($db
->query(
"SELECT AVG(julianday(adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))) FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE adoption_date IS NOT NULL AND date(adoption_date)>=date(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))",
)
->fetchColumn() ?:
0);
} else {
$stats['handled'] = (float) count($ids);
$stats['avg'] =
(float) ($db
->query(
"SELECT AVG(julianday(adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))) FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE adoption_date IS NOT NULL AND date(adoption_date)>=date(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))" .
self::idSql($ids, 'a.id'),
)
->fetchColumn() ?:
0);
}
$stats['entries'] = $stats['handled'];
$stats['exits'] =
(float) ($db
->query(
'SELECT (SELECT COUNT(*) FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE a.deleted_at IS NULL' .
self::idSql($ids, 'ad.animal_id') .
')+(SELECT COUNT(*) FROM animal_deaths d JOIN animals a ON a.id=d.animal_id WHERE a.deleted_at IS NULL AND NOT EXISTS(SELECT 1 FROM adoptions ad WHERE ad.animal_id=d.animal_id)' .
self::idSql($ids, 'd.animal_id') .
")+(SELECT COUNT(*) FROM animals a WHERE a.deleted_at IS NULL AND a.status='enfui' AND NOT EXISTS(SELECT 1 FROM adoptions ad WHERE ad.animal_id=a.id) AND NOT EXISTS(SELECT 1 FROM animal_deaths d WHERE d.animal_id=a.id)" .
self::idSql($ids, 'a.id') .
')',
)
->fetchColumn() ?:
0);
$stats['adoptions'] =
(float) ($db
->query(
'SELECT COUNT(*) FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE a.deleted_at IS NULL' .
self::idSql($ids, 'ad.animal_id'),
)
->fetchColumn() ?:
0);
$stats['births'] =
(float) ($db
->query(
'SELECT COUNT(*) FROM litter_kittens lk JOIN animals a ON a.id=lk.animal_id WHERE a.deleted_at IS NULL' .
self::idSql($ids, 'lk.animal_id'),
)
->fetchColumn() ?:
0);
$stats['vaccinations'] =
(float) ($db
->query(
'SELECT COUNT(*) FROM vaccinations v JOIN animals a ON a.id=v.animal_id WHERE a.deleted_at IS NULL' .
self::idSql($ids, 'v.animal_id'),
)
->fetchColumn() ?:
0);
$stats['medical_notes'] =
(float) ($db
->query(
'SELECT COUNT(*) FROM medical_notes mn JOIN animals a ON a.id=mn.animal_id WHERE a.deleted_at IS NULL' .
self::idSql($ids, 'mn.animal_id'),
)
->fetchColumn() ?:
0);
$stats['current'] = (float) (self::currentHealth($ids)['total'] ?? 0);
return $stats;
}
public static function undatedHandled(?array $ids = null): int
{
return (int) (DB::pdo()
->query(
"SELECT COUNT(*) FROM animals a WHERE a.deleted_at IS NULL AND (a.intake_date IS NULL OR TRIM(a.intake_date)='') AND NOT EXISTS(SELECT 1 FROM animal_movements m WHERE m.animal_id=a.id AND m.kind='entry')" .
self::idSql($ids, 'a.id'),
)
->fetchColumn() ?:
0);
}
public static function undatedDeaths(?array $ids = null): int
{
return (int) (DB::pdo()
->query(
"SELECT COUNT(*) FROM animal_deaths d JOIN animals a ON a.id=d.animal_id WHERE a.deleted_at IS NULL AND (d.deceased_date IS NULL OR TRIM(d.deceased_date)='') AND d.death_year IS NULL" .
self::idSql($ids, 'd.animal_id'),
)
->fetchColumn() ?:
0);
}
public static function undatedBirths(?array $ids = null): int
{
return (int) (DB::pdo()
->query(
"SELECT COUNT(*) FROM litter_kittens lk JOIN litters l ON l.id=lk.litter_id JOIN animals a ON a.id=lk.animal_id WHERE a.deleted_at IS NULL AND COALESCE(NULLIF(l.birth_date,''),NULLIF(a.birth_date,'')) IS NULL" .
self::idSql($ids, 'lk.animal_id'),
)
->fetchColumn() ?:
0);
}
public static function undatedExits(?array $ids = null): int
{
return (int) (DB::pdo()
->query(
"SELECT (SELECT COUNT(*) FROM animal_deaths d JOIN animals a ON a.id=d.animal_id WHERE a.deleted_at IS NULL AND (d.deceased_date IS NULL OR TRIM(d.deceased_date)='') AND d.death_year IS NULL AND NOT EXISTS(SELECT 1 FROM adoptions ad WHERE ad.animal_id=d.animal_id)" .
self::idSql($ids, 'd.animal_id') .
")+(SELECT COUNT(*) FROM animals a WHERE a.deleted_at IS NULL AND a.status='enfui' AND NOT EXISTS(SELECT 1 FROM adoptions ad WHERE ad.animal_id=a.id) AND NOT EXISTS(SELECT 1 FROM animal_deaths d WHERE d.animal_id=a.id)" .
self::idSql($ids, 'a.id') .
')',
)
->fetchColumn() ?:
0);
}
public static function scopeCounts(?array $ids = null): array
{
$db = DB::pdo();
return [
'vaccinated_animals' => (int) $db
->query(
'SELECT COUNT(DISTINCT v.animal_id) FROM vaccinations v JOIN animals a ON a.id=v.animal_id WHERE a.deleted_at IS NULL' .
self::idSql($ids, 'v.animal_id'),
)
->fetchColumn(),
'medical_animals' => (int) $db
->query(
'SELECT COUNT(DISTINCT mn.animal_id) FROM medical_notes mn JOIN animals a ON a.id=mn.animal_id WHERE a.deleted_at IS NULL' .
self::idSql($ids, 'mn.animal_id'),
)
->fetchColumn(),
];
}
private static function details(int $year, array $ids): array
{
$db = DB::pdo();
$p = [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)];
$base =
"WITH entry_sources AS (SELECT m.animal_id,date(m.created_at) entry_date FROM animal_movements m JOIN animals source_animal ON source_animal.id=m.animal_id WHERE source_animal.deleted_at IS NULL AND m.kind='entry' AND date(m.created_at) BETWEEN :start AND :end" .
self::idSql($ids, 'm.animal_id') .
' UNION SELECT id animal_id,date(intake_date) entry_date FROM animals WHERE deleted_at IS NULL AND date(intake_date) BETWEEN :start AND :end' .
self::idSql($ids, 'id') .
'), entered AS (SELECT animal_id,MIN(entry_date) entry_date FROM entry_sources GROUP BY animal_id) ';
$map = static function (string $sql) use ($db, $p): array {
$s = $db->prepare($sql);
$s->execute($p);
$out = [];
foreach ($s->fetchAll(PDO::FETCH_ASSOC) as $r) {
$out[(string) $r['label']] = (int) $r['total'];
}
return $out;
};
$sex = $map(
$base .
"SELECT CASE a.sex WHEN 'F' THEN 'Femelles' WHEN 'M' THEN 'Mâles' ELSE 'Inconnu' END label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY a.sex ORDER BY total DESC",
);
$ages = $map(
$base .
"SELECT CASE WHEN a.birth_date IS NULL THEN 'Non renseigné' WHEN (julianday(e.entry_date)-julianday(a.birth_date))/365.25<1 THEN 'Moins d’un an' WHEN (julianday(e.entry_date)-julianday(a.birth_date))/365.25<3 THEN '1 à 2 ans' WHEN (julianday(e.entry_date)-julianday(a.birth_date))/365.25<8 THEN '3 à 7 ans' ELSE '8 ans et plus' END label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC",
);
$deathCauses = $map(
"SELECT cause_code label,COUNT(*) total FROM animal_deaths WHERE date(COALESCE(deceased_date,printf('%04d-01-01',death_year))) BETWEEN :start AND :end" .
$in .
' GROUP BY cause_code ORDER BY total DESC',
);
$stays = $map(
"SELECT CASE WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<30 THEN 'Moins d’un mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<90 THEN '1 à 3 mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<180 THEN '3 à 6 mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<365 THEN '6 à 12 mois' ELSE 'Plus d’un an' END label,COUNT(*) total FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE date(ad.adoption_date) BETWEEN :start AND :end AND date(ad.adoption_date)>=date(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))" .
self::idSql($ids, 'a.id') .
' GROUP BY label ORDER BY total DESC',
);
return compact('sex', 'ages', 'deathCauses', 'stays');
}
public static function distributions(?int $year, ?array $ids = null, ?string $municipality = null): array
{
$db = DB::pdo();
$p = $year === null ? [] : [':start' => sprintf('%04d-01-01', $year), ':end' => sprintf('%04d-12-31', $year)];
$rows = static function (string $sql, array $params = []) use ($db): array {
$s = $db->prepare($sql);
$s->execute($params);
$out = [];
foreach ($s->fetchAll(PDO::FETCH_ASSOC) as $r) {
$out[(string) $r['label']] = (int) $r['total'];
}
return $out;
};
$movementDate = $year === null ? '' : ' AND date(m.created_at) BETWEEN :start AND :end';
$animalDate = $year === null ? '' : ' AND date(intake_date) BETWEEN :start AND :end';
$datedAnimal = $year === null ? '' : " AND NULLIF(TRIM(intake_date),'') IS NOT NULL";
$entered =
"WITH entry_sources AS (SELECT m.animal_id,date(m.created_at) entry_date FROM animal_movements m JOIN animals source_animal ON source_animal.id=m.animal_id WHERE source_animal.deleted_at IS NULL AND m.kind='entry'" .
$movementDate .
self::idSql($ids, 'm.animal_id') .
' UNION SELECT id animal_id,date(intake_date) entry_date FROM animals WHERE deleted_at IS NULL' .
$datedAnimal .
$animalDate .
self::idSql($ids, 'id') .
'), entered AS (SELECT animal_id,MIN(entry_date) entry_date FROM entry_sources GROUP BY animal_id) ';
$colors = $rows(
$entered .
"SELECT COALESCE(NULLIF(TRIM(a.color),''),'Non renseignée') label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC,label",
$p,
);
$breeds = $rows(
$entered .
"SELECT COALESCE(NULLIF(TRIM(a.breed),''),'Non renseignée') label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC,label",
$p,
);
$species = $rows(
$entered .
"SELECT CASE lower(a.species) WHEN 'cat' THEN 'Chats' WHEN 'chat' THEN 'Chats' WHEN 'dog' THEN 'Chiens' WHEN 'chien' THEN 'Chiens' ELSE COALESCE(NULLIF(TRIM(a.species),''),'Inconnue') END label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC",
$p,
);
$intakeTypes = $rows(
$entered .
"SELECT COALESCE(NULLIF(a.intake_type,''),'unknown') label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC",
$p,
);
$intakeReasons = $rows(
$entered .
"SELECT CASE WHEN a.intake_owner_care_home=1 THEN 'owner_care_home' ELSE COALESCE(NULLIF(a.intake_reason,''),'unknown') END label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC",
$p,
);
if ($year === null) {
$animalFilter = self::idSql($ids, 'a.id');
$details = [];
$details['sex'] = $rows(
"SELECT CASE a.sex WHEN 'F' THEN 'Femelles' WHEN 'M' THEN 'Mâles' ELSE 'Inconnu' END label,COUNT(*) total FROM animals a WHERE a.deleted_at IS NULL" .
$animalFilter .
' GROUP BY a.sex ORDER BY total DESC',
);
$details['ages'] = $rows(
"SELECT CASE WHEN a.birth_date IS NULL OR NULLIF(TRIM(a.intake_date),'') IS NULL THEN 'Non renseigné' WHEN (julianday(a.intake_date)-julianday(a.birth_date))/365.25<1 THEN 'Moins d’un an' WHEN (julianday(a.intake_date)-julianday(a.birth_date))/365.25<3 THEN '1 à 2 ans' WHEN (julianday(a.intake_date)-julianday(a.birth_date))/365.25<8 THEN '3 à 7 ans' ELSE '8 ans et plus' END label,COUNT(*) total FROM animals a WHERE a.deleted_at IS NULL" .
$animalFilter .
' GROUP BY label ORDER BY total DESC',
);
$details['deathCauses'] = $rows(
"SELECT COALESCE(NULLIF(d.cause_code,''),'unknown') label,COUNT(*) total FROM animal_deaths d JOIN animals a ON a.id=d.animal_id WHERE a.deleted_at IS NULL" .
self::idSql($ids, 'd.animal_id') .
' GROUP BY label ORDER BY total DESC',
);
$details['stays'] = $rows(
"SELECT CASE WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<30 THEN 'Moins d’un mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<90 THEN '1 à 3 mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<180 THEN '3 à 6 mois' WHEN julianday(ad.adoption_date)-julianday(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))<365 THEN '6 à 12 mois' ELSE 'Plus d’un an' END label,COUNT(*) total FROM adoptions ad JOIN animals a ON a.id=ad.animal_id WHERE a.deleted_at IS NULL AND ad.adoption_date IS NOT NULL AND date(ad.adoption_date)>=date(COALESCE(NULLIF(a.intake_date,''),date(a.created_at)))" .
$animalFilter .
' GROUP BY label ORDER BY total DESC',
);
$origins = $municipality !== null ? [$municipality => (int) array_sum($species)] : self::municipalities();
} elseif ($ids !== null) {
$details = self::details($year, $ids);
$origins = $municipality !== null ? [$municipality => (int) array_sum($species)] : [];
} else {
$details = GrantService::detailedStatistics($year);
$origins = $details['origins'];
}
foreach ($details as &$items) {
arsort($items, SORT_NUMERIC);
}
unset($items);
$filter = self::idSql($ids, 'id');
$currentStatus = $rows(
"SELECT CASE status WHEN 'refuge' THEN 'Refuge' WHEN 'fa' THEN 'Famille d’accueil' WHEN 'fa_permanente' THEN 'Accueil permanent' WHEN 'chat_libre' THEN 'Chats libres suivis' WHEN 'quarantaine' THEN 'Quarantaine' WHEN 'soin' THEN 'Infirmerie' WHEN 'reserve' THEN 'Réservé' ELSE status END label,COUNT(*) total FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND lower(status) NOT IN ('adopte','adopté','decede','décédé')" .
$filter .
' GROUP BY status ORDER BY total DESC',
);
$freeCatSites = $rows(
"SELECT COALESCE(NULLIF(TRIM(free_cat_site_name),''),NULLIF(TRIM(free_cat_city),''),'Site non renseigné') label,COUNT(*) total FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND status='chat_libre'" .
$filter .
' GROUP BY label ORDER BY total DESC,label',
);
$releaseSql =
$year === null
? "SELECT substr(free_cat_released_at,1,4) label,COUNT(*) total FROM animals WHERE deleted_at IS NULL AND free_cat_released_at IS NOT NULL AND trim(free_cat_released_at)<>''" .
$filter .
' GROUP BY label ORDER BY label DESC'
: "SELECT COALESCE(NULLIF(TRIM(free_cat_site_name),''),NULLIF(TRIM(free_cat_city),''),'Site non renseigné') label,COUNT(*) total FROM animals WHERE deleted_at IS NULL AND date(free_cat_released_at) BETWEEN :start AND :end" .
$filter .
' GROUP BY label ORDER BY total DESC,label';
$freeCatReleases = $rows($releaseSql, $p);
return [
'colors' => $colors,
'breeds' => $breeds,
'species' => $species,
'intakeTypes' => $intakeTypes,
'intakeReasons' => $intakeReasons,
'sex' => $details['sex'],
'ages' => $details['ages'],
'origins' => $origins,
'deathCauses' => $details['deathCauses'],
'stays' => $details['stays'],
'currentStatus' => $currentStatus,
'freeCatSites' => $freeCatSites,
'freeCatReleases' => $freeCatReleases,
];
}
public static function currentHealth(?array $ids = null): array
{
$sql =
"SELECT COUNT(*) total,SUM(CASE WHEN status='chat_libre' THEN 1 ELSE 0 END) free_cats,SUM(CASE WHEN sterilization_status='yes' THEN 1 ELSE 0 END) sterilized,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) sterilization_unknown,SUM(CASE WHEN COALESCE(NULLIF(TRIM(chip_id),''),NULLIF(TRIM(chip),'')) IS NOT NULL THEN 1 ELSE 0 END) chipped,SUM(CASE WHEN fiv_status='positive' THEN 1 ELSE 0 END) fiv_positive,SUM(CASE WHEN felv_status='positive' THEN 1 ELSE 0 END) felv_positive FROM animals WHERE deleted_at IS NULL AND archived_at IS NULL AND lower(status) NOT IN ('adopte','adopté','decede','décédé')" .
self::idSql($ids, 'id');
$row = DB::pdo()->query($sql)->fetch(PDO::FETCH_ASSOC) ?: [];
return array_map('intval', $row);
}
}

View file

@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
final class SystemHealthService
{
public static function report(): array
{
$checks = [];
$root = dirname(__DIR__, 2);
$db = DB::pdo();
foreach (InstallationService::runtimeChecks() as $c) {
$checks[] = self::check(
$c['ok'] ? 'ok' : ($c['level'] === 'required' ? 'error' : 'warning'),
'environment',
$c['label'],
$c['detail'],
);
}
try {
$integrity = (string) $db->query('PRAGMA integrity_check')->fetchColumn();
$checks[] = self::check(
$integrity === 'ok' ? 'ok' : 'error',
'database',
t('health.sqlite_integrity'),
$integrity === 'ok' ? t('health.sqlite_ok') : $integrity,
);
} catch (Throwable $e) {
$checks[] = self::check('error', 'database', t('health.sqlite_integrity'), $e->getMessage());
}
try {
$fk = $db->query('PRAGMA foreign_key_check')->fetchAll(PDO::FETCH_ASSOC);
$checks[] = self::check(
!$fk ? 'ok' : 'error',
'database',
t('health.foreign_keys'),
!$fk ? t('health.no_fk_error') : t('health.fk_errors', ['count' => count($fk)]),
);
} catch (Throwable $e) {
$checks[] = self::check('error', 'database', t('health.foreign_keys'), $e->getMessage());
}
$migrationFiles = array_map('basename', glob($root . '/migrations/*.sql') ?: []);
$applied = $db->query('SELECT name FROM _migrations')->fetchAll(PDO::FETCH_COLUMN);
$pending = array_values(array_diff($migrationFiles, $applied));
$checks[] = self::check(
!$pending ? 'ok' : 'warning',
'database',
t('health.migrations'),
!$pending
? t('health.migrations_ok', ['count' => count($applied)])
: t('health.migrations_pending', ['count' => count($pending)]),
);
$dbFile = $root . '/data/refuge.sqlite';
$checks[] = self::check(
is_file($dbFile) && is_readable($dbFile) ? 'ok' : 'error',
'storage',
t('health.database_file'),
is_file($dbFile) ? self::bytes((int) filesize($dbFile)) : t('health.file_missing'),
);
$free = @disk_free_space($root);
$status =
$free === false
? 'warning'
: ($free >= 1_073_741_824
? 'ok'
: ($free >= 268_435_456
? 'warning'
: 'error'));
$checks[] = self::check(
$status,
'storage',
t('health.disk_space'),
$free === false ? t('health.unknown') : self::bytes((int) $free),
);
$backups = BackupService::list();
if (!$backups) {
$checks[] = self::check('error', 'continuity', t('health.last_backup'), t('health.no_backup'));
} else {
$latest = $backups[0];
$date = DateTimeImmutable::createFromFormat(
'd/m/Y H:i:s',
(string) $latest['date'],
new DateTimeZone('Europe/Paris'),
);
$days = $date
? (int) $date->diff(new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')))->format('%a')
: 999;
$checks[] = self::check(
$days <= 7 ? 'ok' : ($days <= 30 ? 'warning' : 'error'),
'continuity',
t('health.last_backup'),
t('health.backup_age', ['date' => $latest['date'], 'days' => $days]),
);
}
[$missing, $orphans] = self::photoConsistency($db, $root);
$checks[] = self::check(
$missing === 0 ? 'ok' : 'warning',
'files',
t('health.missing_photos'),
t('health.file_count', ['count' => $missing]),
);
$checks[] = self::check(
$orphans === 0 ? 'ok' : 'warning',
'files',
t('health.orphan_photos'),
t('health.file_count', ['count' => $orphans]),
);
$display = (string) ini_get('display_errors');
$production = getenv('APP_ENV') !== 'development';
$checks[] = self::check(
!$production || !in_array(strtolower($display), ['1', 'on', 'yes', 'true'], true) ? 'ok' : 'warning',
'security',
t('health.display_errors'),
$production ? t('health.production_value', ['value' => $display ?: 'Off']) : t('health.development_mode'),
);
$logErrors = filter_var(ini_get('log_errors'), FILTER_VALIDATE_BOOL);
$errorLog = trim((string) ini_get('error_log'));
$checks[] = self::check(
$logErrors ? 'ok' : 'warning',
'security',
t('health.error_log'),
$logErrors ? ($errorLog ?: t('health.system_log')) : t('health.logging_disabled'),
);
$applicationLog = $root . '/storage/logs/php-errors.log';
$recent = self::recentLogLines($applicationLog, 7);
$checks[] = self::check(
$recent === 0 ? 'ok' : 'warning',
'security',
t('health.recent_errors'),
$recent ? t('health.recent_error_count', ['count' => $recent]) : t('health.no_recent_error'),
);
$summary = ['ok' => 0, 'warning' => 0, 'error' => 0];
foreach ($checks as $c) {
$summary[$c['status']]++;
}
return [
'checks' => $checks,
'summary' => $summary,
'generated_at' => new DateTimeImmutable('now', new DateTimeZone('Europe/Paris'))->format('d/m/Y H:i:s'),
'php' => PHP_VERSION,
'sqlite' => (string) $db->query('SELECT sqlite_version()')->fetchColumn(),
];
}
private static function photoConsistency(PDO $db, string $root): array
{
$expected = [];
$missing = 0;
try {
foreach ($db->query('SELECT animal_id,filename FROM animal_photos')->fetchAll(PDO::FETCH_ASSOC) as $row) {
$path =
$root .
'/public/media/animals/' .
(int) $row['animal_id'] .
'/' .
basename((string) $row['filename']);
$expected[$path] = true;
if (!is_file($path)) {
$missing++;
}
}
} catch (Throwable) {
return [0, 0];
}
$orphans = 0;
foreach (glob($root . '/public/media/animals/*/*') ?: [] as $file) {
if (is_file($file) && !isset($expected[$file]) && !str_starts_with(basename($file), '.')) {
$orphans++;
}
}
return [$missing, $orphans];
}
private static function check(string $status, string $group, string $title, string $detail): array
{
return compact('status', 'group', 'title', 'detail');
}
private static function recentLogLines(string $path, int $days): int
{
if (!is_file($path) || filesize($path) === 0) {
return 0;
}
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
$dates = [];
for ($i = 0; $i < $days; $i++) {
$dates[] = new DateTimeImmutable("-$i days")->format('d-M-Y');
}
$count = 0;
foreach (array_slice($lines, -1000) as $line) {
foreach ($dates as $date) {
if (str_contains($line, $date)) {
$count++;
break;
}
}
}
return $count;
}
private static function bytes(int $bytes): string
{
foreach (['o', 'Ko', 'Mo', 'Go', 'To'] as $unit) {
if ($bytes < 1024 || $unit === 'To') {
return number_format($bytes, $unit === 'o' ? 0 : 1, ',', ' ') . ' ' . $unit;
}
$bytes = (int) round($bytes / 1024);
}
return '';
}
}

View file

@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
final class TrustedDeviceService
{
private const COOKIE = 'globinours_device';
public static function create(int $userId, string $name = ''): void
{
$selector = bin2hex(random_bytes(9));
$token = random_bytes(32);
$expires = time() + 60 * 60 * 24 * 90;
$name = trim($name) ?: self::deviceLabel();
DB::pdo()
->prepare(
'INSERT INTO trusted_devices(user_id,selector,token_hash,device_name,user_agent,ip_address,expires_at) VALUES(?,?,?,?,?,?,?)',
)
->execute([
$userId,
$selector,
hash('sha256', $token),
mb_substr($name, 0, 100),
mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 500) ?: null,
$_SERVER['REMOTE_ADDR'] ?? null,
date('Y-m-d H:i:s', $expires),
]);
self::cookie($selector . '.' . bin2hex($token), $expires);
}
public static function authenticate(): ?int
{
$raw = (string) ($_COOKIE[self::COOKIE] ?? '');
if (!preg_match('/^([a-f0-9]{18})\.([a-f0-9]{64})$/', $raw, $m)) {
return null;
}
try {
$s = DB::pdo()->prepare(
"SELECT td.*,u.active FROM trusted_devices td JOIN users u ON u.id=td.user_id WHERE td.selector=? AND td.revoked_at IS NULL AND datetime(td.expires_at)>datetime('now') AND (td.last_used_at IS NULL OR datetime(td.last_used_at)>datetime('now','-30 days'))",
);
$s->execute([$m[1]]);
$row = $s->fetch(PDO::FETCH_ASSOC);
if (
!$row ||
!hash_equals((string) $row['token_hash'], hash('sha256', hex2bin($m[2]) ?: '')) ||
(int) $row['active'] !== 1
) {
self::forget();
return null;
}
$new = random_bytes(32);
DB::pdo()
->prepare(
"UPDATE trusted_devices SET token_hash=?,last_used_at=datetime('now'),ip_address=? WHERE id=?",
)
->execute([hash('sha256', $new), $_SERVER['REMOTE_ADDR'] ?? null, $row['id']]);
self::cookie($m[1] . '.' . bin2hex($new), strtotime((string) $row['expires_at']));
return (int) $row['user_id'];
} catch (Throwable) {
return null;
}
}
public static function currentSelector(): ?string
{
$raw = (string) ($_COOKIE[self::COOKIE] ?? '');
return preg_match('/^([a-f0-9]{18})\./', $raw, $m) ? $m[1] : null;
}
public static function revokeCurrent(): void
{
$selector = self::currentSelector();
if ($selector) {
try {
DB::pdo()
->prepare("UPDATE trusted_devices SET revoked_at=datetime('now') WHERE selector=?")
->execute([$selector]);
} catch (Throwable) {
}
}
self::forget();
}
public static function devices(?int $userId = null): array
{
$userId ??= Auth::id();
$s = DB::pdo()->prepare(
'SELECT * FROM trusted_devices WHERE user_id=? ORDER BY revoked_at IS NULL DESC,last_used_at DESC',
);
$s->execute([$userId]);
return $s->fetchAll(PDO::FETCH_ASSOC);
}
public static function revoke(int $id, int $userId, bool $admin = false): void
{
$sql =
'UPDATE trusted_devices SET revoked_at=datetime(\'now\') WHERE id=:id' .
($admin ? '' : ' AND user_id=:uid');
$params = [':id' => $id];
if (!$admin) {
$params[':uid'] = $userId;
}
DB::pdo()->prepare($sql)->execute($params);
if (
self::currentSelector() &&
self::currentSelector() ===
(string) DB::pdo()
->query('SELECT selector FROM trusted_devices WHERE id=' . (int) $id)
->fetchColumn()
) {
self::forget();
}
}
private static function deviceLabel(): string
{
$ua = strtolower((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''));
if (str_contains($ua, 'globinoursandroid')) {
return 'Application Android';
}
if (str_contains($ua, 'android')) {
return 'Smartphone ou tablette Android';
}
if (str_contains($ua, 'iphone') || str_contains($ua, 'ipad')) {
return 'iPhone ou iPad';
}
return 'Navigateur web';
}
private static function cookie(string $value, int $expires): void
{
setcookie(self::COOKIE, $value, [
'expires' => $expires,
'path' => '/',
'secure' => Auth::isSecureRequest(),
'httponly' => true,
'samesite' => 'Strict',
]);
$_COOKIE[self::COOKIE] = $value;
}
private static function forget(): void
{
setcookie(self::COOKIE, '', [
'expires' => time() - 3600,
'path' => '/',
'secure' => Auth::isSecureRequest(),
'httponly' => true,
'samesite' => 'Strict',
]);
unset($_COOKIE[self::COOKIE]);
}
}

View file

@ -0,0 +1,82 @@
<?php
// Sous-vue de la fiche animale ; les variables sont préparées par AnimalsController::show().
?>
<!-- ACTIVITÉ -->
<div class="tab-pane" id="tab-activity">
<?php /** @var array $activity */ ?>
<?php if (empty($activity)): ?>
<div class="text-muted"><?= h(t('animal.no_activity')) ?></div>
<?php else: ?>
<?php
$actMeta = [
'history' => ['label' => t('animal.system'), 'iconBg' => 'bg-blue', 'icon' => 'history', 'badge' => 'bg-blue-lt'],
'medical' => ['label' => t('animal.tab.medical'), 'iconBg' => 'bg-red', 'icon' => 'heart', 'badge' => 'bg-red-lt'],
'vaccine' => ['label' => t('animal.vaccine'), 'iconBg' => 'bg-yellow', 'icon' => 'shield-check', 'badge' => 'bg-yellow-lt'],
'deworming' => ['label' => t('animals.dewormer'), 'iconBg' => 'bg-lime', 'icon' => 'pill', 'badge' => 'bg-lime-lt'],
'treatment' => ['label' => t('animal.treatment'), 'iconBg' => 'bg-orange', 'icon' => 'pill', 'badge' => 'bg-orange-lt'],
'weight' => ['label' => t('common.weight'), 'iconBg' => 'bg-indigo', 'icon' => 'scale', 'badge' => 'bg-indigo-lt'],
'care_round' => ['label' => t('animal.care_round'), 'iconBg' => 'bg-red', 'icon' => 'alert-triangle','badge' => 'bg-red-lt'],
'death' => ['label' => t('status.decede'), 'iconBg' => 'bg-dark', 'icon' => 'rainbow', 'badge' => 'bg-dark-lt'],
];
?>
<ul class="list list-timeline" style="list-style-type:none;">
<?php foreach ($activity as $a):
$kind = (string)($a['kind'] ?? 'history');
if (!$canViewMedical && in_array($kind, ['medical','vaccine','treatment','deworming','weight','care_round','death'], true)) {
continue;
}
$meta = $actMeta[$kind] ?? ['label' => ucfirst($kind), 'iconBg' => 'bg-muted', 'icon' => 'circle', 'badge' => 'bg-muted-lt'];
$lbl = (string)($a['label'] ?? '');
$details = (string)($a['details'] ?? '');
$at = (string)($a['at'] ?? '');
?>
<li>
<!-- icône timeline (gauche) -->
<div class="list-timeline-icon <?= h($meta['iconBg']) ?>">
<i class="ti ti-<?= h($meta['icon']) ?>"></i>
</div>
<div class="list-timeline-content">
<div class="d-flex align-items-start justify-content-between">
<div class="d-flex align-items-center gap-3 flex-grow-1">
<!-- BADGE FIXE aligné -->
<div style="min-width:110px">
<span class="badge <?= h($meta['badge']) ?>">
<?= strtoupper(h($meta['label'])) ?>
</span>
</div>
<!-- TITRE -->
<div class="fw-bold">
<?= h($lbl) ?>
</div>
</div>
<!-- DATE -->
<div class="text-muted small text-end" style="min-width:140px">
<?= h($at) ?>
</div>
</div>
<?php //if ($details !== ''):?>
<!-- <div class="mt-2 ms-3"> -->
<?php //echo nl2br(h($details));?>
<!-- </div>-->
<?php //endif;?>
</div>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>

View file

@ -0,0 +1,48 @@
<?php
// Sous-vue de la fiche animale ; les variables sont préparées par AnimalsController::show().
?>
<!-- DOCUMENTS -->
<?php if ($canViewMedical): ?>
<div class="tab-pane" id="tab-documents">
<div class="card card-sm">
<div class="card-header">
<div>
<h3 class="card-title mb-1"><?= h(t('animal.adoption_contract')) ?></h3>
<div class="text-muted small"><?= h(t('animal.generated_copy_help')) ?></div>
</div>
</div>
<div class="card-body">
<div class="row g-3 align-items-center">
<div class="col-md">
<div class="fw-bold"><?= h(t('animal.prefilled_information')) ?></div>
<div class="text-muted small"><?= h(t('animal.adoption_prefill')) ?></div>
<?php if (!empty($adoption)): ?><div class="text-success small mt-1">✓
<?= h(t('animal.linked_adopter', ['name' => $adoption['adopter_name'] ?? ''])) ?></div>
<?php else: ?><div class="text-warning small mt-1">⚠ <?= h(t('animal.no_linked_adopter')) ?></div>
<?php endif; ?>
</div>
<div class="col-md-auto d-flex flex-wrap gap-2"><a class="btn btn-outline-primary"
href="/animal/documents/adoption?animal_id=<?= (int)$animal['id'] ?>&format=odt"><?= h(t('animal.download_odt')) ?></a><a
class="btn btn-danger"
href="/animal/documents/adoption?animal_id=<?= (int)$animal['id'] ?>&format=pdf"
target="_blank"><?= h(t('animal.view_print_pdf')) ?></a></div>
</div>
</div>
</div>
<div class="card card-sm mt-3">
<div class="card-header">
<div>
<h3 class="card-title mb-1"><?= h(t('animal.abandon_found')) ?></h3>
<div class="text-muted small"><?= h(t('animal.known_info_added')) ?></div>
</div>
</div>
<div class="card-body d-flex flex-wrap align-items-center gap-2">
<div class="me-auto text-muted small"><?= h(t('animal.identity_summary')) ?></div><a
class="btn btn-outline-primary"
href="/animal/documents/abandon?animal_id=<?= (int)$animal['id'] ?>&format=odt"><?= h(t('animal.download_odt')) ?></a><a
class="btn btn-danger" href="/animal/documents/abandon?animal_id=<?= (int)$animal['id'] ?>&format=pdf"
target="_blank"><?= h(t('animal.view_print_pdf')) ?></a>
</div>
</div>
</div>
<?php endif; ?>

View file

@ -0,0 +1,31 @@
<?php
// Sous-vue de la fiche animale ; les variables sont préparées par AnimalsController::show().
?>
<!-- HISTORIQUE -->
<div class="tab-pane" id="tab-hist">
<?php $visibleHistory = $canViewMedical ? $history : array_values(array_filter($history, static fn (array $row): bool => !in_array((string)($row['type'] ?? ''), ['medical','treatment','vaccine','deworming','death'], true))); ?>
<?php if (empty($visibleHistory)): ?>
<div class="text-muted"><?= h(t('animal.no_history_event')) ?></div>
<?php else: ?>
<ul class="list list-timeline">
<?php foreach ($visibleHistory as $h):
$style = $historyStyles[$h['type']] ?? ['bg' => 'bg-muted','icon' => 'circle'];
?>
<li>
<div class="list-timeline-icon <?= $style['bg'] ?>">
<i class="ti ti-<?= $style['icon'] ?>"></i>
</div>
<div class="list-timeline-content">
<div class="fw-bold"><?= h($h['label']) ?></div>
<div class="text-muted small"><?= h($h['created_at']) ?></div>
<?php if (!empty($h['actor_name'])): ?><div class="text-muted small">
<?= h(t('common.by', ['name' => $h['actor_name']])) ?></div><?php endif; ?>
<?php if (!empty($h['details'])): ?>
<div class="mt-1"><?= nl2br(h($h['details'])) ?></div>
<?php endif; ?>
</div>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>

View file

@ -0,0 +1,459 @@
<?php
// Sous-vue de la fiche animale ; les variables sont préparées par AnimalsController::show().
?>
<!-- IDENTITÉ -->
<div class="tab-pane active show" id="tab-ident">
<?php if ($canViewMedical && !empty($death)): ?>
<?php
$deathCauseLabels = array_combine(array_keys(DeathController::CAUSES), array_map(static fn ($key) => DeathController::causeLabel($key), array_keys(DeathController::CAUSES)));
$deathPlaceLabels = array_combine(array_keys(DeathController::PLACES), array_map(static fn ($key) => DeathController::placeLabel($key), array_keys(DeathController::PLACES)));
$deathDispositionLabels = array_combine(array_keys(DeathController::DISPOSITIONS), array_map(static fn ($key) => DeathController::dispositionLabel($key), array_keys(DeathController::DISPOSITIONS)));
$deathDate = DateTimeImmutable::createFromFormat('Y-m-d', (string)$death['deceased_date']);
$deathDateLabel = !empty($death['death_year']) ? t('animal.year', ['year' => (int)$death['death_year']]) : ($deathDate ? $deathDate->format('d/m/Y') : t('animal.date_unknown'));
?>
<div class="card border-dark mb-3 overflow-hidden">
<div class="card-status-start bg-dark"></div>
<div class="card-header d-flex align-items-center justify-content-between">
<div>
<h3 class="card-title mb-1">🌈 <?= h(t('animal.death_recorded')) ?></h3>
<div class="text-muted small"><?= h($deathDateLabel) ?> ·
<?= h($deathCauseLabels[$death['cause_code']] ?? $death['cause_code']) ?></div>
</div><a class="btn btn-outline-dark btn-sm"
href="/animal/death?id=<?= (int)$animal['id'] ?>"><?= h(t('common.edit')) ?></a>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-3">
<div class="subheader"><?= h(t('animal.statistics')) ?></div><span
class="badge <?= !empty($death['occurred_in_care']) ? 'bg-dark' : 'bg-secondary-lt' ?>"><?= h(t(!empty($death['occurred_in_care']) ? 'animal.in_shelter_figures' : 'animal.outside_care')) ?></span>
</div>
<div class="col-md-3">
<div class="subheader"><?= h(t('death.place')) ?></div>
<strong><?= h($deathPlaceLabels[$death['place_type']] ?? $death['place_type']) ?></strong><?php if ($death['place_details']): ?>
<div class="small text-muted"><?= h($death['place_details']) ?></div><?php endif; ?>
</div>
<div class="col-md-3">
<div class="subheader"><?= h(t('death.care_body')) ?></div>
<strong><?= h($deathDispositionLabels[$death['body_disposition']] ?? $death['body_disposition']) ?></strong><?php if ($death['crematorium_name']): ?>
<div class="small text-muted"><?= h($death['crematorium_name']) ?></div><?php endif; ?>
</div>
<div class="col-md-3">
<div class="subheader"><?= h(t('animal.context')) ?></div>
<?php if (!empty($death['euthanized'])): ?><span
class="badge bg-purple-lt"><?= h(t('animal.euthanasia')) ?></span><?php else: ?><span
class="text-muted"><?= h(t('animal.natural_death_short')) ?></span><?php endif; ?><?php if ($death['veterinarian_name']): ?>
<div class="small text-muted"><?= h($death['veterinarian_name']) ?></div><?php endif; ?>
</div>
<?php if ($death['cause_details'] || $death['notes']): ?><div class="col-12 small">
<?php if ($death['cause_details']): ?><strong><?= h(t('animal.cause')) ?> :</strong>
<?= nl2br(h($death['cause_details'])) ?><?php endif; ?><?php if ($death['notes']): ?><div
class="mt-1"><strong><?= h(t('common.notes')) ?> :</strong> <?= nl2br(h($death['notes'])) ?>
</div><?php endif; ?></div><?php endif; ?>
<?php if (!empty($death['death_age_value'])): ?><div class="col-12 small">
<strong><?= h(t('animal.death_age')) ?></strong> <?= (int)$death['death_age_value'] ?> <?= h(match($death['death_age_unit']) {
'day' => t('animal.age_days'),'week' => t('animal.age_weeks'),'month' => t('animal.age_months'),'year' => t('animal.age_years'),default => ''
}) ?></div><?php endif; ?>
</div>
</div>
</div>
<?php endif; ?>
<?php if (!empty($bondedGroup)): ?>
<div class="card border-purple mb-3">
<div class="card-status-start bg-purple"></div>
<div class="card-header d-flex align-items-center justify-content-between">
<div>
<h3 class="card-title mb-1">🔗 <?= h($bondedGroup['name'] ?: t('animal.bonded_cats')) ?></h3>
<div class="text-muted small"><?= h(t('animal.bonded_help')) ?></div>
</div>
<form method="post" action="/bonded/dissolve"
onsubmit="return confirm(<?= h(json_encode(t('animal.bonded_dissolve_confirm'), JSON_HEX_APOS | JSON_HEX_QUOT)) ?>);">
<input type="hidden" name="group_id" value="<?= (int)$bondedGroup['id'] ?>">
<input type="hidden" name="animal_id" value="<?= (int)$animal['id'] ?>">
<button class="btn btn-outline-secondary btn-sm"
type="submit"><?= h(t('animal.dissolve_group')) ?></button>
</form>
</div>
<div class="card-body">
<?php if (!empty($bondedGroup['notes'])): ?><p class="text-muted mb-3">
<?= nl2br(h($bondedGroup['notes'])) ?></p><?php endif; ?>
<div class="d-flex flex-wrap gap-2">
<?php foreach ($bondedMembers as $member): ?>
<a class="btn <?= (int)$member['id'] === (int)$animal['id'] ? 'btn-purple' : 'btn-outline-purple' ?>"
href="/animal?id=<?= (int)$member['id'] ?>">
<span class="coat-swatch me-1"
style="<?= h(coat_swatch_style($member['color'] ?? '')) ?>"></span><?= h($member['name']) ?>
</a>
<?php endforeach; ?>
</div>
</div>
</div>
<?php endif; ?>
<?php
$lifeProfileMeta = [
'yes' => [t('common.yes'),'bg-green-lt text-green','✓'],
'no' => [t('common.no'),'bg-red-lt text-red','✕'],
'unknown' => [t('common.unknown'),'bg-secondary-lt text-secondary','—'],
];
?>
<div class="card mt-3">
<div class="card-header">
<div>
<h3 class="card-title mb-1"><?= h(t('animal.form.intake')) ?></h3>
<div class="text-muted small"><?= h(t('animal.intake_origin_help')) ?></div>
</div>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-6 col-lg-3">
<div class="subheader"><?= h(t('common.date')) ?></div>
<strong><?= h(!empty($animal['intake_date']) ? date('d/m/Y', strtotime((string)$animal['intake_date'])) : '—') ?></strong>
</div>
<div class="col-6 col-lg-3">
<div class="subheader"><?= h(t('animal.intake_mode')) ?></div>
<strong><?= h($intakeTypes[$animal['intake_type'] ?? 'unknown'] ?? t('common.not_provided')) ?></strong>
</div>
<div class="col-6 col-lg-3">
<div class="subheader"><?= h(t('animal.reason')) ?></div>
<strong><?= h($intakeReasons[$animal['intake_reason'] ?? 'unknown'] ?? t('common.not_provided')) ?></strong>
</div>
<div class="col-6 col-lg-3">
<div class="subheader"><?= h(t('animal.origin_city')) ?></div>
<strong><?= h(($animal['rescue_location_name'] ?? '') ?: '—') ?></strong>
</div>
<div class="col-12 col-lg-6">
<div class="subheader"><?= h(t('animal.form.depositor')) ?></div><?php if ($depositor): ?><a
class="fw-bold"
href="/directory/edit?id=<?= (int)$depositor['id'] ?>"><?= h($depositor['name']) ?></a><?php if (!empty($depositor['city'])): ?><span
class="text-muted"> · <?= h($depositor['city']) ?></span><?php endif; ?><?php else: ?><span
class="text-muted"><?= h(t('animal.form.no_depositor')) ?></span><?php endif; ?>
</div>
<?php if (!empty($animal['intake_circumstances'])): ?><div class="col-12">
<div class="subheader"><?= h(t('animal.form.circumstances')) ?></div>
<div><?= nl2br(h($animal['intake_circumstances'])) ?></div>
</div><?php endif; ?>
</div>
</div>
</div>
<div class="card mt-3">
<div class="card-header">
<div>
<h3 class="card-title mb-1"><?= h(t('animal.form.life_profile')) ?></h3>
<div class="text-muted small"><?= h(t('animal.life_profile_help')) ?></div>
</div>
</div>
<div class="card-body">
<div class="row g-3">
<?php foreach ([
'compatibility_dogs' => ['🐶',t('animals.dogs')],
'compatibility_cats' => ['🐱',t('animals.cats')],
'compatibility_children' => ['🧒',t('animals.children')],
'house_trained' => ['🧼',t('animals.cleanliness')],
] as $field => $profile): [$profileLabel,$profileClass,$profileSymbol] = $lifeProfileMeta[$animal[$field] ?? 'unknown'] ?? $lifeProfileMeta['unknown']; ?>
<div class="col-6 col-lg-3">
<div class="border rounded p-3 h-100 d-flex align-items-center gap-3"><span class="fs-2"
aria-hidden="true"><?= $profile[0] ?></span>
<div>
<div class="text-muted small"><?= h($profile[1]) ?></div><span
class="badge <?= h($profileClass) ?>"><?= h($profileSymbol . ' ' . $profileLabel) ?></span>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<?php require __DIR__ . '/_expense_summary.php'; ?>
<div class="card mt-3">
<div class="card-header">
<h3 class="card-title m-0"><?= h(t('common.notes')) ?></h3>
</div>
<div class="card-body"><?= $animal['notes'] ? nl2br(h($animal['notes'])) : '—' ?></div>
</div>
<?php
$rescueAddr = trim((string)($animal['rescue_address'] ?? ''));
$rescueLat = isset($animal['rescue_lat']) && $animal['rescue_lat'] !== '' ? (float)$animal['rescue_lat'] : null;
$rescueLng = isset($animal['rescue_lng']) && $animal['rescue_lng'] !== '' ? (float)$animal['rescue_lng'] : null;
$currentAddr = trim((string)($animal['current_address'] ?? '5 RUE NOTRE DAME 29260 LESNEVEN'));
$currentLat = isset($animal['current_lat']) && $animal['current_lat'] !== '' ? (float)$animal['current_lat'] : null;
$currentLng = isset($animal['current_lng']) && $animal['current_lng'] !== '' ? (float)$animal['current_lng'] : null;
$hasRescue = ($rescueLat !== null && $rescueLng !== null);
$hasCurrent = ($currentLat !== null && $currentLng !== null);
?>
<div class="card mt-3">
<div class="card-header d-flex align-items-center justify-content-between">
<h3 class="card-title m-0"><?= h(t('animal.location')) ?></h3>
<div class="d-flex gap-2">
<span class="badge bg-azure-lt"><?= h(t('animal.rescue')) ?></span>
<span class="badge bg-lime-lt"><?= h(t('animal.current')) ?></span>
</div>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<div class="text-muted mb-1"><?= h(t('animal.rescue_place')) ?></div>
<div class="fw-bold"><?= $rescueAddr !== '' ? h($rescueAddr) : '—' ?></div>
<?php if (!$hasRescue): ?>
<div class="text-muted small mt-1"><?= h(t('animal.coordinates_missing')) ?></div>
<?php endif; ?>
</div>
<div class="col-md-6">
<div class="text-muted mb-1"><?= h(t('animal.form.current_location')) ?></div>
<div class="fw-bold"><?= h($currentAddr) ?></div>
<?php if (!$hasCurrent): ?>
<div class="text-muted small mt-1"><?= h(t('animal.coordinates_missing')) ?></div>
<?php endif; ?>
</div>
</div>
<?php if ($animal['status'] === 'adopte'): ?>
<div class="alert alert-success mt-3">
<strong><?= h(t('animal.adopted_by')) ?> :</strong><br>
<?= htmlspecialchars($adoption['adopter_name'] . ' - ' . $adoption['adopter_address'] . ' ' . $adoption['adopter_postal_code'] . ' ' . $adoption['adopter_city'] . ' - (' . $adoption['adopter_phone'] . ' - ' . $adoption['adopter_email'] . ')') ?>
</div>
<?php endif; ?>
<div class="mt-3" id="mapJourney"
style="height:320px;border-radius:10px;overflow:hidden; border:1px solid #ccc"></div>
<?php
function haversine_km($lat1, $lon1, $lat2, $lon2)
{
$earthRadius = 6371; // km
$dLat = deg2rad($lat2 - $lat1);
$dLon = deg2rad($lon2 - $lon1);
$a = sin($dLat / 2) * sin($dLat / 2) +
cos(deg2rad($lat1)) * cos(deg2rad($lat2)) *
sin($dLon / 2) * sin($dLon / 2);
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
return $earthRadius * $c;
}
$distanceKm = null;
if ($hasRescue && $hasCurrent) {
$distanceKm = haversine_km(
$rescueLat,
$rescueLng,
$currentLat,
$currentLng,
);
}
?>
<?php if ($distanceKm !== null): ?>
<div class="mt-3 text-muted small">
<?= h(t('animal.distance_story', ['name' => $animal['name'],'distance' => number_format($distanceKm, 2)])) ?><br>
<em><?= h(t('animal.distance_caption')) ?></em>
</div>
<?php endif; ?>
</div>
</div>
<?php
/** @var array|null $icad Structure = ['cached'=>..., 'stale'=>bool] comme ton endpoint */
$cache = $icad['cached'] ?? null;
$data = $cache['data'] ?? null;
$status = $cache['status'] ?? 'empty';
$fetched = $cache['fetched_at'] ?? '';
$icadUrl = $data['icad_url'] ?? null;
$badge = match ($status) {
'ok' => 'bg-success-lt',
'error' => 'bg-danger-lt',
default => 'bg-muted-lt',
};
$label = match ($status) {
'ok' => 'i-CAD : OK',
'error' => 'i-CAD : Erreur',
default => t('animal.icad_not_checked'),
};
$err = (string)($cache['error'] ?? '');
?>
<div class="card mt-3">
<div class="card-header d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center gap-2">
<span class="badge <?= $badge ?>"><?= h($label) ?></span>
<?php if ($fetched): ?>
<span class="text-muted small"><?= h(t('animal.last_sync', ['date' => $fetched])) ?></span>
<?php endif; ?>
</div>
<div class="d-flex gap-2">
<?php if ($icadUrl): ?>
<a class="btn btn-sm btn-outline-primary" href="<?= h($icadUrl) ?>" target="_blank" rel="noopener">
<?= h(t('animal.open_icad')) ?>
</a>
<?php endif; ?>
<button class="btn btn-sm btn-outline-secondary" type="button" id="btn-icad-refresh"
data-animal-id="<?= (int)$animal['id'] ?>">
<?= h(t('common.refresh')) ?>
</button>
</div>
</div>
<div class="card-body" id="icad-card-body">
<?php if ($status === 'error'): ?>
<div class="alert alert-danger">
<strong><?= h(t('animal.icad_error')) ?></strong><br>
<?= h($err) ?>
</div>
<?php elseif ($status !== 'ok' || !$data): ?>
<div class="text-muted"><?= h(t('animal.no_icad')) ?></div>
<?php else: ?>
<div class="row g-3">
<div class="col-md-6">
<div class="datagrid">
<div class="datagrid-item">
<div class="datagrid-title"><?= h(t('animal.icad_name')) ?></div>
<div class="datagrid-content"><?= h($data['name'] ?? '') ?></div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">Puce</div>
<div class="datagrid-content"><?= h($data['insert'] ?? '') ?></div>
</div>
<div class="datagrid-item">
<div class="datagrid-title"><?= h(t('animal.sex')) ?></div>
<div class="datagrid-content"><?= h($data['sex'] ?? '') ?></div>
</div>
<div class="datagrid-item">
<div class="datagrid-title"><?= h(t('animal.birth')) ?></div>
<div class="datagrid-content"><?= h($data['birth_date'] ?? '') ?></div>
</div>
<div class="datagrid-item">
<div class="datagrid-title"><?= h(t('animal.appearance')) ?></div>
<div class="datagrid-content"><?= h($data['appearance'] ?? '') ?></div>
</div>
</div>
<?php
$holder =
$data['holder'] ??
$data['owner'] ??
$data['detenteur'] ??
null;
// Si ton parse aplatit au lieu d'imbriquer :
if (!$holder && (
isset($data['holder_name']) || isset($data['holder_phone']) || isset($data['holder_email'])
)) {
$holder = [
'name' => $data['holder_name'] ?? '',
'phone' => $data['holder_phone'] ?? '',
'email' => $data['holder_email'] ?? '',
'type' => $data['holder_type'] ?? '',
'address' => $data['holder_address'] ?? '',
];
}
?>
<?php if (is_array($holder) && array_filter($holder, fn ($v) => trim((string)$v) !== '')): ?>
<hr class="my-3">
<div class="row g-3">
<div class="col-md-12">
<div class="d-flex align-items-center justify-content-between mb-2">
<div class="fw-bold"><?= h(t('animal.holder')) ?></div>
<span class="text-muted small"><span
class="badge bg-danger-lt"><?= h(t('animal.do_not_share')) ?></span></span>
</div>
<div class="datagrid">
<?php if (!empty($holder['type'])): ?>
<div class="datagrid-item">
<div class="datagrid-title">Type</div>
<div class="datagrid-content"><?= h($holder['type']) ?></div>
</div>
<?php endif; ?>
<?php if (!empty($holder['name'])): ?>
<div class="datagrid-item">
<div class="datagrid-title"><?= h(t('common.name')) ?></div>
<div class="datagrid-content"><?= h($holder['name']) ?></div>
</div>
<?php endif; ?>
<?php if (!empty($holder['phone'])): ?>
<div class="datagrid-item">
<div class="datagrid-title"><?= h(t('common.phone')) ?></div>
<div class="datagrid-content"><?= h($holder['phone']) ?></div>
</div>
<?php endif; ?>
<?php if (!empty($holder['email'])): ?>
<div class="datagrid-item">
<div class="datagrid-title"><?= h(t('common.email')) ?></div>
<div class="datagrid-content"><?= h($holder['email']) ?></div>
</div>
<?php endif; ?>
<?php if (!empty($holder['address'])): ?>
<div class="datagrid-item" style="grid-column: 1 / -1;">
<div class="datagrid-title"><?= h(t('common.address')) ?></div>
<div class="datagrid-content"><?= nl2br(h($holder['address'])) ?></div>
</div>
<?php endif; ?>
</div>
</div>
</div>
<?php endif; ?>
</div>
<div class="col-md-6">
<div class="text-muted mb-2"><?= h(t('animal.icad_history')) ?></div>
<?php $events = $data['events'] ?? []; ?>
<?php if (empty($events)): ?>
<div class="text-muted"><?= h(t('animal.no_event')) ?></div>
<?php else: ?>
<ul class="list list-timeline">
<?php foreach ($events as $e): ?>
<li>
<div class="list-timeline-icon bg-blue-lt">
<i class="ti ti-history"></i>
</div>
<div class="list-timeline-content">
<div class="fw-bold"><?= h($e['label'] ?? '') ?></div>
<div class="text-muted small">
<?= h(t('common.date')) ?> : <?= h($e['date'] ?? '') ?> —
<?= h(t('animal.recorded')) ?> : <?= h($e['recorded_at'] ?? '') ?>
</div>
</div>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
</div>
</div>
</div>

View file

@ -0,0 +1,68 @@
<?php
// Sous-vue de la fiche animale ; les variables sont préparées par AnimalsController::show().
?>
<!-- PORTÉES -->
<?php if ($isCat): ?><div class="tab-pane" id="tab-litters">
<div class="d-flex align-items-center justify-content-between mb-3">
<div>
<h3 class="mb-0"><?= h(t('animal.litters_parentage')) ?></h3>
<div class="text-muted"><?= h(t('animal.litters_help')) ?></div>
</div>
<a class="btn btn-primary" href="/litter/new?animal_id=<?= (int)$animal['id'] ?>">+
<?= h(t('animal.add_litter')) ?></a>
</div>
<?php if (empty($litters)): ?>
<div class="empty">
<div class="empty-description"><?= h(t('animal.no_litter')) ?></div>
</div>
<?php else: ?>
<div class="row row-cards">
<?php foreach ($litters as $litter): ?>
<?php
$litterDateLabel = t('animal.date_unknown');
if (!empty($litter['birth_date'])) {
$litterDate = DateTimeImmutable::createFromFormat('Y-m-d', (string)$litter['birth_date']);
if ($litterDate) {
$litterDateLabel = $litterDate->format('d/m/Y');
}
}
?>
<div class="col-12">
<div class="card card-sm">
<div class="card-header">
<div>
<h4 class="card-title mb-1"><?= h(t('animal.litter_on', ['date' => $litterDateLabel])) ?></h4>
<div class="text-muted small">
<?= h(t('animal.mother')) ?> :
<?= $litter['mother_id'] ? '<a href="/animal?id=' . (int)$litter['mother_id'] . '">' . h($litter['mother_name']) . '</a>' : h(t('animal.unknown_female')) ?>
· <?= h(t('animal.father')) ?> :
<?= $litter['father_id'] ? '<a href="/animal?id=' . (int)$litter['father_id'] . '">' . h($litter['father_name']) . '</a>' : h(t('animal.unknown_male')) ?>
</div>
</div>
<a class="btn btn-outline-primary btn-sm ms-auto"
href="/litter/add-existing?litter_id=<?= (int)$litter['id'] ?>&amp;animal_id=<?= (int)$animal['id'] ?>">+
<?= h(t('animal.add_existing_cat')) ?></a>
</div>
<div class="list-group list-group-flush">
<?php foreach ($litter['kittens'] as $index => $kitten): ?>
<a class="list-group-item list-group-item-action d-flex align-items-center gap-2"
href="/animal?id=<?= (int)$kitten['id'] ?>">
<span
class="text-muted font-monospace"><?= $index === count($litter['kittens']) - 1 ? '└' : '├' ?></span>
<span class="coat-swatch" style="<?= h(coat_swatch_style($kitten['color'] ?? '')) ?>"></span>
<strong><?= h($kitten['name']) ?></strong>
<?php if (!empty($kitten['archived_at'])): ?><span
class="badge bg-secondary-lt"><?= h(t('animal.archived')) ?></span><?php endif; ?>
<span
class="text-muted"><?= h(($kitten['sex'] ?? 'U') === 'F' ? t('sex.female') : (($kitten['sex'] ?? 'U') === 'M' ? t('sex.male') : t('animal.sex_define'))) ?></span>
<?php [$kittenStatusClass, $kittenStatusLabel] = status_badge((string)$kitten['status']); ?>
<span class="badge <?= h($kittenStatusClass) ?> ms-auto"><?= h($kittenStatusLabel) ?></span>
</a>
<?php endforeach; ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div><?php endif; ?>

View file

@ -0,0 +1,71 @@
<?php
// Sous-vue de la fiche animale ; les variables sont préparées par AnimalsController::show().
?>
<!-- EMPLACEMENTS -->
<div class="tab-pane" id="tab-placements">
<div class="d-flex justify-content-between align-items-start gap-2 mb-3">
<div>
<h3 class="mb-1"><?= h(t('animal.placement_journey')) ?></h3>
<div class="text-muted"><?= h(t('animal.placement_journey_help')) ?></div>
</div><button class="btn btn-primary btn-sm" data-bs-toggle="modal"
data-bs-target="#modalPlacement"><?= h(t('animal.add_event')) ?></button>
</div>
<?php $placementLabels = ['reservation' => t('animal.reservation'),'foster' => t('animal.form.foster'),'adoption' => t('animal.final_adoption'),'return' => t('animal.return_shelter'),'cancellation' => t('animal.cancellation')];
$placementColors = ['reservation' => 'purple','foster' => 'cyan','adoption' => 'green','return' => 'orange','cancellation' => 'secondary']; ?>
<?php if (empty($placements)): ?><div class="text-muted"><?= h(t('animal.no_placement')) ?></div><?php else: ?><ul
class="list list-timeline"><?php foreach ($placements as $placement): ?><li>
<div class="list-timeline-icon bg-<?= h($placementColors[$placement['event_type']] ?? 'secondary') ?>"><i
class="ti ti-home"></i></div>
<div class="list-timeline-content">
<div class="d-flex justify-content-between gap-2">
<div><span
class="badge bg-<?= h($placementColors[$placement['event_type']] ?? 'secondary') ?>-lt"><?= h($placementLabels[$placement['event_type']] ?? $placement['event_type']) ?></span><?php if (!empty($placement['contact_name'])): ?>
<strong class="ms-2"><?= h($placement['contact_name']) ?></strong><?php endif; ?></div>
<div class="text-muted small"><?= h(date('d/m/Y', strtotime((string)$placement['event_date']))) ?>
</div>
</div><?php if (!empty($placement['reason'])): ?><div class="mt-1"><strong><?= h(t('animal.reason')) ?>
:</strong> <?= h($placement['reason']) ?></div>
<?php endif; ?><?php if (!empty($placement['notes'])): ?><div class="text-muted">
<?= nl2br(h($placement['notes'])) ?></div>
<?php endif; ?><?php if (!empty($placement['actor_name'])): ?><div class="text-muted small">
<?= h(t('common.by', ['name' => $placement['actor_name']])) ?></div><?php endif; ?>
</div>
</li><?php endforeach; ?></ul><?php endif; ?>
</div>
<div class="tab-pane" id="tab-locations">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-2 mb-3">
<div>
<h3 class="mb-1"><?= h(t('animal.location_history')) ?></h3>
<div class="text-muted"><?= h(t('animal.location_history_help')) ?></div>
</div>
<span class="badge bg-blue-lt"><?= h(tn('animal.step', 'animal.steps', count($locationHistory ?? []))) ?></span>
</div>
<?php if (empty($locationHistory)): ?>
<div class="text-muted"><?= h(t('animal.no_location')) ?></div>
<?php else: ?>
<ul class="list list-timeline">
<?php foreach ($locationHistory as $location):
$fromLabel = LocationHistoryService::label($location, 'from');
$toLabel = LocationHistoryService::label($location, 'to');
$isInitial = empty($location['from_status']) && empty($location['from_refuge_room']) && empty($location['from_care_box_key']) && empty($location['from_address']);
?>
<li>
<div class="list-timeline-icon bg-blue"><i class="ti ti-map-pin"></i></div>
<div class="list-timeline-content">
<div class="d-flex flex-wrap justify-content-between gap-2">
<div class="fw-bold">
<?= $isInitial ? h($toLabel) : h($fromLabel) . ' <span class="text-muted mx-1">→</span> ' . h($toLabel) ?>
</div>
<div class="text-muted small"><?= h($location['moved_at']) ?></div>
</div>
<?php if (!empty($location['reason'])): ?><div class="text-muted mt-1"><?= h($location['reason']) ?>
</div><?php endif; ?>
<?php if (!empty($location['actor_name'])): ?><div class="text-muted small mt-1">
<?= h(t('common.by', ['name' => $location['actor_name']])) ?></div><?php endif; ?>
</div>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>

View file

@ -0,0 +1,436 @@
<?php
// Sous-vue de la fiche animale ; les variables sont préparées par AnimalsController::show().
?>
<!-- MÉDICAL -->
<?php if ($canViewMedical): ?>
<div class="tab-pane" id="tab-med">
<div class="card mt-3">
<div class="card-header d-flex align-items-center justify-content-between">
<div>
<h3 class="card-title m-0"><?= h(t('animal.medical_status_global')) ?></h3>
<div class="text-muted small"><?= h(t('animal.current_health_help')) ?></div>
</div>
<button class="btn btn-sm btn-outline-primary" type="button" data-bs-toggle="modal"
data-bs-target="#modalMedicalStatus"><?= h(t('common.edit')) ?></button>
</div>
<div class="card-body">
<div class="row g-3">
<?php if ($isCat): ?><div class="col-12 col-md-3">
<div class="subheader mb-1">FIV</div>
<span class="badge <?= $fivClass ?>"><?= h($fivLabel) ?></span>
</div>
<div class="col-12 col-md-3">
<div class="subheader mb-1">FeLV</div>
<span class="badge <?= $felvClass ?>"><?= h($felvLabel) ?></span>
</div><?php endif; ?>
<div class="col-12 <?= $isCat ? 'col-md-6' : '' ?>">
<div class="subheader mb-1"><?= h(t('animal.conditions_short')) ?></div>
<?php $activeConditions = array_values(array_filter($healthConditions ?? [], fn ($condition) => ($condition['status'] ?? '') === 'active')); ?>
<?php if (!$activeConditions): ?>
<span class="text-muted"><?= h(t('animal.no_active_condition')) ?></span>
<?php else: ?>
<div class="d-flex flex-wrap gap-2">
<?php foreach ($activeConditions as $condition): ?>
<span class="badge bg-red-lt"><?= h($condition['name']) ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
</div>
</div>
<div class="row g-3 mt-0">
<div class="col-12 col-xl-5">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<h3 class="card-title mb-0">📄 <?= h(t('medical_document.prescriptions')) ?></h3>
<div class="text-muted small"><?= h(t('medical_document.private_documents')) ?></div>
</div><button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal"
data-bs-target="#modalPrescription"><?= h(t('common.add')) ?></button>
</div>
<div class="card-body">
<?php if (empty($prescriptions)): ?><div class="text-muted">
<?= h(t('medical_document.no_prescription')) ?></div><?php else: ?><div class="divide-y">
<?php foreach ($prescriptions as $p): $statusKey = 'medical_document.status_' . $p['status']; ?>
<div class="py-2">
<div class="d-flex justify-content-between gap-2">
<div>
<strong><?= h((new DateTimeImmutable($p['prescribed_on']))->format('d/m/Y')) ?></strong>
<span
class="badge <?= $p['status'] === 'active' ? 'bg-green-lt' : 'bg-secondary-lt' ?>"><?= h(t($statusKey)) ?></span>
</div><a class="btn btn-sm btn-ghost-primary" target="_blank"
href="/animal/medical-document?kind=prescription&amp;id=<?= (int)$p['id'] ?>"><i
class="ti ti-file-search"></i> <?= h(t('common.open')) ?></a>
</div><?php if (!empty($p['clinic_name']) || !empty($p['veterinarian_name'])): ?><div
class="text-muted small">
<?= h(implode(' · ', array_filter([$p['clinic_name'],$p['veterinarian_name']]))) ?>
</div><?php endif; ?><?php if (!empty($prescriptionTreatments[$p['id']])): ?><div
class="mt-1 d-flex flex-wrap gap-1">
<?php foreach ($prescriptionTreatments[$p['id']] as $linked): ?><span
class="badge bg-orange-lt">💊
<?= h($linked['medication_name'] ?: t('animal.treatment')) ?></span><?php endforeach; ?>
</div><?php endif; ?><?php if (!empty($p['notes'])): ?><div class="small mt-1">
<?= nl2br(h($p['notes'])) ?></div><?php endif; ?>
</div><?php endforeach; ?></div><?php endif; ?>
</div>
</div>
</div>
<div class="col-12 col-xl-7">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<h3 class="card-title mb-0">🧪 <?= h(t('medical_document.lab_results')) ?></h3>
<div class="text-muted small"><?= h(t('medical_document.lab_help')) ?></div>
</div><button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal"
data-bs-target="#modalLabReport"><?= h(t('common.add')) ?></button>
</div>
<div class="card-body">
<?php if (empty($labReports)): ?><div class="text-muted"><?= h(t('medical_document.no_analysis')) ?>
</div><?php else: ?><div class="accordion" id="labReportsAccordion">
<?php foreach ($labReports as $i => $report): $results = $labResultsByReport[$report['id']] ?? []; ?>
<div class="accordion-item">
<h2 class="accordion-header"><button class="accordion-button <?= $i ? 'collapsed' : '' ?>"
type="button" data-bs-toggle="collapse"
data-bs-target="#lab-report-<?= (int)$report['id'] ?>"><span
class="me-2"><?= h((new DateTimeImmutable($report['sampled_on']))->format('d/m/Y')) ?></span><span
class="text-muted"><?= h($report['report_type'] ?: t('medical_document.analysis')) ?></span></button>
</h2>
<div id="lab-report-<?= (int)$report['id'] ?>"
class="accordion-collapse collapse <?= !$i ? 'show' : '' ?>"
data-bs-parent="#labReportsAccordion">
<div class="accordion-body"><?php if ($report['stored_name']): ?><a
class="btn btn-sm btn-outline-primary mb-2" target="_blank"
href="/animal/medical-document?kind=analysis&amp;id=<?= (int)$report['id'] ?>"><i
class="ti ti-paperclip"></i>
<?= h(t('medical_document.open_report')) ?></a><?php endif; ?><?php if ($results): ?>
<div class="table-responsive">
<table class="table table-sm table-vcenter mb-0">
<thead>
<tr>
<th><?= h(t('medical_document.parameter')) ?></th>
<th><?= h(t('medical_document.value')) ?></th>
<th><?= h(t('medical_document.reference')) ?></th>
</tr>
</thead>
<tbody><?php foreach ($results as $result): $hasReference = $result['reference_min'] !== null || $result['reference_max'] !== null;
$low = $result['reference_min'] !== null && (float)$result['value'] < (float)$result['reference_min'];
$high = $result['reference_max'] !== null && (float)$result['value'] > (float)$result['reference_max']; ?><tr>
<td><?= h($result['parameter_name']) ?></td>
<td><strong
class="<?= !$hasReference ? '' : ($low || $high ? 'text-danger' : 'text-success') ?>"><?= h((string)$result['value'] . ' ' . ($result['unit'] ?? '')) ?></strong>
</td>
<td><?= $hasReference ? h(($result['reference_min'] ?? '…') . ' – ' . ($result['reference_max'] ?? '…') . ' ' . ($result['unit'] ?? '')) : '—' ?>
</td>
</tr><?php endforeach; ?></tbody>
</table>
</div><?php endif; ?><div class="text-muted small mt-2">
<?= h(implode(' · ', array_filter([$report['laboratory_name'],$report['clinic_name'],$report['veterinarian_name']]))) ?>
</div><?php if ($report['notes']): ?><div class="small mt-1">
<?= nl2br(h($report['notes'])) ?></div><?php endif; ?></div>
</div>
</div><?php endforeach; ?></div><?php endif; ?>
</div>
</div>
</div>
</div>
<?php if (!empty($labSeries)): ?><div class="card mt-3">
<div class="card-header">
<div>
<h3 class="card-title mb-0"><?= h(t('medical_document.evolution')) ?></h3>
<div class="text-muted small"><?= h(t('medical_document.graph_help')) ?></div>
</div>
</div>
<div class="card-body">
<div class="row g-3"><?php foreach (array_values($labSeries) as $i => $series): ?><div
class="col-12 col-xl-6">
<div class="border rounded p-3">
<h4 class="mb-2">
<?= h($series['name']) ?><?= $series['unit'] ? ' <span class="text-muted fw-normal">(' . h($series['unit']) . ')</span>' : '' ?>
</h4>
<div style="height:230px"><canvas class="lab-chart"
data-series="<?= h(json_encode($series, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) ?>"></canvas>
</div>
</div>
</div><?php endforeach; ?></div>
</div>
</div><?php endif; ?>
<div class="card mt-3">
<div class="card-header d-flex align-items-center justify-content-between">
<div>
<h3 class="card-title m-0">🏥 Chirurgies</h3>
<div class="text-muted small">Interventions planifiées et réalisées</div>
</div><a class="btn btn-sm btn-outline-primary"
href="/animal/surgery/new?animal_id=<?= (int)$animal['id'] ?>"><?= h(t('common.add')) ?></a>
</div>
<div class="card-body"><?php if (empty($surgeries)):?><span class="text-muted">Aucune chirurgie
enregistrée.</span><?php else:?><div class="table-responsive">
<table class="table table-sm table-vcenter mb-0">
<thead>
<tr>
<th>Date</th>
<th>Intervention</th>
<th>Structure / vétérinaire</th>
<th>Statut</th>
<th class="text-end">Coût</th>
</tr>
</thead>
<tbody><?php foreach ($surgeries as $s):?><tr>
<td><?= h(date('d/m/Y', strtotime($s['surgery_date']))) ?></td>
<td><strong><?= h($s['procedure_name']) ?></strong><?php if ($s['notes']):?><div
class="small text-muted"><?= h($s['notes']) ?></div><?php endif?></td>
<td><?= h(implode(' · ', array_filter([$s['clinic_name'],$s['veterinarian_name']])) ?: '—') ?>
</td>
<td><span
class="badge <?= $s['status'] === 'completed' ? 'bg-green-lt' : ($s['status'] === 'planned' ? 'bg-blue-lt' : 'bg-secondary-lt') ?>"><?= h(['completed' => 'Réalisée','planned' => 'Planifiée','cancelled' => 'Annulée'][$s['status']] ?? $s['status']) ?></span>
</td>
<td class="text-end">
<?= $s['amount_cents'] === null ? '—' : number_format($s['amount_cents'] / 100, 2, ',', ' ') . ' €' ?>
</td>
</tr><?php endforeach?></tbody>
</table>
</div><?php endif?></div>
</div>
<div class="card mt-3">
<div class="card-header d-flex align-items-center justify-content-between">
<div>
<h3 class="card-title m-0"><?= h(t('animals.dewormer')) ?></h3>
<div class="text-muted small"><?= h(t('animal.deworming_schedule')) ?></div>
</div><button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal"
data-bs-target="#modalDeworming"><?= h(t('common.add')) ?></button>
</div>
<div class="card-body"><?php if (!$dewormings): ?><span
class="text-muted"><?= h(t('animal.no_deworming')) ?></span><?php else: ?><div class="mb-3">
<strong><?= h(t('animal.latest_deworming', ['date' => (new DateTimeImmutable($lastDeworming['administered_on']))->format('d/m/Y')])) ?></strong><?php if ($lastDeworming['next_due_date']): $due = new DateTimeImmutable($lastDeworming['next_due_date']);
$late = $due < new DateTimeImmutable('today'); ?><span
class="badge ms-2 <?= $late ? 'bg-red-lt' : 'bg-blue-lt' ?>"><?= h(t('animal.next_date', ['date' => $due->format('d/m/Y')])) ?></span><?php endif; ?>
</div>
<div class="table-responsive">
<table class="table table-sm table-vcenter mb-0">
<thead>
<tr>
<th><?= h(t('common.date')) ?></th>
<th><?= h(t('animal.product')) ?></th>
<th><?= h(t('animal.dose')) ?></th>
<th><?= h(t('common.weight')) ?></th>
<th><?= h(t('animal.recorded_by')) ?></th>
</tr>
</thead>
<tbody><?php foreach ($dewormings as $d): ?><tr>
<td><?= h((new DateTimeImmutable($d['administered_on']))->format('d/m/Y')) ?></td>
<td><?= h($d['medication_name'] ?: t('common.not_provided')) ?></td>
<td><?= h($d['dose_text'] ?: '—') ?></td>
<td><?= $d['weight_kg'] !== null ? h(number_format((float)$d['weight_kg'], 2, ',', ' ')) . ' kg' : '—' ?>
</td>
<td><?= h($d['actor_name'] ?: '—') ?></td>
</tr><?php endforeach; ?></tbody>
</table>
</div><?php endif; ?>
</div>
</div>
<div class="card mt-3">
<div class="card-header">
<h3 class="card-title m-0"><?= h(t('animal.weight_evolution')) ?></h3>
</div>
<?php if (!$weights): ?>
<div class="card-body text-muted"><?= h(t('animal.no_weight')) ?></div>
<?php else: ?>
<div style="height:260px">
<canvas id="weightChart"></canvas>
</div>
<?php endif; ?>
</div>
<hr />
<?php if (!$medical_notes): ?>
<div class="text-muted"><?= h(t('animal.no_medical_note')) ?></div>
<?php else: ?>
<div class="divide-y">
<?php foreach ($medical_notes as $n): ?>
<div class="py-3">
<div class="d-flex justify-content-between">
<div>
<?php
$kind = (string)$n['kind'];
$badge = match ($kind) {
'consult' => 'bg-blue-lt',
'controle' => 'bg-green-lt',
'symptome' => 'bg-yellow-lt',
'diagnostic' => 'bg-purple-lt',
'acte' => 'bg-orange-lt',
'hosp' => 'bg-red-lt',
default => 'bg-secondary-lt',
};
$stmtPhotos = DB::pdo()->prepare('
SELECT *
FROM medical_photos
WHERE medical_note_id = ?
ORDER BY created_at ASC
');
$stmtPhotos->execute([(int)$n['id']]);
$notePhotos = $stmtPhotos->fetchAll(PDO::FETCH_ASSOC);
?>
<span class="badge <?= $badge ?>"><?= ucfirst(h($kind)) ?></span>
<?php if (!empty($n['reason'])): ?>
<strong class="ms-2"><?= h($n['reason']) ?></strong>
<?php endif; ?>
</div>
<div class="text-muted"><?= h($n['noted_at']) ?></div>
</div>
<div class="mt-2">
<?php if (!empty($n['weight_kg']) || !empty($n['temperature_c'])): ?>
<div class="text-muted">
<?= !empty($n['weight_kg']) ? (h(t('pdf.weight')) . ': <strong>' . h($n['weight_kg']) . ' kg</strong>') : '' ?>
<?= (!empty($n['weight_kg']) && !empty($n['temperature_c'])) ? ' • ' : '' ?>
<?= !empty($n['temperature_c']) ? ('Temp: <strong>' . h($n['temperature_c']) . ' °C</strong>') : '' ?>
</div>
<?php endif; ?>
<?php if (!empty($n['clinic_name']) || !empty($n['vet_name'])): ?>
<div class="text-muted">
<?= !empty($n['clinic_name']) ? h($n['clinic_name']) : '' ?>
<?= (!empty($n['clinic_name']) && !empty($n['vet_name'])) ? ' • ' : '' ?>
<?= !empty($n['vet_name']) ? h($n['vet_name']) : '' ?>
</div>
<?php endif; ?>
<?php if (!empty($n['symptoms'])): ?>
<div><span class="text-muted"><?= h(t('animal.symptoms')) ?> :</span> <?= nl2br(h($n['symptoms'])) ?>
</div>
<?php endif; ?>
<?php if (!empty($n['exam'])): ?>
<div><span class="text-muted"><?= h(t('animal.exam')) ?> :</span> <?= nl2br(h($n['exam'])) ?></div>
<?php endif; ?>
<?php if (!empty($n['diagnosis'])): ?>
<div><span class="text-muted"><?= h(t('animal.diagnosis')) ?> :</span> <?= nl2br(h($n['diagnosis'])) ?>
</div>
<?php endif; ?>
<?php if (!empty($n['plan'])): ?>
<div><span class="text-muted"><?= h(t('animal.plan')) ?> :</span> <?= nl2br(h($n['plan'])) ?></div>
<?php endif; ?>
</div>
<?php if (!empty($notePhotos)): ?>
<div class="mt-3">
<div class="text-muted mb-1"><?= h(t('animal.photos')) ?> :</div>
<div class="row row-cards">
<?php foreach ($notePhotos as $p): ?>
<?php
$src = '/private-media?kind=medical&id=' . (int)$p['id'];
?>
<div class="col-6 col-sm-4 col-md-3 col-lg-2">
<a href="<?= $src ?>" target="_blank" class="d-block">
<img src="<?= $src ?>" class="img-fluid rounded border"
style="height:140px;object-fit:cover;">
</a>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<!-- TRAITEMENTS -->
<div class="tab-pane" id="tab-trt">
<?php if (!$treatments): ?>
<div class="text-muted"><?= h(t('animal.no_treatment')) ?></div>
<?php else: ?>
<div class="table-responsive">
<table class="table table-vcenter">
<thead>
<tr>
<th><?= h(t('animal.status')) ?></th>
<th><?= h(t('animal.medication')) ?></th>
<th><?= h(t('animal.route')) ?></th>
<th><?= h(t('animal.dosage')) ?></th>
<th><?= h(t('animal.start')) ?></th>
<th><?= h(t('dashboard.end')) ?></th>
<th><?= h(t('common.notes')) ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($treatments as $t): ?>
<?php
$isFinished = !empty($t['end_date'])
&& $t['end_date'] < date('Y-m-d');
?>
<tr class="<?= $isFinished ? 'table-secondary text-muted' : '' ?>">
<td>
<span class="badge <?= $isFinished ? 'bg-grey-lt' : 'bg-green-lt' ?>">
<?= h(t($isFinished ? 'animal.finished' : 'animal.ongoing')) ?>
</span>
</td>
<td>
<strong><?= h($t['medication_name']) ?></strong>
<?php if (!empty($t['molecule'])): ?>
<div class="text-muted">
<?= h($t['molecule']) ?><?= !empty($t['form']) ? (' • ' . h($t['form'])) : '' ?></div>
<?php endif; ?>
</td>
<td><?= $t['route'] ? h($t['route']) : '—' ?></td>
<td><?= h($t['dose_text']) ?></td>
<td><?= h($t['start_date']) ?></td>
<td><?= $t['end_date'] ? h($t['end_date']) : '—' ?></td>
<td><?= $t['notes'] ? h($t['notes']) : '—' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<!-- VACCINS -->
<div class="tab-pane" id="tab-vac">
<?php if (!$vaccinations): ?>
<div class="text-muted"><?= h(t('animal.no_vaccine_recorded')) ?></div>
<?php else: ?>
<div class="table-responsive">
<table class="table table-vcenter">
<thead>
<tr>
<th><?= h(t('animal.vaccine')) ?></th>
<th><?= h(t('animal.done_on')) ?></th>
<th><?= h(t('animal.reminder_due')) ?></th>
<th><?= h(t('animal.batch_traceability')) ?></th>
<th><?= h(t('animal.administered_by')) ?></th>
<th><?= h(t('common.notes')) ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($vaccinations as $v): ?>
<tr>
<td><strong><?= h($v['vaccine_name']) ?></strong></td>
<td><?= h($v['done_date']) ?></td>
<td><?= $v['due_date'] ? h($v['due_date']) : '—' ?></td>
<td><?php if (!empty($v['manufacturer'])): ?><strong><?= h($v['manufacturer']) ?></strong><br><?php endif; ?><?= $v['lot'] ? h(t('animal.batch')) . ' ' . h($v['lot']) : '—' ?><?php if (!empty($v['batch_expires_on'])): ?>
<div class="text-muted small">
<?= h(t('animal.expires_on', ['date' => date('d/m/Y', strtotime((string)$v['batch_expires_on']))])) ?>
</div><?php endif; ?></td>
<td><?= !empty($v['administrator_name']) ? h($v['administrator_name']) : '—' ?></td>
<td><?= $v['notes'] ? h($v['notes']) : '—' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<?php endif; ?>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,205 @@
<?php
// Sous-vue de la fiche animale ; les variables sont préparées par AnimalsController::show().
?>
<!-- PHOTOS -->
<div class="tab-pane" id="tab-photos">
<?php /** @var array $photos */ ?>
<?php
$publicPhotos = array_values(array_filter($photos ?? [], fn ($p) => (int)($p['is_public'] ?? 1) === 1));
$privatePhotos = array_values(array_filter($photos ?? [], fn ($p) => (int)($p['is_public'] ?? 1) === 0));
?>
<div class="row row-cards">
<!-- PUBLIC -->
<div class="col-12">
<div class="card border-success">
<div class="card-header d-flex align-items-center justify-content-between">
<div class="d-flex align-items-baseline gap-2">
<h3 class="card-title m-0"><?= h(t('animal.public_photos')) ?></h3>
<div class="text-muted small">(<?= h(t('animal.visible_online')) ?>)</div>
</div>
<form method="post" action="/animal/photos/upload" enctype="multipart/form-data"
class="d-flex gap-2">
<input type="hidden" name="animal_id" value="<?= (int)$animal['id'] ?>">
<input type="hidden" name="is_public" value="1">
<input type="hidden" name="redirect_hash" value="#tab-photos">
<input class="form-control form-control-sm" type="file" name="photos[]" multiple
accept="image/*" required>
<button class="btn btn-sm btn-success"
type="submit"><?= h(t('animal.upload_public')) ?></button>
</form>
</div>
<div class="card-body">
<?php if (empty($publicPhotos)): ?>
<div class="text-muted"><?= h(t('animal.no_public_photo')) ?></div>
<?php else: ?>
<div class="row row-cards">
<?php foreach ($publicPhotos as $p): ?>
<?php
$src = '/media/animals/' . (int)$animal['id'] . '/' . h($p['filename']);
$isPrimary = (int)($p['is_primary'] ?? 0) === 1;
?>
<div class="col-6 col-sm-4 col-md-3 col-lg-2">
<div class="card card-sm">
<a href="<?= $src ?>" target="_blank" class="d-block">
<img src="<?= $src ?>" class="card-img-top" style="height:140px;object-fit:cover"
alt="">
</a>
<div class="card-body p-2">
<?= $isPrimary
? '<span class="badge bg-primary-lt">' . h(t('animal.primary')) . '</span>'
: '<span class="badge bg-success-lt">' . h(t('animal.public')) . '</span>' ?>
<?php if ($isPrimary): ?>
<button type="submit" class="btn btn-sm btn-icon btn-light position-absolute"
title="<?= h(t('animal.set_primary')) ?>"
style="border-radius:999px; top:10px;right:10px;">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="18" height="18"
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"
fill="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path fill="#edd180"
d="M12 17.75l-5.878 3.09l1.122-6.545l-4.756-4.635l6.57-.955l2.942-5.955l2.942 5.955l6.57 .955l-4.756 4.635l1.122 6.545z" />
</svg>
</button>
<?php else: ?>
<form method="post" action="/animal/photos/primary" class="position-absolute"
style="top:8px;right:8px;">
<input type="hidden" name="animal_id" value="<?= (int)$animal['id'] ?>">
<input type="hidden" name="photo_id" value="<?= (int)$p['id'] ?>">
<input type="hidden" name="redirect_hash" value="#tab-photos">
<button type="submit" class="btn btn-sm btn-icon btn-light"
title="<?= h(t('animal.set_primary')) ?>"
style="border-radius:999px;top:-2px; right:2px;">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="18" height="18"
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"
fill="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M12 17.75l-5.878 3.09l1.122-6.545l-4.756-4.635l6.57-.955l2.942-5.955l2.942 5.955l6.57 .955l-4.756 4.635l1.122 6.545z" />
</svg>
</button>
</form>
<?php endif; ?>
<div class="d-flex gap-1 mt-2">
<form method="post" action="/animal/photos/toggle" class="flex-fill">
<input type="hidden" name="animal_id" value="<?= (int)$animal['id'] ?>">
<input type="hidden" name="photo_id" value="<?= (int)$p['id'] ?>">
<input type="hidden" name="is_public" value="0">
<input type="hidden" name="redirect_hash" value="#tab-photos">
<button class="btn btn-sm btn-outline-warning w-100"
type="submit"><?= h(t('animal.private')) ?></button>
</form>
<form method="post" action="/animal/photos/delete"
onsubmit="return confirm(<?= h(json_encode(t('animal.delete_photo_confirm'), JSON_HEX_APOS | JSON_HEX_QUOT)) ?>);"
class="flex-fill">
<input type="hidden" name="animal_id" value="<?= (int)$animal['id'] ?>">
<input type="hidden" name="photo_id" value="<?= (int)$p['id'] ?>">
<input type="hidden" name="redirect_hash" value="#tab-photos">
<button class="btn btn-sm btn-outline-danger w-100"
type="submit"><?= h(t('common.delete_short')) ?></button>
</form>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
</div>
<!-- PRIVE -->
<div class="col-12">
<div class="card border-danger">
<div class="card-header d-flex align-items-center justify-content-between">
<div class="d-flex align-items-baseline gap-2">
<h3 class="card-title m-0"><?= h(t('animal.private_photos')) ?></h3>
<div class="text-muted small">(<?= h(t('animal.not_visible_online')) ?>)</div>
</div>
<form method="post" action="/animal/photos/upload" enctype="multipart/form-data"
class="d-flex gap-2">
<input type="hidden" name="animal_id" value="<?= (int)$animal['id'] ?>">
<input type="hidden" name="is_public" value="0">
<input type="hidden" name="redirect_hash" value="#tab-photos">
<input class="form-control form-control-sm" type="file" name="photos[]" multiple
accept="image/*" required>
<button class="btn btn-sm btn-danger"
type="submit"><?= h(t('animal.upload_private')) ?></button>
</form>
</div>
<div class="card-body">
<?php if (empty($privatePhotos)): ?>
<div class="text-muted"><?= h(t('animal.no_private_photo')) ?></div>
<?php else: ?>
<div class="row row-cards">
<?php foreach ($privatePhotos as $p): ?>
<?php $src = '/private-media?kind=photo&id=' . (int)$p['id']; ?>
<div class="col-6 col-sm-4 col-md-3 col-lg-2">
<div class="card card-sm bg-muted-lt">
<a href="<?= $src ?>" target="_blank" class="d-block">
<img src="<?= $src ?>" class="card-img-top"
style="height:140px;object-fit:cover;filter:grayscale(20%);" alt="">
</a>
<div class="card-body p-2">
<span class="badge bg-danger-lt"><?= h(t('animal.private')) ?></span>
<?php if (!empty($p['caption'])): ?><div class="small mt-2">
<?= nl2br(h($p['caption'])) ?></div><?php endif; ?>
<?php if (!empty($p['care_round_id'])): ?><div class="text-muted small mt-1">📋
<?= h(t('animal.round_photo')) ?></div><?php endif; ?>
<div class="d-flex gap-1 mt-2">
<form method="post" action="/animal/photos/toggle" class="flex-fill">
<input type="hidden" name="animal_id" value="<?= (int)$animal['id'] ?>">
<input type="hidden" name="photo_id" value="<?= (int)$p['id'] ?>">
<input type="hidden" name="is_public" value="1">
<input type="hidden" name="redirect_hash" value="#tab-photos">
<button class="btn btn-sm btn-outline-success w-100"
type="submit"><?= h(t('animal.public')) ?></button>
</form>
<form method="post" action="/animal/photos/delete"
onsubmit="return confirm(<?= h(json_encode(t('animal.delete_photo_confirm'), JSON_HEX_APOS | JSON_HEX_QUOT)) ?>);"
class="flex-fill">
<input type="hidden" name="animal_id" value="<?= (int)$animal['id'] ?>">
<input type="hidden" name="photo_id" value="<?= (int)$p['id'] ?>">
<input type="hidden" name="redirect_hash" value="#tab-photos">
<button class="btn btn-sm btn-outline-danger w-100"
type="submit"><?= h(t('common.delete_short')) ?></button>
</form>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,67 @@
<div class="modal modal-blur fade" id="modalDewormingCatalog" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<form class="modal-content" method="post" action="/animal/add-deworming">
<div class="modal-header">
<h5 class="modal-title"><?= h(t('deworm.catalog_record')) ?></h5><button type="button" class="btn-close"
data-bs-dismiss="modal"></button>
</div>
<div class="modal-body"><input type="hidden" name="animal_id" value="<?= (int)$animal['id'] ?>">
<div class="row g-3">
<div class="col-md-6"><label class="form-label"><?= h(t('common.date')) ?></label><input
class="form-control" type="date" name="administered_on" value="<?= date('Y-m-d') ?>"
required></div>
<div class="col-md-6"><label class="form-label"><?= h(t('deworm.product')) ?></label><select
class="form-select" name="dewormer_id">
<option value=""><?= h(t('common.not_provided')) ?></option>
<?php foreach (($dewormers ?? []) as $d): ?><option value="<?= (int)$d['id'] ?>">
<?= h($d['name']) ?></option><?php endforeach; ?>
</select></div>
<div class="col-12"><label class="form-check"><input class="form-check-input" type="checkbox"
name="create_dewormer" value="1" id="dewormerNewToggle"><span
class="form-check-label"><?= h(t('deworm.add_catalog')) ?></span></label></div>
<div class="col-12 d-none" id="dewormerNewFields">
<div class="row g-2">
<div class="col-md-5"><input class="form-control" name="new_dewormer_name"
placeholder="<?= h(t('deworm.name')) ?>"></div>
<div class="col-md-4"><input class="form-control" name="new_dewormer_ingredient"
placeholder="<?= h(t('deworm.ingredient')) ?>"></div>
<div class="col-md-3"><input class="form-control" name="new_dewormer_form"
placeholder="<?= h(t('deworm.form')) ?>"></div>
</div>
</div>
<div class="col-md-6"><label class="form-label"><?= h(t('animal.dose')) ?></label><input
class="form-control" name="dose_text" placeholder="<?= h(t('deworm.dose_example')) ?>">
</div>
<div class="col-md-6"><label class="form-label"><?= h(t('common.weight')) ?></label>
<div class="input-group"><input class="form-control" type="number" min="0" step="0.01"
name="weight_kg"><span class="input-group-text">kg</span></div>
</div>
<div class="col-12"><label class="form-label"><?= h(t('deworm.next_due')) ?> <span
class="text-muted"><?= h(t('common.optional_parentheses')) ?></span></label><input
class="form-control" type="date" name="next_due_date">
<div class="form-hint"><?= h(t('deworm.auto_age')) ?></div>
</div>
<?php if (!empty($litters)): ?><div class="col-12"><label class="form-check"><input
class="form-check-input" type="checkbox" name="apply_litter" value="1"><span
class="form-check-label"><?= h(t('deworm.apply_litter')) ?></span></label></div>
<?php endif; ?>
<div class="col-12"><label class="form-label"><?= h(t('animal.comment')) ?></label><textarea
class="form-control" name="notes" rows="2"></textarea></div>
</div>
</div>
<div class="modal-footer"><button class="btn me-auto" type="button"
data-bs-dismiss="modal"><?= h(t('common.cancel')) ?></button><button
class="btn btn-primary"><?= h(t('common.save')) ?></button></div>
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[data-bs-target="#modalDeworming"]').forEach(b => b.setAttribute(
'data-bs-target', '#modalDewormingCatalog'));
const t = document.getElementById('dewormerNewToggle'),
p = document.getElementById('dewormerNewFields');
t?.addEventListener('change', () => p?.classList.toggle('d-none', !t.checked));
});
</script>

View file

@ -0,0 +1,29 @@
<?php $directoryFieldId = $directoryFieldId ?? uniqid('medical_', false); ?>
<div class="col-md-6">
<label class="form-label"><?= h(t('animal.veterinary_clinic')) ?></label>
<select class="form-select directory-contact-select medical-clinic-select" name="clinic_contact_id"
data-pair="<?= h($directoryFieldId) ?>" data-new-target="<?= h($directoryFieldId) ?>ClinicNew" required>
<option value="">— <?= h(t('common.choose')) ?> —</option>
<?php foreach ($directoryClinics as $contact): ?><option value="<?= (int)$contact['id'] ?>">
<?= h($contact['name']) ?></option><?php endforeach; ?>
<option value="new">+ <?= h(t('animal.create_clinic')) ?></option>
</select>
<input class="form-control mt-2 d-none" id="<?= h($directoryFieldId) ?>ClinicNew" name="new_clinic_name"
placeholder="<?= h(t('animal.new_clinic_name')) ?>">
</div>
<div class="col-md-6">
<label class="form-label"><?= h(t('animal.form.veterinarian')) ?></label>
<select class="form-select directory-contact-select medical-vet-select" name="vet_contact_id"
data-pair="<?= h($directoryFieldId) ?>" data-new-target="<?= h($directoryFieldId) ?>VetNew" required>
<option value="">— <?= h(t('common.choose')) ?> —</option>
<?php foreach ($veterinarians as $contact): ?><option value="<?= (int)$contact['id'] ?>"
data-organization="<?= (int)($contact['organization_id'] ?? 0) ?>"><?= h($contact['name']) ?></option>
<?php endforeach; ?>
<option value="new">+ <?= h(t('animal.create_vet')) ?></option>
</select>
<input class="form-control mt-2 d-none" id="<?= h($directoryFieldId) ?>VetNew" name="new_vet_name"
placeholder="<?= h(t('animal.new_vet_name')) ?>">
</div>
<div class="col-12">
<div class="form-hint"><?= h(t('animal.medical_pair_hint')) ?></div>
</div>

View file

@ -0,0 +1,63 @@
<?php if ($canViewMedical): ?>
<div class="modal modal-blur fade" id="modalExpense" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<form class="modal-content" method="post" action="/animal/add-expense">
<div class="modal-header">
<div>
<h5 class="modal-title"><?= h(t('expense.add')) ?></h5>
<div class="text-muted small"><?= h(t('expense.history_help')) ?></div>
</div><button class="btn-close" type="button" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body"><input type="hidden" name="animal_id" value="<?= (int)$animal['id'] ?>">
<div class="row g-3">
<div class="col-md-8"><label
class="form-label required"><?= h(t('expense.act_product')) ?></label><select
class="form-select" name="tariff_id" id="expenseTariff" required>
<option value="">— <?= h(t('expense.choose')) ?> —</option><?php $lastClinic = '';
foreach ($expenseTariffs as $t):if ($lastClinic !== $t['clinic_name']):if ($lastClinic !== ''):?></optgroup><?php endif;
$lastClinic = $t['clinic_name'];?><optgroup label="<?= h($lastClinic) ?>"><?php endif;?><option
value="<?= (int)$t['id'] ?>" data-price="<?= (int)$t['effective_cents'] ?>"
data-discount="<?= h((string)$t['discount_percent']) ?>"><?= h($t['label']) ?> ·
<?= number_format($t['effective_cents'] / 100, 2, ',', ' ') ?> €</option><?php endforeach;
if ($lastClinic !== ''):?></optgroup><?php endif;?>
</select>
<div class="form-hint" id="expenseHint"><?= h(t('expense.price_after_discount')) ?></div>
</div>
<div class="col-md-4"><label class="form-label"><?= h(t('common.date')) ?></label><input
class="form-control" type="date" name="occurred_on" value="<?= date('Y-m-d') ?>" required>
</div>
<div class="col-md-4"><label class="form-label"><?= h(t('expense.quantity')) ?></label><input
class="form-control" type="number" min="0.01" step="0.01" name="quantity" value="1"
required></div>
<div class="col-md-8"><label class="form-label"><?= h(t('expense.comment')) ?></label><input
class="form-control" name="notes" placeholder="<?= h(t('expense.comment_placeholder')) ?>">
</div>
</div>
</div>
<div class="modal-footer"><button class="btn me-auto" type="button"
data-bs-dismiss="modal"><?= h(t('common.cancel')) ?></button><button
class="btn btn-primary"><?= h(t('expense.save')) ?></button></div>
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const s = document.getElementById('expenseTariff'),
h = document.getElementById('expenseHint'),
template = < ? = json_encode(t('expense.unit_estimate'), JSON_UNESCAPED_UNICODE |
JSON_UNESCAPED_SLASHES) ? > ,
empty = < ? = json_encode(t('expense.price_after_discount'), JSON_UNESCAPED_UNICODE |
JSON_UNESCAPED_SLASHES) ? > ;
s?.addEventListener('change', () => {
const o = s.selectedOptions[0],
price = (Number(o?.dataset.price || 0) / 100).toLocaleString( < ? = json_encode(I18n::
locale() === 'fr' ? 'fr-FR' : 'en-GB') ? > , {
minimumFractionDigits: 2
});
h.textContent = o?.value ? template.replace(':price', price).replace(':discount', o.dataset
.discount) : empty;
});
});
</script>
<?php endif; ?>

View file

@ -0,0 +1,37 @@
<?php if ($canViewMedical): ?>
<div class="card mt-3">
<div class="card-body d-flex flex-wrap align-items-center gap-3">
<?php $expenseCount = count($expenses ?? []); ?><div class="me-auto">
<div class="subheader"><?= h(t('expense.estimated_vet')) ?></div>
<div class="text-muted small"><?= h(t('expense.recorded', ['count' => $expenseCount])) ?></div>
</div>
<div class="h2 mb-0"><?= number_format(($expenseTotal ?? 0) / 100, 2, ',', ' ') ?> €</div>
<?php if (!empty($expenses)): ?><button class="btn btn-sm btn-outline-secondary" type="button"
data-bs-toggle="collapse" data-bs-target="#animalExpenseDetails"
aria-expanded="false"><?= h(t('expense.details')) ?></button><?php endif; ?>
</div>
<?php if (!empty($expenses)): ?><div class="collapse" id="animalExpenseDetails">
<div class="table-responsive border-top">
<table class="table table-sm table-vcenter mb-0">
<thead>
<tr>
<th><?= h(t('common.date')) ?></th>
<th><?= h(t('expense.expense')) ?></th>
<th><?= h(t('pricing.structure')) ?></th>
<th><?= h(t('pricing.discount')) ?></th>
<th class="text-end"><?= h(t('expense.total')) ?></th>
</tr>
</thead>
<tbody><?php foreach ($expenses as $e): ?><tr>
<td><?= h(date('d/m/Y', strtotime($e['occurred_on']))) ?></td>
<td><?= h($e['label']) ?><?php if ((float)$e['quantity'] !== 1.0): ?><span
class="text-muted small"> × <?= h((string)$e['quantity']) ?></span><?php endif; ?></td>
<td><?= h($e['clinic_name'] ?: '—') ?></td>
<td><?= number_format((float)$e['discount_percent'], 2, ',', ' ') ?> %</td>
<td class="text-end fw-bold"><?= number_format($e['total_cents'] / 100, 2, ',', ' ') ?> €</td>
</tr><?php endforeach; ?></tbody>
</table>
</div>
</div><?php endif; ?>
</div>
<?php endif; ?>

View file

@ -0,0 +1,15 @@
<div class="col-12"><label class="form-label"><?= h(t('animal.tariff_record')) ?> <span
class="text-muted">(<?= h(mb_strtolower(t('common.optional'))) ?>)</span></label><select class="form-select"
name="tariff_id">
<option value=""><?= h(t('animal.no_expense')) ?></option><?php $expenseClinic = '';
foreach (($expenseTariffs ?? []) as $t):if ($expenseClinic !== $t['clinic_name']):if ($expenseClinic !== ''):?>
</optgroup><?php endif;
$expenseClinic = $t['clinic_name'];?><optgroup label="<?= h($expenseClinic) ?>"><?php endif;?><option
value="<?= (int)$t['id'] ?>"><?= h($t['label']) ?> ·
<?= number_format($t['effective_cents'] / 100, 2, ',', ' ') ?> €
<?= h(t('animal.net')) ?><?= (float)$t['discount_percent'] > 0 ? ' (−' . number_format((float)$t['discount_percent'], 0) . ' %)' : '' ?>
</option><?php endforeach;
if ($expenseClinic !== ''):?></optgroup><?php endif;?>
</select>
<div class="form-hint"><?= h(t('animal.expense_hint')) ?></div>
</div>

99
app/Views/_helpers.php Normal file
View file

@ -0,0 +1,99 @@
<?php
// app/Views/_helpers.php
if (!function_exists('h')) {
function h($s): string
{
return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
}
function app_version(): string
{
static $version = null;
if ($version !== null) {
return $version;
}
$path = dirname(__DIR__, 2) . '/VERSION';
$value = is_file($path) ? trim((string) file_get_contents($path)) : '';
return $version = ($value !== '' ? $value : 'développement');
}
function status_badge(string $st): array
{
$st = strtolower(trim($st));
return match ($st) {
'refuge' => ['bg-blue-lt',t('status.refuge')],
'fa' => ['bg-cyan-lt',t('status.fa')],
'fa_permanente' => ['bg-indigo-lt',t('status.fa_permanente')],
'chat_libre' => ['bg-green-lt text-green','🌿 ' . t('status.chat_libre')],
'quarantaine' => ['bg-yellow-lt',t('status.quarantaine')],
'soin' => ['bg-red-lt',t('status.soin')],
'isolation' => ['bg-orange-lt',t('status.isolation')],
'hospitalise','hospitalisé' => ['bg-red-lt',t('status.hospitalise')],
'reserve','réservé' => ['bg-purple-lt',t('status.reserve')],
'adopte','adopté' => ['bg-green-lt',t('status.adopte')],
'decede','décédé' => ['bg-dark-lt',t('status.decede')],
'enfui' => ['bg-secondary-lt',t('status.enfui')],
default => ['bg-secondary-lt', $st !== '' ? $st : '—'],
};
}
function species_label(?string $species): string
{
if (class_exists('SpeciesService')) {
return SpeciesService::label($species);
}
return match (strtolower(trim((string)$species))) {
'cat', 'chat' => t('species.cat'),
'dog', 'chien' => t('species.dog'),
'nac' => 'NAC',
'' => '—',
default => ucfirst(trim((string)$species)),
};
}
function coat_swatch_style(?string $coat): string
{
return match (mb_strtolower(trim((string)$coat), 'UTF-8')) {
'bicolore noir / blanc' => 'background:linear-gradient(135deg,#252525 0 50%,#fff 50% 100%)',
'bicolore gris / blanc' => 'background:linear-gradient(135deg,#8b95a1 0 50%,#fff 50% 100%)',
'bicolore roux / blanc' => 'background:linear-gradient(135deg,#d9822b 0 50%,#fff 50% 100%)',
'bicolore tigré et blanc' => 'background:linear-gradient(90deg,transparent 0 55%,#fff 55% 100%),repeating-linear-gradient(135deg,#8a6848 0 5px,#3f3328 5px 8px)',
'gris' => 'background:#8b95a1',
'roux' => 'background:#d9822b',
'noir' => 'background:#252525',
'chocolat' => 'background:#6f4e37',
'blanc' => 'background:#fff',
'tricolore / calico' => 'background:conic-gradient(#252525 0 33%,#fff 33% 66%,#d9822b 66% 100%)',
'écaille de tortue' => 'background:conic-gradient(#2d241f 0 28%,#b86326 28% 48%,#171513 48% 72%,#d18a45 72% 100%)',
'colourpoint / siamois' => 'background:radial-gradient(circle at center,#f2dfbb 0 48%,#654b3c 52% 100%)',
'tigré / tabby' => 'background:repeating-linear-gradient(135deg,#8a6848 0 5px,#3f3328 5px 8px)',
default => 'background:#d9dde3',
};
}
function sort_th(string $label, string $key, string $sort, string $dir, string $path = '/animals'): string
{
$nextDir = ($sort === $key && strtoupper($dir) === 'ASC') ? 'desc' : 'asc';
$arrow = ($sort === $key) ? (strtoupper($dir) === 'ASC' ? ' ↑' : ' ↓') : '';
$qs = $_GET;
$qs['sort'] = $key;
$qs['dir'] = $nextDir;
$href = $path . '?' . http_build_query($qs);
return '<a class="text-reset text-decoration-none" href="' . htmlspecialchars($href, ENT_QUOTES) . '">' . $label . $arrow . '</a>';
}
function sortLink($column, $label, $sort, $dir, $year, $q, $species)
{
$newDir = ($sort === $column && $dir === 'ASC') ? 'desc' : 'asc';
$arrow = '';
if ($sort === $column) {
$arrow = $dir === 'ASC' ? ' ↑' : ' ↓';
}
return '<a href="?year=' . $year . '&q=' . urlencode($q) . '&species=' . urlencode($species) . '&sort=' . $column . '&dir=' . $newDir . '">' . $label . $arrow . '</a>';
}

View file

@ -0,0 +1,35 @@
<?php
/** Pied de page commun. */
?>
<footer class="globinours-footer d-print-none">
<div class="container-xl">
<div class="footer-surface d-flex flex-wrap align-items-center justify-content-between gap-3">
<div>
<div class="fw-bold">Globinours</div>
<div class="text-muted small"><?= h(t('footer.description')) ?></div>
</div>
<div class="footer-meta d-flex flex-wrap align-items-center gap-3"><span
class="d-inline-flex align-items-center gap-1"><span role="img"
aria-label="<?= h(t('footer.brewed_label')) ?>">🧪 <?= h(t('footer.brewed')) ?></span>
<?= t('footer.by_brittany', ['developer' => '<a href="https://oversu.fr" target="_blank" rel="noopener noreferrer" class="text-reset fw-semibold">OverSu</a>']) ?>
<span class="breton-flag" role="img" aria-label="<?= h(t('footer.breton_flag')) ?>"
title="Gwen ha Du"></span></span><span class="badge footer-version">Version
<?= h(app_version()) ?></span><a class="footer-source" href="/evolutions"
title="<?= h(t('footer.evolutions_title')) ?>">🧭
<span><?= h(t('footer.evolutions')) ?></span></a><a class="footer-source"
href="https://nnsprod.com/git/oversu/Globinours" target="_blank" rel="noopener noreferrer"
title="<?= h(t('footer.source_title')) ?>" aria-label="<?= h(t('footer.source_title')) ?>"><svg
xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true">
<circle cx="6" cy="6" r="2" />
<circle cx="18" cy="6" r="2" />
<circle cx="6" cy="18" r="2" />
<path d="M6 8v8" />
<path d="M8 6h8" />
<path d="M18 8a6 6 0 0 1 -6 6h-4" />
</svg><span><?= h(t('footer.source')) ?></span></a></div>
</div>
</div>
<div class="paw-footer" aria-hidden="true"></div>
</footer>

View file

@ -0,0 +1,152 @@
<?php
/** Barre de navigation et recherche globale. */
?>
<header class="navbar navbar-expand-md d-print-none">
<div class="container-xl">
<div class="topbar-surface d-flex align-items-center justify-content-between w-100">
<a class="navbar-brand d-flex flex-row align-items-center py-0"
href="<?= h(PermissionService::can('dashboard') ? '/dashboard' : PermissionService::firstAllowedPath()) ?>">
<img src="/assets/logo-globinours-64.png" width="52" height="54" alt="" class="flex-shrink-0"
style="margin-right:10px">
<span class="d-flex flex-column align-items-start">
<span class="fw-bold">Globinours</span>
<span class="text-muted fw-normal" style="font-size:.72rem; line-height:1.2; letter-spacing:0;">
<?= h(t('footer.tagline')) ?>
</span>
</span>
</a>
<div class="navbar-nav flex-row order-md-last">
<span class="search-trigger-wrap"><button class="search-trigger" type="button" data-bs-toggle="modal"
data-bs-target="#globalSearchModal"
title="<?= h(t('search.open')) ?> — <?= h(t('search.shortcut')) ?>"
aria-label="<?= h(t('search.open')) ?> (<?= h(t('search.shortcut')) ?>)"><svg
xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<circle cx="10" cy="10" r="7" />
<path d="M21 21l-6 -6" />
</svg></button></span>
<!-- Dashboard -->
<?php if (PermissionService::can('dashboard')): ?>
<a class="btn <?= $dashboardActive ? 'btn-teal' : 'btn-outline-teal' ?> btn-sm me-2" href="/dashboard">
<svg xmlns="http://www.w3.org/2000/svg" class="icon me-1" width="18" height="18" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M4 4h6v8h-6z" />
<path d="M4 16h6v4h-6z" />
<path d="M14 12h6v8h-6z" />
<path d="M14 4h6v6h-6z" />
</svg>
<?= h(t('nav.dashboard')) ?>
</a>
<?php endif; ?>
<!-- Animaux -->
<?php if (PermissionService::can('animals')): ?>
<a class="btn <?= $animalsActive ? 'btn-primary' : 'btn-outline-primary' ?> btn-sm me-2"
href="/animals">
<svg xmlns="http://www.w3.org/2000/svg" class="icon me-1" width="18" height="18" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M4.5 9.5a2.5 2.5 0 1 0 0 -5a2.5 2.5 0 0 0 0 5z" />
<path d="M9 5.5a2.5 2.5 0 1 0 5 0a2.5 2.5 0 0 0 -5 0z" />
<path d="M17 9.5a2.5 2.5 0 1 0 0 -5a2.5 2.5 0 0 0 0 5z" />
<path
d="M7 15.5c0 -2.5 2.2 -4.5 5 -4.5s5 2 5 4.5c0 2 -1.3 4 -3.5 4c-.8 0 -1.1 -.5 -1.5 -.5s-.7 .5 -1.5 .5c-2.2 0 -3.5 -2 -3.5 -4z" />
</svg>
<?= h(t('nav.animals')) ?>
</a>
<?php endif; ?>
<!-- Administratif -->
<?php if (PermissionService::can('directory')): ?>
<a class="btn <?= $directoryActive ? 'btn-purple' : 'btn-outline-purple' ?> btn-sm me-2"
href="/directory">👥 <?= h(t('nav.directory')) ?></a>
<?php endif; ?>
<!-- Tournée de soins -->
<?php if (PermissionService::can('care')): ?>
<a class="btn <?= $careRoundActive ? 'btn-orange' : 'btn-outline-orange' ?> btn-sm me-2"
href="/care-round">🩺 <?= h(t('nav.care_round')) ?></a>
<?php endif; ?>
<!-- Agenda -->
<?php if (PermissionService::can('agenda')): ?><a
class="btn <?= $agendaActive ? 'btn-pink' : 'btn-outline-pink' ?> btn-sm me-2" href="/agenda">📅
<?= h(t('nav.agenda')) ?></a><?php endif; ?>
<!-- Statistiques -->
<?php if (PermissionService::can('statistics')): ?>
<a class="btn <?= $statisticsActive ? 'btn-cyan' : 'btn-outline-cyan' ?> btn-sm me-2" href="/statistics"
title="<?= h(t('nav.statistics')) ?>">📊 <?= h(t('nav.statistics')) ?></a>
<?php endif; ?>
<?php if (PermissionService::can('accounting')): ?><a
class="btn <?= $accountingActive ? 'btn-teal' : 'btn-outline-teal' ?> btn-sm me-2"
href="/accounting?year=0">🧾 Comptabilité</a><?php endif; ?>
<div class="dropdown settings-dropdown"><button class="btn btn-dark btn-sm me-2 settings-trigger"
data-bs-toggle="dropdown" aria-expanded="false" title="<?= h(t('account.settings')) ?>"
aria-label="<?= h(t('account.settings')) ?>"><svg xmlns="http://www.w3.org/2000/svg"
class="icon m-0" width="22" height="22" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 15.5a3.5 3.5 0 1 0 0 -7a3.5 3.5 0 0 0 0 7z" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06 .06a2 2 0 1 1 -2.83 2.83l-.06 -.06a1.65 1.65 0 0 0 -1.82 -.33a1.65 1.65 0 0 0 -1 1.51v.17a2 2 0 1 1 -4 0v-.09a1.65 1.65 0 0 0 -1.08 -1.51a1.65 1.65 0 0 0 -1.82 .33l-.06 .06a2 2 0 1 1 -2.83 -2.83l.06 -.06a1.65 1.65 0 0 0 .33 -1.82a1.65 1.65 0 0 0 -1.51 -1h-.17a2 2 0 1 1 0 -4h.09a1.65 1.65 0 0 0 1.51 -1.08a1.65 1.65 0 0 0 -.33 -1.82l-.06 -.06a2 2 0 1 1 2.83 -2.83l.06 .06a1.65 1.65 0 0 0 1.82 .33h.08a1.65 1.65 0 0 0 1 -1.51v-.17a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51a1.65 1.65 0 0 0 1.82 -.33l.06 -.06a2 2 0 1 1 2.83 2.83l-.06 .06a1.65 1.65 0 0 0 -.33 1.82v.08a1.65 1.65 0 0 0 1.51 1h.17a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0 -1.51 1z" />
</svg></button>
<div class="dropdown-menu dropdown-menu-end">
<div class="dropdown-item-text">
<div class="fw-bold">👤 <?= h($currentUser['display_name'] ?: $currentUser['username']) ?>
</div>
<div class="small text-muted"><?= h(ucfirst((string)$currentUser['role'])) ?></div>
</div>
<div class="dropdown-divider"></div><?php if (PermissionService::can('administrative')):?><a
class="dropdown-item" href="/admin/register">📁
<?= h(t('nav.administrative')) ?></a><?php endif;?><?php if (PermissionService::can('grants')):?><a
class="dropdown-item" href="/admin/grants">📊
<?= h(t('nav.grants')) ?></a><?php endif;?><?php if (PermissionService::can('administrative') || PermissionService::can('grants')):?>
<div class="dropdown-divider"></div>
<?php endif;?><?php if (Auth::is('admin')): ?><?php $lastSettingsGroup = null;
foreach ($settingsMenuLinks as [$settingsHref,$settingsIcon,$settingsLabel,$settingsGroup]): if ($settingsGroup !== $lastSettingsGroup): if ($lastSettingsGroup !== null): ?>
<div class="dropdown-divider"></div><?php endif; ?><div class="dropdown-header">
<?= h(settingsGroupLabel($settingsGroup)) ?></div>
<?php $lastSettingsGroup = $settingsGroup;endif; ?><a class="dropdown-item"
href="<?= h($settingsHref) ?>"><?= $settingsIcon ?>
<?= h($settingsLabel) ?></a><?php endforeach; ?><div class="dropdown-divider"></div>
<?php endif; ?><form method="post" action="/logout"><input type="hidden" name="csrf"
value="<?= h(Auth::csrf()) ?>"><button class="dropdown-item text-danger" type="submit">↪
<?= h(t('account.logout')) ?></button></form>
</div>
</div>
</div>
</div>
</div>
</header>
<div class="modal modal-blur fade" id="globalSearchModal" tabindex="-1" aria-hidden="true"
data-hint="<?= h(t('search.hint')) ?>" data-loading="<?= h(t('search.loading')) ?>"
data-none="<?= h(t('search.none')) ?>" data-error="<?= h(t('search.error')) ?>"
data-all-results="<?= h(t('search.all_results')) ?>"
data-groups="<?= h(json_encode(['animals' => t('search.group.animals'),'directory' => t('search.group.directory'),'agenda' => t('search.group.agenda'),'accounting' => t('search.group.accounting')], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) ?>">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title"><?= h(t('search.title')) ?></h2><button type="button" class="btn-close"
data-bs-dismiss="modal" aria-label="<?= h(t('common.close')) ?>"></button>
</div>
<div class="modal-body">
<div class="input-group input-group-lg mb-3"><span class="input-group-text">⌕</span><input
class="form-control" id="globalSearchInput" maxlength="120" autocomplete="off"
placeholder="<?= h(t('search.placeholder')) ?>"></div>
<div id="globalSearchResults" class="list-group">
<div class="text-muted text-center py-4"><?= h(t('search.hint')) ?></div>
</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,14 @@
<?php if (!$animals): ?><div class="empty"><?= h(t('public_site.no_animals')) ?></div><?php else: ?><div class="grid">
<?php foreach ($animals as $a): ?><a class="animal-card"
href="<?= h($siteUrl('animal', ['id' => (int)$a['id']])) ?>"><?php if ($a['primary_photo']): ?><img
loading="lazy" src="/media/animals/<?= (int)$a['id'] ?>/<?= h(rawurlencode($a['primary_photo'])) ?>"
alt="<?= h($a['name']) ?>"><?php else: ?><div class="photo-placeholder"><?= h($a['species_icon'] ?: '🐾') ?>
</div><?php endif; ?><div class="animal-card-body">
<h3><?= h($a['name']) ?></h3>
<div class="meta">
<?= h(($a['species_name'] ?: SpeciesService::label($a['species'])) . ' · ' . public_age($a)) ?></div>
<div class="chips"><?php if ($a['sex'] === 'F'): ?><span class="chip">♀
<?= h(t('sex.female')) ?></span><?php elseif ($a['sex'] === 'M'): ?><span class="chip">♂
<?= h(t('sex.male')) ?></span><?php endif; ?><?php if ($a['sterilization_status'] === 'yes'): ?><span
class="chip">✓ <?= h(t('public_site.sterilized')) ?></span><?php endif; ?></div>
</div></a><?php endforeach; ?></div><?php endif; ?>

View file

@ -0,0 +1,31 @@
<?php
if (!function_exists('settingsNavigationLinks')) {
function settingsGroupLabel(string$group): string
{
return match($group) {
'general' => t('settings.group_general'),'business' => t('settings.group_business'),'access' => t('settings.group_access'),'maintenance' => t('settings.group_maintenance'),default => $group
};
}
function settingsNavigationLinks(): array
{
return [
'general' => ['/settings/general','⚙️',t('settings.general'),'general'],
'public-site' => ['/settings/public-site','🌐',t('settings.public_site'),'general'],
'species' => ['/settings/species','🐾',t('settings.species'),'business'],
'rooms' => ['/settings/rooms','🚪',t('settings.rooms'),'business'],
'pricing' => ['/settings/pricing','💶',t('settings.pricing'),'business'],
'inventory' => ['/settings/inventory','💊',t('settings.inventory'),'business'],
'lab-references' => ['/settings/lab-references','🩸',t('settings.lab_references'),'business'],
'users' => ['/settings/users','👥',t('settings.users'),'access'],
'permissions' => ['/settings/permissions','🔐',t('settings.permissions'),'access'],
'privacy' => ['/settings/privacy','🔏',t('settings.privacy'),'access'],
'backups' => ['/settings/backups','💾',t('settings.backups'),'maintenance'],
'health' => ['/settings/health','🩺',t('settings.health'),'maintenance'],
'notifications' => ['/settings/notifications','🔔',t('settings.notifications'),'maintenance'],
'data' => ['/settings/data','🧪',t('settings.data'),'maintenance'],
'asm3' => ['/settings/asm3','🚚',t('settings.asm3'),'maintenance'],
'audit' => ['/settings/audit','📜',t('settings.audit'),'maintenance'],
];
}
}

View file

@ -0,0 +1,21 @@
<?php $editing = ($deniedAction ?? 'view') === 'edit';
$module = $deniedModule ?? t('access.requested'); ?>
<div class="row justify-content-center py-5">
<div class="col-md-8 col-lg-6">
<div class="card text-center overflow-hidden">
<div class="card-status-top bg-orange"></div>
<div class="card-body p-5">
<div class="display-3 mb-3">🔒</div>
<div class="text-uppercase text-muted fw-bold small mb-2"><?= h(t('access.denied')) ?></div>
<h2 class="mb-3"><?= h(t($editing ? 'access.edit_denied' : 'access.page_denied')) ?></h2>
<p class="text-muted mb-4">
<?= h(t($editing ? 'access.explanation_edit' : 'access.explanation_view', ['module' => $module])) ?>
</p>
<div class="d-flex flex-wrap justify-content-center gap-2"><a class="btn btn-primary"
href="<?= h(PermissionService::firstAllowedPath()) ?>"><?= h(t('access.back_space')) ?></a><button
class="btn btn-outline-secondary" type="button"
onclick="history.back()"><?= h(t('access.previous')) ?></button></div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,35 @@
<div class="d-flex flex-wrap align-items-center gap-2 mb-3">
<div class="me-auto">
<h2 class="mb-1">📱 <?= h(t('devices.title')) ?></h2>
<div class="text-muted"><?= h(t('devices.intro')) ?></div>
</div>
<form method="post" action="/account/devices/revoke-all"
onsubmit="return confirm(<?= h(json_encode(t('devices.revoke_all_confirm'), JSON_HEX_APOS | JSON_HEX_QUOT)) ?>)">
<button class="btn btn-outline-danger"><?= h(t('devices.revoke_all')) ?></button></form>
</div>
<?php if ($saved): ?><div class="alert alert-success">✅ <?= h(t('devices.revoked')) ?></div><?php endif; ?>
<div class="row g-3">
<?php foreach ($devices as $device): $active = !$device['revoked_at'] && strtotime($device['expires_at']) > time(); ?>
<div class="col-12 col-lg-6">
<div class="card h-100">
<div class="card-body d-flex gap-3">
<div class="fs-1">📱</div>
<div class="flex-fill">
<div class="d-flex flex-wrap gap-2 align-items-center">
<strong><?= h($device['device_name']) ?></strong><?php if ($device['selector'] === $currentSelector): ?><span
class="badge bg-green-lt"><?= h(t('devices.this_device')) ?></span><?php endif; ?><span
class="badge <?= $active ? 'bg-blue-lt' : 'bg-secondary-lt' ?>"><?= h(t($active ? 'devices.active' : 'devices.inactive')) ?></span>
</div>
<div class="small text-muted mt-2">
<?= h(t('devices.created', ['date' => date('d/m/Y H:i', strtotime($device['created_at']))])) ?><br><?= h(t('devices.last_used', ['date' => date('d/m/Y H:i', strtotime($device['last_used_at']))])) ?><br><?= h(t('devices.expires', ['date' => date('d/m/Y', strtotime($device['expires_at']))])) ?>
</div>
</div><?php if ($active): ?><form method="post" action="/account/devices/revoke"><input type="hidden"
name="id" value="<?= (int)$device['id'] ?>"><button class="btn btn-sm btn-outline-danger"
title="<?= h(t('devices.revoke')) ?>">×</button></form><?php endif; ?>
</div>
</div>
</div><?php endforeach; ?><?php if (!$devices): ?><div class="col-12">
<div class="card">
<div class="card-body text-center text-muted py-5"><?= h(t('devices.none')) ?></div>
</div>
</div><?php endif; ?></div>

Some files were not shown because too many files have changed in this diff Show more