新增:批量导入商品功能

This commit is contained in:
2025-12-30 17:19:06 +08:00
parent 33a04d7f07
commit 67fa6a1f14
8 changed files with 171 additions and 81 deletions

46
.idea/workspace.xml generated
View File

@@ -4,8 +4,13 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="3ac55b60-f0f7-4a9b-af40-ce3c8e3c4479" name="更改" comment="">
<list default="true" id="3ac55b60-f0f7-4a9b-af40-ce3c8e3c4479" name="更改" comment="feat(goods): 添加Excel批量导入商品功能&#10;&#10;- 集成POI库支持Excel文件读取和解析&#10;- 实现商品信息从Excel表格到数据库的批量导入&#10;- 支持商品分类自动创建和映射&#10;- 添加商品图片从Excel单元格提取和保存功能&#10;- 实现导入过程中的数据验证和错误处理&#10;- 提供导入结果统计和反馈信息&#10;- 支持跳过已存在商品的可选配置&#10;- 添加导入参数的灵活配置选项">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/main/java/com/gxwebsoft/cms/vo/SideItemVo.java" beforeDir="false" afterPath="$PROJECT_DIR$/src/main/java/com/gxwebsoft/cms/vo/SideItemVo.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/main/java/com/gxwebsoft/common/system/entity/Cache.java" beforeDir="false" afterPath="$PROJECT_DIR$/src/main/java/com/gxwebsoft/common/system/entity/Cache.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/main/java/com/gxwebsoft/oa/param/CompanyParam.java" beforeDir="false" afterPath="$PROJECT_DIR$/src/main/java/com/gxwebsoft/oa/param/CompanyParam.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/main/java/com/gxwebsoft/shop/controller/GoodsController.java" beforeDir="false" afterPath="$PROJECT_DIR$/src/main/java/com/gxwebsoft/shop/controller/GoodsController.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/main/java/com/gxwebsoft/shop/vo/CartVo.java" beforeDir="false" afterPath="$PROJECT_DIR$/src/main/java/com/gxwebsoft/shop/vo/CartVo.java" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -15,6 +20,9 @@
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="MavenRunner">
<option name="skipTests" value="true" />
</component>
<component name="ProjectColorInfo"><![CDATA[{
"associatedIndex": 6
}]]></component>
@@ -81,6 +89,42 @@
<option name="presentableId" value="Default" />
<updated>1766987144189</updated>
</task>
<task id="LOCAL-00001" summary="feat(goods): 添加Excel批量导入商品功能&#10;&#10;- 集成POI库支持Excel文件读取和解析&#10;- 实现商品信息从Excel表格到数据库的批量导入&#10;- 支持商品分类自动创建和映射&#10;- 添加商品图片从Excel单元格提取和保存功能&#10;- 实现导入过程中的数据验证和错误处理&#10;- 提供导入结果统计和反馈信息&#10;- 支持跳过已存在商品的可选配置&#10;- 添加导入参数的灵活配置选项">
<option name="closed" value="true" />
<created>1766992044615</created>
<option name="number" value="00001" />
<option name="presentableId" value="LOCAL-00001" />
<option name="project" value="LOCAL" />
<updated>1766992044615</updated>
</task>
<option name="localTasksCounter" value="2" />
<servers />
</component>
<component name="Vcs.Log.Tabs.Properties">
<option name="TAB_STATES">
<map>
<entry key="MAIN">
<value>
<State>
<option name="FILTERS">
<map>
<entry key="branch">
<value>
<list>
<option value="dev" />
</list>
</value>
</entry>
</map>
</option>
</State>
</value>
</entry>
</map>
</option>
</component>
<component name="VcsManagerConfiguration">
<MESSAGE value="feat(goods): 添加Excel批量导入商品功能&#10;&#10;- 集成POI库支持Excel文件读取和解析&#10;- 实现商品信息从Excel表格到数据库的批量导入&#10;- 支持商品分类自动创建和映射&#10;- 添加商品图片从Excel单元格提取和保存功能&#10;- 实现导入过程中的数据验证和错误处理&#10;- 提供导入结果统计和反馈信息&#10;- 支持跳过已存在商品的可选配置&#10;- 添加导入参数的灵活配置选项" />
<option name="LAST_COMMIT_MESSAGE" value="feat(goods): 添加Excel批量导入商品功能&#10;&#10;- 集成POI库支持Excel文件读取和解析&#10;- 实现商品信息从Excel表格到数据库的批量导入&#10;- 支持商品分类自动创建和映射&#10;- 添加商品图片从Excel单元格提取和保存功能&#10;- 实现导入过程中的数据验证和错误处理&#10;- 提供导入结果统计和反馈信息&#10;- 支持跳过已存在商品的可选配置&#10;- 添加导入参数的灵活配置选项" />
</component>
</project>

