diff --git a/app/Http/Controllers/Web/ReportsController.php b/app/Http/Controllers/Web/ReportsController.php index 4ba39ad..2e1083f 100644 --- a/app/Http/Controllers/Web/ReportsController.php +++ b/app/Http/Controllers/Web/ReportsController.php @@ -3,13 +3,18 @@ namespace App\Http\Controllers\Web; use App\Http\Controllers\Controller; +use App\Models\CustomMeasure; use App\Models\Department; +use App\Models\MisLpuDoctor; +use App\Models\ReportDocument; use App\Models\User; +use App\Services\Analytics\AnalyticsQuery; +use App\Services\Analytics\DataSetRegistry; +use App\Services\Analytics\Export\AnalyticsExcelExport; +use App\Services\Analytics\Export\AnalyticsPdfExport; +use App\Services\Analytics\Measure; +use App\Services\Analytics\ReportPresetRegistry; use App\Services\DateRangeService; -use App\Services\Reports\Contracts\ReportDefinition; -use App\Services\Reports\Export\ReportExcelExport; -use App\Services\Reports\Export\ReportPdfExport; -use App\Services\Reports\ReportRegistry; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Inertia\Inertia; @@ -18,92 +23,359 @@ use Maatwebsite\Excel\Facades\Excel; class ReportsController extends Controller { public function __construct( - protected ReportRegistry $reportRegistry, + protected DataSetRegistry $datasets, + protected ReportPresetRegistry $presets, protected DateRangeService $dateRangeService, ) {} - public function index(Request $request) + /** Каталог: системные шаблоны + сохранённые отчёты филиала. */ + public function index() { $user = Auth::user(); - $available = $this->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, + $documents = ReportDocument::with('creator')->latest('updated_at')->get()->map(fn (ReportDocument $d) => [ + 'id' => $d->id, + 'name' => $d->name, + 'description' => $d->description, + 'datasetLabel' => $this->datasets->has($d->dataset) ? $this->datasets->get($d->dataset)->label() : $d->dataset, + 'author' => $d->creator?->name, + 'updatedAt' => $d->updated_at?->format('d.m.Y'), ]); + + return Inertia::render('Analytics/Index', [ + 'documents' => $documents, + 'presets' => $this->presets->all(), + 'categories' => $this->presets->categories(), + 'canManage' => $this->canManage($user), + ]); + } + + /** Редактор нового отчёта (с нуля или из пресета). */ + public function create(Request $request) + { + $user = Auth::user(); + abort_unless($this->canManage($user), 403); + + $preset = $request->query('preset') ? $this->presets->find($request->query('preset')) : null; + $config = $preset['config'] ?? $this->presets->find('blank')['config']; + + return $this->renderBuilder($request, $user, [ + 'document' => null, + 'name' => $preset && $preset['key'] !== 'blank' ? $preset['label'] : 'Новый отчёт', + 'description' => '', + 'config' => $config, + ]); + } + + /** Редактор существующего отчёта. */ + public function show(Request $request, ReportDocument $document) + { + $user = Auth::user(); + + return $this->renderBuilder($request, $user, [ + 'document' => ['id' => $document->id], + 'name' => $document->name, + 'description' => $document->description, + // dataset хранится отдельной колонкой — возвращаем его внутри config для редактора + 'config' => [...($document->config ?? []), 'dataset' => $document->dataset], + ]); + } + + /** Построение отчёта (живой предпросмотр) — JSON. */ + public function run(Request $request) + { + $user = Auth::user(); + $query = $this->buildQuery($request, $user); + + return response()->json($this->datasets->get($query->datasetKey)->run($query)->toArray()); + } + + public function store(Request $request) + { + $user = Auth::user(); + abort_unless($this->canManage($user), 403); + + $document = ReportDocument::create([ + ...$this->validateDocument($request), + 'created_by' => Auth::id(), + ]); + + return redirect("/reports/{$document->id}")->with('success', 'Отчёт сохранён'); + } + + public function update(Request $request, ReportDocument $document) + { + $user = Auth::user(); + abort_unless($this->canManage($user), 403); + + $document->update($this->validateDocument($request)); + + return back()->with('success', 'Отчёт сохранён'); + } + + public function destroy(ReportDocument $document) + { + abort_unless($this->canManage(Auth::user()), 403); + + $document->delete(); + + return redirect('/reports')->with('success', 'Отчёт удалён'); + } + + public function duplicate(ReportDocument $document) + { + abort_unless($this->canManage(Auth::user()), 403); + + $copy = $document->replicate(['created_by']); + $copy->name = $document->name.' (копия)'; + $copy->created_by = Auth::id(); + $copy->save(); + + return redirect("/reports/{$copy->id}")->with('success', 'Создана копия'); } public function exportExcel(Request $request) { - [$definition, $department, $dateRange] = $this->resolveExportContext($request); + $result = $this->buildResult($request); - $payload = $definition->build($department, $dateRange); - - return Excel::download(new ReportExcelExport($payload), $this->fileName($definition, 'xlsx')); + return Excel::download(new AnalyticsExcelExport($result['result'], $result['title']), $this->fileName($result['title'], 'xlsx')); } public function exportPdf(Request $request) { - [$definition, $department, $dateRange] = $this->resolveExportContext($request); + $result = $this->buildResult($request); - $payload = $definition->build($department, $dateRange); - - return ReportPdfExport::render($payload)->download($this->fileName($definition, 'pdf')); + return AnalyticsPdfExport::render($result['result'], $result['title'])->download($this->fileName($result['title'], 'pdf')); } - /** - * @return array{0: ReportDefinition, 1: Department, 2: \App\Services\DateRange} - */ - private function resolveExportContext(Request $request): array + // --- helpers --- + + private function renderBuilder(Request $request, User $user, array $payload): \Inertia\Response { - $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]; + return Inertia::render('Analytics/Builder', [ + ...$payload, + 'datasets' => $this->datasetsMetadata($department), + 'presets' => $this->presets->all(), + 'categories' => $this->presets->categories(), + 'departments' => collect($user->misDepartments)->map(fn (Department $d) => [ + 'id' => $d->department_id, + 'name' => $d->name_full ?? $d->name_short, + ])->values()->all(), + 'selectedDepartmentId' => $department->department_id, + 'isHeadOrAdmin' => $user->isSeniorStaff(), + 'canManage' => $this->canManage($user), + 'date' => [$dateRange->start()->getTimestampMs(), $dateRange->end()->getTimestampMs()], + ]); + } + + /** + * Метаданные датасетов: + пользовательские показатели и динамические опции + * фильтров (например, список врачей отделения). + * + * @return array> + */ + private function datasetsMetadata(Department $department): array + { + $meta = $this->datasets->metadata(); + $options = []; // ленивый кэш динамических опций по источнику + + foreach ($meta as &$dataset) { + foreach ($this->customMeasuresFor($dataset['key']) as $custom) { + $dataset['measures'][] = ['key' => $custom->key, 'label' => $custom->label, 'unit' => $custom->unit]; + } + + foreach ($dataset['filters'] as &$filter) { + $source = $filter['optionsSource'] ?? null; + if ($source === 'doctors' || $source === 'nurse_doctors') { + $options[$source] ??= $this->staffOptions($department, $source === 'nurse_doctors' ? 'nurse' : 'duty'); + $filter['options'] = $options[$source]; + } + } + unset($filter); + } + + return $meta; + } + + /** + * Врачи/медсёстры отделения из справочника МИС (по должностям в МИС-отделении). + * Список не зависит от того, проставлен ли врач в уже сданных сменах — он готов + * к работе сразу, фильтрация идёт по rf_lpudoctor_id = LPUDoctorID. + * + * @param 'duty'|'nurse' $kind + * @return array + */ + private function staffOptions(Department $department, string $kind): array + { + $misIds = array_filter([$department->rf_mis_department_id]); + + if ($misIds === []) { + return []; + } + + $prvdIds = $kind === 'nurse' ? [1567, 1629] : []; + $fallback = $kind === 'nurse' ? 'Медсестра' : 'Врач'; + + return MisLpuDoctor::active() + ->whereNotIn('LPUDoctorID', [0, 1]) + ->whereHas('prvds', function ($q) use ($misIds, $prvdIds) { + $q->whereIn('rf_DepartmentID', $misIds) + ->whereDate('D_END', '2222-01-01 00:00:00.000000') + ->when($prvdIds !== [], fn ($qq) => $qq->whereIn('rf_PRVDID', $prvdIds)); + }) + ->orderBy('FAM_V') + ->get(['LPUDoctorID', 'FAM_V', 'IM_V', 'OT_V']) + ->map(fn ($d) => [ + 'label' => trim("{$d->FAM_V} {$d->IM_V} {$d->OT_V}") ?: "{$fallback} #{$d->LPUDoctorID}", + 'value' => (int) $d->LPUDoctorID, + ]) + ->all(); + } + + private function buildQuery(Request $request, User $user): AnalyticsQuery + { + $datasetKey = (string) $request->input('dataset'); + abort_unless($this->datasets->has($datasetKey), 422); + + $dataset = $this->datasets->get($datasetKey); + $customMeasures = $this->customMeasuresFor($datasetKey); + $dimKeys = array_map(fn ($d) => $d->key, $dataset->dimensions()); + $measureKeys = [ + ...array_map(fn ($m) => $m->key, $dataset->measures()), + ...array_map(fn (Measure $m) => $m->key, $customMeasures), + ]; + $filterKeys = array_map(fn ($f) => $f->key, $dataset->filters()); + + $dimensions = array_values(array_intersect((array) $request->input('dimensions', []), $dimKeys)); + $measures = array_values(array_intersect((array) $request->input('measures', []), $measureKeys)); + + $filters = []; + foreach ((array) $request->input('filters', []) as $filter) { + if (isset($filter['key']) && in_array($filter['key'], $filterKeys, true)) { + $filters[] = ['key' => $filter['key'], 'value' => $filter['value'] ?? null]; + } + } + + $mode = $request->input('mode') === 'dynamics' ? 'dynamics' : 'period'; + $detalization = in_array($request->input('detalization'), ['day', 'week', 'month'], true) + ? $request->input('detalization') : 'month'; + + return new AnalyticsQuery( + datasetKey: $datasetKey, + dimensions: $dimensions, + measures: $measures, + filters: $filters, + mode: $mode, + detalization: $detalization, + department: $this->resolveDepartment($request, $user), + dateRange: $this->dateRangeService->getDateRangeFromRequest($request, $user), + chart: (array) $request->input('chart', []), + customMeasures: $customMeasures, + user: $user, + ); + } + + /** + * Пользовательские показатели датасета как derived-Measure (базовый показатель × коэффициент). + * + * @return Measure[] + */ + private function customMeasuresFor(string $datasetKey): array + { + if (! $this->datasets->has($datasetKey)) { + return []; + } + + $baseByKey = []; + foreach ($this->datasets->get($datasetKey)->measures() as $measure) { + $baseByKey[$measure->key] = $measure; + } + + $result = []; + foreach (CustomMeasure::where('dataset', $datasetKey)->get() as $custom) { + $base = $baseByKey[$custom->base_measure] ?? null; + if ($base === null) { + continue; + } + $factor = (float) ($custom->factor ?? 1); + $result[] = new Measure('custom_'.$custom->id, $custom->name, $custom->unit, "({$base->select}) * {$factor}"); + } + + return $result; + } + + public function storeMeasure(Request $request) + { + $user = Auth::user(); + abort_unless($this->canManage($user), 403); + + $datasetKeys = array_keys($this->datasets->all()); + $data = $request->validate([ + 'name' => 'required|string|max:50', + 'dataset' => 'required|in:'.implode(',', $datasetKeys), + 'base_measure' => 'required|string', + 'factor' => 'nullable|numeric', + 'unit' => 'nullable|in:count,percent,money', + ]); + + $baseKeys = array_map(fn ($m) => $m->key, $this->datasets->get($data['dataset'])->measures()); + abort_unless(in_array($data['base_measure'], $baseKeys, true), 422); + + CustomMeasure::create([ + ...$data, + 'factor' => $data['factor'] ?? 1, + 'created_by' => Auth::id(), + ]); + + return back()->with('success', 'Показатель создан'); + } + + /** @return array{result: \App\Services\Analytics\AnalyticsResult, title: string} */ + private function buildResult(Request $request): array + { + $user = Auth::user(); + $query = $this->buildQuery($request, $user); + $title = (string) ($request->input('name') ?: $this->datasets->get($query->datasetKey)->label()); + + return ['result' => $this->datasets->get($query->datasetKey)->run($query), 'title' => $title]; + } + + private function validateDocument(Request $request): array + { + $datasetKeys = array_keys($this->datasets->all()); + + $validated = $request->validate([ + 'name' => 'required|string|max:50', + 'description' => 'nullable|string|max:200', + 'dataset' => 'required|string|in:'.implode(',', $datasetKeys), + 'config' => 'required|array', + 'period' => 'nullable|array', + ]); + + // Чистим config от ключей, которых нет в датасете. + $dataset = $this->datasets->get($validated['dataset']); + $dimKeys = array_map(fn ($d) => $d->key, $dataset->dimensions()); + $measureKeys = [ + ...array_map(fn ($m) => $m->key, $dataset->measures()), + ...array_map(fn (Measure $m) => $m->key, $this->customMeasuresFor($validated['dataset'])), + ]; + + $config = $validated['config']; + $config['dimensions'] = array_values(array_intersect($config['dimensions'] ?? [], $dimKeys)); + $config['measures'] = array_values(array_intersect($config['measures'] ?? [], $measureKeys)); + $config['mode'] = ($config['mode'] ?? 'period') === 'dynamics' ? 'dynamics' : 'period'; + $config['detalization'] = in_array($config['detalization'] ?? 'month', ['day', 'week', 'month'], true) ? $config['detalization'] : 'month'; + $validated['config'] = $config; + + return $validated; } private function resolveDepartment(Request $request, User $user): Department { - $departmentId = $request->query('departmentId'); + $departmentId = $request->input('departmentId', $request->query('departmentId')); $available = collect($user->availableDepartments()); if ($departmentId && $available->contains(fn (Department $d) => (int) $d->department_id === (int) $departmentId)) { @@ -113,26 +385,14 @@ class ReportsController extends Controller return $user->department ?? $available->first(); } - /** - * @param array $permissions - */ - private function audienceLabel(array $permissions): string + private function canManage(User $user): bool { - if (empty($permissions)) { - return 'Доступен всем'; - } - - $labels = [ - 'report.view' => 'Дежурный врач', - 'nurse.report.view' => 'Старшая медсестра', - ]; - - return implode(' · ', array_map(fn ($p) => $labels[$p] ?? $p, $permissions)); + return $user->isAdmin() || $user->isChiefDoctor() || $user->isDeputyChief(); } - private function fileName(ReportDefinition $definition, string $extension): string + private function fileName(string $title, string $extension): string { - $slug = str_replace([' ', ':', '/', '\\'], '_', $definition->label()); + $slug = str_replace([' ', ':', '/', '\\'], '_', $title); return sprintf('%s_%s.%s', $slug, now('Asia/Yakutsk')->format('Ymd_His'), $extension); } diff --git a/resources/js/Pages/Admin/Index.vue b/resources/js/Pages/Admin/Index.vue index f16ffe9..8c00951 100644 --- a/resources/js/Pages/Admin/Index.vue +++ b/resources/js/Pages/Admin/Index.vue @@ -85,13 +85,6 @@ const dividerColor = computed(() => themeVars.value.dividerColor) :tag="Link" href="/admin/replication" /> - diff --git a/routes/web.php b/routes/web.php index 39c963c..d2a2e0b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -61,14 +61,6 @@ Route::prefix('admin')->middleware(['auth'])->group(function () { 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 () { @@ -79,8 +71,16 @@ Route::prefix('statistic')->middleware(['auth'])->group(function () { 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('/new', [\App\Http\Controllers\Web\ReportsController::class, 'create'])->name('reports.create'); + Route::post('/run', [\App\Http\Controllers\Web\ReportsController::class, 'run'])->name('reports.run'); + Route::post('/measures', [\App\Http\Controllers\Web\ReportsController::class, 'storeMeasure'])->name('reports.measures.store'); + Route::post('/export/excel', [\App\Http\Controllers\Web\ReportsController::class, 'exportExcel'])->name('reports.export.excel'); + Route::post('/export/pdf', [\App\Http\Controllers\Web\ReportsController::class, 'exportPdf'])->name('reports.export.pdf'); + Route::post('/', [\App\Http\Controllers\Web\ReportsController::class, 'store'])->name('reports.store'); + Route::get('/{document}', [\App\Http\Controllers\Web\ReportsController::class, 'show'])->name('reports.show'); + Route::put('/{document}', [\App\Http\Controllers\Web\ReportsController::class, 'update'])->name('reports.update'); + Route::delete('/{document}', [\App\Http\Controllers\Web\ReportsController::class, 'destroy'])->name('reports.destroy'); + Route::post('/{document}/duplicate', [\App\Http\Controllers\Web\ReportsController::class, 'duplicate'])->name('reports.duplicate'); }); Route::get('/logout', [\App\Http\Controllers\AuthController::class, 'logout'])