]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
Отправка отчета по спецификации
authorVladimir Fomichev <vladimir.fomichev@erp-flowers.ru>
Fri, 16 Jan 2026 15:15:42 +0000 (18:15 +0300)
committerVladimir Fomichev <vladimir.fomichev@erp-flowers.ru>
Fri, 16 Jan 2026 15:15:42 +0000 (18:15 +0300)
erp24/commands/MarketplaceController.php
erp24/config/params.php
erp24/services/MarketplaceService.php
erp24/services/UncheckedOrdersReportService.php [new file with mode: 0644]
erp24/services/dto/ReportResult.php [new file with mode: 0644]

index f3d2d606b43f7382999f3f793c0d5d6ffc6c4173..009804e8a9072f148637d537b30b42c666549715 100644 (file)
@@ -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;
+        }
+    }
 }
index 8c95f87b138a0efebdc15bfd8dec45b9b2de33b3..dbcd544ddb3609d973cfef750e77a0f8c4eaabaf 100644 (file)
@@ -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' => 'Отчёт о заказах с непробитыми чеками',
+    ],
 ];
index ee04ec6fc2de7c061c139ebc33c7208f90bb4898..1a96c2b213273a2b70adacf4262a2eb2e04b7cea 100644 (file)
@@ -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 (file)
index 0000000..6adfb53
--- /dev/null
@@ -0,0 +1,849 @@
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\services;
+
+use Yii;
+use yii_app\records\MarketplaceOrders;
+use yii_app\records\MarketplaceOrderStatusHistory;
+use yii_app\records\MarketplaceOrderStatusTypes;
+use yii_app\records\CreateChecks;
+use yii_app\records\CityStore;
+use yii_app\services\dto\ReportResult;
+
+/**
+ * Сервис формирования и отправки отчёта о заказах с непробитыми чеками
+ *
+ * Выбирает заказы маркетплейсов в статусе DELIVERED/DELIVERY_SERVICE_DELIVERED
+ * за последние 12 часов, у которых отсутствует пробитый чек, и отправляет
+ * отчёт в Telegram и на Email.
+ *
+ * Критерии "непробитого" чека:
+ * 1. check_guid IS NULL в marketplace_orders
+ * 2. Нет записи в create_checks (cc.status IS NULL)
+ * 3. cc.status = 0 (STATUS_CHECK_CREATED_ERP) - создан в ERP, не отправлен
+ * 4. cc.status = 8 (STATUS_CHECK_ERROR_1C) - ошибка при создании в 1С
+ */
+class UncheckedOrdersReportService
+{
+    /**
+     * Период выборки заказов в часах
+     */
+    public const REPORT_PERIOD_HOURS = 12;
+
+    /**
+     * Максимальное количество попыток отправки
+     */
+    public const MAX_RETRIES = 3;
+
+    /**
+     * Задержка между попытками в секундах
+     */
+    public const RETRY_DELAY_SECONDS = 5;
+
+    /**
+     * Максимальная длина сообщения Telegram
+     */
+    public const TELEGRAM_MAX_LENGTH = 4000;
+
+    /**
+     * Часовой пояс для отчёта
+     */
+    public const TIMEZONE = 'Europe/Moscow';
+
+    /**
+     * Telegram bot token для development
+     */
+    private const TELEGRAM_BOT_DEV = '8063257458:AAGnMf4cxwJWlYLF1wS_arn4PrOaLs9ERQQ';
+
+    /**
+     * Telegram bot token для production
+     */
+    private const TELEGRAM_BOT_PROD = '5456741805:AAG7xOSiYDwUdV5NMb2v9vh8CWzEczDP4yU';
+
+    /**
+     * Конфигурация отчёта
+     */
+    private array $config;
+
+    public function __construct()
+    {
+        $this->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<int, array{store: CityStore|null, orders: MarketplaceOrders[], store_name: string}>
+     */
+    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 = '<!DOCTYPE html>
+<html>
+<head>
+    <meta charset="UTF-8">
+    <style>
+        body { font-family: Arial, sans-serif; }
+        table { border-collapse: collapse; width: 100%; margin-bottom: 20px; }
+        th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
+        th { background-color: #f4f4f4; }
+        .store-header { background-color: #e8f4e8; font-weight: bold; padding: 10px; margin-top: 15px; }
+        .total { font-weight: bold; margin-top: 20px; font-size: 14px; }
+        .error { color: #c00; font-size: 0.9em; }
+        .warning { color: #f90; }
+        h2 { color: #333; }
+        p { margin: 5px 0; }
+    </style>
+</head>
+<body>
+    <h2>Отчёт о заказах с непробитыми чеками</h2>
+    <p>Дата: ' . $this->escapeHtml($result->reportDate) . ' MSK</p>
+    <p>Период: последние ' . self::REPORT_PERIOD_HOURS . ' часов</p>';
+
+        foreach ($groupedOrders as $storeData) {
+            $storeName = $this->escapeHtml($storeData['store_name']);
+            $html .= '
+    <h3 class="store-header">Магазин: ' . $storeName . '</h3>
+    <table>
+        <tr>
+            <th>№ заказа</th>
+            <th>Маркетплейс</th>
+            <th>Дата доставки</th>
+            <th>Сумма</th>
+            <th>Статус чека</th>
+        </tr>';
+
+            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 .= '
+        <tr>
+            <td>' . $this->escapeHtml($order->marketplace_order_id) . '</td>
+            <td>' . $this->escapeHtml($order->marketplace_name ?? 'Неизвестно') . '</td>
+            <td>' . $this->escapeHtml($deliveryDate) . '</td>
+            <td>' . $total . '</td>
+            <td class="' . $statusClass . '">' . $this->escapeHtml($checkStatus) . '</td>
+        </tr>';
+            }
+
+            $html .= '
+    </table>';
+        }
+
+        $totalAmount = number_format($result->totalAmount, 0, '.', ' ');
+        $html .= '
+    <p class="total">Итого: ' . $result->totalOrders . ' заказ(ов) в ' . $result->storesCount . ' магазин(ах) на сумму ' . $totalAmount . ' руб.</p>
+</body>
+</html>';
+
+        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<string> Массив частей сообщения
+     */
+    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 (file)
index 0000000..8876363
--- /dev/null
@@ -0,0 +1,130 @@
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\services\dto;
+
+/**
+ * DTO для результата генерации и отправки отчёта о непробитых чеках
+ *
+ * Содержит информацию о количестве найденных заказов, магазинов,
+ * статусе отправки в различные каналы (Telegram, Email) и ошибках.
+ */
+class ReportResult
+{
+    /**
+     * Общее количество заказов с непробитыми чеками
+     */
+    public int $totalOrders = 0;
+
+    /**
+     * Количество магазинов с непробитыми заказами
+     */
+    public int $storesCount = 0;
+
+    /**
+     * Успешность отправки в Telegram
+     */
+    public bool $telegramSent = false;
+
+    /**
+     * Успешность отправки на Email
+     */
+    public bool $emailSent = false;
+
+    /**
+     * Текст ошибки при отправке в Telegram (если была)
+     */
+    public ?string $telegramError = null;
+
+    /**
+     * Текст ошибки при отправке на Email (если была)
+     */
+    public ?string $emailError = null;
+
+    /**
+     * Дата и время формирования отчёта
+     */
+    public string $reportDate = '';
+
+    /**
+     * Общая сумма непробитых заказов
+     */
+    public float $totalAmount = 0.0;
+
+    /**
+     * Проверяет полный успех (оба канала отправлены)
+     *
+     * @return bool true если отчёт отправлен и в Telegram, и на Email
+     */
+    public function isSuccess(): bool
+    {
+        return $this->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(),
+        ];
+    }
+}