Files
econom-calculator/app/Support/MedicalReport/SourceStructuredTemplateFactory.php
brusnitsyn 3edc8e667e
Some checks failed
tests / ci (8.5) (push) Has been cancelled
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
first commit
2026-04-03 17:20:05 +09:00

827 lines
30 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
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))));
}
}