Добавил базовые датасеты и агрегации для конструктора отчетов
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user