From: Vladimir Fomichev Date: Mon, 19 Jan 2026 08:37:17 +0000 (+0300) Subject: Переработка отчета X-Git-Url: https://gitweb.erp-flowers.ru/?a=commitdiff_plain;h=64d03a5fb8a8fcb6a1a5e09b08f4ed90be36b605;p=erp24_rep%2Fyii-erp24%2F.git Переработка отчета --- diff --git a/erp24/commands/MarketplaceController.php b/erp24/commands/MarketplaceController.php index 009804e8..9bfe21b8 100644 --- a/erp24/commands/MarketplaceController.php +++ b/erp24/commands/MarketplaceController.php @@ -27,7 +27,7 @@ use OpenAPI\Client\Model; use GuzzleHttp; use yii_app\services\WhatsAppService; use yii_app\records\MarketplaceOrderDelivery; -use yii_app\services\UncheckedOrdersReportService; +use yii_app\services\OrderControlReportService; class MarketplaceController extends Controller { @@ -362,71 +362,78 @@ class MarketplaceController extends Controller /** - * Отправка отчёта о заказах с непробитыми чеками + * Отправляет отчёт контроля статусов заказов МП * - * Выбирает заказы в статусе DELIVERED/DELIVERY_SERVICE_DELIVERED за последние 12 часов, - * у которых отсутствует пробитый чек: - * - check_guid IS NULL - * - CreateChecks.status IS NULL (запись отсутствует) - * - CreateChecks.status = 0 (STATUS_CHECK_CREATED_ERP - создан в ERP, не отправлен в 1С) - * - CreateChecks.status = 8 (STATUS_CHECK_ERROR_1C - ошибка в 1С) + * Проверяет расхождения между статусами РМК/1С и статусами маркетплейсов: + * 1. "Завис в доставке" - РМК="Передан курьеру", МП НЕ "Выполнен" + * 2. "Успех без чека" - МП="Выполнен", РМК НЕ "Успех" + * 3. "Отмена без обработки" - МП="Отменён", РМК НЕ "Отказ" * - * Отправляет отчёт в Telegram (MarkdownV2) и на email (HTML). - * В development окружении использует тестовый бот и канал. + * Запуск по расписанию: 08:00 и 20:00 MSK + * Команда: php yii marketplace/send-order-control-report * - * Запуск: php yii marketplace/send-unchecked-orders-report [--date=YYYY-MM-DD] - * Cron (UTC): 0 5,17 * * * - * Время MSK: 8:00 и 20:00 - * - * @param string|null $date Дата для отчёта (YYYY-MM-DD), по умолчанию - сегодня - * @return int Exit code: - * 0 = успех (отчёт отправлен или нет заказов) - * 1 = критическая ошибка (БД, конфигурация) - * 2 = частичный успех (TG или email не отправлен) + * @param int $hours Период выборки в часах (по умолчанию 24) + * @param bool $onlyNew Отправлять только новые проблемы (по умолчанию true) + * @return int Код завершения (0 = успех, 1 = ошибка, 2 = частичный успех) */ - public function actionSendUncheckedOrdersReport(?string $date = null): int + public function actionSendOrderControlReport(int $hours = 24, bool $onlyNew = true): int { set_time_limit(300); // 5 минут максимум - $this->stdout("Запуск отчёта о заказах с непробитыми чеками...\n", BaseConsole::FG_YELLOW); - - // Валидация даты - if ($date !== null && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { - $this->stderr("Неверный формат даты. Ожидается: YYYY-MM-DD\n", BaseConsole::FG_RED); - return ExitCode::UNSPECIFIED_ERROR; - } + $this->stdout("Запуск отчёта контроля статусов заказов МП...\n", BaseConsole::FG_YELLOW); + $this->stdout("Период: {$hours} часов\n", BaseConsole::FG_CYAN); + $this->stdout("Только новые: " . ($onlyNew ? 'да' : 'нет') . "\n", BaseConsole::FG_CYAN); try { - $service = new UncheckedOrdersReportService(); - $result = $service->generateAndSendReport($date); + $service = new OrderControlReportService(); + $result = $service->generateControlReport($hours, $onlyNew); - if (!$result->hasOrders()) { - $this->stdout("Заказы с непробитыми чеками не найдены за период.\n", BaseConsole::FG_GREEN); - return ExitCode::OK; - } + // Вывод результатов + $this->stdout("\n--- Результаты контроля статусов ---\n", BaseConsole::FG_YELLOW); + $this->stdout("Дата отчёта: {$result->reportDate}\n", BaseConsole::FG_CYAN); + $this->stdout("Интервал: {$result->interval}\n", BaseConsole::FG_CYAN); + + $this->stdout("\nПроблемы по типам:\n", BaseConsole::FG_YELLOW); + $this->stdout(" 🚚 Завис в доставке: {$result->getHungInDeliveryCount()}\n", + $result->getHungInDeliveryCount() > 0 ? BaseConsole::FG_RED : BaseConsole::FG_GREEN); + $this->stdout(" ✅❌ Успех без чека: {$result->getSuccessNoCheckCount()}\n", + $result->getSuccessNoCheckCount() > 0 ? BaseConsole::FG_RED : BaseConsole::FG_GREEN); + $this->stdout(" 🚫 Отмена без обработки: {$result->getCancelNoProcessCount()}\n", + $result->getCancelNoProcessCount() > 0 ? BaseConsole::FG_RED : BaseConsole::FG_GREEN); - $this->stdout("Найдено заказов: {$result->totalOrders}\n", BaseConsole::FG_CYAN); - $this->stdout("Магазинов: {$result->storesCount}\n", BaseConsole::FG_CYAN); - $this->stdout("Общая сумма: " . number_format($result->totalAmount, 0, '.', ' ') . " руб.\n", BaseConsole::FG_CYAN); + $this->stdout("\nВсего проблем: {$result->totalIssues}\n", + $result->totalIssues > 0 ? BaseConsole::FG_RED : BaseConsole::FG_GREEN); + $this->stdout("Сохранено состояний: {$result->statesSaved}\n", BaseConsole::FG_CYAN); + + // Статус отправки + $this->stdout("\nОтправка уведомлений:\n", BaseConsole::FG_YELLOW); if ($result->telegramSent) { - $this->stdout("Telegram: отправлено\n", BaseConsole::FG_GREEN); + $this->stdout(" Telegram: ✅ отправлено\n", BaseConsole::FG_GREEN); } else { - $this->stdout("Telegram: ошибка - {$result->telegramError}\n", BaseConsole::FG_RED); + $errorMsg = $result->telegramError ?? 'неизвестная ошибка'; + $this->stdout(" Telegram: ❌ ошибка - {$errorMsg}\n", BaseConsole::FG_RED); } if ($result->emailSent) { - $this->stdout("Email: отправлено\n", BaseConsole::FG_GREEN); + $this->stdout(" Email: ✅ отправлено\n", BaseConsole::FG_GREEN); } else { - $this->stdout("Email: ошибка - {$result->emailError}\n", BaseConsole::FG_RED); + $errorMsg = $result->emailError ?? 'неизвестная ошибка'; + $this->stdout(" Email: ❌ ошибка - {$errorMsg}\n", BaseConsole::FG_RED); } + // Итоговый статус $exitCode = $result->getExitCode(); + $this->stdout("\n", BaseConsole::FG_CYAN); if ($exitCode === 0) { - $this->stdout("Отчёт успешно отправлен.\n", BaseConsole::FG_GREEN); + if ($result->hasIssues()) { + $this->stdout("Отчёт успешно отправлен.\n", BaseConsole::FG_GREEN); + } else { + $this->stdout("Проблемных заказов не найдено.\n", BaseConsole::FG_GREEN); + } } elseif ($exitCode === 2) { - $this->stdout("Отчёт отправлен частично.\n", BaseConsole::FG_YELLOW); + $this->stdout("Отчёт отправлен частично (проверьте логи).\n", BaseConsole::FG_YELLOW); } else { $this->stdout("Не удалось отправить отчёт.\n", BaseConsole::FG_RED); } @@ -435,7 +442,8 @@ class MarketplaceController extends Controller } catch (\Exception $e) { $this->stderr("Критическая ошибка: {$e->getMessage()}\n", BaseConsole::FG_RED); - Yii::error("Ошибка отчёта о непробитых чеках: " . $e->getMessage(), 'marketplace-checks'); + $this->stderr("Trace: {$e->getTraceAsString()}\n", BaseConsole::FG_RED); + Yii::error("Ошибка отчёта контроля статусов МП: " . $e->getMessage(), 'marketplace-control'); return ExitCode::UNSPECIFIED_ERROR; } } diff --git a/erp24/config/params.php b/erp24/config/params.php index dbcd544d..8997e908 100644 --- a/erp24/config/params.php +++ b/erp24/config/params.php @@ -26,7 +26,7 @@ return [ 'retry_delay_seconds' => 5, 'telegram_max_message_length' => 4000, 'telegram_chat_id_dev' => getenv('TELEGRAM_UNCHECKED_ORDERS_CHAT_ID_DEV') ?: '-1001861631125', - 'telegram_chat_id_prod' => getenv('TELEGRAM_UNCHECKED_ORDERS_CHAT_ID_PROD') ?: '', + 'telegram_chat_id_prod' => getenv('TELEGRAM_UNCHECKED_ORDERS_CHAT_ID_PROD') ?: '4886272326', 'email_recipients' => array_filter(explode(',', getenv('UNCHECKED_ORDERS_EMAIL_RECIPIENTS') ?: 'vladimir.fomichev@erp-flowers.ru')), 'email_subject' => 'Отчёт о заказах с непробитыми чеками', ], diff --git a/erp24/migrations/m260119_100000_create_marketplace_order_daily_issues_table.php b/erp24/migrations/m260119_100000_create_marketplace_order_daily_issues_table.php new file mode 100644 index 00000000..72a6604b --- /dev/null +++ b/erp24/migrations/m260119_100000_create_marketplace_order_daily_issues_table.php @@ -0,0 +1,61 @@ +createTable(self::TABLE_NAME, [ + 'id' => $this->primaryKey(), + 'order_id' => $this->integer()->notNull()->comment('ID заказа'), + 'marketplace_order_id' => $this->string(64)->notNull()->comment('Номер заказа в МП'), + 'problem_type' => $this->string(32)->notNull()->comment('Тип проблемы: hung_in_delivery, success_no_check, cancel_no_process'), + 'report_date' => $this->date()->notNull()->comment('Дата отчёта'), + 'interval' => $this->string(8)->notNull()->comment('Интервал проверки: 08:00 или 20:00'), + 'rmk_status_id' => $this->string(16)->null()->comment('Код статуса РМК/1С'), + 'rmk_status' => $this->string(64)->null()->comment('Название статуса РМК/1С'), + 'mp_status_code' => $this->string(64)->null()->comment('Код статуса МП'), + 'mp_substatus_code' => $this->string(64)->null()->comment('Код субстатуса МП'), + 'mp_status' => $this->string(128)->null()->comment('Название статуса МП'), + 'store_id' => $this->integer()->null()->comment('ID магазина'), + 'store_name' => $this->string(128)->null()->comment('Название магазина'), + 'marketplace_id' => $this->integer()->null()->comment('ID маркетплейса'), + 'marketplace_name' => $this->string(64)->null()->comment('Название маркетплейса'), + 'total' => $this->decimal(10, 2)->defaultValue(0)->comment('Сумма заказа'), + 'is_notified' => $this->boolean()->defaultValue(false)->comment('Отправлено уведомление'), + 'notified_at' => $this->timestamp()->null()->comment('Время отправки уведомления'), + 'is_resolved' => $this->boolean()->defaultValue(false)->comment('Проблема решена'), + 'resolved_at' => $this->timestamp()->null()->comment('Время решения'), + 'created_at' => $this->timestamp()->defaultExpression('CURRENT_TIMESTAMP')->comment('Время создания записи'), + 'updated_at' => $this->timestamp()->null()->comment('Время обновления'), + ]); + + // Комментарий к таблице + $this->addCommentOnTable(self::TABLE_NAME, 'Таблица состояния проблемных заказов МП для контроля статусов'); + } + + /** + * {@inheritdoc} + */ + public function safeDown() + { + $this->dropTable(self::TABLE_NAME); + } +} diff --git a/erp24/records/MarketplaceOrderDailyIssues.php b/erp24/records/MarketplaceOrderDailyIssues.php new file mode 100644 index 00000000..65f0a0b8 --- /dev/null +++ b/erp24/records/MarketplaceOrderDailyIssues.php @@ -0,0 +1,328 @@ + TimestampBehavior::class, + 'createdAtAttribute' => 'created_at', + 'updatedAtAttribute' => 'updated_at', + 'value' => new Expression('CURRENT_TIMESTAMP'), + ], + ]; + } + + /** + * {@inheritdoc} + */ + public static function tableName() + { + return 'marketplace_order_daily_issues'; + } + + /** + * {@inheritdoc} + */ + public function rules() + { + return [ + [['order_id', 'marketplace_order_id', 'problem_type', 'report_date', 'interval'], 'required'], + [['order_id', 'store_id', 'marketplace_id'], 'integer'], + [['report_date', 'notified_at', 'resolved_at', 'created_at', 'updated_at'], 'safe'], + [['total'], 'number'], + [['is_notified', 'is_resolved'], 'boolean'], + [['marketplace_order_id', 'mp_status_code', 'mp_substatus_code', 'marketplace_name'], 'string', 'max' => 64], + [['problem_type'], 'string', 'max' => 32], + [['interval'], 'string', 'max' => 8], + [['rmk_status_id'], 'string', 'max' => 16], + [['rmk_status'], 'string', 'max' => 64], + [['mp_status', 'store_name'], 'string', 'max' => 128], + [['problem_type'], 'in', 'range' => [ + self::TYPE_HUNG_IN_DELIVERY, + self::TYPE_SUCCESS_NO_CHECK, + self::TYPE_CANCEL_NO_PROCESS, + ]], + [['interval'], 'in', 'range' => ['08:00', '20:00']], + [['order_id', 'problem_type', 'report_date', 'interval'], 'unique', 'targetAttribute' => ['order_id', 'problem_type', 'report_date', 'interval']], + [['order_id'], 'exist', 'skipOnError' => true, 'targetClass' => MarketplaceOrders::class, 'targetAttribute' => ['order_id' => 'id']], + ]; + } + + /** + * {@inheritdoc} + */ + public function attributeLabels() + { + return [ + 'id' => 'ID', + 'order_id' => 'ID заказа', + 'marketplace_order_id' => 'Номер заказа в МП', + 'problem_type' => 'Тип проблемы', + 'report_date' => 'Дата отчёта', + 'interval' => 'Интервал проверки', + 'rmk_status_id' => 'Код статуса РМК/1С', + 'rmk_status' => 'Статус РМК/1С', + 'mp_status_code' => 'Код статуса МП', + 'mp_substatus_code' => 'Код субстатуса МП', + 'mp_status' => 'Статус МП', + 'store_id' => 'ID магазина', + 'store_name' => 'Магазин', + 'marketplace_id' => 'ID маркетплейса', + 'marketplace_name' => 'Маркетплейс', + 'total' => 'Сумма заказа', + 'is_notified' => 'Уведомление отправлено', + 'notified_at' => 'Время уведомления', + 'is_resolved' => 'Проблема решена', + 'resolved_at' => 'Время решения', + 'created_at' => 'Создано', + 'updated_at' => 'Обновлено', + ]; + } + + /** + * Связь с заказом маркетплейса + * + * @return \yii\db\ActiveQuery + */ + public function getOrder() + { + return $this->hasOne(MarketplaceOrders::class, ['id' => 'order_id']); + } + + /** + * Связь с магазином + * + * @return \yii\db\ActiveQuery + */ + public function getStore() + { + return $this->hasOne(Store::class, ['id' => 'store_id']); + } + + /** + * Получает человекочитаемую метку типа проблемы + * + * @return string + */ + public function getProblemTypeLabel(): string + { + return self::TYPE_LABELS[$this->problem_type] ?? $this->problem_type; + } + + /** + * Создаёт запись из DTO OrderIssue + * + * @param OrderIssue $issue DTO проблемного заказа + * @return self + */ + public static function fromOrderIssue(OrderIssue $issue): self + { + $model = new self(); + $model->order_id = $issue->orderId; + $model->marketplace_order_id = $issue->orderNumber; + $model->problem_type = $issue->problemType; + $model->report_date = date('Y-m-d', strtotime($issue->reportDate)); + $model->interval = $issue->interval; + $model->rmk_status_id = $issue->rmkStatusId; + $model->rmk_status = $issue->rmkStatus; + $model->mp_status_code = $issue->mpStatusCode; + $model->mp_substatus_code = $issue->mpSubstatusCode; + $model->mp_status = $issue->mpStatus; + $model->store_id = $issue->storeId; + $model->store_name = $issue->storeName; + $model->marketplace_id = $issue->marketplaceId; + $model->marketplace_name = $issue->marketplaceName; + $model->total = $issue->total; + + return $model; + } + + /** + * Конвертирует в DTO OrderIssue + * + * @return OrderIssue + */ + public function toOrderIssue(): OrderIssue + { + $issue = new OrderIssue( + $this->problem_type, + $this->order_id, + $this->marketplace_order_id + ); + + $issue->reportDate = date('d.m.Y', strtotime($this->report_date)); + $issue->interval = $this->interval; + $issue->rmkStatusId = $this->rmk_status_id; + $issue->rmkStatus = $this->rmk_status; + $issue->mpStatusCode = $this->mp_status_code; + $issue->mpSubstatusCode = $this->mp_substatus_code; + $issue->mpStatus = $this->mp_status; + $issue->storeId = $this->store_id; + $issue->storeName = $this->store_name; + $issue->marketplaceId = $this->marketplace_id; + $issue->marketplaceName = $this->marketplace_name; + $issue->total = (float)$this->total; + $issue->creationDate = null; + + return $issue; + } + + /** + * Помечает проблему как решённую + * + * @return bool + */ + public function markAsResolved(): bool + { + $this->is_resolved = true; + $this->resolved_at = date('Y-m-d H:i:s'); + + return $this->save(false, ['is_resolved', 'resolved_at', 'updated_at']); + } + + /** + * Помечает проблему как уведомлённую + * + * @return bool + */ + public function markAsNotified(): bool + { + $this->is_notified = true; + $this->notified_at = date('Y-m-d H:i:s'); + + return $this->save(false, ['is_notified', 'notified_at', 'updated_at']); + } + + /** + * Находит нерешённые проблемы за указанный период + * + * @param string|null $fromDate Дата начала (Y-m-d) + * @param string|null $toDate Дата конца (Y-m-d) + * @return array + */ + public static function findUnresolved(?string $fromDate = null, ?string $toDate = null): array + { + $query = self::find()->where(['is_resolved' => false]); + + if ($fromDate !== null) { + $query->andWhere(['>=', 'report_date', $fromDate]); + } + + if ($toDate !== null) { + $query->andWhere(['<=', 'report_date', $toDate]); + } + + return $query->orderBy(['created_at' => SORT_DESC])->all(); + } + + /** + * Находит проблемы по заказу + * + * @param int $orderId ID заказа + * @return array + */ + public static function findByOrderId(int $orderId): array + { + return self::find() + ->where(['order_id' => $orderId]) + ->orderBy(['created_at' => SORT_DESC]) + ->all(); + } + + /** + * Проверяет существование проблемы + * + * @param int $orderId ID заказа + * @param string $problemType Тип проблемы + * @param string $reportDate Дата отчёта (Y-m-d) + * @param string $interval Интервал (08:00 или 20:00) + * @return bool + */ + public static function issueExists(int $orderId, string $problemType, string $reportDate, string $interval): bool + { + return self::find() + ->where([ + 'order_id' => $orderId, + 'problem_type' => $problemType, + 'report_date' => $reportDate, + 'interval' => $interval, + ]) + ->exists(); + } + + /** + * Получает статистику по типам проблем за период + * + * @param string $fromDate Дата начала (Y-m-d) + * @param string $toDate Дата конца (Y-m-d) + * @return array + */ + public static function getStatsByProblemType(string $fromDate, string $toDate): array + { + return self::find() + ->select(['problem_type', 'COUNT(*) as count']) + ->where(['between', 'report_date', $fromDate, $toDate]) + ->groupBy(['problem_type']) + ->asArray() + ->all(); + } +} diff --git a/erp24/records/MarketplaceOrderStatusTypes.php b/erp24/records/MarketplaceOrderStatusTypes.php index 1b3c0bc9..da8c0797 100644 --- a/erp24/records/MarketplaceOrderStatusTypes.php +++ b/erp24/records/MarketplaceOrderStatusTypes.php @@ -22,6 +22,18 @@ class MarketplaceOrderStatusTypes extends \yii\db\ActiveRecord const DELIVERED_CODE = 'DELIVERED'; const DELIVERY_SERVICE_DELIVERED_CODE = 'DELIVERY_SERVICE_DELIVERED'; + /** + * Статус "Передан в службу доставки" + * Используется для проверки "Завис в доставке" + */ + const DELIVERY_CODE = 'DELIVERY'; + + /** + * Субстатус "Курьер получил заказ" + * Используется вместе с DELIVERY_CODE для проверки "Завис в доставке" + */ + const COURIER_RECEIVED_CODE = 'COURIER_RECEIVED'; + public function behaviors() { return [ diff --git a/erp24/services/OrderControlReportService.php b/erp24/services/OrderControlReportService.php new file mode 100644 index 00000000..3adaa31e --- /dev/null +++ b/erp24/services/OrderControlReportService.php @@ -0,0 +1,1055 @@ +config = Yii::$app->params['MARKETPLACE_ORDER_CONTROL_REPORT'] ?? []; + } + + /** + * Генерирует отчёт контроля статусов заказов МП + * + * @param int $hoursAgo Период выборки в часах + * @param bool $onlyNew Отправлять только новые проблемы + * @return ControlReportResult + */ + public function generateControlReport(int $hoursAgo = 24, bool $onlyNew = true): ControlReportResult + { + $result = new ControlReportResult(); + + $this->logInfo('Запуск отчёта контроля статусов МП', [ + 'hours_ago' => $hoursAgo, + 'only_new' => $onlyNew, + ]); + + try { + // 1. Получаем кандидатов "Завис в доставке" и сохраняем их состояние + $hungCandidates = $this->getHungInDeliveryCandidates($hoursAgo); + $this->saveHungInDeliveryCandidates($hungCandidates); + + // 2. Фильтруем "Завис в доставке" - только те, что были в предыдущей проверке + $hungInDelivery = $this->filterHungInDeliveryByPreviousState($hungCandidates); + + // 3. Получаем остальные типы проблем + $successNoCheck = $this->getSuccessNoCheckOrders($hoursAgo); + $cancelNoProcess = $this->getCancelNoProcessOrders($hoursAgo); + + // 4. Фильтруем только новые для остальных типов, если требуется + if ($onlyNew) { + $prevSuccess = $this->loadPreviousIssues(OrderIssue::TYPE_SUCCESS_NO_CHECK); + $prevCancel = $this->loadPreviousIssues(OrderIssue::TYPE_CANCEL_NO_PROCESS); + + $successNoCheck = $this->filterNewIssues($successNoCheck, $prevSuccess); + $cancelNoProcess = $this->filterNewIssues($cancelNoProcess, $prevCancel); + } + + $result->hungInDelivery = $hungInDelivery; + $result->successNoCheck = $successNoCheck; + $result->cancelNoProcess = $cancelNoProcess; + $result->calculateTotal(); + + // 5. Сохраняем состояние проблем (кроме кандидатов hung_in_delivery, они уже сохранены) + $issuesToSave = array_merge($hungInDelivery, $successNoCheck, $cancelNoProcess); + $result->statesSaved = $this->saveControlIssues($issuesToSave); + + // 6. Отправляем уведомления только если есть проблемы + if ($result->hasIssues()) { + // Telegram + $telegramMessage = $this->formatTelegramControlReport($result); + $result->telegramSent = $this->sendToTelegram($telegramMessage); + if (!$result->telegramSent) { + $result->telegramError = 'Не удалось отправить в Telegram'; + } + + // Email + $emailHtml = $this->formatEmailControlReport($result); + $result->emailSent = $this->sendToEmail($emailHtml); + if (!$result->emailSent) { + $result->emailError = 'Не удалось отправить Email'; + } + + // Помечаем отправленные + $this->markIssuesAsNotified($issuesToSave); + } else { + $result->telegramSent = true; + $result->emailSent = true; + $this->logInfo('Нет проблемных заказов, уведомления не требуются'); + } + + $this->logInfo('Отчёт контроля статусов завершён', $result->toArray()); + + } catch (\Exception $e) { + $this->logError('Ошибка генерации отчёта контроля', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + $result->telegramError = $e->getMessage(); + $result->emailError = $e->getMessage(); + } + + return $result; + } + + /** + * Получает кандидатов "Завис в доставке" + * + * Критерий: РМК статус = "Передан курьеру" (1004/1011) + * + МП статус НЕ "Выполнен" (НЕ DELIVERED и НЕ DELIVERY_SERVICE_DELIVERED) + * + * ВАЖНО: Это только кандидаты! Заказ становится проблемой "Завис в доставке" + * только если он был кандидатом в ПРЕДЫДУЩЕЙ проверке с тем же статусом РМК. + * + * @param int $hoursAgo Период выборки в часах (по умолчанию 24) + * @return OrderIssue[] + */ + public function getHungInDeliveryCandidates(int $hoursAgo = 24): array + { + $this->logInfo('Выборка кандидатов "Завис в доставке"', ['hours_ago' => $hoursAgo]); + + $startDate = new \DateTime('now', new \DateTimeZone(self::TIMEZONE)); + $startDate->modify("-{$hoursAgo} hours"); + $startDateStr = $startDate->format('Y-m-d H:i:s'); + + // Выбираем заказы с РМК-статусом "Передан курьеру", где МП-статус НЕ "Выполнен" + $sql = " + SELECT + mo.id, + mo.marketplace_order_id, + mo.store_id, + cs.name as store_name, + mo.marketplace_name, + mo.marketplace_id, + mo.total, + mo.creation_date, + mo.status_processing_1c as rmk_status_id, + mocs.name as rmk_status, + most.code as mp_status_code, + mosub.code as mp_substatus_code, + most.name as mp_status_name + FROM marketplace_orders mo + LEFT JOIN city_store cs ON cs.id = mo.store_id + LEFT JOIN marketplace_order_1c_statuses mocs ON mocs.id::text = mo.status_processing_1c + LEFT JOIN marketplace_order_status_types most ON most.id = mo.status_id + LEFT JOIN marketplace_order_status_types mosub ON mosub.id = mo.substatus_id + WHERE mo.fake = 0 + AND mo.status_processing_1c IN (:rmk_1004, :rmk_1011) + AND mo.updated_at >= :start_date + AND ( + most.code IS NULL + OR (most.code != :delivered AND mosub.code IS DISTINCT FROM :delivery_service_delivered) + ) + ORDER BY cs.name ASC, mo.creation_date DESC + "; + + $orders = Yii::$app->db->createCommand($sql, [ + ':rmk_1004' => '1004', + ':rmk_1011' => '1011', + ':start_date' => $startDateStr, + ':delivered' => MarketplaceOrderStatusTypes::DELIVERED_CODE, + ':delivery_service_delivered' => MarketplaceOrderStatusTypes::DELIVERY_SERVICE_DELIVERED_CODE, + ])->queryAll(); + + $issues = []; + foreach ($orders as $orderData) { + $issues[] = OrderIssue::fromOrderData(OrderIssue::TYPE_HUNG_IN_DELIVERY, $orderData); + } + + $this->logInfo('Найдено кандидатов "Завис в доставке"', ['count' => count($issues)]); + + return $issues; + } + + /** + * Сохраняет кандидатов "Завис в доставке" для сравнения в следующей проверке + * + * Использует отдельный problem_type для хранения состояния кандидатов. + * + * @param OrderIssue[] $candidates Массив кандидатов + * @return int Количество сохранённых записей + */ + private function saveHungInDeliveryCandidates(array $candidates): int + { + $saved = 0; + $reportDate = date('Y-m-d'); + $interval = (int)date('H') < 12 ? '08:00' : '20:00'; + + foreach ($candidates as $candidate) { + // Проверяем, не существует ли уже такая запись + if (MarketplaceOrderDailyIssues::issueExists( + $candidate->orderId, + $candidate->problemType, + $reportDate, + $interval + )) { + continue; + } + + $model = MarketplaceOrderDailyIssues::fromOrderIssue($candidate); + + if ($model->save()) { + $saved++; + } else { + $this->logWarning('Не удалось сохранить кандидата hung_in_delivery', [ + 'order_id' => $candidate->orderId, + 'errors' => $model->getErrors(), + ]); + } + } + + $this->logInfo('Сохранено кандидатов "Завис в доставке"', ['count' => $saved, 'total' => count($candidates)]); + + return $saved; + } + + /** + * Фильтрует кандидатов "Завис в доставке" по предыдущему состоянию + * + * Возвращает только те заказы, которые были в ПРЕДЫДУЩЕЙ проверке + * с тем же статусом РМК (т.е. статус не изменился). + * + * @param OrderIssue[] $candidates Текущие кандидаты + * @return OrderIssue[] Подтверждённые проблемы + */ + private function filterHungInDeliveryByPreviousState(array $candidates): array + { + // Определяем предыдущий интервал + $currentHour = (int)date('H'); + $currentInterval = $currentHour < 12 ? '08:00' : '20:00'; + + // Предыдущий интервал: если сейчас 08:00, то предыдущий был 20:00 вчера + // Если сейчас 20:00, то предыдущий был 08:00 сегодня + if ($currentInterval === '08:00') { + $prevDate = date('Y-m-d', strtotime('-1 day')); + $prevInterval = '20:00'; + } else { + $prevDate = date('Y-m-d'); + $prevInterval = '08:00'; + } + + // Получаем предыдущие записи кандидатов со статусом "Передан курьеру" + $previousRecords = MarketplaceOrderDailyIssues::find() + ->where([ + 'problem_type' => OrderIssue::TYPE_HUNG_IN_DELIVERY, + 'report_date' => $prevDate, + 'interval' => $prevInterval, + ]) + ->andWhere(['rmk_status_id' => self::RMK_STATUS_COURIER]) + ->indexBy('order_id') + ->asArray() + ->all(); + + $this->logInfo('Загружено предыдущих кандидатов "Завис в доставке"', [ + 'count' => count($previousRecords), + 'prev_date' => $prevDate, + 'prev_interval' => $prevInterval, + ]); + + // Фильтруем: оставляем только те заказы, которые были в предыдущей проверке + $confirmedIssues = []; + foreach ($candidates as $candidate) { + if (isset($previousRecords[$candidate->orderId])) { + // Заказ был кандидатом в предыдущей проверке с тем же статусом → подтверждённая проблема + $confirmedIssues[] = $candidate; + } + } + + $this->logInfo('Подтверждено проблем "Завис в доставке"', [ + 'candidates' => count($candidates), + 'confirmed' => count($confirmedIssues), + ]); + + return $confirmedIssues; + } + + /** + * Получает заказы типа "Успех без чека" + * + * Критерий: МП статус = "Выполнен" (DELIVERED или DELIVERY_SERVICE_DELIVERED) + * + РМК статус НЕ "Успех" (НЕ 1005/1012) + * + * @param int $hoursAgo Период выборки в часах (по умолчанию 24) + * @return OrderIssue[] + */ + public function getSuccessNoCheckOrders(int $hoursAgo = 24): array + { + $this->logInfo('Выборка заказов "Успех без чека"', ['hours_ago' => $hoursAgo]); + + $startDate = new \DateTime('now', new \DateTimeZone(self::TIMEZONE)); + $startDate->modify("-{$hoursAgo} hours"); + $startDateStr = $startDate->format('Y-m-d H:i:s'); + + // Выбираем заказы с МП-статусом "Выполнен", где РМК-статус НЕ "Успех" + $sql = " + SELECT + mo.id, + mo.marketplace_order_id, + mo.store_id, + cs.name as store_name, + mo.marketplace_name, + mo.marketplace_id, + mo.total, + mo.creation_date, + mo.status_processing_1c as rmk_status_id, + mocs.name as rmk_status, + most.code as mp_status_code, + mosub.code as mp_substatus_code, + most.name as mp_status_name + FROM marketplace_orders mo + LEFT JOIN city_store cs ON cs.id = mo.store_id + LEFT JOIN marketplace_order_1c_statuses mocs ON mocs.id::text = mo.status_processing_1c + LEFT JOIN marketplace_order_status_types most ON most.id = mo.status_id + LEFT JOIN marketplace_order_status_types mosub ON mosub.id = mo.substatus_id + WHERE mo.fake = 0 + AND mo.updated_at >= :start_date + AND ( + most.code = :delivered + OR mosub.code = :delivery_service_delivered + ) + AND ( + mo.status_processing_1c IS NULL + OR mo.status_processing_1c NOT IN (:rmk_1005, :rmk_1012) + ) + ORDER BY cs.name ASC, mo.creation_date DESC + "; + + $orders = Yii::$app->db->createCommand($sql, [ + ':start_date' => $startDateStr, + ':delivered' => MarketplaceOrderStatusTypes::DELIVERED_CODE, + ':delivery_service_delivered' => MarketplaceOrderStatusTypes::DELIVERY_SERVICE_DELIVERED_CODE, + ':rmk_1005' => '1005', + ':rmk_1012' => '1012', + ])->queryAll(); + + $issues = []; + foreach ($orders as $orderData) { + $issues[] = OrderIssue::fromOrderData(OrderIssue::TYPE_SUCCESS_NO_CHECK, $orderData); + } + + $this->logInfo('Найдено "Успех без чека"', ['count' => count($issues)]); + + return $issues; + } + + /** + * Получает заказы типа "Отмена без обработки" + * + * Критерий: МП статус = "Отменён" (CANCELLED) + * + РМК статус НЕ "Отказ" (НЕ 1006/1013) + * + * @param int $hoursAgo Период выборки в часах (по умолчанию 24) + * @return OrderIssue[] + */ + public function getCancelNoProcessOrders(int $hoursAgo = 24): array + { + $this->logInfo('Выборка заказов "Отмена без обработки"', ['hours_ago' => $hoursAgo]); + + $startDate = new \DateTime('now', new \DateTimeZone(self::TIMEZONE)); + $startDate->modify("-{$hoursAgo} hours"); + $startDateStr = $startDate->format('Y-m-d H:i:s'); + + // Выбираем заказы с МП-статусом "Отменён", где РМК-статус НЕ "Отказ" + $sql = " + SELECT + mo.id, + mo.marketplace_order_id, + mo.store_id, + cs.name as store_name, + mo.marketplace_name, + mo.marketplace_id, + mo.total, + mo.creation_date, + mo.status_processing_1c as rmk_status_id, + mocs.name as rmk_status, + most.code as mp_status_code, + mosub.code as mp_substatus_code, + most.name as mp_status_name + FROM marketplace_orders mo + LEFT JOIN city_store cs ON cs.id = mo.store_id + LEFT JOIN marketplace_order_1c_statuses mocs ON mocs.id::text = mo.status_processing_1c + LEFT JOIN marketplace_order_status_types most ON most.id = mo.status_id + LEFT JOIN marketplace_order_status_types mosub ON mosub.id = mo.substatus_id + WHERE mo.fake = 0 + AND mo.updated_at >= :start_date + AND most.code = :cancelled + AND ( + mo.status_processing_1c IS NULL + OR mo.status_processing_1c NOT IN (:rmk_1006, :rmk_1013) + ) + ORDER BY cs.name ASC, mo.creation_date DESC + "; + + $orders = Yii::$app->db->createCommand($sql, [ + ':start_date' => $startDateStr, + ':cancelled' => MarketplaceOrderStatusTypes::CANSELLED_CODE, + ':rmk_1006' => '1006', + ':rmk_1013' => '1013', + ])->queryAll(); + + $issues = []; + foreach ($orders as $orderData) { + $issues[] = OrderIssue::fromOrderData(OrderIssue::TYPE_CANCEL_NO_PROCESS, $orderData); + } + + $this->logInfo('Найдено "Отмена без обработки"', ['count' => count($issues)]); + + return $issues; + } + + /** + * Сохраняет состояние проблемных заказов в БД + * + * @param OrderIssue[] $issues Массив проблемных заказов + * @return int Количество сохранённых записей + */ + public function saveControlIssues(array $issues): int + { + $saved = 0; + $reportDate = date('Y-m-d'); + $interval = (int)date('H') < 12 ? '08:00' : '20:00'; + + foreach ($issues as $issue) { + // Проверяем, не существует ли уже такая запись + if (MarketplaceOrderDailyIssues::issueExists( + $issue->orderId, + $issue->problemType, + $reportDate, + $interval + )) { + continue; + } + + $model = MarketplaceOrderDailyIssues::fromOrderIssue($issue); + + if ($model->save()) { + $saved++; + } else { + $this->logWarning('Не удалось сохранить issue', [ + 'order_id' => $issue->orderId, + 'errors' => $model->getErrors(), + ]); + } + } + + $this->logInfo('Сохранено состояний', ['count' => $saved, 'total' => count($issues)]); + + return $saved; + } + + /** + * Загружает предыдущие проблемы (для определения новых) + * + * @param string $problemType Тип проблемы + * @return array Карта order_id => true + */ + public function loadPreviousIssues(string $problemType): array + { + $yesterday = date('Y-m-d', strtotime('-1 day')); + + $issues = MarketplaceOrderDailyIssues::find() + ->select(['order_id']) + ->where(['problem_type' => $problemType]) + ->andWhere(['>=', 'report_date', $yesterday]) + ->andWhere(['is_resolved' => false]) + ->asArray() + ->all(); + + $map = []; + foreach ($issues as $issue) { + $map[(int)$issue['order_id']] = true; + } + + return $map; + } + + /** + * Фильтрует только новые проблемы (которых не было в предыдущих отчётах) + * + * @param OrderIssue[] $issues Все найденные проблемы + * @param array $previousMap Карта предыдущих проблем + * @return OrderIssue[] Только новые проблемы + */ + public function filterNewIssues(array $issues, array $previousMap): array + { + return array_filter($issues, function (OrderIssue $issue) use ($previousMap) { + return !isset($previousMap[$issue->orderId]); + }); + } + + /** + * Помечает проблемы как отправленные + * + * @param OrderIssue[] $issues + */ + private function markIssuesAsNotified(array $issues): void + { + $reportDate = date('Y-m-d'); + $interval = (int)date('H') < 12 ? '08:00' : '20:00'; + + foreach ($issues as $issue) { + $model = MarketplaceOrderDailyIssues::find() + ->where([ + 'order_id' => $issue->orderId, + 'problem_type' => $issue->problemType, + 'report_date' => $reportDate, + 'interval' => $interval, + ]) + ->one(); + + if ($model) { + $model->markAsNotified(); + } + } + } + + /** + * Формирует текстовый отчёт контроля статусов для Telegram (MarkdownV2) + * + * @param ControlReportResult $result Результат отчёта + * @return string Текст сообщения + */ + public function formatTelegramControlReport(ControlReportResult $result): string + { + $lines = []; + $lines[] = $this->escapeMarkdownV2('[Контроль MP]') . ' *Отчёт за ' . $this->escapeMarkdownV2($result->reportDate) . ' ' . $this->escapeMarkdownV2($result->interval) . '*'; + $lines[] = ''; + + // Секция "Завис в доставке" + if (!empty($result->hungInDelivery)) { + $lines[] = '*' . $this->escapeMarkdownV2('Завис в доставке') . '*'; + $lines[] = $this->escapeMarkdownV2('| Дата | Интервал | Заказ | РМК | МП'); + + foreach ($result->hungInDelivery as $issue) { + $line = $this->formatIssueLineForTelegram($issue); + $lines[] = $this->escapeMarkdownV2($line); + } + $lines[] = ''; + } + + // Секция "Успех без чека" + if (!empty($result->successNoCheck)) { + $lines[] = '*' . $this->escapeMarkdownV2('Успех без чека') . '*'; + $lines[] = $this->escapeMarkdownV2('| Дата | Интервал | Заказ | РМК | МП'); + + foreach ($result->successNoCheck as $issue) { + $line = $this->formatIssueLineForTelegram($issue); + $lines[] = $this->escapeMarkdownV2($line); + } + $lines[] = ''; + } + + // Секция "Отмена без обработки" + if (!empty($result->cancelNoProcess)) { + $lines[] = '*' . $this->escapeMarkdownV2('Отмена без обработки') . '*'; + $lines[] = $this->escapeMarkdownV2('| Дата | Интервал | Заказ | РМК | МП'); + + foreach ($result->cancelNoProcess as $issue) { + $line = $this->formatIssueLineForTelegram($issue); + $lines[] = $this->escapeMarkdownV2($line); + } + $lines[] = ''; + } + + return implode("\n", $lines); + } + + /** + * Форматирует строку проблемы для Telegram + * + * @param OrderIssue $issue + * @return string + */ + private function formatIssueLineForTelegram(OrderIssue $issue): string + { + $date = $issue->reportDate ?: date('d.m.Y'); + $interval = $issue->interval ?: ((int)date('H') < 12 ? '08:00' : '20:00'); + $rmk = $issue->rmkStatus ?? '-'; + $mp = $issue->mpStatus ?? '-'; + + return sprintf( + '| %s | %s | %s | %s | %s', + $date, + $interval, + $issue->orderNumber, + $rmk, + $mp + ); + } + + /** + * Формирует HTML-отчёт контроля статусов для Email + * + * @param ControlReportResult $result Результат отчёта + * @return string HTML-контент + */ + public function formatEmailControlReport(ControlReportResult $result): string + { + $html = ' + + + + + + +

