]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
Правки по ревью
authorVladimir Fomichev <vladimir.fomichev@erp-flowers.ru>
Mon, 26 Jan 2026 14:30:37 +0000 (17:30 +0300)
committerVladimir Fomichev <vladimir.fomichev@erp-flowers.ru>
Mon, 26 Jan 2026 14:30:37 +0000 (17:30 +0300)
erp24/.env.example
erp24/jobs/SendOrderControlEmailJob.php [new file with mode: 0644]
erp24/jobs/SendOrderControlTelegramJob.php [new file with mode: 0644]
erp24/jobs/SendTelegramTestMessageJob.php
erp24/services/OrderControlReportService.php
erp24/services/dto/OrderIssue.php
erp24/tests/unit/services/OrderControlReportServiceTest.php

index 804571fba5bc1660b6119e37d9fe6642897dd264..7051d8f1cd85be0ff360af50eb02058a1d10eea5 100644 (file)
 APP_ENV=development
 SERVER_NAME=local-dev
 
+# === DEBUG & GII ===
+# Разрешённые IP для Yii debug toolbar и Gii (через запятую)
+# По умолчанию (если не задано): только localhost (127.0.0.1, ::1)
+# Пример для разрешения всех IP: DEBUG_ALLOWED_IPS=*
+# Пример для конкретных IP: DEBUG_ALLOWED_IPS=192.168.1.100,10.0.0.5
+# ВАЖНО: НЕ используйте '*' в production!
+DEBUG_ALLOWED_IPS=127.0.0.1,::1
+
 # === SMTP Configuration ===
 # Для отправки email из консольных команд (отчёты, уведомления)
 # Для Яндекса с SSL на порту 465 используйте scheme=smtps
