Browse Source

Merge branch 'master' of http://git.huaml.com/zhh/huahuibao

shish 2 months ago
parent
commit
0b504bc1cc

+ 25 - 11
app-ghs/controllers/OrderController.php

@@ -1507,18 +1507,29 @@ class OrderController extends BaseController
             }
         }
 
+        $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);
+
         $connection = Yii::$app->db;
-        $transaction = $connection->beginTransaction();
         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 ($connection, $post, $custom, $hasPay) {
+                $transaction = $connection->beginTransaction();
+                try {
+                    $return = OrderService::createNewOrder($post, $custom, $hasPay);
+                    $transaction->commit();
+                    return $return;
+                } catch (\Exception $exception) {
+                    if ($transaction->isActive) {
+                        $transaction->rollBack();
+                    }
+                    throw $exception;
+                }
+            });
 
             $saleId = $return->id ?? 0;
             $order = OrderClass::getById($saleId, true);
@@ -1582,10 +1593,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('下单失败');
+            }
         }
     }
 

+ 75 - 31
app-ghs/controllers/PurchaseOrderController.php

@@ -311,19 +311,32 @@ class PurchaseOrderController extends BaseController
         if ($cg->status == PurchaseOrderClass::PURCHASE_ORDER_STATUS_COMPLETE) {
             util::fail('已入库,无法取消');
         }
-        $connection = Yii::$app->db;
-        $transaction = $connection->beginTransaction();
         try {
-            $shop = $this->shop;
-            $staff = $this->shopAdmin;
-            $staffId = $staff->id ?? 0;
-            $staffName = $staff->name ?? '';
-            $params = ['staffId' => $staffId, 'staffName' => $staffName, 'staff' => $staff];
-            PurchaseOrderClass::cancel($cg, $shop, $params);
-            $transaction->commit();
+            util::runWithDbConcurrencyRetry(function () use ($cg) {
+                $connection = Yii::$app->db;
+                $transaction = $connection->beginTransaction();
+                try {
+                    $shop = $this->shop;
+                    $staff = $this->shopAdmin;
+                    $staffId = $staff->id ?? 0;
+                    $staffName = $staff->name ?? '';
+                    $params = ['staffId' => $staffId, 'staffName' => $staffName, 'staff' => $staff];
+                    PurchaseOrderClass::cancel($cg, $shop, $params);
+                    $transaction->commit();
+                } catch (\Exception $exception) {
+                    if ($transaction->isActive) {
+                        $transaction->rollBack();
+                    }
+                    throw $exception;
+                }
+            });
             util::complete();
         } catch (\Exception $e) {
-            util::fail('取消失败');
+            if (util::isDbConcurrencyException($e)) {
+                util::fail('系统繁忙中,请稍后再试');
+            } else {
+                util::fail('取消失败');
+            }
         }
     }
 
@@ -338,15 +351,9 @@ class PurchaseOrderController extends BaseController
             util::fail('超管才能修改');
         }
 
-        $connection = Yii::$app->db;
-        $transaction = $connection->beginTransaction();
         try {
             $get = Yii::$app->request->get();
             $id = $get['id'] ?? 0;
-            $cg = PurchaseOrderClass::getLockById($id);
-            if (!isset($cg->mainId) || $cg->mainId != $this->mainId) {
-                util::fail('请确认是否您的采购单');
-            }
 
             //解决重复请求问题
             $cacheKey = 'confirm_put_in_action_' . $id;
@@ -356,14 +363,31 @@ class PurchaseOrderController extends BaseController
             }
             Yii::$app->redis->executeCommand('SETEX', [$cacheKey, 5, 'has']);
 
-            $staff = $this->shopAdmin;
-            $data = [];
-            $data['staffId'] = $staff->id ?? 0;
-            $data['staffName'] = $staff->name ?? '';
-            $data['adminId'] = $this->adminId ?? 0;
-            $data['shop'] = $this->shop;
-            $return = PurchaseOrderClass::putIn($cg, $data);
-            $transaction->commit();
+            $return = util::runWithDbConcurrencyRetry(function () use ($id) {
+                $connection = Yii::$app->db;
+                $transaction = $connection->beginTransaction();
+                try {
+                    $cg = PurchaseOrderClass::getLockById($id);
+                    if (!isset($cg->mainId) || $cg->mainId != $this->mainId) {
+                        util::fail('请确认是否您的采购单');
+                    }
+
+                    $staff = $this->shopAdmin;
+                    $data = [];
+                    $data['staffId'] = $staff->id ?? 0;
+                    $data['staffName'] = $staff->name ?? '';
+                    $data['adminId'] = $this->adminId ?? 0;
+                    $data['shop'] = $this->shop;
+                    $return = PurchaseOrderClass::putIn($cg, $data);
+                    $transaction->commit();
+                    return $return;
+                } catch (\Exception $exception) {
+                    if ($transaction->isActive) {
+                        $transaction->rollBack();
+                    }
+                    throw $exception;
+                }
+            });
 
             //入库成功通知相关人员,关键词 put_in_notice
             //$shop = $this->shop;
@@ -371,10 +395,13 @@ class PurchaseOrderController extends BaseController
 
             util::success($return);
         } catch (\Exception $e) {
-            $transaction->rollBack();
             noticeUtil::push('确认入库失败,原因:' . $e->getMessage(), '15280215347');
             Yii::error("确认入库失败,原因:" . $e->getMessage());
-            util::fail('确认失败');
+            if (util::isDbConcurrencyException($e)) {
+                util::fail('系统繁忙中,请稍后再试');
+            } else {
+                util::fail('确认失败');
+            }
         }
     }
 
@@ -422,9 +449,11 @@ class PurchaseOrderController extends BaseController
             util::fail('车销不能采购');
         }
 
-        $connection = Yii::$app->db;
-        $transaction = $connection->beginTransaction();
         try {
+            $result = util::runWithDbConcurrencyRetry(function () use ($post, $staff, $shop, $shopAdmin) {
+                $connection = Yii::$app->db;
+                $transaction = $connection->beginTransaction();
+                try {
             // [{productId:0,itemPrice:1,bigNum:1,smallNum:0}]
             $ghsItemInfo = $post['itemInfo'] ?? '';
             if (empty($ghsItemInfo)) {
@@ -553,6 +582,18 @@ class PurchaseOrderController extends BaseController
                 $order->save();
             }
             $transaction->commit();
+                    return ['order' => $order, 'bookSn' => $bookSn, 'inType' => $inType];
+                } catch (\Exception $exception) {
+                    if ($transaction->isActive) {
+                        $transaction->rollBack();
+                    }
+                    throw $exception;
+                }
+            });
+
+            $order = $result['order'];
+            $bookSn = $result['bookSn'];
+            $inType = $result['inType'];
 
             if (isset($order->needPrint) && $order->needPrint == 1) {
                 PurchaseOrderClass::printTicket($order);
@@ -571,10 +612,13 @@ class PurchaseOrderController extends BaseController
             }
             util::success($order);
         } catch (\Exception $e) {
-            $transaction->rollBack();
             noticeUtil::push('批发店采购入库失败,原因:' . $e->getMessage(), '15280215347');
             Yii::error("操作失败原因:" . $e->getMessage());
-            util::fail('操作失败');
+            if (util::isDbConcurrencyException($e)) {
+                util::fail('系统繁忙中,请稍后再试');
+            } else {
+                util::fail('操作失败');
+            }
         }
     }
 
