]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
Правки по отчету и тесты
authorVladimir Fomichev <vladimir.fomichev@erp-flowers.ru>
Fri, 23 Jan 2026 09:38:27 +0000 (12:38 +0300)
committerVladimir Fomichev <vladimir.fomichev@erp-flowers.ru>
Fri, 23 Jan 2026 09:38:27 +0000 (12:38 +0300)
erp24/config/web.php
erp24/services/MarketplaceService.php
erp24/services/OrderControlReportService.php
erp24/services/dto/OrderIssue.php
erp24/tests/unit/commands/MarketplaceControllerTest.php [new file with mode: 0644]
erp24/tests/unit/services/OrderControlReportServiceNotificationTest.php [new file with mode: 0644]

index 0e140e1f06a51ad3ab57df2efe9c9d036799c111..dacd629a63871cf35dfa5865fbe26ec5c59fbd28 100644 (file)
@@ -79,8 +79,8 @@ $config = [
                 'dsn' => sprintf(
                     '%s://%s:%s@%s:%d',
                     getenv('MAIL_SCHEME') ?: 'smtps',
-                    urlencode(getenv('MAIL_USERNAME') ?: ''),
-                    urlencode(getenv('MAIL_PASSWORD') ?: ''),
+                    urlencode(getenv('MAIL_USERNAME') ?: 'flow@bazacvetov24.ru'),
+                    urlencode(getenv('MAIL_PASSWORD') ?: 'ctqamxqeshgxwsgn'),
                     getenv('MAIL_HOST') ?: 'smtp.yandex.ru',
                     (int)(getenv('MAIL_PORT') ?: 465)
                 ),
index f71c967c07a1d78458a80c97cd9ffa83c8d02cbe..81365810009d1937dd5d996588920a52953cd19a 100644 (file)
@@ -2653,7 +2653,7 @@ class MarketplaceService
             Yii::warning('Не удалось извлечь данные заказа из HTML', __METHOD__);
             return [];
         } else {
-            Yii::warning('Успешно распарен заказ №' . $orderNumber, __METHOD__);
+            Yii::warning('Успешно распарсен заказ №' . $orderNumber, __METHOD__);
             return $order;
         }
     }
@@ -2673,9 +2673,22 @@ class MarketplaceService
         $statusCodes = array_unique(array_keys($statuses));
         $newOrdersCount = 0;
         $campaignId = $store;
+        
+        // Проверяем, что $order не пустой
+        if (empty($order) || !is_array($order)) {
+            Yii::warning('Пустой или невалидный массив заказов передан в processFlowwowOrders', __METHOD__);
+            return 0;
+        }
+        
         $orderNumber = key($order);
         $orderDetails = reset($order);
 
+        // Проверяем, что удалось извлечь данные заказа
+        if ($orderDetails === false || !is_array($orderDetails)) {
+            Yii::warning('Не удалось извлечь данные заказа из массива', __METHOD__);
+            return 0;
+        }
+
         $statusId = self::getOrCreateStatus($statusCode, $statuses, $statusCodes);
         $substatusId = self::getOrCreateStatus($substatusCode, $statuses, $statusCodes);
 