0
mvnw vendored Normal file → Executable file
View File

View File

@@ -10,7 +10,7 @@ import java.io.Serializable;
@Data
@EqualsAndHashCode(callSuper = false)
@ApiModel(value = "幻灯片广告", description = "幻灯片广告")
@ApiModel(value = "幻灯片广告", description = "幻灯片广告")
public class SideItemVo implements Serializable {
@ApiModelProperty("ID")

View File

@@ -15,7 +15,7 @@ import java.io.Serializable;
*/
@Data
@EqualsAndHashCode(callSuper = false)
@ApiModel(value = "Setting对象", description = "缓存管理")
@ApiModel(value = "Cache对象", description = "缓存管理")
public class Cache implements Serializable {
private static final long serialVersionUID = 1L;

View File

@@ -21,7 +21,7 @@ import java.util.Set;
@Data
@EqualsAndHashCode(callSuper = false)
@JsonInclude(JsonInclude.Include.NON_NULL)
@ApiModel(value = "CompanyParam对象", description = "企业信息查询参数")
@ApiModel(value = "OaCompanyParam对象", description = "企业信息查询参数(OA)")
public class CompanyParam extends BaseParam {
private static final long serialVersionUID = 1L;

View File

@@ -26,6 +26,7 @@ import com.gxwebsoft.common.core.web.PageParam;
import com.gxwebsoft.common.core.web.BatchParam;
import com.gxwebsoft.common.core.annotation.OperationLog;
import com.gxwebsoft.common.system.entity.User;
import com.gxwebsoft.shop.vo.GoodsExcelImportResult;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.apache.poi.ss.usermodel.*;
@@ -45,6 +46,7 @@ import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.math.BigDecimal;
import java.net.URI;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -297,17 +299,25 @@ public class GoodsController extends BaseController {
@ApiOperation("批量导入商品(Excel)")
@PostMapping("/import-excel")
public ApiResult<GoodsImportResult> importExcel(@RequestParam(value = "path", required = false) String path,
@RequestParam(value = "sheetName", required = false) String sheetName,
@RequestParam(value = "skipExisting", required = false, defaultValue = "true") Boolean skipExisting,
@RequestParam(value = "createCategory", required = false, defaultValue = "true") Boolean createCategory,
@RequestParam(value = "merchantId", required = false) Integer merchantId,
@RequestParam(value = "defaultIsShow", required = false) Integer defaultIsShow,
@RequestParam(value = "defaultStock", required = false) Integer defaultStock) {
public ApiResult<GoodsExcelImportResult> importExcel(@RequestParam(value = "path", required = false) String path,
@RequestParam(value = "sheetName", required = false) String sheetName,
@RequestParam(value = "skipExisting", required = false, defaultValue = "true") Boolean skipExisting,
@RequestParam(value = "createCategory", required = false, defaultValue = "true") Boolean createCategory,
@RequestParam(value = "merchantId", required = false) Integer merchantId,
@RequestParam(value = "defaultIsShow", required = false) Integer defaultIsShow,
@RequestParam(value = "defaultStock", required = false) Integer defaultStock) {
String excelPath = StrUtil.blankToDefault(path, DEFAULT_IMPORT_EXCEL_PATH);
String targetSheetName = StrUtil.blankToDefault(sheetName, "Sheet1");
if (!FileUtil.exist(excelPath)) {
return fail("Excel文件不存在: " + excelPath,null);
GoodsExcelImportResult result = new GoodsExcelImportResult();
result.setExcelPath(excelPath);
result.setSheetName(targetSheetName);
ResolvedExcelFile resolvedExcelFile;
try {
resolvedExcelFile = resolveExcelFile(excelPath);
} catch (IllegalArgumentException e) {
return fail(e.getMessage(), result);
} catch (Exception e) {
return fail("Excel文件读取失败: " + e.getMessage(), result).setError(e.toString());
}
User loginUser = getLoginUser();
@@ -315,16 +325,16 @@ public class GoodsController extends BaseController {
Integer importMerchantId = merchantId != null ? merchantId : (loginUser == null ? 0 : (loginUser.getMerchantId() == null ? 0 : loginUser.getMerchantId()));
int importIsShow = defaultIsShow != null ? defaultIsShow : 0;
int importStock = defaultStock != null ? defaultStock : 0;
GoodsImportResult result = new GoodsImportResult();
result.setExcelPath(excelPath);
result.setSheetName(targetSheetName);
result.setMerchantId(importMerchantId);
DataFormatter formatter = new DataFormatter();
try (ZipFile zipFile = new ZipFile(excelPath); InputStream in = new FileInputStream(excelPath); Workbook workbook = new XSSFWorkbook(in)) {
List<Goods> toInsert = new java.util.ArrayList<>();
try (ZipFile zipFile = new ZipFile(resolvedExcelFile.file);
InputStream in = new FileInputStream(resolvedExcelFile.file);
Workbook workbook = new XSSFWorkbook(in)) {
Sheet sheet = workbook.getSheet(targetSheetName);
if (sheet == null) {
return fail("未找到工作表: " + targetSheetName,null);
return fail("未找到工作表: " + targetSheetName, result);
}
Map<String, String> cellImageIdToMediaEntry = buildCellImageIdToMediaEntry(zipFile);
@@ -393,6 +403,7 @@ public class GoodsController extends BaseController {
if (category == null && createCategory) {
category = createCategory(importMerchantId, categoryName.trim());
categoryByTitle.put(category.getTitle(), category);
result.setCreatedCategories(result.getCreatedCategories() + 1);
}
if (category != null) {
goods.setCategoryId(category.getCategoryId());
@@ -406,26 +417,82 @@ public class GoodsController extends BaseController {
String url = saveCellImage(zipFile, mediaEntry, imageId);
if (StrUtil.isNotBlank(url)) {
goods.setImage(url);
result.setSavedImages(result.getSavedImages() + 1);
}
}
}
result.getGoods().add(goods);
toInsert.add(goods);
}
if (result.getGoods().isEmpty()) {
if (toInsert.isEmpty()) {
return success("未读取到可导入的数据", result);
}
if (goodsService.saveBatch(result.getGoods())) {
result.setInserted(result.getGoods().size());
if (goodsService.saveBatch(toInsert)) {
result.setInserted(toInsert.size());
return success("导入成功", result);
}
return fail("导入失败", result);
} catch (Exception e) {
return fail("导入异常: " + e.getMessage(), result).setError(e.toString());
} finally {
resolvedExcelFile.cleanupQuietly();
}
}
private static final class ResolvedExcelFile {
private final File file;
private final boolean deleteOnClose;
private ResolvedExcelFile(File file, boolean deleteOnClose) {
this.file = file;
this.deleteOnClose = deleteOnClose;
}
private void cleanupQuietly() {
if (!deleteOnClose) {
return;
}
try {
FileUtil.del(file);
} catch (Exception ignored) {
}
}
}
private ResolvedExcelFile resolveExcelFile(String excelPath) throws Exception {
if (StrUtil.isBlank(excelPath)) {
throw new IllegalArgumentException("Excel路径为空");
}
String trimmed = excelPath.trim();
if (StrUtil.startWithIgnoreCase(trimmed, "http://") || StrUtil.startWithIgnoreCase(trimmed, "https://")) {
String suffix = ".xlsx";
String withoutQuery = StrUtil.subBefore(trimmed, "?", false);
int dot = withoutQuery.lastIndexOf('.');
if (dot > 0 && dot < withoutQuery.length() - 1) {
suffix = "." + withoutQuery.substring(dot + 1);
}
File tmp = File.createTempFile("goods-import-", suffix);
HttpUtil.downloadFile(trimmed, tmp);
if (!tmp.exists() || tmp.length() == 0) {
FileUtil.del(tmp);
throw new IllegalStateException("Excel下载失败或文件为空");
}
return new ResolvedExcelFile(tmp, true);
}
if (StrUtil.startWithIgnoreCase(trimmed, "file:")) {
File f = new File(URI.create(trimmed));
if (!f.exists()) {
throw new IllegalArgumentException("Excel文件不存在: " + excelPath);
}
return new ResolvedExcelFile(f, false);
}
if (!FileUtil.exist(trimmed)) {
throw new IllegalArgumentException("Excel文件不存在: " + excelPath);
}
return new ResolvedExcelFile(FileUtil.file(trimmed), false);
}
private Map<String, GoodsCategory> loadCategoryMap(Integer merchantId) {
List<GoodsCategory> categories = goodsCategoryService.list(new LambdaQueryWrapper<GoodsCategory>()
.eq(GoodsCategory::getMerchantId, merchantId)
@@ -597,63 +664,6 @@ public class GoodsController extends BaseController {
return idToMedia;
}
public static class GoodsImportResult {
private String excelPath;
private String sheetName;
private int totalRows;
private int inserted;
private int skippedExists;
private List<Goods> goods = new java.util.ArrayList<>();
public String getExcelPath() {
return excelPath;
}
public void setExcelPath(String excelPath) {
this.excelPath = excelPath;
}
public String getSheetName() {
return sheetName;
}
public void setSheetName(String sheetName) {
this.sheetName = sheetName;
}
public int getTotalRows() {
return totalRows;
}
public void setTotalRows(int totalRows) {
this.totalRows = totalRows;
}
public int getInserted() {
return inserted;
}
public void setInserted(int inserted) {
this.inserted = inserted;
}
public int getSkippedExists() {
return skippedExists;
}
public void setSkippedExists(int skippedExists) {
this.skippedExists = skippedExists;
}
public List<Goods> getGoods() {
return goods;
}
public void setGoods(List<Goods> goods) {
this.goods = goods;
}
}
@ApiOperation("生成海报")
@PostMapping("/make-goods-poster/{goodsId}")
public ApiResult<?> makePoster(@PathVariable Integer goodsId) throws Exception {

View File

@@ -15,7 +15,7 @@ import java.util.List;
@Data
@EqualsAndHashCode(callSuper = false)
@ApiModel(value = "Cart对象", description = "购物车")
@ApiModel(value = "CartVo对象", description = "购物车(返回结构)")
@TableName("shop_cart")
public class CartVo implements Serializable {

View File

@@ -0,0 +1,36 @@
package com.gxwebsoft.shop.vo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
@Data
@ApiModel(value = "GoodsExcelImportResult", description = "商品Excel导入结果")
public class GoodsExcelImportResult implements Serializable {
@ApiModelProperty("Excel文件路径")
private String excelPath;
@ApiModelProperty("工作表名称")
private String sheetName;
@ApiModelProperty("导入到的商户ID")
private Integer merchantId;
@ApiModelProperty("扫描到的有效行数(有品种/商品名)")
private int totalRows;
@ApiModelProperty("成功入库数量")
private int inserted;
@ApiModelProperty("跳过(已存在同名商品)")
private int skippedExists;
@ApiModelProperty("自动创建的分类数量")
private int createdCategories;
@ApiModelProperty("成功导出的图片数量")
private int savedImages;
}