diff --git a/erp24/jobs/SendOrderControlEmailJob.php b/erp24/jobs/SendOrderControlEmailJob.php
new file mode 100644 (file)
index 0000000..7b38c68
--- /dev/null
@@ -0,0 +1,158 @@
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\jobs;
+
+use Yii;
+use yii\base\BaseObject;
+use yii\queue\JobInterface;
+use yii\queue\RetryableJobInterface;
+use Symfony\Component\Mailer\Mailer;
+use Symfony\Component\Mailer\Transport;
+use Symfony\Component\Mime\Email;
+
+/**
+ * Job для неблокирующей отправки отчёта контроля заказов по Email
+ *
+ * Использует RetryableJobInterface для автоматических повторных попыток
+ * через механизм очереди вместо блокирующего sleep().
+ *
+ * @see \yii_app\services\OrderControlReportService
+ */
+class SendOrderControlEmailJob extends BaseObject implements JobInterface, RetryableJobInterface
+{
+    /**
+     * Максимальное количество попыток
+     */
+    private const MAX_ATTEMPTS = 5;
+
+    /**
+     * Время жизни Job в очереди (5 минут)
+     */
+    private const TTR = 300;
+
+    /**
+     * Тема письма
+     */
+    public string $subject = '';
+
+    /**
+     * HTML-содержимое письма
+     */
+    public string $htmlBody = '';
+
+    /**
+     * Текстовое содержимое письма
+     */
+    public string $textBody = '';
+
+    /**
+     * Список получателей (email адреса)
+     *
+     * @var string[]
+     */
+    public array $recipients = [];
+
+    /**
+     * Адрес отправителя
+     */
+    public string $fromEmail = '';
+
+    /**
+     * Имя отправителя
+     */
+    public string $fromName = 'ERP24 Отчёты';
+
+    /**
+     * DSN для Symfony Mailer
+     */
+    public string $mailerDsn = '';
+
+    /**
+     * Идентификатор отчёта (для логирования)
+     */
+    public string $reportId = '';
+
+    /**
+     * Выполняет отправку email
+     *
+     * @param \yii\queue\Queue $queue
+     * @return void
+     * @throws \RuntimeException При ошибке отправки (для retry)
+     */
+    public function execute($queue): void
+    {
+        if (empty($this->recipients)) {
+            Yii::warning("SendOrderControlEmailJob: нет получателей, report={$this->reportId}", 'email');
+            return;
+        }
+
+        if (empty($this->mailerDsn)) {
+            Yii::error("SendOrderControlEmailJob: mailerDsn не настроен", 'email');
+            return; // Не повторяем — ошибка конфигурации
+        }
+
+        try {
+            $transport = Transport::fromDsn($this->mailerDsn);
+            $mailer = new Mailer($transport);
+
+            $email = (new Email())
+                ->from("{$this->fromName} <{$this->fromEmail}>")
+                ->subject($this->subject)
+                ->html($this->htmlBody);
+
+            if (!empty($this->textBody)) {
+                $email->text($this->textBody);
+            }
+
+            // Добавляем получателей
+            foreach ($this->recipients as $recipient) {
+                $email->addTo($recipient);
+            }
+
+            $mailer->send($email);
+
+            Yii::info("SendOrderControlEmailJob: email отправлен на " . count($this->recipients) . " адресов, report={$this->reportId}", 'email');
+        } catch (\Exception $e) {
+            Yii::error("SendOrderControlEmailJob: ошибка отправки - {$e->getMessage()}", 'email');
+            throw new \RuntimeException("Email send error: {$e->getMessage()}", 0, $e);
+        }
+    }
+
+    /**
+     * Время жизни Job в очереди
+     *
+     * @return int Время в секундах
+     */
+    public function getTtr(): int
+    {
+        return self::TTR;
+    }
+
+    /**
+     * Определяет, можно ли повторить выполнение Job
+     *
+     * @param int $attempt Номер текущей попытки
+     * @param \Throwable $error Ошибка
+     * @return bool
+     */
+    public function canRetry($attempt, $error): bool
+    {
+        if ($attempt >= self::MAX_ATTEMPTS) {
+            Yii::error("SendOrderControlEmailJob: превышено количество попыток ({$attempt}), report={$this->reportId}", 'email');
+            return false;
+        }
+
+        $errorMessage = $error->getMessage();
+
+        // Не повторяем при ошибках конфигурации
+        if (str_contains($errorMessage, 'не настроен')
+            || str_contains($errorMessage, 'Authentication failed')
+            || str_contains($errorMessage, 'Invalid address')) {
+            return false;
+        }
+
+        return true;
+    }
+}
diff --git a/erp24/jobs/SendOrderControlTelegramJob.php b/erp24/jobs/SendOrderControlTelegramJob.php
new file mode 100644 (file)
index 0000000..4c1e239
--- /dev/null
@@ -0,0 +1,167 @@
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\jobs;
+
+use Yii;
+use yii\base\BaseObject;
+use yii\queue\JobInterface;
+use yii\queue\RetryableJobInterface;
+
+/**
+ * Job для неблокирующей отправки отчёта контроля заказов в Telegram
+ *
+ * Использует RetryableJobInterface для автоматических повторных попыток
+ * через механизм очереди вместо блокирующего sleep().
+ *
+ * @see \yii_app\services\OrderControlReportService
+ */
+class SendOrderControlTelegramJob extends BaseObject implements JobInterface, RetryableJobInterface
+{
+    /**
+     * Максимальное количество попыток
+     */
+    private const MAX_ATTEMPTS = 5;
+
+    /**
+     * Время жизни Job в очереди (5 минут)
+     */
+    private const TTR = 300;
+
+    /**
+     * Текст сообщения для отправки (MarkdownV2)
+     */
+    public string $message = '';
+
+    /**
+     * ID чата Telegram
+     */
+    public string $chatId = '';
+
+    /**
+     * Токен бота Telegram
+     */
+    public string $botToken = '';
+
+    /**
+     * Индекс чанка (для многочастных сообщений)
+     */
+    public int $chunkIndex = 0;
+
+    /**
+     * Общее количество чанков
+     */
+    public int $totalChunks = 1;
+
+    /**
+     * Идентификатор отчёта (для логирования)
+     */
+    public string $reportId = '';
+
+    /**
+     * Выполняет отправку сообщения в Telegram
+     *
+     * @param \yii\queue\Queue $queue
+     * @return void
+     * @throws \RuntimeException При ошибке отправки (для retry)
+     */
+    public function execute($queue): void
+    {
+        if (empty($this->botToken)) {
+            Yii::error("SendOrderControlTelegramJob: botToken не установлен", 'telegram');
+            return; // Не повторяем — ошибка конфигурации
+        }
+
+        if (empty($this->chatId)) {
+            Yii::error("SendOrderControlTelegramJob: chatId не установлен", 'telegram');
+            return; // Не повторяем — ошибка конфигурации
+        }
+
+        $url = "https://api.telegram.org/bot{$this->botToken}/sendMessage";
+
+        $ch = curl_init();
+        curl_setopt_array($ch, [
+            CURLOPT_URL => $url,
+            CURLOPT_POST => true,
+            CURLOPT_POSTFIELDS => [
+                'chat_id' => $this->chatId,
+                'text' => $this->message,
+                'parse_mode' => 'MarkdownV2',
+                'disable_web_page_preview' => true,
+            ],
+            CURLOPT_RETURNTRANSFER => true,
+            CURLOPT_TIMEOUT => 30,
+            CURLOPT_SSL_VERIFYPEER => true,
+        ]);
+
+        $response = curl_exec($ch);
+        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+        $curlError = curl_error($ch);
+        curl_close($ch);
+
+        if ($curlError) {
+            Yii::error("SendOrderControlTelegramJob: cURL error - {$curlError}", 'telegram');
+            throw new \RuntimeException("cURL error: {$curlError}");
+        }
+
+        $data = json_decode($response, true);
+
+        if ($httpCode === 200 && ($data['ok'] ?? false)) {
+            $chunkInfo = $this->totalChunks > 1
+                ? " (чанк {$this->chunkIndex}/{$this->totalChunks})"
+                : '';
+            Yii::info("SendOrderControlTelegramJob: сообщение отправлено{$chunkInfo}, report={$this->reportId}", 'telegram');
+            return;
+        }
+
+        // Обработка rate limit (429)
+        if ($httpCode === 429) {
+            $retryAfter = $data['parameters']['retry_after'] ?? 10;
+            Yii::warning("SendOrderControlTelegramJob: rate limit, retry_after={$retryAfter}s", 'telegram');
+            throw new \RuntimeException("Rate limit: retry after {$retryAfter}s");
+        }
+
+        // Другие ошибки
+        $errorDescription = $data['description'] ?? 'Unknown error';
+        Yii::error("SendOrderControlTelegramJob: HTTP {$httpCode}, error: {$errorDescription}", 'telegram');
+        throw new \RuntimeException("Telegram API error: {$errorDescription}");
+    }
+
+    /**
+     * Время жизни Job в очереди
+     *
+     * @return int Время в секундах
+     */
+    public function getTtr(): int
+    {
+        return self::TTR;
+    }
+
+    /**
+     * Определяет, можно ли повторить выполнение Job
+     *
+     * @param int $attempt Номер текущей попытки
+     * @param \Throwable $error Ошибка
+     * @return bool
+     */
+    public function canRetry($attempt, $error ): bool
+    {
+        if ($attempt >= self::MAX_ATTEMPTS) {
+            Yii::error("SendOrderControlTelegramJob: превышено количество попыток ({$attempt}), report={$this->reportId}", 'telegram');
+            return false;
+        }
+
+        $errorMessage = $error->getMessage();
+
+        // Не повторяем при ошибках конфигурации
+        if (str_contains($errorMessage, 'не установлен')
+            || str_contains($errorMessage, 'Invalid token')
+            || str_contains($errorMessage, 'chat not found')
+            || str_contains($errorMessage, 'bot was blocked')) {
+            return false;
+        }
+
+        return true;
+    }
+}
index fb7fc40738f735466c5a8dcbc7e0991c36282b97..841df4a05f5268824c4eb8d27436020c1f1ddb15 100644 (file)
@@ -8,6 +8,7 @@ use GuzzleHttp\Client;
 use Yii;
 use yii\helpers\Json;
 use yii\queue\JobInterface;
