Добавил базовые датасеты и агрегации для конструктора отчетов

This commit is contained in:
brusnitsyn
2026-06-22 17:00:58 +09:00
parent 71bd4b9d1a
commit 5bad7599cf
16 changed files with 1453 additions and 0 deletions

View File

@@ -0,0 +1,332 @@
<?php
namespace App\Services\Analytics;
use App\Services\Analytics\Contracts\DataSet;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* Базовый построитель отчётов по модели «сводная таблица»: измерения × показатели,
* режимы «За период» (агрегаты по группам) и «В динамике» (разворот показателя по
* временным интервалам день/неделя/месяц).
*
* Наследник задаёт baseQuery() (со скоупом отделения и периода), dateField() и
* наборы dimensions()/measures()/filters().
*/
abstract class AbstractDataSet implements DataSet
{
/** Базовый запрос источника со скоупом отделения и периода. */
abstract protected function baseQuery(AnalyticsQuery $query): Builder;
/** Колонка-дата для детализации в режиме «В динамике». */
abstract protected function dateField(): string;
public function description(): string
{
return '';
}
public function category(): string
{
return 'Прочее';
}
public function fixed(): bool
{
return false;
}
public function filters(): array
{
return [];
}
public function run(AnalyticsQuery $query): AnalyticsResult
{
$dimensions = $this->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<string,array<int|string,string>>
*/
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<int,array{key:string,value:mixed}> $filters
* @param array<string,FilterDef> $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<int,string> $keys
* @param array<string,T> $available
* @return array<int,T>
*/
private function pick(array $keys, array $available): array
{
$picked = [];
foreach ($keys as $key) {
if (isset($available[$key])) {
$picked[] = $available[$key];
}
}
return $picked;
}
/**
* @param array<int,Dimension|Measure|FilterDef> $items
* @return array<string,mixed>
*/
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);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Services\Analytics;
use App\Models\Department;
use App\Models\User;
use App\Services\DateRange;
/**
* Запрос на построение отчёта: что и как агрегировать.
*/
readonly class AnalyticsQuery
{
/**
* @param array<int,string> $dimensions ключи измерений (в порядке отображения)
* @param array<int,string> $measures ключи показателей (в порядке отображения)
* @param array<int,array{key:string,value:mixed}> $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,
) {}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Services\Analytics;
/**
* Результат построения отчёта: описания колонок, строки и итоги.
*/
readonly class AnalyticsResult
{
/**
* @param Column[] $columns
* @param array<int,array<string,mixed>> $rows
* @param array<string,mixed> $totals
*/
public function __construct(
public array $columns,
public array $rows,
public array $totals = [],
) {}
/** @return array<string,mixed> */
public function toArray(): array
{
return [
'columns' => array_map(fn (Column $c) => $c->toArray(), $this->columns),
'rows' => $this->rows,
'totals' => $this->totals,
];
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Services\Analytics;
/**
* Колонка результата отчёта (для таблицы и графика).
*/
readonly class Column
{
/**
* @param 'dimension'|'measure'|'period' $kind
* @param 'string'|'date'|'number'|'money'|'percent' $type
*/
public function __construct(
public string $key,
public string $label,
public string $kind,
public string $type,
public ?string $unit = null,
) {}
/** @return array<string,mixed> */
public function toArray(): array
{
return [
'key' => $this->key,
'label' => $this->label,
'kind' => $this->kind,
'type' => $this->type,
'unit' => $this->unit,
];
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Services\Analytics;
use App\Services\Analytics\Contracts\DataSet;
use App\Services\Analytics\DataSets\DepartmentStatisticsDataSet;
use App\Services\Analytics\DataSets\NurseJournalDataSet;
use App\Services\Analytics\DataSets\NurseShiftsDataSet;
use App\Services\Analytics\DataSets\PatientsDataSet;
use App\Services\Analytics\DataSets\ShiftsDataSet;
use App\Services\Analytics\DataSets\UnwantedEventsDataSet;
use InvalidArgumentException;
/**
* Каталог источников данных конструктора отчётов.
*/
class DataSetRegistry
{
/** @var array<string,DataSet> */
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<string,DataSet> */
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<int,array<string,mixed>>
*/
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));
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Services\Analytics\DataSets\Concerns;
use App\Services\Analytics\Dimension;
use App\Services\Analytics\Measure;
/**
* Общие измерения и показатели для пациентских источников (дежурный врач / медсестра).
* Псевдоним пациентской таблицы передаётся параметром ($p), исходы считаются по той же
* логике, что и ReportSourceRegistry::outcomeLabel (death_date / visit_result_id∈{4,14} / extract_date).
*/
trait PatientAggregates
{
private const URGENCY_OPTIONS = [1 => 'Планово', 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)"),
];
}
}

View File

@@ -0,0 +1,146 @@
<?php
namespace App\Services\Analytics\DataSets;
use App\Services\Analytics\AnalyticsQuery;
use App\Services\Analytics\AnalyticsResult;
use App\Services\Analytics\Column;
use App\Services\Analytics\Contracts\DataSet;
use App\Services\StatisticsService;
/**
* Фиксированный отчёт «Статистика по отделениям» переиспользует StatisticsService,
* поэтому цифры 1:1 совпадают со страницей /statistic (профили, под-итоги «ИТОГО»).
* Колонки заданы жёстко, выбор группировок/показателей не требуется.
*/
class DepartmentStatisticsDataSet implements DataSet
{
public function key(): string
{
return 'department_statistics';
}
public function label(): string
{
return 'Статистика по отделениям';
}
public function description(): string
{
return 'Сводная статистика по всем отделениям';
}
public function category(): string
{
return 'Сводные';
}
public function fixed(): bool
{
return true;
}
public function dimensions(): array
{
return [];
}
public function measures(): array
{
return [];
}
public function filters(): array
{
return [];
}
/** @return Column[] */
private function columns(): array
{
return [
new Column('department', 'Отделение', 'dimension', 'string'),
new Column('beds', 'Кол-во коек', 'measure', 'number', 'count'),
new Column('rec_all', 'Поступило: всего', 'measure', 'number', 'count'),
new Column('rec_plan', 'Поступило: план', 'measure', 'number', 'count'),
new Column('rec_emergency', 'Поступило: экстр', 'measure', 'number', 'count'),
new Column('rec_transferred', 'Поступило: перевод', 'measure', 'number', 'count'),
new Column('outcome', 'Выбыло', 'measure', 'number', 'count'),
new Column('consist', 'Состоит', 'measure', 'number', 'count'),
new Column('avg_bed_days', 'Ср. койко-день', 'measure', 'number', null),
new Column('preoperative_days', 'Пред. опер. койко-день', 'measure', 'number', null),
new Column('percent_loaded', '% загруженности', 'measure', 'number', 'percent'),
new Column('lethality', '% смертности', 'measure', 'number', 'percent'),
new Column('surgery_emergency', 'Операции Э', 'measure', 'number', 'count'),
new Column('surgery_plan', 'Операции П', 'measure', 'number', 'count'),
new Column('deceased', 'Умерло', 'measure', 'number', 'count'),
new Column('staff', 'Мед. персонал', 'measure', 'number', 'count'),
];
}
public function run(AnalyticsQuery $query): AnalyticsResult
{
$columns = $this->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<string,mixed>
*/
private function groupHeaderRow(array $columns, string $name): array
{
$row = [];
foreach ($columns as $column) {
$row[$column->key] = '';
}
$row['department'] = $name;
return $row;
}
/**
* @param array<string,mixed> $item
* @return array<string,mixed>
*/
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,
];
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Services\Analytics\DataSets;
use App\Models\Department;
use App\Services\Analytics\AbstractDataSet;
use App\Services\Analytics\AnalyticsQuery;
use App\Services\Analytics\DataSets\Concerns\PatientAggregates;
use App\Services\Analytics\Dimension;
use App\Services\Analytics\FilterDef;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
class NurseJournalDataSet extends AbstractDataSet
{
use PatientAggregates;
public function key(): string
{
return 'nurse_journal';
}
public function label(): string
{
return 'Журнал медсестры';
}
public function description(): string
{
return 'Пациенты из сданных журналов старшей медсестры';
}
public function category(): string
{
return 'Журнал';
}
protected function dateField(): string
{
return 'p.recipient_date';
}
protected function baseQuery(AnalyticsQuery $query): Builder
{
return DB::table('report_nurse_patients as p')
->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 => 'Экстренно']),
];
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Services\Analytics\DataSets;
use App\Models\Department;
use App\Models\MisLpuDoctor;
use App\Services\Analytics\AbstractDataSet;
use App\Services\Analytics\AnalyticsQuery;
use App\Services\Analytics\DataSets\Concerns\PatientAggregates;
use App\Services\Analytics\Dimension;
use App\Services\Analytics\FilterDef;
use App\Services\Analytics\Measure;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
/**
* Смены медсестры сданные журналы (ReportNurse) с показателями по пациентам.
* Позволяет медсестре сформировать и выгрузить список своих смен.
*/
class NurseShiftsDataSet extends AbstractDataSet
{
use PatientAggregates;
public function key(): string
{
return 'nurse_shifts';
}
public function label(): string
{
return 'Смены медсестры';
}
public function description(): string
{
return 'Сданные журналы медсестры с показателями по пациентам';
}
public function category(): string
{
return 'Журнал';
}
protected function dateField(): string
{
return 'rn.period_start';
}
protected function baseQuery(AnalyticsQuery $query): Builder
{
return DB::table('report_nurses as rn')
->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 => 'Экстренно']),
];
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace App\Services\Analytics\DataSets;
use App\Models\Department;
use App\Models\MisLpuDoctor;
use App\Services\Analytics\AbstractDataSet;
use App\Services\Analytics\AnalyticsQuery;
use App\Services\Analytics\DataSets\Concerns\PatientAggregates;
use App\Services\Analytics\Dimension;
use App\Services\Analytics\FilterDef;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
class PatientsDataSet extends AbstractDataSet
{
use PatientAggregates;
public function key(): string
{
return 'patients';
}
public function label(): string
{
return 'Пациенты';
}
public function description(): string
{
return 'Пациенты дежурных смен: поступления, исходы, срочность';
}
public function category(): string
{
return 'Пациенты';
}
protected function dateField(): string
{
return 'p.recipient_date';
}
protected function baseQuery(AnalyticsQuery $query): Builder
{
return DB::table('report_duty_patients as p')
->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 => 'Экстренно']),
];
}
}

