From bd2cc24b987f18e3265eaa65b0c49f50ea0c0b89 Mon Sep 17 00:00:00 2001 From: brusnitsyn Date: Sun, 21 Jun 2026 23:40:55 +0900 Subject: [PATCH] =?UTF-8?q?=D0=9C=D0=BE=D0=B4=D1=83=D0=BB=D1=8C=20=D0=BE?= =?UTF-8?q?=D1=82=D1=87=D0=B5=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Reports/ValueObjects/MetrikaConfig.php | 2 + .../Web/Admin/ReportTemplateController.php | 148 +++++ .../Controllers/Web/ReportsController.php | 139 +++++ app/Models/ReportTemplate.php | 26 + .../Reports/BuiltIn/DutyDoctorReport.php | 49 ++ .../Reports/BuiltIn/HeadNurseReport.php | 106 ++++ .../Reports/Contracts/ReportDefinition.php | 24 + .../Reports/Export/ReportExcelExport.php | 62 +++ .../Reports/Export/ReportPdfExport.php | 16 + app/Services/Reports/ReportPayload.php | 16 + app/Services/Reports/ReportRegistry.php | 66 +++ app/Services/Reports/ReportSection.php | 16 + app/Services/Reports/ReportSource.php | 63 +++ app/Services/Reports/ReportSourceRegistry.php | 342 ++++++++++++ .../Reports/TemplateReportDefinition.php | 63 +++ composer.json | 1 + composer.lock | 524 +++++++++++++++++- config/dompdf.php | 301 ++++++++++ ...1_224657_create_report_templates_table.php | 33 ++ resources/js/Pages/Admin/Index.vue | 9 +- .../Components/SectionEditor.vue | 92 +++ .../js/Pages/Admin/ReportTemplates/Form.vue | 135 +++++ .../js/Pages/Admin/ReportTemplates/Index.vue | 135 +++++ resources/js/Pages/Index.vue | 10 +- resources/js/Pages/Reports/Index.vue | 306 ++++++++++ resources/views/reports/pdf.blade.php | 85 +++ routes/web.php | 15 + 27 files changed, 2781 insertions(+), 3 deletions(-) create mode 100644 app/Http/Controllers/Web/Admin/ReportTemplateController.php create mode 100644 app/Http/Controllers/Web/ReportsController.php create mode 100644 app/Models/ReportTemplate.php create mode 100644 app/Services/Reports/BuiltIn/DutyDoctorReport.php create mode 100644 app/Services/Reports/BuiltIn/HeadNurseReport.php create mode 100644 app/Services/Reports/Contracts/ReportDefinition.php create mode 100644 app/Services/Reports/Export/ReportExcelExport.php create mode 100644 app/Services/Reports/Export/ReportPdfExport.php create mode 100644 app/Services/Reports/ReportPayload.php create mode 100644 app/Services/Reports/ReportRegistry.php create mode 100644 app/Services/Reports/ReportSection.php create mode 100644 app/Services/Reports/ReportSource.php create mode 100644 app/Services/Reports/ReportSourceRegistry.php create mode 100644 app/Services/Reports/TemplateReportDefinition.php create mode 100644 config/dompdf.php create mode 100644 database/migrations/2026_06_21_224657_create_report_templates_table.php create mode 100644 resources/js/Pages/Admin/ReportTemplates/Components/SectionEditor.vue create mode 100644 resources/js/Pages/Admin/ReportTemplates/Form.vue create mode 100644 resources/js/Pages/Admin/ReportTemplates/Index.vue create mode 100644 resources/js/Pages/Reports/Index.vue create mode 100644 resources/views/reports/pdf.blade.php diff --git a/app/Domain/Reports/ValueObjects/MetrikaConfig.php b/app/Domain/Reports/ValueObjects/MetrikaConfig.php index b982c39..2e1a688 100644 --- a/app/Domain/Reports/ValueObjects/MetrikaConfig.php +++ b/app/Domain/Reports/ValueObjects/MetrikaConfig.php @@ -34,6 +34,8 @@ final class MetrikaConfig public const AVERAGE_BED_DAYS = 18; + public const LETHALITY = 19; + public const PREOPERATIVE_AVERAGE_DAYS = 21; public const DEPARTMENT_LOADED = 22; diff --git a/app/Http/Controllers/Web/Admin/ReportTemplateController.php b/app/Http/Controllers/Web/Admin/ReportTemplateController.php new file mode 100644 index 0000000..f746937 --- /dev/null +++ b/app/Http/Controllers/Web/Admin/ReportTemplateController.php @@ -0,0 +1,148 @@ +authorizeAccess(); + + $templates = ReportTemplate::with('creator')->latest()->get()->map(fn (ReportTemplate $t) => [ + 'id' => $t->id, + 'name' => $t->name, + 'sourceLabels' => collect($t->sections ?? []) + ->map(fn (array $s) => $this->sources->all()[$s['source']]?->label ?? $s['source']) + ->unique() + ->values() + ->all(), + 'sectionsCount' => count($t->sections ?? []), + 'requiredPermissions' => $t->required_permissions ?? [], + 'creator' => $t->creator?->name, + ]); + + return Inertia::render('Admin/ReportTemplates/Index', ['templates' => $templates]); + } + + public function create() + { + $this->authorizeAccess(); + + return Inertia::render('Admin/ReportTemplates/Form', [ + 'template' => null, + 'sources' => $this->sourcesPayload(), + ]); + } + + public function store(Request $request) + { + $this->authorizeAccess(); + + ReportTemplate::create([ + ...$this->validateTemplate($request), + 'created_by' => Auth::id(), + ]); + + return redirect('/admin/report-templates')->with('success', 'Шаблон создан'); + } + + public function edit(ReportTemplate $template) + { + $this->authorizeAccess(); + + return Inertia::render('Admin/ReportTemplates/Form', [ + 'template' => [ + 'id' => $template->id, + 'name' => $template->name, + 'sections' => $template->sections, + 'requiredPermissions' => $template->required_permissions ?? [], + ], + 'sources' => $this->sourcesPayload(), + ]); + } + + public function update(ReportTemplate $template, Request $request) + { + $this->authorizeAccess(); + + $template->update($this->validateTemplate($request)); + + return redirect('/admin/report-templates')->with('success', 'Шаблон сохранён'); + } + + public function destroy(ReportTemplate $template) + { + $this->authorizeAccess(); + + $template->delete(); + + return redirect('/admin/report-templates')->with('success', 'Шаблон удалён'); + } + + private function validateTemplate(Request $request): array + { + $sourceKeys = array_keys($this->sources->all()); + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'sections' => 'required|array|min:1', + 'sections.*.source' => ['required', 'string', 'in:'.implode(',', $sourceKeys)], + 'sections.*.title' => 'nullable|string|max:255', + 'sections.*.columns' => 'required|array|min:1', + 'sections.*.columns.*' => 'string', + 'sections.*.filters' => 'nullable|array', + 'sections.*.filters.*.field' => 'required_with:sections.*.filters|string', + 'sections.*.filters.*.value' => 'nullable', + 'required_permissions' => 'nullable|array', + 'required_permissions.*' => 'in:'.implode(',', self::PERMISSION_OPTIONS), + ]); + + // Допускаем только колонки/поля фильтров, реально существующие у источника секции — + // защита от рассинхрона формы и произвольных значений в БД. + $validated['sections'] = array_map(function (array $section) { + $source = $this->sources->get($section['source']); + + return [ + 'source' => $section['source'], + 'title' => $section['title'] ?? $source->label, + 'columns' => array_values(array_intersect($section['columns'], array_keys($source->columns))), + 'filters' => array_values(array_filter( + $section['filters'] ?? [], + fn (array $filter) => array_key_exists($filter['field'], $source->filterableFields) + )), + ]; + }, $validated['sections']); + + $validated['required_permissions'] = array_values($validated['required_permissions'] ?? []); + + return $validated; + } + + private function sourcesPayload(): array + { + return collect($this->sources->all())->map(fn ($source) => [ + 'key' => $source->key, + 'label' => $source->label, + 'columns' => $source->columns, + 'filterableFields' => $source->filterableFields, + ])->values()->all(); + } + + private function authorizeAccess(): void + { + $user = Auth::user(); + + abort_unless($user->isAdmin() || $user->isChiefDoctor() || $user->isDeputyChief(), 403); + } +} diff --git a/app/Http/Controllers/Web/ReportsController.php b/app/Http/Controllers/Web/ReportsController.php new file mode 100644 index 0000000..4ba39ad --- /dev/null +++ b/app/Http/Controllers/Web/ReportsController.php @@ -0,0 +1,139 @@ +reportRegistry->availableFor($user); + + abort_if(empty($available), 403); + + $department = $this->resolveDepartment($request, $user); + $dateRange = $this->dateRangeService->getDateRangeFromRequest($request, $user); + + // Без явного типа в запросе показываем только список отчётов — без предпросмотра. + $type = $request->query('type'); + $definition = $type ? $this->reportRegistry->find($type, $user) : null; + $payload = $definition?->build($department, $dateRange); + + return Inertia::render('Reports/Index', [ + 'reportTypes' => array_map(fn (ReportDefinition $d) => [ + 'code' => $d->code(), + 'label' => $d->label(), + 'audience' => $this->audienceLabel($d->requiredPermissions()), + ], $available), + 'departments' => collect($user->availableDepartments())->map(fn (Department $d) => [ + 'id' => $d->department_id, + 'name' => $d->name_full ?? $d->name_short, + ])->values()->all(), + 'selectedType' => $definition?->code(), + 'selectedDepartmentId' => $department->department_id, + 'isHeadOrAdmin' => $user->isSeniorStaff(), + 'date' => [ + $dateRange->start()->getTimestampMs(), + $dateRange->end()->getTimestampMs(), + ], + 'payload' => $payload ? [ + 'title' => $payload->title, + 'meta' => $payload->meta, + 'sections' => array_map(fn ($section) => [ + 'title' => $section->title, + 'columns' => $section->columns, + 'rows' => $section->rows, + ], $payload->sections), + ] : null, + ]); + } + + public function exportExcel(Request $request) + { + [$definition, $department, $dateRange] = $this->resolveExportContext($request); + + $payload = $definition->build($department, $dateRange); + + return Excel::download(new ReportExcelExport($payload), $this->fileName($definition, 'xlsx')); + } + + public function exportPdf(Request $request) + { + [$definition, $department, $dateRange] = $this->resolveExportContext($request); + + $payload = $definition->build($department, $dateRange); + + return ReportPdfExport::render($payload)->download($this->fileName($definition, 'pdf')); + } + + /** + * @return array{0: ReportDefinition, 1: Department, 2: \App\Services\DateRange} + */ + private function resolveExportContext(Request $request): array + { + $user = Auth::user(); + $type = $request->query('type'); + $definition = $type ? $this->reportRegistry->find($type, $user) : null; + + abort_if($definition === null, 403); + + $department = $this->resolveDepartment($request, $user); + $dateRange = $this->dateRangeService->getDateRangeFromRequest($request, $user); + + return [$definition, $department, $dateRange]; + } + + private function resolveDepartment(Request $request, User $user): Department + { + $departmentId = $request->query('departmentId'); + $available = collect($user->availableDepartments()); + + if ($departmentId && $available->contains(fn (Department $d) => (int) $d->department_id === (int) $departmentId)) { + return $available->first(fn (Department $d) => (int) $d->department_id === (int) $departmentId); + } + + return $user->department ?? $available->first(); + } + + /** + * @param array $permissions + */ + private function audienceLabel(array $permissions): string + { + if (empty($permissions)) { + return 'Доступен всем'; + } + + $labels = [ + 'report.view' => 'Дежурный врач', + 'nurse.report.view' => 'Старшая медсестра', + ]; + + return implode(' · ', array_map(fn ($p) => $labels[$p] ?? $p, $permissions)); + } + + private function fileName(ReportDefinition $definition, string $extension): string + { + $slug = str_replace([' ', ':', '/', '\\'], '_', $definition->label()); + + return sprintf('%s_%s.%s', $slug, now('Asia/Yakutsk')->format('Ymd_His'), $extension); + } +} diff --git a/app/Models/ReportTemplate.php b/app/Models/ReportTemplate.php new file mode 100644 index 0000000..fe60c8e --- /dev/null +++ b/app/Models/ReportTemplate.php @@ -0,0 +1,26 @@ + 'array', + 'required_permissions' => 'array', + ]; + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } +} diff --git a/app/Services/Reports/BuiltIn/DutyDoctorReport.php b/app/Services/Reports/BuiltIn/DutyDoctorReport.php new file mode 100644 index 0000000..d854a01 --- /dev/null +++ b/app/Services/Reports/BuiltIn/DutyDoctorReport.php @@ -0,0 +1,49 @@ +sources->get('duty_metrics')->toSection($department, $dateRange), + $this->sources->get('duty_patients')->toSection($department, $dateRange), + $this->sources->get('unwanted_events')->toSection($department, $dateRange), + $this->sources->get('observable_patients')->toSection($department, $dateRange), + ]; + + return new ReportPayload( + title: $this->label(), + meta: [ + 'Отделение' => $department->name_full ?? $department->name_short, + 'Период' => $dateRange->start()->format('d.m.Y H:i').' — '.$dateRange->end()->format('d.m.Y H:i'), + 'Сформирован' => now('Asia/Yakutsk')->format('d.m.Y H:i'), + ], + sections: $sections, + ); + } +} diff --git a/app/Services/Reports/BuiltIn/HeadNurseReport.php b/app/Services/Reports/BuiltIn/HeadNurseReport.php new file mode 100644 index 0000000..2805ba9 --- /dev/null +++ b/app/Services/Reports/BuiltIn/HeadNurseReport.php @@ -0,0 +1,106 @@ +buildMetricsSection($department, $dateRange), + $this->sources->get('nurse_patients')->toSection($department, $dateRange, null, [], 'Журнал пациентов'), + ]; + + return new ReportPayload( + title: $this->label(), + meta: [ + 'Отделение' => $department->name_full ?? $department->name_short, + 'Период' => $dateRange->start()->format('d.m.Y H:i').' — '.$dateRange->end()->format('d.m.Y H:i'), + 'Сформирован' => now('Asia/Yakutsk')->format('d.m.Y H:i'), + ], + sections: $sections, + ); + } + + private function buildMetricsSection(Department $department, DateRange $dateRange): ReportSection + { + $reportIds = $this->sources->nurseReports($department, $dateRange)->pluck('id'); + + $counts = [ + 'recipient' => 0, + 'discharged' => 0, + 'transferred' => 0, + 'deceased' => 0, + 'in_department' => 0, + ]; + + if ($reportIds->isNotEmpty()) { + $patients = ReportNursePatient::whereIn('report_nurse_id', $reportIds)->with('migrations')->get(); + + foreach ($patients as $patient) { + match (PatientStatusClassifier::classify($patient, $dateRange)) { + PatientStatusClassifier::STATUS_RECIPIENT => $counts['recipient']++, + PatientStatusClassifier::STATUS_DISCHARGED => $counts['discharged']++, + PatientStatusClassifier::STATUS_TRANSFERRED => $counts['transferred']++, + PatientStatusClassifier::STATUS_DECEASED => $counts['deceased']++, + PatientStatusClassifier::STATUS_IN_DEPARTMENT => $counts['in_department']++, + default => null, + }; + } + } + + $beds = (int) (DepartmentMetrikaDefault::where('rf_department_id', $department->department_id) + ->where('rf_metrika_item_id', MetrikaConfig::BEDS) + ->value('value') ?? 0); + + $occupancy = $beds > 0 ? round($counts['in_department'] * 100 / $beds, 1) : 0; + + $row = [ + 'beds' => $beds, + 'recipient' => $counts['recipient'], + 'discharged' => $counts['discharged'], + 'transferred' => $counts['transferred'], + 'deceased' => $counts['deceased'], + 'in_department' => $counts['in_department'], + 'occupancy_percent' => $occupancy, + ]; + + return new ReportSection('Показатели', [ + 'beds' => 'Коек', + 'recipient' => 'Поступило', + 'discharged' => 'Выписано', + 'transferred' => 'Переведено', + 'deceased' => 'Умерло', + 'in_department' => 'В отделении', + 'occupancy_percent' => 'Занятость, %', + ], [$row]); + } +} diff --git a/app/Services/Reports/Contracts/ReportDefinition.php b/app/Services/Reports/Contracts/ReportDefinition.php new file mode 100644 index 0000000..8b652c2 --- /dev/null +++ b/app/Services/Reports/Contracts/ReportDefinition.php @@ -0,0 +1,24 @@ + + */ + public function requiredPermissions(): array; + + public function build(Department $department, DateRange $dateRange): ReportPayload; +} diff --git a/app/Services/Reports/Export/ReportExcelExport.php b/app/Services/Reports/Export/ReportExcelExport.php new file mode 100644 index 0000000..9e02af6 --- /dev/null +++ b/app/Services/Reports/Export/ReportExcelExport.php @@ -0,0 +1,62 @@ +metaRows()), + ]; + + foreach ($this->payload->sections as $section) { + $sheets[] = new ArraySheetExport($this->sheetTitle($section->title), $this->sectionRows($section)); + } + + return $sheets; + } + + private function metaRows(): array + { + $rows = [['Показатель', 'Значение']]; + + foreach ($this->payload->meta as $label => $value) { + $rows[] = [$label, $value]; + } + + return $rows; + } + + private function sectionRows(ReportSection $section): array + { + $rows = [array_values($section->columns)]; + + if (empty($section->rows)) { + $rows[] = ['Нет данных за выбранный период']; + } + + foreach ($section->rows as $row) { + $line = []; + foreach (array_keys($section->columns) as $key) { + $line[] = $row[$key] ?? ''; + } + $rows[] = $line; + } + + return $rows; + } + + private function sheetTitle(string $title): string + { + // Excel ограничивает название листа 31 символом и запрещает символы \/?*[]: + return mb_substr(preg_replace('/[\\\\\/\?\*\[\]:]/', ' ', $title), 0, 31); + } +} diff --git a/app/Services/Reports/Export/ReportPdfExport.php b/app/Services/Reports/Export/ReportPdfExport.php new file mode 100644 index 0000000..8c69294 --- /dev/null +++ b/app/Services/Reports/Export/ReportPdfExport.php @@ -0,0 +1,16 @@ + $payload]) + ->setPaper('a4', 'portrait'); + } +} diff --git a/app/Services/Reports/ReportPayload.php b/app/Services/Reports/ReportPayload.php new file mode 100644 index 0000000..a0d4eb6 --- /dev/null +++ b/app/Services/Reports/ReportPayload.php @@ -0,0 +1,16 @@ + $meta пары "показатель => значение" для шапки отчёта + * @param ReportSection[] $sections + */ + public function __construct( + public string $title, + public array $meta, + public array $sections, + ) {} +} diff --git a/app/Services/Reports/ReportRegistry.php b/app/Services/Reports/ReportRegistry.php new file mode 100644 index 0000000..f2ba795 --- /dev/null +++ b/app/Services/Reports/ReportRegistry.php @@ -0,0 +1,66 @@ +sources), + new HeadNurseReport($this->sources), + ]; + + foreach (ReportTemplate::all() as $template) { + $definitions[] = new TemplateReportDefinition($template, $this->sources); + } + + return $definitions; + } + + /** @return ReportDefinition[] */ + public function availableFor(User $user): array + { + return array_values(array_filter( + $this->all(), + fn (ReportDefinition $definition) => $this->isVisible($definition, $user) + )); + } + + public function find(string $code, User $user): ?ReportDefinition + { + foreach ($this->availableFor($user) as $definition) { + if ($definition->code() === $code) { + return $definition; + } + } + + return null; + } + + private function isVisible(ReportDefinition $definition, User $user): bool + { + $permissions = $definition->requiredPermissions(); + + if (empty($permissions)) { + return $user->currentRoleCan('report.view') || $user->currentRoleCan('nurse.report.view'); + } + + foreach ($permissions as $permission) { + if ($user->currentRoleCan($permission)) { + return true; + } + } + + return false; + } +} diff --git a/app/Services/Reports/ReportSection.php b/app/Services/Reports/ReportSection.php new file mode 100644 index 0000000..a290946 --- /dev/null +++ b/app/Services/Reports/ReportSection.php @@ -0,0 +1,16 @@ + $columns ключ колонки => подпись + * @param array> $rows строки данных, ключи соответствуют ключам $columns + */ + public function __construct( + public string $title, + public array $columns, + public array $rows, + ) {} +} diff --git a/app/Services/Reports/ReportSource.php b/app/Services/Reports/ReportSource.php new file mode 100644 index 0000000..c2dd6be --- /dev/null +++ b/app/Services/Reports/ReportSource.php @@ -0,0 +1,63 @@ + $columns ключ колонки => подпись (все колонки, доступные для этого источника) + * @param array}> $filterableFields allow-list полей, по которым можно фильтровать в конструкторе шаблонов + * @param Closure(Department,DateRange,array,array):array> $resolver + */ + public function __construct( + public string $key, + public string $label, + public array $columns, + public array $filterableFields, + private Closure $resolver, + ) {} + + /** + * @param array $columns ключи выбранных колонок (по умолчанию — все) + * @param array $filters + * @return array> + */ + public function rows(Department $department, DateRange $dateRange, ?array $columns = null, array $filters = []): array + { + $columns ??= array_keys($this->columns); + + return ($this->resolver)($department, $dateRange, $columns, $filters); + } + + public function toSection( + Department $department, + DateRange $dateRange, + ?array $columns = null, + array $filters = [], + ?string $title = null, + ): ReportSection { + $columns ??= array_keys($this->columns); + + $columnDefs = []; + foreach ($columns as $key) { + if (isset($this->columns[$key])) { + $columnDefs[$key] = $this->columns[$key]; + } + } + + if (empty($columnDefs)) { + $columnDefs = $this->columns; + $columns = array_keys($this->columns); + } + + return new ReportSection( + $title ?? $this->label, + $columnDefs, + $this->rows($department, $dateRange, $columns, $filters), + ); + } +} diff --git a/app/Services/Reports/ReportSourceRegistry.php b/app/Services/Reports/ReportSourceRegistry.php new file mode 100644 index 0000000..3c84dea --- /dev/null +++ b/app/Services/Reports/ReportSourceRegistry.php @@ -0,0 +1,342 @@ + 'Планово', 2 => 'Экстренно']; + + /** @var array */ + private array $sources; + + public function __construct() + { + $this->sources = [ + 'duty_metrics' => $this->dutyMetricsSource(), + 'duty_patients' => $this->dutyPatientsSource(), + 'nurse_patients' => $this->nursePatientsSource(), + 'unwanted_events' => $this->unwantedEventsSource(), + 'observable_patients' => $this->observablePatientsSource(), + ]; + } + + /** @return array */ + public function all(): array + { + return $this->sources; + } + + public function get(string $key): ReportSource + { + if (! isset($this->sources[$key])) { + throw new InvalidArgumentException("Неизвестный источник отчёта: {$key}"); + } + + return $this->sources[$key]; + } + + /** + * Сданные дежурные отчёты отделения, пересекающиеся с периодом. + */ + public function dutyReports(Department $department, DateRange $dateRange): Collection + { + return ReportDuty::where('rf_department_id', $department->department_id) + ->withinPeriod($dateRange->startSql(), $dateRange->endSql()) + ->onlySubmitted() + ->orderBy('period_end') + ->get(); + } + + /** + * Сданные отчёты (журналы) медсестры отделения, пересекающиеся с периодом. + */ + public function nurseReports(Department $department, DateRange $dateRange): Collection + { + return ReportNurse::where('rf_department_id', $department->department_id) + ->where('status_id', 2) + ->where('period_end', '>=', $dateRange->startSql()) + ->where('period_start', '<=', $dateRange->endSql()) + ->orderBy('period_end') + ->get(); + } + + private function dutyMetricsSource(): ReportSource + { + $columns = [ + 'period' => 'Период', + 'beds' => 'Коек', + 'recipient_plan' => 'Поступило плановых', + 'recipient_emergency' => 'Поступило экстренных', + 'discharged' => 'Выписано', + 'transferred' => 'Переведено', + 'deceased' => 'Умерло', + 'occupancy_percent' => 'Занятость, %', + 'avg_bed_days' => 'Ср. койко-день', + 'lethality_percent' => 'Летальность, %', + 'surgery_plan' => 'Операции плановые', + 'surgery_emergency' => 'Операции экстренные', + 'staff_count' => 'Мед. персонал', + ]; + + $metricMap = [ + 'beds' => MetrikaConfig::BEDS, + 'recipient_plan' => MetrikaConfig::PLAN, + 'recipient_emergency' => MetrikaConfig::EMERGENCY, + 'discharged' => MetrikaConfig::DISCHARGED, + 'transferred' => MetrikaConfig::TRANSFERRED, + 'deceased' => MetrikaConfig::DECEASED, + 'occupancy_percent' => MetrikaConfig::DEPARTMENT_LOADED, + 'avg_bed_days' => MetrikaConfig::AVERAGE_BED_DAYS, + 'lethality_percent' => MetrikaConfig::LETHALITY, + 'surgery_plan' => MetrikaConfig::PLAN_SURGERY, + 'surgery_emergency' => MetrikaConfig::EMERGENCY_SURGERY, + 'staff_count' => MetrikaConfig::STAFF_COUNT, + ]; + + $resolver = function (Department $department, DateRange $dateRange, array $columns) use ($metricMap) { + $reports = $this->dutyReports($department, $dateRange); + + if ($reports->isEmpty()) { + return []; + } + + $values = DutyReportMetricResult::whereIn('rf_report_id', $reports->pluck('id')) + ->get(['rf_report_id', 'rf_metrika_item_id', 'value']) + ->groupBy('rf_report_id'); + + $rows = []; + foreach ($reports as $report) { + $byMetric = ($values->get($report->id) ?? collect())->pluck('value', 'rf_metrika_item_id'); + + $row = []; + foreach ($columns as $key) { + if ($key === 'period') { + $row[$key] = $report->period_start?->format('d.m.Y H:i').' — '.$report->period_end?->format('d.m.Y H:i'); + + continue; + } + + $metricId = $metricMap[$key] ?? null; + $row[$key] = $metricId !== null ? ($byMetric->get($metricId) ?? 0) : null; + } + + $rows[] = $row; + } + + return $rows; + }; + + return new ReportSource('duty_metrics', 'Показатели смены (дежурный врач)', $columns, [], $resolver); + } + + private function patientColumns(): array + { + return [ + 'full_name' => 'ФИО', + 'birth_date' => 'Дата рождения', + 'medical_card_number' => '№ карты', + 'recipient_date' => 'Дата поступления', + 'extract_date' => 'Дата выбытия', + 'diagnosis_code' => 'Код диагноза', + 'diagnosis_name' => 'Диагноз', + 'urgency' => 'Срочность', + 'outcome' => 'Исход', + ]; + } + + private function patientFilterableFields(): array + { + return [ + 'urgency_id' => ['label' => 'Срочность', 'options' => self::URGENCY_OPTIONS], + 'visit_result_id' => ['label' => 'Код исхода', 'options' => null], + ]; + } + + private function dutyPatientsSource(): ReportSource + { + $columns = $this->patientColumns(); + $filterable = $this->patientFilterableFields(); + + $resolver = function (Department $department, DateRange $dateRange, array $columns, array $filters) { + $reportIds = $this->dutyReports($department, $dateRange)->pluck('id'); + + if ($reportIds->isEmpty()) { + return []; + } + + $query = ReportDutyPatient::whereIn('report_duty_id', $reportIds)->with('latestMigration'); + $this->applyEqualityFilters($query, $filters, ['urgency_id', 'visit_result_id']); + + return $query->get()->map(fn ($patient) => $this->mapPatientRow($patient, $columns))->all(); + }; + + return new ReportSource('duty_patients', 'Пациенты (дежурный врач)', $columns, $filterable, $resolver); + } + + private function nursePatientsSource(): ReportSource + { + $columns = $this->patientColumns(); + $filterable = $this->patientFilterableFields(); + + $resolver = function (Department $department, DateRange $dateRange, array $columns, array $filters) { + $reportIds = $this->nurseReports($department, $dateRange)->pluck('id'); + + if ($reportIds->isEmpty()) { + return []; + } + + $query = ReportNursePatient::whereIn('report_nurse_id', $reportIds)->with('latestMigration'); + $this->applyEqualityFilters($query, $filters, ['urgency_id', 'visit_result_id']); + + return $query->get()->map(fn ($patient) => $this->mapPatientRow($patient, $columns))->all(); + }; + + return new ReportSource('nurse_patients', 'Журнал пациентов (старшая медсестра)', $columns, $filterable, $resolver); + } + + private function unwantedEventsSource(): ReportSource + { + $columns = [ + 'title' => 'Событие', + 'comment' => 'Комментарий', + 'created_at' => 'Дата', + ]; + + $resolver = function (Department $department, DateRange $dateRange) { + $reportIds = $this->dutyReports($department, $dateRange)->pluck('id'); + + if ($reportIds->isEmpty()) { + return []; + } + + return DutyUnwantedEvent::whereIn('report_duty_id', $reportIds)->get() + ->map(fn ($event) => [ + 'title' => $event->title, + 'comment' => $event->comment, + 'created_at' => $event->created_at?->format('d.m.Y H:i'), + ])->all(); + }; + + return new ReportSource('unwanted_events', 'Нежелательные события', $columns, [], $resolver); + } + + private function observablePatientsSource(): ReportSource + { + $columns = [ + 'full_name' => 'ФИО', + 'birth_date' => 'Дата рождения', + 'observable_in' => 'Начало наблюдения', + 'observable_out' => 'Конец наблюдения', + 'observable_reason' => 'Причина', + ]; + + $resolver = function (Department $department, DateRange $dateRange) { + $reportIds = $this->dutyReports($department, $dateRange)->pluck('id'); + + if ($reportIds->isEmpty()) { + return []; + } + + return DB::table('observable_medical_histories as omh') + ->join('report_duty_patients as rdp', 'rdp.original_id', '=', 'omh.original_id') + ->whereIn('rdp.report_duty_id', $reportIds) + ->where('omh.observable_in', '>=', $dateRange->startSql()) + ->where('omh.observable_in', '<=', $dateRange->endSql()) + ->select('omh.full_name', 'omh.birth_date', 'omh.observable_in', 'omh.observable_out', 'omh.observable_reason') + ->distinct() + ->get() + ->map(fn ($row) => [ + 'full_name' => $row->full_name, + 'birth_date' => $this->formatDate($row->birth_date, 'd.m.Y'), + 'observable_in' => $this->formatDate($row->observable_in, 'd.m.Y H:i'), + 'observable_out' => $this->formatDate($row->observable_out, 'd.m.Y H:i'), + 'observable_reason' => $row->observable_reason, + ])->all(); + }; + + return new ReportSource('observable_patients', 'Пациенты на контроле', $columns, [], $resolver); + } + + private function mapPatientRow(ReportDutyPatient|ReportNursePatient $patient, array $columns): array + { + $migration = $patient->latestMigration; + + $row = []; + foreach ($columns as $key) { + $row[$key] = match ($key) { + 'full_name' => $patient->full_name, + 'birth_date' => $patient->birth_date?->format('d.m.Y'), + 'medical_card_number' => $patient->medical_card_number, + 'recipient_date' => $patient->recipient_date?->format('d.m.Y H:i'), + 'extract_date' => $patient->extract_date?->format('d.m.Y H:i'), + 'diagnosis_code' => $migration?->diagnosis_code, + 'diagnosis_name' => $migration?->diagnosis_name, + 'urgency' => self::URGENCY_OPTIONS[(int) $patient->urgency_id] ?? '—', + 'outcome' => $this->outcomeLabel($patient), + default => null, + }; + } + + return $row; + } + + private function outcomeLabel(ReportDutyPatient|ReportNursePatient $patient): string + { + if ($patient->death_date) { + return 'Умер'; + } + + if (in_array((int) $patient->visit_result_id, [4, 14], true)) { + return 'Переведён'; + } + + if ($patient->extract_date) { + return 'Выписан'; + } + + return 'В отделении'; + } + + /** + * @param array $filters + * @param array $allowedFields allow-list полей источника — защита от произвольных имён колонок + */ + private function applyEqualityFilters(Builder $query, array $filters, array $allowedFields): void + { + foreach ($filters as $filter) { + $field = $filter['field'] ?? null; + $value = $filter['value'] ?? null; + + if ($field === null || $value === null || $value === '' || ! in_array($field, $allowedFields, true)) { + continue; + } + + $query->where($field, $value); + } + } + + private function formatDate(?string $value, string $format): ?string + { + return $value ? Carbon::parse($value)->format($format) : null; + } +} diff --git a/app/Services/Reports/TemplateReportDefinition.php b/app/Services/Reports/TemplateReportDefinition.php new file mode 100644 index 0000000..383d8ee --- /dev/null +++ b/app/Services/Reports/TemplateReportDefinition.php @@ -0,0 +1,63 @@ +template->id; + } + + public function label(): string + { + return $this->template->name; + } + + public function requiredPermissions(): array + { + return $this->template->required_permissions ?? []; + } + + public function build(Department $department, DateRange $dateRange): ReportPayload + { + $sections = []; + + foreach ($this->template->sections ?? [] as $sectionConfig) { + $source = $this->sources->get($sectionConfig['source']); + + $sections[] = $source->toSection( + $department, + $dateRange, + $sectionConfig['columns'] ?? null, + $sectionConfig['filters'] ?? [], + $sectionConfig['title'] ?? null, + ); + } + + return new ReportPayload( + title: $this->template->name, + meta: [ + 'Отделение' => $department->name_full ?? $department->name_short, + 'Период' => $dateRange->start()->format('d.m.Y H:i').' — '.$dateRange->end()->format('d.m.Y H:i'), + 'Сформирован' => now('Asia/Yakutsk')->format('d.m.Y H:i'), + ], + sections: $sections, + ); + } +} diff --git a/composer.json b/composer.json index f52ce92..bb8f06f 100644 --- a/composer.json +++ b/composer.json @@ -7,6 +7,7 @@ "license": "MIT", "require": { "php": "^8.2", + "barryvdh/laravel-dompdf": "^3.1", "inertiajs/inertia-laravel": "^2.0", "laravel/framework": "^12.0", "laravel/reverb": "^1.10", diff --git a/composer.lock b/composer.lock index b4c3d78..773f830 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,85 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "881498953f8dada90e156b144d811703", + "content-hash": "a82c5f712051f2c26c196e50b7057bf4", "packages": [ + { + "name": "barryvdh/laravel-dompdf", + "version": "v3.1.2", + "source": { + "type": "git", + "url": "https://github.com/barryvdh/laravel-dompdf.git", + "reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/ee3b72b19ccdf57d0243116ecb2b90261344dedc", + "reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc", + "shasum": "" + }, + "require": { + "dompdf/dompdf": "^3.0", + "illuminate/support": "^9|^10|^11|^12|^13.0", + "php": "^8.1" + }, + "require-dev": { + "larastan/larastan": "^2.7|^3.0", + "orchestra/testbench": "^7|^8|^9.16|^10|^11.0", + "phpro/grumphp": "^2.5", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "PDF": "Barryvdh\\DomPDF\\Facade\\Pdf", + "Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf" + }, + "providers": [ + "Barryvdh\\DomPDF\\ServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Barryvdh\\DomPDF\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Barry vd. Heuvel", + "email": "barryvdh@gmail.com" + } + ], + "description": "A DOMPDF Wrapper for Laravel", + "keywords": [ + "dompdf", + "laravel", + "pdf" + ], + "support": { + "issues": "https://github.com/barryvdh/laravel-dompdf/issues", + "source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.1.2" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2026-02-21T08:51:10+00:00" + }, { "name": "brick/math", "version": "0.14.8", @@ -663,6 +740,161 @@ ], "time": "2024-02-05T11:56:58+00:00" }, + { + "name": "dompdf/dompdf", + "version": "v3.1.5", + "source": { + "type": "git", + "url": "https://github.com/dompdf/dompdf.git", + "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "shasum": "" + }, + "require": { + "dompdf/php-font-lib": "^1.0.0", + "dompdf/php-svg-lib": "^1.0.0", + "ext-dom": "*", + "ext-mbstring": "*", + "masterminds/html5": "^2.0", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "ext-gd": "*", + "ext-json": "*", + "ext-zip": "*", + "mockery/mockery": "^1.3", + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.5", + "symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0" + }, + "suggest": { + "ext-gd": "Needed to process images", + "ext-gmagick": "Improves image processing performance", + "ext-imagick": "Improves image processing performance", + "ext-zlib": "Needed for pdf stream compression" + }, + "type": "library", + "autoload": { + "psr-4": { + "Dompdf\\": "src/" + }, + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1" + ], + "authors": [ + { + "name": "The Dompdf Community", + "homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md" + } + ], + "description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter", + "homepage": "https://github.com/dompdf/dompdf", + "support": { + "issues": "https://github.com/dompdf/dompdf/issues", + "source": "https://github.com/dompdf/dompdf/tree/v3.1.5" + }, + "time": "2026-03-03T13:54:37+00:00" + }, + { + "name": "dompdf/php-font-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-font-lib.git", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12" + }, + "type": "library", + "autoload": { + "psr-4": { + "FontLib\\": "src/FontLib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "The FontLib Community", + "homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse, export and make subsets of different types of font files.", + "homepage": "https://github.com/dompdf/php-font-lib", + "support": { + "issues": "https://github.com/dompdf/php-font-lib/issues", + "source": "https://github.com/dompdf/php-font-lib/tree/1.0.2" + }, + "time": "2026-01-20T14:10:26+00:00" + }, + { + "name": "dompdf/php-svg-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-svg-lib.git", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0", + "sabberworm/php-css-parser": "^8.4 || ^9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11" + }, + "type": "library", + "autoload": { + "psr-4": { + "Svg\\": "src/Svg" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "The SvgLib Community", + "homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse and export to PDF SVG files.", + "homepage": "https://github.com/dompdf/php-svg-lib", + "support": { + "issues": "https://github.com/dompdf/php-svg-lib/issues", + "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2" + }, + "time": "2026-01-02T16:01:13+00:00" + }, { "name": "dragonmantank/cron-expression", "version": "v3.6.0", @@ -2893,6 +3125,73 @@ }, "time": "2022-12-02T22:17:43+00:00" }, + { + "name": "masterminds/html5", + "version": "2.10.0", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "fcf91eb64359852f00d921887b219479b4f21251" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", + "reference": "fcf91eb64359852f00d921887b219479b4f21251", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" + }, + "time": "2025-07-25T09:04:22+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", @@ -4925,6 +5224,86 @@ ], "time": "2024-06-11T12:45:25+00:00" }, + { + "name": "sabberworm/php-css-parser", + "version": "v9.4.0", + "source": { + "type": "git", + "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", + "reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", + "reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "1.4.0", + "phpstan/extension-installer": "1.4.3", + "phpstan/phpstan": "1.12.33 || 2.2.2", + "phpstan/phpstan-phpunit": "1.4.2 || 2.0.16", + "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.11", + "phpunit/phpunit": "8.5.52", + "rawr/phpunit-data-provider": "3.3.1", + "rector/rector": "1.2.10 || 2.4.6", + "rector/type-perfect": "1.0.0 || 2.1.3", + "squizlabs/php_codesniffer": "4.0.1", + "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.3" + }, + "suggest": { + "ext-mbstring": "for parsing UTF-8 CSS" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.5.x-dev" + } + }, + "autoload": { + "files": [ + "src/Rule/Rule.php", + "src/RuleSet/RuleContainer.php" + ], + "psr-4": { + "Sabberworm\\CSS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Raphael Schweikert" + }, + { + "name": "Oliver Klee", + "email": "github@oliverklee.de" + }, + { + "name": "Jake Hotson", + "email": "jake.github@qzdesign.co.uk" + } + ], + "description": "Parser for CSS Files written in PHP", + "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser", + "keywords": [ + "css", + "parser", + "stylesheet" + ], + "support": { + "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.4.0" + }, + "time": "2026-06-18T15:10:53+00:00" + }, { "name": "spatie/laravel-package-tools", "version": "1.93.0", @@ -7659,6 +8038,149 @@ ], "time": "2026-03-30T13:44:50+00:00" }, + { + "name": "thecodingmachine/safe", + "version": "v3.4.0", + "source": { + "type": "git", + "url": "https://github.com/thecodingmachine/safe.git", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10", + "squizlabs/php_codesniffer": "^3.2" + }, + "type": "library", + "autoload": { + "files": [ + "lib/special_cases.php", + "generated/apache.php", + "generated/apcu.php", + "generated/array.php", + "generated/bzip2.php", + "generated/calendar.php", + "generated/classobj.php", + "generated/com.php", + "generated/cubrid.php", + "generated/curl.php", + "generated/datetime.php", + "generated/dir.php", + "generated/eio.php", + "generated/errorfunc.php", + "generated/exec.php", + "generated/fileinfo.php", + "generated/filesystem.php", + "generated/filter.php", + "generated/fpm.php", + "generated/ftp.php", + "generated/funchand.php", + "generated/gettext.php", + "generated/gmp.php", + "generated/gnupg.php", + "generated/hash.php", + "generated/ibase.php", + "generated/ibmDb2.php", + "generated/iconv.php", + "generated/image.php", + "generated/imap.php", + "generated/info.php", + "generated/inotify.php", + "generated/json.php", + "generated/ldap.php", + "generated/libxml.php", + "generated/lzf.php", + "generated/mailparse.php", + "generated/mbstring.php", + "generated/misc.php", + "generated/mysql.php", + "generated/mysqli.php", + "generated/network.php", + "generated/oci8.php", + "generated/opcache.php", + "generated/openssl.php", + "generated/outcontrol.php", + "generated/pcntl.php", + "generated/pcre.php", + "generated/pgsql.php", + "generated/posix.php", + "generated/ps.php", + "generated/pspell.php", + "generated/readline.php", + "generated/rnp.php", + "generated/rpminfo.php", + "generated/rrd.php", + "generated/sem.php", + "generated/session.php", + "generated/shmop.php", + "generated/sockets.php", + "generated/sodium.php", + "generated/solr.php", + "generated/spl.php", + "generated/sqlsrv.php", + "generated/ssdeep.php", + "generated/ssh2.php", + "generated/stream.php", + "generated/strings.php", + "generated/swoole.php", + "generated/uodbc.php", + "generated/uopz.php", + "generated/url.php", + "generated/var.php", + "generated/xdiff.php", + "generated/xml.php", + "generated/xmlrpc.php", + "generated/yaml.php", + "generated/yaz.php", + "generated/zip.php", + "generated/zlib.php" + ], + "classmap": [ + "lib/DateTime.php", + "lib/DateTimeImmutable.php", + "lib/Exceptions/", + "generated/Exceptions/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHP core functions that throw exceptions instead of returning FALSE on error", + "support": { + "issues": "https://github.com/thecodingmachine/safe/issues", + "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0" + }, + "funding": [ + { + "url": "https://github.com/OskarStark", + "type": "github" + }, + { + "url": "https://github.com/shish", + "type": "github" + }, + { + "url": "https://github.com/silasjoisten", + "type": "github" + }, + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2026-02-04T18:08:13+00:00" + }, { "name": "tijsverkoyen/css-to-inline-styles", "version": "v2.4.0", diff --git a/config/dompdf.php b/config/dompdf.php new file mode 100644 index 0000000..36f6049 --- /dev/null +++ b/config/dompdf.php @@ -0,0 +1,301 @@ + false, // Throw an Exception on warnings from dompdf + + 'public_path' => null, // Override the public path if needed + + /* + * Dejavu Sans font is missing glyphs for converted entities, turn it off if you need to show € and £. + */ + 'convert_entities' => true, + + 'options' => [ + /** + * The location of the DOMPDF font directory + * + * The location of the directory where DOMPDF will store fonts and font metrics + * Note: This directory must exist and be writable by the webserver process. + * *Please note the trailing slash.* + * + * Notes regarding fonts: + * Additional .afm font metrics can be added by executing load_font.php from command line. + * + * Only the original "Base 14 fonts" are present on all pdf viewers. Additional fonts must + * be embedded in the pdf file or the PDF may not display correctly. This can significantly + * increase file size unless font subsetting is enabled. Before embedding a font please + * review your rights under the font license. + * + * Any font specification in the source HTML is translated to the closest font available + * in the font directory. + * + * The pdf standard "Base 14 fonts" are: + * Courier, Courier-Bold, Courier-BoldOblique, Courier-Oblique, + * Helvetica, Helvetica-Bold, Helvetica-BoldOblique, Helvetica-Oblique, + * Times-Roman, Times-Bold, Times-BoldItalic, Times-Italic, + * Symbol, ZapfDingbats. + */ + 'font_dir' => storage_path('fonts'), // advised by dompdf (https://github.com/dompdf/dompdf/pull/782) + + /** + * The location of the DOMPDF font cache directory + * + * This directory contains the cached font metrics for the fonts used by DOMPDF. + * This directory can be the same as DOMPDF_FONT_DIR + * + * Note: This directory must exist and be writable by the webserver process. + */ + 'font_cache' => storage_path('fonts'), + + /** + * The location of a temporary directory. + * + * The directory specified must be writeable by the webserver process. + * The temporary directory is required to download remote images and when + * using the PDFLib back end. + */ + 'temp_dir' => sys_get_temp_dir(), + + /** + * ==== IMPORTANT ==== + * + * dompdf's "chroot": Prevents dompdf from accessing system files or other + * files on the webserver. All local files opened by dompdf must be in a + * subdirectory of this directory. DO NOT set it to '/' since this could + * allow an attacker to use dompdf to read any files on the server. This + * should be an absolute path. + * This is only checked on command line call by dompdf.php, but not by + * direct class use like: + * $dompdf = new DOMPDF(); $dompdf->load_html($htmldata); $dompdf->render(); $pdfdata = $dompdf->output(); + */ + 'chroot' => realpath(base_path()), + + /** + * Protocol whitelist + * + * Protocols and PHP wrappers allowed in URIs, and the validation rules + * that determine if a resouce may be loaded. Full support is not guaranteed + * for the protocols/wrappers specified + * by this array. + * + * @var array + */ + 'allowed_protocols' => [ + 'data://' => ['rules' => []], + 'file://' => ['rules' => []], + 'http://' => ['rules' => []], + 'https://' => ['rules' => []], + ], + + /** + * Operational artifact (log files, temporary files) path validation + */ + 'artifactPathValidation' => null, + + /** + * @var string + */ + 'log_output_file' => null, + + /** + * Whether to enable font subsetting or not. + */ + 'enable_font_subsetting' => false, + + /** + * The PDF rendering backend to use + * + * Valid settings are 'PDFLib', 'CPDF' (the bundled R&OS PDF class), 'GD' and + * 'auto'. 'auto' will look for PDFLib and use it if found, or if not it will + * fall back on CPDF. 'GD' renders PDFs to graphic files. + * {@link * Canvas_Factory} ultimately determines which rendering class to + * instantiate based on this setting. + * + * Both PDFLib & CPDF rendering backends provide sufficient rendering + * capabilities for dompdf, however additional features (e.g. object, + * image and font support, etc.) differ between backends. Please see + * {@link PDFLib_Adapter} for more information on the PDFLib backend + * and {@link CPDF_Adapter} and lib/class.pdf.php for more information + * on CPDF. Also see the documentation for each backend at the links + * below. + * + * The GD rendering backend is a little different than PDFLib and + * CPDF. Several features of CPDF and PDFLib are not supported or do + * not make any sense when creating image files. For example, + * multiple pages are not supported, nor are PDF 'objects'. Have a + * look at {@link GD_Adapter} for more information. GD support is + * experimental, so use it at your own risk. + * + * @link http://www.pdflib.com + * @link http://www.ros.co.nz/pdf + * @link http://www.php.net/image + */ + 'pdf_backend' => 'CPDF', + + /** + * html target media view which should be rendered into pdf. + * List of types and parsing rules for future extensions: + * http://www.w3.org/TR/REC-html40/types.html + * screen, tty, tv, projection, handheld, print, braille, aural, all + * Note: aural is deprecated in CSS 2.1 because it is replaced by speech in CSS 3. + * Note, even though the generated pdf file is intended for print output, + * the desired content might be different (e.g. screen or projection view of html file). + * Therefore allow specification of content here. + */ + 'default_media_type' => 'screen', + + /** + * The default paper size. + * + * North America standard is "letter"; other countries generally "a4" + * + * @see CPDF_Adapter::PAPER_SIZES for valid sizes ('letter', 'legal', 'A4', etc.) + */ + 'default_paper_size' => 'a4', + + /** + * The default paper orientation. + * + * The orientation of the page (portrait or landscape). + * + * @var string + */ + 'default_paper_orientation' => 'portrait', + + /** + * The default font family + * + * Used if no suitable fonts can be found. This must exist in the font folder. + * + * @var string + */ + 'default_font' => 'DejaVu Sans', + + /** + * Image DPI setting + * + * This setting determines the default DPI setting for images and fonts. The + * DPI may be overridden for inline images by explictly setting the + * image's width & height style attributes (i.e. if the image's native + * width is 600 pixels and you specify the image's width as 72 points, + * the image will have a DPI of 600 in the rendered PDF. The DPI of + * background images can not be overridden and is controlled entirely + * via this parameter. + * + * For the purposes of DOMPDF, pixels per inch (PPI) = dots per inch (DPI). + * If a size in html is given as px (or without unit as image size), + * this tells the corresponding size in pt. + * This adjusts the relative sizes to be similar to the rendering of the + * html page in a reference browser. + * + * In pdf, always 1 pt = 1/72 inch + * + * Rendering resolution of various browsers in px per inch: + * Windows Firefox and Internet Explorer: + * SystemControl->Display properties->FontResolution: Default:96, largefonts:120, custom:? + * Linux Firefox: + * about:config *resolution: Default:96 + * (xorg screen dimension in mm and Desktop font dpi settings are ignored) + * + * Take care about extra font/image zoom factor of browser. + * + * In images, size in pixel attribute, img css style, are overriding + * the real image dimension in px for rendering. + * + * @var int + */ + 'dpi' => 96, + + /** + * Enable embedded PHP + * + * If this setting is set to true then DOMPDF will automatically evaluate embedded PHP contained + * within tags. + * + * ==== IMPORTANT ==== Enabling this for documents you do not trust (e.g. arbitrary remote html pages) + * is a security risk. + * Embedded scripts are run with the same level of system access available to dompdf. + * Set this option to false (recommended) if you wish to process untrusted documents. + * This setting may increase the risk of system exploit. + * Do not change this settings without understanding the consequences. + * Additional documentation is available on the dompdf wiki at: + * https://github.com/dompdf/dompdf/wiki + * + * @var bool + */ + 'enable_php' => false, + + /** + * Enable inline JavaScript + * + * If this setting is set to true then DOMPDF will automatically insert JavaScript code contained + * within tags as written into the PDF. + * NOTE: This is PDF-based JavaScript to be executed by the PDF viewer, + * not browser-based JavaScript executed by Dompdf. + * + * @var bool + */ + 'enable_javascript' => true, + + /** + * Enable remote file access + * + * If this setting is set to true, DOMPDF will access remote sites for + * images and CSS files as required. + * + * ==== IMPORTANT ==== + * This can be a security risk, in particular in combination with isPhpEnabled and + * allowing remote html code to be passed to $dompdf = new DOMPDF(); $dompdf->load_html(...); + * This allows anonymous users to download legally doubtful internet content which on + * tracing back appears to being downloaded by your server, or allows malicious php code + * in remote html pages to be executed by your server with your account privileges. + * + * This setting may increase the risk of system exploit. Do not change + * this settings without understanding the consequences. Additional + * documentation is available on the dompdf wiki at: + * https://github.com/dompdf/dompdf/wiki + * + * @var bool + */ + 'enable_remote' => false, + + /** + * List of allowed remote hosts + * + * Each value of the array must be a valid hostname. + * + * This will be used to filter which resources can be loaded in combination with + * isRemoteEnabled. If enable_remote is FALSE, then this will have no effect. + * + * Leave to NULL to allow any remote host. + * + * @var array|null + */ + 'allowed_remote_hosts' => null, + + /** + * A ratio applied to the fonts height to be more like browsers' line height + */ + 'font_height_ratio' => 1.1, + + /** + * Use the HTML5 Lib parser + * + * @deprecated This feature is now always on in dompdf 2.x + * + * @var bool + */ + 'enable_html5_parser' => true, + ], + +]; diff --git a/database/migrations/2026_06_21_224657_create_report_templates_table.php b/database/migrations/2026_06_21_224657_create_report_templates_table.php new file mode 100644 index 0000000..348a9fc --- /dev/null +++ b/database/migrations/2026_06_21_224657_create_report_templates_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('name'); + // [{source, title, columns: [...], filters: [{field,value}, ...]}, ...] — одна или несколько секций на отчёт + $table->json('sections'); + // ['report.view', 'nurse.report.view'] — пусто/null = виден всем с доступом к отчётам + $table->json('required_permissions')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('report_templates'); + } +}; diff --git a/resources/js/Pages/Admin/Index.vue b/resources/js/Pages/Admin/Index.vue index c0e04cf..f16ffe9 100644 --- a/resources/js/Pages/Admin/Index.vue +++ b/resources/js/Pages/Admin/Index.vue @@ -3,7 +3,7 @@ import AppLayout from "../../Layouts/AppLayout.vue" import { useAuthStore } from "../../Stores/auth.js" import { NEl, NFlex, NText, NTag, NAvatar } from 'naive-ui' import ActionTile from "../../Components/ActionTile.vue" -import { TbUsers, TbChartBar, TbLayoutDashboard, TbRefresh } from "vue-icons-plus/tb" +import { TbUsers, TbChartBar, TbLayoutDashboard, TbRefresh, TbReportAnalytics } from "vue-icons-plus/tb" import { Link } from "@inertiajs/vue3" import { computed } from "vue" import { useThemeVars } from "naive-ui" @@ -85,6 +85,13 @@ const dividerColor = computed(() => themeVars.value.dividerColor) :tag="Link" href="/admin/replication" /> + diff --git a/resources/js/Pages/Admin/ReportTemplates/Components/SectionEditor.vue b/resources/js/Pages/Admin/ReportTemplates/Components/SectionEditor.vue new file mode 100644 index 0000000..6d28cbe --- /dev/null +++ b/resources/js/Pages/Admin/ReportTemplates/Components/SectionEditor.vue @@ -0,0 +1,92 @@ + + + diff --git a/resources/js/Pages/Admin/ReportTemplates/Form.vue b/resources/js/Pages/Admin/ReportTemplates/Form.vue new file mode 100644 index 0000000..9e1da31 --- /dev/null +++ b/resources/js/Pages/Admin/ReportTemplates/Form.vue @@ -0,0 +1,135 @@ + + + diff --git a/resources/js/Pages/Admin/ReportTemplates/Index.vue b/resources/js/Pages/Admin/ReportTemplates/Index.vue new file mode 100644 index 0000000..2c53f14 --- /dev/null +++ b/resources/js/Pages/Admin/ReportTemplates/Index.vue @@ -0,0 +1,135 @@ + + + diff --git a/resources/js/Pages/Index.vue b/resources/js/Pages/Index.vue index bd13ed9..c1cd9c5 100644 --- a/resources/js/Pages/Index.vue +++ b/resources/js/Pages/Index.vue @@ -7,7 +7,7 @@ import { computed, ref } from "vue" import { format, getHours } from "date-fns" import { ru } from "date-fns/locale" import { useNow } from "@vueuse/core" -import { TbArticle, TbChartTreemap, TbDoorExit, TbUserCog, TbStethoscope } from "vue-icons-plus/tb" +import { TbArticle, TbChartTreemap, TbDoorExit, TbUserCog, TbStethoscope, TbReportMedical } from "vue-icons-plus/tb" import SelectUserModal from "./Report/Components/SelectUserModal.vue" import { Link, router } from "@inertiajs/vue3" import { useServerTime } from "../Composables/useServerTime.js" @@ -154,6 +154,14 @@ const dividerColor = computed(() => themeVars.value.dividerColor) :tag="Link" href="/statistic" /> + +import { computed, ref } from 'vue' +import { NSelect, NButton, NDataTable, NEmpty, NFlex, NText, NTabs, NTabPane, NBadge, NDivider, NGrid, NGi } from 'naive-ui' +import { router, Link } from '@inertiajs/vue3' +import AppLayout from '../../Layouts/AppLayout.vue' +import AppContainer from '../../Components/AppContainer.vue' +import SectionCard from '../../Components/SectionCard.vue' +import PageBanner from '../../Components/PageBanner.vue' +import ActionTile from '../../Components/ActionTile.vue' +import DatePickerQuery from '../../Components/DatePickerQuery.vue' +import { useAuthStore } from '../../Stores/auth.js' +import { TbReportMedical, TbBuildingHospital, TbSettings, TbArrowLeft } from 'vue-icons-plus/tb' +import { TbFileSpreadsheet, TbFileTypePdf } from 'vue-icons-plus/tb' + +const authStore = useAuthStore() +const canManageTemplates = computed(() => authStore.isAdmin || authStore.isChiefDoctor || authStore.isDeputyChief) + +const props = defineProps({ + reportTypes: { type: Array, default: () => [] }, + departments: { type: Array, default: () => [] }, + selectedType: { type: String, default: null }, + selectedDepartmentId: { type: [Number, String], default: null }, + isHeadOrAdmin: { type: Boolean, default: false }, + date: { type: [Number, Array], default: () => [] }, + payload: { type: Object, default: null }, +}) + +// Локальный выбор отчёта до того, как параметры подтверждены и отчёт сформирован на сервере. +const pendingType = ref(props.selectedType) +const dateModel = ref(props.date) +const departmentModel = ref(props.selectedDepartmentId) +const downloading = ref(null) + +// list — выбираем отчёт из каталога; params — задаём период/отделение перед формированием; result — отчёт построен +const stage = computed(() => { + if (props.payload) return 'result' + if (pendingType.value) return 'params' + return 'list' +}) + +const typeOptions = computed(() => props.reportTypes.map(t => ({ label: t.label, value: t.code }))) +const departmentOptions = computed(() => props.departments.map(d => ({ label: d.name, value: d.id }))) + +const selectReport = (code) => { + pendingType.value = code +} + +const backToList = () => { + pendingType.value = null + if (props.payload) { + router.get('/reports', {}, { preserveState: false }) + } +} + +const generate = () => { + router.get('/reports', { + type: pendingType.value, + departmentId: departmentModel.value, + startAt: dateModel.value?.[0], + endAt: dateModel.value?.[1], + }) +} + +const onTypeChange = (value) => { + router.reload({ data: { type: value } }) +} + +const onDepartmentChange = (value) => { + router.reload({ data: { departmentId: value } }) +} + +const metaLine = computed(() => Object.values(props.payload?.meta ?? {}).join(' · ')) + +// Раздел с одной строкой коротких значений (показатели смены) удобнее показать +// как карточки KPI, а не как таблицу из одной строки. +const isKpiSection = (section) => { + if (section.rows.length !== 1) return false + const row = section.rows[0] + return Object.keys(section.columns).every((key) => String(row[key] ?? '').length <= 40) +} + +const kpiSections = computed(() => props.payload?.sections.filter(isKpiSection) ?? []) +const tableSections = computed(() => props.payload?.sections.filter((s) => !isKpiSection(s)) ?? []) + +const kpiCards = computed(() => kpiSections.value.flatMap((section) => { + const row = section.rows[0] + return Object.entries(section.columns).map(([key, label]) => ({ + sectionTitle: section.title, + label, + value: row[key] ?? '—', + })) +})) + +const sectionColumns = (section) => Object.entries(section.columns ?? {}).map(([key, title]) => ({ key, title })) + +const buildExportUrl = (kind) => { + const params = new URLSearchParams(window.location.search) + return `/reports/export/${kind}?${params.toString()}` +} + +const download = async (kind) => { + downloading.value = kind + try { + const response = await fetch(buildExportUrl(kind), { method: 'GET' }) + + if (!response.ok) { + throw new Error('Ошибка загрузки файла') + } + + const blob = await response.blob() + const url = window.URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `${props.payload?.title || 'report'}.${kind === 'excel' ? 'xlsx' : 'pdf'}` + document.body.appendChild(a) + a.click() + window.URL.revokeObjectURL(url) + document.body.removeChild(a) + } catch { + // silent + } finally { + downloading.value = null + } +} + + + + + diff --git a/resources/views/reports/pdf.blade.php b/resources/views/reports/pdf.blade.php new file mode 100644 index 0000000..2f9c1a3 --- /dev/null +++ b/resources/views/reports/pdf.blade.php @@ -0,0 +1,85 @@ + + + + + {{ $payload->title }} + + + +

