827 lines
30 KiB
PHP
827 lines
30 KiB
PHP
<?php
|
||
|
||
namespace App\Support\MedicalReport;
|
||
|
||
use App\Models\Department;
|
||
use Illuminate\Support\Arr;
|
||
use Illuminate\Support\Str;
|
||
use RuntimeException;
|
||
use SimpleXMLElement;
|
||
use Symfony\Component\Process\Process;
|
||
use ZipArchive;
|
||
|
||
class SourceStructuredTemplateFactory
|
||
{
|
||
/**
|
||
* @var array<string, array<string, mixed>|null>
|
||
*/
|
||
private array $cache = [];
|
||
|
||
/**
|
||
* @var array<string, int>
|
||
*/
|
||
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<string, mixed>|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<string, mixed> $department
|
||
* @return array<string, mixed>|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<array<string, mixed>> $sources
|
||
* @return array<string, mixed>|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<string, mixed> $department
|
||
* @param list<array<string, string>> $fields
|
||
* @return array<string, mixed>
|
||
*/
|
||
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<string, mixed> $department
|
||
* @param list<array{key: string, title: string, fields: list<array<string, string>>}> $sections
|
||
* @return array<string, mixed>
|
||
*/
|
||
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<string, mixed> $department
|
||
* @param array<string, mixed> $source
|
||
* @return array<string, mixed>|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<string, mixed> $department
|
||
* @param array<string, mixed> $source
|
||
* @return array<string, mixed>|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<string, mixed> $department
|
||
* @param array<string, mixed> $source
|
||
* @param list<string> $lines
|
||
* @return array<string, mixed>|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<string, mixed> $department
|
||
* @param array<string, mixed> $source
|
||
* @param list<list<string>> $rows
|
||
* @return array<string, mixed>|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<string, mixed> $department
|
||
* @param array<string, mixed> $source
|
||
* @param list<string> $columnLabels
|
||
* @param list<string> $rawRows
|
||
* @param list<int>|null $columnStarts
|
||
* @param list<list<string>>|null $xlsxRows
|
||
* @return array<string, mixed>|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<array{id: int, name: string}>
|
||
*/
|
||
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<array{text: string, start: int}> $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<array{text: string, start: int}>
|
||
*/
|
||
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<string> $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<int> $columnStarts
|
||
* @return list<string>
|
||
*/
|
||
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<list<string>>
|
||
*/
|
||
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<string, string>
|
||
*/
|
||
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<string>
|
||
*/
|
||
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<string, string> $entry
|
||
* @param list<array<string, string>> $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))));
|
||
}
|
||
}
|