--- /dev/null
+<?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());
+ }
+}