first commit
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

This commit is contained in:
brusnitsyn
2026-04-03 17:20:05 +09:00
commit 3edc8e667e
358 changed files with 39258 additions and 0 deletions

View File

@@ -0,0 +1,826 @@
<?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))));
}
}