Forráskód Böngészése

app-ghs 库存并发企业级修复--本次只修复 app-ghs 的开单与出库的库存并发一致性问题,没有顺手改采购、盘点、预订等其他业务流程

shizhongqi 2 hónapja
szülő
commit
dd1e4c76e8

+ 24 - 13
app-ghs/controllers/OrderController.php

@@ -1491,18 +1491,26 @@ class OrderController extends BaseController
             }
         }
 
-        $connection = Yii::$app->db;
-        $transaction = $connection->beginTransaction();
+        $post['product'] = $productList;
+        //商家开单到时间会自动标记为欠款,所以360天内不过期,以保证足够时间标记欠款,具体参数查看dict local_gys_kd_auto_set_debt_time
+        $time = time();
+        $hasTime = dict::getDict('order_pay_has_time');
+        $totalTime = bcadd($time, $hasTime);
+        $post['deadline'] = date("Y-m-d H:i:s", $totalTime);
+
         try {
-            $post['product'] = $productList;
-            //商家开单到时间会自动标记为欠款,所以360天内不过期,以保证足够时间标记欠款,具体参数查看dict local_gys_kd_auto_set_debt_time
-            $time = time();
-            $hasTime = dict::getDict('order_pay_has_time');
-            $totalTime = bcadd($time, $hasTime);
-            $post['deadline'] = date("Y-m-d H:i:s", $totalTime);
-            //多处有用到此方法,需要同步修改,搜索关键词create_new_order
-            $return = OrderService::createNewOrder($post, $custom, $hasPay);
-            $transaction->commit();
+            $return = util::runWithDbConcurrencyRetry(function () use ($post, $custom, $hasPay) {
+                $connection = Yii::$app->db;
+                $transaction = $connection->beginTransaction();
+                try {
+                    $return = OrderService::createNewOrder($post, $custom, $hasPay);//多处有用到此方法,需要同步修改,搜索关键词create_new_order
+                    $transaction->commit();
+                    return $return;
+                } catch (\Exception $exception) {
+                    $transaction->rollBack();
+                    throw $exception;
+                }
+            });
 
             $saleId = $return->id ?? 0;
             $order = OrderClass::getById($saleId, true);
@@ -1566,10 +1574,13 @@ class OrderController extends BaseController
             }
             util::success($return);
         } catch (\Exception $e) {
-            $transaction->rollBack();
             Yii::error("下单失败原因:" . $e->getMessage());
             noticeUtil::push("批发线下开单失败,原因:" . $e->getMessage(), '15280215347');
-            util::fail('下单失败');
+            if (util::isDbConcurrencyException($e)) {
+                util::fail('系统繁忙中,请稍后再试');
+            } else {
+                util::fail('下单失败');
+            }
         }
     }
 

+ 40 - 32
app-ghs/controllers/StockOutController.php

@@ -262,44 +262,52 @@ class StockOutController extends BaseController
         $adminId = $this->adminId;
         util::checkRepeatCommit($adminId, 5);
 
-        $connection = Yii::$app->db;
-        $transaction = $connection->beginTransaction();
-
         try {
+            $order = util::runWithDbConcurrencyRetry(function () use ($post, $ghsItemInfo, $inShopId) {
+                $connection = Yii::$app->db;
+                $transaction = $connection->beginTransaction();
+                try {
+                    //入库门店有效性判断
+                    if ($this->shopId == $inShopId) {
+                        util::fail('不能出库给当前门店');
+                    }
+                    $inShop = ShopClass::getById($inShopId);
+                    if (empty($inShop)) {
+                        util::fail('没有找到门店68');
+                    }
+                    ShopClass::valid($inShop, $this->sjId);
+                    $post['inMainId'] = $inShop['mainId'] ?? 0;
 
-            //入库门店有效性判断
-            if ($this->shopId == $inShopId) {
-                util::fail('不能出库给当前门店');
-            }
-            $inShop = ShopClass::getById($inShopId);
-            if (empty($inShop)) {
-                util::fail('没有找到门店68');
-            }
-            ShopClass::valid($inShop, $this->sjId);
-            $post['inMainId'] = $inShop['mainId'] ?? 0;
-
-            $staff = $this->shopAdmin;
-            $post['staffName'] = $staff->name ?? '';
-            $post['staffId'] = $staff->id ?? 0;
-            $post['adminId'] = $this->adminId;
+                    $staff = $this->shopAdmin;
+                    $post['staffName'] = $staff->name ?? '';
+                    $post['staffId'] = $staff->id ?? 0;
+                    $post['adminId'] = $this->adminId;
 
-            ProductClass::valid($ghsItemInfo, $this->mainId);
-            //判断花材里的信息
-            foreach ($ghsItemInfo as $v) {
-                $bigNum = $v['bigNum'] ?? 0;
-                $smallNum = $v['smallNum'] ?? 0;
-                $name = $v['name'] ?? '';
-                if ($bigNum <= 0 && $smallNum <= 0) {
-                    util::fail('花材数量不能为0');
+                    ProductClass::valid($ghsItemInfo, $this->mainId);
+                    //判断花材里的信息
+                    foreach ($ghsItemInfo as $v) {
+                        $bigNum = $v['bigNum'] ?? 0;
+                        $smallNum = $v['smallNum'] ?? 0;
+                        if ($bigNum <= 0 && $smallNum <= 0) {
+                            util::fail('花材数量不能为0');
+                        }
+                    }
+                    $post['itemInfo'] = $ghsItemInfo;
+                    $order = StockOutOrderClass::addOrder($post);
+                    $transaction->commit();
+                    return $order;
+                } catch (\Exception $exception) {
+                    $transaction->rollBack();
+                    throw $exception;
                 }
-            }
-            $post['itemInfo'] = $ghsItemInfo;
-            $order = StockOutOrderClass::addOrder($post);
-            $transaction->commit();
+            });
             util::success($order);
         } catch (\Exception $exception) {
-            $transaction->rollBack();
-            util::fail('出库失败' . $exception->getMessage());
+            if (util::isDbConcurrencyException($exception)) {
+                util::fail('系统繁忙中,请稍后再试');
+            } else {
+                util::fail('出库失败' . $exception->getMessage());
+            }
         }
     }
 

