]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
Правки и тесты
authorVladimir Fomichev <vladimir.fomichev@erp-flowers.ru>
Tue, 20 Jan 2026 08:47:25 +0000 (11:47 +0300)
committerVladimir Fomichev <vladimir.fomichev@erp-flowers.ru>
Tue, 20 Jan 2026 08:47:25 +0000 (11:47 +0300)
erp24/commands/MarketplaceController.php
erp24/records/MarketplaceOrderDailyIssues.php
erp24/services/OrderControlReportService.php
erp24/services/dto/ControlReportResult.php
erp24/services/dto/OrderIssue.php
erp24/tests/unit/records/MarketplaceOrderDailyIssuesTest.php [new file with mode: 0644]
erp24/tests/unit/services/OrderControlReportServiceTest.php [new file with mode: 0644]
erp24/tests/unit/services/dto/ControlReportResultTest.php [new file with mode: 0644]
erp24/tests/unit/services/dto/OrderIssueTest.php [new file with mode: 0644]

index fed78608fc3d8d39c42e78c8eb3369c652ba78eb..90e9b9c89f85a1c39d81c067413cceea43868844 100644 (file)
@@ -52,6 +52,11 @@ class MarketplaceController extends Controller
      */
     public $unseen;
 
+    /**
+     * @var bool Тестовый режим: endDate = текущее время (для проверки заказов в середине смены)
+     */
+    public $test = false;
+
     public function actionYandex() {
         $infoForMarketplace = MarketplaceService::infoForMarketplace(MarketplaceStore::YANDEX_WAREHOUSE_ID);
 
@@ -176,6 +181,7 @@ class MarketplaceController extends Controller
         $options[] = 'oldMail';
         $options[] = 'seen';
         $options[] = 'unseen';
+        $options[] = 'test';
 
         return $options;
     }
@@ -368,9 +374,17 @@ class MarketplaceController extends Controller
      * 1. "Завис в доставке" - РМК="Передан курьеру", МП НЕ "Выполнен"
      * 2. "Успех без чека" - МП="Выполнен", РМК НЕ "Успех"
      * 3. "Отмена без обработки" - МП="Отменён", РМК НЕ "Отказ"
+     * 4. "Успех без данных" - МП="Выполнен", РМК="Успех", но нет seller_id и/или check_guid
      *
      * Запуск по расписанию: 08:00 и 20:00 MSK (каждые 12 часов)
-     * Команда: php yii marketplace/send-order-control-report
+     *
+     * Команды:
+     * - Стандартный режим: php yii marketplace/send-order-control-report
+     * - Тестовый режим:    php yii marketplace/send-order-control-report --test
+     *
+     * Тестовый режим (--test):
+     * - endDate = текущее время (вместо фиксированного 08:00/20:00)
+     * - Полезно для проверки заказов в середине смены
      *
      * @param int $hours Период выборки в часах (по умолчанию 12 — соответствует расписанию)
      * @param bool $onlyNew Отправлять только новые проблемы (по умолчанию true)
