Добавил базовые датасеты и агрегации для конструктора отчетов
This commit is contained in:
332
app/Services/Analytics/AbstractDataSet.php
Normal file
332
app/Services/Analytics/AbstractDataSet.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/Services/Analytics/AnalyticsQuery.php
Normal file
36
app/Services/Analytics/AnalyticsQuery.php
Normal 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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
30
app/Services/Analytics/AnalyticsResult.php
Normal file
30
app/Services/Analytics/AnalyticsResult.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Services/Analytics/Column.php
Normal file
33
app/Services/Analytics/Column.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
85
app/Services/Analytics/DataSetRegistry.php
Normal file
85
app/Services/Analytics/DataSetRegistry.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)"),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
146
app/Services/Analytics/DataSets/DepartmentStatisticsDataSet.php
Normal file
146
app/Services/Analytics/DataSets/DepartmentStatisticsDataSet.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
83
app/Services/Analytics/DataSets/NurseJournalDataSet.php
Normal file
83
app/Services/Analytics/DataSets/NurseJournalDataSet.php
Normal 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 => 'Экстренно']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
103
app/Services/Analytics/DataSets/NurseShiftsDataSet.php
Normal file
103
app/Services/Analytics/DataSets/NurseShiftsDataSet.php
Normal 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 => 'Экстренно']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
93
app/Services/Analytics/DataSets/PatientsDataSet.php
Normal file
93
app/Services/Analytics/DataSets/PatientsDataSet.php
Normal 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 => 'Экстренно']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
122
app/Services/Analytics/DataSets/ShiftsDataSet.php
Normal file
122
app/Services/Analytics/DataSets/ShiftsDataSet.php
Normal 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)";
|
||||||
|
}
|
||||||
|
}
|
||||||
73
app/Services/Analytics/DataSets/UnwantedEventsDataSet.php
Normal file
73
app/Services/Analytics/DataSets/UnwantedEventsDataSet.php
Normal 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(*)'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/Services/Analytics/Dimension.php
Normal file
56
app/Services/Analytics/Dimension.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/Services/Analytics/FilterDef.php
Normal file
24
app/Services/Analytics/FilterDef.php
Normal 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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
21
app/Services/Analytics/Measure.php
Normal file
21
app/Services/Analytics/Measure.php
Normal 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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
138
app/Services/Analytics/ReportPresetRegistry.php
Normal file
138
app/Services/Analytics/ReportPresetRegistry.php
Normal 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',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user