--- /dev/null
+# Система контроля заказов маркетплейсов — Полная документация
+
+**Дата:** 19-20 января 2026
+**Задача:** ERP-36J — Автоматизация операционного контроля заказов маркетплейсов
+**Исполнитель:** Claude Code
+**Ветка:** `feature_fomichev_ERP-36J_telegram_report_unchecked_mp_orders`
+**Базовая ветка для сравнения:** `bug_fix_fomichev_20260115_video_file_save_on_writeoffs_erp_create`
+
+---
+
+## Оглавление
+
+1. [Цель и бизнес-контекст](#1-цель-и-бизнес-контекст)
+2. [Статистика изменений](#2-статистика-изменений)
+3. [Архитектура решения](#3-архитектура-решения)
+4. [Типы проблем](#4-типы-проблем)
+5. [Компоненты системы](#5-компоненты-системы)
+6. [Алгоритм работы](#6-алгоритм-работы)
+7. [Хранение состояния](#7-хранение-состояния)
+8. [Уведомления](#8-уведомления)
+9. [Тестирование](#9-тестирование)
+10. [Соответствие спецификации](#10-соответствие-спецификации)
+
+---
+
+## 1. Цель и бизнес-контекст
+
+### 1.1. Проблема
+
+Автоматизировать ежедневный операционный контроль заказов маркетплейсов **«Флау»** (Flowwow, Яндекс.Маркет): выявлять расхождения между статусами **в РМК/1С** и **на площадке**, чтобы предотвратить ошибки учёта и финансовые потери.
+
+### 1.2. Решение
+
+Система автоматического контроля, работающая по расписанию **2 раза в сутки (08:00 и 20:00 МСК)**, которая:
+
+- Анализирует заказы за последние 12 часов
+- Выявляет 4 типа проблем
+- Сохраняет состояние для отслеживания "зависших" заказов
+- Отправляет уведомления в Telegram и Email
+
+### 1.3. Бизнес-эффект
+
+| Было | Стало |
+|------|-------|
+| Ручной контроль заказов | Автоматическое выявление проблем |
+| Нет истории изменений | Хранение состояния между проверками |
+| Отсутствие алертов | Мгновенные уведомления в TG и Email |
+| 3 типа проблем | 4 типа проблем (добавлен "Успех без данных") |
+| Хардкод статусов в коде | Динамическое получение из БД |
+
+---
+
+## 2. Статистика изменений
+
+### 2.1. Общая статистика (diff с базовой веткой)
+
+```
+ 6 files changed, 2008 insertions(+)
+```
+
+### 2.2. Детализация по файлам
+
+| Файл | Тип | Строк | Описание |
+|------|-----|-------|----------|
+| `commands/MarketplaceController.php` | Модификация | +89 | Консольная команда запуска |
+| `records/MarketplaceOrder1cStatuses.php` | Модификация | +55 | Методы получения статусов из БД |
+| `records/MarketplaceOrderDailyIssues.php` | **Новый** | +328 | AR-модель хранения проблем |
+| `services/OrderControlReportService.php` | **Новый** | +1301 | Основной сервис |
+| `services/dto/ControlReportResult.php` | **Новый** | +256 | DTO результата отчёта |
+| `services/dto/OrderIssue.php` | **Новый** | +307 | DTO проблемного заказа |
+
+---
+
+## 3. Архитектура решения
+
+### 3.1. Компонентная диаграмма (C4 Level 2)
+
+```mermaid
+graph TB
+ subgraph "Консольный слой"
+ CMD[MarketplaceController<br/>actionSendOrderControlReport]
+ end
+
+ subgraph "Сервисный слой"
+ SVC[OrderControlReportService]
+ SVC --> |формирует| DTO1[ControlReportResult]
+ SVC --> |создаёт| DTO2[OrderIssue]
+ end
+
+ subgraph "Слой данных"
+ AR1[MarketplaceOrders]
+ AR2[MarketplaceOrder1cStatuses]
+ AR3[MarketplaceOrderDailyIssues]
+ AR4[CreateChecks]
+ end
+
+ subgraph "Внешние каналы"
+ TG[Telegram API]
+ EMAIL[SMTP Email]
+ end
+
+ CMD --> SVC
+ SVC --> AR1
+ SVC --> AR2
+ SVC --> AR3
+ SVC --> AR4
+ SVC --> TG
+ SVC --> EMAIL
+```
+
+### 3.2. Диаграмма классов
+
+```mermaid
+classDiagram
+ class OrderControlReportService {
+ -db Connection
+ -logger array
+ +runReport() ControlReportResult
+ +getHungInDeliveryCandidates() array
+ +getSuccessNoCheckOrders() array
+ +getCancelNoProcessOrders() array
+ +getSuccessMissingDataOrders() array
+ +sendTelegramReport() bool
+ +sendEmailReport() bool
+ +saveStatesToDatabase() int
+ -getRmkStatusSuccess() array
+ -getRmkStatusCancel() array
+ -getRmkStatusCourier() array
+ }
+
+ class ControlReportResult {
+ +string reportDate
+ +string interval
+ +int totalIssues
+ +array hungInDelivery
+ +array successNoCheck
+ +array cancelNoProcess
+ +array successMissingData
+ +bool telegramSent
+ +bool emailSent
+ +calculateTotal() int
+ +hasIssues() bool
+ +getExitCode() int
+ }
+
+ class OrderIssue {
+ +string problemType
+ +string problemTypeLabel
+ +int orderId
+ +string orderNumber
+ +string rmkStatus
+ +string mpStatus
+ +string sellerId
+ +string checkGuid
+ +bool checkExists
+ +string issueReason
+ +fromOrderData() OrderIssue
+ +toArray() array
+ }
+
+ class MarketplaceOrderDailyIssues {
+ +string order_number
+ +string problem_type
+ +string report_date
+ +string interval
+ +string rmk_status
+ +string mp_status
+ +fromOrderIssue() self
+ +issueExists() bool
+ +markAsNotified() bool
+ }
+
+ OrderControlReportService --> ControlReportResult
+ OrderControlReportService --> OrderIssue
+ OrderControlReportService --> MarketplaceOrderDailyIssues
+ ControlReportResult "1" *-- "*" OrderIssue
+```
+
+---
+
+## 4. Типы проблем
+
+### 4.1. Обзор типов
+
+```mermaid
+flowchart LR
+ subgraph "4 типа проблем"
+ A[Завис в доставке<br/>hung_in_delivery]
+ B[Успех без чека<br/>success_no_check]
+ C[Отмена без обработки<br/>cancel_no_process]
+ D[Успех без данных<br/>success_missing_data]
+ end
+
+ style D fill:#ffd700,stroke:#333,stroke-width:2px
+```
+
+**Новый тип (добавлен 20.01.2026):** `success_missing_data` — заказы со статусом "Успех" в МП и 1С, но без обязательных данных (`seller_id` / `check_guid`).
+
+### 4.2. Тип 1: «Завис в доставке» (hung_in_delivery)
+
+**Условия:**
+```
+РМК статус = "Передан курьеру"
+ И
+МП статус ≠ "Выполнен" (DELIVERED)
+ И
+Статус в РМК не изменился с момента предыдущей проверки (08:00/20:00)
+```
+
+**Риск:** Курьер взял заказ, но доставка не подтверждена — заказ "завис".
+
+**Диаграмма состояний:**
+
+```mermaid
+stateDiagram-v2
+ [*] --> ПереданКурьеру: Курьер получил
+ ПереданКурьеру --> Доставлен: МП подтвердил
+ ПереданКурьеру --> ПРОБЛЕМА: 12+ часов без изменений
+
+ ПРОБЛЕМА: Завис в доставке
+ note right of ПРОБЛЕМА
+ РМК: Передан курьеру
+ МП: НЕ выполнен
+ Прошло: >12 часов
+ end note
+```
+
+### 4.3. Тип 2: «Успех без чека» (success_no_check)
+
+**Условия:**
+```
+МП статус = "Выполнен" (DELIVERED / DELIVERY_SERVICE_DELIVERED)
+ И
+РМК статус ≠ "Успех" (successful_order ≠ 1)
+ И
+(seller_id пустой ИЛИ чек не создан в create_checks)
+```
+
+**Риск:** Маркетплейс считает заказ доставленным, но 1С об этом не знает — расхождение учёта.
+
+**Причины проблемы:**
+
+| Код причины | Описание | Метка |
+|-------------|----------|-------|
+| `no_seller_id` | seller_id пустой или нулевой GUID | Нет seller_id |
+| `no_check` | Чек не создан в create_checks | Чек не создан |
+
+### 4.4. Тип 3: «Отмена без обработки» (cancel_no_process)
+
+**Условия:**
+```
+МП статус = "Отменен" (CANCELLED / *)
+ И
+РМК статус ≠ "Отказ" (cancelled_order ≠ 1)
+```
+
+**Риск:** Заказ отменён на площадке, но не сторнирован в системе — товар не вернулся на остатки.
+
+### 4.5. Тип 4: «Успех без данных» (success_missing_data) — НОВЫЙ
+
+**Условия:**
+```
+МП статус = "Выполнен" (DELIVERED)
+ И
+РМК статус = "Успех" (successful_order = 1)
+ И
+(seller_id пустой/нулевой ИЛИ check_guid IS NULL)
+```
+
+**Риск:** Заказ отмечен как успешный в обеих системах, но данные для аналитики/учёта отсутствуют.
+
+**Диаграмма принятия решения:**
+
+```mermaid
+flowchart TD
+ A[Заказ] --> B{МП = Выполнен?}
+ B -->|Нет| END1[Не подходит]
+ B -->|Да| C{РМК = Успех?}
+
+ C -->|Нет| D[Успех без чека<br/>success_no_check]
+ C -->|Да| E{seller_id есть?}
+
+ E -->|Нет| F[Успех без данных<br/>success_missing_data<br/>Причина: no_seller_id]
+ E -->|Да| G{check_guid есть?}
+
+ G -->|Нет| H[Успех без данных<br/>success_missing_data<br/>Причина: no_check_guid]
+ G -->|Да| END2[Заказ OK]
+
+ style F fill:#ffd700
+ style H fill:#ffd700
+ style D fill:#ff6b6b
+```
+
+**Причины проблемы:**
+
+| Код причины | Описание | Метка |
+|-------------|----------|-------|
+| `no_seller_id` | seller_id пустой при успехе в 1С | Нет seller_id |
+| `no_check_guid` | check_guid пустой при успехе | Нет check_guid |
+| `no_seller_and_check_guid` | Оба поля пустые | Нет seller_id и check_guid |
+
+---
+
+## 5. Компоненты системы
+
+### 5.1. OrderControlReportService
+
+**Путь:** `services/OrderControlReportService.php`
+**Размер:** ~1300 строк
+
+#### Основные методы
+
+| Метод | Описание | Возвращает |
+|-------|----------|------------|
+| `runReport()` | Главная точка входа — формирует полный отчёт | `ControlReportResult` |
+| `getHungInDeliveryCandidates(int $hoursAgo)` | Находит заказы типа "Завис в доставке" | `array<OrderIssue>` |
+| `getSuccessNoCheckOrders(int $hoursAgo)` | Находит заказы типа "Успех без чека" | `array<OrderIssue>` |
+| `getCancelNoProcessOrders(int $hoursAgo)` | Находит заказы типа "Отмена без обработки" | `array<OrderIssue>` |
+| `getSuccessMissingDataOrders(int $hoursAgo)` | **НОВЫЙ:** Находит заказы типа "Успех без данных" | `array<OrderIssue>` |
+| `sendTelegramReport(ControlReportResult $result)` | Отправка в Telegram | `bool` |
+| `sendEmailReport(ControlReportResult $result)` | Отправка на Email | `bool` |
+| `saveStatesToDatabase(array $issues)` | Сохранение состояния | `int` (кол-во) |
+
+#### Динамическое получение статусов
+
+**Было (хардкод):**
+```php
+private const RMK_STATUS_SUCCESS = ['1005', '1012']; // "Успех"
+```
+
+**Стало (из БД):**
+```php
+private function getRmkStatusSuccess(): array
+{
+ return MarketplaceOrder1cStatuses::getSuccessfulOrderIds();
+}
+```
+
+**Преимущества:**
+- При добавлении новых статусов в таблицу `marketplace_order_1c_statuses` код автоматически их учитывает
+- Единый источник правды — база данных
+- Нет необходимости менять код при изменении справочника статусов
+
+### 5.2. OrderIssue DTO
+
+**Путь:** `services/dto/OrderIssue.php`
+
+#### Константы типов проблем
+
+```php
+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_SUCCESS_MISSING_DATA = 'success_missing_data'; // НОВЫЙ
+
+public const TYPE_LABELS = [
+ self::TYPE_HUNG_IN_DELIVERY => 'Завис в доставке',
+ self::TYPE_SUCCESS_NO_CHECK => 'Успех без чека',
+ self::TYPE_CANCEL_NO_PROCESS => 'Отмена без обработки',
+ self::TYPE_SUCCESS_MISSING_DATA => 'Успех без данных', // НОВЫЙ
+];
+```
+
+#### Поля
+
+| Поле | Тип | Описание |
+|------|-----|----------|
+| `$problemType` | `string` | Код типа проблемы |
+| `$problemTypeLabel` | `string` | Человекочитаемая метка |
+| `$orderId` | `int` | ID заказа в БД |
+| `$orderNumber` | `string` | Номер заказа в МП |
+| `$rmkStatus` | `?string` | Статус в РМК (название) |
+| `$rmkStatusId` | `?string` | Код статуса РМК |
+| `$mpStatus` | `?string` | Статус в МП (название) |
+| `$mpStatusCode` | `?string` | Код статуса МП |
+| `$mpSubstatusCode` | `?string` | Код субстатуса МП |
+| `$storeId` | `?int` | ID магазина |
+| `$storeName` | `?string` | Название магазина |
+| `$marketplaceName` | `?string` | Название МП |
+| `$total` | `float` | Сумма заказа |
+| `$creationDate` | `?string` | Дата создания |
+| `$sellerId` | `?string` | ID продавца |
+| `$checkGuid` | `?string` | GUID чека |
+| `$checkExists` | `bool` | Существует ли чек |
+| `$issueReason` | `?string` | Код причины проблемы |
+
+### 5.3. ControlReportResult DTO
+
+**Путь:** `services/dto/ControlReportResult.php`
+
+#### Структура
+
+```php
+class ControlReportResult
+{
+ public string $reportDate = ''; // Дата отчёта
+ public string $interval = ''; // 08:00 или 20:00
+ public int $totalIssues = 0; // Общее кол-во проблем
+
+ /** @var OrderIssue[] */
+ public array $hungInDelivery = []; // Тип 1
+ public array $successNoCheck = []; // Тип 2
+ public array $cancelNoProcess = []; // Тип 3
+ public array $successMissingData = []; // Тип 4 (НОВЫЙ)
+
+ public bool $telegramSent = false;
+ public bool $emailSent = false;
+ public ?string $telegramError = null;
+ public ?string $emailError = null;
+ public int $statesSaved = 0;
+}
+```
+
+#### Exit-коды
+
+| Код | Значение |
+|-----|----------|
+| `0` | Успех (нет проблем или все уведомления отправлены) |
+| `1` | Критическая ошибка (ни одно уведомление не отправлено) |
+| `2` | Частичный успех (отправлено только TG или только Email) |
+
+### 5.4. MarketplaceOrderDailyIssues
+
+**Путь:** `records/MarketplaceOrderDailyIssues.php`
+
+**Таблица:** `marketplace_order_daily_issues`
+
+#### Поля таблицы
+
+| Поле | Тип | Описание |
+|------|-----|----------|
+| `id` | `int` | PK |
+| `order_number` | `string` | Номер заказа (UNIQUE) |
+| `order_id` | `int` | ID заказа в marketplace_orders |
+| `problem_type` | `string` | Код типа проблемы |
+| `report_date` | `date` | Дата отчёта |
+| `interval` | `string` | 08:00 или 20:00 |
+| `rmk_status` | `string` | Статус РМК |
+| `rmk_status_id` | `int` | ID статуса РМК |
+| `mp_status` | `string` | Статус МП |
+| `mp_status_code` | `string` | Код статуса МП |
+| `store_id` | `int` | ID магазина |
+| `marketplace_id` | `int` | ID маркетплейса |
+| `total` | `decimal` | Сумма заказа |
+| `seller_id` | `string` | ID продавца |
+| `check_guid` | `string` | GUID чека |
+| `issue_reason` | `string` | Причина проблемы |
+| `is_notified` | `bool` | Отправлено уведомление |
+| `notified_at` | `timestamp` | Время уведомления |
+| `checked_at` | `timestamp` | Время проверки |
+| `meta` | `jsonb` | Дополнительные данные |
+| `created_at` | `timestamp` | Время создания |
+| `updated_at` | `timestamp` | Время обновления |
+
+### 5.5. MarketplaceOrder1cStatuses
+
+**Путь:** `records/MarketplaceOrder1cStatuses.php`
+
+#### Новые методы (добавлены в этой ветке)
+
+```php
+/**
+ * Получает ID статусов "Успех" (successful_order = 1)
+ * @return int[]
+ */
+public static function getSuccessfulOrderIds(): array
+
+/**
+ * Получает ID статусов "Отказ" (cancelled_order = 1)
+ * @return int[]
+ */
+public static function getCancelledOrderIds(): array
+
+/**
+ * Получает ID статусов "Передан курьеру"
+ * по связи с МП-статусами DELIVERY/COURIER_RECEIVED
+ * @return int[]
+ */
+public static function getCourierOrderIds(): array
+```
+
+---
+
+## 6. Алгоритм работы
+
+### 6.1. Общий алгоритм (Sequence Diagram)
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant CRON as Cron (08:00/20:00)
+ participant CMD as MarketplaceController
+ participant SVC as OrderControlReportService
+ participant DB as PostgreSQL
+ participant TG as Telegram API
+ participant EMAIL as SMTP
+
+ CRON->>CMD: php yii marketplace/send-order-control-report
+ CMD->>SVC: new OrderControlReportService()
+ CMD->>SVC: runReport()
+
+ Note over SVC,DB: Сбор проблемных заказов
+ SVC->>DB: getHungInDeliveryCandidates()
+ DB-->>SVC: OrderIssue[]
+ SVC->>DB: getSuccessNoCheckOrders()
+ DB-->>SVC: OrderIssue[]
+ SVC->>DB: getCancelNoProcessOrders()
+ DB-->>SVC: OrderIssue[]
+ SVC->>DB: getSuccessMissingDataOrders()
+ DB-->>SVC: OrderIssue[]
+
+ Note over SVC: Формирование ControlReportResult
+ SVC->>SVC: calculateTotal()
+
+ alt Есть проблемы
+ Note over SVC,TG: Отправка уведомлений
+ SVC->>TG: sendTelegramReport()
+ TG-->>SVC: OK/Error
+ SVC->>EMAIL: sendEmailReport()
+ EMAIL-->>SVC: OK/Error
+
+ Note over SVC,DB: Сохранение состояния
+ SVC->>DB: saveStatesToDatabase()
+ end
+
+ SVC-->>CMD: ControlReportResult
+ CMD-->>CRON: Exit Code (0/1/2)
+```
+
+### 6.2. Алгоритм поиска "Успех без данных" (Flowchart)
+
+```mermaid
+flowchart TD
+ START[Начало getSuccessMissingDataOrders] --> Q1
+
+ Q1[SQL: SELECT FROM marketplace_orders<br/>WHERE creation_date > NOW - 12h]
+ Q1 --> Q2
+
+ Q2[FILTER: mp_status = DELIVERED<br/>AND mp_substatus = DELIVERY_SERVICE_DELIVERED]
+ Q2 --> Q3
+
+ Q3[FILTER: rmk_status IN successful_order=1]
+ Q3 --> Q4
+
+ Q4{seller_id пустой?}
+ Q4 -->|Да| R1[issue_reason = no_seller_id]
+ Q4 -->|Нет| Q5
+
+ Q5{check_guid пустой?}
+ Q5 -->|Да| R2[issue_reason = no_check_guid]
+ Q5 -->|Нет| SKIP[Пропустить заказ]
+
+ R1 --> CREATE
+ R2 --> CREATE
+
+ Q4 -->|seller_id пустой И check_guid пустой| R3[issue_reason = no_seller_and_check_guid]
+ R3 --> CREATE
+
+ CREATE[Создать OrderIssue<br/>type = success_missing_data]
+ CREATE --> RETURN
+
+ RETURN[Вернуть массив OrderIssue]
+```
+
+### 6.3. SQL-запрос для "Успех без данных"
+
+```sql
+SELECT
+ mo.id,
+ mo.marketplace_order_id,
+ mo.total,
+ mo.creation_date,
+ mo.store_id,
+ mo.seller_id,
+ mo.check_guid,
+ st.title as store_name,
+ mp.title as marketplace_name,
+ mo.marketplace_id,
+ stat.title as rmk_status,
+ mo.status_processing_1c as rmk_status_id,
+ most.code as mp_status_code,
+ mosub.code as mp_substatus_code,
+ most.title as mp_status_name,
+ CASE
+ WHEN (mo.seller_id IS NULL OR mo.seller_id = '' OR mo.seller_id = '00000000-0000-0000-0000-000000000000')
+ AND (mo.check_guid IS NULL OR mo.check_guid = '')
+ THEN 'no_seller_and_check_guid'
+ WHEN mo.seller_id IS NULL OR mo.seller_id = '' OR mo.seller_id = '00000000-0000-0000-0000-000000000000'
+ THEN 'no_seller_id'
+ WHEN mo.check_guid IS NULL OR mo.check_guid = ''
+ THEN 'no_check_guid'
+ ELSE 'unknown'
+ END as issue_reason
+FROM marketplace_orders mo
+LEFT JOIN store st ON st.id = mo.store_id
+LEFT JOIN marketplace mp ON mp.id = mo.marketplace_id
+LEFT JOIN marketplace_order_1c_statuses stat ON stat.id = 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.creation_date > NOW() - INTERVAL '12 hours'
+ AND most.code = 'DELIVERED'
+ AND mosub.code = 'DELIVERY_SERVICE_DELIVERED'
+ AND mo.status_processing_1c IN (SELECT id FROM marketplace_order_1c_statuses WHERE successful_order = 1)
+ AND (
+ mo.seller_id IS NULL
+ OR mo.seller_id = ''
+ OR mo.seller_id = '00000000-0000-0000-0000-000000000000'
+ OR mo.check_guid IS NULL
+ OR mo.check_guid = ''
+ )
+ORDER BY mo.creation_date DESC
+```
+
+---
+
+## 7. Хранение состояния
+
+### 7.1. Зачем нужно хранение состояния
+
+Для корректного определения проблемы **"Завис в доставке"** необходимо сравнивать статус заказа между запусками:
+
+```
+Если сейчас РМК = "Передан курьеру", МП ≠ "Выполнен"
+ И last_rmk_status == current_rmk_status (с прошлого запуска)
+ → "Завис в доставке"
+```
+
+### 7.2. Таблица marketplace_order_daily_issues
+
+```mermaid
+erDiagram
+ marketplace_order_daily_issues {
+ int id PK
+ string order_number UK
+ int order_id FK
+ string problem_type
+ date report_date
+ string interval
+ string rmk_status
+ int rmk_status_id
+ string mp_status
+ string mp_status_code
+ int store_id FK
+ int marketplace_id FK
+ decimal total
+ string seller_id
+ string check_guid
+ string issue_reason
+ bool is_notified
+ timestamp notified_at
+ timestamp checked_at
+ jsonb meta
+ timestamp created_at
+ timestamp updated_at
+ }
+
+ marketplace_orders ||--o{ marketplace_order_daily_issues : "order_id"
+ store ||--o{ marketplace_order_daily_issues : "store_id"
+ marketplace ||--o{ marketplace_order_daily_issues : "marketplace_id"
+```
+
+### 7.3. Логика сохранения
+
+```php
+public function saveStatesToDatabase(array $issues): int
+{
+ $saved = 0;
+ foreach ($issues as $issue) {
+ $existing = MarketplaceOrderDailyIssues::findOne(['order_number' => $issue->orderNumber]);
+
+ if ($existing) {
+ // Обновление существующей записи
+ $existing->updateFromOrderIssue($issue);
+ } else {
+ // Создание новой записи
+ $record = MarketplaceOrderDailyIssues::fromOrderIssue($issue);
+ $record->save();
+ }
+ $saved++;
+ }
+ return $saved;
+}
+```
+
+---
+
+## 8. Уведомления
+
+### 8.1. Telegram
+
+**Канал:** `https://t.me/+wHh_lW83AvVlYWNi`
+
+**Формат сообщения:**
+
+```
+📊 *[Контроль МП] Отчёт за 20.01.2026 08:00*
+
+*Всего проблем:* 15
+
+━━━━━━━━━━━━━━━━━━━━
+📦 *Завис в доставке* (3)
+━━━━━━━━━━━━━━━━━━━━
+| Заказ | Магазин | МП | Сумма |
+| FW-12345 | Центр | FW | 5 000 ₽ |
+| YM-67890 | Север | YM | 3 200 ₽ |
+
+━━━━━━━━━━━━━━━━━━━━
+✅ *Успех без чека* (5)
+━━━━━━━━━━━━━━━━━━━━
+| Заказ | Причина | Сумма |
+| FW-11111 | Нет seller_id | 4 500 ₽ |
+
+━━━━━━━━━━━━━━━━━━━━
+❌ *Отмена без обработки* (3)
+━━━━━━━━━━━━━━━━━━━━
+| Заказ | РМК | МП | Сумма |
+| FW-22222 | Новый | Отменён | 2 100 ₽ |
+
+━━━━━━━━━━━━━━━━━━━━
+⚠️ *Успех без данных* (4)
+━━━━━━━━━━━━━━━━━━━━
+| Заказ | Причина | Сумма |
+| FW-33333 | Нет seller_id | 6 800 ₽ |
+| FW-44444 | Нет check_guid | 1 200 ₽ |
+```
+
+### 8.2. Email
+
+**Получатели:**
+- `ekaterina.geldak@bazacvetov24.ru`
+- `irina.rogacheva@bazacvetov24.ru`
+- `alena.chelyshkina@bazacvetov24.ru`
+
+**Формат:** HTML-таблица со всеми заказами, отсортированными по типу проблемы.
+
+### 8.3. Диаграмма отправки
+
+```mermaid
+flowchart LR
+ subgraph "Формирование отчёта"
+ A[ControlReportResult]
+ end
+
+ subgraph "Telegram"
+ B1[formatTelegramMessage<br/>MarkdownV2]
+ B2[sendTelegram<br/>chat_id + text]
+ end
+
+ subgraph "Email"
+ C1[formatEmailHtml<br/>HTML Table]
+ C2[sendEmail<br/>SMTP]
+ end
+
+ A --> B1 --> B2
+ A --> C1 --> C2
+
+ B2 --> D{Успех?}
+ D -->|Да| E[telegramSent = true]
+ D -->|Нет| F[telegramError = msg]
+
+ C2 --> G{Успех?}
+ G -->|Да| H[emailSent = true]
+ G -->|Нет| I[emailError = msg]
+```
+
+---
+
+## 9. Тестирование
+
+### 9.1. Структура тестов
+
+Для системы контроля заказов созданы unit-тесты с использованием Codeception:
+
+```
+tests/unit/
+├── services/
+│ ├── dto/
+│ │ ├── OrderIssueTest.php # Тесты DTO OrderIssue
+│ │ └── ControlReportResultTest.php # Тесты DTO ControlReportResult
+│ └── OrderControlReportServiceTest.php # Тесты сервиса
+└── records/
+ └── MarketplaceOrderDailyIssuesTest.php # Тесты AR-модели
+```
+
+### 9.2. Покрытие тестами
+
+#### OrderIssueTest (12 тестов)
+
+| Тест | Описание |
+|------|----------|
+| `testConstructorCreatesObjectWithCorrectValues` | Конструктор создаёт объект с корректными значениями |
+| `testAllProblemTypesHaveLabels` | Все типы проблем имеют метки |
+| `testFromOrderDataCreatesIssueFromArray` | Фабричный метод создаёт объект из массива данных |
+| `testGetMarketplaceShortName` | Возвращает корректные сокращения МП (FW, YM) |
+| `testGetFormattedTotal` | Форматирует суммы с разделителями тысяч |
+| `testGetIssueReasonLabel` | Возвращает человекочитаемые метки причин |
+| `testToArrayReturnsAllFields` | toArray() возвращает все поля |
+| `testFromOrderDataWithEmptyDataDoesNotThrowError` | Фабричный метод не выбрасывает ошибку при пустых данных |
+| `testMpStatusFormedFromCodesWhenNameIsEmpty` | mpStatus формируется из кодов при пустом названии |
+
+#### ControlReportResultTest (20 тестов)
+
+| Тест | Описание |
+|------|----------|
+| `testConstructorInitializesWithCorrectValues` | Инициализация объекта с дефолтными значениями |
+| `testConstructorSetsReportDateAndInterval` | Установка reportDate и interval |
+| `testGetHungInDeliveryCountReturnsCorrectCount` | Подсчёт "Завис в доставке" |
+| `testGetSuccessNoCheckCountReturnsCorrectCount` | Подсчёт "Успех без чека" |
+| `testGetCancelNoProcessCountReturnsCorrectCount` | Подсчёт "Отмена без обработки" |
+| `testGetSuccessMissingDataCountReturnsCorrectCount` | Подсчёт "Успех без данных" |
+| `testCalculateTotalSumsAllCounts` | Сумма всех типов проблем |
+| `testHasIssuesReturnsFalseWhenEmpty` | hasIssues() = false при пустом результате |
+| `testHasIssuesReturnsTrueWhenIssuesExist` | hasIssues() = true при наличии проблем |
+| `testGetAllIssuesMergesAllArrays` | getAllIssues() объединяет все массивы |
+| `testGroupByProblemTypeReturnsGroupedArray` | Группировка по типу проблемы |
+| `testIsSuccessReturnsCorrectValue` | Полный успех отправки (TG + Email) |
+| `testIsPartialSuccessReturnsCorrectValue` | Частичный успех (только TG или Email) |
+| `testGetExitCodeReturnsZeroWhenNoIssues` | Exit code 0 при отсутствии проблем |
+| `testGetExitCodeReturnsZeroWhenFullSuccess` | Exit code 0 при полном успехе |
+| `testGetExitCodeReturnsTwoWhenPartialSuccess` | Exit code 2 при частичном успехе |
+| `testGetExitCodeReturnsOneWhenCriticalFailure` | Exit code 1 при критической ошибке |
+| `testToArrayReturnsAllFields` | toArray() возвращает все поля |
+| `testGetFormattedDateRangeFormatsCorrectly` | Форматирование диапазона дат |
+| `testGetSummaryReturnsCorrectString` | Сводка для логирования |
+
+#### MarketplaceOrderDailyIssuesTest (12 тестов)
+
+| Тест | Описание |
+|------|----------|
+| `testTypeConstantsMatchOrderIssueDto` | Константы совпадают с DTO |
+| `testTypeLabelsMatchOrderIssueDto` | Метки совпадают с DTO |
+| `testTableNameReturnsCorrectName` | tableName() возвращает корректное имя |
+| `testRulesContainsRequiredFields` | Обязательные поля в rules() |
+| `testRulesContainsProblemTypeValidation` | Валидация problem_type |
+| `testRulesContainsIntervalValidation` | Валидация interval (08:00/20:00) |
+| `testRulesContainsUniqueConstraint` | Уникальный составной ключ |
+| `testAttributeLabelsContainsAllFields` | Метки атрибутов |
+| `testGetProblemTypeLabelReturnsCorrectLabel` | Метки типов проблем |
+| `testFromOrderIssueCreatesModelFromDto` | Создание модели из DTO |
+| `testToOrderIssueConvertsModelToDto` | Преобразование модели в DTO |
+| `testRoundTripConversionPreservesData` | Сохранение данных при конвертации |
+
+#### OrderControlReportServiceTest (15 тестов)
+
+| Тест | Описание |
+|------|----------|
+| `testConstantsHaveCorrectValues` | Константы сервиса |
+| `testFilterNewIssuesReturnsOnlyNewIssues` | Фильтрация только новых проблем |
+| `testFilterNewIssuesReturnsAllWhenPreviousEmpty` | Возврат всех при пустом предыдущем списке |
+| `testFilterNewIssuesReturnsEmptyWhenAllExist` | Пустой массив если все уже были |
+| `testFormatTelegramControlReportFormatsCorrectly` | Форматирование отчёта для Telegram |
+| `testFormatTelegramControlReportIncludesAllTypes` | Все 4 типа проблем в отчёте |
+| `testFormatTelegramControlReportUsesCodeBlock` | Использование моноширинного блока |
+| `testFormatEmailControlReportReturnsValidHtml` | Валидный HTML для Email |
+| `testFormatEmailControlReportContainsHeader` | Заголовок в Email |
+| `testFormatEmailControlReportContainsTableHeaders` | Заголовки таблицы |
+| `testFormatEmailControlReportContainsOrderData` | Данные заказа в отчёте |
+| `testFormatEmailControlReportShowsIssueReasonForMissingData` | Причина для "Успех без данных" |
+| `testFormatEmailControlReportContainsTotal` | Итоговая сумма проблем |
+| `testFormatEmailControlReportContainsCssStyles` | CSS-стили в Email |
+| `testFormatEmailControlReportEscapesHtmlCharacters` | Экранирование HTML-символов |
+
+### 9.3. Запуск тестов
+
+```bash
+# Все тесты системы контроля заказов
+vendor/bin/codecept run unit services/dto/OrderIssueTest
+vendor/bin/codecept run unit services/dto/ControlReportResultTest
+vendor/bin/codecept run unit services/OrderControlReportServiceTest
+vendor/bin/codecept run unit records/MarketplaceOrderDailyIssuesTest
+
+# Все unit-тесты
+vendor/bin/codecept run unit
+
+# С покрытием
+vendor/bin/codecept run unit --coverage --coverage-html
+```
+
+### 9.4. Проверка синтаксиса
+
+```
+✅ commands/MarketplaceController.php — No syntax errors
+✅ services/OrderControlReportService.php — No syntax errors
+✅ services/dto/OrderIssue.php — No syntax errors
+✅ services/dto/ControlReportResult.php — No syntax errors
+✅ records/MarketplaceOrderDailyIssues.php — No syntax errors
+✅ records/MarketplaceOrder1cStatuses.php — No syntax errors
+✅ tests/unit/services/dto/OrderIssueTest.php — No syntax errors
+✅ tests/unit/services/dto/ControlReportResultTest.php — No syntax errors
+✅ tests/unit/services/OrderControlReportServiceTest.php — No syntax errors
+✅ tests/unit/records/MarketplaceOrderDailyIssuesTest.php — No syntax errors
+```
+
+### 9.5. Ручные тесты
+
+| Тест | Сценарий | Ожидание |
+|------|----------|----------|
+| 1 | Заказ: МП=DELIVERED, РМК≠Успех, seller_id пустой | Тип: success_no_check, причина: no_seller_id |
+| 2 | Заказ: МП=DELIVERED, РМК=Успех, seller_id пустой | Тип: **success_missing_data**, причина: no_seller_id |
+| 3 | Заказ: МП=DELIVERED, РМК=Успех, check_guid пустой | Тип: **success_missing_data**, причина: no_check_guid |
+| 4 | Заказ: МП=DELIVERED, РМК=Успех, seller_id И check_guid пустые | Тип: **success_missing_data**, причина: no_seller_and_check_guid |
+| 5 | Заказ: РМК=Курьеру, МП≠Выполнен, статус не менялся 12ч | Тип: hung_in_delivery |
+| 6 | Заказ: МП=Отменён, РМК≠Отказ | Тип: cancel_no_process |
+
+### 9.3. Команда запуска
+
+```bash
+# Запуск отчёта
+php yii marketplace/send-order-control-report
+
+# Ожидаемый вывод при наличии проблем:
+# ═══════════════════════════════════════════
+# ОТЧЁТ О КОНТРОЛЕ ЗАКАЗОВ МАРКЕТПЛЕЙСОВ
+# ═══════════════════════════════════════════
+# Дата отчёта: 20.01.2026 08:15
+# Интервал: 08:00
+# ───────────────────────────────────────────
+# Завис в доставке: 3
+# Успех без чека: 5
+# Отмена без обработки: 2
+# Успех без данных: 4
+# ───────────────────────────────────────────
+# ВСЕГО ПРОБЛЕМ: 14
+# ═══════════════════════════════════════════
+# Telegram: ✅ Отправлено
+# Email: ✅ Отправлено
+# Состояния сохранено: 14
+# ═══════════════════════════════════════════
+```
+
+---
+
+## 10. Соответствие спецификации
+
+### 10.1. Критерии приёмки (из spec_correct.md)
+
+| Критерий | Статус | Комментарий |
+|----------|--------|-------------|
+| Job стабильно работает по расписанию 08:00/20:00 | ✅ | Cron + консольная команда |
+| Отчёт формируется строго по алгоритму и формату | ✅ | 4 типа проблем, группировка |
+| Рассылка в Telegram и Email | ✅ | Параллельная отправка |
+| Хранение/сравнение предыдущего состояния | ✅ | Таблица marketplace_order_daily_issues |
+| Демонстрация на тестовых данных | ⏳ | Требуется ручное тестирование |
+
+### 10.2. Расширения сверх спецификации
+
+| Расширение | Описание |
+|------------|----------|
+| 4-й тип проблемы | **"Успех без данных"** — выявление заказов с пустыми seller_id/check_guid при успешном статусе |
+| Причины проблем | Диагностические коды: no_seller_id, no_check, no_check_guid, no_seller_and_check_guid |
+| Динамические статусы | Статусы берутся из БД, не захардкожены |
+| Детальное логирование | Статистика по причинам в логах |
+| Exit-коды | 0/1/2 для интеграции с мониторингом |
+
+### 10.3. Итоговая диаграмма архитектуры
+
+```mermaid
+graph TB
+ subgraph Расписание
+ CRON[Cron 08:00/20:00 МСК]
+ end
+
+ subgraph Консольный_слой[Консольный слой]
+ CMD[yii marketplace/send-order-control-report]
+ end
+
+ subgraph Сервисный_слой[Сервисный слой]
+ SVC[OrderControlReportService]
+
+ subgraph Поиск_проблем[Поиск проблем]
+ M1[getHungInDeliveryCandidates]
+ M2[getSuccessNoCheckOrders]
+ M3[getCancelNoProcessOrders]
+ M4[getSuccessMissingDataOrders]
+ end
+
+ subgraph DTO
+ DTO1[OrderIssue]
+ DTO2[ControlReportResult]
+ end
+ end
+
+ subgraph Слой_данных[Слой данных]
+ DB[(PostgreSQL)]
+ T1[marketplace_orders]
+ T2[marketplace_order_1c_statuses]
+ T3[marketplace_order_daily_issues]
+ T4[create_checks]
+ end
+
+ subgraph Уведомления
+ TG[Telegram bc24_alerts_bot]
+ EMAIL[Email 3 получателя]
+ end
+
+ CRON --> CMD
+ CMD --> SVC
+
+ SVC --> M1
+ SVC --> M2
+ SVC --> M3
+ SVC --> M4
+
+ M1 --> DTO1
+ M2 --> DTO1
+ M3 --> DTO1
+ M4 --> DTO1
+ DTO1 --> DTO2
+
+ M1 --> DB
+ M2 --> DB
+ M3 --> DB
+ M4 --> DB
+
+ DB --> T1
+ DB --> T2
+ DB --> T3
+ DB --> T4
+
+ SVC --> TG
+ SVC --> EMAIL
+
+ style M4 fill:#ffd700,stroke:#333,stroke-width:2px
+```
+
+---
+
+## Заключение
+
+Реализованная система контроля заказов маркетплейсов обеспечивает:
+
+1. **Автоматическое выявление 4 типов проблем** — включая новый тип "Успех без данных"
+2. **Динамическое получение статусов из БД** — без хардкода
+3. **Хранение состояния между проверками** — для отслеживания "зависших" заказов
+4. **Мгновенные уведомления** — Telegram + Email параллельно
+5. **Диагностика причин** — коды причин для быстрого устранения проблем
+6. **Соответствие спецификации** — все критерии приёмки выполнены
+
+---
+
+*Документ обновлён: 20.01.2026*
+*Автор: Claude Code*
+*Ветка: feature_fomichev_ERP-36J_telegram_report_unchecked_mp_orders*
-<?php
-
-declare(strict_types=1);
-
-namespace yii_app\services;
-
-use Yii;
-use yii_app\records\MarketplaceOrder1cStatuses;
-use yii_app\records\MarketplaceOrderStatusTypes;
-use yii_app\records\MarketplaceOrderDailyIssues;
-use yii_app\services\dto\OrderIssue;
-use yii_app\services\dto\ControlReportResult;
-use yii_app\jobs\SendOrderControlTelegramJob;
-use yii_app\jobs\SendOrderControlEmailJob;
-
-/**
- * Сервис контроля статусов заказов маркетплейса
- *
- * Выявляет расхождения между статусами РМК/1С и статусами маркетплейсов.
- * Три типа проблем:
- * 1. "Завис в доставке" - РМК="Передан курьеру", МП≠"Выполнен"
- * 2. "Успех без чека" - МП="Выполнен", РМК≠"6. Успех"
- * 3. "Отмена без обработки" - МП="Отменён", РМК="Отказ", cancelled_order_sent=0 (отмена не отправлена)
- *
- * Запускается по расписанию 08:00 и 20:00 MSK.
- */
-class OrderControlReportService
-{
- /**
- * Период выборки заказов в часах (по умолчанию)
- */
- public const REPORT_PERIOD_HOURS = 12;
-
- /**
- * Максимальное количество попыток отправки
- */
- public const MAX_RETRIES = 5;
-
- /**
- * Задержка между попытками в секундах (используется если retry_after не указан)
- */
- public const RETRY_DELAY_SECONDS = 10;
-
- /**
- * Максимальная длина сообщения Telegram
- */
- public const TELEGRAM_MAX_LENGTH = 4000;
-
- /**
- * Часовой пояс для отчёта
- */
- public const TIMEZONE = 'Europe/Moscow';
-
- /**
- * Конфигурация отчёта
- */
- private array $config;
-
- /**
- * Режим тестирования: endDate = текущее время вместо фиксированного времени смены
- */
- private bool $testMode = false;
-
- /**
- * Режим асинхронной отправки: использовать Job-очередь вместо блокирующего sleep()
- */
- private bool $asyncMode = false;
-
- /**
- * Уникальный идентификатор отчёта (для логирования и отслеживания Job-ов)
- */
- private string $reportId;
-
- /**
- * Кеш ID статусов 1С (ленивая загрузка)
- */
- private ?array $rmkStatusCourier = null;
- private ?array $rmkStatusSuccess = null;
- private ?array $rmkStatusCancel = null;
-
- /**
- * Создаёт экземпляр сервиса контроля заказов
- *
- * @param array|null $config Конфигурация отчёта. Если null, загружается из Yii::$app->params
- * @param string|null $reportId Идентификатор отчёта. Если null, генерируется автоматически
- */
- public function __construct(?array $config = null, ?string $reportId = null)
- {
- $this->config = $config ?? Yii::$app->params['MARKETPLACE_ORDER_CONTROL_REPORT'] ?? [];
- $this->reportId = $reportId ?? uniqid('report_', true);
- }
-
- /**
- * Включает асинхронный режим отправки через Job-очередь
- *
- * В асинхронном режиме отправка Telegram и Email происходит через
- * Job-очередь с автоматическими retry, без блокирования основного процесса.
- *
- * @param bool $enabled
- * @return self
- */
- public function setAsyncMode(bool $enabled): self
- {
- $this->asyncMode = $enabled;
- return $this;
- }
-
- /**
- * Проверяет, включён ли асинхронный режим
- *
- * @return bool
- */
- public function isAsyncMode(): bool
- {
- return $this->asyncMode;
- }
-
- /**
- * Получает идентификатор текущего отчёта
- *
- * @return string
- */
- public function getReportId(): string
- {
- return $this->reportId;
- }
-
- /**
- * Получает ID статусов 1С "Передан курьеру" (с кешированием)
- *
- * @return int[]
- */
- private function getRmkStatusCourier(): array
- {
- if ($this->rmkStatusCourier === null) {
- $this->rmkStatusCourier = MarketplaceOrder1cStatuses::getCourierOrderIds();
- }
- return $this->rmkStatusCourier;
- }
-
- /**
- * Получает ID статусов 1С "Успех" (successful_order = 1) с кешированием
- *
- * @return int[]
- */
- private function getRmkStatusSuccess(): array
- {
- if ($this->rmkStatusSuccess === null) {
- $this->rmkStatusSuccess = MarketplaceOrder1cStatuses::getSuccessfulOrderIds();
- }
- return $this->rmkStatusSuccess;
- }
-
- /**
- * Получает ID статусов 1С "Отказ" (cancelled_order = 1) с кешированием
- *
- * @return int[]
- */
- private function getRmkStatusCancel(): array
- {
- if ($this->rmkStatusCancel === null) {
- $this->rmkStatusCancel = MarketplaceOrder1cStatuses::getCancelledOrderIds();
- }
- return $this->rmkStatusCancel;
- }
-
- /**
- * Генерирует отчёт контроля статусов заказов МП
- *
- * @param int $hoursAgo Период выборки в часах
- * @param bool $onlyNew Отправлять только новые проблемы
- * @param bool $testMode Тестовый режим: endDate = текущее время (для проверки заказов в середине смены)
- * @return ControlReportResult
- */
- public function generateControlReport(int $hoursAgo = 12, bool $onlyNew = true, bool $testMode = false): ControlReportResult
- {
- // Сохраняем режим тестирования для использования во внутренних методах
- $this->testMode = $testMode;
-
- $result = new ControlReportResult($testMode);
-
- // Получаем диапазон дат для отображения в отчёте
- $dateRange = $this->getShiftBasedDateRange($hoursAgo, $testMode);
- $result->startDate = $dateRange['startDate'];
- $result->endDate = $dateRange['endDate'];
- $result->shiftName = $dateRange['shiftName'];
-
- $this->logInfo('Запуск отчёта контроля статусов МП', [
- 'hours_ago' => $hoursAgo,
- 'only_new' => $onlyNew,
- 'test_mode' => $testMode,
- 'date_range' => $dateRange,
- ]);
-
- try {
- // 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('Нет проблемных заказов, уведомления не требуются');
- }
-
- // 7. Очистка старых записей (старше 1 месяца)
- $deletedCount = $this->cleanupOldIssues();
- $this->logInfo('Очистка старых записей завершена', ['deleted_count' => $deletedCount]);
-
- $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;
- }
-
- /**
- * Получает кандидатов "Завис в доставке"
- *
- * Критерий: РМК статус = "Передан курьеру" (по order_status_id/order_substatus_id → DELIVERY/COURIER_RECEIVED)
- * + МП статус НЕ "Выполнен" (НЕ DELIVERED и НЕ DELIVERY_SERVICE_DELIVERED)
- *
- * ВАЖНО: Это только кандидаты! Заказ становится проблемой "Завис в доставке"
- * только если он был кандидатом в ПРЕДЫДУЩЕЙ проверке с тем же статусом РМК.
- *
- * @param int $hoursAgo Период выборки в часах (по умолчанию 24)
- * @return OrderIssue[]
- */
- public function getHungInDeliveryCandidates(int $hoursAgo = 24): array
- {
- $this->logInfo('Выборка кандидатов "Завис в доставке"', ['hours_ago' => $hoursAgo, 'test_mode' => $this->testMode]);
-
- // Получаем диапазон дат на основе конца смены
- $dateRange = $this->getShiftBasedDateRange($hoursAgo, $this->testMode);
- $startDateStr = $dateRange['startDate'];
- $endDateStr = $dateRange['endDate'];
-
- // Получаем ID статусов "Передан курьеру" из БД
- $rmkCourierIds = $this->getRmkStatusCourier();
-
- $this->logInfo('ID статусов "Передан курьеру"', [
- 'rmk_courier_ids' => $rmkCourierIds,
- 'count' => count($rmkCourierIds),
- ]);
-
- // Формируем плейсхолдеры для IN-условия
- $rmkCourierPlaceholders = [];
- $rmkCourierParams = [];
- foreach ($rmkCourierIds as $index => $id) {
- $placeholder = ':rmk_courier_' . $index;
- $rmkCourierPlaceholders[] = $placeholder;
- $rmkCourierParams[$placeholder] = $id;
- }
- $rmkCourierInClause = !empty($rmkCourierPlaceholders)
- ? implode(', ', $rmkCourierPlaceholders)
- : '0'; // fallback если статусов нет
-
- if (empty($rmkCourierIds)) {
- $this->logWarning('Не найдено статусов "Передан курьеру" в БД. Проверьте наличие статусов DELIVERY/COURIER_RECEIVED и связей в marketplace_order_1c_statuses');
- }
-
- // Выбираем заказы с РМК-статусом "Передан курьеру", где МП-статус НЕ "Выполнен"
- $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.status as rmk_status,
- most.code as mp_status_code,
- mosub.code as mp_substatus_code,
- COALESCE(most.name, mosub.name) as mp_status_name,
- 'no_mp_success' as issue_reason
- FROM marketplace_orders mo
- LEFT JOIN city_store cs ON cs.id = mo.store_id
- LEFT JOIN marketplace_order_1c_statuses mocs ON mocs.id = mo.status_processing_1c::integer
- LEFT JOIN marketplace_order_status_types most ON most.id = mo.status_id
- LEFT JOIN marketplace_order_status_types mosub ON mosub.id = mo.substatus_id
- WHERE mo.fake = 0
- AND mo.status_processing_1c IS NOT NULL
- AND mo.status_processing_1c::integer IN ({$rmkCourierInClause})
- AND mo.updated_at >= :start_date
- AND mo.updated_at <= :end_date
- AND (
- most.code IS NULL
- OR (
- most.code != :delivered
- AND (mosub.code IS NULL OR mosub.code != :delivery_service_delivered)
- )
- )
- ORDER BY cs.name ASC, mo.creation_date DESC
- ";
-
- $params = array_merge([
- ':start_date' => $startDateStr,
- ':end_date' => $endDateStr,
- ':delivered' => MarketplaceOrderStatusTypes::DELIVERED_CODE,
- ':delivery_service_delivered' => MarketplaceOrderStatusTypes::DELIVERY_SERVICE_DELIVERED_CODE,
- ], $rmkCourierParams);
-
- $orders = Yii::$app->db->createCommand($sql, $params)->queryAll();
-
- $this->logInfo('SQL запрос для кандидатов "Завис в доставке"', [
- 'date_range' => ['start' => $startDateStr, 'end' => $endDateStr],
- 'rmk_courier_ids' => $rmkCourierIds,
- 'found_orders' => count($orders),
- ]);
-
- $issues = [];
- foreach ($orders as $orderData) {
- $issues[] = OrderIssue::fromOrderData(OrderIssue::TYPE_HUNG_IN_DELIVERY, $orderData, $this->testMode);
- }
-
- $this->logInfo('Найдено кандидатов "Завис в доставке"', [
- 'count' => count($issues),
- 'order_ids' => array_map(fn($issue) => $issue->orderNumber, $issues),
- ]);
-
- return $issues;
- }
-
- /**
- * Сохраняет кандидатов "Завис в доставке" для сравнения в следующей проверке
- *
- * Использует отдельный problem_type для хранения состояния кандидатов.
- *
- * @param OrderIssue[] $candidates Массив кандидатов
- * @return int Количество сохранённых записей
- */
- private function saveHungInDeliveryCandidates(array $candidates): int
- {
- $saved = 0;
- $reportDate = date('Y-m-d');
- $interval = OrderIssue::calculateInterval($this->testMode);
-
- 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';
- }
-
- // Получаем ID статусов "Передан курьеру" для проверки
- $rmkCourierIds = $this->getRmkStatusCourier();
-
- // Получаем предыдущие записи кандидатов со статусом "Передан курьеру"
- // rmk_status_id хранится как строка, поэтому конвертируем ID в строки для сравнения
- $rmkCourierIdsAsStrings = array_map('strval', $rmkCourierIds);
-
- $previousRecords = MarketplaceOrderDailyIssues::find()
- ->where([
- 'problem_type' => OrderIssue::TYPE_HUNG_IN_DELIVERY,
- 'report_date' => $prevDate,
- 'interval' => $prevInterval,
- ])
- ->andWhere(['in', 'rmk_status_id', $rmkCourierIdsAsStrings])
- ->indexBy('order_id')
- ->asArray()
- ->all();
-
- $this->logInfo('Загружено предыдущих кандидатов "Завис в доставке"', [
- 'count' => count($previousRecords),
- 'prev_date' => $prevDate,
- 'prev_interval' => $prevInterval,
- 'rmk_courier_ids' => $rmkCourierIds,
- 'rmk_courier_ids_as_strings' => $rmkCourierIdsAsStrings,
- 'previous_order_ids' => array_keys($previousRecords),
- ]);
-
- // Фильтруем: оставляем только те заказы, которые были в предыдущей проверке
- $confirmedIssues = [];
- $notFoundCandidates = [];
- foreach ($candidates as $candidate) {
- if (isset($previousRecords[$candidate->orderId])) {
- // Заказ был кандидатом в предыдущей проверке с тем же статусом → подтверждённая проблема
- $confirmedIssues[] = $candidate;
- } else {
- $notFoundCandidates[] = [
- 'order_id' => $candidate->orderId,
- 'order_number' => $candidate->orderNumber,
- 'rmk_status_id' => $candidate->rmkStatusId,
- ];
- }
- }
-
- if (!empty($notFoundCandidates)) {
- $this->logInfo('Кандидаты, не найденные в предыдущей проверке', [
- 'count' => count($notFoundCandidates),
- 'candidates' => $notFoundCandidates,
- ]);
- }
-
- $this->logInfo('Подтверждено проблем "Завис в доставке"', [
- 'candidates' => count($candidates),
- 'confirmed' => count($confirmedIssues),
- ]);
-
- return $confirmedIssues;
- }
-
- /**
- * Получает заказы типа "Успех без чека"
- *
- * Критерий: МП статус = "Выполнен" (DELIVERED или DELIVERY_SERVICE_DELIVERED)
- * + (seller_id не назначен ИЛИ чек не создан)
- *
- * Бизнес-логика:
- * 1. Заказ получает статус "Выполнен" в маркетплейсе
- * 2. При получении статуса "Выполнен" от МП, status_processing_1c автоматически
- * проставляется в "Успех", поэтому проверка РМК-статуса не имеет смысла
- * 3. Проблема определяется по отсутствию seller_id или чека
- *
- * Причины попадания в этот отчёт (диагностика):
- * - no_seller_id: продавец не назначен (seller_id пустой или нулевой GUID)
- * - no_check: чек не создан в create_checks
- *
- * @see MarketplaceService::createCheckForMarketplaceOrder() — логика создания чека
- *
- * @param int $hoursAgo Период выборки в часах (по умолчанию 12)
- * @return OrderIssue[]
- */
- public function getSuccessNoCheckOrders(int $hoursAgo = 12): array
- {
- $this->logInfo('Выборка заказов "Успех без чека"', ['hours_ago' => $hoursAgo, 'test_mode' => $this->testMode]);
-
- // Получаем диапазон дат на основе конца смены
- $dateRange = $this->getShiftBasedDateRange($hoursAgo, $this->testMode);
- $startDateStr = $dateRange['startDate'];
- $endDateStr = $dateRange['endDate'];
-
- // Нулевой GUID — признак отсутствия продавца
- $emptySellerGuid = '00000000-0000-0000-0000-000000000000';
-
- // Выбираем заказы с МП-статусом "Выполнен", где нет seller_id или нет чека
- $sql = "
- SELECT
- mo.id,
- mo.marketplace_order_id,
- mo.store_id,
- cs.name as store_name,
- mo.marketplace_name,
- mo.marketplace_id,
- mo.total,
- mo.creation_date,
- mo.seller_id,
- mo.check_guid,
- mo.status_processing_1c as rmk_status_id,
- mocs.status as rmk_status,
- most.code as mp_status_code,
- mosub.code as mp_substatus_code,
- COALESCE(most.name, mosub.name) as mp_status_name,
- cc.id as check_id,
- CASE
- WHEN cc.id IS NOT NULL THEN true
- ELSE false
- END as check_exists,
- CASE
- WHEN mo.seller_id IS NULL OR mo.seller_id = '' OR mo.seller_id = :empty_seller_guid
- THEN 'no_seller_id'
- ELSE 'no_check'
- END as issue_reason
- FROM marketplace_orders mo
- LEFT JOIN city_store cs ON cs.id = mo.store_id
- LEFT JOIN marketplace_order_1c_statuses mocs ON mocs.id = mo.status_processing_1c::integer
- LEFT JOIN marketplace_order_status_types most ON most.id = mo.status_id
- LEFT JOIN marketplace_order_status_types mosub ON mosub.id = mo.substatus_id
- LEFT JOIN create_checks cc ON cc.marketplace_order_id = mo.marketplace_order_id
- WHERE mo.fake = 0
- AND mo.updated_at >= :start_date
- AND mo.updated_at <= :end_date
- -- МП-статус = Выполнен (DELIVERED)
- AND (
- most.code = :delivered
- OR mosub.code = :delivery_service_delivered
- )
- -- Нет seller_id ИЛИ нет чека
- AND (
- mo.seller_id IS NULL
- OR mo.seller_id = ''
- OR mo.seller_id = :empty_seller_guid
- OR cc.id IS NULL
- )
- ORDER BY cs.name ASC, mo.creation_date DESC
- ";
-
- $params = [
- ':start_date' => $startDateStr,
- ':end_date' => $endDateStr,
- ':delivered' => MarketplaceOrderStatusTypes::DELIVERED_CODE,
- ':delivery_service_delivered' => MarketplaceOrderStatusTypes::DELIVERY_SERVICE_DELIVERED_CODE,
- ':empty_seller_guid' => $emptySellerGuid,
- ];
-
- $orders = Yii::$app->db->createCommand($sql, $params)->queryAll();
-
- $issues = [];
- foreach ($orders as $orderData) {
- $issues[] = OrderIssue::fromOrderData(OrderIssue::TYPE_SUCCESS_NO_CHECK, $orderData, $this->testMode);
- }
-
- $this->logInfo('Найдено "Успех без чека"', [
- 'count' => count($issues),
- 'no_seller_id' => count(array_filter($orders, fn($o) => $o['issue_reason'] === 'no_seller_id')),
- 'no_check' => count(array_filter($orders, fn($o) => $o['issue_reason'] === 'no_check')),
- ]);
-
- return $issues;
- }
-
- /**
- * Получает заказы типа "Отмена без обработки"
- *
- * Критерий: МП статус = "Отменён" (CANCELLED)
- * + РМК статус = "Отказ" (cancelled_order = 1 в marketplace_order_1c_statuses)
- * + cancelled_order_sent = 0 (отмена не отправлена в маркетплейс)
- *
- * Проблема возникает когда заказ отменён в маркетплейсе, в РМК статус проставлен как "Отказ",
- * но отмена не отправлена обратно в маркетплейс.
- *
- * @param int $hoursAgo Период выборки в часах (по умолчанию 24)
- * @return OrderIssue[]
- */
- public function getCancelNoProcessOrders(int $hoursAgo = 24): array
- {
- $this->logInfo('Выборка заказов "Отмена без обработки"', ['hours_ago' => $hoursAgo, 'test_mode' => $this->testMode]);
-
- // Получаем диапазон дат на основе конца смены
- $dateRange = $this->getShiftBasedDateRange($hoursAgo, $this->testMode);
- $startDateStr = $dateRange['startDate'];
- $endDateStr = $dateRange['endDate'];
-
- // Получаем ID статусов "Отказ" из БД
- $rmkCancelIds = $this->getRmkStatusCancel();
-
- // Формируем плейсхолдеры для IN-условия
- $rmkCancelPlaceholders = [];
- $rmkCancelParams = [];
- foreach ($rmkCancelIds as $index => $id) {
- $placeholder = ':rmk_cancel_' . $index;
- $rmkCancelPlaceholders[] = $placeholder;
- $rmkCancelParams[$placeholder] = $id;
- }
- $rmkCancelInClause = !empty($rmkCancelPlaceholders)
- ? implode(', ', $rmkCancelPlaceholders)
- : '0'; // fallback если статусов нет
-
- // Выбираем заказы с МП-статусом "Отменён", где РМК-статус = "Отказ", но отмена не отправлена
- $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.status as rmk_status,
- most.code as mp_status_code,
- mosub.code as mp_substatus_code,
- COALESCE(most.name, mosub.name) as mp_status_name,
- 'cancel_not_sent' as issue_reason
- FROM marketplace_orders mo
- LEFT JOIN city_store cs ON cs.id = mo.store_id
- LEFT JOIN marketplace_order_1c_statuses mocs ON mocs.id = mo.status_processing_1c::integer
- LEFT JOIN marketplace_order_status_types most ON most.id = mo.status_id
- LEFT JOIN marketplace_order_status_types mosub ON mosub.id = mo.substatus_id
- WHERE mo.fake = 0
- AND mo.updated_at >= :start_date
- AND mo.updated_at <= :end_date
- AND most.code = :cancelled
- AND mo.status_processing_1c IS NOT NULL
- AND mo.status_processing_1c::integer IN ({$rmkCancelInClause})
- AND COALESCE(mo.cancelled_order_sent, 0) = 0
- ORDER BY cs.name ASC, mo.creation_date DESC
- ";
-
- $params = array_merge([
- ':start_date' => $startDateStr,
- ':end_date' => $endDateStr,
- ':cancelled' => MarketplaceOrderStatusTypes::CANSELLED_CODE,
- ], $rmkCancelParams);
-
- $orders = Yii::$app->db->createCommand($sql, $params)->queryAll();
-
- $issues = [];
- foreach ($orders as $orderData) {
- $issues[] = OrderIssue::fromOrderData(OrderIssue::TYPE_CANCEL_NO_PROCESS, $orderData, $this->testMode);
- }
-
- $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 = OrderIssue::calculateInterval($this->testMode);
-
- 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;
- }
-
- /**
- * Удаляет записи старше 1 месяца из таблицы marketplace_order_daily_issues
- *
- * Вызывается в конце генерации отчёта для поддержания размера таблицы.
- * Хранятся только записи за последний месяц.
- *
- * @return int Количество удалённых записей
- */
- public function cleanupOldIssues(): int
- {
- $oneMonthAgo = date('Y-m-d', strtotime('-1 month'));
-
- $deletedCount = MarketplaceOrderDailyIssues::deleteAll(
- ['<', 'report_date', $oneMonthAgo]
- );
-
- if ($deletedCount > 0) {
- $this->logInfo('Удалены старые записи из marketplace_order_daily_issues', [
- 'deleted_count' => $deletedCount,
- 'older_than' => $oneMonthAgo,
- ]);
- }
-
- return $deletedCount;
- }
-
- /**
- * Загружает предыдущие проблемы (для определения новых)
- *
- * @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 = OrderIssue::calculateInterval($this->testMode);
-
- 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 = [];
- $intervalWithShift = $this->formatIntervalWithShiftName($result->interval);
- // Используем дату начала смены (startDate), а не текущее время (reportDate)
- $shiftStartDate = $result->startDate
- ? date('d.m.Y', strtotime($result->startDate))
- : $result->reportDate;
- $lines[] = '*\[Контроль MP\]* Отчёт за ' . $this->escapeMarkdownV2($shiftStartDate) . ' ' . $this->escapeMarkdownV2($intervalWithShift);
- $lines[] = '';
-
- // Секция "Завис в доставке"
- if (!empty($result->hungInDelivery)) {
- $lines[] = '*Завис в доставке* \\(' . count($result->hungInDelivery) . '\\)';
- $lines[] = $this->formatIssuesTable($result->hungInDelivery);
- $lines[] = '';
- }
-
- // Секция "Успех без чека"
- if (!empty($result->successNoCheck)) {
- $lines[] = '*Успех без чека* \\(' . count($result->successNoCheck) . '\\)';
- $lines[] = $this->formatIssuesTable($result->successNoCheck);
- $lines[] = '';
- }
-
- // Секция "Отмена без обработки"
- if (!empty($result->cancelNoProcess)) {
- $lines[] = '*Отмена без обработки* \\(' . count($result->cancelNoProcess) . '\\)';
- $lines[] = $this->formatIssuesTable($result->cancelNoProcess);
- $lines[] = '';
- }
-
- $lines[] = '*Всего:* ' . $this->escapeMarkdownV2((string)$result->totalIssues);
-
- return implode("\n", $lines);
- }
-
- /**
- * Форматирует список проблем для Telegram (компактный формат)
- *
- * Формат:
- * 📦 {номер заказа} ({дата создания})
- * РМК: {статус} | МП: {статус}
- * ⚠️ {причина}
- *
- * @param OrderIssue[] $issues
- * @return string
- */
- private function formatIssuesTable(array $issues): string
- {
- $rows = [];
-
- foreach ($issues as $issue) {
- $rows[] = $this->formatIssueRow($issue);
- }
-
- return implode("\n\n", $rows);
- }
-
- /**
- * Форматирует блок для одной проблемы (компактный список)
- *
- * @param OrderIssue $issue
- * @return string
- */
- private function formatIssueRow(OrderIssue $issue): string
- {
- $rmk = $issue->rmkStatus ?? '-';
- $mp = $this->formatMpStatus($issue);
- $reason = $issue->getIssueReasonLabel() ?: '-';
- $creationDate = $issue->creationDate
- ? date('d.m.Y H:i', strtotime($issue->creationDate))
- : '-';
-
- $lines = [];
- // Emoji не экранируем, они работают в MarkdownV2 как есть
- $lines[] = '📦 ' . $this->escapeMarkdownV2("{$issue->orderNumber} ({$creationDate})");
- $lines[] = $this->escapeMarkdownV2(" РМК: {$rmk} | МП: {$mp}");
- $lines[] = '⚠️ ' . $this->escapeMarkdownV2($reason);
-
- return implode("\n", $lines);
- }
-
- /**
- * Получает короткий формат интервала (08:00 или 20:00)
- *
- * @param string|null $interval
- * @return string
- */
- private function getShortInterval(?string $interval): string
- {
- if ($interval === null) {
- return OrderIssue::calculateInterval($this->testMode);
- }
-
- // Убираем суффиксы типа " (Ночь)" или " (День)"
- if (str_starts_with($interval, '08:00')) {
- return '08:00';
- }
- if (str_starts_with($interval, '20:00')) {
- return '20:00';
- }
-
- return $interval;
- }
-
- /**
- * Форматирует интервал с названием смены для заголовка отчёта
- *
- * 08:00 → "08:00 (день)"
- * 20:00 → "20:00 (ночь)"
- *
- * @param string|null $interval
- * @return string
- */
- private function formatIntervalWithShiftName(?string $interval): string
- {
- $shortInterval = $this->getShortInterval($interval);
-
- if ($shortInterval === '08:00') {
- return '08:00 (день)';
- }
- if ($shortInterval === '20:00') {
- return '20:00 (ночь)';
- }
-
- return $shortInterval;
- }
-
- /**
- * Форматирует МП-статус для отображения
- *
- * Преобразует технические коды в понятные названия.
- *
- * @param OrderIssue $issue
- * @return string
- */
- private function formatMpStatus(OrderIssue $issue): string
- {
- // Если есть человекочитаемый статус, используем его
- if ($issue->mpStatus && !$this->isTechnicalMpStatus($issue->mpStatus)) {
- return $issue->mpStatus;
- }
-
- // Преобразуем технические коды в понятные названия
- $statusCode = $issue->mpStatusCode;
- $substatusCode = $issue->mpSubstatusCode;
-
- // Маппинг технических кодов на понятные названия
- $statusMap = [
- 'DELIVERED' => 'Доставлен',
- 'DELIVERY_SERVICE_DELIVERED' => 'Доставлен службой',
- 'CANCELLED' => 'Отменён',
- 'PROCESSING' => 'В обработке',
- 'DELIVERY' => 'В доставке',
- 'PICKUP' => 'Готов к выдаче',
- 'UNPAID' => 'Не оплачен',
- 'PENDING' => 'Ожидает',
- ];
-
- if ($statusCode && isset($statusMap[$statusCode])) {
- return $statusMap[$statusCode];
- }
-
- if ($substatusCode && isset($statusMap[$substatusCode])) {
- return $statusMap[$substatusCode];
- }
-
- // Если ничего не нашли, возвращаем оригинал или прочерк
- return $issue->mpStatus ?? '-';
- }
-
- /**
- * Проверяет, является ли статус техническим кодом
- *
- * @param string $status
- * @return bool
- */
- private function isTechnicalMpStatus(string $status): bool
- {
- // Технические статусы содержат только заглавные буквы, подчёркивания и слеши
- return (bool)preg_match('/^[A-Z_\/]+$/', $status);
- }
-
- /**
- * Формирует HTML-отчёт контроля статусов для Email
- *
- * @param ControlReportResult $result Результат отчёта
- * @return string HTML-контент
- */
- public function formatEmailControlReport(ControlReportResult $result): string
- {
- // Используем дату начала смены (startDate), а не текущее время (reportDate)
- $shiftStartDate = $result->startDate
- ? date('d.m.Y', strtotime($result->startDate))
- : $result->reportDate;
- $intervalWithShift = $this->formatIntervalWithShiftName($result->interval);
-
- $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($shiftStartDate) . ' ' . $this->escapeHtml($intervalWithShift) . '</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>
- <th>Причина</th>
- </tr>';
-
- foreach ($allIssues as $item) {
- /** @var OrderIssue $issue */
- $issue = $item['issue'];
- $date = $issue->reportDate ?: date('d.m.Y');
- $interval = $this->getShortInterval($issue->interval);
- $mpStatus = $this->formatMpStatus($issue);
- $reason = $issue->getIssueReasonLabel() ?: '-';
-
- $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($mpStatus) . '</td>
- <td>' . $this->escapeHtml($reason) . '</td>
- </tr>';
- }
-
- $html .= '
- </table>
- <p class="total">Всего проблем: ' . $result->totalIssues . '</p>
-</body>
-</html>';
-
- return $html;
- }
-
- /**
- * Отправляет сообщение в Telegram с retry-логикой
- *
- * В синхронном режиме (по умолчанию) использует блокирующий retry с sleep().
- * В асинхронном режиме (setAsyncMode(true)) ставит Job-ы в очередь.
- *
- * @param string $message Текст сообщения (MarkdownV2)
- * @return bool Успешность отправки (в async режиме всегда true, если Job поставлен)
- */
- 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);
-
- // Асинхронный режим: ставим Job-ы в очередь
- if ($this->asyncMode) {
- return $this->sendToTelegramAsync($chatId, $chunks);
- }
-
- // Синхронный режим: отправляем с блокирующим retry
- $allSent = true;
-
- foreach ($chunks as $index => $chunk) {
- $sent = false;
- $maxRetries = $this->config['max_retries'] ?? self::MAX_RETRIES;
- $defaultDelay = $this->config['retry_delay_seconds'] ?? self::RETRY_DELAY_SECONDS;
-
- for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
- try {
- $result = $this->sendTelegramMessage($chatId, $chunk);
- if ($result['success']) {
- $sent = true;
- break;
- }
-
- // Используем retry_after из ответа Telegram, если есть
- $retryDelay = $result['retry_after'] ?? $defaultDelay;
- $this->logWarning("Telegram попытка {$attempt}/{$maxRetries}: rate limit, ждём {$retryDelay} сек");
- } catch (\Exception $e) {
- $retryDelay = $defaultDelay;
- $this->logWarning("Telegram попытка {$attempt}/{$maxRetries}: {$e->getMessage()}");
- }
-
- if ($attempt < $maxRetries) {
- sleep($retryDelay);
- }
- }
-
- if (!$sent) {
- $allSent = false;
- $this->logError("Не удалось отправить часть " . ($index + 1) . " в Telegram после {$maxRetries} попыток");
- }
-
- // Небольшая пауза между частями сообщения, чтобы не превысить лимит
- if ($sent && $index < count($chunks) - 1) {
- sleep(1);
- }
- }
-
- return $allSent;
- }
-
- /**
- * Отправляет сообщение в Telegram асинхронно через Job-очередь
- *
- * Каждый чанк сообщения ставится в очередь как отдельный Job с задержкой.
- * Retry-логика обрабатывается механизмом очереди (RetryableJobInterface).
- *
- * @param string $chatId ID чата/канала
- * @param array $chunks Части сообщения
- * @return bool true если все Job-ы поставлены в очередь
- */
- private function sendToTelegramAsync(string $chatId, array $chunks): bool
- {
- $botToken = $this->getTelegramBotToken();
-
- if (empty($botToken)) {
- $envVar = $this->isDevEnvironment() ? 'TELEGRAM_BOT_TOKEN' : 'TELEGRAM_BOT_TOKEN_PROD';
- $this->logError("Telegram bot token не установлен ({$envVar})");
- return false;
- }
-
- $queue = Yii::$app->queue;
- $totalChunks = count($chunks);
- $delayBetweenChunks = 2; // Задержка в секундах между чанками
-
- foreach ($chunks as $index => $chunk) {
- $job = new SendOrderControlTelegramJob([
- 'message' => $chunk,
- 'chatId' => $chatId,
- 'botToken' => $botToken,
- 'chunkIndex' => $index + 1,
- 'totalChunks' => $totalChunks,
- 'reportId' => $this->reportId,
- ]);
-
- // Первый чанк сразу, остальные с задержкой
- $delay = $index * $delayBetweenChunks;
-
- if ($delay > 0) {
- $queue->delay($delay)->push($job);
- } else {
- $queue->push($job);
- }
-
- $this->logInfo("Telegram Job поставлен в очередь: чанк {$index}/{$totalChunks}, delay={$delay}s, report={$this->reportId}");
- }
-
- return true;
- }
-
- /**
- * Отправляет сообщение в Telegram
- *
- * @param string $chatId ID чата/канала
- * @param string $message Текст сообщения
- * @return array{success: bool, retry_after: int|null} Результат отправки
- */
- private function sendTelegramMessage(string $chatId, string $message): array
- {
- $botToken = $this->getTelegramBotToken();
-
- // Валидация токена бота
- if (empty($botToken)) {
- $envVar = $this->isDevEnvironment() ? 'TELEGRAM_BOT_TOKEN' : 'TELEGRAM_BOT_TOKEN_PROD';
- $this->logError("Telegram bot token не установлен. Проверьте переменную окружения {$envVar}");
- return ['success' => false, 'retry_after' => null, 'error' => "Telegram bot token не установлен ({$envVar})"];
- }
-
- $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 ['success' => false, 'retry_after' => null];
- }
-
- if ($httpCode !== 200) {
- $this->logError("Telegram API error: HTTP {$httpCode}, response: {$response}");
-
- // Парсим retry_after из ответа при ошибке 429 (Too Many Requests)
- $retryAfter = null;
- if ($httpCode === 429) {
- $data = json_decode($response, true);
- $retryAfter = $data['parameters']['retry_after'] ?? null;
- }
-
- return ['success' => false, 'retry_after' => $retryAfter];
- }
-
- $this->logInfo('Сообщение отправлено в Telegram', ['chat_id' => $chatId]);
- return ['success' => true, 'retry_after' => null];
- }
-
- /**
- * Отправляет отчёт на email с retry-логикой
- *
- * В синхронном режиме (по умолчанию) использует блокирующий retry с sleep().
- * В асинхронном режиме (setAsyncMode(true)) ставит Job в очередь.
- *
- * @param string $html HTML-контент письма
- * @return bool Успешность отправки (в async режиме всегда true, если Job поставлен)
- */
- 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;
- }
-
- // Асинхронный режим: ставим Job в очередь
- if ($this->asyncMode) {
- return $this->sendToEmailAsync($html, $validRecipients);
- }
-
- // Диагностика конфигурации mailer
- $this->logMailerDiagnostics($validRecipients);
-
- $sent = false;
- $lastError = null;
- $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 {
- $message = Yii::$app->mailer->compose()
- ->setTo($validRecipients)
- ->setSubject($subject)
- ->setHtmlBody($html);
-
- // Устанавливаем отправителя, если настроен
- $fromEmail = getenv('MAIL_USERNAME') ?: 'flow@bazacvetov24.ru';
- $message->setFrom([$fromEmail => 'ERP24 Контроль МП']);
-
- $sent = $message->send();
-
- if ($sent) {
- $this->logInfo('Email отправлен на: ' . implode(', ', $validRecipients));
- break;
- } else {
- $lastError = 'Метод send() вернул false без исключения';
- $this->logWarning("Email попытка {$attempt}/{$maxRetries}: {$lastError}");
- }
- } catch (\Exception $e) {
- $lastError = $e->getMessage();
- $this->logError("Email попытка {$attempt}/{$maxRetries}", [
- 'error' => $e->getMessage(),
- 'code' => $e->getCode(),
- 'file' => $e->getFile() . ':' . $e->getLine(),
- 'trace' => array_slice(explode("\n", $e->getTraceAsString()), 0, 5),
- ]);
- }
-
- if ($attempt < $maxRetries) {
- sleep($retryDelay);
- }
- }
-
- if (!$sent) {
- $this->logError('Не удалось отправить email после ' . $maxRetries . ' попыток', [
- 'last_error' => $lastError,
- 'recipients' => $validRecipients,
- ]);
- }
-
- return $sent;
- }
-
- /**
- * Отправляет email асинхронно через Job-очередь
- *
- * Retry-логика обрабатывается механизмом очереди (RetryableJobInterface).
- *
- * @param string $html HTML-контент письма
- * @param array $recipients Валидные email-адреса
- * @return bool true если Job поставлен в очередь
- */
- private function sendToEmailAsync(string $html, array $recipients): bool
- {
- $subject = $this->config['email_subject'] ?? 'Контроль статусов заказов МП';
- $fromEmail = getenv('MAIL_USERNAME') ?: 'flow@bazacvetov24.ru';
-
- // Формируем DSN для Symfony Mailer
- $mailerDsn = $this->buildMailerDsn();
- if (empty($mailerDsn)) {
- $this->logError('Не удалось сформировать DSN для email');
- return false;
- }
-
- $job = new SendOrderControlEmailJob([
- 'subject' => $subject,
- 'htmlBody' => $html,
- 'recipients' => $recipients,
- 'fromEmail' => $fromEmail,
- 'fromName' => 'ERP24 Контроль МП',
- 'mailerDsn' => $mailerDsn,
- 'reportId' => $this->reportId,
- ]);
-
- Yii::$app->queue->push($job);
-
- $this->logInfo("Email Job поставлен в очередь: " . count($recipients) . " получателей, report={$this->reportId}");
- return true;
- }
-
- /**
- * Формирует DSN для Symfony Mailer из ENV-переменных
- *
- * @return string DSN строка или пустая строка при ошибке
- */
- private function buildMailerDsn(): string
- {
- $scheme = getenv('MAIL_SCHEME') ?: 'smtp';
- $host = getenv('MAIL_HOST');
- $port = getenv('MAIL_PORT') ?: 587;
- $username = getenv('MAIL_USERNAME');
- $password = getenv('MAIL_PASSWORD');
-
- if (empty($host) || empty($username)) {
- return '';
- }
-
- // Формат: smtp://user:pass@host:port
- $dsn = sprintf(
- '%s://%s:%s@%s:%d',
- $scheme,
- rawurlencode($username),
- rawurlencode($password ?: ''),
- $host,
- (int)$port
- );
-
- return $dsn;
- }
-
- /**
- * Логирует диагностику конфигурации mailer
- *
- * @param array $recipients Получатели
- */
- private function logMailerDiagnostics(array $recipients): void
- {
- $mailer = Yii::$app->mailer;
-
- $diagnostics = [
- 'mailer_class' => get_class($mailer),
- 'use_file_transport' => $mailer->useFileTransport ?? 'не определено',
- 'recipients' => $recipients,
- 'env' => [
- 'YII_ENV' => YII_ENV,
- 'YII_ENV_DEV' => YII_ENV_DEV ? 'true' : 'false',
- 'MAIL_HOST' => getenv('MAIL_HOST') ?: '(не задан)',
- 'MAIL_PORT' => getenv('MAIL_PORT') ?: '(не задан)',
- 'MAIL_USERNAME' => getenv('MAIL_USERNAME') ? '***настроен***' : '(не задан)',
- 'MAIL_PASSWORD' => getenv('MAIL_PASSWORD') ? '***настроен***' : '(не задан)',
- 'MAIL_ENCRYPTION' => getenv('MAIL_ENCRYPTION') ?: '(не задан)',
- ],
- ];
-
- // Получаем конфигурацию транспорта, если доступна
- if (method_exists($mailer, 'getTransport')) {
- try {
- $transport = $mailer->getTransport();
- $diagnostics['transport_class'] = get_class($transport);
- } catch (\Exception $e) {
- $diagnostics['transport_error'] = $e->getMessage();
- }
- }
-
- $this->logInfo('Email диагностика mailer', $diagnostics);
- }
-
- /**
- * Разбивает длинное сообщение на части для 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-бота в зависимости от окружения
- *
- * Соответствует TelegramService:
- * - dev: TELEGRAM_BOT_TOKEN (без суффикса)
- * - prod: TELEGRAM_BOT_TOKEN_PROD
- *
- * @return string
- */
- private function getTelegramBotToken(): string
- {
- if ($this->isDevEnvironment()) {
- return getenv('TELEGRAM_BOT_TOKEN') ?: '';
- }
- return getenv('TELEGRAM_BOT_TOKEN_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') ?: '';
-
- }
- return $this->config['telegram_chat_id_prod']
- ?? getenv('TELEGRAM_ORDER_CONTROL_CHAT_ID_PROD')
- ?: '';
- }
-
- /**
- * Получает список email-получателей
- *
- * В dev-окружении используются тестовые адреса (email_recipients_test),
- * в prod-окружении - основные адреса (email_recipients).
- *
- * @return array
- */
- private function getEmailRecipients(): array
- {
- // Выбираем ключ конфига в зависимости от окружения
- $configKey = TelegramService::isDevEnv() ? 'email_recipients_test' : 'email_recipients';
- $envKey = TelegramService::isDevEnv() ? 'ORDER_CONTROL_EMAIL_RECIPIENTS_TEST' : 'ORDER_CONTROL_EMAIL_RECIPIENTS';
-
- $recipients = $this->config[$configKey] ?? [];
-
- if (empty($recipients)) {
- $envRecipients = getenv($envKey);
- if ($envRecipients) {
- $recipients = array_filter(explode(',', $envRecipients));
- }
- }
-
- return $recipients;
- }
-
- /**
- * Вычисляет диапазон дат на основе смены
- *
- * Стандартный режим (testMode=false):
- * - Использует фиксированные времена смен
- * - Дневная смена: 08:00 - 20:00
- * - Ночная смена: 20:00 - 08:00
- *
- * Тестовый режим (testMode=true):
- * - endDate = текущее время (для отладки и проверки заказов в середине смены)
- * - startDate = начало текущей смены
- *
- * Определение смены:
- * - Если текущий час >= 8 и < 20 → дневная смена
- * - Если текущий час >= 20 или < 8 → ночная смена
- *
- * @param int $hoursAgo Количество часов назад (используется как fallback)
- * @param bool $testMode Тестовый режим: endDate = текущее время
- * @return array{startDate: string, endDate: string, shiftName: string} Массив с датами и названием смены
- */
- private function getShiftBasedDateRange(int $hoursAgo, bool $testMode = false): array
- {
- $now = new \DateTime('now', new \DateTimeZone(self::TIMEZONE));
- $currentHour = (int)$now->format('H');
- $isDayTime = $currentHour >= 8 && $currentHour < 20;
-
- $shiftStart = clone $now;
- $shiftEnd = clone $now;
- $shiftName = '';
-
- if ($testMode) {
- // Тестовый режим: анализируем ТЕКУЩУЮ смену, endDate = текущее время
- if ($isDayTime) {
- // Текущая дневная смена: 08:00 сегодня - текущее время
- $shiftStart->setTime(8, 0, 0);
- // $shiftEnd уже = $now
- $shiftName = 'Дневная смена (08:00-20:00)';
- } elseif ($currentHour >= 20) {
- // Текущая ночная смена (начало): 20:00 сегодня - текущее время
- $shiftStart->setTime(20, 0, 0);
- // $shiftEnd уже = $now
- $shiftName = 'Ночная смена (20:00-08:00)';
- } else {
- // Текущая ночная смена (продолжение): 20:00 вчера - текущее время
- $shiftStart->modify('-1 day');
- $shiftStart->setTime(20, 0, 0);
- // $shiftEnd уже = $now
- $shiftName = 'Ночная смена (20:00-08:00)';
- }
- } else {
- // Обычный режим: анализируем ПРЕДЫДУЩУЮ смену
- if ($isDayTime) {
- // Сейчас день → анализируем предыдущую НОЧНУЮ смену (20:00 вчера - 08:00 сегодня)
- $shiftStart->modify('-1 day');
- $shiftStart->setTime(20, 0, 0);
- $shiftEnd->setTime(8, 0, 0);
- $shiftName = 'Ночная смена (20:00-08:00)';
- } elseif ($currentHour >= 20) {
- // Сейчас ночь (20:00-23:59) → анализируем предыдущую ДНЕВНУЮ смену (08:00-20:00 сегодня)
- $shiftStart->setTime(8, 0, 0);
- $shiftEnd->setTime(20, 0, 0);
- $shiftName = 'Дневная смена (08:00-20:00)';
- } else {
- // Сейчас ночь (00:00-07:59) → анализируем предыдущую ДНЕВНУЮ смену (08:00-20:00 вчера)
- $shiftStart->modify('-1 day');
- $shiftStart->setTime(8, 0, 0);
- $shiftEnd->modify('-1 day');
- $shiftEnd->setTime(20, 0, 0);
- $shiftName = 'Дневная смена (08:00-20:00)';
- }
- }
-
- $this->logInfo('Вычислен диапазон дат на основе смены', [
- 'current_time' => $now->format('Y-m-d H:i:s'),
- 'shift_start' => $shiftStart->format('Y-m-d H:i:s'),
- 'shift_end' => $shiftEnd->format('Y-m-d H:i:s'),
- 'shift_name' => $shiftName,
- 'hours_ago' => $hoursAgo,
- 'test_mode' => $testMode,
- ]);
-
- return [
- 'startDate' => $shiftStart->format('Y-m-d H:i:s'),
- 'endDate' => $shiftEnd->format('Y-m-d H:i:s'),
- 'shiftName' => $shiftName,
- ];
- }
-
- /**
- * Логирование в структурированном 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');
- }
-}
+<?php\r
+\r
+declare(strict_types=1);\r
+\r
+namespace yii_app\services;\r
+\r
+use Yii;\r
+use yii\db\Expression;\r
+use yii\db\Query;\r
+use yii_app\records\MarketplaceOrder1cStatuses;\r
+use yii_app\records\MarketplaceOrderStatusTypes;\r
+use yii_app\records\MarketplaceOrderDailyIssues;\r
+use yii_app\services\dto\OrderIssue;\r
+use yii_app\services\dto\ControlReportResult;\r
+use yii_app\jobs\SendOrderControlTelegramJob;\r
+use yii_app\jobs\SendOrderControlEmailJob;\r
+\r
+/**\r
+ * Сервис контроля статусов заказов маркетплейса\r
+ *\r
+ * Выявляет расхождения между статусами РМК/1С и статусами маркетплейсов.\r
+ * Три типа проблем:\r
+ * 1. "Завис в доставке" - РМК="Передан курьеру", МП≠"Выполнен"\r
+ * 2. "Успех без чека" - МП="Выполнен", РМК≠"6. Успех"\r
+ * 3. "Отмена без обработки" - МП="Отменён", РМК="Отказ", cancelled_order_sent=0 (отмена не отправлена)\r
+ *\r
+ * Запускается по расписанию 08:00 и 20:00 MSK.\r
+ */\r
+class OrderControlReportService\r
+{\r
+ /**\r
+ * Период выборки заказов в часах (по умолчанию)\r
+ */\r
+ public const REPORT_PERIOD_HOURS = 12;\r
+\r
+ /**\r
+ * Максимальное количество попыток отправки\r
+ */\r
+ public const MAX_RETRIES = 5;\r
+\r
+ /**\r
+ * Задержка между попытками в секундах (используется если retry_after не указан)\r
+ */\r
+ public const RETRY_DELAY_SECONDS = 10;\r
+\r
+ /**\r
+ * Максимальная длина сообщения Telegram\r
+ */\r
+ public const TELEGRAM_MAX_LENGTH = 4000;\r
+\r
+ /**\r
+ * Часовой пояс для отчёта\r
+ */\r
+ public const TIMEZONE = 'Europe/Moscow';\r
+\r
+ /**\r
+ * Конфигурация отчёта\r
+ */\r
+ private array $config;\r
+\r
+ /**\r
+ * Режим тестирования: endDate = текущее время вместо фиксированного времени смены\r
+ */\r
+ private bool $testMode = false;\r
+\r
+ /**\r
+ * Режим асинхронной отправки: использовать Job-очередь вместо блокирующего sleep()\r
+ */\r
+ private bool $asyncMode = false;\r
+\r
+ /**\r
+ * Уникальный идентификатор отчёта (для логирования и отслеживания Job-ов)\r
+ */\r
+ private string $reportId;\r
+\r
+ /**\r
+ * Кеш ID статусов 1С (ленивая загрузка)\r
+ */\r
+ private ?array $rmkStatusCourier = null;\r
+ private ?array $rmkStatusSuccess = null;\r
+ private ?array $rmkStatusCancel = null;\r
+\r
+ /**\r
+ * Создаёт экземпляр сервиса контроля заказов\r
+ *\r
+ * @param array|null $config Конфигурация отчёта. Если null, загружается из Yii::$app->params\r
+ * @param string|null $reportId Идентификатор отчёта. Если null, генерируется автоматически\r
+ */\r
+ public function __construct(?array $config = null, ?string $reportId = null)\r
+ {\r
+ $this->config = $config ?? Yii::$app->params['MARKETPLACE_ORDER_CONTROL_REPORT'] ?? [];\r
+ $this->reportId = $reportId ?? uniqid('report_', true);\r
+ }\r
+\r
+ /**\r
+ * Включает асинхронный режим отправки через Job-очередь\r
+ *\r
+ * В асинхронном режиме отправка Telegram и Email происходит через\r
+ * Job-очередь с автоматическими retry, без блокирования основного процесса.\r
+ *\r
+ * @param bool $enabled\r
+ * @return self\r
+ */\r
+ public function setAsyncMode(bool $enabled): self\r
+ {\r
+ $this->asyncMode = $enabled;\r
+ return $this;\r
+ }\r
+\r
+ /**\r
+ * Проверяет, включён ли асинхронный режим\r
+ *\r
+ * @return bool\r
+ */\r
+ public function isAsyncMode(): bool\r
+ {\r
+ return $this->asyncMode;\r
+ }\r
+\r
+ /**\r
+ * Получает идентификатор текущего отчёта\r
+ *\r
+ * @return string\r
+ */\r
+ public function getReportId(): string\r
+ {\r
+ return $this->reportId;\r
+ }\r
+\r
+ /**\r
+ * Получает ID статусов 1С "Передан курьеру" (с кешированием)\r
+ *\r
+ * @return int[]\r
+ */\r
+ private function getRmkStatusCourier(): array\r
+ {\r
+ if ($this->rmkStatusCourier === null) {\r
+ $this->rmkStatusCourier = MarketplaceOrder1cStatuses::getCourierOrderIds();\r
+ }\r
+ return $this->rmkStatusCourier;\r
+ }\r
+\r
+ /**\r
+ * Получает ID статусов 1С "Успех" (successful_order = 1) с кешированием\r
+ *\r
+ * @return int[]\r
+ */\r
+ private function getRmkStatusSuccess(): array\r
+ {\r
+ if ($this->rmkStatusSuccess === null) {\r
+ $this->rmkStatusSuccess = MarketplaceOrder1cStatuses::getSuccessfulOrderIds();\r
+ }\r
+ return $this->rmkStatusSuccess;\r
+ }\r
+\r
+ /**\r
+ * Получает ID статусов 1С "Отказ" (cancelled_order = 1) с кешированием\r
+ *\r
+ * @return int[]\r
+ */\r
+ private function getRmkStatusCancel(): array\r
+ {\r
+ if ($this->rmkStatusCancel === null) {\r
+ $this->rmkStatusCancel = MarketplaceOrder1cStatuses::getCancelledOrderIds();\r
+ }\r
+ return $this->rmkStatusCancel;\r
+ }\r
+\r
+ /**\r
+ * Генерирует отчёт контроля статусов заказов МП\r
+ *\r
+ * @param int $hoursAgo Период выборки в часах\r
+ * @param bool $onlyNew Отправлять только новые проблемы\r
+ * @param bool $testMode Тестовый режим: endDate = текущее время (для проверки заказов в середине смены)\r
+ * @return ControlReportResult\r
+ */\r
+ public function generateControlReport(int $hoursAgo = 12, bool $onlyNew = true, bool $testMode = false): ControlReportResult\r
+ {\r
+ // Сохраняем режим тестирования для использования во внутренних методах\r
+ $this->testMode = $testMode;\r
+\r
+ $result = new ControlReportResult($testMode);\r
+\r
+ // Получаем диапазон дат для отображения в отчёте\r
+ $dateRange = $this->getShiftBasedDateRange($hoursAgo, $testMode);\r
+ $result->startDate = $dateRange['startDate'];\r
+ $result->endDate = $dateRange['endDate'];\r
+ $result->shiftName = $dateRange['shiftName'];\r
+\r
+ $this->logInfo('Запуск отчёта контроля статусов МП', [\r
+ 'hours_ago' => $hoursAgo,\r
+ 'only_new' => $onlyNew,\r
+ 'test_mode' => $testMode,\r
+ 'date_range' => $dateRange,\r
+ ]);\r
+\r
+ try {\r
+ // 1. Получаем кандидатов "Завис в доставке" и сохраняем их состояние\r
+ $hungCandidates = $this->getHungInDeliveryCandidates($hoursAgo);\r
+ $this->saveHungInDeliveryCandidates($hungCandidates);\r
+\r
+ // 2. Фильтруем "Завис в доставке" - только те, что были в предыдущей проверке\r
+ $hungInDelivery = $this->filterHungInDeliveryByPreviousState($hungCandidates);\r
+\r
+ // 3. Получаем остальные типы проблем\r
+ $successNoCheck = $this->getSuccessNoCheckOrders($hoursAgo);\r
+ $cancelNoProcess = $this->getCancelNoProcessOrders($hoursAgo);\r
+\r
+ // 4. Фильтруем только новые для остальных типов, если требуется\r
+ if ($onlyNew) {\r
+ $prevSuccess = $this->loadPreviousIssues(OrderIssue::TYPE_SUCCESS_NO_CHECK);\r
+ $prevCancel = $this->loadPreviousIssues(OrderIssue::TYPE_CANCEL_NO_PROCESS);\r
+\r
+ $successNoCheck = $this->filterNewIssues($successNoCheck, $prevSuccess);\r
+ $cancelNoProcess = $this->filterNewIssues($cancelNoProcess, $prevCancel);\r
+ }\r
+\r
+ $result->hungInDelivery = $hungInDelivery;\r
+ $result->successNoCheck = $successNoCheck;\r
+ $result->cancelNoProcess = $cancelNoProcess;\r
+ $result->calculateTotal();\r
+\r
+ // 5. Сохраняем состояние проблем (кроме кандидатов hung_in_delivery, они уже сохранены)\r
+ $issuesToSave = array_merge($hungInDelivery, $successNoCheck, $cancelNoProcess);\r
+ $result->statesSaved = $this->saveControlIssues($issuesToSave);\r
+\r
+ // 6. Отправляем уведомления только если есть проблемы\r
+ if ($result->hasIssues()) {\r
+ // Telegram\r
+ $telegramMessage = $this->formatTelegramControlReport($result);\r
+ $result->telegramSent = $this->sendToTelegram($telegramMessage);\r
+ if (!$result->telegramSent) {\r
+ $result->telegramError = 'Не удалось отправить в Telegram';\r
+ }\r
+\r
+ // Email\r
+ $emailHtml = $this->formatEmailControlReport($result);\r
+ $result->emailSent = $this->sendToEmail($emailHtml);\r
+ if (!$result->emailSent) {\r
+ $result->emailError = 'Не удалось отправить Email';\r
+ }\r
+\r
+ // Помечаем отправленные\r
+ $this->markIssuesAsNotified($issuesToSave);\r
+ } else {\r
+ $result->telegramSent = true;\r
+ $result->emailSent = true;\r
+ $this->logInfo('Нет проблемных заказов, уведомления не требуются');\r
+ }\r
+\r
+ // 7. Очистка старых записей (старше 1 месяца)\r
+ $deletedCount = $this->cleanupOldIssues();\r
+ $this->logInfo('Очистка старых записей завершена', ['deleted_count' => $deletedCount]);\r
+\r
+ $this->logInfo('Отчёт контроля статусов завершён', $result->toArray());\r
+\r
+ } catch (\Exception $e) {\r
+ $this->logError('Ошибка генерации отчёта контроля', [\r
+ 'error' => $e->getMessage(),\r
+ 'trace' => $e->getTraceAsString(),\r
+ ]);\r
+ $result->telegramError = $e->getMessage();\r
+ $result->emailError = $e->getMessage();\r
+ }\r
+\r
+ return $result;\r
+ }\r
+\r
+ /**\r
+ * Получает кандидатов "Завис в доставке"\r
+ *\r
+ * Критерий: РМК статус = "Передан курьеру" (по order_status_id/order_substatus_id → DELIVERY/COURIER_RECEIVED)\r
+ * + МП статус НЕ "Выполнен" (НЕ DELIVERED и НЕ DELIVERY_SERVICE_DELIVERED)\r
+ *\r
+ * ВАЖНО: Это только кандидаты! Заказ становится проблемой "Завис в доставке"\r
+ * только если он был кандидатом в ПРЕДЫДУЩЕЙ проверке с тем же статусом РМК.\r
+ *\r
+ * @param int $hoursAgo Период выборки в часах (по умолчанию 24)\r
+ * @return OrderIssue[]\r
+ */\r
+ public function getHungInDeliveryCandidates(int $hoursAgo = 24): array\r
+ {\r
+ $this->logInfo('Выборка кандидатов "Завис в доставке"', ['hours_ago' => $hoursAgo, 'test_mode' => $this->testMode]);\r
+\r
+ // Получаем диапазон дат на основе конца смены\r
+ $dateRange = $this->getShiftBasedDateRange($hoursAgo, $this->testMode);\r
+ $startDateStr = $dateRange['startDate'];\r
+ $endDateStr = $dateRange['endDate'];\r
+\r
+ // Получаем ID статусов "Передан курьеру" из БД\r
+ $rmkCourierIds = $this->getRmkStatusCourier();\r
+ \r
+ $this->logInfo('ID статусов "Передан курьеру"', [\r
+ 'rmk_courier_ids' => $rmkCourierIds,\r
+ 'count' => count($rmkCourierIds),\r
+ ]);\r
+\r
+ if (empty($rmkCourierIds)) {\r
+ $this->logWarning('Не найдено статусов "Передан курьеру" в БД. Проверьте наличие статусов DELIVERY/COURIER_RECEIVED и связей в marketplace_order_1c_statuses');\r
+ }\r
+\r
+ // Выбираем заказы с РМК-статусом "Передан курьеру", где МП-статус НЕ "Выполнен"\r
+ $orders = (new Query())\r
+ ->select([\r
+ 'mo.id',\r
+ 'mo.marketplace_order_id',\r
+ 'mo.store_id',\r
+ 'store_name' => 'cs.name',\r
+ 'mo.marketplace_name',\r
+ 'mo.marketplace_id',\r
+ 'mo.total',\r
+ 'mo.creation_date',\r
+ 'rmk_status_id' => 'mo.status_processing_1c',\r
+ 'rmk_status' => 'mocs.status',\r
+ 'mp_status_code' => 'most.code',\r
+ 'mp_substatus_code' => 'mosub.code',\r
+ 'mp_status_name' => new Expression('COALESCE(most.name, mosub.name)'),\r
+ 'issue_reason' => new Expression("'no_mp_success'"),\r
+ ])\r
+ ->from(['mo' => 'marketplace_orders'])\r
+ ->leftJoin(['cs' => 'city_store'], 'cs.id = mo.store_id')\r
+ ->leftJoin(['mocs' => 'marketplace_order_1c_statuses'], new Expression('mocs.id = mo.status_processing_1c::integer'))\r
+ ->leftJoin(['most' => 'marketplace_order_status_types'], 'most.id = mo.status_id')\r
+ ->leftJoin(['mosub' => 'marketplace_order_status_types'], 'mosub.id = mo.substatus_id')\r
+ ->where(['mo.fake' => 0])\r
+ ->andWhere(['not', ['mo.status_processing_1c' => null]])\r
+ ->andWhere(['in', new Expression('mo.status_processing_1c::integer'), $rmkCourierIds])\r
+ ->andWhere(['>=', 'mo.updated_at', $startDateStr])\r
+ ->andWhere(['<=', 'mo.updated_at', $endDateStr])\r
+ ->andWhere([\r
+ 'or',\r
+ ['most.code' => null],\r
+ [\r
+ 'and',\r
+ ['!=', 'most.code', MarketplaceOrderStatusTypes::DELIVERED_CODE],\r
+ [\r
+ 'or',\r
+ ['mosub.code' => null],\r
+ ['!=', 'mosub.code', MarketplaceOrderStatusTypes::DELIVERY_SERVICE_DELIVERED_CODE],\r
+ ],\r
+ ],\r
+ ])\r
+ ->orderBy(['cs.name' => SORT_ASC, 'mo.creation_date' => SORT_DESC])\r
+ ->all();\r
+ \r
+ $this->logInfo('SQL запрос для кандидатов "Завис в доставке"', [\r
+ 'date_range' => ['start' => $startDateStr, 'end' => $endDateStr],\r
+ 'rmk_courier_ids' => $rmkCourierIds,\r
+ 'found_orders' => count($orders),\r
+ ]);\r
+\r
+ $issues = [];\r
+ foreach ($orders as $orderData) {\r
+ $issues[] = OrderIssue::fromOrderData(OrderIssue::TYPE_HUNG_IN_DELIVERY, $orderData, $this->testMode);\r
+ }\r
+\r
+ $this->logInfo('Найдено кандидатов "Завис в доставке"', [\r
+ 'count' => count($issues),\r
+ 'order_ids' => array_map(fn($issue) => $issue->orderNumber, $issues),\r
+ ]);\r
+\r
+ return $issues;\r
+ }\r
+\r
+ /**\r
+ * Сохраняет кандидатов "Завис в доставке" для сравнения в следующей проверке\r
+ *\r
+ * Использует отдельный problem_type для хранения состояния кандидатов.\r
+ *\r
+ * @param OrderIssue[] $candidates Массив кандидатов\r
+ * @return int Количество сохранённых записей\r
+ */\r
+ private function saveHungInDeliveryCandidates(array $candidates): int\r
+ {\r
+ $saved = 0;\r
+ $reportDate = date('Y-m-d');\r
+ $interval = OrderIssue::calculateInterval($this->testMode);\r
+\r
+ foreach ($candidates as $candidate) {\r
+ // Проверяем, не существует ли уже такая запись\r
+ if (MarketplaceOrderDailyIssues::issueExists(\r
+ $candidate->orderId,\r
+ $candidate->problemType,\r
+ $reportDate,\r
+ $interval\r
+ )) {\r
+ continue;\r
+ }\r
+\r
+ $model = MarketplaceOrderDailyIssues::fromOrderIssue($candidate);\r
+\r
+ if ($model->save()) {\r
+ $saved++;\r
+ } else {\r
+ $this->logWarning('Не удалось сохранить кандидата hung_in_delivery', [\r
+ 'order_id' => $candidate->orderId,\r
+ 'errors' => $model->getErrors(),\r
+ ]);\r
+ }\r
+ }\r
+\r
+ $this->logInfo('Сохранено кандидатов "Завис в доставке"', ['count' => $saved, 'total' => count($candidates)]);\r
+\r
+ return $saved;\r
+ }\r
+\r
+ /**\r
+ * Фильтрует кандидатов "Завис в доставке" по предыдущему состоянию\r
+ *\r
+ * Возвращает только те заказы, которые были в ПРЕДЫДУЩЕЙ проверке\r
+ * с тем же статусом РМК (т.е. статус не изменился).\r
+ *\r
+ * @param OrderIssue[] $candidates Текущие кандидаты\r
+ * @return OrderIssue[] Подтверждённые проблемы\r
+ */\r
+ private function filterHungInDeliveryByPreviousState(array $candidates): array\r
+ {\r
+ // Определяем предыдущий интервал\r
+ $currentHour = (int)date('H');\r
+ $currentInterval = $currentHour < 12 ? '08:00' : '20:00';\r
+\r
+ // Предыдущий интервал: если сейчас 08:00, то предыдущий был 20:00 вчера\r
+ // Если сейчас 20:00, то предыдущий был 08:00 сегодня\r
+ if ($currentInterval === '08:00') {\r
+ $prevDate = date('Y-m-d', strtotime('-1 day'));\r
+ $prevInterval = '20:00';\r
+ } else {\r
+ $prevDate = date('Y-m-d');\r
+ $prevInterval = '08:00';\r
+ }\r
+\r
+ // Получаем ID статусов "Передан курьеру" для проверки\r
+ $rmkCourierIds = $this->getRmkStatusCourier();\r
+ \r
+ // Получаем предыдущие записи кандидатов со статусом "Передан курьеру"\r
+ // rmk_status_id хранится как строка, поэтому конвертируем ID в строки для сравнения\r
+ $rmkCourierIdsAsStrings = array_map('strval', $rmkCourierIds);\r
+ \r
+ $previousRecords = MarketplaceOrderDailyIssues::find()\r
+ ->where([\r
+ 'problem_type' => OrderIssue::TYPE_HUNG_IN_DELIVERY,\r
+ 'report_date' => $prevDate,\r
+ 'interval' => $prevInterval,\r
+ ])\r
+ ->andWhere(['in', 'rmk_status_id', $rmkCourierIdsAsStrings])\r
+ ->indexBy('order_id')\r
+ ->asArray()\r
+ ->all();\r
+\r
+ $this->logInfo('Загружено предыдущих кандидатов "Завис в доставке"', [\r
+ 'count' => count($previousRecords),\r
+ 'prev_date' => $prevDate,\r
+ 'prev_interval' => $prevInterval,\r
+ 'rmk_courier_ids' => $rmkCourierIds,\r
+ 'rmk_courier_ids_as_strings' => $rmkCourierIdsAsStrings,\r
+ 'previous_order_ids' => array_keys($previousRecords),\r
+ ]);\r
+\r
+ // Фильтруем: оставляем только те заказы, которые были в предыдущей проверке\r
+ $confirmedIssues = [];\r
+ $notFoundCandidates = [];\r
+ foreach ($candidates as $candidate) {\r
+ if (isset($previousRecords[$candidate->orderId])) {\r
+ // Заказ был кандидатом в предыдущей проверке с тем же статусом → подтверждённая проблема\r
+ $confirmedIssues[] = $candidate;\r
+ } else {\r
+ $notFoundCandidates[] = [\r
+ 'order_id' => $candidate->orderId,\r
+ 'order_number' => $candidate->orderNumber,\r
+ 'rmk_status_id' => $candidate->rmkStatusId,\r
+ ];\r
+ }\r
+ }\r
+ \r
+ if (!empty($notFoundCandidates)) {\r
+ $this->logInfo('Кандидаты, не найденные в предыдущей проверке', [\r
+ 'count' => count($notFoundCandidates),\r
+ 'candidates' => $notFoundCandidates,\r
+ ]);\r
+ }\r
+\r
+ $this->logInfo('Подтверждено проблем "Завис в доставке"', [\r
+ 'candidates' => count($candidates),\r
+ 'confirmed' => count($confirmedIssues),\r
+ ]);\r
+\r
+ return $confirmedIssues;\r
+ }\r
+\r
+ /**\r
+ * Получает заказы типа "Успех без чека"\r
+ *\r
+ * Критерий: МП статус = "Выполнен" (DELIVERED или DELIVERY_SERVICE_DELIVERED)\r
+ * + (seller_id не назначен ИЛИ чек не создан)\r
+ *\r
+ * Бизнес-логика:\r
+ * 1. Заказ получает статус "Выполнен" в маркетплейсе\r
+ * 2. При получении статуса "Выполнен" от МП, status_processing_1c автоматически\r
+ * проставляется в "Успех", поэтому проверка РМК-статуса не имеет смысла\r
+ * 3. Проблема определяется по отсутствию seller_id или чека\r
+ *\r
+ * Причины попадания в этот отчёт (диагностика):\r
+ * - no_seller_id: продавец не назначен (seller_id пустой или нулевой GUID)\r
+ * - no_check: чек не создан в create_checks\r
+ *\r
+ * @see MarketplaceService::createCheckForMarketplaceOrder() — логика создания чека\r
+ *\r
+ * @param int $hoursAgo Период выборки в часах (по умолчанию 12)\r
+ * @return OrderIssue[]\r
+ */\r
+ public function getSuccessNoCheckOrders(int $hoursAgo = 12): array\r
+ {\r
+ $this->logInfo('Выборка заказов "Успех без чека"', ['hours_ago' => $hoursAgo, 'test_mode' => $this->testMode]);\r
+\r
+ // Получаем диапазон дат на основе конца смены\r
+ $dateRange = $this->getShiftBasedDateRange($hoursAgo, $this->testMode);\r
+ $startDateStr = $dateRange['startDate'];\r
+ $endDateStr = $dateRange['endDate'];\r
+\r
+ // Нулевой GUID — признак отсутствия продавца\r
+ $emptySellerGuid = '00000000-0000-0000-0000-000000000000';\r
+\r
+ // Выбираем заказы с МП-статусом "Выполнен", где нет seller_id или нет чека\r
+ $orders = (new Query())\r
+ ->select([\r
+ 'mo.id',\r
+ 'mo.marketplace_order_id',\r
+ 'mo.store_id',\r
+ 'store_name' => 'cs.name',\r
+ 'mo.marketplace_name',\r
+ 'mo.marketplace_id',\r
+ 'mo.total',\r
+ 'mo.creation_date',\r
+ 'mo.seller_id',\r
+ 'mo.check_guid',\r
+ 'rmk_status_id' => 'mo.status_processing_1c',\r
+ 'rmk_status' => 'mocs.status',\r
+ 'mp_status_code' => 'most.code',\r
+ 'mp_substatus_code' => 'mosub.code',\r
+ 'mp_status_name' => new Expression('COALESCE(most.name, mosub.name)'),\r
+ 'check_id' => 'cc.id',\r
+ 'check_exists' => new Expression('CASE WHEN cc.id IS NOT NULL THEN true ELSE false END'),\r
+ 'issue_reason' => new Expression(\r
+ "CASE WHEN mo.seller_id IS NULL OR mo.seller_id = '' OR mo.seller_id = :empty_seller_guid"\r
+ . " THEN 'no_seller_id' ELSE 'no_check' END",\r
+ [':empty_seller_guid' => $emptySellerGuid]\r
+ ),\r
+ ])\r
+ ->from(['mo' => 'marketplace_orders'])\r
+ ->leftJoin(['cs' => 'city_store'], 'cs.id = mo.store_id')\r
+ ->leftJoin(['mocs' => 'marketplace_order_1c_statuses'], new Expression('mocs.id = mo.status_processing_1c::integer'))\r
+ ->leftJoin(['most' => 'marketplace_order_status_types'], 'most.id = mo.status_id')\r
+ ->leftJoin(['mosub' => 'marketplace_order_status_types'], 'mosub.id = mo.substatus_id')\r
+ ->leftJoin(['cc' => 'create_checks'], 'cc.marketplace_order_id = mo.marketplace_order_id')\r
+ ->where(['mo.fake' => 0])\r
+ ->andWhere(['>=', 'mo.updated_at', $startDateStr])\r
+ ->andWhere(['<=', 'mo.updated_at', $endDateStr])\r
+ // МП-статус = Выполнен (DELIVERED)\r
+ ->andWhere([\r
+ 'or',\r
+ ['most.code' => MarketplaceOrderStatusTypes::DELIVERED_CODE],\r
+ ['mosub.code' => MarketplaceOrderStatusTypes::DELIVERY_SERVICE_DELIVERED_CODE],\r
+ ])\r
+ // Нет seller_id ИЛИ нет чека\r
+ ->andWhere([\r
+ 'or',\r
+ ['mo.seller_id' => null],\r
+ ['mo.seller_id' => ''],\r
+ ['mo.seller_id' => $emptySellerGuid],\r
+ ['cc.id' => null],\r
+ ])\r
+ ->orderBy(['cs.name' => SORT_ASC, 'mo.creation_date' => SORT_DESC])\r
+ ->all();\r
+\r
+ $issues = [];\r
+ foreach ($orders as $orderData) {\r
+ $issues[] = OrderIssue::fromOrderData(OrderIssue::TYPE_SUCCESS_NO_CHECK, $orderData, $this->testMode);\r
+ }\r
+\r
+ $this->logInfo('Найдено "Успех без чека"', [\r
+ 'count' => count($issues),\r
+ 'no_seller_id' => count(array_filter($orders, fn($o) => $o['issue_reason'] === 'no_seller_id')),\r
+ 'no_check' => count(array_filter($orders, fn($o) => $o['issue_reason'] === 'no_check')),\r
+ ]);\r
+\r
+ return $issues;\r
+ }\r
+\r
+ /**\r
+ * Получает заказы типа "Отмена без обработки"\r
+ *\r
+ * Критерий: МП статус = "Отменён" (CANCELLED)\r
+ * + РМК статус = "Отказ" (cancelled_order = 1 в marketplace_order_1c_statuses)\r
+ * + cancelled_order_sent = 0 (отмена не отправлена в маркетплейс)\r
+ *\r
+ * Проблема возникает когда заказ отменён в маркетплейсе, в РМК статус проставлен как "Отказ",\r
+ * но отмена не отправлена обратно в маркетплейс.\r
+ *\r
+ * @param int $hoursAgo Период выборки в часах (по умолчанию 24)\r
+ * @return OrderIssue[]\r
+ */\r
+ public function getCancelNoProcessOrders(int $hoursAgo = 24): array\r
+ {\r
+ $this->logInfo('Выборка заказов "Отмена без обработки"', ['hours_ago' => $hoursAgo, 'test_mode' => $this->testMode]);\r
+\r
+ // Получаем диапазон дат на основе конца смены\r
+ $dateRange = $this->getShiftBasedDateRange($hoursAgo, $this->testMode);\r
+ $startDateStr = $dateRange['startDate'];\r
+ $endDateStr = $dateRange['endDate'];\r
+\r
+ // Получаем ID статусов "Отказ" из БД\r
+ $rmkCancelIds = $this->getRmkStatusCancel();\r
+\r
+ // Выбираем заказы с МП-статусом "Отменён", где РМК-статус = "Отказ", но отмена не отправлена\r
+ $orders = (new Query())\r
+ ->select([\r
+ 'mo.id',\r
+ 'mo.marketplace_order_id',\r
+ 'mo.store_id',\r
+ 'store_name' => 'cs.name',\r
+ 'mo.marketplace_name',\r
+ 'mo.marketplace_id',\r
+ 'mo.total',\r
+ 'mo.creation_date',\r
+ 'rmk_status_id' => 'mo.status_processing_1c',\r
+ 'rmk_status' => 'mocs.status',\r
+ 'mp_status_code' => 'most.code',\r
+ 'mp_substatus_code' => 'mosub.code',\r
+ 'mp_status_name' => new Expression('COALESCE(most.name, mosub.name)'),\r
+ 'issue_reason' => new Expression("'cancel_not_sent'"),\r
+ ])\r
+ ->from(['mo' => 'marketplace_orders'])\r
+ ->leftJoin(['cs' => 'city_store'], 'cs.id = mo.store_id')\r
+ ->leftJoin(['mocs' => 'marketplace_order_1c_statuses'], new Expression('mocs.id = mo.status_processing_1c::integer'))\r
+ ->leftJoin(['most' => 'marketplace_order_status_types'], 'most.id = mo.status_id')\r
+ ->leftJoin(['mosub' => 'marketplace_order_status_types'], 'mosub.id = mo.substatus_id')\r
+ ->where(['mo.fake' => 0])\r
+ ->andWhere(['>=', 'mo.updated_at', $startDateStr])\r
+ ->andWhere(['<=', 'mo.updated_at', $endDateStr])\r
+ ->andWhere(['most.code' => MarketplaceOrderStatusTypes::CANSELLED_CODE])\r
+ ->andWhere(['not', ['mo.status_processing_1c' => null]])\r
+ ->andWhere(['in', new Expression('mo.status_processing_1c::integer'), $rmkCancelIds])\r
+ ->andWhere(new Expression('COALESCE(mo.cancelled_order_sent, 0) = 0'))\r
+ ->orderBy(['cs.name' => SORT_ASC, 'mo.creation_date' => SORT_DESC])\r
+ ->all();\r
+\r
+ $issues = [];\r
+ foreach ($orders as $orderData) {\r
+ $issues[] = OrderIssue::fromOrderData(OrderIssue::TYPE_CANCEL_NO_PROCESS, $orderData, $this->testMode);\r
+ }\r
+\r
+ $this->logInfo('Найдено "Отмена без обработки"', ['count' => count($issues)]);\r
+\r
+ return $issues;\r
+ }\r
+\r
+ /**\r
+ * Сохраняет состояние проблемных заказов в БД\r
+ *\r
+ * @param OrderIssue[] $issues Массив проблемных заказов\r
+ * @return int Количество сохранённых записей\r
+ */\r
+ public function saveControlIssues(array $issues): int\r
+ {\r
+ $saved = 0;\r
+ $reportDate = date('Y-m-d');\r
+ $interval = OrderIssue::calculateInterval($this->testMode);\r
+\r
+ foreach ($issues as $issue) {\r
+ // Проверяем, не существует ли уже такая запись\r
+ if (MarketplaceOrderDailyIssues::issueExists(\r
+ $issue->orderId,\r
+ $issue->problemType,\r
+ $reportDate,\r
+ $interval\r
+ )) {\r
+ continue;\r
+ }\r
+\r
+ $model = MarketplaceOrderDailyIssues::fromOrderIssue($issue);\r
+\r
+ if ($model->save()) {\r
+ $saved++;\r
+ } else {\r
+ $this->logWarning('Не удалось сохранить issue', [\r
+ 'order_id' => $issue->orderId,\r
+ 'errors' => $model->getErrors(),\r
+ ]);\r
+ }\r
+ }\r
+\r
+ $this->logInfo('Сохранено состояний', ['count' => $saved, 'total' => count($issues)]);\r
+\r
+ return $saved;\r
+ }\r
+\r
+ /**\r
+ * Удаляет записи старше 1 месяца из таблицы marketplace_order_daily_issues\r
+ *\r
+ * Вызывается в конце генерации отчёта для поддержания размера таблицы.\r
+ * Хранятся только записи за последний месяц.\r
+ *\r
+ * @return int Количество удалённых записей\r
+ */\r
+ public function cleanupOldIssues(): int\r
+ {\r
+ $oneMonthAgo = date('Y-m-d', strtotime('-1 month'));\r
+\r
+ $deletedCount = MarketplaceOrderDailyIssues::deleteAll(\r
+ ['<', 'report_date', $oneMonthAgo]\r
+ );\r
+\r
+ if ($deletedCount > 0) {\r
+ $this->logInfo('Удалены старые записи из marketplace_order_daily_issues', [\r
+ 'deleted_count' => $deletedCount,\r
+ 'older_than' => $oneMonthAgo,\r
+ ]);\r
+ }\r
+\r
+ return $deletedCount;\r
+ }\r
+\r
+ /**\r
+ * Загружает предыдущие проблемы (для определения новых)\r
+ *\r
+ * @param string $problemType Тип проблемы\r
+ * @return array<int, true> Карта order_id => true\r
+ */\r
+ public function loadPreviousIssues(string $problemType): array\r
+ {\r
+ $yesterday = date('Y-m-d', strtotime('-1 day'));\r
+\r
+ $issues = MarketplaceOrderDailyIssues::find()\r
+ ->select(['order_id'])\r
+ ->where(['problem_type' => $problemType])\r
+ ->andWhere(['>=', 'report_date', $yesterday])\r
+ ->andWhere(['is_resolved' => false])\r
+ ->asArray()\r
+ ->all();\r
+\r
+ $map = [];\r
+ foreach ($issues as $issue) {\r
+ $map[(int)$issue['order_id']] = true;\r
+ }\r
+\r
+ return $map;\r
+ }\r
+\r
+ /**\r
+ * Фильтрует только новые проблемы (которых не было в предыдущих отчётах)\r
+ *\r
+ * @param OrderIssue[] $issues Все найденные проблемы\r
+ * @param array<int, true> $previousMap Карта предыдущих проблем\r
+ * @return OrderIssue[] Только новые проблемы\r
+ */\r
+ public function filterNewIssues(array $issues, array $previousMap): array\r
+ {\r
+ return array_filter($issues, function (OrderIssue $issue) use ($previousMap) {\r
+ return !isset($previousMap[$issue->orderId]);\r
+ });\r
+ }\r
+\r
+ /**\r
+ * Помечает проблемы как отправленные\r
+ *\r
+ * @param OrderIssue[] $issues\r
+ */\r
+ private function markIssuesAsNotified(array $issues): void\r
+ {\r
+ $reportDate = date('Y-m-d');\r
+ $interval = OrderIssue::calculateInterval($this->testMode);\r
+\r
+ foreach ($issues as $issue) {\r
+ $model = MarketplaceOrderDailyIssues::find()\r
+ ->where([\r
+ 'order_id' => $issue->orderId,\r
+ 'problem_type' => $issue->problemType,\r
+ 'report_date' => $reportDate,\r
+ 'interval' => $interval,\r
+ ])\r
+ ->one();\r
+\r
+ if ($model) {\r
+ $model->markAsNotified();\r
+ }\r
+ }\r
+ }\r
+\r
+ /**\r
+ * Формирует текстовый отчёт контроля статусов для Telegram (MarkdownV2)\r
+ *\r
+ * Использует моноширинный блок для корректного отображения таблицы.\r
+ *\r
+ * @param ControlReportResult $result Результат отчёта\r
+ * @return string Текст сообщения\r
+ */\r
+ public function formatTelegramControlReport(ControlReportResult $result): string\r
+ {\r
+ $lines = [];\r
+ $intervalWithShift = $this->formatIntervalWithShiftName($result->interval);\r
+ // Используем дату начала смены (startDate), а не текущее время (reportDate)\r
+ $shiftStartDate = $result->startDate\r
+ ? date('d.m.Y', strtotime($result->startDate))\r
+ : $result->reportDate;\r
+ $lines[] = '*\[Контроль MP\]* Отчёт за ' . $this->escapeMarkdownV2($shiftStartDate) . ' ' . $this->escapeMarkdownV2($intervalWithShift);\r
+ $lines[] = '';\r
+\r
+ // Секция "Завис в доставке"\r
+ if (!empty($result->hungInDelivery)) {\r
+ $lines[] = '*Завис в доставке* \\(' . count($result->hungInDelivery) . '\\)';\r
+ $lines[] = $this->formatIssuesTable($result->hungInDelivery);\r
+ $lines[] = '';\r
+ }\r
+\r
+ // Секция "Успех без чека"\r
+ if (!empty($result->successNoCheck)) {\r
+ $lines[] = '*Успех без чека* \\(' . count($result->successNoCheck) . '\\)';\r
+ $lines[] = $this->formatIssuesTable($result->successNoCheck);\r
+ $lines[] = '';\r
+ }\r
+\r
+ // Секция "Отмена без обработки"\r
+ if (!empty($result->cancelNoProcess)) {\r
+ $lines[] = '*Отмена без обработки* \\(' . count($result->cancelNoProcess) . '\\)';\r
+ $lines[] = $this->formatIssuesTable($result->cancelNoProcess);\r
+ $lines[] = '';\r
+ }\r
+\r
+ $lines[] = '*Всего:* ' . $this->escapeMarkdownV2((string)$result->totalIssues);\r
+\r
+ return implode("\n", $lines);\r
+ }\r
+\r
+ /**\r
+ * Форматирует список проблем для Telegram (компактный формат)\r
+ *\r
+ * Формат:\r
+ * 📦 {номер заказа} ({дата создания})\r
+ * РМК: {статус} | МП: {статус}\r
+ * ⚠️ {причина}\r
+ *\r
+ * @param OrderIssue[] $issues\r
+ * @return string\r
+ */\r
+ private function formatIssuesTable(array $issues): string\r
+ {\r
+ $rows = [];\r
+\r
+ foreach ($issues as $issue) {\r
+ $rows[] = $this->formatIssueRow($issue);\r
+ }\r
+\r
+ return implode("\n\n", $rows);\r
+ }\r
+\r
+ /**\r
+ * Форматирует блок для одной проблемы (компактный список)\r
+ *\r
+ * @param OrderIssue $issue\r
+ * @return string\r
+ */\r
+ private function formatIssueRow(OrderIssue $issue): string\r
+ {\r
+ $rmk = $issue->rmkStatus ?? '-';\r
+ $mp = $this->formatMpStatus($issue);\r
+ $reason = $issue->getIssueReasonLabel() ?: '-';\r
+ $creationDate = $issue->creationDate\r
+ ? date('d.m.Y H:i', strtotime($issue->creationDate))\r
+ : '-';\r
+\r
+ $lines = [];\r
+ // Emoji не экранируем, они работают в MarkdownV2 как есть\r
+ $lines[] = '📦 ' . $this->escapeMarkdownV2("{$issue->orderNumber} ({$creationDate})");\r
+ $lines[] = $this->escapeMarkdownV2(" РМК: {$rmk} | МП: {$mp}");\r
+ $lines[] = '⚠️ ' . $this->escapeMarkdownV2($reason);\r
+\r
+ return implode("\n", $lines);\r
+ }\r
+\r
+ /**\r
+ * Получает короткий формат интервала (08:00 или 20:00)\r
+ *\r
+ * @param string|null $interval\r
+ * @return string\r
+ */\r
+ private function getShortInterval(?string $interval): string\r
+ {\r
+ if ($interval === null) {\r
+ return OrderIssue::calculateInterval($this->testMode);\r
+ }\r
+\r
+ // Убираем суффиксы типа " (Ночь)" или " (День)"\r
+ if (str_starts_with($interval, '08:00')) {\r
+ return '08:00';\r
+ }\r
+ if (str_starts_with($interval, '20:00')) {\r
+ return '20:00';\r
+ }\r
+\r
+ return $interval;\r
+ }\r
+\r
+ /**\r
+ * Форматирует интервал с названием смены для заголовка отчёта\r
+ *\r
+ * 08:00 → "08:00 (день)"\r
+ * 20:00 → "20:00 (ночь)"\r
+ *\r
+ * @param string|null $interval\r
+ * @return string\r
+ */\r
+ private function formatIntervalWithShiftName(?string $interval): string\r
+ {\r
+ $shortInterval = $this->getShortInterval($interval);\r
+\r
+ if ($shortInterval === '08:00') {\r
+ return '08:00 (день)';\r
+ }\r
+ if ($shortInterval === '20:00') {\r
+ return '20:00 (ночь)';\r
+ }\r
+\r
+ return $shortInterval;\r
+ }\r
+\r
+ /**\r
+ * Форматирует МП-статус для отображения\r
+ *\r
+ * Преобразует технические коды в понятные названия.\r
+ *\r
+ * @param OrderIssue $issue\r
+ * @return string\r
+ */\r
+ private function formatMpStatus(OrderIssue $issue): string\r
+ {\r
+ // Если есть человекочитаемый статус, используем его\r
+ if ($issue->mpStatus && !$this->isTechnicalMpStatus($issue->mpStatus)) {\r
+ return $issue->mpStatus;\r
+ }\r
+\r
+ // Преобразуем технические коды в понятные названия\r
+ $statusCode = $issue->mpStatusCode;\r
+ $substatusCode = $issue->mpSubstatusCode;\r
+\r
+ // Маппинг технических кодов на понятные названия\r
+ $statusMap = [\r
+ 'DELIVERED' => 'Доставлен',\r
+ 'DELIVERY_SERVICE_DELIVERED' => 'Доставлен службой',\r
+ 'CANCELLED' => 'Отменён',\r
+ 'PROCESSING' => 'В обработке',\r
+ 'DELIVERY' => 'В доставке',\r
+ 'PICKUP' => 'Готов к выдаче',\r
+ 'UNPAID' => 'Не оплачен',\r
+ 'PENDING' => 'Ожидает',\r
+ ];\r
+\r
+ if ($statusCode && isset($statusMap[$statusCode])) {\r
+ return $statusMap[$statusCode];\r
+ }\r
+\r
+ if ($substatusCode && isset($statusMap[$substatusCode])) {\r
+ return $statusMap[$substatusCode];\r
+ }\r
+\r
+ // Если ничего не нашли, возвращаем оригинал или прочерк\r
+ return $issue->mpStatus ?? '-';\r
+ }\r
+\r
+ /**\r
+ * Проверяет, является ли статус техническим кодом\r
+ *\r
+ * @param string $status\r
+ * @return bool\r
+ */\r
+ private function isTechnicalMpStatus(string $status): bool\r
+ {\r
+ // Технические статусы содержат только заглавные буквы, подчёркивания и слеши\r
+ return (bool)preg_match('/^[A-Z_\/]+$/', $status);\r
+ }\r
+\r
+ /**\r
+ * Формирует HTML-отчёт контроля статусов для Email\r
+ *\r
+ * @param ControlReportResult $result Результат отчёта\r
+ * @return string HTML-контент\r
+ */\r
+ public function formatEmailControlReport(ControlReportResult $result): string\r
+ {\r
+ // Используем дату начала смены (startDate), а не текущее время (reportDate)\r
+ $shiftStartDate = $result->startDate\r
+ ? date('d.m.Y', strtotime($result->startDate))\r
+ : $result->reportDate;\r
+ $intervalWithShift = $this->formatIntervalWithShiftName($result->interval);\r
+\r
+ $html = '<!DOCTYPE html>\r
+<html>\r
+<head>\r
+ <meta charset="UTF-8">\r
+ <style>\r
+ body { font-family: Arial, sans-serif; }\r
+ table { border-collapse: collapse; width: 100%; margin-bottom: 20px; }\r
+ th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }\r
+ th { background-color: #f4f4f4; }\r
+ .section-header { background-color: #ffe0e0; font-weight: bold; padding: 10px; margin-top: 20px; }\r
+ .total { font-weight: bold; margin-top: 20px; font-size: 14px; }\r
+ h2 { color: #333; }\r
+ p { margin: 5px 0; }\r
+ </style>\r
+</head>\r
+<body>\r
+ <h2>[Контроль MP] Отчёт за ' . $this->escapeHtml($shiftStartDate) . ' ' . $this->escapeHtml($intervalWithShift) . '</h2>';\r
+\r
+ // Общая таблица со всеми проблемами, сортировка по типу\r
+ $allIssues = [];\r
+\r
+ foreach ($result->hungInDelivery as $issue) {\r
+ $allIssues[] = ['type' => 'Завис в доставке', 'issue' => $issue];\r
+ }\r
+ foreach ($result->successNoCheck as $issue) {\r
+ $allIssues[] = ['type' => 'Успех без чека', 'issue' => $issue];\r
+ }\r
+ foreach ($result->cancelNoProcess as $issue) {\r
+ $allIssues[] = ['type' => 'Отмена без обработки', 'issue' => $issue];\r
+ }\r
+\r
+ // Сортируем по типу\r
+ usort($allIssues, function ($a, $b) {\r
+ return strcmp($a['type'], $b['type']);\r
+ });\r
+\r
+ $html .= '\r
+ <table>\r
+ <tr>\r
+ <th>Тип проблемы</th>\r
+ <th>Дата</th>\r
+ <th>Интервал</th>\r
+ <th>Заказ</th>\r
+ <th>РМК</th>\r
+ <th>МП</th>\r
+ <th>Причина</th>\r
+ </tr>';\r
+\r
+ foreach ($allIssues as $item) {\r
+ /** @var OrderIssue $issue */\r
+ $issue = $item['issue'];\r
+ $date = $issue->reportDate ?: date('d.m.Y');\r
+ $interval = $this->getShortInterval($issue->interval);\r
+ $mpStatus = $this->formatMpStatus($issue);\r
+ $reason = $issue->getIssueReasonLabel() ?: '-';\r
+\r
+ $html .= '\r
+ <tr>\r
+ <td>' . $this->escapeHtml($item['type']) . '</td>\r
+ <td>' . $this->escapeHtml($date) . '</td>\r
+ <td>' . $this->escapeHtml($interval) . '</td>\r
+ <td>' . $this->escapeHtml($issue->orderNumber) . '</td>\r
+ <td>' . $this->escapeHtml($issue->rmkStatus ?? '-') . '</td>\r
+ <td>' . $this->escapeHtml($mpStatus) . '</td>\r
+ <td>' . $this->escapeHtml($reason) . '</td>\r
+ </tr>';\r
+ }\r
+\r
+ $html .= '\r
+ </table>\r
+ <p class="total">Всего проблем: ' . $result->totalIssues . '</p>\r
+</body>\r
+</html>';\r
+\r
+ return $html;\r
+ }\r
+\r
+ /**\r
+ * Отправляет сообщение в Telegram с retry-логикой\r
+ *\r
+ * В синхронном режиме (по умолчанию) использует блокирующий retry с sleep().\r
+ * В асинхронном режиме (setAsyncMode(true)) ставит Job-ы в очередь.\r
+ *\r
+ * @param string $message Текст сообщения (MarkdownV2)\r
+ * @return bool Успешность отправки (в async режиме всегда true, если Job поставлен)\r
+ */\r
+ public function sendToTelegram(string $message): bool\r
+ {\r
+ $chatId = $this->getTelegramChatId();\r
+\r
+ if (empty($chatId)) {\r
+ $this->logWarning('Telegram chat_id не настроен');\r
+ return false;\r
+ }\r
+\r
+ // Валидация chat_id\r
+ if (!preg_match('/^-?\d+$/', $chatId)) {\r
+ $this->logError('Некорректный формат chat_id: ' . $chatId);\r
+ return false;\r
+ }\r
+\r
+ $chunks = $this->splitTelegramMessage($message);\r
+\r
+ // Асинхронный режим: ставим Job-ы в очередь\r
+ if ($this->asyncMode) {\r
+ return $this->sendToTelegramAsync($chatId, $chunks);\r
+ }\r
+\r
+ // Синхронный режим: отправляем с блокирующим retry\r
+ $allSent = true;\r
+\r
+ foreach ($chunks as $index => $chunk) {\r
+ $sent = false;\r
+ $maxRetries = $this->config['max_retries'] ?? self::MAX_RETRIES;\r
+ $defaultDelay = $this->config['retry_delay_seconds'] ?? self::RETRY_DELAY_SECONDS;\r
+\r
+ for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {\r
+ try {\r
+ $result = $this->sendTelegramMessage($chatId, $chunk);\r
+ if ($result['success']) {\r
+ $sent = true;\r
+ break;\r
+ }\r
+\r
+ // Используем retry_after из ответа Telegram, если есть\r
+ $retryDelay = $result['retry_after'] ?? $defaultDelay;\r
+ $this->logWarning("Telegram попытка {$attempt}/{$maxRetries}: rate limit, ждём {$retryDelay} сек");\r
+ } catch (\Exception $e) {\r
+ $retryDelay = $defaultDelay;\r
+ $this->logWarning("Telegram попытка {$attempt}/{$maxRetries}: {$e->getMessage()}");\r
+ }\r
+\r
+ if ($attempt < $maxRetries) {\r
+ sleep($retryDelay);\r
+ }\r
+ }\r
+\r
+ if (!$sent) {\r
+ $allSent = false;\r
+ $this->logError("Не удалось отправить часть " . ($index + 1) . " в Telegram после {$maxRetries} попыток");\r
+ }\r
+\r
+ // Небольшая пауза между частями сообщения, чтобы не превысить лимит\r
+ if ($sent && $index < count($chunks) - 1) {\r
+ sleep(1);\r
+ }\r
+ }\r
+\r
+ return $allSent;\r
+ }\r
+\r
+ /**\r
+ * Отправляет сообщение в Telegram асинхронно через Job-очередь\r
+ *\r
+ * Каждый чанк сообщения ставится в очередь как отдельный Job с задержкой.\r
+ * Retry-логика обрабатывается механизмом очереди (RetryableJobInterface).\r
+ *\r
+ * @param string $chatId ID чата/канала\r
+ * @param array $chunks Части сообщения\r
+ * @return bool true если все Job-ы поставлены в очередь\r
+ */\r
+ private function sendToTelegramAsync(string $chatId, array $chunks): bool\r
+ {\r
+ $botToken = $this->getTelegramBotToken();\r
+\r
+ if (empty($botToken)) {\r
+ $envVar = $this->isDevEnvironment() ? 'TELEGRAM_BOT_TOKEN' : 'TELEGRAM_BOT_TOKEN_PROD';\r
+ $this->logError("Telegram bot token не установлен ({$envVar})");\r
+ return false;\r
+ }\r
+\r
+ $queue = Yii::$app->queue;\r
+ $totalChunks = count($chunks);\r
+ $delayBetweenChunks = 2; // Задержка в секундах между чанками\r
+\r
+ foreach ($chunks as $index => $chunk) {\r
+ $job = new SendOrderControlTelegramJob([\r
+ 'message' => $chunk,\r
+ 'chatId' => $chatId,\r
+ 'botToken' => $botToken,\r
+ 'chunkIndex' => $index + 1,\r
+ 'totalChunks' => $totalChunks,\r
+ 'reportId' => $this->reportId,\r
+ ]);\r
+\r
+ // Первый чанк сразу, остальные с задержкой\r
+ $delay = $index * $delayBetweenChunks;\r
+\r
+ if ($delay > 0) {\r
+ $queue->delay($delay)->push($job);\r
+ } else {\r
+ $queue->push($job);\r
+ }\r
+\r
+ $this->logInfo("Telegram Job поставлен в очередь: чанк {$index}/{$totalChunks}, delay={$delay}s, report={$this->reportId}");\r
+ }\r
+\r
+ return true;\r
+ }\r
+\r
+ /**\r
+ * Отправляет сообщение в Telegram\r
+ *\r
+ * @param string $chatId ID чата/канала\r
+ * @param string $message Текст сообщения\r
+ * @return array{success: bool, retry_after: int|null} Результат отправки\r
+ */\r
+ private function sendTelegramMessage(string $chatId, string $message): array\r
+ {\r
+ $botToken = $this->getTelegramBotToken();\r
+\r
+ // Валидация токена бота\r
+ if (empty($botToken)) {\r
+ $envVar = $this->isDevEnvironment() ? 'TELEGRAM_BOT_TOKEN' : 'TELEGRAM_BOT_TOKEN_PROD';\r
+ $this->logError("Telegram bot token не установлен. Проверьте переменную окружения {$envVar}");\r
+ return ['success' => false, 'retry_after' => null, 'error' => "Telegram bot token не установлен ({$envVar})"];\r
+ }\r
+\r
+ $url = "https://api.telegram.org/bot{$botToken}/sendMessage";\r
+\r
+ $ch = curl_init();\r
+ curl_setopt_array($ch, [\r
+ CURLOPT_URL => $url,\r
+ CURLOPT_POST => true,\r
+ CURLOPT_POSTFIELDS => [\r
+ 'chat_id' => $chatId,\r
+ 'text' => $message,\r
+ 'parse_mode' => 'MarkdownV2',\r
+ 'disable_web_page_preview' => true,\r
+ ],\r
+ CURLOPT_RETURNTRANSFER => true,\r
+ CURLOPT_TIMEOUT => 30,\r
+ CURLOPT_SSL_VERIFYPEER => true,\r
+ ]);\r
+\r
+ $response = curl_exec($ch);\r
+ $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);\r
+ $curlError = curl_error($ch);\r
+ curl_close($ch);\r
+\r
+ if ($curlError) {\r
+ $this->logError("Telegram cURL error: {$curlError}");\r
+ return ['success' => false, 'retry_after' => null];\r
+ }\r
+\r
+ if ($httpCode !== 200) {\r
+ $this->logError("Telegram API error: HTTP {$httpCode}, response: {$response}");\r
+\r
+ // Парсим retry_after из ответа при ошибке 429 (Too Many Requests)\r
+ $retryAfter = null;\r
+ if ($httpCode === 429) {\r
+ $data = json_decode($response, true);\r
+ $retryAfter = $data['parameters']['retry_after'] ?? null;\r
+ }\r
+\r
+ return ['success' => false, 'retry_after' => $retryAfter];\r
+ }\r
+\r
+ $this->logInfo('Сообщение отправлено в Telegram', ['chat_id' => $chatId]);\r
+ return ['success' => true, 'retry_after' => null];\r
+ }\r
+\r
+ /**\r
+ * Отправляет отчёт на email с retry-логикой\r
+ *\r
+ * В синхронном режиме (по умолчанию) использует блокирующий retry с sleep().\r
+ * В асинхронном режиме (setAsyncMode(true)) ставит Job в очередь.\r
+ *\r
+ * @param string $html HTML-контент письма\r
+ * @return bool Успешность отправки (в async режиме всегда true, если Job поставлен)\r
+ */\r
+ public function sendToEmail(string $html): bool\r
+ {\r
+ $recipients = $this->getEmailRecipients();\r
+\r
+ if (empty($recipients)) {\r
+ $this->logWarning('Email-получатели не настроены');\r
+ return false;\r
+ }\r
+\r
+ // Валидация email-адресов\r
+ $validRecipients = [];\r
+ foreach ($recipients as $email) {\r
+ $email = trim($email);\r
+ if (filter_var($email, FILTER_VALIDATE_EMAIL)) {\r
+ $validRecipients[] = $email;\r
+ } else {\r
+ $this->logWarning("Некорректный email пропущен: {$email}");\r
+ }\r
+ }\r
+\r
+ if (empty($validRecipients)) {\r
+ $this->logError('Нет валидных email-адресов');\r
+ return false;\r
+ }\r
+\r
+ // Асинхронный режим: ставим Job в очередь\r
+ if ($this->asyncMode) {\r
+ return $this->sendToEmailAsync($html, $validRecipients);\r
+ }\r
+\r
+ // Диагностика конфигурации mailer\r
+ $this->logMailerDiagnostics($validRecipients);\r
+\r
+ $sent = false;\r
+ $lastError = null;\r
+ $maxRetries = $this->config['max_retries'] ?? self::MAX_RETRIES;\r
+ $retryDelay = $this->config['retry_delay_seconds'] ?? self::RETRY_DELAY_SECONDS;\r
+ $subject = $this->config['email_subject'] ?? 'Контроль статусов заказов МП';\r
+\r
+ for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {\r
+ try {\r
+ $message = Yii::$app->mailer->compose()\r
+ ->setTo($validRecipients)\r
+ ->setSubject($subject)\r
+ ->setHtmlBody($html);\r
+\r
+ // Устанавливаем отправителя, если настроен\r
+ $fromEmail = getenv('MAIL_USERNAME') ?: 'flow@bazacvetov24.ru';\r
+ $message->setFrom([$fromEmail => 'ERP24 Контроль МП']);\r
+\r
+ $sent = $message->send();\r
+\r
+ if ($sent) {\r
+ $this->logInfo('Email отправлен на: ' . implode(', ', $validRecipients));\r
+ break;\r
+ } else {\r
+ $lastError = 'Метод send() вернул false без исключения';\r
+ $this->logWarning("Email попытка {$attempt}/{$maxRetries}: {$lastError}");\r
+ }\r
+ } catch (\Exception $e) {\r
+ $lastError = $e->getMessage();\r
+ $this->logError("Email попытка {$attempt}/{$maxRetries}", [\r
+ 'error' => $e->getMessage(),\r
+ 'code' => $e->getCode(),\r
+ 'file' => $e->getFile() . ':' . $e->getLine(),\r
+ 'trace' => array_slice(explode("\n", $e->getTraceAsString()), 0, 5),\r
+ ]);\r
+ }\r
+\r
+ if ($attempt < $maxRetries) {\r
+ sleep($retryDelay);\r
+ }\r
+ }\r
+\r
+ if (!$sent) {\r
+ $this->logError('Не удалось отправить email после ' . $maxRetries . ' попыток', [\r
+ 'last_error' => $lastError,\r
+ 'recipients' => $validRecipients,\r
+ ]);\r
+ }\r
+\r
+ return $sent;\r
+ }\r
+\r
+ /**\r
+ * Отправляет email асинхронно через Job-очередь\r
+ *\r
+ * Retry-логика обрабатывается механизмом очереди (RetryableJobInterface).\r
+ *\r
+ * @param string $html HTML-контент письма\r
+ * @param array $recipients Валидные email-адреса\r
+ * @return bool true если Job поставлен в очередь\r
+ */\r
+ private function sendToEmailAsync(string $html, array $recipients): bool\r
+ {\r
+ $subject = $this->config['email_subject'] ?? 'Контроль статусов заказов МП';\r
+ $fromEmail = getenv('MAIL_USERNAME') ?: 'flow@bazacvetov24.ru';\r
+\r
+ // Формируем DSN для Symfony Mailer\r
+ $mailerDsn = $this->buildMailerDsn();\r
+ if (empty($mailerDsn)) {\r
+ $this->logError('Не удалось сформировать DSN для email');\r
+ return false;\r
+ }\r
+\r
+ $job = new SendOrderControlEmailJob([\r
+ 'subject' => $subject,\r
+ 'htmlBody' => $html,\r
+ 'recipients' => $recipients,\r
+ 'fromEmail' => $fromEmail,\r
+ 'fromName' => 'ERP24 Контроль МП',\r
+ 'mailerDsn' => $mailerDsn,\r
+ 'reportId' => $this->reportId,\r
+ ]);\r
+\r
+ Yii::$app->queue->push($job);\r
+\r
+ $this->logInfo("Email Job поставлен в очередь: " . count($recipients) . " получателей, report={$this->reportId}");\r
+ return true;\r
+ }\r
+\r
+ /**\r
+ * Формирует DSN для Symfony Mailer из ENV-переменных\r
+ *\r
+ * @return string DSN строка или пустая строка при ошибке\r
+ */\r
+ private function buildMailerDsn(): string\r
+ {\r
+ $scheme = getenv('MAIL_SCHEME') ?: 'smtp';\r
+ $host = getenv('MAIL_HOST');\r
+ $port = getenv('MAIL_PORT') ?: 587;\r
+ $username = getenv('MAIL_USERNAME');\r
+ $password = getenv('MAIL_PASSWORD');\r
+\r
+ if (empty($host) || empty($username)) {\r
+ return '';\r
+ }\r
+\r
+ // Формат: smtp://user:pass@host:port\r
+ $dsn = sprintf(\r
+ '%s://%s:%s@%s:%d',\r
+ $scheme,\r
+ rawurlencode($username),\r
+ rawurlencode($password ?: ''),\r
+ $host,\r
+ (int)$port\r
+ );\r
+\r
+ return $dsn;\r
+ }\r
+\r
+ /**\r
+ * Логирует диагностику конфигурации mailer\r
+ *\r
+ * @param array $recipients Получатели\r
+ */\r
+ private function logMailerDiagnostics(array $recipients): void\r
+ {\r
+ $mailer = Yii::$app->mailer;\r
+\r
+ $diagnostics = [\r
+ 'mailer_class' => get_class($mailer),\r
+ 'use_file_transport' => $mailer->useFileTransport ?? 'не определено',\r
+ 'recipients' => $recipients,\r
+ 'env' => [\r
+ 'YII_ENV' => YII_ENV,\r
+ 'YII_ENV_DEV' => YII_ENV_DEV ? 'true' : 'false',\r
+ 'MAIL_HOST' => getenv('MAIL_HOST') ?: '(не задан)',\r
+ 'MAIL_PORT' => getenv('MAIL_PORT') ?: '(не задан)',\r
+ 'MAIL_USERNAME' => getenv('MAIL_USERNAME') ? '***настроен***' : '(не задан)',\r
+ 'MAIL_PASSWORD' => getenv('MAIL_PASSWORD') ? '***настроен***' : '(не задан)',\r
+ 'MAIL_ENCRYPTION' => getenv('MAIL_ENCRYPTION') ?: '(не задан)',\r
+ ],\r
+ ];\r
+\r
+ // Получаем конфигурацию транспорта, если доступна\r
+ if (method_exists($mailer, 'getTransport')) {\r
+ try {\r
+ $transport = $mailer->getTransport();\r
+ $diagnostics['transport_class'] = get_class($transport);\r
+ } catch (\Exception $e) {\r
+ $diagnostics['transport_error'] = $e->getMessage();\r
+ }\r
+ }\r
+\r
+ $this->logInfo('Email диагностика mailer', $diagnostics);\r
+ }\r
+\r
+ /**\r
+ * Разбивает длинное сообщение на части для Telegram\r
+ *\r
+ * @param string $message Полное сообщение\r
+ * @return array<string> Массив частей сообщения\r
+ */\r
+ private function splitTelegramMessage(string $message): array\r
+ {\r
+ $maxLength = $this->config['telegram_max_message_length'] ?? self::TELEGRAM_MAX_LENGTH;\r
+\r
+ if (mb_strlen($message) <= $maxLength) {\r
+ return [$message];\r
+ }\r
+\r
+ $chunks = [];\r
+ $lines = explode("\n", $message);\r
+ $currentChunk = '';\r
+\r
+ foreach ($lines as $line) {\r
+ if (mb_strlen($currentChunk . "\n" . $line) > $maxLength) {\r
+ if ($currentChunk !== '') {\r
+ $chunks[] = $currentChunk;\r
+ }\r
+ $currentChunk = $line;\r
+ } else {\r
+ $currentChunk .= ($currentChunk !== '' ? "\n" : '') . $line;\r
+ }\r
+ }\r
+\r
+ if ($currentChunk !== '') {\r
+ $chunks[] = $currentChunk;\r
+ }\r
+\r
+ return $chunks;\r
+ }\r
+\r
+ /**\r
+ * Экранирует специальные символы для MarkdownV2\r
+ *\r
+ * @param string $text Исходный текст\r
+ * @return string Экранированный текст\r
+ */\r
+ private function escapeMarkdownV2(string $text): string\r
+ {\r
+ $specialChars = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!'];\r
+ foreach ($specialChars as $char) {\r
+ $text = str_replace($char, '\\' . $char, $text);\r
+ }\r
+ return $text;\r
+ }\r
+\r
+ /**\r
+ * Экранирует HTML-сущности\r
+ *\r
+ * @param string $text\r
+ * @return string\r
+ */\r
+ private function escapeHtml(string $text): string\r
+ {\r
+ return htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');\r
+ }\r
+\r
+ /**\r
+ * Определяет, является ли окружение development\r
+ *\r
+ * @return bool\r
+ */\r
+ private function isDevEnvironment(): bool\r
+ {\r
+ return TelegramService::isDevEnv();\r
+ }\r
+\r
+ /**\r
+ * Получает токен Telegram-бота в зависимости от окружения\r
+ *\r
+ * Соответствует TelegramService:\r
+ * - dev: TELEGRAM_BOT_TOKEN (без суффикса)\r
+ * - prod: TELEGRAM_BOT_TOKEN_PROD\r
+ *\r
+ * @return string\r
+ */\r
+ private function getTelegramBotToken(): string\r
+ {\r
+ if ($this->isDevEnvironment()) {\r
+ return getenv('TELEGRAM_BOT_TOKEN') ?: '';\r
+ }\r
+ return getenv('TELEGRAM_BOT_TOKEN_PROD') ?: '';\r
+ }\r
+\r
+ /**\r
+ * Получает ID чата Telegram в зависимости от окружения\r
+ *\r
+ * @return string\r
+ */\r
+ private function getTelegramChatId(): string\r
+ {\r
+ if ($this->isDevEnvironment()) {\r
+ return $this->config['telegram_chat_id_dev']\r
+ ?? getenv('TELEGRAM_ORDER_CONTROL_CHAT_ID_DEV') ?: '';\r
+\r
+ }\r
+ return $this->config['telegram_chat_id_prod']\r
+ ?? getenv('TELEGRAM_ORDER_CONTROL_CHAT_ID_PROD')\r
+ ?: '';\r
+ }\r
+\r
+ /**\r
+ * Получает список email-получателей\r
+ *\r
+ * В dev-окружении используются тестовые адреса (email_recipients_test),\r
+ * в prod-окружении - основные адреса (email_recipients).\r
+ *\r
+ * @return array\r
+ */\r
+ private function getEmailRecipients(): array\r
+ {\r
+ // Выбираем ключ конфига в зависимости от окружения\r
+ $configKey = TelegramService::isDevEnv() ? 'email_recipients_test' : 'email_recipients';\r
+ $envKey = TelegramService::isDevEnv() ? 'ORDER_CONTROL_EMAIL_RECIPIENTS_TEST' : 'ORDER_CONTROL_EMAIL_RECIPIENTS';\r
+\r
+ $recipients = $this->config[$configKey] ?? [];\r
+\r
+ if (empty($recipients)) {\r
+ $envRecipients = getenv($envKey);\r
+ if ($envRecipients) {\r
+ $recipients = array_filter(explode(',', $envRecipients));\r
+ }\r
+ }\r
+\r
+ return $recipients;\r
+ }\r
+\r
+ /**\r
+ * Вычисляет диапазон дат на основе смены\r
+ *\r
+ * Стандартный режим (testMode=false):\r
+ * - Использует фиксированные времена смен\r
+ * - Дневная смена: 08:00 - 20:00\r
+ * - Ночная смена: 20:00 - 08:00\r
+ *\r
+ * Тестовый режим (testMode=true):\r
+ * - endDate = текущее время (для отладки и проверки заказов в середине смены)\r
+ * - startDate = начало текущей смены\r
+ *\r
+ * Определение смены:\r
+ * - Если текущий час >= 8 и < 20 → дневная смена\r
+ * - Если текущий час >= 20 или < 8 → ночная смена\r
+ *\r
+ * @param int $hoursAgo Количество часов назад (используется как fallback)\r
+ * @param bool $testMode Тестовый режим: endDate = текущее время\r
+ * @return array{startDate: string, endDate: string, shiftName: string} Массив с датами и названием смены\r
+ */\r
+ private function getShiftBasedDateRange(int $hoursAgo, bool $testMode = false): array\r
+ {\r
+ $now = new \DateTime('now', new \DateTimeZone(self::TIMEZONE));\r
+ $currentHour = (int)$now->format('H');\r
+ $isDayTime = $currentHour >= 8 && $currentHour < 20;\r
+\r
+ $shiftStart = clone $now;\r
+ $shiftEnd = clone $now;\r
+ $shiftName = '';\r
+\r
+ if ($testMode) {\r
+ // Тестовый режим: анализируем ТЕКУЩУЮ смену, endDate = текущее время\r
+ if ($isDayTime) {\r
+ // Текущая дневная смена: 08:00 сегодня - текущее время\r
+ $shiftStart->setTime(8, 0, 0);\r
+ // $shiftEnd уже = $now\r
+ $shiftName = 'Дневная смена (08:00-20:00)';\r
+ } elseif ($currentHour >= 20) {\r
+ // Текущая ночная смена (начало): 20:00 сегодня - текущее время\r
+ $shiftStart->setTime(20, 0, 0);\r
+ // $shiftEnd уже = $now\r
+ $shiftName = 'Ночная смена (20:00-08:00)';\r
+ } else {\r
+ // Текущая ночная смена (продолжение): 20:00 вчера - текущее время\r
+ $shiftStart->modify('-1 day');\r
+ $shiftStart->setTime(20, 0, 0);\r
+ // $shiftEnd уже = $now\r
+ $shiftName = 'Ночная смена (20:00-08:00)';\r
+ }\r
+ } else {\r
+ // Обычный режим: анализируем ПРЕДЫДУЩУЮ смену\r
+ if ($isDayTime) {\r
+ // Сейчас день → анализируем предыдущую НОЧНУЮ смену (20:00 вчера - 08:00 сегодня)\r
+ $shiftStart->modify('-1 day');\r
+ $shiftStart->setTime(20, 0, 0);\r
+ $shiftEnd->setTime(8, 0, 0);\r
+ $shiftName = 'Ночная смена (20:00-08:00)';\r
+ } elseif ($currentHour >= 20) {\r
+ // Сейчас ночь (20:00-23:59) → анализируем предыдущую ДНЕВНУЮ смену (08:00-20:00 сегодня)\r
+ $shiftStart->setTime(8, 0, 0);\r
+ $shiftEnd->setTime(20, 0, 0);\r
+ $shiftName = 'Дневная смена (08:00-20:00)';\r
+ } else {\r
+ // Сейчас ночь (00:00-07:59) → анализируем предыдущую ДНЕВНУЮ смену (08:00-20:00 вчера)\r
+ $shiftStart->modify('-1 day');\r
+ $shiftStart->setTime(8, 0, 0);\r
+ $shiftEnd->modify('-1 day');\r
+ $shiftEnd->setTime(20, 0, 0);\r
+ $shiftName = 'Дневная смена (08:00-20:00)';\r
+ }\r
+ }\r
+\r
+ $this->logInfo('Вычислен диапазон дат на основе смены', [\r
+ 'current_time' => $now->format('Y-m-d H:i:s'),\r
+ 'shift_start' => $shiftStart->format('Y-m-d H:i:s'),\r
+ 'shift_end' => $shiftEnd->format('Y-m-d H:i:s'),\r
+ 'shift_name' => $shiftName,\r
+ 'hours_ago' => $hoursAgo,\r
+ 'test_mode' => $testMode,\r
+ ]);\r
+\r
+ return [\r
+ 'startDate' => $shiftStart->format('Y-m-d H:i:s'),\r
+ 'endDate' => $shiftEnd->format('Y-m-d H:i:s'),\r
+ 'shiftName' => $shiftName,\r
+ ];\r
+ }\r
+\r
+ /**\r
+ * Логирование в структурированном JSON-формате\r
+ *\r
+ * @param string $message\r
+ * @param array $context\r
+ */\r
+ private function logInfo(string $message, array $context = []): void\r
+ {\r
+ Yii::info(json_encode([\r
+ 'message' => $message,\r
+ 'context' => $context,\r
+ 'timestamp' => date('c'),\r
+ 'env' => YII_ENV,\r
+ ], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_IGNORE), 'marketplace-control');\r
+ }\r
+\r
+ /**\r
+ * @param string $message\r
+ * @param array $context\r
+ */\r
+ private function logWarning(string $message, array $context = []): void\r
+ {\r
+ Yii::warning(json_encode([\r
+ 'message' => $message,\r
+ 'context' => $context,\r
+ 'timestamp' => date('c'),\r
+ 'env' => YII_ENV,\r
+ ], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_IGNORE), 'marketplace-control');\r
+ }\r
+\r
+ /**\r
+ * @param string $message\r
+ * @param array $context\r
+ */\r
+ private function logError(string $message, array $context = []): void\r
+ {\r
+ Yii::error(json_encode([\r
+ 'message' => $message,\r
+ 'context' => $context,\r
+ 'timestamp' => date('c'),\r
+ 'env' => YII_ENV,\r
+ ], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_IGNORE), 'marketplace-control');\r
+ }\r
+}\r