+use yii\queue\RetryableJobInterface;
 use yii_app\records\MarketplaceOrders;
 use yii_app\services\TelegramService;
 
@@ -16,34 +17,61 @@ use yii_app\services\TelegramService;
  *
  * Используется для уведомлений о заказах с Яндекс.Маркета и Flowwow.
  * Отправляет сообщения в канал из TELEGRAM_CHAT_CHANNEL_ID (.env)
+ *
+ * Реализует RetryableJobInterface для повторных попыток при сбоях.
  */
-class SendTelegramTestMessageJob extends \yii\base\BaseObject implements JobInterface
+class SendTelegramTestMessageJob extends \yii\base\BaseObject implements JobInterface, RetryableJobInterface
 {
-    public $message;
-    public $guid;
-
-    public function execute($queue)
+    /**
+     * Максимальное количество попыток выполнения Job
+     */
+    private const MAX_ATTEMPTS = 5;
+
+    /**
+     * Время жизни Job в очереди (в секундах)
+     */
+    private const TTR = 300;
+
+    /**
+     * Сообщение для отправки
+     */
+    public string $message = '';
+
+    /**
+     * GUID заказа
+     */
+    public string $guid = '';
+
+    /**
+     * Выполняет отправку сообщения в Telegram
+     *
+     * @param \yii\queue\Queue $queue
+     * @return void
+     */
+    public function execute($queue): void
     {
         $message = $this->message;
         $guid = $this->guid;
 
-        // Находим заказ по guid со статусом "не отправлено"
-        $marketplaceOrder = MarketplaceOrders::find()
-            ->where([
+        // Атомарно захватываем заказ через updateAll (защита от Race Condition)
+        $updatedCount = MarketplaceOrders::updateAll(
+            ['status_telegram' => MarketplaceOrders::STATUS_TELEGRAM_PREPARED_TO_SEND],
+            [
                 'status_telegram' => MarketplaceOrders::STATUS_TELEGRAM_NOT_SENT,
                 'guid' => $guid
-            ])
-            ->one();
+            ]
+        );
 
-        if (!$marketplaceOrder) {
+        if ($updatedCount === 0) {
             Yii::warning("SendTelegramTestMessageJob: заказ с guid={$guid} не найден или уже обработан", 'telegram');
             return;
         }
 
-        // Меняем статус на "подготовлен к отправке"
-        $marketplaceOrder->status_telegram = MarketplaceOrders::STATUS_TELEGRAM_PREPARED_TO_SEND;
-        if (!$marketplaceOrder->save()) {
-            Yii::error("Ошибка сохранения статуса заказа: " . Json::encode($marketplaceOrder->getErrors()), 'telegram');
+        // Получаем заказ для дальнейшей работы
+        $marketplaceOrder = MarketplaceOrders::findOne(['guid' => $guid]);
+        if (!$marketplaceOrder) {
+            Yii::error("SendTelegramTestMessageJob: заказ с guid={$guid} не найден после updateAll", 'telegram');
+            return;
         }
 
         // Получаем токен бота из .env через TelegramService
@@ -52,9 +80,7 @@ class SendTelegramTestMessageJob extends \yii\base\BaseObject implements JobInte
 
         if (empty($botToken)) {
             Yii::error("SendTelegramTestMessageJob: TELEGRAM_BOT_TOKEN не установлен в .env", 'telegram');
-            $marketplaceOrder->status_telegram = MarketplaceOrders::STATUS_TELEGRAM_ERROR;
-            $marketplaceOrder->telegram_error = 'TELEGRAM_BOT_TOKEN не установлен';
-            $marketplaceOrder->save();
+            $this->markOrderError($marketplaceOrder, 'TELEGRAM_BOT_TOKEN не установлен');
             return;
         }
 
@@ -66,10 +92,9 @@ class SendTelegramTestMessageJob extends \yii\base\BaseObject implements JobInte
             : getenv('TELEGRAM_CHAT_CHANNEL_ERP_ID');
 
         if (empty($chatId)) {
-            Yii::error("SendTelegramTestMessageJob: TELEGRAM_CHAT_CHANNEL_ID не установлен в .env", 'telegram');
-            $marketplaceOrder->status_telegram = MarketplaceOrders::STATUS_TELEGRAM_ERROR;
-            $marketplaceOrder->telegram_error = 'TELEGRAM_CHAT_CHANNEL_ID не установлен';
-            $marketplaceOrder->save();
+            $envVar = $isDev ? 'TELEGRAM_CHAT_CHANNEL_ID' : 'TELEGRAM_CHAT_CHANNEL_ERP_ID';
+            Yii::error("SendTelegramTestMessageJob: {$envVar} не установлен в .env", 'telegram');
+            $this->markOrderError($marketplaceOrder, "{$envVar} не установлен");
             return;
         }
 
@@ -85,15 +110,16 @@ class SendTelegramTestMessageJob extends \yii\base\BaseObject implements JobInte
                     'text' => $escapedMessage,
                     'parse_mode' => 'MarkdownV2',
                 ],
+                'timeout' => 30,
             ]);
 
             if ($response->getStatusCode() === 200) {
                 $marketplaceOrder->status_telegram = MarketplaceOrders::STATUS_TELEGRAM_SENT;
+                $marketplaceOrder->telegram_error = null;
                 Yii::info("Telegram уведомление отправлено: {$message} -> chat_id={$chatId}", 'telegram');
             } else {
-                $marketplaceOrder->status_telegram = MarketplaceOrders::STATUS_TELEGRAM_ERROR;
-                $marketplaceOrder->telegram_error = "HTTP {$response->getStatusCode()}";
-                Yii::error("Ошибка отправки в Telegram: HTTP {$response->getStatusCode()}", 'telegram');
+                // Выбрасываем исключение для retry
+                throw new \RuntimeException("HTTP {$response->getStatusCode()}");
             }
         } catch (\Exception $e) {
             Yii::error("Ошибка отправки сообщения в Telegram: " . $e->getMessage(), 'telegram');
@@ -104,10 +130,65 @@ class SendTelegramTestMessageJob extends \yii\base\BaseObject implements JobInte
 
             $marketplaceOrder->telegram_error = mb_substr($errorMessage, 0, 255);
             $marketplaceOrder->status_telegram = MarketplaceOrders::STATUS_TELEGRAM_ERROR;
+
+            // Перебрасываем исключение для механизма retry
+            if (!$marketplaceOrder->save()) {
+                Yii::error("Ошибка сохранения статуса заказа: " . Json::encode($marketplaceOrder->getErrors()), 'telegram');
+            }
+            throw $e;
         }
 
         if (!$marketplaceOrder->save()) {
             Yii::error("Ошибка сохранения статуса заказа после отправки: " . Json::encode($marketplaceOrder->getErrors()), 'telegram');
         }
     }
