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