]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
fix(TO8-22): критические исправления промо-списания БЛАГО по код-ревью
authorVVF <developer@DeepBlue.localdomain>
Fri, 6 Mar 2026 12:46:54 +0000 (15:46 +0300)
committerVVF <developer@DeepBlue.localdomain>
Fri, 6 Mar 2026 12:46:54 +0000 (15:46 +0300)
Исправлены баги, выявленные при ревью:

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 <noreply@anthropic.com>
erp24/api2/controllers/BonusController.php
erp24/records/Promocode.php
erp24/tests/unit/controllers/BonusControllerPromoTest.php

index f7f453e1017bd4adfef0ace0da736cee09a60018..c54ec8a706ff794e940a3b89b79f89fc0985caea 100644 (file)
@@ -769,9 +769,11 @@ class BonusController extends BaseController
         $promoWriteOffAmount = 350;\r
         $promoMinCheckAmount = 1700;\r
 \r
+        $now = date('Y-m-d H:i:s');\r
         $promoPlusSum = (float) UsersBonus::find()\r
             ->where(['phone' => $phone, 'tip' => 'plus', 'tip_sale' => Promocode::TIP_SALE_PROMOBONUS])\r
-            ->andWhere(['<=', 'date_start', date('Y-m-d H:i:s')])\r
+            ->andWhere(['<=', 'date_start', $now])\r
+            ->andWhere(['>=', 'date_end', $now])\r
             ->sum('bonus');\r
         $promoMinusSum = (float) UsersBonus::find()\r
             ->where(['phone' => $phone, 'tip' => 'minus', 'tip_sale' => Promocode::TIP_SALE_PROMOBONUS])\r
@@ -803,7 +805,10 @@ class BonusController extends BaseController
 \r
             return $this->asJson($mess);\r
         }\r
-        $user->burn_balans = max(0, $user->burn_balans - $write_off_bonuses);\r
+        // TO8-22: При промо-списании burn_balans не трогаем — списываются промо-бонусы, а не обычные\r
+        if (!$usePromoWriteOff) {\r
+            $user->burn_balans = max(0, $user->burn_balans - $write_off_bonuses);\r
+        }\r
         // [balans - burn_balance, burn_balans] - показать клиенту что мы сожгли сжигаемый баланс\r
 \r
 // старая точка проверки кода\r
@@ -844,7 +849,8 @@ class BonusController extends BaseController
         if (!empty($lid_id)) {\r
 \r
             file_put_contents(self::OUT_DIR . '/sale_bonuses_' . $fl . '.json', PHP_EOL . '--' . __LINE__, FILE_APPEND);\r
-            $writeOffAlready = UsersBonus::find()->where(['lid_id' => $lid_id, 'phone' => $phone, 'tip_sale' => 'sale', 'tip' => 'minus'])->one() != null;\r
+            $tipSaleForCheck = $usePromoWriteOff ? Promocode::TIP_SALE_PROMOBONUS : 'sale';\r
+            $writeOffAlready = UsersBonus::find()->where(['lid_id' => $lid_id, 'phone' => $phone, 'tip_sale' => $tipSaleForCheck, 'tip' => 'minus'])->one() != null;\r
         }\r
 \r
         file_put_contents(self::OUT_DIR . '/sale_bonuses_' . $fl . '.json', PHP_EOL . '--' . __LINE__, FILE_APPEND);\r
@@ -862,8 +868,8 @@ class BonusController extends BaseController
                 }\r
             }\r
 \r
-            file_put_contents(self::OUT_DIR . '/sale_bonuses_' . $fl . '.json', PHP_EOL . '--' . __LINE__, FILE_APPEND);\r
-            $user_balans_new = $user_balans - $write_off_bonuses;\r
+            // TO8-22: При промо-списании обычный баланс не уменьшается — списываются промо-бонусы\r
+            $user_balans_new = $usePromoWriteOff ? $user_balans : ($user_balans - $write_off_bonuses);\r
             $tipSaleForWriteOff = $usePromoWriteOff ? Promocode::TIP_SALE_PROMOBONUS : 'sale';\r
             $name_b = $usePromoWriteOff\r
                 ? "Списание промо-бонусов БЛАГО по чеку $check_name"\r
