From 1b8511c09267d048887526951a44a9da993aa174 Mon Sep 17 00:00:00 2001 From: rcx <3084692334@qq.com> Date: Fri, 4 Sep 2026 10:55:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(oms):=20=E5=87=BA=E5=BA=93/=E5=85=A5?= =?UTF-8?q?=E5=BA=93=E4=B8=9A=E5=8A=A1=E5=8D=95=E5=AF=BC=E5=85=A5=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=A4=9A=E9=A1=B5=E7=AD=BE=E2=80=94=E2=80=94=E6=AF=8F?= =?UTF-8?q?=E4=B8=AA=E5=B7=A5=E4=BD=9C=E8=A1=A8=E7=8B=AC=E7=AB=8B=E7=94=9F?= =?UTF-8?q?=E6=88=90=E4=B8=80=E5=BC=A0=E5=8D=95=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、行为变更(ReservationStockInOrderImpl) - 出库导入(parseExcelFile)/入库导入(parseStockInExcel):不再固定读单一页签,枚举Excel全部工作表,每个页签独立解析为一张单据(5个页签=5张单5个单号) - 全部回滚策略:任一页签校验失败整体不落库,报错按页签聚合列出(页签[x]:原因;页签[y]:原因) - 空页签(无货品/客户明细)自动跳过;单页签文件行为与原先完全一致 - 出库单号改到落库阶段逐张生成:多页签下校验阶段统一生成会因全部未入库而取到相同单号 - 入库模板OrderNO增加本文件内跨页签查重:各页签都未入库时库查重互相不可见,防两页签同OrderNO重复落库 - 页签解析异常message为空时兜底用异常类名,避免报错显示null - 方法级@Transactional(rollbackFor=Exception.class):顺带修复旧入库导入因this自调用导致事务代理失效的问题 二、接口与返回结构(IReservationStockInOrderService / ReservationStockInOrderApi) - 两个导入方法返回类型改为List(每页签一条:页签名/单号/物料行数/数量合计/解析数据) - /parseExcel与/parse-excel返回:result=第一个页签解析数据(兼容老前端)、results=全部页签结果、msg=成功导入N张单:单号1、单号2… - 入库接口不传sheetName为多页签模式,显式传页签名保持单页签老行为 --- .../IReservationStockInOrderService.java | 19 +- .../ReservationStockInOrderImpl.java | 222 +++++++++++++++--- .../repository/vo/SheetImportResultDTO.java | 31 +++ .../ReservationStockInOrderApi.java | 28 ++- 4 files changed, 255 insertions(+), 45 deletions(-) create mode 100644 mhd_oms/src/main/java/com/mhd/oms/domain/reservationStockInOrder/repository/vo/SheetImportResultDTO.java diff --git a/mhd_oms/src/main/java/com/mhd/oms/domain/reservationStockInOrder/repository/facade/IReservationStockInOrderService.java b/mhd_oms/src/main/java/com/mhd/oms/domain/reservationStockInOrder/repository/facade/IReservationStockInOrderService.java index d2963405b..97435b85f 100644 --- a/mhd_oms/src/main/java/com/mhd/oms/domain/reservationStockInOrder/repository/facade/IReservationStockInOrderService.java +++ b/mhd_oms/src/main/java/com/mhd/oms/domain/reservationStockInOrder/repository/facade/IReservationStockInOrderService.java @@ -6,6 +6,7 @@ import com.mhd.oms.domain.reservationStockInOrder.entity.ReservationStockInOrder import com.mhd.oms.domain.reservationStockInOrder.repository.po.ReservationStockInOrderPO; import com.mhd.oms.domain.reservationStockInOrder.repository.todo.ReservationStockInOrderDO; import com.mhd.oms.domain.reservationStockInOrder.repository.vo.DeliveryDataDTO; +import com.mhd.oms.domain.reservationStockInOrder.repository.vo.SheetImportResultDTO; import com.mhd.oms.domain.reservationStockInOrder.repository.vo.StockInDataDTO; import org.springframework.web.multipart.MultipartFile; @@ -50,21 +51,23 @@ public interface IReservationStockInOrderService extends IService queryNoticePrintDetail(ReservationStockInOrderDO stockInOrderDTO); /** - * 解析Excel文件并获取出库数据 + * 解析Excel文件并导入出库单(多页签:文件中每个工作表独立解析为一张出库单,单页签文件行为不变)。 + * 任一页签校验失败则整体不落库,报错聚合列出所有失败页签 * @param file Excel文件 - * @param sheetName 工作表名称(出库导入/参考数据) + * @param sheetName 兼容参数(多页签模式下不再按名字筛选,传空即可) * @param warehouseId 当前仓库id(非空时校验导入物料是否属于该仓库,并按该仓库过滤批次库存) - * @return 出库数据DTO + * @return 每个工作表的导入结果(含生成的出库单号与解析数据) * @throws Exception 解析异常 */ - DeliveryDataDTO parseExcelFile(MultipartFile file, String sheetName, Long warehouseId) throws Exception; + List parseExcelFile(MultipartFile file, String sheetName, Long warehouseId) throws Exception; /** - * 解析入库单Excel文件 + * 解析并导入入库单Excel(多页签:未指定页签名时文件中每个工作表独立解析为一张入库单,单页签文件行为不变)。 + * 任一页签校验失败则整体不落库,报错聚合列出所有失败页签 * @param file Excel文件 - * @param sheetName 工作表名称(默认:入库模版) - * @return 入库单完整数据(表头+明细) + * @param sheetName 显式指定页签时保持单页签行为;不传/为空则为多页签模式 + * @return 每个工作表的导入结果(含生成的入库单号与解析数据) * @throws Exception 解析异常 */ - StockInDataDTO parseStockInExcel(MultipartFile file, String sheetName) throws Exception; + List parseStockInExcel(MultipartFile file, String sheetName) throws Exception; } \ No newline at end of file diff --git a/mhd_oms/src/main/java/com/mhd/oms/domain/reservationStockInOrder/repository/persistence/ReservationStockInOrderImpl.java b/mhd_oms/src/main/java/com/mhd/oms/domain/reservationStockInOrder/repository/persistence/ReservationStockInOrderImpl.java index 21ed397d6..c1a474c7a 100644 --- a/mhd_oms/src/main/java/com/mhd/oms/domain/reservationStockInOrder/repository/persistence/ReservationStockInOrderImpl.java +++ b/mhd_oms/src/main/java/com/mhd/oms/domain/reservationStockInOrder/repository/persistence/ReservationStockInOrderImpl.java @@ -4,6 +4,8 @@ import cn.hutool.core.util.ObjectUtil; import cn.hutool.json.JSONArray; import cn.hutool.json.JSONUtil; import com.alibaba.excel.EasyExcel; +import com.alibaba.excel.ExcelReader; +import com.alibaba.excel.read.metadata.ReadSheet; import com.alibaba.fastjson.JSON; import com.alibaba.fastjson2.JSONObject; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; @@ -57,6 +59,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; +import java.io.ByteArrayInputStream; import java.io.InputStream; import java.math.BigDecimal; import java.text.SimpleDateFormat; @@ -220,19 +223,90 @@ public class ReservationStockInOrderImpl extends ServiceImpl parseExcelFile(MultipartFile file, String sheetName, Long warehouseId) throws Exception { LoginUser loginUser = SecurityUtils.getLoginUser(); - // 校验文件和工作表名称(逻辑不变) + // 校验文件(逻辑不变) if (file.isEmpty()) { throw new IllegalArgumentException("上传的Excel文件不能为空"); } - if (!"出库导入".equals(sheetName) && !"参考数据".equals(sheetName)) { - sheetName = "出库导入"; + // 多页签模式:文件中每个工作表独立解析为一张出库单(老逻辑固定只读"出库导入"页签) + byte[] fileBytes = file.getBytes(); + List sheetNames = listSheetNames(fileBytes); + if (sheetNames.isEmpty()) { + throw new IllegalArgumentException("Excel文件中没有工作表"); } + // ====== 第一阶段:逐页签解析+校验+组装(不落库),失败按页签聚合收集,全部页签处理完统一报错 ====== + List preparedOrders = new ArrayList<>(); + List sheetProblems = new ArrayList<>(); + for (String name : sheetNames) { + try { + PreparedDeliveryOrder prepared = buildDeliveryOrder(fileBytes, name, warehouseId, loginUser); + if (prepared != null) { + preparedOrders.add(prepared); // null=空页签,跳过 + } + } catch (Exception e) { + // 非模板页签的解析异常(如NPE)message 可能为空,兜底用异常类名,避免报错显示"页签[x]:null" + sheetProblems.add("页签[" + name + "]:" + (e.getMessage() == null ? e.toString() : e.getMessage())); + } + } + if (!sheetProblems.isEmpty()) { + throw new Exception(String.join(";", sheetProblems)); + } + if (preparedOrders.isEmpty()) { + throw new IllegalArgumentException("Excel中没有可导入的数据页签"); + } + // ====== 第二阶段:全部页签校验通过后统一落库(同一事务,任一失败整体回滚) ====== + List results = new ArrayList<>(); + for (PreparedDeliveryOrder prepared : preparedOrders) { + // 出库单号在落库阶段逐张生成:此时上一张已入库(同事务内可见),取号递增不会重复; + // 校验阶段统一生成会因全部页签都未入库而取到相同号 + String orderNumber = reservationStockOutOrderApplicationService.generateReserveOrderNumber(); + ReservationStockOutOrderDO order = prepared.order; + order.setOutOrderNumber(orderNumber); + order.setBusinessOrderNo(orderNumber); + if (order.getMaterialDetailList() != null) { + for (ReservationOutMaterialDetailDO detail : order.getMaterialDetailList()) { + detail.setOutOrderNumber(orderNumber); + } + } + reservationStockOutOrderApplicationService.insert(order); + saveDeliveryLinks(prepared); + prepared.result.setOrderNumber(orderNumber); + results.add(prepared.result); + } + return results; + } + /** + * 枚举Excel全部工作表名(EasyExcel 3.x:excelExecutor().sheetList()) + */ + private List listSheetNames(byte[] fileBytes) { + List sheetNames = new ArrayList<>(); + try (ExcelReader reader = EasyExcel.read(new ByteArrayInputStream(fileBytes)).build()) { + for (ReadSheet readSheet : reader.excelExecutor().sheetList()) { + sheetNames.add(readSheet.getSheetName()); + } + } + return sheetNames; + } + + /** 出库单页签解析/校验/组装完成后的暂存容器(统一落库阶段使用) */ + private static class PreparedDeliveryOrder { + ReservationStockOutOrderDO order; + List customerDeliveryDetailList; + Map customerCache; + SheetImportResultDTO result; + } + + /** + * 单个页签的出库单解析+校验+组装(不落库,由调用方在全部页签通过后统一插入)。 + * 空页签(货品明细与客户配送明细均为空)返回 null 由调用方跳过。 + */ + private PreparedDeliveryOrder buildDeliveryOrder(byte[] fileBytes, String sheetName, Long warehouseId, LoginUser loginUser) throws Exception { // 关键变更:使用动态行数监听器 DynamicDeliveryExcelListener listener = new DynamicDeliveryExcelListener(); - try (InputStream inputStream = file.getInputStream()) { + try (InputStream inputStream = new ByteArrayInputStream(fileBytes)) { EasyExcel.read(inputStream, listener) .sheet(sheetName) .headRowNumber(0) // 无固定表头,全部行交由监听器处理 @@ -254,8 +328,17 @@ public class ReservationStockInOrderImpl extends ServiceImpl> shipperIds = new ArrayList<>(); Set processedShipperIds = new HashSet<>(); @@ -356,10 +439,8 @@ public class ReservationStockInOrderImpl extends ServiceImpl customerCache = prepared.customerCache; // 客户配送明细判空处理:为空时跳过关联保存 - for (CustomerDeliveryDetail customerDeliveryDetail : customerDeliveryDetailList) { + for (CustomerDeliveryDetail customerDeliveryDetail : prepared.customerDeliveryDetailList) { ReservationDeliveryDetailsLink reservationDeliveryDetailsLink = new ReservationDeliveryDetailsLink(); reservationDeliveryDetailsLink.setInvoiceNumber(customerDeliveryDetail.getInvoiceNo()); reservationDeliveryDetailsLink.setAmount(customerDeliveryDetail.getAmount()); @@ -507,7 +609,6 @@ public class ReservationStockInOrderImpl extends ServiceImpl parseStockInExcel(MultipartFile file, String sheetName) throws Exception { LoginUser loginUser = SecurityUtils.getLoginUser(); // 1. 校验文件 if (file.isEmpty()) { @@ -537,29 +637,78 @@ public class ReservationStockInOrderImpl extends ServiceImpl sheetNames; + if (sheetName != null && !sheetName.trim().isEmpty()) { + sheetNames = Collections.singletonList(sheetName.trim()); + } else { + sheetNames = listSheetNames(fileBytes); + } - // 3. 使用EasyExcel解析(指定监听器) + // ====== 第一阶段:逐页签解析+校验+组装(不落库),失败按页签聚合收集,全部页签处理完统一报错 ====== + List preparedOrders = new ArrayList<>(); + List sheetProblems = new ArrayList<>(); + // 本文件内已使用的模板OrderNO:各页签都未入库时 existsByBusinessOrderNo 查不到彼此,需按页签追加查重防跨页签重复 + Set usedBusinessOrderNos = new HashSet<>(); + for (String name : sheetNames) { + try { + PreparedStockInOrder prepared = buildStockInOrder(fileBytes, name, loginUser, usedBusinessOrderNos); + if (prepared != null) { + preparedOrders.add(prepared); // null=空页签,跳过 + } + } catch (Exception e) { + sheetProblems.add("页签[" + name + "]:" + (e.getMessage() == null ? e.toString() : e.getMessage())); + } + } + if (!sheetProblems.isEmpty()) { + throw new Exception(String.join(";", sheetProblems)); + } + if (preparedOrders.isEmpty()) { + throw new IllegalArgumentException("未解析到产品明细数据,请检查模板中是否填写了產品編號"); + } + + // ====== 第二阶段:全部页签校验通过后统一落库(同一事务,任一失败整体回滚) ====== + List results = new ArrayList<>(); + for (PreparedStockInOrder prepared : preparedOrders) { + insertApplication(prepared.order); + // 入库单号在落库阶段生成(insertDomain),回填到页签结果 + prepared.result.setOrderNumber(prepared.order.getInOrderNumber()); + results.add(prepared.result); + } + return results; + } + + /** 入库单页签解析/校验/组装完成后的暂存容器(统一落库阶段使用) */ + private static class PreparedStockInOrder { + ReservationStockInOrderDO order; + SheetImportResultDTO result; + } + + /** + * 单个页签的入库单解析+校验+组装(不落库,由调用方在全部页签通过后统一插入)。 + * 空页签(未解析到产品明细)返回 null 由调用方跳过。 + */ + private PreparedStockInOrder buildStockInOrder(byte[] fileBytes, String sheetName, LoginUser loginUser, Set usedBusinessOrderNos) throws Exception { + // 3. 使用EasyExcel解析(指定监听器,多页签模式下 sheetName 为实际页签名) StockInExcelListener listener = new StockInExcelListener(); - try (InputStream inputStream = file.getInputStream()) { + try (InputStream inputStream = new ByteArrayInputStream(fileBytes)) { EasyExcel.read(inputStream, listener) - .sheet(targetSheetName) // 指定工作表 + .sheet(sheetName) // 指定工作表 .headRowNumber(0) // 从第1行开始解析(不跳过任何行) .doRead(); } - // 4. 封装DTO返回 + // 4. 封装DTO StockInDataDTO resultDTO = new StockInDataDTO(); resultDTO.setStockInHeader(listener.getStockInHeader()); resultDTO.setProductDetailList(listener.getProductDetailList()); - LOGGER.info("Excel解析成功,工作表:{},产品明细数:{}", - targetSheetName, resultDTO.getProductDetailList().size()); + LOGGER.info("页签[{}]解析成功,产品明细数:{}", sheetName, resultDTO.getProductDetailList().size()); List productDetailList = resultDTO.getProductDetailList(); + // 空页签跳过:该页签未解析到产品明细时视为非数据页签(说明页/空表),不算失败 if (productDetailList == null || productDetailList.isEmpty()) { - throw new ServiceException("未解析到产品明细数据,请检查模板中是否填写了產品編號"); + return null; } StockInHeader stockInHeader = resultDTO.getStockInHeader(); // ====== 第一步:物料编号存在性校验(模板只填物料编号即可,名称按编号自动匹配) ====== @@ -664,6 +813,10 @@ public class ReservationStockInOrderImpl extends ServiceImpl 0) { throw new ServiceException("业务单号已存在:" + templateOrderNo + ",请修改模板中的OrderNO后重新导入"); } + // 多页签查重:各页签都未入库时上面的库查重互相看不见,需对本文件内已使用的OrderNO追加拦截 + if (!usedBusinessOrderNos.add(templateOrderNo)) { + throw new ServiceException("业务单号已存在:" + templateOrderNo + "(与本文件其他页签的OrderNO重复),请修改模板中的OrderNO后重新导入"); + } reservationStockInOrderDO.setBusinessOrderNo(templateOrderNo); } reservationStockInOrderDO.setOrganizationId(loginUser.getUserPo().getOrganizationId()); @@ -740,8 +893,19 @@ public class ReservationStockInOrderImpl extends ServiceImpl result = new HashMap<>(); try { - DeliveryDataDTO deliveryDataDTO = reservationStockInOrderService.parseExcelFile(file, "", warehouseId); - result.put("result", deliveryDataDTO); + // 多页签导入:Excel中每个工作表独立生成一张出库单,任一页签校验失败整体不落库 + List results = reservationStockInOrderService.parseExcelFile(file, "", warehouseId); + // result 保留第一个页签的解析数据(兼容老前端);results 为全部页签结果 + result.put("result", results.isEmpty() ? null : results.get(0).getDeliveryData()); + result.put("results", results); result.put("code", 200); - result.put("msg", "解析成功"); + String orderNumbers = results.stream() + .map(SheetImportResultDTO::getOrderNumber) + .collect(java.util.stream.Collectors.joining("、")); + result.put("msg", "成功导入" + results.size() + "张出库单:" + orderNumbers); return new ResponseEntity<>(result, HttpStatus.OK); } catch (IllegalArgumentException e) { result.put("code", 400); @@ -237,18 +244,23 @@ public class ReservationStockInOrderApi extends BaseController { } /** - * 接口1:仅解析Excel文件(返回解析结果,不保存) - * 用途:预览Excel解析数据 + * 接口1:解析并导入入库单Excel(多页签:不传sheetName时每个工作表独立生成一张入库单,任一页签校验失败整体不落库) */ @PostMapping("/parse-excel") public ResponseEntity> parseStockInExcel( @RequestParam("file") MultipartFile file, @RequestParam(value = "sheetName", required = false) String sheetName) { try { - StockInDataDTO resultDTO = reservationStockInOrderService.parseStockInExcel(file, sheetName); + List results = reservationStockInOrderService.parseStockInExcel(file, sheetName); Map result = new HashMap<>(); - result.put("result", resultDTO); - result.put("code",200); + // result 保留第一个页签的解析数据(兼容老前端);results 为全部页签结果 + result.put("result", results.isEmpty() ? null : results.get(0).getStockInData()); + result.put("results", results); + result.put("code", 200); + String orderNumbers = results.stream() + .map(SheetImportResultDTO::getOrderNumber) + .collect(java.util.stream.Collectors.joining("、")); + result.put("msg", "成功导入" + results.size() + "张入库单:" + orderNumbers); return new ResponseEntity<>(result, HttpStatus.OK); } catch (IllegalArgumentException e) { // 参数错误(如文件为空)