diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..0bd0248 --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,199 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.mvn/wrapper/maven-wrapper.jar b/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 0000000..2cc7d4a Binary files /dev/null and b/.mvn/wrapper/maven-wrapper.jar differ diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..642d572 --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,2 @@ +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.6.3/apache-maven-3.6.3-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar diff --git a/src/main/java/com/gxwebsoft/shop/controller/GoodsController.java b/src/main/java/com/gxwebsoft/shop/controller/GoodsController.java index ae46fcc..15d209b 100644 --- a/src/main/java/com/gxwebsoft/shop/controller/GoodsController.java +++ b/src/main/java/com/gxwebsoft/shop/controller/GoodsController.java @@ -1,5 +1,9 @@ package com.gxwebsoft.shop.controller; +import cn.afterturn.easypoi.excel.ExcelExportUtil; +import cn.afterturn.easypoi.excel.ExcelImportUtil; +import cn.afterturn.easypoi.excel.entity.ExportParams; +import cn.afterturn.easypoi.excel.entity.ImportParams; import cn.binarywang.wx.miniapp.api.WxMaQrcodeService; import cn.binarywang.wx.miniapp.api.WxMaService; import cn.binarywang.wx.miniapp.api.impl.WxMaQrcodeServiceImpl; @@ -18,6 +22,7 @@ import com.gxwebsoft.common.system.entity.DictData; import com.gxwebsoft.shop.entity.*; import com.gxwebsoft.shop.service.*; import com.gxwebsoft.shop.param.GoodsParam; +import com.gxwebsoft.shop.param.GoodsImportParam; import com.gxwebsoft.common.core.web.ApiResult; import com.gxwebsoft.common.core.web.PageResult; import com.gxwebsoft.common.core.web.PageParam; @@ -26,20 +31,32 @@ import com.gxwebsoft.common.core.annotation.OperationLog; import com.gxwebsoft.common.system.entity.User; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import org.apache.poi.ss.usermodel.Workbook; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import javax.annotation.Resource; import javax.imageio.ImageIO; +import javax.servlet.http.HttpServletResponse; import java.awt.*; import java.awt.image.BufferedImage; import java.io.File; +import java.io.IOException; +import java.io.InputStream; import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; import java.util.HashMap; +import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Objects; /** * 商品记录表控制器 @@ -240,6 +257,195 @@ public class GoodsController extends BaseController { return fail("删除失败"); } + /** + * 批量导入商品(对应 goods-import.xlsx 第一页) + * 表头:序号、作用分类(除消费者外可见)、品种、品牌、厂家、销量、规格、产品图片、成本(来华+运费)、商城售价(无标注按厂家)、产品分类(所有可见)、公司 + */ + @ApiOperation("批量导入商品") + @PostMapping("/import") + public ApiResult> importBatch(@RequestParam("file") MultipartFile file) { + List errorMessages = new ArrayList<>(); + int insertCount = 0; + int updateCount = 0; + + try { + List list = null; + int usedTitleRows = 0; + int usedHeadRows = 0; + int[][] tryConfigs = new int[][]{{1, 1}, {0, 1}, {0, 2}, {0, 3}}; + + for (int[] config : tryConfigs) { + list = filterEmptyRows(tryImport(file, config[0], config[1])); + if (list != null && !list.isEmpty()) { + usedTitleRows = config[0]; + usedHeadRows = config[1]; + break; + } + } + if (list == null || list.isEmpty()) { + return fail("未读取到数据,请确认模板表头与示例格式一致", null); + } + + User loginUser = getLoginUser(); + Integer currentUserId = loginUser != null ? loginUser.getUserId() : null; + Integer currentTenantId = loginUser != null ? loginUser.getTenantId() : null; + Integer defaultMerchantId = loginUser != null ? loginUser.getMerchantId() : null; + + Map merchantCache = new HashMap<>(); + Merchant defaultMerchant = defaultMerchantId != null ? merchantService.getById(defaultMerchantId) : null; + Map imageLinkMap = readHyperlinksByHeader(file, 0, usedTitleRows, usedHeadRows, "产品图片"); + + for (int i = 0; i < list.size(); i++) { + GoodsImportParam param = list.get(i); + int excelRowNumber = i + 1 + usedTitleRows + usedHeadRows; + try { + Goods item = convertImportParamToEntity(param); + String imageLink = imageLinkMap.get(i); + if (isBlank(item.getImage()) && !isBlank(imageLink)) { + item.setImage(imageLink); + } + // 兼容数据库 shop_goods.files 非空约束:默认使用image或空串兜底 + if (isBlank(item.getFiles())) { + item.setFiles(!isBlank(item.getImage()) ? item.getImage() : ""); + } + + if (isBlank(item.getGoodsName())) { + errorMessages.add("第" + excelRowNumber + "行:品种(商品名称)不能为空"); + continue; + } + + Integer merchantId = defaultMerchantId != null ? defaultMerchantId : 0; + Merchant merchant = defaultMerchant; + if (merchantId == 0 && !isBlank(param.getCompany())) { + merchant = resolveMerchantByName(param.getCompany(), merchantCache); + if (merchant != null && merchant.getMerchantId() != null) { + merchantId = merchant.getMerchantId(); + } + } + item.setMerchantId(merchantId); + if (isBlank(item.getMerchantName())) { + if (merchant != null && !isBlank(merchant.getMerchantName())) { + item.setMerchantName(merchant.getMerchantName()); + } else if (!isBlank(param.getCompany())) { + item.setMerchantName(param.getCompany()); + } + } + + if (item.getUserId() == null && currentUserId != null) { + item.setUserId(currentUserId); + } + if (item.getTenantId() == null) { + if (currentTenantId != null) { + item.setTenantId(currentTenantId); + } else if (merchant != null && merchant.getTenantId() != null) { + item.setTenantId(merchant.getTenantId()); + } + } + if (item.getType() == null) { + item.setType(0); + } + if (item.getSpecs() == null) { + item.setSpecs(0); + } + if (item.getDeductStockType() == null) { + item.setDeductStockType(10); + } + if (item.getIsShow() == null) { + item.setIsShow(0); + } + if (item.getStatus() == null) { + item.setStatus(0); + } + if (item.getRecommend() == null) { + item.setRecommend(0); + } + if (item.getDeleted() == null) { + item.setDeleted(0); + } + if (item.getStock() == null) { + item.setStock(0); + } + if (item.getSales() == null) { + item.setSales(0); + } + + // upsert:同商户下按商品名称匹配 + Goods existing = goodsService.getOne( + new LambdaQueryWrapper() + .eq(Goods::getMerchantId, merchantId) + .eq(Goods::getGoodsName, item.getGoodsName()) + .last("limit 1") + ); + + if (existing == null) { + if (goodsService.save(item)) { + insertCount++; + } else { + errorMessages.add("第" + excelRowNumber + "行:保存失败"); + } + } else { + mergeImportedToExisting(existing, item); + if (goodsService.updateById(existing)) { + updateCount++; + } else { + errorMessages.add("第" + excelRowNumber + "行:更新失败"); + } + } + } catch (Exception e) { + errorMessages.add("第" + excelRowNumber + "行:" + e.getMessage()); + e.printStackTrace(); + } + } + + String message = "导入完成,新增" + insertCount + "条"; + if (updateCount > 0) { + message += ",更新" + updateCount + "条"; + } + if (errorMessages.isEmpty()) { + return success(message, null); + } + message += ",失败" + errorMessages.size() + "条"; + return success(message, errorMessages); + } catch (Exception e) { + e.printStackTrace(); + return fail("导入失败:" + e.getMessage(), null); + } + } + + /** + * 下载商品导入模板(仅生成第一张sheet的表头及示例数据) + */ + @ApiOperation("下载商品导入模板") + @GetMapping("/import/template") + public void downloadImportTemplate(HttpServletResponse response) throws IOException { + List templateList = new ArrayList<>(); + + GoodsImportParam example = new GoodsImportParam(); + example.setCode("1"); + example.setEnsureTag("示例分类"); + example.setGoodsName("示例商品"); + example.setBrand("示例品牌"); + example.setSupplierName("示例厂家"); + example.setSales("2000+"); + example.setUnitName("40袋/盒"); + example.setImage(""); + example.setCostPrice("8.2"); + example.setSalePrice("44.97"); + example.setCategory("示例分类"); + example.setCompany("示例公司"); + templateList.add(example); + + ExportParams exportParams = new ExportParams("商品导入模板", "商品"); + Workbook workbook = ExcelExportUtil.exportExcel(exportParams, GoodsImportParam.class, templateList); + + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + String filename = java.net.URLEncoder.encode("goods_import_template.xlsx", StandardCharsets.UTF_8); + response.setHeader("Content-Disposition", "attachment; filename=" + filename); + + workbook.write(response.getOutputStream()); + workbook.close(); + } + @ApiOperation("统计信息") @GetMapping("/data") public ApiResult> data(GoodsParam param) { @@ -418,4 +624,239 @@ public class GoodsController extends BaseController { return success("生成成功", serverUrl.concat("/" + filePath)); } + private List tryImport(MultipartFile file, int titleRows, int headRows) throws Exception { + ImportParams importParams = new ImportParams(); + importParams.setTitleRows(titleRows); + importParams.setHeadRows(headRows); + importParams.setStartSheetIndex(0); + importParams.setSheetNum(1); + return ExcelImportUtil.importExcel(file.getInputStream(), GoodsImportParam.class, importParams); + } + + /** + * 读取指定表头列的超链接地址,按数据行顺序返回(key从0开始)。 + */ + private Map readHyperlinksByHeader(MultipartFile file, int sheetIndex, int titleRows, int headRows, String headerName) throws Exception { + Map result = new HashMap<>(); + if (isBlank(headerName)) { + return result; + } + try (InputStream is = file.getInputStream(); org.apache.poi.ss.usermodel.Workbook workbook = WorkbookFactory.create(is)) { + Sheet sheet = workbook.getSheetAt(sheetIndex); + if (sheet == null) { + return result; + } + int headerRowNum = titleRows + headRows - 1; + Row headerRow = sheet.getRow(headerRowNum); + if (headerRow == null) { + return result; + } + int colIndex = -1; + for (int c = headerRow.getFirstCellNum(); c < headerRow.getLastCellNum(); c++) { + Cell cell = headerRow.getCell(c); + if (cell != null && headerName.equals(cell.getStringCellValue())) { + colIndex = c; + break; + } + } + if (colIndex < 0) { + return result; + } + int dataStartRow = titleRows + headRows; + for (int r = dataStartRow; r <= sheet.getLastRowNum(); r++) { + Row row = sheet.getRow(r); + if (row == null) { + continue; + } + Cell cell = row.getCell(colIndex); + if (cell != null && cell.getHyperlink() != null) { + String address = cell.getHyperlink().getAddress(); + if (!isBlank(address)) { + result.put(r - dataStartRow, address); + } + } + } + } + return result; + } + + /** + * 过滤掉完全空白的导入行,避免空行导致导入失败 + */ + private List filterEmptyRows(List rawList) { + if (rawList == null || rawList.isEmpty()) { + return rawList; + } + rawList.removeIf(this::isEmptyImportRow); + return rawList; + } + + private boolean isEmptyImportRow(GoodsImportParam param) { + if (param == null) { + return true; + } + return isBlank(param.getGoodsName()) + && isBlank(param.getCode()) + && isBlank(param.getCategory()) + && isBlank(param.getCompany()) + && isBlank(param.getSupplierName()) + && isBlank(param.getSalePrice()) + && isBlank(param.getCostPrice()); + } + + private boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } + + private Merchant resolveMerchantByName(String merchantName, Map cache) { + if (isBlank(merchantName)) { + return null; + } + if (cache.containsKey(merchantName)) { + return cache.get(merchantName); + } + Merchant merchant = merchantService.getOne( + new LambdaQueryWrapper() + .eq(Merchant::getMerchantName, merchantName) + .last("limit 1") + ); + cache.put(merchantName, merchant); + return merchant; + } + + private Goods convertImportParamToEntity(GoodsImportParam param) { + Goods entity = new Goods(); + if (param == null) { + return entity; + } + entity.setCode(trimToNull(param.getCode())); + entity.setGoodsName(trimToNull(param.getGoodsName())); + entity.setEnsureTag(trimToNull(param.getEnsureTag())); + entity.setCategory(trimToNull(param.getCategory())); + entity.setUnitName(trimToNull(param.getUnitName())); + entity.setImage(trimToNull(param.getImage())); + // 兼容数据库 shop_goods.files 非空约束:默认使用image或空串兜底 + entity.setFiles(!isBlank(entity.getImage()) ? entity.getImage() : ""); + entity.setSupplierName(trimToNull(param.getSupplierName())); + entity.setMerchantName(trimToNull(param.getCompany())); + + Integer sales = parseSales(param.getSales()); + if (sales != null) { + entity.setSales(sales); + } + + BigDecimal cost = parseBigDecimal(param.getCostPrice()); + BigDecimal sale = parseBigDecimal(param.getSalePrice()); + if (cost != null) { + entity.setPrice(cost); + } + if (sale != null) { + entity.setSalePrice(sale); + entity.setOriginPrice(sale); + } else if (cost != null) { + entity.setSalePrice(cost); + entity.setOriginPrice(cost); + } + + String comments = buildComments(param); + if (!isBlank(comments)) { + entity.setComments(comments); + } + return entity; + } + + private void mergeImportedToExisting(Goods existing, Goods incoming) { + if (existing == null || incoming == null) { + return; + } + if (!isBlank(incoming.getCode())) { + existing.setCode(incoming.getCode()); + } + if (!isBlank(incoming.getEnsureTag())) { + existing.setEnsureTag(incoming.getEnsureTag()); + } + if (!isBlank(incoming.getCategory())) { + existing.setCategory(incoming.getCategory()); + } + if (!isBlank(incoming.getUnitName())) { + existing.setUnitName(incoming.getUnitName()); + } + if (!isBlank(incoming.getImage())) { + existing.setImage(incoming.getImage()); + } + if (!isBlank(incoming.getFiles())) { + existing.setFiles(incoming.getFiles()); + } + if (!isBlank(incoming.getSupplierName())) { + existing.setSupplierName(incoming.getSupplierName()); + } + if (!isBlank(incoming.getMerchantName())) { + existing.setMerchantName(incoming.getMerchantName()); + } + if (!isBlank(incoming.getComments())) { + existing.setComments(incoming.getComments()); + } + if (incoming.getSales() != null) { + existing.setSales(incoming.getSales()); + } + if (incoming.getPrice() != null) { + existing.setPrice(incoming.getPrice()); + } + if (incoming.getSalePrice() != null) { + existing.setSalePrice(incoming.getSalePrice()); + } + if (incoming.getOriginPrice() != null) { + existing.setOriginPrice(incoming.getOriginPrice()); + } + } + + private BigDecimal parseBigDecimal(String value) { + if (isBlank(value)) { + return null; + } + String v = value.trim(); + // 去掉可能存在的中文/符号 + v = v.replace(",", ","); + v = v.replaceAll("[^0-9+\\-.,]", ""); + v = v.replace(",", ""); + if (isBlank(v)) { + return null; + } + return new BigDecimal(v); + } + + private Integer parseSales(String value) { + if (isBlank(value)) { + return null; + } + String digits = value.replaceAll("[^0-9]", ""); + if (isBlank(digits)) { + return null; + } + try { + return Integer.parseInt(digits); + } catch (Exception ignored) { + return null; + } + } + + private String trimToNull(String value) { + if (value == null) { + return null; + } + String v = value.trim(); + return v.isEmpty() ? null : v; + } + + private String buildComments(GoodsImportParam param) { + if (param == null) { + return null; + } + String brand = trimToNull(param.getBrand()); + if (brand == null) { + return null; + } + return "品牌:" + brand; + } + } diff --git a/src/main/java/com/gxwebsoft/shop/param/GoodsImportParam.java b/src/main/java/com/gxwebsoft/shop/param/GoodsImportParam.java new file mode 100644 index 0000000..73e04e4 --- /dev/null +++ b/src/main/java/com/gxwebsoft/shop/param/GoodsImportParam.java @@ -0,0 +1,57 @@ +package com.gxwebsoft.shop.param; + +import cn.afterturn.easypoi.excel.annotation.Excel; +import lombok.Data; + +import java.io.Serializable; + +/** + * 商品导入参数(对应 goods-import.xlsx 第一页) + * + * 表头:序号、作用分类(除消费者外可见)、品种、品牌、厂家、销量、规格、产品图片、成本(来华+运费)、商城售价(无标注按厂家)、产品分类(所有可见)、公司 + * + * 说明:产品图片列支持直接填写图片URL,或在单元格中设置超链接(导入时读取超链接地址)。 + * + * @author 科技小王子 + * @since 2025-12-30 + */ +@Data +public class GoodsImportParam implements Serializable { + private static final long serialVersionUID = 1L; + + @Excel(name = "序号") + private String code; + + @Excel(name = "作用分类(除消费者外可见)") + private String ensureTag; + + @Excel(name = "品种") + private String goodsName; + + @Excel(name = "品牌") + private String brand; + + @Excel(name = "厂家") + private String supplierName; + + @Excel(name = "销量") + private String sales; + + @Excel(name = "规格") + private String unitName; + + @Excel(name = "产品图片") + private String image; + + @Excel(name = "成本(来华+运费)") + private String costPrice; + + @Excel(name = "商城售价(无标注按厂家)") + private String salePrice; + + @Excel(name = "产品分类(所有可见)") + private String category; + + @Excel(name = "公司") + private String company; +}