@@ -921,25 +927,41 @@ class BonusController extends BaseController
         }\r
         // TO8-22: При промо-списании кэшбек НЕ начисляется\r
         if ($usePromoWriteOff) {\r
-            file_put_contents(self::OUT_DIR . '/sale_bonuses_' . $fl . '.json', PHP_EOL . '--promo_writeoff_no_cashback--' . __LINE__, FILE_APPEND);\r
             Yii::info("PROMO write_off={$write_off_bonuses}, no cashback for {$phone}", self::LOG_CATEGORY_BONUS);\r
 \r
-            // Сохраняем продажу и обновляем уровень\r
-            $userFound = Users::find()->where(['phone' => $result['phone']])->one();\r
-            /** @var $userFound Users */\r
-            if ($userFound) {\r
-                $sale_price_new = ($userFound->sale_price ?? 0) + $amount_all;\r
-                $sale_cnt_new = ($userFound->sale_cnt ?? 0) + 1;\r
-                $userFound->sale_price = $sale_price_new;\r
-                $userFound->sale_cnt = $sale_cnt_new;\r
-                $userFound->save(false);\r
-                $this->updateUserBonusLevel($userFound, $sale_price_new, $check_id, $check_name);\r
+            // Обновляем поля пользователя (аналогично стандартному пути)\r
+            $sale_price += $check_amount;\r
+            $sale_avg_price = round($sale_price / ($sale_cnt + 1));\r
+\r
+            $user->keycode = "" . rand(1000, 9999);\r
+            $user->password = ClientHelper::generatePassword(8);\r
+            $user->date_last_sale = date('Y-m-d H:i:s');\r
+            $user->sale_cnt = $sale_cnt + 1;\r
+            if ($user->sale_cnt == 1) {\r
+                $user->date_first_sale = $user->date_last_sale;\r
+            }\r
+            $user->sale_store_id = $store_id;\r
+            $user->sale_price = $sale_price;\r
+            $user->sale_avg_price = $sale_avg_price;\r
+            $user->check_id_last_sale = $check_id;\r
+            if (!$user->date) {\r
+                $user->date = (new \DateTime('now', new \DateTimeZone('Europe/Moscow')))->format('Y-m-d H:i:sP');\r
+            }\r
+            $user->balans = ClientHelper::getBonusBalance($phone);\r
+            $user->save();\r
+\r
+            if ($user->getErrors()) {\r
+                LogService::apiErrorLog(json_encode(["error_id" => 6, "error" => $user->getErrors()], JSON_UNESCAPED_UNICODE));\r
+                return $this->asJson(["error_id" => 6, "error" => $user->getErrors()]);\r
             }\r
 \r
+            $this->updateUserBonusLevel($user, $sale_price, $check_id, $check_name);\r
+\r
             $mess["write_off_bonuses"] = $write_off_bonuses;\r
             $mess["summa_chek"] = $summa_chek;\r
             $mess["bonus_back"] = 0;\r
             $mess["user_balans"] = $user_balans_new;\r
+            $mess["user_balans_actual"] = $user->balans;\r
             $mess["promo_writeoff"] = true;\r
 \r
             return $this->asJson($mess);\r
@@ -1799,27 +1821,6 @@ class BonusController extends BaseController
             return $this->asJson(["error_id" => 4, "error" => "phone is not valid"]);\r
         }\r
 \r
-        $promocode = Promocode::find()\r
-            ->where(['code' => $result['code']])\r
-            ->one();\r
-\r
-        if (!$promocode) {\r
-            return $this->asJson(["error_id" => 1, "error" => "Промокод не найден"]);\r
-        }\r
-\r
-        $activatable = $promocode->isActivatable();\r
-        if ($activatable !== true) {\r
-            $errorMessages = [\r
-                1 => "Промокод неактивен",\r
-                2 => "Промокод уже использован",\r
-                3 => "Срок действия промокода истёк",\r
-            ];\r
-            return $this->asJson([\r
-                "error_id" => $activatable,\r
-                "error" => $errorMessages[$activatable] ?? "Промокод недоступен",\r
-            ]);\r
-        }\r
-\r
         $user = Users::find()\r
             ->where(['phone' => $phone])\r
             ->andWhere(['phone_true' => '1'])\r
@@ -1831,7 +1832,33 @@ class BonusController extends BaseController
 \r
         $transaction = Yii::$app->db->beginTransaction();\r
         try {\r
+            // SELECT FOR UPDATE — блокируем строку промокода от параллельной активации\r
+            $promocode = Promocode::find()\r
+                ->where(['code' => $result['code']])\r
+                ->forUpdate()\r
+                ->one();\r
+\r
+            if (!$promocode) {\r
+                $transaction->rollBack();\r
+                return $this->asJson(["error_id" => 1, "error" => "Промокод не найден"]);\r
+            }\r
+\r
+            $activatable = $promocode->isActivatable();\r
+            if ($activatable !== true) {\r
+                $transaction->rollBack();\r
+                $errorMessages = [\r
+                    1 => "Промокод неактивен",\r
+                    2 => "Промокод уже использован",\r
+                    3 => "Срок действия промокода истёк",\r
+                ];\r
+                return $this->asJson([\r
+                    "error_id" => $activatable,\r
+                    "error" => $errorMessages[$activatable] ?? "Промокод недоступен",\r
+                ]);\r
+            }\r
+\r
             $bonusAmount = $promocode->bonus ?: 350;\r
+            $duration = $promocode->duration ?: self::$YEAR_PERIOD;\r
 \r
             $usersBonus = new UsersBonus();\r
             $usersBonus->phone = $phone;\r
@@ -1850,7 +1877,7 @@ class BonusController extends BaseController
             $usersBonus->admin_id = 0;\r
             $usersBonus->lid_id = 0;\r
             $usersBonus->date_start = date('Y-m-d H:i:s');\r
-            $usersBonus->date_end = date('Y-m-d H:i:s', strtotime('+' . self::$YEAR_PERIOD . ' day'));\r
+            $usersBonus->date_end = date('Y-m-d H:i:s', strtotime('+' . $duration . ' day'));\r
             $usersBonus->date_dell = $usersBonus->date_end;\r
             $usersBonus->ip = $_SERVER['REMOTE_ADDR'] ?? '';\r
 \r
index 36e4b352a4eb8b9246a388e2061bf90f8f988f85..e268498dd09adccedd92c4761eeb8ac368e8bcc0 100644 (file)
@@ -74,6 +74,8 @@ class Promocode extends \yii\db\ActiveRecord
             'updated_by' => 'Updated By',\r
             'created_at' => 'Created At',\r
             'updated_at' => 'Updated At',\r
+            'activated_by' => 'Activated By',\r
+            'activated_at' => 'Activated At',\r
         ];\r
     }\r
 \r
