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

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