return ExitCode::OK;
}
+ /**
+ * Повторная обработка писем Flowwow, которые не были успешно обработаны.
+ * Выбирает из БД письма со статусом NEW или RETRY и запускает processMessage для каждого.
+ *
+ * Использование: php yii marketplace/retry-flowwow-emails
+ */
+ public function actionRetryFlowwowEmails(): int
+ {
+ $progressCallback = function (string $message) {
+ $this->stdout($message . "\n", BaseConsole::FG_YELLOW);
+ };
+
+ $result = MarketplaceService::processUnprocessedEmails($progressCallback);
+
+ $this->stdout(
+ "Итог: обработано {$result['processed']} из {$result['total']}, ошибок: {$result['failed']}.\n",
+ BaseConsole::FG_GREEN
+ );
+
+ return ExitCode::OK;
+ }
+
public function actionGetYandexOrders()
{
$fromDate = date('d-m-Y', strtotime('-1 day'));
{
$searchModel = new MarketplaceFlowwowEmailsSearch();
$dataProvider = $searchModel->search($this->request->queryParams);
+ $dataProvider->query->with(['order']);
return $this->render('index', [
'searchModel' => $searchModel,
$oldMail = 0;
$seen = 0;
- $countMessages = 0;
- $count = 0;
-
$messages = MarketplaceService::getFlowwowOrdersFromMail($date, $since, $oldMail, null, $seen);
- $countMessages = count($messages);
- $count = MarketplaceService::processMessages($messages);
+ if (!is_array($messages)) {
+ return ['success' => false, 'processed' => 0, 'all' => 0];
+ }
- return ['success' => true, 'count' => $count];
+ return ['success' => true, 'processed' => $messages['processed'], 'all' => $messages['all']];
}
--- /dev/null
+<?php
+
+use yii\db\Migration;
+
+/**
+ * Улучшение таблицы marketplace_flowwow_emails:
+ * - Добавляет колонки для отслеживания состояния обработки
+ * - Помечает все существующие записи как обработанные
+ */
+class m260218_000001_improve_marketplace_flowwow_emails extends Migration
+{
+ public function safeUp()
+ {
+ $this->addColumn(
+ 'marketplace_flowwow_emails',
+ 'subject_type',
+ $this->smallInteger()->null()
+ ->comment('Тип письма (1=NEW, 2=APPROVED, 3=CHANGED, 4=CANCELLED, 5=DELIVERED)')
+ );
+
+ $this->addColumn(
+ 'marketplace_flowwow_emails',
+ 'processing_attempts',
+ $this->integer()->notNull()->defaultValue(0)
+ ->comment('Счётчик попыток обработки')
+ );
+
+ $this->addColumn(
+ 'marketplace_flowwow_emails',
+ 'processed_at',
+ $this->timestamp()->null()
+ ->comment('Время успешной обработки')
+ );
+
+ $this->addColumn(
+ 'marketplace_flowwow_emails',
+ 'error_message',
+ $this->text()->null()
+ ->comment('Текст последней ошибки обработки')
+ );
+
+ $this->addColumn(
+ 'marketplace_flowwow_emails',
+ 'marketplace_order_id',
+ $this->string(50)->null()
+ ->comment('ID заказа Flowwow (связка с marketplace_orders.marketplace_order_id)')
+ );
+
+ // Все существующие записи помечаем как обработанные, чтобы новый механизм retry их не трогал.
+ // processed_at берётся из created_at — так дата обработки соответствует фактическому времени письма.
+ $this->execute(
+ "UPDATE marketplace_flowwow_emails SET email_status = 1, processed_at = COALESCE(created_at, NOW()) WHERE email_status IS NULL OR email_status = 0"
+ );
+ }
+
+ public function safeDown()
+ {
+ $this->dropColumn('marketplace_flowwow_emails', 'marketplace_order_id');
+ $this->dropColumn('marketplace_flowwow_emails', 'error_message');
+ $this->dropColumn('marketplace_flowwow_emails', 'processed_at');
+ $this->dropColumn('marketplace_flowwow_emails', 'processing_attempts');
+ $this->dropColumn('marketplace_flowwow_emails', 'subject_type');
+ }
+}
*
* @property int $id ID
* @property string $subject Тема письма
- * @property int|null $email_status СÑ\82аÑ\82Ñ\83Ñ\81 пиÑ\81Ñ\8cма - Ñ\80азобÑ\80ано - 1, не Ñ\80азобÑ\80ано - 2
+ * @property int|null $email_status СÑ\82аÑ\82Ñ\83Ñ\81 обÑ\80абоÑ\82ки
* @property string $from Отправитель письма
* @property string $to Получатель письма
* @property string $date Дата письма
* @property string $body Тело письма
* @property string|null $created_at Дата создания записи
+ * @property int|null $subject_type Тип письма (1=NEW, 2=APPROVED, 3=CHANGED, 4=CANCELLED, 5=DELIVERED)
+ * @property int $processing_attempts Счётчик попыток обработки
+ * @property string|null $processed_at Время успешной обработки
+ * @property string|null $error_message Текст последней ошибки
+ * @property string|null $marketplace_order_id ID заказа Flowwow
+ *
+ * @property-read \yii_app\records\MarketplaceOrders|null $order Связанный заказ
*/
class MarketplaceFlowwowEmails extends \yii\db\ActiveRecord
{
+ public const STATUS_NEW = 0; // Зарегистрировано, ожидает обработки
+ public const STATUS_PROCESSED = 1; // Успешно обработано
+ public const STATUS_ERROR = 2; // Ошибка (исчерпаны попытки)
+ public const STATUS_RETRY = 3; // Ожидает повторной обработки
+ public const MAX_PROCESSING_ATTEMPTS = 5;
/**
* {@inheritdoc}
public function rules()
{
return [
- [['email_status'], 'default', 'value' => 0],
+ [['email_status'], 'default', 'value' => self::STATUS_NEW],
[['subject', 'from', 'to', 'date', 'body'], 'required'],
- [['date', 'created_at'], 'safe'],
- [['body'], 'string'],
- [['email_status'], 'integer'],
- [['subject', 'from', 'to'], 'string', 'max' => 255],
+ [['date', 'created_at', 'processed_at'], 'safe'],
+ [['body', 'error_message'], 'string'],
+ [['email_status', 'subject_type', 'processing_attempts'], 'integer'],
+ [['subject', 'from', 'to'], 'string', 'max' => 255],
+ [['marketplace_order_id'], 'string', 'max' => 50],
];
}
return [
'id' => 'ID',
'subject' => 'Тема письма',
- 'email_status' => 'Статус письма',
- 'from' => 'Отправитель письма',
- 'to' => 'Получатель письма',
+ 'email_status' => 'Статус',
+ 'from' => 'Отправитель',
+ 'to' => 'Получатель',
'date' => 'Дата письма',
'body' => 'Тело письма',
- 'created_at' => 'Дата создания записи',
+ 'created_at' => 'Дата создания',
+ 'subject_type' => 'Тип письма',
+ 'processing_attempts' => 'Попыток',
+ 'processed_at' => 'Обработано в',
+ 'error_message' => 'Текст ошибки',
+ 'marketplace_order_id' => 'ID заказа',
+ ];
+ }
+
+ /**
+ * Связь с заказом маркетплейса по marketplace_order_id.
+ * Поле marketplace_order_id в emails хранит ID заказа из маркетплейса (например "123456"),
+ * которому соответствует marketplace_orders.marketplace_order_id.
+ */
+ public function getOrder(): \yii\db\ActiveQuery
+ {
+ return $this->hasOne(MarketplaceOrders::class, ['marketplace_order_id' => 'marketplace_order_id'])
+ ->andWhere(['marketplace_id' => \yii_app\records\MarketplaceStore::FLOWWOW_WAREHOUSE_ID]);
+ }
+
+ /**
+ * Помечает письмо как успешно обработанное.
+ * Устанавливает STATUS_PROCESSED и фиксирует время обработки.
+ */
+ public function markAsProcessed(): bool
+ {
+ $this->email_status = self::STATUS_PROCESSED;
+ $this->processed_at = date('Y-m-d H:i:s');
+ return $this->save(false, ['email_status', 'processed_at']);
+ }
+
+ /**
+ * Помечает письмо как завершённое с ошибкой (исчерпаны попытки).
+ */
+ public function markAsError(string $errorMessage): bool
+ {
+ $this->email_status = self::STATUS_ERROR;
+ $this->error_message = $errorMessage;
+ $this->processing_attempts++;
+ return $this->save(false, ['email_status', 'error_message', 'processing_attempts']);
+ }
+
+ /**
+ * Помечает письмо для повторной обработки.
+ */
+ public function markForRetry(string $reason): bool
+ {
+ $this->email_status = self::STATUS_RETRY;
+ $this->error_message = $reason;
+ $this->processing_attempts++;
+ return $this->save(false, ['email_status', 'error_message', 'processing_attempts']);
+ }
+
+ /**
+ * Проверяет, разрешена ли ещё повторная обработка.
+ */
+ public function isRetryAllowed(): bool
+ {
+ return $this->processing_attempts < self::MAX_PROCESSING_ATTEMPTS;
+ }
+
+ /**
+ * Возвращает ActiveQuery для писем, ожидающих обработки (STATUS_NEW или STATUS_RETRY).
+ */
+ public static function findUnprocessed(): \yii\db\ActiveQuery
+ {
+ return static::find()
+ ->where(['in', 'email_status', [self::STATUS_NEW, self::STATUS_RETRY]])
+ ->andWhere(['<', 'processing_attempts', self::MAX_PROCESSING_ATTEMPTS]);
+ }
+
+ /**
+ * Текстовые метки статусов обработки.
+ */
+ public static function statusLabels(): array
+ {
+ return [
+ self::STATUS_NEW => 'Необработано',
+ self::STATUS_PROCESSED => 'Обработано',
+ self::STATUS_ERROR => 'Ошибка',
+ self::STATUS_RETRY => 'Повтор',
];
}
+ /**
+ * Текстовые метки типов писем.
+ */
+ public static function subjectTypeLabels(): array
+ {
+ return [
+ 1 => 'Новый заказ',
+ 2 => 'Принят',
+ 3 => 'Изменён',
+ 4 => 'Отменён',
+ 5 => 'Доставлен',
+ ];
+ }
}
public function rules()
{
return [
- [['id', 'email_status'], 'integer'],
- [['subject', 'from', 'to', 'date', 'body', 'created_at', 'email_status'], 'safe'],
+ [['id', 'email_status', 'subject_type', 'processing_attempts'], 'integer'],
+ [['subject', 'from', 'to', 'date', 'body', 'created_at', 'email_status',
+ 'processed_at', 'error_message', 'marketplace_order_id'], 'safe'],
];
}
{
$query = MarketplaceFlowwowEmails::find();
- // add conditions that should always apply here
-
$dataProvider = new ActiveDataProvider([
'query' => $query,
]);
$this->load($params, $formName);
if (!$this->validate()) {
- // uncomment the following line if you do not want to return any records when validation fails
- // $query->where('0=1');
return $dataProvider;
}
'id' => $this->id,
'date' => $this->date,
'created_at' => $this->created_at,
+ 'subject_type' => $this->subject_type,
+ 'processing_attempts' => $this->processing_attempts,
]);
$query->andFilterWhere(['ilike', 'subject', $this->subject])
->andFilterWhere(['email_status' => $this->email_status])
->andFilterWhere(['ilike', 'from', $this->from])
->andFilterWhere(['ilike', 'to', $this->to])
- ->andFilterWhere(['ilike', 'body', $this->body]);
+ ->andFilterWhere(['ilike', 'body', $this->body])
+ ->andFilterWhere(['ilike', 'error_message', $this->error_message])
+ ->andFilterWhere(['ilike', 'marketplace_order_id', $this->marketplace_order_id]);
return $dataProvider;
}
}
$savedEmail = self::saveEmailIfNotExists($subject, null, $from, $to, $date, $htmlMessage);
+ // Если письмо уже существует — подгружаем из БД
+ if ($savedEmail === null) {
+ $emailRecord = MarketplaceFlowwowEmails::find()
+ ->where(['subject' => $subject, 'from' => $from, 'date' => $date])
+ ->one();
+ } else {
+ $emailRecord = $savedEmail;
+ }
+
+ // Уже обработанное письмо — только ставим SEEN и пропускаем
+ if ($emailRecord !== null && $emailRecord->email_status === MarketplaceFlowwowEmails::STATUS_PROCESSED) {
+ imap_setflag_full($inbox, $email_number, "\\Seen");
+ continue;
+ }
+
foreach ($subjectPatterns as $pattern) {
if (preg_match($pattern, $subject)) {
$subjectIndex = self::SUBJECT_INDEX[$pattern];
- if ($savedEmail !== null) {
- $savedEmail->email_status = 1;
- $savedEmail->save();
- }
$message = [
'subject' => $subject,
'subject_index' => $subjectIndex,
'body' => quoted_printable_decode($htmlMessage),
];
- $output = MarketplaceService::processMessage($message);
+ try {
+ $output = MarketplaceService::processMessage($message);
+
+ if ($emailRecord !== null) {
+ $orderData = self::getOrdersDataFromMessage($message);
+ if (!empty($orderData)) {
+ $emailRecord->marketplace_order_id = (string)key($orderData);
+ }
+ $emailRecord->markAsProcessed();
+ }
- if ($output > 0) {
self::imap_debug_log("Установка флага SEEN для сообшения #" . $email_number, $debugMode, $progressCallback);
$result = imap_setflag_full($inbox, $email_number, "\\Seen");
if (!$result) {
} else {
self::imap_debug_log("WARNING: Сообщение #" . $email_number . " не удалось пометить как SEEN", $debugMode, $progressCallback);
}
+
+ $countProcessedMessages += $output;
+ } catch (\Throwable $e) {
+ Yii::error('Ошибка при обработке письма: ' . $e->getMessage(), __METHOD__);
+ if ($emailRecord !== null) {
+ if ($emailRecord->isRetryAllowed()) {
+ $emailRecord->markForRetry($e->getMessage());
+ } else {
+ $emailRecord->markAsError($e->getMessage());
+ }
+ }
}
- $countProcessedMessages += $output;
if ($progressCallback) {
call_user_func($progressCallback, "От: " . $from . " тема " . $subject . " от " . $date);
return ['processed' => $countProcessedMessages, 'all' => $countAllMessages];
}
+ /**
+ * Определяет тип письма по теме через SUBJECT_INDEX regex patterns.
+ *
+ * @param string $subject Тема письма (уже декодированная)
+ * @return int|null Тип письма (1=NEW, 2=APPROVED, 3=CHANGED, 4=CANCELLED, 5=DELIVERED) или null
+ */
+ private static function detectSubjectType(string $subject): ?int
+ {
+ foreach (self::SUBJECT_INDEX as $pattern => $index) {
+ if (preg_match($pattern, $subject)) {
+ return $index;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Обрабатывает все необработанные письма из БД (статус NEW или RETRY с заполненным subject_type).
+ * Используется командой marketplace/retry-flowwow-emails для повторной обработки.
+ *
+ * @param callable|null $progressCallback
+ * @return array ['processed' => int, 'failed' => int, 'total' => int]
+ */
+ public static function processUnprocessedEmails(?callable $progressCallback = null): array
+ {
+ $emails = MarketplaceFlowwowEmails::findUnprocessed()
+ ->andWhere(['not', ['subject_type' => null]])
+ ->all();
+
+ $total = count($emails);
+ $processed = 0;
+ $failed = 0;
+
+ if ($progressCallback) {
+ call_user_func($progressCallback, "Найдено необработанных писем: {$total}");
+ }
+
+ foreach ($emails as $emailRecord) {
+ $message = [
+ 'subject' => $emailRecord->subject,
+ 'subject_index' => $emailRecord->subject_type,
+ 'from' => $emailRecord->from,
+ 'to' => $emailRecord->to,
+ 'date' => $emailRecord->date,
+ 'body' => $emailRecord->body,
+ ];
+
+ try {
+ self::processMessage($message);
+
+ $orderData = self::getOrdersDataFromMessage($message);
+ if (!empty($orderData)) {
+ $emailRecord->marketplace_order_id = (string)key($orderData);
+ }
+ $emailRecord->markAsProcessed();
+ $processed++;
+
+ if ($progressCallback) {
+ call_user_func($progressCallback, "Обработано письмо #{$emailRecord->id}: {$emailRecord->subject}");
+ }
+ } catch (\Throwable $e) {
+ $failed++;
+ Yii::error("Ошибка при повторной обработке письма #{$emailRecord->id}: " . $e->getMessage(), __METHOD__);
+
+ if ($emailRecord->isRetryAllowed()) {
+ $emailRecord->markForRetry($e->getMessage());
+ } else {
+ $emailRecord->markAsError($e->getMessage());
+ }
+
+ if ($progressCallback) {
+ call_user_func($progressCallback, "Ошибка письма #{$emailRecord->id}: " . $e->getMessage());
+ }
+ }
+ }
+
+ return ['processed' => $processed, 'failed' => $failed, 'total' => $total];
+ }
+
public static function saveEmailIfNotExists($subject, $subjectPattern, $from, $to, $date, $body)
{
if (strpos($from, 'info@flowwow.com') === false) {
$email->date = $date;
$email->body = quoted_printable_decode($body);
$email->created_at = date('Y-m-d H:i:s');
+ $email->subject_type = self::detectSubjectType($subject);
if ($email->save()) {
return $email;
$statuses = ArrayHelper::map($statuses, 'code', 'id');
$statusCodes = array_unique(array_keys($statuses));
$newOrdersCount = 0;
+ $processingSuccess = false;
$campaignId = $store;
// Проверяем, что $order не пустой
if ($index == self::SUBJECT_INDEX[self::SUBJECT_NEW]) {
$marketplaceOrder = self::createOrderFlowwow($orderDetails, $campaignId, $statusId, $substatusId);
if ($marketplaceOrder && $marketplaceOrder->save()) {
+ $processingSuccess = true;
self::sendMessageToTelegram($marketplaceOrder->guid, "Новый заказ Флаувау №" . $marketplaceOrder->marketplace_order_id);
$newOrdersCount += 1;
self::createOrUpdateStatusHistory($marketplaceOrder->id, $statusId, $substatusId, $orderDetails);
}
if ($marketplaceOrder->save()) {
+ $processingSuccess = true;
self::createOrUpdateStatusHistory($marketplaceOrder->id, $statusId, $substatusId, $orderDetails);
if (isset($orderDetails['delivery'])) {
$deliveryRecord = self::saveFromDeliveryText($marketplaceOrder->id, $orderDetails['delivery']);
if ($isChanged) {
$marketplaceOrder->raw_data = json_encode($oldRawData, JSON_UNESCAPED_UNICODE);
if ($marketplaceOrder->save()) {
+ $processingSuccess = true;
self::createOrUpdateStatusHistory($marketplaceOrder->id, $statusId, $substatusId, $orderDetails);
if (isset($orderDetails['delivery'])) {
$deliveryRecord = self::saveFromDeliveryText($marketplaceOrder->id, $orderDetails['delivery']);
} else {
// отмена или успешное выполнение
if ($marketplaceOrder->save()) {
+ $processingSuccess = true;
self::createOrUpdateStatusHistory($marketplaceOrder->id, $statusId, $substatusId, $orderDetails);
} else {
Yii::error('Не удалось обновить заказ' . json_encode($marketplaceOrder->getErrors(), JSON_UNESCAPED_UNICODE));
self::checkAndSetReadyTo1c($marketplaceOrder);
}
- return $newOrdersCount;
+ return $processingSuccess ? max($newOrdersCount, 1) : 0;
}
/**
'id',
'subject',
-
+
[
'attribute' => 'email_status',
'format' => 'raw',
'value' => function ($model) {
- $text = '';
- $class = '';
- if ($model->email_status === 1) {
- $class = 'bg-success text-white';
- $text = 'Обработано';
- } elseif ($model->email_status === 0) {
- $class = 'bg-danger text-white';
- $text = 'Необработано';
- }
+ $labels = [
+ MarketplaceFlowwowEmails::STATUS_NEW => ['Необработано', 'bg-danger text-white'],
+ MarketplaceFlowwowEmails::STATUS_PROCESSED => ['Обработано', 'bg-success text-white'],
+ MarketplaceFlowwowEmails::STATUS_ERROR => ['Ошибка', 'bg-dark text-white'],
+ MarketplaceFlowwowEmails::STATUS_RETRY => ['Повтор', 'bg-primary text-white'],
+ ];
+ [$text, $class] = $labels[$model->email_status] ?? ['—', ''];
return Html::tag('span', $text, ['class' => "badge $class"]);
},
- 'filter' => Html::input('text', 'MarketplaceFlowwowEmailsSearch[email_status]', $searchModel->email_status, ['class' => 'form-control']),
+ 'filter' => Html::activeDropDownList(
+ $searchModel,
+ 'email_status',
+ MarketplaceFlowwowEmails::statusLabels(),
+ ['class' => 'form-control', 'prompt' => '— все —']
+ ),
+ ],
+
+ [
+ 'attribute' => 'subject_type',
+ 'value' => fn($model) => MarketplaceFlowwowEmails::subjectTypeLabels()[$model->subject_type] ?? '—',
+ 'filter' => Html::activeDropDownList(
+ $searchModel,
+ 'subject_type',
+ MarketplaceFlowwowEmails::subjectTypeLabels(),
+ ['class' => 'form-control', 'prompt' => '— все —']
+ ),
],
+
+ [
+ 'attribute' => 'marketplace_order_id',
+ 'format' => 'raw',
+ 'value' => function ($model) {
+ if (!$model->marketplace_order_id) {
+ return '—';
+ }
+ $order = $model->order;
+ if ($order) {
+ return Html::a(
+ '№' . $model->marketplace_order_id,
+ ['/marketplace-orders/view', 'id' => $order->id],
+ ['class' => 'btn btn-xs btn-outline-primary', 'target' => '_blank']
+ );
+ }
+ return Html::encode($model->marketplace_order_id) . ' (не найден)';
+ },
+ 'filter' => Html::activeInput(
+ 'text',
+ $searchModel,
+ 'marketplace_order_id',
+ ['class' => 'form-control', 'placeholder' => '№ заказа']
+ ),
+ ],
+
+ 'processing_attempts',
+
'from',
'to',
'date',