diff --git a/.dockerignore b/.dockerignore index ede6f97..fb47be0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,9 +1,14 @@ .git +.idea node_modules vendor *.md .editorconfig .env +.env.example .gitignore .gitattributes storage/logs/*.log +docker-compose.yml +Dockerfile +.dockerignore diff --git a/.env.example b/.env.example index 56fa0f0..acef165 100644 --- a/.env.example +++ b/.env.example @@ -63,3 +63,7 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" +VITE_APP_VERSION="${APP_VERSION}" +VITE_APP_TAG="${APP_TAG}" + +VITE_SENTRY_DNS= diff --git a/app/Console/Commands/RecalculatePreoperativeMetric.php b/app/Console/Commands/RecalculatePreoperativeMetric.php new file mode 100644 index 0000000..dab0231 --- /dev/null +++ b/app/Console/Commands/RecalculatePreoperativeMetric.php @@ -0,0 +1,244 @@ +info('🚀 Начинаем пересчет предоперационного койко-дня...'); + + $query = Report::query(); + + // Фильтр по конкретному отчету + if ($reportId = $this->option('report')) { + $query->where('report_id', $reportId); + $this->info("📋 Фильтр: отчет ID {$reportId}"); + } + + // Фильтр по отделению + if ($departmentId = $this->option('department')) { + $query->where('rf_department_id', $departmentId); + $this->info("🏥 Фильтр: отделение ID {$departmentId}"); + } + + // Фильтр по дате + if ($from = $this->option('from')) { + $query->whereDate('created_at', '>=', $from); + $this->info("📅 Фильтр: с {$from}"); + } + + if ($to = $this->option('to')) { + $query->whereDate('created_at', '<=', $to); + $this->info("📅 Фильтр: по {$to}"); + } + + $force = $this->option('force'); + $chunkSize = (int)$this->option('chunk'); + + $totalReports = $query->count(); + + if ($totalReports === 0) { + $this->warn('❌ Отчеты не найдены'); + return 0; + } + + $this->info("📊 Найдено отчетов: {$totalReports}"); + + if ($totalReports > 1000 && !$this->confirm("⚠️ Обработка {$totalReports} отчетов может занять время. Продолжить?")) { + $this->info('❌ Операция отменена'); + return 0; + } + + $bar = $this->output->createProgressBar($totalReports); + $bar->start(); + + $updated = 0; + $skipped = 0; + $errors = 0; + $noPatients = 0; + + $query->orderBy('report_id')->chunk($chunkSize, function ($reports) use ($force, &$updated, &$skipped, &$errors, &$noPatients, $bar) { + foreach ($reports as $report) { + try { + $result = $this->processReport($report, $force); + + match ($result) { + 'updated' => $updated++, + 'skipped' => $skipped++, + 'no_patients' => $noPatients++, + default => null + }; + + } catch (\Exception $e) { + $errors++; + Log::error("Ошибка обработки отчета {$report->report_id}: " . $e->getMessage()); + } + + $bar->advance(); + } + }); + + $bar->finish(); + $this->newLine(2); + + // Итоговая таблица + $this->table( + ['Статус', 'Количество'], + [ + ['✅ Обновлено', $updated], + ['⏭️ Пропущено (уже есть)', $skipped], + ['👤 Нет пациентов', $noPatients], + ['❌ Ошибок', $errors], + ['📊 Всего', $totalReports], + ] + ); + + // Покажем примеры обновленных отчетов + if ($updated > 0) { + $this->newLine(); + $this->info('📋 Примеры обновленных отчетов:'); + + $samples = Report::whereHas('metrikaResults', function($q) { + $q->where('rf_metrika_item_id', 21); + }) + ->orderBy('report_id', 'desc') + ->limit(5) + ->get() + ->map(function($report) { + $metric = $report->metrikaResults + ->where('rf_metrika_item_id', 21) + ->first(); + return [ + 'report_id' => $report->report_id, + 'department' => $report->rf_department_id, + 'date' => $report->created_at->format('Y-m-d'), + 'preoperative_days' => $metric ? $metric->value : 'N/A', + ]; + }); + + $this->table(['ID отчета', 'Отделение', 'Дата', 'Предоп. дни'], $samples->toArray()); + } + + $this->newLine(); + $this->info('✅ Готово!'); + + return 0; + } + + /** + * Обработка одного отчета + */ + private function processReport(Report $report, bool $force): string + { + // Проверяем, есть ли уже метрика + $existing = MetrikaResult::where('rf_report_id', $report->report_id) + ->where('rf_metrika_item_id', 21) + ->first(); + + if ($existing && !$force) { + return 'skipped'; + } + + // Получаем пациентов из снапшотов + $snapshots = MedicalHistorySnapshot::where('rf_report_id', $report->report_id) + ->whereIn('patient_type', ['discharged', 'deceased']) + ->pluck('rf_medicalhistory_id'); + + if ($snapshots->isEmpty()) { + // Сохраняем 0 если нет пациентов + MetrikaResult::updateOrCreate( + [ + 'rf_report_id' => $report->report_id, + 'rf_metrika_item_id' => 21, + ], + ['value' => 0] + ); + return 'no_patients'; + } + + // Получаем первые операции и первые поступления + $operations = DB::table('stt_surgicaloperation as so') + ->join('stt_migrationpatient as mp', 'so.rf_MedicalHistoryID', '=', 'mp.rf_MedicalHistoryID') + ->whereIn('so.rf_MedicalHistoryID', $snapshots) + ->whereNotNull('so.Date') + ->whereNotNull('mp.DateIngoing') + ->select( + 'so.rf_MedicalHistoryID', + DB::raw('MIN(so."Date") as first_operation'), + DB::raw('MIN(mp."DateIngoing") as first_admission') + ) + ->groupBy('so.rf_MedicalHistoryID') + ->get(); + + if ($operations->isEmpty()) { + // Сохраняем 0 если нет операций + MetrikaResult::updateOrCreate( + [ + 'rf_report_id' => $report->report_id, + 'rf_metrika_item_id' => 21, + ], + ['value' => 0] + ); + return 'no_patients'; + } + + $totalDays = 0; + $count = 0; + + foreach ($operations as $op) { + $days = Carbon::parse($op->first_admission) + ->diffInDays(Carbon::parse($op->first_operation)); + + if ($days >= 0) { + $totalDays += $days; + $count++; + } + } + + $avgDays = $count > 0 ? round($totalDays / $count, 1) : 0; + + // Сохраняем метрику + MetrikaResult::updateOrCreate( + [ + 'rf_report_id' => $report->report_id, + 'rf_metrika_item_id' => 21, + ], + ['value' => $avgDays] + ); + + return 'updated'; + } +} diff --git a/app/Contracts/MetricCalculatorInterface.php b/app/Contracts/MetricCalculatorInterface.php new file mode 100644 index 0000000..fa0dd3e --- /dev/null +++ b/app/Contracts/MetricCalculatorInterface.php @@ -0,0 +1,9 @@ +data = $data; + $this->dateRange = $dateRange; + $this->reportName = $reportName; + } + + /** + * @return \Illuminate\Support\Collection + */ + public function collection() + { + return collect($this->data); + } + + /** + * Заголовки (с вложенной структурой) + */ + public function headings(): array + { + return [ + // Шапка отчета (первые 3 строки) + [$this->reportName], + ['Дата создания: ' . now()->format('d.m.Y H:i:s')], + [$this->formatDateRange()], + [], // Пустая строка для отступа + + // Первый уровень заголовков (с объединением) + [ + 'Отделение', + 'Кол-во коек', + 'Поступило', // Будет объединено с 4 колонками + '', '', '', // Пустые для заполнения + 'Выбыло', + 'Состоит', + 'Ср. койко-день', + 'Пред. опер. койко-день', + '% загруженности', + '% смертности', + 'Операции', // Будет объединено с 2 колонками + '', // Пустые для заполнения + 'Умерло', + 'Мед. персонал' + ], + + // Второй уровень заголовков (детализация) + [ + '', + '', + 'Всего', + 'План', + 'Экстр', + 'Перевод', + '', + '', + '', + '', + '', + '', + 'Э', + 'П', + '', + '' + ] + ]; + } + + /** + * Форматирование дат для шапки + */ + protected function formatDateRange(): string + { + if (isset($this->dateRange[0]) && isset($this->dateRange[1])) { + $startAt = Carbon::create($this->dateRange[0])->format('d.m.Y H:i'); + $endAt = Carbon::create($this->dateRange[1])->format('d.m.Y H:i'); + return 'Период: ' . $startAt . ' - ' . $endAt; + } + return 'Период: За весь период'; + } + + /** + * Маппинг данных для каждой строки + */ + public function map($row): array + { + // Заголовок группы (Хирургические отделения, Терапевтические отделения) + if (isset($row['isGroupHeader']) && $row['isGroupHeader']) { + return [ + $row['groupName'], // Название группы + '', '', '', '', '', '', '', '', '', '', '', '', '' + ]; + } + + // Итоговая строка + if (isset($row['isTotalRow']) && $row['isTotalRow']) { + return [ + $row['department'], + $row['beds'] ?? '', + $this->formatZero($row['recipients']['all'] ?? 0), + $this->formatZero($row['recipients']['plan'] ?? 0), + $this->formatZero($row['recipients']['emergency'] ?? 0), + $this->formatZero($row['recipients']['transferred'] ?? 0), + $this->formatZero($row['outcome'] ?? 0), + $this->formatZero($row['consist'] ?? 0), + $this->formatZero($row['averageBedDays'] ?? 0), + $this->formatZero($row['preoperativeDays'] ?? 0), + $this->formatZero($row['percentLoadedBeds'] ?? 0), + $this->formatZero($row['overallLethality'] ?? 0), + $this->formatZero($row['surgical']['emergency'] ?? 0), + $this->formatZero($row['surgical']['plan'] ?? 0), + $this->formatZero($row['deceased'] ?? 0), + $this->formatZero($row['countStaff'] ?? 0) + ]; + } + + // Обычное отделение + return [ + $row['department'] ?? '', + $this->formatZero($row['beds'] ?? 0), + $this->formatZero($row['recipients']['all'] ?? 0), + $this->formatZero($row['recipients']['plan'] ?? 0), + $this->formatZero($row['recipients']['emergency'] ?? 0), + $this->formatZero($row['recipients']['transferred'] ?? 0), + $this->formatZero($row['outcome'] ?? 0), + $this->formatZero($row['consist'] ?? 0), + $this->formatZero($row['averageBedDays'] ?? 0), + $this->formatZero($row['preoperativeDays'] ?? 0), + $this->formatZero($row['percentLoadedBeds'] ?? 0), + $this->formatZero($row['lethality'] ?? 0), + $this->formatZero($row['surgical']['emergency'] ?? 0), + $this->formatZero($row['surgical']['plan'] ?? 0), + $this->formatZero($row['deceased'] ?? 0), + $this->formatZero($row['countStaff'] ?? 0) + ]; + } + + /** + * Стилизация Excel файла + */ + public function styles(Worksheet $sheet) + { + // Поля (уменьшаем для экономии места) + $sheet->getPageMargins()->setLeft(0.2); + $sheet->getPageMargins()->setRight(0.2); + $sheet->getPageMargins()->setTop(0.2); + $sheet->getPageMargins()->setBottom(0.2); + + $sheet->getPageSetup()->setPaperSize(PageSetup::PAPERSIZE_A4); + $sheet->getPageSetup()->setOrientation(PageSetup::ORIENTATION_LANDSCAPE); + + $sheet->getPageSetup()->setFitToPage(true); + $sheet->getPageSetup()->setFitToWidth(1); // 1 страница в ширину + $sheet->getPageSetup()->setFitToHeight(1); // 1 страница в высоту (опционально) + +// $sheet->getPageSetup()->setScale(90); + + $highestRow = $sheet->getHighestRow(); + $highestColumn = $sheet->getHighestColumn(); + + // ОБЪЕДИНЕНИЕ ЯЧЕЕК ДЛЯ ШАПКИ (строки 1-3) + $sheet->mergeCells('A1:' . $highestColumn . '1'); // Наименование + $sheet->mergeCells('A2:' . $highestColumn . '2'); // Дата создания + $sheet->mergeCells('A3:' . $highestColumn . '3'); // Временной интервал + + // ОБЪЕДИНЕНИЕ ДЛЯ ОБЫЧНЫХ ЗАГОЛОВКОВ + $sheet->mergeCells('A5:A6'); + $sheet->mergeCells('B5:B6'); + $sheet->mergeCells('G5:G6'); + $sheet->mergeCells('H5:H6'); + $sheet->mergeCells('I5:I6'); + $sheet->mergeCells('J5:J6'); + $sheet->mergeCells('K5:K6'); + $sheet->mergeCells('L5:L6'); + $sheet->mergeCells('O5:O6'); + $sheet->mergeCells('P5:P6'); + + // ОБЪЕДИНЕНИЕ ДЛЯ ВЛОЖЕННЫХ ЗАГОЛОВКОВ + // Строка 5 (первый уровень заголовков) + $sheet->mergeCells('C5:F5'); // Объединяем "Поступило" (колонки C, D, E, F) + $sheet->mergeCells('M5:N5'); // Объединяем "Операции" (колонки M, N) + + // Устанавливаем значения для объединенных ячеек + $sheet->setCellValue('C5', 'Поступило'); + $sheet->setCellValue('M5', 'Операции'); + + // СТИЛИ ДЛЯ ШАПКИ ОТЧЕТА (строки 1-3) + $sheet->getStyle('A1:A3')->applyFromArray([ + 'font' => [ + 'bold' => true, + 'size' => 14, + ], + 'alignment' => [ + 'horizontal' => Alignment::HORIZONTAL_LEFT, + 'vertical' => Alignment::VERTICAL_CENTER, + ], + ]); + + // СТИЛИ ДЛЯ ЗАГОЛОВКОВ (строки 5 и 6) + $sheet->getStyle('A5:' . $highestColumn . '6')->applyFromArray([ + 'font' => [ + 'bold' => true, + 'color' => ['argb' => '000000'], + 'size' => 11, + ], + 'alignment' => [ + 'horizontal' => Alignment::HORIZONTAL_CENTER, + 'vertical' => Alignment::VERTICAL_CENTER, + 'wrapText' => true, + ], + 'borders' => [ + 'allBorders' => [ + 'borderStyle' => Border::BORDER_THIN, + 'color' => ['argb' => '2d2d30'], + ], + ], + ]); + + // ДОПОЛНИТЕЛЬНЫЕ СТИЛИ ДЛЯ ОБЪЕДИНЕННЫХ ЗАГОЛОВКОВ + $sheet->getStyle('C5')->applyFromArray([ + 'font' => ['bold' => true, 'color' => ['argb' => '000000']], + 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER], + ]); + + $sheet->getStyle('M5')->applyFromArray([ + 'font' => ['bold' => true, 'color' => ['argb' => '000000']], + 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER], + ]); + + // СТИЛИ ДЛЯ ВСЕХ СТРОК С ДАННЫМИ + $indexData = 0; + for ($row = 7; $row <= $highestRow; $row++) { + $cellValue = $sheet->getCell('A' . $row)->getValue(); + + $currentRowInData = $this->data[$indexData]; + // Заголовки групп (Хирургические отделения, Терапевтические отделения) + if (array_key_exists('isGroupHeader', $currentRowInData)) + { + $sheet->getStyle('A' . $row . ':' . $highestColumn . $row)->applyFromArray([ + 'font' => [ + 'bold' => true, + 'size' => 11, + ], + 'fill' => [ + 'fillType' => Fill::FILL_SOLID, + 'startColor' => ['argb' => 'D9E1F2'], // Светло-синий + ], + ]); + + // Объединяем ячейки для группы + $sheet->mergeCells('A' . $row . ':' . $highestColumn . $row); + } + + // Итоговые строки (ИТОГО:) + if (array_key_exists('isTotalRow', $currentRowInData)) { + $sheet->getStyle('A' . $row . ':' . $highestColumn . $row)->applyFromArray([ + 'font' => [ + 'bold' => true, + ], + 'fill' => [ + 'fillType' => Fill::FILL_SOLID, + 'startColor' => ['argb' => 'F2F2F2'], // Серый + ], + 'borders' => [ + 'top' => [ + 'borderStyle' => Border::BORDER_MEDIUM, + 'color' => ['argb' => '000000'], + ], + ], + ]); + } + + if ($indexData < count($this->data)) + $indexData++; + } + + // ГРАНИЦЫ ДЛЯ ВСЕЙ ТАБЛИЦЫ + $sheet->getStyle('A5:' . $highestColumn . $highestRow)->applyFromArray([ + 'borders' => [ + 'allBorders' => [ + 'borderStyle' => Border::BORDER_THIN, + 'color' => ['argb' => '000000'], + ], + ], + ]); + + // ВЫРАВНИВАНИЕ ДЛЯ ЧИСЛОВЫХ КОЛОНОК + $sheet->getStyle('B7:' . $highestColumn . $highestRow)->applyFromArray([ + 'alignment' => [ + 'horizontal' => Alignment::HORIZONTAL_CENTER, + ], + ]); + + // ВЫРАВНИВАНИЕ ДЛЯ НАЗВАНИЙ ОТДЕЛЕНИЙ + $sheet->getStyle('A7:A' . $highestRow)->applyFromArray([ + 'alignment' => [ + 'horizontal' => Alignment::HORIZONTAL_LEFT, + 'vertical' => Alignment::VERTICAL_CENTER, + ], + ]); + + // Устанавливаем ширину колонок + $sheet->getColumnDimension('A')->setWidth(25); // Отделение + $sheet->getColumnDimension('B')->setWidth(8); // Кол-во коек + $sheet->getColumnDimension('C')->setWidth(8); // Всего + $sheet->getColumnDimension('D')->setWidth(8); // План + $sheet->getColumnDimension('E')->setWidth(8); // Экстр + $sheet->getColumnDimension('F')->setWidth(10); // Перевод + $sheet->getColumnDimension('G')->setWidth(10); // Выбыло + $sheet->getColumnDimension('H')->setWidth(10); // Состоит + $sheet->getColumnDimension('I')->setWidth(10); // Ср. койко-день + $sheet->getColumnDimension('J')->setWidth(10); // Пред. опер. койко-день + $sheet->getColumnDimension('K')->setWidth(10); // % загруженности + $sheet->getColumnDimension('L')->setWidth(8); // % летальности + $sheet->getColumnDimension('M')->setWidth(8); // Операции Э + $sheet->getColumnDimension('N')->setWidth(8); // Операции П + $sheet->getColumnDimension('O')->setWidth(10); // Умерло + $sheet->getColumnDimension('P')->setWidth(10); // Мед. персонал + + // Увеличиваем высоту строк с заголовками + $sheet->getRowDimension(5)->setRowHeight(30); + $sheet->getRowDimension(6)->setRowHeight(25); + + // Добавляем примечание внизу +// $this->addFootnote($sheet, $highestRow, $highestColumn); + + return []; + } + + /** + * Форматирование нулевых значений + */ + protected function formatZero($value) + { + // Если значение null или пустая строка - возвращаем "0" + if (is_null($value) || $value === '' || $value === []) { + return '—'; + } + + // Если это массив (для recipients или surgical) - такого не должно быть, + // но на всякий случай обработаем + if (is_array($value)) { + return '0'; + } + + // Если это число с плавающей точкой и оно равно 0 + if (is_numeric($value) && $value == 0) { + return '0'; + } + + return $value; + } + + /** + * Добавить примечание в конец файла + */ + public function addFootnote(Worksheet $sheet, int $startRow, string $highestColumn): void + { + $footnoteRow = $startRow + 2; // Отступаем 2 строки от таблицы + + $sheet->setCellValue('A' . $footnoteRow, 'ПРИМЕЧАНИЕ:'); + + $sheet->setCellValue('B' . $footnoteRow, '• % загруженности актуален на дату формирования отчёта (' . now()->format('d.m.Y') . ')'); + $sheet->mergeCells('B' . $footnoteRow . ':' . $highestColumn . $footnoteRow); + + $sheet->setCellValue('B' . ($footnoteRow + 1), '• Поступление, выбытие, операции — за указанный в шапке период'); + $sheet->mergeCells('B' . ($footnoteRow + 1) . ':' . $highestColumn . ($footnoteRow + 1)); + + // Стили для примечания + $sheet->getStyle('A' . $footnoteRow . ':' . $highestColumn . ($footnoteRow + 1))->applyFromArray([ + 'font' => [ + 'italic' => true, + 'size' => 9, + 'color' => ['argb' => '666666'], + ], + 'alignment' => [ + 'vertical' => Alignment::VERTICAL_CENTER, + ], + ]); + + // Жирный шрифт для слова "ПРИМЕЧАНИЕ" + $sheet->getStyle('A' . $footnoteRow)->applyFromArray([ + 'font' => [ + 'bold' => true, + 'color' => ['argb' => '000000'], + ], + ]); + } +} diff --git a/app/Factories/MetricCalculatorFactory.php b/app/Factories/MetricCalculatorFactory.php new file mode 100644 index 0000000..7324915 --- /dev/null +++ b/app/Factories/MetricCalculatorFactory.php @@ -0,0 +1,43 @@ +calculators = [ + 18 => AverageBedDaysCalculator::class, + 19 => LethalityCalculator::class, + 21 => PreoperativeDaysCalculator::class, + ]; + } + + public function getCalculator(int $metricId): MetricCalculatorInterface + { + if (!isset($this->calculators[$metricId])) { + throw new RuntimeException("No calculator for metric ID: {$metricId}"); + } + + $class = $this->calculators[$metricId]; + return app($class); + } + + public function getAllCalculators(): array + { + $calculators = []; + foreach (array_keys($this->calculators) as $metricId) { + $calculators[$metricId] = $this->getCalculator($metricId); + } + return $calculators; + } +} diff --git a/app/Http/Controllers/Api/DepartmentController.php b/app/Http/Controllers/Api/DepartmentController.php index 9748e98..7674319 100644 --- a/app/Http/Controllers/Api/DepartmentController.php +++ b/app/Http/Controllers/Api/DepartmentController.php @@ -10,7 +10,10 @@ class DepartmentController extends Controller { public function index(Request $request) { - $departments = Department::all(); + $user = $request->user(); + + $departmentIds = $user->departments()->pluck('rf_department_id'); + $departments = Department::whereIn('department_id', $departmentIds)->orderBy('name_short')->get(); return response()->json($departments); } diff --git a/app/Http/Controllers/Api/MetrikaFormController.php b/app/Http/Controllers/Api/MetrikaFormController.php index 8111018..14ceee9 100644 --- a/app/Http/Controllers/Api/MetrikaFormController.php +++ b/app/Http/Controllers/Api/MetrikaFormController.php @@ -260,7 +260,9 @@ class MetrikaFormController extends Controller ->join('metrika_result_values as mv', 'mr.metrika_result_id', '=', 'mv.rf_metrika_result_id') ->join('reports as r', 'mr.rf_report_id', '=', 'r.report_id') ->where('mr.rf_metrika_group_id', $groupId) - ->whereBetween('r.sent_at', [$dateStart, $dateEnd]) +// ->whereBetween('r.sent_at', [$dateStart, $dateEnd]) + ->where('r.sent_at', '>', $dateStart) + ->where('r.sent_at', '<=', $dateEnd) ->when(!$user->isAdmin() && !$user->isHeadOfDepartment(), function ($query) use ($user) { return $query->where('r.rf_user_id', $user->id); }) @@ -353,7 +355,9 @@ class MetrikaFormController extends Controller $endDate = date("{$year}-{$month}-t", strtotime($startDate)); $reports = Report::where('rf_user_id', $user->id) - ->whereBetween('sent_at', [$startDate, $endDate]) +// ->whereBetween('sent_at', [$startDate, $endDate]) + ->where('sent_at', '>', $startDate) + ->where('sent_at', '<=', $endDate) ->get(); // Создаем календарь diff --git a/app/Http/Controllers/Api/OperationController.php b/app/Http/Controllers/Api/OperationController.php new file mode 100644 index 0000000..480b80e --- /dev/null +++ b/app/Http/Controllers/Api/OperationController.php @@ -0,0 +1,24 @@ +validate([ + 'historyId' => 'required|integer' + ]); + + $operations = MisSurgicalOperation::where('rf_MedicalHistoryID', $request->historyId)->get(); + + return response()->json( + OperationsResource::collection($operations) + ); + } +} diff --git a/app/Http/Controllers/Api/ReportController.php b/app/Http/Controllers/Api/ReportController.php index 6f097f7..975136b 100644 --- a/app/Http/Controllers/Api/ReportController.php +++ b/app/Http/Controllers/Api/ReportController.php @@ -45,7 +45,7 @@ class ReportController extends Controller $endDateCarbon = Carbon::now(); // Определяем даты в зависимости от роли - [$startDate, $endDate] = $this->dateService->getDateRangeForUser($user, $request->query('startAt'), $request->query('endAt')); + [$startDate, $endDate] = $this->dateRangeService->getDateRangeForUser($user, $request->query('startAt'), $request->query('endAt')); if (Carbon::parse($startDate)->isValid()) { $startDateCarbon = Carbon::parse($startDate)->setTimeZone('Asia/Yakutsk'); } @@ -737,12 +737,16 @@ class ReportController extends Controller if ($isHeadOrAdmin) { // Заведующий: используем whereInDepartment $query = MisMigrationPatient::whereInDepartment($branchId) - ->whereBetween('DateIngoing', [$startDate, $endDate]); +// ->whereBetween('DateIngoing', [$startDate, $endDate]); + ->where('DateIngoing', '>=', $startDate) + ->where('DateIngoing', '<=', $endDate); } else { // Врач: используем currentlyInTreatment + фильтр по дате $query = MisMigrationPatient::currentlyInTreatment($branchId) ->when($today, function ($query) use ($startDate, $endDate) { - return $query->whereBetween('DateIngoing', [$startDate, $endDate]); +// return $query->whereBetween('DateIngoing', [$startDate, $endDate]); + return $query->where('DateIngoing', '>=', $startDate) + ->where('DateIngoing', '<=', $endDate); }); } } @@ -757,7 +761,9 @@ class ReportController extends Controller // Получаем истории $query = MisMedicalHistory::whereIn('MedicalHistoryID', $medicalHistoryIds) ->with(['surgicalOperations' => function ($query) use ($startDate, $endDate) { - $query->whereBetween('Date', [$startDate, $endDate]); +// $query->whereBetween('Date', [$startDate, $endDate]); + $query->where('Date', '>=', $startDate) + ->where('Date', '<=', $endDate); }]) ->orderBy('DateRecipient', 'DESC'); @@ -874,7 +880,9 @@ class ReportController extends Controller private function getSurgicalPatients(string $status, bool $isHeadOrAdmin, $branchId, $startDate, $endDate, bool $returnedCount = false) { $query = MisSurgicalOperation::where('rf_StationarBranchID', $branchId) - ->whereBetween('Date', [$startDate, $endDate]) +// ->whereBetween('Date', [$startDate, $endDate]) + ->where('Date', '>=', $startDate) + ->where('Date', '<=', $endDate) ->orderBy('Date', 'DESC'); if ($status === 'plan') { @@ -953,6 +961,7 @@ class ReportController extends Controller }) ->active() ->whereNotIn('LPUDoctorID', [0, 1]) + ->orderBy('FAM_V') ->get(); return response()->json([ @@ -967,8 +976,10 @@ class ReportController extends Controller { if (Carbon::parse($startDate)->diffInDays(Carbon::parse($endDate)) > 1.0) return Report::where('rf_department_id', $departmentId) - ->whereBetween('created_at', [$startDate, $endDate]) - ->orderBy('created_at', 'ASC') +// ->whereBetween('created_at', [$startDate, $endDate]) + ->where('sent_at', '>=', $startDate) + ->where('sent_at', '<=', $endDate) + ->orderBy('sent_at', 'ASC') ->get(); else return Report::where('rf_department_id', $departmentId) diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index 06fe039..1024e75 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -5,8 +5,10 @@ namespace App\Http\Controllers; use App\Models\User; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Redis; use Illuminate\Support\Facades\Validator; +use Illuminate\Support\Str; use Inertia\Inertia; class AuthController extends Controller @@ -16,39 +18,41 @@ class AuthController extends Controller $validator = Validator::make($request->all(), [ 'login' => 'required', 'password' => 'required', - 'remember' => 'boolean' ]); + $login = Str::lower($request->login); + $password = $request->password; + if ($validator->fails()) { - return response()->json([ - 'success' => false, - 'errors' => $validator->errors() - ], 422); + return back()->withErrors([ + $validator->errors() + ]); } - $credentials = $request->only('login', 'password'); + $credentials = [ + 'login' => $login, + 'password' => $password, + ]; - if (!Auth::attempt($credentials, $request->remember)) { - return response()->json([ - 'success' => false, - 'message' => 'Неверный login или пароль' - ], 401); + if (!Auth::attempt($credentials)) { + return back()->withErrors([ + 'Неверный логин или пароль' + ]); } - $user = User::where('login', $request->login)->first(); + $user = User::where('login', $login)->first(); if (!$user->is_active) { Auth::logout(); - return response()->json([ - 'success' => false, - 'message' => 'Учетная запись отключена' - ], 403); + return back()->withErrors([ + 'Учетная запись отключена' + ]); } $request->session()->regenerate(); - $deviceName = 'web-' . ($request->header('User-Agent') ?: 'browser'); - $token = $user->createToken($deviceName)->plainTextToken; + $tokenName = $request->session()->getId(); + $token = $user->createToken($tokenName, ['*'], now()->addYears(5))->plainTextToken; $request->session()->put('token', $token); @@ -65,11 +69,42 @@ class AuthController extends Controller 'role_id' => 'required|integer|exists:roles,role_id' ]); - $sessionKey = 'user_' . $user->id . '_current_role'; + $sessionId = session()->getId(); - $user->current_role_id = $data['role_id']; - $user->save(); + $token = $user->tokens()->where('name', $sessionId)->first(); + if ($token) { + $token->abilities = ['role:' . $request->role_id]; + $token->save(); + } + + DB::table('sessions') + ->where('id', $sessionId) + ->update(['role_id' => $request->role_id]); + +// $sessionKey = 'user_' . $user->id . '_current_role'; +// +// $user->current_role_id = $data['role_id']; +// $user->save(); return redirect()->route('start')->setStatusCode(302); } + + public function logout(Request $request) + { + $user = Auth::user(); + + if ($user) { + $tokenName = $request->session()->getId(); + + // Удаляем все токены пользователя + $user->tokens()->where('name', $tokenName)->delete(); + + // Очищаем сессию + $request->session()->invalidate(); + $request->session()->regenerateToken(); + } + + return redirect()->route('login'); + } + } diff --git a/app/Http/Controllers/Web/ReportController.php b/app/Http/Controllers/Web/ReportController.php index 7409c67..51c11b3 100644 --- a/app/Http/Controllers/Web/ReportController.php +++ b/app/Http/Controllers/Web/ReportController.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Controller; use App\Http\Resources\Mis\FormattedPatientResource; use App\Models\Department; use App\Models\MetrikaGroup; +use App\Models\MetrikaItem; use App\Models\MisLpuDoctor; use App\Models\Report; use App\Models\UnwantedEvent; @@ -35,8 +36,9 @@ class ReportController extends Controller $statistics = $this->reportService->getReportStatistics($department, $user, $dateRange); // Получаем метрики - $metrikaGroup = MetrikaGroup::whereMetrikaGroupId(2)->first(); - $metrikaItems = $metrikaGroup->metrikaItems; +// $metrikaGroup = MetrikaGroup::whereMetrikaGroupId(2)->first(); +// $metrikaItems = $metrikaGroup->metrikaItems; + $metrikaItems = MetrikaItem::whereIn('metrika_item_id', [3, 7, 8, 17])->get(); // Получаем информацию о текущем отчете $reportInfo = $this->reportService->getCurrentReportInfo($department, $user, $dateRange); @@ -47,6 +49,8 @@ class ReportController extends Controller 'department_id' => $department->department_id, 'beds' => $department->beds, 'percentLoadedBeds' => $this->calculateBedOccupancy($department, $user), + 'recipientPlanOfYear' => $this->reportService->getRecipientPlanOfYear($department, $dateRange)['plan'], + 'progressPlanOfYear' => $this->reportService->getRecipientPlanOfYear($department, $dateRange)['progress'], ...$statistics, ], 'dates' => [ diff --git a/app/Http/Controllers/Web/StatisticController.php b/app/Http/Controllers/Web/StatisticController.php index f005240..1b646fb 100644 --- a/app/Http/Controllers/Web/StatisticController.php +++ b/app/Http/Controllers/Web/StatisticController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Web; +use App\Exports\StatisticsExport; use App\Http\Controllers\Controller; use App\Models\Department; use App\Models\MetrikaForm; @@ -18,6 +19,7 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; use Inertia\Inertia; +use Maatwebsite\Excel\Facades\Excel; class StatisticController extends Controller { @@ -54,6 +56,21 @@ class StatisticController extends Controller 'isHeadOrAdmin' => $isHeadOrAdmin, 'date' => $date, 'isOneDay' => $isRangeOneDay, + 'recipientPlanOfYear' => $finalData['recipientPlanOfYear'] ]); } + + public function report(Request $request) + { + $user = $request->user(); + + $queryStartDate = $request->query('startAt'); + $queryEndDate = $request->query('endAt'); + [$startDate, $endDate] = $this->dateService->getStatisticsDateRange($user, $queryStartDate, $queryEndDate); + $isRangeOneDay = $this->dateService->isRangeOneDay($startDate, $endDate); + + $finalData = $this->statisticsService->getStatisticsData($user, $startDate, $endDate, $isRangeOneDay); + + return Excel::download(new StatisticsExport($finalData['data'], [$startDate, $endDate]), 'statistics.xlsx'); + } } diff --git a/app/Http/Middleware/Authenticate.php b/app/Http/Middleware/Authenticate.php new file mode 100644 index 0000000..642cf6d --- /dev/null +++ b/app/Http/Middleware/Authenticate.php @@ -0,0 +1,35 @@ +user() === null) { + // Проверяем, не находится ли пользователь на странице входа или в процессе авторизации + if ($request->is('login') || $request->is('auth/login')) { + return $next($request); + } + + throw new AuthenticationException( + 'Unauthenticated.', + $guards, + $request->expectsJson() ? null : $this->redirectTo($request), + ); + } + + return $next($request); + } + + protected function redirectTo($request): ?string + { + return $request->expectsJson() ? null : route('login'); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 855c8da..131bef0 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -40,6 +40,10 @@ class HandleInertiaRequests extends Middleware $user = $request->user() ?? Auth::guard('sanctum')->user(); return [ ...parent::share($request), + 'app' => [ + 'version' => config('app.version'), + 'tag' => config('app.tag') + ], 'user' => $user ? [ 'name' => $user->name, 'token' => Session::get('token'), @@ -47,7 +51,7 @@ class HandleInertiaRequests extends Middleware 'role' => $user->currentRole(), 'available_roles' => $user->roles, 'available_departments' => $user->availableDepartments(), - 'current_department' => $user->department + 'current_department' => $user->department->load('departmentType') ] : null, ]; } diff --git a/app/Http/Resources/Api/OperationsResource.php b/app/Http/Resources/Api/OperationsResource.php new file mode 100644 index 0000000..791b9e7 --- /dev/null +++ b/app/Http/Resources/Api/OperationsResource.php @@ -0,0 +1,29 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->SurgicalOperationID, + 'num' => $this->Num, + 'description' => trim($this->Description), + 'startAt' => $this->Date, + 'endAt' => $this->DataEnd, + 'duration' => $this->Date && $this->DataEnd ? + Carbon::parse($this->Date)->diffInMinutes(Carbon::parse($this->DataEnd)) : null, + 'service' => $this->serviceMedical, + ]; + } +} diff --git a/app/Http/Resources/Mis/FormattedPatientResource.php b/app/Http/Resources/Mis/FormattedPatientResource.php index 731675a..c83f070 100644 --- a/app/Http/Resources/Mis/FormattedPatientResource.php +++ b/app/Http/Resources/Mis/FormattedPatientResource.php @@ -20,10 +20,14 @@ class FormattedPatientResource extends JsonResource return [ 'id' => $this->MedicalHistoryID, 'num' => $this->num, - 'mkb.ds' => $this->migrations->first()->diagnosis->first()?->mkb?->DS, + 'mkb' => [ + 'ds' => $this->outcomeMigration->first()->mainDiagnosis?->mkb?->DS, + 'name' => $this->outcomeMigration->first()->mainDiagnosis?->mkb?->NAME + ], 'operations' => $this->surgicalOperations->map(function ($operation) { return [ - 'code' => $operation->serviceMedical->ServiceMedicalCode + 'code' => $operation->serviceMedical->ServiceMedicalCode, + 'name' => $operation->serviceMedical->ServiceMedicalName, ]; }), 'fullname' => Str::ucwords(Str::lower("$this->FAMILY $this->Name $this->OT")), diff --git a/app/Models/Department.php b/app/Models/Department.php index f0aa74a..ca0ef6b 100644 --- a/app/Models/Department.php +++ b/app/Models/Department.php @@ -3,6 +3,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Carbon; class Department extends Model { @@ -36,4 +37,19 @@ class Department extends Model { return $this->belongsTo(DepartmentType::class, 'rf_department_type', 'department_type_id'); } + + public function userDepartment() + { + return $this->belongsTo(UserDepartment::class, 'rf_department_id', 'department_id'); + } + + public function recipientPlanOfYear() + { + $now = Carbon::now(); + + return $this->metrikaDefault() + ->where('date_end', '>', $now) + ->where('rf_metrika_item_id', 23) + ->first(); + } } diff --git a/app/Models/LifeMisMigrationPatient.php b/app/Models/LifeMisMigrationPatient.php index 141f72b..aac655a 100644 --- a/app/Models/LifeMisMigrationPatient.php +++ b/app/Models/LifeMisMigrationPatient.php @@ -37,7 +37,7 @@ class LifeMisMigrationPatient extends Model { $query->where('rf_kl_VisitResultID', 0) ->where('rf_kl_StatCureResultID', 0) - ->whereBetween('DateIngoing', [$dateRange->startSql(), $dateRange->endSql()]) +// ->whereBetween('DateIngoing', [$dateRange->startSql(), $dateRange->endSql()]) // ->whereHas('medicalHistory', function ($query) use ($branchId, $dateRange) { // $query->whereDate('DateExtract', '1900-01-01'); // }) @@ -48,7 +48,9 @@ class LifeMisMigrationPatient extends Model } if ($dateRange) { - $query->whereBetween('DateIngoing', [$dateRange->startSql(), $dateRange->endSql()]); +// $query->whereBetween('DateIngoing', [$dateRange->startSql(), $dateRange->endSql()]); + $query->where('DateIngoing', '>=', $dateRange->startSql()) + ->where('DateIngoing', '<=', $dateRange->endSql()); } return $query; @@ -79,7 +81,9 @@ class LifeMisMigrationPatient extends Model } if ($dateRange) { - $query->whereBetween('DateOut', [$dateRange->startSql(), $dateRange->endSql()]); +// $query->whereBetween('DateOut', [$dateRange->startSql(), $dateRange->endSql()]); + $query->where('DateOut', '>=', $dateRange->startSql()) + ->where('DateOut', '<=', $dateRange->endSql()); } return $query; @@ -102,7 +106,9 @@ class LifeMisMigrationPatient extends Model } if ($dateRange) { - $query->whereBetween('DateOut', [$dateRange->startSql(), $dateRange->endSql()]); +// $query->whereBetween('DateOut', [$dateRange->startSql(), $dateRange->endSql()]); + $query->where('DateOut', '>=', $dateRange->startSql()) + ->where('DateOut', '<=', $dateRange->endSql()); } return $query; @@ -125,7 +131,9 @@ class LifeMisMigrationPatient extends Model } if ($dateRange) { - $query->whereBetween('DateOut', [$dateRange->startSql(), $dateRange->endSql()]); +// $query->whereBetween('DateOut', [$dateRange->startSql(), $dateRange->endSql()]); + $query->where('DateOut', '>=', $dateRange->startSql()) + ->where('DateOut', '<=', $dateRange->endSql()); } return $query; @@ -148,7 +156,9 @@ class LifeMisMigrationPatient extends Model } if ($dateRange) { - $query->whereBetween('DateOut', [$dateRange->startSql(), $dateRange->endSql()]); +// $query->whereBetween('DateOut', [$dateRange->startSql(), $dateRange->endSql()]); + $query->where('DateOut', '>=', $dateRange->startSql()) + ->where('DateOut', '<=', $dateRange->endSql()); } return $query; @@ -168,7 +178,9 @@ class LifeMisMigrationPatient extends Model } if ($dateRange) { - $query->whereBetween('DateOut', [$dateRange->startSql(), $dateRange->endSql()]); +// $query->whereBetween('DateOut', [$dateRange->startSql(), $dateRange->endSql()]); + $query->where('DateOut', '>=', $dateRange->startSql()) + ->where('DateOut', '<=', $dateRange->endSql()); } return $query; @@ -182,7 +194,9 @@ class LifeMisMigrationPatient extends Model $query->where('rf_kl_VisitResultID', '<>', 0) ->where('rf_MedicalHistoryID', '<>', 0) ->when($dateRange, function($query) use ($dateRange) { - return $query->whereBetween('DateOut', [$dateRange->startSql(), $dateRange->endSql()]); +// return $query->whereBetween('DateOut', [$dateRange->startSql(), $dateRange->endSql()]); + return $query->where('DateOut', '>=', $dateRange->startSql()) + ->where('DateOut', '<=', $dateRange->endSql()); }); if ($branchId) { diff --git a/app/Models/MisMedicalHistory.php b/app/Models/MisMedicalHistory.php index 8a70da4..5440169 100644 --- a/app/Models/MisMedicalHistory.php +++ b/app/Models/MisMedicalHistory.php @@ -102,7 +102,9 @@ class MisMedicalHistory extends Model public function scopeOperationOnBranch($query, $branchId, $startDate, $endDate) { return $this->surgicalOperations()->where('rf_StationarBranchID', $branchId) - ->whereBetween('Date', [$startDate, $endDate]); + ->where('Date', '>=', $startDate) + ->where('Date', '<=', $endDate); +// ->whereBetween('Date', [$startDate, $endDate]); } public function scopeCurrentlyHospitalized($query) @@ -143,6 +145,13 @@ class MisMedicalHistory extends Model return $this->hasMany(MisMigrationPatient::class, 'rf_MedicalHistoryID', 'MedicalHistoryID'); } + public function outcomeMigration() + { + return $this->migrations() + ->whereDate('DateOut', '<>', '2222-01-01') + ->orderBy('DateOut', 'desc'); + } + /* * Движение по StationarBranch */ @@ -166,7 +175,9 @@ class MisMedicalHistory extends Model ->where('sb.rf_DepartmentID', $departmentId); if ($startDate && $endDate) { - $q->whereBetween('mp.DateIngoing', [$startDate, $endDate]); + $q->where('mp.DateIngoing', '>=', $startDate) + ->where('mp.DateIngoing', '<=', $endDate); +// $q->whereBetween('mp.DateIngoing', [$startDate, $endDate]); } }); } diff --git a/app/Models/MisMigrationPatient.php b/app/Models/MisMigrationPatient.php index 0a999dd..b649028 100644 --- a/app/Models/MisMigrationPatient.php +++ b/app/Models/MisMigrationPatient.php @@ -21,6 +21,11 @@ class MisMigrationPatient extends Model return $this->hasMany(MisDiagnos::class, 'rf_MigrationPatientID', 'MigrationPatientID'); } + public function mainDiagnosis() + { + return $this->hasOne(MisDiagnos::class, 'rf_MigrationPatientID', 'MigrationPatientID')->where('rf_DiagnosTypeID', 3); + } + public function mkb() { return $this->hasOne(MisMKB::class, 'MKBID', 'rf_MKBID'); @@ -48,7 +53,9 @@ class MisMigrationPatient extends Model } if ($dateRange) { - $query->whereBetween('DateIngoing', [$dateRange->startSql(), $dateRange->endSql()]); +// $query->whereBetween('DateIngoing', [$dateRange->startSql(), $dateRange->endSql()]); + $query->where('DateIngoing', '>=', $dateRange->startSql()) + ->where('DateIngoing', '<=', $dateRange->endSql()); } return $query; @@ -79,7 +86,9 @@ class MisMigrationPatient extends Model } if ($dateRange) { - $query->whereBetween('DateOut', [$dateRange->startSql(), $dateRange->endSql()]); + $query->where('DateOut', '>=', $dateRange->startSql()) + ->where('DateOut', '<=', $dateRange->endSql()); +// $query->whereBetween('DateOut', [$dateRange->startSql(), $dateRange->endSql()]); } return $query; @@ -102,7 +111,8 @@ class MisMigrationPatient extends Model } if ($dateRange) { - $query->whereBetween('DateOut', [$dateRange->startSql(), $dateRange->endSql()]); + $query->where('DateOut', '>=', $dateRange->startSql()) + ->where('DateOut', '<=', $dateRange->endSql()); } return $query; @@ -125,7 +135,8 @@ class MisMigrationPatient extends Model } if ($dateRange) { - $query->whereBetween('DateOut', [$dateRange->startSql(), $dateRange->endSql()]); + $query->where('DateOut', '>=', $dateRange->startSql()) + ->where('DateOut', '<=', $dateRange->endSql()); } return $query; @@ -148,7 +159,8 @@ class MisMigrationPatient extends Model } if ($dateRange) { - $query->whereBetween('DateOut', [$dateRange->startSql(), $dateRange->endSql()]); + $query->where('DateOut', '>=', $dateRange->startSql()) + ->where('DateOut', '<=', $dateRange->endSql()); } return $query; @@ -168,7 +180,8 @@ class MisMigrationPatient extends Model } if ($dateRange) { - $query->whereBetween('DateOut', [$dateRange->startSql(), $dateRange->endSql()]); + $query->where('DateOut', '>=', $dateRange->startSql()) + ->where('DateOut', '<=', $dateRange->endSql()); } return $query; @@ -182,7 +195,8 @@ class MisMigrationPatient extends Model $query->where('rf_kl_VisitResultID', '<>', 0) ->where('rf_MedicalHistoryID', '<>', 0) ->when($dateRange, function($query) use ($dateRange) { - return $query->whereBetween('DateOut', [$dateRange->startSql(), $dateRange->endSql()]); + return $query->where('DateOut', '>=', $dateRange->startSql()) + ->where('DateOut', '<=', $dateRange->endSql()); }); if ($branchId) { diff --git a/app/Models/MisSurgicalOperation.php b/app/Models/MisSurgicalOperation.php index 1cb3e43..ef80dac 100644 --- a/app/Models/MisSurgicalOperation.php +++ b/app/Models/MisSurgicalOperation.php @@ -13,4 +13,9 @@ class MisSurgicalOperation extends Model { return $this->belongsTo(MisServiceMedical::class, 'rf_kl_ServiceMedicalID', 'ServiceMedicalID'); } + + public function medicalHistory() + { + return $this->belongsTo(MisMedicalHistory::class, 'rf_MedicalHistoryID', 'MedicalHistoryID'); + } } diff --git a/app/Models/User.php b/app/Models/User.php index bdc4504..c854290 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -10,6 +10,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable @@ -61,7 +63,7 @@ class User extends Authenticatable public function departments() { - return $this->hasMany(UserDepartment::class, 'rf_user_id', 'id'); + return $this->hasMany(UserDepartment::class, 'rf_user_id', 'id')->orderBy('order'); } public function favoriteDepartment() @@ -89,9 +91,37 @@ class User extends Authenticatable public function currentRole() { $defaultRoleId = $this->roles()->where('is_default', true)->first()->role_id; - $sessionKey = 'user_' . $this->id . '_current_role'; - $roleId = $this->current_role_id ?? $defaultRoleId; + if (app()->runningInConsole()) { + // Код выполняется в CLI (команда artisan, тесты и т.д.) + return Role::where('role_id', $defaultRoleId)->first(); + } + + $sessionId = session()->getId(); + $token = Auth::user()->currentAccessToken(); +// $sessionKey = 'user_' . $this->id . '_current_role'; +// $roleId = $this->current_role_id ?? $defaultRoleId; +// +// $role = Role::where('role_id', $roleId)->first(); + + if ($token) { + foreach ($token->abilities as $ability) { + if (str_starts_with($ability, 'role:')) { + $apiRoleId = (int) str_replace('role:', '', $ability); + $roleId = $apiRoleId ?? $defaultRoleId; + $role = Role::where('role_id', $roleId)->first(); + + if ($role) { + return $role; + } + } + } + } + + $sessionRoleId = DB::table('sessions')->where('id', $sessionId)->value('role_id'); + + $roleId = $sessionRoleId ?? $defaultRoleId; +// dd($sessionId); $role = Role::where('role_id', $roleId)->first(); return $role; diff --git a/app/Models/UserDepartment.php b/app/Models/UserDepartment.php index 78051fe..a184055 100644 --- a/app/Models/UserDepartment.php +++ b/app/Models/UserDepartment.php @@ -12,6 +12,13 @@ class UserDepartment extends Model 'rf_user_id', 'rf_department_id', 'is_favorite', + 'order', + 'user_name' + ]; + + protected $casts = [ + 'is_favorite' => 'boolean', + 'order' => 'integer', ]; public function user() diff --git a/app/Services/AutoReportService.php b/app/Services/AutoReportService.php index cb747fd..69d1883 100644 --- a/app/Services/AutoReportService.php +++ b/app/Services/AutoReportService.php @@ -6,7 +6,9 @@ use App\Models\Department; use App\Models\MedicalHistorySnapshot; use App\Models\MetrikaResult; use App\Models\MisStationarBranch; +use App\Models\ObservationPatient; use App\Models\Report; +use App\Models\UnwantedEvent; use App\Models\User; use Carbon\Carbon; use Carbon\CarbonPeriod; @@ -73,6 +75,8 @@ class AutoReportService if ($existingReport && $force) { MetrikaResult::where('rf_report_id', $existingReport->report_id)->delete(); MedicalHistorySnapshot::where('rf_report_id', $existingReport->report_id)->delete(); + UnwantedEvent::where('rf_report_id', $existingReport->report_id)->delete(); + ObservationPatient::where('rf_report_id', $existingReport->report_id)->delete(); $existingReport->delete(); } @@ -228,13 +232,14 @@ class AutoReportService 'metrika_item_12' => $metrics['emergency'] ?? 0, // экстренные 'metrika_item_3' => $metrics['recipient'] ?? 0, // поступившие // 'metrika_item_6' => ($metrics['plan_surgery'] ?? 0) + ($metrics['emergency_surgery'] ?? 0), // всего операций - 'metrika_item_7' => $metrics['discharged'] ?? 0, // выписанные + 'metrika_item_7' => $metrics['discharged'] + $metrics['deceased'], // выписанные 'metrika_item_8' => $metrics['current'] ?? 0, // текущие 'metrika_item_9' => $metrics['deceased'] ?? 0, // умершие 'metrika_item_11' => $metrics['plan_surgery'] ?? 0, // плановые операции 'metrika_item_10' => $metrics['emergency_surgery'] ?? 0, // экстренные операции 'metrika_item_13' => $metrics['transferred'] ?? 0, // переведенные 'metrika_item_14' => 0, // под наблюдением (будет заполнено отдельно) + 'metrika_item_15' => $metrics['discharged'] ?? 0, // выбыло ]; } diff --git a/app/Services/Base/BaseMetricService.php b/app/Services/Base/BaseMetricService.php new file mode 100644 index 0000000..af348c9 --- /dev/null +++ b/app/Services/Base/BaseMetricService.php @@ -0,0 +1,45 @@ +getCacheKey($departmentIds, $startDate, $endDate); + + if (isset($this->cache[$cacheKey])) { + return $this->cache[$cacheKey]; + } + + try { + $result = $this->calculate($departmentIds, $startDate, $endDate); + $this->cache[$cacheKey] = $result; + return $result; + } catch (\Exception $e) { + Log::error("Error in " . static::class . ": " . $e->getMessage()); + return array_fill_keys($departmentIds, 0); + } + } + + protected function getCacheKey(array $departmentIds, string $startDate, string $endDate): string + { + return static::class . '_' . md5(implode(',', $departmentIds) . $startDate . $endDate); + } + + public function clearCache(): void + { + $this->cache = []; + } +} diff --git a/app/Services/BedDayService.php b/app/Services/BedDayService.php index da14490..a9a00a0 100644 --- a/app/Services/BedDayService.php +++ b/app/Services/BedDayService.php @@ -36,12 +36,14 @@ class BedDayService // Для одного дня берем последние 30 дней для статистической значимости $actualStartDate = $isRangeOneDay - ? Carbon::parse($endDate)->subDays(30)->format('Y-m-d') + ? Carbon::now('Asia/Yakutsk')->startOfYear()->format('Y-m-d') : $startDate; // Находим отчеты за период $reports = Report::where('rf_department_id', $departmentId) - ->whereBetween('created_at', [$actualStartDate, $endDate]) +// ->whereBetween('created_at', [$actualStartDate, $endDate]) + ->where('sent_at', '>=', $actualStartDate) + ->where('sent_at', '<=', $endDate) ->pluck('report_id'); if ($reports->isEmpty()) { @@ -107,7 +109,9 @@ class BedDayService // Находим все отчеты за период по отделениям $reportsByDepartment = Report::whereIn('rf_department_id', $departmentIds) - ->whereBetween('created_at', [$actualStartDate, $endDate]) +// ->whereBetween('created_at', [$actualStartDate, $endDate]) + ->where('sent_at', '>=', $actualStartDate) + ->where('sent_at', '<=', $endDate) ->select('report_id', 'rf_department_id') ->get() ->groupBy('rf_department_id'); @@ -186,7 +190,9 @@ class BedDayService public function getDetailedStatsFromSnapshots(int $departmentId, string $startDate, string $endDate): array { $reports = Report::where('rf_department_id', $departmentId) - ->whereBetween('created_at', [$startDate, $endDate]) +// ->whereBetween('created_at', [$startDate, $endDate]) + ->where('sent_at', '>', $startDate) + ->where('sent_at', '<=', $endDate) ->pluck('report_id'); if ($reports->isEmpty()) { @@ -292,7 +298,6 @@ class BedDayService // Для каждого отчета считаем средний койко-день за последние 30 дней до даты отчета $endDate = $report->created_at; $startDate = Carbon::startOfYear(); - dd($startDate); $avg = $this->getAverageBedDaysFromSnapshots( $report->rf_department_id, diff --git a/app/Services/DateRange.php b/app/Services/DateRange.php index d7c0ba7..a493af9 100644 --- a/app/Services/DateRange.php +++ b/app/Services/DateRange.php @@ -63,6 +63,16 @@ readonly class DateRange return $this->endDate->getTimestampMs(); } + public function startFirstOfMonth() + { + return $this->startDate->firstOfMonth()->setHour(6)->format('Y-m-d H:i:s'); + } + + public function endFirstOfMonth() + { + return $this->endDate->firstOfMonth()->setHour(6)->format('Y-m-d H:i:s'); + } + /** * Проверить, является ли дата сегодняшней */ diff --git a/app/Services/MetricCalculators/AverageBedDaysCalculator.php b/app/Services/MetricCalculators/AverageBedDaysCalculator.php new file mode 100644 index 0000000..209cd57 --- /dev/null +++ b/app/Services/MetricCalculators/AverageBedDaysCalculator.php @@ -0,0 +1,47 @@ +join('metrika_results as mr', 'r.report_id', '=', 'mr.rf_report_id') + ->whereIn('r.rf_department_id', $departmentIds) + ->where('mr.rf_metrika_item_id', 18) +// ->whereBetween('r.created_at', [$startDate, $endDate]) + ->where('r.sent_at', '>', $startDate) + ->where('r.sent_at', '<=', $endDate) + ->select( + 'r.rf_department_id', + DB::raw('AVG(CAST(mr.value AS DECIMAL)) as avg_value') + ) + ->groupBy('r.rf_department_id') + ->get() + ->keyBy('rf_department_id'); + + $averages = []; + foreach ($departmentIds as $deptId) { + $averages[$deptId] = isset($results[$deptId]) + ? round((float)$results[$deptId]->avg_value, 1) + : 0; + } + + return $averages; + } +} diff --git a/app/Services/MetricCalculators/LethalityCalculator.php b/app/Services/MetricCalculators/LethalityCalculator.php new file mode 100644 index 0000000..f8f9a25 --- /dev/null +++ b/app/Services/MetricCalculators/LethalityCalculator.php @@ -0,0 +1,58 @@ +join('metrika_results as mr', 'r.report_id', '=', 'mr.rf_report_id') + ->whereIn('r.rf_department_id', $departmentIds) + ->whereIn('mr.rf_metrika_item_id', [7, 9]) +// ->whereBetween('r.created_at', [$startDate, $endDate]) + ->where('r.sent_at', '>', $startDate) + ->where('r.sent_at', '<=', $endDate) + ->select( + 'r.rf_department_id', + 'mr.rf_metrika_item_id', + DB::raw('SUM(CAST(mr.value AS INTEGER)) as total') + ) + ->groupBy('r.rf_department_id', 'mr.rf_metrika_item_id') + ->get() + ->groupBy('rf_department_id'); + + $lethality = []; + foreach ($departmentIds as $deptId) { + $deceased = 0; + $discharged = 0; + + if (isset($results[$deptId])) { + foreach ($results[$deptId] as $item) { + if ($item->rf_metrika_item_id == 9) $deceased = (int)$item->total; + else $discharged = (int)$item->total; + } + } + + $lethality[$deptId] = ($discharged > 0 && $deceased > 0) + ? round(($deceased / $discharged) * 100, 1) + : 0; + } + + return $lethality; + } +} diff --git a/app/Services/MetricCalculators/PreoperativeDaysCalculator.php b/app/Services/MetricCalculators/PreoperativeDaysCalculator.php new file mode 100644 index 0000000..454c6d5 --- /dev/null +++ b/app/Services/MetricCalculators/PreoperativeDaysCalculator.php @@ -0,0 +1,99 @@ +where('sent_at', '>', $startDate) + ->where('sent_at', '<=', $endDate) +// ->whereBetween('created_at', [$startDate, $endDate]) + ->get(['report_id', 'rf_department_id']) + ->keyBy('report_id'); + + if ($reports->isEmpty()) { + return array_fill_keys($departmentIds, 0); + } + + $reportIds = $reports->keys()->toArray(); + + // Получаем пациентов из снапшотов + $snapshots = MedicalHistorySnapshot::whereIn('rf_report_id', $reportIds) + ->whereIn('patient_type', ['discharged', 'deceased']) + ->get(['rf_report_id', 'rf_medicalhistory_id']); + + if ($snapshots->isEmpty()) { + return array_fill_keys($departmentIds, 0); + } + + $historyIds = $snapshots->pluck('rf_medicalhistory_id')->unique()->toArray(); + + // Получаем первые операции и первые поступления одним запросом + $operations = DB::table('stt_surgicaloperation as so') + ->join('stt_migrationpatient as mp', 'so.rf_MedicalHistoryID', '=', 'mp.rf_MedicalHistoryID') + ->whereIn('so.rf_MedicalHistoryID', $historyIds) + ->whereNotNull('so.Date') + ->whereNotNull('mp.DateIngoing') + ->select( + 'so.rf_MedicalHistoryID', + DB::raw('MIN(so."Date") as first_operation'), + DB::raw('MIN(mp."DateIngoing") as first_admission') + ) + ->groupBy('so.rf_MedicalHistoryID') + ->get() + ->keyBy('rf_MedicalHistoryID'); + + // Группируем по отделениям + $results = []; + foreach ($snapshots as $snapshot) { + $deptId = $reports[$snapshot->rf_report_id]->rf_department_id; + $historyId = $snapshot->rf_medicalhistory_id; + + if (!isset($operations[$historyId])) { + continue; + } + + $op = $operations[$historyId]; + $days = Carbon::parse($op->first_admission) + ->diffInDays(Carbon::parse($op->first_operation)); + + if ($days >= 0) { + if (!isset($results[$deptId])) { + $results[$deptId] = ['total' => 0, 'count' => 0]; + } + $results[$deptId]['total'] += $days; + $results[$deptId]['count']++; + } + } + + // Усредняем по отделениям + $preoperative = []; + foreach ($departmentIds as $deptId) { + $preoperative[$deptId] = isset($results[$deptId]) && $results[$deptId]['count'] > 0 + ? round($results[$deptId]['total'] / $results[$deptId]['count'], 1) + : 0; + } + + return $preoperative; + } +} diff --git a/app/Services/MetrikaService.php b/app/Services/MetrikaService.php new file mode 100644 index 0000000..157992b --- /dev/null +++ b/app/Services/MetrikaService.php @@ -0,0 +1,76 @@ +join('reports as r', 'mhs.rf_report_id', '=', 'r.report_id') + ->join('stt_migrationpatient as mp', 'mhs.rf_medicalhistory_id', '=', 'mp.rf_MedicalHistoryID') + ->join('stt_surgicaloperation as so', 'mhs.rf_medicalhistory_id', '=', 'so.rf_MedicalHistoryID') + ->whereIn('r.rf_department_id', $departmentIds) + ->whereDate('r.sent_at', '>=', $startDate) + ->whereDate('r.sent_at', '<=', $endDate) + ->whereIn('mhs.patient_type', ['discharged', 'deceased']) + ->select( + 'r.rf_department_id', + 'mp.rf_MedicalHistoryID', + DB::raw('MIN(mp."DateIngoing") as admission_date'), + DB::raw('MIN(so."Date") as first_operation_date') + ) + ->groupBy('r.rf_department_id', 'mp.rf_MedicalHistoryID') + ->havingRaw('MIN(so."Date") IS NOT NULL') + ->get() + ->groupBy('rf_department_id'); + + $preoperativeDays = []; + foreach ($departmentIds as $deptId) { + if (!isset($results[$deptId]) || $results[$deptId]->isEmpty()) { + $preoperativeDays[$deptId] = 0; + continue; + } + + $totalDays = 0; + $count = 0; + + foreach ($results[$deptId] as $item) { + $admission = Carbon::parse($item->admission_date); + $operation = Carbon::parse($item->first_operation_date); + $days = $admission->diffInDays($operation); + + if ($days >= 0) { + $totalDays += $days; + $count++; + } + } + + $preoperativeDays[$deptId] = $count > 0 ? round($totalDays / $count, 1) : 0; + } + + return $preoperativeDays; + + } catch (\Exception $e) { + \Log::error("Error in calculatePreoperativeDaysFromSnapshots: " . $e->getMessage()); + return array_fill_keys($departmentIds, 0); + } + } +} diff --git a/app/Services/MisPatientService.php b/app/Services/MisPatientService.php index d84be2b..6fbce98 100644 --- a/app/Services/MisPatientService.php +++ b/app/Services/MisPatientService.php @@ -71,7 +71,9 @@ class MisPatientService $query->with('migrations') ->whereHas('migrations', function ($q) use ($branchId, $dateRange) { $q->where('rf_StationarBranchID', $branchId) - ->whereBetween('DateIngoing', $dateRange); + ->where('DateIngoing', '>', $dateRange[0]) + ->where('DateIngoing', '<=', $dateRange[1]); +// ->whereBetween('DateIngoing', $dateRange); }); }) ->whereIn('rf_EmerSignID', [2, 4]) @@ -92,7 +94,9 @@ class MisPatientService $query->with('migrations') ->whereHas('migrations', function ($q) use ($branchId, $dateRange) { $q->where('rf_StationarBranchID', $branchId) - ->whereBetween('DateIngoing', $dateRange); + ->where('DateIngoing', '>=', $dateRange[0]) + ->where('DateIngoing', '<=', $dateRange[1]); +// ->whereBetween('DateIngoing', $dateRange); })->where('MedicalHistoryID', '<>', 0); return $query; diff --git a/app/Services/PatientService.php b/app/Services/PatientService.php index a6d6a2b..59fe762 100644 --- a/app/Services/PatientService.php +++ b/app/Services/PatientService.php @@ -59,15 +59,15 @@ class PatientService $query = MisMedicalHistory::whereIn('MedicalHistoryID', $medicalHistoryIds) ->with([ 'surgicalOperations' => function ($q) use ($dateRange) { - $q->whereBetween('Date', [$dateRange->startSql(), $dateRange->endSql()]); +// $q->whereBetween('Date', [$dateRange->startSql(), $dateRange->endSql()]); + $q->where('Date', '>=', $dateRange->startSql()) + ->where('Date', '<=', $dateRange->endSql()); }, 'migrations' => function ($q) use ($branchId) { $q->where('rf_StationarBranchID', $branchId) ->take(1) // берем только одну последнюю - ->with(['diagnosis' => function ($q) { - $q->where('rf_DiagnosTypeID', 3) - ->take(1) - ->with('mkb'); + ->with(['mainDiagnosis' => function ($q) { + $q->with('mkb'); }]); } ]) @@ -133,7 +133,9 @@ class PatientService return MisMedicalHistory::whereIn('MedicalHistoryID', $allIds) ->with(['surgicalOperations' => function ($q) use ($dateRange) { - $q->whereBetween('Date', [$dateRange->startSql(), $dateRange->endSql()]); +// $q->whereBetween('Date', [$dateRange->startSql(), $dateRange->endSql()]); + $q->where('Date', '>=', $dateRange->startSql()) + ->where('Date', '<=', $dateRange->endSql()); }]) ->orderBy('DateRecipient', 'DESC') ->get() @@ -161,7 +163,14 @@ class PatientService } if ($onlyIds) $patients = $query->pluck('MedicalHistoryID'); - else $patients = $query->get(); + else { + // Загрузка отношений, необходимых для FormattedPatientResource + $query->with([ + 'outcomeMigration.mainDiagnosis.mkb', // mkb через диагноз + 'surgicalOperations.serviceMedical', // операции с услугами + ]); + $patients = $query->get(); + } return $patients->map(function ($patient) { $patient->comment = $patient->observationPatient @@ -221,7 +230,9 @@ class PatientService bool $countOnly = false ) { $query = MisSurgicalOperation::where('rf_StationarBranchID', $branchId) - ->whereBetween('Date', [$dateRange->startSql(), $dateRange->endSql()]); + ->where('Date', '>=', $dateRange->startSql()) + ->where('Date', '<=', $dateRange->endSql()); +// ->whereBetween('Date', [$dateRange->startSql(), $dateRange->endSql()]); if ($type === 'plan') { $query->where('rf_TypeSurgOperationInTimeID', 6); @@ -273,10 +284,14 @@ class PatientService ) { if ($isHeadOrAdmin) { $query = MisMigrationPatient::whereInDepartment($branchId) - ->whereBetween('DateIngoing', [$dateRange->startSql(), $dateRange->endSql()]); + ->where('DateIngoing', '>=', $dateRange->startSql()) + ->where('DateIngoing', '<=', $dateRange->endSql()); +// ->whereBetween('DateIngoing', [$dateRange->startSql(), $dateRange->endSql()]); } else { $query = MisMigrationPatient::currentlyInTreatment($branchId) - ->whereBetween('DateIngoing', [$dateRange->startSql(), $dateRange->endSql()]); + ->where('DateIngoing', '>=', $dateRange->startSql()) + ->where('DateIngoing', '<=', $dateRange->endSql()); +// ->whereBetween('DateIngoing', [$dateRange->startSql(), $dateRange->endSql()]); } $medicalHistoryIds = $query->pluck('rf_MedicalHistoryID')->toArray(); @@ -315,19 +330,27 @@ class PatientService // Заведующий: все поступившие за период if ($fillableAuto) { $query = LifeMisMigrationPatient::whereInDepartment($branchId) - ->whereBetween('DateIngoing', [$dateRange->startSql(), $dateRange->endSql()]); + ->where('DateIngoing', '>=', $dateRange->startSql()) + ->where('DateIngoing', '<=', $dateRange->endSql()); +// ->whereBetween('DateIngoing', [$dateRange->startSql(), $dateRange->endSql()]); } else { $query = MisMigrationPatient::whereInDepartment($branchId) - ->whereBetween('DateIngoing', [$dateRange->startSql(), $dateRange->endSql()]); + ->where('DateIngoing', '>=', $dateRange->startSql()) + ->where('DateIngoing', '<=', $dateRange->endSql()); +// ->whereBetween('DateIngoing', [$dateRange->startSql(), $dateRange->endSql()]); } } else { // Врач: только поступившие за сутки if ($fillableAuto) { $query = LifeMisMigrationPatient::whereInDepartment($branchId) - ->whereBetween('DateIngoing', [$dateRange->startSql(), $dateRange->endSql()]); + ->where('DateIngoing', '>=', $dateRange->startSql()) + ->where('DateIngoing', '<=', $dateRange->endSql()); +// ->whereBetween('DateIngoing', [$dateRange->startSql(), $dateRange->endSql()]); } else { $query = MisMigrationPatient::whereInDepartment($branchId) - ->whereBetween('DateIngoing', [$dateRange->startSql(), $dateRange->endSql()]); + ->where('DateIngoing', '>=', $dateRange->startSql()) + ->where('DateIngoing', '<=', $dateRange->endSql()); +// ->whereBetween('DateIngoing', [$dateRange->startSql(), $dateRange->endSql()]); }; } diff --git a/app/Services/ReportService.php b/app/Services/ReportService.php index ffebdcd..747690a 100644 --- a/app/Services/ReportService.php +++ b/app/Services/ReportService.php @@ -50,35 +50,36 @@ class ReportService */ public function storeReport(array $data, User $user, $fillableAuto = false): Report { - DB::beginTransaction(); - - try { + $report = DB::transaction(function () use ($data, $user, $fillableAuto) { $report = $this->createOrUpdateReport($data, $user); + // Сохраняем все, что НЕ зависит от других отчетов $this->saveMetrics($report, $data['metrics'] ?? []); $this->saveUnwantedEvents($report, $data['unwantedEvents'] ?? []); $this->saveObservationPatients($report, $data['observationPatients'] ?? [], $user->rf_department_id); - - // Сохраняем снапшоты пациентов $this->snapshotService->createPatientSnapshots($report, $user, $data['dates'], $fillableAuto); + return $report; + }); + + DB::transaction(function () use ($report) { // Сохраняем метрику среднего койко-дня из снапшотов $this->saveAverageBedDaysMetricFromSnapshots($report); - DB::commit(); + $this->saveLethalMetricFromSnapshots($report); - // ОЧИСТКА КЭША ПОСЛЕ УСПЕШНОГО СОЗДАНИЯ ОТЧЕТА - $this->clearCacheAfterReportCreation($user, $report); + $this->savePreoperativeMetric($report); - return $report; - } catch (\Exception $e) { - DB::rollBack(); - throw $e; - } + $this->saveDepartmentLoadedMetric($report); + }); + + $this->clearCacheAfterReportCreation($user, $report); + + return $report; } /** - * Сохранить метрику среднего койко-дня из снапшотов отчета + * Сохранить метрику койко-дня из снапшотов отчета */ protected function saveAverageBedDaysMetricFromSnapshots(Report $report): void { @@ -128,7 +129,7 @@ class ReportService } } - $avgBedDays = $validCount > 0 ? round($totalDays / $validCount, 1) : 0; + $bedDays = $validCount > 0 ? $totalDays: 0; // Сохраняем метрику MetrikaResult::updateOrCreate( @@ -136,10 +137,10 @@ class ReportService 'rf_report_id' => $report->report_id, 'rf_metrika_item_id' => 18, ], - ['value' => $avgBedDays] + ['value' => $bedDays] ); - \Log::info("Saved average bed days metric for report {$report->report_id}: {$avgBedDays} (from {$validCount} patients)"); + //\Log::info("Saved average bed days metric for report {$report->report_id}: {$avgBedDays} (from {$validCount} patients)"); } catch (\Exception $e) { \Log::error("Failed to save average bed days metric: " . $e->getMessage()); @@ -147,16 +148,120 @@ class ReportService } } + protected function saveLethalMetricFromSnapshots(Report $report): void + { + // Получаем все снапшоты выписанных пациентов из этого отчета + $snapshots = MedicalHistorySnapshot::where('rf_report_id', $report->report_id) + ->whereIn('patient_type', ['discharged', 'deceased']) // выписанные и умершие + ->with('medicalHistory') + ->get(); + + if ($snapshots->isEmpty()) { + // Если нет выписанных, сохраняем 0 + MetrikaResult::updateOrCreate( + [ + 'rf_report_id' => $report->report_id, + 'rf_metrika_item_id' => 18, + ], + ['value' => 0] + ); + + \Log::info("No discharged patients in report {$report->report_id}, saved 0"); + return; + } + } + + /** + * Сохранить предоперационный койко-день из снапшотов + */ + protected function savePreoperativeMetric(Report $report): void + { + // 1. Получаем ВСЕ предыдущие отчеты этого отделения + $allPreviousReports = Report::where('rf_department_id', $report->rf_department_id) + ->where('sent_at', '<=', $report->sent_at) + ->orderBy('sent_at') + ->pluck('report_id'); + + if ($allPreviousReports->isEmpty()) { + $this->saveMetric($report, 21, 0); + return; + } + + // 2. Получаем ВСЕХ пациентов из всех отчетов (discharged + deceased) + $allPatients = MedicalHistorySnapshot::whereIn('rf_report_id', $allPreviousReports) + ->whereIn('patient_type', ['discharged', 'deceased']) + ->pluck('rf_medicalhistory_id') + ->unique(); + + if ($allPatients->isEmpty()) { + $this->saveMetric($report, 21, 0); + return; + } + + // 3. Получаем операции для ВСЕХ пациентов + $operations = DB::table('stt_surgicaloperation as so') + ->join('stt_migrationpatient as mp', 'so.rf_MedicalHistoryID', '=', 'mp.rf_MedicalHistoryID') + ->whereIn('so.rf_MedicalHistoryID', $allPatients) + ->whereNotNull('so.Date') + ->whereNotNull('mp.DateIngoing') + ->select( + 'so.rf_MedicalHistoryID', + DB::raw('MIN(so."Date") as first_operation'), + DB::raw('MIN(mp."DateIngoing") as first_admission') + ) + ->groupBy('so.rf_MedicalHistoryID') + ->get(); + + if ($operations->isEmpty()) { + $this->saveMetric($report, 21, 0); + return; + } + + // 4. Считаем общее количество дней и пациентов + $totalDays = 0; + $patientCount = 0; + + foreach ($operations as $op) { + $days = Carbon::parse($op->first_admission) + ->diffInDays(Carbon::parse($op->first_operation)); + + if ($days >= 0) { + $totalDays += $days; + $patientCount++; + } + } + + // 5. Нарастающий итог = общее количество дней / общее количество пациентов + $avgDays = $patientCount > 0 ? round($totalDays / $patientCount, 1) : 0; + + // 6. Сохраняем метрику + $this->saveMetric($report, 21, $avgDays); + } + + /** + * Сохранить предоперационный койко-день из снапшотов + */ + protected function saveDepartmentLoadedMetric(Report $report): void + { + // Получаем все снапшоты выписанных пациентов из этого отчета + $currentCount = $report->metrikaResults()->where('rf_metrika_item_id', 8)->value('value'); + $bedsCount = $report->metrikaResults()->where('rf_metrika_item_id', 1)->value('value'); + + $percentLoaded = $bedsCount > 0 ? round($currentCount * 100 / $bedsCount) : 0; + + $this->saveMetric($report, 22, $percentLoaded); + } + /** * Очистить кэш после создания отчета */ private function clearCacheAfterReportCreation(User $user, Report $report): void { // Очищаем кэш статистики для пользователя - $this->statisticsService->clearStatisticsCache($user); +// $this->statisticsService->clearStatisticsCache($user); // Также можно очистить кэш для всех пользователей отдела - $this->statisticsService->clearDepartmentStatisticsCache($user->rf_department_id); +// $this->statisticsService->clearDepartmentStatisticsCache($user->rf_department_id); // Очищаем кэш за сегодня и вчера (так как отчеты влияют на эти даты) $this->clearDailyCache($user, $report->created_at); @@ -305,6 +410,22 @@ class ReportService } } + /** + * Сохранить метрику отчета + */ + private function saveMetric(Report $report, int $metrikaId, float $value): void + { + MetrikaResult::updateOrCreate( + [ + 'rf_report_id' => $report->report_id, + 'rf_metrika_item_id' => $metrikaId, + ], + [ + 'value' => $value, + ] + ); + } + /** * Сохранить нежелательные события */ @@ -460,6 +581,7 @@ class ReportService ); $reportIds = $reports->pluck('report_id')->toArray(); + $lastReport = array_first($reportIds); // Получаем статистику из снапшотов $snapshotStats = [ @@ -471,7 +593,8 @@ class ReportService // 'discharged' => $this->getMetrikaResultCount('discharged', $reportIds), 'transferred' => $this->getMetrikaResultCount(13, $reportIds), 'recipient' => $this->getMetrikaResultCount(3, $reportIds), - 'beds' => $this->getMetrikaResultCount(1, $reportIds, false) + 'beds' => $this->getMetrikaResultCount(1, $reportIds, false), + 'countStaff' => $this->getMetrikaResultCount(17, [$lastReport], false) ]; // Получаем ID поступивших пациентов @@ -499,6 +622,7 @@ class ReportService 'extractCount' => $snapshotStats['outcome'] ?? 0, 'currentCount' => $snapshotStats['current'] ?? 0,//$this->calculateCurrentPatientsFromSnapshots($reportIds, $branchId), 'deadCount' => $snapshotStats['deceased'] ?? 0, + 'countStaff' => $snapshotStats['countStaff'] ?? 0, 'surgicalCount' => $surgicalCount, 'recipientIds' => $recipientIds, 'beds' => $snapshotStats['beds'] ?? 0, @@ -819,7 +943,8 @@ class ReportService { return UnwantedEvent::whereHas('report', function ($query) use ($department, $dateRange) { $query->where('rf_department_id', $department->department_id) - ->whereBetween('sent_at', [$dateRange->startSql(), $dateRange->endSql()]); + ->whereDate('sent_at', '>=', $dateRange->startSql()) + ->whereDate('sent_at', '<=', $dateRange->endSql()); }) ->get() ->map(function ($item) { @@ -876,14 +1001,16 @@ class ReportService { if ($dateRange->isOneDay) { return Report::where('rf_department_id', $departmentId) - ->whereDate('created_at', $dateRange->endSql()) - ->orderBy('created_at', 'ASC') + ->whereDate('sent_at', $dateRange->endSql()) + ->orderBy('sent_at', 'DESC') ->get(); } return Report::where('rf_department_id', $departmentId) - ->whereBetween('created_at', [$dateRange->startSql(), $dateRange->endSql()]) - ->orderBy('created_at', 'ASC') +// ->whereBetween('created_at', [$dateRange->startSql(), $dateRange->endSql()]) + ->where('sent_at', '>', $dateRange->startSql()) + ->where('sent_at', '<=', $dateRange->endSql()) + ->orderBy('sent_at', 'DESC') ->get(); } @@ -972,4 +1099,44 @@ class ReportService return $patient; }); } + + /** + * Получить статистику выполнения плана по госпитализации + */ + public function getRecipientPlanOfYear(Department $department, DateRange $dateRange): array + { + $periodPlanModel = $department->recipientPlanOfYear(); + // Рассчитываем коэффициент периода (округляем в большую сторону) + $monthsInPeriod = ceil($dateRange->startDate->diffInMonths($dateRange->endDate)); + $annualPlan = $periodPlanModel ? (int)$periodPlanModel->value : 0; + $oneMonthPlan = ceil($annualPlan / 12); + $periodPlan = round($oneMonthPlan * $monthsInPeriod); + + $progress = 0; + $query = $department->reports() + ->with('metrikaResults') + ->where('sent_at', '>=', $dateRange->startSql()) + ->where('sent_at', '<=', $dateRange->endSql()); + + if ($dateRange->isOneDay) { + $query->where('sent_at', '>=', $dateRange->startFirstOfMonth()) + ->where('sent_at', '<=', $dateRange->endSql()); + } else { + $query->where('sent_at', '>=', $dateRange->startSql()) + ->where('sent_at', '<=', $dateRange->endSql()); + } + + $reports = $query->get(); + + foreach ($reports as $report) { + $outcome = $report->metrikaResults()->where('rf_metrika_item_id', 7)->first(); + if ($outcome) $progress += (int)$outcome->value; + } + + return [ + 'plan' => $periodPlan, + 'progress' => $progress + ]; + } + } diff --git a/app/Services/SnapshotService.php b/app/Services/SnapshotService.php index 16867df..ed44aaa 100644 --- a/app/Services/SnapshotService.php +++ b/app/Services/SnapshotService.php @@ -11,6 +11,7 @@ use App\Models\Report; use App\Models\User; use Carbon\Carbon; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; class SnapshotService { @@ -182,28 +183,41 @@ class SnapshotService ?int $branchId = null, bool $onlyIds = false ): Collection { - // Для плановых и экстренных включаем уже лечащихся -// $includeCurrent = in_array($type, ['plan', 'emergency']); - - $medicalHistoryIds = MedicalHistorySnapshot::whereIn('rf_report_id', $reportIds) + // Получаем ID историй болезни напрямую через DB::table() — это быстрее + $medicalHistoryIds = DB::table('medical_history_snapshots') + ->select('rf_medicalhistory_id') + ->whereIn('rf_report_id', $reportIds) ->where('patient_type', $type) - ->pluck('rf_medicalhistory_id') - ->unique() - ->toArray(); + ->distinct() + ->pluck('rf_medicalhistory_id'); - if (empty($medicalHistoryIds)) { + if ($medicalHistoryIds->isEmpty()) { return collect(); } - if ($onlyIds) { - return collect($medicalHistoryIds); + $query = MisMedicalHistory::whereIn('MedicalHistoryID', $medicalHistoryIds); + + if ($type === 'plan') { + $query->plan(); + } elseif ($type === 'emergency') { + $query->emergency(); } - return MisMedicalHistory::whereIn('MedicalHistoryID', $medicalHistoryIds) - ->when($type === 'plan', fn($q) => $q->plan()) - ->when($type === 'emergency', fn($q) => $q->emergency()) - ->orderBy('DateRecipient', 'DESC') - ->get(); + // Загрузка отношений, необходимых для FormattedPatientResource + $query->with([ + 'outcomeMigration.mainDiagnosis.mkb', // mkb через диагноз + 'surgicalOperations.serviceMedical', // операции с услугами + ]); + + $query->orderBy('DateRecipient', 'DESC'); + + $results = $query->get(); + + if ($onlyIds) { + return $results->pluck('MedicalHistoryID'); + } + + return $results; } /** diff --git a/app/Services/StatisticsService.php b/app/Services/StatisticsService.php index 0434883..6806a93 100644 --- a/app/Services/StatisticsService.php +++ b/app/Services/StatisticsService.php @@ -1,707 +1,245 @@ 'plan', // Плановые поступления - 12 => 'emergency', // Экстренные поступления - 11 => 'plan_surgical', // Плановые операции - 10 => 'emergency_surgical', // Экстренные операции - 13 => 'transferred', // Переведенные - 7 => 'outcome', // Выбыло - 9 => 'deceased', // Умерло - 8 => 'current', // Состоит - 17 => 'count_staff', // Кол-во мед. персонала - 14 => 'count_observable', // Кол-во пациентов на контроле - 16 => 'count_unwanted', // Кол-во нежелательных событий - 18 => 'average_bed_days' // Средний койко-день - ]; - public function __construct( protected BedDayService $bedDayService - ) { } + ) + { + } - /** - * Получить статистические данные с оптимизацией - */ public function getStatisticsData(User $user, string $startDate, string $endDate, bool $isRangeOneDay): array { - // Очищаем кэш памяти перед началом $this->bedDayService->clearMemoryCache(); - // Определяем порог для использования оптимизированного метода - $daysDiff = Carbon::parse($startDate)->diffInDays(Carbon::parse($endDate)); + // Годовой план + $recipientPlanOfYear = 0; + $progressPlanOfYear = 0; - // Для диапазонов больше 30 дней используем агрегированные данные - if ($daysDiff > 30) { - return $this->getAggregatedStatistics($user, $startDate, $endDate, $isRangeOneDay); - } - - // Для диапазонов 7-30 дней используем оптимизированный метод - if ($daysDiff > 7) { - return $this->getOptimizedStatistics($user, $startDate, $endDate, $isRangeOneDay); - } - - // Для малых диапазонов используем детальный метод - return $this->getDetailedStatistics($user, $startDate, $endDate, $isRangeOneDay); - } - - /** - * Получить средние койко-дни за период из сохраненных метрик отчетов - */ - protected function getAverageBedDaysFromReports(array $departmentIds, string $startDate, string $endDate): array - { - if (empty($departmentIds)) { - return []; - } - - try { - $results = DB::table('reports as r') - ->join('metrika_results as mr', 'r.report_id', '=', 'mr.rf_report_id') - ->whereIn('r.rf_department_id', $departmentIds) - ->where('mr.rf_metrika_item_id', 18) - ->whereBetween('r.created_at', [$startDate, $endDate]) - ->select( - 'r.rf_department_id', - DB::raw('AVG(CAST(mr.value AS DECIMAL)) as avg_value') - ) - ->groupBy('r.rf_department_id') - ->get() - ->keyBy('rf_department_id'); - - $averages = []; - foreach ($departmentIds as $departmentId) { - if (isset($results[$departmentId]) && $results[$departmentId]->avg_value !== null) { - $averages[$departmentId] = round((float)$results[$departmentId]->avg_value, 1); - } else { - $averages[$departmentId] = 0; - } - } - - return $averages; - } catch (\Exception $e) { - \Log::error("Error in getAverageBedDaysFromReports: " . $e->getMessage()); - return array_fill_keys($departmentIds, 0); - } - } - - /** - * Получить общий средний койко-день - */ - protected function getOverallAverageBedDays(array $averages): float - { - $total = 0; - $count = 0; - - foreach ($averages as $avg) { - if ($avg > 0) { - $total += $avg; - $count++; - } - } - - return $count > 0 ? round($total / $count, 1) : 0; - } - - /** - * Агрегированный метод для очень больших диапазонов (больше 30 дней) - */ - private function getAggregatedStatistics(User $user, string $startDate, string $endDate, bool $isRangeOneDay): array - { - // Устанавливаем дату отчета - if ($isRangeOneDay) { - $dateReport = $endDate; - } else { - $dateReport = [$startDate, $endDate]; - } - - // Загружаем все отделения - $departments = Department::select('department_id', 'rf_department_type', 'name_short') - ->with(['departmentType']) + // 1. Получаем отделения + $departments = Department::select('department_id', 'name_short', 'rf_department_type', 'user_name', 'order') + ->with('departmentType') + ->join((new UserDepartment)->getTable(), (new Department)->getTable() . '.department_id', (new UserDepartment)->getTable() . '.rf_department_id') + ->where((new UserDepartment)->getTable() . '.rf_user_id', $user->id) ->orderBy('rf_department_type') + ->orderBy((new UserDepartment)->getTable() . '.order', 'asc') ->get() - ->keyBy('department_id'); + ->groupBy('departmentType.name_full'); - // Загружаем метрики по умолчанию - $defaultMetrics = $this->getDefaultMetricsBatch($departments->pluck('department_id')->toArray()); + if ($departments->isEmpty()) { + return $this->emptyResponse(); + } - // Получаем агрегированные данные по отчетам - $aggregatedData = $this->getAggregatedReportData( - $departments->pluck('department_id')->toArray(), - $dateReport, - $isRangeOneDay - ); + // Рассчитываем коэффициент периода (дни периода / 365) + $start = Carbon::parse($startDate); + $end = Carbon::parse($endDate); + $monthsInPeriod = $start->diffInMonths($end); // +1 чтобы включить оба дня + $periodCoefficient = $monthsInPeriod / 12; + $monthsInPeriod = ceil($start->diffInMonths($end)); - // Получаем последние отчеты для текущих пациентов - $lastReportsData = $this->getLastReportsData( - $departments->pluck('department_id')->toArray(), - $isRangeOneDay ? $dateReport : $dateReport[1] - ); +// foreach ($departments as $departmentType) { +// foreach ($departmentType as $department) { +// if ($department->recipientPlanOfYear() === null) continue; +// $recipientPlanOfYear += (int)$department->recipientPlanOfYear()->value; +// } +// } - // Получаем средние койко-дни из метрик отчетов - $averageBedDays = $this->getAverageBedDaysFromReports( - $departments->pluck('department_id')->toArray(), - $startDate, - $endDate - ); + $allDeptIds = $departments->flatten()->pluck('department_id')->toArray(); - // Общий средний койко-день - $overallAverageBedDays = $this->getOverallAverageBedDays($averageBedDays); - - return $this->processAggregatedData( - $departments, - $defaultMetrics, - $aggregatedData, - $lastReportsData, - $isRangeOneDay, - $averageBedDays, - $overallAverageBedDays - ); - } - - /** - * Получить агрегированные данные по отчетам - */ - private function getAggregatedReportData(array $departmentIds, $dateReport, bool $isRangeOneDay): Collection - { - // Для суммируемых метрик - SUM - $summableQuery = DB::table('reports as r') + // 2. Получаем ВСЕ метрики за период ОДНИМ запросом + $metrics = DB::table('reports as r') ->join('metrika_results as mr', 'r.report_id', '=', 'mr.rf_report_id') + ->whereIn('r.rf_department_id', $allDeptIds) + ->whereDate('r.sent_at', '>=', $startDate) + ->whereDate('r.sent_at', '<=', $endDate) ->select( 'r.rf_department_id', 'mr.rf_metrika_item_id', - DB::raw('SUM(CAST(mr.value AS DECIMAL)) as total') + DB::raw('SUM(CAST(mr.value AS DECIMAL)) as total'), + DB::raw('COUNT(*) as records_count') ) - ->whereIn('r.rf_department_id', $departmentIds) - ->whereIn('mr.rf_metrika_item_id', $this->summableMetrics); - - if ($isRangeOneDay) { - $summableQuery->whereDate('r.created_at', $dateReport); - } else { - $summableQuery->whereBetween('r.created_at', $dateReport); - } - - $summableResults = $summableQuery + ->whereIn('mr.rf_metrika_item_id', [1, 4, 12, 11, 10, 13, 7, 9, 17, 14, 16, 18, 19, 22]) ->groupBy('r.rf_department_id', 'mr.rf_metrika_item_id') - ->get(); - - // Для усредняемых метрик - AVG (например, средний койко-день) - $averageQuery = DB::table('reports as r') - ->join('metrika_results as mr', 'r.report_id', '=', 'mr.rf_report_id') - ->select( - 'r.rf_department_id', - 'mr.rf_metrika_item_id', - DB::raw('AVG(CAST(mr.value AS DECIMAL)) as total') - ) - ->whereIn('r.rf_department_id', $departmentIds) - ->whereIn('mr.rf_metrika_item_id', $this->averageMetrics); - - if ($isRangeOneDay) { - $averageQuery->whereDate('r.created_at', $dateReport); - } else { - $averageQuery->whereBetween('r.created_at', $dateReport); - } - - $averageResults = $averageQuery - ->groupBy('r.rf_department_id', 'mr.rf_metrika_item_id') - ->get(); - - // Объединяем результаты - $allResults = $summableResults->concat($averageResults); - - return $allResults->groupBy('rf_department_id'); - } - - /** - * Получить данные последних отчетов - */ - private function getLastReportsData(array $departmentIds, string $date): Collection - { - // Находим ID последних отчетов для каждого отделения - $subQuery = DB::table('reports') - ->select('rf_department_id', DB::raw('MAX(report_id) as last_report_id')) - ->whereIn('rf_department_id', $departmentIds) - ->whereDate('created_at', '<=', $date) + ->get() ->groupBy('rf_department_id'); - return DB::table('metrika_results as mr') - ->joinSub($subQuery, 'last_reports', function ($join) { - $join->on('mr.rf_report_id', '=', 'last_reports.last_report_id'); - }) - ->where('mr.rf_metrika_item_id', 8) // Только текущие пациенты - ->select('last_reports.rf_department_id', 'mr.value') + // 3. Получаем текущих пациентов + $currentPatients = DB::table('reports as r') + ->join('metrika_results as mr', 'r.report_id', '=', 'mr.rf_report_id') + ->whereIn('r.rf_department_id', $allDeptIds) + ->where('mr.rf_metrika_item_id', 8) + ->where('r.sent_at', '<=', $endDate) + ->select('r.rf_department_id', 'mr.value', 'r.created_at') + ->orderBy('r.rf_department_id') // Сначала поле из DISTINCT ON + ->orderBy('r.sent_at', 'desc') // Потом остальные + ->distinct('r.rf_department_id') ->get() ->keyBy('rf_department_id'); - } - /** - * Обработать агрегированные данные - */ - private function processAggregatedData( - Collection $departments, - Collection $defaultMetrics, - Collection $aggregatedData, - Collection $lastReportsData, - bool $isRangeOneDay, - array $averageBedDays, - float $overallAverageBedDays - ): array { - $groupedData = []; - $totalsByType = []; - - foreach ($departments as $department) { - $departmentId = $department->department_id; - $departmentType = $department->departmentType->name_full; - - if (!isset($groupedData[$departmentType])) { - $groupedData[$departmentType] = []; - $totalsByType[$departmentType] = $this->initTypeTotals(); - } - - // Получаем агрегированные метрики - $metrics = $aggregatedData->get($departmentId, collect()); - $counters = array_fill_keys(array_values($this->metricMapping), 0); - - foreach ($metrics as $metric) { - $key = $this->metricMapping[$metric->rf_metrika_item_id] ?? null; - if ($key) { - // Для агрегированных данных всегда берем сумму - $counters[$key] = (int)$metric->total; - } - } - - // Текущие пациенты из последнего отчета - $currentCount = (int)($lastReportsData->get($departmentId)?->value ?? 0); - $counters['current'] = $currentCount; - - // Количество коек - $bedsCount = (int)($defaultMetrics->get($departmentId)?->value ?? 0); - - // Рассчитываем значения - $allCount = $counters['plan'] + $counters['emergency']; - $percentLoadedBeds = $bedsCount > 0 ? round($currentCount * 100 / $bedsCount) : 0; - - // Получаем средний койко-день для отделения - $departmentAverageBedDays = $averageBedDays[$departmentId] ?? 0; - - // Формируем данные - $departmentData = $this->createDepartmentData( - $department->name_short, - $departmentId, - $bedsCount, - $allCount, - $counters, - $percentLoadedBeds, - $departmentType, - null, - $departmentAverageBedDays, - $overallAverageBedDays - ); - - $groupedData[$departmentType][] = $departmentData; - $this->updateTypeTotals($totalsByType[$departmentType], $departmentData); - } - - return $this->buildFinalData($groupedData, $totalsByType); - } - - /** - * Оптимизированный метод для средних диапазонов (7-30 дней) - */ - private function getOptimizedStatistics(User $user, string $startDate, string $endDate, bool $isRangeOneDay): array - { - // Устанавливаем дату отчета - if ($isRangeOneDay) { - $dateReport = $endDate; - } else { - $dateReport = [$startDate, $endDate]; - } - - // Загружаем все отделения - $departments = Department::select('department_id', 'rf_department_type', 'name_short') - ->with(['departmentType']) - ->orderBy('rf_department_type') - ->get() - ->keyBy('department_id'); - - // Загружаем метрики по умолчанию - $defaultMetrics = $this->getDefaultMetricsBatch($departments->pluck('department_id')->toArray()); - - // Загружаем отчеты - $reports = $this->getReportsBatch($departments->pluck('department_id')->toArray(), $dateReport, $isRangeOneDay); - - // Загружаем метрики отчетов - $reportIds = $reports->flatMap(fn($items) => $items->pluck('report_id'))->toArray(); - $reportMetrics = $this->getReportMetricsBatch($reportIds); - - // Получаем средние койко-дни из метрик отчетов - $averageBedDays = $this->getAverageBedDaysFromReports( - $departments->pluck('department_id')->toArray(), - $startDate, - $endDate - ); - - // Общий средний койко-день - $overallAverageBedDays = $this->getOverallAverageBedDays($averageBedDays); - - return $this->processOptimizedData( - $departments, - $defaultMetrics, - $reports, - $reportMetrics, - $dateReport, - $isRangeOneDay, - $averageBedDays, - $overallAverageBedDays - ); - } - - /** - * Получить метрики по умолчанию для всех отделений пачкой - */ - private function getDefaultMetricsBatch(array $departmentIds): Collection - { - return DB::table('department_metrika_defaults') - ->whereIn('rf_department_id', $departmentIds) - ->where('rf_metrika_item_id', 1) // только койки + // 4. Получаем количество коек + $beds = DB::table('department_metrika_defaults') + ->whereIn('rf_department_id', $allDeptIds) + ->where('rf_metrika_item_id', 1) ->select('rf_department_id', 'value') ->get() ->keyBy('rf_department_id'); - } - /** - * Получить отчеты для всех отделений пачкой - */ - private function getReportsBatch(array $departmentIds, $dateReport, bool $isRangeOneDay): Collection - { - $query = Report::whereIn('rf_department_id', $departmentIds); - - if ($isRangeOneDay) { - $query->whereDate('created_at', $dateReport); - } else { - $query->whereBetween('created_at', $dateReport); - } - - return $query->select('report_id', 'rf_department_id', 'created_at') - ->orderBy('created_at') - ->get() - ->groupBy('rf_department_id'); - } - - /** - * Получить метрики отчетов пачкой - */ - private function getReportMetricsBatch(array $reportIds): Collection - { - if (empty($reportIds)) { - return collect(); - } - - // Получаем все метрики, но для 18 не преобразуем в integer - $results = DB::table('metrika_results') - ->whereIn('rf_report_id', $reportIds) - ->whereIn('rf_metrika_item_id', array_keys($this->metricMapping)) - ->select('rf_report_id', 'rf_metrika_item_id', 'value') - ->get() - ->groupBy('rf_report_id'); - - // Преобразуем значения в зависимости от типа метрики - foreach ($results as $reportId => $metrics) { - foreach ($metrics as $metric) { - if (in_array($metric->rf_metrika_item_id, $this->summableMetrics)) { - $metric->value = (int)$metric->value; - } else { - $metric->value = (float)$metric->value; - } - } - } - - return $results; - } - - /** - * Обработать оптимизированные данные - */ - private function processOptimizedData( - Collection $departments, - Collection $defaultMetrics, - Collection $reports, - Collection $reportMetrics, - $dateReport, - bool $isRangeOneDay, - array $averageBedDays, - float $overallAverageBedDays - ): array { + // 5. Собираем данные $groupedData = []; $totalsByType = []; + $grandRecipientPlan = 0; + $grandProgressPlan = 0; - foreach ($departments as $department) { - $departmentId = $department->department_id; - $departmentType = $department->departmentType->name_full; + foreach ($departments as $typeName => $deptList) { + $groupedData[$typeName] = []; + $totalsByType[$typeName] = $this->initTypeTotals(); - if (!isset($groupedData[$departmentType])) { - $groupedData[$departmentType] = []; - $totalsByType[$departmentType] = $this->initTypeTotals(); - } + foreach ($deptList as $dept) { + $deptId = $dept->department_id; + $lastReport = Report::where('rf_department_id', $deptId) + ->whereDate('sent_at', '>=', Carbon::parse($startDate)->format('Y-m-d')) + ->whereDate('sent_at', '<=', Carbon::parse($endDate)->format('Y-m-d')) + ->orderBy('sent_at', 'desc') + ->first(); - // Получаем отчеты отделения - $departmentReports = $reports->get($departmentId, collect()); - $lastReport = $departmentReports->last(); + // Базовые показатели + $bedsCount = (int)($beds[$deptId]->value ?? 0); + $currentCount = (int)($currentPatients[$deptId]->value ?? 0); - // Инициализируем счетчики - $counters = array_fill_keys(array_values($this->metricMapping), 0); + // Получаем годовой план + $annualPlanModel = $dept->recipientPlanOfYear(); +// $annualPlan = $annualPlanModel ? (int)$annualPlanModel->value : 0; + $annualPlan = $annualPlanModel ? (int)$annualPlanModel->value : 0; + $oneMonthPlan = ceil($annualPlan / 12); - // Обрабатываем каждый отчет - foreach ($departmentReports as $report) { - $metrics = $reportMetrics->get($report->report_id, collect()) - ->keyBy('rf_metrika_item_id'); + // Рассчитываем план на период + $periodPlan = round($oneMonthPlan * $monthsInPeriod); +// $periodPlan = round($annualPlan * $periodCoefficient); - foreach ($this->metricMapping as $metricId => $key) { - if ($metrics->has($metricId)) { - $value = (int)$metrics[$metricId]->value; + // Счетчики + $plan = 0; + $emergency = 0; + $planSurgical = 0; + $emergencySurgical = 0; + $transferred = 0; + $outcome = 0; + $deceased = 0; + $staff = 0; + $observable = 0; + $unwanted = 0; + $bedDaysSum = 0; - // Разная логика для одного дня и диапазона - if ($isRangeOneDay) { - // Для одного дня: суммируем - $counters[$key] += $value; - } else { - // Для диапазона: - if ($metricId === 8) { - // Для текущих пациентов берем ПОСЛЕДНЕЕ значение - if ($report === $lastReport) { - $counters[$key] = $value; - } - } else { - // Для остальных суммируем - $counters[$key] += $value; - } - } + if (isset($metrics[$deptId])) { + foreach ($metrics[$deptId] as $item) { + $value = (float)$item->total; + + match ($item->rf_metrika_item_id) { + 4 => $plan = (int)$value, + 12 => $emergency = (int)$value, + 11 => $planSurgical = (int)$value, + 10 => $emergencySurgical = (int)$value, + 13 => $transferred = (int)$value, + 7 => $outcome = (int)$value, + 9 => $deceased = (int)$value, + 17 => $staff = (int)$value, + 14 => $observable = (int)$value, + 16 => $unwanted = (int)$value, + 18 => $bedDaysSum += $value, + 19 => $lethalitySum = $value, +// 24 => $completePlanProgress = (int)$value, + default => null + }; } } + + $grandRecipientPlan += $periodPlan; + $grandProgressPlan += $outcome; + + $percentPlanOfYear = $periodPlan > 0 ? round($outcome * 100 / $periodPlan) : 0; + + // Расчеты + $allCount = $plan + $emergency; + $percentLoaded = $bedsCount > 0 ? round($currentCount * 100 / $bedsCount) : 0; + + // Средний койко-день + $avgBedDays = $outcome > 0 ? round($bedDaysSum / $outcome, 2) : 0; + + // Предоперационный койко-день + $preoperativeValue = $lastReport + ? (float)MetrikaResult::where('rf_report_id', $lastReport->report_id) + ->where('rf_metrika_item_id', 21) + ->value('value') + : 0; + + // Летальность + $lethality = $outcome > 0 ? round(($deceased / $outcome) * 100, 2) : 0; + + $departmentName = $dept->user_name ?? $dept->name_short; + + $progressPlanOfYear += $outcome; + + $data = [ + 'department' => $departmentName, + 'department_id' => $deptId, + 'beds' => $bedsCount, + 'recipients' => [ + 'all' => $allCount, + 'plan' => $plan, + 'emergency' => $emergency, + 'transferred' => $transferred, + ], + 'outcome' => $outcome, + 'consist' => $currentCount, + 'percentLoadedBeds' => $percentLoaded, + 'surgical' => [ + 'plan' => $planSurgical, + 'emergency' => $emergencySurgical + ], + 'deceased' => $deceased, + 'countStaff' => $staff, + 'countObservable' => $observable, + 'countUnwanted' => $unwanted, + 'averageBedDays' => $avgBedDays, + 'preoperativeDays' => $preoperativeValue, + 'progressPlanOfYear' => $periodPlan, + 'percentPlanOfYear' => $percentPlanOfYear, + 'lethality' => $lethality, + 'type' => $typeName, + 'isDepartment' => true, + 'isReportToday' => $lastReport ? Carbon::parse($lastReport->sent_at)->isSameDay($endDate) : null, + ]; + + $groupedData[$typeName][] = $data; + $this->updateTypeTotals($totalsByType[$typeName], $data); } - - // Если нет отчетов за день, но есть последний отчет ранее - if ($counters['current'] === 0 && $lastReport) { - $metrics = $reportMetrics->get($lastReport->report_id, collect()); - $currentMetric = $metrics->firstWhere('rf_metrika_item_id', 8); - if ($currentMetric) { - $counters['current'] = (int)$currentMetric->value; - } - } - - // Получаем количество коек - $bedsCount = (int)($defaultMetrics->get($departmentId)?->value ?? 0); - - // Рассчитываем значения - $allCount = $counters['plan'] + $counters['emergency']; - $percentLoadedBeds = $bedsCount > 0 ? round($counters['current'] * 100 / $bedsCount) : 0; - - // Получаем средний койко-день для отделения - $departmentAverageBedDays = $averageBedDays[$departmentId] ?? 0; - - // Формируем данные отделения - $departmentData = $this->createDepartmentData( - $department->name_short, - $departmentId, - $bedsCount, - $allCount, - $counters, - $percentLoadedBeds, - $departmentType, - null, - $departmentAverageBedDays, - $overallAverageBedDays - ); - - $groupedData[$departmentType][] = $departmentData; - $this->updateTypeTotals($totalsByType[$departmentType], $departmentData); } - return $this->buildFinalData($groupedData, $totalsByType); - } - - /** - * Детальный метод для небольших диапазонов (до 7 дней) - */ - private function getDetailedStatistics(User $user, string $startDate, string $endDate, bool $isRangeOneDay): array - { - // Устанавливаем дату отчета - if ($isRangeOneDay) { - $dateReport = $endDate; - } else { - $dateReport = [$startDate, $endDate]; - } - - $groupedData = []; - $totalsByType = []; - - $departments = Department::select('department_id', 'rf_department_type', 'name_short') - ->with(['departmentType', 'reports' => function ($query) use ($dateReport, $isRangeOneDay) { - if ($isRangeOneDay) { - $query->whereDate('created_at', $dateReport); - } else { - $query->whereBetween('created_at', $dateReport); - } - $query->with('metrikaResults'); - }]) - ->orderBy('rf_department_type') - ->get(); - - // Получаем средние койко-дни из метрик отчетов - $averageBedDays = $this->getAverageBedDaysFromReports( - $departments->pluck('department_id')->toArray(), - $startDate, - $endDate - ); - - // Общий средний койко-день - $overallAverageBedDays = $this->getOverallAverageBedDays($averageBedDays); - - foreach ($departments as $department) { - $departmentType = $department->departmentType->name_full; - - if (!isset($groupedData[$departmentType])) { - $groupedData[$departmentType] = []; - $totalsByType[$departmentType] = $this->initTypeTotals(); - } - - // Получаем отчеты - $reports = $department->reports; - $lastReport = $reports->last(); - - // Инициализируем счетчики - $counters = array_fill_keys(array_values($this->metricMapping), 0); - - // Суммируем метрики - foreach ($reports as $report) { - foreach ($report->metrikaResults as $metric) { - $key = $this->metricMapping[$metric->rf_metrika_item_id] ?? null; - if ($key) { - $value = (int)$metric->value; - - // ВАЖНО: разная логика для одного дня и диапазона - if ($isRangeOneDay) { - // Для одного дня: суммируем все значения - $counters[$key] += $value; - } else { - // Для диапазона: - if ($metric->rf_metrika_item_id === 8) { - // Для текущих пациентов берем ПОСЛЕДНЕЕ значение - // из последнего отчета за день - if ($report === $lastReport) { - $counters[$key] = $value; - } - } else { - // Для остальных метрик СУММИРУЕМ за весь период - $counters[$key] += $value; - } - } - } - } - } - - // Получаем количество коек - $bedsCount = (int)$department->metrikaDefault() - ->where('rf_metrika_item_id', 1) - ->value('value') ?? 0; - - // Рассчитываем итоговые значения - $allCount = $counters['plan'] + $counters['emergency']; - $percentLoadedBeds = $bedsCount > 0 ? round($counters['current'] * 100 / $bedsCount) : 0; - - if ($isRangeOneDay) - $isReportToday = !empty($lastReport); - else $isReportToday = null; - - $departmentAverageBedDays = $averageBedDays[$department->department_id] ?? 0; - - // Формируем данные отделения - $departmentData = $this->createDepartmentData( - $department->name_short, - $department->department_id, - $bedsCount, - $allCount, - $counters, - $percentLoadedBeds, - $departmentType, - $isReportToday, - $departmentAverageBedDays, - $overallAverageBedDays - ); - - $groupedData[$departmentType][] = $departmentData; - $this->updateTypeTotals($totalsByType[$departmentType], $departmentData); - } - - return $this->buildFinalData($groupedData, $totalsByType); - } - - /** - * Создать данные отделения - */ - private function createDepartmentData( - string $name, - int $departmentId, - int $beds, - int $allCount, - array $counters, - int $percentLoadedBeds, - string $type, - ?bool $isReportToday = null, - float $departmentAverageBedDays = 0, - float $overallAverageBedDays = 0 - ): array { return [ - 'department' => $name, - 'department_id' => $departmentId, - 'beds' => $beds, - 'recipients' => [ - 'all' => $allCount, - 'plan' => $counters['plan'], - 'emergency' => $counters['emergency'], - 'transferred' => $counters['transferred'], - ], - 'outcome' => $counters['outcome'], - 'consist' => $counters['current'], - 'percentLoadedBeds' => $percentLoadedBeds, - 'surgical' => [ - 'plan' => $counters['plan_surgical'], - 'emergency' => $counters['emergency_surgical'] - ], - 'deceased' => $counters['deceased'], - 'countStaff' => $counters['count_staff'], - 'countObservable' => $counters['count_observable'], - 'countUnwanted' => $counters['count_unwanted'], - 'averageBedDays' => $departmentAverageBedDays, - 'overallAverageBedDays' => $overallAverageBedDays, - 'type' => $type, - 'isDepartment' => true, - 'isReportToday' => $isReportToday, + 'data' => $this->buildFinalData($groupedData, $totalsByType), + 'totalsByType' => $totalsByType, + 'grandTotals' => $this->calculateGrandTotals($totalsByType), + 'recipientPlanOfYear' => [ + 'plan' => $grandRecipientPlan, // Сумма планов по периоду + 'progress' => $grandProgressPlan, // Сумма фактов по периоду + ] ]; } /** - * Инициализировать итоги по типу + * Инициализация итогов по типу */ private function initTypeTotals(): array { @@ -722,104 +260,84 @@ class StatisticsService 'staff_sum' => 0, 'observable_sum' => 0, 'unwanted_sum' => 0, - 'average_bed_days_total' => 0, - 'average_bed_days_count' => 0, ]; } /** - * Обновить итоги по типу + * Обновление итогов по типу */ - private function updateTypeTotals(array &$totals, array $departmentData): void + private function updateTypeTotals(array &$totals, array $data): void { $totals['departments_count']++; - $totals['beds_sum'] += $departmentData['beds']; - $totals['recipients_all_sum'] += $departmentData['recipients']['all']; - $totals['recipients_plan_sum'] += $departmentData['recipients']['plan']; - $totals['recipients_emergency_sum'] += $departmentData['recipients']['emergency']; - $totals['recipients_transferred_sum'] += $departmentData['recipients']['transferred']; - $totals['outcome_sum'] += $departmentData['outcome']; - $totals['consist_sum'] += $departmentData['consist']; - $totals['plan_surgical_sum'] += $departmentData['surgical']['plan']; - $totals['emergency_surgical_sum'] += $departmentData['surgical']['emergency']; - $totals['deceased_sum'] += $departmentData['deceased']; - $totals['percentLoadedBeds_total'] += $departmentData['percentLoadedBeds']; + $totals['beds_sum'] += $data['beds']; + $totals['recipients_all_sum'] += $data['recipients']['all']; + $totals['recipients_plan_sum'] += $data['recipients']['plan']; + $totals['recipients_emergency_sum'] += $data['recipients']['emergency']; + $totals['recipients_transferred_sum'] += $data['recipients']['transferred']; + $totals['outcome_sum'] += $data['outcome']; + $totals['consist_sum'] += $data['consist']; + $totals['plan_surgical_sum'] += $data['surgical']['plan']; + $totals['emergency_surgical_sum'] += $data['surgical']['emergency']; + $totals['deceased_sum'] += $data['deceased']; + $totals['percentLoadedBeds_total'] += $data['percentLoadedBeds']; $totals['percentLoadedBeds_count']++; - $totals['staff_sum'] += $departmentData['countStaff']; - $totals['observable_sum'] += $departmentData['countObservable']; - $totals['unwanted_sum'] += $departmentData['countUnwanted']; - $totals['average_bed_days_total'] += $departmentData['averageBedDays']; - $totals['average_bed_days_count']++; + $totals['staff_sum'] += $data['countStaff']; + $totals['observable_sum'] += $data['countObservable']; + $totals['unwanted_sum'] += $data['countUnwanted']; } /** - * Построить финальные данные с итогами + * Расчет общих итогов + */ + private function calculateGrandTotals(array $totalsByType): array + { + $grand = $this->initTypeTotals(); + foreach ($totalsByType as $totals) { + foreach ($grand as $key => &$value) { + if (isset($totals[$key])) { + $value += $totals[$key]; + } + } + } + return $grand; + } + + /** + * Построение финальных данных */ private function buildFinalData(array $groupedData, array $totalsByType): array { - $finalData = []; - $grandTotals = $this->initTypeTotals(); + $final = []; - foreach ($groupedData as $type => $departmentsInType) { - // Добавляем заголовок группы - $finalData[] = [ + foreach ($groupedData as $type => $items) { + $final[] = [ 'isGroupHeader' => true, 'groupName' => $type, - 'colspan' => 14, - 'type' => $type + 'colspan' => 16 ]; - // Добавляем отделения - foreach ($departmentsInType as $department) { - $finalData[] = $department; + foreach ($items as $item) { + $final[] = $item; } - // Добавляем итоги по группе - if (!empty($departmentsInType) && isset($totalsByType[$type])) { - $total = $totalsByType[$type]; - $avgPercent = $total['percentLoadedBeds_count'] > 0 - ? round($total['percentLoadedBeds_total'] / $total['percentLoadedBeds_count']) - : 0; - - $finalData[] = $this->createTotalRow($type, $total, $avgPercent, false); - - // Обновляем общие итоги - $this->updateGrandTotals($grandTotals, $total); + if (!empty($items) && isset($totalsByType[$type])) { + $final[] = $this->createTotalRow($type, $totalsByType[$type], false); } } - // Добавляем общие итоги -// if ($grandTotals['departments_count'] > 0) { -// $avgPercent = $grandTotals['percentLoadedBeds_count'] > 0 -// ? round($grandTotals['percentLoadedBeds_total'] / $grandTotals['percentLoadedBeds_count']) -// : 0; -// -// $grandAvgBedDays = $grandTotals['averageBedDays_count'] > 0 -// ? round($grandTotals['averageBedDays_total'] / $grandTotals['averageBedDays_count'], 1) -// : $overallAverageBedDays; -// -// $finalData[] = $this->createTotalRow('all', $grandTotals, $avgPercent, true, $grandAvgBedDays); -// } - - return [ - 'data' => $finalData, - 'totalsByType' => $totalsByType, - 'grandTotals' => $grandTotals - ]; + return $final; } /** - * Создать строку итогов + * Создание строки итогов */ - private function createTotalRow(string $type, array $total, int $avgPercent, bool $isGrandTotal, float $avgBedDays = 0): array + private function createTotalRow(string $type, array $total, bool $isGrandTotal): array { - $row = [ + return [ 'isTotalRow' => !$isGrandTotal, 'isGrandTotal' => $isGrandTotal, - 'department' => $isGrandTotal - ? 'ОБЩИЕ ИТОГИ:' - : 'ИТОГО:', - 'beds' => '—',//$total['beds_sum'], + 'department' => $isGrandTotal ? 'ОБЩИЕ ИТОГИ:' : 'ИТОГО:', + 'beds' => '—', 'recipients' => [ 'all' => $total['recipients_all_sum'], 'plan' => $total['recipients_plan_sum'], @@ -828,13 +346,15 @@ class StatisticsService ], 'outcome' => $total['outcome_sum'], 'consist' => $total['consist_sum'], - 'percentLoadedBeds' => '—',//$avgPercent, + 'percentLoadedBeds' => '—', 'surgical' => [ 'plan' => $total['plan_surgical_sum'], 'emergency' => $total['emergency_surgical_sum'] ], 'deceased' => $total['deceased_sum'], 'averageBedDays' => '—', + 'preoperativeDays' => '—', + 'lethality' => '—', 'type' => $type, 'departments_count' => $total['departments_count'], 'countStaff' => $total['staff_sum'], @@ -842,52 +362,17 @@ class StatisticsService 'countUnwanted' => $total['unwanted_sum'], 'isBold' => true ]; - - if ($isGrandTotal) { - $row['backgroundColor'] = '#f0f8ff'; - } - - return $row; } /** - * Обновить общие итоги + * Пустой ответ */ - private function updateGrandTotals(array &$grandTotals, array $typeTotal): void + private function emptyResponse(): array { - foreach ($grandTotals as $key => &$value) { - if (isset($typeTotal[$key])) { - $value += $typeTotal[$key]; - } - } - } - - /** - * Очистить кэш статистики при создании отчета - */ - public function clearStatisticsCache(User $user, ?string $date = null): void - { - // Очищаем кэш по тегам - Cache::tags([ - 'statistics', - 'reports', - 'department_' . $user->rf_department_id - ])->flush(); - - \Log::info("Statistics cache cleared for department: " . $user->rf_department_id); - } - - /** - * Очистить кэш статистики для всех пользователей отдела - */ - public function clearDepartmentStatisticsCache(int $departmentId): void - { - Cache::tags([ - 'statistics', - 'reports', - 'department_' . $departmentId - ])->flush(); - - \Log::info("Statistics cache cleared for entire department: " . $departmentId); + return [ + 'data' => [], + 'totalsByType' => [], + 'grandTotals' => $this->initTypeTotals() + ]; } } diff --git a/bootstrap/app.php b/bootstrap/app.php index 49c7a38..09bc208 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -15,6 +15,7 @@ return Application::configure(basePath: dirname(__DIR__)) ->withMiddleware(function (Middleware $middleware): void { $middleware->web(append: [ HandleInertiaRequests::class, + 'auth' => \App\Http\Middleware\Authenticate::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/composer.json b/composer.json index 7f5c016..c94a175 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,8 @@ "inertiajs/inertia-laravel": "^2.0", "laravel/framework": "^12.0", "laravel/sanctum": "^4.0", - "laravel/tinker": "^2.10.1" + "laravel/tinker": "^2.10.1", + "maatwebsite/excel": "^3.1" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/composer.lock b/composer.lock index 1ac273b..a9eec14 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7350cb791701191aaa453da0160486f9", + "content-hash": "402f4b0051ff4985e04537ae489f5c02", "packages": [ { "name": "brick/math", @@ -135,6 +135,162 @@ ], "time": "2024-02-09T16:56:22+00:00" }, + { + "name": "composer/pcre", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-11-12T16:29:46+00:00" + }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -508,6 +664,67 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "ezyang/htmlpurifier", + "version": "v4.19.0", + "source": { + "type": "git", + "url": "https://github.com/ezyang/htmlpurifier.git", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf", + "shasum": "" + }, + "require": { + "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "cerdic/css-tidy": "^1.7 || ^2.0", + "simpletest/simpletest": "dev-master" + }, + "suggest": { + "cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.", + "ext-bcmath": "Used for unit conversion and imagecrash protection", + "ext-iconv": "Converts text to and from non-UTF-8 encodings", + "ext-tidy": "Used for pretty-printing HTML" + }, + "type": "library", + "autoload": { + "files": [ + "library/HTMLPurifier.composer.php" + ], + "psr-0": { + "HTMLPurifier": "library/" + }, + "exclude-from-classmap": [ + "/library/HTMLPurifier/Language/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Edward Z. Yang", + "email": "admin@htmlpurifier.org", + "homepage": "http://ezyang.com" + } + ], + "description": "Standards compliant HTML filter written in PHP", + "homepage": "http://htmlpurifier.org/", + "keywords": [ + "html" + ], + "support": { + "issues": "https://github.com/ezyang/htmlpurifier/issues", + "source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0" + }, + "time": "2025-10-17T16:34:55+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.4.0", @@ -2152,6 +2369,272 @@ ], "time": "2025-12-07T16:03:21+00:00" }, + { + "name": "maatwebsite/excel", + "version": "3.1.67", + "source": { + "type": "git", + "url": "https://github.com/SpartnerNL/Laravel-Excel.git", + "reference": "e508e34a502a3acc3329b464dad257378a7edb4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SpartnerNL/Laravel-Excel/zipball/e508e34a502a3acc3329b464dad257378a7edb4d", + "reference": "e508e34a502a3acc3329b464dad257378a7edb4d", + "shasum": "" + }, + "require": { + "composer/semver": "^3.3", + "ext-json": "*", + "illuminate/support": "5.8.*||^6.0||^7.0||^8.0||^9.0||^10.0||^11.0||^12.0", + "php": "^7.0||^8.0", + "phpoffice/phpspreadsheet": "^1.30.0", + "psr/simple-cache": "^1.0||^2.0||^3.0" + }, + "require-dev": { + "laravel/scout": "^7.0||^8.0||^9.0||^10.0", + "orchestra/testbench": "^6.0||^7.0||^8.0||^9.0||^10.0", + "predis/predis": "^1.1" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Excel": "Maatwebsite\\Excel\\Facades\\Excel" + }, + "providers": [ + "Maatwebsite\\Excel\\ExcelServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Maatwebsite\\Excel\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Patrick Brouwers", + "email": "patrick@spartner.nl" + } + ], + "description": "Supercharged Excel exports and imports in Laravel", + "keywords": [ + "PHPExcel", + "batch", + "csv", + "excel", + "export", + "import", + "laravel", + "php", + "phpspreadsheet" + ], + "support": { + "issues": "https://github.com/SpartnerNL/Laravel-Excel/issues", + "source": "https://github.com/SpartnerNL/Laravel-Excel/tree/3.1.67" + }, + "funding": [ + { + "url": "https://laravel-excel.com/commercial-support", + "type": "custom" + }, + { + "url": "https://github.com/patrickbrouwers", + "type": "github" + } + ], + "time": "2025-08-26T09:13:16+00:00" + }, + { + "name": "maennchen/zipstream-php", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/maennchen/ZipStream-PHP.git", + "reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/682f1098a8fddbaf43edac2306a691c7ad508ec5", + "reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-zlib": "*", + "php-64bit": "^8.3" + }, + "require-dev": { + "brianium/paratest": "^7.7", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.86", + "guzzlehttp/guzzle": "^7.5", + "mikey179/vfsstream": "^1.6", + "php-coveralls/php-coveralls": "^2.5", + "phpunit/phpunit": "^12.0", + "vimeo/psalm": "^6.0" + }, + "suggest": { + "guzzlehttp/psr7": "^2.4", + "psr/http-message": "^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "ZipStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paul Duncan", + "email": "pabs@pablotron.org" + }, + { + "name": "Jonatan Männchen", + "email": "jonatan@maennchen.ch" + }, + { + "name": "Jesse Donat", + "email": "donatj@gmail.com" + }, + { + "name": "András Kolesár", + "email": "kolesar@kolesar.hu" + } + ], + "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.", + "keywords": [ + "stream", + "zip" + ], + "support": { + "issues": "https://github.com/maennchen/ZipStream-PHP/issues", + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.1" + }, + "funding": [ + { + "url": "https://github.com/maennchen", + "type": "github" + } + ], + "time": "2025-12-10T09:58:31+00:00" + }, + { + "name": "markbaker/complex", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPComplex.git", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Complex\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@lange.demon.co.uk" + } + ], + "description": "PHP Class for working with complex numbers", + "homepage": "https://github.com/MarkBaker/PHPComplex", + "keywords": [ + "complex", + "mathematics" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPComplex/issues", + "source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2" + }, + "time": "2022-12-06T16:21:08+00:00" + }, + { + "name": "markbaker/matrix", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPMatrix.git", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpdocumentor/phpdocumentor": "2.*", + "phploc/phploc": "^4.0", + "phpmd/phpmd": "2.*", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "sebastian/phpcpd": "^4.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Matrix\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@demon-angel.eu" + } + ], + "description": "PHP Class for working with matrices", + "homepage": "https://github.com/MarkBaker/PHPMatrix", + "keywords": [ + "mathematics", + "matrix", + "vector" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPMatrix/issues", + "source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1" + }, + "time": "2022-12-02T22:17:43+00:00" + }, { "name": "monolog/monolog", "version": "3.9.0", @@ -2659,6 +3142,114 @@ ], "time": "2025-11-20T02:34:59+00:00" }, + { + "name": "phpoffice/phpspreadsheet", + "version": "1.30.2", + "source": { + "type": "git", + "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", + "reference": "09cdde5e2f078b9a3358dd217e2c8cb4dac84be2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/09cdde5e2f078b9a3358dd217e2c8cb4dac84be2", + "reference": "09cdde5e2f078b9a3358dd217e2c8cb4dac84be2", + "shasum": "" + }, + "require": { + "composer/pcre": "^1||^2||^3", + "ext-ctype": "*", + "ext-dom": "*", + "ext-fileinfo": "*", + "ext-gd": "*", + "ext-iconv": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-simplexml": "*", + "ext-xml": "*", + "ext-xmlreader": "*", + "ext-xmlwriter": "*", + "ext-zip": "*", + "ext-zlib": "*", + "ezyang/htmlpurifier": "^4.15", + "maennchen/zipstream-php": "^2.1 || ^3.0", + "markbaker/complex": "^3.0", + "markbaker/matrix": "^3.0", + "php": ">=7.4.0 <8.5.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-main", + "doctrine/instantiator": "^1.5", + "dompdf/dompdf": "^1.0 || ^2.0 || ^3.0", + "friendsofphp/php-cs-fixer": "^3.2", + "mitoteam/jpgraph": "^10.3", + "mpdf/mpdf": "^8.1.1", + "phpcompatibility/php-compatibility": "^9.3", + "phpstan/phpstan": "^1.1", + "phpstan/phpstan-phpunit": "^1.0", + "phpunit/phpunit": "^8.5 || ^9.0", + "squizlabs/php_codesniffer": "^3.7", + "tecnickcom/tcpdf": "^6.5" + }, + "suggest": { + "dompdf/dompdf": "Option for rendering PDF with PDF Writer", + "ext-intl": "PHP Internationalization Functions", + "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers", + "mpdf/mpdf": "Option for rendering PDF with PDF Writer", + "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Maarten Balliauw", + "homepage": "https://blog.maartenballiauw.be" + }, + { + "name": "Mark Baker", + "homepage": "https://markbakeruk.net" + }, + { + "name": "Franck Lefevre", + "homepage": "https://rootslabs.net" + }, + { + "name": "Erik Tilt" + }, + { + "name": "Adrien Crivelli" + }, + { + "name": "Owen Leibman" + } + ], + "description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine", + "homepage": "https://github.com/PHPOffice/PhpSpreadsheet", + "keywords": [ + "OpenXML", + "excel", + "gnumeric", + "ods", + "php", + "spreadsheet", + "xls", + "xlsx" + ], + "support": { + "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", + "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.30.2" + }, + "time": "2026-01-11T05:58:24+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.4", @@ -9477,12 +10068,12 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { "php": "^8.2" }, - "platform-dev": [], + "platform-dev": {}, "plugin-api-version": "2.6.0" } diff --git a/config/app.php b/config/app.php index 02d4ed4..ada063c 100644 --- a/config/app.php +++ b/config/app.php @@ -123,4 +123,8 @@ return [ 'store' => env('APP_MAINTENANCE_STORE', 'database'), ], + 'version' => env('APP_VERSION', 'vNone'), + + 'tag' => env('APP_TAG', 'dev') + ]; diff --git a/config/excel.php b/config/excel.php new file mode 100644 index 0000000..63746e1 --- /dev/null +++ b/config/excel.php @@ -0,0 +1,380 @@ + [ + + /* + |-------------------------------------------------------------------------- + | Chunk size + |-------------------------------------------------------------------------- + | + | When using FromQuery, the query is automatically chunked. + | Here you can specify how big the chunk should be. + | + */ + 'chunk_size' => 1000, + + /* + |-------------------------------------------------------------------------- + | Pre-calculate formulas during export + |-------------------------------------------------------------------------- + */ + 'pre_calculate_formulas' => false, + + /* + |-------------------------------------------------------------------------- + | Enable strict null comparison + |-------------------------------------------------------------------------- + | + | When enabling strict null comparison empty cells ('') will + | be added to the sheet. + */ + 'strict_null_comparison' => false, + + /* + |-------------------------------------------------------------------------- + | CSV Settings + |-------------------------------------------------------------------------- + | + | Configure e.g. delimiter, enclosure and line ending for CSV exports. + | + */ + 'csv' => [ + 'delimiter' => ',', + 'enclosure' => '"', + 'line_ending' => PHP_EOL, + 'use_bom' => false, + 'include_separator_line' => false, + 'excel_compatibility' => false, + 'output_encoding' => '', + 'test_auto_detect' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Worksheet properties + |-------------------------------------------------------------------------- + | + | Configure e.g. default title, creator, subject,... + | + */ + 'properties' => [ + 'creator' => '', + 'lastModifiedBy' => '', + 'title' => '', + 'description' => '', + 'subject' => '', + 'keywords' => '', + 'category' => '', + 'manager' => '', + 'company' => '', + ], + ], + + 'imports' => [ + + /* + |-------------------------------------------------------------------------- + | Read Only + |-------------------------------------------------------------------------- + | + | When dealing with imports, you might only be interested in the + | data that the sheet exists. By default we ignore all styles, + | however if you want to do some logic based on style data + | you can enable it by setting read_only to false. + | + */ + 'read_only' => true, + + /* + |-------------------------------------------------------------------------- + | Ignore Empty + |-------------------------------------------------------------------------- + | + | When dealing with imports, you might be interested in ignoring + | rows that have null values or empty strings. By default rows + | containing empty strings or empty values are not ignored but can be + | ignored by enabling the setting ignore_empty to true. + | + */ + 'ignore_empty' => false, + + /* + |-------------------------------------------------------------------------- + | Heading Row Formatter + |-------------------------------------------------------------------------- + | + | Configure the heading row formatter. + | Available options: none|slug|custom + | + */ + 'heading_row' => [ + 'formatter' => 'slug', + ], + + /* + |-------------------------------------------------------------------------- + | CSV Settings + |-------------------------------------------------------------------------- + | + | Configure e.g. delimiter, enclosure and line ending for CSV imports. + | + */ + 'csv' => [ + 'delimiter' => null, + 'enclosure' => '"', + 'escape_character' => '\\', + 'contiguous' => false, + 'input_encoding' => Csv::GUESS_ENCODING, + ], + + /* + |-------------------------------------------------------------------------- + | Worksheet properties + |-------------------------------------------------------------------------- + | + | Configure e.g. default title, creator, subject,... + | + */ + 'properties' => [ + 'creator' => '', + 'lastModifiedBy' => '', + 'title' => '', + 'description' => '', + 'subject' => '', + 'keywords' => '', + 'category' => 'Отчетность', + 'manager' => 'Метрика 1.0', + 'company' => 'ГАУЗ АО "АОКБ"', + ], + + /* + |-------------------------------------------------------------------------- + | Cell Middleware + |-------------------------------------------------------------------------- + | + | Configure middleware that is executed on getting a cell value + | + */ + 'cells' => [ + 'middleware' => [ + //\Maatwebsite\Excel\Middleware\TrimCellValue::class, + //\Maatwebsite\Excel\Middleware\ConvertEmptyCellValuesToNull::class, + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Extension detector + |-------------------------------------------------------------------------- + | + | Configure here which writer/reader type should be used when the package + | needs to guess the correct type based on the extension alone. + | + */ + 'extension_detector' => [ + 'xlsx' => Excel::XLSX, + 'xlsm' => Excel::XLSX, + 'xltx' => Excel::XLSX, + 'xltm' => Excel::XLSX, + 'xls' => Excel::XLS, + 'xlt' => Excel::XLS, + 'ods' => Excel::ODS, + 'ots' => Excel::ODS, + 'slk' => Excel::SLK, + 'xml' => Excel::XML, + 'gnumeric' => Excel::GNUMERIC, + 'htm' => Excel::HTML, + 'html' => Excel::HTML, + 'csv' => Excel::CSV, + 'tsv' => Excel::TSV, + + /* + |-------------------------------------------------------------------------- + | PDF Extension + |-------------------------------------------------------------------------- + | + | Configure here which Pdf driver should be used by default. + | Available options: Excel::MPDF | Excel::TCPDF | Excel::DOMPDF + | + */ + 'pdf' => Excel::DOMPDF, + ], + + /* + |-------------------------------------------------------------------------- + | Value Binder + |-------------------------------------------------------------------------- + | + | PhpSpreadsheet offers a way to hook into the process of a value being + | written to a cell. In there some assumptions are made on how the + | value should be formatted. If you want to change those defaults, + | you can implement your own default value binder. + | + | Possible value binders: + | + | [x] Maatwebsite\Excel\DefaultValueBinder::class + | [x] PhpOffice\PhpSpreadsheet\Cell\StringValueBinder::class + | [x] PhpOffice\PhpSpreadsheet\Cell\AdvancedValueBinder::class + | + */ + 'value_binder' => [ + 'default' => Maatwebsite\Excel\DefaultValueBinder::class, + ], + + 'cache' => [ + /* + |-------------------------------------------------------------------------- + | Default cell caching driver + |-------------------------------------------------------------------------- + | + | By default PhpSpreadsheet keeps all cell values in memory, however when + | dealing with large files, this might result into memory issues. If you + | want to mitigate that, you can configure a cell caching driver here. + | When using the illuminate driver, it will store each value in the + | cache store. This can slow down the process, because it needs to + | store each value. You can use the "batch" store if you want to + | only persist to the store when the memory limit is reached. + | + | Drivers: memory|illuminate|batch + | + */ + 'driver' => 'memory', + + /* + |-------------------------------------------------------------------------- + | Batch memory caching + |-------------------------------------------------------------------------- + | + | When dealing with the "batch" caching driver, it will only + | persist to the store when the memory limit is reached. + | Here you can tweak the memory limit to your liking. + | + */ + 'batch' => [ + 'memory_limit' => 60000, + ], + + /* + |-------------------------------------------------------------------------- + | Illuminate cache + |-------------------------------------------------------------------------- + | + | When using the "illuminate" caching driver, it will automatically use + | your default cache store. However if you prefer to have the cell + | cache on a separate store, you can configure the store name here. + | You can use any store defined in your cache config. When leaving + | at "null" it will use the default store. + | + */ + 'illuminate' => [ + 'store' => null, + ], + + /* + |-------------------------------------------------------------------------- + | Cache Time-to-live (TTL) + |-------------------------------------------------------------------------- + | + | The TTL of items written to cache. If you want to keep the items cached + | indefinitely, set this to null. Otherwise, set a number of seconds, + | a \DateInterval, or a callable. + | + | Allowable types: callable|\DateInterval|int|null + | + */ + 'default_ttl' => 10800, + ], + + /* + |-------------------------------------------------------------------------- + | Transaction Handler + |-------------------------------------------------------------------------- + | + | By default the import is wrapped in a transaction. This is useful + | for when an import may fail and you want to retry it. With the + | transactions, the previous import gets rolled-back. + | + | You can disable the transaction handler by setting this to null. + | Or you can choose a custom made transaction handler here. + | + | Supported handlers: null|db + | + */ + 'transactions' => [ + 'handler' => 'db', + 'db' => [ + 'connection' => null, + ], + ], + + 'temporary_files' => [ + + /* + |-------------------------------------------------------------------------- + | Local Temporary Path + |-------------------------------------------------------------------------- + | + | When exporting and importing files, we use a temporary file, before + | storing reading or downloading. Here you can customize that path. + | permissions is an array with the permission flags for the directory (dir) + | and the create file (file). + | + */ + 'local_path' => storage_path('framework/cache/laravel-excel'), + + /* + |-------------------------------------------------------------------------- + | Local Temporary Path Permissions + |-------------------------------------------------------------------------- + | + | Permissions is an array with the permission flags for the directory (dir) + | and the create file (file). + | If omitted the default permissions of the filesystem will be used. + | + */ + 'local_permissions' => [ + // 'dir' => 0755, + // 'file' => 0644, + ], + + /* + |-------------------------------------------------------------------------- + | Remote Temporary Disk + |-------------------------------------------------------------------------- + | + | When dealing with a multi server setup with queues in which you + | cannot rely on having a shared local temporary path, you might + | want to store the temporary file on a shared disk. During the + | queue executing, we'll retrieve the temporary file from that + | location instead. When left to null, it will always use + | the local path. This setting only has effect when using + | in conjunction with queued imports and exports. + | + */ + 'remote_disk' => null, + 'remote_prefix' => null, + + /* + |-------------------------------------------------------------------------- + | Force Resync + |-------------------------------------------------------------------------- + | + | When dealing with a multi server setup as above, it's possible + | for the clean up that occurs after entire queue has been run to only + | cleanup the server that the last AfterImportJob runs on. The rest of the server + | would still have the local temporary file stored on it. In this case your + | local storage limits can be exceeded and future imports won't be processed. + | To mitigate this you can set this config value to be true, so that after every + | queued chunk is processed the local temporary file is deleted on the server that + | processed it. + | + */ + 'force_resync_remote' => null, + ], +]; diff --git a/database/migrations/2026_02_25_155843_add_role_id_in_sessions_table.php b/database/migrations/2026_02_25_155843_add_role_id_in_sessions_table.php new file mode 100644 index 0000000..b770dd0 --- /dev/null +++ b/database/migrations/2026_02_25_155843_add_role_id_in_sessions_table.php @@ -0,0 +1,30 @@ +foreignIdFor(\App\Models\Role::class, 'role_id') + ->nullable() + ->constrained(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('sessions', function (Blueprint $table) { + $table->dropColumn('role_id'); + }); + } +}; diff --git a/database/migrations/2026_02_26_112830_add_indexes_in_reports_table.php b/database/migrations/2026_02_26_112830_add_indexes_in_reports_table.php new file mode 100644 index 0000000..62ed88c --- /dev/null +++ b/database/migrations/2026_02_26_112830_add_indexes_in_reports_table.php @@ -0,0 +1,29 @@ +index(['rf_department_id', 'created_at'], 'idx_reports_dept_created'); + $table->index('created_at', 'idx_reports_created_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('reports', function (Blueprint $table) { + // + }); + } +}; diff --git a/database/migrations/2026_02_26_112835_add_indexes_in_medical_history_snapshots_table.php b/database/migrations/2026_02_26_112835_add_indexes_in_medical_history_snapshots_table.php new file mode 100644 index 0000000..ea9aefc --- /dev/null +++ b/database/migrations/2026_02_26_112835_add_indexes_in_medical_history_snapshots_table.php @@ -0,0 +1,29 @@ +index(['rf_report_id', 'patient_type'], 'idx_snapshots_report_patient'); + $table->index('rf_medicalhistory_id', 'idx_snapshots_medicalhistory'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('medical_history_snapshots', function (Blueprint $table) { + // + }); + } +}; diff --git a/database/migrations/2026_03_19_135640_add_order_column_in_user_departments_table.php b/database/migrations/2026_03_19_135640_add_order_column_in_user_departments_table.php new file mode 100644 index 0000000..21fddf5 --- /dev/null +++ b/database/migrations/2026_03_19_135640_add_order_column_in_user_departments_table.php @@ -0,0 +1,28 @@ +integer('order')->default(0); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('user_departments', function (Blueprint $table) { + $table->dropColumn('order'); + }); + } +}; diff --git a/database/migrations/2026_03_19_140719_add_user_name_column_in_user_departments_table.php b/database/migrations/2026_03_19_140719_add_user_name_column_in_user_departments_table.php new file mode 100644 index 0000000..ad19e72 --- /dev/null +++ b/database/migrations/2026_03_19_140719_add_user_name_column_in_user_departments_table.php @@ -0,0 +1,28 @@ +string('user_name')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('user_departments', function (Blueprint $table) { + $table->dropColumn('user_name'); + }); + } +}; diff --git a/database/migrations/2026_03_20_094321_add_date_end_column_in_department_metrika_defaults_table.php b/database/migrations/2026_03_20_094321_add_date_end_column_in_department_metrika_defaults_table.php new file mode 100644 index 0000000..d9f1a2d --- /dev/null +++ b/database/migrations/2026_03_20_094321_add_date_end_column_in_department_metrika_defaults_table.php @@ -0,0 +1,28 @@ +dateTime('date_end')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('department_metrika_defaults', function (Blueprint $table) { + $table->dropColumn('date_end'); + }); + } +}; diff --git a/package-lock.json b/package-lock.json index cd160c2..5a75ca7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "dependencies": { "@arco-design/color": "^0.4.0", "@inertiajs/vue3": "^2.3.4", + "@sentry/vue": "^10.43.0", "@vitejs/plugin-vue": "^6.0.2", "@vue-flow/core": "^1.48.1", "@vueuse/core": "^14.1.0", @@ -972,6 +973,107 @@ "win32" ] }, + "node_modules/@sentry-internal/browser-utils": { + "version": "10.43.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.43.0.tgz", + "integrity": "sha512-8zYTnzhAPvNkVH1Irs62wl0J/c+0QcJ62TonKnzpSFUUD3V5qz8YDZbjIDGfxy+1EB9fO0sxtddKCzwTHF/MbQ==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.43.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/feedback": { + "version": "10.43.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.43.0.tgz", + "integrity": "sha512-YoXuwluP6eOcQxTeTtaWb090++MrLyWOVsUTejzUQQ6LFL13Jwt+bDPF1kvBugMq4a7OHw/UNKQfd6//rZMn2g==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.43.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/replay": { + "version": "10.43.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.43.0.tgz", + "integrity": "sha512-khCXlGrlH1IU7P5zCEAJFestMeH97zDVCekj8OsNNDtN/1BmCJ46k6Xi0EqAUzdJgrOLJeLdoYdgtiIjovZ8Sg==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "10.43.0", + "@sentry/core": "10.43.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/replay-canvas": { + "version": "10.43.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.43.0.tgz", + "integrity": "sha512-ZIw1UNKOFXo1LbPCJPMAx9xv7D8TMZQusLDUgb6BsPQJj0igAuwd7KRGTkjjgnrwBp2O/sxcQFRhQhknWk7QPg==", + "license": "MIT", + "dependencies": { + "@sentry-internal/replay": "10.43.0", + "@sentry/core": "10.43.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/browser": { + "version": "10.43.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.43.0.tgz", + "integrity": "sha512-2V3I3sXi3SMeiZpKixd9ztokSgK27cmvsD9J5oyOyjhGLTW/6QKCwHbKnluMgQMXq20nixQk5zN4wRjRUma3sg==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "10.43.0", + "@sentry-internal/feedback": "10.43.0", + "@sentry-internal/replay": "10.43.0", + "@sentry-internal/replay-canvas": "10.43.0", + "@sentry/core": "10.43.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/core": { + "version": "10.43.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.43.0.tgz", + "integrity": "sha512-l0SszQAPiQGWl/ferw8GP3ALyHXiGiRKJaOvNmhGO+PrTQyZTZ6OYyPnGijAFRg58dE1V3RCH/zw5d2xSUIiNg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/vue": { + "version": "10.43.0", + "resolved": "https://registry.npmjs.org/@sentry/vue/-/vue-10.43.0.tgz", + "integrity": "sha512-PYBJVHfd7JwnQv92sTnsfLVNwVEKY2wQzXt9aux6QNcKh4g3pyK68PoEBrcCSEHpUb7zs3lJedk3+aeX+kN7fw==", + "license": "MIT", + "dependencies": { + "@sentry/browser": "10.43.0", + "@sentry/core": "10.43.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@tanstack/vue-router": "^1.64.0", + "pinia": "2.x || 3.x", + "vue": "2.x || 3.x" + }, + "peerDependenciesMeta": { + "@tanstack/vue-router": { + "optional": true + }, + "pinia": { + "optional": true + } + } + }, "node_modules/@tailwindcss/language-server": { "version": "0.14.29", "resolved": "https://registry.npmjs.org/@tailwindcss/language-server/-/language-server-0.14.29.tgz", diff --git a/package.json b/package.json index 51f4aae..3d3ecc4 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "dependencies": { "@arco-design/color": "^0.4.0", "@inertiajs/vue3": "^2.3.4", + "@sentry/vue": "^10.43.0", "@vitejs/plugin-vue": "^6.0.2", "@vue-flow/core": "^1.48.1", "@vueuse/core": "^14.1.0", diff --git a/resources/js/Components/AppContainer.vue b/resources/js/Components/AppContainer.vue index 49aac39..0153f3a 100644 --- a/resources/js/Components/AppContainer.vue +++ b/resources/js/Components/AppContainer.vue @@ -3,7 +3,7 @@ diff --git a/resources/js/Components/AppPanel.vue b/resources/js/Components/AppPanel.vue index 4053335..8adfa78 100644 --- a/resources/js/Components/AppPanel.vue +++ b/resources/js/Components/AppPanel.vue @@ -18,6 +18,10 @@ const props = defineProps({ type: [String, Number], default: null }, + noPadding: { + type: Boolean, + default: false + } }) const hasHeader = computed(() => props.header.trim().length > 0) @@ -52,7 +56,7 @@ watch(() => [props.minH, props.maxH], ([minH, maxH]) => { diff --git a/resources/js/Components/DatePickerQuery.vue b/resources/js/Components/DatePickerQuery.vue index e963964..8f186dd 100644 --- a/resources/js/Components/DatePickerQuery.vue +++ b/resources/js/Components/DatePickerQuery.vue @@ -1,10 +1,11 @@ diff --git a/resources/js/Layouts/Components/Statistic/StatisticRecipientPlanOfYear.vue b/resources/js/Layouts/Components/Statistic/StatisticRecipientPlanOfYear.vue new file mode 100644 index 0000000..33c8f0f --- /dev/null +++ b/resources/js/Layouts/Components/Statistic/StatisticRecipientPlanOfYear.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/resources/js/Pages/Admin/Users/Create.vue b/resources/js/Pages/Admin/Users/Create.vue index df4fd9d..2740848 100644 --- a/resources/js/Pages/Admin/Users/Create.vue +++ b/resources/js/Pages/Admin/Users/Create.vue @@ -1,10 +1,10 @@ + + + + diff --git a/resources/js/Pages/Report/Components/ReportFormInput.vue b/resources/js/Pages/Report/Components/ReportFormInput.vue index 6062cfc..4ffb65d 100644 --- a/resources/js/Pages/Report/Components/ReportFormInput.vue +++ b/resources/js/Pages/Report/Components/ReportFormInput.vue @@ -1,5 +1,5 @@