]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
Переработка отчета
authorVladimir Fomichev <vladimir.fomichev@erp-flowers.ru>
Mon, 19 Jan 2026 08:37:17 +0000 (11:37 +0300)
committerVladimir Fomichev <vladimir.fomichev@erp-flowers.ru>
Mon, 19 Jan 2026 08:37:17 +0000 (11:37 +0300)
erp24/commands/MarketplaceController.php
erp24/config/params.php
erp24/migrations/m260119_100000_create_marketplace_order_daily_issues_table.php [new file with mode: 0644]
erp24/records/MarketplaceOrderDailyIssues.php [new file with mode: 0644]
erp24/records/MarketplaceOrderStatusTypes.php
erp24/services/OrderControlReportService.php [new file with mode: 0644]
erp24/services/UncheckedOrdersReportService.php [deleted file]
erp24/services/dto/ControlReportResult.php [new file with mode: 0644]
erp24/services/dto/OrderIssue.php [new file with mode: 0644]
erp24/services/dto/ReportResult.php [deleted file]

index 009804e8a9072f148637d537b30b42c666549715..9bfe21b8290c9f5e8b6a6f70dbd50bcce95e9651 100644 (file)
@@ -27,7 +27,7 @@ use OpenAPI\Client\Model;
 use GuzzleHttp;
 use yii_app\services\WhatsAppService;
 use yii_app\records\MarketplaceOrderDelivery;
