Добавлен генератор отчетов мед. сестры

This commit is contained in:
brusnitsyn
2026-07-10 17:14:08 +09:00
parent fc33be3926
commit eb3ac66393
3 changed files with 247 additions and 16 deletions

View File

@@ -0,0 +1,192 @@
<?php
namespace App\Console\Commands;
use App\Models\Department;
use App\Models\ReportDuty;
use App\Models\ReportNurse;
use App\Models\User;
use App\Services\DateRange;
use App\Services\DateRangeService;
use App\Services\DutyReportService;
use App\Services\NurseReportService;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Console\Command\Command as CommandAlias;
use Throwable;
class GenerateNurseReport extends Command
{
protected $signature = 'nurse:generate
{--start= : Начальная дата смены (YYYY-MM-DD). По умолчанию сегодня}
{--end= : Конечная дата смены (YYYY-MM-DD). По умолчанию равно --start}
{--departments= : ID отделений через запятую или "all"}
{--user= : ID пользователя для аудита (обязательно для CLI)}
{--shift-start=09:00 : Время начала смены (HH:MM)}
{--timezone= : Часовой пояс смены (по умолчанию config(\'app.timezone\'))}
{--dry-run : Тестовый режим без записи в БД}
{--skip-existing : Пропускать смены, где отчёт уже существует}';
protected $description = 'Пакетная генерация суточных отчётов за период по сменам (09:0009:00)';
public function __construct(
protected NurseReportService $reportService,
protected DateRangeService $dateRangeService
) {
parent::__construct();
}
public function handle()
{
$tz = $this->option('timezone') ?: config('app.timezone', 'Europe/Moscow');
$shiftStartTime = $this->option('shift-start') ?: '09:00';
// 1. Валидация и парсинг дат
$startDate = Carbon::parse($this->option('start') ?: now($tz)->format('Y-m-d'), $tz)->setTimeFromTimeString($shiftStartTime);
$endDate = Carbon::parse($this->option('end') ?: $this->option('start') ?: now($tz)->format('Y-m-d'), $tz)->setTimeFromTimeString($shiftStartTime);
if ($endDate->lt($startDate)) {
$this->error('Конечная дата не может быть раньше начальной.');
return CommandAlias::FAILURE;
}
// 2. Разрешение списка отделений
$departments = $this->resolveDepartments($this->option('departments'));
if ($departments->isEmpty()) {
$this->error('Отделения не найдены.');
return CommandAlias::FAILURE;
}
// 3. Генерация массива смен
$shifts = [];
$current = $startDate->copy();
while ($current->lte($endDate)) {
$shifts[] = [
'start' => $current->copy(),
'end' => $current->copy()->addDay(), // 09:00 → 09:00 следующего дня
];
$current->addDay();
}
// 4. Пользователь CLI
$userId = $this->option('user') ? (int) $this->option('user') : null;
if (!$userId && !$this->option('dry-run')) {
$this->error('Для записи в БД в CLI режиме укажите параметр --user=<ID>');
return CommandAlias::FAILURE;
}
// 5. Вывод информации
$totalTasks = count($shifts) * $departments->count();
if ($totalTasks === 0) {
$this->warn('Нет задач для выполнения.');
return CommandAlias::SUCCESS;
}
$this->info("Период: {$shifts[0]['start']->format('Y-m-d H:i')}{$shifts[array_key_last($shifts)]['end']->format('Y-m-d H:i')} ({$tz})");
$this->info("Отделений: {$departments->count()}");
$this->info("Всего смен: {$totalTasks}");
if ($this->option('dry-run')) $this->warn("Режим DRY RUN");
if ($this->option('skip-existing')) $this->warn("Пропуск существующих отчётов включён");
$progressBar = $this->output->createProgressBar($totalTasks);
$progressBar->start();
$success = 0; $skipped = 0; $errors = 0;
// 6. Основной цикл
foreach ($departments as $dept) {
foreach ($shifts as $shift) {
try {
$status = $this->processShift(
$shift['start'], $shift['end'], $dept, $userId,
$this->reportService, $this->dateRangeService
);
if ($status === 'skip' || $status === 'dry_run') {
$skipped++;
} else {
$success++;
}
} catch (Throwable $e) {
$errors++;
// Безопасное получение имени отделения
$deptName = $dept->name ?? $dept->department_name ?? "Отдел #{$dept->department_id}";
$this->error("\n[{$deptName}] {$shift['start']->format('Y-m-d H:i')}: {$e->getMessage()}");
Log::error('NurseReportShiftGeneration', [
'department_id' => $dept->department_id,
'shift_start' => $shift['start']->format('Y-m-d H:i:s'),
'shift_end' => $shift['end']->format('Y-m-d H:i:s'),
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
} finally {
// ✅ ГАРАНТИРУЕМ ровно 1 шаг прогресса на каждую итерацию
$progressBar->advance();
}
}
}
$progressBar->finish();
$this->newLine(2);
$this->info("Завершено. Успешно: {$success} | Пропущено: {$skipped} | Ошибок: {$errors}");
return $errors > 0 ? CommandAlias::FAILURE : CommandAlias::SUCCESS;
}
/**
* Получение списка отделений
*/
private function resolveDepartments($input)
{
if (!$input || strtolower($input) === 'all') {
return Department::orderBy('department_id')->get();
}
$ids = array_map('trim', explode(',', $input));
return Department::whereIn('department_id', $ids)->get();
}
/**
* Обработка одной смены
* @return string 'success' | 'skip' | 'dry_run'
*/
private function processShift(
Carbon $shiftStart,
Carbon $shiftEnd,
$dept,
int $userId,
NurseReportService $reportService,
DateRangeService $dateRangeService
): string {
$deptId = $dept->department_id;
$misDeptId = $dept->rf_mis_department_id;
// Пропуск, если отчёт уже существует за ЭТУ ЖЕ СМЕНУ
if ($this->option('skip-existing')) {
$exists = ReportNurse::where('rf_department_id', $deptId)
->where('period_start', $shiftStart->format('Y-m-d H:i:s'))
->where('period_end', $shiftEnd->format('Y-m-d H:i:s'))
->exists();
if ($exists) {
return 'skip';
}
}
// Тестовый режим
if ($this->option('dry-run')) {
return 'dry_run';
}
// Формируем DateRange через ваш сервис (учёт смен, часовых поясов)
$user = User::find($userId);
$lpuDoctorId = 0;
$dateRange = $dateRangeService->createDateRangeForDate($shiftEnd, $user);
// Цепочка из вашего контроллера
$report = $reportService->saveReport($dateRange, $userId, $lpuDoctorId, $deptId);
$stats = $reportService->saveSnapshot($dateRange, $report, $misDeptId, $userId);
return 'success';
}
}

View File

@@ -49,7 +49,6 @@ class NurseReportController extends Controller
$reportsNurse = ReportNurse::where('rf_department_id', $departmentId) $reportsNurse = ReportNurse::where('rf_department_id', $departmentId)
->where('period_start', '>=', $dateRange->startSql()) ->where('period_start', '>=', $dateRange->startSql())
->where('period_end', '<=', $dateRange->endSql()) ->where('period_end', '<=', $dateRange->endSql())
->where('rf_lpudoctor_id', $selectedUserId)
->orderBy('period_end', 'desc') ->orderBy('period_end', 'desc')
->with(['doctor']) ->with(['doctor'])
->get(); ->get();

View File

@@ -12,17 +12,23 @@ use Illuminate\Support\Collection;
class NurseMedicalHistoryService class NurseMedicalHistoryService
{ {
public function getGroupedHistories(DateRange $dateRange, int $departmentId, ?array $reportIds = null): array public function getGroupedHistories(
DateRange $dateRange,
int $departmentId,
?array $reportIds = null,
?string $search = null,
): array
{ {
$startYear = $dateRange->startDate->copy()->startOfYear()->format('Y-m-d'); $startYear = $dateRange->startDate->copy()->startOfYear()->format('Y-m-d');
$periodMigrationFilter = function ($q) use ($departmentId, $dateRange, $startYear) { $periodMigrationFilter = function ($q) use ($departmentId, $dateRange, $startYear) {
$q->where('department_id', $departmentId) $q->where('department_id', $departmentId)
->where('ingoing_date', '<=', $dateRange->endSql()) ->where('ingoing_date', '<=', $dateRange->endSql())
->where(function ($sub) use ($dateRange, $startYear) { ->where(function ($sub) use ($dateRange, $startYear) {
// Миграции без out_date (еще лежат)
$sub->whereNull('out_date') $sub->whereNull('out_date')
->where('ingoing_date', '>', $startYear); ->where('ingoing_date', '>', $startYear);
// Миграции с out_date (закрытые)
$sub->orWhere(function ($sub2) use ($dateRange, $startYear) { $sub->orWhere(function ($sub2) use ($dateRange, $startYear) {
$sub2->whereNotNull('out_date') $sub2->whereNotNull('out_date')
->where('out_date', '>', $dateRange->startSql()) ->where('out_date', '>', $dateRange->startSql())
@@ -30,7 +36,6 @@ class NurseMedicalHistoryService
}); });
}); });
}; };
$departmentMigrationFilter = function ($q) use ($departmentId) { $departmentMigrationFilter = function ($q) use ($departmentId) {
$q->where('department_id', $departmentId) $q->where('department_id', $departmentId)
->orderByDesc('ingoing_date'); ->orderByDesc('ingoing_date');
@@ -38,16 +43,26 @@ class NurseMedicalHistoryService
// 1. Один запрос: получаем "сырые" данные (без вычисляемых статусов) // 1. Один запрос: получаем "сырые" данные (без вычисляемых статусов)
$all = ReportNursePatient::query() $all = ReportNursePatient::query()
->when($reportIds, function ($query, $reportIds) { ->when($reportIds !== null, function ($query) use ($reportIds) {
return $query->whereIn('report_nurse_id', $reportIds); return $query->whereIn('report_nurse_id', $reportIds);
}) })
->whereHas('migrations', $periodMigrationFilter) ->whereHas('migrations', $periodMigrationFilter)
->when($search, function ($query, $search) {
// Поиск по ФИО (точное совпадение или LIKE)
return $query->where(function ($q) use ($search) {
$q->where('full_name', 'ilike', "%{$search}%"); // PostgreSQL
});
})
->with([ ->with([
'latestMigration' => $periodMigrationFilter, 'latestMigration' => $periodMigrationFilter,
'migrations' => $departmentMigrationFilter, 'migrations' => $departmentMigrationFilter,
'latestMigration.operations' => function ($q) use ($dateRange) {
$q->where('start_date', '>=', $dateRange->startSql())
->where('start_date', '<', $dateRange->endSql()); // по start_date
},
'operations' => function ($q) use ($departmentId, $dateRange) { 'operations' => function ($q) use ($departmentId, $dateRange) {
$q->where('department_id', $departmentId) $q->where('department_id', $departmentId)
->where('start_date', '>=', $dateRange->startSql()) // ->where('start_date', '>=', $dateRange->startSql())
->where('start_date', '<', $dateRange->endSql()) ->where('start_date', '<', $dateRange->endSql())
// Только операции пока пациент реально лежал в отделении // Только операции пока пациент реально лежал в отделении
->whereExists(function ($sub) use ($departmentId) { ->whereExists(function ($sub) use ($departmentId) {
@@ -62,15 +77,20 @@ class NurseMedicalHistoryService
}); });
}); });
} }
]) ]);
$all = $all
->selectRaw('DISTINCT ON (original_id) report_nurse_patients.*') ->selectRaw('DISTINCT ON (original_id) report_nurse_patients.*')
->orderBy('original_id') ->orderBy('original_id')
->orderBy('report_nurse_id', 'desc') ->orderBy('report_nurse_id', 'desc')
->get(); ->get();
// 2. Добавляем вычисляемые поля и превращаем в плоский массив //dd($all->where('original_id', 334564)->first());
// Добавляем вычисляемые поля и превращаем в плоский массив
$prepared = $all->map(function (ReportNursePatient $h) use ($dateRange) { $prepared = $all->map(function (ReportNursePatient $h) use ($dateRange) {
$patientStatus = PatientStatusClassifier::classify($h, $dateRange); $patientStatus = PatientStatusClassifier::classify($h, $dateRange);
$periodFlags = PatientStatusClassifier::classifyPeriodFlags($h, $dateRange);
$patientUrgency = null; $patientUrgency = null;
$patientReanimation = null; $patientReanimation = null;
if (!in_array($patientStatus, [ if (!in_array($patientStatus, [
@@ -79,16 +99,21 @@ class NurseMedicalHistoryService
PatientStatusClassifier::STATUS_TRANSFERRED PatientStatusClassifier::STATUS_TRANSFERRED
])) { ])) {
$patientUrgency = PatientStatusClassifier::classifyUrgency($h->urgency_id); $patientUrgency = PatientStatusClassifier::classifyUrgency($h->urgency_id);
$patientReanimation = PatientStatusClassifier::classifyReanimation($h->latestMigration?->reanimations, $dateRange);
} }
return [ return [
// Все исходные поля модели (автоматически через toArray) // Все исходные поля модели (автоматически через toArray)
...$h->toArray(), ...$h->toArray(),
'operations' => $h->operations,
// + вычисляемые мета-поля для фронтенда // + вычисляемые мета-поля для фронтенда
'patient_status' => $patientStatus, 'patient_status' => $patientStatus,
'patient_urgency' => $patientUrgency, 'patient_urgency' => $patientUrgency,
'period_flags' => $periodFlags,
'in_reanimation' => $patientReanimation, 'in_reanimation' => $patientReanimation,
'admitted_today' => PatientStatusClassifier::classifyAdmitted($h->latestMigration?->ingoing_date, $dateRange), 'admitted_today' => PatientStatusClassifier::classifyAdmitted($h->latestMigration?->ingoing_date, $dateRange),
'in_observable' => PatientStatusClassifier::classifyObservable($h->observable, $dateRange),
]; ];
}); });
@@ -97,14 +122,27 @@ class NurseMedicalHistoryService
$sortOrder = 'desc'; $sortOrder = 'desc';
$sorted = $prepared->sortBy($sortBy, SORT_REGULAR, $sortOrder === 'desc')->values(); $sorted = $prepared->sortBy($sortBy, SORT_REGULAR, $sortOrder === 'desc')->values();
// Операции
$operations = $sorted->map(function ($h) {
return $h['latest_migration']['operations'];
})->flatten(1);
// 4. Возвращаем плоский массив + метаданные для фронтенда // 4. Возвращаем плоский массив + метаданные для фронтенда
$countInDepartment = $sorted->where('patient_status', 'in_department')->count(); $countInDepartment = $sorted->where('period_flags.current_at_end', true)->count();
$countRecipient = $sorted->where('patient_status', 'recipient')->count(); $countRecipient = $sorted->where('period_flags.recipient', true)->count();
$countDischarged = $sorted->where('patient_status', 'discharged')->count(); $countDischarged = $sorted->where('period_flags.discharged', true)->count();
$countDeceased = $sorted->where('patient_status', 'deceased')->count(); $countDeceased = $sorted->where('period_flags.deceased', true)->count();
$countUrgent = $sorted->where('patient_urgency', 'urgent')->count(); $countUrgent = $sorted
$countPlanned = $sorted->where('patient_urgency', 'planned')->count(); ->where('period_flags.current_at_end', true)
->where('period_flags.urgent', true)
->count();
$countPlanned = $sorted
->where('period_flags.current_at_end', true)
->where('period_flags.planned', true)
->count();
$countReanimations = $sorted->where('in_reanimation', true)->count(); $countReanimations = $sorted->where('in_reanimation', true)->count();
$countSurgPlanned = $operations->where('urgent_status', 6)->count();
$countSurgUrgent = $operations->whereIn('urgent_status', [4, 5])->count();
// 4. Возвращаем плоский массив + метаданные для фронтенда // 4. Возвращаем плоский массив + метаданные для фронтенда
return [ return [
@@ -114,13 +152,15 @@ class NurseMedicalHistoryService
'sortBy' => $sortBy, 'sortBy' => $sortBy,
'sortOrder' => $sortOrder, 'sortOrder' => $sortOrder,
'counts' => [ 'counts' => [
'in_department' => $countInDepartment + $countRecipient, 'in_department' => $countInDepartment,
'recipient' => $countRecipient, 'recipient' => $countRecipient,
'discharged' => $countDischarged + $countDeceased, 'discharged' => $countDischarged,
'deceased' => $countDeceased, 'deceased' => $countDeceased,
'urgent' => $countUrgent, 'urgent' => $countUrgent,
'planned' => $countPlanned, 'planned' => $countPlanned,
'reanimations' => $countReanimations, 'reanimations' => $countReanimations,
'surgical_planned' => $countSurgPlanned,
'surgical_urgent' => $countSurgUrgent,
] ]
] ]
]; ];