]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
fix(ERP-255): защита от гонки процессов в маркетплейс-командах
authorAleksey Filippov <Aleksey.Filippov@erp-flowers.ru>
Sat, 7 Mar 2026 21:36:28 +0000 (00:36 +0300)
committerAleksey Filippov <Aleksey.Filippov@erp-flowers.ru>
Sat, 7 Mar 2026 21:36:28 +0000 (00:36 +0300)
- Добавлен компонент PgsqlMutex в console.php для advisory locks
- Обёрнуты 4 action MarketplaceController в мьютекс (yandex-orders,
  flowwow-orders, flowwow-retry, check-ready-to-1c) — повторный запуск
  cron пропускается без ошибок
- Добавлен try-catch IntegrityException в processOrders() вокруг
  save() нового заказа — дубликат marketplace_order_id при гонке
  теперь логируется как warning, а не падает в ошибку
- Исправлен NPE в SendBonusInfoToSiteJob: добавлена проверка на null
  перед обращением к ->status++ (Fatal Error при отсутствии записи)
- Добавлен layout => false в api1.config.php

erp24/api1/config/api1.config.php
erp24/commands/MarketplaceController.php
erp24/config/console.php
erp24/jobs/SendBonusInfoToSiteJob.php
erp24/services/MarketplaceService.php

index 8eaa855b1d4aad0c02812f437c121ffe81d9c887..1ae89ef38634fcbe6014e164fc58c7bb65759441 100644 (file)
@@ -92,4 +92,5 @@ return [
     ],
     'params' => require dirname(__DIR__, 2) . '/config/params.php',
     'timeZone' => 'Europe/Moscow',
+    'layout' => false,
 ];
