package cn.iocoder.yudao.module.erp.service.purchase; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.number.MoneyUtils; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi; import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO; import cn.iocoder.yudao.module.bpm.enums.task.BpmTaskStatusEnum; import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.request.ErpPurchaseRequestImportExcelVO; import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.request.ErpPurchaseRequestImportReqVO; import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.request.ErpPurchaseRequestImportRespVO; import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.request.ErpPurchaseRequestPageReqVO; import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.request.ErpPurchaseRequestSaveReqVO; import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseOrderDO; import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseOrderItemDO; import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseRequestDO; import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchaseRequestItemDO; import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpSupplierDO; import cn.iocoder.yudao.module.erp.dal.mysql.purchase.ErpPurchaseOrderItemMapper; import cn.iocoder.yudao.module.erp.dal.mysql.purchase.ErpPurchaseOrderMapper; import cn.iocoder.yudao.module.erp.dal.mysql.purchase.ErpPurchaseRequestItemMapper; import cn.iocoder.yudao.module.erp.dal.mysql.purchase.ErpPurchaseRequestMapper; import cn.iocoder.yudao.module.erp.dal.mysql.purchase.ErpSupplierMapper; import cn.iocoder.yudao.module.erp.dal.redis.no.ErpNoRedisDAO; import cn.iocoder.yudao.module.erp.enums.ErpPurchaseOrderStatusEnum; import cn.iocoder.yudao.module.erp.enums.ErpPurchaseRequestStatusEnum; import cn.iocoder.yudao.module.mdm.api.item.MdmItemApi; import cn.iocoder.yudao.module.mdm.api.item.dto.MdmItemRespDTO; import cn.iocoder.yudao.module.mdm.api.unit.MdmUnitMeasureApi; import cn.iocoder.yudao.module.mdm.api.unit.dto.MdmUnitMeasureRespDTO; import cn.iocoder.yudao.module.system.api.approval.ApprovalConfigApi; import cn.iocoder.yudao.module.system.api.dept.DeptApi; import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi; import cn.iocoder.yudao.module.system.api.user.AdminUserApi; import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; import java.math.BigDecimal; import java.util.*; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; import static cn.iocoder.yudao.module.erp.enums.ErrorCodeConstants.*; /** * ERP 采购申请 Service 实现类 */ @Service @Validated @Slf4j public class ErpPurchaseRequestServiceImpl implements ErpPurchaseRequestService { /** * 通用审批配置的业务类型编码(对应 system_approval_config.biz_type) */ private static final String PURCHASE_REQUEST_APPROVE_BIZ_TYPE = "purchase_request_approve"; /** * 采购申请审批的 BPM 流程定义 Key */ private static final String PURCHASE_REQUEST_PROCESS_KEY = "purchase_request_approve"; @Resource private ErpPurchaseRequestMapper purchaseRequestMapper; @Resource private ErpPurchaseRequestItemMapper purchaseRequestItemMapper; @Resource private ErpPurchaseOrderMapper purchaseOrderMapper; @Resource private ErpPurchaseOrderItemMapper purchaseOrderItemMapper; @Resource private ErpNoRedisDAO noRedisDAO; @Resource private ErpSupplierService supplierService; @Resource private ErpSupplierMapper supplierMapper; @Resource private MdmItemApi mdmItemApi; @Resource private MdmUnitMeasureApi mdmUnitMeasureApi; @Resource private ApprovalConfigApi approvalConfigApi; @Resource private BpmProcessInstanceApi bpmProcessInstanceApi; @Resource private AdminUserApi adminUserApi; @Resource private DeptApi deptApi; @Resource private StorageAttachmentApi storageAttachmentApi; @Override @Transactional(rollbackFor = Exception.class) public Long createPurchaseRequest(ErpPurchaseRequestSaveReqVO createReqVO) { // 1. 校验订单项的有效性 List requestItems = validatePurchaseRequestItems(createReqVO.getItems()); // 2. 校验供应商(如果指定) if (createReqVO.getSupplierId() != null) { supplierService.validateSupplier(createReqVO.getSupplierId()); } // 3. 生成申请单号 String no = noRedisDAO.generate(ErpNoRedisDAO.PURCHASE_REQUEST_NO_PREFIX); if (purchaseRequestMapper.selectByNo(no) != null) { throw exception(PURCHASE_REQUEST_NO_EXISTS); } // 4. 获取当前用户信息 Long userId = Long.parseLong(getCurrentUserId()); AdminUserRespDTO user = adminUserApi.getUser(userId); Long deptId = user != null ? user.getDeptId() : null; // 5. 插入申请单 ErpPurchaseRequestDO purchaseRequest = BeanUtils.toBean(createReqVO, ErpPurchaseRequestDO.class, req -> req .setNo(no) .setStatus(ErpPurchaseRequestStatusEnum.DRAFT.getStatus()) .setRequestUserId(userId) .setRequestDeptId(deptId)); // 兼容旧客户端未传定金的情况,数据库与业务层统一按 0 处理 if (purchaseRequest.getDepositPrice() == null) { purchaseRequest.setDepositPrice(BigDecimal.ZERO); } calculateTotalPrice(purchaseRequest, requestItems); purchaseRequestMapper.insert(purchaseRequest); // 6. 绑定附件 storageAttachmentApi.bindAttachments("file", "erp_purchase_request", purchaseRequest.getId(), createReqVO.getBlobIds()); // 7. 插入申请明细 requestItems.forEach(o -> o.setRequestId(purchaseRequest.getId())); purchaseRequestItemMapper.insertBatch(requestItems); return purchaseRequest.getId(); } @Override @Transactional(rollbackFor = Exception.class) public void updatePurchaseRequest(ErpPurchaseRequestSaveReqVO updateReqVO) { // 1. 校验存在 + 草稿状态 ErpPurchaseRequestDO purchaseRequest = validatePurchaseRequestCanModify(updateReqVO.getId()); // 2. 校验供应商(如果指定) if (updateReqVO.getSupplierId() != null) { supplierService.validateSupplier(updateReqVO.getSupplierId()); } // 3. 校验订单项的有效性 List requestItems = validatePurchaseRequestItems(updateReqVO.getItems()); // 4. 更新申请单 ErpPurchaseRequestDO updateObj = BeanUtils.toBean(updateReqVO, ErpPurchaseRequestDO.class); if (updateObj.getDepositPrice() == null) { updateObj.setDepositPrice(BigDecimal.ZERO); } calculateTotalPrice(updateObj, requestItems); purchaseRequestMapper.updateById(updateObj); // 5. 更新申请明细 updatePurchaseRequestItemList(updateReqVO.getId(), requestItems); // 6. 更新附件 storageAttachmentApi.updateAttachments("file", "erp_purchase_request", updateReqVO.getId(), updateReqVO.getBlobIds()); } private void calculateTotalPrice(ErpPurchaseRequestDO purchaseRequest, List requestItems) { purchaseRequest.setTotalCount(getSumValue(requestItems, ErpPurchaseRequestItemDO::getCount, BigDecimal::add)); purchaseRequest.setTotalProductPrice(getSumValue(requestItems, ErpPurchaseRequestItemDO::getTotalPrice, BigDecimal::add, BigDecimal.ZERO)); purchaseRequest.setTotalTaxPrice(getSumValue(requestItems, ErpPurchaseRequestItemDO::getTaxPrice, BigDecimal::add, BigDecimal.ZERO)); purchaseRequest.setTotalPrice(purchaseRequest.getTotalProductPrice().add(purchaseRequest.getTotalTaxPrice())); // 计算优惠价格 if (purchaseRequest.getDiscountPercent() == null) { purchaseRequest.setDiscountPercent(BigDecimal.ZERO); } purchaseRequest.setDiscountPrice(MoneyUtils.priceMultiplyPercent(purchaseRequest.getTotalPrice(), purchaseRequest.getDiscountPercent())); purchaseRequest.setTotalPrice(purchaseRequest.getTotalPrice().subtract(purchaseRequest.getDiscountPrice())); } private List validatePurchaseRequestItems(List list) { return convertList(list, o -> BeanUtils.toBean(o, ErpPurchaseRequestItemDO.class, item -> { if (item.getProductPrice() != null && item.getCount() != null) { item.setTotalPrice(MoneyUtils.priceMultiply(item.getProductPrice(), item.getCount())); if (item.getTaxPercent() != null) { item.setTaxPrice(MoneyUtils.priceMultiplyPercent(item.getTotalPrice(), item.getTaxPercent())); } } })); } private void updatePurchaseRequestItemList(Long id, List newList) { List oldList = purchaseRequestItemMapper.selectListByRequestId(id); List> diffList = diffList(oldList, newList, (oldVal, newVal) -> oldVal.getId().equals(newVal.getId())); if (CollUtil.isNotEmpty(diffList.get(0))) { diffList.get(0).forEach(o -> o.setRequestId(id)); purchaseRequestItemMapper.insertBatch(diffList.get(0)); } if (CollUtil.isNotEmpty(diffList.get(1))) { purchaseRequestItemMapper.updateBatch(diffList.get(1)); } if (CollUtil.isNotEmpty(diffList.get(2))) { purchaseRequestItemMapper.deleteByIds(convertList(diffList.get(2), ErpPurchaseRequestItemDO::getId)); } } @Override @Transactional(rollbackFor = Exception.class) public void deletePurchaseRequest(List ids) { List purchaseRequests = purchaseRequestMapper.selectRequestListByIds(ids); if (CollUtil.isEmpty(purchaseRequests)) { return; } purchaseRequests.forEach(purchaseRequest -> { Integer status = purchaseRequest.getStatus(); if (!ErpPurchaseRequestStatusEnum.DRAFT.getStatus().equals(status) && !ErpPurchaseRequestStatusEnum.REJECT.getStatus().equals(status)) { throw exception(PURCHASE_REQUEST_DELETE_FAIL_NOT_DRAFT, purchaseRequest.getNo()); } }); purchaseRequests.forEach(purchaseRequest -> { storageAttachmentApi.deleteAttachmentsByRecord("erp_purchase_request", purchaseRequest.getId()); purchaseRequestMapper.deleteById(purchaseRequest.getId()); purchaseRequestItemMapper.deleteByRequestId(purchaseRequest.getId()); }); } @Override public ErpPurchaseRequestDO getPurchaseRequest(Long id) { return purchaseRequestMapper.selectById(id); } @Override public PageResult getPurchaseRequestPage(ErpPurchaseRequestPageReqVO pageReqVO) { return purchaseRequestMapper.selectPage(pageReqVO); } @Override public List getPurchaseRequestItemListByRequestId(Long requestId) { return purchaseRequestItemMapper.selectListByRequestId(requestId); } @Override public List getPurchaseRequestItemListByRequestIds(Collection requestIds) { if (CollUtil.isEmpty(requestIds)) { return Collections.emptyList(); } return purchaseRequestItemMapper.selectListByRequestIds(requestIds); } @Override public List getPurchaseRequestList(List ids) { if (CollUtil.isEmpty(ids)) { return Collections.emptyList(); } return purchaseRequestMapper.selectRequestListByIds(ids); } @Override @Transactional(rollbackFor = Exception.class) public void submitPurchaseRequest(Long id) { // 1. 校验存在 + 可提交状态(未提交 / 审核不通过) ErpPurchaseRequestDO purchaseRequest = validatePurchaseRequestExists(id); if (!ErpPurchaseRequestStatusEnum.DRAFT.getStatus().equals(purchaseRequest.getStatus()) && !ErpPurchaseRequestStatusEnum.REJECT.getStatus().equals(purchaseRequest.getStatus())) { throw exception(PURCHASE_REQUEST_SUBMIT_FAIL_NOT_ALLOWED); } // 2. 校验是否有明细 List items = purchaseRequestItemMapper.selectListByRequestId(id); if (CollUtil.isEmpty(items)) { throw exception(PURCHASE_REQUEST_ITEM_EMPTY); } // 3. 校验审批配置已启用且配置了有效审批人 approvalConfigApi.validateApprovalEnabledAndGetApprovers(PURCHASE_REQUEST_APPROVE_BIZ_TYPE); // 4. 状态置为审批中,并清空上一轮审核结果 if (purchaseRequestMapper.submitAndResetAuditInfo(id, ErpPurchaseRequestStatusEnum.PROCESS.getStatus()) != 1) { throw exception(PURCHASE_REQUEST_SUBMIT_FAIL_NOT_ALLOWED); } // 5. 创建 BPM 流程实例,使审批出现在「协同办公 - 我的流程」 bpmProcessInstanceApi.createProcessInstance(getLoginUserId(), new BpmProcessInstanceCreateReqDTO() .setProcessDefinitionKey(PURCHASE_REQUEST_PROCESS_KEY) .setBusinessKey(String.valueOf(id))); } @Override public Set getPurchaseRequestApproverUserIds() { return approvalConfigApi.getApproverUserIds(PURCHASE_REQUEST_APPROVE_BIZ_TYPE); } @Override @Transactional(rollbackFor = Exception.class) public void auditPurchaseRequest(Long id, Boolean pass, String reviewRemark) { // 1. 校验存在 ErpPurchaseRequestDO purchaseRequest = validatePurchaseRequestExists(id); // 2. 校验处于审批中状态 if (!ErpPurchaseRequestStatusEnum.PROCESS.getStatus().equals(purchaseRequest.getStatus())) { throw exception(PURCHASE_REQUEST_AUDIT_FAIL_NOT_PROCESS); } // 3. 校验当前登录用户是该业务类型的审批人(或签:任一人均可审核) Long userId = getLoginUserId(); approvalConfigApi.validateApprover(PURCHASE_REQUEST_APPROVE_BIZ_TYPE, userId); // 4. 审核不通过:必须填写原因,退回申请人修改后可重新提交 if (!Boolean.TRUE.equals(pass)) { if (StrUtil.isBlank(reviewRemark)) { throw exception(PURCHASE_REQUEST_AUDIT_REJECT_REASON_REQUIRED); } if (purchaseRequestMapper.auditPurchaseRequest(id, ErpPurchaseRequestStatusEnum.REJECT.getStatus(), userId, getUserNickname(userId), reviewRemark) != 1) { throw exception(PURCHASE_REQUEST_AUDIT_FAIL_NOT_PROCESS); } return; } // 5. 审核通过:记录审核人与审核意见 if (purchaseRequestMapper.auditPurchaseRequest(id, ErpPurchaseRequestStatusEnum.APPROVE.getStatus(), userId, getUserNickname(userId), reviewRemark) != 1) { throw exception(PURCHASE_REQUEST_AUDIT_FAIL_NOT_PROCESS); } // 6. 审核通过且有供应商、未生成订单时,自动生成采购订单 if (purchaseRequest.getSupplierId() != null && purchaseRequest.getOrderId() == null) { try { Long orderId = doGeneratePurchaseOrder(purchaseRequest); log.info("[auditPurchaseRequest] 采购申请({}) 自动生成采购订单({})", id, orderId); } catch (Exception e) { // 不抛出异常,避免影响审核结果落库;生成失败可由用户手动触发 log.error("[auditPurchaseRequest] 采购申请({}) 自动生成采购订单失败", id, e); } } } /** * 获取用户昵称 * * @param userId 用户编号 * @return 昵称,用户不存在时返回 null */ private String getUserNickname(Long userId) { if (userId == null) { return null; } AdminUserRespDTO user = adminUserApi.getUser(userId); return user == null ? null : user.getNickname(); } @Override @Transactional(rollbackFor = Exception.class) public void updatePurchaseRequestAuditStatus(Long id, Integer bpmStatus, String reason) { // 1. BPM 审批结果映射为采购申请状态:通过=20 / 不通过=30 / 取消=40 Integer newStatus = convertBpmResultToStatus(bpmStatus); if (newStatus == null) { return; } // 2. 仅当仍处于「审批中」时回写,避免重复覆盖 if (purchaseRequestMapper.updateAuditStatusByBpm(id, newStatus, reason) != 1) { log.warn("[updatePurchaseRequestAuditStatus] 采购申请({}) 不处于审批中状态,忽略 BPM 结果({})", id, bpmStatus); return; } // 3. 审批通过且有供应商、未生成订单时,自动生成采购订单 if (!ErpPurchaseRequestStatusEnum.APPROVE.getStatus().equals(newStatus)) { return; } ErpPurchaseRequestDO purchaseRequest = purchaseRequestMapper.selectById(id); if (purchaseRequest == null || purchaseRequest.getSupplierId() == null || purchaseRequest.getOrderId() != null) { return; } try { Long orderId = doGeneratePurchaseOrder(purchaseRequest); log.info("[updatePurchaseRequestAuditStatus] 采购申请({}) 自动生成采购订单({})", id, orderId); } catch (Exception e) { // 不抛出异常,避免影响审批结果落库;生成失败可由用户手动触发 log.error("[updatePurchaseRequestAuditStatus] 采购申请({}) 自动生成采购订单失败", id, e); } } /** * 转换 BPM 审批结果为采购申请状态 */ private Integer convertBpmResultToStatus(Integer bpmStatus) { if (BpmTaskStatusEnum.APPROVE.getStatus().equals(bpmStatus)) { return ErpPurchaseRequestStatusEnum.APPROVE.getStatus(); } if (BpmTaskStatusEnum.REJECT.getStatus().equals(bpmStatus)) { return ErpPurchaseRequestStatusEnum.REJECT.getStatus(); } if (BpmTaskStatusEnum.CANCEL.getStatus().equals(bpmStatus)) { return ErpPurchaseRequestStatusEnum.CANCEL.getStatus(); } return null; } @Override @Transactional(rollbackFor = Exception.class) public Long generatePurchaseOrder(Long id, Long supplierId) { // 1. 校验存在 + 审核通过状态 ErpPurchaseRequestDO purchaseRequest = validatePurchaseRequestExists(id); if (!ErpPurchaseRequestStatusEnum.APPROVE.getStatus().equals(purchaseRequest.getStatus())) { throw exception(PURCHASE_REQUEST_NOT_APPROVE); } // 2. 校验是否已生成订单 if (purchaseRequest.getOrderId() != null) { throw exception(PURCHASE_REQUEST_ORDER_EXISTS); } // 3. 校验供应商 supplierService.validateSupplier(supplierId); // 4. 设置供应商并生成订单 purchaseRequest.setSupplierId(supplierId); return doGeneratePurchaseOrder(purchaseRequest); } /** * 执行生成采购订单 * * @param purchaseRequest 采购申请(需包含 supplierId) * @return 采购订单 ID */ private Long doGeneratePurchaseOrder(ErpPurchaseRequestDO purchaseRequest) { Long id = purchaseRequest.getId(); // 1. 获取申请明细 List requestItems = purchaseRequestItemMapper.selectListByRequestId(id); if (CollUtil.isEmpty(requestItems)) { throw exception(PURCHASE_REQUEST_NOT_EXISTS); } requestItems.forEach(item -> { if (item.getProductPrice() == null || item.getTotalPrice() == null || item.getTaxPrice() == null) { throw exception(PURCHASE_REQUEST_ITEM_PRICE_EMPTY, item.getId()); } }); // 2. 生成采购订单号 String orderNo = noRedisDAO.generate(ErpNoRedisDAO.PURCHASE_ORDER_NO_PREFIX); if (purchaseOrderMapper.selectByNo(orderNo) != null) { throw exception(PURCHASE_ORDER_NO_EXISTS); } // 3. 创建采购订单 ErpPurchaseOrderDO purchaseOrder = new ErpPurchaseOrderDO(); purchaseOrder.setNo(orderNo); // 自动生成的采购订单仍为草稿,必须走订单自身的提交/审批流程,避免绕过审批 purchaseOrder.setStatus(ErpPurchaseOrderStatusEnum.DRAFT.getStatus()); purchaseOrder.setSupplierId(purchaseRequest.getSupplierId()); purchaseOrder.setOrderTime(purchaseRequest.getRequestTime()); purchaseOrder.setTotalCount(purchaseRequest.getTotalCount()); purchaseOrder.setTotalPrice(purchaseRequest.getTotalPrice()); purchaseOrder.setTotalProductPrice(purchaseRequest.getTotalProductPrice()); purchaseOrder.setTotalTaxPrice(purchaseRequest.getTotalTaxPrice()); purchaseOrder.setDiscountPercent(purchaseRequest.getDiscountPercent()); purchaseOrder.setDiscountPrice(purchaseRequest.getDiscountPrice()); purchaseOrder.setDepositPrice(purchaseRequest.getDepositPrice() != null ? purchaseRequest.getDepositPrice() : BigDecimal.ZERO); purchaseOrder.setRemark(purchaseRequest.getRemark()); purchaseOrder.setInCount(BigDecimal.ZERO); purchaseOrder.setInStatus(0); purchaseOrderMapper.insert(purchaseOrder); // 4. 创建采购订单明细 List orderItems = convertList(requestItems, item -> { ErpPurchaseOrderItemDO orderItem = new ErpPurchaseOrderItemDO(); orderItem.setOrderId(purchaseOrder.getId()); orderItem.setRequestItemId(item.getId()); // 关联采购申请明细 orderItem.setProductId(item.getProductId()); orderItem.setProductUnitId(item.getProductUnitId()); orderItem.setProductPrice(item.getProductPrice()); orderItem.setCount(item.getCount()); orderItem.setTotalPrice(item.getTotalPrice()); orderItem.setTaxPercent(item.getTaxPercent()); orderItem.setTaxPrice(item.getTaxPrice()); orderItem.setRemark(item.getRemark()); orderItem.setInCount(BigDecimal.ZERO); orderItem.setQcCheckFlag(item.getQcCheckFlag() != null ? item.getQcCheckFlag() : false); return orderItem; }); purchaseOrderItemMapper.insertBatch(orderItems); // 5. 更新采购申请的订单信息,并回写最终使用的供应商 purchaseRequestMapper.updateById(new ErpPurchaseRequestDO() .setId(id) .setSupplierId(purchaseRequest.getSupplierId()) .setOrderId(purchaseOrder.getId()) .setOrderNo(orderNo)); return purchaseOrder.getId(); } private ErpPurchaseRequestDO validatePurchaseRequestExists(Long id) { ErpPurchaseRequestDO purchaseRequest = purchaseRequestMapper.selectById(id); if (purchaseRequest == null) { throw exception(PURCHASE_REQUEST_NOT_EXISTS); } return purchaseRequest; } private ErpPurchaseRequestDO validatePurchaseRequestCanModify(Long id) { ErpPurchaseRequestDO purchaseRequest = validatePurchaseRequestExists(id); Integer status = purchaseRequest.getStatus(); if (!ErpPurchaseRequestStatusEnum.DRAFT.getStatus().equals(status) && !ErpPurchaseRequestStatusEnum.REJECT.getStatus().equals(status)) { throw exception(PURCHASE_REQUEST_UPDATE_FAIL_NOT_DRAFT, purchaseRequest.getNo()); } return purchaseRequest; } private String getCurrentUserId() { return cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId().toString(); } @Override public ErpPurchaseRequestImportRespVO importPurchaseRequestList(List importRequests, ErpPurchaseRequestImportReqVO importReqVO) { if (Boolean.TRUE.equals(importReqVO.getUpdateSupport())) throw exception(PURCHASE_REQUEST_IMPORT_INVALID, "仅支持新增,不支持覆盖"); if (CollUtil.isEmpty(importRequests)) { throw exception(PURCHASE_REQUEST_IMPORT_LIST_IS_EMPTY); } // 1. 逐行分类:有效行 / 被跳过的行。 // 这里不再用 filterList 静默丢弃「物料编码为空」的行 —— 那会让用户看到「导入成功」 // 但数据无声消失。被跳过的行必须带 Excel 原始行号返回给前端。 ErpPurchaseRequestImportRespVO respVO = ErpPurchaseRequestImportRespVO.builder() .createPurchaseRequestNos(new ArrayList<>()) .failurePurchaseRequestNos(new LinkedHashMap<>()) .skippedRows(new LinkedHashMap<>()) .build(); List validRows = new ArrayList<>(); for (int i = 0; i < importRequests.size(); i++) { ErpPurchaseRequestImportExcelVO row = importRequests.get(i); int excelRowNo = i + 2; // +1 转 1 基,+1 跳过表头 String rowLabel = "第" + excelRowNo + "行"; // 1.1 完全空白的行(所有列都没有值):Excel 常在末尾留空行,静默跳过即可 if (isBlankRow(row)) { continue; } validRows.add(new ImportRow(excelRowNo, row)); } respVO.setTotalRows(validRows.size() + respVO.getSkippedRows().size()); if (CollUtil.isEmpty(validRows)) { // 没有任何一行可导入:直接报错。 // 若是有行因「物料编码为空」被跳过,异常信息里带上行号,帮助用户定位。 if (CollUtil.isNotEmpty(respVO.getSkippedRows())) { throw exception(PURCHASE_REQUEST_IMPORT_ALL_ROWS_SKIPPED, String.join("、", respVO.getSkippedRows().keySet())); } throw exception(PURCHASE_REQUEST_IMPORT_LIST_IS_EMPTY); } // 2. 按「单号」分组:相同单号的多行合并成一张采购申请;单号为空时每行各自成一张申请 List> groups = new ArrayList<>(); Map groupIndexMap = new LinkedHashMap<>(); for (ImportRow row : validRows) { String no = row.data.getNo(); if (StrUtil.isBlank(no)) { groups.add(new ArrayList<>(Collections.singletonList(row))); continue; } String groupKey = no.trim(); Integer index = groupIndexMap.get(groupKey); if (index == null) { index = groups.size(); groupIndexMap.put(groupKey, index); groups.add(new ArrayList<>()); } groups.get(index).add(row); } // 3. 逐组合并创建采购申请 for (List group : groups) { ImportRow firstRow = group.get(0); // 失败/跳过的标识优先用单号,没有单号则用 Excel 里的真实行号 String groupKey = StrUtil.isNotBlank(firstRow.data.getNo()) ? firstRow.data.getNo() : "第" + firstRow.excelRowNo + "行"; try { Long requestId = cn.hutool.extra.spring.SpringUtil.getBean(ErpPurchaseRequestServiceImpl.class) .createImportedPurchaseRequest(convertList(group, ImportRow::getData)); respVO.getCreatePurchaseRequestNos().add(purchaseRequestMapper.selectById(requestId).getNo()); respVO.setSuccessRows(respVO.getSuccessRows() == null ? group.size() : respVO.getSuccessRows() + group.size()); } catch (ServiceException ex) { // 业务校验失败:记录原因,继续处理下一组 respVO.getFailurePurchaseRequestNos().put(groupKey, ex.getMessage()); } catch (Exception ex) { // 非业务异常(如远程调用 NPE、数据解析失败):不能让整个导入 500, // 记录为失败并继续,保证其它能导的行正常入库 log.error("[importPurchaseRequestList] 导入行({}) 发生非业务异常", groupKey, ex); respVO.getFailurePurchaseRequestNos().put(groupKey, "导入失败,请检查基础资料和申请明细"); } } return respVO; } /** * 判断一行是否完全空白(所有业务列都没有值) */ private boolean isBlankRow(ErpPurchaseRequestImportExcelVO row) { return row == null || StrUtil.isBlank(row.getNo()) && StrUtil.isBlank(row.getProductCode()) && StrUtil.isBlank(row.getProductUnitCode()) && row.getRequestTime() == null && row.getCount() == null && row.getProductPrice() == null && row.getDiscountPercent() == null && row.getDepositPrice() == null && row.getTaxPercent() == null && row.getDemandTime() == null && row.getQcCheckFlag() == null && StrUtil.isBlank(row.getSupplierName()) && StrUtil.isBlank(row.getRequestReason()) && StrUtil.isBlank(row.getRemark()) && StrUtil.isBlank(row.getItemRemark()); } /** * 导入行包装:携带 Excel 中的真实行号,用于错误提示定位。 */ private static class ImportRow { private final int excelRowNo; private final ErpPurchaseRequestImportExcelVO data; private ImportRow(int excelRowNo, ErpPurchaseRequestImportExcelVO data) { this.excelRowNo = excelRowNo; this.data = data; } private ErpPurchaseRequestImportExcelVO getData() { return data; } } /** * 将同一张采购申请的多行明细转换成创建 Request VO */ @Transactional(rollbackFor = Exception.class) public Long createImportedPurchaseRequest(List group) { return createPurchaseRequest(buildPurchaseRequestImportSaveReqVO(group)); } private ErpPurchaseRequestSaveReqVO buildPurchaseRequestImportSaveReqVO(List group) { ErpPurchaseRequestImportExcelVO first = group.get(0); // 1. 校验申请时间 if (first.getRequestTime() == null) { throw exception(PURCHASE_REQUEST_IMPORT_REQUEST_TIME_REQUIRED); } if (first.getDiscountPercent() != null && (first.getDiscountPercent().signum() < 0 || first.getDiscountPercent().compareTo(new BigDecimal("100")) > 0) || first.getDepositPrice() != null && first.getDepositPrice().signum() < 0) { throw exception(PURCHASE_REQUEST_IMPORT_INVALID, "优惠率须在 0 至 100 之间,定金不能为负数"); } for (ErpPurchaseRequestImportExcelVO row : group) { if (row.getRequestTime() != null && !row.getRequestTime().equals(first.getRequestTime()) || StrUtil.isNotBlank(row.getSupplierName()) && !StrUtil.equals(first.getSupplierName(), row.getSupplierName()) || StrUtil.isNotBlank(row.getRequestReason()) && !StrUtil.equals(first.getRequestReason(), row.getRequestReason()) || row.getDiscountPercent() != null && (first.getDiscountPercent() == null || row.getDiscountPercent().compareTo(first.getDiscountPercent()) != 0) || row.getDepositPrice() != null && (first.getDepositPrice() == null || row.getDepositPrice().compareTo(first.getDepositPrice()) != 0) || StrUtil.isNotBlank(row.getRemark()) && !StrUtil.equals(first.getRemark(), row.getRemark())) { throw exception(PURCHASE_REQUEST_IMPORT_INVALID, "同一申请单的主表信息不一致"); } if (StrUtil.isBlank(row.getProductCode()) || StrUtil.isBlank(row.getProductUnitCode()) || row.getCount() == null || row.getCount().signum() <= 0 || row.getProductPrice() == null || row.getProductPrice().signum() < 0 || row.getTaxPercent() != null && (row.getTaxPercent().signum() < 0 || row.getTaxPercent().compareTo(new BigDecimal("100")) > 0)) { throw exception(PURCHASE_REQUEST_IMPORT_INVALID, "每行须填写物料、单位、正数数量、非负单价;税率须在 0 至 100 之间"); } } // 2. 解析供应商(可选) Long supplierId = null; if (StrUtil.isNotBlank(first.getSupplierName())) { ErpSupplierDO supplier = supplierMapper.selectByName(first.getSupplierName()); if (supplier == null) { throw exception(PURCHASE_REQUEST_IMPORT_SUPPLIER_NOT_EXISTS, first.getSupplierName()); } supplierId = supplier.getId(); } // 3. 组装明细 List items = new ArrayList<>(); for (ErpPurchaseRequestImportExcelVO row : group) { MdmItemRespDTO product = mdmItemApi.getItemByCode(row.getProductCode().trim()).getCheckedData(); if (product == null) { throw exception(PURCHASE_REQUEST_IMPORT_PRODUCT_NOT_EXISTS, row.getProductCode()); } MdmUnitMeasureRespDTO unit = mdmUnitMeasureApi.getUnitMeasureByCode(row.getProductUnitCode().trim()).getCheckedData(); if (unit == null) { throw exception(PURCHASE_REQUEST_IMPORT_UNIT_NOT_EXISTS, row.getProductUnitCode()); } if (product.getStatus() != null && product.getStatus() != 0 || unit.getStatus() != null && unit.getStatus() != 0) { throw exception(PURCHASE_REQUEST_IMPORT_INVALID, "物料或计量单位已停用"); } ErpPurchaseRequestSaveReqVO.Item item = new ErpPurchaseRequestSaveReqVO.Item() .setProductId(product.getId()) .setProductUnitId(unit.getId()) .setProductPrice(row.getProductPrice()) .setCount(row.getCount()) .setTaxPercent(row.getTaxPercent()) .setDemandTime(row.getDemandTime()) .setQcCheckFlag(row.getQcCheckFlag()) .setRemark(row.getItemRemark()); items.add(item); } // 4. 组装主表 return new ErpPurchaseRequestSaveReqVO() .setRequestTime(first.getRequestTime()) .setRequestReason(first.getRequestReason()) .setSupplierId(supplierId) .setDiscountPercent(first.getDiscountPercent()) .setDepositPrice(first.getDepositPrice()) .setRemark(first.getRemark()) .setItems(items); } }