{{ $payload->title }}

+ + + @foreach ($payload->meta as $label => $value) + + + + + @endforeach +
{{ $label }}{{ $value }}
+ + @foreach ($payload->sections as $section) +

{{ $section->title }}

+ + @if (empty($section->rows)) +

Нет данных за выбранный период.

+ @else + + + + @foreach ($section->columns as $label) + + @endforeach + + + + @foreach ($section->rows as $row) + + @foreach (array_keys($section->columns) as $key) + + @endforeach + + @endforeach + +
{{ $label }}
{{ $row[$key] ?? '' }}
+ @endif + @endforeach + + diff --git a/routes/web.php b/routes/web.php index daf8cee..39c963c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -60,6 +60,15 @@ Route::prefix('admin')->middleware(['auth'])->group(function () { Route::put('/schedules/{schedule}', [\App\Http\Controllers\Web\Admin\ReplicationController::class, 'updateSchedule']); Route::delete('/schedules/{schedule}', [\App\Http\Controllers\Web\Admin\ReplicationController::class, 'destroySchedule']); }); + + Route::prefix('report-templates')->group(function () { + Route::get('/', [\App\Http\Controllers\Web\Admin\ReportTemplateController::class, 'index']); + Route::get('/new', [\App\Http\Controllers\Web\Admin\ReportTemplateController::class, 'create']); + Route::post('/new', [\App\Http\Controllers\Web\Admin\ReportTemplateController::class, 'store']); + Route::get('/{template}', [\App\Http\Controllers\Web\Admin\ReportTemplateController::class, 'edit']); + Route::put('/{template}', [\App\Http\Controllers\Web\Admin\ReportTemplateController::class, 'update']); + Route::delete('/{template}', [\App\Http\Controllers\Web\Admin\ReportTemplateController::class, 'destroy']); + }); }); Route::prefix('statistic')->middleware(['auth'])->group(function () { @@ -68,6 +77,12 @@ Route::prefix('statistic')->middleware(['auth'])->group(function () { Route::get('/headquarters', [\App\Http\Controllers\Web\HeadquartersController::class, 'index']); }); +Route::prefix('reports')->middleware(['auth'])->group(function () { + Route::get('/', [\App\Http\Controllers\Web\ReportsController::class, 'index'])->name('reports.index'); + Route::get('/export/excel', [\App\Http\Controllers\Web\ReportsController::class, 'exportExcel'])->name('reports.export.excel'); + Route::get('/export/pdf', [\App\Http\Controllers\Web\ReportsController::class, 'exportPdf'])->name('reports.export.pdf'); +}); + Route::get('/logout', [\App\Http\Controllers\AuthController::class, 'logout']) ->middleware(['auth']) ->name('logout');