+
+    /**
+     * Время жизни Job в очереди (Time To Reserve)
+     *
+     * @return int Время в секундах
+     */
+    public function getTtr(): int
+    {
+        return self::TTR;
+    }
+
+    /**
+     * Определяет, можно ли повторить выполнение Job при ошибке
+     *
+     * @param int $attempt Номер текущей попытки
+     * @param \Throwable $error Ошибка, из-за которой Job завершился
+     * @return bool
+     */
+    public function canRetry($attempt, $error): bool
+    {
+        // Повторяем при сетевых ошибках и ошибках API
+        if ($attempt >= self::MAX_ATTEMPTS) {
+            Yii::warning("SendTelegramTestMessageJob: превышено количество попыток ({$attempt}) для guid={$this->guid}", 'telegram');
+            return false;
+        }
+
+        // Не повторяем при критических ошибках конфигурации
+        $errorMessage = $error->getMessage();
+        if (str_contains($errorMessage, 'не установлен') || str_contains($errorMessage, 'Invalid token')) {
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * Помечает заказ как ошибочный
+     *
+     * @param MarketplaceOrders $order Заказ
+     * @param string $errorMessage Текст ошибки
+     */
+    private function markOrderError(MarketplaceOrders $order, string $errorMessage): void
+    {
+        $order->status_telegram = MarketplaceOrders::STATUS_TELEGRAM_ERROR;
+        $order->telegram_error = mb_substr($errorMessage, 0, 255);
+        if (!$order->save()) {
+            Yii::error("Ошибка сохранения статуса заказа: " . Json::encode($order->getErrors()), 'telegram');
+        }
+    }
 }
index ee5de5bb3e1528a129d8ac6147f42be70fd61b90..cffd2b38780edded615fecb96d3658dfee697990 100644 (file)
@@ -10,6 +10,8 @@ use yii_app\records\MarketplaceOrderStatusTypes;
 use yii_app\records\MarketplaceOrderDailyIssues;
 use yii_app\services\dto\OrderIssue;
 use yii_app\services\dto\ControlReportResult;
+use yii_app\jobs\SendOrderControlTelegramJob;
+use yii_app\jobs\SendOrderControlEmailJob;
 
 /**
  * Сервис контроля статусов заказов маркетплейса
@@ -59,6 +61,16 @@ class OrderControlReportService
      */
     private bool $testMode = false;
 
+    /**
+     * Режим асинхронной отправки: использовать Job-очередь вместо блокирующего sleep()
+     */
+    private bool $asyncMode = false;
+
+    /**
+     * Уникальный идентификатор отчёта (для логирования и отслеживания Job-ов)
+     */
+    private string $reportId;
+
     /**
      * Кеш ID статусов 1С (ленивая загрузка)
      */
@@ -66,9 +78,51 @@ class OrderControlReportService
     private ?array $rmkStatusSuccess = null;
     private ?array $rmkStatusCancel = null;
 
-    public function __construct()
+    /**
+     * Создаёт экземпляр сервиса контроля заказов
+     *
+     * @param array|null $config Конфигурация отчёта. Если null, загружается из Yii::$app->params
+     * @param string|null $reportId Идентификатор отчёта. Если null, генерируется автоматически
+     */
+    public function __construct(?array $config = null, ?string $reportId = null)
+    {
+        $this->config = $config ?? Yii::$app->params['MARKETPLACE_ORDER_CONTROL_REPORT'] ?? [];
+        $this->reportId = $reportId ?? uniqid('report_', true);
+    }
+
+    /**
+     * Включает асинхронный режим отправки через Job-очередь
+     *
+     * В асинхронном режиме отправка Telegram и Email происходит через
+     * Job-очередь с автоматическими retry, без блокирования основного процесса.
+     *
+     * @param bool $enabled
+     * @return self
+     */
+    public function setAsyncMode(bool $enabled): self
+    {
+        $this->asyncMode = $enabled;
+        return $this;
+    }
+
+    /**
+     * Проверяет, включён ли асинхронный режим
+     *
+     * @return bool
+     */
+    public function isAsyncMode(): bool
     {
-        $this->config = Yii::$app->params['MARKETPLACE_ORDER_CONTROL_REPORT'] ?? [];
+        return $this->asyncMode;
+    }
+
+    /**
+     * Получает идентификатор текущего отчёта
+     *
+     * @return string
+     */
+    public function getReportId(): string
+    {
+        return $this->reportId;
     }
 
     /**
@@ -1059,8 +1113,11 @@ class OrderControlReportService
     /**
      * Отправляет сообщение в Telegram с retry-логикой
      *
+     * В синхронном режиме (по умолчанию) использует блокирующий retry с sleep().
+     * В асинхронном режиме (setAsyncMode(true)) ставит Job-ы в очередь.
+     *
      * @param string $message Текст сообщения (MarkdownV2)
-     * @return bool Успешность отправки
+     * @return bool Успешность отправки (в async режиме всегда true, если Job поставлен)
      */
     public function sendToTelegram(string $message): bool
     {
@@ -1078,6 +1135,13 @@ class OrderControlReportService
         }
 
         $chunks = $this->splitTelegramMessage($message);
+
+        // Асинхронный режим: ставим Job-ы в очередь
+        if ($this->asyncMode) {
+            return $this->sendToTelegramAsync($chatId, $chunks);
+        }
+
+        // Синхронный режим: отправляем с блокирующим retry
         $allSent = true;
 
         foreach ($chunks as $index => $chunk) {
@@ -1120,6 +1184,55 @@ class OrderControlReportService
         return $allSent;
     }
 
+    /**
+     * Отправляет сообщение в Telegram асинхронно через Job-очередь
+     *
+     * Каждый чанк сообщения ставится в очередь как отдельный Job с задержкой.
+     * Retry-логика обрабатывается механизмом очереди (RetryableJobInterface).
+     *
+     * @param string $chatId ID чата/канала
+     * @param array $chunks Части сообщения
+     * @return bool true если все Job-ы поставлены в очередь
+     */
+    private function sendToTelegramAsync(string $chatId, array $chunks): bool
+    {
+        $botToken = $this->getTelegramBotToken();
+
+        if (empty($botToken)) {
+            $envVar = $this->isDevEnvironment() ? 'TELEGRAM_BOT_TOKEN' : 'TELEGRAM_BOT_TOKEN_PROD';
+            $this->logError("Telegram bot token не установлен ({$envVar})");
+            return false;
+        }
+
+        $queue = Yii::$app->queue;
+        $totalChunks = count($chunks);
+        $delayBetweenChunks = 2; // Задержка в секундах между чанками
+
+        foreach ($chunks as $index => $chunk) {
+            $job = new SendOrderControlTelegramJob([
+                'message' => $chunk,
+                'chatId' => $chatId,
+                'botToken' => $botToken,
+                'chunkIndex' => $index + 1,
+                'totalChunks' => $totalChunks,
+                'reportId' => $this->reportId,
+            ]);
+
+            // Первый чанк сразу, остальные с задержкой
+            $delay = $index * $delayBetweenChunks;
+
+            if ($delay > 0) {
+                $queue->delay($delay)->push($job);
+            } else {
+                $queue->push($job);
+            }
+
+            $this->logInfo("Telegram Job поставлен в очередь: чанк {$index}/{$totalChunks}, delay={$delay}s, report={$this->reportId}");
+        }
+
+        return true;
+    }
+
     /**
      * Отправляет сообщение в Telegram
      *
@@ -1185,8 +1298,11 @@ class OrderControlReportService
     /**
      * Отправляет отчёт на email с retry-логикой
      *
+     * В синхронном режиме (по умолчанию) использует блокирующий retry с sleep().
+     * В асинхронном режиме (setAsyncMode(true)) ставит Job в очередь.
+     *
      * @param string $html HTML-контент письма
-     * @return bool Успешность отправки
+     * @return bool Успешность отправки (в async режиме всегда true, если Job поставлен)
      */
     public function sendToEmail(string $html): bool
     {
@@ -1213,6 +1329,11 @@ class OrderControlReportService
             return false;
         }
 
+        // Асинхронный режим: ставим Job в очередь
+        if ($this->asyncMode) {
+            return $this->sendToEmailAsync($html, $validRecipients);
+        }
+
         // Диагностика конфигурации mailer
         $this->logMailerDiagnostics($validRecipients);
 
@@ -1267,6 +1388,73 @@ class OrderControlReportService
         return $sent;
     }
 
