Globinours/app/Controllers/MedicalDocumentsController.php

334 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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