Добавлены роуты для отчетов

This commit is contained in:
brusnitsyn
2026-06-22 16:54:00 +09:00
parent bd2cc24b98
commit a47c2ad9b1
3 changed files with 349 additions and 96 deletions

View File

@@ -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<int,array<string,mixed>>
*/
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<int,array{label:string,value:int}>
*/
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<int,string> $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);
}

View File

@@ -85,13 +85,6 @@ const dividerColor = computed(() => themeVars.value.dividerColor)
:tag="Link"
href="/admin/replication"
/>
<ActionTile
:icon="TbReportAnalytics"
title="Шаблоны отчётов"
description="Конструктор пользовательских отчётов"
:tag="Link"
href="/admin/report-templates"
/>
</div>
<!-- Назад -->

View File

@@ -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'])