From e76c07fb5eef35c0f4448f5b9fd54017662814e8 Mon Sep 17 00:00:00 2001 From: lixiaolong <702156524@qq.com> Date: Fri, 17 Jul 2026 15:00:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(warehouse):=20=E6=96=B0=E5=A2=9E=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E5=AF=BC=E5=85=A5=E5=8A=9F=E8=83=BD=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E7=B3=BB=E7=BB=9F=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加批量导入页面支持Excel和图片上传解析 - 实现移动端批量导入功能界面和交互 - 集成图片压缩上传功能提升用户体验 - 增加批量任务状态管理和进度跟踪 - 完善批量数据确认和执行流程 - 配置环境变量支持Docker部署 - 提高文件上传大小限制增强实用性 - 优化系统配置以适应生产环境需求 --- .../warehouse/WmsBatchImportController.java | 80 + .../src/main/resources/application.yml | 8 +- .../domain/WmsBatchActionUpdateRequest.java | 29 + .../ruoyi/warehouse/domain/WmsBatchItem.java | 74 + .../ruoyi/warehouse/domain/WmsBatchJob.java | 141 ++ .../warehouse/mapper/WmsBatchItemMapper.java | 15 + .../warehouse/mapper/WmsBatchJobMapper.java | 12 + .../service/IWmsBatchImportService.java | 18 + .../impl/WmsBatchImportServiceImpl.java | 1430 +++++++++++++++++ .../mapper/warehouse/WmsBatchItemMapper.xml | 98 ++ .../mapper/warehouse/WmsBatchJobMapper.xml | 67 + ruoyi-ui/src/api/warehouse/batchImport.js | 46 + ruoyi-ui/src/router/index.js | 13 + ruoyi-ui/src/utils/imageCompress.js | 77 + ruoyi-ui/src/views/index.vue | 11 +- ruoyi-ui/src/views/mobile/index.vue | 339 ++++ .../src/views/warehouse/batchImport/index.vue | 415 +++++ sql/wms_batch_import.sql | 49 + 18 files changed, 2917 insertions(+), 5 deletions(-) create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/web/controller/warehouse/WmsBatchImportController.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/WmsBatchActionUpdateRequest.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/WmsBatchItem.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/WmsBatchJob.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/warehouse/mapper/WmsBatchItemMapper.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/warehouse/mapper/WmsBatchJobMapper.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/warehouse/service/IWmsBatchImportService.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/WmsBatchImportServiceImpl.java create mode 100644 ruoyi-system/src/main/resources/mapper/warehouse/WmsBatchItemMapper.xml create mode 100644 ruoyi-system/src/main/resources/mapper/warehouse/WmsBatchJobMapper.xml create mode 100644 ruoyi-ui/src/api/warehouse/batchImport.js create mode 100644 ruoyi-ui/src/utils/imageCompress.js create mode 100644 ruoyi-ui/src/views/warehouse/batchImport/index.vue create mode 100644 sql/wms_batch_import.sql diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/warehouse/WmsBatchImportController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/warehouse/WmsBatchImportController.java new file mode 100644 index 0000000..b15f544 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/warehouse/WmsBatchImportController.java @@ -0,0 +1,80 @@ +package com.ruoyi.web.controller.warehouse; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.warehouse.domain.WmsBatchActionUpdateRequest; +import com.ruoyi.warehouse.domain.WmsBatchItem; +import com.ruoyi.warehouse.domain.WmsBatchJob; +import com.ruoyi.warehouse.service.IWmsBatchImportService; + +@RestController +@RequestMapping("/warehouse/batch") +public class WmsBatchImportController extends BaseController +{ + @Autowired + private IWmsBatchImportService batchImportService; + + @GetMapping("/list") + public TableDataInfo list(WmsBatchJob job) + { + startPage(); + return getDataTable(batchImportService.selectJobList(job)); + } + + @PostMapping("/upload") + @Log(title = "WMS批量导入", businessType = BusinessType.IMPORT) + public AjaxResult upload(MultipartFile file) throws Exception + { + return success(batchImportService.uploadExcel(file, getUsername())); + } + + @GetMapping("/{id}") + public AjaxResult getInfo(@PathVariable Long id) + { + return success(batchImportService.selectJobById(id)); + } + + @GetMapping("/{jobId}/items") + public AjaxResult items(@PathVariable Long jobId) + { + return success(batchImportService.selectItemList(jobId)); + } + + @PutMapping("/item") + public AjaxResult updateItem(@RequestBody WmsBatchItem item) + { + return success(batchImportService.updateItem(item)); + } + + @PutMapping("/{jobId}/items/action") + public AjaxResult updateItemAction(@PathVariable Long jobId, @RequestBody WmsBatchActionUpdateRequest request) + { + return toAjax(batchImportService.updateItemAction(jobId, request.getActionType(), request.getItemIds())); + } + + @DeleteMapping("/item/{id}") + public AjaxResult deleteItem(@PathVariable Long id) + { + return toAjax(batchImportService.deleteItem(id)); + } + + @PostMapping("/{jobId}/confirm") + @Log(title = "WMS批量确认执行", businessType = BusinessType.INSERT) + public AjaxResult confirm(@PathVariable Long jobId) + { + return success(batchImportService.confirmJob(jobId, getUsername())); + } +} diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index 2f3b659..34990da 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -6,8 +6,8 @@ ruoyi: version: 3.9.2 # 版权年份 copyrightYear: 2026 - # 文件路径 示例( Windows配置D:/ruoyi/uploadPath,Linux配置 /home/ruoyi/uploadPath) - profile: D:/ruoyi/uploadPath + # 文件路径 示例( Windows配置D:/ruoyi/uploadPath,Linux/Docker配置 /app/uploadPath) + profile: ${RUOYI_PROFILE:D:/ruoyi/uploadPath} # 获取ip地址开关 addressEnabled: false # 验证码类型 math 数字计算 char 字符验证 @@ -56,9 +56,9 @@ spring: servlet: multipart: # 单个文件大小 - max-file-size: 10MB + max-file-size: ${MAX_FILE_SIZE:50MB} # 设置总上传的文件大小 - max-request-size: 20MB + max-request-size: ${MAX_REQUEST_SIZE:60MB} # 服务模块 devtools: restart: diff --git a/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/WmsBatchActionUpdateRequest.java b/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/WmsBatchActionUpdateRequest.java new file mode 100644 index 0000000..88a5724 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/WmsBatchActionUpdateRequest.java @@ -0,0 +1,29 @@ +package com.ruoyi.warehouse.domain; + +import java.util.List; + +public class WmsBatchActionUpdateRequest +{ + private String actionType; + private List itemIds; + + public String getActionType() + { + return actionType; + } + + public void setActionType(String actionType) + { + this.actionType = actionType; + } + + public List getItemIds() + { + return itemIds; + } + + public void setItemIds(List itemIds) + { + this.itemIds = itemIds; + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/WmsBatchItem.java b/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/WmsBatchItem.java new file mode 100644 index 0000000..5216b3b --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/WmsBatchItem.java @@ -0,0 +1,74 @@ +package com.ruoyi.warehouse.domain; + +import java.math.BigDecimal; +import com.ruoyi.common.core.domain.BaseEntity; + +public class WmsBatchItem extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + private Long id; + private Long jobId; + private Integer rowNo; + private String actionType; + private String rawText; + private String parsedJson; + private Integer status; + private String errorMsg; + private String warningMsg; + private Long refId; + private String areaName; + private String productName; + private String brand; + private String category; + private String spec; + private String unit; + private BigDecimal quantity; + private String operatorName; + private Integer typeCode; + private String sourceNo; + private String remark; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public Long getJobId() { return jobId; } + public void setJobId(Long jobId) { this.jobId = jobId; } + public Integer getRowNo() { return rowNo; } + public void setRowNo(Integer rowNo) { this.rowNo = rowNo; } + public String getActionType() { return actionType; } + public void setActionType(String actionType) { this.actionType = actionType; } + public String getRawText() { return rawText; } + public void setRawText(String rawText) { this.rawText = rawText; } + public String getParsedJson() { return parsedJson; } + public void setParsedJson(String parsedJson) { this.parsedJson = parsedJson; } + public Integer getStatus() { return status; } + public void setStatus(Integer status) { this.status = status; } + public String getErrorMsg() { return errorMsg; } + public void setErrorMsg(String errorMsg) { this.errorMsg = errorMsg; } + public String getWarningMsg() { return warningMsg; } + public void setWarningMsg(String warningMsg) { this.warningMsg = warningMsg; } + public Long getRefId() { return refId; } + public void setRefId(Long refId) { this.refId = refId; } + public String getAreaName() { return areaName; } + public void setAreaName(String areaName) { this.areaName = areaName; } + public String getProductName() { return productName; } + public void setProductName(String productName) { this.productName = productName; } + public String getBrand() { return brand; } + public void setBrand(String brand) { this.brand = brand; } + public String getCategory() { return category; } + public void setCategory(String category) { this.category = category; } + public String getSpec() { return spec; } + public void setSpec(String spec) { this.spec = spec; } + public String getUnit() { return unit; } + public void setUnit(String unit) { this.unit = unit; } + public BigDecimal getQuantity() { return quantity; } + public void setQuantity(BigDecimal quantity) { this.quantity = quantity; } + public String getOperatorName() { return operatorName; } + public void setOperatorName(String operatorName) { this.operatorName = operatorName; } + public Integer getTypeCode() { return typeCode; } + public void setTypeCode(Integer typeCode) { this.typeCode = typeCode; } + public String getSourceNo() { return sourceNo; } + public void setSourceNo(String sourceNo) { this.sourceNo = sourceNo; } + public String getRemark() { return remark; } + public void setRemark(String remark) { this.remark = remark; } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/WmsBatchJob.java b/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/WmsBatchJob.java new file mode 100644 index 0000000..c9cfb12 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/warehouse/domain/WmsBatchJob.java @@ -0,0 +1,141 @@ +package com.ruoyi.warehouse.domain; + +import com.ruoyi.common.core.domain.BaseEntity; + +public class WmsBatchJob extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + private Long id; + private String jobNo; + private String sourceType; + private String bizType; + private String fileName; + private String filePath; + private Integer status; + private Integer progress; + private Integer totalCount; + private Integer successCount; + private Integer failCount; + private String errorMsg; + + public Long getId() + { + return id; + } + + public void setId(Long id) + { + this.id = id; + } + + public String getJobNo() + { + return jobNo; + } + + public void setJobNo(String jobNo) + { + this.jobNo = jobNo; + } + + public String getSourceType() + { + return sourceType; + } + + public void setSourceType(String sourceType) + { + this.sourceType = sourceType; + } + + public String getBizType() + { + return bizType; + } + + public void setBizType(String bizType) + { + this.bizType = bizType; + } + + public String getFileName() + { + return fileName; + } + + public void setFileName(String fileName) + { + this.fileName = fileName; + } + + public String getFilePath() + { + return filePath; + } + + public void setFilePath(String filePath) + { + this.filePath = filePath; + } + + public Integer getStatus() + { + return status; + } + + public void setStatus(Integer status) + { + this.status = status; + } + + public Integer getProgress() + { + return progress; + } + + public void setProgress(Integer progress) + { + this.progress = progress; + } + + public Integer getTotalCount() + { + return totalCount; + } + + public void setTotalCount(Integer totalCount) + { + this.totalCount = totalCount; + } + + public Integer getSuccessCount() + { + return successCount; + } + + public void setSuccessCount(Integer successCount) + { + this.successCount = successCount; + } + + public Integer getFailCount() + { + return failCount; + } + + public void setFailCount(Integer failCount) + { + this.failCount = failCount; + } + + public String getErrorMsg() + { + return errorMsg; + } + + public void setErrorMsg(String errorMsg) + { + this.errorMsg = errorMsg; + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/warehouse/mapper/WmsBatchItemMapper.java b/ruoyi-system/src/main/java/com/ruoyi/warehouse/mapper/WmsBatchItemMapper.java new file mode 100644 index 0000000..4dbffda --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/warehouse/mapper/WmsBatchItemMapper.java @@ -0,0 +1,15 @@ +package com.ruoyi.warehouse.mapper; + +import java.util.List; +import com.ruoyi.warehouse.domain.WmsBatchItem; + +public interface WmsBatchItemMapper +{ + public List selectWmsBatchItemList(WmsBatchItem item); + public WmsBatchItem selectWmsBatchItemById(Long id); + public int insertWmsBatchItem(WmsBatchItem item); + public int updateWmsBatchItem(WmsBatchItem item); + public int deleteWmsBatchItemById(Long id); + public int deleteWmsBatchItemByJobId(Long jobId); + public int countByJobIdAndStatus(WmsBatchItem item); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/warehouse/mapper/WmsBatchJobMapper.java b/ruoyi-system/src/main/java/com/ruoyi/warehouse/mapper/WmsBatchJobMapper.java new file mode 100644 index 0000000..6847fb8 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/warehouse/mapper/WmsBatchJobMapper.java @@ -0,0 +1,12 @@ +package com.ruoyi.warehouse.mapper; + +import java.util.List; +import com.ruoyi.warehouse.domain.WmsBatchJob; + +public interface WmsBatchJobMapper +{ + public List selectWmsBatchJobList(WmsBatchJob job); + public WmsBatchJob selectWmsBatchJobById(Long id); + public int insertWmsBatchJob(WmsBatchJob job); + public int updateWmsBatchJob(WmsBatchJob job); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/IWmsBatchImportService.java b/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/IWmsBatchImportService.java new file mode 100644 index 0000000..bb6ca1f --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/IWmsBatchImportService.java @@ -0,0 +1,18 @@ +package com.ruoyi.warehouse.service; + +import java.util.List; +import org.springframework.web.multipart.MultipartFile; +import com.ruoyi.warehouse.domain.WmsBatchItem; +import com.ruoyi.warehouse.domain.WmsBatchJob; + +public interface IWmsBatchImportService +{ + public WmsBatchJob uploadExcel(MultipartFile file, String username) throws Exception; + public List selectJobList(WmsBatchJob job); + public WmsBatchJob selectJobById(Long id); + public List selectItemList(Long jobId); + public WmsBatchItem updateItem(WmsBatchItem item); + public int updateItemAction(Long jobId, String actionType, List itemIds); + public int deleteItem(Long id); + public WmsBatchJob confirmJob(Long jobId, String username); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/WmsBatchImportServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/WmsBatchImportServiceImpl.java new file mode 100644 index 0000000..3e30118 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/warehouse/service/impl/WmsBatchImportServiceImpl.java @@ -0,0 +1,1430 @@ +package com.ruoyi.warehouse.service.impl; + +import java.io.File; +import java.io.FileInputStream; +import java.math.BigDecimal; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import org.apache.commons.io.FilenameUtils; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.DataFormatter; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import com.ruoyi.common.config.RuoYiConfig; +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.system.service.ISysUserService; +import com.ruoyi.warehouse.domain.InRecord; +import com.ruoyi.warehouse.domain.OutRecord; +import com.ruoyi.warehouse.domain.ProductInfo; +import com.ruoyi.warehouse.domain.StockCheckRecord; +import com.ruoyi.warehouse.domain.StockInfo; +import com.ruoyi.warehouse.domain.WarehouseArea; +import com.ruoyi.warehouse.domain.WmsBatchItem; +import com.ruoyi.warehouse.domain.WmsBatchJob; +import com.ruoyi.warehouse.mapper.WmsBatchItemMapper; +import com.ruoyi.warehouse.mapper.WmsBatchJobMapper; +import com.ruoyi.warehouse.service.IInRecordService; +import com.ruoyi.warehouse.service.IOutRecordService; +import com.ruoyi.warehouse.service.IProductInfoService; +import com.ruoyi.warehouse.service.IStockCheckRecordService; +import com.ruoyi.warehouse.service.IStockInfoService; +import com.ruoyi.warehouse.service.IWarehouseAreaService; +import com.ruoyi.warehouse.service.IWmsBatchImportService; + +@Service +public class WmsBatchImportServiceImpl implements IWmsBatchImportService +{ + private static final String ACTION_AREA = "create_area"; + private static final String ACTION_PRODUCT = "create_product"; + private static final String ACTION_IN = "create_in_record"; + private static final String ACTION_OUT = "create_out_record"; + private static final String ACTION_CHECK = "create_stock_check"; + private static final String ACTION_UNKNOWN = "unknown"; + + private static final int JOB_UPLOADED = 0; + private static final int JOB_PARSING = 1; + private static final int JOB_WAIT_CONFIRM = 2; + private static final int JOB_EXECUTING = 3; + private static final int JOB_DONE = 4; + private static final int JOB_FAILED = 5; + + private static final int ITEM_VALID = 1; + private static final int ITEM_INVALID = 2; + private static final int ITEM_DONE = 3; + private static final int ITEM_FAILED = 4; + + @Value("${agent.openai.base-url}") + private String baseUrl; + + @Value("${agent.openai.api-key}") + private String apiKey; + + @Value("${agent.openai.model}") + private String model; + + @Autowired + private WmsBatchJobMapper jobMapper; + + @Autowired + private WmsBatchItemMapper itemMapper; + + @Autowired + private IWarehouseAreaService areaService; + + @Autowired + private IProductInfoService productService; + + @Autowired + private IInRecordService inRecordService; + + @Autowired + private IOutRecordService outRecordService; + + @Autowired + private IStockCheckRecordService stockCheckRecordService; + + @Autowired + private IStockInfoService stockInfoService; + + @Autowired + private ISysUserService userService; + + @Override + public WmsBatchJob uploadExcel(MultipartFile file, String username) throws Exception + { + if (file == null || file.isEmpty()) + { + throw new ServiceException("请选择要上传的文件"); + } + String extension = StringUtils.defaultString(FilenameUtils.getExtension(file.getOriginalFilename())).toLowerCase(Locale.ROOT); + if (!isExcel(extension) && !isImage(extension)) + { + throw new ServiceException("当前支持 xls/xlsx/jpg/jpeg/png/bmp 文件"); + } + + String jobNo = "BATCH" + System.currentTimeMillis(); + String safeName = jobNo + "." + extension; + Path dir = Paths.get(RuoYiConfig.getUploadPath(), "wms-batch").toAbsolutePath().normalize(); + Files.createDirectories(dir); + Path savedFile = dir.resolve(safeName).normalize(); + try (var inputStream = file.getInputStream()) + { + Files.copy(inputStream, savedFile, StandardCopyOption.REPLACE_EXISTING); + } + + WmsBatchJob job = new WmsBatchJob(); + job.setJobNo(jobNo); + job.setSourceType(isExcel(extension) ? "excel" : "image"); + job.setBizType("mixed"); + job.setFileName(file.getOriginalFilename()); + job.setFilePath(savedFile.toString()); + job.setStatus(JOB_UPLOADED); + job.setProgress(0); + job.setTotalCount(0); + job.setSuccessCount(0); + job.setFailCount(0); + job.setCreateBy(username); + jobMapper.insertWmsBatchJob(job); + + if (isExcel(extension)) + { + CompletableFuture.runAsync(() -> parseExcelJob(job.getId(), username)); + } + else + { + CompletableFuture.runAsync(() -> parseImageJob(job.getId(), username)); + } + return job; + } + + @Override + public List selectJobList(WmsBatchJob job) + { + return jobMapper.selectWmsBatchJobList(job); + } + + @Override + public WmsBatchJob selectJobById(Long id) + { + return jobMapper.selectWmsBatchJobById(id); + } + + @Override + public List selectItemList(Long jobId) + { + WmsBatchItem query = new WmsBatchItem(); + query.setJobId(jobId); + return itemMapper.selectWmsBatchItemList(query); + } + + @Override + public WmsBatchItem updateItem(WmsBatchItem item) + { + WmsBatchItem dbItem = itemMapper.selectWmsBatchItemById(item.getId()); + if (dbItem == null) + { + throw new ServiceException("明细不存在"); + } + ensureJobEditable(dbItem.getJobId()); + WmsBatchJob job = jobMapper.selectWmsBatchJobById(dbItem.getJobId()); + item.setJobId(dbItem.getJobId()); + item.setRowNo(dbItem.getRowNo()); + item.setRawText(StringUtils.defaultIfBlank(item.getRawText(), dbItem.getRawText())); + normalizeItem(item); + validateItem(item, job == null ? null : job.getCreateBy()); + fillParsedJson(item); + itemMapper.updateWmsBatchItem(item); + refreshJobCount(item.getJobId(), JOB_WAIT_CONFIRM, 100, null); + return itemMapper.selectWmsBatchItemById(item.getId()); + } + + @Override + public int updateItemAction(Long jobId, String actionType, List itemIds) + { + ensureJobEditable(jobId); + String parsedAction = parseAction(actionType); + if (StringUtils.isBlank(parsedAction) || ACTION_UNKNOWN.equals(parsedAction)) + { + throw new ServiceException("请选择有效的操作类型"); + } + WmsBatchJob job = jobMapper.selectWmsBatchJobById(jobId); + List items = selectItemList(jobId); + int rows = 0; + for (WmsBatchItem item : items) + { + if (itemIds != null && !itemIds.isEmpty() && !itemIds.contains(item.getId())) + { + continue; + } + item.setActionType(parsedAction); + if (ACTION_IN.equals(parsedAction) && item.getTypeCode() == null) + { + item.setTypeCode(2); + } + if (ACTION_OUT.equals(parsedAction) && item.getTypeCode() == null) + { + item.setTypeCode(1); + } + normalizeItem(item); + validateItem(item, job == null ? null : job.getCreateBy()); + fillParsedJson(item); + rows += itemMapper.updateWmsBatchItem(item); + } + refreshJobCount(jobId, JOB_WAIT_CONFIRM, 100, null); + return rows; + } + + @Override + public int deleteItem(Long id) + { + WmsBatchItem item = itemMapper.selectWmsBatchItemById(id); + if (item == null) + { + return 0; + } + ensureJobEditable(item.getJobId()); + int rows = itemMapper.deleteWmsBatchItemById(id); + refreshJobCount(item.getJobId(), JOB_WAIT_CONFIRM, 100, null); + return rows; + } + + @Override + public WmsBatchJob confirmJob(Long jobId, String username) + { + WmsBatchJob job = jobMapper.selectWmsBatchJobById(jobId); + if (job == null) + { + throw new ServiceException("批量任务不存在"); + } + if (!Integer.valueOf(JOB_WAIT_CONFIRM).equals(job.getStatus())) + { + throw new ServiceException("当前任务还不能确认执行"); + } + WmsBatchItem invalidQuery = new WmsBatchItem(); + invalidQuery.setJobId(jobId); + invalidQuery.setStatus(ITEM_INVALID); + if (itemMapper.countByJobIdAndStatus(invalidQuery) > 0) + { + throw new ServiceException("存在校验失败的明细,请先修改或删除后再确认"); + } + + updateJob(jobId, JOB_EXECUTING, 0, null); + List items = selectItemList(jobId); + int success = 0; + int fail = 0; + int total = items.size(); + for (int i = 0; i < total; i++) + { + WmsBatchItem item = items.get(i); + try + { + executeItem(item, username); + item.setStatus(ITEM_DONE); + item.setErrorMsg(null); + success++; + } + catch (Exception e) + { + item.setStatus(ITEM_FAILED); + item.setErrorMsg(e.getMessage()); + fail++; + } + itemMapper.updateWmsBatchItem(item); + updateJobProgress(jobId, JOB_EXECUTING, total, i + 1, success, fail); + } + WmsBatchJob finish = new WmsBatchJob(); + finish.setId(jobId); + finish.setStatus(JOB_DONE); + finish.setProgress(100); + finish.setTotalCount(total); + finish.setSuccessCount(success); + finish.setFailCount(fail); + finish.setErrorMsg(fail > 0 ? "部分明细执行失败,请查看明细错误原因" : null); + jobMapper.updateWmsBatchJob(finish); + return jobMapper.selectWmsBatchJobById(jobId); + } + + private void parseExcelJob(Long jobId, String username) + { + try + { + WmsBatchJob job = jobMapper.selectWmsBatchJobById(jobId); + updateJob(jobId, JOB_PARSING, 5, null); + itemMapper.deleteWmsBatchItemByJobId(jobId); + List items = parseExcel(job, username); + int total = items.size(); + if (total == 0) + { + updateJob(jobId, JOB_FAILED, 100, "没有解析到有效数据行"); + return; + } + for (int i = 0; i < total; i++) + { + WmsBatchItem item = items.get(i); + item.setJobId(jobId); + normalizeItem(item); + validateItem(item, username); + fillParsedJson(item); + itemMapper.insertWmsBatchItem(item); + if ((i + 1) % 10 == 0 || i + 1 == total) + { + int progress = 10 + (int) Math.floor((i + 1) * 80.0 / total); + refreshJobCount(jobId, JOB_PARSING, progress, null); + } + } + refreshJobCount(jobId, JOB_WAIT_CONFIRM, 100, null); + } + catch (Exception e) + { + updateJob(jobId, JOB_FAILED, 100, StringUtils.defaultIfBlank(e.getMessage(), "解析失败")); + } + } + + private void parseImageJob(Long jobId, String username) + { + try + { + WmsBatchJob job = jobMapper.selectWmsBatchJobById(jobId); + updateJob(jobId, JOB_PARSING, 10, null); + itemMapper.deleteWmsBatchItemByJobId(jobId); + List items = parseImage(job, username); + int total = items.size(); + if (total == 0) + { + updateJob(jobId, JOB_FAILED, 100, "图片中没有识别到可导入的数据"); + return; + } + for (int i = 0; i < total; i++) + { + WmsBatchItem item = items.get(i); + item.setJobId(jobId); + normalizeItem(item); + validateItem(item, username); + fillParsedJson(item); + itemMapper.insertWmsBatchItem(item); + int progress = 20 + (int) Math.floor((i + 1) * 70.0 / total); + refreshJobCount(jobId, JOB_PARSING, progress, null); + } + refreshJobCount(jobId, JOB_WAIT_CONFIRM, 100, null); + } + catch (Exception e) + { + updateJob(jobId, JOB_FAILED, 100, StringUtils.defaultIfBlank(e.getMessage(), "图片解析失败")); + } + } + + private List parseImage(WmsBatchJob job, String username) throws Exception + { + if (StringUtils.isBlank(apiKey)) + { + throw new ServiceException("图片导入需要配置 OPENAI_API_KEY 环境变量"); + } + updateJob(job.getId(), JOB_PARSING, 30, null); + String output = callVisionModel(job); + updateJob(job.getId(), JOB_PARSING, 70, null); + return parseImageItems(output, username); + } + + private List parseExcel(WmsBatchJob job, String username) throws Exception + { + List items = new ArrayList<>(); + DataFormatter formatter = new DataFormatter(); + try (FileInputStream inputStream = new FileInputStream(job.getFilePath()); Workbook workbook = WorkbookFactory.create(inputStream)) + { + Sheet sheet = workbook.getSheetAt(0); + if (sheet == null) + { + return items; + } + int headerRowIndex = findHeaderRow(sheet, formatter); + if (headerRowIndex < 0) + { + throw new ServiceException("未识别到表头,请确认Excel第一行包含产品名称、库区、数量等字段"); + } + Row headerRow = sheet.getRow(headerRowIndex); + Map headerMap = buildHeaderMap(headerRow, formatter); + String sheetAction = inferActionFromHeaderRow(headerRow, formatter); + for (int rowIndex = headerRowIndex + 1; rowIndex <= sheet.getLastRowNum(); rowIndex++) + { + Row row = sheet.getRow(rowIndex); + if (row == null || isEmptyRow(row, formatter)) + { + continue; + } + WmsBatchItem item = rowToItem(row, headerMap, formatter, sheetAction, username); + item.setRowNo(rowIndex + 1); + items.add(item); + } + } + return items; + } + + private WmsBatchItem rowToItem(Row row, Map headerMap, DataFormatter formatter, String sheetAction, String username) + { + WmsBatchItem item = new WmsBatchItem(); + item.setActionType(sheetAction); + item.setOperatorName(resolveNickName(username)); + StringBuilder raw = new StringBuilder(); + for (Map.Entry entry : headerMap.entrySet()) + { + String field = entry.getValue(); + String value = trimToNull(formatter.formatCellValue(row.getCell(entry.getKey()))); + if (StringUtils.isBlank(value)) + { + continue; + } + raw.append(field).append("=").append(value).append("; "); + applyCellValue(item, field, value); + } + if (StringUtils.isBlank(item.getActionType())) + { + item.setActionType(inferActionFromItem(item)); + } + item.setRawText(raw.toString()); + return item; + } + + private List parseImageItems(String output, String username) + { + List items = new ArrayList<>(); + String jsonText = extractJson(output); + JSONArray rows; + if (jsonText.trim().startsWith("[")) + { + rows = JSON.parseArray(jsonText); + } + else + { + JSONObject root = JSON.parseObject(jsonText); + rows = root.getJSONArray("items"); + } + if (rows == null) + { + return items; + } + for (int i = 0; i < rows.size(); i++) + { + JSONObject row = rows.getJSONObject(i); + if (row == null) + { + continue; + } + WmsBatchItem item = new WmsBatchItem(); + item.setRowNo(i + 1); + item.setActionType(parseAction(firstText(row, "actionType", "action", "操作类型", "业务类型"))); + item.setAreaName(trimToNull(firstText(row, "areaName", "area", "库区", "库区名称", "仓库"))); + item.setProductName(trimToNull(firstText(row, "productName", "product", "产品名称", "物料名称", "商品名称"))); + item.setBrand(trimToNull(firstText(row, "brand", "品牌"))); + item.setCategory(trimToNull(firstText(row, "category", "类别", "分类"))); + item.setSpec(trimToNull(firstText(row, "spec", "规格", "规格型号", "型号"))); + item.setUnit(trimToNull(firstText(row, "unit", "单位"))); + item.setQuantity(parseQuantity(firstText(row, "quantity", "num", "数量", "入库数量", "出库数量", "实际盘点数量"))); + item.setOperatorName(trimToNull(firstText(row, "operatorName", "operator", "入库人", "出库人", "盘点人", "操作人"))); + if (StringUtils.isBlank(item.getOperatorName())) + { + item.setOperatorName(resolveNickName(username)); + } + item.setTypeCode(parseTypeCode(firstText(row, "typeCode", "inType", "outType", "类型", "入库类型", "出库类型"), item.getActionType())); + item.setSourceNo(trimToNull(firstText(row, "sourceNo", "来源单据号", "关联单据号"))); + item.setRemark(trimToNull(firstText(row, "remark", "备注", "原因"))); + item.setRawText(row.toJSONString()); + items.add(item); + } + return items; + } + + private void applyCellValue(WmsBatchItem item, String field, String value) + { + if ("actionType".equals(field)) + { + item.setActionType(parseAction(value)); + } + else if ("areaName".equals(field)) + { + item.setAreaName(value); + } + else if ("productName".equals(field)) + { + item.setProductName(value); + } + else if ("brand".equals(field)) + { + item.setBrand(value); + } + else if ("category".equals(field)) + { + item.setCategory(value); + } + else if ("spec".equals(field)) + { + item.setSpec(value); + } + else if ("unit".equals(field)) + { + item.setUnit(value); + } + else if ("quantity".equals(field)) + { + item.setQuantity(parseQuantity(value)); + } + else if ("operatorName".equals(field)) + { + item.setOperatorName(value); + } + else if ("typeCode".equals(field)) + { + item.setTypeCode(parseTypeCode(value, item.getActionType())); + } + else if ("sourceNo".equals(field)) + { + item.setSourceNo(value); + } + else if ("remark".equals(field)) + { + item.setRemark(value); + } + } + + private String callVisionModel(WmsBatchJob job) throws Exception + { + File image = new File(job.getFilePath()); + String extension = StringUtils.defaultString(FilenameUtils.getExtension(job.getFileName())).toLowerCase(Locale.ROOT); + String mimeType = "jpg".equals(extension) ? "image/jpeg" : "image/" + extension; + String encoded = Base64.getEncoder().encodeToString(Files.readAllBytes(image.toPath())); + + JSONObject body = new JSONObject(); + body.put("model", model); + JSONArray input = new JSONArray(); + input.add(message("system", visionSystemPrompt())); + + JSONArray content = new JSONArray(); + JSONObject text = new JSONObject(); + text.put("type", "input_text"); + text.put("text", visionUserPrompt()); + content.add(text); + JSONObject imageContent = new JSONObject(); + imageContent.put("type", "input_image"); + imageContent.put("image_url", "data:" + mimeType + ";base64," + encoded); + content.add(imageContent); + input.add(message("user", content)); + body.put("input", input); + + JSONObject format = new JSONObject(); + format.put("type", "json_object"); + JSONObject textFormat = new JSONObject(); + textFormat.put("format", format); + body.put("text", textFormat); + + HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build(); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(normalizeEndpoint(baseUrl))) + .timeout(Duration.ofSeconds(60)) + .header("Authorization", "Bearer " + apiKey) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body.toJSONString(), StandardCharsets.UTF_8)) + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() < 200 || response.statusCode() >= 300) + { + throw new ServiceException("图片模型解析失败,HTTP " + response.statusCode()); + } + String output = extractOutputText(response.body()); + if (StringUtils.isBlank(output)) + { + throw new ServiceException("图片模型未返回解析内容"); + } + return output; + } + + private JSONObject message(String role, Object content) + { + JSONObject message = new JSONObject(); + message.put("role", role); + message.put("content", content); + return message; + } + + private String visionSystemPrompt() + { + return "你是库存管理系统的图片表格解析器,只返回JSON对象,不要解释。" + + "从图片中识别可导入的库存业务明细,返回字段 items。" + + "每个item字段包括 actionType, areaName, productName, brand, category, spec, unit, quantity, operatorName, typeCode, sourceNo, remark。" + + "actionType 只能是 create_area, create_product, create_in_record, create_out_record, create_stock_check, unknown。" + + "入库类型typeCode:1生产入库,2采购入库,3归还入库,4退货入库;出库类型typeCode:1生产出库,2退货出库,3销售出库。" + + "如果只有库区信息、没有产品信息,actionType填create_area;如果有产品信息、没有库区信息,actionType填create_product。" + + "如果库区和产品都有但图片没有明确操作类型,actionType填unknown,不要猜入库或出库。" + + "如果类别缺失可以按物品合理推断简短类别;无法识别的字段填null。"; + } + + private String visionUserPrompt() + { + return "请解析这张图片中的库存数据,可能是表格、截图或手写/打印清单。" + + "不要执行操作,只生成待确认明细。" + + "返回格式:{\"items\":[{\"actionType\":\"create_in_record\",\"areaName\":\"库区\",\"productName\":\"产品名称\",\"brand\":\"品牌\",\"category\":\"类别\",\"spec\":\"规格\",\"unit\":\"单位\",\"quantity\":1,\"operatorName\":\"人员\",\"typeCode\":2,\"sourceNo\":\"单号\",\"remark\":\"备注\"}]}"; + } + + private void normalizeItem(WmsBatchItem item) + { + item.setActionType(parseAction(item.getActionType())); + item.setAreaName(trimToNull(item.getAreaName())); + item.setProductName(trimToNull(item.getProductName())); + item.setBrand(trimToNull(item.getBrand())); + item.setCategory(trimToNull(item.getCategory())); + item.setSpec(trimToNull(item.getSpec())); + item.setUnit(trimToNull(item.getUnit())); + item.setOperatorName(trimToNull(item.getOperatorName())); + item.setSourceNo(trimToNull(item.getSourceNo())); + item.setRemark(trimToNull(item.getRemark())); + if (StringUtils.isBlank(item.getActionType()) || ACTION_UNKNOWN.equals(item.getActionType())) + { + String inferredAction = inferActionFromItem(item); + if (StringUtils.isNotBlank(inferredAction)) + { + item.setActionType(inferredAction); + } + } + if (StringUtils.isBlank(item.getActionType())) + { + item.setActionType(ACTION_UNKNOWN); + } + if (ACTION_IN.equals(item.getActionType()) && item.getTypeCode() == null) + { + item.setTypeCode(2); + } + if (ACTION_OUT.equals(item.getActionType()) && item.getTypeCode() == null) + { + item.setTypeCode(1); + } + if (StringUtils.isBlank(item.getCategory()) && StringUtils.isNotBlank(item.getProductName())) + { + ProductInfo draft = new ProductInfo(); + draft.setProductName(item.getProductName()); + draft.setBrand(item.getBrand()); + draft.setSpec(item.getSpec()); + draft.setUnit(item.getUnit()); + item.setCategory(productService.inferProductCategory(draft)); + appendWarning(item, "未提供类别,已自动分类为:" + item.getCategory()); + } + } + + private void validateItem(WmsBatchItem item, String username) + { + item.setStatus(ITEM_VALID); + item.setErrorMsg(null); + item.setWarningMsg(trimToNull(item.getWarningMsg())); + if (StringUtils.isBlank(item.getActionType()) || ACTION_UNKNOWN.equals(item.getActionType())) + { + fail(item, "未识别操作类型,请填写操作类型"); + return; + } + if (ACTION_AREA.equals(item.getActionType())) + { + validateAreaItem(item); + } + else if (ACTION_PRODUCT.equals(item.getActionType())) + { + validateProductItem(item); + } + else if (ACTION_IN.equals(item.getActionType())) + { + validateInItem(item, username); + } + else if (ACTION_OUT.equals(item.getActionType())) + { + validateOutItem(item, username); + } + else if (ACTION_CHECK.equals(item.getActionType())) + { + validateCheckItem(item, username); + } + else + { + fail(item, "暂不支持的操作类型:" + item.getActionType()); + } + } + + private void validateAreaItem(WmsBatchItem item) + { + if (StringUtils.isBlank(item.getAreaName())) + { + fail(item, "库区名称不能为空"); + return; + } + if (findArea(item.getAreaName()) != null) + { + appendWarning(item, "库区已存在,确认执行时不会重复新增"); + } + } + + private void validateProductItem(WmsBatchItem item) + { + if (StringUtils.isBlank(item.getProductName())) + { + fail(item, "产品名称不能为空"); + return; + } + if (findProduct(item) != null) + { + appendWarning(item, "产品已存在,确认执行时不会重复新增"); + } + } + + private void validateInItem(WmsBatchItem item, String username) + { + validateAreaProductQuantity(item, true, true); + if (ITEM_INVALID == item.getStatus()) + { + return; + } + if (StringUtils.isBlank(item.getOperatorName())) + { + item.setOperatorName(resolveNickName(username)); + } + if (findArea(item.getAreaName()) == null) + { + appendWarning(item, "库区不存在,确认执行时将自动新增"); + } + if (findProduct(item) == null) + { + appendWarning(item, "产品不存在,确认执行时将自动新增"); + } + } + + private void validateOutItem(WmsBatchItem item, String username) + { + validateAreaProductQuantity(item, true, true); + if (ITEM_INVALID == item.getStatus()) + { + return; + } + if (StringUtils.isBlank(item.getOperatorName())) + { + item.setOperatorName(resolveNickName(username)); + } + WarehouseArea area = findArea(item.getAreaName()); + ProductInfo product = findProduct(item); + if (area == null) + { + fail(item, "库区不存在,不能出库"); + return; + } + if (product == null) + { + fail(item, "产品不存在,不能出库"); + return; + } + StockInfo query = new StockInfo(); + query.setAreaId(area.getId()); + query.setProductId(product.getId()); + List stockList = stockInfoService.selectStockInfoList(query); + BigDecimal stockNum = stockList.isEmpty() || stockList.get(0).getStockNum() == null ? BigDecimal.ZERO : stockList.get(0).getStockNum(); + if (stockNum.compareTo(item.getQuantity()) < 0) + { + fail(item, "库存不足,当前库存:" + stockNum); + } + } + + private void validateCheckItem(WmsBatchItem item, String username) + { + validateAreaProductQuantity(item, true, false); + if (ITEM_INVALID == item.getStatus()) + { + return; + } + if (StringUtils.isBlank(item.getOperatorName())) + { + item.setOperatorName(resolveNickName(username)); + } + if (findArea(item.getAreaName()) == null) + { + fail(item, "库区不存在,不能盘库"); + return; + } + if (findProduct(item) == null) + { + fail(item, "产品不存在,不能盘库"); + } + } + + private void validateAreaProductQuantity(WmsBatchItem item, boolean productRequired, boolean positiveQuantity) + { + if (StringUtils.isBlank(item.getAreaName())) + { + fail(item, "库区名称不能为空"); + return; + } + if (productRequired && StringUtils.isBlank(item.getProductName())) + { + fail(item, "产品名称不能为空"); + return; + } + if (item.getQuantity() == null) + { + fail(item, "数量不能为空"); + return; + } + if (positiveQuantity && item.getQuantity().signum() <= 0) + { + fail(item, "数量必须大于0"); + return; + } + if (!positiveQuantity && item.getQuantity().signum() < 0) + { + fail(item, "数量不能小于0"); + } + } + + private void executeItem(WmsBatchItem item, String username) + { + normalizeItem(item); + validateItem(item, username); + if (ITEM_INVALID == item.getStatus()) + { + throw new ServiceException(item.getErrorMsg()); + } + if (ACTION_AREA.equals(item.getActionType())) + { + WarehouseArea area = ensureArea(item.getAreaName()); + item.setRefId(area.getId()); + } + else if (ACTION_PRODUCT.equals(item.getActionType())) + { + ProductInfo product = ensureProduct(item); + item.setRefId(product.getId()); + } + else if (ACTION_IN.equals(item.getActionType())) + { + WarehouseArea area = ensureArea(item.getAreaName()); + ProductInfo product = ensureProduct(item); + InRecord record = new InRecord(); + record.setAreaId(area.getId()); + record.setProductId(product.getId()); + record.setInType(item.getTypeCode()); + record.setInNum(item.getQuantity()); + record.setInOperator(item.getOperatorName()); + record.setOperator(username); + record.setRemark(item.getRemark()); + inRecordService.insertInRecord(record); + item.setRefId(record.getId()); + } + else if (ACTION_OUT.equals(item.getActionType())) + { + WarehouseArea area = findArea(item.getAreaName()); + ProductInfo product = findProduct(item); + OutRecord record = new OutRecord(); + record.setAreaId(area.getId()); + record.setProductId(product.getId()); + record.setOutType(item.getTypeCode()); + record.setOutNum(item.getQuantity()); + record.setOutOperator(item.getOperatorName()); + record.setOperator(username); + record.setSourceNo(item.getSourceNo()); + record.setRemark(item.getRemark()); + outRecordService.insertOutRecord(record); + item.setRefId(record.getId()); + } + else if (ACTION_CHECK.equals(item.getActionType())) + { + WarehouseArea area = findArea(item.getAreaName()); + ProductInfo product = findProduct(item); + StockCheckRecord record = new StockCheckRecord(); + record.setAreaId(area.getId()); + record.setProductId(product.getId()); + record.setRealNum(item.getQuantity()); + record.setCheckUser(item.getOperatorName()); + record.setReason(item.getRemark()); + stockCheckRecordService.insertStockCheckRecord(record); + item.setRefId(record.getId()); + } + } + + private void ensureJobEditable(Long jobId) + { + WmsBatchJob job = jobMapper.selectWmsBatchJobById(jobId); + if (job == null) + { + throw new ServiceException("批量任务不存在"); + } + if (Integer.valueOf(JOB_EXECUTING).equals(job.getStatus()) || Integer.valueOf(JOB_DONE).equals(job.getStatus())) + { + throw new ServiceException("任务已执行,不能再修改明细"); + } + } + + private WarehouseArea ensureArea(String areaName) + { + WarehouseArea area = findArea(areaName); + if (area != null) + { + return area; + } + area = new WarehouseArea(); + area.setAreaName(areaName); + areaService.insertWarehouseArea(area); + return area; + } + + private ProductInfo ensureProduct(WmsBatchItem item) + { + ProductInfo product = findProduct(item); + if (product != null) + { + return product; + } + product = new ProductInfo(); + product.setProductName(item.getProductName()); + product.setBrand(item.getBrand()); + product.setCategory(item.getCategory()); + product.setSpec(item.getSpec()); + product.setUnit(item.getUnit()); + product.setRemark(item.getRemark()); + productService.insertProductInfo(product); + return product; + } + + private WarehouseArea findArea(String areaName) + { + if (StringUtils.isBlank(areaName)) + { + return null; + } + for (WarehouseArea area : areaService.selectWarehouseAreaAll()) + { + if (sameText(area.getAreaName(), areaName) || sameText(area.getAreaCode(), areaName)) + { + return area; + } + } + return null; + } + + private ProductInfo findProduct(WmsBatchItem item) + { + if (StringUtils.isBlank(item.getProductName())) + { + return null; + } + for (ProductInfo product : productService.selectProductInfoAll()) + { + if (sameText(product.getProductName(), item.getProductName()) + && sameText(product.getBrand(), item.getBrand()) + && sameText(product.getCategory(), item.getCategory()) + && sameText(product.getSpec(), item.getSpec())) + { + return product; + } + } + return null; + } + + private void refreshJobCount(Long jobId, Integer status, Integer progress, String errorMsg) + { + WmsBatchItem validQuery = new WmsBatchItem(); + validQuery.setJobId(jobId); + validQuery.setStatus(ITEM_VALID); + WmsBatchItem invalidQuery = new WmsBatchItem(); + invalidQuery.setJobId(jobId); + invalidQuery.setStatus(ITEM_INVALID); + int valid = itemMapper.countByJobIdAndStatus(validQuery); + int invalid = itemMapper.countByJobIdAndStatus(invalidQuery); + + WmsBatchJob job = new WmsBatchJob(); + job.setId(jobId); + job.setStatus(status); + job.setProgress(progress); + job.setTotalCount(valid + invalid); + job.setSuccessCount(valid); + job.setFailCount(invalid); + job.setErrorMsg(errorMsg); + jobMapper.updateWmsBatchJob(job); + } + + private void updateJob(Long jobId, Integer status, Integer progress, String errorMsg) + { + WmsBatchJob job = new WmsBatchJob(); + job.setId(jobId); + job.setStatus(status); + job.setProgress(progress); + job.setErrorMsg(errorMsg); + jobMapper.updateWmsBatchJob(job); + } + + private void updateJobProgress(Long jobId, Integer status, int total, int done, int success, int fail) + { + WmsBatchJob job = new WmsBatchJob(); + job.setId(jobId); + job.setStatus(status); + job.setProgress(total == 0 ? 100 : (int) Math.floor(done * 100.0 / total)); + job.setTotalCount(total); + job.setSuccessCount(success); + job.setFailCount(fail); + jobMapper.updateWmsBatchJob(job); + } + + private int findHeaderRow(Sheet sheet, DataFormatter formatter) + { + int max = Math.min(sheet.getLastRowNum(), 10); + for (int i = 0; i <= max; i++) + { + Row row = sheet.getRow(i); + if (row == null) + { + continue; + } + int count = 0; + for (Cell cell : row) + { + if (mapHeader(formatter.formatCellValue(cell)) != null) + { + count++; + } + } + if (count >= 2) + { + return i; + } + } + return -1; + } + + private Map buildHeaderMap(Row row, DataFormatter formatter) + { + Map map = new HashMap<>(); + for (Cell cell : row) + { + String field = mapHeader(formatter.formatCellValue(cell)); + if (field != null) + { + map.put(cell.getColumnIndex(), field); + } + } + return map; + } + + private String mapHeader(String header) + { + String text = normalizeHeader(header); + if (StringUtils.isBlank(text)) + { + return null; + } + if (containsAny(text, "库区", "仓库", "库位")) + { + return "areaName"; + } + if (containsAny(text, "产品名称", "物料名称", "商品名称") || "产品".equals(text) || "物料".equals(text)) + { + return "productName"; + } + if (containsAny(text, "品牌")) + { + return "brand"; + } + if (containsAny(text, "类别", "分类")) + { + return "category"; + } + if (containsAny(text, "规格", "型号")) + { + return "spec"; + } + if (containsAny(text, "单位")) + { + return "unit"; + } + if (containsAny(text, "数量", "入库数量", "出库数量", "实际盘点数量", "实际数量")) + { + return "quantity"; + } + if (containsAny(text, "入库人", "出库人", "盘点人", "操作人", "经办人")) + { + return "operatorName"; + } + if (containsAny(text, "入库类型", "出库类型")) + { + return "typeCode"; + } + if (containsAny(text, "操作类型", "业务类型", "动作")) + { + return "actionType"; + } + if (containsAny(text, "来源单据", "关联单据", "单据号")) + { + return "sourceNo"; + } + if (containsAny(text, "备注", "原因")) + { + return "remark"; + } + return null; + } + + private String inferActionFromHeaderRow(Row row, DataFormatter formatter) + { + StringBuilder builder = new StringBuilder(); + for (Cell cell : row) + { + builder.append(formatter.formatCellValue(cell)).append(' '); + } + String joined = builder.toString(); + if (containsAny(joined, "入库")) + { + return ACTION_IN; + } + if (containsAny(joined, "出库")) + { + return ACTION_OUT; + } + if (containsAny(joined, "盘点", "盘库")) + { + return ACTION_CHECK; + } + return null; + } + + private String inferActionFromItem(WmsBatchItem item) + { + if (StringUtils.isNotBlank(item.getAreaName()) && StringUtils.isBlank(item.getProductName())) + { + return ACTION_AREA; + } + if (StringUtils.isNotBlank(item.getProductName()) && StringUtils.isBlank(item.getAreaName())) + { + return ACTION_PRODUCT; + } + return null; + } + + private String parseAction(String value) + { + String text = StringUtils.defaultString(value).trim(); + if (StringUtils.isBlank(text) || text.startsWith("create_")) + { + return text; + } + if (ACTION_UNKNOWN.equals(text)) + { + return ACTION_UNKNOWN; + } + if (containsAny(text, "新增库区", "库区定义", "库区维护")) + { + return ACTION_AREA; + } + if (containsAny(text, "新增产品", "产品信息", "产品维护", "商品")) + { + return ACTION_PRODUCT; + } + if (containsAny(text, "入库")) + { + return ACTION_IN; + } + if (containsAny(text, "出库")) + { + return ACTION_OUT; + } + if (containsAny(text, "盘库", "盘点")) + { + return ACTION_CHECK; + } + return text; + } + + private boolean isExcel(String extension) + { + return "xls".equals(extension) || "xlsx".equals(extension); + } + + private boolean isImage(String extension) + { + return "jpg".equals(extension) || "jpeg".equals(extension) || "png".equals(extension) || "bmp".equals(extension); + } + + private String normalizeEndpoint(String configuredBaseUrl) + { + String url = StringUtils.defaultIfBlank(configuredBaseUrl, "http://192.168.28.10:18080").trim(); + while (url.endsWith("/")) + { + url = url.substring(0, url.length() - 1); + } + if (url.endsWith("/v1")) + { + return url + "/responses"; + } + if (url.endsWith("/v1/responses")) + { + return url; + } + return url + "/v1/responses"; + } + + private String extractOutputText(String responseBody) + { + JSONObject root = JSON.parseObject(responseBody); + String outputText = root.getString("output_text"); + if (StringUtils.isNotBlank(outputText)) + { + return outputText; + } + StringBuilder builder = new StringBuilder(); + JSONArray output = root.getJSONArray("output"); + if (output != null) + { + for (int i = 0; i < output.size(); i++) + { + JSONObject item = output.getJSONObject(i); + JSONArray content = item == null ? null : item.getJSONArray("content"); + if (content == null) + { + continue; + } + for (int j = 0; j < content.size(); j++) + { + JSONObject contentItem = content.getJSONObject(j); + if (contentItem != null && StringUtils.isNotBlank(contentItem.getString("text"))) + { + builder.append(contentItem.getString("text")); + } + } + } + } + return builder.toString(); + } + + private String extractJson(String output) + { + String text = StringUtils.defaultString(output).trim(); + int objectStart = text.indexOf('{'); + int arrayStart = text.indexOf('['); + int start; + int end; + if (arrayStart >= 0 && (objectStart < 0 || arrayStart < objectStart)) + { + start = arrayStart; + end = text.lastIndexOf(']'); + } + else + { + start = objectStart; + end = text.lastIndexOf('}'); + } + if (start >= 0 && end > start) + { + return text.substring(start, end + 1); + } + return text; + } + + private Integer parseTypeCode(String value, String actionType) + { + if (StringUtils.isBlank(value)) + { + return null; + } + String text = value.trim(); + if (text.matches("\\d+")) + { + return Integer.valueOf(text); + } + if (ACTION_OUT.equals(actionType)) + { + if (text.contains("退货")) return 2; + if (text.contains("销售")) return 3; + return 1; + } + if (text.contains("生产")) return 1; + if (text.contains("归还")) return 3; + if (text.contains("退货")) return 4; + return 2; + } + + private BigDecimal parseQuantity(String value) + { + if (StringUtils.isBlank(value)) + { + return null; + } + String text = value.replace(",", "").trim(); + java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("-?\\d+(?:\\.\\d+)?").matcher(text); + if (matcher.find()) + { + return new BigDecimal(matcher.group()); + } + return null; + } + + private String firstText(JSONObject json, String... keys) + { + for (String key : keys) + { + Object value = json.get(key); + if (value != null && StringUtils.isNotBlank(String.valueOf(value))) + { + return String.valueOf(value); + } + } + return null; + } + + private boolean isEmptyRow(Row row, DataFormatter formatter) + { + for (Cell cell : row) + { + if (StringUtils.isNotBlank(formatter.formatCellValue(cell))) + { + return false; + } + } + return true; + } + + private void fillParsedJson(WmsBatchItem item) + { + JSONObject json = new JSONObject(); + json.put("actionType", item.getActionType()); + json.put("areaName", item.getAreaName()); + json.put("productName", item.getProductName()); + json.put("brand", item.getBrand()); + json.put("category", item.getCategory()); + json.put("spec", item.getSpec()); + json.put("unit", item.getUnit()); + json.put("quantity", item.getQuantity()); + json.put("operatorName", item.getOperatorName()); + json.put("typeCode", item.getTypeCode()); + json.put("sourceNo", item.getSourceNo()); + json.put("remark", item.getRemark()); + item.setParsedJson(json.toJSONString()); + } + + private void fail(WmsBatchItem item, String message) + { + item.setStatus(ITEM_INVALID); + item.setErrorMsg(message); + } + + private void appendWarning(WmsBatchItem item, String message) + { + if (StringUtils.isBlank(message)) + { + return; + } + if (StringUtils.isBlank(item.getWarningMsg())) + { + item.setWarningMsg(message); + } + else if (!item.getWarningMsg().contains(message)) + { + item.setWarningMsg(item.getWarningMsg() + ";" + message); + } + } + + private String resolveNickName(String value) + { + if (StringUtils.isBlank(value)) + { + return value; + } + SysUser user = userService.selectUserByUserName(value); + if (user != null && StringUtils.isNotBlank(user.getNickName())) + { + return user.getNickName(); + } + return value.trim(); + } + + private boolean sameText(String left, String right) + { + return StringUtils.defaultString(left).trim().equalsIgnoreCase(StringUtils.defaultString(right).trim()); + } + + private String normalizeHeader(String value) + { + return StringUtils.defaultString(value).replaceAll("[\\s::_\\-()()]", "").trim(); + } + + private String trimToNull(String value) + { + return StringUtils.isBlank(value) ? null : value.trim(); + } + + private boolean containsAny(String text, String... keywords) + { + if (StringUtils.isBlank(text)) + { + return false; + } + for (String keyword : keywords) + { + if (StringUtils.isNotBlank(keyword) && text.toLowerCase(Locale.ROOT).contains(keyword.toLowerCase(Locale.ROOT))) + { + return true; + } + } + return false; + } +} diff --git a/ruoyi-system/src/main/resources/mapper/warehouse/WmsBatchItemMapper.xml b/ruoyi-system/src/main/resources/mapper/warehouse/WmsBatchItemMapper.xml new file mode 100644 index 0000000..55f6395 --- /dev/null +++ b/ruoyi-system/src/main/resources/mapper/warehouse/WmsBatchItemMapper.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + select id, job_id, row_no, action_type, raw_text, parsed_json, status, error_msg, warning_msg, ref_id, + area_name, product_name, brand, category, spec, unit, quantity, operator_name, type_code, + source_no, remark, create_time, update_time + from wms_batch_item + + + + + + + + + + insert into wms_batch_item(job_id, row_no, action_type, raw_text, parsed_json, status, error_msg, warning_msg, + ref_id, area_name, product_name, brand, category, spec, unit, quantity, + operator_name, type_code, source_no, remark, create_time) + values(#{jobId}, #{rowNo}, #{actionType}, #{rawText}, #{parsedJson}, #{status}, #{errorMsg}, #{warningMsg}, + #{refId}, #{areaName}, #{productName}, #{brand}, #{category}, #{spec}, #{unit}, #{quantity}, + #{operatorName}, #{typeCode}, #{sourceNo}, #{remark}, sysdate()) + + + + update wms_batch_item + + row_no = #{rowNo}, + action_type = #{actionType}, + raw_text = #{rawText}, + parsed_json = #{parsedJson}, + status = #{status}, + error_msg = #{errorMsg}, + warning_msg = #{warningMsg}, + ref_id = #{refId}, + area_name = #{areaName}, + product_name = #{productName}, + brand = #{brand}, + category = #{category}, + spec = #{spec}, + unit = #{unit}, + quantity = #{quantity}, + operator_name = #{operatorName}, + type_code = #{typeCode}, + source_no = #{sourceNo}, + remark = #{remark}, + update_time = sysdate() + + where id = #{id} + + + + delete from wms_batch_item where id = #{id} + + + + delete from wms_batch_item where job_id = #{value} + + diff --git a/ruoyi-system/src/main/resources/mapper/warehouse/WmsBatchJobMapper.xml b/ruoyi-system/src/main/resources/mapper/warehouse/WmsBatchJobMapper.xml new file mode 100644 index 0000000..798a29a --- /dev/null +++ b/ruoyi-system/src/main/resources/mapper/warehouse/WmsBatchJobMapper.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + select id, job_no, source_type, biz_type, file_name, file_path, status, progress, + total_count, success_count, fail_count, error_msg, create_by, create_time, update_time + from wms_batch_job + + + + + + + + insert into wms_batch_job(job_no, source_type, biz_type, file_name, file_path, status, progress, + total_count, success_count, fail_count, error_msg, create_by, create_time) + values(#{jobNo}, #{sourceType}, #{bizType}, #{fileName}, #{filePath}, #{status}, #{progress}, + #{totalCount}, #{successCount}, #{failCount}, #{errorMsg}, #{createBy}, sysdate()) + + + + update wms_batch_job + + source_type = #{sourceType}, + biz_type = #{bizType}, + file_name = #{fileName}, + file_path = #{filePath}, + status = #{status}, + progress = #{progress}, + total_count = #{totalCount}, + success_count = #{successCount}, + fail_count = #{failCount}, + error_msg = #{errorMsg}, + update_time = sysdate() + + where id = #{id} + + diff --git a/ruoyi-ui/src/api/warehouse/batchImport.js b/ruoyi-ui/src/api/warehouse/batchImport.js new file mode 100644 index 0000000..2375fe0 --- /dev/null +++ b/ruoyi-ui/src/api/warehouse/batchImport.js @@ -0,0 +1,46 @@ +import request from '@/utils/request' + +export function listBatchJob(query) { + return request({ url: '/warehouse/batch/list', method: 'get', params: query }) +} + +export function uploadBatchFile(file) { + const data = new FormData() + data.append('file', file) + return request({ + url: '/warehouse/batch/upload', + method: 'post', + data, + headers: { 'Content-Type': 'multipart/form-data', repeatSubmit: false }, + timeout: 60000 + }) +} + +export function getBatchJob(id) { + return request({ url: '/warehouse/batch/' + id, method: 'get' }) +} + +export function listBatchItems(jobId) { + return request({ url: '/warehouse/batch/' + jobId + '/items', method: 'get' }) +} + +export function updateBatchItem(data) { + return request({ url: '/warehouse/batch/item', method: 'put', data }) +} + +export function updateBatchItemAction(jobId, data) { + return request({ url: '/warehouse/batch/' + jobId + '/items/action', method: 'put', data }) +} + +export function deleteBatchItem(id) { + return request({ url: '/warehouse/batch/item/' + id, method: 'delete' }) +} + +export function confirmBatchJob(jobId) { + return request({ + url: '/warehouse/batch/' + jobId + '/confirm', + method: 'post', + timeout: 120000, + headers: { repeatSubmit: false } + }) +} diff --git a/ruoyi-ui/src/router/index.js b/ruoyi-ui/src/router/index.js index 39f6d0f..af76bc3 100644 --- a/ruoyi-ui/src/router/index.js +++ b/ruoyi-ui/src/router/index.js @@ -67,6 +67,19 @@ export const constantRoutes = [ hidden: true, meta: { title: '移动库存' } }, + { + path: '/warehouse/batch-import', + component: Layout, + hidden: true, + children: [ + { + path: '', + component: () => import('@/views/warehouse/batchImport/index'), + name: 'WmsBatchImport', + meta: { title: '批量导入' } + } + ] + }, { path: '', component: Layout, diff --git a/ruoyi-ui/src/utils/imageCompress.js b/ruoyi-ui/src/utils/imageCompress.js new file mode 100644 index 0000000..3040071 --- /dev/null +++ b/ruoyi-ui/src/utils/imageCompress.js @@ -0,0 +1,77 @@ +function isImageFile(file) { + return file && file.type && file.type.indexOf('image/') === 0 +} + +function canvasToBlob(canvas, type, quality) { + return new Promise(resolve => { + if (!canvas.toBlob) { + resolve(null) + return + } + canvas.toBlob(blob => resolve(blob), type, quality) + }) +} + +function loadImage(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = event => { + const image = new Image() + image.onload = () => resolve(image) + image.onerror = reject + image.src = event.target.result + } + reader.onerror = reject + reader.readAsDataURL(file) + }) +} + +export async function compressImageForUpload(file, options = {}) { + const maxSize = options.maxSize || 3 * 1024 * 1024 + const maxWidth = options.maxWidth || 1600 + const maxHeight = options.maxHeight || 1600 + const outputType = options.outputType || 'image/jpeg' + const minQuality = options.minQuality || 0.55 + + if (!isImageFile(file) || typeof FileReader === 'undefined' || typeof document === 'undefined') { + return file + } + + try { + const image = await loadImage(file) + let scale = Math.min(maxWidth / image.width, maxHeight / image.height, 1) + let quality = options.quality || 0.82 + let bestBlob = null + + for (let i = 0; i < 7; i++) { + const width = Math.max(1, Math.round(image.width * scale)) + const height = Math.max(1, Math.round(image.height * scale)) + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + const context = canvas.getContext('2d') + context.fillStyle = '#ffffff' + context.fillRect(0, 0, width, height) + context.drawImage(image, 0, 0, width, height) + + const blob = await canvasToBlob(canvas, outputType, quality) + if (!blob) { + return file + } + bestBlob = blob + if (blob.size <= maxSize) { + break + } + scale = scale * 0.82 + quality = Math.max(minQuality, quality - 0.08) + } + + if (!bestBlob || bestBlob.size >= file.size) { + return file + } + const fileName = file.name ? file.name.replace(/\.[^.]+$/, '.jpg') : 'upload.jpg' + return new File([bestBlob], fileName, { type: outputType, lastModified: Date.now() }) + } catch (error) { + return file + } +} diff --git a/ruoyi-ui/src/views/index.vue b/ruoyi-ui/src/views/index.vue index de9eef3..64f2b37 100644 --- a/ruoyi-ui/src/views/index.vue +++ b/ruoyi-ui/src/views/index.vue @@ -5,7 +5,10 @@