@@ -1150,4 +1194,4 @@ class PurchaseOrderController extends BaseController
 
     }
 
-}
+}

+ 5 - 2
app-ghs/controllers/PurchaseOrderItemController.php

@@ -133,7 +133,10 @@ class PurchaseOrderItemController extends BaseController
                 $list[$key]['beforeModifyPrice'] = $beforeModifyPrice;
                 $beforeModifyStock = $current['stock'] ?? 0;
                 $list[$key]['beforeModifyStock'] = $beforeModifyStock;
-                $list[$key]['limitBuy'] = intval($current['limitBuy'] ?? 0);
+                $list[$key]['limitBuy'] = intval($current['limitBuy']);
+                $list[$key]['reachNum'] = $current['reachNum'];
+                $list[$key]['reachNumDiscount'] = $current['reachNumDiscount'];
+                $list[$key]['discountPrice'] = $current['discountPrice'];
                 $list[$key]['itemRemark'] = $current['itemRemark'] ?? '';
             }
         }
@@ -157,4 +160,4 @@ class PurchaseOrderItemController extends BaseController
         PurchaseOrderItemClass::exportExcel($order, $list, $this->mainId);
     }
 
-}
+}

+ 52 - 39
app-ghs/controllers/RefundController.php

@@ -650,49 +650,62 @@ class RefundController extends BaseController
         }
         Yii::$app->redis->executeCommand('SETEX', [$cacheKey, 5, 'has']);
 
-        $connection = Yii::$app->db;
-        $transaction = $connection->beginTransaction();
         try {
-            if ($refundType == RefundOrderClass::REFUND_TYPE_MONEY_GOOD) {
-                //花材列表结构
-                // [{productId:0,num:1,unitType:0,unitPrice:12,unitName:'扎'}]
-                $productJson = $post['product'] ?? '';
-                if (empty($productJson)) {
-                    util::fail('请选择花材');
+            $respond = util::runWithDbConcurrencyRetry(function () use ($post, $id, $refundType) {
+                $connection = Yii::$app->db;
+                $transaction = $connection->beginTransaction();
+                try {
+                    if ($refundType == RefundOrderClass::REFUND_TYPE_MONEY_GOOD) {
+                        //花材列表结构
+                        // [{productId:0,num:1,unitType:0,unitPrice:12,unitName:'扎'}]
+                        $productJson = $post['product'] ?? '';
+                        if (empty($productJson)) {
+                            util::fail('请选择花材');
+                        }
+                        $productList = json_decode($productJson, true);
+                        if (!is_array($productList)) {
+                            util::fail('请选择花材哦');
+                        }
+                        $post['product'] = $productList;
+                    } else {
+                        //仅退款花材直接设置为空
+                        $post['product'] = [];
+                    }
+                    $post['price'] = $post['price'] ?? 0;
+                    if ($post['price'] <= 0) {
+                        util::fail("退款金额不能小于0");
+                    }
+
+                    $post['shopId'] = $this->shopId;
+                    $post['sjId'] = $this->sjId;
+                    $post['shopAdminId'] = $this->shopAdminId;
+                    $post['mainId'] = $this->mainId;
+                    $shopAdmin = $this->shopAdmin;
+                    $adminName = $shopAdmin['name'] ?? '';
+                    $post['shopAdminName'] = $adminName;
+                    $respond = OrderService::refund($id, $post);
+
+                    $refundId = $respond->id;
+                    $refundInfo = RefundOrderClass::getById($refundId, true);
+                    RefundOrderService::passRefund($refundInfo);
+
+                    $transaction->commit();
+                    return $respond;
+                } catch (\Exception $exception) {
+                    if ($transaction->isActive) {
+                        $transaction->rollBack();
+                    }
+                    throw $exception;
                 }
-                $productList = json_decode($productJson, true);
-                if (!is_array($productList)) {
-                    util::fail('请选择花材哦');
-                }
-                $post['product'] = $productList;
-            } else {
-                //仅退款花材直接设置为空
-                $post['product'] = [];
-            }
-            $post['price'] = $post['price'] ?? 0;
-            if ($post['price'] <= 0) {
-                util::fail("退款金额不能小于0");
-            }
-
-            $post['shopId'] = $this->shopId;
-            $post['sjId'] = $this->sjId;
-            $post['shopAdminId'] = $this->shopAdminId;
-            $post['mainId'] = $this->mainId;
-            $shopAdmin = $this->shopAdmin;
-            $adminName = $shopAdmin['name'] ?? '';
-            $post['shopAdminName'] = $adminName;
-            $respond = OrderService::refund($id, $post);
-
-            $refundId = $respond->id;
-            $refundInfo = RefundOrderClass::getById($refundId, true);
-            RefundOrderService::passRefund($refundInfo);
-
-            $transaction->commit();
+            });
             util::success($respond);
         } catch (\Exception $exception) {
-            $transaction->rollBack();
             Yii::info("退款出错了,报错信息:" . $exception->getMessage());
-            util::fail('操作失败');
+            if (util::isDbConcurrencyException($exception)) {
+                util::fail('系统繁忙中,请稍后再试');
+            } else {
+                util::fail('操作失败');
+            }
         }
     }
 
@@ -727,4 +740,4 @@ class RefundController extends BaseController
         util::complete('修改成功');
     }
 
-}
+}

+ 42 - 10
app-ghs/controllers/StockInController.php

@@ -153,16 +153,28 @@ class StockInController extends BaseController
     {
         $get = Yii::$app->request->get();
         $orderSn = $get['orderSn'];
-        $connection = Yii::$app->db;
-        $transaction = $connection->beginTransaction();
         try {
-            StockInOrderClass::confirmOrder($orderSn, $this->shop, $this->adminId);
-            $transaction->commit();
+            util::runWithDbConcurrencyRetry(function () use ($orderSn) {
+                $connection = Yii::$app->db;
+                $transaction = $connection->beginTransaction();
+                try {
+                    StockInOrderClass::confirmOrder($orderSn, $this->shop, $this->adminId);
+                    $transaction->commit();
+                } catch (\Exception $exception) {
+                    if ($transaction->isActive) {
+                        $transaction->rollBack();
+                    }
+                    throw $exception;
+                }
+            });
             util::complete("入库成功");
         } catch (\Exception $exception) {
             Yii::info("确认入库报错:" . $exception->getMessage());
-            $transaction->rollBack();
-            util::fail();
+            if (util::isDbConcurrencyException($exception)) {
+                util::fail('系统繁忙中,请稍后再试');
+            } else {
+                util::fail();
+            }
         }
     }
 