index 27669f8eae0f217c036d5ef3ba71070f4eeb1751..ebf3c91823171860dbdb6b77073921cff862872b 100644 (file)
@@ -162,9 +162,50 @@ class BonusControllerPromoTest extends Unit
         $this->assertSame(Promocode::TIP_SALE_PROMOBONUS, $result['tipSale']);\r
     }\r
 \r
+    /**\r
+     * При промо-списании обычный баланс НЕ уменьшается.\r
+     */\r
+    public function testRegularBalanceUnchangedWithPromo()\r
+    {\r
+        $result = $this->calculateWriteOffChoice(\r
+            checkAmount: 2000,\r
+            amountReal: 2000,\r
+            bonusRate: 0.10,\r
+            promoBalance: 350,\r
+            userBalance: 500\r
+        );\r
+\r
+        $this->assertTrue($result['usePromoWriteOff']);\r
+        // Обычный баланс остаётся неизменным при промо-списании\r
+        $this->assertSame(500, $result['userBalansNew']);\r
+    }\r
+\r
+    /**\r
+     * При стандартном списании обычный баланс уменьшается.\r
+     */\r
+    public function testRegularBalanceDecreasedWithStandard()\r
+    {\r
+        $result = $this->calculateWriteOffChoice(\r
+            checkAmount: 5000,\r
+            amountReal: 5000,\r
+            bonusRate: 0.10,\r
+            promoBalance: 350,\r
+            userBalance: 1000\r
+        );\r
+\r
+        $this->assertFalse($result['usePromoWriteOff']);\r
+        // 1000 - 500 = 500\r
+        $this->assertSame(500, $result['userBalansNew']);\r
+    }\r
+\r
     /**\r
      * Воспроизводит алгоритм выбора списания из actionSale().\r
      * Это чистая логика без БД — тестируем алгоритм.\r
+     *\r
+     * Параметры соответствуют переменным в actionSale():\r
+     *   checkAmount -> amount_all\r
+     *   amountReal -> amount_real\r
+     *   summaNoWriteoffs -> summa_no_writeoffs (товары без списания)\r
      */\r
     private function calculateWriteOffChoice(\r
         int $checkAmount,\r
@@ -173,12 +214,13 @@ class BonusControllerPromoTest extends Unit
         float $promoBalance,\r
         float $cashbackRate = 0.10,\r
         int $userBalance = 10000,\r
-        int $requestedWriteOff = 0\r
+        int $requestedWriteOff = 0,\r
+        int $summaNoWriteoffs = 0\r
     ): array {\r
         $promoWriteOffAmount = 350;\r
         $promoMinCheckAmount = 1700;\r
 \r
-        // Стандартный расчёт\r
+        // Стандартный расчёт (как в actionSale)\r
         $writeOffBonusesTheory = (int) round($amountReal * $bonusRate);\r
         $writeOffBonuses = $requestedWriteOff ?: $writeOffBonusesTheory;\r
         if ($writeOffBonuses > $writeOffBonusesTheory) {\r
@@ -188,7 +230,7 @@ class BonusControllerPromoTest extends Unit
             $writeOffBonuses = $userBalance;\r
         }\r
 \r
-        // Промо-проверка\r
+        // Промо-проверка (amount_all используется, не amount_real)\r
         $usePromoWriteOff = false;\r
         if ($promoBalance >= $promoWriteOffAmount\r
             && $checkAmount >= $promoMinCheckAmount\r
@@ -198,8 +240,11 @@ class BonusControllerPromoTest extends Unit
             $writeOffBonuses = $promoWriteOffAmount;\r
         }\r
 \r
-        // Кэшбек\r
-        $bazaBack = $amountReal - $writeOffBonuses;\r
+        // user_balans_new: при промо обычный баланс не трогается\r
+        $userBalansNew = $usePromoWriteOff ? $userBalance : ($userBalance - $writeOffBonuses);\r
+\r
+        // Кэшбек: baza_back = amount_real + summa_no_writeoffs - write_off_bonuses\r
+        $bazaBack = $amountReal + $summaNoWriteoffs - $writeOffBonuses;\r
         $cashback = $usePromoWriteOff ? 0 : (int) round($bazaBack * $cashbackRate);\r
 \r
         // tip_sale\r
@@ -210,6 +255,7 @@ class BonusControllerPromoTest extends Unit
             'writeOffBonuses' => $writeOffBonuses,\r
             'cashback' => $cashback,\r
             'tipSale' => $tipSale,\r
+            'userBalansNew' => $userBalansNew,\r
         ];\r
     }\r
 }\r