+    /**
+     * Отправляет email асинхронно через Job-очередь
+     *
+     * Retry-логика обрабатывается механизмом очереди (RetryableJobInterface).
+     *
+     * @param string $html HTML-контент письма
+     * @param array $recipients Валидные email-адреса
+     * @return bool true если Job поставлен в очередь
+     */
+    private function sendToEmailAsync(string $html, array $recipients): bool
+    {
+        $subject = $this->config['email_subject'] ?? 'Контроль статусов заказов МП';
+        $fromEmail = getenv('MAIL_USERNAME') ?: 'flow@bazacvetov24.ru';
+
+        // Формируем DSN для Symfony Mailer
+        $mailerDsn = $this->buildMailerDsn();
+        if (empty($mailerDsn)) {
+            $this->logError('Не удалось сформировать DSN для email');
+            return false;
+        }
+
+        $job = new SendOrderControlEmailJob([
+            'subject' => $subject,
+            'htmlBody' => $html,
+            'recipients' => $recipients,
+            'fromEmail' => $fromEmail,
+            'fromName' => 'ERP24 Контроль МП',
+            'mailerDsn' => $mailerDsn,
+            'reportId' => $this->reportId,
+        ]);
+
+        Yii::$app->queue->push($job);
+
+        $this->logInfo("Email Job поставлен в очередь: " . count($recipients) . " получателей, report={$this->reportId}");
+        return true;
+    }
+
+    /**
+     * Формирует DSN для Symfony Mailer из ENV-переменных
+     *
+     * @return string DSN строка или пустая строка при ошибке
+     */
+    private function buildMailerDsn(): string
+    {
+        $scheme = getenv('MAIL_SCHEME') ?: 'smtp';
+        $host = getenv('MAIL_HOST');
+        $port = getenv('MAIL_PORT') ?: 587;
+        $username = getenv('MAIL_USERNAME');
+        $password = getenv('MAIL_PASSWORD');
+
+        if (empty($host) || empty($username)) {
+            return '';
+        }
+
+        // Формат: smtp://user:pass@host:port
+        $dsn = sprintf(
+            '%s://%s:%s@%s:%d',
+            $scheme,
+            rawurlencode($username),
+            rawurlencode($password ?: ''),
+            $host,
+            (int)$port
+        );
+
+        return $dsn;
+    }
+
     /**
      * Логирует диагностику конфигурации mailer
      *
index 36000b5d76b5900b5fc33db62da1ed3f79b128a3..ffe72584f5ecdcff92ce0d4facf9f30660e2ec05 100644 (file)
@@ -154,6 +154,15 @@ class OrderIssue
         'unknown' => 'Неизвестно',
     ];
 
+    /**
+     * Допустимые типы проблем
+     */
+    private const VALID_PROBLEM_TYPES = [
+        self::TYPE_HUNG_IN_DELIVERY,
+        self::TYPE_SUCCESS_NO_CHECK,
+        self::TYPE_CANCEL_NO_PROCESS,
+    ];
+
     /**
      * Конструктор с параметрами
      *
@@ -161,6 +170,7 @@ class OrderIssue
      * @param int $orderId ID заказа
      * @param string $orderNumber Номер заказа
      * @param bool $testMode Тестовый режим (анализ текущей смены вместо предыдущей)
+     * @throws \InvalidArgumentException Если передан недопустимый тип проблемы
      */
     public function __construct(
         string $problemType,
@@ -168,6 +178,15 @@ class OrderIssue
         string $orderNumber,
         bool $testMode = false
     ) {
+        // Валидация типа проблемы
+        if (!in_array($problemType, self::VALID_PROBLEM_TYPES, true)) {
+            throw new \InvalidArgumentException(sprintf(
+                'Недопустимый тип проблемы: "%s". Допустимые значения: %s',
+                $problemType,
+                implode(', ', self::VALID_PROBLEM_TYPES)
+            ));
+        }
+
         $this->problemType = $problemType;
         $this->problemTypeLabel = self::TYPE_LABELS[$problemType] ?? $problemType;
         $this->orderId = $orderId;
@@ -177,6 +196,17 @@ class OrderIssue
         $this->total = 0.0;
     }
 
