From a7c2f381c5d52c5272ec52b3d665bded0a57366b Mon Sep 17 00:00:00 2001 From: Vladimir Fomichev Date: Fri, 16 Jan 2026 18:15:42 +0300 Subject: [PATCH] =?utf8?q?=D0=9E=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B0?= =?utf8?q?=20=D0=BE=D1=82=D1=87=D0=B5=D1=82=D0=B0=20=D0=BF=D0=BE=20=D1=81?= =?utf8?q?=D0=BF=D0=B5=D1=86=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=86=D0=B8?= =?utf8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit --- erp24/commands/MarketplaceController.php | 81 ++ erp24/config/params.php | 13 + erp24/services/MarketplaceService.php | 1 - .../services/UncheckedOrdersReportService.php | 849 ++++++++++++++++++ erp24/services/dto/ReportResult.php | 130 +++ 5 files changed, 1073 insertions(+), 1 deletion(-) create mode 100644 erp24/services/UncheckedOrdersReportService.php create mode 100644 erp24/services/dto/ReportResult.php diff --git a/erp24/commands/MarketplaceController.php b/erp24/commands/MarketplaceController.php index f3d2d606..009804e8 100644 --- a/erp24/commands/MarketplaceController.php +++ b/erp24/commands/MarketplaceController.php @@ -27,6 +27,7 @@ use OpenAPI\Client\Model; use GuzzleHttp; use yii_app\services\WhatsAppService; use yii_app\records\MarketplaceOrderDelivery; +use yii_app\services\UncheckedOrdersReportService; class MarketplaceController extends Controller { @@ -358,4 +359,84 @@ class MarketplaceController extends Controller $delivery->courier_vehicle_description, ])); } + + + /** + * Отправка отчёта о заказах с непробитыми чеками + * + * Выбирает заказы в статусе DELIVERED/DELIVERY_SERVICE_DELIVERED за последние 12 часов, + * у которых отсутствует пробитый чек: + * - check_guid IS NULL + * - CreateChecks.status IS NULL (запись отсутствует) + * - CreateChecks.status = 0 (STATUS_CHECK_CREATED_ERP - создан в ERP, не отправлен в 1С) + * - CreateChecks.status = 8 (STATUS_CHECK_ERROR_1C - ошибка в 1С) + * + * Отправляет отчёт в Telegram (MarkdownV2) и на email (HTML). + * В development окружении использует тестовый бот и канал. + * + * Запуск: php yii marketplace/send-unchecked-orders-report [--date=YYYY-MM-DD] + * Cron (UTC): 0 5,17 * * * + * Время MSK: 8:00 и 20:00 + * + * @param string|null $date Дата для отчёта (YYYY-MM-DD), по умолчанию - сегодня + * @return int Exit code: + * 0 = успех (отчёт отправлен или нет заказов) + * 1 = критическая ошибка (БД, конфигурация) + * 2 = частичный успех (TG или email не отправлен) + */ + public function actionSendUncheckedOrdersReport(?string $date = null): int + { + set_time_limit(300); // 5 минут максимум + + $this->stdout("Запуск отчёта о заказах с непробитыми чеками...\n", BaseConsole::FG_YELLOW); + + // Валидация даты + if ($date !== null && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { + $this->stderr("Неверный формат даты. Ожидается: YYYY-MM-DD\n", BaseConsole::FG_RED); + return ExitCode::UNSPECIFIED_ERROR; + } + + try { + $service = new UncheckedOrdersReportService(); + $result = $service->generateAndSendReport($date); + + if (!$result->hasOrders()) { + $this->stdout("Заказы с непробитыми чеками не найдены за период.\n", BaseConsole::FG_GREEN); + return ExitCode::OK; + } + + $this->stdout("Найдено заказов: {$result->totalOrders}\n", BaseConsole::FG_CYAN); + $this->stdout("Магазинов: {$result->storesCount}\n", BaseConsole::FG_CYAN); + $this->stdout("Общая сумма: " . number_format($result->totalAmount, 0, '.', ' ') . " руб.\n", BaseConsole::FG_CYAN); + + if ($result->telegramSent) { + $this->stdout("Telegram: отправлено\n", BaseConsole::FG_GREEN); + } else { + $this->stdout("Telegram: ошибка - {$result->telegramError}\n", BaseConsole::FG_RED); + } + + if ($result->emailSent) { + $this->stdout("Email: отправлено\n", BaseConsole::FG_GREEN); + } else { + $this->stdout("Email: ошибка - {$result->emailError}\n", BaseConsole::FG_RED); + } + + $exitCode = $result->getExitCode(); + + if ($exitCode === 0) { + $this->stdout("Отчёт успешно отправлен.\n", BaseConsole::FG_GREEN); + } elseif ($exitCode === 2) { + $this->stdout("Отчёт отправлен частично.\n", BaseConsole::FG_YELLOW); + } else { + $this->stdout("Не удалось отправить отчёт.\n", BaseConsole::FG_RED); + } + + return $exitCode; + + } catch (\Exception $e) { + $this->stderr("Критическая ошибка: {$e->getMessage()}\n", BaseConsole::FG_RED); + Yii::error("Ошибка отчёта о непробитых чеках: " . $e->getMessage(), 'marketplace-checks'); + return ExitCode::UNSPECIFIED_ERROR; + } + } } diff --git a/erp24/config/params.php b/erp24/config/params.php index 8c95f87b..dbcd544d 100644 --- a/erp24/config/params.php +++ b/erp24/config/params.php @@ -17,4 +17,17 @@ return [ 'SWITCH_USER_COOKIE_PASSWORD' => '123pass@WORD', 'YANDEX_MARKET_API_KEY' => 'ACMA:r3sa2VyjkgcO0aOxGoyAWuGH15g5mWAqXRMuylVA:a0bccb7e', 'RABBIT_HOST' => getenv('RABBIT_HOST') ?: 'localhost', + + // Отчёт о непробитых чеках маркетплейсов + 'MARKETPLACE_UNCHECKED_ORDERS_REPORT' => [ + 'period_hours' => 12, + 'timezone' => 'Europe/Moscow', + 'max_retries' => 3, + 'retry_delay_seconds' => 5, + 'telegram_max_message_length' => 4000, + 'telegram_chat_id_dev' => getenv('TELEGRAM_UNCHECKED_ORDERS_CHAT_ID_DEV') ?: '-1001861631125', + 'telegram_chat_id_prod' => getenv('TELEGRAM_UNCHECKED_ORDERS_CHAT_ID_PROD') ?: '', + 'email_recipients' => array_filter(explode(',', getenv('UNCHECKED_ORDERS_EMAIL_RECIPIENTS') ?: 'vladimir.fomichev@erp-flowers.ru')), + 'email_subject' => 'Отчёт о заказах с непробитыми чеками', + ], ]; diff --git a/erp24/services/MarketplaceService.php b/erp24/services/MarketplaceService.php index ee04ec6f..1a96c2b2 100644 --- a/erp24/services/MarketplaceService.php +++ b/erp24/services/MarketplaceService.php @@ -1521,7 +1521,6 @@ class MarketplaceService $marketplaceOrder->raw_data = $newRawData; $marketplaceOrder->status_id = (int)$statusId; $marketplaceOrder->substatus_id = (int)$substatusId; - $marketplaceOrder->updated_at = date('Y-m-d H:i:s', strtotime($order->getUpdatedAt())); $delivery = $order->getDelivery(); // Проверяем и создаем/обновляем доставку diff --git a/erp24/services/UncheckedOrdersReportService.php b/erp24/services/UncheckedOrdersReportService.php new file mode 100644 index 00000000..6adfb53f --- /dev/null +++ b/erp24/services/UncheckedOrdersReportService.php @@ -0,0 +1,849 @@ +config = Yii::$app->params['MARKETPLACE_UNCHECKED_ORDERS_REPORT'] ?? []; + } + + /** + * Генерирует и отправляет отчёт о непробитых заказах + * + * @param string|null $targetDate Дата для отчёта (YYYY-MM-DD), по умолчанию - сегодня + * @return ReportResult Результат генерации и отправки + */ + public function generateAndSendReport(?string $targetDate = null): ReportResult + { + $result = new ReportResult(); + $tz = new \DateTimeZone(self::TIMEZONE); + $result->reportDate = (new \DateTime('now', $tz))->format('d.m.Y H:i'); + + try { + // Получение заказов + $orders = $this->getUncheckedOrders($targetDate); + $result->totalOrders = count($orders); + + if ($result->totalOrders === 0) { + $this->logInfo('Нет заказов с непробитыми чеками за период'); + return $result; + } + + // Группировка и форматирование + $grouped = $this->groupOrdersByStore($orders); + $result->storesCount = count($grouped); + $result->totalAmount = $this->calculateTotalAmount($orders); + + $telegramMessage = $this->formatTelegramReport($grouped, $result); + $emailHtml = $this->formatEmailReport($grouped, $result); + + // Отправка с независимым retry + $result->telegramSent = $this->sendToTelegram($telegramMessage); + if (!$result->telegramSent) { + $result->telegramError = 'Не удалось отправить в Telegram после ' . self::MAX_RETRIES . ' попыток'; + } + + $result->emailSent = $this->sendToEmail($emailHtml); + if (!$result->emailSent) { + $result->emailError = 'Не удалось отправить Email после ' . self::MAX_RETRIES . ' попыток'; + } + + $this->logResult($result); + + } catch (\Exception $e) { + $this->logError('Критическая ошибка при формировании отчёта', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + throw $e; + } + + return $result; + } + + /** + * Получает заказы в статусе DELIVERED без пробитого чека + * за последние REPORT_PERIOD_HOURS часов + * + * @param string|null $targetDate Дата для отчёта + * @return array Массив заказов + */ + public function getUncheckedOrders(?string $targetDate = null): array + { + $periodHours = $this->config['period_hours'] ?? self::REPORT_PERIOD_HOURS; + $tz = new \DateTimeZone(self::TIMEZONE); + + // Определяем границу времени для выборки + if ($targetDate !== null) { + // Если указана дата, берём весь день + $startDate = new \DateTime($targetDate . ' 00:00:00', $tz); + $endDate = new \DateTime($targetDate . ' 23:59:59', $tz); + } else { + // Берём последние N часов от текущего момента + $endDate = new \DateTime('now', $tz); + $startDate = (clone $endDate)->modify("-{$periodHours} hours"); + } + + $startDateStr = $startDate->format('Y-m-d H:i:s'); + $endDateStr = $endDate->format('Y-m-d H:i:s'); + + $this->logInfo('Выборка заказов за период', [ + 'start' => $startDateStr, + 'end' => $endDateStr, + ]); + + // Основной запрос для выборки заказов с непробитыми чеками + // Используем joinWith для связи с таблицей статусов через модель + $query = MarketplaceOrders::find() + ->alias('mo') + ->joinWith(['status' => function($q) { + $q->alias('most_status'); + }], false, 'INNER JOIN') + ->joinWith(['substatus' => function($q) { + $q->alias('most_substatus'); + }], false, 'LEFT JOIN') + ->innerJoin( + ['mosh' => MarketplaceOrderStatusHistory::tableName()], + 'mo.id = mosh.order_id' + ) + ->leftJoin( + ['cc' => CreateChecks::tableName()], + 'mo.marketplace_order_id = cc.marketplace_order_id' + ) + ->where([ + 'or', + ['most_status.code' => MarketplaceOrderStatusTypes::DELIVERED_CODE], + ['most_substatus.code' => MarketplaceOrderStatusTypes::DELIVERY_SERVICE_DELIVERED_CODE], + ]) + ->andWhere(['>=', 'mosh.date_from', $startDateStr]) + ->andWhere(['<=', 'mosh.date_from', $endDateStr]) + ->andWhere(['mosh.active' => 1]) + ->andWhere(['mo.fake' => 0]) // Исключаем тестовые заказы + ->andWhere([ + 'or', + ['mo.check_guid' => null], + ['cc.status' => null], // Нет записи в create_checks + ['cc.status' => CreateChecks::STATUS_CHECK_CREATED_ERP], // Создан в ERP, не отправлен + ['cc.status' => CreateChecks::STATUS_CHECK_ERROR_1C], // Ошибка в 1С + ]) + ->with(['store']) + ->orderBy(['mo.store_id' => SORT_ASC, 'mo.marketplace_name' => SORT_ASC, 'mo.creation_date' => SORT_DESC]) + ->distinct(); + + $orders = $query->all(); + + $this->logInfo('Найдено заказов', ['count' => count($orders)]); + + return $orders; + } + + /** + * Группирует заказы по магазинам + * + * @param MarketplaceOrders[] $orders + * @return array + */ + public function groupOrdersByStore(array $orders): array + { + $grouped = []; + + foreach ($orders as $order) { + $storeId = $order->store_id ?? 0; + + if (!isset($grouped[$storeId])) { + $grouped[$storeId] = [ + 'store' => $order->store, + 'store_name' => $order->store->name ?? 'Магазин не указан', + 'orders' => [], + ]; + } + + $grouped[$storeId]['orders'][] = $order; + } + + // Сортируем по имени магазина + uasort($grouped, function ($a, $b) { + return strcmp($a['store_name'], $b['store_name']); + }); + + return $grouped; + } + + /** + * Вычисляет общую сумму заказов + * + * @param MarketplaceOrders[] $orders + * @return float + */ + private function calculateTotalAmount(array $orders): float + { + $total = 0.0; + foreach ($orders as $order) { + $total += (float)$order->total; + } + return $total; + } + + /** + * Формирует HTML-таблицу для email + * + * @param array $groupedOrders Сгруппированные заказы + * @param ReportResult $result Результат для итогов + * @return string HTML-контент + */ + public function formatEmailReport(array $groupedOrders, ReportResult $result): string + { + $html = ' + + + + + + +

Отчёт о заказах с непробитыми чеками

+

Дата: ' . $this->escapeHtml($result->reportDate) . ' MSK

+

Период: последние ' . self::REPORT_PERIOD_HOURS . ' часов

'; + + foreach ($groupedOrders as $storeData) { + $storeName = $this->escapeHtml($storeData['store_name']); + $html .= ' +

Магазин: ' . $storeName . '

+ + + + + + + + '; + + foreach ($storeData['orders'] as $order) { + /** @var MarketplaceOrders $order */ + $checkStatus = $this->getCheckStatusText($order); + $statusClass = $this->getCheckStatusClass($order); + $deliveryDate = $this->formatDeliveryDate($order); + $total = number_format((float)$order->total, 0, '.', ' ') . ' руб.'; + + $html .= ' + + + + + + + '; + } + + $html .= ' +
№ заказаМаркетплейсДата доставкиСуммаСтатус чека
' . $this->escapeHtml($order->marketplace_order_id) . '' . $this->escapeHtml($order->marketplace_name ?? 'Неизвестно') . '' . $this->escapeHtml($deliveryDate) . '' . $total . '' . $this->escapeHtml($checkStatus) . '
'; + } + + $totalAmount = number_format($result->totalAmount, 0, '.', ' '); + $html .= ' +

Итого: ' . $result->totalOrders . ' заказ(ов) в ' . $result->storesCount . ' магазин(ах) на сумму ' . $totalAmount . ' руб.

+ +'; + + return $html; + } + + /** + * Формирует текстовый отчёт для Telegram в формате MarkdownV2 + * + * @param array $groupedOrders Сгруппированные заказы + * @param ReportResult $result Результат для итогов + * @return string Текст сообщения (MarkdownV2) + */ + public function formatTelegramReport(array $groupedOrders, ReportResult $result): string + { + $lines = []; + $lines[] = $this->escapeMarkdownV2('🔴') . ' *Заказы с непробитыми чеками*'; + $lines[] = ''; + $lines[] = $this->escapeMarkdownV2('📅 ' . $result->reportDate . ' MSK'); + $lines[] = $this->escapeMarkdownV2('⏰ Период: ' . self::REPORT_PERIOD_HOURS . ' часов'); + $lines[] = ''; + + foreach ($groupedOrders as $storeData) { + $storeName = $storeData['store_name']; + $lines[] = $this->escapeMarkdownV2('📍') . ' *' . $this->escapeMarkdownV2($storeName) . '*'; + + foreach ($storeData['orders'] as $order) { + /** @var MarketplaceOrders $order */ + $checkStatusShort = $this->getCheckStatusShort($order); + $mpName = $this->getMarketplaceShortName($order->marketplace_name); + $total = number_format((float)$order->total, 0, '.', ' '); + + $line = '• ' . $order->marketplace_order_id . ' | ' . $mpName . ' | ' . $total . ' ₽ | ' . $checkStatusShort; + $lines[] = $this->escapeMarkdownV2($line); + } + $lines[] = ''; + } + + $totalAmount = number_format($result->totalAmount, 0, '.', ' '); + $lines[] = $this->escapeMarkdownV2('━━━━━━━━━━━'); + $lines[] = $this->escapeMarkdownV2('📊 Итого: ' . $result->totalOrders . ' заказ(ов) | ' . $result->storesCount . ' магазин(а) | ' . $totalAmount . ' ₽'); + + return implode("\n", $lines); + } + + /** + * Экранирует специальные символы для MarkdownV2 + * Символы: _ * [ ] ( ) ~ ` > # + - = | { } . ! + * + * @param string $text Исходный текст + * @return string Экранированный текст + */ + private function escapeMarkdownV2(string $text): string + { + $specialChars = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!']; + foreach ($specialChars as $char) { + $text = str_replace($char, '\\' . $char, $text); + } + return $text; + } + + /** + * Экранирует HTML-сущности + * + * @param string $text + * @return string + */ + private function escapeHtml(string $text): string + { + return htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } + + /** + * Разбивает длинное сообщение на части для Telegram + * + * @param string $message Полное сообщение + * @return array Массив частей сообщения + */ + private function splitTelegramMessage(string $message): array + { + $maxLength = $this->config['telegram_max_message_length'] ?? self::TELEGRAM_MAX_LENGTH; + + if (mb_strlen($message) <= $maxLength) { + return [$message]; + } + + $chunks = []; + $lines = explode("\n", $message); + $currentChunk = ''; + + foreach ($lines as $line) { + if (mb_strlen($currentChunk . "\n" . $line) > $maxLength) { + if ($currentChunk !== '') { + $chunks[] = $currentChunk; + } + $currentChunk = $line; + } else { + $currentChunk .= ($currentChunk !== '' ? "\n" : '') . $line; + } + } + + if ($currentChunk !== '') { + $chunks[] = $currentChunk; + } + + return $chunks; + } + + /** + * Отправляет отчёт в Telegram с retry-логикой + * + * @param string $message Текст сообщения (MarkdownV2) + * @return bool Успешность отправки + */ + public function sendToTelegram(string $message): bool + { + $chatId = $this->getTelegramChatId(); + + if (empty($chatId)) { + $this->logWarning('Telegram chat_id не настроен'); + return false; + } + + // Валидация chat_id (должен начинаться с - для каналов/групп или быть числом) + if (!preg_match('/^-?\d+$/', $chatId)) { + $this->logError('Некорректный формат chat_id: ' . $chatId); + return false; + } + + $chunks = $this->splitTelegramMessage($message); + $allSent = true; + + 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; + + for ($attempt = 1; $attempt <= $maxRetries; $attempt++) { + try { + $sent = $this->sendTelegramMessage($chatId, $chunk); + if ($sent) { + break; + } + } catch (\Exception $e) { + $this->logWarning("Telegram попытка {$attempt}/{$maxRetries}: {$e->getMessage()}"); + } + + if ($attempt < $maxRetries) { + sleep($retryDelay); + } + } + + if (!$sent) { + $allSent = false; + $this->logError("Не удалось отправить часть " . ($index + 1) . " в Telegram после {$maxRetries} попыток"); + } + } + + return $allSent; + } + + /** + * Отправляет сообщение в Telegram + * + * @param string $chatId ID чата/канала + * @param string $message Текст сообщения + * @return bool Успешность + */ + private function sendTelegramMessage(string $chatId, string $message): bool + { + $botToken = $this->getTelegramBotToken(); + $url = "https://api.telegram.org/bot{$botToken}/sendMessage"; + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => [ + 'chat_id' => $chatId, + 'text' => $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) { + $this->logError("Telegram cURL error: {$curlError}"); + return false; + } + + if ($httpCode !== 200) { + $this->logError("Telegram API error: HTTP {$httpCode}, response: {$response}"); + return false; + } + + $this->logInfo('Сообщение отправлено в Telegram', ['chat_id' => $chatId]); + return true; + } + + /** + * Отправляет отчёт на email с retry-логикой + * + * @param string $html HTML-контент письма + * @return bool Успешность отправки + */ + public function sendToEmail(string $html): bool + { + $recipients = $this->getEmailRecipients(); + + if (empty($recipients)) { + $this->logWarning('Email-получатели не настроены'); + return false; + } + + // Валидация email-адресов + $validRecipients = []; + foreach ($recipients as $email) { + $email = trim($email); + if (filter_var($email, FILTER_VALIDATE_EMAIL) && + preg_match('/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/', $email)) { + $validRecipients[] = $email; + } else { + $this->logWarning("Некорректный email пропущен: {$email}"); + } + } + + if (empty($validRecipients)) { + $this->logError('Нет валидных email-адресов'); + return false; + } + + $sent = false; + $maxRetries = $this->config['max_retries'] ?? self::MAX_RETRIES; + $retryDelay = $this->config['retry_delay_seconds'] ?? self::RETRY_DELAY_SECONDS; + $subject = $this->config['email_subject'] ?? 'Отчёт о заказах с непробитыми чеками'; + + for ($attempt = 1; $attempt <= $maxRetries; $attempt++) { + try { + $sent = Yii::$app->mailer->compose() + ->setTo($validRecipients) + ->setSubject($subject) + ->setHtmlBody($html) + ->send(); + + if ($sent) { + $this->logInfo('Email отправлен на: ' . implode(', ', $validRecipients)); + break; + } + } catch (\Exception $e) { + $this->logWarning("Email попытка {$attempt}/{$maxRetries}: {$e->getMessage()}"); + } + + if ($attempt < $maxRetries) { + sleep($retryDelay); + } + } + + if (!$sent) { + $this->logError('Не удалось отправить email после ' . $maxRetries . ' попыток'); + } + + return $sent; + } + + /** + * Определяет, является ли окружение development + * + * @return bool + */ + private function isDevEnvironment(): bool + { + return TelegramService::isDevEnv(); + } + + /** + * Получает токен Telegram-бота в зависимости от окружения + * + * @return string + */ + private function getTelegramBotToken(): string + { + if ($this->isDevEnvironment()) { + return getenv('TELEGRAM_BOT_TOKEN_DEV') ?: self::TELEGRAM_BOT_DEV; + } + return getenv('TELEGRAM_BOT_TOKEN_PROD') ?: self::TELEGRAM_BOT_PROD; + } + + /** + * Получает ID чата Telegram в зависимости от окружения + * + * @return string + */ + private function getTelegramChatId(): string + { + if ($this->isDevEnvironment()) { + return $this->config['telegram_chat_id_dev'] + ?? getenv('TELEGRAM_UNCHECKED_ORDERS_CHAT_ID_DEV') + ?: TelegramService::CHAT_CHANNEL_ID; + } + return $this->config['telegram_chat_id_prod'] + ?? getenv('TELEGRAM_UNCHECKED_ORDERS_CHAT_ID_PROD') + ?: ''; + } + + /** + * Получает список email-получателей + * + * @return array + */ + private function getEmailRecipients(): array + { + $recipients = $this->config['email_recipients'] ?? []; + + if (empty($recipients)) { + $envRecipients = getenv('UNCHECKED_ORDERS_EMAIL_RECIPIENTS'); + if ($envRecipients) { + $recipients = array_filter(explode(',', $envRecipients)); + } + } + + return $recipients; + } + + /** + * Получает текстовое описание статуса чека + * + * @param MarketplaceOrders $order + * @return string + */ + private function getCheckStatusText(MarketplaceOrders $order): string + { + if ($order->check_guid === null) { + // Проверяем наличие записи в create_checks + $check = CreateChecks::findOne(['marketplace_order_id' => $order->marketplace_order_id]); + + if ($check === null) { + return 'Не создан'; + } + + switch ($check->status) { + case CreateChecks::STATUS_CHECK_CREATED_ERP: + return 'Ожидает отправки в 1С'; + case CreateChecks::STATUS_CHECK_ERROR_1C: + $errorText = $check->error_text ? ': ' . $check->error_text : ''; + return 'Ошибка 1С' . $errorText; + default: + return 'Статус неизвестен'; + } + } + + return 'Чек создан'; + } + + /** + * Получает короткое описание статуса чека для Telegram + * + * @param MarketplaceOrders $order + * @return string + */ + private function getCheckStatusShort(MarketplaceOrders $order): string + { + if ($order->check_guid === null) { + $check = CreateChecks::findOne(['marketplace_order_id' => $order->marketplace_order_id]); + + if ($check === null) { + return 'Не создан'; + } + + switch ($check->status) { + case CreateChecks::STATUS_CHECK_CREATED_ERP: + return 'Ожидает'; + case CreateChecks::STATUS_CHECK_ERROR_1C: + return '❌ Ошибка 1С'; + default: + return '?'; + } + } + + return '✅'; + } + + /** + * Получает CSS-класс для статуса чека + * + * @param MarketplaceOrders $order + * @return string + */ + private function getCheckStatusClass(MarketplaceOrders $order): string + { + if ($order->check_guid !== null) { + return ''; + } + + $check = CreateChecks::findOne(['marketplace_order_id' => $order->marketplace_order_id]); + + if ($check !== null && $check->status === CreateChecks::STATUS_CHECK_ERROR_1C) { + return 'error'; + } + + if ($check !== null && $check->status === CreateChecks::STATUS_CHECK_CREATED_ERP) { + return 'warning'; + } + + return ''; + } + + /** + * Форматирует дату доставки + * + * @param MarketplaceOrders $order + * @return string + */ + private function formatDeliveryDate(MarketplaceOrders $order): string + { + // Ищем дату из истории статусов + $history = MarketplaceOrderStatusHistory::find() + ->where(['order_id' => $order->id]) + ->andWhere(['active' => 1]) + ->orderBy(['date_from' => SORT_DESC]) + ->one(); + + if ($history && $history->date_from) { + try { + $date = new \DateTime($history->date_from); + return $date->format('d.m.Y H:i'); + } catch (\Exception $e) { + // Игнорируем ошибку парсинга даты + } + } + + // Используем дату обновления заказа + if ($order->updated_at) { + try { + $date = new \DateTime($order->updated_at); + return $date->format('d.m.Y H:i'); + } catch (\Exception $e) { + // Игнорируем ошибку парсинга даты + } + } + + return '-'; + } + + /** + * Получает короткое название маркетплейса + * + * @param string|null $name + * @return string + */ + private function getMarketplaceShortName(?string $name): string + { + if ($name === null) { + return '?'; + } + + $name = mb_strtolower($name); + + if (str_contains($name, 'flowwow') || str_contains($name, 'флаувау')) { + return 'Flowwow'; + } + + if (str_contains($name, 'yandex') || str_contains($name, 'яндекс')) { + return 'Yandex'; + } + + return $name; + } + + /** + * Логирование в структурированном JSON-формате + * + * @param string $message + * @param array $context + */ + private function logInfo(string $message, array $context = []): void + { + Yii::info(json_encode([ + 'message' => $message, + 'context' => $context, + 'timestamp' => date('c'), + 'env' => YII_ENV, + ], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_IGNORE), 'marketplace-checks'); + } + + /** + * @param string $message + * @param array $context + */ + private function logWarning(string $message, array $context = []): void + { + Yii::warning(json_encode([ + 'message' => $message, + 'context' => $context, + 'timestamp' => date('c'), + 'env' => YII_ENV, + ], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_IGNORE), 'marketplace-checks'); + } + + /** + * @param string $message + * @param array $context + */ + private function logError(string $message, array $context = []): void + { + Yii::error(json_encode([ + 'message' => $message, + 'context' => $context, + 'timestamp' => date('c'), + 'env' => YII_ENV, + ], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_IGNORE), 'marketplace-checks'); + } + + /** + * Логирует результат выполнения отчёта + * + * @param ReportResult $result + */ + private function logResult(ReportResult $result): void + { + $this->logInfo('Отчёт завершён', $result->toArray()); + } +} diff --git a/erp24/services/dto/ReportResult.php b/erp24/services/dto/ReportResult.php new file mode 100644 index 00000000..88763637 --- /dev/null +++ b/erp24/services/dto/ReportResult.php @@ -0,0 +1,130 @@ +telegramSent && $this->emailSent; + } + + /** + * Проверяет частичный успех (хотя бы один канал отправлен) + * + * @return bool true если отправлен только один из каналов + */ + public function isPartialSuccess(): bool + { + return ($this->telegramSent || $this->emailSent) && !$this->isSuccess(); + } + + /** + * Проверяет наличие заказов + * + * @return bool true если есть непробитые заказы + */ + public function hasOrders(): bool + { + return $this->totalOrders > 0; + } + + /** + * Возвращает exit code для консольной команды + * + * @return int 0 = успех, 1 = критическая ошибка, 2 = частичный успех + */ + public function getExitCode(): int + { + // Нет заказов - это успех + if (!$this->hasOrders()) { + return 0; + } + + // Оба канала отправлены + if ($this->isSuccess()) { + return 0; + } + + // Хотя бы один канал отправлен + if ($this->isPartialSuccess()) { + return 2; + } + + // Ничего не отправлено + return 1; + } + + /** + * Преобразует результат в массив для логирования + * + * @return array + */ + public function toArray(): array + { + return [ + 'total_orders' => $this->totalOrders, + 'stores_count' => $this->storesCount, + 'total_amount' => $this->totalAmount, + 'telegram_sent' => $this->telegramSent, + 'email_sent' => $this->emailSent, + 'telegram_error' => $this->telegramError, + 'email_error' => $this->emailError, + 'report_date' => $this->reportDate, + 'exit_code' => $this->getExitCode(), + ]; + } +} -- 2.39.5