View File

@@ -0,0 +1,122 @@
<?php
namespace App\Services\Analytics\DataSets;
use App\Domain\Reports\ValueObjects\MetrikaConfig;
use App\Models\Department;
use App\Models\MisLpuDoctor;
use App\Services\Analytics\AbstractDataSet;
use App\Services\Analytics\AnalyticsQuery;
use App\Services\Analytics\Dimension;
use App\Services\Analytics\FilterDef;
use App\Services\Analytics\Measure;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
class ShiftsDataSet extends AbstractDataSet
{
private const STATUS_OPTIONS = [1 => 'Черновик', 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)";
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Services\Analytics\DataSets;
use App\Models\Department;
use App\Services\Analytics\AbstractDataSet;
use App\Services\Analytics\AnalyticsQuery;
use App\Services\Analytics\Dimension;
use App\Services\Analytics\Measure;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
class UnwantedEventsDataSet extends AbstractDataSet
{
public function key(): string
{
return 'unwanted_events';
}
public function label(): string
{
return 'Нежелательные события';
}
public function description(): string
{
return 'Нежелательные события дежурных смен отделения';
}
public function category(): string
{
return 'События';
}
protected function dateField(): string
{
return 'e.created_at';
}
protected function baseQuery(AnalyticsQuery $query): Builder
{
return DB::table('duty_unwanted_events as e')
->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(*)'),
];
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Services\Analytics;
use Closure;
/**
* Измерение (группировка) отчёта: по чему группируем строки таблицы.
* Несёт SQL-выражение для SELECT/GROUP BY и опциональные join + резолвер подписей.
*/
readonly class Dimension
{
/**
* @param 'string'|'date' $type
* @param string $select raw SQL-выражение, дающее сырое значение измерения
* @param ?string $groupBy выражение для GROUP BY (по умолчанию = $select)
* @param ?Closure $applyJoin fn(\Illuminate\Database\Query\Builder $q): void добавить нужный JOIN
* @param ?Closure $labels fn(array $rawValues): array<int|string,string> карта «сырое значение => подпись»
*/
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<int|string,string>|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;
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Services\Analytics;
/**
* Описание фильтра датасета: по какому полю и как можно фильтровать таблицу.
*/
readonly class FilterDef
{
/**
* @param 'select'|'number'|'date' $type
* @param string $column raw-колонка для WHERE
* @param ?array<int|string,string> $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,
) {}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Services\Analytics;
/**
* Показатель (метрика) отчёта: агрегируемое значение. Несёт готовое
* SQL-выражение агрегата (SUM/AVG/COUNT и т.п.).
*/
readonly class Measure
{
/**
* @param ?string $unit 'count'|'money'|'percent'|null для форматирования и подписи
* @param string $select raw SQL-агрегат, напр. SUM(CASE WHEN ... THEN value END)
*/
public function __construct(
public string $key,
public string $label,
public ?string $unit,
public string $select,
) {}
}

View File

@@ -0,0 +1,138 @@
<?php
namespace App\Services\Analytics;
/**
* Готовые пресеты (системные шаблоны) для каталога и модалки выбора шаблона.
* Каждый пресет это преднастроенная конфигурация конструктора.
*/
class ReportPresetRegistry
{
/**
* @return array<int,array<string,mixed>>
*/
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<int,string>
*/
public function categories(): array
{
return ['Все', ...collect($this->all())
->pluck('category')
->reject(fn ($c) => $c === 'Все')
->unique()
->values()
->all()];
}
/**
* @param array<int,string> $dimensions
* @param array<int,string> $measures
* @param array<string,string> $chart
* @return array<string,mixed>
*/
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',
],
];
}
}