index a2f5c7b16c8f633da28ce96a998f2ecb6f5552dc..65d33e35141612d04803e34ff7fad56132eddaa3 100644 (file)
@@ -119,44 +119,51 @@ class MarketplaceController extends Controller
 
     public function actionGetFlowwowOrders()
     {
-        $date = $this->date ?? null;
-        $since = (bool)($this->since ?? 0);
-        $oldMail = (bool)($this->oldMail ?? 0);
-        $seen = (bool)($this->seen ?? 0);
-        $unseen = (bool)($this->unseen ?? 0);
-
-        $countMessages = 0;
-        $count = 0;
-
-        $progressCallback = function ($message) {
-            $this->stdout($message . "\n", BaseConsole::FG_YELLOW);
-        };
-
-        $messages = MarketplaceService::getFlowwowOrdersFromMail(
-            $date,
-            $since,
-            $oldMail,
-            $progressCallback,
-            $seen,
-            $unseen
-        );
-
-        if (!is_array($messages)) {
-            $errorMsg = 'MarketplaceController::actionGetFlowwowOrders — не удалось получить заказы FlowWow. Проверьте IMAP настройки в .env (IMAP_FLOWWOW_USERNAME, IMAP_FLOWWOW_PASSWORD)';
-            Yii::error($errorMsg, 'marketplace');
-            $this->stderr($errorMsg . "\n", BaseConsole::FG_RED);
-            return ExitCode::SOFTWARE;
+        $mutex = Yii::$app->mutex;
+        if (!$mutex->acquire('marketplace:flowwow-orders', 0)) {
+            Yii::warning('actionGetFlowwowOrders: уже выполняется, пропуск', 'marketplace');
+            return ExitCode::OK;
         }
 
-        $countMessages = $messages['all'];
-        $count = $messages['processed'];
+        try {
+            $date = $this->date ?? null;
+            $since = (bool)($this->since ?? 0);
+            $oldMail = (bool)($this->oldMail ?? 0);
+            $seen = (bool)($this->seen ?? 0);
+            $unseen = (bool)($this->unseen ?? 0);
+
+            $progressCallback = function ($message) {
+                $this->stdout($message . "\n", BaseConsole::FG_YELLOW);
+            };
+
+            $messages = MarketplaceService::getFlowwowOrdersFromMail(
+                $date,
+                $since,
+                $oldMail,
+                $progressCallback,
+                $seen,
+                $unseen
+            );
+
+            if (!is_array($messages)) {
+                $errorMsg = 'MarketplaceController::actionGetFlowwowOrders — не удалось получить заказы FlowWow. Проверьте IMAP настройки в .env (IMAP_FLOWWOW_USERNAME, IMAP_FLOWWOW_PASSWORD)';
+                Yii::error($errorMsg, 'marketplace');
+                $this->stderr($errorMsg . "\n", BaseConsole::FG_RED);
+                return ExitCode::SOFTWARE;
+            }
 
-        $this->stdout(
-            "Удалось сохранить {$count} новых заказов из {$countMessages} сообщений почты.\n",
-            BaseConsole::FG_GREEN
-        );
+            $countMessages = $messages['all'];
+            $count = $messages['processed'];
 
-        return ExitCode::OK;
+            $this->stdout(
+                "Удалось сохранить {$count} новых заказов из {$countMessages} сообщений почты.\n",
+                BaseConsole::FG_GREEN
+            );
+
+            return ExitCode::OK;
+        } finally {
+            $mutex->release('marketplace:flowwow-orders');
+        }
     }
 
     /**
@@ -167,46 +174,66 @@ class MarketplaceController extends Controller
      */
     public function actionRetryFlowwowEmails(): int
     {
-        $progressCallback = function (string $message) {
-            $this->stdout($message . "\n", BaseConsole::FG_YELLOW);
-        };
+        $mutex = Yii::$app->mutex;
+        if (!$mutex->acquire('marketplace:flowwow-retry', 0)) {
+            Yii::warning('actionRetryFlowwowEmails: уже выполняется, пропуск', 'marketplace');
+            return ExitCode::OK;
+        }
 
-        $result = MarketplaceService::processUnprocessedEmails($progressCallback);
+        try {
+            $progressCallback = function (string $message) {
+                $this->stdout($message . "\n", BaseConsole::FG_YELLOW);
+            };
 
-        $this->stdout(
-            "Итог: обработано {$result['processed']} из {$result['total']}, ошибок: {$result['failed']}.\n",
-            BaseConsole::FG_GREEN
-        );
+            $result = MarketplaceService::processUnprocessedEmails($progressCallback);
 
-        return ExitCode::OK;
+            $this->stdout(
+                "Итог: обработано {$result['processed']} из {$result['total']}, ошибок: {$result['failed']}.\n",
+                BaseConsole::FG_GREEN
+            );
+
+            return ExitCode::OK;
+        } finally {
+            $mutex->release('marketplace:flowwow-retry');
+        }
     }
 
     public function actionGetYandexOrders()
     {
-        $fromDate = date('d-m-Y', strtotime('-1 day'));
-        $toDate   = null;
-        $status   = null;
-        $substatus = null;
-
-        $campaignIds = MarketplaceStore::find()
-            ->select(['warehouse_guid'])
-            ->where(['warehouse_id' => MarketplaceStore::YANDEX_WAREHOUSE_ID])
-            ->column();
+        $mutex = Yii::$app->mutex;
+        if (!$mutex->acquire('marketplace:yandex-orders', 0)) {
+            Yii::warning('actionGetYandexOrders: уже выполняется, пропуск', 'marketplace');
+            return ExitCode::OK;
+        }
 
-        $allOrders = MarketplaceService::fetchOrders($campaignIds, $fromDate, $toDate, $status, $substatus);
+        try {
+            $fromDate = date('d-m-Y', strtotime('-1 day'));
+            $toDate   = null;
+            $status   = null;
+            $substatus = null;
 
-        $result = MarketplaceService::processOrders($allOrders);
+            $campaignIds = MarketplaceStore::find()
+                ->select(['warehouse_guid'])
+                ->where(['warehouse_id' => MarketplaceStore::YANDEX_WAREHOUSE_ID])
+                ->column();
 
-        $newOrders = $result['newOrders'];
-        $updateOrders = $result['updateOrders'];
-        $storeCount = count($allOrders);
+            $allOrders = MarketplaceService::fetchOrders($campaignIds, $fromDate, $toDate, $status, $substatus);
 
-        $this->stdout(
-            "Удалось сохранить {$newOrders} новых заказов из {$storeCount} и обновить {$updateOrders} от {$fromDate}. \n",
-            BaseConsole::FG_GREEN
-        );
+            $result = MarketplaceService::processOrders($allOrders);
 
-        return ExitCode::OK;
+            $newOrders = $result['newOrders'];
+            $updateOrders = $result['updateOrders'];
+            $storeCount = count($allOrders);
+
+            $this->stdout(
+                "Удалось сохранить {$newOrders} новых заказов из {$storeCount} и обновить {$updateOrders} от {$fromDate}. \n",
+                BaseConsole::FG_GREEN
+            );
+
+            return ExitCode::OK;
+        } finally {
+            $mutex->release('marketplace:yandex-orders');
+        }
     }
 
     public function options($actionID)
@@ -553,8 +580,18 @@ class MarketplaceController extends Controller
      */
     public function actionCheckReadyTo1c(): int
     {
-        $count = MarketplaceService::forceReadyTo1cByTimeout(15);
-        $this->stdout("Помечено готовыми к отправке в 1С: {$count} заказов\n");
-        return ExitCode::OK;
+        $mutex = Yii::$app->mutex;
+        if (!$mutex->acquire('marketplace:check-ready-to-1c', 0)) {
+            Yii::warning('actionCheckReadyTo1c: уже выполняется, пропуск', 'marketplace');
+            return ExitCode::OK;
+        }
+
+        try {
+            $count = MarketplaceService::forceReadyTo1cByTimeout(15);
+            $this->stdout("Помечено готовыми к отправке в 1С: {$count} заказов\n");
+            return ExitCode::OK;
+        } finally {
+            $mutex->release('marketplace:check-ready-to-1c');
+        }
     }
 }
