getDateRangeForUser($user, $startAt, $endAt, $extendRangeEnd); $startCarbon = Carbon::parse($startDate)->setTimeZone('Asia/Yakutsk'); $endCarbon = Carbon::parse($endDate)->setTimeZone('Asia/Yakutsk'); // Для календаря в UI показываем ровно то, что выбрал пользователь — без служебного // сдвига конца периода на +1 сутки (см. getCustomDateRange). Если конец не передан // явно (дефолтный диапазон) — сдвига и не было, показываем как есть. $displayEndCarbon = $endAt ? $this->parseDate($endAt) : $endCarbon; return new DateRange( startDate: $startCarbon, endDate: $endCarbon, startDateRaw: $startDate, endDateRaw: $endDate, isOneDay: $this->isRangeOneDay($startDate, $endDate), displayEndDate: $displayEndCarbon, ); } /** * Получить диапазон дат из запроса * * @param bool $extendRangeEnd Сдвигать конец многодневного диапазона на +1 сутки, * чтобы захватить смену, начинающуюся в последний выбранный день (нужно для * агрегированной статистики за период; для отчёта дежурного по конкретной * смене/сменам — не нужно, там диапазон должен быть ровно тем, что выбрали). */ public function getDateRangeFromRequest(Request $request, User $user, bool $extendRangeEnd = true): DateRange { return $this->getNormalizedDateRange( $user, $request->query('startAt', $request->get('startAt')), $request->query('endAt', $request->get('endAt')), $extendRangeEnd ); } /** * Получить диапазон дат для страницы /report: * всегда сутки (07:00 предыдущего дня -> 07:00 выбранного дня) */ public function getReportDateRangeFromRequest(Request $request, User $user): DateRange { $endAtInput = $request->query('endAt', $request->get('endAt')); $startAtInput = $request->query('startAt', $request->get('startAt')); if (!$endAtInput && !$startAtInput) { [$startDate, $endDate] = $this->currentShiftBoundaries(); } else { $endDate = ($endAtInput ? $this->parseDate($endAtInput) : Carbon::now('Asia/Yakutsk')) ->copy()->setTime(9, 0); $startDate = ($startAtInput ? $this->parseDate($startAtInput) : Carbon::now('Asia/Yakutsk')) ->copy()->subDay()->setTime(9, 0); } return new DateRange( startDate: $startDate, endDate: $endDate, startDateRaw: $startDate->format('Y-m-d H:i:s'), endDateRaw: $endDate->format('Y-m-d H:i:s'), isOneDay: true ); } /** * Получить диапазон дат для статистики (по умолчанию с начала года) */ public function getStatisticsDateRange(User $user, ?string $startAt = null, ?string $endAt = null): array { // Если даты переданы - используем их if ($startAt && $endAt) { return $this->getDateRangeForUser($user, $startAt, $endAt); } if ($user->isDoctor()) { return $this->getDateRangeForUser($user, $startAt, $endAt); } // По умолчанию: с начала года до сегодня $startDate = Carbon::now('Asia/Yakutsk') ->startOfYear() // 1 января текущего года ->subDay() ->setTime(9, 0); $endDate = Carbon::now('Asia/Yakutsk') ->setTime(9, 0); return [ $startDate->format('Y-m-d H:i:s'), $endDate->format('Y-m-d H:i:s'), ]; } public function getDateRangeForUser($user, $startAt = null, $endAt = null, bool $extendRangeEnd = true): array { if ($startAt && $endAt) { return $this->getCustomDateRange($startAt, $endAt, $user, $extendRangeEnd); } return $this->getDefaultDateRange($user); } public function isRangeOneDay($startAt = null, $endAt = null): bool { if (! $startAt || ! $endAt) { return false; } $startDate = $this->parseDate($startAt); $endDate = $this->parseDate($endAt); return $startDate->isSameDay($endDate) || $startDate->diffInDays($endDate) === 1.0; } private function getCustomDateRange($startAt, $endAt, $user, bool $extendRangeEnd = true): array { $startDate = $this->parseDate($startAt); $endDate = $this->parseDate($endAt); // Шорткаты календаря (Сегодня/За 7 дней/За 30 дней/Текущий год) уже присылают // готовые границы смен ровно на 09:00 — их трогать не нужно. "Сырые" даты из // календаря (выбор конкретных дней кликом) приходят на 00:00 и требуют нормализации. $endAlreadyResolved = $endDate->format('H:i:s') === '09:00:00'; if ($startDate->isSameDay($endDate)) { $startDate = $startDate->setTime(9, 0); $endDate = $endDate->addDay()->setTime(9, 0); } else { $startDate = $startDate->setTime(9, 0); // Смена длится сутки и "принадлежит" дню, в который начинается (см. currentShiftBoundaries). // Поэтому смена последнего выбранного дня захватывает следующие календарные сутки — // но только там, где нужен захват целиком (агрегированная статистика за период, // $extendRangeEnd = true). Для конкретной смены/отчёта диапазон должен быть ровно тем, // что выбрали. $shouldExtend = $extendRangeEnd && ! $endAlreadyResolved; $endDate = $shouldExtend ? $endDate->addDay()->setTime(9, 0) : $endDate->setTime(9, 0); } return [ $startDate->format('Y-m-d H:i:s'), $endDate->format('Y-m-d H:i:s'), ]; } private function getDefaultDateRange($user): array { $requestPath = request()->path(); $switchToday = $requestPath === 'report' || $requestPath === 'nurse/report'; [$startDate, $endDate] = $this->currentShiftBoundaries($switchToday); return [ $startDate->format('Y-m-d H:i:s'), $endDate->format('Y-m-d H:i:s'), ]; } private function currentShiftBoundaries(bool $switchToday = false): array { $now = Carbon::now('Asia/Yakutsk'); $todayAt9 = $now->copy()->setTime(9, 0, 0); if ($switchToday && $now->gte($todayAt9)) { return [$todayAt9, $todayAt9->copy()->addDay()]; } return [$todayAt9->copy()->subDay(), $todayAt9]; } public function parseDate($dateInput): Carbon { if (is_numeric($dateInput)) { $timestamp = (string) $dateInput; $isMilliseconds = strlen(ltrim($timestamp, '-')) > 10; return $isMilliseconds ? Carbon::createFromTimestampMs((int) $dateInput)->setTimezone('Asia/Yakutsk') : Carbon::createFromTimestamp((int) $dateInput)->setTimezone('Asia/Yakutsk'); } return Carbon::parse($dateInput, 'Asia/Yakutsk'); } /** * Конвертировать дату в Carbon объект */ public function toCarbon($date): Carbon { if ($date instanceof Carbon) { return $date; } if (is_numeric($date)) { return $this->parseDate($date); } if (is_string($date)) { return Carbon::parse($date, 'Asia/Yakutsk'); } return Carbon::parse($date); } /** * Получить дату в формате для SQL запроса */ public function toSqlFormat($date): string { return $this->toCarbon($date)->format('Y-m-d H:i:s'); } public function createDateRangeForDate(Carbon $date, User $user): DateRange { // Для автоматического заполнения используем логику как для врача // (вчера 07:00 - сегодня 07:00) $startDate = $date->copy()->subDay()->setTime(9, 0); $endDate = $date->copy()->setTime(9, 0); return new DateRange( startDate: $startDate, endDate: $endDate, startDateRaw: $startDate->format('Y-m-d H:i:s'), endDateRaw: $endDate->format('Y-m-d H:i:s'), isOneDay: true ); } /** * Проверяет, является ли запрошенный период полностью прошедшим */ public function isPastPeriod(DateRange $dateRange): bool { $periodStart = $dateRange->start(); $now = now(); // Получаем начало текущей смены (сегодня 09:00) $currentShiftStart = $now->copy()->setTime(9, 0, 0); if ($now->lt($currentShiftStart)) { $currentShiftStart->subDay(); } // Если период начался ДО начала текущей смены - он прошедший // (даже если формально он еще не закончился) return $periodStart->lt($currentShiftStart); } public function dateInPeriod(Carbon|string|null $date, DateRange $dateRange): bool { $date = self::toCarbon($date); return $date && $date->gt($dateRange->startDate) && $date->lte($dateRange->endDate); } }