From 0cae9da1c0d808594701ed772a62b33a32db1059 Mon Sep 17 00:00:00 2001 From: Vladimir Fomichev Date: Tue, 20 Jan 2026 11:47:25 +0300 Subject: [PATCH] =?utf8?q?=D0=9F=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20=D0=B8=20?= =?utf8?q?=D1=82=D0=B5=D1=81=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit --- erp24/commands/MarketplaceController.php | 27 +- erp24/records/MarketplaceOrderDailyIssues.php | 2 + erp24/services/OrderControlReportService.php | 409 +++++++++++++-- erp24/services/dto/ControlReportResult.php | 84 ++- erp24/services/dto/OrderIssue.php | 27 +- .../MarketplaceOrderDailyIssuesTest.php | 363 +++++++++++++ .../OrderControlReportServiceTest.php | 366 +++++++++++++ .../services/dto/ControlReportResultTest.php | 496 ++++++++++++++++++ .../unit/services/dto/OrderIssueTest.php | 344 ++++++++++++ 9 files changed, 2070 insertions(+), 48 deletions(-) create mode 100644 erp24/tests/unit/records/MarketplaceOrderDailyIssuesTest.php create mode 100644 erp24/tests/unit/services/OrderControlReportServiceTest.php create mode 100644 erp24/tests/unit/services/dto/ControlReportResultTest.php create mode 100644 erp24/tests/unit/services/dto/OrderIssueTest.php diff --git a/erp24/commands/MarketplaceController.php b/erp24/commands/MarketplaceController.php index fed78608..90e9b9c8 100644 --- a/erp24/commands/MarketplaceController.php +++ b/erp24/commands/MarketplaceController.php @@ -52,6 +52,11 @@ class MarketplaceController extends Controller */ public $unseen; + /** + * @var bool Тестовый режим: endDate = текущее время (для проверки заказов в середине смены) + */ + public $test = false; + public function actionYandex() { $infoForMarketplace = MarketplaceService::infoForMarketplace(MarketplaceStore::YANDEX_WAREHOUSE_ID); @@ -176,6 +181,7 @@ class MarketplaceController extends Controller $options[] = 'oldMail'; $options[] = 'seen'; $options[] = 'unseen'; + $options[] = 'test'; return $options; } @@ -368,9 +374,17 @@ class MarketplaceController extends Controller * 1. "Завис в доставке" - РМК="Передан курьеру", МП НЕ "Выполнен" * 2. "Успех без чека" - МП="Выполнен", РМК НЕ "Успех" * 3. "Отмена без обработки" - МП="Отменён", РМК НЕ "Отказ" + * 4. "Успех без данных" - МП="Выполнен", РМК="Успех", но нет seller_id и/или check_guid * * Запуск по расписанию: 08:00 и 20:00 MSK (каждые 12 часов) - * Команда: php yii marketplace/send-order-control-report + * + * Команды: + * - Стандартный режим: php yii marketplace/send-order-control-report + * - Тестовый режим: php yii marketplace/send-order-control-report --test + * + * Тестовый режим (--test): + * - endDate = текущее время (вместо фиксированного 08:00/20:00) + * - Полезно для проверки заказов в середине смены * * @param int $hours Период выборки в часах (по умолчанию 12 — соответствует расписанию) * @param bool $onlyNew Отправлять только новые проблемы (по умолчанию true) @@ -380,18 +394,23 @@ class MarketplaceController extends Controller { set_time_limit(300); // 5 минут максимум + $testMode = (bool)$this->test; + $this->stdout("Запуск отчёта контроля статусов заказов МП...\n", BaseConsole::FG_YELLOW); $this->stdout("Период: {$hours} часов\n", BaseConsole::FG_CYAN); $this->stdout("Только новые: " . ($onlyNew ? 'да' : 'нет') . "\n", BaseConsole::FG_CYAN); + if ($testMode) { + $this->stdout("Режим: ТЕСТОВЫЙ (endDate = текущее время)\n", BaseConsole::FG_YELLOW); + } try { $service = new OrderControlReportService(); - $result = $service->generateControlReport($hours, $onlyNew); + $result = $service->generateControlReport($hours, $onlyNew, $testMode); // Вывод результатов $this->stdout("\n--- Результаты контроля статусов ---\n", BaseConsole::FG_YELLOW); $this->stdout("Дата отчёта: {$result->reportDate}\n", BaseConsole::FG_CYAN); - $this->stdout("Интервал: {$result->interval}\n", BaseConsole::FG_CYAN); + $this->stdout("Период: {$result->getFormattedDateRange()}\n", BaseConsole::FG_CYAN); $this->stdout("\nПроблемы по типам:\n", BaseConsole::FG_YELLOW); $this->stdout(" 🚚 Завис в доставке: {$result->getHungInDeliveryCount()}\n", @@ -400,6 +419,8 @@ class MarketplaceController extends Controller $result->getSuccessNoCheckCount() > 0 ? BaseConsole::FG_RED : BaseConsole::FG_GREEN); $this->stdout(" 🚫 Отмена без обработки: {$result->getCancelNoProcessCount()}\n", $result->getCancelNoProcessCount() > 0 ? BaseConsole::FG_RED : BaseConsole::FG_GREEN); + $this->stdout(" ⚠️ Успех без данных: {$result->getSuccessMissingDataCount()}\n", + $result->getSuccessMissingDataCount() > 0 ? BaseConsole::FG_RED : BaseConsole::FG_GREEN); $this->stdout("\nВсего проблем: {$result->totalIssues}\n", $result->totalIssues > 0 ? BaseConsole::FG_RED : BaseConsole::FG_GREEN); diff --git a/erp24/records/MarketplaceOrderDailyIssues.php b/erp24/records/MarketplaceOrderDailyIssues.php index 65f0a0b8..e6e6c818 100644 --- a/erp24/records/MarketplaceOrderDailyIssues.php +++ b/erp24/records/MarketplaceOrderDailyIssues.php @@ -50,6 +50,7 @@ class MarketplaceOrderDailyIssues extends \yii\db\ActiveRecord public const TYPE_HUNG_IN_DELIVERY = OrderIssue::TYPE_HUNG_IN_DELIVERY; public const TYPE_SUCCESS_NO_CHECK = OrderIssue::TYPE_SUCCESS_NO_CHECK; public const TYPE_CANCEL_NO_PROCESS = OrderIssue::TYPE_CANCEL_NO_PROCESS; + public const TYPE_SUCCESS_MISSING_DATA = OrderIssue::TYPE_SUCCESS_MISSING_DATA; /** * Метки типов проблем @@ -100,6 +101,7 @@ class MarketplaceOrderDailyIssues extends \yii\db\ActiveRecord self::TYPE_HUNG_IN_DELIVERY, self::TYPE_SUCCESS_NO_CHECK, self::TYPE_CANCEL_NO_PROCESS, + self::TYPE_SUCCESS_MISSING_DATA, ]], [['interval'], 'in', 'range' => ['08:00', '20:00']], [['order_id', 'problem_type', 'report_date', 'interval'], 'unique', 'targetAttribute' => ['order_id', 'problem_type', 'report_date', 'interval']], diff --git a/erp24/services/OrderControlReportService.php b/erp24/services/OrderControlReportService.php index 8e8ce048..9230b915 100644 --- a/erp24/services/OrderControlReportService.php +++ b/erp24/services/OrderControlReportService.php @@ -64,6 +64,11 @@ class OrderControlReportService */ private array $config; + /** + * Режим тестирования: endDate = текущее время вместо фиксированного времени смены + */ + private bool $testMode = false; + /** * Кеш ID статусов 1С (ленивая загрузка) */ @@ -120,15 +125,27 @@ class OrderControlReportService * * @param int $hoursAgo Период выборки в часах * @param bool $onlyNew Отправлять только новые проблемы + * @param bool $testMode Тестовый режим: endDate = текущее время (для проверки заказов в середине смены) * @return ControlReportResult */ - public function generateControlReport(int $hoursAgo = 12, bool $onlyNew = true): ControlReportResult + public function generateControlReport(int $hoursAgo = 12, bool $onlyNew = true, bool $testMode = false): ControlReportResult { $result = new ControlReportResult(); + // Сохраняем режим тестирования для использования во внутренних методах + $this->testMode = $testMode; + + // Получаем диапазон дат для отображения в отчёте + $dateRange = $this->getShiftBasedDateRange($hoursAgo, $testMode); + $result->startDate = $dateRange['startDate']; + $result->endDate = $dateRange['endDate']; + $result->shiftName = $dateRange['shiftName']; + $this->logInfo('Запуск отчёта контроля статусов МП', [ 'hours_ago' => $hoursAgo, 'only_new' => $onlyNew, + 'test_mode' => $testMode, + 'date_range' => $dateRange, ]); try { @@ -142,23 +159,27 @@ class OrderControlReportService // 3. Получаем остальные типы проблем $successNoCheck = $this->getSuccessNoCheckOrders($hoursAgo); $cancelNoProcess = $this->getCancelNoProcessOrders($hoursAgo); + $successMissingData = $this->getSuccessMissingDataOrders($hoursAgo); // 4. Фильтруем только новые для остальных типов, если требуется if ($onlyNew) { $prevSuccess = $this->loadPreviousIssues(OrderIssue::TYPE_SUCCESS_NO_CHECK); $prevCancel = $this->loadPreviousIssues(OrderIssue::TYPE_CANCEL_NO_PROCESS); + $prevMissingData = $this->loadPreviousIssues(OrderIssue::TYPE_SUCCESS_MISSING_DATA); $successNoCheck = $this->filterNewIssues($successNoCheck, $prevSuccess); $cancelNoProcess = $this->filterNewIssues($cancelNoProcess, $prevCancel); + $successMissingData = $this->filterNewIssues($successMissingData, $prevMissingData); } $result->hungInDelivery = $hungInDelivery; $result->successNoCheck = $successNoCheck; $result->cancelNoProcess = $cancelNoProcess; + $result->successMissingData = $successMissingData; $result->calculateTotal(); // 5. Сохраняем состояние проблем (кроме кандидатов hung_in_delivery, они уже сохранены) - $issuesToSave = array_merge($hungInDelivery, $successNoCheck, $cancelNoProcess); + $issuesToSave = array_merge($hungInDelivery, $successNoCheck, $cancelNoProcess, $successMissingData); $result->statesSaved = $this->saveControlIssues($issuesToSave); // 6. Отправляем уведомления только если есть проблемы @@ -185,6 +206,10 @@ class OrderControlReportService $this->logInfo('Нет проблемных заказов, уведомления не требуются'); } + // 7. Очистка старых записей (старше 1 месяца) + $deletedCount = $this->cleanupOldIssues(); + $this->logInfo('Очистка старых записей завершена', ['deleted_count' => $deletedCount]); + $this->logInfo('Отчёт контроля статусов завершён', $result->toArray()); } catch (\Exception $e) { @@ -213,10 +238,10 @@ class OrderControlReportService */ public function getHungInDeliveryCandidates(int $hoursAgo = 24): array { - $this->logInfo('Выборка кандидатов "Завис в доставке"', ['hours_ago' => $hoursAgo]); + $this->logInfo('Выборка кандидатов "Завис в доставке"', ['hours_ago' => $hoursAgo, 'test_mode' => $this->testMode]); // Получаем диапазон дат на основе конца смены - $dateRange = $this->getShiftBasedDateRange($hoursAgo); + $dateRange = $this->getShiftBasedDateRange($hoursAgo, $this->testMode); $startDateStr = $dateRange['startDate']; $endDateStr = $dateRange['endDate']; @@ -405,10 +430,10 @@ class OrderControlReportService */ public function getSuccessNoCheckOrders(int $hoursAgo = 12): array { - $this->logInfo('Выборка заказов "Успех без чека"', ['hours_ago' => $hoursAgo]); + $this->logInfo('Выборка заказов "Успех без чека"', ['hours_ago' => $hoursAgo, 'test_mode' => $this->testMode]); // Получаем диапазон дат на основе конца смены - $dateRange = $this->getShiftBasedDateRange($hoursAgo); + $dateRange = $this->getShiftBasedDateRange($hoursAgo, $this->testMode); $startDateStr = $dateRange['startDate']; $endDateStr = $dateRange['endDate']; @@ -525,10 +550,10 @@ class OrderControlReportService */ public function getCancelNoProcessOrders(int $hoursAgo = 24): array { - $this->logInfo('Выборка заказов "Отмена без обработки"', ['hours_ago' => $hoursAgo]); + $this->logInfo('Выборка заказов "Отмена без обработки"', ['hours_ago' => $hoursAgo, 'test_mode' => $this->testMode]); // Получаем диапазон дат на основе конца смены - $dateRange = $this->getShiftBasedDateRange($hoursAgo); + $dateRange = $this->getShiftBasedDateRange($hoursAgo, $this->testMode); $startDateStr = $dateRange['startDate']; $endDateStr = $dateRange['endDate']; @@ -597,6 +622,128 @@ class OrderControlReportService return $issues; } + /** + * Получает заказы типа "Успех без данных" + * + * Критерий: МП статус = "Выполнен" (DELIVERED или DELIVERY_SERVICE_DELIVERED) + * + РМК статус = "Успех" (successful_order = 1 в marketplace_order_1c_statuses) + * + (seller_id пустой/нулевой ИЛИ check_guid пустой/нулевой) + * + * Это заказы, которые успешно завершены и в МП, и в 1С, но при этом + * отсутствуют важные данные: продавец (seller_id) и/или GUID чека (check_guid). + * + * @param int $hoursAgo Период выборки в часах (по умолчанию 12) + * @return OrderIssue[] + */ + public function getSuccessMissingDataOrders(int $hoursAgo = 12): array + { + $this->logInfo('Выборка заказов "Успех без данных"', ['hours_ago' => $hoursAgo, 'test_mode' => $this->testMode]); + + // Получаем диапазон дат на основе конца смены + $dateRange = $this->getShiftBasedDateRange($hoursAgo, $this->testMode); + $startDateStr = $dateRange['startDate']; + $endDateStr = $dateRange['endDate']; + + // Нулевой GUID — признак отсутствия продавца + $emptySellerGuid = '00000000-0000-0000-0000-000000000000'; + + // Получаем ID статусов "Успех" из БД + $rmkSuccessIds = $this->getRmkStatusSuccess(); + + // Формируем плейсхолдеры для IN-условия + $rmkSuccessPlaceholders = []; + $rmkSuccessParams = []; + foreach ($rmkSuccessIds as $index => $id) { + $placeholder = ':rmk_success_' . $index; + $rmkSuccessPlaceholders[] = $placeholder; + $rmkSuccessParams[$placeholder] = $id; + } + $rmkSuccessInClause = !empty($rmkSuccessPlaceholders) + ? implode(', ', $rmkSuccessPlaceholders) + : '0'; // fallback если статусов нет + + // Выбираем заказы с МП-статусом "Выполнен" И РМК-статусом "Успех", где: + // seller_id пустой/нулевой ИЛИ check_guid пустой + $sql = " + SELECT + mo.id, + mo.marketplace_order_id, + mo.store_id, + cs.name as store_name, + mo.marketplace_name, + mo.marketplace_id, + mo.total, + mo.creation_date, + mo.seller_id, + mo.check_guid, + mo.status_processing_1c as rmk_status_id, + mocs.status as rmk_status, + most.code as mp_status_code, + mosub.code as mp_substatus_code, + COALESCE(most.name, mosub.name) as mp_status_name, + CASE + WHEN (mo.seller_id IS NULL OR mo.seller_id = '' OR mo.seller_id = :empty_seller_guid) + AND (mo.check_guid IS NULL OR mo.check_guid = '') + THEN 'no_seller_and_check_guid' + WHEN mo.seller_id IS NULL OR mo.seller_id = '' OR mo.seller_id = :empty_seller_guid2 + THEN 'no_seller_id' + WHEN mo.check_guid IS NULL OR mo.check_guid = '' + THEN 'no_check_guid' + ELSE 'unknown' + END as issue_reason + FROM marketplace_orders mo + LEFT JOIN city_store cs ON cs.id = mo.store_id + LEFT JOIN marketplace_order_1c_statuses mocs ON mocs.id = mo.status_processing_1c::integer + LEFT JOIN marketplace_order_status_types most ON most.id = mo.status_id + LEFT JOIN marketplace_order_status_types mosub ON mosub.id = mo.substatus_id + WHERE mo.fake = 0 + AND mo.updated_at >= :start_date + AND mo.updated_at <= :end_date + -- МП-статус = Выполнен (DELIVERED) + AND ( + most.code = :delivered + OR mosub.code = :delivery_service_delivered + ) + -- РМК-статус = Успех (1С знает о доставке) + AND mo.status_processing_1c::integer IN ({$rmkSuccessInClause}) + -- Проблема: seller_id пустой ИЛИ check_guid пустой + AND ( + mo.seller_id IS NULL + OR mo.seller_id = '' + OR mo.seller_id = :empty_seller_guid3 + OR mo.check_guid IS NULL + OR mo.check_guid = '' + ) + ORDER BY cs.name ASC, mo.creation_date DESC + "; + + $params = array_merge([ + ':start_date' => $startDateStr, + ':end_date' => $endDateStr, + ':delivered' => MarketplaceOrderStatusTypes::DELIVERED_CODE, + ':delivery_service_delivered' => MarketplaceOrderStatusTypes::DELIVERY_SERVICE_DELIVERED_CODE, + ':empty_seller_guid' => $emptySellerGuid, + ':empty_seller_guid2' => $emptySellerGuid, + ':empty_seller_guid3' => $emptySellerGuid, + ], $rmkSuccessParams); + + $orders = Yii::$app->db->createCommand($sql, $params)->queryAll(); + + $issues = []; + foreach ($orders as $orderData) { + $issues[] = OrderIssue::fromOrderData(OrderIssue::TYPE_SUCCESS_MISSING_DATA, $orderData); + } + + $this->logInfo('Найдено "Успех без данных"', [ + 'count' => count($issues), + 'no_seller_id' => count(array_filter($orders, fn($o) => $o['issue_reason'] === 'no_seller_id')), + 'no_check_guid' => count(array_filter($orders, fn($o) => $o['issue_reason'] === 'no_check_guid')), + 'no_seller_and_check_guid' => count(array_filter($orders, fn($o) => $o['issue_reason'] === 'no_seller_and_check_guid')), + ]); + + return $issues; + } + /** * Сохраняет состояние проблемных заказов в БД * @@ -637,6 +784,32 @@ class OrderControlReportService return $saved; } + /** + * Удаляет записи старше 1 месяца из таблицы marketplace_order_daily_issues + * + * Вызывается в конце генерации отчёта для поддержания размера таблицы. + * Хранятся только записи за последний месяц. + * + * @return int Количество удалённых записей + */ + public function cleanupOldIssues(): int + { + $oneMonthAgo = date('Y-m-d', strtotime('-1 month')); + + $deletedCount = MarketplaceOrderDailyIssues::deleteAll( + ['<', 'report_date', $oneMonthAgo] + ); + + if ($deletedCount > 0) { + $this->logInfo('Удалены старые записи из marketplace_order_daily_issues', [ + 'deleted_count' => $deletedCount, + 'older_than' => $oneMonthAgo, + ]); + } + + return $deletedCount; + } + /** * Загружает предыдущие проблемы (для определения новых) * @@ -738,6 +911,13 @@ class OrderControlReportService $lines[] = ''; } + // Секция "Успех без данных" - специальный формат с причиной + if (!empty($result->successMissingData)) { + $lines[] = '*Успех без данных* \\(' . count($result->successMissingData) . '\\)'; + $lines[] = $this->formatSuccessMissingDataTable($result->successMissingData); + $lines[] = ''; + } + $lines[] = '*Всего:* ' . $this->escapeMarkdownV2((string)$result->totalIssues); return implode("\n", $lines); @@ -746,6 +926,8 @@ class OrderControlReportService /** * Форматирует таблицу проблем для Telegram (моноширинный блок) * + * Формат: | Дата | Интервал | Заказ | РМК | МП + * * @param OrderIssue[] $issues * @return string */ @@ -753,6 +935,7 @@ class OrderControlReportService { $rows = []; $rows[] = '```'; + $rows[] = '| Дата | Интервал | Заказ | РМК | МП'; foreach ($issues as $issue) { $rows[] = $this->formatIssueRow($issue); @@ -763,27 +946,137 @@ class OrderControlReportService return implode("\n", $rows); } + /** + * Форматирует таблицу "Успех без данных" для Telegram + * + * Специальный формат: | Дата | Интервал | Заказ | Причина + * Показывает причину проблемы вместо статусов РМК/МП. + * + * @param OrderIssue[] $issues + * @return string + */ + private function formatSuccessMissingDataTable(array $issues): string + { + $rows = []; + $rows[] = '```'; + $rows[] = '| Дата | Интервал | Заказ | Причина'; + + foreach ($issues as $issue) { + $date = $issue->reportDate ?: date('d.m.Y'); + $interval = $this->getShortInterval($issue->interval); + $reason = $issue->getIssueReasonLabel() ?? 'Неизвестно'; + + $rows[] = sprintf('| %s | %s | %s | %s', $date, $interval, $issue->orderNumber, $reason); + } + + $rows[] = '```'; + + return implode("\n", $rows); + } + /** * Форматирует строку таблицы для проблемы * - * Формат: Заказ | РМК | МП + * Формат: | Дата | Интервал | Заказ | РМК | МП * * @param OrderIssue $issue * @return string */ private function formatIssueRow(OrderIssue $issue): string { + $date = $issue->reportDate ?: date('d.m.Y'); + $interval = $this->getShortInterval($issue->interval); $rmk = $issue->rmkStatus ?? '-'; - $mp = $issue->mpStatus ?? '-'; + $mp = $this->formatMpStatus($issue); return sprintf( - '%s | %s | %s', + '| %s | %s | %s | %s | %s', + $date, + $interval, $issue->orderNumber, $rmk, $mp ); } + /** + * Получает короткий формат интервала (08:00 или 20:00) + * + * @param string|null $interval + * @return string + */ + private function getShortInterval(?string $interval): string + { + if ($interval === null) { + return (int)date('H') < 12 ? '08:00' : '20:00'; + } + + // Убираем суффиксы типа " (Ночь)" или " (День)" + if (str_starts_with($interval, '08:00')) { + return '08:00'; + } + if (str_starts_with($interval, '20:00')) { + return '20:00'; + } + + return $interval; + } + + /** + * Форматирует МП-статус для отображения + * + * Преобразует технические коды в понятные названия. + * + * @param OrderIssue $issue + * @return string + */ + private function formatMpStatus(OrderIssue $issue): string + { + // Если есть человекочитаемый статус, используем его + if ($issue->mpStatus && !$this->isTechnicalMpStatus($issue->mpStatus)) { + return $issue->mpStatus; + } + + // Преобразуем технические коды в понятные названия + $statusCode = $issue->mpStatusCode; + $substatusCode = $issue->mpSubstatusCode; + + // Маппинг технических кодов на понятные названия + $statusMap = [ + 'DELIVERED' => 'Доставлен', + 'DELIVERY_SERVICE_DELIVERED' => 'Доставлен службой', + 'CANCELLED' => 'Отменён', + 'PROCESSING' => 'В обработке', + 'DELIVERY' => 'В доставке', + 'PICKUP' => 'Готов к выдаче', + 'UNPAID' => 'Не оплачен', + 'PENDING' => 'Ожидает', + ]; + + if ($statusCode && isset($statusMap[$statusCode])) { + return $statusMap[$statusCode]; + } + + if ($substatusCode && isset($statusMap[$substatusCode])) { + return $statusMap[$substatusCode]; + } + + // Если ничего не нашли, возвращаем оригинал или прочерк + return $issue->mpStatus ?? '-'; + } + + /** + * Проверяет, является ли статус техническим кодом + * + * @param string $status + * @return bool + */ + private function isTechnicalMpStatus(string $status): bool + { + // Технические статусы содержат только заглавные буквы, подчёркивания и слеши + return (bool)preg_match('/^[A-Z_\/]+$/', $status); + } + /** * Формирует HTML-отчёт контроля статусов для Email * @@ -822,6 +1115,9 @@ class OrderControlReportService foreach ($result->cancelNoProcess as $issue) { $allIssues[] = ['type' => 'Отмена без обработки', 'issue' => $issue]; } + foreach ($result->successMissingData as $issue) { + $allIssues[] = ['type' => 'Успех без данных', 'issue' => $issue]; + } // Сортируем по типу usort($allIssues, function ($a, $b) { @@ -836,7 +1132,7 @@ class OrderControlReportService Интервал Заказ РМК - МП + МП / Причина '; foreach ($allIssues as $item) { @@ -845,6 +1141,13 @@ class OrderControlReportService $date = $issue->reportDate ?: date('d.m.Y'); $interval = $issue->interval ?: ((int)date('H') < 12 ? '08:00' : '20:00'); + // Для "Успех без данных" показываем причину вместо МП-статуса + if ($issue->problemType === OrderIssue::TYPE_SUCCESS_MISSING_DATA) { + $mpOrReason = $issue->getIssueReasonLabel() ?? 'Неизвестно'; + } else { + $mpOrReason = $issue->mpStatus ?? '-'; + } + $html .= ' ' . $this->escapeHtml($item['type']) . ' @@ -852,7 +1155,7 @@ class OrderControlReportService ' . $this->escapeHtml($interval) . ' ' . $this->escapeHtml($issue->orderNumber) . ' ' . $this->escapeHtml($issue->rmkStatus ?? '-') . ' - ' . $this->escapeHtml($issue->mpStatus ?? '-') . ' + ' . $this->escapeHtml($mpOrReason) . ' '; } @@ -1208,50 +1511,84 @@ class OrderControlReportService } /** - * Вычисляет диапазон дат на основе конца смены + * Вычисляет диапазон дат на основе смены + * + * Стандартный режим (testMode=false): + * - Использует фиксированные времена смен + * - Дневная смена: 08:00 - 20:00 + * - Ночная смена: 20:00 - 08:00 * - * Логика: - * - Если текущий час < 20, база = 08:00 сегодня (конец утренней смены) - * - Если текущий час >= 20, база = 20:00 сегодня (конец вечерней смены) - * - startDate = база - $hoursAgo часов - * - endDate = база + * Тестовый режим (testMode=true): + * - endDate = текущее время (для отладки и проверки заказов в середине смены) + * - startDate = начало текущей смены * - * Пример: - * - 15:00, hoursAgo=12 → база 08:00 → диапазон: вчера 20:00 - сегодня 08:00 - * - 22:00, hoursAgo=12 → база 20:00 → диапазон: сегодня 08:00 - сегодня 20:00 + * Определение смены: + * - Если текущий час >= 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, ]; } diff --git a/erp24/services/dto/ControlReportResult.php b/erp24/services/dto/ControlReportResult.php index a09f1262..bf50531a 100644 --- a/erp24/services/dto/ControlReportResult.php +++ b/erp24/services/dto/ControlReportResult.php @@ -24,6 +24,21 @@ class ControlReportResult */ public string $interval = ''; + /** + * Дата-время начала периода проверки (формат Y-m-d H:i:s) + */ + public string $startDate = ''; + + /** + * Дата-время окончания периода проверки (формат Y-m-d H:i:s) + */ + public string $endDate = ''; + + /** + * Название смены (Дневная смена, Ночная смена) + */ + public string $shiftName = ''; + /** * Общее количество проблемных заказов */ @@ -50,6 +65,13 @@ class ControlReportResult */ public array $cancelNoProcess = []; + /** + * Заказы типа "Успех без данных" (успех в МП и 1С, но нет seller_id и/или check_guid) + * + * @var OrderIssue[] + */ + public array $successMissingData = []; + /** * Успешность отправки в Telegram */ @@ -116,6 +138,16 @@ class ControlReportResult return count($this->cancelNoProcess); } + /** + * Получает количество заказов по типу "Успех без данных" + * + * @return int + */ + public function getSuccessMissingDataCount(): int + { + return count($this->successMissingData); + } + /** * Рассчитывает и обновляет общее количество проблем * @@ -125,7 +157,8 @@ class ControlReportResult { $this->totalIssues = $this->getHungInDeliveryCount() + $this->getSuccessNoCheckCount() - + $this->getCancelNoProcessCount(); + + $this->getCancelNoProcessCount() + + $this->getSuccessMissingDataCount(); return $this->totalIssues; } @@ -140,7 +173,8 @@ class ControlReportResult return $this->totalIssues > 0 || !empty($this->hungInDelivery) || !empty($this->successNoCheck) - || !empty($this->cancelNoProcess); + || !empty($this->cancelNoProcess) + || !empty($this->successMissingData); } /** @@ -153,7 +187,8 @@ class ControlReportResult return array_merge( $this->hungInDelivery, $this->successNoCheck, - $this->cancelNoProcess + $this->cancelNoProcess, + $this->successMissingData ); } @@ -168,6 +203,7 @@ class ControlReportResult OrderIssue::TYPE_HUNG_IN_DELIVERY => $this->hungInDelivery, OrderIssue::TYPE_SUCCESS_NO_CHECK => $this->successNoCheck, OrderIssue::TYPE_CANCEL_NO_PROCESS => $this->cancelNoProcess, + OrderIssue::TYPE_SUCCESS_MISSING_DATA => $this->successMissingData, ]; } @@ -223,10 +259,14 @@ class ControlReportResult return [ 'report_date' => $this->reportDate, 'interval' => $this->interval, + 'start_date' => $this->startDate, + 'end_date' => $this->endDate, + 'shift_name' => $this->shiftName, 'total_issues' => $this->totalIssues, 'hung_in_delivery_count' => $this->getHungInDeliveryCount(), 'success_no_check_count' => $this->getSuccessNoCheckCount(), 'cancel_no_process_count' => $this->getCancelNoProcessCount(), + 'success_missing_data_count' => $this->getSuccessMissingDataCount(), 'telegram_sent' => $this->telegramSent, 'email_sent' => $this->emailSent, 'telegram_error' => $this->telegramError, @@ -236,6 +276,41 @@ class ControlReportResult ]; } + /** + * Форматирует диапазон дат для отображения в консоли + * + * Формат: "с 20:00 19.01.2026 по 08:00 20.01.2026 (Ночная смена)" + * + * @return string + */ + public function getFormattedDateRange(): string + { + if (empty($this->startDate) || empty($this->endDate)) { + return $this->interval; + } + + $tz = new \DateTimeZone('Europe/Moscow'); + + try { + $start = new \DateTime($this->startDate, $tz); + $end = new \DateTime($this->endDate, $tz); + + $result = sprintf( + 'с %s по %s', + $start->format('H:i d.m.Y'), + $end->format('H:i d.m.Y') + ); + + if (!empty($this->shiftName)) { + $result .= ' (' . $this->shiftName . ')'; + } + + return $result; + } catch (\Exception $e) { + return $this->interval; + } + } + /** * Получает сводку для логирования * @@ -244,11 +319,12 @@ class ControlReportResult public function getSummary(): string { return sprintf( - 'Контроль МП: %d проблем (завис: %d, успех без чека: %d, отмена: %d). TG: %s, Email: %s', + 'Контроль МП: %d проблем (завис: %d, успех без чека: %d, отмена: %d, успех без данных: %d). TG: %s, Email: %s', $this->totalIssues, $this->getHungInDeliveryCount(), $this->getSuccessNoCheckCount(), $this->getCancelNoProcessCount(), + $this->getSuccessMissingDataCount(), $this->telegramSent ? 'OK' : 'FAIL', $this->emailSent ? 'OK' : 'FAIL' ); diff --git a/erp24/services/dto/OrderIssue.php b/erp24/services/dto/OrderIssue.php index 51166277..8d54138f 100644 --- a/erp24/services/dto/OrderIssue.php +++ b/erp24/services/dto/OrderIssue.php @@ -27,6 +27,11 @@ class OrderIssue */ public const TYPE_CANCEL_NO_PROCESS = 'cancel_no_process'; + /** + * Тип проблемы: "Успех без данных" (успех в МП и 1С, но нет seller_id и/или check_guid) + */ + public const TYPE_SUCCESS_MISSING_DATA = 'success_missing_data'; + /** * Метки типов проблем для отображения */ @@ -34,6 +39,7 @@ class OrderIssue self::TYPE_HUNG_IN_DELIVERY => 'Завис в доставке', self::TYPE_SUCCESS_NO_CHECK => 'Успех без чека', self::TYPE_CANCEL_NO_PROCESS => 'Отмена без обработки', + self::TYPE_SUCCESS_MISSING_DATA => 'Успех без данных', ]; /** @@ -126,13 +132,18 @@ class OrderIssue */ public ?string $sellerId; + /** + * GUID чека заказа (check_guid из marketplace_orders) + */ + public ?string $checkGuid; + /** * Существует ли чек в create_checks */ public bool $checkExists = false; /** - * Причина проблемы (no_seller_id, no_check) + * Причина проблемы (no_seller_id, no_check, no_check_guid, no_seller_and_check_guid) */ public ?string $issueReason; @@ -142,6 +153,8 @@ class OrderIssue public const ISSUE_REASON_LABELS = [ 'no_seller_id' => 'Нет seller_id', 'no_check' => 'Чек не создан', + 'no_check_guid' => 'Нет check_guid', + 'no_seller_and_check_guid' => 'Нет seller_id и check_guid', 'unknown' => 'Неизвестно', ]; @@ -169,14 +182,16 @@ class OrderIssue /** * Определяет интервал проверки (Дневная/Ночная смена) * + * Возвращает только время без суффикса, т.к. поле в БД ограничено 8 символами. + * 08:00 - утренняя проверка (ночная смена завершилась) + * 20:00 - вечерняя проверка (дневная смена завершилась) + * * @return string */ private function calculateInterval(): string { $hour = (int)date('H'); - // 08:00 - утренняя проверка (ночная смена завершилась) - // 20:00 - вечерняя проверка (дневная смена завершилась) - return $hour < 12 ? '08:00 (Ночь)' : '20:00 (День)'; + return $hour < 12 ? '08:00' : '20:00'; } /** @@ -217,8 +232,9 @@ class OrderIssue $issue->total = (float)($orderData['total'] ?? 0); $issue->creationDate = $orderData['creation_date'] ?? null; - // Поля для диагностики "Успех без чека" + // Поля для диагностики "Успех без чека" и "Успех без данных" $issue->sellerId = $orderData['seller_id'] ?? null; + $issue->checkGuid = $orderData['check_guid'] ?? null; $issue->checkExists = (bool)($orderData['check_exists'] ?? false); $issue->issueReason = $orderData['issue_reason'] ?? null; @@ -251,6 +267,7 @@ class OrderIssue 'total' => $this->total, 'creation_date' => $this->creationDate, 'seller_id' => $this->sellerId, + 'check_guid' => $this->checkGuid, 'check_exists' => $this->checkExists, 'issue_reason' => $this->issueReason, 'issue_reason_label' => $this->getIssueReasonLabel(), diff --git a/erp24/tests/unit/records/MarketplaceOrderDailyIssuesTest.php b/erp24/tests/unit/records/MarketplaceOrderDailyIssuesTest.php new file mode 100644 index 00000000..70d83868 --- /dev/null +++ b/erp24/tests/unit/records/MarketplaceOrderDailyIssuesTest.php @@ -0,0 +1,363 @@ +assertSame(OrderIssue::TYPE_HUNG_IN_DELIVERY, MarketplaceOrderDailyIssues::TYPE_HUNG_IN_DELIVERY); + $this->assertSame(OrderIssue::TYPE_SUCCESS_NO_CHECK, MarketplaceOrderDailyIssues::TYPE_SUCCESS_NO_CHECK); + $this->assertSame(OrderIssue::TYPE_CANCEL_NO_PROCESS, MarketplaceOrderDailyIssues::TYPE_CANCEL_NO_PROCESS); + $this->assertSame(OrderIssue::TYPE_SUCCESS_MISSING_DATA, MarketplaceOrderDailyIssues::TYPE_SUCCESS_MISSING_DATA); + } + + /** + * Тест: метки типов проблем совпадают с DTO + */ + public function testTypeLabelsMatchOrderIssueDto(): void + { + $this->assertSame(OrderIssue::TYPE_LABELS, MarketplaceOrderDailyIssues::TYPE_LABELS); + } + + /** + * Тест: tableName возвращает корректное имя таблицы + */ + public function testTableNameReturnsCorrectName(): void + { + $this->assertSame('marketplace_order_daily_issues', MarketplaceOrderDailyIssues::tableName()); + } + + /** + * Тест: rules содержит обязательные поля + */ + public function testRulesContainsRequiredFields(): void + { + $model = new MarketplaceOrderDailyIssues(); + $rules = $model->rules(); + + // Находим правило с required + $requiredFields = []; + foreach ($rules as $rule) { + if ($rule[1] === 'required') { + $fields = is_array($rule[0]) ? $rule[0] : [$rule[0]]; + $requiredFields = array_merge($requiredFields, $fields); + } + } + + $this->assertContains('order_id', $requiredFields); + $this->assertContains('marketplace_order_id', $requiredFields); + $this->assertContains('problem_type', $requiredFields); + $this->assertContains('report_date', $requiredFields); + $this->assertContains('interval', $requiredFields); + } + + /** + * Тест: rules содержит валидацию problem_type + */ + public function testRulesContainsProblemTypeValidation(): void + { + $model = new MarketplaceOrderDailyIssues(); + $rules = $model->rules(); + + // Находим правило 'in' для problem_type + $inRule = null; + foreach ($rules as $rule) { + $fields = is_array($rule[0]) ? $rule[0] : [$rule[0]]; + if (in_array('problem_type', $fields) && $rule[1] === 'in') { + $inRule = $rule; + break; + } + } + + $this->assertNotNull($inRule, 'Правило in для problem_type должно существовать'); + $this->assertArrayHasKey('range', $inRule); + $this->assertContains(MarketplaceOrderDailyIssues::TYPE_HUNG_IN_DELIVERY, $inRule['range']); + $this->assertContains(MarketplaceOrderDailyIssues::TYPE_SUCCESS_NO_CHECK, $inRule['range']); + $this->assertContains(MarketplaceOrderDailyIssues::TYPE_CANCEL_NO_PROCESS, $inRule['range']); + $this->assertContains(MarketplaceOrderDailyIssues::TYPE_SUCCESS_MISSING_DATA, $inRule['range']); + } + + /** + * Тест: rules содержит валидацию interval + */ + public function testRulesContainsIntervalValidation(): void + { + $model = new MarketplaceOrderDailyIssues(); + $rules = $model->rules(); + + // Находим правило 'in' для interval + $inRule = null; + foreach ($rules as $rule) { + $fields = is_array($rule[0]) ? $rule[0] : [$rule[0]]; + if (in_array('interval', $fields) && $rule[1] === 'in') { + $inRule = $rule; + break; + } + } + + $this->assertNotNull($inRule, 'Правило in для interval должно существовать'); + $this->assertArrayHasKey('range', $inRule); + $this->assertContains('08:00', $inRule['range']); + $this->assertContains('20:00', $inRule['range']); + } + + /** + * Тест: rules содержит уникальный составной ключ + */ + public function testRulesContainsUniqueConstraint(): void + { + $model = new MarketplaceOrderDailyIssues(); + $rules = $model->rules(); + + // Находим правило unique + $uniqueRule = null; + foreach ($rules as $rule) { + if ($rule[1] === 'unique' && isset($rule['targetAttribute'])) { + $uniqueRule = $rule; + break; + } + } + + $this->assertNotNull($uniqueRule, 'Правило unique должно существовать'); + $this->assertContains('order_id', $uniqueRule['targetAttribute']); + $this->assertContains('problem_type', $uniqueRule['targetAttribute']); + $this->assertContains('report_date', $uniqueRule['targetAttribute']); + $this->assertContains('interval', $uniqueRule['targetAttribute']); + } + + /** + * Тест: attributeLabels содержит все основные поля + */ + public function testAttributeLabelsContainsAllFields(): void + { + $model = new MarketplaceOrderDailyIssues(); + $labels = $model->attributeLabels(); + + $expectedLabels = [ + 'id', 'order_id', 'marketplace_order_id', 'problem_type', + 'report_date', 'interval', 'rmk_status_id', 'rmk_status', + 'mp_status_code', 'mp_substatus_code', 'mp_status', + 'store_id', 'store_name', 'marketplace_id', 'marketplace_name', + 'total', 'is_notified', 'notified_at', 'is_resolved', + 'resolved_at', 'created_at', 'updated_at', + ]; + + foreach ($expectedLabels as $attribute) { + $this->assertArrayHasKey($attribute, $labels, "Метка для {$attribute} должна существовать"); + } + } + + /** + * Тест: getProblemTypeLabel возвращает корректные метки + * + * @dataProvider problemTypeLabelProvider + */ + public function testGetProblemTypeLabelReturnsCorrectLabel(string $type, string $expectedLabel): void + { + $model = new MarketplaceOrderDailyIssues(); + $model->problem_type = $type; + + $this->assertSame($expectedLabel, $model->getProblemTypeLabel()); + } + + /** + * Провайдер данных для testGetProblemTypeLabelReturnsCorrectLabel + */ + public static function problemTypeLabelProvider(): array + { + return [ + 'hung_in_delivery' => [ + MarketplaceOrderDailyIssues::TYPE_HUNG_IN_DELIVERY, + 'Завис в доставке', + ], + 'success_no_check' => [ + MarketplaceOrderDailyIssues::TYPE_SUCCESS_NO_CHECK, + 'Успех без чека', + ], + 'cancel_no_process' => [ + MarketplaceOrderDailyIssues::TYPE_CANCEL_NO_PROCESS, + 'Отмена без обработки', + ], + 'success_missing_data' => [ + MarketplaceOrderDailyIssues::TYPE_SUCCESS_MISSING_DATA, + 'Успех без данных', + ], + ]; + } + + /** + * Тест: getProblemTypeLabel возвращает тип для неизвестных значений + */ + public function testGetProblemTypeLabelReturnsTypeForUnknownValue(): void + { + $model = new MarketplaceOrderDailyIssues(); + $model->problem_type = 'unknown_type'; + + $this->assertSame('unknown_type', $model->getProblemTypeLabel()); + } + + /** + * Тест: fromOrderIssue создаёт модель из DTO + */ + public function testFromOrderIssueCreatesModelFromDto(): void + { + $issue = new OrderIssue(OrderIssue::TYPE_HUNG_IN_DELIVERY, 123, 'FW-12345'); + $issue->rmkStatusId = '5'; + $issue->rmkStatus = 'Передан курьеру'; + $issue->mpStatusCode = 'DELIVERY'; + $issue->mpSubstatusCode = 'DELIVERY_USER_RECEIVED'; + $issue->mpStatus = 'В доставке'; + $issue->storeId = 10; + $issue->storeName = 'Магазин Центр'; + $issue->marketplaceId = 1; + $issue->marketplaceName = 'Flowwow'; + $issue->total = 5500.50; + + $model = MarketplaceOrderDailyIssues::fromOrderIssue($issue); + + $this->assertInstanceOf(MarketplaceOrderDailyIssues::class, $model); + $this->assertSame(123, $model->order_id); + $this->assertSame('FW-12345', $model->marketplace_order_id); + $this->assertSame(OrderIssue::TYPE_HUNG_IN_DELIVERY, $model->problem_type); + $this->assertSame(date('Y-m-d'), $model->report_date); + $this->assertMatchesRegularExpression('/^(08:00|20:00)$/', $model->interval); + $this->assertSame('5', $model->rmk_status_id); + $this->assertSame('Передан курьеру', $model->rmk_status); + $this->assertSame('DELIVERY', $model->mp_status_code); + $this->assertSame('DELIVERY_USER_RECEIVED', $model->mp_substatus_code); + $this->assertSame('В доставке', $model->mp_status); + $this->assertSame(10, $model->store_id); + $this->assertSame('Магазин Центр', $model->store_name); + $this->assertSame(1, $model->marketplace_id); + $this->assertSame('Flowwow', $model->marketplace_name); + $this->assertSame(5500.50, $model->total); + } + + /** + * Тест: toOrderIssue преобразует модель в DTO + */ + public function testToOrderIssueConvertsModelToDto(): void + { + $model = new MarketplaceOrderDailyIssues(); + $model->order_id = 456; + $model->marketplace_order_id = 'YM-456'; + $model->problem_type = MarketplaceOrderDailyIssues::TYPE_SUCCESS_NO_CHECK; + $model->report_date = '2026-01-20'; + $model->interval = '20:00'; + $model->rmk_status_id = '1'; + $model->rmk_status = 'Новый'; + $model->mp_status_code = 'DELIVERED'; + $model->mp_substatus_code = 'DELIVERY_SERVICE_DELIVERED'; + $model->mp_status = 'Доставлен'; + $model->store_id = 20; + $model->store_name = 'Магазин Север'; + $model->marketplace_id = 2; + $model->marketplace_name = 'Yandex Market'; + $model->total = 3200.00; + + $issue = $model->toOrderIssue(); + + $this->assertInstanceOf(OrderIssue::class, $issue); + $this->assertSame(456, $issue->orderId); + $this->assertSame('YM-456', $issue->orderNumber); + $this->assertSame(OrderIssue::TYPE_SUCCESS_NO_CHECK, $issue->problemType); + $this->assertSame('20.01.2026', $issue->reportDate); + $this->assertSame('20:00', $issue->interval); + $this->assertSame('1', $issue->rmkStatusId); + $this->assertSame('Новый', $issue->rmkStatus); + $this->assertSame('DELIVERED', $issue->mpStatusCode); + $this->assertSame('DELIVERY_SERVICE_DELIVERED', $issue->mpSubstatusCode); + $this->assertSame('Доставлен', $issue->mpStatus); + $this->assertSame(20, $issue->storeId); + $this->assertSame('Магазин Север', $issue->storeName); + $this->assertSame(2, $issue->marketplaceId); + $this->assertSame('Yandex Market', $issue->marketplaceName); + $this->assertSame(3200.00, $issue->total); + } + + /** + * Тест: fromOrderIssue -> toOrderIssue сохраняет основные данные + */ + public function testRoundTripConversionPreservesData(): void + { + $originalIssue = new OrderIssue(OrderIssue::TYPE_CANCEL_NO_PROCESS, 789, 'FW-789'); + $originalIssue->rmkStatusId = '7'; + $originalIssue->rmkStatus = 'Отменён'; + $originalIssue->mpStatusCode = 'CANCELLED'; + $originalIssue->mpSubstatusCode = 'USER_CANCELLED'; + $originalIssue->mpStatus = 'Отменён покупателем'; + $originalIssue->storeId = 30; + $originalIssue->storeName = 'Магазин Запад'; + $originalIssue->marketplaceId = 1; + $originalIssue->marketplaceName = 'Flowwow'; + $originalIssue->total = 8000.00; + + $model = MarketplaceOrderDailyIssues::fromOrderIssue($originalIssue); + $convertedIssue = $model->toOrderIssue(); + + // Сравниваем основные поля (кроме reportDate и interval, которые могут измениться) + $this->assertSame($originalIssue->orderId, $convertedIssue->orderId); + $this->assertSame($originalIssue->orderNumber, $convertedIssue->orderNumber); + $this->assertSame($originalIssue->problemType, $convertedIssue->problemType); + $this->assertSame($originalIssue->rmkStatusId, $convertedIssue->rmkStatusId); + $this->assertSame($originalIssue->rmkStatus, $convertedIssue->rmkStatus); + $this->assertSame($originalIssue->mpStatusCode, $convertedIssue->mpStatusCode); + $this->assertSame($originalIssue->mpSubstatusCode, $convertedIssue->mpSubstatusCode); + $this->assertSame($originalIssue->mpStatus, $convertedIssue->mpStatus); + $this->assertSame($originalIssue->storeId, $convertedIssue->storeId); + $this->assertSame($originalIssue->storeName, $convertedIssue->storeName); + $this->assertSame($originalIssue->marketplaceId, $convertedIssue->marketplaceId); + $this->assertSame($originalIssue->marketplaceName, $convertedIssue->marketplaceName); + $this->assertSame($originalIssue->total, $convertedIssue->total); + } + + /** + * Тест: fromOrderIssue с минимальными данными + */ + public function testFromOrderIssueWithMinimalData(): void + { + $issue = new OrderIssue(OrderIssue::TYPE_SUCCESS_MISSING_DATA, 1, 'TEST-1'); + + $model = MarketplaceOrderDailyIssues::fromOrderIssue($issue); + + $this->assertSame(1, $model->order_id); + $this->assertSame('TEST-1', $model->marketplace_order_id); + $this->assertSame(OrderIssue::TYPE_SUCCESS_MISSING_DATA, $model->problem_type); + $this->assertNull($model->rmk_status_id); + $this->assertNull($model->rmk_status); + $this->assertNull($model->mp_status_code); + $this->assertNull($model->mp_status); + $this->assertNull($model->store_id); + $this->assertNull($model->store_name); + $this->assertNull($model->marketplace_id); + $this->assertNull($model->marketplace_name); + $this->assertSame(0.0, $model->total); + } +} diff --git a/erp24/tests/unit/services/OrderControlReportServiceTest.php b/erp24/tests/unit/services/OrderControlReportServiceTest.php new file mode 100644 index 00000000..28e2b657 --- /dev/null +++ b/erp24/tests/unit/services/OrderControlReportServiceTest.php @@ -0,0 +1,366 @@ +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('', $html); + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('', $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('

', $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('

', $html); + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('', $html); + $this->assertStringContainsString('', $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('
Тип проблемыДатаИнтервалЗаказРМКМП / Причина