feat(warehouse): 优化库存搜索功能支持关键词分词匹配

- 将产品信息搜索字段改为关键词搜索,支持输入多个条件
- 实现分词搜索算法,支持按名称/规格/品牌/类别/单位/库区等多维度匹配
- 添加搜索结果相关性评分排序机制
- 优化移动端和PC端搜索界面提示文案
- 实现关键词高亮显示和精确/前缀/包含匹配策略
- 添加最大分词数量限制防止过度匹配
- 重构后端分页处理逻辑以适应新的搜索模式
This commit is contained in:
lixiaolong 2026-07-27 09:57:42 +08:00
parent e76c07fb5e
commit 46ec60d6f5
5 changed files with 151 additions and 6 deletions

View File

@ -1,5 +1,6 @@
package com.ruoyi.web.controller.warehouse;
import java.util.Collections;
import java.util.List;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
@ -11,10 +12,14 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.constant.HttpStatus;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.PageDomain;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.core.page.TableSupport;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.warehouse.domain.OutRecord;
import com.ruoyi.warehouse.domain.StockInfo;
@ -31,10 +36,31 @@ public class StockInfoController extends BaseController
@GetMapping("/list")
public TableDataInfo list(StockInfo stockInfo)
{
if (StringUtils.isNotBlank(stockInfo.getProductName()))
{
return getKeywordDataTable(stockInfoService.selectStockInfoList(stockInfo));
}
startPage();
return getDataTable(stockInfoService.selectStockInfoList(stockInfo));
}
private TableDataInfo getKeywordDataTable(List<StockInfo> list)
{
PageDomain pageDomain = TableSupport.buildPageRequest();
int pageNum = pageDomain.getPageNum() == null || pageDomain.getPageNum() < 1 ? 1 : pageDomain.getPageNum();
int pageSize = pageDomain.getPageSize() == null || pageDomain.getPageSize() < 1 ? 10 : pageDomain.getPageSize();
int total = list == null ? 0 : list.size();
int fromIndex = Math.min((pageNum - 1) * pageSize, total);
int toIndex = Math.min(fromIndex + pageSize, total);
TableDataInfo rspData = new TableDataInfo();
rspData.setCode(HttpStatus.SUCCESS);
rspData.setMsg("查询成功");
rspData.setTotal(total);
rspData.setRows(total == 0 ? Collections.emptyList() : list.subList(fromIndex, toIndex));
return rspData;
}
@PreAuthorize("@ss.hasPermi('warehouse:stock:export')")
@Log(title = "库存信息", businessType = BusinessType.EXPORT)
@PostMapping("/export")

View File