@@ -179,10 +191,30 @@ class StockInController extends BaseController
         if (isset($order->inShopId) == false || $order->inShopId != $this->shopId) {
             util::fail('没有权限');
         }
-        $id = $order->id ?? 0;
-        $info = StockInOrderClass::getLockById($id);
-        StockInOrderClass::cancelOrder($info, $this->adminId);
-        util::complete("取消成功");
+        try {
+            util::runWithDbConcurrencyRetry(function () use ($order) {
+                $connection = Yii::$app->db;
+                $transaction = $connection->beginTransaction();
+                try {
+                    $id = $order->id ?? 0;
+                    $info = StockInOrderClass::getLockById($id);
+                    StockInOrderClass::cancelOrder($info, $this->adminId);
+                    $transaction->commit();
+                } catch (\Exception $exception) {
+                    if ($transaction->isActive) {
+                        $transaction->rollBack();
+                    }
+                    throw $exception;
+                }
+            });
+            util::complete("取消成功");
+        } catch (\Exception $exception) {
+            if (util::isDbConcurrencyException($exception)) {
+                util::fail('系统繁忙中,请稍后再试');
+            } else {
+                util::fail('取消失败');
+            }
+        }
     }
 
 }

+ 86 - 36
app-ghs/controllers/StockOutController.php

@@ -262,44 +262,54 @@ 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) {
+                    if ($transaction->isActive) {
+                        $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());
+            }
         }
     }
 
@@ -316,8 +326,28 @@ class StockOutController extends BaseController
     {
         $get = Yii::$app->request->get();
         $orderSn = $get['orderSn'];
-        StockOutOrderClass::confirmOrder($orderSn, $this->shopId, $this->adminId);
-        util::complete("出库成功");
+        try {
+            util::runWithDbConcurrencyRetry(function () use ($orderSn) {
+                $connection = Yii::$app->db;
+                $transaction = $connection->beginTransaction();
+                try {
+                    StockOutOrderClass::confirmOrder($orderSn, $this->shopId, $this->adminId);
+                    $transaction->commit();
+                } catch (\Exception $exception) {
+                    if ($transaction->isActive) {
+                        $transaction->rollBack();
+                    }
+                    throw $exception;
+                }
+            });
+            util::complete("出库成功");
+        } catch (\Exception $exception) {
+            if (util::isDbConcurrencyException($exception)) {
+                util::fail('系统繁忙中,请稍后再试');
+            } else {
+                util::fail($exception->getMessage());
+            }
+        }
     }
 
 
@@ -326,8 +356,28 @@ class StockOutController extends BaseController
     {
         $get = Yii::$app->request->get();
         $orderSn = $get['orderSn'];
-        StockOutOrderClass::cancelOrder($orderSn, $this->shopId, $this->adminId);
-        util::complete("取消成功");
+        try {
+            util::runWithDbConcurrencyRetry(function () use ($orderSn) {
+                $connection = Yii::$app->db;
+                $transaction = $connection->beginTransaction();
+                try {
+                    StockOutOrderClass::cancelOrder($orderSn, $this->shopId, $this->adminId);
+                    $transaction->commit();
+                } catch (\Exception $exception) {
+                    if ($transaction->isActive) {
+                        $transaction->rollBack();
+                    }
+                    throw $exception;
+                }
+            });
+            util::complete("取消成功");
+        } catch (\Exception $exception) {
+            if (util::isDbConcurrencyException($exception)) {
+                util::fail('系统繁忙中,请稍后再试');
+            } else {
+                util::fail($exception->getMessage());
+            }
+        }
     }
 
     //打印出库单 ssh 20231026

+ 22 - 9
app-ghs/controllers/WastageController.php

@@ -63,19 +63,32 @@ class WastageController extends BaseController
                 util::fail('花材数量不能为0');
             }
         }
-        $connection = Yii::$app->db;
-        $transaction = $connection->beginTransaction();
         try {
-            //判断花材有效性
-            ProductClass::valid($productList, $this->mainId);
-            $post['product'] = $productList;
-            $return = StockWastageOrderClass::addOrder($post, $this->shop, true);
-            $transaction->commit();
+            $return = util::runWithDbConcurrencyRetry(function () use ($post, $productList) {
+                $connection = Yii::$app->db;
+                $transaction = $connection->beginTransaction();
+                try {
+                    //判断花材有效性
+                    ProductClass::valid($productList, $this->mainId);
+                    $post['product'] = $productList;
+                    $return = StockWastageOrderClass::addOrder($post, $this->shop, true);
+                    $transaction->commit();
+                    return $return;
+                } catch (\Exception $exception) {
+                    if ($transaction->isActive) {
+                        $transaction->rollBack();
+                    }
+                    throw $exception;
+                }
+            });
             util::success($return);
         } catch (\Exception $e) {
-            $transaction->rollBack();
             Yii::info("报损失败原因:" . $e->getMessage());
-            util::fail('报损保存失败');
+            if (util::isDbConcurrencyException($e)) {
+                util::fail('系统繁忙中,请稍后再试');
+            } else {
+                util::fail('报损保存失败');
+            }
         }
     }
 

+ 1 - 0
app-hd/controllers/PurchaseController.php

@@ -822,6 +822,7 @@ class PurchaseController extends BaseController
             }
             ProductClass::rollbackLimitBuySnapshot();
             $transactionFinished = true;
+            Yii::error($e->getMessage());
             util::fail();
         }
     }

+ 2 - 2
biz-ghs/order/classes/CheckOrderClass.php

@@ -394,11 +394,11 @@ class CheckOrderClass extends BaseClass
                     }
                     //上架
                     if ($v['itemNum'] > 0 && $productData->status == 2) {
-                        $productData->status = 1;
+                        ProductClass::changeStatusAndReset($productData, 1);
                     }
                     //下架
                     if ($v['itemNum'] <= 0 && $productData->status == 1) {
-                        $productData->status = 2;
+                        ProductClass::changeStatusAndReset($productData, 2, true);
                     }
                     $oldStockVal = $productData->stock ?? 0;
                     $productData->stock = $v['itemNum'];

+ 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]);
     }
 
-}
+}

+ 32 - 6
biz-ghs/order/classes/PurchaseOrderClass.php

