Модуль отчетов

This commit is contained in:
brusnitsyn
2026-06-21 23:40:55 +09:00
parent f163b95663
commit bd2cc24b98
27 changed files with 2781 additions and 3 deletions

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Services\Reports\BuiltIn;
use App\Models\Department;
use App\Services\DateRange;
use App\Services\Reports\Contracts\ReportDefinition;
use App\Services\Reports\ReportPayload;
use App\Services\Reports\ReportSourceRegistry;
class DutyDoctorReport implements ReportDefinition
{
public function __construct(private readonly ReportSourceRegistry $sources) {}
public function code(): string
{
return 'duty';
}
public function label(): string
{
return 'Отчёт дежурного врача';
}
public function requiredPermissions(): array
{
return ['report.view'];
}
public function build(Department $department, DateRange $dateRange): ReportPayload
{
$sections = [
$this->sources->get('duty_metrics')->toSection($department, $dateRange),
$this->sources->get('duty_patients')->toSection($department, $dateRange),
$this->sources->get('unwanted_events')->toSection($department, $dateRange),
$this->sources->get('observable_patients')->toSection($department, $dateRange),
];
return new ReportPayload(
title: $this->label(),
meta: [
'Отделение' => $department->name_full ?? $department->name_short,
'Период' => $dateRange->start()->format('d.m.Y H:i').' — '.$dateRange->end()->format('d.m.Y H:i'),
'Сформирован' => now('Asia/Yakutsk')->format('d.m.Y H:i'),
],
sections: $sections,
);
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace App\Services\Reports\BuiltIn;
use App\Domain\Reports\ValueObjects\MetrikaConfig;
use App\Models\Department;
use App\Models\DepartmentMetrikaDefault;
use App\Models\ReportNursePatient;
use App\Services\Classification\PatientStatusClassifier;
use App\Services\DateRange;
use App\Services\Reports\Contracts\ReportDefinition;
use App\Services\Reports\ReportPayload;
use App\Services\Reports\ReportSection;
use App\Services\Reports\ReportSourceRegistry;
class HeadNurseReport implements ReportDefinition
{
public function __construct(private readonly ReportSourceRegistry $sources) {}
public function code(): string
{
return 'nurse';
}
public function label(): string
{
return 'Отчёт старшей медсестры';
}
public function requiredPermissions(): array
{
return ['nurse.report.view'];
}
public function build(Department $department, DateRange $dateRange): ReportPayload
{
$sections = [
$this->buildMetricsSection($department, $dateRange),
$this->sources->get('nurse_patients')->toSection($department, $dateRange, null, [], 'Журнал пациентов'),
];
return new ReportPayload(
title: $this->label(),
meta: [
'Отделение' => $department->name_full ?? $department->name_short,
'Период' => $dateRange->start()->format('d.m.Y H:i').' — '.$dateRange->end()->format('d.m.Y H:i'),
'Сформирован' => now('Asia/Yakutsk')->format('d.m.Y H:i'),
],
sections: $sections,
);
}
private function buildMetricsSection(Department $department, DateRange $dateRange): ReportSection
{
$reportIds = $this->sources->nurseReports($department, $dateRange)->pluck('id');
$counts = [
'recipient' => 0,
'discharged' => 0,
'transferred' => 0,
'deceased' => 0,
'in_department' => 0,
];
if ($reportIds->isNotEmpty()) {
$patients = ReportNursePatient::whereIn('report_nurse_id', $reportIds)->with('migrations')->get();
foreach ($patients as $patient) {
match (PatientStatusClassifier::classify($patient, $dateRange)) {
PatientStatusClassifier::STATUS_RECIPIENT => $counts['recipient']++,
PatientStatusClassifier::STATUS_DISCHARGED => $counts['discharged']++,
PatientStatusClassifier::STATUS_TRANSFERRED => $counts['transferred']++,
PatientStatusClassifier::STATUS_DECEASED => $counts['deceased']++,
PatientStatusClassifier::STATUS_IN_DEPARTMENT => $counts['in_department']++,
default => null,
};
}
}
$beds = (int) (DepartmentMetrikaDefault::where('rf_department_id', $department->department_id)
->where('rf_metrika_item_id', MetrikaConfig::BEDS)
->value('value') ?? 0);
$occupancy = $beds > 0 ? round($counts['in_department'] * 100 / $beds, 1) : 0;
$row = [
'beds' => $beds,
'recipient' => $counts['recipient'],
'discharged' => $counts['discharged'],
'transferred' => $counts['transferred'],
'deceased' => $counts['deceased'],
'in_department' => $counts['in_department'],
'occupancy_percent' => $occupancy,
];
return new ReportSection('Показатели', [
'beds' => 'Коек',
'recipient' => 'Поступило',
'discharged' => 'Выписано',
'transferred' => 'Переведено',
'deceased' => 'Умерло',
'in_department' => 'В отделении',
'occupancy_percent' => 'Занятость, %',
], [$row]);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Services\Reports\Contracts;
use App\Models\Department;
use App\Services\DateRange;
use App\Services\Reports\ReportPayload;
interface ReportDefinition
{
public function code(): string;
public function label(): string;
/**
* Права, любое из которых открывает доступ к просмотру отчёта. Пустой массив
* отчёт доступен всем, у кого есть общий доступ к отчётам (report.view или nurse.report.view).
*
* @return array<int,string>
*/
public function requiredPermissions(): array;
public function build(Department $department, DateRange $dateRange): ReportPayload;
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Services\Reports\Export;
use App\Exports\Sheets\ArraySheetExport;
use App\Services\Reports\ReportPayload;
use App\Services\Reports\ReportSection;
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
class ReportExcelExport implements WithMultipleSheets
{
public function __construct(private readonly ReportPayload $payload) {}
public function sheets(): array
{
$sheets = [
new ArraySheetExport('Сводка', $this->metaRows()),
];
foreach ($this->payload->sections as $section) {
$sheets[] = new ArraySheetExport($this->sheetTitle($section->title), $this->sectionRows($section));
}
return $sheets;
}
private function metaRows(): array
{
$rows = [['Показатель', 'Значение']];
foreach ($this->payload->meta as $label => $value) {
$rows[] = [$label, $value];
}
return $rows;
}
private function sectionRows(ReportSection $section): array
{
$rows = [array_values($section->columns)];
if (empty($section->rows)) {
$rows[] = ['Нет данных за выбранный период'];
}
foreach ($section->rows as $row) {
$line = [];
foreach (array_keys($section->columns) as $key) {
$line[] = $row[$key] ?? '';
}
$rows[] = $line;
}
return $rows;
}
private function sheetTitle(string $title): string
{
// Excel ограничивает название листа 31 символом и запрещает символы \/?*[]:
return mb_substr(preg_replace('/[\\\\\/\?\*\[\]:]/', ' ', $title), 0, 31);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Services\Reports\Export;
use App\Services\Reports\ReportPayload;
use Barryvdh\DomPDF\Facade\Pdf;
use Barryvdh\DomPDF\PDF as PdfDocument;
class ReportPdfExport
{
public static function render(ReportPayload $payload): PdfDocument
{
return Pdf::loadView('reports.pdf', ['payload' => $payload])
->setPaper('a4', 'portrait');
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Services\Reports;
readonly class ReportPayload
{
/**
* @param array<string,string> $meta пары "показатель => значение" для шапки отчёта
* @param ReportSection[] $sections
*/
public function __construct(
public string $title,
public array $meta,
public array $sections,
) {}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Services\Reports;
use App\Models\ReportTemplate;
use App\Models\User;
use App\Services\Reports\BuiltIn\DutyDoctorReport;
use App\Services\Reports\BuiltIn\HeadNurseReport;
use App\Services\Reports\Contracts\ReportDefinition;
class ReportRegistry
{
public function __construct(private readonly ReportSourceRegistry $sources) {}
/** @return ReportDefinition[] */
public function all(): array
{
$definitions = [
new DutyDoctorReport($this->sources),
new HeadNurseReport($this->sources),
];
foreach (ReportTemplate::all() as $template) {
$definitions[] = new TemplateReportDefinition($template, $this->sources);
}
return $definitions;
}
/** @return ReportDefinition[] */
public function availableFor(User $user): array
{
return array_values(array_filter(
$this->all(),
fn (ReportDefinition $definition) => $this->isVisible($definition, $user)
));
}
public function find(string $code, User $user): ?ReportDefinition
{
foreach ($this->availableFor($user) as $definition) {
if ($definition->code() === $code) {
return $definition;
}
}
return null;
}
private function isVisible(ReportDefinition $definition, User $user): bool
{
$permissions = $definition->requiredPermissions();
if (empty($permissions)) {
return $user->currentRoleCan('report.view') || $user->currentRoleCan('nurse.report.view');
}
foreach ($permissions as $permission) {
if ($user->currentRoleCan($permission)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Services\Reports;
readonly class ReportSection
{
/**
* @param array<string,string> $columns ключ колонки => подпись
* @param array<int,array<string,mixed>> $rows строки данных, ключи соответствуют ключам $columns
*/
public function __construct(
public string $title,
public array $columns,
public array $rows,
) {}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Services\Reports;
use App\Models\Department;
use App\Services\DateRange;
use Closure;
readonly class ReportSource
{
/**
* @param array<string,string> $columns ключ колонки => подпись (все колонки, доступные для этого источника)
* @param array<string,array{label:string,options:?array<int|string,string>}> $filterableFields allow-list полей, по которым можно фильтровать в конструкторе шаблонов
* @param Closure(Department,DateRange,array<int,string>,array<int,array{field:string,value:mixed}>):array<int,array<string,mixed>> $resolver
*/
public function __construct(
public string $key,
public string $label,
public array $columns,
public array $filterableFields,
private Closure $resolver,
) {}
/**
* @param array<int,string> $columns ключи выбранных колонок (по умолчанию все)
* @param array<int,array{field:string,value:mixed}> $filters
* @return array<int,array<string,mixed>>
*/
public function rows(Department $department, DateRange $dateRange, ?array $columns = null, array $filters = []): array
{
$columns ??= array_keys($this->columns);
return ($this->resolver)($department, $dateRange, $columns, $filters);
}
public function toSection(
Department $department,
DateRange $dateRange,
?array $columns = null,
array $filters = [],
?string $title = null,
): ReportSection {
$columns ??= array_keys($this->columns);
$columnDefs = [];
foreach ($columns as $key) {
if (isset($this->columns[$key])) {
$columnDefs[$key] = $this->columns[$key];
}
}
if (empty($columnDefs)) {
$columnDefs = $this->columns;
$columns = array_keys($this->columns);
}
return new ReportSection(
$title ?? $this->label,
$columnDefs,
$this->rows($department, $dateRange, $columns, $filters),
);
}
}

View File

@@ -0,0 +1,342 @@
<?php
namespace App\Services\Reports;
use App\Domain\Reports\ValueObjects\MetrikaConfig;
use App\Models\Department;
use App\Models\DutyReportMetricResult;
use App\Models\DutyUnwantedEvent;
use App\Models\ReportDuty;
use App\Models\ReportDutyPatient;
use App\Models\ReportNurse;
use App\Models\ReportNursePatient;
use App\Services\DateRange;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
/**
* Реестр источников данных для отчётов. Каждый источник умеет вернуть набор строк
* по отделению и периоду на этих же источниках строятся и встроенные отчёты
* (дежурный врач / старшая медсестра), и пользовательские шаблоны из конструктора.
*/
class ReportSourceRegistry
{
private const URGENCY_OPTIONS = [1 => 'Планово', 2 => 'Экстренно'];
/** @var array<string,ReportSource> */
private array $sources;
public function __construct()
{
$this->sources = [
'duty_metrics' => $this->dutyMetricsSource(),
'duty_patients' => $this->dutyPatientsSource(),
'nurse_patients' => $this->nursePatientsSource(),
'unwanted_events' => $this->unwantedEventsSource(),
'observable_patients' => $this->observablePatientsSource(),
];
}
/** @return array<string,ReportSource> */
public function all(): array
{
return $this->sources;
}
public function get(string $key): ReportSource
{
if (! isset($this->sources[$key])) {
throw new InvalidArgumentException("Неизвестный источник отчёта: {$key}");
}
return $this->sources[$key];
}
/**
* Сданные дежурные отчёты отделения, пересекающиеся с периодом.
*/
public function dutyReports(Department $department, DateRange $dateRange): Collection
{
return ReportDuty::where('rf_department_id', $department->department_id)
->withinPeriod($dateRange->startSql(), $dateRange->endSql())
->onlySubmitted()
->orderBy('period_end')
->get();
}
/**
* Сданные отчёты (журналы) медсестры отделения, пересекающиеся с периодом.
*/
public function nurseReports(Department $department, DateRange $dateRange): Collection
{
return ReportNurse::where('rf_department_id', $department->department_id)
->where('status_id', 2)
->where('period_end', '>=', $dateRange->startSql())
->where('period_start', '<=', $dateRange->endSql())
->orderBy('period_end')
->get();
}
private function dutyMetricsSource(): ReportSource
{
$columns = [
'period' => 'Период',
'beds' => 'Коек',
'recipient_plan' => 'Поступило плановых',
'recipient_emergency' => 'Поступило экстренных',
'discharged' => 'Выписано',
'transferred' => 'Переведено',
'deceased' => 'Умерло',
'occupancy_percent' => 'Занятость, %',
'avg_bed_days' => 'Ср. койко-день',
'lethality_percent' => 'Летальность, %',
'surgery_plan' => 'Операции плановые',
'surgery_emergency' => 'Операции экстренные',
'staff_count' => 'Мед. персонал',
];
$metricMap = [
'beds' => MetrikaConfig::BEDS,
'recipient_plan' => MetrikaConfig::PLAN,
'recipient_emergency' => MetrikaConfig::EMERGENCY,
'discharged' => MetrikaConfig::DISCHARGED,
'transferred' => MetrikaConfig::TRANSFERRED,
'deceased' => MetrikaConfig::DECEASED,
'occupancy_percent' => MetrikaConfig::DEPARTMENT_LOADED,
'avg_bed_days' => MetrikaConfig::AVERAGE_BED_DAYS,
'lethality_percent' => MetrikaConfig::LETHALITY,
'surgery_plan' => MetrikaConfig::PLAN_SURGERY,
'surgery_emergency' => MetrikaConfig::EMERGENCY_SURGERY,
'staff_count' => MetrikaConfig::STAFF_COUNT,
];
$resolver = function (Department $department, DateRange $dateRange, array $columns) use ($metricMap) {
$reports = $this->dutyReports($department, $dateRange);
if ($reports->isEmpty()) {
return [];
}
$values = DutyReportMetricResult::whereIn('rf_report_id', $reports->pluck('id'))
->get(['rf_report_id', 'rf_metrika_item_id', 'value'])
->groupBy('rf_report_id');
$rows = [];
foreach ($reports as $report) {
$byMetric = ($values->get($report->id) ?? collect())->pluck('value', 'rf_metrika_item_id');
$row = [];
foreach ($columns as $key) {
if ($key === 'period') {
$row[$key] = $report->period_start?->format('d.m.Y H:i').' — '.$report->period_end?->format('d.m.Y H:i');
continue;
}
$metricId = $metricMap[$key] ?? null;
$row[$key] = $metricId !== null ? ($byMetric->get($metricId) ?? 0) : null;
}
$rows[] = $row;
}
return $rows;
};
return new ReportSource('duty_metrics', 'Показатели смены (дежурный врач)', $columns, [], $resolver);
}
private function patientColumns(): array
{
return [
'full_name' => 'ФИО',
'birth_date' => 'Дата рождения',
'medical_card_number' => '№ карты',
'recipient_date' => 'Дата поступления',
'extract_date' => 'Дата выбытия',
'diagnosis_code' => 'Код диагноза',
'diagnosis_name' => 'Диагноз',
'urgency' => 'Срочность',
'outcome' => 'Исход',
];
}
private function patientFilterableFields(): array
{
return [
'urgency_id' => ['label' => 'Срочность', 'options' => self::URGENCY_OPTIONS],
'visit_result_id' => ['label' => 'Код исхода', 'options' => null],
];
}
private function dutyPatientsSource(): ReportSource
{
$columns = $this->patientColumns();
$filterable = $this->patientFilterableFields();
$resolver = function (Department $department, DateRange $dateRange, array $columns, array $filters) {
$reportIds = $this->dutyReports($department, $dateRange)->pluck('id');
if ($reportIds->isEmpty()) {
return [];
}
$query = ReportDutyPatient::whereIn('report_duty_id', $reportIds)->with('latestMigration');
$this->applyEqualityFilters($query, $filters, ['urgency_id', 'visit_result_id']);
return $query->get()->map(fn ($patient) => $this->mapPatientRow($patient, $columns))->all();
};
return new ReportSource('duty_patients', 'Пациенты (дежурный врач)', $columns, $filterable, $resolver);
}
private function nursePatientsSource(): ReportSource
{
$columns = $this->patientColumns();
$filterable = $this->patientFilterableFields();
$resolver = function (Department $department, DateRange $dateRange, array $columns, array $filters) {
$reportIds = $this->nurseReports($department, $dateRange)->pluck('id');
if ($reportIds->isEmpty()) {
return [];
}
$query = ReportNursePatient::whereIn('report_nurse_id', $reportIds)->with('latestMigration');
$this->applyEqualityFilters($query, $filters, ['urgency_id', 'visit_result_id']);
return $query->get()->map(fn ($patient) => $this->mapPatientRow($patient, $columns))->all();
};
return new ReportSource('nurse_patients', 'Журнал пациентов (старшая медсестра)', $columns, $filterable, $resolver);
}
private function unwantedEventsSource(): ReportSource
{
$columns = [
'title' => 'Событие',
'comment' => 'Комментарий',
'created_at' => 'Дата',
];
$resolver = function (Department $department, DateRange $dateRange) {
$reportIds = $this->dutyReports($department, $dateRange)->pluck('id');
if ($reportIds->isEmpty()) {
return [];
}
return DutyUnwantedEvent::whereIn('report_duty_id', $reportIds)->get()
->map(fn ($event) => [
'title' => $event->title,
'comment' => $event->comment,
'created_at' => $event->created_at?->format('d.m.Y H:i'),
])->all();
};
return new ReportSource('unwanted_events', 'Нежелательные события', $columns, [], $resolver);
}
private function observablePatientsSource(): ReportSource
{
$columns = [
'full_name' => 'ФИО',
'birth_date' => 'Дата рождения',
'observable_in' => 'Начало наблюдения',
'observable_out' => 'Конец наблюдения',
'observable_reason' => 'Причина',
];
$resolver = function (Department $department, DateRange $dateRange) {
$reportIds = $this->dutyReports($department, $dateRange)->pluck('id');
if ($reportIds->isEmpty()) {
return [];
}
return DB::table('observable_medical_histories as omh')
->join('report_duty_patients as rdp', 'rdp.original_id', '=', 'omh.original_id')
->whereIn('rdp.report_duty_id', $reportIds)
->where('omh.observable_in', '>=', $dateRange->startSql())
->where('omh.observable_in', '<=', $dateRange->endSql())
->select('omh.full_name', 'omh.birth_date', 'omh.observable_in', 'omh.observable_out', 'omh.observable_reason')
->distinct()
->get()
->map(fn ($row) => [
'full_name' => $row->full_name,
'birth_date' => $this->formatDate($row->birth_date, 'd.m.Y'),
'observable_in' => $this->formatDate($row->observable_in, 'd.m.Y H:i'),
'observable_out' => $this->formatDate($row->observable_out, 'd.m.Y H:i'),
'observable_reason' => $row->observable_reason,
])->all();
};
return new ReportSource('observable_patients', 'Пациенты на контроле', $columns, [], $resolver);
}
private function mapPatientRow(ReportDutyPatient|ReportNursePatient $patient, array $columns): array
{
$migration = $patient->latestMigration;
$row = [];
foreach ($columns as $key) {
$row[$key] = match ($key) {
'full_name' => $patient->full_name,
'birth_date' => $patient->birth_date?->format('d.m.Y'),
'medical_card_number' => $patient->medical_card_number,
'recipient_date' => $patient->recipient_date?->format('d.m.Y H:i'),
'extract_date' => $patient->extract_date?->format('d.m.Y H:i'),
'diagnosis_code' => $migration?->diagnosis_code,
'diagnosis_name' => $migration?->diagnosis_name,
'urgency' => self::URGENCY_OPTIONS[(int) $patient->urgency_id] ?? '—',
'outcome' => $this->outcomeLabel($patient),
default => null,
};
}
return $row;
}
private function outcomeLabel(ReportDutyPatient|ReportNursePatient $patient): string
{
if ($patient->death_date) {
return 'Умер';
}
if (in_array((int) $patient->visit_result_id, [4, 14], true)) {
return 'Переведён';
}
if ($patient->extract_date) {
return 'Выписан';
}
return 'В отделении';
}
/**
* @param array<int,array{field:string,value:mixed}> $filters
* @param array<int,string> $allowedFields allow-list полей источника защита от произвольных имён колонок
*/
private function applyEqualityFilters(Builder $query, array $filters, array $allowedFields): void
{
foreach ($filters as $filter) {
$field = $filter['field'] ?? null;
$value = $filter['value'] ?? null;
if ($field === null || $value === null || $value === '' || ! in_array($field, $allowedFields, true)) {
continue;
}
$query->where($field, $value);
}
}
private function formatDate(?string $value, string $format): ?string
{
return $value ? Carbon::parse($value)->format($format) : null;
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Services\Reports;
use App\Models\Department;
use App\Models\ReportTemplate;
use App\Services\DateRange;
use App\Services\Reports\Contracts\ReportDefinition;
/**
* Адаптер, превращающий пользовательский шаблон (ReportTemplate), собранный
* в конструкторе админ-панели, в обычный ReportDefinition для движка
* не важно, встроенный это отчёт или шаблон.
*/
class TemplateReportDefinition implements ReportDefinition
{
public function __construct(
private readonly ReportTemplate $template,
private readonly ReportSourceRegistry $sources,
) {}
public function code(): string
{
return 'template:'.$this->template->id;
}
public function label(): string
{
return $this->template->name;
}
public function requiredPermissions(): array
{
return $this->template->required_permissions ?? [];
}
public function build(Department $department, DateRange $dateRange): ReportPayload
{
$sections = [];
foreach ($this->template->sections ?? [] as $sectionConfig) {
$source = $this->sources->get($sectionConfig['source']);
$sections[] = $source->toSection(
$department,
$dateRange,
$sectionConfig['columns'] ?? null,
$sectionConfig['filters'] ?? [],
$sectionConfig['title'] ?? null,
);
}
return new ReportPayload(
title: $this->template->name,
meta: [
'Отделение' => $department->name_full ?? $department->name_short,
'Период' => $dateRange->start()->format('d.m.Y H:i').' — '.$dateRange->end()->format('d.m.Y H:i'),
'Сформирован' => now('Asia/Yakutsk')->format('d.m.Y H:i'),
],
sections: $sections,
);
}
}