From 739168d42731e3051d1c5a33f779c42929cb0c86 Mon Sep 17 00:00:00 2001 From: brusnitsyn Date: Thu, 28 May 2026 22:10:00 +0900 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20=D1=81=D1=82=D0=B0=D1=80=D1=82=D0=BE=D0=B2=D1=8B=D0=B9?= =?UTF-8?q?=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=20=D0=9F=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D1=8B=20=D0=B7=D0=B0=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=81=D1=8B=20=D0=B4=D0=BB=D1=8F=20=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D0=B8=D1=81=D1=82=D0=B8=D0=BA=D0=B8,=20=D0=BE=D1=82?= =?UTF-8?q?=D1=87=D0=B5=D1=82=D0=BE=D0=B2=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BE=D1=82=D1=87=D0=B5=D1=82=D0=B0?= =?UTF-8?q?=20=D1=81=D0=B5=D1=81=D1=82=D1=80=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Console/Commands/FillReportsFromDate.php | 8 +- app/Console/Commands/GenerateDutyReport.php | 191 ++++ app/Http/Controllers/Api/NurseController.php | 62 +- .../Controllers/Api/StatisticController.php | 45 +- app/Http/Controllers/AuthController.php | 13 +- .../Controllers/Web/Admin/AdminController.php | 6 +- .../Web/Admin/MetrikaController.php | 199 +++++ .../Controllers/Web/Admin/UserController.php | 192 +++- .../Controllers/Web/DutyReportController.php | 306 ++++++- app/Http/Controllers/Web/IndexController.php | 4 +- .../Controllers/Web/NurseReportController.php | 23 +- .../Controllers/Web/StatisticController.php | 21 +- app/Http/Middleware/HandleInertiaRequests.php | 16 +- app/Models/DutyReportMetricResult.php | 28 + app/Models/DutyUnwantedEvent.php | 19 + app/Models/MedicalHistory.php | 12 + app/Models/MigrationPatient.php | 7 +- app/Models/MigrationPatientNurse.php | 3 +- app/Models/ObservableMedicalHistory.php | 36 + app/Models/ReportDuty.php | 18 + app/Models/ReportDutyMigrationPatient.php | 31 +- app/Models/ReportDutyPatient.php | 15 +- app/Models/ReportDutyReanimation.php | 55 ++ app/Models/ReportNurseMigrationPatient.php | 20 + app/Models/UnifiedMigrationPatient.php | 4 +- app/Models/User.php | 64 +- app/Models/UserDepartment.php | 2 + .../PatientStatusClassifier.php | 324 ++++++- app/Services/DateRangeService.php | 60 +- app/Services/DutyMedicalHistoryService.php | 157 ++++ app/Services/DutyReportService.php | 837 ++++++++++++++++-- app/Services/MedicalHistoryService.php | 111 ++- app/Services/NurseMedicalHistoryService.php | 116 +++ app/Services/NurseReportService.php | 41 +- app/Services/StatisticsService.php | 61 +- app/Services/UnifiedMedicalHistoryService.php | 66 +- ...n_report_duty_migration_patients_table.php | 28 + ...reate_duty_report_metric_results_table.php | 34 + ..._create_report_duty_reanimations_table.php | 42 + ...ate_observable_medical_histories_table.php | 48 + ...5330_create_duty_unwanted_events_table.php | 30 + ...6_update_roles_slugs_and_add_new_roles.php | 43 + database/seeders/PermissionAndRoleSeeder.php | 122 ++- package-lock.json | 6 + resources/css/app.css | 15 +- resources/js/Components/ActionTile.vue | 107 +++ resources/js/Components/DatePickerQuery.vue | 317 ++++--- resources/js/Components/PageBanner.vue | 112 +++ resources/js/Components/SectionCard.vue | 85 ++ resources/js/Components/ShiftPickerQuery.vue | 94 ++ resources/js/Components/StartButton.vue | 4 +- resources/js/Composables/usePatientColumns.js | 73 +- resources/js/Layouts/AppLayout.vue | 29 +- resources/js/Layouts/Components/AppHeader.vue | 76 +- .../js/Layouts/Components/AppUserButton.vue | 121 ++- resources/js/Layouts/Components/SideMenu.vue | 4 +- resources/js/Pages/Admin/Index.vue | 128 ++- resources/js/Pages/Admin/Metrics/Group.vue | 106 +++ resources/js/Pages/Admin/Metrics/Index.vue | 205 +++++ resources/js/Pages/Admin/Metrics/Item.vue | 160 ++++ resources/js/Pages/Admin/Users/Create.vue | 189 ++-- resources/js/Pages/Admin/Users/Index.vue | 183 ++-- resources/js/Pages/Admin/Users/User.vue | 370 +++++--- resources/js/Pages/Auth/Login.vue | 10 +- resources/js/Pages/Index.vue | 247 ++++-- .../Pages/Metriks/Components/MetrikaForm.vue | 2 - .../Components/ActionsColumnDataTable.vue | 6 +- .../Components/EditMedicalHistoryModal.vue | 15 +- resources/js/Pages/Nurse/Report/Index.vue | 49 +- .../DataTableColumns/ActionsColumn.vue | 13 +- .../DataTableColumns/OperationsColumn.vue | 5 +- .../Report/Components/DutyPatientsPane.vue | 287 ++++++ .../Pages/Report/Components/HeaderWidget.vue | 59 ++ .../Components/Modals/ObservableModal.vue | 98 ++ .../Components/Modals/UnwantedEventModal.vue | 239 +++++ .../Report/Components/NursePatientsPane.vue | 96 ++ .../Report/Components/OperationInfoModal.vue | 33 +- .../Report/Components/PatientDataTable.vue | 51 +- .../Report/Components/PatientSectionItem.vue | 2 +- .../Pages/Report/Components/ReportSection.vue | 2 - .../Pages/Report/Components/ReportWidget.vue | 27 +- .../Report/Components/SelectUserModal.vue | 29 +- .../Components/Tags/OperationUrgencyTag.vue | 26 + .../Report/Components/UnwantedEventModal.vue | 5 +- .../Components/Widgets/LoadedWidget.vue | 37 + .../Components/Widgets/OperationWidget.vue | 32 + .../Components/Widgets/RecipientWidget.vue | 52 ++ resources/js/Pages/Report/Index.vue | 361 ++++---- .../Components/ModalObservablePatients.vue | 238 ++--- .../Statistic/Components/OutcomeColumn.vue | 67 ++ resources/js/Pages/Statistic/Index.vue | 23 +- resources/js/Stores/auth.js | 51 +- resources/js/app.js | 2 - resources/js/bootstrap.js | 14 - routes/api.php | 7 + routes/web.php | 39 +- 96 files changed, 6663 insertions(+), 1465 deletions(-) create mode 100644 app/Console/Commands/GenerateDutyReport.php create mode 100644 app/Http/Controllers/Web/Admin/MetrikaController.php create mode 100644 app/Models/DutyReportMetricResult.php create mode 100644 app/Models/DutyUnwantedEvent.php create mode 100644 app/Models/ObservableMedicalHistory.php create mode 100644 app/Models/ReportDutyReanimation.php create mode 100644 database/migrations/2026_05_12_115210_add_original_id_in_report_duty_migration_patients_table.php create mode 100644 database/migrations/2026_05_12_115810_create_duty_report_metric_results_table.php create mode 100644 database/migrations/2026_05_12_121534_create_report_duty_reanimations_table.php create mode 100644 database/migrations/2026_05_14_155245_create_observable_medical_histories_table.php create mode 100644 database/migrations/2026_05_15_155330_create_duty_unwanted_events_table.php create mode 100644 database/migrations/2026_05_19_233036_update_roles_slugs_and_add_new_roles.php create mode 100644 resources/js/Components/ActionTile.vue create mode 100644 resources/js/Components/PageBanner.vue create mode 100644 resources/js/Components/SectionCard.vue create mode 100644 resources/js/Components/ShiftPickerQuery.vue create mode 100644 resources/js/Pages/Admin/Metrics/Group.vue create mode 100644 resources/js/Pages/Admin/Metrics/Index.vue create mode 100644 resources/js/Pages/Admin/Metrics/Item.vue create mode 100644 resources/js/Pages/Report/Components/DutyPatientsPane.vue create mode 100644 resources/js/Pages/Report/Components/HeaderWidget.vue create mode 100644 resources/js/Pages/Report/Components/Modals/ObservableModal.vue create mode 100644 resources/js/Pages/Report/Components/Modals/UnwantedEventModal.vue create mode 100644 resources/js/Pages/Report/Components/NursePatientsPane.vue create mode 100644 resources/js/Pages/Report/Components/Tags/OperationUrgencyTag.vue create mode 100644 resources/js/Pages/Report/Components/Widgets/LoadedWidget.vue create mode 100644 resources/js/Pages/Report/Components/Widgets/OperationWidget.vue create mode 100644 resources/js/Pages/Report/Components/Widgets/RecipientWidget.vue create mode 100644 resources/js/Pages/Statistic/Components/OutcomeColumn.vue diff --git a/app/Console/Commands/FillReportsFromDate.php b/app/Console/Commands/FillReportsFromDate.php index fae9119..ef54bd2 100644 --- a/app/Console/Commands/FillReportsFromDate.php +++ b/app/Console/Commands/FillReportsFromDate.php @@ -104,10 +104,10 @@ class FillReportsFromDate extends Command ->whereHas('departments', function ($departmentQuery) use ($department) { $departmentQuery->where('rf_department_id', $department->department_id); }) - ->whereHas('roles', function ($roleQuery) { - $roleQuery->where('slug', 'doctor'); + ->whereHas('appRoles', function ($roleQuery) { + $roleQuery->where('slug', 'dej'); }) - ->with(['roles', 'departments']); + ->with(['appRoles', 'departments']); if ($userId) { return $query->where('id', $userId)->first(); @@ -116,7 +116,7 @@ class FillReportsFromDate extends Command return $query->get() ->sortBy(function (User $user) use ($department) { $departmentLink = $user->departments->firstWhere('rf_department_id', $department->department_id); - $isDoctor = $user->roles->contains('slug', 'doctor'); + $isDoctor = $user->appRoles->contains('slug', 'dej'); $isFavorite = (bool) optional($departmentLink)->is_favorite; $order = optional($departmentLink)->order ?? PHP_INT_MAX; diff --git a/app/Console/Commands/GenerateDutyReport.php b/app/Console/Commands/GenerateDutyReport.php new file mode 100644 index 0000000..74fd451 --- /dev/null +++ b/app/Console/Commands/GenerateDutyReport.php @@ -0,0 +1,191 @@ +option('timezone') ?: config('app.timezone', 'Europe/Moscow'); + $shiftStartTime = $this->option('shift-start') ?: '09:00'; + + // 1. Валидация и парсинг дат + $startDate = Carbon::parse($this->option('start') ?: now($tz)->format('Y-m-d'), $tz)->setTimeFromTimeString($shiftStartTime); + $endDate = Carbon::parse($this->option('end') ?: $this->option('start') ?: now($tz)->format('Y-m-d'), $tz)->setTimeFromTimeString($shiftStartTime); + + if ($endDate->lt($startDate)) { + $this->error('Конечная дата не может быть раньше начальной.'); + return CommandAlias::FAILURE; + } + + // 2. Разрешение списка отделений + $departments = $this->resolveDepartments($this->option('departments')); + if ($departments->isEmpty()) { + $this->error('Отделения не найдены.'); + return CommandAlias::FAILURE; + } + + // 3. Генерация массива смен + $shifts = []; + $current = $startDate->copy(); + while ($current->lte($endDate)) { + $shifts[] = [ + 'start' => $current->copy(), + 'end' => $current->copy()->addDay(), // 09:00 → 09:00 следующего дня + ]; + $current->addDay(); + } + + // 4. Пользователь CLI + $userId = $this->option('user') ? (int) $this->option('user') : null; + if (!$userId && !$this->option('dry-run')) { + $this->error('Для записи в БД в CLI режиме укажите параметр --user='); + return CommandAlias::FAILURE; + } + + // 5. Вывод информации + $totalTasks = count($shifts) * $departments->count(); + if ($totalTasks === 0) { + $this->warn('Нет задач для выполнения.'); + return CommandAlias::SUCCESS; + } + + $this->info("Период: {$shifts[0]['start']->format('Y-m-d H:i')} → {$shifts[array_key_last($shifts)]['end']->format('Y-m-d H:i')} ({$tz})"); + $this->info("Отделений: {$departments->count()}"); + $this->info("Всего смен: {$totalTasks}"); + if ($this->option('dry-run')) $this->warn("Режим DRY RUN"); + if ($this->option('skip-existing')) $this->warn("Пропуск существующих отчётов включён"); + + $progressBar = $this->output->createProgressBar($totalTasks); + $progressBar->start(); + + $success = 0; $skipped = 0; $errors = 0; + + // 6. Основной цикл + foreach ($departments as $dept) { + foreach ($shifts as $shift) { + try { + $status = $this->processShift( + $shift['start'], $shift['end'], $dept, $userId, + $this->reportService, $this->dateRangeService + ); + + if ($status === 'skip' || $status === 'dry_run') { + $skipped++; + } else { + $success++; + } + } catch (Throwable $e) { + $errors++; + // Безопасное получение имени отделения + $deptName = $dept->name ?? $dept->department_name ?? "Отдел #{$dept->department_id}"; + $this->error("\n[{$deptName}] {$shift['start']->format('Y-m-d H:i')}: {$e->getMessage()}"); + Log::error('DutyReportShiftGeneration', [ + 'department_id' => $dept->department_id, + 'shift_start' => $shift['start']->format('Y-m-d H:i:s'), + 'shift_end' => $shift['end']->format('Y-m-d H:i:s'), + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + } finally { + // ✅ ГАРАНТИРУЕМ ровно 1 шаг прогресса на каждую итерацию + $progressBar->advance(); + } + } + } + + $progressBar->finish(); + $this->newLine(2); + $this->info("Завершено. Успешно: {$success} | Пропущено: {$skipped} | Ошибок: {$errors}"); + + return $errors > 0 ? CommandAlias::FAILURE : CommandAlias::SUCCESS; + } + + /** + * Получение списка отделений + */ + private function resolveDepartments($input) + { + if (!$input || strtolower($input) === 'all') { + return Department::orderBy('department_id')->get(); + } + + $ids = array_map('trim', explode(',', $input)); + return Department::whereIn('department_id', $ids)->get(); + } + + /** + * Обработка одной смены + * @return string 'success' | 'skip' | 'dry_run' + */ + private function processShift( + Carbon $shiftStart, + Carbon $shiftEnd, + $dept, + int $userId, + DutyReportService $reportService, + DateRangeService $dateRangeService + ): string { + $deptId = $dept->department_id; + $misDeptId = $dept->rf_mis_department_id; + + // Пропуск, если отчёт уже существует за ЭТУ ЖЕ СМЕНУ + if ($this->option('skip-existing')) { + $exists = ReportDuty::where('rf_department_id', $deptId) + ->where('period_start', $shiftStart->format('Y-m-d H:i:s')) + ->where('period_end', $shiftEnd->format('Y-m-d H:i:s')) + ->exists(); + if ($exists) { + return 'skip'; + } + } + + // Тестовый режим + if ($this->option('dry-run')) { + return 'dry_run'; + } + + // Формируем DateRange через ваш сервис (учёт смен, часовых поясов) + $user = User::find($userId); + $lpuDoctorId = $user->rf_lpudoctor_id ?? 1; + $dateRange = $dateRangeService->createDateRangeForDate($shiftEnd, $user); + + // Цепочка из вашего контроллера + $report = $reportService->saveReport($dateRange, $userId, $lpuDoctorId, $deptId); + $stats = $reportService->saveSnapshot($dateRange, $report, $misDeptId, $userId); + $reportService->saveMetrics($stats, $report); + + return 'success'; + } +} diff --git a/app/Http/Controllers/Api/NurseController.php b/app/Http/Controllers/Api/NurseController.php index 2f0cc67..e45b2f5 100644 --- a/app/Http/Controllers/Api/NurseController.php +++ b/app/Http/Controllers/Api/NurseController.php @@ -10,6 +10,9 @@ use App\Models\MigrationPatient; use App\Models\MigrationPatientCorrection; use App\Models\MigrationPatientNurse; use App\Models\MisStationarBranch; +use App\Models\ReportNurse; +use App\Models\ReportNurseMigrationPatient; +use App\Models\ReportNursePatient; use App\Models\UnifiedMedicalHistory; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -146,6 +149,10 @@ class NurseController extends Controller if ($historyCorrection && $migrationCorrection) { DB::commit(); + // Синхронизируем снапшот, чтобы правки были видны в МИС-вкладке + $reportNurseId = $request->input('report_nurse_id'); + $this->syncNurseSnapshot($originalId, $data, $migrationData, $departmentId, $reportNurseId); + return response()->json([ 'data' => $historyCorrection, ], 201); @@ -157,17 +164,64 @@ class NurseController extends Controller } } + private function syncNurseSnapshot( + mixed $originalId, + array $data, + array $migrationData, + int $departmentId, + ?int $reportNurseId + ): void { + $patientUpdate = collect($data) + ->only(['recipient_date', 'extract_date', 'death_date', 'urgency_id', + 'visit_result_id', 'hospital_result_id', 'full_name', 'birth_date']) + ->toArray(); + + if (empty($patientUpdate)) return; + + // Если известен отчёт — обновляем только его и все последующие по этому отделению + $reportNurseQuery = ReportNursePatient::where('original_id', $originalId); + + if ($reportNurseId) { + $fromReport = ReportNurse::find($reportNurseId); + if ($fromReport) { + $affectedReportIds = ReportNurse::where('rf_department_id', $fromReport->rf_department_id) + ->where('period_start', '>=', $fromReport->period_start) + ->pluck('id'); + $reportNurseQuery->whereIn('report_nurse_id', $affectedReportIds); + } + } + + $snapshotPatientIds = $reportNurseQuery->pluck('id'); + + if ($snapshotPatientIds->isEmpty()) return; + + ReportNursePatient::whereIn('id', $snapshotPatientIds)->update($patientUpdate); + + $migrationUpdate = array_filter([ + 'ingoing_date' => $migrationData['ingoing_date'] ?? null, + 'out_date' => $migrationData['out_date'] ?? null, + 'visit_result_id' => $migrationData['visit_result_id'] ?? null, + ]); + + if (!empty($migrationUpdate)) { + ReportNurseMigrationPatient::whereIn('medical_history_id', $snapshotPatientIds) + ->where('department_id', $departmentId) + ->update($migrationUpdate); + } + } + public function deleteHistory($id, Request $request) { $medicalHistory = UnifiedMedicalHistory::where('id', $id)->first(); - $medicalHistoryId = $medicalHistory->original_id; + $originalMedicalHistoryId = $medicalHistory->original_id; + $nurseMedicalHistory = ReportNursePatient::where('original_id', $originalMedicalHistoryId)->first(); $migrationPatientsIds = $medicalHistory->migrations()->pluck('original_id'); - foreach ($migrationPatientsIds as $migrationPatientsId) { - MigrationPatientNurse::query()->where('id', $migrationPatientsId)->delete(); + foreach ($nurseMedicalHistory->migrations() as $nurseMigrationPatient) { + $nurseMigrationPatient->delete(); } - MedicalHistoryNurse::where('id', $medicalHistoryId)->delete(); + $nurseMedicalHistory->delete(); return response()->json([]); } diff --git a/app/Http/Controllers/Api/StatisticController.php b/app/Http/Controllers/Api/StatisticController.php index 9e7d4a9..a7dc363 100644 --- a/app/Http/Controllers/Api/StatisticController.php +++ b/app/Http/Controllers/Api/StatisticController.php @@ -4,10 +4,13 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Models\Department; +use App\Models\DutyUnwantedEvent; use App\Services\DateRangeService; use App\Services\ReportService; +use Carbon\Carbon; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; class StatisticController extends Controller { @@ -28,7 +31,17 @@ class StatisticController extends Controller $dateRange = $this->dateRangeService->getNormalizedDateRange($user, $validated['startAt'], $validated['endAt']); $department = Department::findSole($request->departmentId, 'department_id'); - $unwantedEvents = $this->reportService->getUnwantedEvents($department, $dateRange); + $unwantedEvents = DutyUnwantedEvent::query() + ->whereHas('reportDuty', function ($q) use ($department, $dateRange) { + $q->where('rf_department_id', $department->department_id) + ->where('period_start', '>=', $dateRange->startSql()) + ->where('period_end', '<=', $dateRange->endSql()); + }) + ->get() + ->map(fn ($e) => [ + ...$e->toArray(), + 'created_at' => Carbon::parse($e->created_at)->format('Создано d.m.Y в H:i'), + ]); return response()->json($unwantedEvents); } @@ -44,7 +57,35 @@ class StatisticController extends Controller $dateRange = $this->dateRangeService->getNormalizedDateRange($user, $validated['startAt'], $validated['endAt']); $department = Department::findSole($request->departmentId, 'department_id'); - $observablePatients = $this->reportService->getPatientsByStatus($department, $user, 'observation', $dateRange); + + $observablePatients = DB::table('observable_medical_histories as omh') + ->join('report_duty_patients as rdp', 'rdp.original_id', '=', 'omh.original_id') + ->join('report_duties as rd', 'rd.id', '=', 'rdp.report_duty_id') + ->leftJoin('report_duty_migration_patients as rdm', function ($join) { + $join->on('rdm.medical_history_id', '=', 'rdp.id') + ->whereNull('rdm.out_date'); + }) + ->where('rd.rf_department_id', $department->department_id) + ->where('omh.observable_in', '>=', $dateRange->startSql()) + ->where('omh.observable_in', '<=', $dateRange->endSql()) + ->select('omh.*', 'rdm.diagnosis_code', 'rdm.diagnosis_name', 'rdm.ingoing_date as migration_ingoing_date') + ->distinct() + ->get() + ->map(fn ($row) => [ + 'id' => $row->id, + 'full_name' => $row->full_name, + 'birth_date' => $row->birth_date, + 'comment' => $row->comment, + 'observable_reason'=> $row->observable_reason, + 'observable_in' => $row->observable_in, + 'observable_out' => $row->observable_out, + 'migrations' => [[ + 'ingoing_date' => $row->migration_ingoing_date ?? $row->recipient_date, + 'diagnosis_code' => $row->diagnosis_code, + 'diagnosis_name' => $row->diagnosis_name, + ]], + 'operations' => [], + ]); return response()->json($observablePatients); } diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index 72ac252..1017bd1 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -75,20 +75,17 @@ class AuthController extends Controller $token = $user->tokens()->where('name', $sessionId)->first(); if ($token) { - $token->abilities = ['role:'.$request->role_id]; + $token->abilities = ['role:'.$data['role_id']]; $token->save(); } + // Store in session payload (web) and DB column (API) for dual access + session(['role_id' => $data['role_id']]); DB::table('sessions') ->where('id', $sessionId) - ->update(['role_id' => $request->role_id]); + ->update(['role_id' => $data['role_id']]); - // $sessionKey = 'user_' . $user->id . '_current_role'; - // - // $user->current_role_id = $data['role_id']; - // $user->save(); - - return redirect()->route('start')->setStatusCode(302); + return redirect()->route('start'); } public function logout(Request $request) diff --git a/app/Http/Controllers/Web/Admin/AdminController.php b/app/Http/Controllers/Web/Admin/AdminController.php index 7ed0e30..3dca86e 100644 --- a/app/Http/Controllers/Web/Admin/AdminController.php +++ b/app/Http/Controllers/Web/Admin/AdminController.php @@ -9,10 +9,8 @@ class AdminController extends Controller { public function index() { - return Inertia::render('Admin/Index', - [ + abort_unless(auth()->user()->isAdmin(), 403); - ] - ); + return Inertia::render('Admin/Index', []); } } diff --git a/app/Http/Controllers/Web/Admin/MetrikaController.php b/app/Http/Controllers/Web/Admin/MetrikaController.php new file mode 100644 index 0000000..0f06e0d --- /dev/null +++ b/app/Http/Controllers/Web/Admin/MetrikaController.php @@ -0,0 +1,199 @@ +user()->isAdmin(), 403); + + $groups = MetrikaGroup::withCount('groupItems')->get()->map(fn($g) => [ + 'id' => $g->metrika_group_id, + 'name' => $g->name, + 'description' => $g->description, + 'items_count' => $g->group_items_count, + ]); + + $items = MetrikaItem::all()->map(fn($i) => [ + 'id' => $i->metrika_item_id, + 'name' => $i->name, + 'description' => $i->description, + 'data_type' => $i->data_type, + 'is_active' => $i->is_active, + 'code' => $i->code, + ]); + + return Inertia::render('Admin/Metrics/Index', [ + 'groups' => $groups, + 'items' => $items, + ]); + } + + // ── Группы ── + + public function createGroup() + { + abort_unless(auth()->user()->isAdmin(), 403); + + return Inertia::render('Admin/Metrics/Group', [ + 'group' => null, + 'itemIds' => [], + 'allItems' => $this->allItems(), + ]); + } + + public function storeGroup(Request $request) + { + abort_unless(auth()->user()->isAdmin(), 403); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'items' => 'array', + 'items.*' => 'integer|exists:metrika_items,metrika_item_id', + ]); + + $group = MetrikaGroup::create([ + 'name' => $validated['name'], + 'description' => $validated['description'] ?? null, + ]); + + foreach ($validated['items'] ?? [] as $itemId) { + MetrikaGroupItem::create([ + 'rf_metrika_group_id' => $group->metrika_group_id, + 'rf_metrika_item_id' => $itemId, + ]); + } + + return redirect('/admin/metrics')->with('success', 'Группа создана'); + } + + public function showGroup(MetrikaGroup $group) + { + abort_unless(auth()->user()->isAdmin(), 403); + + $itemIds = MetrikaGroupItem::where('rf_metrika_group_id', $group->metrika_group_id) + ->pluck('rf_metrika_item_id') + ->toArray(); + + return Inertia::render('Admin/Metrics/Group', [ + 'group' => ['id' => $group->metrika_group_id, 'name' => $group->name, 'description' => $group->description], + 'itemIds' => $itemIds, + 'allItems' => $this->allItems(), + ]); + } + + public function updateGroup(MetrikaGroup $group, Request $request) + { + abort_unless(auth()->user()->isAdmin(), 403); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'items' => 'array', + 'items.*' => 'integer|exists:metrika_items,metrika_item_id', + ]); + + $group->update([ + 'name' => $validated['name'], + 'description' => $validated['description'] ?? null, + ]); + + MetrikaGroupItem::where('rf_metrika_group_id', $group->metrika_group_id)->delete(); + foreach ($validated['items'] ?? [] as $itemId) { + MetrikaGroupItem::create([ + 'rf_metrika_group_id' => $group->metrika_group_id, + 'rf_metrika_item_id' => $itemId, + ]); + } + + return redirect('/admin/metrics')->with('success', 'Группа сохранена'); + } + + // ── Показатели ── + + public function createItem() + { + abort_unless(auth()->user()->isAdmin(), 403); + + return Inertia::render('Admin/Metrics/Item', ['item' => null]); + } + + public function storeItem(Request $request) + { + abort_unless(auth()->user()->isAdmin(), 403); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'data_type' => 'required|string|in:integer,float,string,text,boolean,select', + 'is_active' => 'required|boolean', + 'is_required' => 'boolean', + 'default_value' => 'nullable|string', + 'placeholder' => 'nullable|string', + ]); + + MetrikaItem::create([ + ...$validated, + 'code' => Str::slug($validated['name']), + ]); + + return redirect('/admin/metrics')->with('success', 'Показатель создан'); + } + + public function showItem(MetrikaItem $item) + { + abort_unless(auth()->user()->isAdmin(), 403); + + return Inertia::render('Admin/Metrics/Item', [ + 'item' => [ + 'id' => $item->metrika_item_id, + 'name' => $item->name, + 'description' => $item->description, + 'data_type' => $item->data_type, + 'is_active' => $item->is_active, + 'is_required' => $item->is_required, + 'default_value' => $item->default_value, + 'placeholder' => $item->placeholder, + 'code' => $item->code, + ], + ]); + } + + public function updateItem(MetrikaItem $item, Request $request) + { + abort_unless(auth()->user()->isAdmin(), 403); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'data_type' => 'required|string|in:integer,float,string,text,boolean,select', + 'is_active' => 'required|boolean', + 'is_required' => 'boolean', + 'default_value' => 'nullable|string', + 'placeholder' => 'nullable|string', + ]); + + $item->update($validated); + + return redirect('/admin/metrics')->with('success', 'Показатель сохранён'); + } + + private function allItems(): array + { + return MetrikaItem::where('is_active', true)->get()->map(fn($i) => [ + 'metrika_item_id' => $i->metrika_item_id, + 'name' => $i->name, + 'data_type' => $i->data_type, + ])->toArray(); + } +} diff --git a/app/Http/Controllers/Web/Admin/UserController.php b/app/Http/Controllers/Web/Admin/UserController.php index 79df9b1..afac0f9 100644 --- a/app/Http/Controllers/Web/Admin/UserController.php +++ b/app/Http/Controllers/Web/Admin/UserController.php @@ -7,14 +7,17 @@ use App\Models\Department; use App\Models\Role; use App\Models\User; use App\Models\UserDepartment; +use App\Models\UserRole; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Hash; use Inertia\Inertia; class UserController extends Controller { public function index() { - $users = User::with(['roles', 'department'])->get()->map(function ($user) { + abort_unless(auth()->user()->isAdmin(), 403); + $users = User::with(['appRoles', 'department'])->get()->map(function ($user) { return [ 'id' => $user->id, 'name' => $user->name, @@ -25,76 +28,169 @@ class UserController extends Controller ]; }); - return Inertia::render('Admin/Users/Index', - [ - 'users' => $users, - ] - ); + return Inertia::render('Admin/Users/Index', ['users' => $users]); } public function create() { - $rolesData = Role::all()->map(function ($role) { - return [ - 'role_id' => $role->role_id, - 'name' => $role->name, - ]; - }); - - $departmentData = Department::all()->map(function (Department $department) { - return [ - 'department_id' => $department->department_id, - 'name_full' => $department->name_full, - ]; - }); + abort_unless(auth()->user()->isAdmin(), 403); return Inertia::render('Admin/Users/Create', [ - 'departments' => $departmentData, - 'roles' => $rolesData, + 'departments' => $this->allDepartments(), + 'roles' => $this->allRoles(), ]); } public function store(Request $request) { + abort_unless(auth()->user()->isAdmin(), 403); + $validated = $request->validate([ - 'name' => 'required|string', - 'login' => 'required|string', - 'password' => 'required|string', - 'is_active' => 'required|boolean', + 'name' => 'required|string|max:255', + 'login' => 'required|string|max:255|unique:users,login', + 'password' => 'required|string|min:6', + 'is_active' => 'required|boolean', + 'department_id' => 'required|integer|exists:departments,department_id', + 'departments' => 'array', + 'departments.*' => 'integer|exists:departments,department_id', + 'roles' => 'required|array|min:1', + 'roles.*' => 'integer|exists:roles,role_id', ]); - dd($validated); + $user = User::create([ + 'name' => $validated['name'], + 'login' => $validated['login'], + 'password' => Hash::make($validated['password']), + 'is_active' => $validated['is_active'], + 'rf_department_id' => $validated['department_id'], + ]); + + // Назначаем роли — первая роль становится дефолтной + foreach ($validated['roles'] as $i => $roleId) { + UserRole::create([ + 'rf_user_id' => $user->id, + 'rf_role_id' => $roleId, + 'is_active' => true, + 'is_default' => $i === 0, + ]); + } + + // Привязываем дополнительные отделения + foreach ($validated['departments'] ?? [] as $i => $deptId) { + UserDepartment::create([ + 'rf_user_id' => $user->id, + 'rf_department_id' => $deptId, + 'is_favorite' => false, + 'order' => $i, + ]); + } + + return redirect('/admin/users')->with('success', 'Пользователь создан'); } - public function show(User $user, Request $request) + public function show(User $user) { + abort_unless(auth()->user()->isAdmin(), 403); + $userData = [ - 'id' => $user->id, - 'name' => $user->name, - 'login' => $user->login, - 'is_active' => $user->is_active, - 'created_at' => $user->created_at->format('d.m.Y H:i:s'), - 'updated_at' => $user->updated_at->format('d.m.Y H:i:s'), + 'id' => $user->id, + 'name' => $user->name, + 'login' => $user->login, + 'is_active' => $user->is_active, + 'department_id' => $user->rf_department_id, + 'created_at' => $user->created_at->format('d.m.Y H:i:s'), + 'updated_at' => $user->updated_at->format('d.m.Y H:i:s'), ]; - $rolesData = $user->roles->map(function ($role) { - return [ - 'role_id' => $role->role_id, - 'name' => $role->name, - ]; - }); + $userRoleIds = $user->userRoles()->pluck('rf_role_id')->toArray(); - $departmentData = $user->departments->map(function (UserDepartment $userDepartment) { - return [ - 'department_id' => $userDepartment->department->department_id, - 'name_full' => $userDepartment->department->name_full, - ]; - }); + $userDepartmentIds = UserDepartment::where('rf_user_id', $user->id) + ->pluck('rf_department_id') + ->toArray(); return Inertia::render('Admin/Users/User', [ - 'userData' => $userData, - 'roles' => $rolesData, - 'departments' => $departmentData, + 'userData' => $userData, + 'userRoleIds' => $userRoleIds, + 'userDepartmentIds' => $userDepartmentIds, + 'allRoles' => $this->allRoles(), + 'allDepartments' => $this->allDepartments(), ]); } + + public function update(User $user, Request $request) + { + abort_unless(auth()->user()->isAdmin(), 403); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'login' => 'required|string|max:255|unique:users,login,' . $user->id, + 'is_active' => 'required|boolean', + 'department_id' => 'required|integer|exists:departments,department_id', + 'departments' => 'array', + 'departments.*' => 'integer|exists:departments,department_id', + 'roles' => 'required|array|min:1', + 'roles.*' => 'integer|exists:roles,role_id', + ]); + + $user->update([ + 'name' => $validated['name'], + 'login' => $validated['login'], + 'is_active' => $validated['is_active'], + 'rf_department_id' => $validated['department_id'], + ]); + + // Синхронизируем роли + UserRole::where('rf_user_id', $user->id)->delete(); + foreach ($validated['roles'] as $i => $roleId) { + UserRole::create([ + 'rf_user_id' => $user->id, + 'rf_role_id' => $roleId, + 'is_active' => true, + 'is_default' => $i === 0, + ]); + } + + // Синхронизируем доп. отделения через updateOrCreate + $newDeptIds = $validated['departments'] ?? []; + UserDepartment::where('rf_user_id', $user->id) + ->whereNotIn('rf_department_id', $newDeptIds) + ->delete(); + foreach ($newDeptIds as $i => $deptId) { + UserDepartment::updateOrCreate( + ['rf_user_id' => $user->id, 'rf_department_id' => $deptId], + ['is_favorite' => false, 'order' => $i] + ); + } + + return redirect('/admin/users/' . $user->id)->with('success', 'Данные сохранены'); + } + + public function resetPassword(User $user, Request $request) + { + abort_unless(auth()->user()->isAdmin(), 403); + + $request->validate([ + 'password' => 'required|string|min:6|confirmed', + ]); + + $user->update(['password' => Hash::make($request->password)]); + + return redirect('/admin/users/' . $user->id)->with('success', 'Пароль изменён'); + } + + private function allRoles(): array + { + return Role::all()->map(fn($r) => [ + 'role_id' => $r->role_id, + 'name' => $r->name, + ])->toArray(); + } + + private function allDepartments(): array + { + return Department::orderBy('name_full')->get()->map(fn($d) => [ + 'department_id' => $d->department_id, + 'name_full' => $d->name_full, + ])->toArray(); + } } diff --git a/app/Http/Controllers/Web/DutyReportController.php b/app/Http/Controllers/Web/DutyReportController.php index 12cb37c..738c359 100644 --- a/app/Http/Controllers/Web/DutyReportController.php +++ b/app/Http/Controllers/Web/DutyReportController.php @@ -5,6 +5,8 @@ namespace App\Http\Controllers\Web; use App\Http\Controllers\Controller; use App\Http\Resources\MedicalHistoryResource; use App\Models\Department; +use App\Models\DepartmentMetrikaDefault; +use App\Models\ObservableMedicalHistory; use App\Models\ReportDuty; use App\Models\ReportNurse; use App\Services\DateRangeService; @@ -15,6 +17,7 @@ use App\Services\NurseMedicalHistoryService; use App\Services\NurseReportService; use App\Services\UnifiedMedicalHistoryService; use Illuminate\Http\Request; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Auth; use Inertia\Inertia; @@ -37,65 +40,306 @@ class DutyReportController extends Controller public function index(Request $request) { $user = Auth::user(); + $search = $request->get('search'); + $selectedUserId = $request->query('userId') ? (int) $request->query('userId') : null; $departmentId = $request->query('departmentId', $user->department->department_id); $department = Department::where('department_id', $departmentId)->firstOrFail(); + $bedsInDepartment = DepartmentMetrikaDefault::where('rf_department_id', $departmentId) + ->where('rf_metrika_item_id', 1)->first()->value ?? 0; $dateRange = $this->dateRangeService->getDateRangeFromRequest($request, $user); - // Проверяем, есть ли отчет за этот период + $isRangeOneDay = $this->dateRangeService->isRangeOneDay($dateRange->startDate, $dateRange->endDate); $isPastPeriod = $this->dateRangeService->isPastPeriod($dateRange); - $existsReport = ReportDuty::where('rf_department_id', $departmentId) - ->where('period_end', '>', $dateRange->startSql()) + $isCurrentPeriod = !$isPastPeriod; + + // Всегда загружаем отчеты за период + $reportsDuty = ReportDuty::where('rf_department_id', $departmentId) + ->where('period_start', '>=', $dateRange->startSql()) ->where('period_end', '<=', $dateRange->endSql()) - ->exists(); + ->orderBy('period_end', 'desc') + ->with(['unwantedEvents', 'doctor']) + ->get(); - $hasReport = $existsReport && $isPastPeriod; + $reportsNurse = ReportNurse::where('rf_department_id', $departmentId) + ->where('period_start', '>=', $dateRange->startSql()) + ->where('period_end', '<=', $dateRange->endSql()) + ->orderBy('period_end', 'desc') + ->get(); + + $hasDutyReport = $reportsDuty->count() > 0; + $hasNurseReport = $reportsNurse->count() > 0; + + $reportDutyIds = $reportsDuty->pluck('id')->toArray(); + $reportNurseIds = $reportsNurse->pluck('id')->toArray(); + +// dd($reportsDuty, $dateRange->endSql()); + + // Получаем пациентов (источник зависит от периода) + if ($isCurrentPeriod) { + // Для текущего периода - пациенты из МИС + $patients = $this->medicalHistoryService->getGroupedHistories( + $dateRange, + $department->rf_mis_department_id, + $search + ); + + // Если есть отчет, загружаем из него дополнительные данные + if ($hasDutyReport) { + // Получаем нежелательные события и наблюдения из отчета + $reportData = $this->getReportAdditionalData($reportsDuty); + + // Добавляем эти данные к пациентам + $patients = $this->mergeReportData($patients, $reportData); + } + + $nursePatients = $hasNurseReport + ? $this->nurseMedicalHistoryService->getGroupedHistories( + $dateRange, + $department->rf_mis_department_id, + $reportNurseIds + ) + : []; + + $currentPatients = $patients['meta']['counts']['in_department']; + $loaded = $bedsInDepartment > 0 + ? round(($currentPatients * 100) / $bedsInDepartment) + : 0; + + $latestReport = $reportsDuty->first(); - if ($hasReport) { - $inDepartmentHistories = $this->dutyMedicalHistoryService->getDepartmentHistories($dateRange, $department->rf_mis_department_id); - $plannedHistories = collect([ 'data' => [] ]); - $emergencyHistories = collect([ 'data' => [] ]); - $recipientHistories = $this->dutyMedicalHistoryService->getRecipientHistories($dateRange, $department->rf_mis_department_id); - $dischargedHistories = $this->dutyMedicalHistoryService->getDischargedHistories($dateRange, $department->rf_mis_department_id); - $deceasedHistories = $this->dutyMedicalHistoryService->getDeceasedHistories($dateRange, $department->rf_mis_department_id); - $transferredHistories = $this->dutyMedicalHistoryService->getTransferredHistories($dateRange, $department->rf_mis_department_id); - $reanimationHistories = collect([ 'data' => [] ]); - } else if ($this->dateRangeService->isPastPeriod($dateRange)) { - $inDepartmentHistories = collect([ 'data' => [] ]); - $plannedHistories = collect([ 'data' => [] ]); - $emergencyHistories = collect([ 'data' => [] ]); - $recipientHistories = collect([ 'data' => [] ]); - $dischargedHistories = collect([ 'data' => [] ]); - $deceasedHistories = collect([ 'data' => [] ]); - $transferredHistories = collect([ 'data' => [] ]); - $reanimationHistories = collect([ 'data' => [] ]); } else { - $patients = $this->medicalHistoryService->getGroupedHistories($dateRange, $department->rf_mis_department_id); + // Для прошедшего периода - данные из отчета + $patients = $hasDutyReport + ? $this->dutyMedicalHistoryService->getGroupedHistories( + $dateRange, + $department->rf_mis_department_id, + $reportDutyIds, + $search + ) + : $this->getEmptyPatientsData(); + + $nursePatients = $hasNurseReport + ? $this->nurseMedicalHistoryService->getGroupedHistories( + $dateRange, + $department->rf_mis_department_id, + $reportNurseIds + ) + : []; + + $latestReport = $reportsDuty->first(); + + if ($latestReport && $hasDutyReport) { + $currentPatients = $patients['meta']['counts']['in_department']; + $loaded = $latestReport->getLoadedDepartmentAttribute($currentPatients); + } else { + $loaded = 0; + } } return Inertia::render('Report/Index', [ + 'department' => $department, 'patients' => $patients, - 'departmentInfo' => [ - // TODO: Добавить вывод информации из шапки - ], + 'nursePatients' => $nursePatients, + 'latestReport' => $latestReport ?? null, + 'canSaveReport' => $isRangeOneDay && $user->currentRoleCan('report.create'), + 'canEditPastReport' => $user->currentRoleCan('report.edit.past'), + 'canSaveNurseReport' => $isRangeOneDay && $user->currentRoleCan('nurse.report.create'), + 'stats' => $this->prepareStats($patients, $nursePatients, $loaded, $bedsInDepartment), 'dates' => [ $dateRange->startDate->getTimestampMs(), $dateRange->endDate->getTimestampMs(), - ] + ], + 'selectedUserId' => $selectedUserId, + 'selectedDepartmentId' => (int) $departmentId, ]); } /** - * Сохранение отчета от роли мед. сестра + * Получает дополнительные данные из отчета (нежелательные события, наблюдения) + */ + private function getReportAdditionalData($reportsDuty): array + { + $unwantedEvents = []; + $observations = []; + + foreach ($reportsDuty as $report) { + // Нежелательные события + foreach ($report->unwantedEvents as $event) { + $unwantedEvents[] = [ + ...$event->toArray(), + ]; + } + + // Наблюдения (если есть связь) + if ($report->relationLoaded('observations') || $report->observations) { + foreach ($report->observations as $observation) { + $observations[] = [ + ...$observation->toArray(), + ]; + } + } + } + + return [ + 'unwanted_events' => $unwantedEvents, + 'observations' => $observations + ]; + } + + /** + * Объединяет данные пациентов из МИС с данными из отчета + */ + private function mergeReportData(array $patients, array $reportData): array + { + // Группируем нежелательные события по отчетам +// $eventsByPatient = []; +// foreach ($reportData['unwanted_events'] as $event) { +// $reportId = $event['report_duty_id']; +// if (!isset($eventsByPatient[$reportId])) { +// $eventsByPatient[$reportId] = []; +// } +// $eventsByPatient[$reportId][] = $event; +// } + + // Группируем наблюдения по пациентам + $observationsByPatient = []; + foreach ($reportData['observations'] as $observation) { + $patientId = $observation['patient_id']; + if (!isset($observationsByPatient[$patientId])) { + $observationsByPatient[$patientId] = []; + } + $observationsByPatient[$patientId][] = $observation; + } + + // Добавляем данные из отчета к каждому пациенту + foreach ($patients['data'] as &$patient) { + $patientId = $patient['original_id'] ?? $patient['id'] ?? null; + + // Может быть позже пригодится +// if ($patientId && isset($eventsByPatient[$patientId])) { +// $patient['unwanted_events'] = $eventsByPatient[$patientId]; +// } else { +// $patient['unwanted_events'] = []; +// } + + if ($patientId && isset($observationsByPatient[$patientId])) { + $patient['observations'] = $observationsByPatient[$patientId]; + } else { + $patient['observations'] = []; + } + } + + return $patients; + } + + /** + * Получает данные из сестринского отчета + */ + private function getNurseReportAdditionalData($reportsNurse): array + { + $nurseData = []; + + foreach ($reportsNurse as $report) { + // Загружаем необходимые данные из сестринского отчета + // Например, наблюдения медсестер, процедуры и т.д. + if ($report->relationLoaded('nurseObservations')) { + $nurseData = array_merge($nurseData, $report->nurseObservations->toArray()); + } + } + + return $nurseData; + } + + /** + * Возвращает пустую структуру данных для пациентов + */ + private function getEmptyPatientsData(): array + { + return [ + 'data' => [], + 'meta' => [ + 'total' => 0, + 'sortBy' => 'ingoing_date', + 'sortOrder' => 'desc', + 'counts' => [ + 'in_department' => 0, + 'recipient' => 0, + 'discharged' => 0, + 'deceased' => 0, + 'urgent' => 0, + 'planned' => 0, + 'reanimations' => 0, + 'surgical_planned' => 0, + 'surgical_urgent' => 0, + ] + ] + ]; + } + + /** + * Подготавливает статистику для отображения на фронтенде + */ + private function prepareStats(array $patients, array $nursePatients, int $loaded, ?int $bedsInDepartment): array + { + return [ + 'nurse' => [ + 'current' => empty($nursePatients) ? 0 : ($nursePatients['meta']['counts']['in_department'] ?? 0), + 'recipient' => empty($nursePatients) ? 0 : ($nursePatients['meta']['counts']['recipient'] ?? 0), + 'discharged' => empty($nursePatients) ? 0 : ($nursePatients['meta']['counts']['discharged'] ?? 0), + ], + 'duty' => [ + 'beds' => $bedsInDepartment ?? 0, + 'loaded' => $loaded, + 'current' => $patients['meta']['counts']['in_department'] ?? 0, + 'recipient' => $patients['meta']['counts']['recipient'] ?? 0, + 'discharged' => ($patients['meta']['counts']['discharged'] ?? 0) + ($patients['meta']['counts']['deceased'] ?? 0), + 'deceased' => $patients['meta']['counts']['deceased'] ?? 0, + 'surgical_planned' => $patients['meta']['counts']['surgical_planned'] ?? 0, + 'surgical_urgent' => $patients['meta']['counts']['surgical_urgent'] ?? 0, + ] + ]; + } + + /** + * Сохранение отчета * @return \Illuminate\Http\RedirectResponse */ public function store(Request $request) { + abort_if(!auth()->user()->currentRoleCan('report.create'), 403); + $user = auth()->user(); + $observables = $request->get('observables', []); + $unwantedEvents = $request->get('unwanted_events', []); + $selectedUserId = $request->get('userId') ? (int) $request->get('userId') : null; + $selectedDepartmentId = $request->get('departmentId') ? (int) $request->get('departmentId') : null; + $staff = (int) $request->get('staff', 0); $dateRange = $this->dateRangeService->getDateRangeFromRequest($request, $user); - $report = $this->dutyReportService->saveReport($dateRange); - $this->dutyReportService->saveSnapshot($dateRange, $report); + $report = $this->dutyReportService->saveReport($dateRange, null, $selectedUserId, $selectedDepartmentId); + $stats = $this->dutyReportService->saveSnapshot($dateRange, $report, null, auth()->id()); + $this->dutyReportService->saveObservables($observables, $report); + $this->dutyReportService->saveUnwantedEvents($unwantedEvents, $report); + + $this->dutyReportService->saveMetrics($stats, $report, $staff); return redirect()->back(); } + + public function closeObservation(Request $request) + { + $user = auth()->user(); + $dateRange = $this->dateRangeService->getDateRangeFromRequest($request, $user); + + $observable = $request->get('observable'); + $observableId = $observable['id'] ?? null; + + if ($observableId === null) return response()->json('Observable not found', 404); + + $observable = ObservableMedicalHistory::find($observableId); + $observable->update([ + 'observable_out' => $dateRange->endDate, + 'out_reason' => 'Закрыто пользователем' + ]); + } } diff --git a/app/Http/Controllers/Web/IndexController.php b/app/Http/Controllers/Web/IndexController.php index 9f6a248..933db9b 100644 --- a/app/Http/Controllers/Web/IndexController.php +++ b/app/Http/Controllers/Web/IndexController.php @@ -18,12 +18,10 @@ class IndexController extends Controller ->where('rf_metrika_group_id', $metriks->metrika_group_id) ->get(); - $fillableModel = - $departments = Department::all(); return Inertia::render('Report/Index', [ - 'depatments' => $departments, + 'departments' => $departments, 'metriks' => $metriksItems->map(fn ($item) => [ 'metrika_group_id' => $item->group->metrika_group_id, 'metrika_group_name' => $item->group->name, diff --git a/app/Http/Controllers/Web/NurseReportController.php b/app/Http/Controllers/Web/NurseReportController.php index 4c2cfad..40fb5f7 100644 --- a/app/Http/Controllers/Web/NurseReportController.php +++ b/app/Http/Controllers/Web/NurseReportController.php @@ -31,6 +31,7 @@ class NurseReportController extends Controller public function index(Request $request) { $user = Auth::user(); + $selectedUserId = $request->query('userId') ? (int) $request->query('userId') : null; $departmentId = $request->query('departmentId', $user->department->department_id); $department = Department::where('department_id', $departmentId)->firstOrFail(); $dateRange = $this->dateRangeService->getDateRangeFromRequest($request, $user); @@ -66,8 +67,22 @@ class NurseReportController extends Controller $data = $this->unifiedMedicalHistoryService->getGroupedHistories($dateRange, $department->rf_mis_department_id); + $currentReport = ReportNurse::where('rf_department_id', $departmentId) + ->where('period_start', '>=', $dateRange->startSql()) + ->where('period_end', '<=', $dateRange->endSql()) + ->orderBy('period_end', 'desc') + ->first(); + + $isRangeOneDay = $this->dateRangeService->isRangeOneDay($dateRange->startDate, $dateRange->endDate); + return Inertia::render('Nurse/Report/Index', [ 'patients' => $data, + 'reportNurseId' => $currentReport?->id, + 'canSaveReport' => $isRangeOneDay && $user->currentRoleCan('nurse.report.create'), + 'canEditPastReport' => $user->currentRoleCan('nurse.report.edit.past'), + 'department' => $department, + 'selectedUserId' => $selectedUserId, + 'selectedDepartmentId' => (int) $departmentId, 'dates' => [ $dateRange->startDate->getTimestampMs(), $dateRange->endDate->getTimestampMs(), @@ -81,11 +96,15 @@ class NurseReportController extends Controller */ public function store(Request $request) { + abort_if(!auth()->user()->currentRoleCan('nurse.report.create'), 403); + $user = auth()->user(); + $selectedUserId = $request->input('userId') ? (int) $request->input('userId') : null; + $departmentId = $request->input('departmentId') ? (int) $request->input('departmentId') : null; $dateRange = $this->dateRangeService->getDateRangeFromRequest($request, $user); - $report = $this->nurseReportService->saveReport($dateRange); - $this->nurseReportService->saveSnapshot($dateRange, $report); + $report = $this->nurseReportService->saveReport($dateRange, null, $selectedUserId, $departmentId); + $this->nurseReportService->saveSnapshot($dateRange, $report, null, auth()->id()); return redirect()->back(); } diff --git a/app/Http/Controllers/Web/StatisticController.php b/app/Http/Controllers/Web/StatisticController.php index 243d40b..eb922ae 100644 --- a/app/Http/Controllers/Web/StatisticController.php +++ b/app/Http/Controllers/Web/StatisticController.php @@ -23,27 +23,30 @@ class StatisticController extends Controller $queryStartDate = $request->query('startAt'); $queryEndDate = $request->query('endAt'); - [$startDate, $endDate] = $this->dateService->getStatisticsDateRange($user, $queryStartDate, $queryEndDate); - $isRangeOneDay = $this->dateService->isRangeOneDay($startDate, $endDate); + $dateRange = $this->dateService->getDateRangeFromRequest($request, $user); + $isRangeOneDay = $this->dateService->isRangeOneDay($dateRange->startDate, $dateRange->endDate); // Генерируем ключ кэша на основе параметров запроса // $cacheKey = $this->generateCacheKey($user, $startDate, $endDate, $isRangeOneDay); // Получаем данные из кэша или вычисляем - $finalData = $this->statisticsService->getStatisticsData($user, $startDate, $endDate, $isRangeOneDay); + $finalData = $this->statisticsService->getStatisticsData($user, $dateRange->startDate, $dateRange->endDate, $isRangeOneDay); - $isHeadOrAdmin = $user->isAdmin() || $user->isHeadOfDepartment(); - $date = $isHeadOrAdmin ? [ - $this->dateService->parseDate($isRangeOneDay ? $endDate : $startDate)->getTimestampMs(), - $this->dateService->parseDate($endDate)->getTimestampMs(), - ] : $this->dateService->parseDate($endDate)->getTimestampMs(); + $isHeadOrAdmin = $user->isSeniorStaff(); +// $date = $isHeadOrAdmin ? [ +// $this->dateService->parseDate($isRangeOneDay ? $dateRange->endDate : $dateRange->startDate)->getTimestampMs(), +// $this->dateService->parseDate($dateRange->endDate)->getTimestampMs(), +// ] : $this->dateService->parseDate($dateRange->endDate)->getTimestampMs(); return Inertia::render('Statistic/Index', [ 'data' => $finalData['data'], 'totalsByType' => $finalData['totalsByType'], 'grandTotals' => $finalData['grandTotals'], 'isHeadOrAdmin' => $isHeadOrAdmin, - 'date' => $date, + 'date' => [ + $dateRange->startDate->getTimestampMs(), + $dateRange->endDate->getTimestampMs(), + ], 'isOneDay' => $isRangeOneDay, 'recipientPlanOfYear' => $finalData['recipientPlanOfYear'], ]); diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 75025c9..946f1a7 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -41,6 +41,10 @@ class HandleInertiaRequests extends Middleware return [ ...parent::share($request), + 'flash' => [ + 'success' => $request->session()->get('success'), + 'error' => $request->session()->get('error'), + ], 'app' => [ 'version' => config('app.version'), 'tag' => config('app.tag'), @@ -51,9 +55,17 @@ class HandleInertiaRequests extends Middleware 'user' => $user ? [ 'name' => $user->name, 'token' => Session::get('token'), - 'permissions' => $user->permissions(), + 'permissions' => \Spatie\Permission\Models\Role::findByName($user->currentRole()->slug)?->permissions->pluck('name') ?? collect(), 'role' => $user->currentRole(), - 'available_roles' => $user->roles, + 'available_roles' => \App\Models\UserRole::where('rf_user_id', $user->id) + ->with('role') + ->get() + ->map(fn($ur) => [ + 'role_id' => $ur->role->role_id, + 'name' => $ur->role->name, + 'slug' => $ur->role->slug, + 'is_default' => (bool) $ur->is_default, + ]), 'available_departments' => $user->availableDepartments(), 'current_department' => $user->department->load('departmentType'), ] : null, diff --git a/app/Models/DutyReportMetricResult.php b/app/Models/DutyReportMetricResult.php new file mode 100644 index 0000000..6c3e1cd --- /dev/null +++ b/app/Models/DutyReportMetricResult.php @@ -0,0 +1,28 @@ +belongsTo(ReportDuty::class, 'rf_report_id', 'id'); + } + + public function metrikaItem() + { + return $this->belongsTo(MetrikaItem::class, 'rf_metrika_item_id', 'metrika_item_id'); + } +} diff --git a/app/Models/DutyUnwantedEvent.php b/app/Models/DutyUnwantedEvent.php new file mode 100644 index 0000000..5bad236 --- /dev/null +++ b/app/Models/DutyUnwantedEvent.php @@ -0,0 +1,19 @@ +belongsTo(ReportDuty::class, 'report_duty_id'); + } +} diff --git a/app/Models/MedicalHistory.php b/app/Models/MedicalHistory.php index 37bb19e..0dc0e90 100644 --- a/app/Models/MedicalHistory.php +++ b/app/Models/MedicalHistory.php @@ -33,6 +33,7 @@ class MedicalHistory extends MaterializedViewModel public function latestMigration() { return $this->hasOne(MigrationPatient::class, 'medical_history_id', 'id') + ->whereNotNull('out_date') ->latest('ingoing_date'); } @@ -41,6 +42,17 @@ class MedicalHistory extends MaterializedViewModel return $this->hasMany(Reanimation::class, 'medical_history_id', 'id'); } + public function observables() + { + return $this->hasMany(ObservableMedicalHistory::class, 'original_id', 'id'); + } + + public function observable() + { + return $this->hasOne(ObservableMedicalHistory::class, 'original_id', 'id') + ->latest('observable_in'); + } + public function operationsInDepartment($query, $departmentId) { return $this->operations()->where('department_id', $departmentId); diff --git a/app/Models/MigrationPatient.php b/app/Models/MigrationPatient.php index 2115e76..45ad29a 100644 --- a/app/Models/MigrationPatient.php +++ b/app/Models/MigrationPatient.php @@ -116,11 +116,10 @@ class MigrationPatient extends MaterializedViewModel { return $query->where('medical_history_id', $historyId) ->department($departmentId) - ->orderBy('ingoing_date', 'desc') - ->limit(1)->first(); + ->orderBy('ingoing_date', 'desc'); } - public function getAdmittedInCurrentAttribute(): bool + public function getAdmittedInCurrentAttribute(DateRange $dateRange): bool { // Получаем дату поступления из последнего движения $ingoing = $this->ingoing_date; @@ -130,7 +129,7 @@ class MigrationPatient extends MaterializedViewModel } $ingoingLocal = Carbon::parse($ingoing)->setTimezone(config('app.timezone', 'Europe/Moscow')); - $now = Carbon::now(config('app.timezone', 'Europe/Moscow')); + $now = $dateRange->endDate;//Carbon::now(config('app.timezone', 'Europe/Moscow')); // Окно смены: вчера 09:00 → сегодня 09:00 $shiftStart = $now->copy()->subDay()->setTime(9, 0); diff --git a/app/Models/MigrationPatientNurse.php b/app/Models/MigrationPatientNurse.php index 81b12fd..b70f5b7 100644 --- a/app/Models/MigrationPatientNurse.php +++ b/app/Models/MigrationPatientNurse.php @@ -43,7 +43,6 @@ class MigrationPatientNurse extends Model { return $query->where('medical_history_id', $historyId) ->department($departmentId) - ->orderBy('ingoing_date', 'desc') - ->limit(1)->first(); + ->orderBy('ingoing_date', 'desc'); } } diff --git a/app/Models/ObservableMedicalHistory.php b/app/Models/ObservableMedicalHistory.php new file mode 100644 index 0000000..49e062d --- /dev/null +++ b/app/Models/ObservableMedicalHistory.php @@ -0,0 +1,36 @@ +belongsTo(ReportDutyPatient::class, 'original_id', 'original_id'); + } +} diff --git a/app/Models/ReportDuty.php b/app/Models/ReportDuty.php index 95170ac..b905645 100644 --- a/app/Models/ReportDuty.php +++ b/app/Models/ReportDuty.php @@ -39,4 +39,22 @@ class ReportDuty extends Model { return $this->belongsTo(ReportStatus::class); } + + public function doctor() + { + return $this->belongsTo(MisLpuDoctor::class, 'rf_lpudoctor_id', 'LPUDoctorID'); + } + + public function unwantedEvents() + { + return $this->hasMany(DutyUnwantedEvent::class, 'report_duty_id', 'id'); + } + + public function getLoadedDepartmentAttribute(int $patientsInDepartment) + { + $beds = DutyReportMetricResult::where('rf_report_id', $this->id) + ->where('rf_metrika_item_id', 1)->pluck('value')->first() ?? 1; + + return round((($patientsInDepartment ?? 0) * 100) / $beds); + } } diff --git a/app/Models/ReportDutyMigrationPatient.php b/app/Models/ReportDutyMigrationPatient.php index 8cf4d5a..0190c0f 100644 --- a/app/Models/ReportDutyMigrationPatient.php +++ b/app/Models/ReportDutyMigrationPatient.php @@ -4,11 +4,13 @@ namespace App\Models; use App\Services\DateRange; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; class ReportDutyMigrationPatient extends Model { protected $fillable = [ + 'original_id', 'medical_history_id', 'ingoing_date', 'out_date', @@ -32,12 +34,18 @@ class ReportDutyMigrationPatient extends Model public function medicalHistory() { - return $this->belongsTo(ReportNursePatient::class, 'medical_history_id', 'id'); + return $this->belongsTo(ReportDutyPatient::class, 'medical_history_id', 'id'); } public function operations() { - return $this->hasMany(SurgicalOperation::class, 'migration_patient_id', 'id'); + return $this->hasMany(SurgicalOperation::class, 'migration_patient_id', 'original_id'); + } + + public function reanimations() + { + return $this->hasMany(Reanimation::class, 'migration_patient_id', 'original_id') + ->orderBy('out_date', 'desc'); } // Пересечение с отчетным периодом @@ -105,4 +113,23 @@ class ReportDutyMigrationPatient extends Model ->orderBy('ingoing_date', 'desc') ->limit(1)->first(); } + + public function getAdmittedInCurrentAttribute(DateRange $dateRange): bool + { + // Получаем дату поступления из последнего движения + $ingoing = $this->ingoing_date; + + if (!$ingoing) { + return false; + } + + $ingoingLocal = Carbon::parse($ingoing)->setTimezone(config('app.timezone', 'Europe/Moscow')); + $now = $dateRange->endDate;//Carbon::now(config('app.timezone', 'Europe/Moscow')); + + // Окно смены: вчера 09:00 → сегодня 09:00 + $shiftStart = $now->copy()->subDay()->setTime(9, 0); + $shiftEnd = $now->copy()->setTime(9, 0); + + return $ingoingLocal->between($shiftStart, $shiftEnd); + } } diff --git a/app/Models/ReportDutyPatient.php b/app/Models/ReportDutyPatient.php index 9de90a2..11c982c 100644 --- a/app/Models/ReportDutyPatient.php +++ b/app/Models/ReportDutyPatient.php @@ -39,13 +39,24 @@ class ReportDutyPatient extends Model public function operations(): \Illuminate\Database\Eloquent\Relations\HasMany { - return $this->hasMany(SurgicalOperation::class, 'medical_history_id', 'id'); + return $this->hasMany(SurgicalOperation::class, 'medical_history_id', 'original_id'); } public function latestMigration() { return $this->hasOne(ReportDutyMigrationPatient::class, 'medical_history_id', 'id') - ->latest('ingoing_date'); + ->orderBy('ingoing_date', 'desc'); + } + + public function observables() + { + return $this->hasMany(ObservableMedicalHistory::class, 'original_id', 'original_id'); + } + + public function observable() + { + return $this->hasOne(ObservableMedicalHistory::class, 'original_id', 'original_id') + ->orderBy('observable_in', 'desc'); } public function operationsInDepartment($query, $departmentId) diff --git a/app/Models/ReportDutyReanimation.php b/app/Models/ReportDutyReanimation.php new file mode 100644 index 0000000..775a435 --- /dev/null +++ b/app/Models/ReportDutyReanimation.php @@ -0,0 +1,55 @@ +belongsTo(ReportDutyPatient::class, 'medical_history_id', 'id'); + } + + public function migration() + { + return $this->belongsTo(ReportDutyMigrationPatient::class, 'migration_patient_id', 'id'); + } + + // Фильтр по подразделению + public function scopeDepartment($query, int $departmentId) + { + return $query->where('migration_department_id', $departmentId); + } + + public function scopeCurrentOrAdmitted($query, DateRange $dateRange) + { + return $query->where(function ($q) use ($dateRange) { + // Вариант А: Пациент уже лежит (текущий) + $q->whereNull('out_date') + ->whereNotNull('medical_history_id') + ->where('in_date', '<', $dateRange->startSql()); + }) + ->orWhere(function ($q) use ($dateRange) { + $q->where('in_date', '<=', $dateRange->endSql()) + ->where('in_date', '>', $dateRange->startSql()); + }); + } +} diff --git a/app/Models/ReportNurseMigrationPatient.php b/app/Models/ReportNurseMigrationPatient.php index 0095b4e..583d15d 100644 --- a/app/Models/ReportNurseMigrationPatient.php +++ b/app/Models/ReportNurseMigrationPatient.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Services\DateRange; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; class ReportNurseMigrationPatient extends Model @@ -105,4 +106,23 @@ class ReportNurseMigrationPatient extends Model ->orderBy('ingoing_date', 'desc') ->limit(1)->first(); } + + public function getAdmittedInCurrentAttribute(DateRange $dateRange): bool + { + // Получаем дату поступления из последнего движения + $ingoing = $this->ingoing_date; + + if (!$ingoing) { + return false; + } + + $ingoingLocal = Carbon::parse($ingoing)->setTimezone(config('app.timezone', 'Europe/Moscow')); + $now = $dateRange->endDate;//Carbon::now(config('app.timezone', 'Europe/Moscow')); + + // Окно смены: вчера 09:00 → сегодня 09:00 + $shiftStart = $now->copy()->subDay()->setTime(9, 0); + $shiftEnd = $now->copy()->setTime(9, 0); + + return $ingoingLocal->between($shiftStart, $shiftEnd); + } } diff --git a/app/Models/UnifiedMigrationPatient.php b/app/Models/UnifiedMigrationPatient.php index bdc60a3..91f5e91 100644 --- a/app/Models/UnifiedMigrationPatient.php +++ b/app/Models/UnifiedMigrationPatient.php @@ -91,7 +91,7 @@ class UnifiedMigrationPatient extends Model ->limit(1)->first(); } - public function getAdmittedInCurrentAttribute(): bool + public function getAdmittedInCurrentAttribute(DateRange $dateRange): bool { // Получаем дату поступления из последнего движения $ingoing = $this->ingoing_date; @@ -101,7 +101,7 @@ class UnifiedMigrationPatient extends Model } $ingoingLocal = Carbon::parse($ingoing)->setTimezone(config('app.timezone', 'Europe/Moscow')); - $now = Carbon::now(config('app.timezone', 'Europe/Moscow')); + $now = $dateRange->endDate;//Carbon::now(config('app.timezone', 'Europe/Moscow')); // Окно смены: вчера 09:00 → сегодня 09:00 $shiftStart = $now->copy()->subDay()->setTime(9, 0); diff --git a/app/Models/User.php b/app/Models/User.php index 23dee59..2436698 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -26,8 +26,10 @@ class User extends Authenticatable */ protected $fillable = [ 'name', + 'login', 'email', 'password', + 'is_active', 'rf_lpudoctor_id', 'rf_department_id', 'current_role_id', @@ -76,7 +78,8 @@ class User extends Authenticatable return $this->hasMany(UserRole::class, 'rf_user_id', 'id'); } - public function roles(): HasManyThrough + // Переименовано из roles() чтобы не конфликтовать с Spatie HasRoles::roles() + public function appRoles(): HasManyThrough { return $this->hasManyThrough( Role::class, @@ -90,7 +93,7 @@ class User extends Authenticatable public function currentRole() { - $defaultRoleId = $this->roles()->where('is_default', true)->first()->role_id; + $defaultRoleId = $this->appRoles()->where('is_default', true)->first()->role_id; if (app()->runningInConsole()) { // Код выполняется в CLI (команда artisan, тесты и т.д.) @@ -128,19 +131,47 @@ class User extends Authenticatable } // Методы для проверки ролей - public function isAdmin() + public function isAdmin(): bool { return $this->currentRole()->slug === 'admin'; } - public function isDoctor() + public function isChiefDoctor(): bool { - return $this->currentRole()->slug === 'doctor'; + return $this->currentRole()->slug === 'gv'; } - public function isHeadOfDepartment() + public function isDeputyChief(): bool { - return $this->currentRole()->slug === 'head_of_department'; + return $this->currentRole()->slug === 'zam'; + } + + public function isHeadOfDepartment(): bool + { + return $this->currentRole()->slug === 'zav'; + } + + public function isDoctor(): bool + { + return $this->currentRole()->slug === 'dej'; + } + + public function isNurse(): bool + { + return $this->currentRole()->slug === 'nurse'; + } + + public function isSeniorStaff(): bool + { + return $this->isAdmin() || $this->isChiefDoctor() || $this->isDeputyChief() || $this->isHeadOfDepartment(); + } + + public function currentRoleCan(string $permission): bool + { + $slug = $this->currentRole()->slug; + return \Spatie\Permission\Models\Role::findByName($slug) + ?->permissions->pluck('name') + ->contains($permission) ?? false; } public function lpuDoctor() @@ -153,29 +184,12 @@ class User extends Authenticatable { $departments = Department::all(); - if ($this->isAdmin()) { + if ($this->isSeniorStaff()) { return $departments; } return $this->department ? [$this->department] : []; } - // Получение доступных действий - public function permissions() - { - $permissions = [ - 'view_dashboard' => true, - 'view_metrics' => true, - 'view_reports' => true, - ]; - if ($this->isAdmin() || $this->isDoctor() || $this->isHeadOfDepartment()) { - $permissions['create_metrics'] = true; - $permissions['edit_metrics'] = true; - $permissions['delete_metrics'] = true; - $permissions['manage_users'] = $this->isAdmin(); - } - - return $permissions; - } } diff --git a/app/Models/UserDepartment.php b/app/Models/UserDepartment.php index 1ac09ce..d78f1a5 100644 --- a/app/Models/UserDepartment.php +++ b/app/Models/UserDepartment.php @@ -8,6 +8,8 @@ class UserDepartment extends Model { public $timestamps = false; + protected $primaryKey = 'user_department_id'; + protected $fillable = [ 'rf_user_id', 'rf_department_id', diff --git a/app/Services/Classification/PatientStatusClassifier.php b/app/Services/Classification/PatientStatusClassifier.php index 511b888..6f8a1c1 100644 --- a/app/Services/Classification/PatientStatusClassifier.php +++ b/app/Services/Classification/PatientStatusClassifier.php @@ -2,9 +2,13 @@ namespace App\Services\Classification; use App\Models\MedicalHistory; +use App\Models\ObservableMedicalHistory; +use App\Models\ReportDutyPatient; +use App\Models\ReportNursePatient; use App\Models\UnifiedMedicalHistory; use App\Services\DateRange; use Illuminate\Support\Carbon; +use Illuminate\Support\Collection; class PatientStatusClassifier { @@ -32,53 +36,317 @@ class PatientStatusClassifier /** * Определяет поступил ли пациент в диапазоне. */ - public static function classifyAdmitted(Carbon|string|null $ingoingDate): bool + public static function classifyAdmitted(Carbon|string|null $ingoingDate, DateRange $dateRange): bool { - if (is_null($ingoingDate)) {return false;} + if (is_null($ingoingDate)) { + return false; + } $ingoingLocal = Carbon::parse($ingoingDate); - $now = Carbon::now(); - // Окно смены: вчера 09:00 → сегодня 09:00 - $shiftStart = $now->copy()->subDay()->setTime(9, 0); - $shiftEnd = $now->copy()->setTime(9, 0); + // Начало диапазона (с 09:00 первого дня) + $rangeStart = $dateRange->start()->copy()->setTime(9, 0, 0); - return $ingoingLocal->between($shiftStart, $shiftEnd); + // Конец диапазона (до 09:00 последнего дня) + $rangeEnd = $dateRange->end()->copy()->setTime(9, 0, 0); + + return $ingoingLocal->between($rangeStart, $rangeEnd); + } + + /** + * @param Collection|null $reanimations + * @param DateRange $dateRange + * @return bool + */ + public static function classifyReanimation(Collection|null $reanimations, DateRange $dateRange): bool + { + if ($reanimations === null || $reanimations->isEmpty()) return false; + if ($reanimations + ->where('out_date', '<', $dateRange->endSql()) + ->where('out_date', '>=', $dateRange->startSql()) + ->isNotEmpty()) { + return true; + } + + return false; + } + + public static function classifyObservable(?ObservableMedicalHistory $observable, DateRange $dateRange): bool + { + if (empty($observable)) { + return false; + } + + $start = $dateRange->start(); + $end = $dateRange->end(); + + // Наблюдение началось после окончания диапазона + if ($observable->observable_in > $end) { + return false; + } + + // Наблюдение закончилось до начала диапазона + if ($observable->observable_out !== null && $observable->observable_out <= $start) { + return false; + } + + return true; + } + + public static function classifyPeriodFlags( + UnifiedMedicalHistory|MedicalHistory|ReportDutyPatient|ReportNursePatient $history, + DateRange $dateRange + ): array { + // Получаем все миграции + $migrations = self::historyMigrations($history); + + if ($migrations->isEmpty()) { + return self::emptyFlags($history); + } + + // Приводим даты к Carbon + $deathDate = self::toCarbon($history->death_date ?? null); + + // Получаем границы периода + $periodStart = $dateRange->start(); + $periodEnd = $dateRange->end(); + + // ========== 1. СМЕРТЬ ========== +// if ($deathDate && $deathDate->lte($periodEnd)) { +// return [ +// 'recipient' => false, +// 'discharged' => false, +// 'deceased' => true, +// 'transferred' => false, +// 'outcome' => true, +// 'current_at_end' => false, +// 'planned' => (int) ($history->urgency_id ?? 0) === 2, +// 'urgent' => (int) ($history->urgency_id ?? 0) === 1, +// ]; +// } + + // ========== 2. АНАЛИЗ МИГРАЦИЙ ========== + $hasRecipientInPeriod = false; + $hasDeathInPeriod = false; + $hasTransferInPeriod = false; + $hasDischargeInPeriod = false; + $hasActiveMigrationAtEnd = false; + $transferred = []; + + foreach ($migrations as $migration) { + $ingoingDate = self::toCarbon($migration->ingoing_date ?? null); + $outDate = self::toCarbon($migration->out_date ?? null); + $visitResultId = (int) ($migration->visit_result_id ?? 0); + $statCureResultId = (int) ($migration->stat_cure_result_id ?? 0); + + // Поступление в периоде + if ($ingoingDate && self::dateInPeriod($ingoingDate, $dateRange)) { + $hasRecipientInPeriod = true; + } + + // Проверка на активную миграцию в конце периода + if ($ingoingDate && $ingoingDate <= $periodEnd) { + if (!$outDate || $outDate > $periodEnd) { + $hasActiveMigrationAtEnd = true; + } + } + + // Выбытие в периоде (есть out_date в периоде) + if ($outDate && self::dateInPeriod($outDate, $dateRange)) { + // Смерть по исходу лечения (5, 15) + if (in_array($visitResultId, [5, 15], true)) { + $hasDeathInPeriod = true; + } + // Перевод (коды 4, 14) + elseif (in_array($visitResultId, [4, 14], true)) { + $hasTransferInPeriod = true; + } + // Выписка + else { + $hasDischargeInPeriod = true; + } + } + } + + // ========== 3. ЕСЛИ НЕТ ИСХОДА ПО МИГРАЦИЯМ, ПРОВЕРЯЕМ EXTRACT_DATE ========== +// if (!$hasDeathInPeriod && !$hasTransferInPeriod && !$hasDischargeInPeriod) { +// $extractDate = self::toCarbon($history->extract_date ?? null); +// $visitResultId = (int) ($history->visit_result_id ?? 0); +// +// if ($extractDate && $extractDate->lte($periodEnd)) { +// // Смерть +// if ($deathDate && $deathDate->lte($periodEnd)) { +// $hasDeathInPeriod = true; +// } +// // Перевод +// elseif (in_array($visitResultId, [4, 14], true)) { +// $hasTransferInPeriod = true; +// } +// // Выписка +// else { +// $hasDischargeInPeriod = true; +// } +// } +// } + + // ========== 4. ФОРМИРОВАНИЕ РЕЗУЛЬТАТА ========== + + // Смерть + if ($hasDeathInPeriod) { + return [ + 'recipient' => $hasRecipientInPeriod, + 'discharged' => false, + 'deceased' => true, + 'transferred' => false, + 'outcome' => false, + 'current_at_end' => false, + 'planned' => (int) ($history->urgency_id ?? 0) === 2, + 'urgent' => (int) ($history->urgency_id ?? 0) === 1, + ]; + } + + // Перевод + if ($hasTransferInPeriod) { + return [ + 'recipient' => $hasRecipientInPeriod, + 'discharged' => false, + 'deceased' => false, + 'transferred' => true, + 'outcome' => false, + 'current_at_end' => false, + 'planned' => (int) ($history->urgency_id ?? 0) === 2, + 'urgent' => (int) ($history->urgency_id ?? 0) === 1, + ]; + } + + // Выписка + if ($hasDischargeInPeriod) { + return [ + 'recipient' => $hasRecipientInPeriod, + 'discharged' => true, + 'deceased' => false, + 'transferred' => false, + 'outcome' => true, + 'current_at_end' => false, + 'planned' => (int) ($history->urgency_id ?? 0) === 2, + 'urgent' => (int) ($history->urgency_id ?? 0) === 1, + ]; + } + + // В отделении на конец периода + if ($hasActiveMigrationAtEnd) { + return [ + 'recipient' => $hasRecipientInPeriod, + 'discharged' => false, + 'deceased' => false, + 'transferred' => false, + 'outcome' => false, + 'current_at_end' => true, + 'planned' => (int) ($history->urgency_id ?? 0) === 2, + 'urgent' => (int) ($history->urgency_id ?? 0) === 1, + ]; + } + + // По умолчанию - поступивший, но не в отделении (например, выписан до периода) + return [ + 'recipient' => $hasRecipientInPeriod, + 'discharged' => false, + 'deceased' => false, + 'transferred' => false, + 'outcome' => false, + 'current_at_end' => false, + 'planned' => (int) ($history->urgency_id ?? 0) === 2, + 'urgent' => (int) ($history->urgency_id ?? 0) === 1, + ]; + } + + /** + * Пустые флаги для пациента без миграций + */ + private static function emptyFlags($history): array + { + return [ + 'recipient' => false, + 'discharged' => false, + 'deceased' => false, + 'transferred' => false, + 'outcome' => false, + 'current_at_end' => false, + 'planned' => (int) ($history->urgency_id ?? 0) === 2, + 'urgent' => (int) ($history->urgency_id ?? 0) === 1, + ]; } /** * Определяет статус пациента на основе "сырых" полей из БД. - * Логика изолирована и может быть легко протестирована. */ - public static function classify(UnifiedMedicalHistory|MedicalHistory $history, DateRange $dateRange): string + public static function classify(UnifiedMedicalHistory|MedicalHistory|ReportDutyPatient|ReportNursePatient $history, DateRange $dateRange): string { - // 1. Смерть — приоритет №1 (не зависит от дат) - if (!empty($history->death_date)) { + $flags = self::classifyPeriodFlags($history, $dateRange); + + if ($flags['deceased']) { return self::STATUS_DECEASED; } - - // 2. Если есть дата выбытия - if (!empty($history->extract_date)) { - // Переведён (коды 3, 4) - if (in_array($history->visit_result_id, [3, 4], true)) { - return self::STATUS_TRANSFERRED; - } - // Выписан домой/иное (исключаем коды 3-6) - if (in_array($history->visit_result_id, [1], true)) { - return self::STATUS_DISCHARGED; - } + if ($flags['transferred']) { + return self::STATUS_TRANSFERRED; } - - // 3. Поступившие - if ($history->latestMigration?->getAdmittedInCurrentAttribute()) { + if ($flags['discharged']) { + return self::STATUS_DISCHARGED; + } + if ($flags['recipient']) { return self::STATUS_RECIPIENT; } - - // 4. В отделении - if (empty($history->latestMigration?->out_date) && $history->latestMigration?->ingoing_date < $dateRange->startDate) { + if ($flags['current_at_end']) { return self::STATUS_IN_DEPARTMENT; } return 'unknown'; } + + private static function historyMigrations( + UnifiedMedicalHistory|MedicalHistory|ReportDutyPatient|ReportNursePatient $history + ): Collection { + if (method_exists($history, 'relationLoaded') && $history->relationLoaded('migrations')) { + return $history->migrations ?? collect(); + } + + return collect([$history->latestMigration])->filter(); + } + + private static function isCurrentAtPeriodEnd( + UnifiedMedicalHistory|MedicalHistory|ReportDutyPatient|ReportNursePatient $history, + Collection $migrations, + DateRange $dateRange + ): bool { + if (self::toCarbon($history->death_date ?? null)?->lte($dateRange->endDate)) { + return false; + } + + if (self::toCarbon($history->extract_date ?? null)?->lte($dateRange->endDate)) { + return false; + } + + return $migrations->contains(function ($migration) use ($dateRange) { + $ingoingDate = self::toCarbon($migration->ingoing_date ?? null); + $outDate = self::toCarbon($migration->out_date ?? null); + + return $ingoingDate + && $ingoingDate->lte($dateRange->endDate) + && (! $outDate || $outDate->gt($dateRange->endDate)); + }); + } + + public static function dateInPeriod(Carbon|string|null $date, DateRange $dateRange): bool + { + $date = self::toCarbon($date); + + return $date + && $date->gt($dateRange->startDate) + && $date->lte($dateRange->endDate); + } + + private static function toCarbon(Carbon|string|null $date): ?Carbon + { + return $date ? Carbon::parse($date) : null; + } } diff --git a/app/Services/DateRangeService.php b/app/Services/DateRangeService.php index 092e4c2..8082d7c 100644 --- a/app/Services/DateRangeService.php +++ b/app/Services/DateRangeService.php @@ -47,16 +47,14 @@ class DateRangeService { $endAtInput = $request->query('endAt', $request->get('endAt')); $startAtInput = $request->query('startAt', $request->get('startAt')); - $endDate = $endAtInput - ? $this->parseDate($endAtInput) - : Carbon::now('Asia/Yakutsk'); - - $startDate = $startAtInput - ? $this->parseDate($startAtInput) - : Carbon::now('Asia/Yakutsk'); - - $endDate = $endDate->copy()->setTime(9, 0); - $startDate = $startDate->copy()->subDay()->setTime(9, 0); + if (!$endAtInput && !$startAtInput) { + [$startDate, $endDate] = $this->currentShiftBoundaries(); + } else { + $endDate = ($endAtInput ? $this->parseDate($endAtInput) : Carbon::now('Asia/Yakutsk')) + ->copy()->setTime(9, 0); + $startDate = ($startAtInput ? $this->parseDate($startAtInput) : Carbon::now('Asia/Yakutsk')) + ->copy()->subDay()->setTime(9, 0); + } return new DateRange( startDate: $startDate, @@ -138,12 +136,7 @@ class DateRangeService private function getDefaultDateRange($user): array { - $startDate = Carbon::now('Asia/Yakutsk') - ->subDay() - ->setTime(9, 0); - - $endDate = Carbon::now('Asia/Yakutsk') - ->setTime(9, 0); + [$startDate, $endDate] = $this->currentShiftBoundaries(); return [ $startDate->format('Y-m-d H:i:s'), @@ -151,6 +144,18 @@ class DateRangeService ]; } + private function currentShiftBoundaries(): array + { + $now = Carbon::now('Asia/Yakutsk'); + $todayAt9 = $now->copy()->setTime(9, 0, 0); + + if ($now->gte($todayAt9)) { + return [$todayAt9, $todayAt9->copy()->addDay()]; + } + + return [$todayAt9->copy()->subDay(), $todayAt9]; + } + public function parseDate($dateInput): Carbon { if (is_numeric($dateInput)) { @@ -214,7 +219,26 @@ class DateRangeService */ public function isPastPeriod(DateRange $dateRange): bool { - // Период считается "прошлым", если его конец строго раньше начала текущих суток - return $dateRange->end()->lt(now()->startOfDay()); + $periodStart = $dateRange->start(); + $now = now(); + + // Получаем начало текущей смены (сегодня 09:00) + $currentShiftStart = $now->copy()->setTime(9, 0, 0); + if ($now->lt($currentShiftStart)) { + $currentShiftStart->subDay(); + } + + // Если период начался ДО начала текущей смены - он прошедший + // (даже если формально он еще не закончился) + return $periodStart->lt($currentShiftStart); + } + + public function dateInPeriod(Carbon|string|null $date, DateRange $dateRange): bool + { + $date = self::toCarbon($date); + + return $date + && $date->gt($dateRange->startDate) + && $date->lte($dateRange->endDate); } } diff --git a/app/Services/DutyMedicalHistoryService.php b/app/Services/DutyMedicalHistoryService.php index fa0f496..995f8cb 100644 --- a/app/Services/DutyMedicalHistoryService.php +++ b/app/Services/DutyMedicalHistoryService.php @@ -3,10 +3,167 @@ namespace App\Services; use App\Models\ReportDutyPatient; +use App\Services\Classification\PatientStatusClassifier; use Illuminate\Support\Carbon; class DutyMedicalHistoryService { + public function getGroupedHistories( + DateRange $dateRange, + int $departmentId, + ?array $reportIds = null, + ?string $search = null + ): array + { + $startYear = $dateRange->startDate->copy()->startOfYear()->format('Y-m-d'); + $periodMigrationFilter = function ($q) use ($departmentId, $dateRange, $startYear) { + $q->where('department_id', $departmentId) + ->where('ingoing_date', '<=', $dateRange->endSql()) + ->where(function ($sub) use ($dateRange, $startYear) { + // Миграции без out_date (еще лежат) + $sub->whereNull('out_date') + ->where('ingoing_date', '>', $startYear); + + // Миграции с out_date (закрытые) + $sub->orWhere(function ($sub2) use ($dateRange, $startYear) { + $sub2->whereNotNull('out_date') + ->where('out_date', '>', $dateRange->startSql()) + ->where('out_date', '>', $startYear); + }); + }); + }; + $departmentMigrationFilter = function ($q) use ($departmentId) { + $q->where('department_id', $departmentId) + ->orderByDesc('ingoing_date'); + }; + + // 1. Один запрос: получаем "сырые" данные (без вычисляемых статусов) + $all = ReportDutyPatient::query() + ->when($reportIds, function ($query, $reportIds) { + return $query->whereIn('report_duty_id', $reportIds); + }) + ->whereHas('migrations', $periodMigrationFilter) + ->when($search, function ($query, $search) { + // Поиск по ФИО (точное совпадение или LIKE) + return $query->where(function ($q) use ($search) { + $q->where('full_name', 'ilike', "%{$search}%"); // PostgreSQL + }); + }) + ->with([ + 'latestMigration' => $periodMigrationFilter, + 'migrations' => $departmentMigrationFilter, + 'latestMigration.operations' => function ($q) use ($dateRange) { + $q->where('start_date', '>=', $dateRange->startSql()) + ->where('start_date', '<', $dateRange->endSql()); // по start_date + }, + 'latestMigration.reanimations', + 'observable', + 'operations' => function ($q) use ($departmentId, $dateRange) { + $q->where('department_id', $departmentId) + ->where('start_date', '>=', $dateRange->startSql()) + ->where('start_date', '<', $dateRange->endSql()) + // Только операции пока пациент реально лежал в отделении + ->whereExists(function ($sub) use ($departmentId) { + $sub->select(\DB::raw(1)) + ->from('mv_migrationpatient_details') + ->where('department_id', $departmentId) + ->whereColumn('medical_history_id', 'mv_surgical_operations.medical_history_id') + ->whereColumn('ingoing_date', '<=', 'mv_surgical_operations.start_date') + ->where(function ($q2) { + $q2->whereNull('out_date') + ->orWhereColumn('out_date', '>=', 'mv_surgical_operations.start_date'); + }); + }); + } + ]); + + $all = $all + ->selectRaw('DISTINCT ON (original_id) report_duty_patients.*') + ->orderBy('original_id') + ->orderBy('report_duty_id', 'desc') + ->get(); + + //dd($all->where('original_id', 334564)->first()); + + // Добавляем вычисляемые поля и превращаем в плоский массив + $prepared = $all->map(function (ReportDutyPatient $h) use ($dateRange) { + $patientStatus = PatientStatusClassifier::classify($h, $dateRange); + $periodFlags = PatientStatusClassifier::classifyPeriodFlags($h, $dateRange); + $patientUrgency = null; + $patientReanimation = null; + if (!in_array($patientStatus, [ + PatientStatusClassifier::STATUS_DECEASED, + PatientStatusClassifier::STATUS_DISCHARGED, + PatientStatusClassifier::STATUS_TRANSFERRED + ])) { + $patientUrgency = PatientStatusClassifier::classifyUrgency($h->urgency_id); + $patientReanimation = PatientStatusClassifier::classifyReanimation($h->latestMigration?->reanimations, $dateRange); + } + + return [ + // Все исходные поля модели (автоматически через toArray) + ...$h->toArray(), + + 'operations' => $h->operations, + // + вычисляемые мета-поля для фронтенда + 'patient_status' => $patientStatus, + 'patient_urgency' => $patientUrgency, + 'period_flags' => $periodFlags, + 'in_reanimation' => $patientReanimation, + 'admitted_today' => PatientStatusClassifier::classifyAdmitted($h->latestMigration?->ingoing_date, $dateRange), + 'in_observable' => PatientStatusClassifier::classifyObservable($h->observable, $dateRange), + ]; + }); + + // 3. Сортировка + $sortBy = 'recipient_date'; + $sortOrder = 'desc'; + $sorted = $prepared->sortBy($sortBy, SORT_REGULAR, $sortOrder === 'desc')->values(); + + // Операции + $operations = $sorted->map(function ($h) { + return $h['operations']; + })->flatten(1); + + // 4. Возвращаем плоский массив + метаданные для фронтенда + $countInDepartment = $sorted->where('period_flags.current_at_end', true)->count(); + $countRecipient = $sorted->where('period_flags.recipient', true)->count(); + $countDischarged = $sorted->where('period_flags.discharged', true)->count(); + $countDeceased = $sorted->where('period_flags.deceased', true)->count(); + $countUrgent = $sorted + ->where('period_flags.current_at_end', true) + ->where('period_flags.urgent', true) + ->count(); + $countPlanned = $sorted + ->where('period_flags.current_at_end', true) + ->where('period_flags.planned', true) + ->count(); + $countReanimations = $sorted->where('in_reanimation', true)->count(); + $countSurgPlanned = $operations->where('urgent_status', 6)->count(); + $countSurgUrgent = $operations->whereIn('urgent_status', [4, 5])->count(); + + // 4. Возвращаем плоский массив + метаданные для фронтенда + return [ + 'data' => $sorted->toArray(), + 'meta' => [ + 'total' => $sorted->count(), + 'sortBy' => $sortBy, + 'sortOrder' => $sortOrder, + 'counts' => [ + 'in_department' => $countInDepartment, + 'recipient' => $countRecipient, + 'discharged' => $countDischarged, + 'deceased' => $countDeceased, + 'urgent' => $countUrgent, + 'planned' => $countPlanned, + 'reanimations' => $countReanimations, + 'surgical_planned' => $countSurgPlanned, + 'surgical_urgent' => $countSurgUrgent, + ] + ] + ]; + } + public function getHistories(DateRange $dateRange, int $departmentId) { $query = ReportDutyPatient::query(); diff --git a/app/Services/DutyReportService.php b/app/Services/DutyReportService.php index c2f05ce..8435c59 100644 --- a/app/Services/DutyReportService.php +++ b/app/Services/DutyReportService.php @@ -2,17 +2,27 @@ namespace App\Services; +use App\Models\DepartmentMetrikaDefault; +use App\Models\DutyReportMetricResult; +use App\Models\DutyUnwantedEvent; use App\Models\MedicalHistory; +use App\Models\MetrikaResult; +use App\Models\ObservableMedicalHistory; use App\Models\ReportDuty; use App\Models\ReportNurse; use App\Models\UnifiedMedicalHistory; use App\Models\User; +use App\Services\Classification\PatientStatusClassifier; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; class DutyReportService { + public function __construct( + protected DateRangeService $dateRangeService, + ) { } + /** * Базовый запрос для всех отчётов * Фильтрует по отделению и периоду, подгружает связи @@ -52,8 +62,10 @@ class DutyReportService $report = ReportDuty::updateOrCreate( [ - 'report_date' => $data['report_date'], 'period_start' => $data['period_start'], - 'period_end' => $data['period_end'] + 'report_date' => $data['report_date'], + 'period_start' => $data['period_start'], + 'period_end' => $data['period_end'], + 'rf_department_id' => $data['rf_department_id'], ], $data ); @@ -70,24 +82,86 @@ class DutyReportService $userId = $userId ?? $reportDuty->rf_user_id; $startYear = Carbon::now()->startOfYear()->format('Y-m-d'); - $query = MedicalHistory::query() - // Фильтруем движения по отделению + пересечение дат + $patients = MedicalHistory::query() ->whereHas('migrations', function ($q) use ($departmentId, $dateRange, $startYear) { $q->where('department_id', $departmentId) - // пребывание пересекается с отчётным периодом ->where('ingoing_date', '<=', $dateRange->endSql()) - ->where('ingoing_date', '>=', $startYear) - ->where(function ($sub) use ($dateRange) { + ->where(function ($sub) use ($dateRange, $startYear) { + // Миграции без out_date (еще лежат) $sub->whereNull('out_date') - ->orWhere('out_date', '>=', $dateRange->startSql()) - ->where('out_date', '<=', $dateRange->endSql()); + ->where('ingoing_date', '>', $startYear); + + // Миграции с out_date (закрытые) + $sub->orWhere(function ($sub2) use ($dateRange, $startYear) { + $sub2->whereNotNull('out_date') + ->where('out_date', '>', $dateRange->startSql()) + ->where('out_date', '>', $startYear); + }); }); + }) + ->with([ + 'latestMigration' => function ($q) use ($departmentId) { + $q->where('department_id', $departmentId); + }, + 'latestMigration.operations', + 'latestMigration.reanimations', + 'migrations' => function ($q) use ($departmentId, $dateRange, $startYear) { + // Загружаем только нужные миграции с фильтром по отделению + $q->where('department_id', $departmentId) + ->where('ingoing_date', '<=', $dateRange->endSql()) + ->where(function ($sub) use ($dateRange, $startYear) { + // Миграции без out_date (еще лежат) + $sub->whereNull('out_date') + ->where('ingoing_date', '>', $startYear); + + // Миграции с out_date (закрытые) + $sub->orWhere(function ($sub2) use ($dateRange, $startYear) { + $sub2->whereNotNull('out_date') + ->where('out_date', '>', $dateRange->startSql()) + ->where('out_date', '>', $startYear); + }); + }); + }, + 'migrations.reanimations' => function ($q) use ($dateRange) { + // Фильтруем реанимации по периоду (опционально) + $q->where(function ($sub) use ($dateRange) { + $sub->whereNull('out_date') + ->orWhere('out_date', '>=', $dateRange->startSql()); + }); + }, + 'operations' => function ($q) use ($departmentId, $dateRange) { + $q->where('department_id', $departmentId); + } + ]) + ->lazy()->map(function (MedicalHistory $h) use ($dateRange) { + $patientStatus = PatientStatusClassifier::classify($h, $dateRange); + $periodFlags = PatientStatusClassifier::classifyPeriodFlags($h, $dateRange); + $patientUrgency = null; + $patientReanimation = null; + if (!in_array($patientStatus, [ + PatientStatusClassifier::STATUS_DECEASED, + PatientStatusClassifier::STATUS_DISCHARGED, + PatientStatusClassifier::STATUS_TRANSFERRED + ])) { + $patientUrgency = PatientStatusClassifier::classifyUrgency($h->urgency_id); + $patientReanimation = PatientStatusClassifier::classifyReanimation($h->latestMigration?->reanimations, $dateRange); + } + return [ + // Все исходные поля модели (автоматически через toArray) + ...$h->toArray(), + + 'operations' => $h->operations->toArray(), + // + вычисляемые мета-поля для фронтенда + 'patient_status' => $patientStatus, + 'patient_urgency' => $patientUrgency, + 'period_flags' => $periodFlags, + 'in_reanimation' => $patientReanimation, + 'admitted_today' => PatientStatusClassifier::classifyAdmitted($h->latestMigration?->ingoing_date, $dateRange), + 'in_observable' => PatientStatusClassifier::classifyObservable($h->observable, $dateRange), + ]; }); - // Получаем данные (chunk для памяти, если пациентов > 1000) - $patients = $query->cursor(); - - $savedStats = $this->saveReportSnapshot($reportDuty->id, $patients, $userId); + $savedStats = $this->saveReportSnapshot($reportDuty->id, $patients, $userId, $departmentId, $dateRange); return [ ...$savedStats, @@ -96,7 +170,13 @@ class DutyReportService ]; } - public function saveReportSnapshot(int $reportDutyId, iterable $patients, int $userId): array + public function saveReportSnapshot( + int $reportDutyId, + iterable $patients, + int $userId, + int $departmentId, + DateRange $dateRange + ): array { if (empty($patients)) { return ['saved_patients' => 0, 'saved_migrations' => 0]; @@ -104,83 +184,458 @@ class DutyReportService $patientBatch = []; $migrationBatch = []; + $reanimationBatch = []; $batchSize = 100; + // Инициализация агрегатора статистики + $totalStats = [ + 'saved_patients' => 0, + 'saved_migrations' => 0, + 'saved_reanimations' => 0, + 'by_status' => [], + 'by_urgency' => [], + 'admitted' => [ + 'today' => 0, + 'planned' => 0, + 'urgent' => 0, + ], + 'in_reanimation' => 0, + 'admitted_today' => 0, + 'in_department' => 0, + 'planned' => 0, + 'deceased' => 0, + 'transferred' => 0, + 'discharged' => 0, + 'outcome' => 0, + 'total_bed_days' => 0, + 'total_preop_bed_days' => 0, + 'patients_with_operations' => 0, + 'total_operations' => 0, + 'planned_operations' => 0, + 'urgent_operations' => 0, + 'total_patients' => 0, + ]; + + $outcomeList = []; + + // Статусы операций + $operationPlanned = [6]; + $operationUrgent = [4, 5]; + + // Получаем границы периода + $periodStart = $dateRange->startSql(); + $periodEnd = $dateRange->endSql(); + $periodStartCarbon = Carbon::parse($periodStart); + $periodEndCarbon = Carbon::parse($periodEnd); + + $uniqueOperationIds = []; + foreach ($patients as $patient) { + // ========== 1. ПОДГОТОВКА ДАННЫХ ДЛЯ БД (с обнулением) ========== + // Подготовка данных пациента - $patientBatch[] = [ - 'report_duty_id' => $reportDutyId, + $extractDate = $patient['extract_date'] ? Carbon::parse($patient['extract_date']) : null; + $hasExtractInPeriod = $extractDate && $this->dateRangeService->dateInPeriod($extractDate, $dateRange); + + $patientData = [ + 'report_duty_id' => $reportDutyId, 'source_type' => 'mis', - 'original_id' => $patient->id, - 'medical_card_number' => $patient->medical_card_number, - 'full_name' => $patient->full_name, - 'birth_date' => $patient->birth_date, - 'recipient_date' => $patient->recipient_date, - 'extract_date' => $patient->extract_date, - 'death_date' => $patient->death_date, - 'male' => $patient->male, - 'urgency_id' => $patient->urgency_id, - 'hospital_result_id' => $patient->hospital_result_id, - 'visit_result_id' => $patient->visit_result_id, - 'comment' => $patient->comment, + 'original_id' => $patient['id'], + 'medical_card_number' => $patient['medical_card_number'], + 'full_name' => $patient['full_name'], + 'birth_date' => $patient['birth_date'], + 'recipient_date' => $patient['recipient_date'], + 'male' => $patient['male'], + 'urgency_id' => $patient['urgency_id'], + 'comment' => $patient['comment'] ?? null, 'user_id' => $userId, ]; - // Подготовка данных миграции (если есть) - if (!empty($patient->migrations)) { - foreach ($patient->migrations as $migration) { - $migrationBatch[] = [ - // Временный ключ для связи с пациентом (заполним после первого upsert) - '_temp_key' => [ - 'report_duty_id' => $reportDutyId, - 'source_type' => $patient->source_type, - 'original_id' => $patient->original_id, - ], - 'ingoing_date' => $migration->ingoing_date, - 'out_date' => $migration->out_date, - 'diagnosis_id' => $migration->diagnosis_id, - 'diagnosis_code' => $migration->diagnosis_code, - 'diagnosis_name' => $migration->diagnosis_name, - 'interrupted_event_id' => $migration->interrupted_event_id, - 'stationar_branch_id' => $migration->stationar_branch_id, - 'department_id' => $migration->department_id, - 'visit_result_id' => $migration->visit_result_id, - 'stat_cure_result_id' => $migration->stat_cure_result_id, - 'user_id' => $migration->user_id, - 'mis_user_id' => $migration->mis_user_id, - 'comment' => $migration->comment, - ]; + // ДОПОЛНИТЕЛЬНО: проверяем миграции на наличие выбытия в периоде + $hasDischargeInMigration = false; + $migrationVisitResultId = null; + $migrationOutDate = null; + + // Подготовка данных миграции + $preparedMigrations = []; + foreach ($patient['migrations'] as $migration) { + // Пропускаем миграции не в нашем отделении + if (($migration['department_id'] ?? null) != $departmentId) { + continue; + } + + $outDate = $migration['out_date'] ? Carbon::parse($migration['out_date']) : null; + $hasOutDateInPeriod = $outDate && $this->dateRangeService->dateInPeriod($outDate, $dateRange); + + $migrationItem = [ + '_temp_key' => [ + 'report_duty_id' => $reportDutyId, + 'source_type' => 'mis', + 'original_id' => $patient['id'], + ], + 'original_id' => $migration['id'], + 'ingoing_date' => $migration['ingoing_date'], + 'diagnosis_id' => $migration['diagnosis_id'], + 'diagnosis_code' => $migration['diagnosis_code'], + 'diagnosis_name' => $migration['diagnosis_name'], + 'interrupted_event_id' => $migration['interrupted_event_id'], + 'stationar_branch_id' => $migration['stationar_branch_id'], + 'department_id' => $migration['department_id'], + 'user_id' => $migration['user_id'] ?? null, + 'mis_user_id' => $migration['mis_user_id'] ?? null, + 'comment' => $migration['comment'] ?? null, + ]; + + // Добавляем поля ТОЛЬКО если есть out_date в периоде + if ($hasOutDateInPeriod) { + $hasDischargeInMigration = true; + $migrationVisitResultId = $migration['visit_result_id'] ?? null; + $migrationOutDate = $migration['out_date'] ?? null; + $migrationItem['visit_result_id'] = $migration['visit_result_id'] ?? null; + $migrationItem['stat_cure_result_id'] = $migration['stat_cure_result_id'] ?? null; + $migrationItem['out_date'] = $migration['out_date'] ?? null; + } else { + $migrationItem['visit_result_id'] = null; + $migrationItem['stat_cure_result_id'] = null; + $migrationItem['out_date'] = null; + } + + $preparedMigrations[] = $migrationItem; + + // Подготовка данных реанимации + if (!empty($migration['reanimations'])) { + foreach ($migration['reanimations'] as $reanimation) { + $reanimationBatch[] = [ + '_temp_key' => [ + 'report_duty_id' => $reportDutyId, + 'source_type' => 'mis', + 'patient_id' => $patient['id'], + 'migration_id' => $migration['id'], + ], + 'original_id' => $reanimation['id'], + 'migration_patient_id' => $reanimation['migration_patient_id'], + 'medical_history_id' => $reanimation['medical_history_id'], + 'in_date' => $reanimation['in_date'], + 'out_date' => $reanimation['out_date'], + 'description' => $reanimation['description'], + 'stationar_branch_id' => $reanimation['stationar_branch_id'], + 'migration_stationar_branch_id' => $reanimation['migration_stationar_branch_id'], + 'migration_department_id' => $reanimation['migration_department_id'], + 'doctor_id' => $reanimation['doctor_id'], + 'user_id' => $reanimation['user_id'] ?? null, + 'mis_user_id' => $reanimation['mis_user_id'] ?? null, + 'comment' => $reanimation['comment'] ?? null, + ]; + } } } - // Пакетная запись каждые $batchSize записей + // Если есть выбытие по миграции ИЛИ по карте + $hasOutcomeInPeriod = $hasExtractInPeriod || $hasDischargeInMigration; + + if ($hasOutcomeInPeriod) { + // Приоритет: данные из миграции (если есть), иначе из карты + $patientData['visit_result_id'] = $migrationVisitResultId ?? $patient['visit_result_id'] ?? null; + $patientData['extract_date'] = $migrationOutDate ?? $patient['extract_date'] ?? null; + $patientData['death_date'] = $patient['death_date'] ?? null; + } else { + $patientData['visit_result_id'] = null; + $patientData['extract_date'] = null; + $patientData['death_date'] = null; + } + + // ========== 2. РАСЧЕТ СТАТИСТИКИ НА ОСНОВЕ ПОДГОТОВЛЕННЫХ ДАННЫХ ========== + + // Флаги для пациента + $hasRecipientInPeriod = false; + $hasDeathInPeriod = false; + $hasTransferInPeriod = false; + $hasDischargeInPeriod = false; + $hasActiveMigrationInPeriod = false; + + // Проверяем каждую миграцию для определения статусов + foreach ($preparedMigrations as $migration) { + $ingoingDate = $migration['ingoing_date'] ? Carbon::parse($migration['ingoing_date']) : null; + $outDate = $migration['out_date'] ? Carbon::parse($migration['out_date']) : null; + $visitResultId = $migration['visit_result_id'] ?? null; + $statCureResultId = $migration['stat_cure_result_id'] ?? null; + + // Поступление в периоде + if ($ingoingDate && $this->dateRangeService->dateInPeriod($ingoingDate, $dateRange)) { + $hasRecipientInPeriod = true; + } + + // Проверяем активную миграцию на конец периода + if ($ingoingDate && $ingoingDate <= $periodEndCarbon) { + if (!$outDate || $outDate > $periodEndCarbon) { + $hasActiveMigrationInPeriod = true; + } + } + + // Выбытие в периоде (есть out_date в периоде) + if ($outDate && $this->dateRangeService->dateInPeriod($outDate, $dateRange)) { + // Смерть (коды 5, 15 - умер в стационаре) + if (in_array($visitResultId, [5, 15])) { + $hasDeathInPeriod = true; + } + // Перевод (коды 4, 14) + elseif (in_array($visitResultId, [4, 14])) { + $hasTransferInPeriod = true; + } + // Выписка + else { + $hasDischargeInPeriod = true; + } + } + } + + // Если нет исхода по миграциям, проверяем extract_date на уровне карты +// if (!$hasDeathInPeriod && !$hasTransferInPeriod && !$hasDischargeInPeriod) { +// if ($hasExtractInPeriod) { +// $visitResultId = $patient['visit_result_id'] ?? null; +// $deathDate = $patient['death_date'] ? Carbon::parse($patient['death_date']) : null; +// +// if ($deathDate && $deathDate->lte($periodEndCarbon)) { +// $hasDeathInPeriod = true; +// } elseif (in_array($visitResultId, [4, 14])) { +// $hasTransferInPeriod = true; +// } else { +// $hasDischargeInPeriod = true; +// } +// } +// } + + // Заполнение статистики по статусам + // Умершие + if ($hasDeathInPeriod) { + $totalStats['deceased']++; + $totalStats['by_status']['deceased'] = ($totalStats['by_status']['deceased'] ?? 0) + 1; + } + // Переведенные + elseif ($hasTransferInPeriod) { + $totalStats['transferred']++; + $totalStats['by_status']['transferred'] = ($totalStats['by_status']['transferred'] ?? 0) + 1; + } + // Выписанные + elseif ($hasDischargeInPeriod) { + $totalStats['discharged']++; + $totalStats['outcome']++; + $totalStats['by_status']['discharged'] = ($totalStats['by_status']['discharged'] ?? 0) + 1; + } + // В отделении на конец периода + elseif ($hasActiveMigrationInPeriod) { + $totalStats['in_department']++; + $totalStats['by_status']['in_department'] = ($totalStats['by_status']['in_department'] ?? 0) + 1; + } + + // Поступившие (на основе ingoing_date в периоде) + if ($hasRecipientInPeriod) { + $totalStats['admitted']['today']++; + $totalStats['by_status']['recipient'] = ($totalStats['by_status']['recipient'] ?? 0) + 1; + if ($patient['urgency_id'] == 2) { + $totalStats['admitted']['planned']++; + $totalStats['planned']++; + } + if ($patient['urgency_id'] == 1) { + $totalStats['admitted']['urgent']++; + } + } + + // Срочность + if (!empty($patient['patient_urgency'])) { + $totalStats['by_urgency'][$patient['patient_urgency']] = ($totalStats['by_urgency'][$patient['patient_urgency']] ?? 0) + 1; + } + + // Реанимация + if (!empty($patient['in_reanimation'])) { + $totalStats['in_reanimation']++; + } + + // Поступил сегодня + if (!empty($patient['admitted_today'])) { + $totalStats['admitted_today']++; + } + + // ========== 3. КОЙКО-ДНИ И ОПЕРАЦИИ ========== + $patientBedDays = 0; + $preOpBedDays = 0; + $hasOperation = false; + $patientOperationsCount = 0; + $patientOperationsPlannedCount = 0; + $patientOperationsUrgentCount = 0; + + try { + $migration = $patient['migrations'][0]; + $migrationStart = $migration['ingoing_date'] ? Carbon::parse($migration['ingoing_date']) : null; + $migrationEnd = $migration['out_date'] ? Carbon::parse($migration['out_date']) : null; + + if (!$migrationStart) { + continue; + } + + // Проверяем пересечение с отчетным периодом + $hasIntersection = $migrationStart <= $periodEndCarbon && + ($migrationEnd === null || $migrationEnd >= $periodStartCarbon); + +// if (!$hasIntersection) { +// continue; +// } + + // ===== КОЙКО-ДНИ ===== + $calcStart = $migrationStart > $periodStartCarbon ? $migrationStart : $periodStartCarbon; + $calcEnd = $migrationEnd && $migrationEnd < $periodEndCarbon ? $migrationEnd : $periodEndCarbon; + + $bedDays = $calcStart->diffInDays($calcEnd); + $patientBedDays += max(0, $bedDays); + + // ===== ПРЕДОПЕРАЦИОННЫЕ ДНИ ===== + $opsInPeriod = collect($patient['operations'] ?? []) + ->filter(function ($op) use ($dateRange) { + $opStart = $op['start_date'] ? Carbon::parse($op['start_date']) : null; + return $opStart + && $opStart->gte($dateRange->start()) + && $opStart->lt($dateRange->end()); + }); + + if ($opsInPeriod->isNotEmpty()) { + $hasOperation = true; + $firstOpInPeriod = $opsInPeriod->sortBy('start_date')->first(); + + if ($firstOpInPeriod && $migrationStart) { + $opDate = Carbon::parse($firstOpInPeriod['start_date']); + if ($opDate > $migrationStart) { + $preOpDays = $migrationStart->copy()->startOfDay() + ->diffInDays($opDate->copy()->startOfDay()); + $preOpBedDays += max(0, $preOpDays); + } + } + } + + // ===== ОПЕРАЦИИ ===== + if (count($patient['operations']) > 0) + foreach ($patient['operations'] as $operation) { + $opStart = $operation['start_date'] ? Carbon::parse($operation['start_date']) : null; + $opEnd = $operation['end_date'] ? Carbon::parse($operation['end_date']) : null; + + if ($opStart && $opStart->gte($dateRange->start()) && $opStart->lt($dateRange->end())) { + // Уникализируем по ID операции + $uniqueOperationIds[$operation['id']] = [ + 'id' => $operation['id'], + 'urgent_status' => $operation['urgent_status'], + ]; + } + } + } catch (\Exception $e) { + \Log::error('DutyReportService: ошибка обработки пациента', [ + 'patient_id' => $patient['id'] ?? null, + 'error' => $e->getMessage(), + ]); + } finally { + + } + + // Агрегация койко-дней и операций + $totalStats['total_bed_days'] += $patientBedDays; + $totalStats['total_preop_bed_days'] += $preOpBedDays; + $totalStats['patients_with_operations'] += $hasOperation ? 1 : 0; + $totalStats['total_operations'] = count($uniqueOperationIds); + $totalStats['planned_operations'] = collect($uniqueOperationIds)->where('urgent_status', 6)->count(); + $totalStats['urgent_operations'] = collect($uniqueOperationIds)->whereIn('urgent_status', [4,5])->count(); + $totalStats['total_patients']++; + + // ========== 4. ДОБАВЛЯЕМ В БАТЧИ ========== + $patientBatch[] = $patientData; + $migrationBatch = array_merge($migrationBatch, $preparedMigrations); + + // Контроль размера батча if (count($patientBatch) >= $batchSize) { - [$savedP, $savedM] = $this->upsertBatches($reportDutyId, $patientBatch, $migrationBatch); + $batchStats = $this->upsertBatches($reportDutyId, $patientBatch, $migrationBatch, $reanimationBatch); + $totalStats = $this->mergeStats($totalStats, $batchStats); $patientBatch = []; $migrationBatch = []; + $reanimationBatch = []; } } // Сохраняем остаток - [$savedP, $savedM] = $this->upsertBatches($reportDutyId, $patientBatch, $migrationBatch); + if (!empty($patientBatch)) { + $batchStats = $this->upsertBatches($reportDutyId, $patientBatch, $migrationBatch, $reanimationBatch); + $totalStats = $this->mergeStats($totalStats, $batchStats); + } - return ['saved_patients' => $savedP, 'saved_migrations' => $savedM]; + return $totalStats; } /** - * Вспомогательный метод: выполняет upsert для пациентов и миграций + * Вспомогательный метод: слияние статистики двух массивов */ - private function upsertBatches(int $reportDutyId, array $patientBatch, array $migrationBatch): array + private function mergeStats(array $total, array $new): array + { + $total['saved_patients'] += $new['saved_patients'] ?? 0; + $total['saved_migrations'] += $new['saved_migrations'] ?? 0; + $total['in_reanimation'] += $new['in_reanimation'] ?? 0; + $total['admitted_today'] += $new['admitted_today'] ?? 0; + $total['in_department'] += $new['in_department'] ?? 0; + $total['planned'] += $new['planned'] ?? 0; + $total['deceased'] += $new['deceased'] ?? 0; + $total['transferred'] += $new['transferred'] ?? 0; + $total['outcome'] += $new['outcome'] ?? 0; + + // Койко-дни + $total['total_bed_days'] += $new['total_bed_days'] ?? 0; + $total['total_preop_bed_days'] += $new['total_preop_bed_days'] ?? 0; + + // Операции + $total['patients_with_operations'] += $new['patients_with_operations'] ?? 0; + $total['total_operations'] += $new['total_operations'] ?? 0; + $total['planned_operations'] += $new['planned_operations'] ?? 0; + $total['urgent_operations'] += $new['urgent_operations'] ?? 0; + + // Для расчётов + $total['total_patients'] += $new['total_patients'] ?? 0; + + // Объединение счётчиков по статусам + foreach ($new['by_status'] ?? [] as $status => $count) { + $total['by_status'][$status] = ($total['by_status'][$status] ?? 0) + $count; + } + foreach ($new['by_urgency'] ?? [] as $urgency => $count) { + $total['by_urgency'][$urgency] = ($total['by_urgency'][$urgency] ?? 0) + $count; + } + foreach ($new['admitted'] ?? [] as $status => $count) { + $total['admitted'][$status] = ($total['admitted'][$status] ?? 0) + $count; + } + + return $total; + } + + /** + * Вспомогательный метод: выполняет upsert для пациентов, миграций и реанимаций + */ + private function upsertBatches( + int $reportDutyId, + array $patientBatch, + array $migrationBatch, + array $reanimationBatch = [] + ): array { if (empty($patientBatch)) { - return [0, 0]; + return ['saved_patients' => 0, 'saved_migrations' => 0, 'saved_reanimations' => 0]; } $savedPatients = 0; $savedMigrations = 0; + $savedReanimations = 0; - DB::transaction(function () use ($reportDutyId, $patientBatch, $migrationBatch, &$savedPatients, &$savedMigrations) { - // UPSERT пациентов + DB::transaction(function () use ( + $reportDutyId, + $patientBatch, + $migrationBatch, + $reanimationBatch, + &$savedPatients, + &$savedMigrations, + &$savedReanimations + ) { + // === 1. UPSERT пациентов === $patientUniqueBy = ['report_duty_id', 'source_type', 'original_id']; $patientUpdateColumns = array_diff(array_keys($patientBatch[0]), $patientUniqueBy); @@ -191,46 +646,43 @@ class DutyReportService ); $savedPatients = count($patientBatch); - // Получаем ID сохранённых пациентов для связи с миграциями - if (!empty($migrationBatch)) { - // Извлекаем уникальные ключи для поиска - $tempKeys = array_map(fn($m) => $m['_temp_key'], $migrationBatch); - - // Получаем реальные ID из БД + // === 2. Получаем ID сохранённых пациентов для связи с миграциями === + $patientIds = []; + if (!empty($migrationBatch) || !empty($reanimationBatch)) { $patientIds = DB::table('report_duty_patients') - ->whereIn('report_duty_id', [$reportDutyId]) - ->get() - ->pluck('id', 'original_id') // key=original_id, value=id + ->where('report_duty_id', $reportDutyId) + ->pluck('id', 'original_id') ->toArray(); + } - // Формируем финальный массив миграций с реальными medical_history_id + // === 3. UPSERT миграций === + $migrationIds = []; // [original_id => migration_db_id] + if (!empty($migrationBatch)) { $finalMigrations = []; foreach ($migrationBatch as $m) { - $tempKey = $m['_temp_key']; - $originalId = $tempKey['original_id']; - + $originalId = $m['_temp_key']['original_id']; if (isset($patientIds[$originalId])) { $finalMigrations[] = [ - 'medical_history_id' => $patientIds[$originalId], // Реальный ID + 'medical_history_id' => $patientIds[$originalId], + 'original_id' => $m['original_id'] ?? null, 'ingoing_date' => $m['ingoing_date'], 'out_date' => $m['out_date'], - 'diagnosis_id' => $m['diagnosis_id'], - 'diagnosis_code' => $m['diagnosis_code'], - 'diagnosis_name' => $m['diagnosis_name'], - 'interrupted_event_id' => $m['interrupted_event_id'], + 'diagnosis_id' => $m['diagnosis_id'], + 'diagnosis_code' => $m['diagnosis_code'], + 'diagnosis_name' => $m['diagnosis_name'], + 'interrupted_event_id'=> $m['interrupted_event_id'], 'stationar_branch_id' => $m['stationar_branch_id'], - 'department_id' => $m['department_id'], - 'visit_result_id' => $m['visit_result_id'], + 'department_id' => $m['department_id'], + 'visit_result_id' => $m['visit_result_id'], 'stat_cure_result_id' => $m['stat_cure_result_id'], - 'user_id' => $m['user_id'], - 'mis_user_id' => $m['mis_user_id'], - 'comment' => $m['comment'], + 'user_id' => $m['user_id'], + 'mis_user_id' => $m['mis_user_id'], + 'comment' => $m['comment'], ]; } } if (!empty($finalMigrations)) { - // UPSERT миграций $migrationUniqueBy = ['medical_history_id', 'ingoing_date']; $migrationUpdateColumns = array_diff(array_keys($finalMigrations[0]), $migrationUniqueBy); @@ -240,10 +692,223 @@ class DutyReportService $migrationUpdateColumns ); $savedMigrations = count($finalMigrations); + + // === 4. Получаем ID сохранённых миграций для связи с реанимациями === + // Ключ: [medical_history_id][original_id] => migration_db_id + $savedMigrationRecords = DB::table('report_duty_migration_patients') + ->whereIn('medical_history_id', array_values($patientIds)) + ->get(['id', 'medical_history_id', 'original_id']); + + foreach ($savedMigrationRecords as $record) { + $migrationIds[$record->medical_history_id][$record->original_id] = $record->id; + } + } + } + + // === 5. UPSERT реанимаций === + if (!empty($reanimationBatch) && !empty($patientIds) && !empty($migrationIds)) { + $finalReanimations = []; + foreach ($reanimationBatch as $r) { + $tempKey = $r['_temp_key']; + $patientOriginalId = $tempKey['patient_id'] ?? $tempKey['original_id'] ?? null; + $migrationOriginalId = $tempKey['migration_id'] ?? null; + $medicalHistoryId = $patientIds[$patientOriginalId] ?? null; + + // Ищем ID миграции по medical_history_id + original_id + $migrationDbId = $migrationIds[$medicalHistoryId][$migrationOriginalId] ?? null; + + if ($medicalHistoryId && $migrationDbId) { + $finalReanimations[] = [ + 'migration_patient_id' => $migrationDbId, + 'medical_history_id' => $medicalHistoryId, + 'original_id' => $r['original_id'] ?? null, + 'in_date' => $r['in_date'], + 'out_date' => $r['out_date'], + 'description' => $r['description'], + 'stationar_branch_id' => $r['stationar_branch_id'], + 'migration_stationar_branch_id' => $r['migration_stationar_branch_id'], + 'migration_department_id' => $r['migration_department_id'], + 'doctor_id' => $r['doctor_id'], + 'user_id' => $r['user_id'], + 'mis_user_id' => $r['mis_user_id'], + 'comment' => $r['comment'], + ]; + } + } + + if (!empty($finalReanimations)) { + $reanimationUniqueBy = ['migration_patient_id', 'in_date']; + $reanimationUpdateColumns = array_diff(array_keys($finalReanimations[0]), $reanimationUniqueBy); + + DB::table('report_duty_reanimations')->upsert( + $finalReanimations, + $reanimationUniqueBy, + $reanimationUpdateColumns + ); + $savedReanimations = count($finalReanimations); } } }); - return [$savedPatients, $savedMigrations]; + return [ + 'saved_patients' => $savedPatients, + 'saved_migrations' => $savedMigrations, + 'saved_reanimations' => $savedReanimations, + // Эти поля считаются в saveReportSnapshot, здесь передаём 0 + 'total_bed_days' => 0, + 'total_preop_bed_days' => 0, + 'patients_with_operations' => 0, + 'total_operations' => 0, + 'planned_operations' => 0, + 'urgent_operations' => 0, + 'total_patients' => 0, + ]; + } + + public function saveMetrics(array $stats, ReportDuty $reportDuty, int $staff = 0) + { + $byStatus = $stats['by_status'] ?? []; + $byUrgency = $stats['by_urgency'] ?? []; + $admitted = $stats['admitted'] ?? []; + + // === Базовые счётчики === + $patientsIsRecipient = $byStatus['recipient'] ?? 0; + $patientsInDepartment = $byStatus['in_department'] ?? 0; + $patientsIsDischarged = $stats['outcome'] ?? 0; + $patientsIsTransferred = $stats['transferred'] ?? 0; + $patientsIsDeceased = $byStatus['deceased'] ?? 0; + + // Ср. койко-день (по закрытым эпизодам: выписка/перевод/смерть) + $totalPatients = $stats['total_patients'] ?? 0; + $totalBedDays = $stats['total_bed_days'] ?? 0; + $avgBedDay = $totalPatients > 0 + ? round($totalBedDays / $totalPatients, 2) + : 0; + + // Пред. опер. койко-день (средний по пациентам с операциями) + $patientsWithOps = $stats['patients_with_operations'] ?? 0; + $totalPreOpDays = $stats['total_preop_bed_days'] ?? 0; + $avgPreOpBedDay = $patientsWithOps > 0 + ? round($totalPreOpDays / $patientsWithOps, 2) + : 0; + + // % загруженности + $bedsInDepartment = DepartmentMetrikaDefault::where('rf_department_id', $reportDuty->rf_department_id) + ->where('rf_metrika_item_id', 1) // ID метрики "коек" + ->where('date_end', '>', Carbon::now()) + ->value('value') ?? 0; + + $occupancyPercent = $bedsInDepartment > 0 + ? round(($patientsInDepartment * 100) / $bedsInDepartment, 2) + : 0; + + // % летальности + $totalPatients = $stats['total_patients'] ?? 0; + $mortalityPercent = $totalPatients > 0 + ? round(($patientsIsDeceased * 100) / $totalPatients, 2) + : 0; + + // Операции (общее количество) + $totalOperations = $stats['total_operations'] ?? 0; + $plannedOperations = $stats['planned_operations'] ?? 0; + $urgentOperations = $stats['urgent_operations'] ?? 0; + + // === СОХРАНЕНИЕ МЕТРИК === + $this->saveMetric($reportDuty->id, 1, $bedsInDepartment); // Кол-во коек + $this->saveMetric($reportDuty->id, 8, $patientsInDepartment); // Пациентов в отделении + $this->saveMetric($reportDuty->id, 3, $patientsIsRecipient); // Поступило + $this->saveMetric($reportDuty->id, 15, $patientsIsDischarged); // Выписано + $this->saveMetric($reportDuty->id, 7, $patientsIsDischarged); // Выписано + $this->saveMetric($reportDuty->id, 13, $patientsIsTransferred); // Переведено + $this->saveMetric($reportDuty->id, 9, $patientsIsDeceased); // Умерло + + $this->saveMetric($reportDuty->id, 4, $admitted['planned'] ?? 0); // Планово поступило + $this->saveMetric($reportDuty->id, 12, $admitted['urgent'] ?? 0); // Экстренно поступило + + $this->saveMetric($reportDuty->id, 22, $occupancyPercent); // % загруженности + $this->saveMetric($reportDuty->id, 25, round($totalBedDays, 2)); // Всего койко-дней + $this->saveMetric($reportDuty->id, 18, $avgBedDay); // Ср. койко-день + $this->saveMetric($reportDuty->id, 26, round($totalPreOpDays, 2)); // Пред. опер. койко-день (сумма) + $this->saveMetric($reportDuty->id, 27, $patientsWithOps); // Пациентов с операциями (знаменатель) + $this->saveMetric($reportDuty->id, 21, $avgPreOpBedDay); // Ср. Пред. опер. койко-день + $this->saveMetric($reportDuty->id, 19, $mortalityPercent); // % летальности + $this->saveMetric($reportDuty->id, 11, $plannedOperations); // Плановых операций + $this->saveMetric($reportDuty->id, 10, $urgentOperations); // Экстренных операций + $this->saveMetric($reportDuty->id, 17, $staff); // Мед. персонал + } + + /** + * Рассчитать количество дней пересечения двух периодов + */ + private function calculateBedDays( + ?string $ingoingDate, + ?string $outDate, + string $periodStart, + string $periodEnd + ): int { + if (!$ingoingDate || !$outDate) { + return 0; + } + + $start = Carbon::parse($ingoingDate); + $end = Carbon::parse($outDate); + + return max(1, $start->diffInDays($end, true)); + } + + private function saveMetric(int $reportId, int $metricId, int|float $value) + { + DutyReportMetricResult::updateOrCreate( + ['rf_report_id' => $reportId, 'rf_metrika_item_id' => $metricId], + ['value' => $value] + ); + } + + public function saveObservables(array $observables, ReportDuty $reportDuty) + { + foreach ($observables as $observable) { + ObservableMedicalHistory::updateOrCreate([ + 'original_id' => $observable['original_id'], + 'source_type' => $observable['source_type'], + 'observable_in' => $observable['observable_in'] ?? $reportDuty->period_start, + ], [ + 'source_type' => $observable['source_type'], + 'original_id' => $observable['original_id'], + 'observable_in' => $observable['observable_in'] ?? $reportDuty->period_start, + 'observable_out' => $observable['observable_out'] ?? null, + 'observable_reason' => $observable['observable_reason'], + 'out_reason' => $observable['out_reason'] ?? null, + 'medical_card_number' => $observable['medical_card_number'], + 'full_name' => $observable['full_name'], + 'birth_date' => $observable['birth_date'], + 'recipient_date' => $observable['recipient_date'], + 'extract_date' => $observable['extract_date'], + 'death_date' => $observable['death_date'], + 'male' => $observable['male'], + 'urgency_id' => $observable['urgency_id'], + 'hospital_result_id' => $observable['hospital_result_id'], + 'visit_result_id' => $observable['visit_result_id'], + 'comment' => $observable['comment'], + 'user_id' => $observable['user_id'], + ]); + } + + return $reportDuty->unwantedEvents()->count(); + } + + public function saveUnwantedEvents(array $unwantedEvents, ReportDuty $reportDuty) + { + foreach ($unwantedEvents as $unwantedEvent) { + DutyUnwantedEvent::updateOrCreate([ + 'id' => $unwantedEvent['id'] ?? null, + 'report_duty_id' => $reportDuty->id, + ], [ + 'report_duty_id' => $reportDuty->id, + 'title' => $unwantedEvent['title'], + 'comment' => $unwantedEvent['comment'] + ]); + } + + return $reportDuty->unwantedEvents()->count(); } } diff --git a/app/Services/MedicalHistoryService.php b/app/Services/MedicalHistoryService.php index bc0a6d1..936438a 100644 --- a/app/Services/MedicalHistoryService.php +++ b/app/Services/MedicalHistoryService.php @@ -9,35 +9,81 @@ use Illuminate\Support\Carbon; class MedicalHistoryService { - public function getGroupedHistories(DateRange $dateRange, int $departmentId): array + public function getGroupedHistories(DateRange $dateRange, int $departmentId, ?string $search = null): array { - $startYear = Carbon::now()->startOfYear()->format('Y-m-d'); + $startYear = $dateRange->startDate->copy()->startOfYear()->format('Y-m-d'); + $periodMigrationFilter = function ($q) use ($departmentId, $dateRange, $startYear) { + $q->where('department_id', $departmentId) + ->where('ingoing_date', '<=', $dateRange->endSql()) + ->where(function ($sub) use ($dateRange, $startYear) { + // Миграции без out_date (еще лежат) + $sub->whereNull('out_date') + ->where('ingoing_date', '>', $startYear); + + // Миграции с out_date (закрытые) + $sub->orWhere(function ($sub2) use ($dateRange, $startYear) { + $sub2->whereNotNull('out_date') + ->where('out_date', '>', $dateRange->startSql()) + ->where('out_date', '>', $startYear); + }); + }); + }; + $departmentMigrationFilter = function ($q) use ($departmentId) { + $q->where('department_id', $departmentId) + ->orderByDesc('ingoing_date'); + }; // 1. Один запрос: получаем "сырые" данные (без вычисляемых статусов) - $all = MedicalHistory::query()->whereHas('latestMigration', function ($q) use ($departmentId, $dateRange, $startYear) { - $q->where('department_id', $departmentId) - // пребывание пересекается с отчётным периодом - ->where('ingoing_date', '<=', $dateRange->endSql()) - ->where('ingoing_date', '>=', $startYear) - ->where(function ($sub) use ($dateRange) { - $sub->whereNull('out_date') - ->orWhere('out_date', '>=', $dateRange->startSql()) - ->where('out_date', '<=', $dateRange->endSql()); - }); - })->with(['latestMigration' => function ($q) use ($departmentId) { - $q->where('department_id', $departmentId); - }, 'latestMigration.operations', 'latestMigration.reanimations'])->get(); + $all = MedicalHistory::query() + ->whereHas('migrations', $periodMigrationFilter) + ->when($search, function ($query, $search) { + // Поиск по ФИО (точное совпадение или LIKE) + return $query->where(function ($q) use ($search) { + $q->where('full_name', 'ilike', "%{$search}%"); // PostgreSQL + }); + }) + ->with([ + 'latestMigration' => $periodMigrationFilter, + 'migrations' => $departmentMigrationFilter, + 'latestMigration.operations' => function ($q) use ($dateRange) { + $q->where('start_date', '>=', $dateRange->startSql()) + ->where('start_date', '<', $dateRange->endSql()); // по start_date + }, + 'latestMigration.reanimations', + 'observable', + 'operations' => function ($q) use ($departmentId, $dateRange) { + $q->where('department_id', $departmentId) + ->where('start_date', '>=', $dateRange->startSql()) + ->where('start_date', '<', $dateRange->endSql()) + // Только операции пока пациент реально лежал в отделении + ->whereExists(function ($sub) use ($departmentId) { + $sub->select(\DB::raw(1)) + ->from('mv_migrationpatient_details') + ->where('department_id', $departmentId) + ->whereColumn('medical_history_id', 'mv_surgical_operations.medical_history_id') + ->whereColumn('ingoing_date', '<=', 'mv_surgical_operations.start_date') + ->where(function ($q2) { + $q2->whereNull('out_date') + ->orWhereColumn('out_date', '>=', 'mv_surgical_operations.start_date'); + }); + }); + } + ])->get(); // 2. Добавляем вычисляемые поля и превращаем в плоский массив $prepared = $all->map(function (MedicalHistory $h) use ($dateRange) { $patientStatus = PatientStatusClassifier::classify($h, $dateRange); $patientUrgency = null; + $patientReanimation = null; if (!in_array($patientStatus, [ PatientStatusClassifier::STATUS_DECEASED, PatientStatusClassifier::STATUS_DISCHARGED, PatientStatusClassifier::STATUS_TRANSFERRED - ])) $patientUrgency = PatientStatusClassifier::classifyUrgency($h->urgency_id); + ])) { + $patientUrgency = PatientStatusClassifier::classifyUrgency($h->urgency_id); + $patientReanimation = PatientStatusClassifier::classifyReanimation($h->latestMigration?->reanimations, $dateRange); + } return [ // Все исходные поля модели (автоматически через toArray) ...$h->toArray(), @@ -45,7 +91,9 @@ class MedicalHistoryService // + вычисляемые мета-поля для фронтенда 'patient_status' => $patientStatus, 'patient_urgency' => $patientUrgency, - 'admitted_today' => PatientStatusClassifier::classifyAdmitted($h->latestMigration?->ingoing_date), + 'in_reanimation' => $patientReanimation, + 'admitted_today' => PatientStatusClassifier::classifyAdmitted($h->latestMigration?->ingoing_date, $dateRange), + 'in_observable' => PatientStatusClassifier::classifyObservable($h->observable, $dateRange), ]; }); @@ -54,6 +102,21 @@ class MedicalHistoryService $sortOrder = 'desc'; $sorted = $prepared->sortBy($sortBy, SORT_REGULAR, $sortOrder === 'desc')->values(); + // Операции + $operations = $sorted->map(function ($h) { + return $h['operations']; + })->flatten(1); + + $countInDepartment = $sorted->where('patient_status', 'in_department')->count(); + $countRecipient = $sorted->where('patient_status', 'recipient')->count(); + $countDischarged = $sorted->where('patient_status', 'discharged')->count(); + $countDeceased = $sorted->where('patient_status', 'deceased')->count(); + $countUrgent = $sorted->where('patient_urgency', 'urgent')->count(); + $countPlanned = $sorted->where('patient_urgency', 'planned')->count(); + $countReanimations = $sorted->where('in_reanimation', true)->count(); + $countSurgPlanned = $operations->where('urgent_status', 6)->count(); + $countSurgUrgent = $operations->whereIn('urgent_status', [4, 5])->count(); + // 4. Возвращаем плоский массив + метаданные для фронтенда return [ 'data' => $sorted->toArray(), @@ -61,12 +124,16 @@ class MedicalHistoryService 'total' => $sorted->count(), 'sortBy' => $sortBy, 'sortOrder' => $sortOrder, - // Статистика для фильтров/бейджей (опционально) 'counts' => [ - 'in_department' => $sorted->where('patient_status', 'in_department')->count(), - 'discharged' => $sorted->where('patient_status', 'discharged')->count(), - 'urgent' => $sorted->where('patient_urgency', 'urgent')->count(), - 'planned' => $sorted->where('patient_urgency', 'planned')->count(), + 'in_department' => $countInDepartment + $countRecipient, + 'recipient' => $countRecipient, + 'discharged' => $countDischarged, + 'deceased' => $countDeceased, + 'urgent' => $countUrgent, + 'planned' => $countPlanned, + 'reanimations' => $countReanimations, + 'surgical_planned' => $countSurgPlanned, + 'surgical_urgent' => $countSurgUrgent, ] ] ]; diff --git a/app/Services/NurseMedicalHistoryService.php b/app/Services/NurseMedicalHistoryService.php index 5a0dbc4..1ec1511 100644 --- a/app/Services/NurseMedicalHistoryService.php +++ b/app/Services/NurseMedicalHistoryService.php @@ -6,10 +6,126 @@ use App\Models\MedicalHistory; use App\Models\MigrationPatient; use App\Models\ReportNursePatient; use App\Models\UnifiedMedicalHistory; +use App\Services\Classification\PatientStatusClassifier; use Illuminate\Support\Carbon; +use Illuminate\Support\Collection; class NurseMedicalHistoryService { + public function getGroupedHistories(DateRange $dateRange, int $departmentId, ?array $reportIds = null): array + { + $startYear = $dateRange->startDate->copy()->startOfYear()->format('Y-m-d'); + + $periodMigrationFilter = function ($q) use ($departmentId, $dateRange, $startYear) { + $q->where('department_id', $departmentId) + ->where('ingoing_date', '<=', $dateRange->endSql()) + ->where(function ($sub) use ($dateRange, $startYear) { + $sub->whereNull('out_date') + ->where('ingoing_date', '>', $startYear); + + $sub->orWhere(function ($sub2) use ($dateRange, $startYear) { + $sub2->whereNotNull('out_date') + ->where('out_date', '>', $dateRange->startSql()) + ->where('out_date', '>', $startYear); + }); + }); + }; + + $departmentMigrationFilter = function ($q) use ($departmentId) { + $q->where('department_id', $departmentId) + ->orderByDesc('ingoing_date'); + }; + + // 1. Один запрос: получаем "сырые" данные (без вычисляемых статусов) + $all = ReportNursePatient::query() + ->when($reportIds, function ($query, $reportIds) { + return $query->whereIn('report_nurse_id', $reportIds); + }) + ->whereHas('migrations', $periodMigrationFilter) + ->with([ + 'latestMigration' => $periodMigrationFilter, + 'migrations' => $departmentMigrationFilter, + 'operations' => function ($q) use ($departmentId, $dateRange) { + $q->where('department_id', $departmentId) + ->where('start_date', '>=', $dateRange->startSql()) + ->where('start_date', '<', $dateRange->endSql()) + // Только операции пока пациент реально лежал в отделении + ->whereExists(function ($sub) use ($departmentId) { + $sub->select(\DB::raw(1)) + ->from('mv_migrationpatient_details') + ->where('department_id', $departmentId) + ->whereColumn('medical_history_id', 'mv_surgical_operations.medical_history_id') + ->whereColumn('ingoing_date', '<=', 'mv_surgical_operations.start_date') + ->where(function ($q2) { + $q2->whereNull('out_date') + ->orWhereColumn('out_date', '>=', 'mv_surgical_operations.start_date'); + }); + }); + } + ]) + ->selectRaw('DISTINCT ON (original_id) report_nurse_patients.*') + ->orderBy('original_id') + ->orderBy('report_nurse_id', 'desc') + ->get(); + + // 2. Добавляем вычисляемые поля и превращаем в плоский массив + $prepared = $all->map(function (ReportNursePatient $h) use ($dateRange) { + $patientStatus = PatientStatusClassifier::classify($h, $dateRange); + $patientUrgency = null; + $patientReanimation = null; + if (!in_array($patientStatus, [ + PatientStatusClassifier::STATUS_DECEASED, + PatientStatusClassifier::STATUS_DISCHARGED, + PatientStatusClassifier::STATUS_TRANSFERRED + ])) { + $patientUrgency = PatientStatusClassifier::classifyUrgency($h->urgency_id); + } + return [ + // Все исходные поля модели (автоматически через toArray) + ...$h->toArray(), + + // + вычисляемые мета-поля для фронтенда + 'patient_status' => $patientStatus, + 'patient_urgency' => $patientUrgency, + 'in_reanimation' => $patientReanimation, + 'admitted_today' => PatientStatusClassifier::classifyAdmitted($h->latestMigration?->ingoing_date, $dateRange), + ]; + }); + + // 3. Сортировка + $sortBy = 'recipient_date'; + $sortOrder = 'desc'; + $sorted = $prepared->sortBy($sortBy, SORT_REGULAR, $sortOrder === 'desc')->values(); + + // 4. Возвращаем плоский массив + метаданные для фронтенда + $countInDepartment = $sorted->where('patient_status', 'in_department')->count(); + $countRecipient = $sorted->where('patient_status', 'recipient')->count(); + $countDischarged = $sorted->where('patient_status', 'discharged')->count(); + $countDeceased = $sorted->where('patient_status', 'deceased')->count(); + $countUrgent = $sorted->where('patient_urgency', 'urgent')->count(); + $countPlanned = $sorted->where('patient_urgency', 'planned')->count(); + $countReanimations = $sorted->where('in_reanimation', true)->count(); + + // 4. Возвращаем плоский массив + метаданные для фронтенда + return [ + 'data' => $sorted->toArray(), + 'meta' => [ + 'total' => $sorted->count(), + 'sortBy' => $sortBy, + 'sortOrder' => $sortOrder, + 'counts' => [ + 'in_department' => $countInDepartment + $countRecipient, + 'recipient' => $countRecipient, + 'discharged' => $countDischarged + $countDeceased, + 'deceased' => $countDeceased, + 'urgent' => $countUrgent, + 'planned' => $countPlanned, + 'reanimations' => $countReanimations, + ] + ] + ]; + } + public function getHistories(DateRange $dateRange, int $departmentId) { $query = ReportNursePatient::query(); diff --git a/app/Services/NurseReportService.php b/app/Services/NurseReportService.php index 6f4c52b..13e34d0 100644 --- a/app/Services/NurseReportService.php +++ b/app/Services/NurseReportService.php @@ -52,8 +52,10 @@ class NurseReportService $report = ReportNurse::updateOrCreate( [ - 'report_date' => $data['report_date'], 'period_start' => $data['period_start'], - 'period_end' => $data['period_end'] + 'report_date' => $data['report_date'], + 'period_start' => $data['period_start'], + 'period_end' => $data['period_end'], + 'rf_department_id' => $data['rf_department_id'], ], $data ); @@ -71,21 +73,36 @@ class NurseReportService $startYear = Carbon::now()->startOfYear()->format('Y-m-d'); $query = UnifiedMedicalHistory::query() - // Фильтруем движения по отделению + пересечение дат ->whereHas('migrations', function ($q) use ($departmentId, $dateRange, $startYear) { $q->where('department_id', $departmentId) - // пребывание пересекается с отчётным периодом ->where('ingoing_date', '<=', $dateRange->endSql()) - ->where('ingoing_date', '>=', $startYear) - ->where(function ($sub) use ($dateRange) { + ->where(function ($sub) use ($dateRange, $startYear) { $sub->whereNull('out_date') - ->orWhere('out_date', '>=', $dateRange->startSql()) - ->where('out_date', '<=', $dateRange->endSql()); - }); - }); + ->where('ingoing_date', '>', $startYear); - // Получаем данные (chunk для памяти, если пациентов > 1000) - $patients = $query->cursor(); + $sub->orWhere(function ($sub2) use ($dateRange, $startYear) { + $sub2->whereNotNull('out_date') + ->where('out_date', '>', $dateRange->startSql()) + ->where('out_date', '>', $startYear); + }); + }); + }) + ->with(['migrations' => function ($q) use ($departmentId, $dateRange, $startYear) { + $q->where('department_id', $departmentId) + ->where('ingoing_date', '<=', $dateRange->endSql()) + ->where(function ($sub) use ($dateRange, $startYear) { + $sub->whereNull('out_date') + ->where('ingoing_date', '>', $startYear); + + $sub->orWhere(function ($sub2) use ($dateRange, $startYear) { + $sub2->whereNotNull('out_date') + ->where('out_date', '>', $dateRange->startSql()) + ->where('out_date', '>', $startYear); + }); + }); + }]); + + $patients = $query->lazy(); $savedStats = $this->saveReportSnapshot($reportNurse->id, $patients, $userId); diff --git a/app/Services/StatisticsService.php b/app/Services/StatisticsService.php index ce2f953..b10fe24 100644 --- a/app/Services/StatisticsService.php +++ b/app/Services/StatisticsService.php @@ -47,13 +47,35 @@ class StatisticsService $allDeptIds = $departments->flatten()->pluck('department_id')->toArray(); + // 2а. Нежелательные события по отделениям за период (прямой запрос) + $unwantedCounts = DB::table('duty_unwanted_events as due') + ->join('report_duties as rd', 'rd.id', '=', 'due.report_duty_id') + ->whereIn('rd.rf_department_id', $allDeptIds) + ->where('rd.period_start', '>=', $startDate) + ->where('rd.period_end', '<=', $endDate) + ->select('rd.rf_department_id', DB::raw('COUNT(*) as count')) + ->groupBy('rd.rf_department_id') + ->get() + ->keyBy('rf_department_id'); + + // 2б. Пациенты на контроле по отделениям за период (прямой запрос) + $observableCounts = DB::table('observable_medical_histories as omh') + ->join('report_duty_patients as rdp', 'rdp.original_id', '=', 'omh.original_id') + ->join('report_duties as rd', 'rd.id', '=', 'rdp.report_duty_id') + ->whereIn('rd.rf_department_id', $allDeptIds) + ->where('omh.observable_in', '>=', $startDate) + ->where('omh.observable_in', '<=', $endDate) + ->select('rd.rf_department_id', DB::raw('COUNT(DISTINCT omh.id) as count')) + ->groupBy('rd.rf_department_id') + ->get() + ->keyBy('rf_department_id'); + // 2. Получаем ВСЕ метрики за период ОДНИМ запросом - $metrics = DB::table('reports as r') - ->join('metrika_results as mr', 'r.report_id', '=', 'mr.rf_report_id') + $metrics = DB::table('report_duties as r') + ->join('duty_report_metric_results as mr', 'r.id', '=', 'mr.rf_report_id') ->whereIn('r.rf_department_id', $allDeptIds) ->where('r.period_start', '>=', $startDate) - ->where('r.period_start', '<=', $endDate) - ->where('r.status', 'submitted') + ->where('r.period_end', '<=', $endDate) ->select( 'r.rf_department_id', 'mr.rf_metrika_item_id', @@ -65,11 +87,11 @@ class StatisticsService ->groupBy('rf_department_id'); // 3. Получаем текущих пациентов - $currentPatients = DB::table('reports as r') - ->join('metrika_results as mr', 'r.report_id', '=', 'mr.rf_report_id') + $currentPatients = DB::table('report_duties as r') + ->join('duty_report_metric_results as mr', 'r.id', '=', 'mr.rf_report_id') ->whereIn('r.rf_department_id', $allDeptIds) ->where('mr.rf_metrika_item_id', 8) - ->where('r.period_start', '<=', $endDate) + ->where('r.period_end', '<=', $endDate) ->select('r.rf_department_id', 'mr.value', 'r.created_at') ->orderBy('r.rf_department_id') // Сначала поле из DISTINCT ON ->orderBy('r.period_end', 'desc') // Потом остальные @@ -130,8 +152,8 @@ class StatisticsService $outcome = 0; $deceased = 0; $staff = 0; - $observable = 0; - $unwanted = 0; + $observable = (int) ($observableCounts[$deptId]->count ?? 0); + $unwanted = (int) ($unwantedCounts[$deptId]->count ?? 0); $bedDaysSum = 0; $avgBedDays = 0; $preoperativeSum = 0; @@ -154,8 +176,6 @@ class StatisticsService 7 => $outcome = (int) $value, 9 => $deceased = (int) $value, 17 => $staff = (int) $value, - 14 => $observable = (int) $value, - 16 => $unwanted = (int) $value, 25 => $bedDaysSum += $value, 19 => $lethalitySum = $value, 21 => $preoperativeAverageSum += $value, @@ -363,8 +383,9 @@ class StatisticsService */ private function createTotalRow(string $type, array $total, bool $isGrandTotal): array { - if ($total['preoperativePatientCount'] === 0) $total['preoperativePatientCount'] = 1; - if ($total['outcome_sum'] === 0) $total['outcome_sum'] = 1; + $outcomeSum = $total['outcome_sum']; + $preopPatients = $total['preoperativePatientCount']; + return [ 'isTotalRow' => ! $isGrandTotal, 'isGrandTotal' => $isGrandTotal, @@ -376,7 +397,7 @@ class StatisticsService 'emergency' => $total['recipients_emergency_sum'], 'transferred' => $total['recipients_transferred_sum'], ], - 'outcome' => $total['outcome_sum'], + 'outcome' => $outcomeSum, 'consist' => $total['consist_sum'], 'percentLoadedBeds' => '—', 'surgical' => [ @@ -384,9 +405,15 @@ class StatisticsService 'emergency' => $total['emergency_surgical_sum'], ], 'deceased' => $total['deceased_sum'], - 'averageBedDays' => round($total['bedDaysSum'] / $total['outcome_sum'], 1), - 'preoperativeDays' => round($total['preoperativeSum'] / $total['preoperativePatientCount'] < 0 ?? 1, 1), - 'lethality' => round(($total['deceased_sum'] / $total['outcome_sum']) * 100, 2), + 'averageBedDays' => $outcomeSum > 0 + ? round($total['bedDaysSum'] / $outcomeSum, 1) + : 0, + 'preoperativeDays' => $preopPatients > 0 + ? round($total['preoperativeSum'] / $preopPatients, 1) + : 0, + 'lethality' => $outcomeSum > 0 + ? round(($total['deceased_sum'] / $outcomeSum) * 100, 2) + : 0, 'type' => $type, 'departments_count' => $total['departments_count'], 'countStaff' => $total['staff_sum'], diff --git a/app/Services/UnifiedMedicalHistoryService.php b/app/Services/UnifiedMedicalHistoryService.php index 044cae1..ac41a99 100644 --- a/app/Services/UnifiedMedicalHistoryService.php +++ b/app/Services/UnifiedMedicalHistoryService.php @@ -12,34 +12,43 @@ class UnifiedMedicalHistoryService { public function getGroupedHistories(DateRange $dateRange, int $departmentId): array { + $startYear = $dateRange->startDate->copy()->startOfYear()->format('Y-m-d'); + // 1. Один запрос: получаем "сырые" данные (без вычисляемых статусов) - $all = UnifiedMedicalHistory::with([ - 'latestMigration' => fn($q) => $q->where(function ($q) use ($dateRange) { - // Вариант А: Пациент уже лежит (текущий) - $q->whereNull('out_date') - ->whereNotNull('medical_history_id') - ->where('ingoing_date', '<', $dateRange->startSql()); - }) - ->orWhere(function ($q) use ($dateRange) { - $q->where('ingoing_date', '<=', $dateRange->endSql()) - ->where('ingoing_date', '>', $dateRange->startSql()); - }) - ]) - ->whereNull('extract_date') - ->get() - // Фильтр по отделению в памяти (быстро для <1000 записей) - ->filter(fn($h) => $h->latestMigration?->department_id === $departmentId); + $all = UnifiedMedicalHistory::query()->whereHas('latestMigration', function ($q) use ($departmentId, $dateRange, $startYear) { + $q->where('department_id', $departmentId) + // пребывание пересекается с отчётным периодом: ingoing <= end AND (out IS NULL OR out >= start) + ->where('ingoing_date', '<=', $dateRange->endSql()) + ->where('ingoing_date', '>=', $startYear) + ->where(function ($sub) use ($dateRange) { + $sub->whereNull('out_date') + ->orWhere('out_date', '>=', $dateRange->startSql()); + }); + })->with(['latestMigration' => function ($q) use ($departmentId) { + $q->where('department_id', $departmentId); + }, 'latestMigration.operations'])->get(); // 2. Добавляем вычисляемые поля и превращаем в плоский массив $prepared = $all->map(function (UnifiedMedicalHistory $h) use ($dateRange) { + $patientStatus = PatientStatusClassifier::classify($h, $dateRange); + $patientUrgency = null; + $patientReanimation = null; + if (!in_array($patientStatus, [ + PatientStatusClassifier::STATUS_DECEASED, + PatientStatusClassifier::STATUS_DISCHARGED, + PatientStatusClassifier::STATUS_TRANSFERRED + ])) { + $patientUrgency = PatientStatusClassifier::classifyUrgency($h->urgency_id); + } return [ // Все исходные поля модели (автоматически через toArray) ...$h->toArray(), // + вычисляемые мета-поля для фронтенда - 'patient_status' => PatientStatusClassifier::classify($h, $dateRange), - 'patient_urgency' => PatientStatusClassifier::classifyUrgency($h->urgency_id), - 'admitted_today' => PatientStatusClassifier::classifyAdmitted($h->latestMigration?->ingoing_date), + 'patient_status' => $patientStatus, + 'patient_urgency' => $patientUrgency, + 'in_reanimation' => $patientReanimation, + 'admitted_today' => PatientStatusClassifier::classifyAdmitted($h->latestMigration?->ingoing_date, $dateRange), ]; }); @@ -48,6 +57,14 @@ class UnifiedMedicalHistoryService $sortOrder = 'desc'; $sorted = $prepared->sortBy($sortBy, SORT_REGULAR, $sortOrder === 'desc')->values(); + // 4. Возвращаем плоский массив + метаданные для фронтенда + $countInDepartment = $sorted->where('patient_status', 'in_department')->count(); + $countRecipient = $sorted->where('patient_status', 'recipient')->count(); + $countDischarged = $sorted->where('patient_status', 'discharged')->count(); + $countUrgent = $sorted->where('patient_urgency', 'urgent')->count(); + $countPlanned = $sorted->where('patient_urgency', 'planned')->count(); + $countReanimations = $sorted->where('in_reanimation', true)->count(); + // 4. Возвращаем плоский массив + метаданные для фронтенда return [ 'data' => $sorted->toArray(), @@ -55,12 +72,13 @@ class UnifiedMedicalHistoryService 'total' => $sorted->count(), 'sortBy' => $sortBy, 'sortOrder' => $sortOrder, - // Статистика для фильтров/бейджей (опционально) 'counts' => [ - 'in_department' => $sorted->where('patient_status', 'in_department')->count(), - 'discharged' => $sorted->where('patient_status', 'discharged')->count(), - 'urgent' => $sorted->where('patient_urgency', 'urgent')->count(), - 'planned' => $sorted->where('patient_urgency', 'planned')->count(), + 'in_department' => $countInDepartment + $countRecipient, + 'recipient' => $countRecipient, + 'discharged' => $countDischarged, + 'urgent' => $countUrgent, + 'planned' => $countPlanned, + 'reanimations' => $countReanimations, ] ] ]; diff --git a/database/migrations/2026_05_12_115210_add_original_id_in_report_duty_migration_patients_table.php b/database/migrations/2026_05_12_115210_add_original_id_in_report_duty_migration_patients_table.php new file mode 100644 index 0000000..cfe2bb3 --- /dev/null +++ b/database/migrations/2026_05_12_115210_add_original_id_in_report_duty_migration_patients_table.php @@ -0,0 +1,28 @@ +bigInteger('original_id')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('report_duty_migration_patients', function (Blueprint $table) { + $table->dropColumn('original_id'); + }); + } +}; diff --git a/database/migrations/2026_05_12_115810_create_duty_report_metric_results_table.php b/database/migrations/2026_05_12_115810_create_duty_report_metric_results_table.php new file mode 100644 index 0000000..a99d9c7 --- /dev/null +++ b/database/migrations/2026_05_12_115810_create_duty_report_metric_results_table.php @@ -0,0 +1,34 @@ +id('metrika_result_id') + ->comment('Идентификатор результата метрики'); + $table->foreignIdFor(\App\Models\MetrikaItem::class, 'rf_metrika_item_id') + ->comment('Идентификатор метрики') + ->constrained(); + $table->foreignIdFor(\App\Models\ReportDuty::class, 'rf_report_id') + ->comment('Идентификатор отчета') + ->constrained(); + $table->text('value'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('duty_report_metric_results'); + } +}; diff --git a/database/migrations/2026_05_12_121534_create_report_duty_reanimations_table.php b/database/migrations/2026_05_12_121534_create_report_duty_reanimations_table.php new file mode 100644 index 0000000..5116769 --- /dev/null +++ b/database/migrations/2026_05_12_121534_create_report_duty_reanimations_table.php @@ -0,0 +1,42 @@ +id(); + $table->bigInteger('original_id')->unique(); + $table->bigInteger('migration_patient_id'); + $table->bigInteger('medical_history_id'); + $table->dateTime('in_date')->unique(); + $table->dateTime('out_date')->nullable(); + $table->text('description')->nullable(); + $table->text('comment')->nullable(); + $table->bigInteger('stationar_branch_id'); + $table->bigInteger('migration_stationar_branch_id'); + $table->bigInteger('migration_department_id'); + $table->bigInteger('doctor_id'); + $table->bigInteger('user_id')->nullable(); + $table->bigInteger('mis_user_id')->nullable(); + $table->timestamps(); + + $table->unique(['migration_patient_id', 'in_date'], 'uniq_rdr_migration_patient_id_and_in_date'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('report_duty_reanimations'); + } +}; diff --git a/database/migrations/2026_05_14_155245_create_observable_medical_histories_table.php b/database/migrations/2026_05_14_155245_create_observable_medical_histories_table.php new file mode 100644 index 0000000..18a118b --- /dev/null +++ b/database/migrations/2026_05_14_155245_create_observable_medical_histories_table.php @@ -0,0 +1,48 @@ +id(); + $table->string('source_type'); + $table->bigInteger('original_id'); + + $table->dateTime('observable_in'); + $table->dateTime('observable_out')->nullable(); + $table->text('observable_reason')->nullable(); + $table->text('out_reason')->nullable(); + + $table->string('medical_card_number')->nullable(); + $table->string('full_name'); + $table->date('birth_date')->nullable(); + $table->dateTime('recipient_date'); + $table->dateTime('extract_date')->nullable(); + $table->dateTime('death_date')->nullable(); + $table->boolean('male')->default(true); + $table->integer('urgency_id')->nullable(); + $table->integer('hospital_result_id')->nullable(); + $table->integer('visit_result_id')->nullable(); + $table->text('comment')->nullable(); + $table->foreignIdFor(\App\Models\User::class, 'user_id'); + $table->unique(['source_type', 'original_id']); // защита от дублей + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('observable_medical_histories'); + } +}; diff --git a/database/migrations/2026_05_15_155330_create_duty_unwanted_events_table.php b/database/migrations/2026_05_15_155330_create_duty_unwanted_events_table.php new file mode 100644 index 0000000..1beca53 --- /dev/null +++ b/database/migrations/2026_05_15_155330_create_duty_unwanted_events_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignIdFor(\App\Models\ReportDuty::class, 'report_duty_id'); + $table->string('title'); + $table->text('comment'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('duty_unwanted_events'); + } +}; diff --git a/database/migrations/2026_05_19_233036_update_roles_slugs_and_add_new_roles.php b/database/migrations/2026_05_19_233036_update_roles_slugs_and_add_new_roles.php new file mode 100644 index 0000000..d6a5f10 --- /dev/null +++ b/database/migrations/2026_05_19_233036_update_roles_slugs_and_add_new_roles.php @@ -0,0 +1,43 @@ +where('slug', 'head_of_department')->update([ + 'slug' => 'zav', + 'name' => 'Заведующий отделением', + ]); + + DB::table('roles')->where('slug', 'doctor')->update([ + 'slug' => 'dej', + 'name' => 'Врач-дежурный', + ]); + + // Добавляем новые роли (если ещё не существуют) + $existing = DB::table('roles')->pluck('slug')->toArray(); + + $newRoles = [ + ['name' => 'Главный врач', 'slug' => 'gv', 'is_active' => true], + ['name' => 'Зам. главного врача', 'slug' => 'zam', 'is_active' => true], + ['name' => 'Старшая мед. сестра', 'slug' => 'nurse', 'is_active' => true], + ]; + + foreach ($newRoles as $role) { + if (! in_array($role['slug'], $existing)) { + DB::table('roles')->insert($role); + } + } + } + + public function down(): void + { + DB::table('roles')->where('slug', 'zav')->update(['slug' => 'head_of_department', 'name' => 'Заведующий отделением']); + DB::table('roles')->where('slug', 'dej')->update(['slug' => 'doctor', 'name' => 'Врач']); + DB::table('roles')->whereIn('slug', ['gv', 'zam', 'nurse'])->delete(); + } +}; diff --git a/database/seeders/PermissionAndRoleSeeder.php b/database/seeders/PermissionAndRoleSeeder.php index fc2bf23..84b59eb 100644 --- a/database/seeders/PermissionAndRoleSeeder.php +++ b/database/seeders/PermissionAndRoleSeeder.php @@ -2,61 +2,103 @@ namespace Database\Seeders; -use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; use Spatie\Permission\Models\Permission; use Spatie\Permission\Models\Role; class PermissionAndRoleSeeder extends Seeder { - /** - * Run the database seeds. - */ public function run(): void { - Permission::create(['name' => 'Создание отчета']); - Permission::create(['name' => 'Редактирование отчета']); - Permission::create(['name' => 'Просмотр статистики']); + // Сброс кэша перед изменениями + app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions(); - Permission::create(['name' => 'Создание и редактирование пользователей']); - Permission::create(['name' => 'Создание и редактирование прав и ролей']); - Permission::create(['name' => 'Создание и редактирование метрик']); + // --- Права --- + $permissions = [ + // Сводный отчёт (дежурный) + 'report.create' => 'Создание сводного отчёта', + 'report.edit' => 'Редактирование сводного отчёта (текущий период)', + 'report.edit.past' => 'Редактирование сводного отчёта за прошлые периоды (+ нежелательные события, пациенты на контроле)', + 'report.view' => 'Просмотр отчётов', - $admin = Role::create(['name' => 'admin']); - $gv = Role::create(['name' => 'gv']); - $zam = Role::create(['name' => 'zam']); - $zav = Role::create(['name' => 'zav']); - $dej = Role::create(['name' => 'dej']); - $nurse = Role::create(['name' => 'nurse']); + // Журнал пациентов (мед. сестра) + 'nurse.report.view' => 'Просмотр журнала пациентов', + 'nurse.report.create' => 'Создание и редактирование журнала пациентов', + 'nurse.report.edit.past' => 'Редактирование журнала пациентов за прошлые периоды', - $admin->givePermissionTo([ - 'Создание отчета', - 'Редактирование отчета', - 'Просмотр статистики', - 'Создание и редактирование пользователей', - 'Создание и редактирование прав и ролей', - 'Создание и редактирование метрик', + // Общее + 'stats.view' => 'Просмотр статистики', + 'users.manage' => 'Управление пользователями', + 'roles.manage' => 'Управление ролями и правами', + 'metrics.manage' => 'Управление метриками', + ]; + + foreach ($permissions as $name => $label) { + Permission::firstOrCreate(['name' => $name]); + } + + // --- Роли (slug совпадает со slug в App\Models\Role) --- + $admin = Role::firstOrCreate(['name' => 'admin']); // Администратор + $gv = Role::firstOrCreate(['name' => 'gv']); // Главный врач + $zam = Role::firstOrCreate(['name' => 'zam']); // Зам. глав. врача + $zav = Role::firstOrCreate(['name' => 'zav']); // Заведующий отделением + $dej = Role::firstOrCreate(['name' => 'dej']); // Врач-дежурный + $nurse = Role::firstOrCreate(['name' => 'nurse']); // Старшая мед. сестра + + // --- Назначение прав --- + $admin->syncPermissions(array_keys($permissions)); + + // Главный врач: полный доступ к отчётам, статистике, метрикам + $gv->syncPermissions([ + 'report.create', + 'report.edit', + 'report.edit.past', + 'report.view', + 'nurse.report.view', + 'nurse.report.create', + 'nurse.report.edit.past', + 'stats.view', + 'metrics.manage', ]); - $gv->givePermissionTo([ - 'Создание отчета', - 'Редактирование отчета', - 'Просмотр статистики', + + // Зам. главного врача: то же что и главный врач + $zam->syncPermissions([ + 'report.create', + 'report.edit', + 'report.edit.past', + 'report.view', + 'nurse.report.view', + 'nurse.report.create', + 'nurse.report.edit.past', + 'stats.view', + 'metrics.manage', ]); - $zam->givePermissionTo([ - 'Создание отчета', - 'Редактирование отчета', - 'Просмотр статистики', + + // Заведующий отделением: создание/редактирование отчётов своего отделения + $zav->syncPermissions([ + 'report.create', + 'report.edit', + 'report.edit.past', + 'report.view', + 'nurse.report.view', + 'nurse.report.create', + 'nurse.report.edit.past', + 'stats.view', ]); - $zav->givePermissionTo([ - 'Создание отчета', - 'Редактирование отчета', - 'Просмотр статистики', + + // Врач-дежурный: создание сводного + просмотр журнала (без редактирования) + $dej->syncPermissions([ + 'report.create', + 'report.edit', + 'report.view', + 'nurse.report.view', ]); - $dej->givePermissionTo([ - 'Создание отчета', - ]); - $nurse->givePermissionTo([ - 'Создание отчета', + + // Старшая мед. сестра: журнал пациентов (создание и просмотр) + $nurse->syncPermissions([ + 'report.view', + 'nurse.report.view', + 'nurse.report.create', ]); } } diff --git a/package-lock.json b/package-lock.json index 6cdf5b0..2def047 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2112,6 +2112,7 @@ "integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@emotion/hash": "~0.8.0", "csstype": "~3.0.5" @@ -2187,6 +2188,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -2240,6 +2242,7 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -3183,6 +3186,7 @@ "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", "license": "MIT", + "peer": true, "dependencies": { "@vue/devtools-api": "^7.7.7" }, @@ -3664,6 +3668,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -4021,6 +4026,7 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz", "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.26", "@vue/compiler-sfc": "3.5.26", diff --git a/resources/css/app.css b/resources/css/app.css index 7ac934d..91b533e 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -39,17 +39,6 @@ } body { - background-image: - radial-gradient( - circle at top center, - color-mix(in oklch, var(--primary) 10%, transparent) 0, - transparent 34rem - ), - radial-gradient( - circle at bottom left, - color-mix(in oklch, var(--chart-2) 12%, transparent) 0, - transparent 26rem - ); min-height: 100vh; } } @@ -59,12 +48,12 @@ background-image: linear-gradient( to right, - color-mix(in oklch, var(--color-neutral-800) 50%, transparent) 1px, + color-mix(in srgb, var(--color-neutral-800) 50%, transparent) 1px, transparent 1px ), linear-gradient( to bottom, - color-mix(in oklch, var(--color-neutral-800) 45%, transparent) 1px, + color-mix(in srgb, var(--color-neutral-800) 45%, transparent) 1px, transparent 1px ); background-size: 40px 40px; diff --git a/resources/js/Components/ActionTile.vue b/resources/js/Components/ActionTile.vue new file mode 100644 index 0000000..995ea29 --- /dev/null +++ b/resources/js/Components/ActionTile.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/resources/js/Components/DatePickerQuery.vue b/resources/js/Components/DatePickerQuery.vue index 8f186dd..0e4622e 100644 --- a/resources/js/Components/DatePickerQuery.vue +++ b/resources/js/Components/DatePickerQuery.vue @@ -1,11 +1,11 @@