'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(); } }