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