gaoluyang
3 天以前 25a40b9c4e6a1b650610b8b13088d486699532ce
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseOrderServiceImpl.java
@@ -16,12 +16,14 @@
import cn.iocoder.yudao.module.bpm.service.definition.BpmProcessDefinitionService;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.order.ErpPurchaseOrderPageReqVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.order.ErpPurchaseOrderSaveReqVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.order.ErpPurchaseOrderImportDuplicateVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.order.ErpPurchaseOrderImportExcelVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.order.ErpPurchaseOrderImportRespVO;
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.mysql.purchase.ErpPurchaseInvoiceOrderMapper;
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;
@@ -49,6 +51,7 @@
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*;
@@ -78,6 +81,8 @@
    private ErpPurchaseOrderMapper purchaseOrderMapper;
    @Resource
    private ErpPurchaseOrderItemMapper purchaseOrderItemMapper;
    @Resource
    private ErpPurchaseInvoiceOrderMapper invoiceOrderMapper;
    @Resource
    private ErpNoRedisDAO noRedisDAO;
@@ -180,11 +185,12 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public ErpPurchaseOrderImportRespVO importPurchaseOrderList(List<ErpPurchaseOrderImportExcelVO> importList,
                                                               Boolean directApprove) {
                                                               Boolean directApprove, Boolean overwrite) {
        ErpPurchaseOrderImportRespVO respVO = ErpPurchaseOrderImportRespVO.builder()
                .createList(new ArrayList<>())
                .updateList(new ArrayList<>())
                .failureList(new LinkedHashMap<>())
                .duplicateList(new ArrayList<>())
                .build();
        if (CollUtil.isEmpty(importList)) {
            return respVO;
@@ -204,7 +210,27 @@
            });
        }
        // 2. 按“采购单编号”分组:同编号多行 = 一张订单的多条明细;编号为空则每行独立成单
        // 2. 预热系统内有效采购订单:按单号、按“供应商 + 明细”指纹建索引,用于识别重复导入
        List<ErpPurchaseOrderDO> existingOrders = purchaseOrderMapper.selectList();
        Map<Long, List<ErpPurchaseOrderItemDO>> existingItemsMap = new HashMap<>();
        if (CollUtil.isNotEmpty(existingOrders)) {
            purchaseOrderItemMapper.selectListByOrderIds(
                            existingOrders.stream().map(ErpPurchaseOrderDO::getId).toList())
                    .forEach(item -> existingItemsMap.computeIfAbsent(item.getOrderId(), k -> new ArrayList<>()).add(item));
        }
        Map<String, ErpPurchaseOrderDO> existingNoMap = new HashMap<>();
        Map<String, List<ErpPurchaseOrderDO>> existingFingerprintMap = new HashMap<>();
        for (ErpPurchaseOrderDO order : existingOrders) {
            if (StrUtil.isNotBlank(order.getNo())) {
                existingNoMap.put(order.getNo(), order);
            }
            String fingerprint = buildOrderFingerprint(order.getSupplierId(), existingItemsMap.get(order.getId()));
            if (fingerprint != null) {
                existingFingerprintMap.computeIfAbsent(fingerprint, k -> new ArrayList<>()).add(order);
            }
        }
        // 3. 按“采购单编号”分组:同编号多行 = 一张订单的多条明细;编号为空则每行独立成单
        Map<String, List<ErpPurchaseOrderImportExcelVO>> orderGroups = new LinkedHashMap<>();
        for (int i = 0; i < importList.size(); i++) {
            ErpPurchaseOrderImportExcelVO row = importList.get(i);
@@ -216,14 +242,50 @@
            orderGroups.computeIfAbsent(key, k -> new ArrayList<>()).add(row);
        }
        // 3. 逐个订单导入(单张订单失败不影响其它)
        // 4. 逐个订单导入(单张订单失败不影响其它)
        for (Map.Entry<String, List<ErpPurchaseOrderImportExcelVO>> entry : orderGroups.entrySet()) {
            List<ErpPurchaseOrderImportExcelVO> rows = entry.getValue();
            String label = StrUtil.isNotBlank(rows.get(0).getNo()) ? rows.get(0).getNo().trim()
                    : "第 " + entry.getKey().substring("__row_".length()) + " 行";
            try {
                ErpPurchaseOrderDO purchaseOrder = createPurchaseOrderByImport(rows, supplierIdMap, directApprove);
                respVO.getCreateList().add(purchaseOrder.getNo());
                ImportOrderData importData = prepareImportOrder(rows, supplierIdMap);
                // 4.1 先按单号找;只有“单号为空、由系统生成编号”的行才按“供应商 + 完整明细”指纹找。
                //     文件里写了明确单号的,视为用户明确要新增该单号,指纹相同也不拦截,避免正常的重复采购被误判。
                ErpPurchaseOrderDO existing = importData.order.getNo() == null ? null
                        : existingNoMap.get(importData.order.getNo());
                String duplicateReason = existing != null ? "采购单编号已存在" : null;
                boolean matchedByFingerprint = false;
                List<ErpPurchaseOrderDO> fingerprintCandidates = null;
                if (existing == null && importData.generatedNo && importData.fingerprint != null) {
                    fingerprintCandidates = existingFingerprintMap.get(importData.fingerprint);
                    if (CollUtil.isNotEmpty(fingerprintCandidates)) {
                        existing = fingerprintCandidates.get(0);
                        matchedByFingerprint = true;
                        duplicateReason = "供应商、明细、数量、单价、金额与已有订单一致";
                    }
                }
                // 4.2 发现重复:默认不导入,返回给前端弹框提示;勾选覆盖后才更新已有订单
                if (existing != null) {
                    if (!Boolean.TRUE.equals(overwrite)) {
                        respVO.getDuplicateList().add(buildImportDuplicate(label, existing, duplicateReason));
                        continue;
                    }
                    if (matchedByFingerprint && fingerprintCandidates.size() > 1) {
                        String nos = fingerprintCandidates.stream().map(ErpPurchaseOrderDO::getNo)
                                .collect(Collectors.joining("、"));
                        throw new IllegalArgumentException("找到多张相同订单(" + nos
                                + "),无法自动覆盖,请先保留一张后再导入");
                    }
                    updatePurchaseOrderByImport(existing, importData, directApprove);
                    respVO.getUpdateList().add(existing.getNo());
                    continue;
                }
                // 4.3 没有重复,正常新增
                createPurchaseOrderByImport(importData, directApprove);
                respVO.getCreateList().add(importData.order.getNo());
            } catch (Exception ex) {
                respVO.getFailureList().put(label, resolveImportErrorMessage(ex));
            }
@@ -232,13 +294,10 @@
    }
    /**
     * 依据导入数据创建一张采购订单(含明细)。
     *
     * 注意:此处不调用 submitPurchaseOrder,因此不会创建 BPM 工作流;订单状态由 directApprove 直接决定:
     * 勾选“是否审核” => 已审核(20),未勾选 => 未审核(10)。
     * 校验并组装一张导入订单(不落库)。
     */
    private ErpPurchaseOrderDO createPurchaseOrderByImport(List<ErpPurchaseOrderImportExcelVO> rows,
                                                           Map<String, Long> supplierIdMap, Boolean directApprove) {
    private ImportOrderData prepareImportOrder(List<ErpPurchaseOrderImportExcelVO> rows,
                                               Map<String, Long> supplierIdMap) {
        // 1. 供应商(取该组首行)
        ErpPurchaseOrderImportExcelVO head = rows.get(0);
        if (StrUtil.isBlank(head.getSupplierName())) {
@@ -278,31 +337,145 @@
            items.add(itemVO);
        }
        // 3. 单号:填了就用(校验唯一),没填自动生成
        String no = StrUtil.isNotBlank(head.getNo()) ? head.getNo().trim()
                : noRedisDAO.generate(ErpNoRedisDAO.PURCHASE_ORDER_NO_PREFIX);
        if (purchaseOrderMapper.selectByNo(no) != null) {
            throw new IllegalArgumentException("采购单编号已存在:" + no);
        }
        // 3. 单号:填了就用;留空时先不生成,等确认要新增时再生成,避免重复行被跳过也消耗流水号
        boolean generatedNo = StrUtil.isBlank(head.getNo());
        // 4. 组装主表:勾选“是否审核” => 已审核(20),否则未审核(10)
        // 4. 组装主表(状态由调用方决定)
        ErpPurchaseOrderDO purchaseOrder = new ErpPurchaseOrderDO();
        purchaseOrder.setNo(no);
        purchaseOrder.setNo(generatedNo ? null : head.getNo().trim());
        purchaseOrder.setSupplierId(supplierId);
        purchaseOrder.setOrderTime(parseImportOrderTime(head.getOrderTime()));
        purchaseOrder.setRemark(head.getRemark());
        purchaseOrder.setStatus(Boolean.TRUE.equals(directApprove)
                ? ErpAuditStatus.APPROVE.getStatus() : ErpAuditStatus.PROCESS.getStatus());
        purchaseOrder.setDepositPrice(BigDecimal.ZERO); // deposit_price 为 NOT NULL 且无默认值,必须显式赋值
        purchaseOrder.setInCount(BigDecimal.ZERO);
        purchaseOrder.setInStatus(ErpPurchaseOrderInStatusEnum.NOT_IN.getStatus());
        List<ErpPurchaseOrderItemDO> itemDOs = validatePurchaseOrderItems(items);
        calculateTotalPrice(purchaseOrder, itemDOs);
        return new ImportOrderData(purchaseOrder, itemDOs,
                buildOrderFingerprint(supplierId, itemDOs), generatedNo,
                StrUtil.isNotBlank(head.getOrderTime()));
    }
    /**
     * 新增导入订单(含明细)。
     */
    private void createPurchaseOrderByImport(ImportOrderData importData, Boolean directApprove) {
        ErpPurchaseOrderDO purchaseOrder = importData.order;
        if (StrUtil.isBlank(purchaseOrder.getNo())) {
            purchaseOrder.setNo(noRedisDAO.generate(ErpNoRedisDAO.PURCHASE_ORDER_NO_PREFIX));
        }
        purchaseOrder.setStatus(Boolean.TRUE.equals(directApprove)
                ? ErpAuditStatus.APPROVE.getStatus() : ErpAuditStatus.PROCESS.getStatus());
        purchaseOrder.setInCount(BigDecimal.ZERO);
        purchaseOrder.setInStatus(ErpPurchaseOrderInStatusEnum.NOT_IN.getStatus());
        purchaseOrderMapper.insert(purchaseOrder);
        itemDOs.forEach(item -> item.setOrderId(purchaseOrder.getId()));
        purchaseOrderItemMapper.insertBatch(itemDOs);
        return purchaseOrder;
        importData.items.forEach(item -> item.setOrderId(purchaseOrder.getId()));
        purchaseOrderItemMapper.insertBatch(importData.items);
    }
    /**
     * 覆盖已有导入订单(含明细)。
     */
    private void updatePurchaseOrderByImport(ErpPurchaseOrderDO existing, ImportOrderData importData,
                                             Boolean directApprove) {
        // 1. 有下游单据的订单不允许覆盖,避免把已入库/已开票的数据改掉
        if (existing.getInCount() != null && existing.getInCount().compareTo(BigDecimal.ZERO) > 0) {
            throw new IllegalArgumentException("采购订单[" + existing.getNo() + "]已入库(已入库数量 "
                    + normalizeNumber(existing.getInCount()) + "),不能覆盖;请先删除对应入库记录后再导入");
        }
        if (existing.getReturnCount() != null && existing.getReturnCount().compareTo(BigDecimal.ZERO) > 0) {
            throw new IllegalArgumentException("采购订单[" + existing.getNo() + "]已退货(已退货数量 "
                    + normalizeNumber(existing.getReturnCount()) + "),不能覆盖;请先删除对应退货记录后再导入");
        }
        if (CollUtil.isNotEmpty(invoiceOrderMapper.selectListByPurchaseOrderId(existing.getId()))) {
            throw new IllegalArgumentException("采购订单[" + existing.getNo()
                    + "]已关联发票,不能覆盖;请先解除发票关联后再导入");
        }
        if (StrUtil.isNotBlank(existing.getProcessInstanceId())) {
            throw new IllegalArgumentException("采购订单[" + existing.getNo()
                    + "]已提交审批流程,不能覆盖;请先在协同办公撤回或作废该审批后再导入");
        }
        List<ErpPurchaseOrderItemDO> oldItems = purchaseOrderItemMapper.selectListByOrderId(existing.getId());
        boolean linkedRequest = oldItems.stream().anyMatch(item -> item.getRequestItemId() != null);
        if (linkedRequest) {
            throw new IllegalArgumentException("采购订单[" + existing.getNo()
                    + "]已关联采购申请,不能覆盖;请先在采购申请中撤销或删除对应申请后再导入");
        }
        // 2. 更新主表(保留原单号、原入库状态)
        ErpPurchaseOrderDO updateObj = importData.order;
        updateObj.setId(existing.getId());
        updateObj.setNo(existing.getNo());
        // 文件没填采购时间时保留原订单时间,避免覆盖时把日期改成当前时间
        if (!importData.orderTimeProvided) {
            updateObj.setOrderTime(existing.getOrderTime());
        }
        updateObj.setStatus(Boolean.TRUE.equals(directApprove)
                ? ErpAuditStatus.APPROVE.getStatus() : ErpAuditStatus.PROCESS.getStatus());
        updateObj.setInCount(existing.getInCount() == null ? BigDecimal.ZERO : existing.getInCount());
        updateObj.setInStatus(existing.getInStatus());
        purchaseOrderMapper.updateById(updateObj);
        // 3. 明细整组替换
        purchaseOrderItemMapper.deleteByOrderId(existing.getId());
        importData.items.forEach(item -> item.setOrderId(existing.getId()));
        purchaseOrderItemMapper.insertBatch(importData.items);
    }
    private ErpPurchaseOrderImportDuplicateVO buildImportDuplicate(String label, ErpPurchaseOrderDO existing,
                                                                   String reason) {
        return ErpPurchaseOrderImportDuplicateVO.builder()
                .key(label)
                .existingNo(existing.getNo())
                .existingId(existing.getId())
                .reason(reason)
                .build();
    }
    /**
     * 生成“供应商 + 完整明细”的订单指纹,用于识别重复导入。
     * 明细按物料、数量、单价、税率排序后比较,不受行顺序影响。
     */
    private String buildOrderFingerprint(Long supplierId, List<ErpPurchaseOrderItemDO> items) {
        if (supplierId == null || CollUtil.isEmpty(items)) {
            return null;
        }
        List<String> parts = items.stream()
                .map(item -> String.join(":",
                        String.valueOf(item.getProductId()),
                        normalizeNumber(item.getCount()),
                        normalizeNumber(item.getProductPrice()),
                        normalizeNumber(item.getTaxPercent())))
                .sorted()
                .toList();
        return supplierId + "|" + String.join(",", parts);
    }
    private String normalizeNumber(BigDecimal value) {
        return value == null ? "" : value.stripTrailingZeros().toPlainString();
    }
    private static class ImportOrderData {
        private final ErpPurchaseOrderDO order;
        private final List<ErpPurchaseOrderItemDO> items;
        private final String fingerprint;
        /**
         * 采购单编号是否由系统自动生成(文件里留空)
         */
        private final boolean generatedNo;
        /**
         * 文件里是否填写了采购时间
         */
        private final boolean orderTimeProvided;
        private ImportOrderData(ErpPurchaseOrderDO order, List<ErpPurchaseOrderItemDO> items, String fingerprint,
                                boolean generatedNo, boolean orderTimeProvided) {
            this.order = order;
            this.items = items;
            this.fingerprint = fingerprint;
            this.generatedNo = generatedNo;
            this.orderTimeProvided = orderTimeProvided;
        }
    }
    /**