From 058b6a4bf568c5b9f51ae87fa4b9849c8ebc5bdf Mon Sep 17 00:00:00 2001 From: Vladimir Fomichev Date: Fri, 23 Jan 2026 12:38:27 +0300 Subject: [PATCH] =?utf8?q?=D0=9F=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20=D0=BF?= =?utf8?q?=D0=BE=20=D0=BE=D1=82=D1=87=D0=B5=D1=82=D1=83=20=D0=B8=20=D1=82?= =?utf8?q?=D0=B5=D1=81=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit --- erp24/config/web.php | 4 +- erp24/services/MarketplaceService.php | 43 +- erp24/services/OrderControlReportService.php | 169 +++-- erp24/services/dto/OrderIssue.php | 10 +- .../commands/MarketplaceControllerTest.php | 222 ++++++ ...erControlReportServiceNotificationTest.php | 675 ++++++++++++++++++ 6 files changed, 1061 insertions(+), 62 deletions(-) create mode 100644 erp24/tests/unit/commands/MarketplaceControllerTest.php create mode 100644 erp24/tests/unit/services/OrderControlReportServiceNotificationTest.php diff --git a/erp24/config/web.php b/erp24/config/web.php index 0e140e1f..dacd629a 100644 --- a/erp24/config/web.php +++ b/erp24/config/web.php @@ -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) ), diff --git a/erp24/services/MarketplaceService.php b/erp24/services/MarketplaceService.php index f71c967c..81365810 100644 --- a/erp24/services/MarketplaceService.php +++ b/erp24/services/MarketplaceService.php @@ -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(); diff --git a/erp24/services/OrderControlReportService.php b/erp24/services/OrderControlReportService.php index bbf76327..bf8b664e 100644 --- a/erp24/services/OrderControlReportService.php +++ b/erp24/services/OrderControlReportService.php @@ -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 } /** - * Форматирует таблицу проблем для Telegram (моноширинный блок) + * Форматирует список проблем для Telegram (компактный формат) * - * Формат: | Дата | Интервал | Заказ | РМК | МП | Причина + * Формат: + * 📦 {номер заказа} ({дата создания}) + * РМК: {статус} | МП: {статус} + * ⚠️ {причина} * * @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 = ' @@ -949,7 +1005,7 @@ class OrderControlReportService -

[Контроль MP] Отчёт за ' . $this->escapeHtml($result->reportDate) . ' ' . $this->escapeHtml($this->formatIntervalWithShiftName($result->interval)) . '

'; +

[Контроль MP] Отчёт за ' . $this->escapeHtml($shiftStartDate) . ' ' . $this->escapeHtml($intervalWithShift) . '

'; // Общая таблица со всеми проблемами, сортировка по типу $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]; } /** diff --git a/erp24/services/dto/OrderIssue.php b/erp24/services/dto/OrderIssue.php index f74b9530..36000b5d 100644 --- a/erp24/services/dto/OrderIssue.php +++ b/erp24/services/dto/OrderIssue.php @@ -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 index 00000000..15839fd4 --- /dev/null +++ b/erp24/tests/unit/commands/MarketplaceControllerTest.php @@ -0,0 +1,222 @@ +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 index 00000000..6f45251e --- /dev/null +++ b/erp24/tests/unit/services/OrderControlReportServiceNotificationTest.php @@ -0,0 +1,675 @@ +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('', $html); + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('', $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('', $html); + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('', $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, ''); + $issue->rmkStatus = 'Статус & "тест"'; + $issue->mpStatus = 'HTML тег'; + $issue->issueReason = null; + $issue->creationDate = '2026-01-19 10:00:00'; + + $result->hungInDelivery = [$issue]; + $result->calculateTotal(); + + $html = $this->service->formatEmailControlReport($result); + + // Проверяем, что опасные символы экранированы + $this->assertStringNotContainsString('
Тип проблемыЗаказРМКМППричина