diff --git a/app/Exports/Sheets/StatisticsEconomistData.php b/app/Exports/Sheets/StatisticsEconomistData.php new file mode 100644 index 0000000..893054e --- /dev/null +++ b/app/Exports/Sheets/StatisticsEconomistData.php @@ -0,0 +1,296 @@ + true, 'groupName' => 'СВОДНАЯ ИНФОРМАЦИЯ (ВСЕ ОТДЕЛЕНИЯ)'], + array_merge(['isTotalRow' => true], $this->grandTotals), + ]; + + $this->allRows = array_merge($summary, $this->rows); + } + + public function title(): string + { + return $this->sheetTitle; + } + + public function array(): array + { + return $this->allRows; + } + + /** + * Заголовки (с вложенной структурой) + */ + public function headings(): array + { + $profitTypeLabels = array_values($this->profitTypes); + $countTypes = count($profitTypeLabels); + + $topRow = array_merge( + ['Отделение', 'Поступило по типу оплаты'], + array_fill(0, max($countTypes - 1, 0), ''), + ['Всего'] + ); + + $subRow = array_merge([''], $profitTypeLabels, ['']); + + return [ + // Шапка отчета (первые 3 строки) + [$this->reportName], + ['Дата создания: '.now()->format('d.m.Y H:i:s')], + [$this->formatDateRange()], + [], // Пустая строка для отступа + + // Первый уровень заголовков (с объединением) + $topRow, + + // Второй уровень заголовков (детализация по типам оплаты) + $subRow, + ]; + } + + /** + * Форматирование дат для шапки + */ + protected function formatDateRange(): string + { + if (isset($this->dateRange[0]) && isset($this->dateRange[1])) { + $startAt = Carbon::create($this->dateRange[0])->format('d.m.Y H:i'); + $endAt = Carbon::create($this->dateRange[1])->format('d.m.Y H:i'); + + return 'Период: '.$startAt.' - '.$endAt; + } + + return 'Период: За весь период'; + } + + /** + * Маппинг данных для каждой строки + */ + public function map($row): array + { + $profitTypeIds = array_keys($this->profitTypes); + + // Заголовок группы (название группы отделений / "Сводная информация") + if (isset($row['isGroupHeader']) && $row['isGroupHeader']) { + return array_merge([$row['groupName']], array_fill(0, count($profitTypeIds) + 1, '')); + } + + // Обычное отделение и итоговые строки имеют одинаковую форму: department/counts/total + $counts = $row['counts'] ?? []; + + $cells = [$row['department'] ?? '']; + foreach ($profitTypeIds as $profitTypeId) { + $cells[] = $this->formatZero($counts[$profitTypeId] ?? 0); + } + $cells[] = $this->formatZero($row['total'] ?? 0); + + return $cells; + } + + /** + * Стилизация Excel файла + */ + public function styles(Worksheet $sheet) + { + // Поля (уменьшаем для экономии места) + $sheet->getPageMargins()->setLeft(0.2); + $sheet->getPageMargins()->setRight(0.2); + $sheet->getPageMargins()->setTop(0.2); + $sheet->getPageMargins()->setBottom(0.2); + + $sheet->getPageSetup()->setPaperSize(PageSetup::PAPERSIZE_A4); + $sheet->getPageSetup()->setOrientation(PageSetup::ORIENTATION_LANDSCAPE); + + $sheet->getPageSetup()->setFitToPage(true); + $sheet->getPageSetup()->setFitToWidth(1); + $sheet->getPageSetup()->setFitToHeight(1); + + $highestRow = $sheet->getHighestRow(); + + $countTypes = count($this->profitTypes); + $totalColumns = $countTypes + 2; // Отделение + типы оплаты + Всего + $highestColumn = Coordinate::stringFromColumnIndex($totalColumns); + $firstTypeColumn = Coordinate::stringFromColumnIndex(2); // B + $lastTypeColumn = Coordinate::stringFromColumnIndex(1 + $countTypes); + + // ОБЪЕДИНЕНИЕ ЯЧЕЕК ДЛЯ ШАПКИ (строки 1-3) + $sheet->mergeCells('A1:'.$highestColumn.'1'); + $sheet->mergeCells('A2:'.$highestColumn.'2'); + $sheet->mergeCells('A3:'.$highestColumn.'3'); + + // ОБЪЕДИНЕНИЕ ДЛЯ ОБЫЧНЫХ ЗАГОЛОВКОВ (Отделение, Всего — по одной колонке на 2 строки) + $sheet->mergeCells('A5:A6'); + $sheet->mergeCells($highestColumn.'5:'.$highestColumn.'6'); + + // ОБЪЕДИНЕНИЕ ДЛЯ ВЛОЖЕННОГО ЗАГОЛОВКА "Поступило по типу оплаты" + $sheet->mergeCells($firstTypeColumn.'5:'.$lastTypeColumn.'5'); + $sheet->setCellValue($firstTypeColumn.'5', 'Поступило по типу оплаты'); + + // СТИЛИ ДЛЯ ШАПКИ ОТЧЕТА (строки 1-3) + $sheet->getStyle('A1:A3')->applyFromArray([ + 'font' => [ + 'bold' => true, + 'size' => 14, + ], + 'alignment' => [ + 'horizontal' => Alignment::HORIZONTAL_LEFT, + 'vertical' => Alignment::VERTICAL_CENTER, + ], + ]); + + // СТИЛИ ДЛЯ ЗАГОЛОВКОВ (строки 5 и 6) + $sheet->getStyle('A5:'.$highestColumn.'6')->applyFromArray([ + 'font' => [ + 'bold' => true, + 'color' => ['argb' => '000000'], + 'size' => 11, + ], + 'alignment' => [ + 'horizontal' => Alignment::HORIZONTAL_CENTER, + 'vertical' => Alignment::VERTICAL_CENTER, + 'wrapText' => true, + ], + 'borders' => [ + 'allBorders' => [ + 'borderStyle' => Border::BORDER_THIN, + 'color' => ['argb' => '2d2d30'], + ], + ], + ]); + + // СТИЛИ ДЛЯ ВСЕХ СТРОК С ДАННЫМИ + $indexData = 0; + for ($row = 7; $row <= $highestRow; $row++) { + $currentRowInData = $this->allRows[$indexData]; + + // Заголовки групп (Сводная информация, Хирургические отделения, Терапевтические отделения...) + if (array_key_exists('isGroupHeader', $currentRowInData)) { + $sheet->getStyle('A'.$row.':'.$highestColumn.$row)->applyFromArray([ + 'font' => [ + 'bold' => true, + 'size' => 11, + ], + 'fill' => [ + 'fillType' => Fill::FILL_SOLID, + 'startColor' => ['argb' => 'D9E1F2'], // Светло-синий + ], + ]); + + $sheet->mergeCells('A'.$row.':'.$highestColumn.$row); + } + + // Итоговые строки (ИТОГО:, ОБЩИЕ ИТОГИ:) + if (array_key_exists('isTotalRow', $currentRowInData)) { + $sheet->getStyle('A'.$row.':'.$highestColumn.$row)->applyFromArray([ + 'font' => [ + 'bold' => true, + ], + 'fill' => [ + 'fillType' => Fill::FILL_SOLID, + 'startColor' => ['argb' => 'F2F2F2'], // Серый + ], + 'borders' => [ + 'top' => [ + 'borderStyle' => Border::BORDER_MEDIUM, + 'color' => ['argb' => '000000'], + ], + ], + ]); + } + + if ($indexData < count($this->allRows) - 1) { + $indexData++; + } + } + + // ГРАНИЦЫ ДЛЯ ВСЕЙ ТАБЛИЦЫ + $sheet->getStyle('A5:'.$highestColumn.$highestRow)->applyFromArray([ + 'borders' => [ + 'allBorders' => [ + 'borderStyle' => Border::BORDER_THIN, + 'color' => ['argb' => '000000'], + ], + ], + ]); + + // ВЫРАВНИВАНИЕ ДЛЯ ЧИСЛОВЫХ КОЛОНОК + $sheet->getStyle('B7:'.$highestColumn.$highestRow)->applyFromArray([ + 'alignment' => [ + 'horizontal' => Alignment::HORIZONTAL_CENTER, + ], + ]); + + // ВЫРАВНИВАНИЕ ДЛЯ НАЗВАНИЙ ОТДЕЛЕНИЙ + $sheet->getStyle('A7:A'.$highestRow)->applyFromArray([ + 'alignment' => [ + 'horizontal' => Alignment::HORIZONTAL_LEFT, + 'vertical' => Alignment::VERTICAL_CENTER, + ], + ]); + + // Ширина колонок + $sheet->getColumnDimension('A')->setWidth(30); // Отделение + for ($colIndex = 2; $colIndex <= $totalColumns; $colIndex++) { + $sheet->getColumnDimension(Coordinate::stringFromColumnIndex($colIndex))->setWidth(16); + } + + // Увеличиваем высоту строк с заголовками + $sheet->getRowDimension(5)->setRowHeight(30); + $sheet->getRowDimension(6)->setRowHeight(25); + + return []; + } + + /** + * Форматирование нулевых значений + */ + protected function formatZero($value) + { + if (is_null($value) || $value === '' || $value === []) { + return '—'; + } + + if (is_array($value)) { + return '0'; + } + + if (is_numeric($value) && $value == 0) { + return '0'; + } + + return $value; + } +} diff --git a/app/Exports/Sheets/StatisticsMainDataExport.php b/app/Exports/Sheets/StatisticsMainDataExport.php new file mode 100644 index 0000000..cf126d7 --- /dev/null +++ b/app/Exports/Sheets/StatisticsMainDataExport.php @@ -0,0 +1,414 @@ +sheetTitle; + } + + + public function array(): array + { + return $this->rows; + } + + /** + * Заголовки (с вложенной структурой) + */ + public function headings(): array + { + return [ + // Шапка отчета (первые 3 строки) + [$this->reportName], + ['Дата создания: '.now()->format('d.m.Y H:i:s')], + [$this->formatDateRange()], + [], // Пустая строка для отступа + + // Первый уровень заголовков (с объединением) + [ + 'Отделение', + 'Кол-во коек', + 'Поступило', // Будет объединено с 4 колонками + '', '', '', // Пустые для заполнения + 'Выбыло', + 'Состоит', + 'Ср. койко-день', + 'Пред. опер. койко-день', + '% загруженности', + '% летальности', + 'Операции', // Будет объединено с 2 колонками + '', // Пустые для заполнения + 'Умерло', + 'Мед. персонал', + ], + + // Второй уровень заголовков (детализация) + [ + '', + '', + 'Всего', + 'План', + 'Экстр', + 'Перевод', + '', + '', + '', + '', + '', + '', + 'Э', + 'П', + '', + '', + ], + ]; + } + + /** + * Форматирование дат для шапки + */ + protected function formatDateRange(): string + { + if (isset($this->dateRange[0]) && isset($this->dateRange[1])) { + $startAt = Carbon::create($this->dateRange[0])->format('d.m.Y H:i'); + $endAt = Carbon::create($this->dateRange[1])->format('d.m.Y H:i'); + + return 'Период: '.$startAt.' - '.$endAt; + } + + return 'Период: За весь период'; + } + + /** + * Маппинг данных для каждой строки + */ + public function map($row): array + { + // Заголовок группы (Хирургические отделения, Терапевтические отделения) + if (isset($row['isGroupHeader']) && $row['isGroupHeader']) { + return [ + $row['groupName'], // Название группы + '', '', '', '', '', '', '', '', '', '', '', '', '', + ]; + } + + // Итоговая строка + if (isset($row['isTotalRow']) && $row['isTotalRow']) { + return [ + $row['department'], + $row['beds'] ?? '', + $this->formatZero($row['recipients']['all'] ?? 0), + $this->formatZero($row['recipients']['plan'] ?? 0), + $this->formatZero($row['recipients']['emergency'] ?? 0), + $this->formatZero($row['recipients']['transferred'] ?? 0), + $this->formatZero($row['outcome'] ?? 0), + $this->formatZero($row['consist'] ?? 0), + $this->formatZero($row['averageBedDays'] ?? 0), + $this->formatZero($row['preoperativeDays'] ?? 0), + $this->formatZero($row['percentLoadedBeds'] ?? 0), + $this->formatZero($row['overallLethality'] ?? 0), + $this->formatZero($row['surgical']['emergency'] ?? 0), + $this->formatZero($row['surgical']['plan'] ?? 0), + $this->formatZero($row['deceased'] ?? 0), + $this->formatZero($row['countStaff'] ?? 0), + ]; + } + + // Обычное отделение + return [ + $row['department'] ?? '', + $this->formatZero($row['beds'] ?? 0), + $this->formatZero($row['recipients']['all'] ?? 0), + $this->formatZero($row['recipients']['plan'] ?? 0), + $this->formatZero($row['recipients']['emergency'] ?? 0), + $this->formatZero($row['recipients']['transferred'] ?? 0), + $this->formatZero($row['outcome'] ?? 0), + $this->formatZero($row['consist'] ?? 0), + $this->formatZero($row['averageBedDays'] ?? 0), + $this->formatZero($row['preoperativeDays'] ?? 0), + $this->formatZero($row['percentLoadedBeds'] ?? 0), + $this->formatZero($row['lethality'] ?? 0), + $this->formatZero($row['surgical']['emergency'] ?? 0), + $this->formatZero($row['surgical']['plan'] ?? 0), + $this->formatZero($row['deceased'] ?? 0), + $this->formatZero($row['countStaff'] ?? 0), + ]; + } + + /** + * Стилизация Excel файла + */ + public function styles(Worksheet $sheet) + { + // Поля (уменьшаем для экономии места) + $sheet->getPageMargins()->setLeft(0.2); + $sheet->getPageMargins()->setRight(0.2); + $sheet->getPageMargins()->setTop(0.2); + $sheet->getPageMargins()->setBottom(0.2); + + $sheet->getPageSetup()->setPaperSize(PageSetup::PAPERSIZE_A4); + $sheet->getPageSetup()->setOrientation(PageSetup::ORIENTATION_LANDSCAPE); + + $sheet->getPageSetup()->setFitToPage(true); + $sheet->getPageSetup()->setFitToWidth(1); // 1 страница в ширину + $sheet->getPageSetup()->setFitToHeight(1); // 1 страница в высоту (опционально) + + // $sheet->getPageSetup()->setScale(90); + + $highestRow = $sheet->getHighestRow(); + $highestColumn = $sheet->getHighestColumn(); + + // ОБЪЕДИНЕНИЕ ЯЧЕЕК ДЛЯ ШАПКИ (строки 1-3) + $sheet->mergeCells('A1:'.$highestColumn.'1'); // Наименование + $sheet->mergeCells('A2:'.$highestColumn.'2'); // Дата создания + $sheet->mergeCells('A3:'.$highestColumn.'3'); // Временной интервал + + // ОБЪЕДИНЕНИЕ ДЛЯ ОБЫЧНЫХ ЗАГОЛОВКОВ + $sheet->mergeCells('A5:A6'); + $sheet->mergeCells('B5:B6'); + $sheet->mergeCells('G5:G6'); + $sheet->mergeCells('H5:H6'); + $sheet->mergeCells('I5:I6'); + $sheet->mergeCells('J5:J6'); + $sheet->mergeCells('K5:K6'); + $sheet->mergeCells('L5:L6'); + $sheet->mergeCells('O5:O6'); + $sheet->mergeCells('P5:P6'); + + // ОБЪЕДИНЕНИЕ ДЛЯ ВЛОЖЕННЫХ ЗАГОЛОВКОВ + // Строка 5 (первый уровень заголовков) + $sheet->mergeCells('C5:F5'); // Объединяем "Поступило" (колонки C, D, E, F) + $sheet->mergeCells('M5:N5'); // Объединяем "Операции" (колонки M, N) + + // Устанавливаем значения для объединенных ячеек + $sheet->setCellValue('C5', 'Поступило'); + $sheet->setCellValue('M5', 'Операции'); + + // СТИЛИ ДЛЯ ШАПКИ ОТЧЕТА (строки 1-3) + $sheet->getStyle('A1:A3')->applyFromArray([ + 'font' => [ + 'bold' => true, + 'size' => 14, + ], + 'alignment' => [ + 'horizontal' => Alignment::HORIZONTAL_LEFT, + 'vertical' => Alignment::VERTICAL_CENTER, + ], + ]); + + // СТИЛИ ДЛЯ ЗАГОЛОВКОВ (строки 5 и 6) + $sheet->getStyle('A5:'.$highestColumn.'6')->applyFromArray([ + 'font' => [ + 'bold' => true, + 'color' => ['argb' => '000000'], + 'size' => 11, + ], + 'alignment' => [ + 'horizontal' => Alignment::HORIZONTAL_CENTER, + 'vertical' => Alignment::VERTICAL_CENTER, + 'wrapText' => true, + ], + 'borders' => [ + 'allBorders' => [ + 'borderStyle' => Border::BORDER_THIN, + 'color' => ['argb' => '2d2d30'], + ], + ], + ]); + + // ДОПОЛНИТЕЛЬНЫЕ СТИЛИ ДЛЯ ОБЪЕДИНЕННЫХ ЗАГОЛОВКОВ + $sheet->getStyle('C5')->applyFromArray([ + 'font' => ['bold' => true, 'color' => ['argb' => '000000']], + 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER], + ]); + + $sheet->getStyle('M5')->applyFromArray([ + 'font' => ['bold' => true, 'color' => ['argb' => '000000']], + 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER], + ]); + + // СТИЛИ ДЛЯ ВСЕХ СТРОК С ДАННЫМИ + $indexData = 0; + for ($row = 7; $row <= $highestRow; $row++) { + $cellValue = $sheet->getCell('A'.$row)->getValue(); + + $currentRowInData = $this->rows[$indexData]; + // Заголовки групп (Хирургические отделения, Терапевтические отделения) + if (array_key_exists('isGroupHeader', $currentRowInData)) { + $sheet->getStyle('A'.$row.':'.$highestColumn.$row)->applyFromArray([ + 'font' => [ + 'bold' => true, + 'size' => 11, + ], + 'fill' => [ + 'fillType' => Fill::FILL_SOLID, + 'startColor' => ['argb' => 'D9E1F2'], // Светло-синий + ], + ]); + + // Объединяем ячейки для группы + $sheet->mergeCells('A'.$row.':'.$highestColumn.$row); + } + + // Итоговые строки (ИТОГО:) + if (array_key_exists('isTotalRow', $currentRowInData)) { + $sheet->getStyle('A'.$row.':'.$highestColumn.$row)->applyFromArray([ + 'font' => [ + 'bold' => true, + ], + 'fill' => [ + 'fillType' => Fill::FILL_SOLID, + 'startColor' => ['argb' => 'F2F2F2'], // Серый + ], + 'borders' => [ + 'top' => [ + 'borderStyle' => Border::BORDER_MEDIUM, + 'color' => ['argb' => '000000'], + ], + ], + ]); + } + + if ($indexData < count($this->rows)) { + $indexData++; + } + } + + // ГРАНИЦЫ ДЛЯ ВСЕЙ ТАБЛИЦЫ + $sheet->getStyle('A5:'.$highestColumn.$highestRow)->applyFromArray([ + 'borders' => [ + 'allBorders' => [ + 'borderStyle' => Border::BORDER_THIN, + 'color' => ['argb' => '000000'], + ], + ], + ]); + + // ВЫРАВНИВАНИЕ ДЛЯ ЧИСЛОВЫХ КОЛОНОК + $sheet->getStyle('B7:'.$highestColumn.$highestRow)->applyFromArray([ + 'alignment' => [ + 'horizontal' => Alignment::HORIZONTAL_CENTER, + ], + ]); + + // ВЫРАВНИВАНИЕ ДЛЯ НАЗВАНИЙ ОТДЕЛЕНИЙ + $sheet->getStyle('A7:A'.$highestRow)->applyFromArray([ + 'alignment' => [ + 'horizontal' => Alignment::HORIZONTAL_LEFT, + 'vertical' => Alignment::VERTICAL_CENTER, + ], + ]); + + // Устанавливаем ширину колонок + $sheet->getColumnDimension('A')->setWidth(25); // Отделение + $sheet->getColumnDimension('B')->setWidth(8); // Кол-во коек + $sheet->getColumnDimension('C')->setWidth(8); // Всего + $sheet->getColumnDimension('D')->setWidth(8); // План + $sheet->getColumnDimension('E')->setWidth(8); // Экстр + $sheet->getColumnDimension('F')->setWidth(10); // Перевод + $sheet->getColumnDimension('G')->setWidth(10); // Выбыло + $sheet->getColumnDimension('H')->setWidth(10); // Состоит + $sheet->getColumnDimension('I')->setWidth(10); // Ср. койко-день + $sheet->getColumnDimension('J')->setWidth(10); // Пред. опер. койко-день + $sheet->getColumnDimension('K')->setWidth(10); // % загруженности + $sheet->getColumnDimension('L')->setWidth(8); // % летальности + $sheet->getColumnDimension('M')->setWidth(8); // Операции Э + $sheet->getColumnDimension('N')->setWidth(8); // Операции П + $sheet->getColumnDimension('O')->setWidth(10); // Умерло + $sheet->getColumnDimension('P')->setWidth(10); // Мед. персонал + + // Увеличиваем высоту строк с заголовками + $sheet->getRowDimension(5)->setRowHeight(30); + $sheet->getRowDimension(6)->setRowHeight(25); + + // Добавляем примечание внизу + // $this->addFootnote($sheet, $highestRow, $highestColumn); + + return []; + } + + /** + * Форматирование нулевых значений + */ + protected function formatZero($value) + { + // Если значение null или пустая строка - возвращаем "0" + if (is_null($value) || $value === '' || $value === []) { + return '—'; + } + + // Если это массив (для recipients или surgical) - такого не должно быть, + // но на всякий случай обработаем + if (is_array($value)) { + return '0'; + } + + // Если это число с плавающей точкой и оно равно 0 + if (is_numeric($value) && $value == 0) { + return '0'; + } + + return $value; + } + + /** + * Добавить примечание в конец файла + */ + public function addFootnote(Worksheet $sheet, int $startRow, string $highestColumn): void + { + $footnoteRow = $startRow + 2; // Отступаем 2 строки от таблицы + + $sheet->setCellValue('A'.$footnoteRow, 'ПРИМЕЧАНИЕ:'); + + $sheet->setCellValue('B'.$footnoteRow, '• % загруженности актуален на дату формирования отчёта ('.now()->format('d.m.Y').')'); + $sheet->mergeCells('B'.$footnoteRow.':'.$highestColumn.$footnoteRow); + + $sheet->setCellValue('B'.($footnoteRow + 1), '• Поступление, выбытие, операции — за указанный в шапке период'); + $sheet->mergeCells('B'.($footnoteRow + 1).':'.$highestColumn.($footnoteRow + 1)); + + // Стили для примечания + $sheet->getStyle('A'.$footnoteRow.':'.$highestColumn.($footnoteRow + 1))->applyFromArray([ + 'font' => [ + 'italic' => true, + 'size' => 9, + 'color' => ['argb' => '666666'], + ], + 'alignment' => [ + 'vertical' => Alignment::VERTICAL_CENTER, + ], + ]); + + // Жирный шрифт для слова "ПРИМЕЧАНИЕ" + $sheet->getStyle('A'.$footnoteRow)->applyFromArray([ + 'font' => [ + 'bold' => true, + 'color' => ['argb' => '000000'], + ], + ]); + } +} diff --git a/app/Exports/StatisticsExport.php b/app/Exports/StatisticsExport.php index 8da7033..41a15a9 100644 --- a/app/Exports/StatisticsExport.php +++ b/app/Exports/StatisticsExport.php @@ -2,412 +2,56 @@ namespace App\Exports; -use Illuminate\Support\Carbon; -use Maatwebsite\Excel\Concerns\FromCollection; -use Maatwebsite\Excel\Concerns\WithHeadings; -use Maatwebsite\Excel\Concerns\WithMapping; -use Maatwebsite\Excel\Concerns\WithStyles; -use PhpOffice\PhpSpreadsheet\Style\Alignment; -use PhpOffice\PhpSpreadsheet\Style\Border; -use PhpOffice\PhpSpreadsheet\Style\Fill; -use PhpOffice\PhpSpreadsheet\Worksheet\PageSetup; -use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; +use App\Exports\Sheets\StatisticsEconomistData; +use App\Exports\Sheets\StatisticsMainDataExport; +use App\Models\User; +use Maatwebsite\Excel\Concerns\WithMultipleSheets; -class StatisticsExport implements FromCollection, WithHeadings, WithMapping, WithStyles +class StatisticsExport implements WithMultipleSheets { protected array $data; protected array $dateRange; + protected User $user; + + protected array $profitTypeData; + protected string $reportName; - public function __construct(array $data, array $dateRange, string $reportName = 'Статистика по отделениям') - { + public function __construct( + array $data, + array $dateRange, + User $user, + array $profitTypeData = [], + string $reportName = 'Статистика по отделениям' + ) { $this->data = $data; $this->dateRange = $dateRange; + $this->user = $user; + $this->profitTypeData = $profitTypeData; $this->reportName = $reportName; } - /** - * @return \Illuminate\Support\Collection - */ - public function collection() + public function sheets(): array { - return collect($this->data); - } - - /** - * Заголовки (с вложенной структурой) - */ - public function headings(): array - { - return [ - // Шапка отчета (первые 3 строки) - [$this->reportName], - ['Дата создания: '.now()->format('d.m.Y H:i:s')], - [$this->formatDateRange()], - [], // Пустая строка для отступа - - // Первый уровень заголовков (с объединением) - [ - 'Отделение', - 'Кол-во коек', - 'Поступило', // Будет объединено с 4 колонками - '', '', '', // Пустые для заполнения - 'Выбыло', - 'Состоит', - 'Ср. койко-день', - 'Пред. опер. койко-день', - '% загруженности', - '% смертности', - 'Операции', // Будет объединено с 2 колонками - '', // Пустые для заполнения - 'Умерло', - 'Мед. персонал', - ], - - // Второй уровень заголовков (детализация) - [ - '', - '', - 'Всего', - 'План', - 'Экстр', - 'Перевод', - '', - '', - '', - '', - '', - '', - 'Э', - 'П', - '', - '', - ], + $sheets = [ + new StatisticsMainDataExport('Сводка', $this->reportName, $this->data, $this->dateRange, $this->user), ]; - } - /** - * Форматирование дат для шапки - */ - protected function formatDateRange(): string - { - if (isset($this->dateRange[0]) && isset($this->dateRange[1])) { - $startAt = Carbon::create($this->dateRange[0])->format('d.m.Y H:i'); - $endAt = Carbon::create($this->dateRange[1])->format('d.m.Y H:i'); - - return 'Период: '.$startAt.' - '.$endAt; + // Лист с разбивкой по типам оплаты — только для роли "Экономист" + if ($this->user->isEconomist()) { + $sheets[] = new StatisticsEconomistData( + 'По источникам доходов', + $this->reportName, + $this->profitTypeData['data'] ?? [], + $this->dateRange, + $this->user, + $this->profitTypeData['grandTotals'] ?? [], + $this->profitTypeData['profitTypes'] ?? [], + ); } - return 'Период: За весь период'; - } - - /** - * Маппинг данных для каждой строки - */ - public function map($row): array - { - // Заголовок группы (Хирургические отделения, Терапевтические отделения) - if (isset($row['isGroupHeader']) && $row['isGroupHeader']) { - return [ - $row['groupName'], // Название группы - '', '', '', '', '', '', '', '', '', '', '', '', '', - ]; - } - - // Итоговая строка - if (isset($row['isTotalRow']) && $row['isTotalRow']) { - return [ - $row['department'], - $row['beds'] ?? '', - $this->formatZero($row['recipients']['all'] ?? 0), - $this->formatZero($row['recipients']['plan'] ?? 0), - $this->formatZero($row['recipients']['emergency'] ?? 0), - $this->formatZero($row['recipients']['transferred'] ?? 0), - $this->formatZero($row['outcome'] ?? 0), - $this->formatZero($row['consist'] ?? 0), - $this->formatZero($row['averageBedDays'] ?? 0), - $this->formatZero($row['preoperativeDays'] ?? 0), - $this->formatZero($row['percentLoadedBeds'] ?? 0), - $this->formatZero($row['overallLethality'] ?? 0), - $this->formatZero($row['surgical']['emergency'] ?? 0), - $this->formatZero($row['surgical']['plan'] ?? 0), - $this->formatZero($row['deceased'] ?? 0), - $this->formatZero($row['countStaff'] ?? 0), - ]; - } - - // Обычное отделение - return [ - $row['department'] ?? '', - $this->formatZero($row['beds'] ?? 0), - $this->formatZero($row['recipients']['all'] ?? 0), - $this->formatZero($row['recipients']['plan'] ?? 0), - $this->formatZero($row['recipients']['emergency'] ?? 0), - $this->formatZero($row['recipients']['transferred'] ?? 0), - $this->formatZero($row['outcome'] ?? 0), - $this->formatZero($row['consist'] ?? 0), - $this->formatZero($row['averageBedDays'] ?? 0), - $this->formatZero($row['preoperativeDays'] ?? 0), - $this->formatZero($row['percentLoadedBeds'] ?? 0), - $this->formatZero($row['lethality'] ?? 0), - $this->formatZero($row['surgical']['emergency'] ?? 0), - $this->formatZero($row['surgical']['plan'] ?? 0), - $this->formatZero($row['deceased'] ?? 0), - $this->formatZero($row['countStaff'] ?? 0), - ]; - } - - /** - * Стилизация Excel файла - */ - public function styles(Worksheet $sheet) - { - // Поля (уменьшаем для экономии места) - $sheet->getPageMargins()->setLeft(0.2); - $sheet->getPageMargins()->setRight(0.2); - $sheet->getPageMargins()->setTop(0.2); - $sheet->getPageMargins()->setBottom(0.2); - - $sheet->getPageSetup()->setPaperSize(PageSetup::PAPERSIZE_A4); - $sheet->getPageSetup()->setOrientation(PageSetup::ORIENTATION_LANDSCAPE); - - $sheet->getPageSetup()->setFitToPage(true); - $sheet->getPageSetup()->setFitToWidth(1); // 1 страница в ширину - $sheet->getPageSetup()->setFitToHeight(1); // 1 страница в высоту (опционально) - - // $sheet->getPageSetup()->setScale(90); - - $highestRow = $sheet->getHighestRow(); - $highestColumn = $sheet->getHighestColumn(); - - // ОБЪЕДИНЕНИЕ ЯЧЕЕК ДЛЯ ШАПКИ (строки 1-3) - $sheet->mergeCells('A1:'.$highestColumn.'1'); // Наименование - $sheet->mergeCells('A2:'.$highestColumn.'2'); // Дата создания - $sheet->mergeCells('A3:'.$highestColumn.'3'); // Временной интервал - - // ОБЪЕДИНЕНИЕ ДЛЯ ОБЫЧНЫХ ЗАГОЛОВКОВ - $sheet->mergeCells('A5:A6'); - $sheet->mergeCells('B5:B6'); - $sheet->mergeCells('G5:G6'); - $sheet->mergeCells('H5:H6'); - $sheet->mergeCells('I5:I6'); - $sheet->mergeCells('J5:J6'); - $sheet->mergeCells('K5:K6'); - $sheet->mergeCells('L5:L6'); - $sheet->mergeCells('O5:O6'); - $sheet->mergeCells('P5:P6'); - - // ОБЪЕДИНЕНИЕ ДЛЯ ВЛОЖЕННЫХ ЗАГОЛОВКОВ - // Строка 5 (первый уровень заголовков) - $sheet->mergeCells('C5:F5'); // Объединяем "Поступило" (колонки C, D, E, F) - $sheet->mergeCells('M5:N5'); // Объединяем "Операции" (колонки M, N) - - // Устанавливаем значения для объединенных ячеек - $sheet->setCellValue('C5', 'Поступило'); - $sheet->setCellValue('M5', 'Операции'); - - // СТИЛИ ДЛЯ ШАПКИ ОТЧЕТА (строки 1-3) - $sheet->getStyle('A1:A3')->applyFromArray([ - 'font' => [ - 'bold' => true, - 'size' => 14, - ], - 'alignment' => [ - 'horizontal' => Alignment::HORIZONTAL_LEFT, - 'vertical' => Alignment::VERTICAL_CENTER, - ], - ]); - - // СТИЛИ ДЛЯ ЗАГОЛОВКОВ (строки 5 и 6) - $sheet->getStyle('A5:'.$highestColumn.'6')->applyFromArray([ - 'font' => [ - 'bold' => true, - 'color' => ['argb' => '000000'], - 'size' => 11, - ], - 'alignment' => [ - 'horizontal' => Alignment::HORIZONTAL_CENTER, - 'vertical' => Alignment::VERTICAL_CENTER, - 'wrapText' => true, - ], - 'borders' => [ - 'allBorders' => [ - 'borderStyle' => Border::BORDER_THIN, - 'color' => ['argb' => '2d2d30'], - ], - ], - ]); - - // ДОПОЛНИТЕЛЬНЫЕ СТИЛИ ДЛЯ ОБЪЕДИНЕННЫХ ЗАГОЛОВКОВ - $sheet->getStyle('C5')->applyFromArray([ - 'font' => ['bold' => true, 'color' => ['argb' => '000000']], - 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER], - ]); - - $sheet->getStyle('M5')->applyFromArray([ - 'font' => ['bold' => true, 'color' => ['argb' => '000000']], - 'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER], - ]); - - // СТИЛИ ДЛЯ ВСЕХ СТРОК С ДАННЫМИ - $indexData = 0; - for ($row = 7; $row <= $highestRow; $row++) { - $cellValue = $sheet->getCell('A'.$row)->getValue(); - - $currentRowInData = $this->data[$indexData]; - // Заголовки групп (Хирургические отделения, Терапевтические отделения) - if (array_key_exists('isGroupHeader', $currentRowInData)) { - $sheet->getStyle('A'.$row.':'.$highestColumn.$row)->applyFromArray([ - 'font' => [ - 'bold' => true, - 'size' => 11, - ], - 'fill' => [ - 'fillType' => Fill::FILL_SOLID, - 'startColor' => ['argb' => 'D9E1F2'], // Светло-синий - ], - ]); - - // Объединяем ячейки для группы - $sheet->mergeCells('A'.$row.':'.$highestColumn.$row); - } - - // Итоговые строки (ИТОГО:) - if (array_key_exists('isTotalRow', $currentRowInData)) { - $sheet->getStyle('A'.$row.':'.$highestColumn.$row)->applyFromArray([ - 'font' => [ - 'bold' => true, - ], - 'fill' => [ - 'fillType' => Fill::FILL_SOLID, - 'startColor' => ['argb' => 'F2F2F2'], // Серый - ], - 'borders' => [ - 'top' => [ - 'borderStyle' => Border::BORDER_MEDIUM, - 'color' => ['argb' => '000000'], - ], - ], - ]); - } - - if ($indexData < count($this->data)) { - $indexData++; - } - } - - // ГРАНИЦЫ ДЛЯ ВСЕЙ ТАБЛИЦЫ - $sheet->getStyle('A5:'.$highestColumn.$highestRow)->applyFromArray([ - 'borders' => [ - 'allBorders' => [ - 'borderStyle' => Border::BORDER_THIN, - 'color' => ['argb' => '000000'], - ], - ], - ]); - - // ВЫРАВНИВАНИЕ ДЛЯ ЧИСЛОВЫХ КОЛОНОК - $sheet->getStyle('B7:'.$highestColumn.$highestRow)->applyFromArray([ - 'alignment' => [ - 'horizontal' => Alignment::HORIZONTAL_CENTER, - ], - ]); - - // ВЫРАВНИВАНИЕ ДЛЯ НАЗВАНИЙ ОТДЕЛЕНИЙ - $sheet->getStyle('A7:A'.$highestRow)->applyFromArray([ - 'alignment' => [ - 'horizontal' => Alignment::HORIZONTAL_LEFT, - 'vertical' => Alignment::VERTICAL_CENTER, - ], - ]); - - // Устанавливаем ширину колонок - $sheet->getColumnDimension('A')->setWidth(25); // Отделение - $sheet->getColumnDimension('B')->setWidth(8); // Кол-во коек - $sheet->getColumnDimension('C')->setWidth(8); // Всего - $sheet->getColumnDimension('D')->setWidth(8); // План - $sheet->getColumnDimension('E')->setWidth(8); // Экстр - $sheet->getColumnDimension('F')->setWidth(10); // Перевод - $sheet->getColumnDimension('G')->setWidth(10); // Выбыло - $sheet->getColumnDimension('H')->setWidth(10); // Состоит - $sheet->getColumnDimension('I')->setWidth(10); // Ср. койко-день - $sheet->getColumnDimension('J')->setWidth(10); // Пред. опер. койко-день - $sheet->getColumnDimension('K')->setWidth(10); // % загруженности - $sheet->getColumnDimension('L')->setWidth(8); // % летальности - $sheet->getColumnDimension('M')->setWidth(8); // Операции Э - $sheet->getColumnDimension('N')->setWidth(8); // Операции П - $sheet->getColumnDimension('O')->setWidth(10); // Умерло - $sheet->getColumnDimension('P')->setWidth(10); // Мед. персонал - - // Увеличиваем высоту строк с заголовками - $sheet->getRowDimension(5)->setRowHeight(30); - $sheet->getRowDimension(6)->setRowHeight(25); - - // Добавляем примечание внизу - // $this->addFootnote($sheet, $highestRow, $highestColumn); - - return []; - } - - /** - * Форматирование нулевых значений - */ - protected function formatZero($value) - { - // Если значение null или пустая строка - возвращаем "0" - if (is_null($value) || $value === '' || $value === []) { - return '—'; - } - - // Если это массив (для recipients или surgical) - такого не должно быть, - // но на всякий случай обработаем - if (is_array($value)) { - return '0'; - } - - // Если это число с плавающей точкой и оно равно 0 - if (is_numeric($value) && $value == 0) { - return '0'; - } - - return $value; - } - - /** - * Добавить примечание в конец файла - */ - public function addFootnote(Worksheet $sheet, int $startRow, string $highestColumn): void - { - $footnoteRow = $startRow + 2; // Отступаем 2 строки от таблицы - - $sheet->setCellValue('A'.$footnoteRow, 'ПРИМЕЧАНИЕ:'); - - $sheet->setCellValue('B'.$footnoteRow, '• % загруженности актуален на дату формирования отчёта ('.now()->format('d.m.Y').')'); - $sheet->mergeCells('B'.$footnoteRow.':'.$highestColumn.$footnoteRow); - - $sheet->setCellValue('B'.($footnoteRow + 1), '• Поступление, выбытие, операции — за указанный в шапке период'); - $sheet->mergeCells('B'.($footnoteRow + 1).':'.$highestColumn.($footnoteRow + 1)); - - // Стили для примечания - $sheet->getStyle('A'.$footnoteRow.':'.$highestColumn.($footnoteRow + 1))->applyFromArray([ - 'font' => [ - 'italic' => true, - 'size' => 9, - 'color' => ['argb' => '666666'], - ], - 'alignment' => [ - 'vertical' => Alignment::VERTICAL_CENTER, - ], - ]); - - // Жирный шрифт для слова "ПРИМЕЧАНИЕ" - $sheet->getStyle('A'.$footnoteRow)->applyFromArray([ - 'font' => [ - 'bold' => true, - 'color' => ['argb' => '000000'], - ], - ]); + return $sheets; } } diff --git a/app/Http/Controllers/Web/StatisticController.php b/app/Http/Controllers/Web/StatisticController.php index 9a9a7e0..94ca6a4 100644 --- a/app/Http/Controllers/Web/StatisticController.php +++ b/app/Http/Controllers/Web/StatisticController.php @@ -62,7 +62,10 @@ class StatisticController extends Controller $isRangeOneDay = $this->dateService->isRangeOneDay($startDate, $endDate); $finalData = $this->statisticsService->getStatisticsData($user, $startDate, $endDate, $isRangeOneDay); + $profitTypeData = $user->isEconomist() + ? $this->statisticsService->getProfitTypeBreakdown($user, $startDate, $endDate) + : []; - return Excel::download(new StatisticsExport($finalData['data'], [$startDate, $endDate]), 'statistics.xlsx'); + return Excel::download(new StatisticsExport($finalData['data'], [$startDate, $endDate], $user, $profitTypeData), 'statistics.xlsx'); } } diff --git a/app/Services/Analytics/DataSets/DepartmentStatisticsDataSet.php b/app/Services/Analytics/DataSets/DepartmentStatisticsDataSet.php index ab499ad..9fd6ce1 100644 --- a/app/Services/Analytics/DataSets/DepartmentStatisticsDataSet.php +++ b/app/Services/Analytics/DataSets/DepartmentStatisticsDataSet.php @@ -70,7 +70,7 @@ class DepartmentStatisticsDataSet implements DataSet new Column('avg_bed_days', 'Ср. койко-день', 'measure', 'number', null), new Column('preoperative_days', 'Пред. опер. койко-день', 'measure', 'number', null), new Column('percent_loaded', '% загруженности', 'measure', 'number', 'percent'), - new Column('lethality', '% смертности', 'measure', 'number', 'percent'), + new Column('lethality', '% летальности', 'measure', 'number', 'percent'), new Column('surgery_emergency', 'Операции Э', 'measure', 'number', 'count'), new Column('surgery_plan', 'Операции П', 'measure', 'number', 'count'), new Column('deceased', 'Умерло', 'measure', 'number', 'count'), diff --git a/app/Services/StatisticsService.php b/app/Services/StatisticsService.php index e3e106b..046cea9 100644 --- a/app/Services/StatisticsService.php +++ b/app/Services/StatisticsService.php @@ -16,17 +16,29 @@ use Illuminate\Support\Facades\DB; class StatisticsService { + /** + * Справочник типов оплаты (profit_type_id) — берётся из классификатора МИС (kl_ProfitType). + */ + public const PROFIT_TYPE_LABELS = [ + 0 => 'Не определено', + 3 => 'ОМС', + 4 => 'Бюджет', + 5 => 'Платные услуги', + 6 => 'ДМС', + 7 => 'Другие', + ]; + public function __construct( protected BedDayService $bedDayService, protected PlanCalculator $planCalculator, ) {} - public function getStatisticsData(User $user, string $startDate, string $endDate, bool $isRangeOneDay): array + /** + * Отделения, доступные пользователю, сгруппированные по типу отделения. + */ + private function resolveUserDepartments(User $user) { - $this->bedDayService->clearMemoryCache(); - - // 1. Получаем отделения - $departments = Department::select('department_id', 'name_short', 'rf_department_type', 'user_name', 'order') + return 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) @@ -34,6 +46,14 @@ class StatisticsService ->orderBy((new UserDepartment)->getTable().'.order', 'asc') ->get() ->groupBy('departmentType.name_full'); + } + + public function getStatisticsData(User $user, string $startDate, string $endDate, bool $isRangeOneDay): array + { + $this->bedDayService->clearMemoryCache(); + + // 1. Получаем отделения + $departments = $this->resolveUserDepartments($user); if ($departments->isEmpty()) { return $this->emptyResponse(); @@ -289,6 +309,106 @@ class StatisticsService ]; } + /** + * Количество поступлений по типу оплаты (profit_type_id), с разбивкой по отделениям. + * "Поступление" определяется так же, как метрика "Поступило" на основном листе: + * дата поступления миграции пациента попадает в период конкретного дежурного отчёта. + */ + public function getProfitTypeBreakdown(User $user, string $startDate, string $endDate): array + { + $departments = $this->resolveUserDepartments($user); + + if ($departments->isEmpty()) { + return $this->emptyProfitTypeResponse(); + } + + $allDeptIds = $departments->flatten()->pluck('department_id')->toArray(); + + $rows = DB::table('report_duties as rd') + ->join('report_duty_patients as rdp', 'rdp.report_duty_id', '=', 'rd.id') + ->join('report_duty_migration_patients as rdmp', 'rdmp.medical_history_id', '=', 'rdp.id') + ->whereIn('rd.rf_department_id', $allDeptIds) + ->where('rd.period_start', '>=', $startDate) + ->where('rd.period_end', '<=', $endDate) + ->whereColumn('rdmp.ingoing_date', '>', 'rd.period_start') + ->whereColumn('rdmp.ingoing_date', '<=', 'rd.period_end') + ->select('rd.rf_department_id', 'rdp.profit_type_id', DB::raw('COUNT(DISTINCT rdp.id) as count')) + ->groupBy('rd.rf_department_id', 'rdp.profit_type_id') + ->get(); + + // Поддерживаем коды, которых пока нет в справочнике (на случай новых типов из МИС) + $profitTypeLabels = self::PROFIT_TYPE_LABELS; + foreach ($rows->pluck('profit_type_id')->unique() as $profitTypeId) { + $profitTypeId = (int) $profitTypeId; + if (! array_key_exists($profitTypeId, $profitTypeLabels)) { + $profitTypeLabels[$profitTypeId] = "Тип оплаты #{$profitTypeId}"; + } + } + + $countsByDept = $rows->groupBy('rf_department_id'); + $emptyCounts = array_fill_keys(array_keys($profitTypeLabels), 0); + + $data = []; + $grandTotals = $emptyCounts; + + foreach ($departments as $typeName => $deptList) { + $groupTotals = $emptyCounts; + $groupRows = []; + + foreach ($deptList as $dept) { + $deptId = $dept->department_id; + $counts = $emptyCounts; + + foreach ($countsByDept->get($deptId, collect()) as $row) { + $counts[(int) $row->profit_type_id] = (int) $row->count; + } + + $groupRows[] = [ + 'department' => $dept->user_name ?? $dept->name_short, + 'counts' => $counts, + 'total' => array_sum($counts), + ]; + + foreach ($counts as $profitTypeId => $count) { + $groupTotals[$profitTypeId] += $count; + $grandTotals[$profitTypeId] += $count; + } + } + + $data[] = ['isGroupHeader' => true, 'groupName' => $typeName]; + array_push($data, ...$groupRows); + $data[] = [ + 'isTotalRow' => true, + 'department' => 'ИТОГО:', + 'counts' => $groupTotals, + 'total' => array_sum($groupTotals), + ]; + } + + return [ + 'data' => $data, + 'grandTotals' => [ + 'department' => 'ОБЩИЕ ИТОГИ:', + 'counts' => $grandTotals, + 'total' => array_sum($grandTotals), + ], + 'profitTypes' => $profitTypeLabels, + ]; + } + + private function emptyProfitTypeResponse(): array + { + return [ + 'data' => [], + 'grandTotals' => [ + 'department' => 'ОБЩИЕ ИТОГИ:', + 'counts' => array_fill_keys(array_keys(self::PROFIT_TYPE_LABELS), 0), + 'total' => 0, + ], + 'profitTypes' => self::PROFIT_TYPE_LABELS, + ]; + } + /** * Посуточные ряды по каждому KPI за период — для фоновых спарклайнов. * Источник — суточные дежурные отчёты (report_duties + duty_report_metric_results).