@@ -263,6 +263,14 @@ class PurchaseOrderClass extends BaseClass
         if (empty($itemList)) {
             util::fail('没有找到花材');
         }
+        usort($itemList, 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 ($itemList as $cgKey => $cgItem) {
             $productId = $cgItem['productId'] ?? 0;
             $currentNum = $cgItem['itemNum'] ?? 0;
@@ -532,6 +540,14 @@ class PurchaseOrderClass extends BaseClass
         if (empty($itemList)) {
             util::fail('没有找到花材');
         }
+        usort($itemList, 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;
+        });
 
         $bookSn = $shop->bookSn ?? 0;
         $pfLevel = $shop->pfLevel ?? 0;
@@ -573,11 +589,12 @@ class PurchaseOrderClass extends BaseClass
                 \bizHd\product\classes\ProductClass::clearLimitBuyCache($productId);
             }
 
-            //有新入库就清掉做特价活动 ssh 20251112
-            $product->discountPrice = 0;
-            $product->skDiscountPrice = 0;
-            $product->hjDiscountPrice = 0;
-            $product->save();
+            //库存为0时重置特价
+            if (intval($product->stock) == 0) {
+                $product->discountPrice = 0;
+                $product->skDiscountPrice = 0;
+                $product->hjDiscountPrice = 0;
+            }
 
             if ($presell == 0) {
 
@@ -623,6 +640,7 @@ class PurchaseOrderClass extends BaseClass
             $product->cost = $unitCost;
             $product->priceLabel = ProductClass::PRICE_LABEL_AUTO;
             $product->save();
+
             //成本变动记录
             $changeData = [
                 'ptStyle' => 2,
@@ -824,6 +842,14 @@ class PurchaseOrderClass extends BaseClass
         //数据结构 [{itemId:0,bigNum:0,smallNum:0,productId:12,itemPrice:1,weight:1}]
         $ghsItemInfo = $data['itemInfo'];
         $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;
+        });
         //总共多少扎
         $totalBigNum = 0;
         //总共多少支
@@ -1832,4 +1858,4 @@ class PurchaseOrderClass 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'];

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

@@ -71,6 +71,14 @@ class StockWastageOrderClass extends BaseClass
         //花材列表
         $ghsItemInfo = $post['product'];
         $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;
+        });
         $totalNum = 0;
         $totalBig = 0;
         $totalSmall = 0;

+ 112 - 82
biz-ghs/product/classes/ProductClass.php

@@ -958,29 +958,42 @@ class ProductClass extends BaseClass
 
     /**
      * 库存变动统一入口:用数据库行锁串行化同一花材的读-算-写,避免并发覆盖库存。
+     * 出现并发时,会自动重试3次。
+     * @param $productId
+     * @param callable $callback
+     * @return mixed
+     * @throws \Exception
      */
     private static function runStockMutation($productId, callable $callback)
     {
         $db = Yii::$app->db;
         $currentTransaction = $db->getTransaction();
         $ownTransaction = empty($currentTransaction) || !$currentTransaction->isActive;
-        $transaction = $ownTransaction ? $db->beginTransaction() : $currentTransaction;
-        try {
-            $productData = self::getLockById($productId);
-            if (!$productData) {
-                util::fail("花材不存在");
-            }
-            $respond = call_user_func($callback, $productData);
-            if ($ownTransaction) {
-                $transaction->commit();
-            }
-            return $respond;
-        } catch (\Exception $exception) {
-            if ($ownTransaction && !empty($transaction) && $transaction->isActive) {
-                $transaction->rollBack();
+        $mutation = function () use ($db, $currentTransaction, $ownTransaction, $productId, $callback) {
+            $transaction = $ownTransaction ? $db->beginTransaction() : $currentTransaction;
+            try {
+                $productData = self::getLockById($productId);
+                if (!$productData) {
+                    util::fail("花材不存在");
+                }
+                $respond = call_user_func($callback, $productData);
+                if ($ownTransaction) {
+                    $transaction->commit();
+                }
+                return $respond;
+            } catch (\Exception $exception) {
+                if ($ownTransaction && !empty($transaction) && $transaction->isActive) {
+                    $transaction->rollBack();
+                }
+                throw $exception;
             }
-            throw $exception;
+        };
+
+        if (!$ownTransaction) {
+            return call_user_func($mutation);
         }
+
+        return util::runWithDbConcurrencyRetry($mutation);
     }
 
     //加库存,允许负库存,负数数量(盘点时)
@@ -988,7 +1001,7 @@ class ProductClass extends BaseClass
     {
         //小单位库存不再考虑 ssh 20230303
         //$itemNumPiece = 0;
-        return self::runStockMutation($productId, function ($productData) use ($itemNumBundle, $itemNumPiece) {
+        $callback = function ($productData) use ($itemNumBundle, $itemNumPiece) {
             $oldStock = $productData->stock;
             $ratio = $productData->ratio;
             //转成小数,加库存
@@ -1002,34 +1015,29 @@ class ProductClass extends BaseClass
             $productData->save();
 
             return ['oldStock' => $oldStock, 'newStock' => $newStock, 'itemNum' => $itemNum];
-        });
+        };
+
+        return self::runStockMutation($productId, $callback);
     }
 
     //加路上库存
     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];
+        $callback = function ($productInfo) use ($itemNum) {
+            $oldOnStock = $productInfo->onStock;
+            $newOnStock = bcadd($oldOnStock, $itemNum);
+            $productInfo->onStock = $newOnStock;
+            $productInfo->save();
+            return ['oldOnStock' => $oldOnStock, 'newOnStock' => $newOnStock, 'itemNum' => $itemNum];
+        };
+
+        return self::runStockMutation($productId, $callback);
     }
 
     //加库存
     public static function addStockByItemNum($productId, $itemNum, $params = [])
     {
-        return self::runStockMutation($productId, function ($productData) use ($itemNum, $params) {
+        $callback = function ($productData) use ($itemNum, $params) {
             $oldStock = $productData->stock;
             $newItemCost = 0;
             $newItemStock = 0;
@@ -1077,7 +1085,9 @@ class ProductClass extends BaseClass
 
             return ['oldStock' => $oldStock, 'newStock' => $newStock, 'itemNum' => $itemNum,
                 'avCost' => $avCost, 'totalCost' => $newItemCost, 'totalStock' => $newItemStock, 'changeCost' => $changeCost, 'unitCost' => $unitCost];
-        });
+        };
+
+        return self::runStockMutation($productId, $callback);
     }
 
     //减库存,允许负库存,负数数量(盘点时)
