yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseRequestServiceImpl.java
@@ -2,22 +2,35 @@
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.ErpAuditStatus;
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;
@@ -50,6 +63,11 @@
     */
    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
@@ -67,7 +85,18 @@
    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;
@@ -102,6 +131,10 @@
                .setStatus(ErpPurchaseRequestStatusEnum.DRAFT.getStatus())
                .setRequestUserId(userId)
                .setRequestDeptId(deptId));
        // 兼容旧客户端未传定金的情况,数据库与业务层统一按 0 处理
        if (purchaseRequest.getDepositPrice() == null) {
            purchaseRequest.setDepositPrice(BigDecimal.ZERO);
        }
        calculateTotalPrice(purchaseRequest, requestItems);
        purchaseRequestMapper.insert(purchaseRequest);
@@ -130,6 +163,9 @@
        // 4. 更新申请单
        ErpPurchaseRequestDO updateObj = BeanUtils.toBean(updateReqVO, ErpPurchaseRequestDO.class);
        if (updateObj.getDepositPrice() == null) {
            updateObj.setDepositPrice(BigDecimal.ZERO);
        }
        calculateTotalPrice(updateObj, requestItems);
        purchaseRequestMapper.updateById(updateObj);
@@ -251,7 +287,14 @@
        // 3. 校验审批配置已启用且配置了有效审批人
        approvalConfigApi.validateApprovalEnabledAndGetApprovers(PURCHASE_REQUEST_APPROVE_BIZ_TYPE);
        // 4. 状态置为审批中,并清空上一轮审核结果
        purchaseRequestMapper.submitAndResetAuditInfo(id, ErpPurchaseRequestStatusEnum.PROCESS.getStatus());
        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
@@ -276,13 +319,17 @@
            if (StrUtil.isBlank(reviewRemark)) {
                throw exception(PURCHASE_REQUEST_AUDIT_REJECT_REASON_REQUIRED);
            }
            purchaseRequestMapper.auditPurchaseRequest(id, ErpPurchaseRequestStatusEnum.REJECT.getStatus(),
                    userId, getUserNickname(userId), reviewRemark);
            if (purchaseRequestMapper.auditPurchaseRequest(id, ErpPurchaseRequestStatusEnum.REJECT.getStatus(),
                    userId, getUserNickname(userId), reviewRemark) != 1) {
                throw exception(PURCHASE_REQUEST_AUDIT_FAIL_NOT_PROCESS);
            }
            return;
        }
        // 5. 审核通过:记录审核人与审核意见
        purchaseRequestMapper.auditPurchaseRequest(id, ErpPurchaseRequestStatusEnum.APPROVE.getStatus(),
                userId, getUserNickname(userId), reviewRemark);
        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 {
@@ -307,6 +354,53 @@
        }
        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
@@ -358,7 +452,8 @@
        // 3. 创建采购订单
        ErpPurchaseOrderDO purchaseOrder = new ErpPurchaseOrderDO();
        purchaseOrder.setNo(orderNo);
        purchaseOrder.setStatus(ErpAuditStatus.APPROVE.getStatus()); // 采购订单直接为已审核状态
        // 自动生成的采购订单仍为草稿,必须走订单自身的提交/审批流程,避免绕过审批
        purchaseOrder.setStatus(ErpPurchaseOrderStatusEnum.DRAFT.getStatus());
        purchaseOrder.setSupplierId(purchaseRequest.getSupplierId());
        purchaseOrder.setOrderTime(purchaseRequest.getRequestTime());
        purchaseOrder.setTotalCount(purchaseRequest.getTotalCount());
@@ -392,9 +487,10 @@
        });
        purchaseOrderItemMapper.insertBatch(orderItems);
        // 5. 更新采购申请的订单信息
        // 5. 更新采购申请的订单信息,并回写最终使用的供应商
        purchaseRequestMapper.updateById(new ErpPurchaseRequestDO()
                .setId(id)
                .setSupplierId(purchaseRequest.getSupplierId())
                .setOrderId(purchaseOrder.getId())
                .setOrderNo(orderNo));
@@ -423,4 +519,172 @@
        return cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId().toString();
    }
    @Override
    public ErpPurchaseRequestImportRespVO importPurchaseRequestList(List<ErpPurchaseRequestImportExcelVO> importRequests,
                                                                    ErpPurchaseRequestImportReqVO importReqVO) {
        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<ImportRow> 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;
            }
            // 1.2 物料编码为空:业务上无法定位物料,跳过并明确告知用户
            if (StrUtil.isBlank(row.getProductCode())) {
                respVO.getSkippedRows().put(rowLabel, "物料编码为空,该行未导入");
                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<List<ImportRow>> groups = new ArrayList<>();
        Map<String, Integer> groupIndexMap = new LinkedHashMap<>();
        for (ImportRow row : validRows) {
            String no = row.data.getNo();
            String groupKey = StrUtil.isNotBlank(no) ? no : ("__row_" + row.excelRowNo);
            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<ImportRow> group : groups) {
            ImportRow firstRow = group.get(0);
            // 失败/跳过的标识优先用单号,没有单号则用 Excel 里的真实行号
            String groupKey = StrUtil.isNotBlank(firstRow.data.getNo())
                    ? firstRow.data.getNo() : "第" + firstRow.excelRowNo + "行";
            try {
                ErpPurchaseRequestSaveReqVO createReqVO = buildPurchaseRequestImportSaveReqVO(
                        convertList(group, ImportRow::getData));
                Long requestId = createPurchaseRequest(createReqVO);
                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,
                        "导入失败:" + (ex instanceof NullPointerException ? "数据解析异常,请检查物料/单位编码" : ex.getMessage()));
            }
        }
        return respVO;
    }
    /**
     * 判断一行是否完全空白(所有业务列都没有值)
     */
    private boolean isBlankRow(ErpPurchaseRequestImportExcelVO row) {
        return StrUtil.isBlank(row.getNo())
                && StrUtil.isBlank(row.getProductCode())
                && StrUtil.isBlank(row.getProductUnitCode())
                && row.getRequestTime() == null
                && row.getCount() == null
                && row.getProductPrice() == 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
     */
    private ErpPurchaseRequestSaveReqVO buildPurchaseRequestImportSaveReqVO(List<ErpPurchaseRequestImportExcelVO> group) {
        ErpPurchaseRequestImportExcelVO first = group.get(0);
        // 1. 校验申请时间
        if (first.getRequestTime() == null) {
            throw exception(PURCHASE_REQUEST_IMPORT_REQUEST_TIME_REQUIRED);
        }
        // 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<ErpPurchaseRequestSaveReqVO.Item> items = new ArrayList<>();
        for (ErpPurchaseRequestImportExcelVO row : group) {
            MdmItemRespDTO product = mdmItemApi.getItemByCode(row.getProductCode()).getCheckedData();
            if (product == null) {
                throw exception(PURCHASE_REQUEST_IMPORT_PRODUCT_NOT_EXISTS, row.getProductCode());
            }
            MdmUnitMeasureRespDTO unit = mdmUnitMeasureApi.getUnitMeasureByCode(row.getProductUnitCode()).getCheckedData();
            if (unit == null) {
                throw exception(PURCHASE_REQUEST_IMPORT_UNIT_NOT_EXISTS, row.getProductUnitCode());
            }
            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);
    }
}