From: Aleksey Filippov Date: Sat, 7 Mar 2026 21:36:28 +0000 (+0300) Subject: fix(ERP-255): защита от гонки процессов в маркетплейс-командах X-Git-Url: https://gitweb.erp-flowers.ru/?a=commitdiff_plain;h=8d60267a5166c991fef27d8119fb7cb15f522a15;p=erp24_rep%2Fyii-erp24%2F.git fix(ERP-255): защита от гонки процессов в маркетплейс-командах - Добавлен компонент 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 --- diff --git a/erp24/api1/config/api1.config.php b/erp24/api1/config/api1.config.php index 8eaa855b..1ae89ef3 100644 --- a/erp24/api1/config/api1.config.php +++ b/erp24/api1/config/api1.config.php @@ -92,4 +92,5 @@ return [ ], 'params' => require dirname(__DIR__, 2) . '/config/params.php', 'timeZone' => 'Europe/Moscow', + 'layout' => false, ]; diff --git a/erp24/commands/MarketplaceController.php b/erp24/commands/MarketplaceController.php index a2f5c7b1..65d33e35 100644 --- a/erp24/commands/MarketplaceController.php +++ b/erp24/commands/MarketplaceController.php @@ -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'); + } } } diff --git a/erp24/config/console.php b/erp24/config/console.php index 05410b6f..046898d9 100755 --- a/erp24/config/console.php +++ b/erp24/config/console.php @@ -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, diff --git a/erp24/jobs/SendBonusInfoToSiteJob.php b/erp24/jobs/SendBonusInfoToSiteJob.php index dee0687a..b0e6ef54 100644 --- a/erp24/jobs/SendBonusInfoToSiteJob.php +++ b/erp24/jobs/SendBonusInfoToSiteJob.php @@ -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()) { diff --git a/erp24/services/MarketplaceService.php b/erp24/services/MarketplaceService.php index e0df763f..7bee00f4 100644 --- a/erp24/services/MarketplaceService.php +++ b/erp24/services/MarketplaceService.php @@ -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) {