[Контроль MP] Отчёт за ' . $this->escapeHtml($result->reportDate) . ' ' . $this->escapeHtml($result->interval) . '

'; + + // Общая таблица со всеми проблемами, сортировка по типу + $allIssues = []; + + foreach ($result->hungInDelivery as $issue) { + $allIssues[] = ['type' => 'Завис в доставке', 'issue' => $issue]; + } + foreach ($result->successNoCheck as $issue) { + $allIssues[] = ['type' => 'Успех без чека', 'issue' => $issue]; + } + foreach ($result->cancelNoProcess as $issue) { + $allIssues[] = ['type' => 'Отмена без обработки', 'issue' => $issue]; + } + + // Сортируем по типу + usort($allIssues, function ($a, $b) { + return strcmp($a['type'], $b['type']); + }); + + $html .= ' + + + + + + + + + '; + + foreach ($allIssues as $item) { + /** @var OrderIssue $issue */ + $issue = $item['issue']; + $date = $issue->reportDate ?: date('d.m.Y'); + $interval = $issue->interval ?: ((int)date('H') < 12 ? '08:00' : '20:00'); + + $html .= ' + + + + + + + + '; + } + + $html .= ' +
Тип проблемыДатаИнтервалЗаказРМКМП
' . $this->escapeHtml($item['type']) . '' . $this->escapeHtml($date) . '' . $this->escapeHtml($interval) . '' . $this->escapeHtml($issue->orderNumber) . '' . $this->escapeHtml($issue->rmkStatus ?? '-') . '' . $this->escapeHtml($issue->mpStatus ?? '-') . '
+

