400 lines
16 KiB
PHP
400 lines
16 KiB
PHP
<?php
|
||
|
||
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 Illuminate\Http\Request;
|
||
use Illuminate\Support\Facades\Auth;
|
||
use Inertia\Inertia;
|
||
use Maatwebsite\Excel\Facades\Excel;
|
||
|
||
class ReportsController extends Controller
|
||
{
|
||
public function __construct(
|
||
protected DataSetRegistry $datasets,
|
||
protected ReportPresetRegistry $presets,
|
||
protected DateRangeService $dateRangeService,
|
||
) {}
|
||
|
||
/** Каталог: системные шаблоны + сохранённые отчёты филиала. */
|
||
public function index()
|
||
{
|
||
$user = Auth::user();
|
||
|
||
$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)
|
||
{
|
||
$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<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->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);
|
||
}
|
||
}
|