+ 9 - 1
biz-ghs/order/classes/OrderItemClass.php

@@ -337,6 +337,14 @@ class OrderItemClass extends BaseClass
         $live = isset($custom->live) ? $custom->live : 1;
         $respond = ProductClass::formatProductInfo($products, $level, $live, $moreParams);
         $products = $respond['product'];
+        usort($products, function ($left, $right) {
+            $leftId = isset($left['productId']) ? intval($left['productId']) : 0;
+            $rightId = isset($right['productId']) ? intval($right['productId']) : 0;
+            if ($leftId == $rightId) {
+                return 0;
+            }
+            return $leftId < $rightId ? -1 : 1;
+        });
         $weight = 0;
         $mainId = $post['mainId'] ?? 0;
         self::deleteByCondition(['orderSn' => $orderSn]);
@@ -620,4 +628,4 @@ class OrderItemClass extends BaseClass
         util::success(['file' => $fileUrl, 'shortFile' => $file]);
     }
 
-}
+}

+ 8 - 0
biz-ghs/order/classes/StockInOrderClass.php

@@ -190,6 +190,14 @@ class StockInOrderClass extends BaseClass
         if (empty($itemInfo)) {
             util::fail("订单异常");
         }
+        usort($itemInfo, function ($left, $right) {
+            $leftId = isset($left['productId']) ? intval($left['productId']) : 0;
+            $rightId = isset($right['productId']) ? intval($right['productId']) : 0;
+            if ($leftId == $rightId) {
+                return 0;
+            }
+            return $leftId < $rightId ? -1 : 1;
+        });
 
         $chainShopList = [];
         $default = $shop->default ?? 0;

+ 16 - 0
biz-ghs/order/classes/StockOutOrderClass.php

@@ -44,6 +44,14 @@ class StockOutOrderClass extends BaseClass
         $data['orderSn'] = $orderSn;
         $ghsItemInfo = $data['itemInfo']; // [{bigNum:0,smallNum:0,productId:1}]
         $ghsItemInfo = self::mergeItemInfo($ghsItemInfo);
+        usort($ghsItemInfo, function ($left, $right) {
+            $leftId = isset($left['productId']) ? intval($left['productId']) : 0;
+            $rightId = isset($right['productId']) ? intval($right['productId']) : 0;
+            if ($leftId == $rightId) {
+                return 0;
+            }
+            return $leftId < $rightId ? -1 : 1;
+        });
         $sumNum = self::sumNumByItemInfo($ghsItemInfo);
         $data['bigNum'] = $sumNum['bigNum'];// 总的大单位数量 总共多少扎
         $data['smallNum'] = $sumNum['smallNum']; //总的小单位数量 总共多少支
@@ -198,6 +206,14 @@ class StockOutOrderClass extends BaseClass
         if (empty($itemInfo)) {
             util::fail("订单异常");
         }
