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) { $result = $this->buildResult($request); return Excel::download(new AnalyticsExcelExport($result['result'], $result['title']), $this->fileName($result['title'], 'xlsx')); } public function exportPdf(Request $request) { $result = $this->buildResult($request); return AnalyticsPdfExport::render($result['result'], $result['title'])->download($this->fileName($result['title'], 'pdf')); } // --- helpers --- private function renderBuilder(Request $request, User $user, array $payload): \Inertia\Response { $department = $this->resolveDepartment($request, $user); $dateRange = $this->dateRangeService->getDateRangeFromRequest($request, $user); 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->input('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(); } private function canManage(User $user): bool { return $user->isAdmin() || $user->isChiefDoctor() || $user->isDeputyChief(); } private function fileName(string $title, string $extension): string { $slug = str_replace([' ', ':', '/', '\\'], '_', $title); return sprintf('%s_%s.%s', $slug, now('Asia/Yakutsk')->format('Ymd_His'), $extension); } }