+    /**
+     * Проверяет, является ли тип проблемы допустимым
+     *
+     * @param string $problemType Тип проблемы
+     * @return bool
+     */
+    public static function isValidProblemType(string $problemType): bool
+    {
+        return in_array($problemType, self::VALID_PROBLEM_TYPES, true);
+    }
+
     /**
      * Определяет интервал проверки (Дневная/Ночная смена)
      *
index 1cb4c0552b19171fb4a85be75dab1cf680b67e22..50146f9479804bca2775986f9969daa569bec4ee 100644 (file)
@@ -27,10 +27,21 @@ class OrderControlReportServiceTest extends Unit
 {
     private OrderControlReportService $service;
 
+    /**
+     * Тестовая конфигурация (внедряется через конструктор)
+     */
+    private array $testConfig = [
+        'max_retries' => 3,
+        'retry_delay_seconds' => 5,
+        'telegram_max_message_length' => 4000,
+        'email_subject' => 'Test Report',
+    ];
+
     protected function _before(): void
     {
         parent::_before();
-        $this->service = new OrderControlReportService();
+        // Внедряем конфигурацию через конструктор (Dependency Injection)
+        $this->service = new OrderControlReportService($this->testConfig, 'test_report_123');
     }
 
     /**
@@ -39,8 +50,8 @@ class OrderControlReportServiceTest extends Unit
     public function testConstantsHaveCorrectValues(): void
     {
         $this->assertSame(12, OrderControlReportService::REPORT_PERIOD_HOURS);
-        $this->assertSame(3, OrderControlReportService::MAX_RETRIES);
-        $this->assertSame(5, OrderControlReportService::RETRY_DELAY_SECONDS);
+        $this->assertSame(5, OrderControlReportService::MAX_RETRIES);
+        $this->assertSame(10, OrderControlReportService::RETRY_DELAY_SECONDS);
         $this->assertSame(4000, OrderControlReportService::TELEGRAM_MAX_LENGTH);
         $this->assertSame('Europe/Moscow', OrderControlReportService::TIMEZONE);
     }
@@ -360,7 +371,7 @@ class OrderControlReportServiceTest extends Unit
     }
 
     /**
-     * Тест: сервис создаётся без ошибок
+     * Тест: сервис создаётся без ошибок (без параметров — обратная совместимость)
      */
     public function testServiceCanBeInstantiated(): void
     {
@@ -368,4 +379,73 @@ class OrderControlReportServiceTest extends Unit
 
         $this->assertInstanceOf(OrderControlReportService::class, $service);
     }
