Publier Globinours 1.0.0-rc.3
This commit is contained in:
parent
ea8c24d622
commit
9a2b4068da
325 changed files with 38230 additions and 20 deletions
513
app/Controllers/AccountingController.php
Normal file
513
app/Controllers/AccountingController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
101
app/Controllers/AdminController.php
Normal file
101
app/Controllers/AdminController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
70
app/Controllers/AdoptionsController.php
Normal file
70
app/Controllers/AdoptionsController.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
}
|
||||
267
app/Controllers/AgendaController.php
Normal file
267
app/Controllers/AgendaController.php
Normal 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 !== '' ? '&' . str_replace('&', '&', $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;
|
||||
}
|
||||
}
|
||||
1017
app/Controllers/AnimalBrowseActions.php
Normal file
1017
app/Controllers/AnimalBrowseActions.php
Normal file
File diff suppressed because it is too large
Load diff
87
app/Controllers/AnimalLifecycleActions.php
Normal file
87
app/Controllers/AnimalLifecycleActions.php
Normal 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();
|
||||
}
|
||||
}
|
||||
706
app/Controllers/AnimalMedicalActions.php
Normal file
706
app/Controllers/AnimalMedicalActions.php
Normal 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);
|
||||
}
|
||||
}
|
||||
296
app/Controllers/AnimalPhotoActions.php
Normal file
296
app/Controllers/AnimalPhotoActions.php
Normal 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();
|
||||
}
|
||||
}
|
||||
1041
app/Controllers/AnimalRecordActions.php
Normal file
1041
app/Controllers/AnimalRecordActions.php
Normal file
File diff suppressed because it is too large
Load diff
443
app/Controllers/AnimalRelationshipActions.php
Normal file
443
app/Controllers/AnimalRelationshipActions.php
Normal 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();
|
||||
}
|
||||
}
|
||||
602
app/Controllers/AnimalsController.php
Normal file
602
app/Controllers/AnimalsController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
209
app/Controllers/Asm3ImportController.php
Normal file
209
app/Controllers/Asm3ImportController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
199
app/Controllers/AuthController.php
Normal file
199
app/Controllers/AuthController.php
Normal 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';
|
||||
}
|
||||
}
|
||||
463
app/Controllers/CareRoundsController.php
Normal file
463
app/Controllers/CareRoundsController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
300
app/Controllers/DashboardController.php
Normal file
300
app/Controllers/DashboardController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
305
app/Controllers/DeathController.php
Normal file
305
app/Controllers/DeathController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
129
app/Controllers/DevController.php
Normal file
129
app/Controllers/DevController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
58
app/Controllers/DevicesController.php
Normal file
58
app/Controllers/DevicesController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
163
app/Controllers/DewormingController.php
Normal file
163
app/Controllers/DewormingController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
279
app/Controllers/DirectoryController.php
Normal file
279
app/Controllers/DirectoryController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
150
app/Controllers/DocumentsController.php
Normal file
150
app/Controllers/DocumentsController.php
Normal 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';
|
||||
}
|
||||
}
|
||||
240
app/Controllers/GrantsController.php
Normal file
240
app/Controllers/GrantsController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
72
app/Controllers/InventoryController.php
Normal file
72
app/Controllers/InventoryController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
334
app/Controllers/MedicalDocumentsController.php
Normal file
334
app/Controllers/MedicalDocumentsController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
172
app/Controllers/PlacementsController.php
Normal file
172
app/Controllers/PlacementsController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
113
app/Controllers/PrivacyController.php
Normal file
113
app/Controllers/PrivacyController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
14
app/Controllers/ProjectController.php
Normal file
14
app/Controllers/ProjectController.php
Normal 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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
272
app/Controllers/PublicSiteController.php
Normal file
272
app/Controllers/PublicSiteController.php
Normal 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')]);
|
||||
}
|
||||
}
|
||||
26
app/Controllers/SearchController.php
Normal file
26
app/Controllers/SearchController.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
998
app/Controllers/SettingsController.php
Normal file
998
app/Controllers/SettingsController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
42
app/Controllers/StatisticsController.php
Normal file
42
app/Controllers/StatisticsController.php
Normal 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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
116
app/Controllers/SurgeriesController.php
Normal file
116
app/Controllers/SurgeriesController.php
Normal 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
441
app/Routes/web.php
Normal 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';
|
||||
}
|
||||
192
app/Services/AdoptionService.php
Normal file
192
app/Services/AdoptionService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
351
app/Services/AgendaService.php
Normal file
351
app/Services/AgendaService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
95
app/Services/AppSettings.php
Normal file
95
app/Services/AppSettings.php
Normal 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;
|
||||
}
|
||||
}
|
||||
490
app/Services/Asm3Analyzer.php
Normal file
490
app/Services/Asm3Analyzer.php
Normal 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;
|
||||
}
|
||||
}
|
||||
138
app/Services/Asm3AnimalImporter.php
Normal file
138
app/Services/Asm3AnimalImporter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
169
app/Services/Asm3ClinicalImporter.php
Normal file
169
app/Services/Asm3ClinicalImporter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
103
app/Services/Asm3ContactImporter.php
Normal file
103
app/Services/Asm3ContactImporter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
164
app/Services/Asm3ContactPlanner.php
Normal file
164
app/Services/Asm3ContactPlanner.php
Normal 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);
|
||||
}
|
||||
}
|
||||
92
app/Services/Asm3IntakeRecovery.php
Normal file
92
app/Services/Asm3IntakeRecovery.php
Normal 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;
|
||||
}
|
||||
}
|
||||
102
app/Services/Asm3LitterImporter.php
Normal file
102
app/Services/Asm3LitterImporter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
199
app/Services/Asm3MigrationPlanner.php
Normal file
199
app/Services/Asm3MigrationPlanner.php
Normal 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 : '';
|
||||
}
|
||||
}
|
||||
271
app/Services/Asm3MovementImporter.php
Normal file
271
app/Services/Asm3MovementImporter.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
76
app/Services/Asm3ResetService.php
Normal file
76
app/Services/Asm3ResetService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
69
app/Services/AssociationBrand.php
Normal file
69
app/Services/AssociationBrand.php
Normal 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';
|
||||
}
|
||||
}
|
||||
40
app/Services/AuditService.php
Normal file
40
app/Services/AuditService.php
Normal 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
201
app/Services/Auth.php
Normal 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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
467
app/Services/BackupService.php
Normal file
467
app/Services/BackupService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
124
app/Services/CalendarFeedService.php
Normal file
124
app/Services/CalendarFeedService.php
Normal 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
41
app/Services/DB.php
Normal 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;
|
||||
}
|
||||
}
|
||||
536
app/Services/DemoDataService.php
Normal file
536
app/Services/DemoDataService.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
537
app/Services/DocumentsService.php
Normal file
537
app/Services/DocumentsService.php
Normal 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));
|
||||
}
|
||||
}
|
||||
178
app/Services/ExportService.php
Normal file
178
app/Services/ExportService.php
Normal 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];
|
||||
}
|
||||
}
|
||||
62
app/Services/GeoService.php
Normal file
62
app/Services/GeoService.php
Normal 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];
|
||||
}
|
||||
}
|
||||
70
app/Services/GeocodeService.php
Normal file
70
app/Services/GeocodeService.php
Normal 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'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
82
app/Services/GlobalSearchService.php
Normal file
82
app/Services/GlobalSearchService.php
Normal 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));
|
||||
}
|
||||
}
|
||||
313
app/Services/GrantService.php
Normal file
313
app/Services/GrantService.php
Normal 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
51
app/Services/I18n.php
Normal 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);
|
||||
}
|
||||
}
|
||||
363
app/Services/IcadService.php
Normal file
363
app/Services/IcadService.php
Normal 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
50
app/Services/Ids.php
Normal 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);
|
||||
}
|
||||
}
|
||||
131
app/Services/ImageService.php
Normal file
131
app/Services/ImageService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
137
app/Services/InstallationService.php
Normal file
137
app/Services/InstallationService.php
Normal 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), '/');
|
||||
}
|
||||
}
|
||||
211
app/Services/InventoryService.php
Normal file
211
app/Services/InventoryService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
36
app/Services/LabReferenceService.php
Normal file
36
app/Services/LabReferenceService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
115
app/Services/LocationHistoryService.php
Normal file
115
app/Services/LocationHistoryService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
62
app/Services/Migrations.php
Normal file
62
app/Services/Migrations.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
142
app/Services/NotificationService.php
Normal file
142
app/Services/NotificationService.php
Normal 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)];
|
||||
}
|
||||
}
|
||||
258
app/Services/PermissionService.php
Normal file
258
app/Services/PermissionService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
78
app/Services/PricingService.php
Normal file
78
app/Services/PricingService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
188
app/Services/PrivacyService.php
Normal file
188
app/Services/PrivacyService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
148
app/Services/PrivateMediaService.php
Normal file
148
app/Services/PrivateMediaService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
97
app/Services/SecurityService.php
Normal file
97
app/Services/SecurityService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
59
app/Services/ShelterRoomService.php
Normal file
59
app/Services/ShelterRoomService.php
Normal 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) ?? '', '-');
|
||||
}
|
||||
}
|
||||
79
app/Services/SpeciesService.php
Normal file
79
app/Services/SpeciesService.php
Normal 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');
|
||||
}
|
||||
}
|
||||
705
app/Services/StatisticsService.php
Normal file
705
app/Services/StatisticsService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
207
app/Services/SystemHealthService.php
Normal file
207
app/Services/SystemHealthService.php
Normal 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 '';
|
||||
}
|
||||
}
|
||||
144
app/Services/TrustedDeviceService.php
Normal file
144
app/Services/TrustedDeviceService.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
82
app/Views/_animal_show_activity.php
Normal file
82
app/Views/_animal_show_activity.php
Normal 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>
|
||||
48
app/Views/_animal_show_documents.php
Normal file
48
app/Views/_animal_show_documents.php
Normal 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; ?>
|
||||
31
app/Views/_animal_show_history.php
Normal file
31
app/Views/_animal_show_history.php
Normal 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>
|
||||
459
app/Views/_animal_show_identity.php
Normal file
459
app/Views/_animal_show_identity.php
Normal 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>
|
||||
68
app/Views/_animal_show_litters.php
Normal file
68
app/Views/_animal_show_litters.php
Normal 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'] ?>&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; ?>
|
||||
71
app/Views/_animal_show_locations.php
Normal file
71
app/Views/_animal_show_locations.php
Normal 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>
|
||||
436
app/Views/_animal_show_medical.php
Normal file
436
app/Views/_animal_show_medical.php
Normal 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&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&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; ?>
|
||||
1105
app/Views/_animal_show_modals.php
Normal file
1105
app/Views/_animal_show_modals.php
Normal file
File diff suppressed because it is too large
Load diff
205
app/Views/_animal_show_photos.php
Normal file
205
app/Views/_animal_show_photos.php
Normal 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>
|
||||
67
app/Views/_deworming_modal.php
Normal file
67
app/Views/_deworming_modal.php
Normal 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>
|
||||
29
app/Views/_directory_medical_fields.php
Normal file
29
app/Views/_directory_medical_fields.php
Normal 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>
|
||||
63
app/Views/_expense_modal.php
Normal file
63
app/Views/_expense_modal.php
Normal 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; ?>
|
||||
37
app/Views/_expense_summary.php
Normal file
37
app/Views/_expense_summary.php
Normal 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; ?>
|
||||
15
app/Views/_expense_tariff_field.php
Normal file
15
app/Views/_expense_tariff_field.php
Normal 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
99
app/Views/_helpers.php
Normal 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>';
|
||||
}
|
||||
35
app/Views/_layout_footer.php
Normal file
35
app/Views/_layout_footer.php
Normal 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>
|
||||
152
app/Views/_layout_topbar.php
Normal file
152
app/Views/_layout_topbar.php
Normal 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>
|
||||
14
app/Views/_public_animal_cards.php
Normal file
14
app/Views/_public_animal_cards.php
Normal 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; ?>
|
||||
31
app/Views/_settings_links.php
Normal file
31
app/Views/_settings_links.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
21
app/Views/access_denied.php
Normal file
21
app/Views/access_denied.php
Normal 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>
|
||||
35
app/Views/account_devices.php
Normal file
35
app/Views/account_devices.php
Normal 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
Loading…
Reference in a new issue