gaoluyang
9 天以前 25a40b9c4e6a1b650610b8b13088d486699532ce
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ErpPurchaseOrderServiceImpl.java
@@ -2,6 +2,8 @@
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
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;
@@ -14,10 +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;
@@ -26,6 +32,10 @@
import cn.iocoder.yudao.module.erp.enums.ErpAuditStatus;
import cn.iocoder.yudao.module.erp.enums.ErpPurchaseOrderInStatusEnum;
import cn.iocoder.yudao.module.erp.service.finance.ErpAccountService;
import cn.iocoder.yudao.module.mdm.api.item.MdmItemApi;
import cn.iocoder.yudao.module.mdm.api.item.dto.MdmItemRespDTO;
import cn.iocoder.yudao.module.srm.api.supplier.SrmSupplierApi;
import cn.iocoder.yudao.module.srm.api.supplier.dto.SrmSupplierRespDTO;
import cn.iocoder.yudao.module.mes.api.arrivalnotice.MesArrivalNoticeApi;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
import jakarta.annotation.Resource;
@@ -37,7 +47,11 @@
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.time.LocalDate;
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.*;
@@ -67,6 +81,8 @@
    private ErpPurchaseOrderMapper purchaseOrderMapper;
    @Resource
    private ErpPurchaseOrderItemMapper purchaseOrderItemMapper;
    @Resource
    private ErpPurchaseInvoiceOrderMapper invoiceOrderMapper;
    @Resource
    private ErpNoRedisDAO noRedisDAO;
