فهرست منبع

1. ProductClass.php: addOnStockByItemNum() / decreaseOnStockByItemNum() 也改为走现有 runStockMutation(),统一用数据库行锁,不再走旧 Redis 锁。
OrderItemClass.php: 下单商品按 productId ASC 扣库存。
StockOutOrderClass.php: 出库、取消出库按 productId ASC 处理库存。
StockInOrderClass.php: 调拨确认入库按 productId ASC 加库存。
OrderController.php: 下单创建事务包进 util::runWithDbConcurrencyRetry()。
StockOutController.php: 出库创建事务包进 util::runWithDbConcurrencyRetry()。

2. 测试脚本 scripts/test_ghs_stock_concurrency.php 现在纳入更多库存接口:
php scripts/test_ghs_stock_concurrency.php --rounds=1

默认会并发测试:
order/create-order 扣库存
stock-out/create-order 扣库存
wastage/create-order 扣库存

也支持依赖已有单据的参数化场景:
php scripts/test_ghs_stock_concurrency.php --order=0 --stock-out=0 --wastage=0 --stock-in-confirm=入库单号
php scripts/test_ghs_stock_concurrency.php --order=0 --stock-out=0 --wastage=0 --stock-out-cancel=出库单号
php scripts/test_ghs_stock_concurrency.php --order=0 --stock-out=0 --wastage=0 --purchase-put-in=采购单ID

并发重试已补到这些库存相关入口:
order/create-order
stock-out/create-order
stock-out/confirm-order
stock-out/cancel-order
stock-in/confirm-order
stock-in/cancel-order
wastage/create-order
purchase-order/create-order
purchase-order/confirm-put-in
purchase-order/cancel
refund/create-order
同时对相关批量库存处理补了 productId ASC 排序,包括报损和采购入库/取消路径,减少多商品交叉锁死锁。
已跑语法检查,覆盖脚本和改动文件,均通过。建议先跑:
php scripts/test_ghs_stock_concurrency.php --rounds=1

--rounds=2 仍可能触发业务重复提交保护,出现“请稍等几秒再试”,这不是库存并发结论。

shizhongqi 2 ماه پیش
والد
کامیت
455681448a

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

@@ -1499,18 +1499,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);
@@ -1574,10 +1585,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
 
     }
 
-}
+}

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

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

+ 25 - 1
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;
@@ -826,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;
         //总共多少支
@@ -1834,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;

+ 22 - 38
biz-ghs/product/classes/ProductClass.php

@@ -996,22 +996,15 @@ class ProductClass extends BaseClass
     //加路上库存
     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);
     }
 
     //加库存
@@ -1179,27 +1172,19 @@ class ProductClass extends BaseClass
     //减少在路上的库存
     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);
     }
 
     //计算价格
@@ -3391,4 +3376,3 @@ class ProductClass extends BaseClass
         return true;
     }
 }
-

+ 191 - 33
scripts/test_ghs_stock_concurrency.php

@@ -1,7 +1,7 @@
 #!/usr/bin/env php
 <?php
 /**
- * Concurrently calls app-ghs order/create-order and stock-out/create-order,
+ * 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
@@ -10,7 +10,8 @@
  * 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
+ *   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');
@@ -34,13 +35,16 @@ if (!extension_loaded('curl')) {
     exit(1);
 }
 
-$options = getopt('', ['order::', 'stock-out::', 'rounds::']);
-$orderCount = normalizePositiveInt($options, 'order', 1);
-$stockOutCount = normalizePositiveInt($options, 'stock-out', 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';
-$productIds = [27286, 27282];
+$hdBaseUrl = 'http://api.shop.huaml.com';
+$defaultProductIds = [27286, 27282];
 
 $commonHeaders = [
     'Connection: keep-alive',
@@ -57,32 +61,76 @@ $commonHeaders = [
     '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 "Target: {$baseUrl}\n";
-echo "Rounds: {$rounds}, order/create-order: {$orderCount}, stock-out/create-order: {$stockOutCount}\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;
-$appLogFile = __DIR__ . '/../app-ghs/runtime/logs/app.log';
+$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";
@@ -94,11 +142,10 @@ for ($round = 1; $round <= $rounds; $round++) {
     $currentRequestDefs['order']['body'] = refreshOrderBodyPrices($orderBody, $priceSummary);
     echo "Order request pricing: {$priceSummary}\n";
 
-    $requests = buildRequests($currentRequestDefs, $orderCount, $stockOutCount);
-    $logOffset = getFileSize($appLogFile);
+    $requests = buildRequests($currentRequestDefs, $requestCounts);
+    $logOffsets = getLogOffsets($appLogFiles);
     $responses = runConcurrentRequests($requests, $commonHeaders);
-    $newLog = readFileFromOffset($appLogFile, $logOffset);
-    $concurrencyErrors = findConcurrencyErrors($newLog);
+    $concurrencyErrors = findConcurrencyErrorsFromLogs($appLogFiles, $logOffsets);
 
     foreach ($responses as $response) {
         $ok = $response['businessSuccess'] ? 'OK' : 'ERR';
@@ -185,6 +232,19 @@ function normalizePositiveInt($options, $key, $default)
     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(
@@ -202,26 +262,95 @@ function loadStocks($productIds)
     return $stocks;
 }
 
-function buildRequests($requestDefs, $orderCount, $stockOutCount)
+function buildRequests($requestDefs, $requestCounts)
 {
     $requests = [];
-    for ($i = 1; $i <= $orderCount; $i++) {
-        $requests[] = [
-            'type' => 'order',
-            'label' => 'order#' . $i,
-            'url' => $requestDefs['order']['url'],
-            'body' => $requestDefs['order']['body'],
+    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;
     }
-    for ($i = 1; $i <= $stockOutCount; $i++) {
-        $requests[] = [
-            'type' => 'stock-out',
-            'label' => 'stock-out#' . $i,
-            'url' => $requestDefs['stock-out']['url'],
-            'body' => $requestDefs['stock-out']['body'],
+
+    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;
     }
-    return $requests;
+
+    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)
@@ -297,15 +426,20 @@ function runConcurrentRequests($requests, $headers)
 
     foreach ($requests as $index => $request) {
         $ch = curl_init($request['url']);
-        curl_setopt_array($ch, [
-            CURLOPT_POST => true,
-            CURLOPT_POSTFIELDS => $request['body'],
-            CURLOPT_HTTPHEADER => $headers,
+        $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,
@@ -395,8 +529,11 @@ function parseBusinessResponse($body, $httpCode, $curlError, $requestType)
 
 function countSuccessfulRequests($responses)
 {
-    $counts = ['order' => 0, 'stock-out' => 0];
+    $counts = [];
     foreach ($responses as $response) {
+        if (!isset($counts[$response['type']])) {
+            $counts[$response['type']] = 0;
+        }
         if ($response['businessSuccess']) {
             $counts[$response['type']]++;
         }
@@ -467,6 +604,27 @@ function getFileSize($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)) {