Удалил старые отчеты
This commit is contained in:
@@ -1,148 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ReportTemplate;
|
||||
use App\Services\Reports\ReportSourceRegistry;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class ReportTemplateController extends Controller
|
||||
{
|
||||
private const PERMISSION_OPTIONS = ['report.view', 'nurse.report.view'];
|
||||
|
||||
public function __construct(protected ReportSourceRegistry $sources) {}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$this->authorizeAccess();
|
||||
|
||||
$templates = ReportTemplate::with('creator')->latest()->get()->map(fn (ReportTemplate $t) => [
|
||||
'id' => $t->id,
|
||||
'name' => $t->name,
|
||||
'sourceLabels' => collect($t->sections ?? [])
|
||||
->map(fn (array $s) => $this->sources->all()[$s['source']]?->label ?? $s['source'])
|
||||
->unique()
|
||||
->values()
|
||||
->all(),
|
||||
'sectionsCount' => count($t->sections ?? []),
|
||||
'requiredPermissions' => $t->required_permissions ?? [],
|
||||
'creator' => $t->creator?->name,
|
||||
]);
|
||||
|
||||
return Inertia::render('Admin/ReportTemplates/Index', ['templates' => $templates]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$this->authorizeAccess();
|
||||
|
||||
return Inertia::render('Admin/ReportTemplates/Form', [
|
||||
'template' => null,
|
||||
'sources' => $this->sourcesPayload(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$this->authorizeAccess();
|
||||
|
||||
ReportTemplate::create([
|
||||
...$this->validateTemplate($request),
|
||||
'created_by' => Auth::id(),
|
||||
]);
|
||||
|
||||
return redirect('/admin/report-templates')->with('success', 'Шаблон создан');
|
||||
}
|
||||
|
||||
public function edit(ReportTemplate $template)
|
||||
{
|
||||
$this->authorizeAccess();
|
||||
|
||||
return Inertia::render('Admin/ReportTemplates/Form', [
|
||||
'template' => [
|
||||
'id' => $template->id,
|
||||
'name' => $template->name,
|
||||
'sections' => $template->sections,
|
||||
'requiredPermissions' => $template->required_permissions ?? [],
|
||||
],
|
||||
'sources' => $this->sourcesPayload(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(ReportTemplate $template, Request $request)
|
||||
{
|
||||
$this->authorizeAccess();
|
||||
|
||||
$template->update($this->validateTemplate($request));
|
||||
|
||||
return redirect('/admin/report-templates')->with('success', 'Шаблон сохранён');
|
||||
}
|
||||
|
||||
public function destroy(ReportTemplate $template)
|
||||
{
|
||||
$this->authorizeAccess();
|
||||
|
||||
$template->delete();
|
||||
|
||||
return redirect('/admin/report-templates')->with('success', 'Шаблон удалён');
|
||||
}
|
||||
|
||||
private function validateTemplate(Request $request): array
|
||||
{
|
||||
$sourceKeys = array_keys($this->sources->all());
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'sections' => 'required|array|min:1',
|
||||
'sections.*.source' => ['required', 'string', 'in:'.implode(',', $sourceKeys)],
|
||||
'sections.*.title' => 'nullable|string|max:255',
|
||||
'sections.*.columns' => 'required|array|min:1',
|
||||
'sections.*.columns.*' => 'string',
|
||||
'sections.*.filters' => 'nullable|array',
|
||||
'sections.*.filters.*.field' => 'required_with:sections.*.filters|string',
|
||||
'sections.*.filters.*.value' => 'nullable',
|
||||
'required_permissions' => 'nullable|array',
|
||||
'required_permissions.*' => 'in:'.implode(',', self::PERMISSION_OPTIONS),
|
||||
]);
|
||||
|
||||
// Допускаем только колонки/поля фильтров, реально существующие у источника секции —
|
||||
// защита от рассинхрона формы и произвольных значений в БД.
|
||||
$validated['sections'] = array_map(function (array $section) {
|
||||
$source = $this->sources->get($section['source']);
|
||||
|
||||
return [
|
||||
'source' => $section['source'],
|
||||
'title' => $section['title'] ?? $source->label,
|
||||
'columns' => array_values(array_intersect($section['columns'], array_keys($source->columns))),
|
||||
'filters' => array_values(array_filter(
|
||||
$section['filters'] ?? [],
|
||||
fn (array $filter) => array_key_exists($filter['field'], $source->filterableFields)
|
||||
)),
|
||||
];
|
||||
}, $validated['sections']);
|
||||
|
||||
$validated['required_permissions'] = array_values($validated['required_permissions'] ?? []);
|
||||
|
||||
return $validated;
|
||||
}
|
||||
|
||||
private function sourcesPayload(): array
|
||||
{
|
||||
return collect($this->sources->all())->map(fn ($source) => [
|
||||
'key' => $source->key,
|
||||
'label' => $source->label,
|
||||
'columns' => $source->columns,
|
||||
'filterableFields' => $source->filterableFields,
|
||||
])->values()->all();
|
||||
}
|
||||
|
||||
private function authorizeAccess(): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
abort_unless($user->isAdmin() || $user->isChiefDoctor() || $user->isDeputyChief(), 403);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ReportTemplate extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'sections',
|
||||
'required_permissions',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sections' => 'array',
|
||||
'required_permissions' => 'array',
|
||||
];
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
<?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]);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Reports;
|
||||
|
||||
use App\Models\MisMedicalHistory;
|
||||
use App\Models\MisMigrationPatient;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class PatientQueryBuilder
|
||||
{
|
||||
public function __construct(
|
||||
private int $branchId,
|
||||
private string $startDate,
|
||||
private string $endDate,
|
||||
private bool $isHeadOrAdmin
|
||||
) {}
|
||||
|
||||
public function forStatus(string $status, bool $onlyIds = false): mixed
|
||||
{
|
||||
$query = match ($status) {
|
||||
'plan', 'emergency' => $this->buildPlanEmergencyQuery($status),
|
||||
'outcome', 'outcome-transferred', 'outcome-deceased' => $this->buildOutcomeQuery($status),
|
||||
'recipient' => $this->buildRecipientQuery(),
|
||||
'current' => $this->buildCurrentQuery(),
|
||||
default => throw new \InvalidArgumentException("Unknown status: $status"),
|
||||
};
|
||||
|
||||
return $onlyIds ? $query->pluck('MedicalHistoryID')->values() : $query->get();
|
||||
}
|
||||
|
||||
private function buildPlanEmergencyQuery(string $status)
|
||||
{
|
||||
// Логика из getPlanOrEmergencyPatients, но без if/else по роли внутри
|
||||
$medicalHistoryIds = $this->isHeadOrAdmin
|
||||
? MisMigrationPatient::whereInDepartment($this->branchId)
|
||||
->whereBetween('DateIngoing', [$this->startDate, $this->endDate])
|
||||
->pluck('rf_MedicalHistoryID')
|
||||
: MisMigrationPatient::currentlyInTreatment($this->branchId)
|
||||
->whereBetween('DateIngoing', [$this->startDate, $this->endDate])
|
||||
->pluck('rf_MedicalHistoryID');
|
||||
|
||||
if ($medicalHistoryIds->isEmpty()) {
|
||||
return MisMedicalHistory::query()->whereRaw('1=0'); // пустой запрос
|
||||
}
|
||||
|
||||
$query = MisMedicalHistory::whereIn('MedicalHistoryID', $medicalHistoryIds)
|
||||
->with(['surgicalOperations' => fn($q) => $q->whereBetween('Date', [$this->startDate, $this->endDate])])
|
||||
->orderBy('DateRecipient', 'DESC');
|
||||
|
||||
if ($status === 'plan') {
|
||||
$query->plan();
|
||||
} elseif ($status === 'emergency') {
|
||||
$query->emergency();
|
||||
}
|
||||
|
||||
if (! $this->isHeadOrAdmin) {
|
||||
$query->currentlyHospitalized();
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function buildOutcomeQuery(string $status): \Illuminate\Database\Eloquent\Builder
|
||||
{
|
||||
$visitResultIds = match ($status) {
|
||||
'outcome-transferred' => [4, 14],
|
||||
'outcome-deceased' => [5, 6, 15, 16],
|
||||
default => [1, 11, 2, 12, 7, 18, 48], // discharged
|
||||
};
|
||||
|
||||
return MisMedicalHistory::query()
|
||||
->whereDate('DateExtract', '>', Carbon::parse($this->startDate)->toDateString())
|
||||
->whereDate('DateExtract', '<=', Carbon::parse($this->endDate)->toDateString())
|
||||
->whereHas('migrations', function ($q) use ($visitResultIds) {
|
||||
$q->where('rf_StationarBranchID', $this->branchId)
|
||||
->whereIn('rf_kl_VisitResultID', $visitResultIds);
|
||||
})
|
||||
->with(['surgicalOperations'])
|
||||
->orderBy('DateRecipient', 'DESC');
|
||||
}
|
||||
|
||||
private function buildRecipientQuery(string $status): \Illuminate\Database\Eloquent\Builder
|
||||
{
|
||||
$visitResultIds = match ($status) {
|
||||
'outcome-transferred' => [4, 14],
|
||||
'outcome-deceased' => [5, 6, 15, 16],
|
||||
default => [1, 11, 2, 12, 7, 18, 48], // discharged
|
||||
};
|
||||
|
||||
return MisMedicalHistory::query()
|
||||
->whereDate('DateExtract', '>', Carbon::parse($this->startDate)->toDateString())
|
||||
->whereDate('DateExtract', '<=', Carbon::parse($this->endDate)->toDateString())
|
||||
->whereHas('migrations', function ($q) use ($visitResultIds) {
|
||||
$q->where('rf_StationarBranchID', $this->branchId)
|
||||
->whereIn('rf_kl_VisitResultID', $visitResultIds);
|
||||
})
|
||||
->with(['surgicalOperations'])
|
||||
->orderBy('DateRecipient', 'DESC');
|
||||
}
|
||||
private function buildCurrentQuery(string $status): \Illuminate\Database\Eloquent\Builder
|
||||
{
|
||||
$visitResultIds = match ($status) {
|
||||
'outcome-transferred' => [4, 14],
|
||||
'outcome-deceased' => [5, 6, 15, 16],
|
||||
default => [1, 11, 2, 12, 7, 18, 48], // discharged
|
||||
};
|
||||
|
||||
return MisMedicalHistory::query()
|
||||
->whereDate('DateExtract', '>', Carbon::parse($this->startDate)->toDateString())
|
||||
->whereDate('DateExtract', '<=', Carbon::parse($this->endDate)->toDateString())
|
||||
->whereHas('migrations', function ($q) use ($visitResultIds) {
|
||||
$q->where('rf_StationarBranchID', $this->branchId)
|
||||
->whereIn('rf_kl_VisitResultID', $visitResultIds);
|
||||
})
|
||||
->with(['surgicalOperations'])
|
||||
->orderBy('DateRecipient', 'DESC');
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<?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,
|
||||
) {}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<?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,
|
||||
) {}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
<?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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
<?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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user