@@ -95,6 +111,11 @@
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
    @Resource
    private SrmSupplierApi srmSupplierApi;
    @Resource
    private MdmItemApi mdmItemApi;
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createPurchaseOrder(ErpPurchaseOrderSaveReqVO createReqVO) {
@@ -106,8 +127,10 @@
        if (createReqVO.getAccountId() != null) {
            accountService.validateAccount(createReqVO.getAccountId());
        }
        // 1.4 生成订单号,并校验唯一性
        String no = noRedisDAO.generate(ErpNoRedisDAO.PURCHASE_ORDER_NO_PREFIX);
        // 1.4 确定订单号(前端填写则用自定义单号,留空则自动生成),并校验唯一性
        String customNo = StrUtil.trim(createReqVO.getNo());
        String no = StrUtil.isNotBlank(customNo) ? customNo
                : noRedisDAO.generate(ErpNoRedisDAO.PURCHASE_ORDER_NO_PREFIX);
        if (purchaseOrderMapper.selectByNo(no) != null) {
            throw exception(PURCHASE_ORDER_NO_EXISTS);
        }
@@ -157,6 +180,337 @@
        updatePurchaseOrderItemList(updateReqVO.getId(), purchaseOrderItems);
        // 2.3 更新附件
        storageAttachmentApi.updateAttachments("file", "erp_purchase_order", updateReqVO.getId(), updateReqVO.getBlobIds());
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public ErpPurchaseOrderImportRespVO importPurchaseOrderList(List<ErpPurchaseOrderImportExcelVO> importList,
                                                               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;
        }
        // 1. 预热“供应商名称/编码 -> 供应商编号”映射(ERP 供应商主数据在 SRM 模块)
        Map<String, Long> supplierIdMap = new HashMap<>();
        CommonResult<List<SrmSupplierRespDTO>> supplierResult = srmSupplierApi.getSupplierSimpleList();
        if (supplierResult != null && CollUtil.isNotEmpty(supplierResult.getData())) {
            supplierResult.getData().forEach(supplier -> {
                if (StrUtil.isNotBlank(supplier.getName())) {
                    supplierIdMap.put(supplier.getName().trim(), supplier.getId());
                }
                if (StrUtil.isNotBlank(supplier.getCode())) {
                    supplierIdMap.putIfAbsent(supplier.getCode().trim(), supplier.getId());
                }
            });
        }
        // 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);
            if (row == null || (StrUtil.isBlank(row.getSupplierName()) && StrUtil.isBlank(row.getProductCode())
                    && StrUtil.isBlank(row.getNo()))) {
                continue; // 跳过空行
            }
            String key = StrUtil.isNotBlank(row.getNo()) ? row.getNo().trim() : "__row_" + (i + 2);
            orderGroups.computeIfAbsent(key, k -> new ArrayList<>()).add(row);
        }
        // 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 {
                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));
            }
        }
        return respVO;
    }
    /**
     * 校验并组装一张导入订单(不落库)。
     */
    private ImportOrderData prepareImportOrder(List<ErpPurchaseOrderImportExcelVO> rows,
                                               Map<String, Long> supplierIdMap) {
        // 1. 供应商(取该组首行)
        ErpPurchaseOrderImportExcelVO head = rows.get(0);
        if (StrUtil.isBlank(head.getSupplierName())) {
            throw new IllegalArgumentException("供应商名称不能为空");
        }
        Long supplierId = supplierIdMap.get(head.getSupplierName().trim());
        if (supplierId == null) {
            throw new IllegalArgumentException("供应商不存在:" + head.getSupplierName());
        }
        supplierService.validateSupplier(supplierId);
        // 2. 明细
        List<ErpPurchaseOrderSaveReqVO.Item> items = new ArrayList<>();
        for (ErpPurchaseOrderImportExcelVO row : rows) {
            if (StrUtil.isBlank(row.getProductCode())) {
                throw new IllegalArgumentException("产品编码不能为空");
            }
            CommonResult<MdmItemRespDTO> itemResult = mdmItemApi.getItemByCode(row.getProductCode().trim());
            MdmItemRespDTO item = itemResult == null ? null : itemResult.getData();
            if (item == null) {
                throw new IllegalArgumentException("产品不存在:" + row.getProductCode());
            }
            if (row.getCount() == null) {
                throw new IllegalArgumentException("产品[" + row.getProductCode() + "]的数量不能为空");
            }
            if (item.getUnitMeasureId() == null) {
                throw new IllegalArgumentException("产品[" + row.getProductCode() + "]未配置计量单位,请先在物料中维护");
            }
            ErpPurchaseOrderSaveReqVO.Item itemVO = new ErpPurchaseOrderSaveReqVO.Item();
            itemVO.setProductId(item.getId());
            itemVO.setProductUnitId(item.getUnitMeasureId());
            itemVO.setProductPrice(row.getProductPrice() == null ? BigDecimal.ZERO : row.getProductPrice());
            itemVO.setCount(row.getCount());
            itemVO.setTaxPercent(row.getTaxPercent());
            itemVO.setRemark(row.getRemark());
            itemVO.setQcCheckFlag("是".equals(StrUtil.trim(row.getQcCheckFlag())));
            items.add(itemVO);
        }
        // 3. 单号:填了就用;留空时先不生成,等确认要新增时再生成,避免重复行被跳过也消耗流水号
        boolean generatedNo = StrUtil.isBlank(head.getNo());
        // 4. 组装主表(状态由调用方决定)
        ErpPurchaseOrderDO purchaseOrder = new ErpPurchaseOrderDO();
        purchaseOrder.setNo(generatedNo ? null : head.getNo().trim());
        purchaseOrder.setSupplierId(supplierId);
        purchaseOrder.setOrderTime(parseImportOrderTime(head.getOrderTime()));
        purchaseOrder.setRemark(head.getRemark());
        purchaseOrder.setDepositPrice(BigDecimal.ZERO); // deposit_price 为 NOT NULL 且无默认值,必须显式赋值
        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);
        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;
        }
    }
    /**
     * 解析导入的采购时间,兼容多种常见格式;为空取当前时间
     */
    private LocalDateTime parseImportOrderTime(String text) {
        if (StrUtil.isBlank(text)) {
            return LocalDateTime.now();
        }
        String value = text.trim();
        for (String pattern : new String[]{"yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "yyyy/MM/dd HH:mm:ss"}) {
            try {
                return LocalDateTime.parse(value, DateTimeFormatter.ofPattern(pattern));
            } catch (Exception ignored) {
                // 继续尝试下一个格式
            }
        }
        for (String pattern : new String[]{"yyyy-MM-dd", "yyyy/M/d"}) {
            try {
                return LocalDate.parse(value, DateTimeFormatter.ofPattern(pattern)).atStartOfDay();
            } catch (Exception ignored) {
                // 继续尝试下一个格式
            }
        }
        throw new IllegalArgumentException("采购时间格式不正确:" + text + ",示例 2026-01-01 10:00:00");
    }
    /**
     * 提取导入失败原因(ServiceException 取业务提示)
     */
    private String resolveImportErrorMessage(Exception ex) {
        if (ex instanceof cn.iocoder.yudao.framework.common.exception.ServiceException serviceException) {
            return serviceException.getMessage();
        }
        return StrUtil.blankToDefault(ex.getMessage(), "导入失败");
    }
    private void calculateTotalPrice(ErpPurchaseOrderDO purchaseOrder, List<ErpPurchaseOrderItemDO> purchaseOrderItems) {
@@ -307,13 +661,17 @@
    private List<ErpPurchaseOrderItemDO> validatePurchaseOrderItems(List<ErpPurchaseOrderSaveReqVO.Item> list) {
        // 转化为 ErpPurchaseOrderItemDO 列表(产品验证已移除)
        return convertList(list, o -> BeanUtils.toBean(o, ErpPurchaseOrderItemDO.class, item -> {
            item.setTotalPrice(MoneyUtils.priceMultiply(item.getProductPrice(), item.getCount()));
            if (item.getTotalPrice() == null) {
            // 单价为含税单价:含税金额 = 单价 × 数量;税额 = 含税金额 × 税率;不含税金额 = 含税金额 - 税额
            BigDecimal taxInclusivePrice = MoneyUtils.priceMultiply(item.getProductPrice(), item.getCount());
            if (taxInclusivePrice == null) {
                return;
            }
            BigDecimal taxPrice = null;
            if (item.getTaxPercent() != null) {
                item.setTaxPrice(MoneyUtils.priceMultiplyPercent(item.getTotalPrice(), item.getTaxPercent()));
                taxPrice = MoneyUtils.priceMultiplyPercent(taxInclusivePrice, item.getTaxPercent());
            }
            item.setTaxPrice(taxPrice);
            item.setTotalPrice(taxPrice != null ? taxInclusivePrice.subtract(taxPrice) : taxInclusivePrice);
        }));
    }