@@ -2688,20 +2701,26 @@ class MarketplaceService
         if (!$marketplaceOrder) {
             if ($index == self::SUBJECT_INDEX[self::SUBJECT_NEW]) {
                 $marketplaceOrder = self::createOrderFlowwow($orderDetails, $campaignId, $statusId, $substatusId);
-                if ($marketplaceOrder->save()) {
+                if ($marketplaceOrder && $marketplaceOrder->save()) {
                     self::sendMessageToTelegram($marketplaceOrder->guid, "Новый  заказ Флаувау №" . $marketplaceOrder->marketplace_order_id);
                      $newOrdersCount += 1;
                      self::createOrUpdateStatusHistory($marketplaceOrder->id, $statusId, $substatusId, $orderDetails);
                      self::saveOrderItems($orderDetails, $marketplaceOrder->id, $marketplaceOrder->warehouse_guid);
                 } else {
-                    Yii::error(
-                        'Ошибка сохранения заказа: ' . json_encode(
-                            $marketplaceOrder->getErrors(),
-                            JSON_UNESCAPED_UNICODE
-                        )
-                    );
+                    if ($marketplaceOrder) {
+                        Yii::error(
+                            'Ошибка сохранения заказа: ' . json_encode(
+                                $marketplaceOrder->getErrors(),
+                                JSON_UNESCAPED_UNICODE
+                            )
+                        );
+                    } else {
+                        Yii::error('Не удалось создать объект заказа Flowwow', __METHOD__);
+                    }
+                }
+                if ($marketplaceOrder) {
+                    self::setReadyMarketplaceOrders($marketplaceOrder);
                 }
-                self::setReadyMarketplaceOrders($marketplaceOrder);
             }
         } else {
             $marketplaceOrder->status_id = $statusId;
@@ -2826,6 +2845,12 @@ class MarketplaceService
 
     private static function createOrderFlowwow($order, $campaignId, $statusId, $substatusId)
     {
+        // Проверяем, что $order является массивом
+        if (!is_array($order)) {
+            Yii::error('Параметр $order должен быть массивом, получен: ' . gettype($order), __METHOD__);
+            return null;
+        }
+        
         $store = MarketplaceStore::find()
             ->where(['warehouse_guid' => (string)$campaignId])
             ->andWhere(['warehouse_id' => 1])->one();
index bbf76327cac16685a0f87cfcb8fb9863b48bf96d..bf8b664e0fb38bf3eda4abac3e3030ba94897928 100644 (file)
@@ -18,7 +18,7 @@ use yii_app\services\dto\ControlReportResult;
  * Три типа проблем:
  * 1. "Завис в доставке" - РМК="Передан курьеру", МП≠"Выполнен"
  * 2. "Успех без чека" - МП="Выполнен", РМК≠"6. Успех"
- * 3. "Отмена без обработки" - МП="Отменён", РМК≠"Отказ"
+ * 3. "Отмена без обработки" - МП="Отменён", РМК="Отказ", cancelled_order_sent=0 (отмена не отправлена)
  *
  * Запускается по расписанию 08:00 и 20:00 MSK.
  */
@@ -32,12 +32,12 @@ class OrderControlReportService
     /**
      * Максимальное количество попыток отправки
      */
-    public const MAX_RETRIES = 3;
+    public const MAX_RETRIES = 5;
 
     /**
-     * Задержка между попытками в секундах
+     * Задержка между попытками в секундах (используется если retry_after не указан)
      */
-    public const RETRY_DELAY_SECONDS = 5;
+    public const RETRY_DELAY_SECONDS = 10;
 
     /**
      * Максимальная длина сообщения Telegram
@@ -243,6 +243,11 @@ class OrderControlReportService
 
         // Получаем ID статусов "Передан курьеру" из БД
         $rmkCourierIds = $this->getRmkStatusCourier();
+        
+        $this->logInfo('ID статусов "Передан курьеру"', [
+            'rmk_courier_ids' => $rmkCourierIds,
+            'count' => count($rmkCourierIds),
+        ]);
 
         // Формируем плейсхолдеры для IN-условия
         $rmkCourierPlaceholders = [];
@@ -255,6 +260,10 @@ class OrderControlReportService
         $rmkCourierInClause = !empty($rmkCourierPlaceholders)
             ? implode(', ', $rmkCourierPlaceholders)
             : '0'; // fallback если статусов нет
+        
+        if (empty($rmkCourierIds)) {
+            $this->logWarning('Не найдено статусов "Передан курьеру" в БД. Проверьте наличие статусов DELIVERY/COURIER_RECEIVED и связей в marketplace_order_1c_statuses');
+        }
 
         // Выбираем заказы с РМК-статусом "Передан курьеру", где МП-статус НЕ "Выполнен"
         $sql = "
@@ -271,19 +280,24 @@ class OrderControlReportService
                 mocs.status as rmk_status,
                 most.code as mp_status_code,
                 mosub.code as mp_substatus_code,
-                COALESCE(most.name, mosub.name) as mp_status_name
+                COALESCE(most.name, mosub.name) as mp_status_name,
+                'no_mp_success' as issue_reason
             FROM marketplace_orders mo
             LEFT JOIN city_store cs ON cs.id = mo.store_id
             LEFT JOIN marketplace_order_1c_statuses mocs ON mocs.id = mo.status_processing_1c::integer
             LEFT JOIN marketplace_order_status_types most ON most.id = mo.status_id
             LEFT JOIN marketplace_order_status_types mosub ON mosub.id = mo.substatus_id
             WHERE mo.fake = 0
+              AND mo.status_processing_1c IS NOT NULL
               AND mo.status_processing_1c::integer IN ({$rmkCourierInClause})
               AND mo.updated_at >= :start_date
               AND mo.updated_at <= :end_date
               AND (
                   most.code IS NULL
-                  OR (most.code != :delivered AND mosub.code IS DISTINCT FROM :delivery_service_delivered)
+                  OR (
+                      most.code != :delivered 
+                      AND (mosub.code IS NULL OR mosub.code != :delivery_service_delivered)
+                  )
               )
             ORDER BY cs.name ASC, mo.creation_date DESC
         ";
@@ -296,13 +310,22 @@ class OrderControlReportService
         ], $rmkCourierParams);
 
         $orders = Yii::$app->db->createCommand($sql, $params)->queryAll();
+        
+        $this->logInfo('SQL запрос для кандидатов "Завис в доставке"', [
+            'date_range' => ['start' => $startDateStr, 'end' => $endDateStr],
+            'rmk_courier_ids' => $rmkCourierIds,
+            'found_orders' => count($orders),
+        ]);
 
         $issues = [];
         foreach ($orders as $orderData) {
             $issues[] = OrderIssue::fromOrderData(OrderIssue::TYPE_HUNG_IN_DELIVERY, $orderData, $this->testMode);
         }
 
-        $this->logInfo('Найдено кандидатов "Завис в доставке"', ['count' => count($issues)]);
+        $this->logInfo('Найдено кандидатов "Завис в доставке"', [
+            'count' => count($issues),
+            'order_ids' => array_map(fn($issue) => $issue->orderNumber, $issues),
+        ]);
 
         return $issues;
     }
@@ -374,14 +397,20 @@ class OrderControlReportService
             $prevInterval = '08:00';
         }
 
+        // Получаем ID статусов "Передан курьеру" для проверки
+        $rmkCourierIds = $this->getRmkStatusCourier();
+        
         // Получаем предыдущие записи кандидатов со статусом "Передан курьеру"
+        // rmk_status_id хранится как строка, поэтому конвертируем ID в строки для сравнения
+        $rmkCourierIdsAsStrings = array_map('strval', $rmkCourierIds);
+        
         $previousRecords = MarketplaceOrderDailyIssues::find()
             ->where([
                 'problem_type' => OrderIssue::TYPE_HUNG_IN_DELIVERY,
                 'report_date' => $prevDate,
                 'interval' => $prevInterval,
             ])
-            ->andWhere(['rmk_status_id' => $this->getRmkStatusCourier()])
+            ->andWhere(['in', 'rmk_status_id', $rmkCourierIdsAsStrings])
             ->indexBy('order_id')
             ->asArray()
             ->all();
@@ -390,16 +419,33 @@ class OrderControlReportService
             'count' => count($previousRecords),
             'prev_date' => $prevDate,
             'prev_interval' => $prevInterval,
+            'rmk_courier_ids' => $rmkCourierIds,
+            'rmk_courier_ids_as_strings' => $rmkCourierIdsAsStrings,
+            'previous_order_ids' => array_keys($previousRecords),
         ]);
 
         // Фильтруем: оставляем только те заказы, которые были в предыдущей проверке
         $confirmedIssues = [];
+        $notFoundCandidates = [];
         foreach ($candidates as $candidate) {
             if (isset($previousRecords[$candidate->orderId])) {
                 // Заказ был кандидатом в предыдущей проверке с тем же статусом → подтверждённая проблема
                 $confirmedIssues[] = $candidate;
+            } else {
+                $notFoundCandidates[] = [
+                    'order_id' => $candidate->orderId,
+                    'order_number' => $candidate->orderNumber,
+                    'rmk_status_id' => $candidate->rmkStatusId,
+                ];
             }
         }
+        
+        if (!empty($notFoundCandidates)) {
+            $this->logInfo('Кандидаты, не найденные в предыдущей проверке', [
+                'count' => count($notFoundCandidates),
+                'candidates' => $notFoundCandidates,
+            ]);
+        }
 
         $this->logInfo('Подтверждено проблем "Завис в доставке"', [
             'candidates' => count($candidates),
@@ -522,7 +568,11 @@ class OrderControlReportService
      * Получает заказы типа "Отмена без обработки"
      *
      * Критерий: МП статус = "Отменён" (CANCELLED)
-     *           + РМК статус НЕ "Отказ" (cancelled_order = 1 в marketplace_order_1c_statuses)
+     *           + РМК статус = "Отказ" (cancelled_order = 1 в marketplace_order_1c_statuses)
+     *           + cancelled_order_sent = 0 (отмена не отправлена в маркетплейс)
+     *
+     * Проблема возникает когда заказ отменён в маркетплейсе, в РМК статус проставлен как "Отказ",
+     * но отмена не отправлена обратно в маркетплейс.
      *
      * @param int $hoursAgo Период выборки в часах (по умолчанию 24)
      * @return OrderIssue[]
@@ -539,7 +589,7 @@ class OrderControlReportService
         // Получаем ID статусов "Отказ" из БД
         $rmkCancelIds = $this->getRmkStatusCancel();
 
-        // Формируем плейсхолдеры для NOT IN-условия
+        // Формируем плейсхолдеры для IN-условия
         $rmkCancelPlaceholders = [];
         $rmkCancelParams = [];
         foreach ($rmkCancelIds as $index => $id) {
@@ -551,7 +601,7 @@ class OrderControlReportService
             ? implode(', ', $rmkCancelPlaceholders)
             : '0'; // fallback если статусов нет
 
-        // Выбираем заказы с МП-статусом "Отменён", где РМК-статус НЕ "Отказ"
+        // Выбираем заказы с МП-статусом "Отменён", где РМК-статус = "Отказ", но отмена не отправлена
         $sql = "
             SELECT
                 mo.id,
@@ -566,7 +616,8 @@ class OrderControlReportService
                 mocs.status as rmk_status,
                 most.code as mp_status_code,
                 mosub.code as mp_substatus_code,
-                COALESCE(most.name, mosub.name) as mp_status_name
+                COALESCE(most.name, mosub.name) as mp_status_name,
+                'cancel_not_sent' as issue_reason
             FROM marketplace_orders mo
             LEFT JOIN city_store cs ON cs.id = mo.store_id
             LEFT JOIN marketplace_order_1c_statuses mocs ON mocs.id = mo.status_processing_1c::integer
@@ -576,10 +627,9 @@ class OrderControlReportService
               AND mo.updated_at >= :start_date
               AND mo.updated_at <= :end_date
               AND most.code = :cancelled
-              AND (
-                  mo.status_processing_1c IS NULL
-                  OR mo.status_processing_1c::integer NOT IN ({$rmkCancelInClause})
-              )
+              AND mo.status_processing_1c IS NOT NULL
+              AND mo.status_processing_1c::integer IN ({$rmkCancelInClause})
+              AND COALESCE(mo.cancelled_order_sent, 0) = 0
             ORDER BY cs.name ASC, mo.creation_date DESC
         ";
 
@@ -745,7 +795,11 @@ class OrderControlReportService
     {
         $lines = [];
         $intervalWithShift = $this->formatIntervalWithShiftName($result->interval);
-        $lines[] = '*\[Контроль MP\]* Отчёт за ' . $this->escapeMarkdownV2($result->reportDate) . ' ' . $this->escapeMarkdownV2($intervalWithShift);
+        // Используем дату начала смены (startDate), а не текущее время (reportDate)
+        $shiftStartDate = $result->startDate
+            ? date('d.m.Y', strtotime($result->startDate))
+            : $result->reportDate;
+        $lines[] = '*\[Контроль MP\]* Отчёт за ' . $this->escapeMarkdownV2($shiftStartDate) . ' ' . $this->escapeMarkdownV2($intervalWithShift);
         $lines[] = '';
 
         // Секция "Завис в доставке"
@@ -775,9 +829,12 @@ class OrderControlReportService
     }
 
     /**
-     * Ð¤Ð¾Ñ\80маÑ\82иÑ\80Ñ\83еÑ\82 Ñ\82аблиÑ\86Ñ\83 Ð¿Ñ\80облем Ð´Ð»Ñ\8f Telegram (моноÑ\88иÑ\80иннÑ\8bй Ð±Ð»Ð¾Ðº)
+     * Ð¤Ð¾Ñ\80маÑ\82иÑ\80Ñ\83еÑ\82 Ñ\81пиÑ\81ок Ð¿Ñ\80облем Ð´Ð»Ñ\8f Telegram (компакÑ\82нÑ\8bй Ñ\84оÑ\80маÑ\82)
      *
-     * Формат: | Дата | Интервал | Заказ | РМК | МП | Причина
+     * Формат:
+     * 📦 {номер заказа} ({дата создания})
+     *    РМК: {статус} | МП: {статус}
+     *    ⚠️ {причина}
      *
      * @param OrderIssue[] $issues
      * @return string
@@ -785,43 +842,36 @@ class OrderControlReportService
     private function formatIssuesTable(array $issues): string
     {
         $rows = [];
-        $rows[] = '```';
-        $rows[] = '| Дата | Интервал | Заказ | РМК | МП | Причина';
 
         foreach ($issues as $issue) {
             $rows[] = $this->formatIssueRow($issue);
         }
 
-        $rows[] = '```';
-
-        return implode("\n", $rows);
+        return implode("\n\n", $rows);
     }
 
     /**
-     * Форматирует строку таблицы для проблемы
-     *
-     * Формат: | Дата | Интервал | Заказ | РМК | МП | Причина
+     * Форматирует блок для одной проблемы (компактный список)
      *
      * @param OrderIssue $issue
      * @return string
      */
     private function formatIssueRow(OrderIssue $issue): string
     {
-        $date = $issue->reportDate ?: date('d.m.Y');
-        $interval = $this->getShortInterval($issue->interval);
         $rmk = $issue->rmkStatus ?? '-';
         $mp = $this->formatMpStatus($issue);
         $reason = $issue->getIssueReasonLabel() ?: '-';
+        $creationDate = $issue->creationDate
+            ? date('d.m.Y H:i', strtotime($issue->creationDate))
+            : '-';
 
-        return sprintf(
-            '| %s | %s | %s | %s | %s | %s',
-            $date,
-            $interval,
-            $issue->orderNumber,
-            $rmk,
-            $mp,
-            $reason
-        );
+        $lines = [];
+        // Emoji не экранируем, они работают в MarkdownV2 как есть
+        $lines[] = '📦 ' . $this->escapeMarkdownV2("{$issue->orderNumber} ({$creationDate})");
+        $lines[] = $this->escapeMarkdownV2("   РМК: {$rmk} | МП: {$mp}");
+        $lines[] = '⚠️ ' . $this->escapeMarkdownV2($reason);
+
+        return implode("\n", $lines);
     }
 
     /**
@@ -933,6 +983,12 @@ class OrderControlReportService
      */
     public function formatEmailControlReport(ControlReportResult $result): string
     {
+        // Используем дату начала смены (startDate), а не текущее время (reportDate)
+        $shiftStartDate = $result->startDate
+            ? date('d.m.Y', strtotime($result->startDate))
+            : $result->reportDate;
+        $intervalWithShift = $this->formatIntervalWithShiftName($result->interval);
+
         $html = '<!DOCTYPE html>
 <html>
 <head>
@@ -949,7 +1005,7 @@ class OrderControlReportService
     </style>
 </head>
 <body>
-    <h2>[Контроль MP] Отчёт за ' . $this->escapeHtml($result->reportDate) . ' ' . $this->escapeHtml($this->formatIntervalWithShiftName($result->interval)) . '</h2>';
+    <h2>[Контроль MP] Отчёт за ' . $this->escapeHtml($shiftStartDate) . ' ' . $this->escapeHtml($intervalWithShift) . '</h2>';
 
         // Общая таблица со всеми проблемами, сортировка по типу
         $allIssues = [];
@@ -1037,15 +1093,21 @@ class OrderControlReportService
         foreach ($chunks as $index => $chunk) {
             $sent = false;
             $maxRetries = $this->config['max_retries'] ?? self::MAX_RETRIES;
-            $retryDelay = $this->config['retry_delay_seconds'] ?? self::RETRY_DELAY_SECONDS;
+            $defaultDelay = $this->config['retry_delay_seconds'] ?? self::RETRY_DELAY_SECONDS;
 
             for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
                 try {
-                    $sent = $this->sendTelegramMessage($chatId, $chunk);
-                    if ($sent) {
+                    $result = $this->sendTelegramMessage($chatId, $chunk);
+                    if ($result['success']) {
+                        $sent = true;
                         break;
                     }
+
+                    // Используем retry_after из ответа Telegram, если есть
+                    $retryDelay = $result['retry_after'] ?? $defaultDelay;
+                    $this->logWarning("Telegram попытка {$attempt}/{$maxRetries}: rate limit, ждём {$retryDelay} сек");
                 } catch (\Exception $e) {
+                    $retryDelay = $defaultDelay;
                     $this->logWarning("Telegram попытка {$attempt}/{$maxRetries}: {$e->getMessage()}");
                 }
 
@@ -1058,6 +1120,11 @@ class OrderControlReportService
                 $allSent = false;
                 $this->logError("Не удалось отправить часть " . ($index + 1) . " в Telegram после {$maxRetries} попыток");
             }
+
+            // Небольшая пауза между частями сообщения, чтобы не превысить лимит
+            if ($sent && $index < count($chunks) - 1) {
+                sleep(1);
+            }
         }
 
         return $allSent;
@@ -1068,9 +1135,9 @@ class OrderControlReportService
      *
      * @param string $chatId ID чата/канала
      * @param string $message Текст сообщения
-     * @return bool Успешность
+     * @return array{success: bool, retry_after: int|null} Результат отправки
      */
-    private function sendTelegramMessage(string $chatId, string $message): bool
+    private function sendTelegramMessage(string $chatId, string $message): array
     {
         $botToken = $this->getTelegramBotToken();
         $url = "https://api.telegram.org/bot{$botToken}/sendMessage";
@@ -1097,16 +1164,24 @@ class OrderControlReportService
 
         if ($curlError) {
             $this->logError("Telegram cURL error: {$curlError}");
-            return false;
+            return ['success' => false, 'retry_after' => null];
         }
 
         if ($httpCode !== 200) {
             $this->logError("Telegram API error: HTTP {$httpCode}, response: {$response}");
-            return false;
+
+            // Парсим retry_after из ответа при ошибке 429 (Too Many Requests)
+            $retryAfter = null;
+            if ($httpCode === 429) {
+                $data = json_decode($response, true);
+                $retryAfter = $data['parameters']['retry_after'] ?? null;
+            }
+
+            return ['success' => false, 'retry_after' => $retryAfter];
         }
 
         $this->logInfo('Сообщение отправлено в Telegram', ['chat_id' => $chatId]);
-        return true;
+        return ['success' => true, 'retry_after' => null];
     }
 
     /**
index f74b9530e155cce77ce112395c756f62a70860ea..36000b5d76b5900b5fc33db62da1ed3f79b128a3 100644 (file)
@@ -145,10 +145,12 @@ class OrderIssue
      * Человекочитаемые метки причин
      */
     public const ISSUE_REASON_LABELS = [
-        'no_seller_id' => 'Нет seller_id',
-        'no_check' => 'Чек не создан',
-        'no_check_guid' => 'Нет check_guid',
-        'no_seller_and_check_guid' => 'Нет seller_id и check_guid',
+        'no_seller_id' => 'Не взят в работу',
+        'no_check' => 'Не пробит чек',
+        'no_check_guid' => 'Не пробит чек',
+        'no_seller_and_check_guid' => 'Не взят в работу',
+        'cancel_not_sent' => 'Отказ не отправлен в 1С',
+        'no_mp_success' => 'Нет Успеха из МП',
         'unknown' => 'Неизвестно',
     ];
 
diff --git a/erp24/tests/unit/commands/MarketplaceControllerTest.php b/erp24/tests/unit/commands/MarketplaceControllerTest.php
new file mode 100644 (file)
index 0000000..15839fd
--- /dev/null
@@ -0,0 +1,222 @@
+<?php
+
+declare(strict_types=1);
+
+namespace app\tests\unit\commands;
+
+use Codeception\Test\Unit;
+use yii\console\ExitCode;
+use yii_app\commands\MarketplaceController;
+use yii_app\services\dto\ControlReportResult;
+use yii_app\services\dto\OrderIssue;
+
+/**
+ * Unit-тесты для MarketplaceController::actionSendOrderControlReport
+ *
+ * Покрывает:
+ * - Логику обработки результатов отчёта
+ * - Возврат корректных exit codes
+ * - Обработку различных сценариев (успех, частичный успех, ошибка)
+ * - Тестовый режим (--test)
+ *
+ * Примечание: для полного функционального тестирования команды с реальной БД
+ * рекомендуется создать функциональные тесты в tests/functional/
+ *
+ * @covers \yii_app\commands\MarketplaceController::actionSendOrderControlReport
+ */
+class MarketplaceControllerTest extends Unit
+{
+    /**
+     * Тест: проверка логики exit code для успешного отчёта без проблем
+     */
+    public function testExitCodeForSuccessWithoutIssues(): void
+    {
+        $result = new ControlReportResult();
+        $result->totalIssues = 0;
+        $result->telegramSent = true;
+        $result->emailSent = true;
+
+        $exitCode = $result->getExitCode();
+
+        $this->assertSame(ExitCode::OK, $exitCode);
+    }
+
+    /**
+     * Тест: проверка логики exit code для успешного отчёта с проблемами
+     */
+    public function testExitCodeForSuccessWithIssues(): void
+    {
+        $result = new ControlReportResult();
+        
+        $issue1 = new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 100, 'FW-100');
+        $issue2 = new OrderIssue(OrderIssue::TYPE_SUCCESS_NO_CHECK, 200, 'YM-200');
+        
+        $result->hungInDelivery = [$issue1];
+        $result->successNoCheck = [$issue2];
+        $result->calculateTotal();
+        $result->telegramSent = true;
+        $result->emailSent = true;
+
+        $exitCode = $result->getExitCode();
+
+        $this->assertSame(ExitCode::OK, $exitCode);
+    }
+
+    /**
+     * Тест: проверка логики exit code для частичного успеха
+     */
+    public function testExitCodeForPartialSuccess(): void
+    {
+        $result = new ControlReportResult();
+        
+        $issue = new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 100, 'FW-100');
+        $result->hungInDelivery = [$issue];
+        $result->calculateTotal();
+        $result->telegramSent = true;
+        $result->emailSent = false;
+        $result->emailError = 'SMTP connection failed';
+
+        $exitCode = $result->getExitCode();
+
+        $this->assertSame(2, $exitCode); // ExitCode для частичного успеха
+    }
+
+    /**
+     * Тест: проверка логики exit code для полной ошибки
+     */
+    public function testExitCodeForFailure(): void
+    {
+        $result = new ControlReportResult();
+        
+        $issue = new OrderIssue(OrderIssue::TYPE_CANCEL_NO_PROCESS, 300, 'FW-300');
+        $result->cancelNoProcess = [$issue];
+        $result->calculateTotal();
+        $result->telegramSent = false;
+        $result->telegramError = 'Telegram API error';
+        $result->emailSent = false;
+        $result->emailError = 'SMTP error';
+
+        $exitCode = $result->getExitCode();
+
+        $this->assertSame(1, $exitCode); // ExitCode для критической ошибки
+    }
+
+    /**
+     * Тест: проверка обработки всех типов проблем
+     */
+    public function testHandlesAllIssueTypes(): void
+    {
+        $result = new ControlReportResult();
+        
+        $issue1 = new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 100, 'FW-100');
+        $issue2 = new OrderIssue(OrderIssue::TYPE_SUCCESS_NO_CHECK, 200, 'YM-200');
+        $issue3 = new OrderIssue(OrderIssue::TYPE_CANCEL_NO_PROCESS, 300, 'FW-300');
+        
+        $result->hungInDelivery = [$issue1];
+        $result->successNoCheck = [$issue2];
+        $result->cancelNoProcess = [$issue3];
+        $result->calculateTotal();
+        $result->telegramSent = true;
+        $result->emailSent = true;
+
+        $this->assertSame(3, $result->totalIssues);
+        $this->assertSame(1, $result->getHungInDeliveryCount());
+        $this->assertSame(1, $result->getSuccessNoCheckCount());
+        $this->assertSame(1, $result->getCancelNoProcessCount());
+        $this->assertSame(ExitCode::OK, $result->getExitCode());
+    }
+
+    /**
+     * Тест: проверка структуры метода actionSendOrderControlReport
+     *
+     * Проверяет, что метод существует и имеет правильную сигнатуру
+     */
+    public function testActionSendOrderControlReportMethodExists(): void
+    {
+        $controller = new MarketplaceController('marketplace', \Yii::$app);
+        
+        $this->assertTrue(
+            method_exists($controller, 'actionSendOrderControlReport'),
+            'Метод actionSendOrderControlReport должен существовать'
+        );
+
+        $reflection = new \ReflectionMethod($controller, 'actionSendOrderControlReport');
+        
+        $this->assertSame('int', $reflection->getReturnType()->getName());
+        
+        $parameters = $reflection->getParameters();
+        $this->assertCount(2, $parameters);
+        $this->assertSame('hours', $parameters[0]->getName());
+        $this->assertSame('onlyNew', $parameters[1]->getName());
+        $this->assertSame(12, $parameters[0]->getDefaultValue());
+        $this->assertTrue($parameters[1]->getDefaultValue());
+    }
+
+    /**
+     * Тест: проверка наличия свойства test в контроллере
+     */
+    public function testControllerHasTestProperty(): void
+    {
+        $controller = new MarketplaceController('marketplace', \Yii::$app);
+        
+        $this->assertTrue(
+            property_exists($controller, 'test'),
+            'Контроллер должен иметь свойство test для тестового режима'
+        );
+
+        // Проверяем, что свойство можно установить
+        $controller->test = true;
+        $this->assertTrue($controller->test);
+        
+        $controller->test = false;
+        $this->assertFalse($controller->test);
+    }
+
+    /**
+     * Тест: проверка наличия метода options для регистрации опции test
+     */
+    public function testOptionsMethodIncludesTest(): void
+    {
+        $controller = new MarketplaceController('marketplace', \Yii::$app);
+        
+        $this->assertTrue(
+            method_exists($controller, 'options'),
+            'Контроллер должен иметь метод options'
+        );
+
+        $options = $controller->options('send-order-control-report');
+        
+        $this->assertIsArray($options);
+        $this->assertContains('test', $options, 'Опция test должна быть зарегистрирована');
+    }
+
+    /**
+     * Тест: проверка логики hasIssues для различных сценариев
+     */
+    public function testHasIssuesLogic(): void
+    {
+        // Без проблем
+        $result = new ControlReportResult();
+        $this->assertFalse($result->hasIssues());
+
+        // С проблемами в hungInDelivery
+        $result->hungInDelivery = [
+            new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 100, 'FW-100')
+        ];
+        $this->assertTrue($result->hasIssues());
+
+        // С проблемами в successNoCheck
+        $result = new ControlReportResult();
+        $result->successNoCheck = [
+            new OrderIssue(OrderIssue::TYPE_SUCCESS_NO_CHECK, 200, 'YM-200')
+        ];
+        $this->assertTrue($result->hasIssues());
+
+        // С проблемами в cancelNoProcess
+        $result = new ControlReportResult();
+        $result->cancelNoProcess = [
+            new OrderIssue(OrderIssue::TYPE_CANCEL_NO_PROCESS, 300, 'FW-300')
+        ];
+        $this->assertTrue($result->hasIssues());
+    }
+}
diff --git a/erp24/tests/unit/services/OrderControlReportServiceNotificationTest.php b/erp24/tests/unit/services/OrderControlReportServiceNotificationTest.php
new file mode 100644 (file)
index 0000000..6f45251
--- /dev/null
@@ -0,0 +1,675 @@
+<?php
+
+declare(strict_types=1);
+
+namespace app\tests\unit\services;
+
+use Codeception\Test\Unit;
+use yii_app\services\OrderControlReportService;
+use yii_app\services\TelegramService;
+use yii_app\services\dto\ControlReportResult;
+use yii_app\services\dto\OrderIssue;
+
+/**
+ * Unit-тесты для проверки отправки уведомлений в OrderControlReportService
+ *
+ * Покрывает:
+ * - Отправку в Telegram (бот, канал/чат)
+ * - Отправку на Email (отправитель, получатели)
+ * - Форматирование сообщений
+ * - Логику выбора бота/канала в зависимости от окружения
+ *
+ * @covers \yii_app\services\OrderControlReportService::sendToTelegram
+ * @covers \yii_app\services\OrderControlReportService::sendToEmail
+ * @covers \yii_app\services\OrderControlReportService::formatTelegramControlReport
+ * @covers \yii_app\services\OrderControlReportService::formatEmailControlReport
+ */
+class OrderControlReportServiceNotificationTest extends Unit
+{
+    private OrderControlReportService $service;
+
+    protected function _before(): void
+    {
+        parent::_before();
+        $this->service = new OrderControlReportService();
+    }
+
+    /**
+     * Тест: formatTelegramControlReport формирует сообщение с правильной структурой
+     */
+    public function testFormatTelegramControlReportStructure(): void
+    {
+        $result = new ControlReportResult();
+        $result->reportDate = '20.01.2026 08:00';
+        $result->startDate = '2026-01-19 20:00:00';
+        $result->endDate = '2026-01-20 08:00:00';
+        $result->shiftName = 'Ночная смена';
+
+        $issue1 = new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 100, 'FW-100');
+        $issue1->rmkStatus = 'Передан курьеру';
+        $issue1->mpStatus = 'В доставке';
+        $issue1->issueReason = null;
+        $issue1->creationDate = '2026-01-19 10:00:00';
+        $issue1->marketplaceName = 'Flowwow';
+        $issue1->storeName = 'Магазин Центр';
+
+        $issue2 = new OrderIssue(OrderIssue::TYPE_SUCCESS_NO_CHECK, 200, 'YM-200');
+        $issue2->rmkStatus = 'Новый';
+        $issue2->mpStatus = 'Доставлен';
+        $issue2->issueReason = null;
+        $issue2->creationDate = '2026-01-19 11:00:00';
+        $issue2->marketplaceName = 'Yandex Market';
+
+        $result->hungInDelivery = [$issue1];
+        $result->successNoCheck = [$issue2];
+        $result->calculateTotal();
+
+        $message = $this->service->formatTelegramControlReport($result);
+
+        // Проверяем наличие основных секций
+        $this->assertStringContainsString('Контроль MP', $message);
+        $this->assertStringContainsString('Завис в доставке', $message);
+        $this->assertStringContainsString('Успех без чека', $message);
+        // В MarkdownV2 дефисы экранируются, поэтому проверяем экранированную версию
+        $this->assertStringContainsString('FW\-100', $message);
+        $this->assertStringContainsString('YM\-200', $message);
+        $this->assertStringContainsString('Всего:', $message);
+    }
+
+    /**
+     * Тест: formatEmailControlReport формирует HTML с правильной структурой
+     */
+    public function testFormatEmailControlReportStructure(): void
+    {
+        $result = new ControlReportResult();
+        $result->reportDate = '20.01.2026 08:00';
+        $result->startDate = '2026-01-19 20:00:00';
+        $result->endDate = '2026-01-20 08:00:00';
+        $result->shiftName = 'Ночная смена';
+
+        $issue = new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 100, 'FW-100');
+        $issue->rmkStatus = 'Передан курьеру';
+        $issue->mpStatus = 'В доставке';
+        $issue->issueReason = null;
+        $issue->creationDate = '2026-01-19 10:00:00';
+        $issue->marketplaceName = 'Flowwow';
+
+        $result->hungInDelivery = [$issue];
+        $result->calculateTotal();
+
+        $html = $this->service->formatEmailControlReport($result);
+
+        // Проверяем HTML-структуру
+        $this->assertStringContainsString('<!DOCTYPE html>', $html);
+        $this->assertStringContainsString('<html>', $html);
+        $this->assertStringContainsString('<head>', $html);
+        $this->assertStringContainsString('<body>', $html);
+        $this->assertStringContainsString('<table>', $html);
+        $this->assertStringContainsString('FW-100', $html);
+        $this->assertStringContainsString('Завис в доставке', $html);
+    }
+
+    /**
+     * Тест: проверка структуры метода sendToTelegram
+     *
+     * Проверяет, что метод существует и имеет правильную сигнатуру
+     */
+    public function testSendToTelegramMethodExists(): void
+    {
+        $reflection = new \ReflectionClass($this->service);
+        $this->assertTrue(
+            $reflection->hasMethod('sendToTelegram'),
+            'Метод sendToTelegram должен существовать'
+        );
+
+        $method = $reflection->getMethod('sendToTelegram');
+        $this->assertTrue($method->isPublic(), 'Метод sendToTelegram должен быть публичным');
+        $this->assertSame('bool', $method->getReturnType()->getName());
+        
+        $parameters = $method->getParameters();
+        $this->assertCount(1, $parameters);
+        $this->assertSame('message', $parameters[0]->getName());
+        $this->assertSame('string', $parameters[0]->getType()->getName());
+    }
+
+    /**
+     * Тест: проверка структуры метода sendToEmail
+     *
+     * Проверяет, что метод существует и имеет правильную сигнатуру
+     */
+    public function testSendToEmailMethodExists(): void
+    {
+        $reflection = new \ReflectionClass($this->service);
+        $this->assertTrue(
+            $reflection->hasMethod('sendToEmail'),
+            'Метод sendToEmail должен существовать'
+        );
+
+        $method = $reflection->getMethod('sendToEmail');
+        $this->assertTrue($method->isPublic(), 'Метод sendToEmail должен быть публичным');
+        $this->assertSame('bool', $method->getReturnType()->getName());
+        
+        $parameters = $method->getParameters();
+        $this->assertCount(1, $parameters);
+        $this->assertSame('html', $parameters[0]->getName());
+        $this->assertSame('string', $parameters[0]->getType()->getName());
+    }
+
+    /**
+     * Тест: проверка наличия приватных методов для получения конфигурации
+     */
+    public function testPrivateConfigurationMethodsExist(): void
+    {
+        $reflection = new \ReflectionClass($this->service);
+        
+        // Проверяем наличие методов получения конфигурации
+        $this->assertTrue(
+            $reflection->hasMethod('getTelegramBotToken'),
+            'Метод getTelegramBotToken должен существовать'
+        );
+        
+        $this->assertTrue(
+            $reflection->hasMethod('getTelegramChatId'),
+            'Метод getTelegramChatId должен существовать'
+        );
+        
+        $this->assertTrue(
+            $reflection->hasMethod('getEmailRecipients'),
+            'Метод getEmailRecipients должен существовать'
+        );
+    }
+
+    /**
+     * Тест: проверка логики выбора бота в зависимости от окружения
+     *
+     * Проверяет, что используются правильные константы для dev и prod
+     */
+    public function testTelegramBotTokenSelectionLogic(): void
+    {
+        $reflection = new \ReflectionClass($this->service);
+        
+        // Проверяем наличие констант для токенов ботов
+        $this->assertTrue(
+            $reflection->hasConstant('TELEGRAM_BOT_DEV'),
+            'Константа TELEGRAM_BOT_DEV должна существовать'
+        );
+        
+        $this->assertTrue(
+            $reflection->hasConstant('TELEGRAM_BOT_PROD'),
+            'Константа TELEGRAM_BOT_PROD должна существовать'
+        );
+
+        $devToken = $reflection->getConstant('TELEGRAM_BOT_DEV');
+        $prodToken = $reflection->getConstant('TELEGRAM_BOT_PROD');
+
+        $this->assertIsString($devToken);
+        $this->assertIsString($prodToken);
+        $this->assertNotEmpty($devToken);
+        $this->assertNotEmpty($prodToken);
+        $this->assertNotSame($devToken, $prodToken, 'Dev и Prod токены должны отличаться');
+    }
+
+    /**
+     * Тест: проверка структуры сообщения Telegram
+     *
+     * Проверяет, что сообщение содержит все необходимые элементы
+     */
+    public function testTelegramMessageContainsAllSections(): void
+    {
+        $result = new ControlReportResult();
+        $result->reportDate = '20.01.2026 08:00';
+        $result->startDate = '2026-01-19 20:00:00';
+        $result->endDate = '2026-01-20 08:00:00';
+        $result->shiftName = 'Ночная смена';
+
+        // Добавляем все типы проблем
+        $issue1 = new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 100, 'FW-100');
+        $issue1->mpStatus = 'В доставке';
+        $issue1->issueReason = null;
+        $issue1->creationDate = '2026-01-19 10:00:00';
+        
+        $issue2 = new OrderIssue(OrderIssue::TYPE_SUCCESS_NO_CHECK, 200, 'YM-200');
+        $issue2->mpStatus = 'Доставлен';
+        $issue2->issueReason = null;
+        $issue2->creationDate = '2026-01-19 11:00:00';
+        
+        $issue3 = new OrderIssue(OrderIssue::TYPE_CANCEL_NO_PROCESS, 300, 'FW-300');
+        $issue3->mpStatus = 'Отменён';
+        $issue3->issueReason = null;
+        $issue3->creationDate = '2026-01-19 12:00:00';
+
+        $result->hungInDelivery = [$issue1];
+        $result->successNoCheck = [$issue2];
+        $result->cancelNoProcess = [$issue3];
+        $result->calculateTotal();
+
+        $message = $this->service->formatTelegramControlReport($result);
+
+        // Проверяем наличие всех секций
+        $this->assertStringContainsString('Завис в доставке', $message);
+        $this->assertStringContainsString('Успех без чека', $message);
+        $this->assertStringContainsString('Отмена без обработки', $message);
+        // В MarkdownV2 дефисы экранируются, поэтому проверяем экранированные версии
+        $this->assertStringContainsString('FW\-100', $message);
+        $this->assertStringContainsString('YM\-200', $message);
+        $this->assertStringContainsString('FW\-300', $message);
+    }
+
+    /**
+     * Тест: проверка структуры HTML Email
+     *
+     * Проверяет, что HTML содержит все необходимые элементы
+     */
+    public function testEmailHtmlContainsAllSections(): void
+    {
+        $result = new ControlReportResult();
+        $result->reportDate = '20.01.2026 08:00';
+        $result->startDate = '2026-01-19 20:00:00';
+        $result->endDate = '2026-01-20 08:00:00';
+        $result->shiftName = 'Ночная смена';
+
+        $issue1 = new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 100, 'FW-100');
+        $issue1->mpStatus = 'В доставке';
+        $issue1->issueReason = null;
+        $issue1->creationDate = '2026-01-19 10:00:00';
+        
+        $issue2 = new OrderIssue(OrderIssue::TYPE_SUCCESS_NO_CHECK, 200, 'YM-200');
+        $issue2->mpStatus = 'Доставлен';
+        $issue2->issueReason = null;
+        $issue2->creationDate = '2026-01-19 11:00:00';
+
+        $result->hungInDelivery = [$issue1];
+        $result->successNoCheck = [$issue2];
+        $result->calculateTotal();
+
+        $html = $this->service->formatEmailControlReport($result);
+
+        // Проверяем наличие таблицы с заголовками
+        $this->assertStringContainsString('<th>Тип проблемы</th>', $html);
+        $this->assertStringContainsString('<th>Заказ</th>', $html);
+        $this->assertStringContainsString('<th>РМК</th>', $html);
+        $this->assertStringContainsString('<th>МП</th>', $html);
+        $this->assertStringContainsString('<th>Причина</th>', $html);
+        
+        // Проверяем наличие данных
+        $this->assertStringContainsString('FW-100', $html);
+        $this->assertStringContainsString('YM-200', $html);
+        $this->assertStringContainsString('Завис в доставке', $html);
+        $this->assertStringContainsString('Успех без чека', $html);
+        
+        // Проверяем итог
+        $this->assertStringContainsString('Всего проблем:', $html);
+    }
+
+    /**
+     * Тест: проверка экранирования HTML в Email
+     */
+    public function testEmailHtmlEscapesSpecialCharacters(): void
+    {
+        $result = new ControlReportResult();
+        
+        $issue = new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 100, '<script>alert("XSS")</script>');
+        $issue->rmkStatus = 'Статус & "тест"';
+        $issue->mpStatus = '<b>HTML</b> тег';
+        $issue->issueReason = null;
+        $issue->creationDate = '2026-01-19 10:00:00';
+
+        $result->hungInDelivery = [$issue];
+        $result->calculateTotal();
+
+        $html = $this->service->formatEmailControlReport($result);
+
+        // Проверяем, что опасные символы экранированы
+        $this->assertStringNotContainsString('<script>', $html);
+        $this->assertStringContainsString('&lt;script&gt;', $html);
+        $this->assertStringContainsString('&amp;', $html);
+        $this->assertStringContainsString('&quot;', $html);
+    }
+
+    /**
+     * Тест: проверка, что сообщения не отправляются при отсутствии проблем
+     */
+    public function testNoNotificationsSentWhenNoIssues(): void
+    {
+        $result = new ControlReportResult();
+        $result->totalIssues = 0;
+        $result->telegramSent = false;
+        $result->emailSent = false;
+
+        // Если нет проблем, методы отправки не должны вызываться
+        // Это проверяется через логику hasIssues()
+        $this->assertFalse($result->hasIssues());
+        
+        // В реальной реализации, если hasIssues() = false,
+        // то telegramSent и emailSent устанавливаются в true без отправки
+        // (см. код в generateControlReport)
+    }
+
+    /**
+     * Тест: проверка формата сообщения Telegram (MarkdownV2)
+     *
+     * Проверяет, что сообщение использует правильный формат разметки
+     */
+    public function testTelegramMessageUsesMarkdownV2Format(): void
+    {
+        $result = new ControlReportResult();
+        $issue = new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 100, 'FW-100');
+        $issue->mpStatus = 'В доставке';
+        $issue->issueReason = null;
+        $issue->creationDate = '2026-01-19 10:00:00';
+        
+        $result->hungInDelivery = [$issue];
+        $result->calculateTotal();
+
+        $message = $this->service->formatTelegramControlReport($result);
+
+        // Проверяем формат MarkdownV2 - сообщение должно содержать экранированные символы
+        // В MarkdownV2 используются звездочки для жирного текста и обратные слэши для экранирования
+        $this->assertStringContainsString('*', $message, 'Сообщение должно использовать MarkdownV2 форматирование');
+        // Проверяем наличие экранированных дефисов
+        $this->assertStringContainsString('FW\-100', $message);
+    }
+
+    /**
+     * Тест: проверка конфигурации Email отправителя
+     *
+     * Проверяет логику определения отправителя из кода
+     */
+    public function testEmailSenderConfiguration(): void
+    {
+        // Проверяем, что в коде используется правильная логика определения отправителя
+        // Из кода: $fromEmail = getenv('MAIL_USERNAME') ?: 'noreply@bazacvetov24.ru';
+        $expectedDefaultSender = 'noreply@bazacvetov24.ru';
+        
+        // Проверяем, что это значение используется как fallback
+        $this->assertNotEmpty($expectedDefaultSender);
+        $this->assertStringContainsString('@', $expectedDefaultSender);
+    }
+
+    /**
+     * Тест: проверка конфигурации Email получателей
+     *
+     * Проверяет логику получения получателей из конфигурации
+     */
+    public function testEmailRecipientsConfiguration(): void
+    {
+        // Метод getEmailRecipients() получает получателей из:
+        // 1. $this->config['email_recipients']
+        // 2. getenv('ORDER_CONTROL_EMAIL_RECIPIENTS')
+        
+        // Проверяем, что метод существует и возвращает массив
+        $reflection = new \ReflectionClass($this->service);
+        $method = $reflection->getMethod('getEmailRecipients');
+        $method->setAccessible(true);
+        
+        $recipients = $method->invoke($this->service);
+        
+        $this->assertIsArray($recipients);
+    }
+
+    /**
+     * Тест: проверка получения Telegram bot token для dev окружения
+     *
+     * Проверяет логику выбора токена бота
+     */
+    public function testGetTelegramBotTokenForDevEnvironment(): void
+    {
+        $reflection = new \ReflectionClass($this->service);
+        $method = $reflection->getMethod('getTelegramBotToken');
+        $method->setAccessible(true);
+        
+        // Сохраняем текущее окружение
+        $originalEnv = getenv('APP_ENV');
+        $originalEnvVar = $_ENV['APP_ENV'] ?? null;
+        
+        try {
+            // Устанавливаем dev окружение
+            putenv('APP_ENV=development');
+            $_ENV['APP_ENV'] = 'development';
+            
+            // Проверяем, что используется dev токен (или из env)
+            $token = $method->invoke($this->service);
+            
+            $this->assertIsString($token);
+            $this->assertNotEmpty($token);
+            
+            // Проверяем формат токена бота Telegram (число:буквы_цифры)
+            $this->assertMatchesRegularExpression(
+                '/^\d+:[A-Za-z0-9_-]+$/',
+                $token,
+                'Токен бота должен соответствовать формату Telegram'
+            );
+        } finally {
+            // Восстанавливаем окружение
+            if ($originalEnv !== false) {
+                putenv("APP_ENV={$originalEnv}");
+            }
+            if ($originalEnvVar !== null) {
+                $_ENV['APP_ENV'] = $originalEnvVar;
+            } else {
+                unset($_ENV['APP_ENV']);
+            }
+        }
+    }
+
+    /**
+     * Тест: проверка получения Telegram chat ID
+     *
+     * Проверяет логику получения ID канала/чата
+     */
+    public function testGetTelegramChatId(): void
+    {
+        $reflection = new \ReflectionClass($this->service);
+        $method = $reflection->getMethod('getTelegramChatId');
+        $method->setAccessible(true);
+        
+        $chatId = $method->invoke($this->service);
+        
+        // Chat ID может быть пустым, если не настроен
+        // Но если он задан, должен быть строкой
+        $this->assertIsString($chatId);
+        
+        // Если chat_id не пустой, проверяем формат (может быть отрицательным для каналов)
+        if (!empty($chatId)) {
+            $this->assertMatchesRegularExpression(
+                '/^-?\d+$/',
+                $chatId,
+                'Chat ID должен быть числом (может быть отрицательным для каналов)'
+            );
+        }
+    }
+
+    /**
+     * Тест: проверка конфигурации Email отправителя
+     *
+     * Проверяет логику определения отправителя из кода
+     */
+    public function testEmailSenderFromConfiguration(): void
+    {
+        // Из кода OrderControlReportService::sendToEmail():
+        // $fromEmail = getenv('MAIL_USERNAME') ?: 'noreply@bazacvetov24.ru';
+        // $message->setFrom([$fromEmail => 'ERP24 Контроль МП']);
+        
+        $expectedDefaultSender = 'noreply@bazacvetov24.ru';
+        $expectedSenderName = 'ERP24 Контроль МП';
+        
+        $this->assertNotEmpty($expectedDefaultSender);
+        $this->assertStringContainsString('@', $expectedDefaultSender);
+        $this->assertNotEmpty($expectedSenderName);
+        
+        // Проверяем формат email
+        $this->assertTrue(
+            filter_var($expectedDefaultSender, FILTER_VALIDATE_EMAIL) !== false,
+            'Email отправителя должен быть валидным'
+        );
+    }
+
+    /**
+     * Тест: проверка структуры URL для отправки в Telegram
+     *
+     * Проверяет формат API URL Telegram
+     */
+    public function testTelegramApiUrlFormat(): void
+    {
+        $reflection = new \ReflectionClass($this->service);
+        $method = $reflection->getMethod('getTelegramBotToken');
+        $method->setAccessible(true);
+        
+        $token = $method->invoke($this->service);
+        
+        // Формируем URL как в коде: "https://api.telegram.org/bot{$botToken}/sendMessage"
+        $url = "https://api.telegram.org/bot{$token}/sendMessage";
+        
+        $this->assertStringStartsWith('https://api.telegram.org/bot', $url);
+        $this->assertStringEndsWith('/sendMessage', $url);
+        $this->assertStringContainsString($token, $url);
+    }
+
+    /**
+     * Тест: проверка параметров отправки в Telegram
+     *
+     * Проверяет структуру данных, отправляемых в Telegram API
+     */
+    public function testTelegramSendParameters(): void
+    {
+        // Из кода OrderControlReportService::sendTelegramMessage():
+        // CURLOPT_POSTFIELDS => [
+        //     'chat_id' => $chatId,
+        //     'text' => $message,
+        //     'parse_mode' => 'MarkdownV2',
+        //     'disable_web_page_preview' => true,
+        // ]
+        
+        $expectedParams = [
+            'chat_id' => '-1001234567890', // Пример chat_id канала
+            'text' => 'Тестовое сообщение',
+            'parse_mode' => 'MarkdownV2',
+            'disable_web_page_preview' => true,
+        ];
+        
+        $this->assertArrayHasKey('chat_id', $expectedParams);
+        $this->assertArrayHasKey('text', $expectedParams);
+        $this->assertArrayHasKey('parse_mode', $expectedParams);
+        $this->assertSame('MarkdownV2', $expectedParams['parse_mode']);
+        $this->assertTrue($expectedParams['disable_web_page_preview']);
+    }
+
+    /**
+     * Тест: проверка параметров отправки Email
+     *
+     * Проверяет структуру данных для отправки Email
+     */
+    public function testEmailSendParameters(): void
+    {
+        // Из кода OrderControlReportService::sendToEmail():
+        // $message = Yii::$app->mailer->compose()
+        //     ->setTo($validRecipients)
+        //     ->setSubject($subject)
+        //     ->setHtmlBody($html);
+        // $message->setFrom([$fromEmail => 'ERP24 Контроль МП']);
+        
+        $expectedSubject = 'Контроль статусов заказов МП';
+        $expectedFromEmail = 'noreply@bazacvetov24.ru';
+        $expectedFromName = 'ERP24 Контроль МП';
+        $expectedRecipients = ['test@example.com'];
+        
+        $this->assertNotEmpty($expectedSubject);
+        $this->assertNotEmpty($expectedFromEmail);
+        $this->assertNotEmpty($expectedFromName);
+        $this->assertIsArray($expectedRecipients);
+        $this->assertNotEmpty($expectedRecipients);
+        
+        // Проверяем формат email получателей
+        foreach ($expectedRecipients as $email) {
+            $this->assertTrue(
+                filter_var($email, FILTER_VALIDATE_EMAIL) !== false,
+                "Email получателя должен быть валидным: {$email}"
+            );
+        }
+    }
+
+    /**
+     * Тест: проверка вывода информации о каналах и получателях
+     *
+     * Выводит информацию о конфигурации уведомлений:
+     * - Telegram Bot Token (dev/prod)
+     * - Telegram Chat ID (канал/чат)
+     * - Email Recipients (получатели)
+     * - Email Sender (отправитель)
+     */
+    public function testOutputNotificationConfiguration(): void
+    {
+        $reflection = new \ReflectionClass($this->service);
+        
+        // Получаем информацию о боте
+        $botTokenMethod = $reflection->getMethod('getTelegramBotToken');
+        $botTokenMethod->setAccessible(true);
+        $botToken = $botTokenMethod->invoke($this->service);
+        
+        // Получаем информацию о канале
+        $chatIdMethod = $reflection->getMethod('getTelegramChatId');
+        $chatIdMethod->setAccessible(true);
+        $chatId = $chatIdMethod->invoke($this->service);
+        
+        // Получаем информацию о получателях Email
+        $recipientsMethod = $reflection->getMethod('getEmailRecipients');
+        $recipientsMethod->setAccessible(true);
+        $recipients = $recipientsMethod->invoke($this->service);
+        
+        // Получаем информацию об отправителе Email
+        $fromEmail = getenv('MAIL_USERNAME') ?: 'noreply@bazacvetov24.ru';
+        $fromName = 'ERP24 Контроль МП';
+        
+        // Определяем окружение
+        $isDev = TelegramService::isDevEnv();
+        $env = $isDev ? 'DEV' : 'PROD';
+        
+        // Выводим информацию в консоль через STDOUT для гарантированного отображения
+        $output = "\n";
+        $output .= "═══════════════════════════════════════════════════════════════\n";
+        $output .= "  КОНФИГУРАЦИЯ УВЕДОМЛЕНИЙ ОТЧЁТА КОНТРОЛЯ СТАТУСОВ МП\n";
+        $output .= "═══════════════════════════════════════════════════════════════\n";
+        $output .= "\n";
+        
+        $output .= "📱 TELEGRAM:\n";
+        $output .= "   Окружение: {$env}\n";
+        $output .= "   Bot Token: " . ($botToken ? substr($botToken, 0, 10) . '...' . substr($botToken, -5) : '(не настроен)') . "\n";
+        $output .= "   Chat ID: " . ($chatId ?: '(не настроен)') . "\n";
+        $output .= "\n";
+        
+        $output .= "📧 EMAIL:\n";
+        $output .= "   Отправитель: {$fromEmail}\n";
+        $output .= "   Имя отправителя: {$fromName}\n";
+        $output .= "   Получатели: " . (empty($recipients) ? '(не настроены)' : implode(', ', $recipients)) . "\n";
+        $output .= "\n";
+        
+        $output .= "═══════════════════════════════════════════════════════════════\n";
+        $output .= "\n";
+        
+        // Выводим информацию в консоль через STDOUT с принудительным flush
+        // Это самый надежный способ вывода в Codeception
+        fwrite(STDOUT, $output);
+        flush();
+        
+        // Проверяем типы данных
+        $this->assertIsString($botToken, 'Bot token должен быть строкой');
+        $this->assertIsString($chatId, 'Chat ID должен быть строкой');
+        $this->assertIsArray($recipients, 'Recipients должны быть массивом');
+        
+        // Проверяем, что если значения заданы, они имеют правильный формат
+        if (!empty($botToken)) {
+            $this->assertMatchesRegularExpression('/^\d+:[A-Za-z0-9_-]+$/', $botToken);
+        }
+        
+        if (!empty($chatId)) {
+            $this->assertMatchesRegularExpression('/^-?\d+$/', $chatId);
+        }
+        
+        foreach ($recipients as $email) {
+            $this->assertTrue(
+                filter_var($email, FILTER_VALIDATE_EMAIL) !== false,
+                "Email получателя должен быть валидным: {$email}"
+            );
+        }
+    }
+}