Эх сурвалжийг харах

1. 花材库存变动时,同步更新限购、特价、满减 2. 开单扣库存与调拨出库扣库 -- 测试并发脚本

shizhongqi 2 сар өмнө
parent
commit
83fdd9cbf4

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

+ 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'];

+ 7 - 5
biz-ghs/order/classes/PurchaseOrderClass.php

@@ -573,11 +573,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 +624,7 @@ class PurchaseOrderClass extends BaseClass
             $product->cost = $unitCost;
             $product->priceLabel = ProductClass::PRICE_LABEL_AUTO;
             $product->save();
+
             //成本变动记录
             $changeData = [
                 'ptStyle' => 2,

+ 34 - 11
biz-ghs/product/classes/ProductClass.php

@@ -2619,25 +2619,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时下架,特价一起去掉
+            $product->discountPrice = 0;
+            $product->hjDiscountPrice = 0;
+            $product->skDiscountPrice = 0;
+
+            //花材满减去掉 ssh 20260318
+            $product->reachNum = 0;
+            $product->reachNumDiscount = 0;
+
+            //花材去除限购
+            $product->limitBuy = 0;
+            self::clearLimitBuyCache($product->id); // 清空限购记录缓存
+        }
+
+        // 新设置的库存为0
+        if ($stockEmpty) {
             //库存=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($productId); // 此处不清空限购记录缓存,留在更新库存的地方来做清空缓存
         }
-        return self::updateById($productId, $data);
     }
 
     //获取当前供货商下的所有item

+ 417 - 0
scripts/test_ghs_stock_concurrency.php

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