Răsfoiți Sursa

perf: 优化销售订单物品清单回显性能,增加商品查询缓存与并发限流

xieyong 48 minute în urmă
părinte
comite
a228046ac0
1 a modificat fișierele cu 280 adăugiri și 140 ștergeri
  1. 280 140
      src/BIZComponents/inventoryTable.vue

+ 280 - 140
src/BIZComponents/inventoryTable.vue

@@ -850,6 +850,7 @@
               :disabled="quoteType === 2 || stockAttrDisabled"
               v-model="scope.row.modelKey"
               :isOne="scope.$index === 0"
+              :dedupe="true"
               filterable
               allow-create
               default-first-option
@@ -865,6 +866,7 @@
               :disabled="quoteType === 2 || stockAttrDisabled"
               v-model="scope.row.colorKey"
               :isOne="scope.$index === 0"
+              :dedupe="true"
               filterable
               allow-create
               default-first-option
@@ -1103,6 +1105,88 @@
   import { parameterGetByCode } from '@/api/main/index.js';
   import { getSummaries } from '@/utils/util.js';
 
+  /**
+   * ==========================================================================
+   * 辅助数据请求调度器
+   * 销售订单新建/修改在“选品回填”时,会按每个物品去查商品信息
+   * (getGoodsByCategoryId)。一次选中几十上百个物品时,这些请求会
+   * 在同一个 tick 里全部并发发出,打满浏览器连接池:
+   *   - 末尾请求的排队时间从 0.2s 涨到 2s 以上
+   *   - 用户反复调整选品时,同一批物品会被重复查询
+   * 这里统一做三件事(与发货单 inventoryTable 的调度器保持一致):
+   *   1. 同键合并:相同查询条件的并发请求复用同一个 Promise,只打一次
+   *   2. 短时缓存:TTL 内直接复用上一次结果
+   *   3. 并发限流:全局最多 LOOKUP_MAX_CONCURRENCY 个请求同时在飞
+   * ==========================================================================
+   */
+  const LOOKUP_CACHE_TTL = 30 * 1000; // 商品信息缓存时长
+  const LOOKUP_MAX_CONCURRENCY = 6; // 同时在飞的请求上限
+
+  const lookupCache = new Map(); // key -> { expire, data }
+  const lookupPending = new Map(); // key -> Promise(同键合并)
+  const lookupQueue = []; // 等待队列
+  let lookupActive = 0; // 当前在飞的请求数
+
+  function pumpLookupQueue() {
+    while (lookupActive < LOOKUP_MAX_CONCURRENCY && lookupQueue.length) {
+      const task = lookupQueue.shift();
+      lookupActive++;
+      Promise.resolve()
+        .then(task.factory)
+        .then(
+          (data) => {
+            lookupActive--;
+            task.resolve(data);
+            pumpLookupQueue();
+          },
+          (error) => {
+            lookupActive--;
+            task.reject(error);
+            pumpLookupQueue();
+          }
+        );
+    }
+  }
+
+  /**
+   * 走调度器的请求:命中缓存直接返回;相同 key 的并发请求合并成一次
+   * @param {string} key 查询键
+   * @param {Function} factory 真正发起请求的工厂函数
+   * @param {number} ttl 缓存时长,0 表示不缓存(只做同键合并 + 限流)
+   */
+  function scheduleLookup(key, factory, ttl = LOOKUP_CACHE_TTL) {
+    if (ttl) {
+      const cached = lookupCache.get(key);
+      if (cached) {
+        if (cached.expire > Date.now()) {
+          return Promise.resolve(cached.data);
+        }
+        lookupCache.delete(key);
+      }
+    }
+    const pending = lookupPending.get(key);
+    if (pending) {
+      return pending;
+    }
+    const promise = new Promise((resolve, reject) => {
+      lookupQueue.push({ factory, resolve, reject });
+      pumpLookupQueue();
+    });
+    lookupPending.set(key, promise);
+    promise.then(
+      (data) => {
+        lookupPending.delete(key);
+        if (ttl) {
+          lookupCache.set(key, { expire: Date.now() + ttl, data });
+        }
+      },
+      () => {
+        lookupPending.delete(key);
+      }
+    );
+    return promise;
+  }
+
   export default {
     mixins: [dictMixins, tabMixins],
     components: {
@@ -1320,6 +1404,9 @@
         tableLoading: false,
         // 分批追加进度
         batchLoading: false,
+        // 回显取数中(供应商/库存批量查询 + 分批入表):
+        // 这段时间表格内容还没完整,保存会被挡住,避免提交半成品数据
+        dataLoading: false,
         loadedCount: 0,
         totalCount: 0,
         allPrice: 0,
@@ -1345,9 +1432,9 @@
       };
     },
     computed: {
-      // 批量追加进行中:锁住会改动数据源的操作,避免行下标错位
+      // 批量追加 / 回显进行中:锁住会改动数据源的操作,避免行下标错位
       tableBusy() {
-        return this.batchLoading;
+        return this.batchLoading || this.dataLoading;
       },
       canHandl() {
         return this.form.datasource.length;
@@ -2155,12 +2242,19 @@
           modelType: item.modelType
         };
       },