@@ -1087,7 +1097,7 @@ class ProductClass extends BaseClass
     {
         //小单位库存不再考虑 ssh 20230303
         //$itemNumPiece = 0;
-        return self::runStockMutation($productId, function ($productData) use ($itemNumBundle, $itemNumPiece, $checkStock, $addSold, $params) {
+        $callback = function ($productData) use ($productId, $itemNumBundle, $itemNumPiece, $checkStock, $addSold, $params) {
             $oldStock = $productData->stock;
             $ratio = $productData->ratio;
             //转成小数,更新库存
@@ -1157,14 +1167,16 @@ class ProductClass extends BaseClass
 
             return ['oldStock' => $oldStock, 'newStock' => $newStock, 'itemNum' => $itemNum,
                 'avCost' => $avCost, 'totalCost' => $newItemCost, 'totalStock' => $newItemStock, 'changeCost' => $changeCost, 'unitCost' => $unitCost];
-        });
+        };
+
+        return self::runStockMutation($productId, $callback);
     }
 
 
     //根据数量减库存
     public static function decreaseStockByItemNum($productId, $itemNum, $checkStock = false)
     {
-        return self::runStockMutation($productId, function ($productData) use ($itemNum, $checkStock) {
+        $callback = function ($productData) use ($itemNum, $checkStock) {
             $oldStock = $productData->stock;
             $newStock = bcsub($oldStock, $itemNum, 2);
             if ($checkStock && $newStock < 0) {
@@ -1179,33 +1191,27 @@ class ProductClass extends BaseClass
             $productData->save();
 
             return ['oldStock' => $oldStock, 'newStock' => $newStock, 'itemNum' => $itemNum];
-        });
+        };
+
+        return self::runStockMutation($productId, $callback);
     }
 
     //减少在路上的库存
     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];
+        $callback = function ($productInfo) use ($itemNum, $checkStock) {
+            $oldOnStock = $productInfo->onStock;
+            $newOnStock = bcsub($oldOnStock, $itemNum);
+            if ($checkStock && $newOnStock < 0) {
+                //需要判断库存是否充足
+                util::fail("库存不足");
+            }
+            $productInfo->onStock = $newOnStock;
+            $productInfo->save();
+            return ['oldOnStock' => $oldOnStock, 'newOnStock' => $newOnStock, 'itemNum' => $itemNum];
+        };
+
+        return self::runStockMutation($productId, $callback);
     }
 
     //计算价格
@@ -1280,7 +1286,7 @@ class ProductClass extends BaseClass
             $diffItemIds = array_diff($itemIds, $productDataItemIds);
             if ($diffItemIds) {
 
-                $oldProductInfo = ProductClass::getAllByCondition(['id' => ['in', $oldProductIds]], null, '*', 'itemId');
+                $oldProductInfo = self::getAllByCondition(['id' => ['in', $oldProductIds]], null, '*', 'itemId');
 
                 //新增到product表,price = itemPrice (出库时的售价), stock=0
                 foreach ($diffItemIds as $v) {
@@ -1362,7 +1368,7 @@ class ProductClass extends BaseClass
     //修改花材商品的库存: 统一方法
     public static function updateStockById($id, $stock, $adminId = 0)
     {
-        return self::runStockMutation($id, function ($productData) use ($stock, $adminId) {
+        $callback = function ($productData) use ($stock, $adminId) {
             $productData->stock = $stock;
             //如果有库存需要保持上架状态
             if ($stock > 0) {
@@ -1372,28 +1378,30 @@ class ProductClass extends BaseClass
                 $productData->adminId = $adminId;
             }
             return $productData->save();
-        });
+        };
+
+        return self::runStockMutation($id, $callback);
     }
 
     //订单退回库存
     public static function backStockByOrderItemInfo($orderItemInfo)
     {
         foreach ($orderItemInfo as $v) {
-            ProductClass::addStockByItemNum($v['productId'], $v['itemNum']);
+            self::addStockByItemNum($v['productId'], $v['itemNum']);
         }
     }
 
     //获取当前门店下商品信息 lqh 2021.1.28
     public static function getProductData($id, $mainId, $obj = false)
     {
-        return ProductClass::getByCondition(['id' => $id, 'mainId' => $mainId], $obj);
+        return self::getByCondition(['id' => $id, 'mainId' => $mainId], $obj);
     }
 
     //单个修改价格 lqh 2021.1.28
     public static function changeSinglePrice($shop, $ptItemId, $price, $skPrice = null, $hjPrice = null, $params = [])
     {
         $mainId = $shop->mainId ?? 0;
-        $productInfo = ProductClass::getByCondition(['itemId' => $ptItemId, 'mainId' => $mainId, 'delStatus' => 0], true);
+        $productInfo = self::getByCondition(['itemId' => $ptItemId, 'mainId' => $mainId, 'delStatus' => 0], true);
         if (empty($productInfo)) {
             return false;
         }
@@ -1703,7 +1711,7 @@ class ProductClass extends BaseClass
                 }
                 //增加盘点记录 2021.7.27
                 $ratio = $product->ratio ?? 0;
-                $formNum = ProductClass::formatStock($data['stock'], $ratio);
+                $formNum = self::formatStock($data['stock'], $ratio);
                 $ghsItemInfo = [['productId' => $product->id, 'bigNum' => $formNum['bigNum'], 'smallNum' => $formNum['smallNum'],]];
                 $pdData = [];
                 $pdData['itemInfo'] = $ghsItemInfo;
@@ -2058,7 +2066,7 @@ class ProductClass extends BaseClass
                 $staffName = $staff->name ?? '';
                 $changeParams2 = ['staffId' => $staffId, 'staffName' => $staffName];
 
-                $chainProduct = ProductClass::getByCondition(['mainId' => $chainMainId, 'itemId' => $ptItemId], true);
+                $chainProduct = self::getByCondition(['mainId' => $chainMainId, 'itemId' => $ptItemId], true);
                 if (empty($chainProduct)) {
                     continue;
                 }
@@ -2337,7 +2345,7 @@ class ProductClass extends BaseClass
                 $productData['status'] = 2;
                 //被归到默认分类,排序值要小,这样在花材列表搜索时,默认分类才不会被排在前面
                 $productData['inTurn'] = -1;
-                $addProduct = ProductClass::addBaseData($productData);
+                $addProduct = self::addBaseData($productData);
                 $hdProductId = $addProduct->id ?? 0;
 
                 //通知新花材需要分类
@@ -2660,25 +2668,48 @@ class ProductClass extends BaseClass
         }
     }
 
