|null> */ private array $cache = []; /** * @var array */ private array $departmentOptionsBySlug; public function __construct( private readonly DepartmentCatalog $departmentCatalog, ) { $this->departmentOptionsBySlug = Department::query() ->where('is_active', true) ->get(['id', 'name']) ->mapWithKeys(fn (Department $department): array => [ $this->slug($department->name) => $department->id, ]) ->all(); } /** * @return array|null */ public function templateForDepartment(string $reportDepartmentKey): ?array { if (array_key_exists($reportDepartmentKey, $this->cache)) { return $this->cache[$reportDepartmentKey]; } $department = collect($this->departmentCatalog->departments()) ->firstWhere('key', $reportDepartmentKey); if ($department === null) { return $this->cache[$reportDepartmentKey] = null; } $manualTemplate = $this->manualTemplate($department); if ($manualTemplate !== null) { return $this->cache[$reportDepartmentKey] = $manualTemplate; } $source = $this->preferredSource($department['sources'] ?? []); if ($source === null) { return $this->cache[$reportDepartmentKey] = null; } $template = match ($source['extension']) { 'pdf' => $this->pdfTemplate($department, $source), 'xlsx' => $this->xlsxTemplate($department, $source), default => null, }; return $this->cache[$reportDepartmentKey] = $template; } /** * @param array $department * @return array|null */ private function manualTemplate(array $department): ?array { return match ($department['template_name'] ?? $department['name']) { 'Рентгенологическое отделение' => $this->manualSectionedDepartmentTemplate( $department, [ [ 'key' => 'adult_polyclinic', 'title' => 'Поликлиника взрослая', 'fields' => [ ['key' => 'study_name', 'label' => 'Исследование', 'type' => 'text'], ['key' => 'units', 'label' => 'Единицы', 'type' => 'number'], ['key' => 'people_count', 'label' => 'Кол-во человек', 'type' => 'number'], ['key' => 'research_count', 'label' => 'Кол-во исследований', 'type' => 'number'], ['key' => 'copies_count', 'label' => 'Скопии', 'type' => 'number'], ['key' => 'barium', 'label' => 'Барий', 'type' => 'number'], ['key' => 'contrast', 'label' => 'Контраст', 'type' => 'number'], ], ], [ 'key' => 'child_polyclinic', 'title' => 'Поликлиника детская', 'fields' => [ ['key' => 'study_name', 'label' => 'Исследование', 'type' => 'text'], ['key' => 'units', 'label' => 'Единицы', 'type' => 'number'], ['key' => 'people_count', 'label' => 'Кол-во человек', 'type' => 'number'], ['key' => 'research_count', 'label' => 'Кол-во исследований', 'type' => 'number'], ['key' => 'copies_count', 'label' => 'Скопии', 'type' => 'number'], ['key' => 'barium', 'label' => 'Барий', 'type' => 'number'], ['key' => 'contrast', 'label' => 'Контраст', 'type' => 'number'], ], ], [ 'key' => 'inpatient', 'title' => 'Стационар', 'fields' => [ ['key' => 'study_name', 'label' => 'Исследование', 'type' => 'text'], ['key' => 'units', 'label' => 'Единицы', 'type' => 'number'], ['key' => 'people_count', 'label' => 'Кол-во человек', 'type' => 'number'], ['key' => 'research_count', 'label' => 'Кол-во исследований', 'type' => 'number'], ['key' => 'copies_count', 'label' => 'Скопии', 'type' => 'number'], ['key' => 'barium', 'label' => 'Барий', 'type' => 'number'], ['key' => 'contrast', 'label' => 'Контраст', 'type' => 'number'], ], ], [ 'key' => 'ct_115', 'title' => 'КТ №115', 'fields' => [ ['key' => 'study_name', 'label' => 'Исследование', 'type' => 'text'], ['key' => 'units', 'label' => 'Единицы', 'type' => 'number'], ['key' => 'people_count', 'label' => 'Количество человек', 'type' => 'number'], ['key' => 'research_count', 'label' => 'Количество исследований', 'type' => 'number'], ['key' => 'contrast', 'label' => 'Контраст', 'type' => 'number'], ], ], [ 'key' => 'ct_125', 'title' => 'КТ №125', 'fields' => [ ['key' => 'study_name', 'label' => 'Исследование', 'type' => 'text'], ['key' => 'units', 'label' => 'Единицы', 'type' => 'number'], ['key' => 'people_count', 'label' => 'Количество человек', 'type' => 'number'], ['key' => 'research_count', 'label' => 'Количество исследований', 'type' => 'number'], ['key' => 'contrast', 'label' => 'Контраст', 'type' => 'number'], ], ], ], 'rentgen-manual', 'Ввод разделен по секциям исходного отчета. Экономистам отдаются только итоги по каждой секции.' ), 'Отделение РХМДиЛ' => $this->manualDepartmentTemplate( $department, [ ['key' => 'operation_name', 'label' => 'Наименование операции', 'type' => 'text'], ['key' => 'total_count', 'label' => 'Всего', 'type' => 'number'], ['key' => 'paid_count', 'label' => 'Платные', 'type' => 'number'], ['key' => 'oms_count', 'label' => 'ОМС', 'type' => 'number'], ['key' => 'emergency_count', 'label' => 'Экстренные', 'type' => 'number'], ['key' => 'planned_count', 'label' => 'Плановые', 'type' => 'number'], ['key' => 'vmp_count', 'label' => 'ВМП', 'type' => 'number'], ], 'rxmdil-manual', 'Поля собраны по шапке отчета РХМДиЛ.' ), 'Отделение функциональной диагностики' => $this->manualDepartmentTemplate( $department, [ ['key' => 'department_id', 'label' => 'Отделение', 'type' => 'department-select'], ['key' => 'eeg', 'label' => 'ЭЭГ', 'type' => 'number'], ['key' => 'holter', 'label' => 'Холтер', 'type' => 'number'], ['key' => 'echo_es', 'label' => 'Эхо-Эс', 'type' => 'number'], ['key' => 'reg', 'label' => 'РЭГ', 'type' => 'number'], ['key' => 'emg_needle', 'label' => 'ЭМГ игол.', 'type' => 'number'], ['key' => 'eng', 'label' => 'ЭНГ', 'type' => 'number'], ['key' => 'nerve_muscle', 'label' => 'Нер. мыш', 'type' => 'number'], ['key' => 'uzdg_mag', 'label' => 'УЗДГ МАГ', 'type' => 'number'], ['key' => 'uzdg_ps', 'label' => 'УЗДГ ПС', 'type' => 'number'], ['key' => 'zvp', 'label' => 'ЗВП', 'type' => 'number'], ['key' => 'kasvp', 'label' => 'КАСВП', 'type' => 'number'], ['key' => 'ssvp', 'label' => 'ССВП', 'type' => 'number'], ['key' => 'smad', 'label' => 'СМАД', 'type' => 'number'], ['key' => 'total', 'label' => 'Итог', 'type' => 'number'], ], 'functional-diagnostics-manual', 'Поля собраны по шапке отчета функциональной диагностики.' ), 'Отделение анастезиологии - реанимации ОПЦ' => $this->manualDepartmentTemplate( $department, [ ['key' => 'department_id', 'label' => 'Отделение', 'type' => 'department-select'], ['key' => 'bed_days', 'label' => 'Койко-дни', 'type' => 'number'], ], 'anesthesiology-opc-manual', 'Поля собраны по отчету по койко-дням.' ), 'Отделение диализа' => $this->manualDepartmentTemplate( $department, [ ['key' => 'department_id', 'label' => 'Отделение', 'type' => 'department-select'], ['key' => 'hemodialysis_count', 'label' => 'Гемодиализов', 'type' => 'number'], ], 'dialysis-manual', 'Поля собраны по отчету отделения диализа.' ), 'Отделение ультразвуковой диагностики ОПЦ' => $this->manualDepartmentTemplate( $department, [ ['key' => 'department_id', 'label' => 'Отделение', 'type' => 'department-select'], ['key' => 'research_count', 'label' => 'Количество исследований', 'type' => 'number'], ['key' => 'units_count', 'label' => 'Количество условных единиц', 'type' => 'number'], ], 'uzd-opc-manual', 'Поля собраны по шапке отчета УЗД ОПЦ.' ), 'Централизованное стерилизационное отделение' => $this->manualDepartmentTemplate( $department, [ ['key' => 'department_id', 'label' => 'Отделение', 'type' => 'department-select'], ['key' => 'packages_count', 'label' => 'Количество упаковок', 'type' => 'number'], ], 'sterilization-manual', 'Поля собраны по отчету централизованного стерилизационного отделения.' ), default => null, }; } /** * @param list> $sources * @return array|null */ private function preferredSource(array $sources): ?array { return collect($sources) ->sortBy(fn (array $source): int => match ($source['extension'] ?? null) { 'xlsx' => 0, 'pdf' => 1, default => 2, }) ->first(); } /** * @param array $department * @param list> $fields * @return array */ private function manualDepartmentTemplate( array $department, array $fields, string $suffix, string $description, ): array { $emptyEntry = collect($fields) ->mapWithKeys(fn (array $field): array => [$field['key'] => '']) ->all(); return [ 'key' => $department['key'].'-'.$suffix, 'title' => $department['name'], 'description' => $description, 'fields' => $fields, 'department_options' => $this->departmentOptions(), 'empty_entry' => $emptyEntry, 'default_entries' => [$emptyEntry], ]; } /** * @param array $department * @param list>}> $sections * @return array */ private function manualSectionedDepartmentTemplate( array $department, array $sections, string $suffix, string $description, ): array { return [ 'key' => $department['key'].'-'.$suffix, 'title' => $department['name'], 'description' => $description, 'department_options' => $this->departmentOptions(), 'sections' => array_map(function (array $section) use ($department): array { $emptyEntry = collect($section['fields']) ->mapWithKeys(fn (array $field): array => [$field['key'] => '']) ->all(); return [ 'key' => $section['key'], 'title' => $section['title'], 'economist_label' => $department['name'].' / '.$section['title'], 'fields' => $section['fields'], 'empty_entry' => $emptyEntry, 'default_entries' => [$emptyEntry], ]; }, $sections), ]; } /** * @param array $department * @param array $source * @return array|null */ private function pdfTemplate(array $department, array $source): ?array { $process = new Process(['pdftotext', '-layout', $source['path'], '-']); $process->run(); if (! $process->isSuccessful()) { throw new RuntimeException($process->getErrorOutput() ?: 'Unable to read PDF source.'); } $text = str_replace("\f", "\n", $process->getOutput()); $lines = collect(preg_split('/\R/u', $text) ?: []) ->map(fn (string $line): string => rtrim($line)) ->filter(fn (string $line): bool => trim($line) !== '') ->values() ->all(); return $this->templateFromLines($department, $source, $lines); } /** * @param array $department * @param array $source * @return array|null */ private function xlsxTemplate(array $department, array $source): ?array { $archive = new ZipArchive; if ($archive->open($source['path']) !== true) { throw new RuntimeException('Unable to open XLSX source.'); } $sharedStrings = $this->sharedStrings($archive); $relations = $this->workbookRelations($archive); $workbookXml = $this->loadXml($archive, 'xl/workbook.xml'); $bestLines = []; foreach ($workbookXml->sheets->sheet as $sheetNode) { $relationId = (string) $sheetNode->attributes('r', true)->id; $sheetPath = Arr::get($relations, $relationId); if ($sheetPath === null) { continue; } $lines = $this->sheetLines($this->loadXml($archive, $sheetPath), $sharedStrings); if (count($lines) > count($bestLines)) { $bestLines = $lines; } } $archive->close(); return $this->templateFromRows($department, $source, $bestLines); } /** * @param array $department * @param array $source * @param list $lines * @return array|null */ private function templateFromLines(array $department, array $source, array $lines): ?array { $segments = array_map(fn (string $line): array => $this->lineSegments($line), $lines); $dataStartIndex = $this->pdfDataStartIndex($segments); if ($dataStartIndex === null) { return null; } $headerIndexes = []; for ($index = max(0, $dataStartIndex - 3); $index < $dataStartIndex; $index++) { if (count($segments[$index]) >= 2) { $headerIndexes[] = $index; } } if ($headerIndexes === []) { return null; } $mainHeaderIndex = collect($headerIndexes) ->sortByDesc(fn (int $index): int => count($segments[$index])) ->first(); $mainColumns = $segments[$mainHeaderIndex]; $columnStarts = array_column($mainColumns, 'start'); $columnLabels = array_fill(0, count($mainColumns), ''); foreach ($headerIndexes as $headerIndex) { foreach (array_values($segments[$headerIndex]) as $position => $segment) { if (! isset($columnLabels[$position])) { continue; } $columnLabels[$position] = trim($columnLabels[$position].' '.$segment['text']); } } return $this->buildTemplate( $department, $source, $columnLabels, array_slice($lines, $dataStartIndex), $columnStarts, ); } /** * @param array $department * @param array $source * @param list> $rows * @return array|null */ private function templateFromRows(array $department, array $source, array $rows): ?array { $rows = array_values(array_filter( $rows, fn (array $row): bool => count(array_filter($row, fn (string $value): bool => trim($value) !== '')) >= 2, )); $dataStartIndex = null; foreach ($rows as $index => $row) { if ($this->isRowData($row)) { $dataStartIndex = $index; break; } } if ($dataStartIndex === null || $dataStartIndex === 0) { return null; } $headerRows = array_slice($rows, max(0, $dataStartIndex - 2), min(2, $dataStartIndex)); $maxColumns = max(array_map('count', $headerRows)); $columnLabels = []; for ($column = 0; $column < $maxColumns; $column++) { $parts = []; foreach ($headerRows as $row) { $value = trim((string) ($row[$column] ?? '')); if ($value !== '') { $parts[] = $value; } } $columnLabels[] = trim(implode(' ', $parts)); } return $this->buildTemplate( $department, $source, $columnLabels, array_map(fn (array $row): string => implode(' ', $row), array_slice($rows, $dataStartIndex)), null, array_slice($rows, $dataStartIndex), ); } /** * @param array $department * @param array $source * @param list $columnLabels * @param list $rawRows * @param list|null $columnStarts * @param list>|null $xlsxRows * @return array|null */ private function buildTemplate( array $department, array $source, array $columnLabels, array $rawRows, ?array $columnStarts = null, ?array $xlsxRows = null, ): ?array { $normalizedColumns = collect($columnLabels) ->map(fn (string $label): string => trim(preg_replace('/\s+/u', ' ', $label) ?? $label)) ->values() ->all(); $keptIndexes = []; $fields = []; foreach ($normalizedColumns as $index => $label) { if ($label === '' || preg_match('/^№|^№ п\/п|^n$/iu', $label) === 1) { continue; } $type = $this->fieldType($label, count($fields) === 0); $keptIndexes[] = $index; $fields[] = [ 'key' => $this->fieldKey($label, $index), 'label' => $label, 'type' => $type, ]; } if ($fields === []) { return null; } $entries = []; foreach ($rawRows as $rowIndex => $rawRow) { $cells = $xlsxRows !== null ? ($xlsxRows[$rowIndex] ?? []) : $this->slicePdfRow($rawRow, $columnStarts ?? []); if ($cells === []) { continue; } $entry = []; $hasValue = false; foreach ($fields as $fieldIndex => $field) { $sourceIndex = $keptIndexes[$fieldIndex]; $cellValue = trim((string) ($cells[$sourceIndex] ?? '')); if ($fieldIndex === 0) { if ($field['type'] === 'department-select') { $entry[$field['key']] = (string) ($this->departmentOptionsBySlug[$this->slug($cellValue)] ?? ''); $hasValue = $hasValue || $entry[$field['key']] !== ''; } else { $entry[$field['key']] = $cellValue; $hasValue = $hasValue || $cellValue !== ''; } continue; } if ($this->containsLetters($cellValue)) { $entry[$field['key']] = ''; continue; } $entry[$field['key']] = ''; $hasValue = $hasValue || $cellValue !== ''; } if (! $hasValue || $this->skipEntry($entry, $fields)) { continue; } $entries[] = $entry; } $emptyEntry = collect($fields) ->mapWithKeys(fn (array $field): array => [$field['key'] => '']) ->all(); return [ 'key' => $department['key'].'-'.Str::slug(pathinfo((string) $source['name'], PATHINFO_FILENAME)), 'title' => $department['name'], 'description' => sprintf('Форма построена по файлу %s.', $source['name']), 'fields' => $fields, 'department_options' => $this->departmentOptions(), 'empty_entry' => $emptyEntry, 'default_entries' => $entries, ]; } /** * @return list */ private function departmentOptions(): array { return Department::query() ->where('is_active', true) ->orderBy('name') ->get(['id', 'name']) ->map(fn (Department $option): array => [ 'id' => $option->id, 'name' => $option->name, ]) ->all(); } /** * @param list $segments */ private function pdfDataStartIndex(array $segments): ?int { foreach ($segments as $index => $lineSegments) { if (count($lineSegments) < 2) { continue; } $values = array_column($lineSegments, 'text'); if ($this->isRowData($values)) { return $index; } } return null; } /** * @return list */ private function lineSegments(string $line): array { preg_match_all('/\S.*?(?=(?:\s{2,}\S)|\s*$)/u', $line, $matches, PREG_OFFSET_CAPTURE); return array_values(array_map( fn (array $match): array => [ 'text' => trim($match[0]), 'start' => $match[1], ], $matches[0] ?? [], )); } /** * @param list $row */ private function isRowData(array $row): bool { if (count($row) < 2) { return false; } $firstValue = trim((string) $row[0]); if ($firstValue === '' || preg_match('/итого|всего/iu', $firstValue) === 1) { return false; } return collect(array_slice($row, 1)) ->contains(fn (string $value): bool => preg_match('/\d/u', $value) === 1); } /** * @param list $columnStarts * @return list */ private function slicePdfRow(string $row, array $columnStarts): array { if ($columnStarts === []) { return []; } $cells = []; foreach ($columnStarts as $index => $start) { $end = $columnStarts[$index + 1] ?? strlen($row); $cells[] = trim(substr($row, $start, $end - $start)); } return $cells; } private function fieldType(string $label, bool $firstField): string { if ($firstField && preg_match('/отдел|отд\b|наименование отделения/iu', $label) === 1) { return 'department-select'; } if (preg_match('/кол|ед|сумм|усл|операц|проц|случ|кусоч|исслед|трансфуз|плазмаферез/iu', $label) === 1) { return 'number'; } return $firstField ? 'text' : 'number'; } private function fieldKey(string $label, int $index): string { $key = Str::snake(preg_replace('/[^A-Za-z0-9]+/', ' ', Str::ascii($label)) ?? ''); return $key !== '' ? $key : 'field_'.$index; } /** * @return list> */ private function sheetLines(SimpleXMLElement $sheetXml, array $sharedStrings): array { $rows = []; foreach ($sheetXml->sheetData->row as $rowNode) { $cells = []; foreach ($rowNode->c as $cellNode) { $coordinate = strtoupper((string) $cellNode['r']); $columnIndex = Coordinates::split($coordinate)['column'] - 1; $value = trim((string) $this->cellValue($cellNode, $sharedStrings)); if ($value === '') { continue; } $cells[$columnIndex] = $value; } if ($cells === []) { continue; } ksort($cells); $rows[] = array_values($cells); } return $rows; } /** * @return array */ private function workbookRelations(ZipArchive $archive): array { $relationsXml = $this->loadXml($archive, 'xl/_rels/workbook.xml.rels'); $relations = []; foreach ($relationsXml->Relationship as $relation) { $target = (string) $relation['Target']; $relations[(string) $relation['Id']] = str_starts_with($target, 'xl/') ? $target : 'xl/'.$target; } return $relations; } /** * @return list */ private function sharedStrings(ZipArchive $archive): array { if ($archive->locateName('xl/sharedStrings.xml') === false) { return []; } $sharedStringsXml = $this->loadXml($archive, 'xl/sharedStrings.xml'); $sharedStrings = []; foreach ($sharedStringsXml->si as $stringNode) { $sharedStrings[] = $this->nodeText($stringNode); } return $sharedStrings; } private function cellValue(SimpleXMLElement $cellNode, array $sharedStrings): ?string { $type = (string) $cellNode['t']; $rawValue = isset($cellNode->v) ? (string) $cellNode->v : null; if ($type === 's' && $rawValue !== null) { return $sharedStrings[(int) $rawValue] ?? null; } if ($type === 'inlineStr') { return $this->nodeText($cellNode->is); } return $rawValue; } private function nodeText(SimpleXMLElement $node): string { $xml = $node->asXML(); if ($xml === false) { return ''; } preg_match_all('/<(?:\w+:)?t[^>]*>(.*?)<\/(?:\w+:)?t>/u', $xml, $matches); return html_entity_decode(implode('', $matches[1] ?? []), ENT_QUOTES | ENT_XML1, 'UTF-8'); } private function loadXml(ZipArchive $archive, string $path): SimpleXMLElement { $contents = $archive->getFromName($path); if ($contents === false) { throw new RuntimeException(sprintf('Unable to read [%s] from workbook.', $path)); } $xml = simplexml_load_string($contents); if (! $xml instanceof SimpleXMLElement) { throw new RuntimeException(sprintf('Unable to parse XML [%s].', $path)); } return $xml; } /** * @param array $entry * @param list> $fields */ private function skipEntry(array $entry, array $fields): bool { $firstField = $fields[0]['key'] ?? null; $firstValue = $firstField === null ? '' : trim((string) ($entry[$firstField] ?? '')); if ($firstValue === '') { return true; } return preg_match('/итого|всего/u', $firstValue) === 1; } private function containsLetters(string $value): bool { return preg_match('/\p{L}/u', $value) === 1; } private function slug(string $value): string { return Str::slug(Str::ascii(mb_strtolower(trim($value)))); } }