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