-    //花材上下架
-    public static function changeStatus($productId, $status)
+    /**
+     * 改变花材状态并重置特价、满减、限购
+     * @param $product
+     * @param $status 1上架 2下架
+     * @param bool $stockEmpty 库存为空(指新设置的库存)
+     * @throws \Exception
+     */
+    public static function changeStatusAndReset($product, $status, $stockEmpty = false)
     {
-        $data = ['status' => $status];
-        if ($status == 2) {
+        $product->status = $status;
+
+        // 当前库存为0
+        if (intval($product->stock) == 0) {
             //库存=0时下架,特价一起去掉
-            $data['discountPrice'] = 0;
-            $data['hjDiscountPrice'] = 0;
-            $data['skDiscountPrice'] = 0;
+            $product->discountPrice = 0;
+            $product->hjDiscountPrice = 0;
+            $product->skDiscountPrice = 0;
 
             //花材满减去掉 ssh 20260318
-            $data['reachNum'] = 0;
-            $data['reachNumDiscount'] = 0;
+            $product->reachNum = 0;
+            $product->reachNumDiscount = 0;
 
             //花材去除限购
-            $data['limitBuy'] = 0;
+            $product->limitBuy = 0;
+            self::clearLimitBuyCache($product->id); // 清空限购记录缓存
+        }
+
+        // 新设置的库存为0
+        if ($stockEmpty) {
+            //库存=0时下架,特价一起去掉
+            $product->discountPrice = 0;
+            $product->hjDiscountPrice = 0;
+            $product->skDiscountPrice = 0;
+
+            //花材满减去掉 ssh 20260318
+            $product->reachNum = 0;
+            $product->reachNumDiscount = 0;
+
+            //花材去除限购
+            $product->limitBuy = 0;
             // self::clearLimitBuyCache($productId); // 此处不清空限购记录缓存,留在更新库存的地方来做清空缓存
         }
-        return self::updateById($productId, $data);
     }
 
     //获取当前供货商下的所有item
@@ -2745,7 +2776,7 @@ class ProductClass extends BaseClass
                     continue;
                 }
                 $ids = array_unique(array_filter($ids));
-                $productList = ProductClass::getAllByCondition(['id' => ['in', $ids]], null, '*', null, true);
+                $productList = self::getAllByCondition(['id' => ['in', $ids]], null, '*', null, true);
                 if (!empty($productList)) {
                     WxMessageClass::stockWarningInform($shop, $productList);
                 }
@@ -3386,4 +3417,3 @@ class ProductClass extends BaseClass
         return true;
     }
 }
-

+ 27 - 1
common/components/util.php

@@ -335,7 +335,6 @@ class util
      */
     public static function getLock($key, $expire = 10)
     {
-
         $redis = Yii::$app->redis;
         return $redis->setex($key, $expire, time());
     }
@@ -442,6 +441,33 @@ class util
         return true;
     }
 