Всего проблем: ' . $result->totalIssues . '

+ +'; + + return $html; + } + + /** + * Отправляет сообщение в Telegram с retry-логикой + * + * @param string $message Текст сообщения (MarkdownV2) + * @return bool Успешность отправки + */ + public function sendToTelegram(string $message): bool + { + $chatId = $this->getTelegramChatId(); + + if (empty($chatId)) { + $this->logWarning('Telegram chat_id не настроен'); + return false; + } + + // Валидация chat_id + if (!preg_match('/^-?\d+$/', $chatId)) { + $this->logError('Некорректный формат chat_id: ' . $chatId); + return false; + } + + $chunks = $this->splitTelegramMessage($message); + $allSent = true; + + foreach ($chunks as $index => $chunk) { + $sent = false; + $maxRetries = $this->config['max_retries'] ?? self::MAX_RETRIES; + $retryDelay = $this->config['retry_delay_seconds'] ?? self::RETRY_DELAY_SECONDS; + + for ($attempt = 1; $attempt <= $maxRetries; $attempt++) { + try { + $sent = $this->sendTelegramMessage($chatId, $chunk); + if ($sent) { + break; + } + } catch (\Exception $e) { + $this->logWarning("Telegram попытка {$attempt}/{$maxRetries}: {$e->getMessage()}"); + } + + if ($attempt < $maxRetries) { + sleep($retryDelay); + } + } + + if (!$sent) { + $allSent = false; + $this->logError("Не удалось отправить часть " . ($index + 1) . " в Telegram после {$maxRetries} попыток"); + } + } + + return $allSent; + } + + /** + * Отправляет сообщение в Telegram + * + * @param string $chatId ID чата/канала + * @param string $message Текст сообщения + * @return bool Успешность + */ + private function sendTelegramMessage(string $chatId, string $message): bool + { + $botToken = $this->getTelegramBotToken(); + $url = "https://api.telegram.org/bot{$botToken}/sendMessage"; + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => [ + 'chat_id' => $chatId, + 'text' => $message, + 'parse_mode' => 'MarkdownV2', + 'disable_web_page_preview' => true, + ], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_SSL_VERIFYPEER => true, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlError = curl_error($ch); + curl_close($ch); + + if ($curlError) { + $this->logError("Telegram cURL error: {$curlError}"); + return false; + } + + if ($httpCode !== 200) { + $this->logError("Telegram API error: HTTP {$httpCode}, response: {$response}"); + return false; + } + + $this->logInfo('Сообщение отправлено в Telegram', ['chat_id' => $chatId]); + return true; + } + + /** + * Отправляет отчёт на email с retry-логикой + * + * @param string $html HTML-контент письма + * @return bool Успешность отправки + */ + public function sendToEmail(string $html): bool + { + $recipients = $this->getEmailRecipients(); + + if (empty($recipients)) { + $this->logWarning('Email-получатели не настроены'); + return false; + } + + // Валидация email-адресов + $validRecipients = []; + foreach ($recipients as $email) { + $email = trim($email); + if (filter_var($email, FILTER_VALIDATE_EMAIL)) { + $validRecipients[] = $email; + } else { + $this->logWarning("Некорректный email пропущен: {$email}"); + } + } + + if (empty($validRecipients)) { + $this->logError('Нет валидных email-адресов'); + return false; + } + + $sent = false; + $maxRetries = $this->config['max_retries'] ?? self::MAX_RETRIES; + $retryDelay = $this->config['retry_delay_seconds'] ?? self::RETRY_DELAY_SECONDS; + $subject = $this->config['email_subject'] ?? 'Контроль статусов заказов МП'; + + for ($attempt = 1; $attempt <= $maxRetries; $attempt++) { + try { + $sent = Yii::$app->mailer->compose() + ->setTo($validRecipients) + ->setSubject($subject) + ->setHtmlBody($html) + ->send(); + + if ($sent) { + $this->logInfo('Email отправлен на: ' . implode(', ', $validRecipients)); + break; + } + } catch (\Exception $e) { + $this->logWarning("Email попытка {$attempt}/{$maxRetries}: {$e->getMessage()}"); + } + + if ($attempt < $maxRetries) { + sleep($retryDelay); + } + } + + if (!$sent) { + $this->logError('Не удалось отправить email после ' . $maxRetries . ' попыток'); + } + + return $sent; + } + + /** + * Разбивает длинное сообщение на части для Telegram + * + * @param string $message Полное сообщение + * @return array Массив частей сообщения + */ + private function splitTelegramMessage(string $message): array + { + $maxLength = $this->config['telegram_max_message_length'] ?? self::TELEGRAM_MAX_LENGTH; + + if (mb_strlen($message) <= $maxLength) { + return [$message]; + } + + $chunks = []; + $lines = explode("\n", $message); + $currentChunk = ''; + + foreach ($lines as $line) { + if (mb_strlen($currentChunk . "\n" . $line) > $maxLength) { + if ($currentChunk !== '') { + $chunks[] = $currentChunk; + } + $currentChunk = $line; + } else { + $currentChunk .= ($currentChunk !== '' ? "\n" : '') . $line; + } + } + + if ($currentChunk !== '') { + $chunks[] = $currentChunk; + } + + return $chunks; + } + + /** + * Экранирует специальные символы для MarkdownV2 + * + * @param string $text Исходный текст + * @return string Экранированный текст + */ + private function escapeMarkdownV2(string $text): string + { + $specialChars = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!']; + foreach ($specialChars as $char) { + $text = str_replace($char, '\\' . $char, $text); + } + return $text; + } + + /** + * Экранирует HTML-сущности + * + * @param string $text + * @return string + */ + private function escapeHtml(string $text): string + { + return htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } + + /** + * Определяет, является ли окружение development + * + * @return bool + */ + private function isDevEnvironment(): bool + { + return TelegramService::isDevEnv(); + } + + /** + * Получает токен Telegram-бота в зависимости от окружения + * + * @return string + */ + private function getTelegramBotToken(): string + { + if ($this->isDevEnvironment()) { + return getenv('TELEGRAM_BOT_TOKEN_DEV') ?: self::TELEGRAM_BOT_DEV; + } + return getenv('TELEGRAM_BOT_TOKEN_PROD') ?: self::TELEGRAM_BOT_PROD; + } + + /** + * Получает ID чата Telegram в зависимости от окружения + * + * @return string + */ + private function getTelegramChatId(): string + { + if ($this->isDevEnvironment()) { + return $this->config['telegram_chat_id_dev'] + ?? getenv('TELEGRAM_ORDER_CONTROL_CHAT_ID_DEV') + ?: TelegramService::CHAT_CHANNEL_ID; + } + return $this->config['telegram_chat_id_prod'] + ?? getenv('TELEGRAM_ORDER_CONTROL_CHAT_ID_PROD') + ?: ''; + } + + /** + * Получает список email-получателей + * + * @return array + */ + private function getEmailRecipients(): array + { + $recipients = $this->config['email_recipients'] ?? []; + + if (empty($recipients)) { + $envRecipients = getenv('ORDER_CONTROL_EMAIL_RECIPIENTS'); + if ($envRecipients) { + $recipients = array_filter(explode(',', $envRecipients)); + } + } + + return $recipients; + } + + /** + * Логирование в структурированном JSON-формате + * + * @param string $message + * @param array $context + */ + private function logInfo(string $message, array $context = []): void + { + Yii::info(json_encode([ + 'message' => $message, + 'context' => $context, + 'timestamp' => date('c'), + 'env' => YII_ENV, + ], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_IGNORE), 'marketplace-control'); + } + + /** + * @param string $message + * @param array $context + */ + private function logWarning(string $message, array $context = []): void + { + Yii::warning(json_encode([ + 'message' => $message, + 'context' => $context, + 'timestamp' => date('c'), + 'env' => YII_ENV, + ], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_IGNORE), 'marketplace-control'); + } + + /** + * @param string $message + * @param array $context + */ + private function logError(string $message, array $context = []): void + { + Yii::error(json_encode([ + 'message' => $message, + 'context' => $context, + 'timestamp' => date('c'), + 'env' => YII_ENV, + ], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_IGNORE), 'marketplace-control'); + } +} diff --git a/erp24/services/UncheckedOrdersReportService.php b/erp24/services/UncheckedOrdersReportService.php deleted file mode 100644 index 6adfb53f..00000000 --- a/erp24/services/UncheckedOrdersReportService.php +++ /dev/null @@ -1,849 +0,0 @@ -config = Yii::$app->params['MARKETPLACE_UNCHECKED_ORDERS_REPORT'] ?? []; - } - - /** - * Генерирует и отправляет отчёт о непробитых заказах - * - * @param string|null $targetDate Дата для отчёта (YYYY-MM-DD), по умолчанию - сегодня - * @return ReportResult Результат генерации и отправки - */ - public function generateAndSendReport(?string $targetDate = null): ReportResult - { - $result = new ReportResult(); - $tz = new \DateTimeZone(self::TIMEZONE); - $result->reportDate = (new \DateTime('now', $tz))->format('d.m.Y H:i'); - - try { - // Получение заказов - $orders = $this->getUncheckedOrders($targetDate); - $result->totalOrders = count($orders); - - if ($result->totalOrders === 0) { - $this->logInfo('Нет заказов с непробитыми чеками за период'); - return $result; - } - - // Группировка и форматирование - $grouped = $this->groupOrdersByStore($orders); - $result->storesCount = count($grouped); - $result->totalAmount = $this->calculateTotalAmount($orders); - - $telegramMessage = $this->formatTelegramReport($grouped, $result); - $emailHtml = $this->formatEmailReport($grouped, $result); - - // Отправка с независимым retry - $result->telegramSent = $this->sendToTelegram($telegramMessage); - if (!$result->telegramSent) { - $result->telegramError = 'Не удалось отправить в Telegram после ' . self::MAX_RETRIES . ' попыток'; - } - - $result->emailSent = $this->sendToEmail($emailHtml); - if (!$result->emailSent) { - $result->emailError = 'Не удалось отправить Email после ' . self::MAX_RETRIES . ' попыток'; - } - - $this->logResult($result); - - } catch (\Exception $e) { - $this->logError('Критическая ошибка при формировании отчёта', [ - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); - throw $e; - } - - return $result; - } - - /** - * Получает заказы в статусе DELIVERED без пробитого чека - * за последние REPORT_PERIOD_HOURS часов - * - * @param string|null $targetDate Дата для отчёта - * @return array Массив заказов - */ - public function getUncheckedOrders(?string $targetDate = null): array - { - $periodHours = $this->config['period_hours'] ?? self::REPORT_PERIOD_HOURS; - $tz = new \DateTimeZone(self::TIMEZONE); - - // Определяем границу времени для выборки - if ($targetDate !== null) { - // Если указана дата, берём весь день - $startDate = new \DateTime($targetDate . ' 00:00:00', $tz); - $endDate = new \DateTime($targetDate . ' 23:59:59', $tz); - } else { - // Берём последние N часов от текущего момента - $endDate = new \DateTime('now', $tz); - $startDate = (clone $endDate)->modify("-{$periodHours} hours"); - } - - $startDateStr = $startDate->format('Y-m-d H:i:s'); - $endDateStr = $endDate->format('Y-m-d H:i:s'); - - $this->logInfo('Выборка заказов за период', [ - 'start' => $startDateStr, - 'end' => $endDateStr, - ]); - - // Основной запрос для выборки заказов с непробитыми чеками - // Используем joinWith для связи с таблицей статусов через модель - $query = MarketplaceOrders::find() - ->alias('mo') - ->joinWith(['status' => function($q) { - $q->alias('most_status'); - }], false, 'INNER JOIN') - ->joinWith(['substatus' => function($q) { - $q->alias('most_substatus'); - }], false, 'LEFT JOIN') - ->innerJoin( - ['mosh' => MarketplaceOrderStatusHistory::tableName()], - 'mo.id = mosh.order_id' - ) - ->leftJoin( - ['cc' => CreateChecks::tableName()], - 'mo.marketplace_order_id = cc.marketplace_order_id' - ) - ->where([ - 'or', - ['most_status.code' => MarketplaceOrderStatusTypes::DELIVERED_CODE], - ['most_substatus.code' => MarketplaceOrderStatusTypes::DELIVERY_SERVICE_DELIVERED_CODE], - ]) - ->andWhere(['>=', 'mosh.date_from', $startDateStr]) - ->andWhere(['<=', 'mosh.date_from', $endDateStr]) - ->andWhere(['mosh.active' => 1]) - ->andWhere(['mo.fake' => 0]) // Исключаем тестовые заказы - ->andWhere([ - 'or', - ['mo.check_guid' => null], - ['cc.status' => null], // Нет записи в create_checks - ['cc.status' => CreateChecks::STATUS_CHECK_CREATED_ERP], // Создан в ERP, не отправлен - ['cc.status' => CreateChecks::STATUS_CHECK_ERROR_1C], // Ошибка в 1С - ]) - ->with(['store']) - ->orderBy(['mo.store_id' => SORT_ASC, 'mo.marketplace_name' => SORT_ASC, 'mo.creation_date' => SORT_DESC]) - ->distinct(); - - $orders = $query->all(); - - $this->logInfo('Найдено заказов', ['count' => count($orders)]); - - return $orders; - } - - /** - * Группирует заказы по магазинам - * - * @param MarketplaceOrders[] $orders - * @return array - */ - public function groupOrdersByStore(array $orders): array - { - $grouped = []; - - foreach ($orders as $order) { - $storeId = $order->store_id ?? 0; - - if (!isset($grouped[$storeId])) { - $grouped[$storeId] = [ - 'store' => $order->store, - 'store_name' => $order->store->name ?? 'Магазин не указан', - 'orders' => [], - ]; - } - - $grouped[$storeId]['orders'][] = $order; - } - - // Сортируем по имени магазина - uasort($grouped, function ($a, $b) { - return strcmp($a['store_name'], $b['store_name']); - }); - - return $grouped; - } - - /** - * Вычисляет общую сумму заказов - * - * @param MarketplaceOrders[] $orders - * @return float - */ - private function calculateTotalAmount(array $orders): float - { - $total = 0.0; - foreach ($orders as $order) { - $total += (float)$order->total; - } - return $total; - } - - /** - * Формирует HTML-таблицу для email - * - * @param array $groupedOrders Сгруппированные заказы - * @param ReportResult $result Результат для итогов - * @return string HTML-контент - */ - public function formatEmailReport(array $groupedOrders, ReportResult $result): string - { - $html = ' - - - - - - -

Отчёт о заказах с непробитыми чеками

-

Дата: ' . $this->escapeHtml($result->reportDate) . ' MSK

-

Период: последние ' . self::REPORT_PERIOD_HOURS . ' часов

'; - - foreach ($groupedOrders as $storeData) { - $storeName = $this->escapeHtml($storeData['store_name']); - $html .= ' -

Магазин: ' . $storeName . '

- - - - - - - - '; - - foreach ($storeData['orders'] as $order) { - /** @var MarketplaceOrders $order */ - $checkStatus = $this->getCheckStatusText($order); - $statusClass = $this->getCheckStatusClass($order); - $deliveryDate = $this->formatDeliveryDate($order); - $total = number_format((float)$order->total, 0, '.', ' ') . ' руб.'; - - $html .= ' - - - - - - - '; - } - - $html .= ' -
№ заказаМаркетплейсДата доставкиСуммаСтатус чека
' . $this->escapeHtml($order->marketplace_order_id) . '' . $this->escapeHtml($order->marketplace_name ?? 'Неизвестно') . '' . $this->escapeHtml($deliveryDate) . '' . $total . '' . $this->escapeHtml($checkStatus) . '
'; - } - - $totalAmount = number_format($result->totalAmount, 0, '.', ' '); - $html .= ' -

Итого: ' . $result->totalOrders . ' заказ(ов) в ' . $result->storesCount . ' магазин(ах) на сумму ' . $totalAmount . ' руб.

- -'; - - return $html; - } - - /** - * Формирует текстовый отчёт для Telegram в формате MarkdownV2 - * - * @param array $groupedOrders Сгруппированные заказы - * @param ReportResult $result Результат для итогов - * @return string Текст сообщения (MarkdownV2) - */ - public function formatTelegramReport(array $groupedOrders, ReportResult $result): string - { - $lines = []; - $lines[] = $this->escapeMarkdownV2('🔴') . ' *Заказы с непробитыми чеками*'; - $lines[] = ''; - $lines[] = $this->escapeMarkdownV2('📅 ' . $result->reportDate . ' MSK'); - $lines[] = $this->escapeMarkdownV2('⏰ Период: ' . self::REPORT_PERIOD_HOURS . ' часов'); - $lines[] = ''; - - foreach ($groupedOrders as $storeData) { - $storeName = $storeData['store_name']; - $lines[] = $this->escapeMarkdownV2('📍') . ' *' . $this->escapeMarkdownV2($storeName) . '*'; - - foreach ($storeData['orders'] as $order) { - /** @var MarketplaceOrders $order */ - $checkStatusShort = $this->getCheckStatusShort($order); - $mpName = $this->getMarketplaceShortName($order->marketplace_name); - $total = number_format((float)$order->total, 0, '.', ' '); - - $line = '• ' . $order->marketplace_order_id . ' | ' . $mpName . ' | ' . $total . ' ₽ | ' . $checkStatusShort; - $lines[] = $this->escapeMarkdownV2($line); - } - $lines[] = ''; - } - - $totalAmount = number_format($result->totalAmount, 0, '.', ' '); - $lines[] = $this->escapeMarkdownV2('━━━━━━━━━━━'); - $lines[] = $this->escapeMarkdownV2('📊 Итого: ' . $result->totalOrders . ' заказ(ов) | ' . $result->storesCount . ' магазин(а) | ' . $totalAmount . ' ₽'); - - return implode("\n", $lines); - } - - /** - * Экранирует специальные символы для MarkdownV2 - * Символы: _ * [ ] ( ) ~ ` > # + - = | { } . ! - * - * @param string $text Исходный текст - * @return string Экранированный текст - */ - private function escapeMarkdownV2(string $text): string - { - $specialChars = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!']; - foreach ($specialChars as $char) { - $text = str_replace($char, '\\' . $char, $text); - } - return $text; - } - - /** - * Экранирует HTML-сущности - * - * @param string $text - * @return string - */ - private function escapeHtml(string $text): string - { - return htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); - } - - /** - * Разбивает длинное сообщение на части для Telegram - * - * @param string $message Полное сообщение - * @return array Массив частей сообщения - */ - private function splitTelegramMessage(string $message): array - { - $maxLength = $this->config['telegram_max_message_length'] ?? self::TELEGRAM_MAX_LENGTH; - - if (mb_strlen($message) <= $maxLength) { - return [$message]; - } - - $chunks = []; - $lines = explode("\n", $message); - $currentChunk = ''; - - foreach ($lines as $line) { - if (mb_strlen($currentChunk . "\n" . $line) > $maxLength) { - if ($currentChunk !== '') { - $chunks[] = $currentChunk; - } - $currentChunk = $line; - } else { - $currentChunk .= ($currentChunk !== '' ? "\n" : '') . $line; - } - } - - if ($currentChunk !== '') { - $chunks[] = $currentChunk; - } - - return $chunks; - } - - /** - * Отправляет отчёт в Telegram с retry-логикой - * - * @param string $message Текст сообщения (MarkdownV2) - * @return bool Успешность отправки - */ - public function sendToTelegram(string $message): bool - { - $chatId = $this->getTelegramChatId(); - - if (empty($chatId)) { - $this->logWarning('Telegram chat_id не настроен'); - return false; - } - - // Валидация chat_id (должен начинаться с - для каналов/групп или быть числом) - if (!preg_match('/^-?\d+$/', $chatId)) { - $this->logError('Некорректный формат chat_id: ' . $chatId); - return false; - } - - $chunks = $this->splitTelegramMessage($message); - $allSent = true; - - foreach ($chunks as $index => $chunk) { - $sent = false; - $maxRetries = $this->config['max_retries'] ?? self::MAX_RETRIES; - $retryDelay = $this->config['retry_delay_seconds'] ?? self::RETRY_DELAY_SECONDS; - - for ($attempt = 1; $attempt <= $maxRetries; $attempt++) { - try { - $sent = $this->sendTelegramMessage($chatId, $chunk); - if ($sent) { - break; - } - } catch (\Exception $e) { - $this->logWarning("Telegram попытка {$attempt}/{$maxRetries}: {$e->getMessage()}"); - } - - if ($attempt < $maxRetries) { - sleep($retryDelay); - } - } - - if (!$sent) { - $allSent = false; - $this->logError("Не удалось отправить часть " . ($index + 1) . " в Telegram после {$maxRetries} попыток"); - } - } - - return $allSent; - } - - /** - * Отправляет сообщение в Telegram - * - * @param string $chatId ID чата/канала - * @param string $message Текст сообщения - * @return bool Успешность - */ - private function sendTelegramMessage(string $chatId, string $message): bool - { - $botToken = $this->getTelegramBotToken(); - $url = "https://api.telegram.org/bot{$botToken}/sendMessage"; - - $ch = curl_init(); - curl_setopt_array($ch, [ - CURLOPT_URL => $url, - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => [ - 'chat_id' => $chatId, - 'text' => $message, - 'parse_mode' => 'MarkdownV2', - 'disable_web_page_preview' => true, - ], - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 30, - CURLOPT_SSL_VERIFYPEER => true, - ]); - - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - $curlError = curl_error($ch); - curl_close($ch); - - if ($curlError) { - $this->logError("Telegram cURL error: {$curlError}"); - return false; - } - - if ($httpCode !== 200) { - $this->logError("Telegram API error: HTTP {$httpCode}, response: {$response}"); - return false; - } - - $this->logInfo('Сообщение отправлено в Telegram', ['chat_id' => $chatId]); - return true; - } - - /** - * Отправляет отчёт на email с retry-логикой - * - * @param string $html HTML-контент письма - * @return bool Успешность отправки - */ - public function sendToEmail(string $html): bool - { - $recipients = $this->getEmailRecipients(); - - if (empty($recipients)) { - $this->logWarning('Email-получатели не настроены'); - return false; - } - - // Валидация email-адресов - $validRecipients = []; - foreach ($recipients as $email) { - $email = trim($email); - if (filter_var($email, FILTER_VALIDATE_EMAIL) && - preg_match('/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/', $email)) { - $validRecipients[] = $email; - } else { - $this->logWarning("Некорректный email пропущен: {$email}"); - } - } - - if (empty($validRecipients)) { - $this->logError('Нет валидных email-адресов'); - return false; - } - - $sent = false; - $maxRetries = $this->config['max_retries'] ?? self::MAX_RETRIES; - $retryDelay = $this->config['retry_delay_seconds'] ?? self::RETRY_DELAY_SECONDS; - $subject = $this->config['email_subject'] ?? 'Отчёт о заказах с непробитыми чеками'; - - for ($attempt = 1; $attempt <= $maxRetries; $attempt++) { - try { - $sent = Yii::$app->mailer->compose() - ->setTo($validRecipients) - ->setSubject($subject) - ->setHtmlBody($html) - ->send(); - - if ($sent) { - $this->logInfo('Email отправлен на: ' . implode(', ', $validRecipients)); - break; - } - } catch (\Exception $e) { - $this->logWarning("Email попытка {$attempt}/{$maxRetries}: {$e->getMessage()}"); - } - - if ($attempt < $maxRetries) { - sleep($retryDelay); - } - } - - if (!$sent) { - $this->logError('Не удалось отправить email после ' . $maxRetries . ' попыток'); - } - - return $sent; - } - - /** - * Определяет, является ли окружение development - * - * @return bool - */ - private function isDevEnvironment(): bool - { - return TelegramService::isDevEnv(); - } - - /** - * Получает токен Telegram-бота в зависимости от окружения - * - * @return string - */ - private function getTelegramBotToken(): string - { - if ($this->isDevEnvironment()) { - return getenv('TELEGRAM_BOT_TOKEN_DEV') ?: self::TELEGRAM_BOT_DEV; - } - return getenv('TELEGRAM_BOT_TOKEN_PROD') ?: self::TELEGRAM_BOT_PROD; - } - - /** - * Получает ID чата Telegram в зависимости от окружения - * - * @return string - */ - private function getTelegramChatId(): string - { - if ($this->isDevEnvironment()) { - return $this->config['telegram_chat_id_dev'] - ?? getenv('TELEGRAM_UNCHECKED_ORDERS_CHAT_ID_DEV') - ?: TelegramService::CHAT_CHANNEL_ID; - } - return $this->config['telegram_chat_id_prod'] - ?? getenv('TELEGRAM_UNCHECKED_ORDERS_CHAT_ID_PROD') - ?: ''; - } - - /** - * Получает список email-получателей - * - * @return array - */ - private function getEmailRecipients(): array - { - $recipients = $this->config['email_recipients'] ?? []; - - if (empty($recipients)) { - $envRecipients = getenv('UNCHECKED_ORDERS_EMAIL_RECIPIENTS'); - if ($envRecipients) { - $recipients = array_filter(explode(',', $envRecipients)); - } - } - - return $recipients; - } - - /** - * Получает текстовое описание статуса чека - * - * @param MarketplaceOrders $order - * @return string - */ - private function getCheckStatusText(MarketplaceOrders $order): string - { - if ($order->check_guid === null) { - // Проверяем наличие записи в create_checks - $check = CreateChecks::findOne(['marketplace_order_id' => $order->marketplace_order_id]); - - if ($check === null) { - return 'Не создан'; - } - - switch ($check->status) { - case CreateChecks::STATUS_CHECK_CREATED_ERP: - return 'Ожидает отправки в 1С'; - case CreateChecks::STATUS_CHECK_ERROR_1C: - $errorText = $check->error_text ? ': ' . $check->error_text : ''; - return 'Ошибка 1С' . $errorText; - default: - return 'Статус неизвестен'; - } - } - - return 'Чек создан'; - } - - /** - * Получает короткое описание статуса чека для Telegram - * - * @param MarketplaceOrders $order - * @return string - */ - private function getCheckStatusShort(MarketplaceOrders $order): string - { - if ($order->check_guid === null) { - $check = CreateChecks::findOne(['marketplace_order_id' => $order->marketplace_order_id]); - - if ($check === null) { - return 'Не создан'; - } - - switch ($check->status) { - case CreateChecks::STATUS_CHECK_CREATED_ERP: - return 'Ожидает'; - case CreateChecks::STATUS_CHECK_ERROR_1C: - return '❌ Ошибка 1С'; - default: - return '?'; - } - } - - return '✅'; - } - - /** - * Получает CSS-класс для статуса чека - * - * @param MarketplaceOrders $order - * @return string - */ - private function getCheckStatusClass(MarketplaceOrders $order): string - { - if ($order->check_guid !== null) { - return ''; - } - - $check = CreateChecks::findOne(['marketplace_order_id' => $order->marketplace_order_id]); - - if ($check !== null && $check->status === CreateChecks::STATUS_CHECK_ERROR_1C) { - return 'error'; - } - - if ($check !== null && $check->status === CreateChecks::STATUS_CHECK_CREATED_ERP) { - return 'warning'; - } - - return ''; - } - - /** - * Форматирует дату доставки - * - * @param MarketplaceOrders $order - * @return string - */ - private function formatDeliveryDate(MarketplaceOrders $order): string - { - // Ищем дату из истории статусов - $history = MarketplaceOrderStatusHistory::find() - ->where(['order_id' => $order->id]) - ->andWhere(['active' => 1]) - ->orderBy(['date_from' => SORT_DESC]) - ->one(); - - if ($history && $history->date_from) { - try { - $date = new \DateTime($history->date_from); - return $date->format('d.m.Y H:i'); - } catch (\Exception $e) { - // Игнорируем ошибку парсинга даты - } - } - - // Используем дату обновления заказа - if ($order->updated_at) { - try { - $date = new \DateTime($order->updated_at); - return $date->format('d.m.Y H:i'); - } catch (\Exception $e) { - // Игнорируем ошибку парсинга даты - } - } - - return '-'; - } - - /** - * Получает короткое название маркетплейса - * - * @param string|null $name - * @return string - */ - private function getMarketplaceShortName(?string $name): string - { - if ($name === null) { - return '?'; - } - - $name = mb_strtolower($name); - - if (str_contains($name, 'flowwow') || str_contains($name, 'флаувау')) { - return 'Flowwow'; - } - - if (str_contains($name, 'yandex') || str_contains($name, 'яндекс')) { - return 'Yandex'; - } - - return $name; - } - - /** - * Логирование в структурированном JSON-формате - * - * @param string $message - * @param array $context - */ - private function logInfo(string $message, array $context = []): void - { - Yii::info(json_encode([ - 'message' => $message, - 'context' => $context, - 'timestamp' => date('c'), - 'env' => YII_ENV, - ], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_IGNORE), 'marketplace-checks'); - } - - /** - * @param string $message - * @param array $context - */ - private function logWarning(string $message, array $context = []): void - { - Yii::warning(json_encode([ - 'message' => $message, - 'context' => $context, - 'timestamp' => date('c'), - 'env' => YII_ENV, - ], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_IGNORE), 'marketplace-checks'); - } - - /** - * @param string $message - * @param array $context - */ - private function logError(string $message, array $context = []): void - { - Yii::error(json_encode([ - 'message' => $message, - 'context' => $context, - 'timestamp' => date('c'), - 'env' => YII_ENV, - ], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_IGNORE), 'marketplace-checks'); - } - - /** - * Логирует результат выполнения отчёта - * - * @param ReportResult $result - */ - private function logResult(ReportResult $result): void - { - $this->logInfo('Отчёт завершён', $result->toArray()); - } -} diff --git a/erp24/services/dto/ControlReportResult.php b/erp24/services/dto/ControlReportResult.php new file mode 100644 index 00000000..a09f1262 --- /dev/null +++ b/erp24/services/dto/ControlReportResult.php @@ -0,0 +1,256 @@ +reportDate = $now->format('d.m.Y H:i'); + $this->interval = (int)$now->format('H') < 12 ? '08:00' : '20:00'; + } + + /** + * Получает количество заказов по типу "Завис в доставке" + * + * @return int + */ + public function getHungInDeliveryCount(): int + { + return count($this->hungInDelivery); + } + + /** + * Получает количество заказов по типу "Успех без чека" + * + * @return int + */ + public function getSuccessNoCheckCount(): int + { + return count($this->successNoCheck); + } + + /** + * Получает количество заказов по типу "Отмена без обработки" + * + * @return int + */ + public function getCancelNoProcessCount(): int + { + return count($this->cancelNoProcess); + } + + /** + * Рассчитывает и обновляет общее количество проблем + * + * @return int + */ + public function calculateTotal(): int + { + $this->totalIssues = $this->getHungInDeliveryCount() + + $this->getSuccessNoCheckCount() + + $this->getCancelNoProcessCount(); + + return $this->totalIssues; + } + + /** + * Проверяет наличие проблемных заказов + * + * @return bool + */ + public function hasIssues(): bool + { + return $this->totalIssues > 0 + || !empty($this->hungInDelivery) + || !empty($this->successNoCheck) + || !empty($this->cancelNoProcess); + } + + /** + * Получает все проблемные заказы одним массивом + * + * @return OrderIssue[] + */ + public function getAllIssues(): array + { + return array_merge( + $this->hungInDelivery, + $this->successNoCheck, + $this->cancelNoProcess + ); + } + + /** + * Группирует заказы по типу проблемы + * + * @return array + */ + public function groupByProblemType(): array + { + return [ + OrderIssue::TYPE_HUNG_IN_DELIVERY => $this->hungInDelivery, + OrderIssue::TYPE_SUCCESS_NO_CHECK => $this->successNoCheck, + OrderIssue::TYPE_CANCEL_NO_PROCESS => $this->cancelNoProcess, + ]; + } + + /** + * Проверяет полный успех отправки + * + * @return bool + */ + public function isSuccess(): bool + { + return $this->telegramSent && $this->emailSent; + } + + /** + * Проверяет частичный успех отправки + * + * @return bool + */ + public function isPartialSuccess(): bool + { + return ($this->telegramSent || $this->emailSent) && !$this->isSuccess(); + } + + /** + * Возвращает exit code для консольной команды + * + * @return int 0 = успех, 1 = критическая ошибка, 2 = частичный успех + */ + public function getExitCode(): int + { + if (!$this->hasIssues()) { + return 0; + } + + if ($this->isSuccess()) { + return 0; + } + + if ($this->isPartialSuccess()) { + return 2; + } + + return 1; + } + + /** + * Преобразует результат в массив для логирования + * + * @return array + */ + public function toArray(): array + { + return [ + 'report_date' => $this->reportDate, + 'interval' => $this->interval, + 'total_issues' => $this->totalIssues, + 'hung_in_delivery_count' => $this->getHungInDeliveryCount(), + 'success_no_check_count' => $this->getSuccessNoCheckCount(), + 'cancel_no_process_count' => $this->getCancelNoProcessCount(), + 'telegram_sent' => $this->telegramSent, + 'email_sent' => $this->emailSent, + 'telegram_error' => $this->telegramError, + 'email_error' => $this->emailError, + 'states_saved' => $this->statesSaved, + 'exit_code' => $this->getExitCode(), + ]; + } + + /** + * Получает сводку для логирования + * + * @return string + */ + public function getSummary(): string + { + return sprintf( + 'Контроль МП: %d проблем (завис: %d, успех без чека: %d, отмена: %d). TG: %s, Email: %s', + $this->totalIssues, + $this->getHungInDeliveryCount(), + $this->getSuccessNoCheckCount(), + $this->getCancelNoProcessCount(), + $this->telegramSent ? 'OK' : 'FAIL', + $this->emailSent ? 'OK' : 'FAIL' + ); + } +} diff --git a/erp24/services/dto/OrderIssue.php b/erp24/services/dto/OrderIssue.php new file mode 100644 index 00000000..c4b57e40 --- /dev/null +++ b/erp24/services/dto/OrderIssue.php @@ -0,0 +1,247 @@ + 'Завис в доставке', + self::TYPE_SUCCESS_NO_CHECK => 'Успех без чека', + self::TYPE_CANCEL_NO_PROCESS => 'Отмена без обработки', + ]; + + /** + * Тип проблемы (hung_in_delivery, success_no_check, cancel_no_process) + */ + public string $problemType; + + /** + * Человекочитаемая метка типа проблемы + */ + public string $problemTypeLabel; + + /** + * Дата отчёта (формат d.m.Y) + */ + public string $reportDate; + + /** + * Интервал проверки (08:00 или 20:00) + */ + public string $interval; + + /** + * Номер заказа в маркетплейсе + */ + public string $orderNumber; + + /** + * Статус в РМК/1С (человекочитаемый) + */ + public ?string $rmkStatus; + + /** + * Код статуса РМК/1С + */ + public ?string $rmkStatusId; + + /** + * Статус в МП (человекочитаемый) + */ + public ?string $mpStatus; + + /** + * Код статуса МП + */ + public ?string $mpStatusCode; + + /** + * Код субстатуса МП + */ + public ?string $mpSubstatusCode; + + /** + * ID заказа в БД + */ + public int $orderId; + + /** + * ID магазина + */ + public ?int $storeId; + + /** + * Название магазина + */ + public ?string $storeName; + + /** + * Название маркетплейса + */ + public ?string $marketplaceName; + + /** + * ID маркетплейса (1 - Flowwow, 2 - YandexMarket) + */ + public ?int $marketplaceId; + + /** + * Сумма заказа + */ + public float $total; + + /** + * Дата создания заказа + */ + public ?string $creationDate; + + /** + * Конструктор с параметрами + * + * @param string $problemType Тип проблемы + * @param int $orderId ID заказа + * @param string $orderNumber Номер заказа + */ + public function __construct( + string $problemType, + int $orderId, + string $orderNumber + ) { + $this->problemType = $problemType; + $this->problemTypeLabel = self::TYPE_LABELS[$problemType] ?? $problemType; + $this->orderId = $orderId; + $this->orderNumber = $orderNumber; + $this->reportDate = date('d.m.Y'); + $this->interval = $this->calculateInterval(); + $this->total = 0.0; + } + + /** + * Определяет интервал проверки (08:00 или 20:00) + * + * @return string + */ + private function calculateInterval(): string + { + $hour = (int)date('H'); + return $hour < 12 ? '08:00' : '20:00'; + } + + /** + * Создаёт экземпляр из данных заказа + * + * @param string $problemType Тип проблемы + * @param array $orderData Данные заказа из БД + * @return self + */ + public static function fromOrderData(string $problemType, array $orderData): self + { + $issue = new self( + $problemType, + (int)($orderData['id'] ?? 0), + $orderData['marketplace_order_id'] ?? '' + ); + + $issue->rmkStatus = $orderData['rmk_status'] ?? null; + $issue->rmkStatusId = $orderData['rmk_status_id'] ?? null; + $issue->mpStatus = $orderData['mp_status_name'] ?? null; + $issue->mpStatusCode = $orderData['mp_status_code'] ?? null; + $issue->mpSubstatusCode = $orderData['mp_substatus_code'] ?? null; + $issue->storeId = isset($orderData['store_id']) ? (int)$orderData['store_id'] : null; + $issue->storeName = $orderData['store_name'] ?? null; + $issue->marketplaceName = $orderData['marketplace_name'] ?? null; + $issue->marketplaceId = isset($orderData['marketplace_id']) ? (int)$orderData['marketplace_id'] : null; + $issue->total = (float)($orderData['total'] ?? 0); + $issue->creationDate = $orderData['creation_date'] ?? null; + + return $issue; + } + + /** + * Преобразует в массив для сохранения/логирования + * + * @return array + */ + public function toArray(): array + { + return [ + 'problem_type' => $this->problemType, + 'problem_type_label' => $this->problemTypeLabel, + 'report_date' => $this->reportDate, + 'interval' => $this->interval, + 'order_id' => $this->orderId, + 'order_number' => $this->orderNumber, + 'rmk_status' => $this->rmkStatus, + 'rmk_status_id' => $this->rmkStatusId, + 'mp_status' => $this->mpStatus, + 'mp_status_code' => $this->mpStatusCode, + 'mp_substatus_code' => $this->mpSubstatusCode, + 'store_id' => $this->storeId, + 'store_name' => $this->storeName, + 'marketplace_name' => $this->marketplaceName, + 'marketplace_id' => $this->marketplaceId, + 'total' => $this->total, + 'creation_date' => $this->creationDate, + ]; + } + + /** + * Получает короткое название маркетплейса + * + * @return string + */ + public function getMarketplaceShortName(): string + { + if ($this->marketplaceName === null) { + return '?'; + } + + $name = mb_strtolower($this->marketplaceName); + + if (str_contains($name, 'flowwow') || str_contains($name, 'флаувау')) { + return 'FW'; + } + + if (str_contains($name, 'yandex') || str_contains($name, 'яндекс')) { + return 'YM'; + } + + return mb_substr($this->marketplaceName, 0, 2); + } + + /** + * Получает отформатированную сумму + * + * @return string + */ + public function getFormattedTotal(): string + { + return number_format($this->total, 0, '.', ' ') . ' ₽'; + } +} diff --git a/erp24/services/dto/ReportResult.php b/erp24/services/dto/ReportResult.php deleted file mode 100644 index 88763637..00000000 --- a/erp24/services/dto/ReportResult.php +++ /dev/null @@ -1,130 +0,0 @@ -telegramSent && $this->emailSent; - } - - /** - * Проверяет частичный успех (хотя бы один канал отправлен) - * - * @return bool true если отправлен только один из каналов - */ - public function isPartialSuccess(): bool - { - return ($this->telegramSent || $this->emailSent) && !$this->isSuccess(); - } - - /** - * Проверяет наличие заказов - * - * @return bool true если есть непробитые заказы - */ - public function hasOrders(): bool - { - return $this->totalOrders > 0; - } - - /** - * Возвращает exit code для консольной команды - * - * @return int 0 = успех, 1 = критическая ошибка, 2 = частичный успех - */ - public function getExitCode(): int - { - // Нет заказов - это успех - if (!$this->hasOrders()) { - return 0; - } - - // Оба канала отправлены - if ($this->isSuccess()) { - return 0; - } - - // Хотя бы один канал отправлен - if ($this->isPartialSuccess()) { - return 2; - } - - // Ничего не отправлено - return 1; - } - - /** - * Преобразует результат в массив для логирования - * - * @return array - */ - public function toArray(): array - { - return [ - 'total_orders' => $this->totalOrders, - 'stores_count' => $this->storesCount, - 'total_amount' => $this->totalAmount, - 'telegram_sent' => $this->telegramSent, - 'email_sent' => $this->emailSent, - 'telegram_error' => $this->telegramError, - 'email_error' => $this->emailError, - 'report_date' => $this->reportDate, - 'exit_code' => $this->getExitCode(), - ]; - } -}