diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..ccf8d31 --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1766987144189 + + + + \ No newline at end of file diff --git a/src/main/java/com/gxwebsoft/shop/controller/GoodsController.java b/src/main/java/com/gxwebsoft/shop/controller/GoodsController.java index ae46fcc..950c988 100644 --- a/src/main/java/com/gxwebsoft/shop/controller/GoodsController.java +++ b/src/main/java/com/gxwebsoft/shop/controller/GoodsController.java @@ -7,10 +7,12 @@ import cn.binarywang.wx.miniapp.bean.WxMaCodeLineColor; import cn.hutool.core.img.ImgUtil; import cn.hutool.core.io.FileUtil; import cn.hutool.http.HttpUtil; +import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.freewayso.image.combiner.ImageCombiner; import com.freewayso.image.combiner.enums.OutputFormat; +import com.gxwebsoft.common.core.config.ConfigProperties; import com.gxwebsoft.common.core.utils.JSONUtil; import com.gxwebsoft.common.core.utils.RequestUtil; import com.gxwebsoft.common.core.web.BaseController; @@ -26,6 +28,8 @@ 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.*; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.access.prepost.PreAuthorize; @@ -33,13 +37,27 @@ import org.springframework.web.bind.annotation.*; import javax.annotation.Resource; import javax.imageio.ImageIO; +import javax.xml.parsers.DocumentBuilderFactory; import java.awt.*; +import java.awt.Color; import java.awt.image.BufferedImage; import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; import java.math.BigDecimal; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; /** * 商品记录表控制器 @@ -51,6 +69,8 @@ import java.util.Map; @RestController @RequestMapping("/api/shop/goods") public class GoodsController extends BaseController { + private static final String DEFAULT_IMPORT_EXCEL_PATH = "/Users/gxwebsoft/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/ip170com_ae28/msg/file/2025-12/产品目录-商城储备12.26(2).xlsx"; + @Resource private GoodsService goodsService; @Resource @@ -61,6 +81,10 @@ public class GoodsController extends BaseController { private MerchantService merchantService; @Resource private GoodsRoleCommissionService goodsRoleCommissionService; + @Resource + private GoodsCategoryService goodsCategoryService; + @Resource + private ConfigProperties config; @Value("${config.upload-path}") private String uploadPath; @@ -271,6 +295,365 @@ public class GoodsController extends BaseController { return success(data); } + @ApiOperation("批量导入商品(Excel)") + @PostMapping("/import-excel") + public ApiResult 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); + } + + User loginUser = getLoginUser(); + Integer importUserId = loginUser == null ? null : loginUser.getUserId(); + 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); + + DataFormatter formatter = new DataFormatter(); + try (ZipFile zipFile = new ZipFile(excelPath); InputStream in = new FileInputStream(excelPath); Workbook workbook = new XSSFWorkbook(in)) { + Sheet sheet = workbook.getSheet(targetSheetName); + if (sheet == null) { + return fail("未找到工作表: " + targetSheetName,null); + } + + Map cellImageIdToMediaEntry = buildCellImageIdToMediaEntry(zipFile); + Map categoryByTitle = loadCategoryMap(importMerchantId); + + Set names = collectGoodsNames(sheet, formatter); + Set existingNames = java.util.Collections.emptySet(); + if (skipExisting && !names.isEmpty()) { + existingNames = goodsService.list(new LambdaQueryWrapper() + .eq(Goods::getMerchantId, importMerchantId) + .in(Goods::getGoodsName, names)) + .stream().map(Goods::getGoodsName).collect(Collectors.toSet()); + } + + int lastRow = sheet.getLastRowNum(); + for (int i = 1; i <= lastRow; i++) { + Row row = sheet.getRow(i); + if (row == null) { + continue; + } + + String goodsName = getCellString(row, 2, formatter); + if (StrUtil.isBlank(goodsName)) { + continue; + } + goodsName = goodsName.trim(); + result.setTotalRows(result.getTotalRows() + 1); + + if (skipExisting && existingNames.contains(goodsName)) { + result.setSkippedExists(result.getSkippedExists() + 1); + continue; + } + + String actionCategory = getCellString(row, 1, formatter); + String brand = getCellString(row, 3, formatter); + String manufacturer = getCellString(row, 4, formatter); + String spec = getCellString(row, 6, formatter); + String categoryName = getCellString(row, 10, formatter); + String supplierName = getCellString(row, 11, formatter); + + BigDecimal costPrice = getCellDecimal(row, 8, formatter); + BigDecimal salePrice = getCellDecimal(row, 9, formatter); + Integer sales = parseSales(getCellString(row, 5, formatter)); + + Goods goods = new Goods(); + goods.setGoodsName(goodsName); + goods.setType(0); + goods.setSpecs(0); + goods.setDeductStockType(10); + goods.setStock(importStock); + goods.setIsShow(importIsShow); + goods.setStatus(1); + goods.setMerchantId(importMerchantId); + goods.setUserId(importUserId); + goods.setUnitName(spec); + goods.setSupplierName(supplierName); + goods.setSales(sales); + goods.setPrice(costPrice); + goods.setSalePrice(salePrice != null ? salePrice : costPrice); + goods.setOriginPrice(goods.getSalePrice()); + goods.setCategoryName(categoryName); + goods.setComments(buildComments(actionCategory, brand, manufacturer)); + + if (StrUtil.isNotBlank(categoryName)) { + GoodsCategory category = categoryByTitle.get(categoryName.trim()); + if (category == null && createCategory) { + category = createCategory(importMerchantId, categoryName.trim()); + categoryByTitle.put(category.getTitle(), category); + } + if (category != null) { + goods.setCategoryId(category.getCategoryId()); + } + } + + String imageId = extractDispimgId(row.getCell(7)); + if (StrUtil.isNotBlank(imageId)) { + String mediaEntry = cellImageIdToMediaEntry.get(imageId); + if (StrUtil.isNotBlank(mediaEntry)) { + String url = saveCellImage(zipFile, mediaEntry, imageId); + if (StrUtil.isNotBlank(url)) { + goods.setImage(url); + } + } + } + + result.getGoods().add(goods); + } + + if (result.getGoods().isEmpty()) { + return success("未读取到可导入的数据", result); + } + if (goodsService.saveBatch(result.getGoods())) { + result.setInserted(result.getGoods().size()); + return success("导入成功", result); + } + return fail("导入失败", result); + } catch (Exception e) { + return fail("导入异常: " + e.getMessage(), result).setError(e.toString()); + } + } + + private Map loadCategoryMap(Integer merchantId) { + List categories = goodsCategoryService.list(new LambdaQueryWrapper() + .eq(GoodsCategory::getMerchantId, merchantId) + .eq(GoodsCategory::getDeleted, 0)); + return categories.stream() + .filter(c -> StrUtil.isNotBlank(c.getTitle())) + .collect(Collectors.toMap(GoodsCategory::getTitle, c -> c, (a, b) -> a)); + } + + private Set collectGoodsNames(Sheet sheet, DataFormatter formatter) { + int lastRow = sheet.getLastRowNum(); + return java.util.stream.IntStream.rangeClosed(1, lastRow) + .mapToObj(sheet::getRow) + .filter(r -> r != null) + .map(r -> getCellString(r, 2, formatter)) + .filter(StrUtil::isNotBlank) + .map(String::trim) + .collect(Collectors.toSet()); + } + + private GoodsCategory createCategory(Integer merchantId, String title) { + GoodsCategory category = new GoodsCategory(); + category.setTitle(title); + category.setType(0); + category.setParentId(0); + category.setMerchantId(merchantId); + category.setStatus(0); + category.setSortNumber(0); + goodsCategoryService.save(category); + return category; + } + + private static String buildComments(String actionCategory, String brand, String manufacturer) { + StringBuilder sb = new StringBuilder(); + if (StrUtil.isNotBlank(actionCategory)) sb.append("作用分类:").append(actionCategory.trim()).append(" "); + if (StrUtil.isNotBlank(brand)) sb.append("品牌:").append(brand.trim()).append(" "); + if (StrUtil.isNotBlank(manufacturer)) sb.append("厂家:").append(manufacturer.trim()).append(" "); + return sb.toString().trim(); + } + + private static String getCellString(Row row, int cellIndex, DataFormatter formatter) { + Cell cell = row.getCell(cellIndex, Row.MissingCellPolicy.RETURN_BLANK_AS_NULL); + if (cell == null) return null; + String v = formatter.formatCellValue(cell); + return StrUtil.trimToNull(v); + } + + private static BigDecimal getCellDecimal(Row row, int cellIndex, DataFormatter formatter) { + String v = getCellString(row, cellIndex, formatter); + if (StrUtil.isBlank(v)) return null; + v = v.replace(",", "").replace("元", "").trim(); + if (v.endsWith("+")) v = v.substring(0, v.length() - 1); + try { + return new BigDecimal(v); + } catch (Exception e) { + return null; + } + } + + private static Integer parseSales(String v) { + if (StrUtil.isBlank(v)) return null; + Matcher m = Pattern.compile("(\\d+)").matcher(v); + if (m.find()) { + try { + return Integer.parseInt(m.group(1)); + } catch (Exception ignored) { + } + } + return null; + } + + private static String extractDispimgId(Cell cell) { + if (cell == null) return null; + String formula = null; + try { + if (cell.getCellType() == CellType.FORMULA) { + formula = cell.getCellFormula(); + } + } catch (Exception ignored) { + } + String v = StrUtil.blankToDefault(formula, cell.toString()); + Matcher matcher = Pattern.compile("ID_[0-9A-Fa-f]+").matcher(v); + return matcher.find() ? matcher.group() : null; + } + + private String saveCellImage(ZipFile zipFile, String mediaEntryName, String imageId) { + ZipEntry entry = zipFile.getEntry(mediaEntryName); + if (entry == null) return null; + String ext = ".jpg"; + int dot = mediaEntryName.lastIndexOf('.'); + if (dot > 0 && dot < mediaEntryName.length() - 1) { + ext = "." + mediaEntryName.substring(dot + 1); + } + String relativePath = "goodsImport/" + imageId + ext; + File out = FileUtil.file(uploadPath, relativePath); + try { + if (!out.exists()) { + FileUtil.mkParentDirs(out); + try (InputStream in = zipFile.getInputStream(entry)) { + FileUtil.writeFromStream(in, out); + } + } + String fileServer = config.getFileServer(); + if (StrUtil.isBlank(fileServer)) { + return serverUrl.concat("/").concat(relativePath); + } + return StrUtil.removeSuffix(fileServer, "/") + "/api/file/" + relativePath; + } catch (Exception e) { + return null; + } + } + + private static Map buildCellImageIdToMediaEntry(ZipFile zipFile) { + final String PKG_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"; + final String XDR_NS = "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing"; + final String A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"; + final String R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + + Map rIdToTarget = new HashMap<>(); + Map idToMedia = new HashMap<>(); + + try { + ZipEntry relsEntry = zipFile.getEntry("xl/_rels/cellimages.xml.rels"); + ZipEntry cellImagesEntry = zipFile.getEntry("xl/cellimages.xml"); + if (relsEntry == null || cellImagesEntry == null) { + return idToMedia; + } + + DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); + dbf.setNamespaceAware(true); + + Document relsDoc; + try (InputStream relsIn = zipFile.getInputStream(relsEntry)) { + relsDoc = dbf.newDocumentBuilder().parse(relsIn); + } + NodeList relNodes = relsDoc.getElementsByTagNameNS(PKG_REL_NS, "Relationship"); + for (int i = 0; i < relNodes.getLength(); i++) { + Element el = (Element) relNodes.item(i); + String id = el.getAttribute("Id"); + String target = el.getAttribute("Target"); + if (StrUtil.isNotBlank(id) && StrUtil.isNotBlank(target)) { + String normalizedTarget = target.startsWith("/") ? target.substring(1) : target; + rIdToTarget.put(id, "xl/" + normalizedTarget); + } + } + + Document imagesDoc; + try (InputStream imagesIn = zipFile.getInputStream(cellImagesEntry)) { + imagesDoc = dbf.newDocumentBuilder().parse(imagesIn); + } + NodeList pics = imagesDoc.getElementsByTagNameNS(XDR_NS, "pic"); + for (int i = 0; i < pics.getLength(); i++) { + Element pic = (Element) pics.item(i); + NodeList cNvPrNodes = pic.getElementsByTagNameNS(XDR_NS, "cNvPr"); + NodeList blipNodes = pic.getElementsByTagNameNS(A_NS, "blip"); + if (cNvPrNodes.getLength() == 0 || blipNodes.getLength() == 0) continue; + + Element cNvPr = (Element) cNvPrNodes.item(0); + Element blip = (Element) blipNodes.item(0); + String name = cNvPr.getAttribute("name"); + String embed = blip.getAttributeNS(R_NS, "embed"); + String target = rIdToTarget.get(embed); + if (StrUtil.isNotBlank(name) && StrUtil.isNotBlank(target)) { + idToMedia.put(name, target); + } + } + } catch (Exception ignored) { + } + return idToMedia; + } + + public static class GoodsImportResult { + private String excelPath; + private String sheetName; + private int totalRows; + private int inserted; + private int skippedExists; + private List 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 getGoods() { + return goods; + } + + public void setGoods(List goods) { + this.goods = goods; + } + } + @ApiOperation("生成海报") @PostMapping("/make-goods-poster/{goodsId}") public ApiResult makePoster(@PathVariable Integer goodsId) throws Exception {