+    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(callable $callback, $maxRetry = 3)
+    {
+        $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(50000, 300000));
+            }
+        }
+    }
+
     /**
      * 用 mainId 验证操作权限
      * @param array $arr 模型数据数组

+ 665 - 0
scripts/test_ghs_stock_concurrency.php

@@ -0,0 +1,665 @@
+#!/usr/bin/env php
+<?php
+/**
+ * Concurrently calls app-ghs stock-changing APIs,
+ * 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 --wastage=2 --hd-purchase=2 --rounds=3
+ *   php scripts/test_ghs_stock_concurrency.php --stock-in-confirm=RK123 --stock-out-cancel=CK123 --purchase-put-in=123
+ */
+
+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::', 'wastage::', 'hd-purchase::', 'stock-in-confirm::', 'stock-out-cancel::', 'purchase-put-in::', 'rounds::']);
+$orderCount = normalizeNonNegativeInt($options, 'order', 1);
+$stockOutCount = normalizeNonNegativeInt($options, 'stock-out', 1);
+$wastageCount = normalizeNonNegativeInt($options, 'wastage', 1);
+$hdPurchaseCount = normalizeNonNegativeInt($options, 'hd-purchase', 1);
+$rounds = normalizePositiveInt($options, 'rounds', 1);
+
+$baseUrl = 'http://api.shop.hzghd.com';
+$hdBaseUrl = 'http://api.shop.huaml.com';
+$defaultProductIds = [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',
+];
+
+$hdHeaders = [
+    'Connection: keep-alive',
+    'account: 12362',
+    'content-type: application/x-www-form-urlencoded;charset=UTF-8',
+    'User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1 wechatdevtools/2.01.2510260 MicroMessenger/8.0.5 Language/zh_CN webview/ hash/1009552471 sid/xxB6sCFvvu',
+    'token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImp0aSI6IjBfMTM5MSJ9.eyJpc3MiOiJodHRwczpcL1wvYXBpLnNob3AuaHVhbWwuY29tIiwiYXVkIjoiaHR0cHM6XC9cL2FwaS5zaG9wLmh1YW1sLmNvbSIsImp0aSI6IjBfMTM5MSIsImlhdCI6MTc4MDAzNzMxMSwibmJmIjoxNzgwMDM3MzExLCJleHAiOjE4NzQ2NDUzMTEsInVuaXF1ZUlkIjoiMTM5MSIsInNvdXJjZUlkIjowfQ.BgiDsxOd9cktOLsDe8Ud_hcFqzmVwSzKjCVwUdrSXKw',
+    'appVersion: 2',
+    'Accept: */*',
+    'Sec-Fetch-Site: cross-site',
+    'Sec-Fetch-Mode: cors',
+    'Sec-Fetch-Dest: empty',
+    'Referer: https://servicewechat.com/wxe4675bab299a52f7/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';
+$hdPurchaseBody = 'getType=2&sendTimeWant=&sendType=0&transType=4&remark=&product=%5B%7B%22productId%22%3A%2227286%22%2C%22bigNum%22%3A1%2C%22smallNum%22%3A0%2C%22classId%22%3A%223322%22%2C%22itemId%22%3A%221009%22%2C%22weight%22%3A%221.20%22%2C%22name%22%3A%22%E8%8B%8F%E9%86%92%22%7D%5D&ghsId=932&hbId=0&wlName=&xj=%22%22&itemTotalAmount=27&version=10&direct=0';
+$wastageBody = http_build_query([
+    'remark' => 'concurrency-test',
+    'product' => json_encode([
+        ['productId' => '27286', 'bigNum' => 1, 'smallNum' => 0, 'classId' => '3322', 'itemId' => '1009'],
+        ['productId' => '27282', 'bigNum' => 1, 'smallNum' => 0, 'classId' => '3322', 'itemId' => '1005'],
+    ], JSON_UNESCAPED_UNICODE),
+]);
+
+$requestDefs = [
+    'order' => [
+        'method' => 'POST',
+        'url' => $baseUrl . '/order/create-order',
+        'body' => $orderBody,
+        'stockDelta' => [27286 => '2', 27282 => '2'],
+    ],
+    'stock-out' => [
+        'method' => 'POST',
+        'url' => $baseUrl . '/stock-out/create-order',
+        'body' => $stockOutBody,
+        'stockDelta' => [27286 => '1', 27282 => '1'],
+    ],
+    'wastage' => [
+        'method' => 'POST',
+        'url' => $baseUrl . '/wastage/create-order',
+        'body' => $wastageBody,
+        'stockDelta' => [27286 => '1', 27282 => '1'],
+    ],
+    'hd-purchase' => [
+        'method' => 'POST',
+        'url' => $hdBaseUrl . '/purchase/create-order',
+        'headers' => $hdHeaders,
+        'body' => $hdPurchaseBody,
+        'stockDelta' => [27286 => '1'],
+    ],
+];
+$requestCounts = ['order' => $orderCount, 'stock-out' => $stockOutCount, 'wastage' => $wastageCount, 'hd-purchase' => $hdPurchaseCount];
+addParameterizedRequests($requestDefs, $requestCounts, $options, $baseUrl);
+$productIds = collectProductIds($requestDefs, $defaultProductIds);
+
+echo "app-ghs stock concurrency test\n";
+echo "Targets: {$baseUrl}, {$hdBaseUrl}\n";
+echo "Rounds: {$rounds}, order/create-order: {$orderCount}, stock-out/create-order: {$stockOutCount}, wastage/create-order: {$wastageCount}, app-hd purchase/create-order: {$hdPurchaseCount}\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;
+$appLogFiles = [
+    'app-ghs' => __DIR__ . '/../app-ghs/runtime/logs/app.log',
+    'app-hd' => __DIR__ . '/../app-hd/runtime/logs/app.log',
+];
+
+for ($round = 1; $round <= $rounds; $round++) {
+    echo "========== Round {$round}/{$rounds} ==========\n";
+    $before = loadStocks($productIds);
+    printStocks('Before', $before, $productIds);
+
+    $currentRequestDefs = $requestDefs;
+    $priceSummary = '';
+    $currentRequestDefs['order']['body'] = refreshOrderBodyPrices($orderBody, $priceSummary);
+    echo "Order request pricing: {$priceSummary}\n";
+
+    $requests = buildRequests($currentRequestDefs, $requestCounts);
+    $logOffsets = getLogOffsets($appLogFiles);
+    $responses = runConcurrentRequests($requests, $commonHeaders);
+    $concurrencyErrors = findConcurrencyErrorsFromLogs($appLogFiles, $logOffsets);
+
+    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, $currentRequestDefs, $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 && $overallPass) {
+    echo "FINAL: INVALID (at least one round had failed business responses)\n";
+    exit(3);
+}
+
+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 normalizeNonNegativeInt($options, $key, $default)
+{
+    if (!isset($options[$key]) || $options[$key] === false || $options[$key] === '') {
+        return $default;
+    }
+    $value = (int)$options[$key];
+    if ($value < 0) {
+        fwrite(STDERR, "ERROR: --{$key} must be a non-negative 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, $requestCounts)
+{
+    $requests = [];
+    foreach ($requestCounts as $type => $count) {
+        if (empty($requestDefs[$type]) || $count <= 0) {
+            continue;
+        }
+        for ($i = 1; $i <= $count; $i++) {
+            $requests[] = [
+                'type' => $type,
+                'label' => $type . '#' . $i,
+                'method' => isset($requestDefs[$type]['method']) ? $requestDefs[$type]['method'] : 'POST',
+                'url' => $requestDefs[$type]['url'],
+                'headers' => isset($requestDefs[$type]['headers']) ? $requestDefs[$type]['headers'] : null,
+                'body' => isset($requestDefs[$type]['body']) ? $requestDefs[$type]['body'] : '',
+            ];
+        }
+    }
+    return $requests;
+}
+
+function addParameterizedRequests(&$requestDefs, &$requestCounts, $options, $baseUrl)
+{
+    if (!empty($options['stock-in-confirm'])) {
+        $orderSn = (string)$options['stock-in-confirm'];
+        $requestDefs['stock-in-confirm'] = [
+            'method' => 'GET',
+            'url' => $baseUrl . '/stock-in/confirm-order?orderSn=' . rawurlencode($orderSn),
+            'body' => '',
+            'stockDelta' => loadItemNumDelta('xhGhsStockInOrderItem', $orderSn, -1),
+        ];
+        $requestCounts['stock-in-confirm'] = 1;
+    }
+
+    if (!empty($options['stock-out-cancel'])) {
+        $orderSn = (string)$options['stock-out-cancel'];
+        $requestDefs['stock-out-cancel'] = [
+            'method' => 'GET',
+            'url' => $baseUrl . '/stock-out/cancel-order?orderSn=' . rawurlencode($orderSn),
+            'body' => '',
+            'stockDelta' => loadItemNumDelta('xhGhsStockOutOrderItem', $orderSn, -1),
+        ];
+        $requestCounts['stock-out-cancel'] = 1;
+    }
+
+    if (!empty($options['purchase-put-in'])) {
+        $purchaseId = intval($options['purchase-put-in']);
+        $orderSn = Yii::$app->db->createCommand('select orderSn from xhGhsCgOrder where id=:id', [':id' => $purchaseId])->queryScalar();
+        if (!empty($orderSn)) {
+            $requestDefs['purchase-put-in'] = [
+                'method' => 'GET',
+                'url' => $baseUrl . '/purchase-order/confirm-put-in?id=' . $purchaseId,
+                'body' => '',
+                'stockDelta' => loadItemNumDelta('xhGhsCgOrderItem', $orderSn, -1),
+            ];
+            $requestCounts['purchase-put-in'] = 1;
+        }
+    }
+}
+
+function loadItemNumDelta($tableName, $orderSn, $direction)
+{
+    $rows = Yii::$app->db->createCommand(
+        "select productId, itemNum from {$tableName} where orderSn=:orderSn",
+        [':orderSn' => $orderSn]
+    )->queryAll();
+    $delta = [];
+    foreach ($rows as $row) {
+        $productId = intval($row['productId']);
+        $itemNum = (string)$row['itemNum'];
+        if ($direction < 0) {
+            $itemNum = bcmul($itemNum, '-1', 2);
+        }
+        $delta[$productId] = isset($delta[$productId]) ? bcadd($delta[$productId], $itemNum, 2) : $itemNum;
+    }
+    return $delta;
+}
+
+function collectProductIds($requestDefs, $defaultProductIds)
+{
+    $ids = $defaultProductIds;
+    foreach ($requestDefs as $requestDef) {
+        foreach (array_keys($requestDef['stockDelta']) as $productId) {
+            $ids[] = intval($productId);
+        }
+    }
+    $ids = array_values(array_unique(array_filter($ids)));
+    sort($ids);
+    return $ids;
+}
+
+function refreshOrderBodyPrices($orderBody, &$summary)
+{
+    parse_str($orderBody, $params);
+    $customId = isset($params['customId']) ? intval($params['customId']) : 0;
+    $products = isset($params['product']) ? json_decode($params['product'], true) : [];
+    if (empty($customId) || empty($products) || !is_array($products)) {
+        $summary = 'keep original prices';
+        return $orderBody;
+    }
+
+    $custom = \bizGhs\custom\classes\CustomClass::getById($customId);
+    if (empty($custom)) {
+        $summary = 'keep original prices; custom not found';
+        return $orderBody;
+    }
+
+    $productIds = array_unique(array_filter(array_column($products, 'productId')));
+    $productInfo = \bizGhs\product\classes\ProductClass::getByIds($productIds, null, 'id');
+    if (empty($productInfo)) {
+        $summary = 'keep original prices; products not found';
+        return $orderBody;
+    }
+
+    $priceMap = \bizGhs\custom\classes\CustomClass::$levelPriceKeyMap;
+    $addPriceMap = \bizGhs\custom\classes\CustomClass::$levelAddPriceKeyMap;
+    $addPriceMap['risePercent'] = isset($custom['risePercent']) ? $custom['risePercent'] : 0;
+    $level = isset($custom['level']) ? $custom['level'] : 1;
+    $modifyPrice = '0';
+    $parts = [];
+
+    foreach ($products as $key => $product) {
+        $productId = isset($product['productId']) ? intval($product['productId']) : 0;
+        if (empty($productInfo[$productId])) {
+            continue;
+        }
+        $systemInfo = $productInfo[$productId];
+        $price = \bizGhs\product\classes\ProductClass::getFinalPrice($systemInfo, $level, $priceMap, $addPriceMap);
+        $bigNum = isset($product['bigNum']) ? (string)$product['bigNum'] : '0';
+        $smallNum = isset($product['smallNum']) ? (string)$product['smallNum'] : '0';
+
+        $reachDiscountPrice = '0';
+        $reachNum = isset($systemInfo['reachNum']) ? $systemInfo['reachNum'] : 0;
+        $reachNumDiscount = isset($systemInfo['reachNumDiscount']) ? $systemInfo['reachNumDiscount'] : 0;
+        if ($reachNum > 0 && $bigNum >= $reachNum && $price > $reachNumDiscount) {
+            $price = bcsub($price, $reachNumDiscount, 2);
+            $reachDiscountPrice = bcmul($reachNumDiscount, $bigNum, 2);
+        }
+
+        $products[$key]['price'] = sprintf('%.2f', (float)$price);
+        $linePrice = bcmul((string)$products[$key]['price'], $bigNum, 2);
+        if ($smallNum > 0) {
+            $ratio = isset($systemInfo['ratio']) && $systemInfo['ratio'] > 0 ? $systemInfo['ratio'] : 1;
+            $smallItemNum = bcdiv($smallNum, $ratio, 2);
+            $linePrice = bcadd($linePrice, bcmul((string)$products[$key]['price'], $smallItemNum, 2), 2);
+        }
+        $modifyPrice = bcadd($modifyPrice, $linePrice, 2);
+        $name = isset($systemInfo['name']) ? $systemInfo['name'] : $productId;
+        $parts[] = $productId . '(' . $name . ')=' . $products[$key]['price'] . 'x' . $bigNum . ($reachDiscountPrice > 0 ? ',reachDiscount=' . $reachDiscountPrice : '');
+    }
+
+    $params['product'] = json_encode($products, JSON_UNESCAPED_UNICODE);
+    $params['modifyPrice'] = $modifyPrice;
+    $summary = implode('; ', $parts) . '; modifyPrice=' . $modifyPrice;
+    return http_build_query($params);
+}
+
+function runConcurrentRequests($requests, $headers)
+{
+    $multi = curl_multi_init();
+    $handles = [];
+
+    foreach ($requests as $index => $request) {
+        $ch = curl_init($request['url']);
+        $method = isset($request['method']) ? strtoupper($request['method']) : 'POST';
+        $requestHeaders = !empty($request['headers']) ? $request['headers'] : $headers;
+        $curlOptions = [
+            CURLOPT_HTTPHEADER => $requestHeaders,
+            CURLOPT_RETURNTRANSFER => true,
+            CURLOPT_ENCODING => '',
+            CURLOPT_CONNECTTIMEOUT => 10,
+            CURLOPT_TIMEOUT => 60,
+        ];
+        if ($method === 'POST') {
+            $curlOptions[CURLOPT_POST] = true;
+            $curlOptions[CURLOPT_POSTFIELDS] = $request['body'];
+        }
+        curl_setopt_array($ch, $curlOptions);
+        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, $item['request']['type']);
+
+        $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, $requestType)
+{
+    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'] : '';
+        $data = isset($decoded['data']) && is_array($decoded['data']) ? $decoded['data'] : [];
+        $respondType = isset($data['respondType']) ? (string)$data['respondType'] : '';
+        $success = ($httpCode >= 200 && $httpCode < 300 && (string)$code === '1');
+        if ($requestType === 'order') {
+            $hasOrderIdentity = !empty($data['id']) || !empty($data['orderSn']);
+            if ($respondType === 'priceError' || !$hasOrderIdentity) {
+                $success = false;
+            }
+        }
+        $extra = [];
+        if ($respondType !== '') {
+            $extra[] = 'respondType=' . $respondType;
+        }
+        if (!empty($data['id'])) {
+            $extra[] = 'id=' . $data['id'];
+        }
+        if (!empty($data['orderSn'])) {
+            $extra[] = 'orderSn=' . $data['orderSn'];
+        }
+        return [
+            'success' => $success,
+            'summary' => trimText('code=' . json_encode($code, JSON_UNESCAPED_UNICODE) . ' msg=' . (string)$msg . (!empty($extra) ? ' ' . implode(' ', $extra) : ''), 240),
+        ];
+    }
+
+    return [
+        'success' => false,
+        'summary' => trimText(preg_replace('/\s+/', ' ', (string)$body), 240),
+    ];
+}
+
+function countSuccessfulRequests($responses)
+{
+    $counts = [];
+    foreach ($responses as $response) {
+        if (!isset($counts[$response['type']])) {
+            $counts[$response['type']] = 0;
+        }
+        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 getLogOffsets($files)
+{
+    $offsets = [];
+    foreach ($files as $name => $file) {
+        $offsets[$name] = getFileSize($file);
+    }
+    return $offsets;
+}
+
+function findConcurrencyErrorsFromLogs($files, $offsets)
+{
+    $errors = [];
+    foreach ($files as $name => $file) {
+        $offset = isset($offsets[$name]) ? $offsets[$name] : 0;
+        foreach (findConcurrencyErrors(readFileFromOffset($file, $offset)) as $error) {
+            $errors[] = $name . ': ' . $error;
+        }
+    }
+    return $errors;
+}
+
+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)));
+}