+        usort($itemInfo, function ($left, $right) {
+            $leftId = isset($left['productId']) ? intval($left['productId']) : 0;
+            $rightId = isset($right['productId']) ? intval($right['productId']) : 0;
+            if ($leftId == $rightId) {
+                return 0;
+            }
+            return $leftId < $rightId ? -1 : 1;
+        });
         foreach ($itemInfo as $k => $v) {
             $stockInfo = ProductClass::addStockByItemNum($v['productId'], $v['itemNum']);
             $itemInfo[$k]['oldStock'] = $stockInfo['oldStock'];

+ 20 - 62
biz-ghs/product/classes/ProductClass.php

@@ -935,66 +935,48 @@ class ProductClass extends BaseClass
         //小单位库存不再考虑 ssh 20230303
         //$itemNumPiece = 0;
 
-        $key = self::LOCK_STOCK . '_' . $productId;
-        $lock = util::lock($key);
-        if (!$lock) {
-            util::fail("系统繁忙中,请稍后再试!");
-        }
-        $productData = self::getById($productId);
+        $productData = self::getLockById($productId);
         if (!$productData) {
-            util::unlock($key);
             util::fail("花材不存在");
         }
         // $itemId = $productData['itemId'];
-        $ratio = $productData['ratio'];
+        $ratio = $productData->ratio;
         //转成小数,加库存
         $itemNum = self::mergeItemNum($itemNumBundle, $itemNumPiece, $ratio);
-        $newStock = bcadd($productData['stock'], $itemNum, 2);
+        $oldStock = $productData->stock;
+        $newStock = bcadd($oldStock, $itemNum, 2);
         self::updateStockById($productId, $newStock);
         //上架
-        if ($newStock > 0 && $productData['status'] == 2) {
+        if ($newStock > 0 && $productData->status == 2) {
             self::changeStatus($productId, 1);
         }
 
-        util::unlock($key);
-        return ['oldStock' => $productData['stock'], 'newStock' => $newStock, 'itemNum' => $itemNum];
+        return ['oldStock' => $oldStock, 'newStock' => $newStock, 'itemNum' => $itemNum];
     }
 
     //加路上库存
     public static function addOnStockByItemNum($productId, $itemNum)
     {
-        $key = self::LOCK_ON_STOCK . '_' . $productId;
-        $lock = util::lock($key);
-        if (!$lock) {
-            util::fail("系统繁忙中,请稍后再试!");
-        }
         $productInfo = self::getLockById($productId);
         if (empty($productInfo)) {
-            util::unlock($key);
             util::fail("花材不存在");
         }
         $oldOnStock = $productInfo->onStock;
         $newOnStock = bcadd($oldOnStock, $itemNum);
         $productInfo->onStock = $newOnStock;
         $productInfo->save();
-        util::unlock($key);
         return ['oldOnStock' => $oldOnStock, 'newOnStock' => $newOnStock, 'itemNum' => $itemNum];
     }
 
     //加库存
     public static function addStockByItemNum($productId, $itemNum, $params = [])
     {
-        $key = self::LOCK_STOCK . '_' . $productId;
-        $lock = util::lock($key);
-        if (!$lock) {
-            util::fail("系统繁忙中,请稍后再试!");
-        }
-        $productData = self::getById($productId, true);
+        $productData = self::getLockById($productId);
         if (!$productData) {
-            util::unlock($key);
             util::fail("花材不存在");
         }
 
+        $oldStock = $productData->stock;
         $newItemCost = 0;
         $newItemStock = 0;
         $avCost = 0;
@@ -1028,7 +1010,7 @@ class ProductClass extends BaseClass
             $productData->save();
         }
 
-        $newStock = bcadd($productData->stock, $itemNum, 2);
+        $newStock = bcadd($oldStock, $itemNum, 2);
         self::updateStockById($productId, $newStock);
         //上架
         if ($newStock > 0 && $productData->status == 2) {
@@ -1039,8 +1021,7 @@ class ProductClass extends BaseClass
             $productData->delStatus = 0;
             $productData->save();
         }
-        util::unlock($key);
-        return ['oldStock' => $productData['stock'], 'newStock' => $newStock, 'itemNum' => $itemNum,
+        return ['oldStock' => $oldStock, 'newStock' => $newStock, 'itemNum' => $itemNum,
             'avCost' => $avCost, 'totalCost' => $newItemCost, 'totalStock' => $newItemStock, 'changeCost' => $changeCost, 'unitCost' => $unitCost];
     }
 
@@ -1052,21 +1033,16 @@ class ProductClass extends BaseClass
         //小单位库存不再考虑 ssh 20230303
         //$itemNumPiece = 0;
 
-        $key = self::LOCK_STOCK . '_' . $productId;
-        $lock = util::lock($key);
-        if (!$lock) {
-            util::fail("系统繁忙中,请稍后再试!");
-        }
-        $productData = self::getById($productId, true);
+        $productData = self::getLockById($productId);
         if (!$productData) {
-            util::unlock($key);
             util::fail("花材不存在");
         }
 
         $ratio = $productData->ratio;
         //转成小数,更新库存
         $itemNum = self::mergeItemNum($itemNumBundle, $itemNumPiece, $ratio);
-        $newStock = bcsub($productData->stock, $itemNum, 2);
+        $oldStock = $productData->stock;
+        $newStock = bcsub($oldStock, $itemNum, 2);
         $avCost = 0;
         $newItemStock = 0;
         $newItemCost = 0;
@@ -1105,9 +1081,8 @@ class ProductClass extends BaseClass
 
         if ($checkStock && $newStock < 0) {
             //需要判断库存是否充足
-            util::unlock($key);
             $currentName = $productData->name ?? '';
-            $currentRemainStock = floatval($productData->stock);
+            $currentRemainStock = floatval($oldStock);
             util::fail("{$currentName} 只剩{$currentRemainStock}{$productData->bigUnit}");
         }
         self::updateStockById($productId, $newStock);
@@ -1132,8 +1107,7 @@ class ProductClass extends BaseClass
             self::createRecoverLimitBuyCache($productId, $productData->limitBuy);
         }
 
