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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue