Files
onboard/app/Services/StatisticsService.php
brusnitsyn 7329893775 Добавил строку с итогами
Рефакторинг контроллера статистики
2026-02-10 08:56:09 +09:00

684 lines
26 KiB
PHP
Raw Permalink 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;
use App\Models\Department;
use App\Models\Report;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Collection;
use Carbon\Carbon;
class StatisticsService
{
protected array $metricMapping = [
4 => 'plan', // Плановые поступления
12 => 'emergency', // Экстренные поступления
11 => 'plan_surgical', // Плановые операции
10 => 'emergency_surgical', // Экстренные операции
13 => 'transferred', // Переведенные
7 => 'outcome', // Выбыло
9 => 'deceased', // Умерло
8 => 'current', // Состоит
];
/**
* Получить статистические данные с оптимизацией
*/
public function getStatisticsData(User $user, string $startDate, string $endDate, bool $isRangeOneDay): array
{
// Определяем порог для использования оптимизированного метода
$daysDiff = Carbon::parse($startDate)->diffInDays(Carbon::parse($endDate));
// Для диапазонов больше 30 дней используем агрегированные данные
if ($daysDiff > 30) {
return $this->getAggregatedStatistics($user, $startDate, $endDate, $isRangeOneDay);
}
// Для диапазонов 7-30 дней используем оптимизированный метод
if ($daysDiff > 7) {
return $this->getOptimizedStatistics($user, $startDate, $endDate, $isRangeOneDay);
}
// Для малых диапазонов используем детальный метод
return $this->getDetailedStatistics($user, $startDate, $endDate, $isRangeOneDay);
}
/**
* Агрегированный метод для очень больших диапазонов (больше 30 дней)
*/
private function getAggregatedStatistics(User $user, string $startDate, string $endDate, bool $isRangeOneDay): array
{
// Устанавливаем дату отчета
if ($isRangeOneDay) {
$dateReport = $endDate;
} else {
$dateReport = [$startDate, $endDate];
}
// Загружаем все отделения
$departments = Department::select('department_id', 'rf_department_type', 'name_short')
->with(['departmentType'])
->orderBy('name_short')
->get()
->keyBy('department_id');
// Загружаем метрики по умолчанию
$defaultMetrics = $this->getDefaultMetricsBatch($departments->pluck('department_id')->toArray());
// Получаем агрегированные данные по отчетам
$aggregatedData = $this->getAggregatedReportData(
$departments->pluck('department_id')->toArray(),
$dateReport,
$isRangeOneDay
);
// Получаем последние отчеты для текущих пациентов
$lastReportsData = $this->getLastReportsData(
$departments->pluck('department_id')->toArray(),
$isRangeOneDay ? $dateReport : $dateReport[1]
);
return $this->processAggregatedData(
$departments,
$defaultMetrics,
$aggregatedData,
$lastReportsData,
$isRangeOneDay
);
}
/**
* Получить агрегированные данные по отчетам
*/
private function getAggregatedReportData(array $departmentIds, $dateReport, bool $isRangeOneDay): Collection
{
$query = DB::table('reports as r')
->join('metrika_results as mr', 'r.report_id', '=', 'mr.rf_report_id')
->select(
'r.rf_department_id',
'mr.rf_metrika_item_id',
DB::raw('SUM(CAST(mr.value AS INTEGER)) as total')
)
->whereIn('r.rf_department_id', $departmentIds);
if ($isRangeOneDay) {
$query->whereDate('r.created_at', $dateReport);
} else {
$query->whereBetween('r.created_at', $dateReport);
}
return $query->whereIn('mr.rf_metrika_item_id', array_keys($this->metricMapping))
->groupBy('r.rf_department_id', 'mr.rf_metrika_item_id')
->get()
->groupBy('rf_department_id');
}
/**
* Получить данные последних отчетов
*/
private function getLastReportsData(array $departmentIds, string $date): Collection
{
// Находим ID последних отчетов для каждого отделения
$subQuery = DB::table('reports')
->select('rf_department_id', DB::raw('MAX(report_id) as last_report_id'))
->whereIn('rf_department_id', $departmentIds)
->whereDate('created_at', '<=', $date)
->groupBy('rf_department_id');
return DB::table('metrika_results as mr')
->joinSub($subQuery, 'last_reports', function ($join) {
$join->on('mr.rf_report_id', '=', 'last_reports.last_report_id');
})
->where('mr.rf_metrika_item_id', 8) // Только текущие пациенты
->select('last_reports.rf_department_id', 'mr.value')
->get()
->keyBy('rf_department_id');
}
/**
* Обработать агрегированные данные
*/
private function processAggregatedData(
Collection $departments,
Collection $defaultMetrics,
Collection $aggregatedData,
Collection $lastReportsData,
bool $isRangeOneDay
): array {
$groupedData = [];
$totalsByType = [];
foreach ($departments as $department) {
$departmentId = $department->department_id;
$departmentType = $department->departmentType->name_full;
if (!isset($groupedData[$departmentType])) {
$groupedData[$departmentType] = [];
$totalsByType[$departmentType] = $this->initTypeTotals();
}
// Получаем агрегированные метрики
$metrics = $aggregatedData->get($departmentId, collect());
$counters = array_fill_keys(array_values($this->metricMapping), 0);
foreach ($metrics as $metric) {
$key = $this->metricMapping[$metric->rf_metrika_item_id] ?? null;
if ($key) {
// Для агрегированных данных всегда берем сумму
$counters[$key] = (int)$metric->total;
}
}
// Текущие пациенты из последнего отчета
$currentCount = (int)($lastReportsData->get($departmentId)?->value ?? 0);
$counters['current'] = $currentCount;
// Количество коек
$bedsCount = (int)($defaultMetrics->get($departmentId)?->value ?? 0);
// Рассчитываем значения
$allCount = $counters['plan'] + $counters['emergency'];
$percentLoadedBeds = $bedsCount > 0 ? round($currentCount * 100 / $bedsCount) : 0;
// Формируем данные
$departmentData = $this->createDepartmentData(
$department->name_short,
$bedsCount,
$allCount,
$counters,
$percentLoadedBeds,
$departmentType
);
$groupedData[$departmentType][] = $departmentData;
$this->updateTypeTotals($totalsByType[$departmentType], $departmentData);
}
return $this->buildFinalData($groupedData, $totalsByType);
}
/**
* Оптимизированный метод для средних диапазонов (7-30 дней)
*/
private function getOptimizedStatistics(User $user, string $startDate, string $endDate, bool $isRangeOneDay): array
{
// Устанавливаем дату отчета
if ($isRangeOneDay) {
$dateReport = $endDate;
} else {
$dateReport = [$startDate, $endDate];
}
// Загружаем все отделения
$departments = Department::select('department_id', 'rf_department_type', 'name_short')
->with(['departmentType'])
->orderBy('name_short')
->get()
->keyBy('department_id');
// Загружаем метрики по умолчанию
$defaultMetrics = $this->getDefaultMetricsBatch($departments->pluck('department_id')->toArray());
// Загружаем отчеты
$reports = $this->getReportsBatch($departments->pluck('department_id')->toArray(), $dateReport, $isRangeOneDay);
// Загружаем метрики отчетов
$reportIds = $reports->flatMap(fn($items) => $items->pluck('report_id'))->toArray();
$reportMetrics = $this->getReportMetricsBatch($reportIds);
return $this->processOptimizedData(
$departments,
$defaultMetrics,
$reports,
$reportMetrics,
$dateReport,
$isRangeOneDay
);
}
/**
* Получить метрики по умолчанию для всех отделений пачкой
*/
private function getDefaultMetricsBatch(array $departmentIds): Collection
{
return DB::table('department_metrika_defaults')
->whereIn('rf_department_id', $departmentIds)
->where('rf_metrika_item_id', 1) // только койки
->select('rf_department_id', 'value')
->get()
->keyBy('rf_department_id');
}
/**
* Получить отчеты для всех отделений пачкой
*/
private function getReportsBatch(array $departmentIds, $dateReport, bool $isRangeOneDay): Collection
{
$query = Report::whereIn('rf_department_id', $departmentIds);
if ($isRangeOneDay) {
$query->whereDate('created_at', $dateReport);
} else {
$query->whereBetween('created_at', $dateReport);
}
return $query->select('report_id', 'rf_department_id', 'created_at')
->orderBy('created_at')
->get()
->groupBy('rf_department_id');
}
/**
* Получить метрики отчетов пачкой
*/
private function getReportMetricsBatch(array $reportIds): Collection
{
if (empty($reportIds)) {
return collect();
}
return DB::table('metrika_results')
->whereIn('rf_report_id', $reportIds)
->whereIn('rf_metrika_item_id', array_keys($this->metricMapping))
->select('rf_report_id', 'rf_metrika_item_id', 'value')
->get()
->groupBy('rf_report_id');
}
/**
* Обработать оптимизированные данные
*/
private function processOptimizedData(
Collection $departments,
Collection $defaultMetrics,
Collection $reports,
Collection $reportMetrics,
$dateReport,
bool $isRangeOneDay
): array {
$groupedData = [];
$totalsByType = [];
foreach ($departments as $department) {
$departmentId = $department->department_id;
$departmentType = $department->departmentType->name_full;
if (!isset($groupedData[$departmentType])) {
$groupedData[$departmentType] = [];
$totalsByType[$departmentType] = $this->initTypeTotals();
}
// Получаем отчеты отделения
$departmentReports = $reports->get($departmentId, collect());
$lastReport = $departmentReports->last();
// Инициализируем счетчики
$counters = array_fill_keys(array_values($this->metricMapping), 0);
// Обрабатываем каждый отчет
foreach ($departmentReports as $report) {
$metrics = $reportMetrics->get($report->report_id, collect())
->keyBy('rf_metrika_item_id');
foreach ($this->metricMapping as $metricId => $key) {
if ($metrics->has($metricId)) {
$value = (int)$metrics[$metricId]->value;
// Разная логика для одного дня и диапазона
if ($isRangeOneDay) {
// Для одного дня: суммируем
$counters[$key] += $value;
} else {
// Для диапазона:
if ($metricId === 8) {
// Для текущих пациентов берем ПОСЛЕДНЕЕ значение
if ($report === $lastReport) {
$counters[$key] = $value;
}
} else {
// Для остальных суммируем
$counters[$key] += $value;
}
}
}
}
}
// Если нет отчетов за день, но есть последний отчет ранее
if ($counters['current'] === 0 && $lastReport) {
$metrics = $reportMetrics->get($lastReport->report_id, collect());
$currentMetric = $metrics->firstWhere('rf_metrika_item_id', 8);
if ($currentMetric) {
$counters['current'] = (int)$currentMetric->value;
}
}
// Получаем количество коек
$bedsCount = (int)($defaultMetrics->get($departmentId)?->value ?? 0);
// Рассчитываем значения
$allCount = $counters['plan'] + $counters['emergency'];
$percentLoadedBeds = $bedsCount > 0 ? round($counters['current'] * 100 / $bedsCount) : 0;
// Формируем данные отделения
$departmentData = $this->createDepartmentData(
$department->name_short,
$bedsCount,
$allCount,
$counters,
$percentLoadedBeds,
$departmentType
);
$groupedData[$departmentType][] = $departmentData;
$this->updateTypeTotals($totalsByType[$departmentType], $departmentData);
}
return $this->buildFinalData($groupedData, $totalsByType);
}
/**
* Детальный метод для небольших диапазонов (до 7 дней)
*/
private function getDetailedStatistics(User $user, string $startDate, string $endDate, bool $isRangeOneDay): array
{
// Устанавливаем дату отчета
if ($isRangeOneDay) {
$dateReport = $endDate;
} else {
$dateReport = [$startDate, $endDate];
}
$groupedData = [];
$totalsByType = [];
$departments = Department::select('department_id', 'rf_department_type', 'name_short')
->with(['departmentType', 'reports' => function ($query) use ($dateReport, $isRangeOneDay) {
if ($isRangeOneDay) {
$query->whereDate('created_at', $dateReport);
} else {
$query->whereBetween('created_at', $dateReport);
}
$query->with('metrikaResults');
}])
->orderBy('name_short')
->get();
foreach ($departments as $department) {
$departmentType = $department->departmentType->name_full;
if (!isset($groupedData[$departmentType])) {
$groupedData[$departmentType] = [];
$totalsByType[$departmentType] = $this->initTypeTotals();
}
// Получаем отчеты
$reports = $department->reports;
$lastReport = $reports->last();
// Инициализируем счетчики
$counters = array_fill_keys(array_values($this->metricMapping), 0);
// Суммируем метрики
foreach ($reports as $report) {
foreach ($report->metrikaResults as $metric) {
$key = $this->metricMapping[$metric->rf_metrika_item_id] ?? null;
if ($key) {
$value = (int)$metric->value;
// ВАЖНО: разная логика для одного дня и диапазона
if ($isRangeOneDay) {
// Для одного дня: суммируем все значения
$counters[$key] += $value;
} else {
// Для диапазона:
if ($metric->rf_metrika_item_id === 8) {
// Для текущих пациентов берем ПОСЛЕДНЕЕ значение
// из последнего отчета за день
if ($report === $lastReport) {
$counters[$key] = $value;
}
} else {
// Для остальных метрик СУММИРУЕМ за весь период
$counters[$key] += $value;
}
}
}
}
}
// Получаем количество коек
$bedsCount = (int)$department->metrikaDefault()
->where('rf_metrika_item_id', 1)
->value('value') ?? 0;
// Рассчитываем итоговые значения
$allCount = $counters['plan'] + $counters['emergency'];
$percentLoadedBeds = $bedsCount > 0 ? round($counters['current'] * 100 / $bedsCount) : 0;
// Формируем данные отделения
$departmentData = $this->createDepartmentData(
$department->name_short,
$bedsCount,
$allCount,
$counters,
$percentLoadedBeds,
$departmentType
);
$groupedData[$departmentType][] = $departmentData;
$this->updateTypeTotals($totalsByType[$departmentType], $departmentData);
}
return $this->buildFinalData($groupedData, $totalsByType);
}
/**
* Создать данные отделения
*/
private function createDepartmentData(
string $name,
int $beds,
int $allCount,
array $counters,
int $percentLoadedBeds,
string $type
): array {
return [
'department' => $name,
'beds' => $beds,
'recipients' => [
'all' => $allCount,
'plan' => $counters['plan'],
'emergency' => $counters['emergency'],
'transferred' => $counters['transferred'],
],
'outcome' => $counters['outcome'],
'consist' => $counters['current'],
'percentLoadedBeds' => $percentLoadedBeds,
'surgical' => [
'plan' => $counters['plan_surgical'],
'emergency' => $counters['emergency_surgical']
],
'deceased' => $counters['deceased'],
'type' => $type,
'isDepartment' => true
];
}
/**
* Инициализировать итоги по типу
*/
private function initTypeTotals(): array
{
return [
'departments_count' => 0,
'beds_sum' => 0,
'recipients_all_sum' => 0,
'recipients_plan_sum' => 0,
'recipients_emergency_sum' => 0,
'recipients_transferred_sum' => 0,
'outcome_sum' => 0,
'consist_sum' => 0,
'plan_surgical_sum' => 0,
'emergency_surgical_sum' => 0,
'deceased_sum' => 0,
'percentLoadedBeds_total' => 0,
'percentLoadedBeds_count' => 0,
];
}
/**
* Обновить итоги по типу
*/
private function updateTypeTotals(array &$totals, array $departmentData): void
{
$totals['departments_count']++;
$totals['beds_sum'] += $departmentData['beds'];
$totals['recipients_all_sum'] += $departmentData['recipients']['all'];
$totals['recipients_plan_sum'] += $departmentData['recipients']['plan'];
$totals['recipients_emergency_sum'] += $departmentData['recipients']['emergency'];
$totals['recipients_transferred_sum'] += $departmentData['recipients']['transferred'];
$totals['outcome_sum'] += $departmentData['outcome'];
$totals['consist_sum'] += $departmentData['consist'];
$totals['plan_surgical_sum'] += $departmentData['surgical']['plan'];
$totals['emergency_surgical_sum'] += $departmentData['surgical']['emergency'];
$totals['deceased_sum'] += $departmentData['deceased'];
$totals['percentLoadedBeds_total'] += $departmentData['percentLoadedBeds'];
$totals['percentLoadedBeds_count']++;
}
/**
* Построить финальные данные с итогами
*/
private function buildFinalData(array $groupedData, array $totalsByType): array
{
$finalData = [];
$grandTotals = $this->initTypeTotals();
foreach ($groupedData as $type => $departmentsInType) {
// Добавляем заголовок группы
$finalData[] = [
'isGroupHeader' => true,
'groupName' => $type,
'colspan' => 12,
'type' => $type
];
// Добавляем отделения
foreach ($departmentsInType as $department) {
$finalData[] = $department;
}
// Добавляем итоги по группе
if (!empty($departmentsInType) && isset($totalsByType[$type])) {
$total = $totalsByType[$type];
$avgPercent = $total['percentLoadedBeds_count'] > 0
? round($total['percentLoadedBeds_total'] / $total['percentLoadedBeds_count'])
: 0;
$finalData[] = $this->createTotalRow($type, $total, $avgPercent, false);
// Обновляем общие итоги
$this->updateGrandTotals($grandTotals, $total);
}
}
// Добавляем общие итоги
// if ($grandTotals['departments_count'] > 0) {
// $avgPercent = $grandTotals['percentLoadedBeds_count'] > 0
// ? round($grandTotals['percentLoadedBeds_total'] / $grandTotals['percentLoadedBeds_count'])
// : 0;
//
// $finalData[] = $this->createTotalRow('all', $grandTotals, $avgPercent, true);
// }
return [
'data' => $finalData,
'totalsByType' => $totalsByType,
'grandTotals' => $grandTotals
];
}
/**
* Создать строку итогов
*/
private function createTotalRow(string $type, array $total, int $avgPercent, bool $isGrandTotal): array
{
$row = [
'isTotalRow' => !$isGrandTotal,
'isGrandTotal' => $isGrandTotal,
'department' => $isGrandTotal
? 'ОБЩИЕ ИТОГИ:'
: 'ИТОГО:',
'beds' => '—',//$total['beds_sum'],
'recipients' => [
'all' => $total['recipients_all_sum'],
'plan' => $total['recipients_plan_sum'],
'emergency' => $total['recipients_emergency_sum'],
'transferred' => $total['recipients_transferred_sum'],
],
'outcome' => $total['outcome_sum'],
'consist' => $total['consist_sum'],
'percentLoadedBeds' => '—',//$avgPercent,
'surgical' => [
'plan' => $total['plan_surgical_sum'],
'emergency' => $total['emergency_surgical_sum']
],
'deceased' => $total['deceased_sum'],
'type' => $type,
'departments_count' => $total['departments_count'],
'isBold' => true
];
if ($isGrandTotal) {
$row['backgroundColor'] = '#f0f8ff';
}
return $row;
}
/**
* Обновить общие итоги
*/
private function updateGrandTotals(array &$grandTotals, array $typeTotal): void
{
foreach ($grandTotals as $key => &$value) {
if (isset($typeTotal[$key])) {
$value += $typeTotal[$key];
}
}
}
/**
* Очистить кэш статистики при создании отчета
*/
public function clearStatisticsCache(User $user, ?string $date = null): void
{
// Очищаем кэш по тегам
Cache::tags([
'statistics',
'reports',
'department_' . $user->rf_department_id
])->flush();
\Log::info("Statistics cache cleared for department: " . $user->rf_department_id);
}
/**
* Очистить кэш статистики для всех пользователей отдела
*/
public function clearDepartmentStatisticsCache(int $departmentId): void
{
Cache::tags([
'statistics',
'reports',
'department_' . $departmentId
])->flush();
\Log::info("Statistics cache cleared for entire department: " . $departmentId);
}
}