库存管理系统

面向库区、产品、入库、出库和盘库的日常库存作业台。

- 移动端入口 +
+ 批量导入 + 移动端入口 +
@@ -337,6 +340,12 @@ export default { border: 1px solid #e6ebf2; border-radius: 8px; } +.hero-actions { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} .hero h1 { margin: 0; font-size: 24px; diff --git a/ruoyi-ui/src/views/mobile/index.vue b/ruoyi-ui/src/views/mobile/index.vue index a8a67e3..972d632 100644 --- a/ruoyi-ui/src/views/mobile/index.vue +++ b/ruoyi-ui/src/views/mobile/index.vue @@ -96,6 +96,7 @@ +
库存概览
@@ -303,6 +304,108 @@
+ +
+
+
+
智能批量导入
+
上传 Excel 或图片,解析后确认再写入库存数据
+
+ 返回 +
+ + + 上传 Excel / 图片 +
图片识别耗时更久,上传后请等待解析进度完成。
+
+ +
+
+
+
{{ batch.currentJob.jobNo }}
+
{{ batch.currentJob.fileName }} · {{ batchStatusLabel(batch.currentJob.status) }}
+
+ {{ batchSourceLabel(batch.currentJob.sourceType) }} +
+ +
+ 总数 {{ batch.currentJob.totalCount || 0 }} + 可执行 {{ batch.currentJob.successCount || 0 }} + 异常 {{ batch.currentJob.failCount || 0 }} +
+ + 确认执行当前任务 +
+ +
+ + + + + + + +
+ 应用到全部 + 应用已选 {{ batch.selectedItemIds.length }} +
+
+ +
正在加载明细...
+
暂无解析明细
+
+
+
+
第 {{ item.rowNo }} 行 · {{ batchActionLabel(item.actionType) }}
+
{{ item.areaName || '未识别库区' }}
+
+ 选择 +
+
{{ item.productName || '未识别产品' }}
+
{{ batchProductMeta(item) || '无产品明细' }}
+
+ 数量:{{ item.quantity }} + 人员:{{ item.operatorName }} +
+
+ {{ batchItemStatusLabel(item.status) }} + {{ item.errorMsg }} + {{ item.warningMsg }} +
+
+ +
最近任务
+
正在加载任务...
+
暂无批量任务
+
+
+
+
{{ job.jobNo }}
+
{{ job.fileName || '-' }}
+
+ {{ batchStatusLabel(job.status) }} +
+
+ {{ batchSourceLabel(job.sourceType) }} + 进度 {{ job.progress || 0 }}% + 异常 {{ job.failCount || 0 }} +
+
+