@ -1,9 +1,12 @@
package com.ruoyi.warehouse.service.impl;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.warehouse.domain.OutRecord;
import com.ruoyi.warehouse.domain.StockInfo;
import com.ruoyi.warehouse.mapper.StockInfoMapper;
@ -13,6 +16,8 @@ import com.ruoyi.warehouse.service.IStockInfoService;
@Service
public class StockInfoServiceImpl implements IStockInfoService
{
private static final int MAX_KEYWORD_TOKEN_COUNT = 5;
@Autowired
private StockInfoMapper stockInfoMapper;
@ -22,7 +27,121 @@ public class StockInfoServiceImpl implements IStockInfoService
@Override
public List<StockInfo> selectStockInfoList(StockInfo stockInfo)
{
return stockInfoMapper.selectStockInfoList(stockInfo);
List<String> tokens = parseKeywordTokens(stockInfo);
List<StockInfo> list = stockInfoMapper.selectStockInfoList(stockInfo);
if (tokens.isEmpty())
{
return list;
}
List<ScoredStock> scoredList = new ArrayList<>();
for (StockInfo item : list)
{
int score = scoreStock(item, tokens);
if (score > 0)
{
scoredList.add(new ScoredStock(item, score));
}
}
scoredList.sort((left, right) -> {
int scoreCompare = Integer.compare(right.score, left.score);
if (scoreCompare != 0)
{
return scoreCompare;
}
long leftId = left.stock.getId() == null ? 0L : left.stock.getId();
long rightId = right.stock.getId() == null ? 0L : right.stock.getId();
return Long.compare(rightId, leftId);
});
List<StockInfo> result = new ArrayList<>(scoredList.size());
for (ScoredStock item : scoredList)
{
result.add(item.stock);
}
return result;
}
private List<String> parseKeywordTokens(StockInfo stockInfo)
{
if (stockInfo == null || StringUtils.isBlank(stockInfo.getProductName()))
{
return new ArrayList<>();
}
LinkedHashSet<String> tokens = new LinkedHashSet<>();
String keyword = stockInfo.getProductName().trim().toLowerCase();
for (String item : keyword.split("[\\s,;;、]+"))
{
if (StringUtils.isNotBlank(item))
{
tokens.add(item.trim());
}
if (tokens.size() >= MAX_KEYWORD_TOKEN_COUNT)
{
break;
}
}
if (tokens.isEmpty())
{
return new ArrayList<>();
}
stockInfo.getParams().put("keywordTokens", new ArrayList<>(tokens));
return new ArrayList<>(tokens);
}
private int scoreStock(StockInfo stock, List<String> tokens)
{
int totalScore = 0;
for (String token : tokens)
{
int tokenScore = 0;
tokenScore += scoreField(stock.getProductName(), token, 120, 90, 60);
tokenScore += scoreField(stock.getSpec(), token, 90, 70, 45);
tokenScore += scoreField(stock.getBrand(), token, 80, 60, 40);
tokenScore += scoreField(stock.getCategory(), token, 70, 50, 35);
tokenScore += scoreField(stock.getAreaName(), token, 65, 45, 30);
tokenScore += scoreField(stock.getUnit(), token, 35, 0, 20);
tokenScore += scoreField(stock.getProductCode(), token, 0, 0, 10);
tokenScore += scoreField(stock.getAreaCode(), token, 0, 0, 10);
if (tokenScore <= 0)
{
return 0;
}
totalScore += tokenScore;
}
return totalScore;
}
private int scoreField(String value, String token, int exactScore, int prefixScore, int containsScore)
{
if (StringUtils.isBlank(value) || StringUtils.isBlank(token))
{
return 0;
}
String lowerValue = value.toLowerCase();
if (exactScore > 0 && lowerValue.equals(token))
{
return exactScore;
}
if (prefixScore > 0 && lowerValue.startsWith(token))
{
return prefixScore;
}
if (containsScore > 0 && lowerValue.contains(token))
{
return containsScore;
}
return 0;
}
private static class ScoredStock
{
private final StockInfo stock;
private final int score;
private ScoredStock(StockInfo stock, int score)
{
this.stock = stock;
this.score = score;
}
}
@Override

View File

@ -33,8 +33,8 @@
<if test="areaId != null">and si.area_id = #{areaId}</if>
<if test="productId != null">and si.product_id = #{productId}</if>
<if test="areaName != null and areaName != ''">and wa.area_name like concat('%', #{areaName}, '%')</if>
<if test="productName != null and productName != ''">
and lower(concat_ws(' ', pi.product_name, pi.brand, pi.category, pi.spec, pi.unit))
<if test="productName != null and productName != '' and (params.keywordTokens == null or params.keywordTokens.size() == 0)">
and lower(concat_ws(' ', pi.product_name, pi.brand, pi.category, pi.spec, pi.unit, wa.area_name))
like concat('%', lower(#{productName}), '%')
</if>
</where>

View File

@ -118,7 +118,7 @@
<el-select v-model="stockQuery.areaId" placeholder="库区" clearable filterable>
<el-option v-for="item in areaOptions" :key="item.id" :label="areaLabel(item)" :value="item.id" />
</el-select>
<el-input v-model="stockQuery.productName" placeholder="产品信息" clearable />
<el-input v-model="stockQuery.productName" placeholder="关键词,如:电机 20" clearable />
<el-button type="primary" icon="el-icon-search" @click="loadStock">搜索</el-button>
</div>

View File

@ -6,8 +6,8 @@
<el-option v-for="item in areaOptions" :key="item.id" :label="areaLabel(item)" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="产品信息" prop="productName">
<el-input v-model="queryParams.productName" placeholder="名称/品牌/类别/规格/单位" clearable @keyup.enter.native="handleQuery" />
<el-form-item label="关键词" prop="productName">
<el-input v-model="queryParams.productName" placeholder="名称/规格/品牌/类别/单位/库区,如:电机 20" clearable @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>