From 5bad7599cf1b1bb74bca9509fa094e89a6cd384b Mon Sep 17 00:00:00 2001 From: brusnitsyn Date: Mon, 22 Jun 2026 17:00:58 +0900 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D0=B1=D0=B0=D0=B7=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=B4=D0=B0=D1=82?= =?UTF-8?q?=D0=B0=D1=81=D0=B5=D1=82=D1=8B=20=D0=B8=20=D0=B0=D0=B3=D1=80?= =?UTF-8?q?=D0=B5=D0=B3=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B0=20=D0=BE=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 --- app/Services/Analytics/AbstractDataSet.php | 332 ++++++++++++++++++ app/Services/Analytics/AnalyticsQuery.php | 36 ++ app/Services/Analytics/AnalyticsResult.php | 30 ++ app/Services/Analytics/Column.php | 33 ++ app/Services/Analytics/DataSetRegistry.php | 85 +++++ .../DataSets/Concerns/PatientAggregates.php | 78 ++++ .../DataSets/DepartmentStatisticsDataSet.php | 146 ++++++++ .../DataSets/NurseJournalDataSet.php | 83 +++++ .../Analytics/DataSets/NurseShiftsDataSet.php | 103 ++++++ .../Analytics/DataSets/PatientsDataSet.php | 93 +++++ .../Analytics/DataSets/ShiftsDataSet.php | 122 +++++++ .../DataSets/UnwantedEventsDataSet.php | 73 ++++ app/Services/Analytics/Dimension.php | 56 +++ app/Services/Analytics/FilterDef.php | 24 ++ app/Services/Analytics/Measure.php | 21 ++ .../Analytics/ReportPresetRegistry.php | 138 ++++++++ 16 files changed, 1453 insertions(+) create mode 100644 app/Services/Analytics/AbstractDataSet.php create mode 100644 app/Services/Analytics/AnalyticsQuery.php create mode 100644 app/Services/Analytics/AnalyticsResult.php create mode 100644 app/Services/Analytics/Column.php create mode 100644 app/Services/Analytics/DataSetRegistry.php create mode 100644 app/Services/Analytics/DataSets/Concerns/PatientAggregates.php create mode 100644 app/Services/Analytics/DataSets/DepartmentStatisticsDataSet.php create mode 100644 app/Services/Analytics/DataSets/NurseJournalDataSet.php create mode 100644 app/Services/Analytics/DataSets/NurseShiftsDataSet.php create mode 100644 app/Services/Analytics/DataSets/PatientsDataSet.php create mode 100644 app/Services/Analytics/DataSets/ShiftsDataSet.php create mode 100644 app/Services/Analytics/DataSets/UnwantedEventsDataSet.php create mode 100644 app/Services/Analytics/Dimension.php create mode 100644 app/Services/Analytics/FilterDef.php create mode 100644 app/Services/Analytics/Measure.php create mode 100644 app/Services/Analytics/ReportPresetRegistry.php diff --git a/app/Services/Analytics/AbstractDataSet.php b/app/Services/Analytics/AbstractDataSet.php new file mode 100644 index 0000000..7c62f1b --- /dev/null +++ b/app/Services/Analytics/AbstractDataSet.php @@ -0,0 +1,332 @@ +indexByKey($this->dimensions()); + $measures = $this->indexByKey([...$this->measures(), ...$query->customMeasures]); + $filters = $this->indexByKey($this->filters()); + + $selDims = $this->pick($query->dimensions, $dimensions); + $selMeasures = $this->pick($query->measures, $measures); + + $builder = $this->baseQuery($query); + $this->applyJoins($builder, $selDims); + $this->applyFilters($builder, $query->filters, $filters); + + if ($query->mode === 'dynamics') { + return $this->buildDynamics($builder, $query, $selDims, $selMeasures); + } + + return $this->buildPeriod($builder, $selDims, $selMeasures); + } + + /** + * Режим «За период»: группировка по измерениям, агрегаты-показатели. + * + * @param Dimension[] $selDims + * @param Measure[] $selMeasures + */ + private function buildPeriod(Builder $builder, array $selDims, array $selMeasures): AnalyticsResult + { + foreach ($selDims as $dim) { + $builder->selectRaw("{$dim->select} as {$dim->key}")->groupByRaw($dim->groupByExpr()); + } + foreach ($selMeasures as $measure) { + $builder->selectRaw("{$measure->select} as {$measure->key}"); + } + + if ($selMeasures !== []) { + $builder->orderByRaw($selMeasures[0]->select.' desc'); + } + + $raw = $builder->get(); + + $columns = []; + foreach ($selDims as $dim) { + $columns[] = new Column($dim->key, $dim->label, 'dimension', $dim->type); + } + foreach ($selMeasures as $measure) { + $columns[] = new Column($measure->key, $measure->label, 'measure', 'number', $measure->unit); + } + + $labelMaps = $this->resolveLabelMaps($selDims, $raw); + + $rows = []; + $totals = []; + foreach ($raw as $record) { + $row = []; + foreach ($selDims as $dim) { + $row[$dim->key] = $dim->display($record->{$dim->key} ?? null, $labelMaps[$dim->key] ?? null); + } + foreach ($selMeasures as $measure) { + $value = $this->number($record->{$measure->key} ?? 0); + $row[$measure->key] = $value; + $totals[$measure->key] = ($totals[$measure->key] ?? 0) + $value; + } + $rows[] = $row; + } + + foreach ($totals as $key => $value) { + $totals[$key] = $this->number($value); + } + + return new AnalyticsResult($columns, $rows, $totals); + } + + /** + * Режим «В динамике»: строки — комбинации измерений, колонки — временные интервалы, + * значение — выбранный показатель (chart.metric или первый из выбранных). + * + * @param Dimension[] $selDims + * @param Measure[] $selMeasures + */ + private function buildDynamics(Builder $builder, AnalyticsQuery $query, array $selDims, array $selMeasures): AnalyticsResult + { + $measure = $this->resolveDynamicsMeasure($query, $selMeasures); + + if ($measure === null) { + return new AnalyticsResult([], []); + } + + $bucket = $this->bucketExpr($query->detalization); + + foreach ($selDims as $dim) { + $builder->selectRaw("{$dim->select} as {$dim->key}")->groupByRaw($dim->groupByExpr()); + } + $builder->selectRaw("{$bucket} as __period")->groupByRaw($bucket); + $builder->selectRaw("{$measure->select} as __value"); + $builder->orderByRaw($bucket); + + $raw = $builder->get(); + + // Уникальные интервалы по возрастанию — это будущие колонки. + $buckets = $raw->pluck('__period')->filter()->unique()->sort()->values()->all(); + + $labelMaps = $this->resolveLabelMaps($selDims, $raw); + + $columns = []; + if ($selDims === []) { + $columns[] = new Column('__metric', $measure->label, 'dimension', 'string'); + } else { + foreach ($selDims as $dim) { + $columns[] = new Column($dim->key, $dim->label, 'dimension', $dim->type); + } + } + foreach ($buckets as $bucketValue) { + $columns[] = new Column('p_'.$bucketValue, $this->bucketLabel($bucketValue, $query->detalization), 'period', 'number', $measure->unit); + } + + // Группируем сырые строки в строки таблицы по комбинации измерений. + $grouped = []; + foreach ($raw as $record) { + $dimKey = $selDims === [] + ? '__all' + : implode('|', array_map(fn (Dimension $d) => (string) ($record->{$d->key} ?? ''), $selDims)); + + if (! isset($grouped[$dimKey])) { + $row = []; + if ($selDims === []) { + $row['__metric'] = $measure->label; + } else { + foreach ($selDims as $dim) { + $row[$dim->key] = $dim->display($record->{$dim->key} ?? null, $labelMaps[$dim->key] ?? null); + } + } + foreach ($buckets as $b) { + $row['p_'.$b] = 0; + } + $grouped[$dimKey] = $row; + } + + if ($record->__period !== null) { + $grouped[$dimKey]['p_'.$record->__period] = $this->number($record->__value ?? 0); + } + } + + return new AnalyticsResult($columns, array_values($grouped)); + } + + /** + * @param Measure[] $selMeasures + */ + private function resolveDynamicsMeasure(AnalyticsQuery $query, array $selMeasures): ?Measure + { + $wanted = $query->chart['metric'] ?? null; + + if ($wanted !== null) { + foreach ($selMeasures as $measure) { + if ($measure->key === $wanted) { + return $measure; + } + } + } + + return $selMeasures[0] ?? null; + } + + private function bucketExpr(string $detalization): string + { + $field = $this->dateField(); + + return match ($detalization) { + 'day' => "to_char($field, 'YYYY-MM-DD')", + 'week' => "to_char($field, 'IYYY-IW')", + default => "to_char($field, 'YYYY-MM')", + }; + } + + private function bucketLabel(string $value, string $detalization): string + { + return match ($detalization) { + 'day' => Carbon::parse($value)->format('d.m.Y'), + 'week' => 'Нед. '.$value, + default => $this->monthLabel($value), + }; + } + + private function monthLabel(string $value): string + { + [$year, $month] = array_pad(explode('-', $value), 2, '1'); + $months = [1 => 'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь']; + + return ($months[(int) $month] ?? $value).' '.$year; + } + + /** + * Сбор карт «сырое значение => подпись» для измерений-справочников (врач, отделение). + * + * @param Dimension[] $selDims + * @return array> + */ + private function resolveLabelMaps(array $selDims, \Illuminate\Support\Collection $raw): array + { + $maps = []; + foreach ($selDims as $dim) { + if ($dim->labels === null) { + continue; + } + $values = $raw->pluck($dim->key)->filter(fn ($v) => $v !== null && $v !== '')->unique()->values()->all(); + $maps[$dim->key] = $values === [] ? [] : ($dim->labels)($values); + } + + return $maps; + } + + /** + * @param Dimension[] $selDims + */ + private function applyJoins(Builder $builder, array $selDims): void + { + $applied = []; + foreach ($selDims as $dim) { + if ($dim->applyJoin !== null && ! isset($applied[$dim->key])) { + ($dim->applyJoin)($builder); + $applied[$dim->key] = true; + } + } + } + + /** + * @param array $filters + * @param array $defs + */ + private function applyFilters(Builder $builder, array $filters, array $defs): void + { + foreach ($filters as $filter) { + $key = $filter['key'] ?? null; + $value = $filter['value'] ?? null; + + if ($key === null || $value === null || $value === '' || ! isset($defs[$key])) { + continue; + } + + $def = $defs[$key]; + + if (is_array($value)) { + $builder->whereIn(DB::raw($def->column), $value); + } else { + $builder->where(DB::raw($def->column), $value); + } + } + } + + /** + * @template T + * @param array $keys + * @param array $available + * @return array + */ + private function pick(array $keys, array $available): array + { + $picked = []; + foreach ($keys as $key) { + if (isset($available[$key])) { + $picked[] = $available[$key]; + } + } + + return $picked; + } + + /** + * @param array $items + * @return array + */ + private function indexByKey(array $items): array + { + $indexed = []; + foreach ($items as $item) { + $indexed[$item->key] = $item; + } + + return $indexed; + } + + private function number(mixed $value): float + { + return round((float) $value, 2); + } +} diff --git a/app/Services/Analytics/AnalyticsQuery.php b/app/Services/Analytics/AnalyticsQuery.php new file mode 100644 index 0000000..421d797 --- /dev/null +++ b/app/Services/Analytics/AnalyticsQuery.php @@ -0,0 +1,36 @@ + $dimensions ключи измерений (в порядке отображения) + * @param array $measures ключи показателей (в порядке отображения) + * @param array $filters + * @param 'period'|'dynamics' $mode + * @param 'day'|'week'|'month' $detalization + * @param array{metric?:string,type?:string} $chart + * @param Measure[] $customMeasures пользовательские показатели, подмешиваемые к датасету + */ + public function __construct( + public string $datasetKey, + public array $dimensions, + public array $measures, + public array $filters, + public string $mode, + public string $detalization, + public Department $department, + public DateRange $dateRange, + public array $chart = [], + public array $customMeasures = [], + public ?User $user = null, + ) {} +} diff --git a/app/Services/Analytics/AnalyticsResult.php b/app/Services/Analytics/AnalyticsResult.php new file mode 100644 index 0000000..6ca60c5 --- /dev/null +++ b/app/Services/Analytics/AnalyticsResult.php @@ -0,0 +1,30 @@ +> $rows + * @param array $totals + */ + public function __construct( + public array $columns, + public array $rows, + public array $totals = [], + ) {} + + /** @return array */ + public function toArray(): array + { + return [ + 'columns' => array_map(fn (Column $c) => $c->toArray(), $this->columns), + 'rows' => $this->rows, + 'totals' => $this->totals, + ]; + } +} diff --git a/app/Services/Analytics/Column.php b/app/Services/Analytics/Column.php new file mode 100644 index 0000000..85ef101 --- /dev/null +++ b/app/Services/Analytics/Column.php @@ -0,0 +1,33 @@ + */ + public function toArray(): array + { + return [ + 'key' => $this->key, + 'label' => $this->label, + 'kind' => $this->kind, + 'type' => $this->type, + 'unit' => $this->unit, + ]; + } +} diff --git a/app/Services/Analytics/DataSetRegistry.php b/app/Services/Analytics/DataSetRegistry.php new file mode 100644 index 0000000..8d2587f --- /dev/null +++ b/app/Services/Analytics/DataSetRegistry.php @@ -0,0 +1,85 @@ + */ + private array $datasets; + + public function __construct() + { + $this->datasets = []; + foreach ([new ShiftsDataSet, new PatientsDataSet, new UnwantedEventsDataSet, new NurseJournalDataSet, new NurseShiftsDataSet, new DepartmentStatisticsDataSet] as $dataset) { + $this->datasets[$dataset->key()] = $dataset; + } + } + + /** @return array */ + public function all(): array + { + return $this->datasets; + } + + public function has(string $key): bool + { + return isset($this->datasets[$key]); + } + + public function get(string $key): DataSet + { + if (! isset($this->datasets[$key])) { + throw new InvalidArgumentException("Неизвестный источник данных: {$key}"); + } + + return $this->datasets[$key]; + } + + /** + * Метаданные всех датасетов для фронта (источники, измерения, показатели, фильтры). + * + * @return array> + */ + public function metadata(): array + { + return array_values(array_map(fn (DataSet $d) => [ + 'key' => $d->key(), + 'label' => $d->label(), + 'description' => $d->description(), + 'category' => $d->category(), + 'fixed' => $d->fixed(), + 'dimensions' => array_map(fn (Dimension $dim) => [ + 'key' => $dim->key, + 'label' => $dim->label, + 'type' => $dim->type, + ], $d->dimensions()), + 'measures' => array_map(fn (Measure $m) => [ + 'key' => $m->key, + 'label' => $m->label, + 'unit' => $m->unit, + ], $d->measures()), + 'filters' => array_map(fn (FilterDef $f) => [ + 'key' => $f->key, + 'label' => $f->label, + 'type' => $f->type, + 'optionsSource' => $f->optionsSource, + 'options' => $f->options === null ? null : collect($f->options) + ->map(fn ($label, $value) => ['label' => $label, 'value' => $value]) + ->values() + ->all(), + ], $d->filters()), + ], $this->datasets)); + } +} diff --git a/app/Services/Analytics/DataSets/Concerns/PatientAggregates.php b/app/Services/Analytics/DataSets/Concerns/PatientAggregates.php new file mode 100644 index 0000000..370a38e --- /dev/null +++ b/app/Services/Analytics/DataSets/Concerns/PatientAggregates.php @@ -0,0 +1,78 @@ + 'Планово', 2 => 'Экстренно']; + + private function urgencyDimension(string $p): Dimension + { + return new Dimension( + 'urgency', + 'Срочность', + 'string', + "{$p}.urgency_id", + labels: fn () => self::URGENCY_OPTIONS, + ); + } + + private function outcomeDimension(string $p): Dimension + { + $expr = "CASE + WHEN {$p}.death_date IS NOT NULL THEN 'Умер' + WHEN COALESCE({$p}.visit_result_id, 0) IN (4, 14) THEN 'Переведён' + WHEN {$p}.extract_date IS NOT NULL THEN 'Выписан' + ELSE 'В отделении' END"; + + return new Dimension('outcome', 'Исход', 'string', $expr); + } + + /** + * @param string $p алиас таблицы пациентов + * @param string|null $report алиас отчёта (report_duties/report_nurses) — если задан, + * добавляется показатель «Поступило» (по дате поступления в период смены) + * @return Measure[] + */ + private function patientMeasures(string $p, ?string $report = null): array + { + return [ + new Measure('patients_count', 'Количество пациентов', 'count', 'COUNT(*)'), + ...($report !== null ? [$this->admittedMeasure($p, $report)] : []), + ...$this->outcomeMeasures($p), + ]; + } + + /** + * «Поступило» — пациенты, чья дата поступления попала в период смены отчёта. + * Требует алиас отчёта ($report), т.к. границы периода лежат в report_duties/report_nurses. + */ + private function admittedMeasure(string $p, string $report): Measure + { + return new Measure('admitted', 'Поступило', 'count', + "SUM(CASE WHEN {$p}.recipient_date >= {$report}.period_start AND {$p}.recipient_date <= {$report}.period_end THEN 1 ELSE 0 END)"); + } + + /** @return Measure[] */ + private function outcomeMeasures(string $p): array + { + return [ + new Measure('discharged', 'Выписано', 'count', + "SUM(CASE WHEN {$p}.death_date IS NULL AND COALESCE({$p}.visit_result_id, 0) NOT IN (4, 14) AND {$p}.extract_date IS NOT NULL THEN 1 ELSE 0 END)"), + new Measure('transferred', 'Переведено', 'count', + "SUM(CASE WHEN {$p}.death_date IS NULL AND COALESCE({$p}.visit_result_id, 0) IN (4, 14) THEN 1 ELSE 0 END)"), + new Measure('deceased', 'Умерло', 'count', + "SUM(CASE WHEN {$p}.death_date IS NOT NULL THEN 1 ELSE 0 END)"), + new Measure('in_department', 'В отделении', 'count', + "SUM(CASE WHEN {$p}.death_date IS NULL AND COALESCE({$p}.visit_result_id, 0) NOT IN (4, 14) AND {$p}.extract_date IS NULL THEN 1 ELSE 0 END)"), + ]; + } +} diff --git a/app/Services/Analytics/DataSets/DepartmentStatisticsDataSet.php b/app/Services/Analytics/DataSets/DepartmentStatisticsDataSet.php new file mode 100644 index 0000000..ab499ad --- /dev/null +++ b/app/Services/Analytics/DataSets/DepartmentStatisticsDataSet.php @@ -0,0 +1,146 @@ +columns(); + + if ($query->user === null) { + return new AnalyticsResult($columns, []); + } + + $payload = app(StatisticsService::class)->getStatisticsData( + $query->user, + $query->dateRange->startSql(), + $query->dateRange->endSql(), + $query->dateRange->isOneDay, + ); + + $rows = []; + foreach ($payload['data'] ?? [] as $item) { + $rows[] = ! empty($item['isGroupHeader']) + ? $this->groupHeaderRow($columns, $item['groupName'] ?? '') + : $this->dataRow($item); + } + + return new AnalyticsResult($columns, $rows); + } + + /** + * @param Column[] $columns + * @return array + */ + private function groupHeaderRow(array $columns, string $name): array + { + $row = []; + foreach ($columns as $column) { + $row[$column->key] = ''; + } + $row['department'] = $name; + + return $row; + } + + /** + * @param array $item + * @return array + */ + private function dataRow(array $item): array + { + return [ + 'department' => $item['department'] ?? '', + 'beds' => $item['beds'] ?? 0, + 'rec_all' => $item['recipients']['all'] ?? 0, + 'rec_plan' => $item['recipients']['plan'] ?? 0, + 'rec_emergency' => $item['recipients']['emergency'] ?? 0, + 'rec_transferred' => $item['recipients']['transferred'] ?? 0, + 'outcome' => $item['outcome'] ?? 0, + 'consist' => $item['consist'] ?? 0, + 'avg_bed_days' => $item['averageBedDays'] ?? 0, + 'preoperative_days' => $item['preoperativeDays'] ?? 0, + 'percent_loaded' => $item['percentLoadedBeds'] ?? 0, + 'lethality' => $item['lethality'] ?? 0, + 'surgery_emergency' => $item['surgical']['emergency'] ?? 0, + 'surgery_plan' => $item['surgical']['plan'] ?? 0, + 'deceased' => $item['deceased'] ?? 0, + 'staff' => $item['countStaff'] ?? 0, + ]; + } +} diff --git a/app/Services/Analytics/DataSets/NurseJournalDataSet.php b/app/Services/Analytics/DataSets/NurseJournalDataSet.php new file mode 100644 index 0000000..c42e7d8 --- /dev/null +++ b/app/Services/Analytics/DataSets/NurseJournalDataSet.php @@ -0,0 +1,83 @@ +join('report_nurses as rn', 'rn.id', '=', 'p.report_nurse_id') + ->where('rn.rf_department_id', $query->department->department_id) + ->where('rn.status_id', 2) + ->where('rn.period_start', '>=', $query->dateRange->startSql()) + ->where('rn.period_end', '<=', $query->dateRange->endSql()); + } + + public function dimensions(): array + { + return [ + $this->urgencyDimension('p'), + $this->outcomeDimension('p'), + new Dimension('recipient_date', 'Дата поступления', 'date', 'CAST(p.recipient_date AS date)'), + new Dimension( + 'department', + 'Отделение', + 'string', + 'rn.rf_department_id', + labels: fn (array $ids) => Department::whereIn('department_id', $ids)->get() + ->mapWithKeys(fn ($d) => [$d->department_id => $d->name_full ?? $d->name_short]) + ->all(), + ), + ]; + } + + public function measures(): array + { + return $this->patientMeasures('p', 'rn'); + } + + public function filters(): array + { + return [ + new FilterDef('nurse', 'Медсестра', 'rn.rf_lpudoctor_id', 'select', null, 'nurse_doctors'), + new FilterDef('urgency', 'Срочность', 'p.urgency_id', 'select', [1 => 'Планово', 2 => 'Экстренно']), + ]; + } +} diff --git a/app/Services/Analytics/DataSets/NurseShiftsDataSet.php b/app/Services/Analytics/DataSets/NurseShiftsDataSet.php new file mode 100644 index 0000000..1203ffc --- /dev/null +++ b/app/Services/Analytics/DataSets/NurseShiftsDataSet.php @@ -0,0 +1,103 @@ +leftJoin('report_nurse_patients as p', 'p.report_nurse_id', '=', 'rn.id') + ->where('rn.rf_department_id', $query->department->department_id) + ->where('rn.status_id', 2) + ->where('rn.period_start', '>=', $query->dateRange->startSql()) + ->where('rn.period_end', '<=', $query->dateRange->endSql()); + } + + public function dimensions(): array + { + return [ + new Dimension('shift_date', 'Дата смены', 'date', 'CAST(rn.period_start AS date)'), + new Dimension( + 'nurse', + 'Медсестра', + 'string', + 'rn.rf_lpudoctor_id', + labels: fn (array $ids) => MisLpuDoctor::whereIn('LPUDoctorID', $ids)->get() + ->mapWithKeys(fn ($d) => [$d->LPUDoctorID => trim("{$d->FAM_V} {$d->IM_V} {$d->OT_V}") ?: "Сотрудник #{$d->LPUDoctorID}"]) + ->all(), + ), + $this->urgencyDimension('p'), + $this->outcomeDimension('p'), + new Dimension( + 'department', + 'Отделение', + 'string', + 'rn.rf_department_id', + labels: fn (array $ids) => Department::whereIn('department_id', $ids)->get() + ->mapWithKeys(fn ($d) => [$d->department_id => $d->name_full ?? $d->name_short]) + ->all(), + ), + ]; + } + + public function measures(): array + { + return [ + new Measure('shifts_count', 'Количество смен', 'count', 'COUNT(DISTINCT rn.id)'), + new Measure('patients_count', 'Пациентов', 'count', 'COUNT(p.id)'), + $this->admittedMeasure('p', 'rn'), + ...$this->outcomeMeasures('p'), + ]; + } + + public function filters(): array + { + return [ + new FilterDef('nurse', 'Медсестра', 'rn.rf_lpudoctor_id', 'select', null, 'nurse_doctors'), + new FilterDef('urgency', 'Срочность', 'p.urgency_id', 'select', [1 => 'Планово', 2 => 'Экстренно']), + ]; + } +} diff --git a/app/Services/Analytics/DataSets/PatientsDataSet.php b/app/Services/Analytics/DataSets/PatientsDataSet.php new file mode 100644 index 0000000..c374907 --- /dev/null +++ b/app/Services/Analytics/DataSets/PatientsDataSet.php @@ -0,0 +1,93 @@ +join('report_duties as rd', 'rd.id', '=', 'p.report_duty_id') + ->where('rd.rf_department_id', $query->department->department_id) + ->where('rd.status_id', 2) + ->where('rd.period_start', '>=', $query->dateRange->startSql()) + ->where('rd.period_end', '<=', $query->dateRange->endSql()); + } + + public function dimensions(): array + { + return [ + $this->urgencyDimension('p'), + $this->outcomeDimension('p'), + new Dimension('recipient_date', 'Дата поступления', 'date', 'CAST(p.recipient_date AS date)'), + new Dimension( + 'doctor', + 'Врач', + 'string', + 'rd.rf_lpudoctor_id', + labels: fn (array $ids) => MisLpuDoctor::whereIn('LPUDoctorID', $ids)->get() + ->mapWithKeys(fn ($d) => [$d->LPUDoctorID => trim("{$d->FAM_V} {$d->IM_V} {$d->OT_V}") ?: "Врач #{$d->LPUDoctorID}"]) + ->all(), + ), + new Dimension( + 'department', + 'Отделение', + 'string', + 'rd.rf_department_id', + labels: fn (array $ids) => Department::whereIn('department_id', $ids)->get() + ->mapWithKeys(fn ($d) => [$d->department_id => $d->name_full ?? $d->name_short]) + ->all(), + ), + ]; + } + + public function measures(): array + { + return $this->patientMeasures('p', 'rd'); + } + + public function filters(): array + { + return [ + new FilterDef('doctor', 'Врач', 'rd.rf_lpudoctor_id', 'select', null, 'doctors'), + new FilterDef('urgency', 'Срочность', 'p.urgency_id', 'select', [1 => 'Планово', 2 => 'Экстренно']), + ]; + } +} diff --git a/app/Services/Analytics/DataSets/ShiftsDataSet.php b/app/Services/Analytics/DataSets/ShiftsDataSet.php new file mode 100644 index 0000000..250e66e --- /dev/null +++ b/app/Services/Analytics/DataSets/ShiftsDataSet.php @@ -0,0 +1,122 @@ + 'Черновик', 2 => 'Сдан']; + + public function key(): string + { + return 'shifts'; + } + + public function label(): string + { + return 'Смены / дежурства'; + } + + public function description(): string + { + return 'Показатели сданных дежурных смен отделения'; + } + + public function category(): string + { + return 'Дежурства'; + } + + protected function dateField(): string + { + return 'rd.period_start'; + } + + protected function baseQuery(AnalyticsQuery $query): Builder + { + return DB::table('report_duties as rd') + ->leftJoin('duty_report_metric_results as m', 'm.rf_report_id', '=', 'rd.id') + ->where('rd.rf_department_id', $query->department->department_id) + ->where('rd.status_id', 2) + ->where('rd.period_start', '>=', $query->dateRange->startSql()) + ->where('rd.period_end', '<=', $query->dateRange->endSql()); + } + + public function dimensions(): array + { + return [ + new Dimension('shift_date', 'Дата смены', 'date', 'CAST(rd.period_start AS date)'), + new Dimension( + 'doctor', + 'Врач', + 'string', + 'rd.rf_lpudoctor_id', + labels: fn (array $ids) => MisLpuDoctor::whereIn('LPUDoctorID', $ids)->get() + ->mapWithKeys(fn ($d) => [$d->LPUDoctorID => trim("{$d->FAM_V} {$d->IM_V} {$d->OT_V}") ?: "Врач #{$d->LPUDoctorID}"]) + ->all(), + ), + new Dimension( + 'department', + 'Отделение', + 'string', + 'rd.rf_department_id', + labels: fn (array $ids) => Department::whereIn('department_id', $ids)->get() + ->mapWithKeys(fn ($d) => [$d->department_id => $d->name_full ?? $d->name_short]) + ->all(), + ), + new Dimension( + 'status', + 'Статус', + 'string', + 'rd.status_id', + labels: fn () => self::STATUS_OPTIONS, + ), + ]; + } + + public function measures(): array + { + return [ + new Measure('shifts_count', 'Количество смен', 'count', 'COUNT(DISTINCT rd.id)'), + new Measure('beds', 'Коек', 'count', $this->sum(MetrikaConfig::BEDS)), + new Measure('recipient_plan', 'Поступило плановых', 'count', $this->sum(MetrikaConfig::PLAN)), + new Measure('recipient_emergency', 'Поступило экстренных', 'count', $this->sum(MetrikaConfig::EMERGENCY)), + new Measure('discharged', 'Выписано', 'count', $this->sum(MetrikaConfig::DISCHARGED)), + new Measure('transferred', 'Переведено', 'count', $this->sum(MetrikaConfig::TRANSFERRED)), + new Measure('deceased', 'Умерло', 'count', $this->sum(MetrikaConfig::DECEASED)), + new Measure('surgery_plan', 'Операции плановые', 'count', $this->sum(MetrikaConfig::PLAN_SURGERY)), + new Measure('surgery_emergency', 'Операции экстренные', 'count', $this->sum(MetrikaConfig::EMERGENCY_SURGERY)), + new Measure('occupancy_percent', 'Занятость, %', 'percent', $this->avg(MetrikaConfig::DEPARTMENT_LOADED)), + new Measure('avg_bed_days', 'Ср. койко-день', null, $this->avg(MetrikaConfig::AVERAGE_BED_DAYS)), + new Measure('lethality_percent', 'Летальность, %', 'percent', $this->avg(MetrikaConfig::LETHALITY)), + new Measure('staff_count', 'Мед. персонал', 'count', $this->avg(MetrikaConfig::STAFF_COUNT)), + ]; + } + + public function filters(): array + { + return [ + new FilterDef('doctor', 'Врач', 'rd.rf_lpudoctor_id', 'select', null, 'doctors'), + ]; + } + + private function sum(int $metricId): string + { + return "SUM(CASE WHEN m.rf_metrika_item_id = {$metricId} THEN NULLIF(m.value, '')::numeric ELSE 0 END)"; + } + + private function avg(int $metricId): string + { + return "AVG(CASE WHEN m.rf_metrika_item_id = {$metricId} THEN NULLIF(m.value, '')::numeric END)"; + } +} diff --git a/app/Services/Analytics/DataSets/UnwantedEventsDataSet.php b/app/Services/Analytics/DataSets/UnwantedEventsDataSet.php new file mode 100644 index 0000000..1a22a36 --- /dev/null +++ b/app/Services/Analytics/DataSets/UnwantedEventsDataSet.php @@ -0,0 +1,73 @@ +join('report_duties as rd', 'rd.id', '=', 'e.report_duty_id') + ->where('rd.rf_department_id', $query->department->department_id) + ->where('rd.status_id', 2) + ->where('rd.period_start', '>=', $query->dateRange->startSql()) + ->where('rd.period_end', '<=', $query->dateRange->endSql()); + } + + public function dimensions(): array + { + return [ + new Dimension('event_title', 'Событие', 'string', 'e.title'), + new Dimension('created_date', 'Дата', 'date', 'CAST(e.created_at AS date)'), + new Dimension( + 'department', + 'Отделение', + 'string', + 'rd.rf_department_id', + labels: fn (array $ids) => Department::whereIn('department_id', $ids)->get() + ->mapWithKeys(fn ($d) => [$d->department_id => $d->name_full ?? $d->name_short]) + ->all(), + ), + ]; + } + + public function measures(): array + { + return [ + new Measure('events_count', 'Количество событий', 'count', 'COUNT(*)'), + ]; + } +} diff --git a/app/Services/Analytics/Dimension.php b/app/Services/Analytics/Dimension.php new file mode 100644 index 0000000..8107960 --- /dev/null +++ b/app/Services/Analytics/Dimension.php @@ -0,0 +1,56 @@ + — карта «сырое значение => подпись» + */ + public function __construct( + public string $key, + public string $label, + public string $type, + public string $select, + public ?string $groupBy = null, + public ?Closure $applyJoin = null, + public ?Closure $labels = null, + ) {} + + public function groupByExpr(): string + { + return $this->groupBy ?? $this->select; + } + + /** + * Подпись сырого значения для вывода в таблице. + * + * @param array|null $labelMap + */ + public function display(mixed $raw, ?array $labelMap = null): string + { + if ($raw === null || $raw === '') { + return '—'; + } + + if ($labelMap !== null && array_key_exists($raw, $labelMap)) { + return $labelMap[$raw]; + } + + if ($this->type === 'date') { + return \Illuminate\Support\Carbon::parse((string) $raw)->format('d.m.Y'); + } + + return (string) $raw; + } +} diff --git a/app/Services/Analytics/FilterDef.php b/app/Services/Analytics/FilterDef.php new file mode 100644 index 0000000..f0f8f1f --- /dev/null +++ b/app/Services/Analytics/FilterDef.php @@ -0,0 +1,24 @@ + $options статичные значения для select-фильтра + * @param ?string $optionsSource ключ динамического источника опций (например, 'doctors') + */ + public function __construct( + public string $key, + public string $label, + public string $column, + public string $type, + public ?array $options = null, + public ?string $optionsSource = null, + ) {} +} diff --git a/app/Services/Analytics/Measure.php b/app/Services/Analytics/Measure.php new file mode 100644 index 0000000..2b50c4d --- /dev/null +++ b/app/Services/Analytics/Measure.php @@ -0,0 +1,21 @@ +> + */ + public function all(): array + { + return [ + [ + 'key' => 'blank', + 'label' => 'Без шаблона', + 'description' => 'Самостоятельно выберите источник, группировки и показатели', + 'category' => 'Все', + 'dataset' => null, + 'thumbnail' => [], + 'config' => $this->config(null), + ], + [ + 'key' => 'by_shifts', + 'label' => 'По сменам', + 'description' => 'Показатели дежурных смен по датам', + 'category' => 'Дежурства', + 'dataset' => 'shifts', + 'thumbnail' => ['Дата смены', 'Поступило', 'Выписано', 'Умерло'], + 'config' => $this->config('shifts', ['shift_date'], + ['shifts_count', 'recipient_plan', 'recipient_emergency', 'discharged', 'deceased'], + ['metric' => 'recipient_plan', 'type' => 'bar']), + ], + [ + 'key' => 'by_patients', + 'label' => 'По пациентам', + 'description' => 'Структура пациентов по срочности и исходам', + 'category' => 'Пациенты', + 'dataset' => 'patients', + 'thumbnail' => ['Срочность', 'Исход', 'Пациентов'], + 'config' => $this->config('patients', ['urgency', 'outcome'], + ['patients_count', 'admitted', 'discharged', 'deceased'], + ['metric' => 'patients_count', 'type' => 'donut']), + ], + [ + 'key' => 'unwanted', + 'label' => 'Нежелательные события', + 'description' => 'Количество нежелательных событий по типам', + 'category' => 'События', + 'dataset' => 'unwanted_events', + 'thumbnail' => ['Событие', 'Количество'], + 'config' => $this->config('unwanted_events', ['event_title'], + ['events_count'], + ['metric' => 'events_count', 'type' => 'bar']), + ], + [ + 'key' => 'nurse', + 'label' => 'Журнал медсестры', + 'description' => 'Пациенты журнала по исходам', + 'category' => 'Журнал', + 'dataset' => 'nurse_journal', + 'thumbnail' => ['Исход', 'Пациентов', 'Умерло'], + 'config' => $this->config('nurse_journal', ['outcome'], + ['patients_count', 'admitted', 'discharged', 'deceased'], + ['metric' => 'patients_count', 'type' => 'bar']), + ], + [ + 'key' => 'department_statistics', + 'label' => 'Статистика по отделениям', + 'description' => 'Сводная статистика по всем отделениям (профили, ИТОГО)', + 'category' => 'Сводные', + 'dataset' => 'department_statistics', + 'thumbnail' => ['Отделение', 'Коек', 'Поступило', 'Состоит', 'Умерло'], + 'config' => $this->config('department_statistics'), + ], + [ + 'key' => 'nurse_shifts', + 'label' => 'Смены медсестры', + 'description' => 'Список своих смен с показателями по пациентам', + 'category' => 'Журнал', + 'dataset' => 'nurse_shifts', + 'thumbnail' => ['Дата смены', 'Поступило', 'Выписано', 'Умерло'], + 'config' => $this->config('nurse_shifts', ['shift_date'], + ['shifts_count', 'patients_count', 'admitted', 'discharged', 'deceased'], + ['metric' => 'admitted', 'type' => 'bar']), + ], + ]; + } + + public function find(string $key): ?array + { + foreach ($this->all() as $preset) { + if ($preset['key'] === $key) { + return $preset; + } + } + + return null; + } + + /** + * @return array + */ + public function categories(): array + { + return ['Все', ...collect($this->all()) + ->pluck('category') + ->reject(fn ($c) => $c === 'Все') + ->unique() + ->values() + ->all()]; + } + + /** + * @param array $dimensions + * @param array $measures + * @param array $chart + * @return array + */ + private function config(?string $dataset, array $dimensions = [], array $measures = [], array $chart = []): array + { + return [ + 'dataset' => $dataset, + 'dimensions' => $dimensions, + 'measures' => $measures, + 'filters' => [], + 'mode' => 'period', + 'detalization' => 'month', + 'chart' => [ + 'metric' => $chart['metric'] ?? ($measures[0] ?? null), + 'type' => $chart['type'] ?? 'bar', + ], + ]; + } +}