-use yii_app\services\UncheckedOrdersReportService;
+use yii_app\services\OrderControlReportService;
 
 class MarketplaceController extends Controller
 {
@@ -362,71 +362,78 @@ class MarketplaceController extends Controller
 
 
     /**
-     * Ð\9eÑ\82пÑ\80авка Ð¾Ñ\82Ñ\87Ñ\91Ñ\82а Ð¾ Ð·Ð°ÐºÐ°Ð·Ð°Ñ\85 Ñ\81 Ð½ÐµÐ¿Ñ\80обиÑ\82Ñ\8bми Ñ\87еками
+     * Ð\9eÑ\82пÑ\80авлÑ\8fеÑ\82 Ð¾Ñ\82Ñ\87Ñ\91Ñ\82 ÐºÐ¾Ð½Ñ\82Ñ\80олÑ\8f Ñ\81Ñ\82аÑ\82Ñ\83Ñ\81ов Ð·Ð°ÐºÐ°Ð·Ð¾Ð² Ð\9cÐ\9f
      *
-     * Выбирает заказы в статусе DELIVERED/DELIVERY_SERVICE_DELIVERED за последние 12 часов,
-     * у которых отсутствует пробитый чек:
-     * - check_guid IS NULL
-     * - CreateChecks.status IS NULL (запись отсутствует)
-     * - CreateChecks.status = 0 (STATUS_CHECK_CREATED_ERP - создан в ERP, не отправлен в 1С)
-     * - CreateChecks.status = 8 (STATUS_CHECK_ERROR_1C - ошибка в 1С)
+     * Проверяет расхождения между статусами РМК/1С и статусами маркетплейсов:
+     * 1. "Завис в доставке" - РМК="Передан курьеру", МП НЕ "Выполнен"
+     * 2. "Успех без чека" - МП="Выполнен", РМК НЕ "Успех"
+     * 3. "Отмена без обработки" - МП="Отменён", РМК НЕ "Отказ"
      *
-     * Ð\9eÑ\82пÑ\80авлÑ\8fеÑ\82 Ð¾Ñ\82Ñ\87Ñ\91Ñ\82 Ð² Telegram (MarkdownV2) Ð¸ Ð½Ð° email (HTML).
-     * Ð\92 development Ð¾ÐºÑ\80Ñ\83жении Ð¸Ñ\81полÑ\8cзÑ\83еÑ\82 Ñ\82еÑ\81Ñ\82овÑ\8bй Ð±Ð¾Ñ\82 Ð¸ ÐºÐ°Ð½Ð°Ð».
+     * Ð\97апÑ\83Ñ\81к Ð¿Ð¾ Ñ\80аÑ\81пиÑ\81аниÑ\8e: 08:00 Ð¸ 20:00 MSK
+     * Ð\9aоманда: php yii marketplace/send-order-control-report
      *
-     * Запуск: php yii marketplace/send-unchecked-orders-report [--date=YYYY-MM-DD]
-     * Cron (UTC): 0 5,17 * * *
-     * Время MSK: 8:00 и 20:00
-     *
-     * @param string|null $date Дата для отчёта (YYYY-MM-DD), по умолчанию - сегодня
-     * @return int Exit code:
-     *             0 = успех (отчёт отправлен или нет заказов)
-     *             1 = критическая ошибка (БД, конфигурация)
-     *             2 = частичный успех (TG или email не отправлен)
+     * @param int $hours Период выборки в часах (по умолчанию 24)
+     * @param bool $onlyNew Отправлять только новые проблемы (по умолчанию true)
+     * @return int Код завершения (0 = успех, 1 = ошибка, 2 = частичный успех)
      */
-    public function actionSendUncheckedOrdersReport(?string $date = null): int
+    public function actionSendOrderControlReport(int $hours = 24, bool $onlyNew = true): int
     {
         set_time_limit(300); // 5 минут максимум
 
-        $this->stdout("Запуск отчёта о заказах с непробитыми чеками...\n", BaseConsole::FG_YELLOW);
-
-        // Валидация даты
-        if ($date !== null && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
-            $this->stderr("Неверный формат даты. Ожидается: YYYY-MM-DD\n", BaseConsole::FG_RED);
-            return ExitCode::UNSPECIFIED_ERROR;
-        }
+        $this->stdout("Запуск отчёта контроля статусов заказов МП...\n", BaseConsole::FG_YELLOW);
+        $this->stdout("Период: {$hours} часов\n", BaseConsole::FG_CYAN);
+        $this->stdout("Только новые: " . ($onlyNew ? 'да' : 'нет') . "\n", BaseConsole::FG_CYAN);
 
         try {
-            $service = new UncheckedOrdersReportService();
-            $result = $service->generateAndSendReport($date);
+            $service = new OrderControlReportService();
+            $result = $service->generateControlReport($hours, $onlyNew);
 
-            if (!$result->hasOrders()) {
-                $this->stdout("Заказы с непробитыми чеками не найдены за период.\n", BaseConsole::FG_GREEN);
-                return ExitCode::OK;
-            }
+            // Вывод результатов
+            $this->stdout("\n--- Результаты контроля статусов ---\n", BaseConsole::FG_YELLOW);
+            $this->stdout("Дата отчёта: {$result->reportDate}\n", BaseConsole::FG_CYAN);
+            $this->stdout("Интервал: {$result->interval}\n", BaseConsole::FG_CYAN);
+
+            $this->stdout("\nПроблемы по типам:\n", BaseConsole::FG_YELLOW);
+            $this->stdout("  🚚 Завис в доставке: {$result->getHungInDeliveryCount()}\n",
+                $result->getHungInDeliveryCount() > 0 ? BaseConsole::FG_RED : BaseConsole::FG_GREEN);
+            $this->stdout("  ✅❌ Успех без чека: {$result->getSuccessNoCheckCount()}\n",
+                $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->totalOrders}\n", BaseConsole::FG_CYAN);
-            $this->stdout("Магазинов: {$result->storesCount}\n", BaseConsole::FG_CYAN);
-            $this->stdout("Общая сумма: " . number_format($result->totalAmount, 0, '.', ' ') . " руб.\n", BaseConsole::FG_CYAN);
+            $this->stdout("\nВсего проблем: {$result->totalIssues}\n",
+                $result->totalIssues > 0 ? BaseConsole::FG_RED : BaseConsole::FG_GREEN);
+            $this->stdout("Сохранено состояний: {$result->statesSaved}\n", BaseConsole::FG_CYAN);
+
+            // Статус отправки
+            $this->stdout("\nОтправка уведомлений:\n", BaseConsole::FG_YELLOW);
 
             if ($result->telegramSent) {
-                $this->stdout("Telegram: отправлено\n", BaseConsole::FG_GREEN);
+                $this->stdout("  Telegram: ✅ отправлено\n", BaseConsole::FG_GREEN);
             } else {
-                $this->stdout("Telegram: ошибка - {$result->telegramError}\n", BaseConsole::FG_RED);
+                $errorMsg = $result->telegramError ?? 'неизвестная ошибка';
+                $this->stdout("  Telegram: ❌ ошибка - {$errorMsg}\n", BaseConsole::FG_RED);
             }
 
             if ($result->emailSent) {
-                $this->stdout("Email: отправлено\n", BaseConsole::FG_GREEN);
+                $this->stdout("  Email: ✅ отправлено\n", BaseConsole::FG_GREEN);
             } else {
-                $this->stdout("Email: ошибка - {$result->emailError}\n", BaseConsole::FG_RED);
+                $errorMsg = $result->emailError ?? 'неизвестная ошибка';
+                $this->stdout("  Email: ❌ ошибка - {$errorMsg}\n", BaseConsole::FG_RED);
             }
 
+            // Итоговый статус
             $exitCode = $result->getExitCode();
 
+            $this->stdout("\n", BaseConsole::FG_CYAN);
             if ($exitCode === 0) {
-                $this->stdout("Отчёт успешно отправлен.\n", BaseConsole::FG_GREEN);
+                if ($result->hasIssues()) {
+                    $this->stdout("Отчёт успешно отправлен.\n", BaseConsole::FG_GREEN);
+                } else {
+                    $this->stdout("Проблемных заказов не найдено.\n", BaseConsole::FG_GREEN);
+                }
             } elseif ($exitCode === 2) {
-                $this->stdout("Отчёт отправлен частично.\n", BaseConsole::FG_YELLOW);
+                $this->stdout("Отчёт отправлен частично (проверьте логи).\n", BaseConsole::FG_YELLOW);
             } else {
                 $this->stdout("Не удалось отправить отчёт.\n", BaseConsole::FG_RED);
             }
@@ -435,7 +442,8 @@ class MarketplaceController extends Controller
 
         } catch (\Exception $e) {
             $this->stderr("Критическая ошибка: {$e->getMessage()}\n", BaseConsole::FG_RED);
-            Yii::error("Ошибка отчёта о непробитых чеках: " . $e->getMessage(), 'marketplace-checks');
+            $this->stderr("Trace: {$e->getTraceAsString()}\n", BaseConsole::FG_RED);
+            Yii::error("Ошибка отчёта контроля статусов МП: " . $e->getMessage(), 'marketplace-control');
             return ExitCode::UNSPECIFIED_ERROR;
         }
     }
index dbcd544ddb3609d973cfef750e77a0f8c4eaabaf..8997e90821d8e16a2e03a184fbec663a66d59b17 100644 (file)
@@ -26,7 +26,7 @@ return [
         'retry_delay_seconds' => 5,
         'telegram_max_message_length' => 4000,
         'telegram_chat_id_dev' => getenv('TELEGRAM_UNCHECKED_ORDERS_CHAT_ID_DEV') ?: '-1001861631125',
-        'telegram_chat_id_prod' => getenv('TELEGRAM_UNCHECKED_ORDERS_CHAT_ID_PROD') ?: '',
+        'telegram_chat_id_prod' => getenv('TELEGRAM_UNCHECKED_ORDERS_CHAT_ID_PROD') ?: '4886272326',
         'email_recipients' => array_filter(explode(',', getenv('UNCHECKED_ORDERS_EMAIL_RECIPIENTS') ?: 'vladimir.fomichev@erp-flowers.ru')),
         'email_subject' => 'Отчёт о заказах с непробитыми чеками',
     ],
diff --git a/erp24/migrations/m260119_100000_create_marketplace_order_daily_issues_table.php b/erp24/migrations/m260119_100000_create_marketplace_order_daily_issues_table.php
new file mode 100644 (file)
index 0000000..72a6604
--- /dev/null
@@ -0,0 +1,61 @@
+<?php
+
+use yii\db\Migration;
+
+/**
+ * Миграция создания таблицы marketplace_order_daily_issues
+ *
+ * Таблица хранит состояние проблемных заказов маркетплейса для отслеживания
+ * расхождений статусов между РМК/1С и МП.
+ *
+ * Используется для:
+ * - Определения новых проблемных заказов (ранее не было в таблице)
+ * - Отслеживания истории проблем по заказам
+ * - Фильтрации уже известных проблем
+ */
+class m260119_100000_create_marketplace_order_daily_issues_table extends Migration
+{
+    private const TABLE_NAME = 'erp24.marketplace_order_daily_issues';
+
+    /**
+     * {@inheritdoc}
+     */
+    public function safeUp()
+    {
+        $this->createTable(self::TABLE_NAME, [
+            'id' => $this->primaryKey(),
+            'order_id' => $this->integer()->notNull()->comment('ID заказа'),
+            'marketplace_order_id' => $this->string(64)->notNull()->comment('Номер заказа в МП'),
+            'problem_type' => $this->string(32)->notNull()->comment('Тип проблемы: hung_in_delivery, success_no_check, cancel_no_process'),
+            'report_date' => $this->date()->notNull()->comment('Дата отчёта'),
+            'interval' => $this->string(8)->notNull()->comment('Интервал проверки: 08:00 или 20:00'),
+            'rmk_status_id' => $this->string(16)->null()->comment('Код статуса РМК/1С'),
+            'rmk_status' => $this->string(64)->null()->comment('Название статуса РМК/1С'),
+            'mp_status_code' => $this->string(64)->null()->comment('Код статуса МП'),
+            'mp_substatus_code' => $this->string(64)->null()->comment('Код субстатуса МП'),
+            'mp_status' => $this->string(128)->null()->comment('Название статуса МП'),
+            'store_id' => $this->integer()->null()->comment('ID магазина'),
+            'store_name' => $this->string(128)->null()->comment('Название магазина'),
+            'marketplace_id' => $this->integer()->null()->comment('ID маркетплейса'),
+            'marketplace_name' => $this->string(64)->null()->comment('Название маркетплейса'),
+            'total' => $this->decimal(10, 2)->defaultValue(0)->comment('Сумма заказа'),
+            'is_notified' => $this->boolean()->defaultValue(false)->comment('Отправлено уведомление'),
+            'notified_at' => $this->timestamp()->null()->comment('Время отправки уведомления'),
+            'is_resolved' => $this->boolean()->defaultValue(false)->comment('Проблема решена'),
+            'resolved_at' => $this->timestamp()->null()->comment('Время решения'),
+            'created_at' => $this->timestamp()->defaultExpression('CURRENT_TIMESTAMP')->comment('Время создания записи'),
+            'updated_at' => $this->timestamp()->null()->comment('Время обновления'),
+        ]);
+
+        // Комментарий к таблице
+        $this->addCommentOnTable(self::TABLE_NAME, 'Таблица состояния проблемных заказов МП для контроля статусов');
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function safeDown()
+    {
+        $this->dropTable(self::TABLE_NAME);
+    }
+}
diff --git a/erp24/records/MarketplaceOrderDailyIssues.php b/erp24/records/MarketplaceOrderDailyIssues.php
new file mode 100644 (file)
index 0000000..65f0a0b
--- /dev/null
@@ -0,0 +1,328 @@
+<?php
+
+namespace yii_app\records;
+
+use Yii;
+use yii\behaviors\TimestampBehavior;
+use yii\db\Expression;
+use yii_app\services\dto\OrderIssue;
+
+/**
+ * Модель для таблицы marketplace_order_daily_issues
+ *
+ * Хранит состояние проблемных заказов маркетплейса для контроля статусов.
+ * Позволяет отслеживать:
+ * - Новые проблемы (которые появились с последней проверки)
+ * - Историю проблем по заказам
+ * - Решённые и нерешённые проблемы
+ *
+ * @property int $id ID записи
+ * @property int $order_id ID заказа
+ * @property string $marketplace_order_id Номер заказа в МП
+ * @property string $problem_type Тип проблемы: hung_in_delivery, success_no_check, cancel_no_process
+ * @property string $report_date Дата отчёта (Y-m-d)
+ * @property string $interval Интервал проверки: 08:00 или 20:00
+ * @property string|null $rmk_status_id Код статуса РМК/1С
+ * @property string|null $rmk_status Название статуса РМК/1С
+ * @property string|null $mp_status_code Код статуса МП
+ * @property string|null $mp_substatus_code Код субстатуса МП
+ * @property string|null $mp_status Название статуса МП
+ * @property int|null $store_id ID магазина
+ * @property string|null $store_name Название магазина
+ * @property int|null $marketplace_id ID маркетплейса
+ * @property string|null $marketplace_name Название маркетплейса
+ * @property float $total Сумма заказа
+ * @property bool $is_notified Отправлено уведомление
+ * @property string|null $notified_at Время отправки уведомления
+ * @property bool $is_resolved Проблема решена
+ * @property string|null $resolved_at Время решения
+ * @property string $created_at Время создания записи
+ * @property string|null $updated_at Время обновления
+ *
+ * @property MarketplaceOrders $order Связанный заказ
+ * @property Store $store Связанный магазин
+ */
+class MarketplaceOrderDailyIssues extends \yii\db\ActiveRecord
+{
+    /**
+     * Типы проблем (алиасы из DTO)
+     */
+    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_LABELS = OrderIssue::TYPE_LABELS;
+
+    /**
+     * {@inheritdoc}
+     */
+    public function behaviors()
+    {
+        return [
+            [
+                'class' => TimestampBehavior::class,
+                'createdAtAttribute' => 'created_at',
+                'updatedAtAttribute' => 'updated_at',
+                'value' => new Expression('CURRENT_TIMESTAMP'),
+            ],
+        ];
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public static function tableName()
+    {
+        return 'marketplace_order_daily_issues';
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function rules()
+    {
+        return [
+            [['order_id', 'marketplace_order_id', 'problem_type', 'report_date', 'interval'], 'required'],
+            [['order_id', 'store_id', 'marketplace_id'], 'integer'],
+            [['report_date', 'notified_at', 'resolved_at', 'created_at', 'updated_at'], 'safe'],
+            [['total'], 'number'],
+            [['is_notified', 'is_resolved'], 'boolean'],
+            [['marketplace_order_id', 'mp_status_code', 'mp_substatus_code', 'marketplace_name'], 'string', 'max' => 64],
+            [['problem_type'], 'string', 'max' => 32],
+            [['interval'], 'string', 'max' => 8],
+            [['rmk_status_id'], 'string', 'max' => 16],
+            [['rmk_status'], 'string', 'max' => 64],
+            [['mp_status', 'store_name'], 'string', 'max' => 128],
+            [['problem_type'], 'in', 'range' => [
+                self::TYPE_HUNG_IN_DELIVERY,
+                self::TYPE_SUCCESS_NO_CHECK,
+                self::TYPE_CANCEL_NO_PROCESS,
+            ]],
+            [['interval'], 'in', 'range' => ['08:00', '20:00']],
+            [['order_id', 'problem_type', 'report_date', 'interval'], 'unique', 'targetAttribute' => ['order_id', 'problem_type', 'report_date', 'interval']],
+            [['order_id'], 'exist', 'skipOnError' => true, 'targetClass' => MarketplaceOrders::class, 'targetAttribute' => ['order_id' => 'id']],
+        ];
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function attributeLabels()
+    {
+        return [
+            'id' => 'ID',
+            'order_id' => 'ID заказа',
+            'marketplace_order_id' => 'Номер заказа в МП',
+            'problem_type' => 'Тип проблемы',
+            'report_date' => 'Дата отчёта',
+            'interval' => 'Интервал проверки',
+            'rmk_status_id' => 'Код статуса РМК/1С',
+            'rmk_status' => 'Статус РМК/1С',
+            'mp_status_code' => 'Код статуса МП',
+            'mp_substatus_code' => 'Код субстатуса МП',
+            'mp_status' => 'Статус МП',
+            'store_id' => 'ID магазина',
+            'store_name' => 'Магазин',
+            'marketplace_id' => 'ID маркетплейса',
+            'marketplace_name' => 'Маркетплейс',
+            'total' => 'Сумма заказа',
+            'is_notified' => 'Уведомление отправлено',
+            'notified_at' => 'Время уведомления',
+            'is_resolved' => 'Проблема решена',
+            'resolved_at' => 'Время решения',
+            'created_at' => 'Создано',
+            'updated_at' => 'Обновлено',
+        ];
+    }
+
+    /**
+     * Связь с заказом маркетплейса
+     *
+     * @return \yii\db\ActiveQuery
+     */
+    public function getOrder()
+    {
+        return $this->hasOne(MarketplaceOrders::class, ['id' => 'order_id']);
+    }
+
+    /**
+     * Связь с магазином
+     *
+     * @return \yii\db\ActiveQuery
+     */
+    public function getStore()
+    {
+        return $this->hasOne(Store::class, ['id' => 'store_id']);
+    }
+
+    /**
+     * Получает человекочитаемую метку типа проблемы
+     *
+     * @return string
+     */
+    public function getProblemTypeLabel(): string
+    {
+        return self::TYPE_LABELS[$this->problem_type] ?? $this->problem_type;
+    }
+
+    /**
+     * Создаёт запись из DTO OrderIssue
+     *
+     * @param OrderIssue $issue DTO проблемного заказа
+     * @return self
+     */
+    public static function fromOrderIssue(OrderIssue $issue): self
+    {
+        $model = new self();
+        $model->order_id = $issue->orderId;
+        $model->marketplace_order_id = $issue->orderNumber;
+        $model->problem_type = $issue->problemType;
+        $model->report_date = date('Y-m-d', strtotime($issue->reportDate));
+        $model->interval = $issue->interval;
+        $model->rmk_status_id = $issue->rmkStatusId;
+        $model->rmk_status = $issue->rmkStatus;
+        $model->mp_status_code = $issue->mpStatusCode;
+        $model->mp_substatus_code = $issue->mpSubstatusCode;
+        $model->mp_status = $issue->mpStatus;
+        $model->store_id = $issue->storeId;
+        $model->store_name = $issue->storeName;
+        $model->marketplace_id = $issue->marketplaceId;
+        $model->marketplace_name = $issue->marketplaceName;
+        $model->total = $issue->total;
+
+        return $model;
+    }
+
+    /**
+     * Конвертирует в DTO OrderIssue
+     *
+     * @return OrderIssue
+     */
+    public function toOrderIssue(): OrderIssue
+    {
+        $issue = new OrderIssue(
+            $this->problem_type,
+            $this->order_id,
+            $this->marketplace_order_id
+        );
+
+        $issue->reportDate = date('d.m.Y', strtotime($this->report_date));
+        $issue->interval = $this->interval;
+        $issue->rmkStatusId = $this->rmk_status_id;
+        $issue->rmkStatus = $this->rmk_status;
+        $issue->mpStatusCode = $this->mp_status_code;
+        $issue->mpSubstatusCode = $this->mp_substatus_code;
+        $issue->mpStatus = $this->mp_status;
+        $issue->storeId = $this->store_id;
+        $issue->storeName = $this->store_name;
+        $issue->marketplaceId = $this->marketplace_id;
+        $issue->marketplaceName = $this->marketplace_name;
+        $issue->total = (float)$this->total;
+        $issue->creationDate = null;
+
+        return $issue;
+    }
+
+    /**
+     * Помечает проблему как решённую
+     *
+     * @return bool
+     */
+    public function markAsResolved(): bool
+    {
+        $this->is_resolved = true;
+        $this->resolved_at = date('Y-m-d H:i:s');
+
+        return $this->save(false, ['is_resolved', 'resolved_at', 'updated_at']);
+    }
+
+    /**
+     * Помечает проблему как уведомлённую
+     *
+     * @return bool
+     */
+    public function markAsNotified(): bool
+    {
+        $this->is_notified = true;
+        $this->notified_at = date('Y-m-d H:i:s');
+
+        return $this->save(false, ['is_notified', 'notified_at', 'updated_at']);
+    }
+
+    /**
+     * Находит нерешённые проблемы за указанный период
+     *
+     * @param string|null $fromDate Дата начала (Y-m-d)
+     * @param string|null $toDate Дата конца (Y-m-d)
+     * @return array
+     */
+    public static function findUnresolved(?string $fromDate = null, ?string $toDate = null): array
+    {
+        $query = self::find()->where(['is_resolved' => false]);
+
+        if ($fromDate !== null) {
+            $query->andWhere(['>=', 'report_date', $fromDate]);
+        }
+
+        if ($toDate !== null) {
+            $query->andWhere(['<=', 'report_date', $toDate]);
+        }
+
+        return $query->orderBy(['created_at' => SORT_DESC])->all();
+    }
+
+    /**
+     * Находит проблемы по заказу
+     *
+     * @param int $orderId ID заказа
+     * @return array
+     */
+    public static function findByOrderId(int $orderId): array
+    {
+        return self::find()
+            ->where(['order_id' => $orderId])
+            ->orderBy(['created_at' => SORT_DESC])
+            ->all();
+    }
+
+    /**
+     * Проверяет существование проблемы
+     *
+     * @param int $orderId ID заказа
+     * @param string $problemType Тип проблемы
+     * @param string $reportDate Дата отчёта (Y-m-d)
+     * @param string $interval Интервал (08:00 или 20:00)
+     * @return bool
+     */
+    public static function issueExists(int $orderId, string $problemType, string $reportDate, string $interval): bool
+    {
+        return self::find()
+            ->where([
+                'order_id' => $orderId,
+                'problem_type' => $problemType,
+                'report_date' => $reportDate,
+                'interval' => $interval,
+            ])
+            ->exists();
+    }
+
+    /**
+     * Получает статистику по типам проблем за период
+     *
+     * @param string $fromDate Дата начала (Y-m-d)
+     * @param string $toDate Дата конца (Y-m-d)
+     * @return array
+     */
+    public static function getStatsByProblemType(string $fromDate, string $toDate): array
+    {
+        return self::find()
+            ->select(['problem_type', 'COUNT(*) as count'])
+            ->where(['between', 'report_date', $fromDate, $toDate])
+            ->groupBy(['problem_type'])
+            ->asArray()
+            ->all();
+    }
+}
index 1b3c0bc9962cf53db4128321756eee887af462d6..da8c0797800a57f51fbd1f90990611928bd9bdbf 100644 (file)
@@ -22,6 +22,18 @@ class MarketplaceOrderStatusTypes extends \yii\db\ActiveRecord
     const DELIVERED_CODE = 'DELIVERED';
     const DELIVERY_SERVICE_DELIVERED_CODE = 'DELIVERY_SERVICE_DELIVERED';
 
+    /**
+     * Статус "Передан в службу доставки"
+     * Используется для проверки "Завис в доставке"
+     */
+    const DELIVERY_CODE = 'DELIVERY';
+
+    /**
+     * Субстатус "Курьер получил заказ"
+     * Используется вместе с DELIVERY_CODE для проверки "Завис в доставке"
+     */
+    const COURIER_RECEIVED_CODE = 'COURIER_RECEIVED';
+
     public function behaviors()
     {
         return [
diff --git a/erp24/services/OrderControlReportService.php b/erp24/services/OrderControlReportService.php
new file mode 100644 (file)
index 0000000..3adaa31
--- /dev/null
@@ -0,0 +1,1055 @@
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\services;
+
+use Yii;
+use yii_app\records\MarketplaceOrderStatusTypes;
+use yii_app\records\MarketplaceOrderDailyIssues;
+use yii_app\services\dto\OrderIssue;
+use yii_app\services\dto\ControlReportResult;
+
+/**
+ * Сервис контроля статусов заказов маркетплейса
+ *
+ * Выявляет расхождения между статусами РМК/1С и статусами маркетплейсов.
+ * Три типа проблем:
+ * 1. "Завис в доставке" - РМК="Передан курьеру", МП≠"Выполнен"
+ * 2. "Успех без чека" - МП="Выполнен", РМК≠"6. Успех"
+ * 3. "Отмена без обработки" - МП="Отменён", РМК≠"Отказ"
+ *
+ * Запускается по расписанию 08:00 и 20:00 MSK.
+ */
+class OrderControlReportService
+{
+    /**
+     * Период выборки заказов в часах (по умолчанию)
+     */
+    public const REPORT_PERIOD_HOURS = 12;
+
+    /**
+     * Максимальное количество попыток отправки
+     */
+    public const MAX_RETRIES = 3;
+
+    /**
+     * Задержка между попытками в секундах
+     */
+    public const RETRY_DELAY_SECONDS = 5;
+
+    /**
+     * Максимальная длина сообщения Telegram
+     */
+    public const TELEGRAM_MAX_LENGTH = 4000;
+
+    /**
+     * Часовой пояс для отчёта
+     */
+    public const TIMEZONE = 'Europe/Moscow';
+
+    /**
+     * Telegram bot token для development
+     */
+    private const TELEGRAM_BOT_DEV = '8063257458:AAGnMf4cxwJWlYLF1wS_arn4PrOaLs9ERQQ';
+
+    /**
+     * Telegram bot token для production
+     */
+    private const TELEGRAM_BOT_PROD = '5456741805:AAG7xOSiYDwUdV5NMb2v9vh8CWzEczDP4yU';
+
+    /**
+     * Статусы 1С для проверок
+     */
+    private const RMK_STATUS_COURIER = ['1004', '1011']; // "Передан курьеру"
+    private const RMK_STATUS_SUCCESS = ['1005', '1012']; // "Успех"
+    private const RMK_STATUS_CANCEL = ['1006', '1013'];  // "Отказ"
+
+    /**
+     * Конфигурация отчёта
+     */
+    private array $config;
+
+    public function __construct()
+    {
+        $this->config = Yii::$app->params['MARKETPLACE_ORDER_CONTROL_REPORT'] ?? [];
+    }
+
+    /**
+     * Генерирует отчёт контроля статусов заказов МП
+     *
+     * @param int $hoursAgo Период выборки в часах
+     * @param bool $onlyNew Отправлять только новые проблемы
+     * @return ControlReportResult
+     */
+    public function generateControlReport(int $hoursAgo = 24, bool $onlyNew = true): ControlReportResult
+    {
+        $result = new ControlReportResult();
+
+        $this->logInfo('Запуск отчёта контроля статусов МП', [
+            'hours_ago' => $hoursAgo,
+            'only_new' => $onlyNew,
+        ]);
+
+        try {
+            // 1. Получаем кандидатов "Завис в доставке" и сохраняем их состояние
+            $hungCandidates = $this->getHungInDeliveryCandidates($hoursAgo);
+            $this->saveHungInDeliveryCandidates($hungCandidates);
+
+            // 2. Фильтруем "Завис в доставке" - только те, что были в предыдущей проверке
+            $hungInDelivery = $this->filterHungInDeliveryByPreviousState($hungCandidates);
+
+            // 3. Получаем остальные типы проблем
+            $successNoCheck = $this->getSuccessNoCheckOrders($hoursAgo);
+            $cancelNoProcess = $this->getCancelNoProcessOrders($hoursAgo);
+
+            // 4. Фильтруем только новые для остальных типов, если требуется
+            if ($onlyNew) {
+                $prevSuccess = $this->loadPreviousIssues(OrderIssue::TYPE_SUCCESS_NO_CHECK);
+                $prevCancel = $this->loadPreviousIssues(OrderIssue::TYPE_CANCEL_NO_PROCESS);
+
+                $successNoCheck = $this->filterNewIssues($successNoCheck, $prevSuccess);
+                $cancelNoProcess = $this->filterNewIssues($cancelNoProcess, $prevCancel);
+            }
+
+            $result->hungInDelivery = $hungInDelivery;
+            $result->successNoCheck = $successNoCheck;
+            $result->cancelNoProcess = $cancelNoProcess;
+            $result->calculateTotal();
+
+            // 5. Сохраняем состояние проблем (кроме кандидатов hung_in_delivery, они уже сохранены)
+            $issuesToSave = array_merge($hungInDelivery, $successNoCheck, $cancelNoProcess);
+            $result->statesSaved = $this->saveControlIssues($issuesToSave);
+
+            // 6. Отправляем уведомления только если есть проблемы
+            if ($result->hasIssues()) {
+                // Telegram
+                $telegramMessage = $this->formatTelegramControlReport($result);
+                $result->telegramSent = $this->sendToTelegram($telegramMessage);
+                if (!$result->telegramSent) {
+                    $result->telegramError = 'Не удалось отправить в Telegram';
+                }
+
+                // Email
+                $emailHtml = $this->formatEmailControlReport($result);
+                $result->emailSent = $this->sendToEmail($emailHtml);
+                if (!$result->emailSent) {
+                    $result->emailError = 'Не удалось отправить Email';
+                }
+
+                // Помечаем отправленные
+                $this->markIssuesAsNotified($issuesToSave);
+            } else {
+                $result->telegramSent = true;
+                $result->emailSent = true;
+                $this->logInfo('Нет проблемных заказов, уведомления не требуются');
+            }
+
+            $this->logInfo('Отчёт контроля статусов завершён', $result->toArray());
+
+        } catch (\Exception $e) {
+            $this->logError('Ошибка генерации отчёта контроля', [
+                'error' => $e->getMessage(),
+                'trace' => $e->getTraceAsString(),
+            ]);
+            $result->telegramError = $e->getMessage();
+            $result->emailError = $e->getMessage();
+        }
+
+        return $result;
+    }
+
+    /**
+     * Получает кандидатов "Завис в доставке"
+     *
+     * Критерий: РМК статус = "Передан курьеру" (1004/1011)
+     *           + МП статус НЕ "Выполнен" (НЕ DELIVERED и НЕ DELIVERY_SERVICE_DELIVERED)
+     *
+     * ВАЖНО: Это только кандидаты! Заказ становится проблемой "Завис в доставке"
+     * только если он был кандидатом в ПРЕДЫДУЩЕЙ проверке с тем же статусом РМК.
+     *
+     * @param int $hoursAgo Период выборки в часах (по умолчанию 24)
+     * @return OrderIssue[]
+     */
+    public function getHungInDeliveryCandidates(int $hoursAgo = 24): array
+    {
+        $this->logInfo('Выборка кандидатов "Завис в доставке"', ['hours_ago' => $hoursAgo]);
+
+        $startDate = new \DateTime('now', new \DateTimeZone(self::TIMEZONE));
+        $startDate->modify("-{$hoursAgo} hours");
+        $startDateStr = $startDate->format('Y-m-d H:i:s');
+
+        // Выбираем заказы с РМК-статусом "Передан курьеру", где МП-статус НЕ "Выполнен"
+        $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.status_processing_1c as rmk_status_id,
+                mocs.name as rmk_status,
+                most.code as mp_status_code,
+                mosub.code as mp_substatus_code,
+                most.name as mp_status_name
+            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::text = mo.status_processing_1c
+            LEFT JOIN marketplace_order_status_types most ON most.id = mo.status_id
+            LEFT JOIN marketplace_order_status_types mosub ON mosub.id = mo.substatus_id
+            WHERE mo.fake = 0
+              AND mo.status_processing_1c IN (:rmk_1004, :rmk_1011)
+              AND mo.updated_at >= :start_date
+              AND (
+                  most.code IS NULL
+                  OR (most.code != :delivered AND mosub.code IS DISTINCT FROM :delivery_service_delivered)
+              )
+            ORDER BY cs.name ASC, mo.creation_date DESC
+        ";
+
+        $orders = Yii::$app->db->createCommand($sql, [
+            ':rmk_1004' => '1004',
+            ':rmk_1011' => '1011',
+            ':start_date' => $startDateStr,
+            ':delivered' => MarketplaceOrderStatusTypes::DELIVERED_CODE,
+            ':delivery_service_delivered' => MarketplaceOrderStatusTypes::DELIVERY_SERVICE_DELIVERED_CODE,
+        ])->queryAll();
+
+        $issues = [];
+        foreach ($orders as $orderData) {
+            $issues[] = OrderIssue::fromOrderData(OrderIssue::TYPE_HUNG_IN_DELIVERY, $orderData);
+        }
+
+        $this->logInfo('Найдено кандидатов "Завис в доставке"', ['count' => count($issues)]);
+
+        return $issues;
+    }
+
+    /**
+     * Сохраняет кандидатов "Завис в доставке" для сравнения в следующей проверке
+     *
+     * Использует отдельный problem_type для хранения состояния кандидатов.
+     *
+     * @param OrderIssue[] $candidates Массив кандидатов
+     * @return int Количество сохранённых записей
+     */
+    private function saveHungInDeliveryCandidates(array $candidates): int
+    {
+        $saved = 0;
+        $reportDate = date('Y-m-d');
+        $interval = (int)date('H') < 12 ? '08:00' : '20:00';
+
+        foreach ($candidates as $candidate) {
+            // Проверяем, не существует ли уже такая запись
+            if (MarketplaceOrderDailyIssues::issueExists(
+                $candidate->orderId,
+                $candidate->problemType,
+                $reportDate,
+                $interval
+            )) {
+                continue;
+            }
+
+            $model = MarketplaceOrderDailyIssues::fromOrderIssue($candidate);
+
+            if ($model->save()) {
+                $saved++;
+            } else {
+                $this->logWarning('Не удалось сохранить кандидата hung_in_delivery', [
+                    'order_id' => $candidate->orderId,
+                    'errors' => $model->getErrors(),
+                ]);
+            }
+        }
+
+        $this->logInfo('Сохранено кандидатов "Завис в доставке"', ['count' => $saved, 'total' => count($candidates)]);
+
+        return $saved;
+    }
+
+    /**
+     * Фильтрует кандидатов "Завис в доставке" по предыдущему состоянию
+     *
+     * Возвращает только те заказы, которые были в ПРЕДЫДУЩЕЙ проверке
+     * с тем же статусом РМК (т.е. статус не изменился).
+     *
+     * @param OrderIssue[] $candidates Текущие кандидаты
+     * @return OrderIssue[] Подтверждённые проблемы
+     */
+    private function filterHungInDeliveryByPreviousState(array $candidates): array
+    {
+        // Определяем предыдущий интервал
+        $currentHour = (int)date('H');
+        $currentInterval = $currentHour < 12 ? '08:00' : '20:00';
+
+        // Предыдущий интервал: если сейчас 08:00, то предыдущий был 20:00 вчера
+        // Если сейчас 20:00, то предыдущий был 08:00 сегодня
+        if ($currentInterval === '08:00') {
+            $prevDate = date('Y-m-d', strtotime('-1 day'));
+            $prevInterval = '20:00';
+        } else {
+            $prevDate = date('Y-m-d');
+            $prevInterval = '08:00';
+        }
+
+        // Получаем предыдущие записи кандидатов со статусом "Передан курьеру"
+        $previousRecords = MarketplaceOrderDailyIssues::find()
+            ->where([
+                'problem_type' => OrderIssue::TYPE_HUNG_IN_DELIVERY,
+                'report_date' => $prevDate,
+                'interval' => $prevInterval,
+            ])
+            ->andWhere(['rmk_status_id' => self::RMK_STATUS_COURIER])
+            ->indexBy('order_id')
+            ->asArray()
+            ->all();
+
+        $this->logInfo('Загружено предыдущих кандидатов "Завис в доставке"', [
+            'count' => count($previousRecords),
+            'prev_date' => $prevDate,
+            'prev_interval' => $prevInterval,
+        ]);
+
+        // Фильтруем: оставляем только те заказы, которые были в предыдущей проверке
+        $confirmedIssues = [];
+        foreach ($candidates as $candidate) {
+            if (isset($previousRecords[$candidate->orderId])) {
+                // Заказ был кандидатом в предыдущей проверке с тем же статусом → подтверждённая проблема
+                $confirmedIssues[] = $candidate;
+            }
+        }
+
+        $this->logInfo('Подтверждено проблем "Завис в доставке"', [
+            'candidates' => count($candidates),
+            'confirmed' => count($confirmedIssues),
+        ]);
+
+        return $confirmedIssues;
+    }
+
+    /**
+     * Получает заказы типа "Успех без чека"
+     *
+     * Критерий: МП статус = "Выполнен" (DELIVERED или DELIVERY_SERVICE_DELIVERED)
+     *           + РМК статус НЕ "Успех" (НЕ 1005/1012)
+     *
+     * @param int $hoursAgo Период выборки в часах (по умолчанию 24)
+     * @return OrderIssue[]
+     */
+    public function getSuccessNoCheckOrders(int $hoursAgo = 24): array
+    {
+        $this->logInfo('Выборка заказов "Успех без чека"', ['hours_ago' => $hoursAgo]);
+
+        $startDate = new \DateTime('now', new \DateTimeZone(self::TIMEZONE));
+        $startDate->modify("-{$hoursAgo} hours");
+        $startDateStr = $startDate->format('Y-m-d H:i:s');
+
+        // Выбираем заказы с МП-статусом "Выполнен", где РМК-статус НЕ "Успех"
+        $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.status_processing_1c as rmk_status_id,
+                mocs.name as rmk_status,
+                most.code as mp_status_code,
+                mosub.code as mp_substatus_code,
+                most.name as mp_status_name
+            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::text = mo.status_processing_1c
+            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 (
+                  most.code = :delivered
+                  OR mosub.code = :delivery_service_delivered
+              )
+              AND (
+                  mo.status_processing_1c IS NULL
+                  OR mo.status_processing_1c NOT IN (:rmk_1005, :rmk_1012)
+              )
+            ORDER BY cs.name ASC, mo.creation_date DESC
+        ";
+
+        $orders = Yii::$app->db->createCommand($sql, [
+            ':start_date' => $startDateStr,
+            ':delivered' => MarketplaceOrderStatusTypes::DELIVERED_CODE,
+            ':delivery_service_delivered' => MarketplaceOrderStatusTypes::DELIVERY_SERVICE_DELIVERED_CODE,
+            ':rmk_1005' => '1005',
+            ':rmk_1012' => '1012',
+        ])->queryAll();
+
+        $issues = [];
+        foreach ($orders as $orderData) {
+            $issues[] = OrderIssue::fromOrderData(OrderIssue::TYPE_SUCCESS_NO_CHECK, $orderData);
+        }
+
+        $this->logInfo('Найдено "Успех без чека"', ['count' => count($issues)]);
+
+        return $issues;
+    }
+
+    /**
+     * Получает заказы типа "Отмена без обработки"
+     *
+     * Критерий: МП статус = "Отменён" (CANCELLED)
+     *           + РМК статус НЕ "Отказ" (НЕ 1006/1013)
+     *
+     * @param int $hoursAgo Период выборки в часах (по умолчанию 24)
+     * @return OrderIssue[]
+     */
+    public function getCancelNoProcessOrders(int $hoursAgo = 24): array
+    {
+        $this->logInfo('Выборка заказов "Отмена без обработки"', ['hours_ago' => $hoursAgo]);
+
+        $startDate = new \DateTime('now', new \DateTimeZone(self::TIMEZONE));
+        $startDate->modify("-{$hoursAgo} hours");
+        $startDateStr = $startDate->format('Y-m-d H:i:s');
+
+        // Выбираем заказы с МП-статусом "Отменён", где РМК-статус НЕ "Отказ"
+        $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.status_processing_1c as rmk_status_id,
+                mocs.name as rmk_status,
+                most.code as mp_status_code,
+                mosub.code as mp_substatus_code,
+                most.name as mp_status_name
+            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::text = mo.status_processing_1c
+            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 most.code = :cancelled
+              AND (
+                  mo.status_processing_1c IS NULL
+                  OR mo.status_processing_1c NOT IN (:rmk_1006, :rmk_1013)
+              )
+            ORDER BY cs.name ASC, mo.creation_date DESC
+        ";
+
+        $orders = Yii::$app->db->createCommand($sql, [
+            ':start_date' => $startDateStr,
+            ':cancelled' => MarketplaceOrderStatusTypes::CANSELLED_CODE,
+            ':rmk_1006' => '1006',
+            ':rmk_1013' => '1013',
+        ])->queryAll();
+
+        $issues = [];
+        foreach ($orders as $orderData) {
+            $issues[] = OrderIssue::fromOrderData(OrderIssue::TYPE_CANCEL_NO_PROCESS, $orderData);
+        }
+
+        $this->logInfo('Найдено "Отмена без обработки"', ['count' => count($issues)]);
+
+        return $issues;
+    }
+
+    /**
+     * Сохраняет состояние проблемных заказов в БД
+     *
+     * @param OrderIssue[] $issues Массив проблемных заказов
+     * @return int Количество сохранённых записей
+     */
+    public function saveControlIssues(array $issues): int
+    {
+        $saved = 0;
+        $reportDate = date('Y-m-d');
+        $interval = (int)date('H') < 12 ? '08:00' : '20:00';
+
+        foreach ($issues as $issue) {
+            // Проверяем, не существует ли уже такая запись
+            if (MarketplaceOrderDailyIssues::issueExists(
+                $issue->orderId,
+                $issue->problemType,
+                $reportDate,
+                $interval
+            )) {
+                continue;
+            }
+
+            $model = MarketplaceOrderDailyIssues::fromOrderIssue($issue);
+
+            if ($model->save()) {
+                $saved++;
+            } else {
+                $this->logWarning('Не удалось сохранить issue', [
+                    'order_id' => $issue->orderId,
+                    'errors' => $model->getErrors(),
+                ]);
+            }
+        }
+
+        $this->logInfo('Сохранено состояний', ['count' => $saved, 'total' => count($issues)]);
+
+        return $saved;
+    }
+
+    /**
+     * Загружает предыдущие проблемы (для определения новых)
+     *
+     * @param string $problemType Тип проблемы
+     * @return array<int, true> Карта order_id => true
+     */
+    public function loadPreviousIssues(string $problemType): array
+    {
+        $yesterday = date('Y-m-d', strtotime('-1 day'));
+
+        $issues = MarketplaceOrderDailyIssues::find()
+            ->select(['order_id'])
+            ->where(['problem_type' => $problemType])
+            ->andWhere(['>=', 'report_date', $yesterday])
+            ->andWhere(['is_resolved' => false])
+            ->asArray()
+            ->all();
+
+        $map = [];
+        foreach ($issues as $issue) {
+            $map[(int)$issue['order_id']] = true;
+        }
+
+        return $map;
+    }
+
+    /**
+     * Фильтрует только новые проблемы (которых не было в предыдущих отчётах)
+     *
+     * @param OrderIssue[] $issues Все найденные проблемы
+     * @param array<int, true> $previousMap Карта предыдущих проблем
+     * @return OrderIssue[] Только новые проблемы
+     */
+    public function filterNewIssues(array $issues, array $previousMap): array
+    {
+        return array_filter($issues, function (OrderIssue $issue) use ($previousMap) {
+            return !isset($previousMap[$issue->orderId]);
+        });
+    }
+
+    /**
+     * Помечает проблемы как отправленные
+     *
+     * @param OrderIssue[] $issues
+     */
+    private function markIssuesAsNotified(array $issues): void
+    {
+        $reportDate = date('Y-m-d');
+        $interval = (int)date('H') < 12 ? '08:00' : '20:00';
+
+        foreach ($issues as $issue) {
+            $model = MarketplaceOrderDailyIssues::find()
+                ->where([
+                    'order_id' => $issue->orderId,
+                    'problem_type' => $issue->problemType,
+                    'report_date' => $reportDate,
+                    'interval' => $interval,
+                ])
+                ->one();
+
+            if ($model) {
+                $model->markAsNotified();
+            }
+        }
+    }
+
+    /**
+     * Формирует текстовый отчёт контроля статусов для Telegram (MarkdownV2)
+     *
+     * @param ControlReportResult $result Результат отчёта
+     * @return string Текст сообщения
+     */
+    public function formatTelegramControlReport(ControlReportResult $result): string
+    {
+        $lines = [];
+        $lines[] = $this->escapeMarkdownV2('[Контроль MP]') . ' *Отчёт за ' . $this->escapeMarkdownV2($result->reportDate) . ' ' . $this->escapeMarkdownV2($result->interval) . '*';
+        $lines[] = '';
+
+        // Секция "Завис в доставке"
+        if (!empty($result->hungInDelivery)) {
+            $lines[] = '*' . $this->escapeMarkdownV2('Завис в доставке') . '*';
+            $lines[] = $this->escapeMarkdownV2('| Дата | Интервал | Заказ | РМК | МП');
+
+            foreach ($result->hungInDelivery as $issue) {
+                $line = $this->formatIssueLineForTelegram($issue);
+                $lines[] = $this->escapeMarkdownV2($line);
+            }
+            $lines[] = '';
+        }
+
+        // Секция "Успех без чека"
+        if (!empty($result->successNoCheck)) {
+            $lines[] = '*' . $this->escapeMarkdownV2('Успех без чека') . '*';
+            $lines[] = $this->escapeMarkdownV2('| Дата | Интервал | Заказ | РМК | МП');
+
+            foreach ($result->successNoCheck as $issue) {
+                $line = $this->formatIssueLineForTelegram($issue);
+                $lines[] = $this->escapeMarkdownV2($line);
+            }
+            $lines[] = '';
+        }
+
+        // Секция "Отмена без обработки"
+        if (!empty($result->cancelNoProcess)) {
+            $lines[] = '*' . $this->escapeMarkdownV2('Отмена без обработки') . '*';
+            $lines[] = $this->escapeMarkdownV2('| Дата | Интервал | Заказ | РМК | МП');
+
+            foreach ($result->cancelNoProcess as $issue) {
+                $line = $this->formatIssueLineForTelegram($issue);
+                $lines[] = $this->escapeMarkdownV2($line);
+            }
+            $lines[] = '';
+        }
+
+        return implode("\n", $lines);
+    }
+
+    /**
+     * Форматирует строку проблемы для Telegram
+     *
+     * @param OrderIssue $issue
+     * @return string
+     */
+    private function formatIssueLineForTelegram(OrderIssue $issue): string
+    {
+        $date = $issue->reportDate ?: date('d.m.Y');
+        $interval = $issue->interval ?: ((int)date('H') < 12 ? '08:00' : '20:00');
+        $rmk = $issue->rmkStatus ?? '-';
+        $mp = $issue->mpStatus ?? '-';
+
+        return sprintf(
+            '| %s | %s | %s | %s | %s',
+            $date,
+            $interval,
+            $issue->orderNumber,
+            $rmk,
+            $mp
+        );
+    }
+
+    /**
+     * Формирует HTML-отчёт контроля статусов для Email
+     *
+     * @param ControlReportResult $result Результат отчёта
+     * @return string HTML-контент
+     */
+    public function formatEmailControlReport(ControlReportResult $result): string
+    {
+        $html = '<!DOCTYPE html>
+<html>
+<head>
+    <meta charset="UTF-8">
+    <style>
+        body { font-family: Arial, sans-serif; }
+        table { border-collapse: collapse; width: 100%; margin-bottom: 20px; }
+        th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
+        th { background-color: #f4f4f4; }
+        .section-header { background-color: #ffe0e0; font-weight: bold; padding: 10px; margin-top: 20px; }
+        .total { font-weight: bold; margin-top: 20px; font-size: 14px; }
+        h2 { color: #333; }
+        p { margin: 5px 0; }
+    </style>
+</head>
+<body>
+    <h2>[Контроль MP] Отчёт за ' . $this->escapeHtml($result->reportDate) . ' ' . $this->escapeHtml($result->interval) . '</h2>';
+
+        // Общая таблица со всеми проблемами, сортировка по типу
+        $allIssues = [];
+
+        foreach ($result->hungInDelivery as $issue) {
+            $allIssues[] = ['type' => 'Завис в доставке', 'issue' => $issue];
+        }
+        foreach ($result->successNoCheck as $issue) {
+            $allIssues[] = ['type' => 'Успех без чека', 'issue' => $issue];
+        }
+        foreach ($result->cancelNoProcess as $issue) {
+            $allIssues[] = ['type' => 'Отмена без обработки', 'issue' => $issue];
+        }
+
+        // Сортируем по типу
+        usort($allIssues, function ($a, $b) {
+            return strcmp($a['type'], $b['type']);
+        });
+
+        $html .= '
+    <table>
+        <tr>
+            <th>Тип проблемы</th>
+            <th>Дата</th>
+            <th>Интервал</th>
+            <th>Заказ</th>
+            <th>РМК</th>
+            <th>МП</th>
+        </tr>';
+
+        foreach ($allIssues as $item) {
+            /** @var OrderIssue $issue */
+            $issue = $item['issue'];
+            $date = $issue->reportDate ?: date('d.m.Y');
+            $interval = $issue->interval ?: ((int)date('H') < 12 ? '08:00' : '20:00');
+
+            $html .= '
+        <tr>
+            <td>' . $this->escapeHtml($item['type']) . '</td>
+            <td>' . $this->escapeHtml($date) . '</td>
+            <td>' . $this->escapeHtml($interval) . '</td>
+            <td>' . $this->escapeHtml($issue->orderNumber) . '</td>
+            <td>' . $this->escapeHtml($issue->rmkStatus ?? '-') . '</td>
+            <td>' . $this->escapeHtml($issue->mpStatus ?? '-') . '</td>
+        </tr>';
+        }
+
+        $html .= '
+    </table>
+    <p class="total">Всего проблем: ' . $result->totalIssues . '</p>
+</body>
+</html>';
+
+        return $html;
+    }
+
+    /**
+     * Отправляет сообщение в Telegram с retry-логикой
+     *
+     * @param string $message Текст сообщения (MarkdownV2)
+     * @return bool Успешность отправки
+     */
+    public function sendToTelegram(string $message): bool
+    {
+        $chatId = $this->getTelegramChatId();
+
+        if (empty($chatId)) {
+            $this->logWarning('Telegram chat_id не настроен');
+            return false;
+        }
+
+        // Валидация chat_id
+        if (!preg_match('/^-?\d+$/', $chatId)) {
+            $this->logError('Некорректный формат chat_id: ' . $chatId);
+            return false;
+        }
+
+        $chunks = $this->splitTelegramMessage($message);
+        $allSent = true;
+
+        foreach ($chunks as $index => $chunk) {
+            $sent = false;
+            $maxRetries = $this->config['max_retries'] ?? self::MAX_RETRIES;
+            $retryDelay = $this->config['retry_delay_seconds'] ?? self::RETRY_DELAY_SECONDS;
+
+            for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
+                try {
+                    $sent = $this->sendTelegramMessage($chatId, $chunk);
+                    if ($sent) {
+                        break;
+                    }
+                } catch (\Exception $e) {
+                    $this->logWarning("Telegram попытка {$attempt}/{$maxRetries}: {$e->getMessage()}");
+                }
+
+                if ($attempt < $maxRetries) {
+                    sleep($retryDelay);
+                }
+            }
+
+            if (!$sent) {
+                $allSent = false;
+                $this->logError("Не удалось отправить часть " . ($index + 1) . " в Telegram после {$maxRetries} попыток");
+            }
+        }
+
+        return $allSent;
+    }
+
+    /**
+     * Отправляет сообщение в Telegram
+     *
+     * @param string $chatId ID чата/канала
+     * @param string $message Текст сообщения
+     * @return bool Успешность
+     */
+    private function sendTelegramMessage(string $chatId, string $message): bool
+    {
+        $botToken = $this->getTelegramBotToken();
+        $url = "https://api.telegram.org/bot{$botToken}/sendMessage";
+
+        $ch = curl_init();
+        curl_setopt_array($ch, [
+            CURLOPT_URL => $url,
+            CURLOPT_POST => true,
+            CURLOPT_POSTFIELDS => [
+                'chat_id' => $chatId,
+                'text' => $message,
+                'parse_mode' => 'MarkdownV2',
+                'disable_web_page_preview' => true,
+            ],
+            CURLOPT_RETURNTRANSFER => true,
+            CURLOPT_TIMEOUT => 30,
+            CURLOPT_SSL_VERIFYPEER => true,
+        ]);
+
+        $response = curl_exec($ch);
+        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+        $curlError = curl_error($ch);
+        curl_close($ch);
+
+        if ($curlError) {
+            $this->logError("Telegram cURL error: {$curlError}");
+            return false;
+        }
+
+        if ($httpCode !== 200) {
+            $this->logError("Telegram API error: HTTP {$httpCode}, response: {$response}");
+            return false;
+        }
+
+        $this->logInfo('Сообщение отправлено в Telegram', ['chat_id' => $chatId]);
+        return true;
+    }
+
+    /**
+     * Отправляет отчёт на email с retry-логикой
+     *
+     * @param string $html HTML-контент письма
+     * @return bool Успешность отправки
+     */
+    public function sendToEmail(string $html): bool
+    {
+        $recipients = $this->getEmailRecipients();
+
+        if (empty($recipients)) {
+            $this->logWarning('Email-получатели не настроены');
+            return false;
+        }
+
+        // Валидация email-адресов
+        $validRecipients = [];
+        foreach ($recipients as $email) {
+            $email = trim($email);
+            if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
+                $validRecipients[] = $email;
+            } else {
+                $this->logWarning("Некорректный email пропущен: {$email}");
+            }
+        }
+
+        if (empty($validRecipients)) {
+            $this->logError('Нет валидных email-адресов');
+            return false;
+        }
+
+        $sent = false;
+        $maxRetries = $this->config['max_retries'] ?? self::MAX_RETRIES;
+        $retryDelay = $this->config['retry_delay_seconds'] ?? self::RETRY_DELAY_SECONDS;
+        $subject = $this->config['email_subject'] ?? 'Контроль статусов заказов МП';
+
+        for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
+            try {
+                $sent = Yii::$app->mailer->compose()
+                    ->setTo($validRecipients)
+                    ->setSubject($subject)
+                    ->setHtmlBody($html)
+                    ->send();
+
+                if ($sent) {
+                    $this->logInfo('Email отправлен на: ' . implode(', ', $validRecipients));
+                    break;
+                }
+            } catch (\Exception $e) {
+                $this->logWarning("Email попытка {$attempt}/{$maxRetries}: {$e->getMessage()}");
+            }
+
+            if ($attempt < $maxRetries) {
+                sleep($retryDelay);
+            }
+        }
+
+        if (!$sent) {
+            $this->logError('Не удалось отправить email после ' . $maxRetries . ' попыток');
+        }
+
+        return $sent;
+    }
+
+    /**
+     * Разбивает длинное сообщение на части для Telegram
+     *
+     * @param string $message Полное сообщение
+     * @return array<string> Массив частей сообщения
+     */
+    private function splitTelegramMessage(string $message): array
+    {
+        $maxLength = $this->config['telegram_max_message_length'] ?? self::TELEGRAM_MAX_LENGTH;
+
+        if (mb_strlen($message) <= $maxLength) {
+            return [$message];
+        }
+
+        $chunks = [];
+        $lines = explode("\n", $message);
+        $currentChunk = '';
+
+        foreach ($lines as $line) {
+            if (mb_strlen($currentChunk . "\n" . $line) > $maxLength) {
+                if ($currentChunk !== '') {
+                    $chunks[] = $currentChunk;
+                }
+                $currentChunk = $line;
+            } else {
+                $currentChunk .= ($currentChunk !== '' ? "\n" : '') . $line;
+            }
+        }
+
+        if ($currentChunk !== '') {
+            $chunks[] = $currentChunk;
+        }
+
+        return $chunks;
+    }
+
+    /**
+     * Экранирует специальные символы для MarkdownV2
+     *
+     * @param string $text Исходный текст
+     * @return string Экранированный текст
+     */
+    private function escapeMarkdownV2(string $text): string
+    {
+        $specialChars = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!'];
+        foreach ($specialChars as $char) {
+            $text = str_replace($char, '\\' . $char, $text);
+        }
+        return $text;
+    }
+
+    /**
+     * Экранирует HTML-сущности
+     *
+     * @param string $text
+     * @return string
+     */
+    private function escapeHtml(string $text): string
+    {
+        return htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+    }
+
+    /**
+     * Определяет, является ли окружение development
+     *
+     * @return bool
+     */
+    private function isDevEnvironment(): bool
+    {
+        return TelegramService::isDevEnv();
+    }
+
+    /**
+     * Получает токен Telegram-бота в зависимости от окружения
+     *
+     * @return string
+     */
+    private function getTelegramBotToken(): string
+    {
+        if ($this->isDevEnvironment()) {
+            return getenv('TELEGRAM_BOT_TOKEN_DEV') ?: self::TELEGRAM_BOT_DEV;
+        }
+        return getenv('TELEGRAM_BOT_TOKEN_PROD') ?: self::TELEGRAM_BOT_PROD;
+    }
+
+    /**
+     * Получает ID чата Telegram в зависимости от окружения
+     *
+     * @return string
+     */
+    private function getTelegramChatId(): string
+    {
+        if ($this->isDevEnvironment()) {
+            return $this->config['telegram_chat_id_dev']
+                ?? getenv('TELEGRAM_ORDER_CONTROL_CHAT_ID_DEV')
+                ?: TelegramService::CHAT_CHANNEL_ID;
+        }
+        return $this->config['telegram_chat_id_prod']
+            ?? getenv('TELEGRAM_ORDER_CONTROL_CHAT_ID_PROD')
+            ?: '';
+    }
+
+    /**
+     * Получает список email-получателей
+     *
+     * @return array
+     */
+    private function getEmailRecipients(): array
+    {
+        $recipients = $this->config['email_recipients'] ?? [];
+
+        if (empty($recipients)) {
+            $envRecipients = getenv('ORDER_CONTROL_EMAIL_RECIPIENTS');
+            if ($envRecipients) {
+                $recipients = array_filter(explode(',', $envRecipients));
+            }
+        }
+
+        return $recipients;
+    }
+
+    /**
+     * Логирование в структурированном JSON-формате
+     *
+     * @param string $message
+     * @param array $context
+     */
+    private function logInfo(string $message, array $context = []): void
+    {
+        Yii::info(json_encode([
+            'message' => $message,
+            'context' => $context,
+            'timestamp' => date('c'),
+            'env' => YII_ENV,
+        ], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_IGNORE), 'marketplace-control');
+    }
+
+    /**
+     * @param string $message
+     * @param array $context
+     */
+    private function logWarning(string $message, array $context = []): void
+    {
+        Yii::warning(json_encode([
+            'message' => $message,
+            'context' => $context,
+            'timestamp' => date('c'),
+            'env' => YII_ENV,
+        ], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_IGNORE), 'marketplace-control');
+    }
+
+    /**
+     * @param string $message
+     * @param array $context
+     */
+    private function logError(string $message, array $context = []): void
+    {
+        Yii::error(json_encode([
+            'message' => $message,
+            'context' => $context,
+            'timestamp' => date('c'),
+            'env' => YII_ENV,
+        ], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_IGNORE), 'marketplace-control');
+    }
+}
diff --git a/erp24/services/UncheckedOrdersReportService.php b/erp24/services/UncheckedOrdersReportService.php
deleted file mode 100644 (file)
index 6adfb53..0000000
+++ /dev/null
@@ -1,849 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace yii_app\services;
-
-use Yii;
-use yii_app\records\MarketplaceOrders;
-use yii_app\records\MarketplaceOrderStatusHistory;
-use yii_app\records\MarketplaceOrderStatusTypes;
-use yii_app\records\CreateChecks;
-use yii_app\records\CityStore;
-use yii_app\services\dto\ReportResult;
-
-/**
- * Сервис формирования и отправки отчёта о заказах с непробитыми чеками
- *
- * Выбирает заказы маркетплейсов в статусе DELIVERED/DELIVERY_SERVICE_DELIVERED
- * за последние 12 часов, у которых отсутствует пробитый чек, и отправляет
- * отчёт в Telegram и на Email.
- *
- * Критерии "непробитого" чека:
- * 1. check_guid IS NULL в marketplace_orders
- * 2. Нет записи в create_checks (cc.status IS NULL)
- * 3. cc.status = 0 (STATUS_CHECK_CREATED_ERP) - создан в ERP, не отправлен
- * 4. cc.status = 8 (STATUS_CHECK_ERROR_1C) - ошибка при создании в 1С
- */
-class UncheckedOrdersReportService
-{
-    /**
-     * Период выборки заказов в часах
-     */
-    public const REPORT_PERIOD_HOURS = 12;
-
-    /**
-     * Максимальное количество попыток отправки
-     */
-    public const MAX_RETRIES = 3;
-
-    /**
-     * Задержка между попытками в секундах
-     */
-    public const RETRY_DELAY_SECONDS = 5;
-
-    /**
-     * Максимальная длина сообщения Telegram
-     */
-    public const TELEGRAM_MAX_LENGTH = 4000;
-
-    /**
-     * Часовой пояс для отчёта
-     */
-    public const TIMEZONE = 'Europe/Moscow';
-
-    /**
-     * Telegram bot token для development
-     */
-    private const TELEGRAM_BOT_DEV = '8063257458:AAGnMf4cxwJWlYLF1wS_arn4PrOaLs9ERQQ';
-
-    /**
-     * Telegram bot token для production
-     */
-    private const TELEGRAM_BOT_PROD = '5456741805:AAG7xOSiYDwUdV5NMb2v9vh8CWzEczDP4yU';
-
-    /**
-     * Конфигурация отчёта
-     */
-    private array $config;
-
-    public function __construct()
-    {
-        $this->config = Yii::$app->params['MARKETPLACE_UNCHECKED_ORDERS_REPORT'] ?? [];
-    }
-
-    /**
-     * Генерирует и отправляет отчёт о непробитых заказах
-     *
-     * @param string|null $targetDate Дата для отчёта (YYYY-MM-DD), по умолчанию - сегодня
-     * @return ReportResult Результат генерации и отправки
-     */
-    public function generateAndSendReport(?string $targetDate = null): ReportResult
-    {
-        $result = new ReportResult();
-        $tz = new \DateTimeZone(self::TIMEZONE);
-        $result->reportDate = (new \DateTime('now', $tz))->format('d.m.Y H:i');
-
-        try {
-            // Получение заказов
-            $orders = $this->getUncheckedOrders($targetDate);
-            $result->totalOrders = count($orders);
-
-            if ($result->totalOrders === 0) {
-                $this->logInfo('Нет заказов с непробитыми чеками за период');
-                return $result;
-            }
-
-            // Группировка и форматирование
-            $grouped = $this->groupOrdersByStore($orders);
-            $result->storesCount = count($grouped);
-            $result->totalAmount = $this->calculateTotalAmount($orders);
-
-            $telegramMessage = $this->formatTelegramReport($grouped, $result);
-            $emailHtml = $this->formatEmailReport($grouped, $result);
-
-            // Отправка с независимым retry
-            $result->telegramSent = $this->sendToTelegram($telegramMessage);
-            if (!$result->telegramSent) {
-                $result->telegramError = 'Не удалось отправить в Telegram после ' . self::MAX_RETRIES . ' попыток';
-            }
-
-            $result->emailSent = $this->sendToEmail($emailHtml);
-            if (!$result->emailSent) {
-                $result->emailError = 'Не удалось отправить Email после ' . self::MAX_RETRIES . ' попыток';
-            }
-
-            $this->logResult($result);
-
-        } catch (\Exception $e) {
-            $this->logError('Критическая ошибка при формировании отчёта', [
-                'error' => $e->getMessage(),
-                'trace' => $e->getTraceAsString(),
-            ]);
-            throw $e;
-        }
-
-        return $result;
-    }
-
-    /**
-     * Получает заказы в статусе DELIVERED без пробитого чека
-     * за последние REPORT_PERIOD_HOURS часов
-     *
-     * @param string|null $targetDate Дата для отчёта
-     * @return array Массив заказов
-     */
-    public function getUncheckedOrders(?string $targetDate = null): array
-    {
-        $periodHours = $this->config['period_hours'] ?? self::REPORT_PERIOD_HOURS;
-        $tz = new \DateTimeZone(self::TIMEZONE);
-
-        // Определяем границу времени для выборки
-        if ($targetDate !== null) {
-            // Если указана дата, берём весь день
-            $startDate = new \DateTime($targetDate . ' 00:00:00', $tz);
-            $endDate = new \DateTime($targetDate . ' 23:59:59', $tz);
-        } else {
-            // Берём последние N часов от текущего момента
-            $endDate = new \DateTime('now', $tz);
-            $startDate = (clone $endDate)->modify("-{$periodHours} hours");
-        }
-
-        $startDateStr = $startDate->format('Y-m-d H:i:s');
-        $endDateStr = $endDate->format('Y-m-d H:i:s');
-
-        $this->logInfo('Выборка заказов за период', [
-            'start' => $startDateStr,
-            'end' => $endDateStr,
-        ]);
-
-        // Основной запрос для выборки заказов с непробитыми чеками
-        // Используем joinWith для связи с таблицей статусов через модель
-        $query = MarketplaceOrders::find()
-            ->alias('mo')
-            ->joinWith(['status' => function($q) {
-                $q->alias('most_status');
-            }], false, 'INNER JOIN')
-            ->joinWith(['substatus' => function($q) {
-                $q->alias('most_substatus');
-            }], false, 'LEFT JOIN')
-            ->innerJoin(
-                ['mosh' => MarketplaceOrderStatusHistory::tableName()],
-                'mo.id = mosh.order_id'
-            )
-            ->leftJoin(
-                ['cc' => CreateChecks::tableName()],
-                'mo.marketplace_order_id = cc.marketplace_order_id'
-            )
-            ->where([
-                'or',
-                ['most_status.code' => MarketplaceOrderStatusTypes::DELIVERED_CODE],
-                ['most_substatus.code' => MarketplaceOrderStatusTypes::DELIVERY_SERVICE_DELIVERED_CODE],
-            ])
-            ->andWhere(['>=', 'mosh.date_from', $startDateStr])
-            ->andWhere(['<=', 'mosh.date_from', $endDateStr])
-            ->andWhere(['mosh.active' => 1])
-            ->andWhere(['mo.fake' => 0]) // Исключаем тестовые заказы
-            ->andWhere([
-                'or',
-                ['mo.check_guid' => null],
-                ['cc.status' => null], // Нет записи в create_checks
-                ['cc.status' => CreateChecks::STATUS_CHECK_CREATED_ERP], // Создан в ERP, не отправлен
-                ['cc.status' => CreateChecks::STATUS_CHECK_ERROR_1C], // Ошибка в 1С
-            ])
-            ->with(['store'])
-            ->orderBy(['mo.store_id' => SORT_ASC, 'mo.marketplace_name' => SORT_ASC, 'mo.creation_date' => SORT_DESC])
-            ->distinct();
-
-        $orders = $query->all();
-
-        $this->logInfo('Найдено заказов', ['count' => count($orders)]);
-
-        return $orders;
-    }
-
-    /**
-     * Группирует заказы по магазинам
-     *
-     * @param MarketplaceOrders[] $orders
-     * @return array<int, array{store: CityStore|null, orders: MarketplaceOrders[], store_name: string}>
-     */
-    public function groupOrdersByStore(array $orders): array
-    {
-        $grouped = [];
-
-        foreach ($orders as $order) {
-            $storeId = $order->store_id ?? 0;
-
-            if (!isset($grouped[$storeId])) {
-                $grouped[$storeId] = [
-                    'store' => $order->store,
-                    'store_name' => $order->store->name ?? 'Магазин не указан',
-                    'orders' => [],
-                ];
-            }
-
-            $grouped[$storeId]['orders'][] = $order;
-        }
-
-        // Сортируем по имени магазина
-        uasort($grouped, function ($a, $b) {
-            return strcmp($a['store_name'], $b['store_name']);
-        });
-
-        return $grouped;
-    }
-
-    /**
-     * Вычисляет общую сумму заказов
-     *
-     * @param MarketplaceOrders[] $orders
-     * @return float
-     */
-    private function calculateTotalAmount(array $orders): float
-    {
-        $total = 0.0;
-        foreach ($orders as $order) {
-            $total += (float)$order->total;
-        }
-        return $total;
-    }
-
-    /**
-     * Формирует HTML-таблицу для email
-     *
-     * @param array $groupedOrders Сгруппированные заказы
-     * @param ReportResult $result Результат для итогов
-     * @return string HTML-контент
-     */
-    public function formatEmailReport(array $groupedOrders, ReportResult $result): string
-    {
-        $html = '<!DOCTYPE html>
-<html>
-<head>
-    <meta charset="UTF-8">
-    <style>
-        body { font-family: Arial, sans-serif; }
-        table { border-collapse: collapse; width: 100%; margin-bottom: 20px; }
-        th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
-        th { background-color: #f4f4f4; }
-        .store-header { background-color: #e8f4e8; font-weight: bold; padding: 10px; margin-top: 15px; }
-        .total { font-weight: bold; margin-top: 20px; font-size: 14px; }
-        .error { color: #c00; font-size: 0.9em; }
-        .warning { color: #f90; }
-        h2 { color: #333; }
-        p { margin: 5px 0; }
-    </style>
-</head>
-<body>
-    <h2>Отчёт о заказах с непробитыми чеками</h2>
-    <p>Дата: ' . $this->escapeHtml($result->reportDate) . ' MSK</p>
-    <p>Период: последние ' . self::REPORT_PERIOD_HOURS . ' часов</p>';
-
-        foreach ($groupedOrders as $storeData) {
-            $storeName = $this->escapeHtml($storeData['store_name']);
-            $html .= '
-    <h3 class="store-header">Магазин: ' . $storeName . '</h3>
-    <table>
-        <tr>
-            <th>№ заказа</th>
-            <th>Маркетплейс</th>
-            <th>Дата доставки</th>
-            <th>Сумма</th>
-            <th>Статус чека</th>
-        </tr>';
-
-            foreach ($storeData['orders'] as $order) {
-                /** @var MarketplaceOrders $order */
-                $checkStatus = $this->getCheckStatusText($order);
-                $statusClass = $this->getCheckStatusClass($order);
-                $deliveryDate = $this->formatDeliveryDate($order);
-                $total = number_format((float)$order->total, 0, '.', ' ') . ' руб.';
-
-                $html .= '
-        <tr>
-            <td>' . $this->escapeHtml($order->marketplace_order_id) . '</td>
-            <td>' . $this->escapeHtml($order->marketplace_name ?? 'Неизвестно') . '</td>
-            <td>' . $this->escapeHtml($deliveryDate) . '</td>
-            <td>' . $total . '</td>
-            <td class="' . $statusClass . '">' . $this->escapeHtml($checkStatus) . '</td>
-        </tr>';
-            }
-
-            $html .= '
-    </table>';
-        }
-
-        $totalAmount = number_format($result->totalAmount, 0, '.', ' ');
-        $html .= '
-    <p class="total">Итого: ' . $result->totalOrders . ' заказ(ов) в ' . $result->storesCount . ' магазин(ах) на сумму ' . $totalAmount . ' руб.</p>
-</body>
-</html>';
-
-        return $html;
-    }
-
-    /**
-     * Формирует текстовый отчёт для Telegram в формате MarkdownV2
-     *
-     * @param array $groupedOrders Сгруппированные заказы
-     * @param ReportResult $result Результат для итогов
-     * @return string Текст сообщения (MarkdownV2)
-     */
-    public function formatTelegramReport(array $groupedOrders, ReportResult $result): string
-    {
-        $lines = [];
-        $lines[] = $this->escapeMarkdownV2('🔴') . ' *Заказы с непробитыми чеками*';
-        $lines[] = '';
-        $lines[] = $this->escapeMarkdownV2('📅 ' . $result->reportDate . ' MSK');
-        $lines[] = $this->escapeMarkdownV2('⏰ Период: ' . self::REPORT_PERIOD_HOURS . ' часов');
-        $lines[] = '';
-
-        foreach ($groupedOrders as $storeData) {
-            $storeName = $storeData['store_name'];
-            $lines[] = $this->escapeMarkdownV2('📍') . ' *' . $this->escapeMarkdownV2($storeName) . '*';
-
-            foreach ($storeData['orders'] as $order) {
-                /** @var MarketplaceOrders $order */
-                $checkStatusShort = $this->getCheckStatusShort($order);
-                $mpName = $this->getMarketplaceShortName($order->marketplace_name);
-                $total = number_format((float)$order->total, 0, '.', ' ');
-
-                $line = '• ' . $order->marketplace_order_id . ' | ' . $mpName . ' | ' . $total . ' ₽ | ' . $checkStatusShort;
-                $lines[] = $this->escapeMarkdownV2($line);
-            }
-            $lines[] = '';
-        }
-
-        $totalAmount = number_format($result->totalAmount, 0, '.', ' ');
-        $lines[] = $this->escapeMarkdownV2('━━━━━━━━━━━');
-        $lines[] = $this->escapeMarkdownV2('📊 Итого: ' . $result->totalOrders . ' заказ(ов) | ' . $result->storesCount . ' магазин(а) | ' . $totalAmount . ' ₽');
-
-        return implode("\n", $lines);
-    }
-
-    /**
-     * Экранирует специальные символы для MarkdownV2
-     * Символы: _ * [ ] ( ) ~ ` > # + - = | { } . !
-     *
-     * @param string $text Исходный текст
-     * @return string Экранированный текст
-     */
-    private function escapeMarkdownV2(string $text): string
-    {
-        $specialChars = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!'];
-        foreach ($specialChars as $char) {
-            $text = str_replace($char, '\\' . $char, $text);
-        }
-        return $text;
-    }
-
-    /**
-     * Экранирует HTML-сущности
-     *
-     * @param string $text
-     * @return string
-     */
-    private function escapeHtml(string $text): string
-    {
-        return htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
-    }
-
-    /**
-     * Разбивает длинное сообщение на части для Telegram
-     *
-     * @param string $message Полное сообщение
-     * @return array<string> Массив частей сообщения
-     */
-    private function splitTelegramMessage(string $message): array
-    {
-        $maxLength = $this->config['telegram_max_message_length'] ?? self::TELEGRAM_MAX_LENGTH;
-
-        if (mb_strlen($message) <= $maxLength) {
-            return [$message];
-        }
-
-        $chunks = [];
-        $lines = explode("\n", $message);
-        $currentChunk = '';
-
-        foreach ($lines as $line) {
-            if (mb_strlen($currentChunk . "\n" . $line) > $maxLength) {
-                if ($currentChunk !== '') {
-                    $chunks[] = $currentChunk;
-                }
-                $currentChunk = $line;
-            } else {
-                $currentChunk .= ($currentChunk !== '' ? "\n" : '') . $line;
-            }
-        }
-
-        if ($currentChunk !== '') {
-            $chunks[] = $currentChunk;
-        }
-
-        return $chunks;
-    }
-
-    /**
-     * Отправляет отчёт в Telegram с retry-логикой
-     *
-     * @param string $message Текст сообщения (MarkdownV2)
-     * @return bool Успешность отправки
-     */
-    public function sendToTelegram(string $message): bool
-    {
-        $chatId = $this->getTelegramChatId();
-
-        if (empty($chatId)) {
-            $this->logWarning('Telegram chat_id не настроен');
-            return false;
-        }
-
-        // Валидация chat_id (должен начинаться с - для каналов/групп или быть числом)
-        if (!preg_match('/^-?\d+$/', $chatId)) {
-            $this->logError('Некорректный формат chat_id: ' . $chatId);
-            return false;
-        }
-
-        $chunks = $this->splitTelegramMessage($message);
-        $allSent = true;
-
-        foreach ($chunks as $index => $chunk) {
-            $sent = false;
-            $maxRetries = $this->config['max_retries'] ?? self::MAX_RETRIES;
-            $retryDelay = $this->config['retry_delay_seconds'] ?? self::RETRY_DELAY_SECONDS;
-
-            for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
-                try {
-                    $sent = $this->sendTelegramMessage($chatId, $chunk);
-                    if ($sent) {
-                        break;
-                    }
-                } catch (\Exception $e) {
-                    $this->logWarning("Telegram попытка {$attempt}/{$maxRetries}: {$e->getMessage()}");
-                }
-
-                if ($attempt < $maxRetries) {
-                    sleep($retryDelay);
-                }
-            }
-
-            if (!$sent) {
-                $allSent = false;
-                $this->logError("Не удалось отправить часть " . ($index + 1) . " в Telegram после {$maxRetries} попыток");
-            }
-        }
-
-        return $allSent;
-    }
-
-    /**
-     * Отправляет сообщение в Telegram
-     *
-     * @param string $chatId ID чата/канала
-     * @param string $message Текст сообщения
-     * @return bool Успешность
-     */
-    private function sendTelegramMessage(string $chatId, string $message): bool
-    {
-        $botToken = $this->getTelegramBotToken();
-        $url = "https://api.telegram.org/bot{$botToken}/sendMessage";
-
-        $ch = curl_init();
-        curl_setopt_array($ch, [
-            CURLOPT_URL => $url,
-            CURLOPT_POST => true,
-            CURLOPT_POSTFIELDS => [
-                'chat_id' => $chatId,
-                'text' => $message,
-                'parse_mode' => 'MarkdownV2',
-                'disable_web_page_preview' => true,
-            ],
-            CURLOPT_RETURNTRANSFER => true,
-            CURLOPT_TIMEOUT => 30,
-            CURLOPT_SSL_VERIFYPEER => true,
-        ]);
-
-        $response = curl_exec($ch);
-        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
-        $curlError = curl_error($ch);
-        curl_close($ch);
-
-        if ($curlError) {
-            $this->logError("Telegram cURL error: {$curlError}");
-            return false;
-        }
-
-        if ($httpCode !== 200) {
-            $this->logError("Telegram API error: HTTP {$httpCode}, response: {$response}");
-            return false;
-        }
-
-        $this->logInfo('Сообщение отправлено в Telegram', ['chat_id' => $chatId]);
-        return true;
-    }
-
-    /**
-     * Отправляет отчёт на email с retry-логикой
-     *
-     * @param string $html HTML-контент письма
-     * @return bool Успешность отправки
-     */
-    public function sendToEmail(string $html): bool
-    {
-        $recipients = $this->getEmailRecipients();
-
-        if (empty($recipients)) {
-            $this->logWarning('Email-получатели не настроены');
-            return false;
-        }
-
-        // Валидация email-адресов
-        $validRecipients = [];
-        foreach ($recipients as $email) {
-            $email = trim($email);
-            if (filter_var($email, FILTER_VALIDATE_EMAIL) &&
-                preg_match('/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/', $email)) {
-                $validRecipients[] = $email;
-            } else {
-                $this->logWarning("Некорректный email пропущен: {$email}");
-            }
-        }
-
-        if (empty($validRecipients)) {
-            $this->logError('Нет валидных email-адресов');
-            return false;
-        }
-
-        $sent = false;
-        $maxRetries = $this->config['max_retries'] ?? self::MAX_RETRIES;
-        $retryDelay = $this->config['retry_delay_seconds'] ?? self::RETRY_DELAY_SECONDS;
-        $subject = $this->config['email_subject'] ?? 'Отчёт о заказах с непробитыми чеками';
-
-        for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
-            try {
-                $sent = Yii::$app->mailer->compose()
-                    ->setTo($validRecipients)
-                    ->setSubject($subject)
-                    ->setHtmlBody($html)
-                    ->send();
-
-                if ($sent) {
-                    $this->logInfo('Email отправлен на: ' . implode(', ', $validRecipients));
-                    break;
-                }
-            } catch (\Exception $e) {
-                $this->logWarning("Email попытка {$attempt}/{$maxRetries}: {$e->getMessage()}");
-            }
-
-            if ($attempt < $maxRetries) {
-                sleep($retryDelay);
-            }
-        }
-
-        if (!$sent) {
-            $this->logError('Не удалось отправить email после ' . $maxRetries . ' попыток');
-        }
-
-        return $sent;
-    }
-
-    /**
-     * Определяет, является ли окружение development
-     *
-     * @return bool
-     */
-    private function isDevEnvironment(): bool
-    {
-        return TelegramService::isDevEnv();
-    }
-
-    /**
-     * Получает токен Telegram-бота в зависимости от окружения
-     *
-     * @return string
-     */
-    private function getTelegramBotToken(): string
-    {
-        if ($this->isDevEnvironment()) {
-            return getenv('TELEGRAM_BOT_TOKEN_DEV') ?: self::TELEGRAM_BOT_DEV;
-        }
-        return getenv('TELEGRAM_BOT_TOKEN_PROD') ?: self::TELEGRAM_BOT_PROD;
-    }
-
-    /**
-     * Получает ID чата Telegram в зависимости от окружения
-     *
-     * @return string
-     */
-    private function getTelegramChatId(): string
-    {
-        if ($this->isDevEnvironment()) {
-            return $this->config['telegram_chat_id_dev']
-                ?? getenv('TELEGRAM_UNCHECKED_ORDERS_CHAT_ID_DEV')
-                ?: TelegramService::CHAT_CHANNEL_ID;
-        }
-        return $this->config['telegram_chat_id_prod']
-            ?? getenv('TELEGRAM_UNCHECKED_ORDERS_CHAT_ID_PROD')
-            ?: '';
-    }
-
-    /**
-     * Получает список email-получателей
-     *
-     * @return array
-     */
-    private function getEmailRecipients(): array
-    {
-        $recipients = $this->config['email_recipients'] ?? [];
-
-        if (empty($recipients)) {
-            $envRecipients = getenv('UNCHECKED_ORDERS_EMAIL_RECIPIENTS');
-            if ($envRecipients) {
-                $recipients = array_filter(explode(',', $envRecipients));
-            }
-        }
-
-        return $recipients;
-    }
-
-    /**
-     * Получает текстовое описание статуса чека
-     *
-     * @param MarketplaceOrders $order
-     * @return string
-     */
-    private function getCheckStatusText(MarketplaceOrders $order): string
-    {
-        if ($order->check_guid === null) {
-            // Проверяем наличие записи в create_checks
-            $check = CreateChecks::findOne(['marketplace_order_id' => $order->marketplace_order_id]);
-
-            if ($check === null) {
-                return 'Не создан';
-            }
-
-            switch ($check->status) {
-                case CreateChecks::STATUS_CHECK_CREATED_ERP:
-                    return 'Ожидает отправки в 1С';
-                case CreateChecks::STATUS_CHECK_ERROR_1C:
-                    $errorText = $check->error_text ? ': ' . $check->error_text : '';
-                    return 'Ошибка 1С' . $errorText;
-                default:
-                    return 'Статус неизвестен';
-            }
-        }
-
-        return 'Чек создан';
-    }
-
-    /**
-     * Получает короткое описание статуса чека для Telegram
-     *
-     * @param MarketplaceOrders $order
-     * @return string
-     */
-    private function getCheckStatusShort(MarketplaceOrders $order): string
-    {
-        if ($order->check_guid === null) {
-            $check = CreateChecks::findOne(['marketplace_order_id' => $order->marketplace_order_id]);
-
-            if ($check === null) {
-                return 'Не создан';
-            }
-
-            switch ($check->status) {
-                case CreateChecks::STATUS_CHECK_CREATED_ERP:
-                    return 'Ожидает';
-                case CreateChecks::STATUS_CHECK_ERROR_1C:
-                    return '❌ Ошибка 1С';
-                default:
-                    return '?';
-            }
-        }
-
-        return '✅';
-    }
-
-    /**
-     * Получает CSS-класс для статуса чека
-     *
-     * @param MarketplaceOrders $order
-     * @return string
-     */
-    private function getCheckStatusClass(MarketplaceOrders $order): string
-    {
-        if ($order->check_guid !== null) {
-            return '';
-        }
-
-        $check = CreateChecks::findOne(['marketplace_order_id' => $order->marketplace_order_id]);
-
-        if ($check !== null && $check->status === CreateChecks::STATUS_CHECK_ERROR_1C) {
-            return 'error';
-        }
-
-        if ($check !== null && $check->status === CreateChecks::STATUS_CHECK_CREATED_ERP) {
-            return 'warning';
-        }
-
-        return '';
-    }
-
-    /**
-     * Форматирует дату доставки
-     *
-     * @param MarketplaceOrders $order
-     * @return string
-     */
-    private function formatDeliveryDate(MarketplaceOrders $order): string
-    {
-        // Ищем дату из истории статусов
-        $history = MarketplaceOrderStatusHistory::find()
-            ->where(['order_id' => $order->id])
-            ->andWhere(['active' => 1])
-            ->orderBy(['date_from' => SORT_DESC])
-            ->one();
-
-        if ($history && $history->date_from) {
-            try {
-                $date = new \DateTime($history->date_from);
-                return $date->format('d.m.Y H:i');
-            } catch (\Exception $e) {
-                // Игнорируем ошибку парсинга даты
-            }
-        }
-
-        // Используем дату обновления заказа
-        if ($order->updated_at) {
-            try {
-                $date = new \DateTime($order->updated_at);
-                return $date->format('d.m.Y H:i');
-            } catch (\Exception $e) {
-                // Игнорируем ошибку парсинга даты
-            }
-        }
-
-        return '-';
-    }
-
-    /**
-     * Получает короткое название маркетплейса
-     *
-     * @param string|null $name
-     * @return string
-     */
-    private function getMarketplaceShortName(?string $name): string
-    {
-        if ($name === null) {
-            return '?';
-        }
-
-        $name = mb_strtolower($name);
-
-        if (str_contains($name, 'flowwow') || str_contains($name, 'флаувау')) {
-            return 'Flowwow';
-        }
-
-        if (str_contains($name, 'yandex') || str_contains($name, 'яндекс')) {
-            return 'Yandex';
-        }
-
-        return $name;
-    }
-
-    /**
-     * Логирование в структурированном JSON-формате
-     *
-     * @param string $message
-     * @param array $context
-     */
-    private function logInfo(string $message, array $context = []): void
-    {
-        Yii::info(json_encode([
-            'message' => $message,
-            'context' => $context,
-            'timestamp' => date('c'),
-            'env' => YII_ENV,
-        ], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_IGNORE), 'marketplace-checks');
-    }
-
-    /**
-     * @param string $message
-     * @param array $context
-     */
-    private function logWarning(string $message, array $context = []): void
-    {
-        Yii::warning(json_encode([
-            'message' => $message,
-            'context' => $context,
-            'timestamp' => date('c'),
-            'env' => YII_ENV,
-        ], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_IGNORE), 'marketplace-checks');
-    }
-
-    /**
-     * @param string $message
-     * @param array $context
-     */
-    private function logError(string $message, array $context = []): void
-    {
-        Yii::error(json_encode([
-            'message' => $message,
-            'context' => $context,
-            'timestamp' => date('c'),
-            'env' => YII_ENV,
-        ], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_IGNORE), 'marketplace-checks');
-    }
-
-    /**
-     * Логирует результат выполнения отчёта
-     *
-     * @param ReportResult $result
-     */
-    private function logResult(ReportResult $result): void
-    {
-        $this->logInfo('Отчёт завершён', $result->toArray());
-    }
-}
diff --git a/erp24/services/dto/ControlReportResult.php b/erp24/services/dto/ControlReportResult.php
new file mode 100644 (file)
index 0000000..a09f126
--- /dev/null
@@ -0,0 +1,256 @@
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\services\dto;
+
+/**
+ * DTO для результата отчёта контроля статусов заказов маркетплейса
+ *
+ * Содержит информацию о найденных проблемных заказах по трём типам:
+ * - Завис в доставке
+ * - Успех без чека
+ * - Отмена без обработки
+ */
+class ControlReportResult
+{
+    /**
+     * Дата и время формирования отчёта
+     */
+    public string $reportDate = '';
+
+    /**
+     * Интервал проверки (08:00 или 20:00)
+     */
+    public string $interval = '';
+
+    /**
+     * Общее количество проблемных заказов
+     */
+    public int $totalIssues = 0;
+
+    /**
+     * Заказы типа "Завис в доставке"
+     *
+     * @var OrderIssue[]
+     */
+    public array $hungInDelivery = [];
+
+    /**
+     * Заказы типа "Успех без чека"
+     *
+     * @var OrderIssue[]
+     */
+    public array $successNoCheck = [];
+
+    /**
+     * Заказы типа "Отмена без обработки"
+     *
+     * @var OrderIssue[]
+     */
+    public array $cancelNoProcess = [];
+
+    /**
+     * Успешность отправки в Telegram
+     */
+    public bool $telegramSent = false;
+
+    /**
+     * Успешность отправки на Email
+     */
+    public bool $emailSent = false;
+
+    /**
+     * Текст ошибки при отправке в Telegram
+     */
+    public ?string $telegramError = null;
+
+    /**
+     * Текст ошибки при отправке на Email
+     */
+    public ?string $emailError = null;
+
+    /**
+     * Количество сохранённых состояний
+     */
+    public int $statesSaved = 0;
+
+    /**
+     * Конструктор
+     */
+    public function __construct()
+    {
+        $tz = new \DateTimeZone('Europe/Moscow');
+        $now = new \DateTime('now', $tz);
+        $this->reportDate = $now->format('d.m.Y H:i');
+        $this->interval = (int)$now->format('H') < 12 ? '08:00' : '20:00';
+    }
+
+    /**
+     * Получает количество заказов по типу "Завис в доставке"
+     *
+     * @return int
+     */
+    public function getHungInDeliveryCount(): int
+    {
+        return count($this->hungInDelivery);
+    }
+
+    /**
+     * Получает количество заказов по типу "Успех без чека"
+     *
+     * @return int
+     */
+    public function getSuccessNoCheckCount(): int
+    {
+        return count($this->successNoCheck);
+    }
+
+    /**
+     * Получает количество заказов по типу "Отмена без обработки"
+     *
+     * @return int
+     */
+    public function getCancelNoProcessCount(): int
+    {
+        return count($this->cancelNoProcess);
+    }
+
+    /**
+     * Рассчитывает и обновляет общее количество проблем
+     *
+     * @return int
+     */
+    public function calculateTotal(): int
+    {
+        $this->totalIssues = $this->getHungInDeliveryCount()
+            + $this->getSuccessNoCheckCount()
+            + $this->getCancelNoProcessCount();
+
+        return $this->totalIssues;
+    }
+
+    /**
+     * Проверяет наличие проблемных заказов
+     *
+     * @return bool
+     */
+    public function hasIssues(): bool
+    {
+        return $this->totalIssues > 0
+            || !empty($this->hungInDelivery)
+            || !empty($this->successNoCheck)
+            || !empty($this->cancelNoProcess);
+    }
+
+    /**
+     * Получает все проблемные заказы одним массивом
+     *
+     * @return OrderIssue[]
+     */
+    public function getAllIssues(): array
+    {
+        return array_merge(
+            $this->hungInDelivery,
+            $this->successNoCheck,
+            $this->cancelNoProcess
+        );
+    }
+
+    /**
+     * Группирует заказы по типу проблемы
+     *
+     * @return array<string, OrderIssue[]>
+     */
+    public function groupByProblemType(): array
+    {
+        return [
+            OrderIssue::TYPE_HUNG_IN_DELIVERY => $this->hungInDelivery,
+            OrderIssue::TYPE_SUCCESS_NO_CHECK => $this->successNoCheck,
+            OrderIssue::TYPE_CANCEL_NO_PROCESS => $this->cancelNoProcess,
+        ];
+    }
+
+    /**
+     * Проверяет полный успех отправки
+     *
+     * @return bool
+     */
+    public function isSuccess(): bool
+    {
+        return $this->telegramSent && $this->emailSent;
+    }
+
+    /**
+     * Проверяет частичный успех отправки
+     *
+     * @return bool
+     */
+    public function isPartialSuccess(): bool
+    {
+        return ($this->telegramSent || $this->emailSent) && !$this->isSuccess();
+    }
+
+    /**
+     * Возвращает exit code для консольной команды
+     *
+     * @return int 0 = успех, 1 = критическая ошибка, 2 = частичный успех
+     */
+    public function getExitCode(): int
+    {
+        if (!$this->hasIssues()) {
+            return 0;
+        }
+
+        if ($this->isSuccess()) {
+            return 0;
+        }
+
+        if ($this->isPartialSuccess()) {
+            return 2;
+        }
+
+        return 1;
+    }
+
+    /**
+     * Преобразует результат в массив для логирования
+     *
+     * @return array
+     */
+    public function toArray(): array
+    {
+        return [
+            'report_date' => $this->reportDate,
+            'interval' => $this->interval,
+            'total_issues' => $this->totalIssues,
+            'hung_in_delivery_count' => $this->getHungInDeliveryCount(),
+            'success_no_check_count' => $this->getSuccessNoCheckCount(),
+            'cancel_no_process_count' => $this->getCancelNoProcessCount(),
+            'telegram_sent' => $this->telegramSent,
+            'email_sent' => $this->emailSent,
+            'telegram_error' => $this->telegramError,
+            'email_error' => $this->emailError,
+            'states_saved' => $this->statesSaved,
+            'exit_code' => $this->getExitCode(),
+        ];
+    }
+
+    /**
+     * Получает сводку для логирования
+     *
+     * @return string
+     */
+    public function getSummary(): string
+    {
+        return sprintf(
+            'Контроль МП: %d проблем (завис: %d, успех без чека: %d, отмена: %d). TG: %s, Email: %s',
+            $this->totalIssues,
+            $this->getHungInDeliveryCount(),
+            $this->getSuccessNoCheckCount(),
+            $this->getCancelNoProcessCount(),
+            $this->telegramSent ? 'OK' : 'FAIL',
+            $this->emailSent ? 'OK' : 'FAIL'
+        );
+    }
+}
diff --git a/erp24/services/dto/OrderIssue.php b/erp24/services/dto/OrderIssue.php
new file mode 100644 (file)
index 0000000..c4b57e4
--- /dev/null
@@ -0,0 +1,247 @@
+<?php
+
+declare(strict_types=1);
+
+namespace yii_app\services\dto;
+
+/**
+ * DTO для проблемного заказа маркетплейса
+ *
+ * Представляет заказ с расхождением статусов между РМК/1С и МП.
+ * Используется в отчёте контроля статусов заказов.
+ */
+class OrderIssue
+{
+    /**
+     * Тип проблемы: "Завис в доставке"
+     */
+    public const TYPE_HUNG_IN_DELIVERY = 'hung_in_delivery';
+
+    /**
+     * Тип проблемы: "Успех без чека"
+     */
+    public const TYPE_SUCCESS_NO_CHECK = 'success_no_check';
+
+    /**
+     * Тип проблемы: "Отмена без обработки"
+     */
+    public const TYPE_CANCEL_NO_PROCESS = 'cancel_no_process';
+
+    /**
+     * Метки типов проблем для отображения
+     */
+    public const TYPE_LABELS = [
+        self::TYPE_HUNG_IN_DELIVERY => 'Завис в доставке',
+        self::TYPE_SUCCESS_NO_CHECK => 'Успех без чека',
+        self::TYPE_CANCEL_NO_PROCESS => 'Отмена без обработки',
+    ];
+
+    /**
+     * Тип проблемы (hung_in_delivery, success_no_check, cancel_no_process)
+     */
+    public string $problemType;
+
+    /**
+     * Человекочитаемая метка типа проблемы
+     */
+    public string $problemTypeLabel;
+
+    /**
+     * Дата отчёта (формат d.m.Y)
+     */
+    public string $reportDate;
+
+    /**
+     * Интервал проверки (08:00 или 20:00)
+     */
+    public string $interval;
+
+    /**
+     * Номер заказа в маркетплейсе
+     */
+    public string $orderNumber;
+
+    /**
+     * Статус в РМК/1С (человекочитаемый)
+     */
+    public ?string $rmkStatus;
+
+    /**
+     * Код статуса РМК/1С
+     */
+    public ?string $rmkStatusId;
+
+    /**
+     * Статус в МП (человекочитаемый)
+     */
+    public ?string $mpStatus;
+
+    /**
+     * Код статуса МП
+     */
+    public ?string $mpStatusCode;
+
+    /**
+     * Код субстатуса МП
+     */
+    public ?string $mpSubstatusCode;
+
+    /**
+     * ID заказа в БД
+     */
+    public int $orderId;
+
+    /**
+     * ID магазина
+     */
+    public ?int $storeId;
+
+    /**
+     * Название магазина
+     */
+    public ?string $storeName;
+
+    /**
+     * Название маркетплейса
+     */
+    public ?string $marketplaceName;
+
+    /**
+     * ID маркетплейса (1 - Flowwow, 2 - YandexMarket)
+     */
+    public ?int $marketplaceId;
+
+    /**
+     * Сумма заказа
+     */
+    public float $total;
+
+    /**
+     * Дата создания заказа
+     */
+    public ?string $creationDate;
+
+    /**
+     * Конструктор с параметрами
+     *
+     * @param string $problemType Тип проблемы
+     * @param int $orderId ID заказа
+     * @param string $orderNumber Номер заказа
+     */
+    public function __construct(
+        string $problemType,
+        int $orderId,
+        string $orderNumber
+    ) {
+        $this->problemType = $problemType;
+        $this->problemTypeLabel = self::TYPE_LABELS[$problemType] ?? $problemType;
+        $this->orderId = $orderId;
+        $this->orderNumber = $orderNumber;
+        $this->reportDate = date('d.m.Y');
+        $this->interval = $this->calculateInterval();
+        $this->total = 0.0;
+    }
+
+    /**
+     * Определяет интервал проверки (08:00 или 20:00)
+     *
+     * @return string
+     */
+    private function calculateInterval(): string
+    {
+        $hour = (int)date('H');
+        return $hour < 12 ? '08:00' : '20:00';
+    }
+
+    /**
+     * Создаёт экземпляр из данных заказа
+     *
+     * @param string $problemType Тип проблемы
+     * @param array $orderData Данные заказа из БД
+     * @return self
+     */
+    public static function fromOrderData(string $problemType, array $orderData): self
+    {
+        $issue = new self(
+            $problemType,
+            (int)($orderData['id'] ?? 0),
+            $orderData['marketplace_order_id'] ?? ''
+        );
+
+        $issue->rmkStatus = $orderData['rmk_status'] ?? null;
+        $issue->rmkStatusId = $orderData['rmk_status_id'] ?? null;
+        $issue->mpStatus = $orderData['mp_status_name'] ?? null;
+        $issue->mpStatusCode = $orderData['mp_status_code'] ?? null;
+        $issue->mpSubstatusCode = $orderData['mp_substatus_code'] ?? null;
+        $issue->storeId = isset($orderData['store_id']) ? (int)$orderData['store_id'] : null;
+        $issue->storeName = $orderData['store_name'] ?? null;
+        $issue->marketplaceName = $orderData['marketplace_name'] ?? null;
+        $issue->marketplaceId = isset($orderData['marketplace_id']) ? (int)$orderData['marketplace_id'] : null;
+        $issue->total = (float)($orderData['total'] ?? 0);
+        $issue->creationDate = $orderData['creation_date'] ?? null;
+
+        return $issue;
+    }
+
+    /**
+     * Преобразует в массив для сохранения/логирования
+     *
+     * @return array
+     */
+    public function toArray(): array
+    {
+        return [
+            'problem_type' => $this->problemType,
+            'problem_type_label' => $this->problemTypeLabel,
+            'report_date' => $this->reportDate,
+            'interval' => $this->interval,
+            'order_id' => $this->orderId,
+            'order_number' => $this->orderNumber,
+            'rmk_status' => $this->rmkStatus,
+            'rmk_status_id' => $this->rmkStatusId,
+            'mp_status' => $this->mpStatus,
+            'mp_status_code' => $this->mpStatusCode,
+            'mp_substatus_code' => $this->mpSubstatusCode,
+            'store_id' => $this->storeId,
+            'store_name' => $this->storeName,
+            'marketplace_name' => $this->marketplaceName,
+            'marketplace_id' => $this->marketplaceId,
+            'total' => $this->total,
+            'creation_date' => $this->creationDate,
+        ];
+    }
+
+    /**
+     * Получает короткое название маркетплейса
+     *
+     * @return string
+     */
+    public function getMarketplaceShortName(): string
+    {
+        if ($this->marketplaceName === null) {
+            return '?';
+        }
+
+        $name = mb_strtolower($this->marketplaceName);
+
+        if (str_contains($name, 'flowwow') || str_contains($name, 'флаувау')) {
+            return 'FW';
+        }
+
+        if (str_contains($name, 'yandex') || str_contains($name, 'яндекс')) {
+            return 'YM';
+        }
+
+        return mb_substr($this->marketplaceName, 0, 2);
+    }
+
+    /**
+     * Получает отформатированную сумму
+     *
+     * @return string
+     */
+    public function getFormattedTotal(): string
+    {
+        return number_format($this->total, 0, '.', ' ') . ' ₽';
+    }
+}
diff --git a/erp24/services/dto/ReportResult.php b/erp24/services/dto/ReportResult.php
deleted file mode 100644 (file)
index 8876363..0000000
+++ /dev/null
@@ -1,130 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace yii_app\services\dto;
-
-/**
- * DTO для результата генерации и отправки отчёта о непробитых чеках
- *
- * Содержит информацию о количестве найденных заказов, магазинов,
- * статусе отправки в различные каналы (Telegram, Email) и ошибках.
- */
-class ReportResult
-{
-    /**
-     * Общее количество заказов с непробитыми чеками
-     */
-    public int $totalOrders = 0;
-
-    /**
-     * Количество магазинов с непробитыми заказами
-     */
-    public int $storesCount = 0;
-
-    /**
-     * Успешность отправки в Telegram
-     */
-    public bool $telegramSent = false;
-
-    /**
-     * Успешность отправки на Email
-     */
-    public bool $emailSent = false;
-
-    /**
-     * Текст ошибки при отправке в Telegram (если была)
-     */
-    public ?string $telegramError = null;
-
-    /**
-     * Текст ошибки при отправке на Email (если была)
-     */
-    public ?string $emailError = null;
-
-    /**
-     * Дата и время формирования отчёта
-     */
-    public string $reportDate = '';
-
-    /**
-     * Общая сумма непробитых заказов
-     */
-    public float $totalAmount = 0.0;
-
-    /**
-     * Проверяет полный успех (оба канала отправлены)
-     *
-     * @return bool true если отчёт отправлен и в Telegram, и на Email
-     */
-    public function isSuccess(): bool
-    {
-        return $this->telegramSent && $this->emailSent;
-    }
-
-    /**
-     * Проверяет частичный успех (хотя бы один канал отправлен)
-     *
-     * @return bool true если отправлен только один из каналов
-     */
-    public function isPartialSuccess(): bool
-    {
-        return ($this->telegramSent || $this->emailSent) && !$this->isSuccess();
-    }
-
-    /**
-     * Проверяет наличие заказов
-     *
-     * @return bool true если есть непробитые заказы
-     */
-    public function hasOrders(): bool
-    {
-        return $this->totalOrders > 0;
-    }
-
-    /**
-     * Возвращает exit code для консольной команды
-     *
-     * @return int 0 = успех, 1 = критическая ошибка, 2 = частичный успех
-     */
-    public function getExitCode(): int
-    {
-        // Нет заказов - это успех
-        if (!$this->hasOrders()) {
-            return 0;
-        }
-
-        // Оба канала отправлены
-        if ($this->isSuccess()) {
-            return 0;
-        }
-
-        // Хотя бы один канал отправлен
-        if ($this->isPartialSuccess()) {
-            return 2;
-        }
-
-        // Ничего не отправлено
-        return 1;
-    }
-
-    /**
-     * Преобразует результат в массив для логирования
-     *
-     * @return array
-     */
-    public function toArray(): array
-    {
-        return [
-            'total_orders' => $this->totalOrders,
-            'stores_count' => $this->storesCount,
-            'total_amount' => $this->totalAmount,
-            'telegram_sent' => $this->telegramSent,
-            'email_sent' => $this->emailSent,
-            'telegram_error' => $this->telegramError,
-            'email_error' => $this->emailError,
-            'report_date' => $this->reportDate,
-            'exit_code' => $this->getExitCode(),
-        ];
-    }
-}