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

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,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;
}
}