From c6ea11b86650fcdc4abd96356c28b66b76b189e7 Mon Sep 17 00:00:00 2001 From: Aleksey Filippov Date: Wed, 11 Feb 2026 17:30:16 +0300 Subject: [PATCH] =?utf8?q?[ERP-43]=20=D0=A1=D0=BE=D0=BE=D0=B1=D1=89=D0=B5?= =?utf8?q?=D0=BD=D0=B8=D0=B5=20=D0=BD=D0=B0=D0=BF=D0=BE=D0=BC=D0=B8=D0=BD?= =?utf8?q?=D0=B0=D0=BD=D0=B8=D0=B5,=20=D0=B2=D1=81=D0=BF=D0=BB=D1=8B=D0=B2?= =?utf8?q?=D0=B0=D1=8E=D1=89=D0=B5=D0=B5=20=D0=B4=D0=BB=D1=8F=20=D0=BE?= =?utf8?q?=D1=82=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D1=8F=20=D0=B8=20=D0=B7?= =?utf8?q?=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D1=8F=20=D1=81=D0=BC=D0=B5?= =?utf8?q?=D0=BD=D1=8B.?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit --- erp24/controllers/ShiftReminderController.php | 44 ++++++- erp24/services/ShiftReminderService.php | 90 ++++++++++++++ .../services/ShiftReminderServiceTest.php | 116 ++++++++++++++++++ erp24/web/js/shift-reminder.js | 55 ++++++--- 4 files changed, 279 insertions(+), 26 deletions(-) diff --git a/erp24/controllers/ShiftReminderController.php b/erp24/controllers/ShiftReminderController.php index 571337db..552df8fa 100644 --- a/erp24/controllers/ShiftReminderController.php +++ b/erp24/controllers/ShiftReminderController.php @@ -189,21 +189,52 @@ class ShiftReminderController extends Controller $reminderKey = 'night_shift'; } - // Проверяем, нужно ли показать напоминание - $shouldShow = ShiftReminderService::shouldShowReminder($userId, $reminderKey); + // Последовательная проверка условий с определением причины отказа. + // Это позволяет вернуть next_check_at — оптимальный момент следующей проверки. - if ($shouldShow) { + // Проверка 1: Пользователь — администратор со сменой? + if (!ShiftReminderService::isUserAdministrator($userId)) { return [ 'success' => true, - 'show_reminder' => true, + 'show_reminder' => false, + 'next_check_at' => ShiftReminderService::getNextCheckTimestamp('not_admin'), + ]; + } + + // Проверка 2: Нужно ли действие со сменой (открытие/закрытие)? + if (!ShiftReminderService::isShiftActionNeeded($userId)) { + return [ + 'success' => true, + 'show_reminder' => false, + 'next_check_at' => ShiftReminderService::getNextCheckTimestamp('no_action_needed'), + ]; + } + + // Проверка 3: Напоминание уже подтверждено для этого периода? + $referenceDate = ShiftReminderService::getReferenceDate(); + $alreadyConfirmed = \app\models\ShiftReminderShown::find() + ->where([ + 'user_id' => $userId, 'reminder_key' => $reminderKey, - 'reference_date' => ShiftReminderService::getReferenceDate(), + 'reference_date' => $referenceDate, + ]) + ->andWhere(['IS NOT', 'confirmed_at', null]) + ->exists(); + + if ($alreadyConfirmed) { + return [ + 'success' => true, + 'show_reminder' => false, + 'next_check_at' => ShiftReminderService::getNextCheckTimestamp('already_confirmed'), ]; } + // Все проверки пройдены — показать напоминание return [ 'success' => true, - 'show_reminder' => false, + 'show_reminder' => true, + 'reminder_key' => $reminderKey, + 'reference_date' => $referenceDate, ]; } catch (\Exception $e) { Yii::error('Error in ShiftReminderController::actionCheck: ' . $e->getMessage(), 'shift-reminder'); @@ -281,6 +312,7 @@ class ShiftReminderController extends Controller 'success' => true, 'message' => 'Reminder confirmed successfully.', 'reference_date' => $referenceDate, + 'next_check_at' => ShiftReminderService::getNextCheckTimestamp('already_confirmed'), ]; } diff --git a/erp24/services/ShiftReminderService.php b/erp24/services/ShiftReminderService.php index c0a3a107..88dbefa0 100644 --- a/erp24/services/ShiftReminderService.php +++ b/erp24/services/ShiftReminderService.php @@ -288,6 +288,96 @@ class ShiftReminderService return $reminder->markAsConfirmed(); } + /** + * Вычислить Unix timestamp ближайшей границы периода смен минус 5 минут + * + * Границы периодов: 07:50, 08:10, 19:50, 20:10 + * Возвращает timestamp за 5 минут до ближайшей будущей границы, + * чтобы фронтенд начал поллинг заблаговременно. + * + * @param string|null $currentTime Текущее время в формате 'Y-m-d H:i:s' + * @return int Unix timestamp + */ + private static function getNextWindowBoundary($currentTime = null) + { + if ($currentTime === null) { + $currentTime = date('Y-m-d H:i:s'); + } + + $dateTime = new \DateTime($currentTime); + $currentTimeStr = $dateTime->format('H:i'); + $today = $dateTime->format('Y-m-d'); + + // Границы периодов в хронологическом порядке + $boundaries = ['07:50', '08:10', '19:50', '20:10']; + + // Находим ближайшую будущую границу + foreach ($boundaries as $boundary) { + if ($currentTimeStr < $boundary) { + $target = new \DateTime($today . ' ' . $boundary . ':00'); + $target->modify('-5 minutes'); + return $target->getTimestamp(); + } + } + + // Все границы на сегодня прошли → следующая 07:50 завтра + $tomorrow = (new \DateTime($today))->modify('+1 day')->format('Y-m-d'); + $target = new \DateTime($tomorrow . ' 07:50:00'); + $target->modify('-5 minutes'); + return $target->getTimestamp(); + } + + /** + * Определить Unix timestamp следующей проверки для фронтенда + * + * На основании причины, по которой show_reminder = false, вычисляет + * оптимальный момент для следующего запроса /shift-reminder/check. + * + * @param string $reason Причина: 'not_admin', 'already_confirmed', 'no_action_needed' + * @param string|null $currentTime Текущее время в формате 'Y-m-d H:i:s' + * @return int Unix timestamp + */ + public static function getNextCheckTimestamp($reason, $currentTime = null) + { + if ($currentTime === null) { + $currentTime = date('Y-m-d H:i:s'); + } + + $now = (new \DateTime($currentTime))->getTimestamp(); + $minimum = $now + 30; // Кламп: не раньше чем через 30 секунд + + switch ($reason) { + case 'not_admin': + // Статус админа не изменится в течение дня. + // Следующая проверка — после rollover reference_date (06:05 следующего дня). + $dateTime = new \DateTime($currentTime); + $hour = (int)$dateTime->format('H'); + if ($hour < 6) { + // Сейчас 00:00-05:59, rollover будет сегодня в 06:05 + $target = new \DateTime($dateTime->format('Y-m-d') . ' 06:05:00'); + } else { + // Сейчас после 06:00, rollover будет завтра в 06:05 + $tomorrow = (clone $dateTime)->modify('+1 day')->format('Y-m-d'); + $target = new \DateTime($tomorrow . ' 06:05:00'); + } + return max($target->getTimestamp(), $minimum); + + case 'already_confirmed': + // Подтверждение действует до конца текущего периода. + // Проверить снова перед следующей границей. + return max(self::getNextWindowBoundary($currentTime), $minimum); + + case 'no_action_needed': + // Смена уже открыта/закрыта, но коллега может изменить состояние. + // Перепроверить через 5 минут. + return max($now + 300, $minimum); + + default: + // Safety net — стандартный интервал 30 секунд + return $minimum; + } + } + /** * Получить список допустимых ключей напоминаний (whitelist) * diff --git a/erp24/tests/unit/services/ShiftReminderServiceTest.php b/erp24/tests/unit/services/ShiftReminderServiceTest.php index 0002d62e..e62f5b21 100644 --- a/erp24/tests/unit/services/ShiftReminderServiceTest.php +++ b/erp24/tests/unit/services/ShiftReminderServiceTest.php @@ -651,4 +651,120 @@ class ShiftReminderServiceTest extends Unit ); } } + + // ======================================================================== + // ГРУППА 6: getNextCheckTimestamp() - Серверное планирование следующей проверки + // ======================================================================== + + /** + * Тест: not_admin возвращает 06:05 следующего дня (после 06:00) + */ + public function testGetNextCheckTimestamp_NotAdmin_AfterSixAm_ReturnsTomorrow() + { + // Arrange: 10:00 утра + $currentTime = '2026-02-04 10:00:00'; + + // Act + $result = ShiftReminderService::getNextCheckTimestamp('not_admin', $currentTime); + + // Assert: должно быть 2026-02-05 06:05:00 + $expected = (new \DateTime('2026-02-05 06:05:00'))->getTimestamp(); + $this->assertEquals($expected, $result, 'not_admin после 06:00 → завтра 06:05'); + } + + /** + * Тест: not_admin возвращает 06:05 сегодня (до 06:00) + */ + public function testGetNextCheckTimestamp_NotAdmin_BeforeSixAm_ReturnsToday() + { + // Arrange: 03:00 ночи + $currentTime = '2026-02-04 03:00:00'; + + // Act + $result = ShiftReminderService::getNextCheckTimestamp('not_admin', $currentTime); + + // Assert: должно быть 2026-02-04 06:05:00 + $expected = (new \DateTime('2026-02-04 06:05:00'))->getTimestamp(); + $this->assertEquals($expected, $result, 'not_admin до 06:00 → сегодня 06:05'); + } + + /** + * Тест: no_action_needed возвращает now + 5 минут + */ + public function testGetNextCheckTimestamp_NoActionNeeded_ReturnsFiveMinutes() + { + // Arrange + $currentTime = '2026-02-04 12:00:00'; + $nowTimestamp = (new \DateTime($currentTime))->getTimestamp(); + + // Act + $result = ShiftReminderService::getNextCheckTimestamp('no_action_needed', $currentTime); + + // Assert: должно быть now + 300 секунд + $this->assertEquals($nowTimestamp + 300, $result, 'no_action_needed → now + 5 минут'); + } + + /** + * Тест: already_confirmed перед первой границей → 07:45 сегодня + */ + public function testGetNextCheckTimestamp_AlreadyConfirmed_BeforeFirstWindow_ReturnsSevenFortyFive() + { + // Arrange: 06:30 утра — до первой границы 07:50 + $currentTime = '2026-02-04 06:30:00'; + + // Act + $result = ShiftReminderService::getNextCheckTimestamp('already_confirmed', $currentTime); + + // Assert: ближайшая граница 07:50 минус 5 минут = 07:45 + $expected = (new \DateTime('2026-02-04 07:45:00'))->getTimestamp(); + $this->assertEquals($expected, $result, 'already_confirmed до 07:50 → 07:45'); + } + + /** + * Тест: already_confirmed между границами → следующая граница минус 5 минут + */ + public function testGetNextCheckTimestamp_AlreadyConfirmed_BetweenWindows_ReturnsNextBoundary() + { + // Arrange: 10:00 — между 08:10 и 19:50 + $currentTime = '2026-02-04 10:00:00'; + + // Act + $result = ShiftReminderService::getNextCheckTimestamp('already_confirmed', $currentTime); + + // Assert: ближайшая граница 19:50 минус 5 минут = 19:45 + $expected = (new \DateTime('2026-02-04 19:45:00'))->getTimestamp(); + $this->assertEquals($expected, $result, 'already_confirmed между 08:10-19:50 → 19:45'); + } + + /** + * Тест: already_confirmed после последней границы → завтра 07:45 + */ + public function testGetNextCheckTimestamp_AlreadyConfirmed_AfterLastWindow_ReturnsTomorrow() + { + // Arrange: 21:00 — после последней границы 20:10 + $currentTime = '2026-02-04 21:00:00'; + + // Act + $result = ShiftReminderService::getNextCheckTimestamp('already_confirmed', $currentTime); + + // Assert: следующая граница — 07:50 завтра минус 5 минут = 07:45 завтра + $expected = (new \DateTime('2026-02-05 07:45:00'))->getTimestamp(); + $this->assertEquals($expected, $result, 'already_confirmed после 20:10 → завтра 07:45'); + } + + /** + * Тест: неизвестная причина → fallback now + 30 секунд + */ + public function testGetNextCheckTimestamp_UnknownReason_ReturnsFallback() + { + // Arrange + $currentTime = '2026-02-04 12:00:00'; + $nowTimestamp = (new \DateTime($currentTime))->getTimestamp(); + + // Act + $result = ShiftReminderService::getNextCheckTimestamp('unknown_reason', $currentTime); + + // Assert: fallback = now + 30 + $this->assertEquals($nowTimestamp + 30, $result, 'Неизвестная причина → now + 30 сек'); + } } diff --git a/erp24/web/js/shift-reminder.js b/erp24/web/js/shift-reminder.js index 26f6229b..7aa14d2c 100644 --- a/erp24/web/js/shift-reminder.js +++ b/erp24/web/js/shift-reminder.js @@ -1,7 +1,8 @@ /** * Shift Reminder System * - * Polls backend every 30 seconds to check if reminder should be shown. + * Uses server-driven scheduling: backend returns next_check_at timestamp + * so frontend checks only when needed (not every 30 seconds). * Displays fullscreen modal with audio alert when reminder is needed. * Implements day rollover logic and multi-tab coordination. */ @@ -54,10 +55,7 @@ // Initialize Web Audio API (lazy loading to respect autoplay policies) initAudioContext(); - // Start polling - startPolling(); - - // Check immediately on load + // First check on load — response will schedule the next one via next_check_at checkReminder(); } @@ -76,24 +74,38 @@ } /** - * Start polling for reminders + * Schedule the next reminder check based on server-provided next_check_at. + * + * @param {number|null} nextCheckAt Unix timestamp (seconds) from server response. + * If null/undefined, falls back to current interval (exponential backoff or default 30s). */ - function startPolling() { + function scheduleNextCheck(nextCheckAt) { if (state.pollTimer) { - clearInterval(state.pollTimer); + clearTimeout(state.pollTimer); + state.pollTimer = null; } - state.pollTimer = setInterval(() => { + var delayMs; + if (nextCheckAt) { + var nowSec = Math.floor(Date.now() / 1000); + delayMs = (nextCheckAt - nowSec) * 1000; + delayMs = Math.max(delayMs, 5000); // minimum 5 seconds + delayMs = Math.min(delayMs, 12 * 60 * 60 * 1000); // maximum 12 hours + } else { + delayMs = state.currentInterval; // fallback: 30s or backoff value + } + + state.pollTimer = setTimeout(function() { checkReminder(); - }, state.currentInterval); + }, delayMs); } /** - * Stop polling + * Cancel any scheduled check */ - function stopPolling() { + function cancelScheduledCheck() { if (state.pollTimer) { - clearInterval(state.pollTimer); + clearTimeout(state.pollTimer); state.pollTimer = null; } } @@ -109,6 +121,7 @@ // Check if another tab already handled this reminder if (isReminderHandledByOtherTab()) { + scheduleNextCheck(null); return; } @@ -126,6 +139,9 @@ if (response.success && response.show_reminder) { showReminderModal(response); + } else { + // Schedule next check using server-provided timestamp + scheduleNextCheck(response.next_check_at || null); } }, error: function(xhr, status, error) { @@ -144,8 +160,7 @@ state.currentInterval * 2, CONFIG.maxPollInterval ); - stopPolling(); - startPolling(); + scheduleNextCheck(null); return; } @@ -155,10 +170,9 @@ } // Ensure retryCount doesn't exceed array bounds (defensive programming) - const safeRetryCount = Math.min(state.retryCount, CONFIG.retryIntervals.length - 1); + var safeRetryCount = Math.min(state.retryCount, CONFIG.retryIntervals.length - 1); state.currentInterval = CONFIG.retryIntervals[safeRetryCount]; - stopPolling(); - startPolling(); + scheduleNextCheck(null); } /** @@ -400,8 +414,9 @@ timestamp: new Date().getTime() }); - // Close modal + // Close modal and schedule next check using server timestamp closeModal(); + scheduleNextCheck(response.next_check_at || null); } else { alert('Ошибка подтверждения: ' + (response.message || 'Неизвестная ошибка')); $('#shift-reminder-confirm-btn').prop('disabled', false).text('Подтвердить'); @@ -475,7 +490,7 @@ * Cleanup on page unload */ function cleanup() { - stopPolling(); + cancelScheduledCheck(); if (state.bcChannel) { state.bcChannel.close(); -- 2.39.5