334 lines
13 KiB
PHP
334 lines
13 KiB
PHP
<?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();
|
||
}
|
||
}
|