+
+    /**
+     * Тест: сервис создаётся с внедрённой конфигурацией (Dependency Injection)
+     */
+    public function testServiceCanBeInstantiatedWithConfig(): void
+    {
+        $config = [
+            'max_retries' => 10,
+            'email_subject' => 'Custom Subject',
+        ];
+        $reportId = 'custom_report_456';
+
+        $service = new OrderControlReportService($config, $reportId);
+
+        $this->assertInstanceOf(OrderControlReportService::class, $service);
+        $this->assertSame($reportId, $service->getReportId());
+    }
+
+    /**
+     * Тест: getReportId возвращает переданный идентификатор
+     */
+    public function testGetReportIdReturnsInjectedValue(): void
+    {
+        $expectedReportId = 'test_report_123';
+        $service = new OrderControlReportService([], $expectedReportId);
+
+        $this->assertSame($expectedReportId, $service->getReportId());
+    }
+
+    /**
+     * Тест: getReportId генерирует идентификатор, если не передан
+     */
+    public function testGetReportIdGeneratesValueIfNotProvided(): void
+    {
+        $service = new OrderControlReportService([]);
+
+        $reportId = $service->getReportId();
+
+        $this->assertNotEmpty($reportId);
+        $this->assertStringStartsWith('report_', $reportId);
+    }
+
+    /**
+     * Тест: setAsyncMode возвращает $this для fluent interface
+     */
+    public function testSetAsyncModeReturnsThis(): void
+    {
+        $service = new OrderControlReportService();
+
+        $result = $service->setAsyncMode(true);
+
+        $this->assertSame($service, $result);
+    }
+
+    /**
+     * Тест: isAsyncMode отражает установленный режим
+     */
+    public function testIsAsyncModeReflectsSetValue(): void
+    {
+        $service = new OrderControlReportService();
+
+        $this->assertFalse($service->isAsyncMode());
+
+        $service->setAsyncMode(true);
+        $this->assertTrue($service->isAsyncMode());
+
+        $service->setAsyncMode(false);
+        $this->assertFalse($service->isAsyncMode());
+    }
 }