Files
onboard/app/Services/Analytics/AbstractDataSet.php

333 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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);
}
}