-        util::unlock($key);
-        return ['oldStock' => $productData['stock'], 'newStock' => $newStock, 'itemNum' => $itemNum,
+        return ['oldStock' => $oldStock, 'newStock' => $newStock, 'itemNum' => $itemNum,
             'avCost' => $avCost, 'totalCost' => $newItemCost, 'totalStock' => $newItemStock, 'changeCost' => $changeCost, 'unitCost' => $unitCost];
     }
 
@@ -1141,56 +1115,41 @@ class ProductClass extends BaseClass
     //根据数量减库存
     public static function decreaseStockByItemNum($productId, $itemNum, $checkStock = false)
     {
-        $key = self::LOCK_STOCK . '_' . $productId;
-        $lock = util::lock($key);
-        if (!$lock) {
-            util::fail("系统繁忙中,请稍后再试!");
-        }
-        $productData = self::getById($productId);
+        $productData = self::getLockById($productId);
         if (!$productData) {
-            util::unlock($key);
             util::fail("花材不存在");
         }
-        $newStock = bcsub($productData['stock'], $itemNum, 2);
+        $oldStock = $productData->stock;
+        $newStock = bcsub($oldStock, $itemNum, 2);
         if ($checkStock && $newStock < 0) {
             //需要判断库存是否充足
-            util::unlock($key);
             util::fail("库存不足");
         }
         self::updateStockById($productId, $newStock);
 
         //减库存,加销量 2021.6.21 lqh
-        $actualSold = $productData['actualSold'];
+        $actualSold = $productData->actualSold;
         $newActualSold = bcadd($actualSold, $itemNum, 2);
         self::updateActualSoldById($productId, $newActualSold);
 
-        util::unlock($key);
-        return ['oldStock' => $productData['stock'], 'newStock' => $newStock, 'itemNum' => $itemNum];
+        return ['oldStock' => $oldStock, 'newStock' => $newStock, 'itemNum' => $itemNum];
     }
 
     //减少在路上的库存
     public static function decreaseOnStockByItemNum($productId, $itemNum, $checkStock = false)
     {
-        $key = self::LOCK_ON_STOCK . '_' . $productId;
-        $lock = util::lock($key);
-        if (!$lock) {
-            util::fail("系统繁忙中,请稍后再试!");
-        }
         $productInfo = self::getLockById($productId);
         if (empty($productInfo)) {
-            util::unlock($key);
             util::fail("花材不存在");
         }
         $oldOnStock = $productInfo->onStock;
         $newOnStock = bcsub($oldOnStock, $itemNum);
         if ($checkStock && $newOnStock < 0) {
             //需要判断库存是否充足
-            util::unlock($key);
             util::fail("库存不足");
         }
         $productInfo->onStock = $newOnStock;
         $productInfo->save();
-        util::unlock($key);
         return ['oldOnStock' => $oldOnStock, 'newOnStock' => $newOnStock, 'itemNum' => $itemNum];
     }
 
@@ -3356,4 +3315,3 @@ class ProductClass extends BaseClass
         return true;
     }
 }
-

+ 57 - 0
common/components/util.php

@@ -404,6 +404,63 @@ class util
         return $redis->del($key);
     }
 
