From 93684fc11cf502f2c66d4a2c0556ac52c157588d Mon Sep 17 00:00:00 2001 From: VVF Date: Fri, 6 Mar 2026 15:46:54 +0300 Subject: [PATCH] =?utf8?q?fix(TO8-22):=20=D0=BA=D1=80=D0=B8=D1=82=D0=B8?= =?utf8?q?=D1=87=D0=B5=D1=81=D0=BA=D0=B8=D0=B5=20=D0=B8=D1=81=D0=BF=D1=80?= =?utf8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D1=80=D0=BE?= =?utf8?q?=D0=BC=D0=BE-=D1=81=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D1=8F=20?= =?utf8?q?=D0=91=D0=9B=D0=90=D0=93=D0=9E=20=D0=BF=D0=BE=20=D0=BA=D0=BE?= =?utf8?q?=D0=B4-=D1=80=D0=B5=D0=B2=D1=8C=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit Исправлены баги, выявленные при ревью: 1. Race condition: SELECT FOR UPDATE в actionActivatePromocode 2. burn_balans не трогается при промо-списании (только обычные бонусы) 3. user_balans_new не уменьшается при промо (списываются промо-бонусы) 4. writeOffAlready проверяет tip_sale='promobonus' для промо-дедупликации 5. date_end фильтр на promoPlusSum (просроченные промо не учитываются) 6. Обновление полей пользователя на промо early-return пути 7. Использование promocode.duration вместо YEAR_PERIOD в активации 8. attributeLabels для activated_by/activated_at в Promocode 9. Убран file_put_contents с промо-пути 10. Тесты обновлены: добавлены проверки баланса, исправлена формула Co-Authored-By: Claude Opus 4.6 --- erp24/api2/controllers/BonusController.php | 103 +++++++++++------- erp24/records/Promocode.php | 2 + .../controllers/BonusControllerPromoTest.php | 56 +++++++++- 3 files changed, 118 insertions(+), 43 deletions(-) diff --git a/erp24/api2/controllers/BonusController.php b/erp24/api2/controllers/BonusController.php index f7f453e1..c54ec8a7 100644 --- a/erp24/api2/controllers/BonusController.php +++ b/erp24/api2/controllers/BonusController.php @@ -769,9 +769,11 @@ class BonusController extends BaseController $promoWriteOffAmount = 350; $promoMinCheckAmount = 1700; + $now = date('Y-m-d H:i:s'); $promoPlusSum = (float) UsersBonus::find() ->where(['phone' => $phone, 'tip' => 'plus', 'tip_sale' => Promocode::TIP_SALE_PROMOBONUS]) - ->andWhere(['<=', 'date_start', date('Y-m-d H:i:s')]) + ->andWhere(['<=', 'date_start', $now]) + ->andWhere(['>=', 'date_end', $now]) ->sum('bonus'); $promoMinusSum = (float) UsersBonus::find() ->where(['phone' => $phone, 'tip' => 'minus', 'tip_sale' => Promocode::TIP_SALE_PROMOBONUS]) @@ -803,7 +805,10 @@ class BonusController extends BaseController return $this->asJson($mess); } - $user->burn_balans = max(0, $user->burn_balans - $write_off_bonuses); + // TO8-22: При промо-списании burn_balans не трогаем — списываются промо-бонусы, а не обычные + if (!$usePromoWriteOff) { + $user->burn_balans = max(0, $user->burn_balans - $write_off_bonuses); + } // [balans - burn_balance, burn_balans] - показать клиенту что мы сожгли сжигаемый баланс // старая точка проверки кода @@ -844,7 +849,8 @@ class BonusController extends BaseController if (!empty($lid_id)) { file_put_contents(self::OUT_DIR . '/sale_bonuses_' . $fl . '.json', PHP_EOL . '--' . __LINE__, FILE_APPEND); - $writeOffAlready = UsersBonus::find()->where(['lid_id' => $lid_id, 'phone' => $phone, 'tip_sale' => 'sale', 'tip' => 'minus'])->one() != null; + $tipSaleForCheck = $usePromoWriteOff ? Promocode::TIP_SALE_PROMOBONUS : 'sale'; + $writeOffAlready = UsersBonus::find()->where(['lid_id' => $lid_id, 'phone' => $phone, 'tip_sale' => $tipSaleForCheck, 'tip' => 'minus'])->one() != null; } file_put_contents(self::OUT_DIR . '/sale_bonuses_' . $fl . '.json', PHP_EOL . '--' . __LINE__, FILE_APPEND); @@ -862,8 +868,8 @@ class BonusController extends BaseController } } - file_put_contents(self::OUT_DIR . '/sale_bonuses_' . $fl . '.json', PHP_EOL . '--' . __LINE__, FILE_APPEND); - $user_balans_new = $user_balans - $write_off_bonuses; + // TO8-22: При промо-списании обычный баланс не уменьшается — списываются промо-бонусы + $user_balans_new = $usePromoWriteOff ? $user_balans : ($user_balans - $write_off_bonuses); $tipSaleForWriteOff = $usePromoWriteOff ? Promocode::TIP_SALE_PROMOBONUS : 'sale'; $name_b = $usePromoWriteOff ? "Списание промо-бонусов БЛАГО по чеку $check_name" @@ -921,25 +927,41 @@ class BonusController extends BaseController } // TO8-22: При промо-списании кэшбек НЕ начисляется if ($usePromoWriteOff) { - file_put_contents(self::OUT_DIR . '/sale_bonuses_' . $fl . '.json', PHP_EOL . '--promo_writeoff_no_cashback--' . __LINE__, FILE_APPEND); Yii::info("PROMO write_off={$write_off_bonuses}, no cashback for {$phone}", self::LOG_CATEGORY_BONUS); - // Сохраняем продажу и обновляем уровень - $userFound = Users::find()->where(['phone' => $result['phone']])->one(); - /** @var $userFound Users */ - if ($userFound) { - $sale_price_new = ($userFound->sale_price ?? 0) + $amount_all; - $sale_cnt_new = ($userFound->sale_cnt ?? 0) + 1; - $userFound->sale_price = $sale_price_new; - $userFound->sale_cnt = $sale_cnt_new; - $userFound->save(false); - $this->updateUserBonusLevel($userFound, $sale_price_new, $check_id, $check_name); + // Обновляем поля пользователя (аналогично стандартному пути) + $sale_price += $check_amount; + $sale_avg_price = round($sale_price / ($sale_cnt + 1)); + + $user->keycode = "" . rand(1000, 9999); + $user->password = ClientHelper::generatePassword(8); + $user->date_last_sale = date('Y-m-d H:i:s'); + $user->sale_cnt = $sale_cnt + 1; + if ($user->sale_cnt == 1) { + $user->date_first_sale = $user->date_last_sale; + } + $user->sale_store_id = $store_id; + $user->sale_price = $sale_price; + $user->sale_avg_price = $sale_avg_price; + $user->check_id_last_sale = $check_id; + if (!$user->date) { + $user->date = (new \DateTime('now', new \DateTimeZone('Europe/Moscow')))->format('Y-m-d H:i:sP'); + } + $user->balans = ClientHelper::getBonusBalance($phone); + $user->save(); + + if ($user->getErrors()) { + LogService::apiErrorLog(json_encode(["error_id" => 6, "error" => $user->getErrors()], JSON_UNESCAPED_UNICODE)); + return $this->asJson(["error_id" => 6, "error" => $user->getErrors()]); } + $this->updateUserBonusLevel($user, $sale_price, $check_id, $check_name); + $mess["write_off_bonuses"] = $write_off_bonuses; $mess["summa_chek"] = $summa_chek; $mess["bonus_back"] = 0; $mess["user_balans"] = $user_balans_new; + $mess["user_balans_actual"] = $user->balans; $mess["promo_writeoff"] = true; return $this->asJson($mess); @@ -1799,27 +1821,6 @@ class BonusController extends BaseController return $this->asJson(["error_id" => 4, "error" => "phone is not valid"]); } - $promocode = Promocode::find() - ->where(['code' => $result['code']]) - ->one(); - - if (!$promocode) { - return $this->asJson(["error_id" => 1, "error" => "Промокод не найден"]); - } - - $activatable = $promocode->isActivatable(); - if ($activatable !== true) { - $errorMessages = [ - 1 => "Промокод неактивен", - 2 => "Промокод уже использован", - 3 => "Срок действия промокода истёк", - ]; - return $this->asJson([ - "error_id" => $activatable, - "error" => $errorMessages[$activatable] ?? "Промокод недоступен", - ]); - } - $user = Users::find() ->where(['phone' => $phone]) ->andWhere(['phone_true' => '1']) @@ -1831,7 +1832,33 @@ class BonusController extends BaseController $transaction = Yii::$app->db->beginTransaction(); try { + // SELECT FOR UPDATE — блокируем строку промокода от параллельной активации + $promocode = Promocode::find() + ->where(['code' => $result['code']]) + ->forUpdate() + ->one(); + + if (!$promocode) { + $transaction->rollBack(); + return $this->asJson(["error_id" => 1, "error" => "Промокод не найден"]); + } + + $activatable = $promocode->isActivatable(); + if ($activatable !== true) { + $transaction->rollBack(); + $errorMessages = [ + 1 => "Промокод неактивен", + 2 => "Промокод уже использован", + 3 => "Срок действия промокода истёк", + ]; + return $this->asJson([ + "error_id" => $activatable, + "error" => $errorMessages[$activatable] ?? "Промокод недоступен", + ]); + } + $bonusAmount = $promocode->bonus ?: 350; + $duration = $promocode->duration ?: self::$YEAR_PERIOD; $usersBonus = new UsersBonus(); $usersBonus->phone = $phone; @@ -1850,7 +1877,7 @@ class BonusController extends BaseController $usersBonus->admin_id = 0; $usersBonus->lid_id = 0; $usersBonus->date_start = date('Y-m-d H:i:s'); - $usersBonus->date_end = date('Y-m-d H:i:s', strtotime('+' . self::$YEAR_PERIOD . ' day')); + $usersBonus->date_end = date('Y-m-d H:i:s', strtotime('+' . $duration . ' day')); $usersBonus->date_dell = $usersBonus->date_end; $usersBonus->ip = $_SERVER['REMOTE_ADDR'] ?? ''; diff --git a/erp24/records/Promocode.php b/erp24/records/Promocode.php index 36e4b352..e268498d 100644 --- a/erp24/records/Promocode.php +++ b/erp24/records/Promocode.php @@ -74,6 +74,8 @@ class Promocode extends \yii\db\ActiveRecord 'updated_by' => 'Updated By', 'created_at' => 'Created At', 'updated_at' => 'Updated At', + 'activated_by' => 'Activated By', + 'activated_at' => 'Activated At', ]; } diff --git a/erp24/tests/unit/controllers/BonusControllerPromoTest.php b/erp24/tests/unit/controllers/BonusControllerPromoTest.php index 27669f8e..ebf3c918 100644 --- a/erp24/tests/unit/controllers/BonusControllerPromoTest.php +++ b/erp24/tests/unit/controllers/BonusControllerPromoTest.php @@ -162,9 +162,50 @@ class BonusControllerPromoTest extends Unit $this->assertSame(Promocode::TIP_SALE_PROMOBONUS, $result['tipSale']); } + /** + * При промо-списании обычный баланс НЕ уменьшается. + */ + public function testRegularBalanceUnchangedWithPromo() + { + $result = $this->calculateWriteOffChoice( + checkAmount: 2000, + amountReal: 2000, + bonusRate: 0.10, + promoBalance: 350, + userBalance: 500 + ); + + $this->assertTrue($result['usePromoWriteOff']); + // Обычный баланс остаётся неизменным при промо-списании + $this->assertSame(500, $result['userBalansNew']); + } + + /** + * При стандартном списании обычный баланс уменьшается. + */ + public function testRegularBalanceDecreasedWithStandard() + { + $result = $this->calculateWriteOffChoice( + checkAmount: 5000, + amountReal: 5000, + bonusRate: 0.10, + promoBalance: 350, + userBalance: 1000 + ); + + $this->assertFalse($result['usePromoWriteOff']); + // 1000 - 500 = 500 + $this->assertSame(500, $result['userBalansNew']); + } + /** * Воспроизводит алгоритм выбора списания из actionSale(). * Это чистая логика без БД — тестируем алгоритм. + * + * Параметры соответствуют переменным в actionSale(): + * checkAmount -> amount_all + * amountReal -> amount_real + * summaNoWriteoffs -> summa_no_writeoffs (товары без списания) */ private function calculateWriteOffChoice( int $checkAmount, @@ -173,12 +214,13 @@ class BonusControllerPromoTest extends Unit float $promoBalance, float $cashbackRate = 0.10, int $userBalance = 10000, - int $requestedWriteOff = 0 + int $requestedWriteOff = 0, + int $summaNoWriteoffs = 0 ): array { $promoWriteOffAmount = 350; $promoMinCheckAmount = 1700; - // Стандартный расчёт + // Стандартный расчёт (как в actionSale) $writeOffBonusesTheory = (int) round($amountReal * $bonusRate); $writeOffBonuses = $requestedWriteOff ?: $writeOffBonusesTheory; if ($writeOffBonuses > $writeOffBonusesTheory) { @@ -188,7 +230,7 @@ class BonusControllerPromoTest extends Unit $writeOffBonuses = $userBalance; } - // Промо-проверка + // Промо-проверка (amount_all используется, не amount_real) $usePromoWriteOff = false; if ($promoBalance >= $promoWriteOffAmount && $checkAmount >= $promoMinCheckAmount @@ -198,8 +240,11 @@ class BonusControllerPromoTest extends Unit $writeOffBonuses = $promoWriteOffAmount; } - // Кэшбек - $bazaBack = $amountReal - $writeOffBonuses; + // user_balans_new: при промо обычный баланс не трогается + $userBalansNew = $usePromoWriteOff ? $userBalance : ($userBalance - $writeOffBonuses); + + // Кэшбек: baza_back = amount_real + summa_no_writeoffs - write_off_bonuses + $bazaBack = $amountReal + $summaNoWriteoffs - $writeOffBonuses; $cashback = $usePromoWriteOff ? 0 : (int) round($bazaBack * $cashbackRate); // tip_sale @@ -210,6 +255,7 @@ class BonusControllerPromoTest extends Unit 'writeOffBonuses' => $writeOffBonuses, 'cashback' => $cashback, 'tipSale' => $tipSale, + 'userBalansNew' => $userBalansNew, ]; } } -- 2.39.5