index 05410b6f2fe452fceb57b731b8ef1f4c899ddc39..046898d95a3b248007063ffd952fa4f1c8311918 100755 (executable)
@@ -53,6 +53,10 @@ $config = [
         'cache' => [
             'class' => 'yii\caching\FileCache',
         ],
+        'mutex' => [
+            'class' => \yii\mutex\PgsqlMutex::class,
+            'db' => 'db',
+        ],
         // Mailer для отправки email (консольные команды)
         'mailer' => [
             'class' => \yii\symfonymailer\Mailer::class,
index dee0687afd12b743d832bfd208dac62af2826514..b0e6ef54a23b7a553fd6d4f37f06be363dc9e4e2 100644 (file)
@@ -32,6 +32,10 @@ class SendBonusInfoToSiteJob extends \yii\base\BaseObject implements JobInterfac
 
         $inputHash = md5(Json::encode($input));
         $userBonusSendToTgLogs = UserBonusSendToTgLogs::find()->where(['input_hash' => $inputHash])->one();
+        if ($userBonusSendToTgLogs === null) {
+            Yii::error("SendBonusInfoToSiteJob: запись не найдена по input_hash={$inputHash}", 'site');
+            return;
+        }
         $userBonusSendToTgLogs->status ++;
         $userBonusSendToTgLogs->save();
         if ($userBonusSendToTgLogs->getErrors()) {
index e0df763f7d64c0a9902be20089df9a7c3bd580b7..7bee00f433ff14e365d6a217d0f1f668824975c0 100644 (file)
@@ -1285,7 +1285,8 @@ class MarketplaceService
                         "Попытка сохранения нового заказа: marketplace_order_id={$marketplaceOrder->marketplace_order_id}, fake={$marketplaceOrder->fake}",
                         'marketplace_order_creation'
                     );
-                    
+
+                    try {
                     if ($marketplaceOrder->save()) {
                         Yii::warning(
                             "Заказ успешно сохранен, ID={$marketplaceOrder->id}",
@@ -1495,6 +1496,12 @@ class MarketplaceService
                             )
                         );
                     }
+                    } catch (\yii\db\IntegrityException $e) {
+                        Yii::warning(
+                            "processOrders: пропуск дубликата marketplace_order_id={$marketplaceOrder->marketplace_order_id} (гонка процессов): " . $e->getMessage(),
+                            'marketplace'
+                        );
+                    }
                     } else {
                         // Логика отмены для ЯндексМаркета: если статус обновлен на отмену
                         if ($statusCode === 'CANCELLED' && $marketplaceOrder) {