@@ -380,18 +394,23 @@ class MarketplaceController extends Controller
     {
         set_time_limit(300); // 5 минут максимум
 
+        $testMode = (bool)$this->test;
+
         $this->stdout("Запуск отчёта контроля статусов заказов МП...\n", BaseConsole::FG_YELLOW);
         $this->stdout("Период: {$hours} часов\n", BaseConsole::FG_CYAN);
         $this->stdout("Только новые: " . ($onlyNew ? 'да' : 'нет') . "\n", BaseConsole::FG_CYAN);
+        if ($testMode) {
+            $this->stdout("Режим: ТЕСТОВЫЙ (endDate = текущее время)\n", BaseConsole::FG_YELLOW);
+        }
 
         try {
             $service = new OrderControlReportService();
-            $result = $service->generateControlReport($hours, $onlyNew);
+            $result = $service->generateControlReport($hours, $onlyNew, $testMode);
 
             // Вывод результатов
             $this->stdout("\n--- Результаты контроля статусов ---\n", BaseConsole::FG_YELLOW);
             $this->stdout("Дата отчёта: {$result->reportDate}\n", BaseConsole::FG_CYAN);
-            $this->stdout("Ð\98нÑ\82еÑ\80вал: {$result->interval}\n", BaseConsole::FG_CYAN);
+            $this->stdout("Ð\9fеÑ\80иод: {$result->getFormattedDateRange()}\n", BaseConsole::FG_CYAN);
 
             $this->stdout("\nПроблемы по типам:\n", BaseConsole::FG_YELLOW);
             $this->stdout("  🚚 Завис в доставке: {$result->getHungInDeliveryCount()}\n",
@@ -400,6 +419,8 @@ class MarketplaceController extends Controller
                 $result->getSuccessNoCheckCount() > 0 ? BaseConsole::FG_RED : BaseConsole::FG_GREEN);
             $this->stdout("  🚫 Отмена без обработки: {$result->getCancelNoProcessCount()}\n",
                 $result->getCancelNoProcessCount() > 0 ? BaseConsole::FG_RED : BaseConsole::FG_GREEN);
+            $this->stdout("  ⚠️ Успех без данных: {$result->getSuccessMissingDataCount()}\n",
+                $result->getSuccessMissingDataCount() > 0 ? BaseConsole::FG_RED : BaseConsole::FG_GREEN);
 
             $this->stdout("\nВсего проблем: {$result->totalIssues}\n",
                 $result->totalIssues > 0 ? BaseConsole::FG_RED : BaseConsole::FG_GREEN);
index 65f0a0b8274cfe3807bcb104a53b0cdd094542a5..e6e6c81828baf2cd895f78e5338458ac37c8c976 100644 (file)
@@ -50,6 +50,7 @@ class MarketplaceOrderDailyIssues extends \yii\db\ActiveRecord
     public const TYPE_HUNG_IN_DELIVERY = OrderIssue::TYPE_HUNG_IN_DELIVERY;
     public const TYPE_SUCCESS_NO_CHECK = OrderIssue::TYPE_SUCCESS_NO_CHECK;
     public const TYPE_CANCEL_NO_PROCESS = OrderIssue::TYPE_CANCEL_NO_PROCESS;
+    public const TYPE_SUCCESS_MISSING_DATA = OrderIssue::TYPE_SUCCESS_MISSING_DATA;
 
     /**
      * Метки типов проблем
@@ -100,6 +101,7 @@ class MarketplaceOrderDailyIssues extends \yii\db\ActiveRecord
                 self::TYPE_HUNG_IN_DELIVERY,
                 self::TYPE_SUCCESS_NO_CHECK,
                 self::TYPE_CANCEL_NO_PROCESS,
+                self::TYPE_SUCCESS_MISSING_DATA,
             ]],
             [['interval'], 'in', 'range' => ['08:00', '20:00']],
             [['order_id', 'problem_type', 'report_date', 'interval'], 'unique', 'targetAttribute' => ['order_id', 'problem_type', 'report_date', 'interval']],
index 8e8ce0481015b66f63d9c933f9ea648458b97148..9230b915c455ca07cb779291cc11da21d14b7d54 100644 (file)
@@ -64,6 +64,11 @@ class OrderControlReportService
      */
     private array $config;
 
+    /**
+     * Режим тестирования: endDate = текущее время вместо фиксированного времени смены
+     */
+    private bool $testMode = false;
+
     /**
      * Кеш ID статусов 1С (ленивая загрузка)
      */
@@ -120,15 +125,27 @@ class OrderControlReportService
      *
      * @param int $hoursAgo Период выборки в часах
      * @param bool $onlyNew Отправлять только новые проблемы
+     * @param bool $testMode Тестовый режим: endDate = текущее время (для проверки заказов в середине смены)
      * @return ControlReportResult
      */
-    public function generateControlReport(int $hoursAgo = 12, bool $onlyNew = true): ControlReportResult
+    public function generateControlReport(int $hoursAgo = 12, bool $onlyNew = true, bool $testMode = false): ControlReportResult
     {
         $result = new ControlReportResult();
 
+        // Сохраняем режим тестирования для использования во внутренних методах
+        $this->testMode = $testMode;
+
+        // Получаем диапазон дат для отображения в отчёте
+        $dateRange = $this->getShiftBasedDateRange($hoursAgo, $testMode);
+        $result->startDate = $dateRange['startDate'];
+        $result->endDate = $dateRange['endDate'];
+        $result->shiftName = $dateRange['shiftName'];
+
         $this->logInfo('Запуск отчёта контроля статусов МП', [
             'hours_ago' => $hoursAgo,
             'only_new' => $onlyNew,
+            'test_mode' => $testMode,
+            'date_range' => $dateRange,
         ]);
 
         try {
@@ -142,23 +159,27 @@ class OrderControlReportService
             // 3. Получаем остальные типы проблем
             $successNoCheck = $this->getSuccessNoCheckOrders($hoursAgo);
             $cancelNoProcess = $this->getCancelNoProcessOrders($hoursAgo);
+            $successMissingData = $this->getSuccessMissingDataOrders($hoursAgo);
 
             // 4. Фильтруем только новые для остальных типов, если требуется
             if ($onlyNew) {
                 $prevSuccess = $this->loadPreviousIssues(OrderIssue::TYPE_SUCCESS_NO_CHECK);
                 $prevCancel = $this->loadPreviousIssues(OrderIssue::TYPE_CANCEL_NO_PROCESS);
+                $prevMissingData = $this->loadPreviousIssues(OrderIssue::TYPE_SUCCESS_MISSING_DATA);
 
                 $successNoCheck = $this->filterNewIssues($successNoCheck, $prevSuccess);
                 $cancelNoProcess = $this->filterNewIssues($cancelNoProcess, $prevCancel);
+                $successMissingData = $this->filterNewIssues($successMissingData, $prevMissingData);
             }
 
             $result->hungInDelivery = $hungInDelivery;
             $result->successNoCheck = $successNoCheck;
             $result->cancelNoProcess = $cancelNoProcess;
+            $result->successMissingData = $successMissingData;
             $result->calculateTotal();
 
             // 5. Сохраняем состояние проблем (кроме кандидатов hung_in_delivery, они уже сохранены)
-            $issuesToSave = array_merge($hungInDelivery, $successNoCheck, $cancelNoProcess);
+            $issuesToSave = array_merge($hungInDelivery, $successNoCheck, $cancelNoProcess, $successMissingData);
             $result->statesSaved = $this->saveControlIssues($issuesToSave);
 
             // 6. Отправляем уведомления только если есть проблемы
@@ -185,6 +206,10 @@ class OrderControlReportService
                 $this->logInfo('Нет проблемных заказов, уведомления не требуются');
             }
 
+            // 7. Очистка старых записей (старше 1 месяца)
+            $deletedCount = $this->cleanupOldIssues();
+            $this->logInfo('Очистка старых записей завершена', ['deleted_count' => $deletedCount]);
+
             $this->logInfo('Отчёт контроля статусов завершён', $result->toArray());
 
         } catch (\Exception $e) {
@@ -213,10 +238,10 @@ class OrderControlReportService
      */
     public function getHungInDeliveryCandidates(int $hoursAgo = 24): array
     {
-        $this->logInfo('Выборка кандидатов "Завис в доставке"', ['hours_ago' => $hoursAgo]);
+        $this->logInfo('Выборка кандидатов "Завис в доставке"', ['hours_ago' => $hoursAgo, 'test_mode' => $this->testMode]);
 
         // Получаем диапазон дат на основе конца смены
-        $dateRange = $this->getShiftBasedDateRange($hoursAgo);
+        $dateRange = $this->getShiftBasedDateRange($hoursAgo, $this->testMode);
         $startDateStr = $dateRange['startDate'];
         $endDateStr = $dateRange['endDate'];
 
@@ -405,10 +430,10 @@ class OrderControlReportService
      */
     public function getSuccessNoCheckOrders(int $hoursAgo = 12): array
     {
-        $this->logInfo('Выборка заказов "Успех без чека"', ['hours_ago' => $hoursAgo]);
+        $this->logInfo('Выборка заказов "Успех без чека"', ['hours_ago' => $hoursAgo, 'test_mode' => $this->testMode]);
 
         // Получаем диапазон дат на основе конца смены
-        $dateRange = $this->getShiftBasedDateRange($hoursAgo);
+        $dateRange = $this->getShiftBasedDateRange($hoursAgo, $this->testMode);
         $startDateStr = $dateRange['startDate'];
         $endDateStr = $dateRange['endDate'];
 
@@ -525,10 +550,10 @@ class OrderControlReportService
      */
     public function getCancelNoProcessOrders(int $hoursAgo = 24): array
     {
-        $this->logInfo('Выборка заказов "Отмена без обработки"', ['hours_ago' => $hoursAgo]);
+        $this->logInfo('Выборка заказов "Отмена без обработки"', ['hours_ago' => $hoursAgo, 'test_mode' => $this->testMode]);
 
         // Получаем диапазон дат на основе конца смены
-        $dateRange = $this->getShiftBasedDateRange($hoursAgo);
+        $dateRange = $this->getShiftBasedDateRange($hoursAgo, $this->testMode);
         $startDateStr = $dateRange['startDate'];
         $endDateStr = $dateRange['endDate'];
 
@@ -597,6 +622,128 @@ class OrderControlReportService
         return $issues;
     }
 
+    /**
+     * Получает заказы типа "Успех без данных"
+     *
+     * Критерий: МП статус = "Выполнен" (DELIVERED или DELIVERY_SERVICE_DELIVERED)
+     *           + РМК статус = "Успех" (successful_order = 1 в marketplace_order_1c_statuses)
+     *           + (seller_id пустой/нулевой ИЛИ check_guid пустой/нулевой)
+     *
+     * Это заказы, которые успешно завершены и в МП, и в 1С, но при этом
+     * отсутствуют важные данные: продавец (seller_id) и/или GUID чека (check_guid).
+     *
+     * @param int $hoursAgo Период выборки в часах (по умолчанию 12)
+     * @return OrderIssue[]
+     */
+    public function getSuccessMissingDataOrders(int $hoursAgo = 12): array
+    {
+        $this->logInfo('Выборка заказов "Успех без данных"', ['hours_ago' => $hoursAgo, 'test_mode' => $this->testMode]);
+
+        // Получаем диапазон дат на основе конца смены
+        $dateRange = $this->getShiftBasedDateRange($hoursAgo, $this->testMode);
+        $startDateStr = $dateRange['startDate'];
+        $endDateStr = $dateRange['endDate'];
+
+        // Нулевой GUID — признак отсутствия продавца
+        $emptySellerGuid = '00000000-0000-0000-0000-000000000000';
+
+        // Получаем ID статусов "Успех" из БД
+        $rmkSuccessIds = $this->getRmkStatusSuccess();
+
+        // Формируем плейсхолдеры для IN-условия
+        $rmkSuccessPlaceholders = [];
+        $rmkSuccessParams = [];
+        foreach ($rmkSuccessIds as $index => $id) {
+            $placeholder = ':rmk_success_' . $index;
+            $rmkSuccessPlaceholders[] = $placeholder;
+            $rmkSuccessParams[$placeholder] = $id;
+        }
+        $rmkSuccessInClause = !empty($rmkSuccessPlaceholders)
+            ? implode(', ', $rmkSuccessPlaceholders)
+            : '0'; // fallback если статусов нет
+
+        // Выбираем заказы с МП-статусом "Выполнен" И РМК-статусом "Успех", где:
+        // seller_id пустой/нулевой ИЛИ check_guid пустой
+        $sql = "
+            SELECT
+                mo.id,
+                mo.marketplace_order_id,
+                mo.store_id,
+                cs.name as store_name,
+                mo.marketplace_name,
+                mo.marketplace_id,
+                mo.total,
+                mo.creation_date,
+                mo.seller_id,
+                mo.check_guid,
+                mo.status_processing_1c as rmk_status_id,
+                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,
+                CASE
+                    WHEN (mo.seller_id IS NULL OR mo.seller_id = '' OR mo.seller_id = :empty_seller_guid)
+                         AND (mo.check_guid IS NULL OR mo.check_guid = '')
+                    THEN 'no_seller_and_check_guid'
+                    WHEN mo.seller_id IS NULL OR mo.seller_id = '' OR mo.seller_id = :empty_seller_guid2
+                    THEN 'no_seller_id'
+                    WHEN mo.check_guid IS NULL OR mo.check_guid = ''
+                    THEN 'no_check_guid'
+                    ELSE 'unknown'
+                END 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.updated_at >= :start_date
+              AND mo.updated_at <= :end_date
+              -- МП-статус = Выполнен (DELIVERED)
+              AND (
+                  most.code = :delivered
+                  OR mosub.code = :delivery_service_delivered
+              )
+              -- РМК-статус = Успех (1С знает о доставке)
+              AND mo.status_processing_1c::integer IN ({$rmkSuccessInClause})
+              -- Проблема: seller_id пустой ИЛИ check_guid пустой
+              AND (
+                  mo.seller_id IS NULL
+                  OR mo.seller_id = ''
+                  OR mo.seller_id = :empty_seller_guid3
+                  OR mo.check_guid IS NULL
+                  OR mo.check_guid = ''
+              )
+            ORDER BY cs.name ASC, mo.creation_date DESC
+        ";
+
+        $params = array_merge([
+            ':start_date' => $startDateStr,
+            ':end_date' => $endDateStr,
+            ':delivered' => MarketplaceOrderStatusTypes::DELIVERED_CODE,
+            ':delivery_service_delivered' => MarketplaceOrderStatusTypes::DELIVERY_SERVICE_DELIVERED_CODE,
+            ':empty_seller_guid' => $emptySellerGuid,
+            ':empty_seller_guid2' => $emptySellerGuid,
+            ':empty_seller_guid3' => $emptySellerGuid,
+        ], $rmkSuccessParams);
+
+        $orders = Yii::$app->db->createCommand($sql, $params)->queryAll();
+
+        $issues = [];
+        foreach ($orders as $orderData) {
+            $issues[] = OrderIssue::fromOrderData(OrderIssue::TYPE_SUCCESS_MISSING_DATA, $orderData);
+        }
+
+        $this->logInfo('Найдено "Успех без данных"', [
+            'count' => count($issues),
+            'no_seller_id' => count(array_filter($orders, fn($o) => $o['issue_reason'] === 'no_seller_id')),
+            'no_check_guid' => count(array_filter($orders, fn($o) => $o['issue_reason'] === 'no_check_guid')),
+            'no_seller_and_check_guid' => count(array_filter($orders, fn($o) => $o['issue_reason'] === 'no_seller_and_check_guid')),
+        ]);
+
+        return $issues;
+    }
+
     /**
      * Сохраняет состояние проблемных заказов в БД
      *
@@ -637,6 +784,32 @@ class OrderControlReportService
         return $saved;
     }
 
+    /**
+     * Удаляет записи старше 1 месяца из таблицы marketplace_order_daily_issues
+     *
+     * Вызывается в конце генерации отчёта для поддержания размера таблицы.
+     * Хранятся только записи за последний месяц.
+     *
+     * @return int Количество удалённых записей
+     */
+    public function cleanupOldIssues(): int
+    {
+        $oneMonthAgo = date('Y-m-d', strtotime('-1 month'));
+
+        $deletedCount = MarketplaceOrderDailyIssues::deleteAll(
+            ['<', 'report_date', $oneMonthAgo]
+        );
+
+        if ($deletedCount > 0) {
+            $this->logInfo('Удалены старые записи из marketplace_order_daily_issues', [
+                'deleted_count' => $deletedCount,
+                'older_than' => $oneMonthAgo,
+            ]);
+        }
+
+        return $deletedCount;
+    }
+
     /**
      * Загружает предыдущие проблемы (для определения новых)
      *
@@ -738,6 +911,13 @@ class OrderControlReportService
             $lines[] = '';
         }
 
+        // Секция "Успех без данных" - специальный формат с причиной
+        if (!empty($result->successMissingData)) {
+            $lines[] = '*Успех без данных* \\(' . count($result->successMissingData) . '\\)';
+            $lines[] = $this->formatSuccessMissingDataTable($result->successMissingData);
+            $lines[] = '';
+        }
+
         $lines[] = '*Всего:* ' . $this->escapeMarkdownV2((string)$result->totalIssues);
 
         return implode("\n", $lines);
@@ -746,6 +926,8 @@ class OrderControlReportService
     /**
      * Форматирует таблицу проблем для Telegram (моноширинный блок)
      *
+     * Формат: | Дата | Интервал | Заказ | РМК | МП
+     *
      * @param OrderIssue[] $issues
      * @return string
      */
@@ -753,6 +935,7 @@ class OrderControlReportService
     {
         $rows = [];
         $rows[] = '```';
+        $rows[] = '| Дата | Интервал | Заказ | РМК | МП';
 
         foreach ($issues as $issue) {
             $rows[] = $this->formatIssueRow($issue);
@@ -763,27 +946,137 @@ class OrderControlReportService
         return implode("\n", $rows);
     }
 
+    /**
+     * Форматирует таблицу "Успех без данных" для Telegram
+     *
+     * Специальный формат: | Дата | Интервал | Заказ | Причина
+     * Показывает причину проблемы вместо статусов РМК/МП.
+     *
+     * @param OrderIssue[] $issues
+     * @return string
+     */
+    private function formatSuccessMissingDataTable(array $issues): string
+    {
+        $rows = [];
+        $rows[] = '```';
+        $rows[] = '| Дата | Интервал | Заказ | Причина';
+
+        foreach ($issues as $issue) {
+            $date = $issue->reportDate ?: date('d.m.Y');
+            $interval = $this->getShortInterval($issue->interval);
+            $reason = $issue->getIssueReasonLabel() ?? 'Неизвестно';
+
+            $rows[] = sprintf('| %s | %s | %s | %s', $date, $interval, $issue->orderNumber, $reason);
+        }
+
+        $rows[] = '```';
+
+        return implode("\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 = $issue->mpStatus ?? '-';
+        $mp = $this->formatMpStatus($issue);
 
         return sprintf(
-            '%s | %s | %s',
+            '| %s | %s | %s | %s | %s',
+            $date,
+            $interval,
             $issue->orderNumber,
             $rmk,
             $mp
         );
     }
 
+    /**
+     * Получает короткий формат интервала (08:00 или 20:00)
+     *
+     * @param string|null $interval
+     * @return string
+     */
+    private function getShortInterval(?string $interval): string
+    {
+        if ($interval === null) {
+            return (int)date('H') < 12 ? '08:00' : '20:00';
+        }
+
+        // Убираем суффиксы типа " (Ночь)" или " (День)"
+        if (str_starts_with($interval, '08:00')) {
+            return '08:00';
+        }
+        if (str_starts_with($interval, '20:00')) {
+            return '20:00';
+        }
+
+        return $interval;
+    }
+
+    /**
+     * Форматирует МП-статус для отображения
+     *
+     * Преобразует технические коды в понятные названия.
+     *
+     * @param OrderIssue $issue
+     * @return string
+     */
+    private function formatMpStatus(OrderIssue $issue): string
+    {
+        // Если есть человекочитаемый статус, используем его
+        if ($issue->mpStatus && !$this->isTechnicalMpStatus($issue->mpStatus)) {
+            return $issue->mpStatus;
+        }
+
+        // Преобразуем технические коды в понятные названия
+        $statusCode = $issue->mpStatusCode;
+        $substatusCode = $issue->mpSubstatusCode;
+
+        // Маппинг технических кодов на понятные названия
+        $statusMap = [
+            'DELIVERED' => 'Доставлен',
+            'DELIVERY_SERVICE_DELIVERED' => 'Доставлен службой',
+            'CANCELLED' => 'Отменён',
+            'PROCESSING' => 'В обработке',
+            'DELIVERY' => 'В доставке',
+            'PICKUP' => 'Готов к выдаче',
+            'UNPAID' => 'Не оплачен',
+            'PENDING' => 'Ожидает',
+        ];
+
+        if ($statusCode && isset($statusMap[$statusCode])) {
+            return $statusMap[$statusCode];
+        }
+
+        if ($substatusCode && isset($statusMap[$substatusCode])) {
+            return $statusMap[$substatusCode];
+        }
+
+        // Если ничего не нашли, возвращаем оригинал или прочерк
+        return $issue->mpStatus ?? '-';
+    }
+
+    /**
+     * Проверяет, является ли статус техническим кодом
+     *
+     * @param string $status
+     * @return bool
+     */
+    private function isTechnicalMpStatus(string $status): bool
+    {
+        // Технические статусы содержат только заглавные буквы, подчёркивания и слеши
+        return (bool)preg_match('/^[A-Z_\/]+$/', $status);
+    }
+
     /**
      * Формирует HTML-отчёт контроля статусов для Email
      *
@@ -822,6 +1115,9 @@ class OrderControlReportService
         foreach ($result->cancelNoProcess as $issue) {
             $allIssues[] = ['type' => 'Отмена без обработки', 'issue' => $issue];
         }
+        foreach ($result->successMissingData as $issue) {
+            $allIssues[] = ['type' => 'Успех без данных', 'issue' => $issue];
+        }
 
         // Сортируем по типу
         usort($allIssues, function ($a, $b) {
@@ -836,7 +1132,7 @@ class OrderControlReportService
             <th>Интервал</th>
             <th>Заказ</th>
             <th>РМК</th>
-            <th>МП</th>
+            <th>МП / Причина</th>
         </tr>';
 
         foreach ($allIssues as $item) {
@@ -845,6 +1141,13 @@ class OrderControlReportService
             $date = $issue->reportDate ?: date('d.m.Y');
             $interval = $issue->interval ?: ((int)date('H') < 12 ? '08:00' : '20:00');
 
+            // Для "Успех без данных" показываем причину вместо МП-статуса
+            if ($issue->problemType === OrderIssue::TYPE_SUCCESS_MISSING_DATA) {
+                $mpOrReason = $issue->getIssueReasonLabel() ?? 'Неизвестно';
+            } else {
+                $mpOrReason = $issue->mpStatus ?? '-';
+            }
+
             $html .= '
         <tr>
             <td>' . $this->escapeHtml($item['type']) . '</td>
@@ -852,7 +1155,7 @@ class OrderControlReportService
             <td>' . $this->escapeHtml($interval) . '</td>
             <td>' . $this->escapeHtml($issue->orderNumber) . '</td>
             <td>' . $this->escapeHtml($issue->rmkStatus ?? '-') . '</td>
-            <td>' . $this->escapeHtml($issue->mpStatus ?? '-') . '</td>
+            <td>' . $this->escapeHtml($mpOrReason) . '</td>
         </tr>';
         }
 
@@ -1208,50 +1511,84 @@ class OrderControlReportService
     }
 
     /**
-     * Вычисляет диапазон дат на основе конца смены
+     * Вычисляет диапазон дат на основе смены
+     *
+     * Стандартный режим (testMode=false):
+     * - Использует фиксированные времена смен
+     * - Дневная смена: 08:00 - 20:00
+     * - Ночная смена: 20:00 - 08:00
      *
-     * Логика:
-     * - Если текущий час < 20, база = 08:00 сегодня (конец утренней смены)
-     * - Если текущий час >= 20, база = 20:00 сегодня (конец вечерней смены)
-     * - startDate = база - $hoursAgo часов
-     * - endDate = база
+     * Тестовый режим (testMode=true):
+     * - endDate = текущее время (для отладки и проверки заказов в середине смены)
+     * - startDate = начало текущей смены
      *
-     * Ð\9fÑ\80имеÑ\80:
-     * - 15:00, hoursAgo=12 → база 08:00 → диапазон: вчера 20:00 - сегодня 08:00
-     * - 22:00, hoursAgo=12 → база 20:00 → диапазон: сегодня 08:00 - сегодня 20:00
+     * Ð\9eпÑ\80еделение Ñ\81менÑ\8b:
+     * - Если текущий час >= 8 и < 20 → дневная смена
+     * - Если текущий час >= 20 или < 8 → ночная смена
      *
-     * @param int $hoursAgo Количество часов назад от конца смены
-     * @return array{startDate: string, endDate: string} Массив с датами в формате 'Y-m-d H:i:s'
+     * @param int $hoursAgo Количество часов назад (используется как fallback)
+     * @param bool $testMode Тестовый режим: endDate = текущее время
+     * @return array{startDate: string, endDate: string, shiftName: string} Массив с датами и названием смены
      */
-    private function getShiftBasedDateRange(int $hoursAgo): array
+    private function getShiftBasedDateRange(int $hoursAgo, bool $testMode = false): array
     {
         $now = new \DateTime('now', new \DateTimeZone(self::TIMEZONE));
         $currentHour = (int)$now->format('H');
 
-        // Определяем конец текущей смены (база для расчёта)
+        $shiftStart = clone $now;
         $shiftEnd = clone $now;
-        if ($currentHour < 20) {
-            // До 20:00 — база 08:00 сегодня
-            $shiftEnd->setTime(8, 0, 0);
+        $shiftName = '';
+
+        if ($currentHour >= 8 && $currentHour < 20) {
+            // Дневная смена: 08:00 - 20:00
+            $shiftStart->setTime(8, 0, 0);
+            if ($testMode) {
+                // Тестовый режим: endDate = текущее время
+                // $shiftEnd уже = $now
+            } else {
+                // Стандартный режим: endDate = 20:00 сегодня
+                $shiftEnd->setTime(20, 0, 0);
+            }
+            $shiftName = 'Дневная смена (08:00-20:00)';
+        } elseif ($currentHour >= 20) {
+            // Ночная смена (начало): 20:00 сегодня - 08:00 завтра
+            $shiftStart->setTime(20, 0, 0);
+            if ($testMode) {
+                // Тестовый режим: endDate = текущее время
+                // $shiftEnd уже = $now
+            } else {
+                // Стандартный режим: endDate = 08:00 завтра
+                $shiftEnd->modify('+1 day');
+                $shiftEnd->setTime(8, 0, 0);
+            }
+            $shiftName = 'Ночная смена (20:00-08:00)';
         } else {
-            // После 20:00 — база 20:00 сегодня
-            $shiftEnd->setTime(20, 0, 0);
+            // Ночная смена (продолжение): 20:00 вчера - 08:00 сегодня
+            $shiftStart->modify('-1 day');
+            $shiftStart->setTime(20, 0, 0);
+            if ($testMode) {
+                // Тестовый режим: endDate = текущее время
+                // $shiftEnd уже = $now
+            } else {
+                // Стандартный режим: endDate = 08:00 сегодня
+                $shiftEnd->setTime(8, 0, 0);
+            }
+            $shiftName = 'Ночная смена (20:00-08:00)';
         }
 
-        // Вычисляем начало периода
-        $shiftStart = clone $shiftEnd;
-        $shiftStart->modify("-{$hoursAgo} hours");
-
         $this->logInfo('Вычислен диапазон дат на основе смены', [
             'current_time' => $now->format('Y-m-d H:i:s'),
-            'shift_end' => $shiftEnd->format('Y-m-d H:i:s'),
             'shift_start' => $shiftStart->format('Y-m-d H:i:s'),
+            'shift_end' => $shiftEnd->format('Y-m-d H:i:s'),
+            'shift_name' => $shiftName,
             'hours_ago' => $hoursAgo,
+            'test_mode' => $testMode,
         ]);
 
         return [
             'startDate' => $shiftStart->format('Y-m-d H:i:s'),
             'endDate' => $shiftEnd->format('Y-m-d H:i:s'),
+            'shiftName' => $shiftName,
         ];
     }
 
index a09f1262ba5963b19b206467d1881582c28d2180..bf50531a5f4703afc8c37503eb527ba7692207c4 100644 (file)
@@ -24,6 +24,21 @@ class ControlReportResult
      */
     public string $interval = '';
 
+    /**
+     * Дата-время начала периода проверки (формат Y-m-d H:i:s)
+     */
+    public string $startDate = '';
+
+    /**
+     * Дата-время окончания периода проверки (формат Y-m-d H:i:s)
+     */
+    public string $endDate = '';
+
+    /**
+     * Название смены (Дневная смена, Ночная смена)
+     */
+    public string $shiftName = '';
+
     /**
      * Общее количество проблемных заказов
      */
@@ -50,6 +65,13 @@ class ControlReportResult
      */
     public array $cancelNoProcess = [];
 
+    /**
+     * Заказы типа "Успех без данных" (успех в МП и 1С, но нет seller_id и/или check_guid)
+     *
+     * @var OrderIssue[]
+     */
+    public array $successMissingData = [];
+
     /**
      * Успешность отправки в Telegram
      */
@@ -116,6 +138,16 @@ class ControlReportResult
         return count($this->cancelNoProcess);
     }
 
+    /**
+     * Получает количество заказов по типу "Успех без данных"
+     *
+     * @return int
+     */
+    public function getSuccessMissingDataCount(): int
+    {
+        return count($this->successMissingData);
+    }
+
     /**
      * Рассчитывает и обновляет общее количество проблем
      *
@@ -125,7 +157,8 @@ class ControlReportResult
     {
         $this->totalIssues = $this->getHungInDeliveryCount()
             + $this->getSuccessNoCheckCount()
-            + $this->getCancelNoProcessCount();
+            + $this->getCancelNoProcessCount()
+            + $this->getSuccessMissingDataCount();
 
         return $this->totalIssues;
     }
@@ -140,7 +173,8 @@ class ControlReportResult
         return $this->totalIssues > 0
             || !empty($this->hungInDelivery)
             || !empty($this->successNoCheck)
-            || !empty($this->cancelNoProcess);
+            || !empty($this->cancelNoProcess)
+            || !empty($this->successMissingData);
     }
 
     /**
@@ -153,7 +187,8 @@ class ControlReportResult
         return array_merge(
             $this->hungInDelivery,
             $this->successNoCheck,
-            $this->cancelNoProcess
+            $this->cancelNoProcess,
+            $this->successMissingData
         );
     }
 
@@ -168,6 +203,7 @@ class ControlReportResult
             OrderIssue::TYPE_HUNG_IN_DELIVERY => $this->hungInDelivery,
             OrderIssue::TYPE_SUCCESS_NO_CHECK => $this->successNoCheck,
             OrderIssue::TYPE_CANCEL_NO_PROCESS => $this->cancelNoProcess,
+            OrderIssue::TYPE_SUCCESS_MISSING_DATA => $this->successMissingData,
         ];
     }
 
@@ -223,10 +259,14 @@ class ControlReportResult
         return [
             'report_date' => $this->reportDate,
             'interval' => $this->interval,
+            'start_date' => $this->startDate,
+            'end_date' => $this->endDate,
+            'shift_name' => $this->shiftName,
             'total_issues' => $this->totalIssues,
             'hung_in_delivery_count' => $this->getHungInDeliveryCount(),
             'success_no_check_count' => $this->getSuccessNoCheckCount(),
             'cancel_no_process_count' => $this->getCancelNoProcessCount(),
+            'success_missing_data_count' => $this->getSuccessMissingDataCount(),
             'telegram_sent' => $this->telegramSent,
             'email_sent' => $this->emailSent,
             'telegram_error' => $this->telegramError,
@@ -236,6 +276,41 @@ class ControlReportResult
         ];
     }
 
+    /**
+     * Форматирует диапазон дат для отображения в консоли
+     *
+     * Формат: "с 20:00 19.01.2026 по 08:00 20.01.2026 (Ночная смена)"
+     *
+     * @return string
+     */
+    public function getFormattedDateRange(): string
+    {
+        if (empty($this->startDate) || empty($this->endDate)) {
+            return $this->interval;
+        }
+
+        $tz = new \DateTimeZone('Europe/Moscow');
+
+        try {
+            $start = new \DateTime($this->startDate, $tz);
+            $end = new \DateTime($this->endDate, $tz);
+
+            $result = sprintf(
+                'с %s по %s',
+                $start->format('H:i d.m.Y'),
+                $end->format('H:i d.m.Y')
+            );
+
+            if (!empty($this->shiftName)) {
+                $result .= ' (' . $this->shiftName . ')';
+            }
+
+            return $result;
+        } catch (\Exception $e) {
+            return $this->interval;
+        }
+    }
+
     /**
      * Получает сводку для логирования
      *
@@ -244,11 +319,12 @@ class ControlReportResult
     public function getSummary(): string
     {
         return sprintf(
-            'Контроль МП: %d проблем (завис: %d, успех без чека: %d, отмена: %d). TG: %s, Email: %s',
+            'Контроль МП: %d проблем (завис: %d, успех без чека: %d, отмена: %d, успех без данных: %d). TG: %s, Email: %s',
             $this->totalIssues,
             $this->getHungInDeliveryCount(),
             $this->getSuccessNoCheckCount(),
             $this->getCancelNoProcessCount(),
+            $this->getSuccessMissingDataCount(),
             $this->telegramSent ? 'OK' : 'FAIL',
             $this->emailSent ? 'OK' : 'FAIL'
         );
index 511662774ecec6a0f90dbbc567f69884bab24969..8d54138f66731e6a59f172516d6f9e94b26e575b 100644 (file)
@@ -27,6 +27,11 @@ class OrderIssue
      */
     public const TYPE_CANCEL_NO_PROCESS = 'cancel_no_process';
 
+    /**
+     * Тип проблемы: "Успех без данных" (успех в МП и 1С, но нет seller_id и/или check_guid)
+     */
+    public const TYPE_SUCCESS_MISSING_DATA = 'success_missing_data';
+
     /**
      * Метки типов проблем для отображения
      */
@@ -34,6 +39,7 @@ class OrderIssue
         self::TYPE_HUNG_IN_DELIVERY => 'Завис в доставке',
         self::TYPE_SUCCESS_NO_CHECK => 'Успех без чека',
         self::TYPE_CANCEL_NO_PROCESS => 'Отмена без обработки',
+        self::TYPE_SUCCESS_MISSING_DATA => 'Успех без данных',
     ];
 
     /**
@@ -126,13 +132,18 @@ class OrderIssue
      */
     public ?string $sellerId;
 
+    /**
+     * GUID чека заказа (check_guid из marketplace_orders)
+     */
+    public ?string $checkGuid;
+
     /**
      * Существует ли чек в create_checks
      */
     public bool $checkExists = false;
 
     /**
-     * Причина проблемы (no_seller_id, no_check)
+     * Причина проблемы (no_seller_id, no_check, no_check_guid, no_seller_and_check_guid)
      */
     public ?string $issueReason;
 
@@ -142,6 +153,8 @@ 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',
         'unknown' => 'Неизвестно',
     ];
 
@@ -169,14 +182,16 @@ class OrderIssue
     /**
      * Определяет интервал проверки (Дневная/Ночная смена)
      *
+     * Возвращает только время без суффикса, т.к. поле в БД ограничено 8 символами.
+     * 08:00 - утренняя проверка (ночная смена завершилась)
+     * 20:00 - вечерняя проверка (дневная смена завершилась)
+     *
      * @return string
      */
     private function calculateInterval(): string
     {
         $hour = (int)date('H');
-        // 08:00 - утренняя проверка (ночная смена завершилась)
-        // 20:00 - вечерняя проверка (дневная смена завершилась)
-        return $hour < 12 ? '08:00 (Ночь)' : '20:00 (День)';
+        return $hour < 12 ? '08:00' : '20:00';
     }
 
     /**
@@ -217,8 +232,9 @@ class OrderIssue
         $issue->total = (float)($orderData['total'] ?? 0);
         $issue->creationDate = $orderData['creation_date'] ?? null;
 
-        // Поля для диагностики "Успех без чека"
+        // Поля для диагностики "Успех без чека" и "Успех без данных"
         $issue->sellerId = $orderData['seller_id'] ?? null;
+        $issue->checkGuid = $orderData['check_guid'] ?? null;
         $issue->checkExists = (bool)($orderData['check_exists'] ?? false);
         $issue->issueReason = $orderData['issue_reason'] ?? null;
 
@@ -251,6 +267,7 @@ class OrderIssue
             'total' => $this->total,
             'creation_date' => $this->creationDate,
             'seller_id' => $this->sellerId,
+            'check_guid' => $this->checkGuid,
             'check_exists' => $this->checkExists,
             'issue_reason' => $this->issueReason,
             'issue_reason_label' => $this->getIssueReasonLabel(),
diff --git a/erp24/tests/unit/records/MarketplaceOrderDailyIssuesTest.php b/erp24/tests/unit/records/MarketplaceOrderDailyIssuesTest.php
new file mode 100644 (file)
index 0000000..70d8386
--- /dev/null
@@ -0,0 +1,363 @@
+<?php
+
+declare(strict_types=1);
+
+namespace app\tests\unit\records;
+
+use Codeception\Test\Unit;
+use yii_app\records\MarketplaceOrderDailyIssues;
+use yii_app\services\dto\OrderIssue;
+
+/**
+ * Unit-тесты для AR-модели MarketplaceOrderDailyIssues
+ *
+ * Покрывает:
+ * - Константы типов проблем
+ * - Метод tableName()
+ * - Метод rules() (валидация без БД)
+ * - Метод attributeLabels()
+ * - Метод getProblemTypeLabel()
+ * - Метод fromOrderIssue() — создание модели из DTO
+ * - Метод toOrderIssue() — преобразование модели в DTO
+ *
+ * Примечание: методы работы с БД (markAsResolved, findUnresolved и т.д.)
+ * тестируются в функциональных тестах с реальной БД.
+ *
+ * @covers \yii_app\records\MarketplaceOrderDailyIssues
+ */
+class MarketplaceOrderDailyIssuesTest extends Unit
+{
+    /**
+     * Тест: константы типов проблем совпадают с DTO
+     */
+    public function testTypeConstantsMatchOrderIssueDto(): void
+    {
+        $this->assertSame(OrderIssue::TYPE_HUNG_IN_DELIVERY, MarketplaceOrderDailyIssues::TYPE_HUNG_IN_DELIVERY);
+        $this->assertSame(OrderIssue::TYPE_SUCCESS_NO_CHECK, MarketplaceOrderDailyIssues::TYPE_SUCCESS_NO_CHECK);
+        $this->assertSame(OrderIssue::TYPE_CANCEL_NO_PROCESS, MarketplaceOrderDailyIssues::TYPE_CANCEL_NO_PROCESS);
+        $this->assertSame(OrderIssue::TYPE_SUCCESS_MISSING_DATA, MarketplaceOrderDailyIssues::TYPE_SUCCESS_MISSING_DATA);
+    }
+
+    /**
+     * Тест: метки типов проблем совпадают с DTO
+     */
+    public function testTypeLabelsMatchOrderIssueDto(): void
+    {
+        $this->assertSame(OrderIssue::TYPE_LABELS, MarketplaceOrderDailyIssues::TYPE_LABELS);
+    }
+
+    /**
+     * Тест: tableName возвращает корректное имя таблицы
+     */
+    public function testTableNameReturnsCorrectName(): void
+    {
+        $this->assertSame('marketplace_order_daily_issues', MarketplaceOrderDailyIssues::tableName());
+    }
+
+    /**
+     * Тест: rules содержит обязательные поля
+     */
+    public function testRulesContainsRequiredFields(): void
+    {
+        $model = new MarketplaceOrderDailyIssues();
+        $rules = $model->rules();
+
+        // Находим правило с required
+        $requiredFields = [];
+        foreach ($rules as $rule) {
+            if ($rule[1] === 'required') {
+                $fields = is_array($rule[0]) ? $rule[0] : [$rule[0]];
+                $requiredFields = array_merge($requiredFields, $fields);
+            }
+        }
+
+        $this->assertContains('order_id', $requiredFields);
+        $this->assertContains('marketplace_order_id', $requiredFields);
+        $this->assertContains('problem_type', $requiredFields);
+        $this->assertContains('report_date', $requiredFields);
+        $this->assertContains('interval', $requiredFields);
+    }
+
+    /**
+     * Тест: rules содержит валидацию problem_type
+     */
+    public function testRulesContainsProblemTypeValidation(): void
+    {
+        $model = new MarketplaceOrderDailyIssues();
+        $rules = $model->rules();
+
+        // Находим правило 'in' для problem_type
+        $inRule = null;
+        foreach ($rules as $rule) {
+            $fields = is_array($rule[0]) ? $rule[0] : [$rule[0]];
+            if (in_array('problem_type', $fields) && $rule[1] === 'in') {
+                $inRule = $rule;
+                break;
+            }
+        }
+
+        $this->assertNotNull($inRule, 'Правило in для problem_type должно существовать');
+        $this->assertArrayHasKey('range', $inRule);
+        $this->assertContains(MarketplaceOrderDailyIssues::TYPE_HUNG_IN_DELIVERY, $inRule['range']);
+        $this->assertContains(MarketplaceOrderDailyIssues::TYPE_SUCCESS_NO_CHECK, $inRule['range']);
+        $this->assertContains(MarketplaceOrderDailyIssues::TYPE_CANCEL_NO_PROCESS, $inRule['range']);
+        $this->assertContains(MarketplaceOrderDailyIssues::TYPE_SUCCESS_MISSING_DATA, $inRule['range']);
+    }
+
+    /**
+     * Тест: rules содержит валидацию interval
+     */
+    public function testRulesContainsIntervalValidation(): void
+    {
+        $model = new MarketplaceOrderDailyIssues();
+        $rules = $model->rules();
+
+        // Находим правило 'in' для interval
+        $inRule = null;
+        foreach ($rules as $rule) {
+            $fields = is_array($rule[0]) ? $rule[0] : [$rule[0]];
+            if (in_array('interval', $fields) && $rule[1] === 'in') {
+                $inRule = $rule;
+                break;
+            }
+        }
+
+        $this->assertNotNull($inRule, 'Правило in для interval должно существовать');
+        $this->assertArrayHasKey('range', $inRule);
+        $this->assertContains('08:00', $inRule['range']);
+        $this->assertContains('20:00', $inRule['range']);
+    }
+
+    /**
+     * Тест: rules содержит уникальный составной ключ
+     */
+    public function testRulesContainsUniqueConstraint(): void
+    {
+        $model = new MarketplaceOrderDailyIssues();
+        $rules = $model->rules();
+
+        // Находим правило unique
+        $uniqueRule = null;
+        foreach ($rules as $rule) {
+            if ($rule[1] === 'unique' && isset($rule['targetAttribute'])) {
+                $uniqueRule = $rule;
+                break;
+            }
+        }
+
+        $this->assertNotNull($uniqueRule, 'Правило unique должно существовать');
+        $this->assertContains('order_id', $uniqueRule['targetAttribute']);
+        $this->assertContains('problem_type', $uniqueRule['targetAttribute']);
+        $this->assertContains('report_date', $uniqueRule['targetAttribute']);
+        $this->assertContains('interval', $uniqueRule['targetAttribute']);
+    }
+
+    /**
+     * Тест: attributeLabels содержит все основные поля
+     */
+    public function testAttributeLabelsContainsAllFields(): void
+    {
+        $model = new MarketplaceOrderDailyIssues();
+        $labels = $model->attributeLabels();
+
+        $expectedLabels = [
+            'id', 'order_id', 'marketplace_order_id', 'problem_type',
+            'report_date', 'interval', 'rmk_status_id', 'rmk_status',
+            'mp_status_code', 'mp_substatus_code', 'mp_status',
+            'store_id', 'store_name', 'marketplace_id', 'marketplace_name',
+            'total', 'is_notified', 'notified_at', 'is_resolved',
+            'resolved_at', 'created_at', 'updated_at',
+        ];
+
+        foreach ($expectedLabels as $attribute) {
+            $this->assertArrayHasKey($attribute, $labels, "Метка для {$attribute} должна существовать");
+        }
+    }
+
+    /**
+     * Тест: getProblemTypeLabel возвращает корректные метки
+     *
+     * @dataProvider problemTypeLabelProvider
+     */
+    public function testGetProblemTypeLabelReturnsCorrectLabel(string $type, string $expectedLabel): void
+    {
+        $model = new MarketplaceOrderDailyIssues();
+        $model->problem_type = $type;
+
+        $this->assertSame($expectedLabel, $model->getProblemTypeLabel());
+    }
+
+    /**
+     * Провайдер данных для testGetProblemTypeLabelReturnsCorrectLabel
+     */
+    public static function problemTypeLabelProvider(): array
+    {
+        return [
+            'hung_in_delivery' => [
+                MarketplaceOrderDailyIssues::TYPE_HUNG_IN_DELIVERY,
+                'Завис в доставке',
+            ],
+            'success_no_check' => [
+                MarketplaceOrderDailyIssues::TYPE_SUCCESS_NO_CHECK,
+                'Успех без чека',
+            ],
+            'cancel_no_process' => [
+                MarketplaceOrderDailyIssues::TYPE_CANCEL_NO_PROCESS,
+                'Отмена без обработки',
+            ],
+            'success_missing_data' => [
+                MarketplaceOrderDailyIssues::TYPE_SUCCESS_MISSING_DATA,
+                'Успех без данных',
+            ],
+        ];
+    }
+
+    /**
+     * Тест: getProblemTypeLabel возвращает тип для неизвестных значений
+     */
+    public function testGetProblemTypeLabelReturnsTypeForUnknownValue(): void
+    {
+        $model = new MarketplaceOrderDailyIssues();
+        $model->problem_type = 'unknown_type';
+
+        $this->assertSame('unknown_type', $model->getProblemTypeLabel());
+    }
+
+    /**
+     * Тест: fromOrderIssue создаёт модель из DTO
+     */
+    public function testFromOrderIssueCreatesModelFromDto(): void
+    {
+        $issue = new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 123, 'FW-12345');
+        $issue->rmkStatusId = '5';
+        $issue->rmkStatus = 'Передан курьеру';
+        $issue->mpStatusCode = 'DELIVERY';
+        $issue->mpSubstatusCode = 'DELIVERY_USER_RECEIVED';
+        $issue->mpStatus = 'В доставке';
+        $issue->storeId = 10;
+        $issue->storeName = 'Магазин Центр';
+        $issue->marketplaceId = 1;
+        $issue->marketplaceName = 'Flowwow';
+        $issue->total = 5500.50;
+
+        $model = MarketplaceOrderDailyIssues::fromOrderIssue($issue);
+
+        $this->assertInstanceOf(MarketplaceOrderDailyIssues::class, $model);
+        $this->assertSame(123, $model->order_id);
+        $this->assertSame('FW-12345', $model->marketplace_order_id);
+        $this->assertSame(OrderIssue::TYPE_HUNG_IN_DELIVERY, $model->problem_type);
+        $this->assertSame(date('Y-m-d'), $model->report_date);
+        $this->assertMatchesRegularExpression('/^(08:00|20:00)$/', $model->interval);
+        $this->assertSame('5', $model->rmk_status_id);
+        $this->assertSame('Передан курьеру', $model->rmk_status);
+        $this->assertSame('DELIVERY', $model->mp_status_code);
+        $this->assertSame('DELIVERY_USER_RECEIVED', $model->mp_substatus_code);
+        $this->assertSame('В доставке', $model->mp_status);
+        $this->assertSame(10, $model->store_id);
+        $this->assertSame('Магазин Центр', $model->store_name);
+        $this->assertSame(1, $model->marketplace_id);
+        $this->assertSame('Flowwow', $model->marketplace_name);
+        $this->assertSame(5500.50, $model->total);
+    }
+
+    /**
+     * Тест: toOrderIssue преобразует модель в DTO
+     */
+    public function testToOrderIssueConvertsModelToDto(): void
+    {
+        $model = new MarketplaceOrderDailyIssues();
+        $model->order_id = 456;
+        $model->marketplace_order_id = 'YM-456';
+        $model->problem_type = MarketplaceOrderDailyIssues::TYPE_SUCCESS_NO_CHECK;
+        $model->report_date = '2026-01-20';
+        $model->interval = '20:00';
+        $model->rmk_status_id = '1';
+        $model->rmk_status = 'Новый';
+        $model->mp_status_code = 'DELIVERED';
+        $model->mp_substatus_code = 'DELIVERY_SERVICE_DELIVERED';
+        $model->mp_status = 'Доставлен';
+        $model->store_id = 20;
+        $model->store_name = 'Магазин Север';
+        $model->marketplace_id = 2;
+        $model->marketplace_name = 'Yandex Market';
+        $model->total = 3200.00;
+
+        $issue = $model->toOrderIssue();
+
+        $this->assertInstanceOf(OrderIssue::class, $issue);
+        $this->assertSame(456, $issue->orderId);
+        $this->assertSame('YM-456', $issue->orderNumber);
+        $this->assertSame(OrderIssue::TYPE_SUCCESS_NO_CHECK, $issue->problemType);
+        $this->assertSame('20.01.2026', $issue->reportDate);
+        $this->assertSame('20:00', $issue->interval);
+        $this->assertSame('1', $issue->rmkStatusId);
+        $this->assertSame('Новый', $issue->rmkStatus);
+        $this->assertSame('DELIVERED', $issue->mpStatusCode);
+        $this->assertSame('DELIVERY_SERVICE_DELIVERED', $issue->mpSubstatusCode);
+        $this->assertSame('Доставлен', $issue->mpStatus);
+        $this->assertSame(20, $issue->storeId);
+        $this->assertSame('Магазин Север', $issue->storeName);
+        $this->assertSame(2, $issue->marketplaceId);
+        $this->assertSame('Yandex Market', $issue->marketplaceName);
+        $this->assertSame(3200.00, $issue->total);
+    }
+
+    /**
+     * Тест: fromOrderIssue -> toOrderIssue сохраняет основные данные
+     */
+    public function testRoundTripConversionPreservesData(): void
+    {
+        $originalIssue = new OrderIssue(OrderIssue::TYPE_CANCEL_NO_PROCESS, 789, 'FW-789');
+        $originalIssue->rmkStatusId = '7';
+        $originalIssue->rmkStatus = 'Отменён';
+        $originalIssue->mpStatusCode = 'CANCELLED';
+        $originalIssue->mpSubstatusCode = 'USER_CANCELLED';
+        $originalIssue->mpStatus = 'Отменён покупателем';
+        $originalIssue->storeId = 30;
+        $originalIssue->storeName = 'Магазин Запад';
+        $originalIssue->marketplaceId = 1;
+        $originalIssue->marketplaceName = 'Flowwow';
+        $originalIssue->total = 8000.00;
+
+        $model = MarketplaceOrderDailyIssues::fromOrderIssue($originalIssue);
+        $convertedIssue = $model->toOrderIssue();
+
+        // Сравниваем основные поля (кроме reportDate и interval, которые могут измениться)
+        $this->assertSame($originalIssue->orderId, $convertedIssue->orderId);
+        $this->assertSame($originalIssue->orderNumber, $convertedIssue->orderNumber);
+        $this->assertSame($originalIssue->problemType, $convertedIssue->problemType);
+        $this->assertSame($originalIssue->rmkStatusId, $convertedIssue->rmkStatusId);
+        $this->assertSame($originalIssue->rmkStatus, $convertedIssue->rmkStatus);
+        $this->assertSame($originalIssue->mpStatusCode, $convertedIssue->mpStatusCode);
+        $this->assertSame($originalIssue->mpSubstatusCode, $convertedIssue->mpSubstatusCode);
+        $this->assertSame($originalIssue->mpStatus, $convertedIssue->mpStatus);
+        $this->assertSame($originalIssue->storeId, $convertedIssue->storeId);
+        $this->assertSame($originalIssue->storeName, $convertedIssue->storeName);
+        $this->assertSame($originalIssue->marketplaceId, $convertedIssue->marketplaceId);
+        $this->assertSame($originalIssue->marketplaceName, $convertedIssue->marketplaceName);
+        $this->assertSame($originalIssue->total, $convertedIssue->total);
+    }
+
+    /**
+     * Тест: fromOrderIssue с минимальными данными
+     */
+    public function testFromOrderIssueWithMinimalData(): void
+    {
+        $issue = new OrderIssue(OrderIssue::TYPE_SUCCESS_MISSING_DATA, 1, 'TEST-1');
+
+        $model = MarketplaceOrderDailyIssues::fromOrderIssue($issue);
+
+        $this->assertSame(1, $model->order_id);
+        $this->assertSame('TEST-1', $model->marketplace_order_id);
+        $this->assertSame(OrderIssue::TYPE_SUCCESS_MISSING_DATA, $model->problem_type);
+        $this->assertNull($model->rmk_status_id);
+        $this->assertNull($model->rmk_status);
+        $this->assertNull($model->mp_status_code);
+        $this->assertNull($model->mp_status);
+        $this->assertNull($model->store_id);
+        $this->assertNull($model->store_name);
+        $this->assertNull($model->marketplace_id);
+        $this->assertNull($model->marketplace_name);
+        $this->assertSame(0.0, $model->total);
+    }
+}
diff --git a/erp24/tests/unit/services/OrderControlReportServiceTest.php b/erp24/tests/unit/services/OrderControlReportServiceTest.php
new file mode 100644 (file)
index 0000000..28e2b65
--- /dev/null
@@ -0,0 +1,366 @@
+<?php
+
+declare(strict_types=1);
+
+namespace app\tests\unit\services;
+
+use Codeception\Test\Unit;
+use yii_app\services\OrderControlReportService;
+use yii_app\services\dto\ControlReportResult;
+use yii_app\services\dto\OrderIssue;
+
+/**
+ * Unit-тесты для OrderControlReportService
+ *
+ * Покрывает:
+ * - Константы сервиса
+ * - Метод filterNewIssues() — фильтрация новых проблем
+ * - Метод formatTelegramControlReport() — форматирование для Telegram
+ * - Метод formatEmailControlReport() — форматирование для Email
+ *
+ * Примечание: методы работы с БД (getHungInDeliveryCandidates и т.д.)
+ * тестируются в функциональных тестах с реальной БД.
+ *
+ * @covers \yii_app\services\OrderControlReportService
+ */
+class OrderControlReportServiceTest extends Unit
+{
+    private OrderControlReportService $service;
+
+    protected function _before(): void
+    {
+        parent::_before();
+        $this->service = new OrderControlReportService();
+    }
+
+    /**
+     * Тест: константы сервиса имеют корректные значения
+     */
+    public function testConstantsHaveCorrectValues(): void
+    {
+        $this->assertSame(12, OrderControlReportService::REPORT_PERIOD_HOURS);
+        $this->assertSame(3, OrderControlReportService::MAX_RETRIES);
+        $this->assertSame(5, OrderControlReportService::RETRY_DELAY_SECONDS);
+        $this->assertSame(4000, OrderControlReportService::TELEGRAM_MAX_LENGTH);
+        $this->assertSame('Europe/Moscow', OrderControlReportService::TIMEZONE);
+    }
+
+    /**
+     * Тест: filterNewIssues возвращает только новые проблемы
+     */
+    public function testFilterNewIssuesReturnsOnlyNewIssues(): void
+    {
+        $issue1 = new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 100, 'FW-100');
+        $issue2 = new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 200, 'FW-200');
+        $issue3 = new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 300, 'FW-300');
+
+        $issues = [$issue1, $issue2, $issue3];
+
+        // Заказ 200 уже был в предыдущей проверке
+        $previousMap = [
+            200 => true,
+        ];
+
+        $newIssues = $this->service->filterNewIssues($issues, $previousMap);
+
+        $this->assertCount(2, $newIssues);
+        $this->assertContains($issue1, $newIssues);
+        $this->assertNotContains($issue2, $newIssues);
+        $this->assertContains($issue3, $newIssues);
+    }
+
+    /**
+     * Тест: filterNewIssues возвращает все, если предыдущий список пуст
+     */
+    public function testFilterNewIssuesReturnsAllWhenPreviousEmpty(): void
+    {
+        $issue1 = new OrderIssue(OrderIssue::TYPE_SUCCESS_NO_CHECK, 100, 'YM-100');
+        $issue2 = new OrderIssue(OrderIssue::TYPE_SUCCESS_NO_CHECK, 200, 'YM-200');
+
+        $issues = [$issue1, $issue2];
+        $previousMap = [];
+
+        $newIssues = $this->service->filterNewIssues($issues, $previousMap);
+
+        $this->assertCount(2, $newIssues);
+    }
+
+    /**
+     * Тест: filterNewIssues возвращает пустой массив, если все уже были
+     */
+    public function testFilterNewIssuesReturnsEmptyWhenAllExist(): void
+    {
+        $issue1 = new OrderIssue(OrderIssue::TYPE_CANCEL_NO_PROCESS, 100, 'FW-100');
+        $issue2 = new OrderIssue(OrderIssue::TYPE_CANCEL_NO_PROCESS, 200, 'FW-200');
+
+        $issues = [$issue1, $issue2];
+        $previousMap = [
+            100 => true,
+            200 => true,
+        ];
+
+        $newIssues = $this->service->filterNewIssues($issues, $previousMap);
+
+        $this->assertCount(0, $newIssues);
+    }
+
+    /**
+     * Тест: formatTelegramControlReport формирует корректный отчёт
+     */
+    public function testFormatTelegramControlReportFormatsCorrectly(): void
+    {
+        $result = new ControlReportResult();
+
+        $issue1 = new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 100, 'FW-100');
+        $issue1->rmkStatus = 'Передан курьеру';
+        $issue1->mpStatus = 'В доставке';
+        $issue1->marketplaceName = 'Flowwow';
+
+        $issue2 = new OrderIssue(OrderIssue::TYPE_SUCCESS_NO_CHECK, 200, 'YM-200');
+        $issue2->rmkStatus = 'Новый';
+        $issue2->mpStatus = 'Доставлен';
+        $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);
+
+        // Проверяем наличие номеров заказов
+        $this->assertStringContainsString('FW-100', $message);
+        $this->assertStringContainsString('YM-200', $message);
+
+        // Проверяем наличие итога
+        $this->assertStringContainsString('Всего:', $message);
+        $this->assertStringContainsString('2', $message);
+    }
+
+    /**
+     * Тест: formatTelegramControlReport включает все типы проблем
+     */
+    public function testFormatTelegramControlReportIncludesAllTypes(): void
+    {
+        $result = new ControlReportResult();
+
+        $result->hungInDelivery = [
+            new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 1, 'FW-1'),
+        ];
+        $result->successNoCheck = [
+            new OrderIssue(OrderIssue::TYPE_SUCCESS_NO_CHECK, 2, 'YM-2'),
+        ];
+        $result->cancelNoProcess = [
+            new OrderIssue(OrderIssue::TYPE_CANCEL_NO_PROCESS, 3, 'FW-3'),
+        ];
+        $result->successMissingData = [
+            new OrderIssue(OrderIssue::TYPE_SUCCESS_MISSING_DATA, 4, 'FW-4'),
+        ];
+        $result->calculateTotal();
+
+        $message = $this->service->formatTelegramControlReport($result);
+
+        $this->assertStringContainsString('Завис в доставке', $message);
+        $this->assertStringContainsString('Успех без чека', $message);
+        $this->assertStringContainsString('Отмена без обработки', $message);
+        $this->assertStringContainsString('Успех без данных', $message);
+    }
+
+    /**
+     * Тест: formatTelegramControlReport использует моноширинный блок
+     */
+    public function testFormatTelegramControlReportUsesCodeBlock(): void
+    {
+        $result = new ControlReportResult();
+        $result->hungInDelivery = [
+            new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 1, 'FW-1'),
+        ];
+        $result->calculateTotal();
+
+        $message = $this->service->formatTelegramControlReport($result);
+
+        // Проверяем наличие моноширинного блока
+        $this->assertStringContainsString('```', $message);
+    }
+
+    /**
+     * Тест: formatEmailControlReport формирует HTML-отчёт
+     */
+    public function testFormatEmailControlReportReturnsValidHtml(): void
+    {
+        $result = new ControlReportResult();
+
+        $issue = new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 100, 'FW-100');
+        $issue->rmkStatus = 'Передан курьеру';
+        $issue->mpStatus = 'В доставке';
+
+        $result->hungInDelivery = [$issue];
+        $result->calculateTotal();
+
+        $html = $this->service->formatEmailControlReport($result);
+
+        // Проверяем наличие HTML-структуры
+        $this->assertStringContainsString('<!DOCTYPE html>', $html);
+        $this->assertStringContainsString('<html>', $html);
+        $this->assertStringContainsString('</html>', $html);
+        $this->assertStringContainsString('<head>', $html);
+        $this->assertStringContainsString('<body>', $html);
+        $this->assertStringContainsString('<table>', $html);
+    }
+
+    /**
+     * Тест: formatEmailControlReport содержит заголовок
+     */
+    public function testFormatEmailControlReportContainsHeader(): void
+    {
+        $result = new ControlReportResult();
+        $result->hungInDelivery = [
+            new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 1, 'FW-1'),
+        ];
+        $result->calculateTotal();
+
+        $html = $this->service->formatEmailControlReport($result);
+
+        $this->assertStringContainsString('[Контроль MP] Отчёт за', $html);
+        $this->assertStringContainsString('<h2>', $html);
+    }
+
+    /**
+     * Тест: formatEmailControlReport содержит таблицу с заголовками
+     */
+    public function testFormatEmailControlReportContainsTableHeaders(): void
+    {
+        $result = new ControlReportResult();
+        $result->hungInDelivery = [
+            new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 1, 'FW-1'),
+        ];
+        $result->calculateTotal();
+
+        $html = $this->service->formatEmailControlReport($result);
+
+        $this->assertStringContainsString('<th>Тип проблемы</th>', $html);
+        $this->assertStringContainsString('<th>Дата</th>', $html);
+        $this->assertStringContainsString('<th>Интервал</th>', $html);
+        $this->assertStringContainsString('<th>Заказ</th>', $html);
+        $this->assertStringContainsString('<th>РМК</th>', $html);
+        $this->assertStringContainsString('<th>МП / Причина</th>', $html);
+    }
+
+    /**
+     * Тест: formatEmailControlReport включает данные заказа
+     */
+    public function testFormatEmailControlReportContainsOrderData(): void
+    {
+        $result = new ControlReportResult();
+
+        $issue = new OrderIssue(OrderIssue::TYPE_SUCCESS_NO_CHECK, 200, 'YM-200');
+        $issue->rmkStatus = 'Новый';
+        $issue->mpStatus = 'Доставлен';
+
+        $result->successNoCheck = [$issue];
+        $result->calculateTotal();
+
+        $html = $this->service->formatEmailControlReport($result);
+
+        $this->assertStringContainsString('YM-200', $html);
+        $this->assertStringContainsString('Успех без чека', $html);
+        $this->assertStringContainsString('Новый', $html);
+        $this->assertStringContainsString('Доставлен', $html);
+    }
+
+    /**
+     * Тест: formatEmailControlReport показывает причину для success_missing_data
+     */
+    public function testFormatEmailControlReportShowsIssueReasonForMissingData(): void
+    {
+        $result = new ControlReportResult();
+
+        $issue = new OrderIssue(OrderIssue::TYPE_SUCCESS_MISSING_DATA, 300, 'FW-300');
+        $issue->rmkStatus = '6. Успех';
+        $issue->mpStatus = 'Доставлен';
+        $issue->issueReason = 'no_seller_and_check_guid';
+
+        $result->successMissingData = [$issue];
+        $result->calculateTotal();
+
+        $html = $this->service->formatEmailControlReport($result);
+
+        $this->assertStringContainsString('Успех без данных', $html);
+        $this->assertStringContainsString('Нет seller_id и check_guid', $html);
+    }
+
+    /**
+     * Тест: formatEmailControlReport содержит итог
+     */
+    public function testFormatEmailControlReportContainsTotal(): void
+    {
+        $result = new ControlReportResult();
+
+        $result->hungInDelivery = [
+            new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 1, 'FW-1'),
+            new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 2, 'FW-2'),
+        ];
+        $result->successNoCheck = [
+            new OrderIssue(OrderIssue::TYPE_SUCCESS_NO_CHECK, 3, 'YM-3'),
+        ];
+        $result->calculateTotal();
+
+        $html = $this->service->formatEmailControlReport($result);
+
+        $this->assertStringContainsString('Всего проблем: 3', $html);
+    }
+
+    /**
+     * Тест: formatEmailControlReport содержит CSS-стили
+     */
+    public function testFormatEmailControlReportContainsCssStyles(): void
+    {
+        $result = new ControlReportResult();
+        $result->hungInDelivery = [
+            new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 1, 'FW-1'),
+        ];
+        $result->calculateTotal();
+
+        $html = $this->service->formatEmailControlReport($result);
+
+        $this->assertStringContainsString('<style>', $html);
+        $this->assertStringContainsString('font-family:', $html);
+        $this->assertStringContainsString('border-collapse:', $html);
+    }
+
+    /**
+     * Тест: formatEmailControlReport экранирует HTML-символы
+     */
+    public function testFormatEmailControlReportEscapesHtmlCharacters(): void
+    {
+        $result = new ControlReportResult();
+
+        $issue = new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 100, '<script>alert(1)</script>');
+        $issue->rmkStatus = '&quot;Test&quot;';
+
+        $result->hungInDelivery = [$issue];
+        $result->calculateTotal();
+
+        $html = $this->service->formatEmailControlReport($result);
+
+        // Проверяем, что опасные символы экранированы
+        $this->assertStringNotContainsString('<script>', $html);
+        $this->assertStringContainsString('&lt;script&gt;', $html);
+    }
+
+    /**
+     * Тест: сервис создаётся без ошибок
+     */
+    public function testServiceCanBeInstantiated(): void
+    {
+        $service = new OrderControlReportService();
+
+        $this->assertInstanceOf(OrderControlReportService::class, $service);
+    }
+}
diff --git a/erp24/tests/unit/services/dto/ControlReportResultTest.php b/erp24/tests/unit/services/dto/ControlReportResultTest.php
new file mode 100644 (file)
index 0000000..c96a5dd
--- /dev/null
@@ -0,0 +1,496 @@
+<?php
+
+declare(strict_types=1);
+
+namespace app\tests\unit\services\dto;
+
+use Codeception\Test\Unit;
+use yii_app\services\dto\ControlReportResult;
+use yii_app\services\dto\OrderIssue;
+
+/**
+ * Unit-тесты для DTO ControlReportResult
+ *
+ * Покрывает:
+ * - Инициализацию через конструктор
+ * - Методы подсчёта по типам проблем
+ * - Методы агрегации (calculateTotal, getAllIssues, groupByProblemType)
+ * - Методы статуса отправки (isSuccess, isPartialSuccess, getExitCode)
+ * - Преобразования (toArray, getSummary, getFormattedDateRange)
+ *
+ * @covers \yii_app\services\dto\ControlReportResult
+ */
+class ControlReportResultTest extends Unit
+{
+    /**
+     * Тест: конструктор инициализирует объект с корректными значениями
+     */
+    public function testConstructorInitializesWithCorrectValues(): void
+    {
+        $result = new ControlReportResult();
+
+        $this->assertSame('', $result->startDate);
+        $this->assertSame('', $result->endDate);
+        $this->assertSame('', $result->shiftName);
+        $this->assertSame(0, $result->totalIssues);
+        $this->assertSame([], $result->hungInDelivery);
+        $this->assertSame([], $result->successNoCheck);
+        $this->assertSame([], $result->cancelNoProcess);
+        $this->assertSame([], $result->successMissingData);
+        $this->assertFalse($result->telegramSent);
+        $this->assertFalse($result->emailSent);
+        $this->assertNull($result->telegramError);
+        $this->assertNull($result->emailError);
+        $this->assertSame(0, $result->statesSaved);
+    }
+
+    /**
+     * Тест: конструктор устанавливает reportDate и interval
+     */
+    public function testConstructorSetsReportDateAndInterval(): void
+    {
+        $result = new ControlReportResult();
+
+        // Формат reportDate: d.m.Y H:i
+        $this->assertMatchesRegularExpression('/^\d{2}\.\d{2}\.\d{4} \d{2}:\d{2}$/', $result->reportDate);
+
+        // Интервал 08:00 или 20:00
+        $this->assertMatchesRegularExpression('/^(08:00|20:00)$/', $result->interval);
+    }
+
+    /**
+     * Тест: getHungInDeliveryCount возвращает количество элементов
+     */
+    public function testGetHungInDeliveryCountReturnsCorrectCount(): void
+    {
+        $result = new ControlReportResult();
+        $this->assertSame(0, $result->getHungInDeliveryCount());
+
+        $result->hungInDelivery = [
+            new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 1, 'FW-1'),
+            new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 2, 'FW-2'),
+        ];
+
+        $this->assertSame(2, $result->getHungInDeliveryCount());
+    }
+
+    /**
+     * Тест: getSuccessNoCheckCount возвращает количество элементов
+     */
+    public function testGetSuccessNoCheckCountReturnsCorrectCount(): void
+    {
+        $result = new ControlReportResult();
+        $this->assertSame(0, $result->getSuccessNoCheckCount());
+
+        $result->successNoCheck = [
+            new OrderIssue(OrderIssue::TYPE_SUCCESS_NO_CHECK, 10, 'YM-10'),
+            new OrderIssue(OrderIssue::TYPE_SUCCESS_NO_CHECK, 11, 'YM-11'),
+            new OrderIssue(OrderIssue::TYPE_SUCCESS_NO_CHECK, 12, 'YM-12'),
+        ];
+
+        $this->assertSame(3, $result->getSuccessNoCheckCount());
+    }
+
+    /**
+     * Тест: getCancelNoProcessCount возвращает количество элементов
+     */
+    public function testGetCancelNoProcessCountReturnsCorrectCount(): void
+    {
+        $result = new ControlReportResult();
+        $this->assertSame(0, $result->getCancelNoProcessCount());
+
+        $result->cancelNoProcess = [
+            new OrderIssue(OrderIssue::TYPE_CANCEL_NO_PROCESS, 100, 'FW-100'),
+        ];
+
+        $this->assertSame(1, $result->getCancelNoProcessCount());
+    }
+
+    /**
+     * Тест: getSuccessMissingDataCount возвращает количество элементов
+     */
+    public function testGetSuccessMissingDataCountReturnsCorrectCount(): void
+    {
+        $result = new ControlReportResult();
+        $this->assertSame(0, $result->getSuccessMissingDataCount());
+
+        $result->successMissingData = [
+            new OrderIssue(OrderIssue::TYPE_SUCCESS_MISSING_DATA, 200, 'FW-200'),
+            new OrderIssue(OrderIssue::TYPE_SUCCESS_MISSING_DATA, 201, 'FW-201'),
+        ];
+
+        $this->assertSame(2, $result->getSuccessMissingDataCount());
+    }
+
+    /**
+     * Тест: calculateTotal рассчитывает и возвращает общее количество
+     */
+    public function testCalculateTotalSumsAllCounts(): void
+    {
+        $result = new ControlReportResult();
+
+        $result->hungInDelivery = [
+            new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 1, 'FW-1'),
+            new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 2, 'FW-2'),
+        ];
+        $result->successNoCheck = [
+            new OrderIssue(OrderIssue::TYPE_SUCCESS_NO_CHECK, 10, 'YM-10'),
+        ];
+        $result->cancelNoProcess = [
+            new OrderIssue(OrderIssue::TYPE_CANCEL_NO_PROCESS, 100, 'FW-100'),
+            new OrderIssue(OrderIssue::TYPE_CANCEL_NO_PROCESS, 101, 'FW-101'),
+            new OrderIssue(OrderIssue::TYPE_CANCEL_NO_PROCESS, 102, 'FW-102'),
+        ];
+        $result->successMissingData = [
+            new OrderIssue(OrderIssue::TYPE_SUCCESS_MISSING_DATA, 200, 'FW-200'),
+        ];
+
+        $total = $result->calculateTotal();
+
+        $this->assertSame(7, $total);
+        $this->assertSame(7, $result->totalIssues);
+    }
+
+    /**
+     * Тест: hasIssues возвращает false для пустого результата
+     */
+    public function testHasIssuesReturnsFalseWhenEmpty(): void
+    {
+        $result = new ControlReportResult();
+
+        $this->assertFalse($result->hasIssues());
+    }
+
+    /**
+     * Тест: hasIssues возвращает true при наличии проблем
+     *
+     * @dataProvider hasIssuesProvider
+     */
+    public function testHasIssuesReturnsTrueWhenIssuesExist(string $property): void
+    {
+        $result = new ControlReportResult();
+
+        if ($property === 'totalIssues') {
+            $result->totalIssues = 5;
+        } else {
+            $result->$property = [
+                new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 1, 'TEST-1'),
+            ];
+        }
+
+        $this->assertTrue($result->hasIssues());
+    }
+
+    /**
+     * Провайдер данных для testHasIssuesReturnsTrueWhenIssuesExist
+     */
+    public static function hasIssuesProvider(): array
+    {
+        return [
+            'totalIssues' => ['totalIssues'],
+            'hungInDelivery' => ['hungInDelivery'],
+            'successNoCheck' => ['successNoCheck'],
+            'cancelNoProcess' => ['cancelNoProcess'],
+            'successMissingData' => ['successMissingData'],
+        ];
+    }
+
+    /**
+     * Тест: getAllIssues объединяет все массивы
+     */
+    public function testGetAllIssuesMergesAllArrays(): void
+    {
+        $result = new ControlReportResult();
+
+        $issue1 = new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 1, 'FW-1');
+        $issue2 = new OrderIssue(OrderIssue::TYPE_SUCCESS_NO_CHECK, 2, 'YM-2');
+        $issue3 = new OrderIssue(OrderIssue::TYPE_CANCEL_NO_PROCESS, 3, 'FW-3');
+        $issue4 = new OrderIssue(OrderIssue::TYPE_SUCCESS_MISSING_DATA, 4, 'FW-4');
+
+        $result->hungInDelivery = [$issue1];
+        $result->successNoCheck = [$issue2];
+        $result->cancelNoProcess = [$issue3];
+        $result->successMissingData = [$issue4];
+
+        $allIssues = $result->getAllIssues();
+
+        $this->assertCount(4, $allIssues);
+        $this->assertSame($issue1, $allIssues[0]);
+        $this->assertSame($issue2, $allIssues[1]);
+        $this->assertSame($issue3, $allIssues[2]);
+        $this->assertSame($issue4, $allIssues[3]);
+    }
+
+    /**
+     * Тест: groupByProblemType группирует по типам проблем
+     */
+    public function testGroupByProblemTypeReturnsGroupedArray(): void
+    {
+        $result = new ControlReportResult();
+
+        $issue1 = new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 1, 'FW-1');
+        $issue2 = new OrderIssue(OrderIssue::TYPE_SUCCESS_NO_CHECK, 2, 'YM-2');
+
+        $result->hungInDelivery = [$issue1];
+        $result->successNoCheck = [$issue2];
+
+        $grouped = $result->groupByProblemType();
+
+        $this->assertArrayHasKey(OrderIssue::TYPE_HUNG_IN_DELIVERY, $grouped);
+        $this->assertArrayHasKey(OrderIssue::TYPE_SUCCESS_NO_CHECK, $grouped);
+        $this->assertArrayHasKey(OrderIssue::TYPE_CANCEL_NO_PROCESS, $grouped);
+        $this->assertArrayHasKey(OrderIssue::TYPE_SUCCESS_MISSING_DATA, $grouped);
+
+        $this->assertSame([$issue1], $grouped[OrderIssue::TYPE_HUNG_IN_DELIVERY]);
+        $this->assertSame([$issue2], $grouped[OrderIssue::TYPE_SUCCESS_NO_CHECK]);
+        $this->assertSame([], $grouped[OrderIssue::TYPE_CANCEL_NO_PROCESS]);
+        $this->assertSame([], $grouped[OrderIssue::TYPE_SUCCESS_MISSING_DATA]);
+    }
+
+    /**
+     * Тест: isSuccess возвращает true только если оба канала отправлены
+     *
+     * @dataProvider isSuccessProvider
+     */
+    public function testIsSuccessReturnsCorrectValue(bool $telegram, bool $email, bool $expected): void
+    {
+        $result = new ControlReportResult();
+        $result->telegramSent = $telegram;
+        $result->emailSent = $email;
+
+        $this->assertSame($expected, $result->isSuccess());
+    }
+
+    /**
+     * Провайдер данных для testIsSuccessReturnsCorrectValue
+     */
+    public static function isSuccessProvider(): array
+    {
+        return [
+            'both_true' => [true, true, true],
+            'both_false' => [false, false, false],
+            'only_telegram' => [true, false, false],
+            'only_email' => [false, true, false],
+        ];
+    }
+
+    /**
+     * Тест: isPartialSuccess возвращает true при частичной отправке
+     *
+     * @dataProvider isPartialSuccessProvider
+     */
+    public function testIsPartialSuccessReturnsCorrectValue(bool $telegram, bool $email, bool $expected): void
+    {
+        $result = new ControlReportResult();
+        $result->telegramSent = $telegram;
+        $result->emailSent = $email;
+
+        $this->assertSame($expected, $result->isPartialSuccess());
+    }
+
+    /**
+     * Провайдер данных для testIsPartialSuccessReturnsCorrectValue
+     */
+    public static function isPartialSuccessProvider(): array
+    {
+        return [
+            'both_true' => [true, true, false],
+            'both_false' => [false, false, false],
+            'only_telegram' => [true, false, true],
+            'only_email' => [false, true, true],
+        ];
+    }
+
+    /**
+     * Тест: getExitCode возвращает 0 при отсутствии проблем
+     */
+    public function testGetExitCodeReturnsZeroWhenNoIssues(): void
+    {
+        $result = new ControlReportResult();
+
+        $this->assertSame(0, $result->getExitCode());
+    }
+
+    /**
+     * Тест: getExitCode возвращает 0 при полном успехе
+     */
+    public function testGetExitCodeReturnsZeroWhenFullSuccess(): void
+    {
+        $result = new ControlReportResult();
+        $result->hungInDelivery = [new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 1, 'FW-1')];
+        $result->calculateTotal();
+        $result->telegramSent = true;
+        $result->emailSent = true;
+
+        $this->assertSame(0, $result->getExitCode());
+    }
+
+    /**
+     * Тест: getExitCode возвращает 2 при частичном успехе
+     */
+    public function testGetExitCodeReturnsTwoWhenPartialSuccess(): void
+    {
+        $result = new ControlReportResult();
+        $result->hungInDelivery = [new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 1, 'FW-1')];
+        $result->calculateTotal();
+        $result->telegramSent = true;
+        $result->emailSent = false;
+
+        $this->assertSame(2, $result->getExitCode());
+    }
+
+    /**
+     * Тест: getExitCode возвращает 1 при критической ошибке
+     */
+    public function testGetExitCodeReturnsOneWhenCriticalFailure(): void
+    {
+        $result = new ControlReportResult();
+        $result->hungInDelivery = [new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 1, 'FW-1')];
+        $result->calculateTotal();
+        $result->telegramSent = false;
+        $result->emailSent = false;
+
+        $this->assertSame(1, $result->getExitCode());
+    }
+
+    /**
+     * Тест: toArray возвращает все поля
+     */
+    public function testToArrayReturnsAllFields(): void
+    {
+        $result = new ControlReportResult();
+        $result->startDate = '2026-01-19 20:00:00';
+        $result->endDate = '2026-01-20 08:00:00';
+        $result->shiftName = 'Ночная смена';
+        $result->hungInDelivery = [new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 1, 'FW-1')];
+        $result->successNoCheck = [new OrderIssue(OrderIssue::TYPE_SUCCESS_NO_CHECK, 2, 'YM-2')];
+        $result->cancelNoProcess = [];
+        $result->successMissingData = [new OrderIssue(OrderIssue::TYPE_SUCCESS_MISSING_DATA, 3, 'FW-3')];
+        $result->calculateTotal();
+        $result->telegramSent = true;
+        $result->emailSent = false;
+        $result->telegramError = null;
+        $result->emailError = 'SMTP connection failed';
+        $result->statesSaved = 3;
+
+        $array = $result->toArray();
+
+        $this->assertArrayHasKey('report_date', $array);
+        $this->assertArrayHasKey('interval', $array);
+        $this->assertArrayHasKey('start_date', $array);
+        $this->assertArrayHasKey('end_date', $array);
+        $this->assertArrayHasKey('shift_name', $array);
+        $this->assertArrayHasKey('total_issues', $array);
+        $this->assertArrayHasKey('hung_in_delivery_count', $array);
+        $this->assertArrayHasKey('success_no_check_count', $array);
+        $this->assertArrayHasKey('cancel_no_process_count', $array);
+        $this->assertArrayHasKey('success_missing_data_count', $array);
+        $this->assertArrayHasKey('telegram_sent', $array);
+        $this->assertArrayHasKey('email_sent', $array);
+        $this->assertArrayHasKey('telegram_error', $array);
+        $this->assertArrayHasKey('email_error', $array);
+        $this->assertArrayHasKey('states_saved', $array);
+        $this->assertArrayHasKey('exit_code', $array);
+
+        $this->assertSame('2026-01-19 20:00:00', $array['start_date']);
+        $this->assertSame('2026-01-20 08:00:00', $array['end_date']);
+        $this->assertSame('Ночная смена', $array['shift_name']);
+        $this->assertSame(3, $array['total_issues']);
+        $this->assertSame(1, $array['hung_in_delivery_count']);
+        $this->assertSame(1, $array['success_no_check_count']);
+        $this->assertSame(0, $array['cancel_no_process_count']);
+        $this->assertSame(1, $array['success_missing_data_count']);
+        $this->assertTrue($array['telegram_sent']);
+        $this->assertFalse($array['email_sent']);
+        $this->assertNull($array['telegram_error']);
+        $this->assertSame('SMTP connection failed', $array['email_error']);
+        $this->assertSame(3, $array['states_saved']);
+        $this->assertSame(2, $array['exit_code']); // partial success
+    }
+
+    /**
+     * Тест: getFormattedDateRange форматирует диапазон дат
+     */
+    public function testGetFormattedDateRangeFormatsCorrectly(): void
+    {
+        $result = new ControlReportResult();
+        $result->startDate = '2026-01-19 20:00:00';
+        $result->endDate = '2026-01-20 08:00:00';
+        $result->shiftName = 'Ночная смена';
+
+        $formatted = $result->getFormattedDateRange();
+
+        $this->assertSame('с 20:00 19.01.2026 по 08:00 20.01.2026 (Ночная смена)', $formatted);
+    }
+
+    /**
+     * Тест: getFormattedDateRange без shiftName
+     */
+    public function testGetFormattedDateRangeWithoutShiftName(): void
+    {
+        $result = new ControlReportResult();
+        $result->startDate = '2026-01-20 08:00:00';
+        $result->endDate = '2026-01-20 20:00:00';
+        $result->shiftName = '';
+
+        $formatted = $result->getFormattedDateRange();
+
+        $this->assertSame('с 08:00 20.01.2026 по 20:00 20.01.2026', $formatted);
+    }
+
+    /**
+     * Тест: getFormattedDateRange с пустыми датами возвращает interval
+     */
+    public function testGetFormattedDateRangeReturnsIntervalWhenDatesEmpty(): void
+    {
+        $result = new ControlReportResult();
+        $result->startDate = '';
+        $result->endDate = '';
+
+        $formatted = $result->getFormattedDateRange();
+
+        $this->assertMatchesRegularExpression('/^(08:00|20:00)$/', $formatted);
+    }
+
+    /**
+     * Тест: getSummary возвращает корректную сводку
+     */
+    public function testGetSummaryReturnsCorrectString(): void
+    {
+        $result = new ControlReportResult();
+        $result->hungInDelivery = [
+            new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 1, 'FW-1'),
+            new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 2, 'FW-2'),
+        ];
+        $result->successNoCheck = [new OrderIssue(OrderIssue::TYPE_SUCCESS_NO_CHECK, 3, 'YM-3')];
+        $result->cancelNoProcess = [];
+        $result->successMissingData = [
+            new OrderIssue(OrderIssue::TYPE_SUCCESS_MISSING_DATA, 4, 'FW-4'),
+        ];
+        $result->calculateTotal();
+        $result->telegramSent = true;
+        $result->emailSent = false;
+
+        $summary = $result->getSummary();
+
+        $this->assertSame(
+            'Контроль МП: 4 проблем (завис: 2, успех без чека: 1, отмена: 0, успех без данных: 1). TG: OK, Email: FAIL',
+            $summary
+        );
+    }
+
+    /**
+     * Тест: getSummary с полным успехом
+     */
+    public function testGetSummaryWithFullSuccess(): void
+    {
+        $result = new ControlReportResult();
+        $result->telegramSent = true;
+        $result->emailSent = true;
+
+        $summary = $result->getSummary();
+
+        $this->assertStringContainsString('TG: OK', $summary);
+        $this->assertStringContainsString('Email: OK', $summary);
+    }
+}
diff --git a/erp24/tests/unit/services/dto/OrderIssueTest.php b/erp24/tests/unit/services/dto/OrderIssueTest.php
new file mode 100644 (file)
index 0000000..7262bc0
--- /dev/null
@@ -0,0 +1,344 @@
+<?php
+
+declare(strict_types=1);
+
+namespace app\tests\unit\services\dto;
+
+use Codeception\Test\Unit;
+use yii_app\services\dto\OrderIssue;
+
+/**
+ * Unit-тесты для DTO OrderIssue
+ *
+ * Покрывает:
+ * - Создание объекта через конструктор
+ * - Фабричный метод fromOrderData()
+ * - Константы типов проблем
+ * - Методы форматирования
+ * - Преобразование toArray()
+ *
+ * @covers \yii_app\services\dto\OrderIssue
+ */
+class OrderIssueTest extends Unit
+{
+    /**
+     * Тест: конструктор создаёт объект с корректными значениями
+     */
+    public function testConstructorCreatesObjectWithCorrectValues(): void
+    {
+        $issue = new OrderIssue(
+            OrderIssue::TYPE_HUNG_IN_DELIVERY,
+            123,
+            'FW-12345'
+        );
+
+        $this->assertSame(OrderIssue::TYPE_HUNG_IN_DELIVERY, $issue->problemType);
+        $this->assertSame('Завис в доставке', $issue->problemTypeLabel);
+        $this->assertSame(123, $issue->orderId);
+        $this->assertSame('FW-12345', $issue->orderNumber);
+        $this->assertSame(date('d.m.Y'), $issue->reportDate);
+        $this->assertMatchesRegularExpression('/^(08:00|20:00)$/', $issue->interval);
+        $this->assertSame(0.0, $issue->total);
+    }
+
+    /**
+     * Тест: все типы проблем имеют метки
+     */
+    public function testAllProblemTypesHaveLabels(): void
+    {
+        $expectedTypes = [
+            OrderIssue::TYPE_HUNG_IN_DELIVERY => 'Завис в доставке',
+            OrderIssue::TYPE_SUCCESS_NO_CHECK => 'Успех без чека',
+            OrderIssue::TYPE_CANCEL_NO_PROCESS => 'Отмена без обработки',
+            OrderIssue::TYPE_SUCCESS_MISSING_DATA => 'Успех без данных',
+        ];
+
+        foreach ($expectedTypes as $type => $label) {
+            $this->assertArrayHasKey($type, OrderIssue::TYPE_LABELS);
+            $this->assertSame($label, OrderIssue::TYPE_LABELS[$type]);
+        }
+    }
+
+    /**
+     * Тест: fromOrderData создаёт объект из массива данных
+     *
+     * @dataProvider orderDataProvider
+     */
+    public function testFromOrderDataCreatesIssueFromArray(string $problemType, array $orderData, array $expected): void
+    {
+        $issue = OrderIssue::fromOrderData($problemType, $orderData);
+
+        $this->assertSame($problemType, $issue->problemType);
+        $this->assertSame($expected['orderId'], $issue->orderId);
+        $this->assertSame($expected['orderNumber'], $issue->orderNumber);
+        $this->assertSame($expected['rmkStatus'], $issue->rmkStatus);
+        $this->assertSame($expected['mpStatus'], $issue->mpStatus);
+        $this->assertSame($expected['storeId'], $issue->storeId);
+        $this->assertSame($expected['storeName'], $issue->storeName);
+        $this->assertSame($expected['total'], $issue->total);
+    }
+
+    /**
+     * Провайдер данных для testFromOrderDataCreatesIssueFromArray
+     */
+    public static function orderDataProvider(): array
+    {
+        return [
+            'hung_in_delivery' => [
+                OrderIssue::TYPE_HUNG_IN_DELIVERY,
+                [
+                    'id' => 100,
+                    'marketplace_order_id' => 'FW-100',
+                    'rmk_status' => 'Передан курьеру',
+                    'rmk_status_id' => '5',
+                    'mp_status_name' => 'В доставке',
+                    'mp_status_code' => 'DELIVERY',
+                    'store_id' => 10,
+                    'store_name' => 'Магазин Центр',
+                    'marketplace_name' => 'Flowwow',
+                    'marketplace_id' => 1,
+                    'total' => 5000.50,
+                    'creation_date' => '2026-01-19 10:00:00',
+                ],
+                [
+                    'orderId' => 100,
+                    'orderNumber' => 'FW-100',
+                    'rmkStatus' => 'Передан курьеру',
+                    'mpStatus' => 'В доставке',
+                    'storeId' => 10,
+                    'storeName' => 'Магазин Центр',
+                    'total' => 5000.50,
+                ],
+            ],
+            'success_no_check_with_reason' => [
+                OrderIssue::TYPE_SUCCESS_NO_CHECK,
+                [
+                    'id' => 200,
+                    'marketplace_order_id' => 'YM-200',
+                    'rmk_status' => 'Новый',
+                    'rmk_status_id' => '1',
+                    'mp_status_name' => 'Доставлен',
+                    'mp_status_code' => 'DELIVERED',
+                    'mp_substatus_code' => 'DELIVERY_SERVICE_DELIVERED',
+                    'store_id' => 20,
+                    'store_name' => 'Магазин Север',
+                    'marketplace_name' => 'Yandex Market',
+                    'marketplace_id' => 2,
+                    'total' => 3200.00,
+                    'seller_id' => null,
+                    'check_exists' => false,
+                    'issue_reason' => 'no_seller_id',
+                ],
+                [
+                    'orderId' => 200,
+                    'orderNumber' => 'YM-200',
+                    'rmkStatus' => 'Новый',
+                    'mpStatus' => 'Доставлен',
+                    'storeId' => 20,
+                    'storeName' => 'Магазин Север',
+                    'total' => 3200.00,
+                ],
+            ],
+            'success_missing_data' => [
+                OrderIssue::TYPE_SUCCESS_MISSING_DATA,
+                [
+                    'id' => 300,
+                    'marketplace_order_id' => 'FW-300',
+                    'rmk_status' => '6. Успех',
+                    'rmk_status_id' => '6',
+                    'mp_status_name' => 'Доставлен',
+                    'mp_status_code' => 'DELIVERED',
+                    'store_id' => 30,
+                    'store_name' => 'Магазин Юг',
+                    'marketplace_name' => 'Flowwow',
+                    'marketplace_id' => 1,
+                    'total' => 6800.00,
+                    'seller_id' => '',
+                    'check_guid' => null,
+                    'issue_reason' => 'no_seller_and_check_guid',
+                ],
+                [
+                    'orderId' => 300,
+                    'orderNumber' => 'FW-300',
+                    'rmkStatus' => '6. Успех',
+                    'mpStatus' => 'Доставлен',
+                    'storeId' => 30,
+                    'storeName' => 'Магазин Юг',
+                    'total' => 6800.00,
+                ],
+            ],
+        ];
+    }
+
+    /**
+     * Тест: getMarketplaceShortName возвращает корректные сокращения
+     *
+     * @dataProvider marketplaceShortNameProvider
+     */
+    public function testGetMarketplaceShortName(?string $marketplaceName, string $expected): void
+    {
+        $issue = new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 1, 'TEST-1');
+        $issue->marketplaceName = $marketplaceName;
+
+        $this->assertSame($expected, $issue->getMarketplaceShortName());
+    }
+
+    /**
+     * Провайдер данных для testGetMarketplaceShortName
+     */
+    public static function marketplaceShortNameProvider(): array
+    {
+        return [
+            'flowwow_english' => ['Flowwow', 'FW'],
+            'flowwow_russian' => ['Флаувау', 'FW'],
+            'yandex_english' => ['Yandex Market', 'YM'],
+            'yandex_russian' => ['Яндекс.Маркет', 'YM'],
+            'null_value' => [null, '?'],
+            'unknown_marketplace' => ['Ozon', 'Oz'],
+        ];
+    }
+
+    /**
+     * Тест: getFormattedTotal возвращает отформатированную сумму
+     *
+     * @dataProvider formattedTotalProvider
+     */
+    public function testGetFormattedTotal(float $total, string $expected): void
+    {
+        $issue = new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 1, 'TEST-1');
+        $issue->total = $total;
+
+        $this->assertSame($expected, $issue->getFormattedTotal());
+    }
+
+    /**
+     * Провайдер данных для testGetFormattedTotal
+     */
+    public static function formattedTotalProvider(): array
+    {
+        return [
+            'zero' => [0.0, '0 ₽'],
+            'small_amount' => [100.0, '100 ₽'],
+            'with_thousands' => [5000.0, '5 000 ₽'],
+            'large_amount' => [150000.0, '150 000 ₽'],
+            'with_decimals_rounded' => [5999.99, '6 000 ₽'],
+        ];
+    }
+
+    /**
+     * Тест: getIssueReasonLabel возвращает корректные метки причин
+     *
+     * @dataProvider issueReasonLabelProvider
+     */
+    public function testGetIssueReasonLabel(?string $reason, ?string $expected): void
+    {
+        $issue = new OrderIssue(OrderIssue::TYPE_SUCCESS_NO_CHECK, 1, 'TEST-1');
+        $issue->issueReason = $reason;
+
+        $this->assertSame($expected, $issue->getIssueReasonLabel());
+    }
+
+    /**
+     * Провайдер данных для testGetIssueReasonLabel
+     */
+    public static function issueReasonLabelProvider(): array
+    {
+        return [
+            'no_seller_id' => ['no_seller_id', 'Нет seller_id'],
+            'no_check' => ['no_check', 'Чек не создан'],
+            'no_check_guid' => ['no_check_guid', 'Нет check_guid'],
+            'no_seller_and_check_guid' => ['no_seller_and_check_guid', 'Нет seller_id и check_guid'],
+            'unknown' => ['unknown', 'Неизвестно'],
+            'null' => [null, null],
+            'custom_reason' => ['custom_reason', 'custom_reason'],
+        ];
+    }
+
+    /**
+     * Тест: toArray возвращает все поля
+     */
+    public function testToArrayReturnsAllFields(): void
+    {
+        $issue = new OrderIssue(OrderIssue::TYPE_SUCCESS_MISSING_DATA, 500, 'FW-500');
+        $issue->rmkStatus = '6. Успех';
+        $issue->rmkStatusId = '6';
+        $issue->mpStatus = 'Доставлен';
+        $issue->mpStatusCode = 'DELIVERED';
+        $issue->mpSubstatusCode = 'DELIVERY_SERVICE_DELIVERED';
+        $issue->storeId = 50;
+        $issue->storeName = 'Тестовый магазин';
+        $issue->marketplaceName = 'Flowwow';
+        $issue->marketplaceId = 1;
+        $issue->total = 9999.99;
+        $issue->creationDate = '2026-01-20 12:00:00';
+        $issue->sellerId = '';
+        $issue->checkGuid = null;
+        $issue->checkExists = false;
+        $issue->issueReason = 'no_seller_and_check_guid';
+
+        $array = $issue->toArray();
+
+        $this->assertArrayHasKey('problem_type', $array);
+        $this->assertArrayHasKey('problem_type_label', $array);
+        $this->assertArrayHasKey('report_date', $array);
+        $this->assertArrayHasKey('interval', $array);
+        $this->assertArrayHasKey('order_id', $array);
+        $this->assertArrayHasKey('order_number', $array);
+        $this->assertArrayHasKey('rmk_status', $array);
+        $this->assertArrayHasKey('rmk_status_id', $array);
+        $this->assertArrayHasKey('mp_status', $array);
+        $this->assertArrayHasKey('mp_status_code', $array);
+        $this->assertArrayHasKey('mp_substatus_code', $array);
+        $this->assertArrayHasKey('store_id', $array);
+        $this->assertArrayHasKey('store_name', $array);
+        $this->assertArrayHasKey('marketplace_name', $array);
+        $this->assertArrayHasKey('marketplace_id', $array);
+        $this->assertArrayHasKey('total', $array);
+        $this->assertArrayHasKey('creation_date', $array);
+        $this->assertArrayHasKey('seller_id', $array);
+        $this->assertArrayHasKey('check_guid', $array);
+        $this->assertArrayHasKey('check_exists', $array);
+        $this->assertArrayHasKey('issue_reason', $array);
+        $this->assertArrayHasKey('issue_reason_label', $array);
+
+        $this->assertSame(OrderIssue::TYPE_SUCCESS_MISSING_DATA, $array['problem_type']);
+        $this->assertSame('Успех без данных', $array['problem_type_label']);
+        $this->assertSame(500, $array['order_id']);
+        $this->assertSame('FW-500', $array['order_number']);
+        $this->assertSame('no_seller_and_check_guid', $array['issue_reason']);
+        $this->assertSame('Нет seller_id и check_guid', $array['issue_reason_label']);
+    }
+
+    /**
+     * Тест: fromOrderData с пустыми данными не вызывает ошибок
+     */
+    public function testFromOrderDataWithEmptyDataDoesNotThrowError(): void
+    {
+        $issue = OrderIssue::fromOrderData(OrderIssue::TYPE_CANCEL_NO_PROCESS, []);
+
+        $this->assertSame(OrderIssue::TYPE_CANCEL_NO_PROCESS, $issue->problemType);
+        $this->assertSame(0, $issue->orderId);
+        $this->assertSame('', $issue->orderNumber);
+        $this->assertNull($issue->rmkStatus);
+        $this->assertNull($issue->mpStatus);
+        $this->assertSame(0.0, $issue->total);
+    }
+
+    /**
+     * Тест: mpStatus формируется из кодов, если mp_status_name пустой
+     */
+    public function testMpStatusFormedFromCodesWhenNameIsEmpty(): void
+    {
+        $orderData = [
+            'id' => 1,
+            'marketplace_order_id' => 'TEST-1',
+            'mp_status_name' => null,
+            'mp_status_code' => 'DELIVERED',
+            'mp_substatus_code' => 'DELIVERY_SERVICE_DELIVERED',
+        ];
+
+        $issue = OrderIssue::fromOrderData(OrderIssue::TYPE_SUCCESS_NO_CHECK, $orderData);
+
+        $this->assertSame('DELIVERED/DELIVERY_SERVICE_DELIVERED', $issue->mpStatus);
+    }
+}