-      async refreshStockOrderInventory(validateQuantity = false) {
+      /**
+       * 刷新库存数量。
+       * rows 传入时按传入的数组分组与回填:修改回显时数据还在暂存数组里
+       * (还没进表格),直接用表格数据源会把结果写回旧数据。
+       * 不传则沿用表格当前数据源。
+       */
+      async refreshStockOrderInventory(validateQuantity = false, rows = null) {
         if (this.needProduce != 0) {
           return true;
         }
+        const source = rows || this.form.datasource;
         const inventoryGroups = new Map();
-        this.form.datasource.forEach((item, index) => {
+        source.forEach((item, index) => {
           if (!item.productCode || !item.warehouseId) {
             return;
           }
@@ -2192,14 +2286,10 @@
           const availableCountBase = Number(inventory.availableCountBase || 0);
           group.rows.forEach((rowIndex) => {
             if (this.showStockCount) {
-              this.$set(
-                this.form.datasource[rowIndex],
-                'stockCountBase',
-                stockCountBase
-              );
+              this.$set(source[rowIndex], 'stockCountBase', stockCountBase);
             }
             this.$set(
-              this.form.datasource[rowIndex],
+              source[rowIndex],
               'availableCountBase',
               availableCountBase
             );
@@ -2541,108 +2631,141 @@
           (data &&
             (data.quoteProductList || data.productList || data.detailList)) ||
           [];
-        if (productList) {
-          let supplierObj = await this.getSupplierObj(productList, 'productId');
-
-          productList.forEach((item, index) => {
-            item['key'] = index + 1;
-            if (item.modelKey) {
-              item.modelKey = item.modelKey;
-            }
-            if (item.colorKey) {
-              item.colorKey = item.colorKey;
-            }
+        if (!productList) {
+          return;
+        }
+        // 回显期间表格内容还不完整:锁住会改动数据源 / 提交的操作,
+        // 并让遮罩覆盖整个回显过程(批量取数 + 分批入表)。
+        this.dataLoading = true;
+        this._loadingStart = Date.now();
+        this.tableLoading = true;
+        try {
+          await this.putTableValueRows(productList, data);
+        } catch (error) {
+          console.error('物品清单回显失败', error);
+          this.$message.error('物品清单加载失败,请重试');
+        } finally {
+          this.dataLoading = false;
+          this.endTableLoading();
+        }
+      },
+      /**
+       * 回显主体:准备行数据 -> 批量取库存 -> 分批入表。
+       * 拆成独立方法,方便 putTableValue 统一兜住 loading 状态与异常。
+       */
+      async putTableValueRows(productList, data) {
+        let supplierObj = await this.getSupplierObj(productList, 'productId');
 
-            item.guaranteePeriodUnitCode = item.guaranteePeriodUnitCode
-              ? item.guaranteePeriodUnitCode + ''
-              : '';
+        productList.forEach((item, index) => {
+          item['key'] = index + 1;
+          if (item.modelKey) {
+            item.modelKey = item.modelKey;
+          }
+          if (item.colorKey) {
+            item.colorKey = item.colorKey;
+          }
 
-            let guaranteePeriodUnitName = this.guaranteePeriodUnit(
-              item.guaranteePeriodUnitCode
-            );
-            if (item.guaranteePeriod && item.guaranteePeriodUnitCode) {
-              item['guaranteePeriodDeadline'] =
-                item['guaranteePeriodDeadline'] ||
-                guaranteePeriodUnitName != 'second'
-                  ? this.setDay(
-                      item.guaranteePeriod,
-                      guaranteePeriodUnitName,
-                      item
-                    )
-                  : '';
-            }
-            item.entrustedEnterpriseIdList = supplierObj[item.productId];
-            // if (supplierObj[item.productId]?.length === 1) {
-            //   item.entrustedEnterpriseId = supplierObj[item.productId][0].id;
-            // }
-            // item.entrustedEnterpriseId = item.entrustedEnterpriseId || undefined;
-            this.$set(
-              item,
-              'entrustedEnterpriseId',
-              item.entrustedEnterpriseId || ''
-            );
-            item['customerMark'] = item.customerMark || this.customerMark;
-            item['arrivalWay'] = item.arrivalWay || 1;
-            item.discountRatio = Number(item.discountRatio) || 100;
-            item.taxRate =
-              item.taxRate !== undefined && item.taxRate !== null
-                ? item.taxRate
-                : this.isContractBook
-                ? this.defTaxRate
-                : this.isTaxRate == 1
-                ? this.defTaxRate
-                : undefined;
-            item.quoteWay = item.quoteWay || 1;
-          });
-          this.form.datasource = productList;
+          item.guaranteePeriodUnitCode = item.guaranteePeriodUnitCode
+            ? item.guaranteePeriodUnitCode + ''
+            : '';
 
-          if (this.needProduce == 0) {
-            await this.refreshStockOrderInventory();
-          } else {
-            let codeList = this.form.datasource
-              .filter((item) => item.productCode)
-              .map((item) => buildInventoryTotalQueryKey(item));
-            // 非库存式业务保留原有物品/批次汇总库存口径。
-            let inventoryTotalList = await getInventoryTotalAPI(codeList);
-            this.form.datasource
-              .filter((item) => item.productCode)
-              .forEach((item, index) => {
-                let find =
-                  inventoryTotalList.find((key) =>
-                    matchInventoryTotal(item, key)
-                  ) || {};
-                this.form.datasource;
-                if (this.showStockCount) {
-                  this.$set(
-                    this.form.datasource[index],
-                    'stockCountBase',
-                    this.getStockCount(find)
-                  );
-                }
+          let guaranteePeriodUnitName = this.guaranteePeriodUnit(
+            item.guaranteePeriodUnitCode
+          );
+          if (item.guaranteePeriod && item.guaranteePeriodUnitCode) {
+            item['guaranteePeriodDeadline'] =
+              item['guaranteePeriodDeadline'] ||
+              guaranteePeriodUnitName != 'second'
+                ? this.setDay(
+                    item.guaranteePeriod,
+                    guaranteePeriodUnitName,
+                    item
+                  )
+                : '';
+          }
+          item.entrustedEnterpriseIdList = supplierObj[item.productId];
+          // if (supplierObj[item.productId]?.length === 1) {
+          //   item.entrustedEnterpriseId = supplierObj[item.productId][0].id;
+          // }
+          // item.entrustedEnterpriseId = item.entrustedEnterpriseId || undefined;
+          this.$set(
+            item,
+            'entrustedEnterpriseId',
+            item.entrustedEnterpriseId || ''
+          );
+          item['customerMark'] = item.customerMark || this.customerMark;
+          item['arrivalWay'] = item.arrivalWay || 1;
+          item.discountRatio = Number(item.discountRatio) || 100;
+          item.taxRate =
+            item.taxRate !== undefined && item.taxRate !== null
+              ? item.taxRate
+              : this.isContractBook
+              ? this.defTaxRate
+              : this.isTaxRate == 1
+              ? this.defTaxRate
+              : undefined;
+          item.quoteWay = item.quoteWay || 1;
+        });
+        // 回显期间数据先放在暂存数组 productList 上处理,不要直接整表塞进表格:
+        // 上百行 × 数十个表单项一次性渲染会让主线程长时间阻塞,页面直接卡死。
+        if (this.needProduce == 0) {
+          await this.refreshStockOrderInventory(false, productList);
+        } else {
+          // 同一「物品编码+批次」只提交一次:订单有上百行时请求体不再重复膨胀,
+          // 回填仍按 matchInventoryTotal 按内容匹配,去重不影响匹配结果。
+          let codeList = [
+            ...new Set(
+              productList
+                .filter((item) => item.productCode)
+                .map((item) => buildInventoryTotalQueryKey(item))
+            )
+          ];
+          // 非库存式业务保留原有物品/批次汇总库存口径。
+          let inventoryTotalList = await getInventoryTotalAPI(codeList);
+          productList
+            .filter((item) => item.productCode)
+            .forEach((item, index) => {
+              let find =
+                inventoryTotalList.find((key) =>
+                  matchInventoryTotal(item, key)
+                ) || {};
+              if (this.showStockCount) {
                 this.$set(
-                  this.form.datasource[index],
-                  'availableCountBase',
-                  this.showStockCount
-                    ? this.getAvailableStockCount(find)
-                    : find.availableCountBase
+                  productList[index],
+                  'stockCountBase',
+                  this.getStockCount(find)
                 );
-              });
-          }
-          if (this.isChangeCount) {
-            this.changeCount();
-          } else {
-            this.allPrice = data.totalAmount || data?.contractVO?.totalPrice;
-            this.allQuantity = getAllQuantity(
-              this.form.datasource,
-              this.countObj
-            );
-          }
+              }
+              this.$set(
+                productList[index],
+                'availableCountBase',
+                this.showStockCount
+                  ? this.getAvailableStockCount(find)
+                  : find.availableCountBase
+              );
+            });
+        }
 
-          if (this.isDiscountTotalPrice) {
-            this.form.discountTotalPrice =
-              data.payAmount || data?.contractVO?.discountTotalPrice;
-          }
-          this.$refs.table.reload();
+        // 分批入表:每批 appendBatchSize 行,批与批之间让出一帧,
+        // 页面在整个回显过程中保持可响应(与发货单编辑回显的处理方式一致)。
+        this.form.datasource = [];
+        await this.appendRowsInBatches(productList, 0, null, {
+          recalculate: false
+        });
+
+        if (this.isChangeCount) {
+          this.changeCount();
+        } else {
+          this.allPrice = data.totalAmount || data?.contractVO?.totalPrice;
+          this.allQuantity = getAllQuantity(
+            this.form.datasource,
+            this.countObj
+          );
+        }
+
+        if (this.isDiscountTotalPrice) {
+          this.form.discountTotalPrice =
+            data.payAmount || data?.contractVO?.discountTotalPrice;
         }
       },
 
@@ -2869,45 +2992,56 @@
        * 分批追加:一次选中上百个物品时,一次性塞进表格会让主线程长时间阻塞,
        * 这里按 appendBatchSize 分批写入并重算,每批之间让出一帧,
        * 浏览器可以正常绘制 loading 遮罩和已经渲染出来的行。
+       *
+       * options.recalculate = false 时不重算本批行:
+       * 修改回显的是用户已经保存过的数据,不能按新增的逻辑重算价格/数量。
        */
       appendRowsInBatches(
         rows,
         startIndex = this.form.datasource.length,
-        onDone
+        onDone,
+        options = {}
       ) {
-        if (!rows.length) {
-          if (onDone) onDone();
-          this.endTableLoading();
-          return;
-        }
-        const batchSize = Math.max(1, this.appendBatchSize || 20);
-        const total = rows.length;
-        this.batchLoading = true;
-        this.loadedCount = 0;
-        this.totalCount = total;
-        let done = 0;
-
-        const step = () => {
-          const batch = rows.slice(done, done + batchSize);
-          if (batch.length) {
-            const from = startIndex + done;
-            this.form.datasource = this.form.datasource.concat(batch);
-            // 只重算本批新增的行,其余行复用原对象
-            this.recalculateTableOnce(batch.map((row, i) => from + i));
-            done += batch.length;
-            this.loadedCount = done;
-          }
-          if (done < total) {
-            requestAnimationFrame(step);
+        const shouldRecalculate = options.recalculate !== false;
+        return new Promise((resolve) => {
+          if (!rows.length) {
+            if (onDone) onDone();
+            this.endTableLoading();
+            resolve();
             return;
           }
-          // 全部批次都写完并渲染完,才收尾关闭 loading
-          this.batchLoading = false;
-          if (onDone) onDone();
-          this.endTableLoading();
-        };
+          const batchSize = Math.max(1, this.appendBatchSize || 20);
+          const total = rows.length;
+          this.batchLoading = true;
+          this.loadedCount = 0;
+          this.totalCount = total;
+          let done = 0;
+
+          const step = () => {
+            const batch = rows.slice(done, done + batchSize);
+            if (batch.length) {
+              const from = startIndex + done;
+              this.form.datasource = this.form.datasource.concat(batch);
+              // 只重算本批新增的行,其余行复用原对象
+              if (shouldRecalculate) {
+                this.recalculateTableOnce(batch.map((row, i) => from + i));
+              }
+              done += batch.length;
+              this.loadedCount = done;
+            }
+            if (done < total) {
+              requestAnimationFrame(step);
+              return;
+            }
+            // 全部批次都写完并渲染完,才收尾关闭 loading
+            this.batchLoading = false;
+            if (onDone) onDone();
+            this.endTableLoading();
+            resolve();
+          };
 
-        requestAnimationFrame(step);
+          requestAnimationFrame(step);
+        });
       },
       async replaceTable(obj, idx) {
         // 先按物品ID去重并并行查询商品信息,避免逐行串行请求,物品较多时明显加快回显
@@ -3090,8 +3224,11 @@
       },
       /**
        * 批量查询物品对应的商品信息。
-       * 按物品ID去重后并行请求,替代逐行 await 的串行查询,
-       * 同一物品选中多行(多批次)时只请求一次。
+       * 按物品ID去重后走调度器并行请求,替代逐行 await 的串行查询:
+       *   - 同一物品选中多行(多批次)时只请求一次
+       *   - 相同物品ID的并发请求合并成一次
+       *   - TTL 内重复选同一批物品直接命中缓存,不再打接口
+       *   - 同时在飞的请求数受 LOOKUP_MAX_CONCURRENCY 限制,避免打满连接池
        */
       async loadGoodsByCategoryIds(list) {
         const goodsMap = new Map();
@@ -3102,7 +3239,10 @@
         ];
         await Promise.all(
           productIds.map(async (productId) => {
-            const goodsList = await getGoodsByCategoryId(productId);
+            const goodsList = await scheduleLookup(
+              `goodsByCategoryId:${productId}`,
+              () => getGoodsByCategoryId(productId)
+            );
             goodsMap.set(productId, goodsList || []);
           })
         );