Files
onboard/app/Services/StatisticsService.php
2026-06-17 17:39:15 +09:00

571 lines
24 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
// app/Services/StatisticsService.php
namespace App\Services;
use App\Models\Department;
use App\Models\MetrikaResult;
use App\Models\Report;
use App\Models\ReportDuty;
use App\Models\User;
use App\Models\UserDepartment;
use App\Services\MetricCalculators\PlanCalculator;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class StatisticsService
{
public function __construct(
protected BedDayService $bedDayService,
protected PlanCalculator $planCalculator,
) {}
public function getStatisticsData(User $user, string $startDate, string $endDate, bool $isRangeOneDay): array
{
$this->bedDayService->clearMemoryCache();
// 1. Получаем отделения
$departments = Department::select('department_id', 'name_short', 'rf_department_type', 'user_name', 'order')
->with('departmentType')
->join((new UserDepartment)->getTable(), (new Department)->getTable().'.department_id', (new UserDepartment)->getTable().'.rf_department_id')
->where((new UserDepartment)->getTable().'.rf_user_id', $user->id)
->orderBy('rf_department_type')
->orderBy((new UserDepartment)->getTable().'.order', 'asc')
->get()
->groupBy('departmentType.name_full');
if ($departments->isEmpty()) {
return $this->emptyResponse();
}
$allDeptIds = $departments->flatten()->pluck('department_id')->toArray();
// 2а. Нежелательные события по отделениям за период (прямой запрос)
$unwantedCounts = DB::table('duty_unwanted_events as due')
->join('report_duties as rd', 'rd.id', '=', 'due.report_duty_id')
->whereIn('rd.rf_department_id', $allDeptIds)
->where('rd.period_start', '>=', $startDate)
->where('rd.period_end', '<=', $endDate)
->select('rd.rf_department_id', DB::raw('COUNT(*) as count'))
->groupBy('rd.rf_department_id')
->get()
->keyBy('rf_department_id');
// 2б. Пациенты на контроле по отделениям за период (прямой запрос)
$observableCounts = DB::table('observable_medical_histories as omh')
->join('report_duty_patients as rdp', 'rdp.original_id', '=', 'omh.original_id')
->join('report_duties as rd', 'rd.id', '=', 'rdp.report_duty_id')
->whereIn('rd.rf_department_id', $allDeptIds)
->where('omh.observable_in', '>=', $startDate)
->where('omh.observable_in', '<=', $endDate)
->select('rd.rf_department_id', DB::raw('COUNT(DISTINCT omh.id) as count'))
->groupBy('rd.rf_department_id')
->get()
->keyBy('rf_department_id');
// 2. Получаем ВСЕ метрики за период ОДНИМ запросом
$metrics = DB::table('report_duties as r')
->join('duty_report_metric_results as mr', 'r.id', '=', 'mr.rf_report_id')
->whereIn('r.rf_department_id', $allDeptIds)
->where('r.period_start', '>=', $startDate)
->where('r.period_end', '<=', $endDate)
->select(
'r.rf_department_id',
'mr.rf_metrika_item_id',
DB::raw('SUM(CAST(mr.value AS DECIMAL)) as total'),
DB::raw('COUNT(*) as records_count')
)
->groupBy('r.rf_department_id', 'mr.rf_metrika_item_id')
->get()
->groupBy('rf_department_id');
// 3. Получаем текущих пациентов
$currentPatients = DB::table('report_duties as r')
->join('duty_report_metric_results as mr', 'r.id', '=', 'mr.rf_report_id')
->whereIn('r.rf_department_id', $allDeptIds)
->where('mr.rf_metrika_item_id', 8)
->where('r.period_end', '<=', $endDate)
->select('r.rf_department_id', 'mr.value', 'r.created_at')
->orderBy('r.rf_department_id') // Сначала поле из DISTINCT ON
->orderBy('r.period_end', 'desc') // Потом остальные
->distinct('r.rf_department_id')
->get()
->keyBy('rf_department_id');
// 4. Получаем количество коек
$beds = DB::table('department_metrika_defaults')
->whereIn('rf_department_id', $allDeptIds)
->where('rf_metrika_item_id', 1)
->select('rf_department_id', 'value')
->get()
->keyBy('rf_department_id');
// 5. Собираем данные
$groupedData = [];
$totalsByType = [];
$departmentsPlans = $this->planCalculator->calculate($allDeptIds, $startDate, $endDate);
$grandRecipientPlan = collect($departmentsPlans)->sum('cumulative_plan');
$grandProgressPlan = collect($departmentsPlans)->sum('actual_year_to_date');
foreach ($departments as $typeName => $deptList) {
$groupedData[$typeName] = [];
$totalsByType[$typeName] = $this->initTypeTotals();
foreach ($deptList as $dept) {
$deptId = $dept->department_id;
$lastReportQuery = ReportDuty::where('rf_department_id', $deptId);
if ($isRangeOneDay) {
$lastReportQuery->exactPeriod($startDate, $endDate);
} else {
$lastReportQuery->withinPeriod($startDate, $endDate);
}
$lastReport = $lastReportQuery
->onlySubmitted()
->orderBy('period_end', 'desc')
->first();
// Базовые показатели
$bedsCount = (int) ($beds[$deptId]->value ?? 0);
$currentCount = (int) ($currentPatients[$deptId]->value ?? 0);
// Счетчики
$plan = 0;
$emergency = 0;
$planSurgical = 0;
$emergencySurgical = 0;
$transferred = 0;
$outcome = 0;
$deceased = 0;
$staff = 0;
$observable = (int) ($observableCounts[$deptId]->count ?? 0);
$unwanted = (int) ($unwantedCounts[$deptId]->count ?? 0);
$bedDaysSum = 0;
$avgBedDays = 0;
$preoperativeSum = 0;
$preoperativePatientCount = 0;
$preoperativeTotalRecords = 0;
$preoperativePatientRecords = 0;
$preoperativeAverageSum = 0;
$preoperativeAverageCount = 0;
if (isset($metrics[$deptId])) {
foreach ($metrics[$deptId] as $item) {
$value = (float) $item->total;
match ($item->rf_metrika_item_id) {
4 => $plan = (int) $value,
12 => $emergency = (int) $value,
11 => $planSurgical = (int) $value,
10 => $emergencySurgical = (int) $value,
13 => $transferred = (int) $value,
7 => $outcome = (int) $value,
9 => $deceased = (int) $value,
17 => $staff = (int) $value,
25 => $bedDaysSum += $value,
19 => $lethalitySum = $value,
21 => $preoperativeAverageSum += $value,
26 => $preoperativeSum += $value,
27 => $preoperativePatientCount += (int) $value,
// 24 => $completePlanProgress = (int)$value,
default => null
};
if ((int) $item->rf_metrika_item_id === 21) {
$preoperativeAverageCount += (int) $item->records_count;
}
if ((int) $item->rf_metrika_item_id === 26) {
$preoperativeTotalRecords += (int) $item->records_count;
}
if ((int) $item->rf_metrika_item_id === 27) {
$preoperativePatientRecords += (int) $item->records_count;
}
}
}
// Расчеты
$allCount = $plan + $emergency;
$percentLoaded = $bedsCount > 0 ? round($currentCount * 100 / $bedsCount) : 0;
// Средний койко-день
$avgBedDays = $outcome > 0 ? round($bedDaysSum / $outcome, 1) : 0;
// Предоперационный койко-день
$canUsePreoperativeTotals = $preoperativePatientCount > 0
&& $preoperativeTotalRecords > 0
&& $preoperativePatientRecords >= $preoperativeTotalRecords;
$preoperativeValue = $canUsePreoperativeTotals
? round($preoperativeSum / $preoperativePatientCount, 1)
: ($preoperativeAverageCount > 0 ? round($preoperativeAverageSum / $preoperativeAverageCount, 1) : 0);
// Летальность
$lethality = $outcome > 0 ? round(($deceased / $outcome) * 100, 2) : 0;
$departmentName = $dept->user_name ?? $dept->name_short;
$data = [
'department' => $departmentName,
'department_id' => $deptId,
'beds' => $bedsCount,
'recipients' => [
'all' => $allCount,
'plan' => $plan,
'emergency' => $emergency,
'transferred' => $transferred,
],
'outcome' => $outcome,
'consist' => $currentCount,
'percentLoadedBeds' => $percentLoaded,
'surgical' => [
'plan' => $planSurgical,
'emergency' => $emergencySurgical,
],
'deceased' => $deceased,
'countStaff' => $staff,
'countObservable' => $observable,
'countUnwanted' => $unwanted,
'averageBedDays' => $avgBedDays,
'bedDaysSum' => $bedDaysSum,
'preoperativeDays' => $preoperativeValue,
'preoperativeSum' => $preoperativeSum,
'preoperativePatientCount' => $preoperativePatientCount,
'plan' => $departmentsPlans[$deptId],
// 'progressPlanOfYear' => $cumulativePlan,
// 'percentPlanOfYear' => $percentPlanOfYear,
// 'needPlanOfYear' => $cumulativePlan > 0 && $cumulativePlan > $outcome
// ? $cumulativePlan - $outcome
// : 0,
// Плановые показатели
// 'cumulative_plan' => $cumulativePlan, // План с начала года нарастающим (включая текущий месяц)
// 'current_month_plan_only' => $currentMonthPlanOnly, // План только на текущий месяц
// 'debt_from_year_start' => $debtFromYearStart, // Долг с начала года (невыполненный план за прошлые месяцы)
// 'total_debt' => $totalDebt, // ИТОГО долг = план на текущий месяц + долг с начала года
// 'currentMonthDebt' => $currentMonthDebt, // Выписать по плану в текущем месяце
// Фактические показатели
// 'actual_year_to_date' => $actualToDate, // Факт с начала года (включая текущий месяц)
// Процент выполнения
// 'cumulative_percent' => $cumulativePlan > 0 ? round($actualToDate * 100 / $cumulativePlan, 2) : 0,
'lethality' => $lethality,
'type' => $typeName,
'isDepartment' => true,
'isReportToday' => $lastReport ? Carbon::parse($lastReport->period_end)->isSameDay($endDate) : null,
];
$groupedData[$typeName][] = $data;
$this->updateTypeTotals($totalsByType[$typeName], $data);
}
}
$grandTotals = $this->calculateGrandTotals($totalsByType);
$bedsTotal = (int) collect($beds)->sum('value');
// Посуточные ряды для фон-спарклайнов KPI-карточек
$grandTotals['sparklines'] = $this->calculateDailySparklines(
$allDeptIds,
$startDate,
$endDate,
$bedsTotal
);
// KPI за предыдущий аналогичный период (для бейджей тренда)
$grandTotals['previous'] = $this->calculatePreviousKpis($allDeptIds, $startDate, $endDate, $bedsTotal);
return [
'data' => $this->buildFinalData($groupedData, $totalsByType),
'totalsByType' => $totalsByType,
'grandTotals' => $grandTotals,
'recipientPlanOfYear' => [
'plan' => $grandRecipientPlan, // Сумма планов по периоду
'progress' => $grandProgressPlan, // Сумма фактов по периоду
],
];
}
/**
* Посуточные ряды по каждому KPI за период — для фоновых спарклайнов.
* Источник — суточные дежурные отчёты (report_duties + duty_report_metric_results).
*/
private function calculateDailySparklines(array $deptIds, string $startDate, string $endDate, int $bedsTotal): array
{
if (empty($deptIds)) {
return ['days' => [], 'consist' => [], 'admissions' => [], 'outcome' => [], 'operations' => [], 'deceased' => [], 'load' => []];
}
$rows = DB::table('report_duties as r')
->join('duty_report_metric_results as mr', 'r.id', '=', 'mr.rf_report_id')
->whereIn('r.rf_department_id', $deptIds)
->where('r.period_end', '>=', $startDate)
->where('r.period_end', '<=', $endDate)
->whereIn('mr.rf_metrika_item_id', [4, 7, 8, 9, 10, 11, 12])
->select(
DB::raw('DATE(r.period_end) as day'),
'mr.rf_metrika_item_id as metric',
DB::raw('SUM(CAST(mr.value AS DECIMAL)) as total')
)
->groupBy(DB::raw('DATE(r.period_end)'), 'mr.rf_metrika_item_id')
->orderBy('day')
->get();
// day => [metric_id => total]
$byDay = [];
foreach ($rows as $row) {
$byDay[(string) $row->day][(int) $row->metric] = (float) $row->total;
}
ksort($byDay);
$days = array_keys($byDay);
$consist = $admissions = $outcome = $operations = $deceased = $load = [];
foreach ($byDay as $m) {
$c = (int) ($m[8] ?? 0);
$consist[] = $c;
$admissions[] = (int) (($m[4] ?? 0) + ($m[12] ?? 0));
$outcome[] = (int) ($m[7] ?? 0);
$operations[] = (int) (($m[11] ?? 0) + ($m[10] ?? 0));
$deceased[] = (int) ($m[9] ?? 0);
$load[] = $bedsTotal > 0 ? (int) round($c / $bedsTotal * 100) : 0;
}
return compact('days', 'consist', 'admissions', 'outcome', 'operations', 'deceased', 'load');
}
/**
* KPI за предыдущий аналогичный период: столько же, вплотную перед выбранным.
* Сдвигаем оба конца назад на (длина периода + 1 день) — так предыдущий идёт встык,
* без нахлёста (для одних суток сравнение с предыдущими сутками).
*/
private function calculatePreviousKpis(array $deptIds, string $startDate, string $endDate, int $bedsTotal): array
{
$start = Carbon::parse($startDate);
$end = Carbon::parse($endDate);
$shiftSeconds = $start->diffInSeconds($end) + 86400;
$prevStart = $start->copy()->subSeconds($shiftSeconds);
$prevEnd = $end->copy()->subSeconds($shiftSeconds);
return $this->kpiTotalsForRange(
$deptIds,
$prevStart->format('Y-m-d H:i:s'),
$prevEnd->format('Y-m-d H:i:s'),
$bedsTotal
);
}
/**
* Сводные KPI за произвольный диапазон (суммы метрик + состав на конец периода).
*/
private function kpiTotalsForRange(array $deptIds, string $start, string $end, int $bedsTotal): array
{
$empty = ['consist' => 0, 'admissions' => 0, 'outcome' => 0, 'operations' => 0, 'deceased' => 0, 'load' => 0];
if (empty($deptIds)) {
return $empty;
}
// Суммы метрик за период
$sums = DB::table('report_duties as r')
->join('duty_report_metric_results as mr', 'r.id', '=', 'mr.rf_report_id')
->whereIn('r.rf_department_id', $deptIds)
->where('r.period_start', '>=', $start)
->where('r.period_end', '<=', $end)
->whereIn('mr.rf_metrika_item_id', [4, 7, 9, 10, 11, 12])
->select('mr.rf_metrika_item_id as metric', DB::raw('SUM(CAST(mr.value AS DECIMAL)) as total'))
->groupBy('mr.rf_metrika_item_id')
->get()
->keyBy(fn ($r) => (int) $r->metric);
$g = fn ($id) => (int) ($sums[$id]->total ?? 0);
// Состав на конец периода: последний отчёт по каждому отделению ≤ end, суммируем
$consistRows = DB::table('report_duties as r')
->join('duty_report_metric_results as mr', 'r.id', '=', 'mr.rf_report_id')
->whereIn('r.rf_department_id', $deptIds)
->where('mr.rf_metrika_item_id', 8)
->where('r.period_end', '<=', $end)
->select('r.rf_department_id', 'mr.value')
->orderBy('r.rf_department_id')
->orderBy('r.period_end', 'desc')
->distinct('r.rf_department_id')
->get();
$consist = (int) $consistRows->sum(fn ($x) => (float) $x->value);
return [
'consist' => $consist,
'admissions' => $g(4) + $g(12),
'outcome' => $g(7),
'operations' => $g(11) + $g(10),
'deceased' => $g(9),
'load' => $bedsTotal > 0 ? (int) round($consist / $bedsTotal * 100) : 0,
];
}
/**
* Инициализация итогов по типу
*/
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,
'bedDaysSum' => 0,
'preoperativeSum' => 0,
'preoperativePatientCount' => 0,
'percentLoaded' => 0,
'staff_sum' => 0,
'observable_sum' => 0,
'unwanted_sum' => 0,
];
}
/**
* Обновление итогов по типу
*/
private function updateTypeTotals(array &$totals, array $data): void
{
$totals['departments_count']++;
$totals['beds_sum'] += $data['beds'];
$totals['recipients_all_sum'] += $data['recipients']['all'];
$totals['recipients_plan_sum'] += $data['recipients']['plan'];
$totals['recipients_emergency_sum'] += $data['recipients']['emergency'];
$totals['recipients_transferred_sum'] += $data['recipients']['transferred'];
$totals['outcome_sum'] += $data['outcome'];
$totals['consist_sum'] += $data['consist'];
$totals['plan_surgical_sum'] += $data['surgical']['plan'];
$totals['emergency_surgical_sum'] += $data['surgical']['emergency'];
$totals['deceased_sum'] += $data['deceased'];
$totals['percentLoadedBeds_total'] += $data['percentLoadedBeds'];
$totals['percentLoadedBeds_count']++;
$totals['bedDaysSum'] += $data['bedDaysSum'];
$totals['preoperativeSum'] += $data['preoperativeSum'];
$totals['preoperativePatientCount'] += $data['preoperativePatientCount'];
$totals['staff_sum'] += $data['countStaff'];
$totals['observable_sum'] += $data['countObservable'];
$totals['unwanted_sum'] += $data['countUnwanted'];
}
/**
* Расчет общих итогов
*/
private function calculateGrandTotals(array $totalsByType): array
{
$grand = $this->initTypeTotals();
foreach ($totalsByType as $totals) {
foreach ($grand as $key => &$value) {
if (isset($totals[$key])) {
$value += $totals[$key];
}
}
}
return $grand;
}
/**
* Построение финальных данных
*/
private function buildFinalData(array $groupedData, array $totalsByType): array
{
$final = [];
foreach ($groupedData as $type => $items) {
$final[] = [
'isGroupHeader' => true,
'groupName' => $type,
'colspan' => 16,
];
foreach ($items as $item) {
$final[] = $item;
}
if (! empty($items) && isset($totalsByType[$type])) {
$final[] = $this->createTotalRow($type, $totalsByType[$type], false);
}
}
return $final;
}
/**
* Создание строки итогов
*/
private function createTotalRow(string $type, array $total, bool $isGrandTotal): array
{
$outcomeSum = $total['outcome_sum'];
$preopPatients = $total['preoperativePatientCount'];
$consistSum = $total['consist_sum'] ?? 0;
$bedsSum = $total['beds_sum'] ?? 0;
return [
'isTotalRow' => ! $isGrandTotal,
'isGrandTotal' => $isGrandTotal,
'department' => $isGrandTotal ? 'ОБЩИЕ ИТОГИ:' : 'ИТОГО:',
'beds' => $bedsSum,
'recipients' => [
'all' => $total['recipients_all_sum'],
'plan' => $total['recipients_plan_sum'],
'emergency' => $total['recipients_emergency_sum'],
'transferred' => $total['recipients_transferred_sum'],
],
'outcome' => $outcomeSum,
'consist' => $consistSum,
'percentLoadedBeds' => $consistSum > 0
? round(($consistSum / $bedsSum) * 100)
: 0, // '—',
'surgical' => [
'plan' => $total['plan_surgical_sum'],
'emergency' => $total['emergency_surgical_sum'],
],
'deceased' => $total['deceased_sum'],
'averageBedDays' => $outcomeSum > 0
? round($total['bedDaysSum'] / $outcomeSum, 1)
: 0,
'preoperativeDays' => $preopPatients > 0
? round($total['preoperativeSum'] / $preopPatients, 1)
: 0,
'lethality' => $outcomeSum > 0
? round(($total['deceased_sum'] / $outcomeSum) * 100, 2)
: 0,
'type' => $type,
'departments_count' => $total['departments_count'],
'countStaff' => $total['staff_sum'],
'countObservable' => $total['observable_sum'],
'countUnwanted' => $total['unwanted_sum'],
'isBold' => true,
];
}
/**
* Пустой ответ
*/
private function emptyResponse(): array
{
return [
'data' => [],
'totalsByType' => [],
'grandTotals' => $this->initTypeTotals(),
];
}
}