From 732989377526c4a401e9aa4e5431a7f4834fa7b2 Mon Sep 17 00:00:00 2001 From: brusnitsyn Date: Tue, 10 Feb 2026 08:56:09 +0900 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=BA=D1=83=20=D1=81=20=D0=B8=D1=82?= =?UTF-8?q?=D0=BE=D0=B3=D0=B0=D0=BC=D0=B8=20=D0=A0=D0=B5=D1=84=D0=B0=D0=BA?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B8=D0=BD=D0=B3=20=D0=BA=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=BB=D0=BB=D0=B5=D1=80=D0=B0=20=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D0=B8=D1=81=D1=82=D0=B8=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/Web/StatisticController.php | 268 +------ app/Services/StatisticsService.php | 683 ++++++++++++++++++ resources/js/Pages/Statistic/Index.vue | 106 ++- 3 files changed, 793 insertions(+), 264 deletions(-) create mode 100644 app/Services/StatisticsService.php diff --git a/app/Http/Controllers/Web/StatisticController.php b/app/Http/Controllers/Web/StatisticController.php index 4be8b4c..f005240 100644 --- a/app/Http/Controllers/Web/StatisticController.php +++ b/app/Http/Controllers/Web/StatisticController.php @@ -10,21 +10,21 @@ use App\Models\MetrikaItem; use App\Models\MetrikaResult; use App\Models\Report; use App\Services\DateRangeService; +use App\Services\StatisticsService; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; use Inertia\Inertia; class StatisticController extends Controller { - protected DateRangeService $dateService; - - public function __construct(DateRangeService $dateRangeService) - { - $this->dateService = $dateRangeService; - } + public function __construct( + protected DateRangeService $dateService, + protected StatisticsService $statisticsService + ) { } public function index(Request $request) { @@ -32,104 +32,14 @@ class StatisticController extends Controller $queryStartDate = $request->query('startAt'); $queryEndDate = $request->query('endAt'); - [$startDate, $endDate] = $this->dateService->getDateRangeForUser($user, $queryStartDate, $queryEndDate); + [$startDate, $endDate] = $this->dateService->getStatisticsDateRange($user, $queryStartDate, $queryEndDate); $isRangeOneDay = $this->dateService->isRangeOneDay($startDate, $endDate); - // Если диапазон содержит сутки - if ($isRangeOneDay) { - // Устанавливаем дату отчета, как последний день из выборки - $dateReport = $endDate; - } else { - // Устанавливаем дату отчета, как выборку - $dateReport = [$startDate, $endDate]; - } + // Генерируем ключ кэша на основе параметров запроса +// $cacheKey = $this->generateCacheKey($user, $startDate, $endDate, $isRangeOneDay); - $groupedData = []; - - $departments = Department::select('department_id', 'rf_department_type', 'name_short') - ->with(['reports']) - ->orderBy('name_short')->get(); - - foreach ($departments as $department) { - $departmentType = $department->departmentType->name_full; - - if (!isset($groupedData[$departmentType])) { - $groupedData[$departmentType] = []; - } - - if ($isRangeOneDay) { - // Статистика выводится с нарастающим числом - $query = $department->reports(); - $reports = $query->whereDate('created_at', $dateReport) - ->get(); - $lastReport = $query->whereDate('created_at', $dateReport)->first(); - } else { - $query = $department->reports(); - $reports = $query->clone()->whereBetween('created_at', $dateReport) - ->get(); - $lastReport = $query->clone()->whereDate('created_at', $dateReport[1])->first(); - } - - // Метрики зависищие от отчетов - $allCount = 0; $outcomeCount = 0; $currentCount = 0; $planCount = 0; - $emergencyCount = 0; $planSurgical = 0; $emergencySurgical = 0; $transferredCount = 0; - $deceasedCount = 0; - $currentCount = $lastReport ? $this->getMetrikaResultFromReport($lastReport, 8, false) : 0; // Состоит - foreach ($reports as $report) { - $planCount += $this->getMetrikaResultFromReport($report, 4, $isRangeOneDay); // Поступление - Планово - $emergencyCount += $this->getMetrikaResultFromReport($report, 12, $isRangeOneDay); // Поступление - Экстренно - $planSurgical += $this->getMetrikaResultFromReport($report, 11, $isRangeOneDay); // Операции - Планово - $emergencySurgical += $this->getMetrikaResultFromReport($report, 10, $isRangeOneDay); // Операции - Экстренно - $transferredCount += $this->getMetrikaResultFromReport($report, 13, $isRangeOneDay); // Поступление - Перевод - $outcomeCount += $this->getMetrikaResultFromReport($report, 7, $isRangeOneDay); // Выбыло - $deceasedCount += $this->getMetrikaResultFromReport($report, 9, $isRangeOneDay); // Умерло - } - - $allCount = $planCount + $emergencyCount; // Поступило - - // Независимые метрики (установки по умолчанию и т.п.) - $bedsCount = $department->metrikaDefault() - ->where('rf_metrika_item_id', 1)->value('value'); - - $percentLoadedBeds = $bedsCount > 0 ? round($currentCount * 100 / $bedsCount) : 0; - - $groupedData[$departmentType][] = [ - 'department' => $department->name_short, - 'beds' => $bedsCount, - 'recipients' => [ - 'all' => $allCount, - 'plan' => $planCount, - 'emergency' => $emergencyCount, - 'transferred' => $transferredCount, - ], - 'outcome' => $outcomeCount, - 'consist' => $currentCount, - 'percentLoadedBeds' => $percentLoadedBeds, - 'surgical' => [ - 'plan' => $planSurgical, - 'emergency' => $emergencySurgical - ], - 'deceased' => $deceasedCount, - 'type' => $departmentType - ]; - } - - // Преобразуем группированные данные в плоский массив с заголовками групп - $finalData = []; - foreach ($groupedData as $type => $departmentsInType) { - // Добавляем строку-заголовок группы - $finalData[] = [ - 'isGroupHeader' => true, - 'groupName' => $type, - 'colspan' => 12, // Количество колонок в таблице - 'type' => $type - ]; - - // Добавляем отделения этой группы - foreach ($departmentsInType as $department) { - $finalData[] = $department; - } - } + // Получаем данные из кэша или вычисляем + $finalData = $this->statisticsService->getStatisticsData($user, $startDate, $endDate, $isRangeOneDay); $isHeadOrAdmin = $user->isAdmin() || $user->isHeadOfDepartment(); $date = $isHeadOrAdmin ? [ @@ -138,160 +48,12 @@ class StatisticController extends Controller ] : $this->dateService->parseDate($endDate)->getTimestampMs(); return Inertia::render('Statistic/Index', [ - 'data' => $finalData, + 'data' => $finalData['data'], + 'totalsByType' => $finalData['totalsByType'], + 'grandTotals' => $finalData['grandTotals'], 'isHeadOrAdmin' => $isHeadOrAdmin, 'date' => $date, - 'isOneDay' => $isRangeOneDay + 'isOneDay' => $isRangeOneDay, ]); } - - private function getMetrikaResultFromReport(Report $report, int $metrikaItem, bool $sum = true) - { - if ($sum) { - return (int) ($report->metrikaResults() - ->where('rf_metrika_item_id', $metrikaItem) - ->sum(DB::raw('CAST(value AS INTEGER)')) ?: 0); - } - - return (int) ($report->metrikaResults() - ->where('rf_metrika_item_id', $metrikaItem) - ->value('value') ?: 0); - } - - public function indexOld(Request $request) - { - $user = Auth::user(); - - $validator = Validator::make($request->all(), [ - 'sent_at' => 'required|string' - ]); - - $groupId = (int)$request->query('groupId'); - - if ($validator->fails()) { - return response()->json([ - 'success' => false, - 'errors' => $validator->errors() - ], 422); - } - - $timestamps = explode(',', $request->sent_at); - $startAt = intval($timestamps[0] / 1000); - $endAt = intval($timestamps[1] / 1000); - - // Проверяем период (максимум 1 год) - $daysDiff = ($endAt - $startAt) / (60 * 60 * 24); - if ($daysDiff > 365) { - return response()->json([ - 'success' => false, - 'message' => 'Период не может превышать 1 год' - ], 400); - } - - $dateStart = date('Y-m-d', $startAt); - $dateEnd = date('Y-m-d', $endAt); - - $group = MetrikaGroup::findOrFail($groupId); - - // Оптимизированный агрегированный запрос - $aggregatedData = DB::table('metrika_results as mr') - ->join('metrika_result_values as mv', 'mr.metrika_result_id', '=', 'mv.rf_metrika_result_id') - ->join('reports as r', 'mr.rf_report_id', '=', 'r.report_id') - ->where('mr.rf_metrika_group_id', $groupId) - ->whereBetween('r.sent_at', [$dateStart, $dateEnd]) - ->when(!$user->isAdmin() && !$user->isHeadOfDepartment(), function ($query) use ($user) { - return $query->where('r.rf_user_id', $user->id); - }) - ->select([ - 'mv.rf_metrika_item_id', - DB::raw('SUM(CAST(mv.value AS DECIMAL(10,2))) as total_sum'), - DB::raw('COUNT(DISTINCT r.report_id) as reports_count'), - DB::raw('AVG(CAST(mv.value AS DECIMAL(10,2))) as avg_value') - ]) - ->groupBy('mv.rf_metrika_item_id') - ->get() - ->keyBy('rf_metrika_item_id'); - - if ($aggregatedData->isEmpty()) { - return response()->json([ - 'success' => false, - 'message' => 'Данные за указанный период не найдены' - ], 404); - } - - // Получаем названия метрик одним запросом - $itemIds = $aggregatedData->pluck('rf_metrika_item_id')->toArray(); - $items = MetrikaItem::whereIn('metrika_item_id', $itemIds) - ->pluck('name', 'metrika_item_id'); - - // Формируем ответ - $formValues = []; - foreach ($aggregatedData as $itemId => $data) { - $formValues["metrika_item_{$itemId}"] = [ - 'sum' => (float) $data->total_sum, - 'average' => (float) $data->avg_value, - 'reports_count' => $data->reports_count, - 'item_name' => $items[$itemId] ?? 'Неизвестный показатель' - ]; - } - - // Получаем структуру формы - $formData = MetrikaForm::getFormData($groupId); - - return Inertia::render('Statistic/Index', [ - 'is_view_only' => true, - 'period' => [ - 'start' => $dateStart, - 'end' => $dateEnd, - 'days' => $daysDiff + 1 - ], - 'group' => [ - 'id' => $group->metrika_group_id, - 'name' => $group->name, - 'description' => $group->description, - ], - 'metrics' => [ - 'total_items' => count($formValues), - 'total_reports' => $aggregatedData->first()->reports_count ?? 0, - 'values' => $formValues, - 'aggregation' => 'sum_and_average' - ], - 'form' => [ - 'fields' => $formData, - 'sections' => $this->groupFieldsBySection($formData) - ] - ]); - } - - private function groupFieldsBySection($fields) - { - $sections = []; - - foreach ($fields as $field) { - $section = $field['section'] ?? 'general'; - - if (!isset($sections[$section])) { - $sections[$section] = [ - 'name' => $this->getSectionName($section), - 'fields' => [] - ]; - } - - $sections[$section]['fields'][] = $field; - } - - return array_values($sections); - } - - private function getSectionName($section) - { - $names = [ - 'general' => 'Основные показатели', - 'admissions' => 'Поступления', - 'discharges' => 'Выписки', - 'additional' => 'Дополнительная информация' - ]; - - return $names[$section] ?? ucfirst($section); - } } diff --git a/app/Services/StatisticsService.php b/app/Services/StatisticsService.php new file mode 100644 index 0000000..6f45131 --- /dev/null +++ b/app/Services/StatisticsService.php @@ -0,0 +1,683 @@ + '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); + } +} diff --git a/resources/js/Pages/Statistic/Index.vue b/resources/js/Pages/Statistic/Index.vue index 4fd7e56..912b9ce 100644 --- a/resources/js/Pages/Statistic/Index.vue +++ b/resources/js/Pages/Statistic/Index.vue @@ -36,6 +36,10 @@ const columns = ref([ }, h(NText, { style: 'font-weight: 600;' }, row.groupName)) } + if (row.isTotalRow) { + return h(NText, { style: 'font-weight: 600;' }, row.department) + } + // Получаем текущие query параметры const { url } = usePage() const currentUrl = new URL(url, window.location.origin) @@ -64,7 +68,14 @@ const columns = ref([ key: 'beds', width: 60, titleAlign: 'center', - align: 'center' + align: 'center', + render: (row) => { + if (row.isTotalRow) { + return h(NText, { style: 'font-weight: 600;' }, row.beds) + } else { + return h(NText, { }, row.beds) + } + } }, { title: 'Поступило', @@ -76,28 +87,56 @@ const columns = ref([ key: 'recipients.all', width: 60, titleAlign: 'center', - align: 'center' + align: 'center', + render: (row) => { + if (row.isTotalRow) { + return h(NText, { style: 'font-weight: 600;' }, row.recipients.all) + } else { + return h(NText, { }, row.recipients.all) + } + } }, { title: 'План', key: 'recipients.plan', width: 60, titleAlign: 'center', - align: 'center' + align: 'center', + render: (row) => { + if (row.isTotalRow) { + return h(NText, { style: 'font-weight: 600;' }, row.recipients.plan) + } else { + return h(NText, { }, row.recipients.plan) + } + } }, { title: 'Экстр', key: 'recipients.emergency', width: 60, titleAlign: 'center', - align: 'center' + align: 'center', + render: (row) => { + if (row.isTotalRow) { + return h(NText, { style: 'font-weight: 600;' }, row.recipients.emergency) + } else { + return h(NText, { }, row.recipients.emergency) + } + } }, { title: 'Перевод', key: 'recipients.transferred', width: 84, titleAlign: 'center', - align: 'center' + align: 'center', + render: (row) => { + if (row.isTotalRow) { + return h(NText, { style: 'font-weight: 600;' }, row.recipients.transferred) + } else { + return h(NText, { }, row.recipients.transferred) + } + } }, ] }, @@ -106,21 +145,42 @@ const columns = ref([ key: 'outcome', width: 84, titleAlign: 'center', - align: 'center' + align: 'center', + render: (row) => { + if (row.isTotalRow) { + return h(NText, { style: 'font-weight: 600;' }, row.outcome) + } else { + return h(NText, { }, row.outcome) + } + } }, { title: 'Состоит', key: 'consist', width: 84, titleAlign: 'center', - align: 'center' + align: 'center', + render: (row) => { + if (row.isTotalRow) { + return h(NText, { style: 'font-weight: 600;' }, row.consist) + } else { + return h(NText, { }, row.consist) + } + } }, { title: '% загруженности', key: 'percentLoadedBeds', width: 84, titleAlign: 'center', - align: 'center' + align: 'center', + render: (row) => { + if (row.isTotalRow) { + return h(NText, { style: 'font-weight: 600;' }, row.percentLoadedBeds) + } else { + return h(NText, { }, row.percentLoadedBeds) + } + } }, { title: 'Операции', @@ -132,14 +192,28 @@ const columns = ref([ key: 'surgical.emergency', width: 60, titleAlign: 'center', - align: 'center' + align: 'center', + render: (row) => { + if (row.isTotalRow) { + return h(NText, { style: 'font-weight: 600;' }, row.surgical.emergency) + } else { + return h(NText, { }, row.surgical.emergency) + } + } }, { title: 'П', key: 'surgical.plan', width: 60, titleAlign: 'center', - align: 'center' + align: 'center', + render: (row) => { + if (row.isTotalRow) { + return h(NText, { style: 'font-weight: 600;' }, row.surgical.plan) + } else { + return h(NText, { }, row.surgical.plan) + } + } }, ] }, @@ -148,7 +222,14 @@ const columns = ref([ key: 'deceased', width: 84, titleAlign: 'center', - align: 'center' + align: 'center', + render: (row) => { + if (row.isTotalRow) { + return h(NText, { style: 'font-weight: 600;' }, row.deceased) + } else { + return h(NText, { }, row.deceased) + } + } }, ]) @@ -156,6 +237,9 @@ const rowProps = (row) => { if (row.isGroupHeader) return { style: `--n-merged-td-color: var(--n-merged-th-color)` } + if (row.isTotalRow) return { + style: `--n-merged-td-color: var(--n-merged-th-color); --n-text-color: var(--n-th-icon-color-active);` + } }