+    /**
+     * 获取安全 Redis 锁,返回释放锁所需 token。库存一致性不能依赖 Redis 锁,库存请使用数据库行锁。
+     */
+    public static function tryLock($key, $expire = 5, $waitTime = 5)
+    {
+        $redis = Yii::$app->redis;
+        $token = md5(uniqid('', true) . mt_rand());
+        $exitTime = microtime(true) + $waitTime;
+        do {
+            $locked = $redis->executeCommand('SET', [$key, $token, 'EX', $expire, 'NX']);
+            if ($locked) {
+                return $token;
+            }
+            usleep(50000);
+        } while (microtime(true) < $exitTime);
+        return false;
+    }
+
+    /**
+     * 只释放自己持有的 Redis 锁,避免误删其他请求新拿到的锁。
+     */
+    public static function releaseLock($key, $token)
+    {
+        if (empty($token)) {
+            return false;
+        }
+        $script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
+        return Yii::$app->redis->executeCommand('EVAL', [$script, 1, $key, $token]);
+    }
+
+    public static function isDbConcurrencyException($exception)
+    {
+        $message = $exception->getMessage();
+        return strpos($message, 'Deadlock found') !== false
+            || strpos($message, 'Serialization failure') !== false
+            || strpos($message, 'Lock wait timeout exceeded') !== false
+            || strpos($message, 'SQLSTATE[40001]') !== false
+            || strpos($message, 'SQLSTATE[HY000]: General error: 1205') !== false;
+    }
+
+    public static function runWithDbConcurrencyRetry($callback, $maxRetry = 2)
+    {
+        $attempt = 0;
+        while (true) {
+            try {
+                return call_user_func($callback);
+            } catch (\Exception $exception) {
+                if (!self::isDbConcurrencyException($exception) || $attempt >= $maxRetry) {
+                    throw $exception;
+                }
+                $attempt++;
+                Yii::warning('数据库并发冲突,准备重试第' . $attempt . '次:' . $exception->getMessage());
+                usleep(mt_rand(30000, 120000));
+            }
+        }
+    }
+
     //检查是否有重复提交 ssh 20211018
     public static function checkRepeatCommit($adminId, $second = 10)
     {

+ 417 - 0
scripts/test_ghs_stock_concurrency.php

@@ -0,0 +1,417 @@
+#!/usr/bin/env php
+<?php
+/**
+ * Concurrently calls app-ghs order/create-order and stock-out/create-order,
+ * then compares xhGhsItem.stock with the expected stock after successful calls.
+ *
+ * WARNING: this script sends real requests to api.shop.hzghd.com and creates
+ * real business records. It does not rollback stock, orders, or stock-out rows.
+ *
+ * Usage:
+ *   php scripts/test_ghs_stock_concurrency.php
+ *   php scripts/test_ghs_stock_concurrency.php --rounds=10
+ *   php scripts/test_ghs_stock_concurrency.php --order=2 --stock-out=2 --rounds=3
+ */
+
+require(__DIR__ . '/../vendor/autoload.php');
+require(__DIR__ . '/../env.php');
+require(__DIR__ . '/../vendor/yiisoft/yii2/Yii.php');
+require(__DIR__ . '/../common/config/bootstrap.php');
+require(__DIR__ . '/../console/config/bootstrap.php');
+
+$config = yii\helpers\ArrayHelper::merge(
+    require(__DIR__ . '/../common/config/main.php'),
+    require(__DIR__ . '/../common/config/main-local.php'),
+    require(__DIR__ . '/../console/config/main.php'),
+    require(__DIR__ . '/../console/config/main-local.php')
+);
+
+unset($config['components']['request']);
+new yii\console\Application($config);
+
+if (!extension_loaded('curl')) {
+    fwrite(STDERR, "ERROR: PHP curl extension is required.\n");
+    exit(1);
+}
+
+$options = getopt('', ['order::', 'stock-out::', 'rounds::']);
+$orderCount = normalizePositiveInt($options, 'order', 1);
+$stockOutCount = normalizePositiveInt($options, 'stock-out', 1);
+$rounds = normalizePositiveInt($options, 'rounds', 1);
+
+$baseUrl = 'http://api.shop.hzghd.com';
+$productIds = [27286, 27282];
+
+$commonHeaders = [
+    'Connection: keep-alive',
+    'account: 12362',
+    'content-type: application/x-www-form-urlencoded;charset=UTF-8',
+    'User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 17_0_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0.1 Mobile/21A360 Safari/604.1 wechatdevtools/2.01.2510260 MicroMessenger/8.0.5 Language/zh_CN webview/ hash/1237023557 sid/o7x10OLPfZ',
+    'token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImp0aSI6IjBfOTMyIn0.eyJpc3MiOiJodHRwczpcL1wvYXBpLnNob3AuaHpnaGQuY29tIiwiYXVkIjoiaHR0cHM6XC9cL2FwaS5zaG9wLmh6Z2hkLmNvbSIsImp0aSI6IjBfOTMyIiwiaWF0IjoxNzc5Mjc1MzgxLCJuYmYiOjE3NzkyNzUzODEsImV4cCI6MTg3Mzg4MzM4MSwidW5pcXVlSWQiOjkzMiwic291cmNlSWQiOjB9.OVuQTjXMvklAFnYdIIRGVR2D5VK14Dp6QX5bDQAUHyE',
+    'appVersion: 2',
+    'Accept: */*',
+    'Sec-Fetch-Site: cross-site',
+    'Sec-Fetch-Mode: cors',
+    'Sec-Fetch-Dest: empty',
+    'Referer: https://servicewechat.com/wx21b7c3ef12082099/devtools/page-frame.html',
+    'Accept-Language: zh-CN,zh;q=0.9',
+];
+
+$orderBody = 'shopId=12986&shopName=&sendType=0&transType=4&sendDate=&sendTimeWant=&historyDate=&sendCost=&customSendCost=0&customId=914&packCost=&product=%5B%7B%22productId%22%3A%2227286%22%2C%22bigNum%22%3A2%2C%22smallNum%22%3A0%2C%22classId%22%3A%223322%22%2C%22itemId%22%3A%221009%22%2C%22price%22%3A%2227.00%22%2C%22remark%22%3A%22%22%7D%2C%7B%22productId%22%3A%2227282%22%2C%22bigNum%22%3A2%2C%22smallNum%22%3A0%2C%22classId%22%3A%223322%22%2C%22itemId%22%3A%221005%22%2C%22price%22%3A%220.30%22%2C%22remark%22%3A%22%22%7D%5D&remark=&payWay=0&needPrint=1&hasPay=2&getStaffId=0&getStaffName=&dealPrice=0&wlName=&reductionRule=0&callErrand=0&weight=1&deliveryRemark=&customName=%E5%BE%AE%E5%8D%87%E6%BC%AB%E8%8A%B1&modifyPrice=54.6&newVersion=1&book=0&xj=%22%22&treeData=%22%22';
+$stockOutBody = 'confirmIn=1&date=2026-05-25&remark=123&inShopId=37043&itemInfo=%5B%7B%22productId%22%3A%2227286%22%2C%22bigNum%22%3A1%2C%22smallNum%22%3A0%2C%22name%22%3A%22%E8%8B%8F%E9%86%92%22%7D%2C%7B%22productId%22%3A%2227282%22%2C%22bigNum%22%3A1%2C%22smallNum%22%3A0%2C%22name%22%3A%22%E6%B4%9B%E7%A5%9E%22%7D%5D&action=confirm';
+
+$requestDefs = [
+    'order' => [
+        'url' => $baseUrl . '/order/create-order',
+        'body' => $orderBody,
+        'stockDelta' => [27286 => '2', 27282 => '2'],
+    ],
+    'stock-out' => [
+        'url' => $baseUrl . '/stock-out/create-order',
+        'body' => $stockOutBody,
+        'stockDelta' => [27286 => '1', 27282 => '1'],
+    ],
+];
+
+echo "app-ghs stock concurrency test\n";
+echo "Target: {$baseUrl}\n";
+echo "Rounds: {$rounds}, order/create-order: {$orderCount}, stock-out/create-order: {$stockOutCount}\n";
+echo "Products: " . implode(', ', $productIds) . "\n";
+echo "WARNING: real requests will create real orders and decrease real stock.\n\n";
+
+$overallPass = true;
+$overallInvalid = false;
+$overallConcurrencyError = false;
+$appLogFile = __DIR__ . '/../app-ghs/runtime/logs/app.log';
+
+for ($round = 1; $round <= $rounds; $round++) {
+    echo "========== Round {$round}/{$rounds} ==========\n";
+    $before = loadStocks($productIds);
+    printStocks('Before', $before, $productIds);
+
+    $requests = buildRequests($requestDefs, $orderCount, $stockOutCount);
+    $logOffset = getFileSize($appLogFile);
+    $responses = runConcurrentRequests($requests, $commonHeaders);
+    $newLog = readFileFromOffset($appLogFile, $logOffset);
+    $concurrencyErrors = findConcurrencyErrors($newLog);
+
+    foreach ($responses as $response) {
+        $ok = $response['businessSuccess'] ? 'OK' : 'ERR';
+        echo sprintf(
+            "[%s] %-18s HTTP %s %.3fs %s\n",
+            $ok,
+            $response['label'],
+            $response['httpCode'],
+            $response['totalTime'],
+            $response['summary']
+        );
+    }
+
+    if (!empty($concurrencyErrors)) {
+        echo "Concurrency errors from app log:\n";
+        foreach ($concurrencyErrors as $errorLine) {
+            echo "  - " . $errorLine . "\n";
+        }
+    }
+
+    $successCounts = countSuccessfulRequests($responses);
+    $after = loadStocks($productIds);
+    $expected = calcExpectedStocks($before, $requestDefs, $successCounts, $productIds);
+
+    printStocks('Expected after successful requests', $expected, $productIds);
+    printStocks('Actual', $after, $productIds);
+    printDiff($expected, $after, $productIds);
+
+    $allBusinessSuccess = count($responses) === array_sum($successCounts);
+    $stockMatches = stocksMatch($expected, $after, $productIds);
+
+    if (!$allBusinessSuccess && !empty($concurrencyErrors)) {
+        $overallConcurrencyError = true;
+        echo "Round result: CONCURRENCY_ERROR (business request failed because concurrent stock update hit a database lock/deadlock)\n\n";
+        continue;
+    }
+
+    if (!$allBusinessSuccess) {
+        $overallInvalid = true;
+        echo "Round result: INVALID (one or more business requests failed; this round is not a valid concurrency conclusion)\n\n";
+        continue;
+    }
+
+    if ($stockMatches) {
+        echo "Round result: PASS\n\n";
+    } else {
+        $overallPass = false;
+        echo "Round result: FAIL (stock mismatch after all business requests succeeded)\n\n";
+    }
+}
+
+if ($overallConcurrencyError) {
+    echo "FINAL: CONCURRENCY_ERROR\n";
+    exit(4);
+}
+
+if ($overallInvalid) {
+    echo "FINAL: INVALID (at least one round had failed business responses)\n";
+}
+
+if (!$overallInvalid && $overallPass) {
+    echo "FINAL: PASS\n";
+    exit(0);
+}
+
+if (!$overallPass) {
+    echo "FINAL: FAIL\n";
+    exit(2);
+}
+
+exit(3);
+
+function normalizePositiveInt($options, $key, $default)
+{
+    if (!isset($options[$key]) || $options[$key] === false || $options[$key] === '') {
+        return $default;
+    }
+    $value = (int)$options[$key];
+    if ($value < 1) {
+        fwrite(STDERR, "ERROR: --{$key} must be a positive integer.\n");
+        exit(1);
+    }
+    return $value;
+}
+
+function loadStocks($productIds)
+{
+    $rows = Yii::$app->db->createCommand(
+        'select id, stock from xhGhsItem where id in (' . implode(',', array_map('intval', $productIds)) . ')'
+    )->queryAll();
+    $stocks = [];
+    foreach ($rows as $row) {
+        $stocks[(int)$row['id']] = (string)$row['stock'];
+    }
+    foreach ($productIds as $productId) {
+        if (!array_key_exists($productId, $stocks)) {
+            throw new RuntimeException("Product {$productId} was not found in xhGhsItem.");
+        }
+    }
+    return $stocks;
+}
+
+function buildRequests($requestDefs, $orderCount, $stockOutCount)
+{
+    $requests = [];
+    for ($i = 1; $i <= $orderCount; $i++) {
+        $requests[] = [
+            'type' => 'order',
+            'label' => 'order#' . $i,
+            'url' => $requestDefs['order']['url'],
+            'body' => $requestDefs['order']['body'],
+        ];
+    }
+    for ($i = 1; $i <= $stockOutCount; $i++) {
+        $requests[] = [
+            'type' => 'stock-out',
+            'label' => 'stock-out#' . $i,
+            'url' => $requestDefs['stock-out']['url'],
+            'body' => $requestDefs['stock-out']['body'],
+        ];
+    }
+    return $requests;
+}
+
+function runConcurrentRequests($requests, $headers)
+{
+    $multi = curl_multi_init();
+    $handles = [];
+
+    foreach ($requests as $index => $request) {
+        $ch = curl_init($request['url']);
+        curl_setopt_array($ch, [
+            CURLOPT_POST => true,
+            CURLOPT_POSTFIELDS => $request['body'],
+            CURLOPT_HTTPHEADER => $headers,
+            CURLOPT_RETURNTRANSFER => true,
+            CURLOPT_ENCODING => '',
+            CURLOPT_CONNECTTIMEOUT => 10,
+            CURLOPT_TIMEOUT => 60,
+        ]);
+        curl_multi_add_handle($multi, $ch);
+        $handles[$index] = [
+            'handle' => $ch,
+            'request' => $request,
+            'startedAt' => microtime(true),
+        ];
+    }
+
+    $running = null;
+    do {
+        $status = curl_multi_exec($multi, $running);
+        if ($running) {
+            $selected = curl_multi_select($multi, 1.0);
+            if ($selected === -1) {
+                usleep(1000);
+            }
+        }
+    } while ($running && $status === CURLM_OK);
+
+    $responses = [];
+    foreach ($handles as $item) {
+        $ch = $item['handle'];
+        $body = curl_multi_getcontent($ch);
+        $info = curl_getinfo($ch);
+        $error = curl_error($ch);
+        $httpCode = isset($info['http_code']) ? (int)$info['http_code'] : 0;
+        $totalTime = isset($info['total_time']) ? (float)$info['total_time'] : microtime(true) - $item['startedAt'];
+        $parsed = parseBusinessResponse($body, $httpCode, $error);
+
+        $responses[] = [
+            'type' => $item['request']['type'],
+            'label' => $item['request']['label'],
+            'httpCode' => $httpCode,
+            'totalTime' => $totalTime,
+            'businessSuccess' => $parsed['success'],
+            'summary' => $parsed['summary'],
+        ];
+
+        curl_multi_remove_handle($multi, $ch);
+        curl_close($ch);
+    }
+    curl_multi_close($multi);
+
+    return $responses;
+}
+
+function parseBusinessResponse($body, $httpCode, $curlError)
+{
+    if ($curlError !== '') {
+        return ['success' => false, 'summary' => 'curl_error=' . $curlError];
+    }
+
+    $decoded = json_decode($body, true);
+    if (is_array($decoded)) {
+        $code = isset($decoded['code']) ? $decoded['code'] : null;
+        $msg = isset($decoded['msg']) ? $decoded['msg'] : '';
+        $success = ($httpCode >= 200 && $httpCode < 300 && (string)$code === '1');
+        return [
+            'success' => $success,
+            'summary' => 'code=' . json_encode($code, JSON_UNESCAPED_UNICODE) . ' msg=' . trimText((string)$msg, 180),
+        ];
+    }
+
+    return [
+        'success' => false,
+        'summary' => trimText(preg_replace('/\s+/', ' ', (string)$body), 240),
+    ];
+}
+
+function countSuccessfulRequests($responses)
+{
+    $counts = ['order' => 0, 'stock-out' => 0];
+    foreach ($responses as $response) {
+        if ($response['businessSuccess']) {
+            $counts[$response['type']]++;
+        }
+    }
+    return $counts;
+}
+
+function calcExpectedStocks($before, $requestDefs, $successCounts, $productIds)
+{
+    $expected = $before;
+    foreach ($successCounts as $type => $count) {
+        foreach ($productIds as $productId) {
+            $delta = isset($requestDefs[$type]['stockDelta'][$productId])
+                ? $requestDefs[$type]['stockDelta'][$productId]
+                : '0';
+            if ($count > 0 && bccomp($delta, '0', 2) !== 0) {
+                $expected[$productId] = bcsub($expected[$productId], bcmul($delta, (string)$count, 2), 2);
+            }
+        }
+    }
+    return $expected;
+}
+
+function stocksMatch($expected, $actual, $productIds)
+{
+    foreach ($productIds as $productId) {
+        if (bccomp((string)$expected[$productId], (string)$actual[$productId], 2) !== 0) {
+            return false;
+        }
+    }
+    return true;
+}
+
+function printStocks($title, $stocks, $productIds)
+{
+    echo $title . ': ';
+    $parts = [];
+    foreach ($productIds as $productId) {
+        $parts[] = $productId . '=' . $stocks[$productId];
+    }
+    echo implode(', ', $parts) . "\n";
+}
+
+function printDiff($expected, $actual, $productIds)
+{
+    echo 'Diff(actual - expected): ';
+    $parts = [];
+    foreach ($productIds as $productId) {
+        $parts[] = $productId . '=' . bcsub((string)$actual[$productId], (string)$expected[$productId], 2);
+    }
+    echo implode(', ', $parts) . "\n";
+}
+
+function trimText($text, $maxLength)
+{
+    $text = trim($text);
+    if (function_exists('mb_strlen') && function_exists('mb_substr')) {
+        return mb_strlen($text, 'UTF-8') > $maxLength
+            ? mb_substr($text, 0, $maxLength, 'UTF-8') . '...'
+            : $text;
+    }
+    return strlen($text) > $maxLength ? substr($text, 0, $maxLength) . '...' : $text;
+}
+
+function getFileSize($file)
+{
+    clearstatcache(true, $file);
+    return is_file($file) ? filesize($file) : 0;
+}
+
+function readFileFromOffset($file, $offset)
+{
+    if (!is_file($file)) {
+        return '';
+    }
+    $handle = fopen($file, 'rb');
+    if (!$handle) {
+        return '';
+    }
+    fseek($handle, $offset);
+    $content = stream_get_contents($handle);
+    fclose($handle);
+    return $content === false ? '' : $content;
+}
+
+function findConcurrencyErrors($logContent)
+{
+    if ($logContent === '') {
+        return [];
+    }
+
+    $patterns = [
+        'Deadlock found',
+        'Serialization failure',
+        'Lock wait timeout exceeded',
+    ];
+    $lines = preg_split('/\r\n|\r|\n/', $logContent);
+    $matched = [];
+    foreach ($lines as $line) {
+        foreach ($patterns as $pattern) {
+            if (strpos($line, $pattern) !== false) {
+                $matched[] = trimText(trim($line), 240);
+                break;
+            }
+        }
+    }
+    return array_values(array_unique(array_filter($matched)));
+}