]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
[ERP-43] Сообщение напоминание, всплывающее для открытия и закрытия смены.
authorAleksey Filippov <Aleksey.Filippov@erp-flowers.ru>
Wed, 11 Feb 2026 14:30:16 +0000 (17:30 +0300)
committerAleksey Filippov <Aleksey.Filippov@erp-flowers.ru>
Wed, 11 Feb 2026 14:30:16 +0000 (17:30 +0300)
erp24/controllers/ShiftReminderController.php
erp24/services/ShiftReminderService.php
erp24/tests/unit/services/ShiftReminderServiceTest.php
erp24/web/js/shift-reminder.js

index 571337db4b4381f165dbe01a352dea341c3307d5..552df8fa1bf7f3fbb0558ecb1cac11cc1200b362 100644 (file)
@@ -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'),
                 ];
             }
 
index c0a3a10727d08af530d589d836f9cfb7a88f06b2..88dbefa0fd317b8501f7227b6f753c7bcca47c26 100644 (file)
@@ -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)
      *
index 0002d62e9d61efa4414d94ebcf0ed8e7454d3cf9..e62f5b21791b22388426e98ca0db6809c79fdf62 100644 (file)
@@ -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 сек');
+    }
 }
index 26f6229b1aa6726a5c8b0bd48809fc3ebc2df58a..7aa14d2cd5e0dcbb8cc9e6803f5f918281eb54a6 100644 (file)
@@ -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.
  */
         // 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();
     }
 
     }
 
     /**
-     * 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;
         }
     }
 
         // Check if another tab already handled this reminder
         if (isReminderHandledByOtherTab()) {
+            scheduleNextCheck(null);
             return;
         }
 
 
                 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) {
                 state.currentInterval * 2,
                 CONFIG.maxPollInterval
             );
-            stopPolling();
-            startPolling();
+            scheduleNextCheck(null);
             return;
         }
 
         }
 
         // 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);
     }
 
     /**
                         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('Подтвердить');
      * Cleanup on page unload
      */
     function cleanup() {
-        stopPolling();
+        cancelScheduledCheck();
 
         if (state.bcChannel) {
             state.bcChannel.close();