tuqin
4 天以前 f785e619ff7cbe99aa80fc7679a244224ab5024f
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/sale/ErpSaleOrderServiceImpl.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;
@@ -13,9 +15,12 @@
import cn.iocoder.yudao.module.bpm.service.definition.BpmCategoryService;
import cn.iocoder.yudao.module.bpm.service.definition.BpmProcessDefinitionService;
import cn.iocoder.yudao.module.crm.api.customer.CrmCustomerApi;
import cn.iocoder.yudao.module.crm.api.customer.dto.CrmCustomerRespDTO;
import cn.iocoder.yudao.module.crm.api.contract.CrmContractApi;
import cn.iocoder.yudao.module.erp.controller.admin.sale.vo.order.ErpSaleOrderPageReqVO;
import cn.iocoder.yudao.module.erp.controller.admin.sale.vo.order.ErpSaleOrderSaveReqVO;
import cn.iocoder.yudao.module.erp.controller.admin.sale.vo.order.ErpSaleOrderImportExcelVO;
import cn.iocoder.yudao.module.erp.controller.admin.sale.vo.order.ErpSaleOrderImportRespVO;
import cn.iocoder.yudao.module.erp.dal.dataobject.sale.ErpSaleOrderDO;
import cn.iocoder.yudao.module.erp.dal.dataobject.sale.ErpSaleOrderItemDO;
import cn.iocoder.yudao.module.erp.dal.mysql.sale.ErpSaleOrderItemMapper;
@@ -39,14 +44,21 @@
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.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
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.*;
@@ -90,6 +102,9 @@
    private CrmContractApi contractApi;
    @Resource
    private ErpAccountService accountService;
    @Resource
    private cn.iocoder.yudao.module.mes.api.wms.MesWmStockApi mesWmStockApi;
    @Resource
    private AdminUserApi adminUserApi;
@@ -178,6 +193,180 @@
        updateSaleOrderItemList(updateReqVO.getId(), saleOrderItems);
        // 2.3 更新附件
        storageAttachmentApi.updateAttachments("file", "erp_sale_order", updateReqVO.getId(), updateReqVO.getBlobIds());
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public ErpSaleOrderImportRespVO importSaleOrderList(List<ErpSaleOrderImportExcelVO> importList) {
        ErpSaleOrderImportRespVO respVO = ErpSaleOrderImportRespVO.builder()
                .createList(new ArrayList<>())
                .updateList(new ArrayList<>())
                .failureList(new LinkedHashMap<>())
                .build();
        if (CollUtil.isEmpty(importList)) {
            return respVO;
        }
        // 1. 预热“客户名称 -> 客户编号”映射(客户主数据在 CRM 模块)
        Map<String, Long> customerIdMap = new HashMap<>();
        List<CrmCustomerRespDTO> customers = customerApi.getCustomerSimpleList();
        if (CollUtil.isNotEmpty(customers)) {
            customers.forEach(customer -> {
                if (StrUtil.isNotBlank(customer.getName())) {
                    customerIdMap.putIfAbsent(customer.getName().trim(), customer.getId());
                }
            });
        }
        // 2. 按“销售单编号”分组:同编号多行 = 一张订单的多条明细;编号为空则每行独立成单
        Map<String, List<ErpSaleOrderImportExcelVO>> orderGroups = new LinkedHashMap<>();
        for (int i = 0; i < importList.size(); i++) {
            ErpSaleOrderImportExcelVO row = importList.get(i);
            if (row == null || (StrUtil.isBlank(row.getCustomerName()) && 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);
        }
        // 3. 逐个订单导入(单张订单失败不影响其它)
        for (Map.Entry<String, List<ErpSaleOrderImportExcelVO>> entry : orderGroups.entrySet()) {
            List<ErpSaleOrderImportExcelVO> rows = entry.getValue();
            String label = StrUtil.isNotBlank(rows.get(0).getNo()) ? rows.get(0).getNo().trim()
                    : "第 " + entry.getKey().substring("__row_".length()) + " 行";
            try {
                ErpSaleOrderDO saleOrder = createSaleOrderByImport(rows, customerIdMap);
                respVO.getCreateList().add(saleOrder.getNo());
            } catch (Exception ex) {
                respVO.getFailureList().put(label, resolveImportErrorMessage(ex));
            }
        }
        return respVO;
    }
    /**
     * 依据导入数据创建一张销售订单(含明细)。
     *
     * 注意:此处不调用 submitSaleOrder,因此不会创建 BPM 工作流;订单状态固定为“未提交(0)”,
     * 可在列表中继续编辑或提交审批。
     */
    private ErpSaleOrderDO createSaleOrderByImport(List<ErpSaleOrderImportExcelVO> rows,
                                                   Map<String, Long> customerIdMap) {
        // 1. 客户(取该组首行)
        ErpSaleOrderImportExcelVO head = rows.get(0);
        if (StrUtil.isBlank(head.getCustomerName())) {
            throw new IllegalArgumentException("客户名称不能为空");
        }
        Long customerId = customerIdMap.get(head.getCustomerName().trim());
        if (customerId == null) {
            throw new IllegalArgumentException("客户不存在:" + head.getCustomerName());
        }
        customerApi.validateCustomer(customerId);
        // 2. 明细
        List<ErpSaleOrderSaveReqVO.Item> items = new ArrayList<>();
        for (ErpSaleOrderImportExcelVO 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() + "]未配置计量单位,请先在物料中维护");
            }
            ErpSaleOrderSaveReqVO.Item itemVO = new ErpSaleOrderSaveReqVO.Item();
            itemVO.setProductId(item.getId());
            itemVO.setProductUnitId(item.getUnitMeasureId());
            // 单价:未填写时取物料的销售价(与页面选产品后的默认值一致)
            itemVO.setProductPrice(row.getProductPrice() != null ? row.getProductPrice()
                    : ObjectUtil.defaultIfNull(item.getSalesPrice(), BigDecimal.ZERO));
            itemVO.setCount(row.getCount());
            itemVO.setTaxPercent(row.getTaxPercent());
            itemVO.setNeedProduction(parseNeedProduction(row.getNeedProduction()));
            itemVO.setRemark(row.getRemark());
            items.add(itemVO);
        }
        // 3. 单号:填了就用(校验唯一),没填自动生成
        String no = StrUtil.isNotBlank(head.getNo()) ? head.getNo().trim()
                : noRedisDAO.generate(ErpNoRedisDAO.SALE_ORDER_NO_PREFIX);
        if (saleOrderMapper.selectByNo(no) != null) {
            throw new IllegalArgumentException("销售单编号已存在:" + no);
        }
        // 4. 组装主表:导入后固定为“未提交(0)”状态
        ErpSaleOrderDO saleOrder = new ErpSaleOrderDO();
        saleOrder.setNo(no);
        saleOrder.setCustomerId(customerId);
        saleOrder.setContractNo(head.getContractNo());
        saleOrder.setOrderTime(parseImportOrderTime(head.getOrderTime()));
        saleOrder.setRemark(head.getRemark());
        saleOrder.setStatus(ErpAuditStatus.DRAFT.getStatus());
        saleOrder.setDepositPrice(BigDecimal.ZERO);
        saleOrder.setOutCount(BigDecimal.ZERO);
        saleOrder.setOutStatus(ErpSaleOrderOutStatusEnum.NOT_OUT.getStatus());
        List<ErpSaleOrderItemDO> itemDOs = validateSaleOrderItems(items);
        calculateTotalPrice(saleOrder, itemDOs);
        saleOrderMapper.insert(saleOrder);
        itemDOs.forEach(item -> item.setOrderId(saleOrder.getId()));
        saleOrderItemMapper.insertBatch(itemDOs);
        return saleOrder;
    }
    /**
     * 解析“是否需要生产”:留空默认“是(1)”,填“否/0/不需要”则为“否(0)”
     */
    private Integer parseNeedProduction(String text) {
        if (StrUtil.isBlank(text)) {
            return 1; // 与页面新增明细的默认值一致
        }
        String value = text.trim();
        boolean no = "否".equals(value) || "0".equals(value) || "不需要".equals(value)
                || "false".equalsIgnoreCase(value) || "no".equalsIgnoreCase(value);
        return no ? 0 : 1;
    }
    /**
     * 解析导入的下单时间,兼容多种常见格式;为空取当前时间
     */
    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(ErpSaleOrderDO saleOrder, List<ErpSaleOrderItemDO> saleOrderItems) {
@@ -379,6 +568,195 @@
        return mpsId;
    }
    // ==================== 从销售订单生成生产计划(生产车间视角) ====================
    @Override
    public List<cn.iocoder.yudao.module.erp.controller.admin.sale.vo.order.ErpSaleOrderPendingProductionRespVO>
            getPendingProductionList() {
        // 1. 取全部已审核的销售订单
        List<ErpSaleOrderDO> orders = saleOrderMapper.selectList(
                new cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX<ErpSaleOrderDO>()
                        .eq(ErpSaleOrderDO::getStatus, ErpAuditStatus.APPROVE.getStatus())
                        .orderByDesc(ErpSaleOrderDO::getId));
        if (CollUtil.isEmpty(orders)) {
            return Collections.emptyList();
        }
        Map<Long, ErpSaleOrderDO> orderMap = orders.stream()
                .collect(Collectors.toMap(ErpSaleOrderDO::getId, o -> o, (a, b) -> a));
        // 2. 取订单明细
        List<ErpSaleOrderItemDO> items = saleOrderItemMapper.selectListByOrderIds(orderMap.keySet());
        if (CollUtil.isEmpty(items)) {
            return Collections.emptyList();
        }
        // 3. 已有有效 MPS 的明细要排除,避免重复安排生产
        Set<Long> plannedItemIds = new HashSet<>();
        List<Long> allItemIds = items.stream().map(ErpSaleOrderItemDO::getId).collect(Collectors.toList());
        try {
            // 注意:该 API 直接返回 List,不是 CommonResult
            List<cn.iocoder.yudao.module.mes.api.mps.dto.MesProMpsRespDTO> existMps =
                    mpsApi.getMpsListBySaleOrderItemIds(allItemIds);
            if (CollUtil.isNotEmpty(existMps)) {
                existMps.stream()
                        .filter(m -> ObjectUtil.notEqual(m.getStatus(), MPS_STATUS_CANCELED))
                        .map(cn.iocoder.yudao.module.mes.api.mps.dto.MesProMpsRespDTO::getSaleOrderItemId)
                        .filter(Objects::nonNull)
                        .forEach(plannedItemIds::add);
            }
        } catch (Exception e) {
            log.warn("[getPendingProductionList] 查询已有 MPS 失败,按全部可安排处理", e);
        }
        // 4. 组装:带出可用库存与建议生产数量
        List<cn.iocoder.yudao.module.erp.controller.admin.sale.vo.order.ErpSaleOrderPendingProductionRespVO> result
                = new ArrayList<>();
        for (ErpSaleOrderItemDO item : items) {
            if (plannedItemIds.contains(item.getId())) {
                continue;
            }
            ErpSaleOrderDO order = orderMap.get(item.getOrderId());
            if (order == null) {
                continue;
            }
            cn.iocoder.yudao.module.erp.controller.admin.sale.vo.order.ErpSaleOrderPendingProductionRespVO vo
                    = new cn.iocoder.yudao.module.erp.controller.admin.sale.vo.order.ErpSaleOrderPendingProductionRespVO();
            vo.setSaleOrderId(order.getId());
            vo.setSaleOrderNo(order.getNo());
            vo.setSaleOrderItemId(item.getId());
            vo.setCustomerId(order.getCustomerId());
            vo.setCustomerName(getCustomerName(order.getCustomerId()));
            vo.setProductId(item.getProductId());
            // 明细表只存 productId,产品编码/名称/规格从 MDM 取
            try {
                MdmItemRespDTO mdmItem = mdmItemApi.getItem(item.getProductId()).getCheckedData();
                if (mdmItem != null) {
                    vo.setProductCode(mdmItem.getCode());
                    vo.setProductName(mdmItem.getName());
                    vo.setProductSpecification(mdmItem.getSpecification());
                }
            } catch (Exception e) {
                log.warn("[getPendingProductionList] 获取物料信息失败,productId={}", item.getProductId(), e);
            }
            vo.setOrderCount(item.getCount());
            vo.setOrderTime(order.getOrderTime());
            // 当前可用库存
            BigDecimal stock = BigDecimal.ZERO;
            try {
                BigDecimal qty = mesWmStockApi.getStockQuantity(item.getProductId());
                if (qty != null) {
                    stock = qty;
                }
            } catch (Exception e) {
                log.warn("[getPendingProductionList] 查询库存失败,productId={}", item.getProductId(), e);
            }
            vo.setStockQuantity(stock);
            // 建议生产数量 = 订购数量 - 可用库存,不足 0 则取 0
            BigDecimal count = item.getCount() == null ? BigDecimal.ZERO : item.getCount();
            BigDecimal suggest = count.subtract(stock);
            vo.setSuggestQuantity(suggest.compareTo(BigDecimal.ZERO) > 0 ? suggest : BigDecimal.ZERO);
            result.add(vo);
        }
        // 按建议生产数量降序,急需生产的排前面
        result.sort((a, b) -> b.getSuggestQuantity().compareTo(a.getSuggestQuantity()));
        return result;
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int generateMpsFromSaleOrder(
            cn.iocoder.yudao.module.erp.controller.admin.sale.vo.order.ErpSaleOrderGenerateMpsReqVO reqVO) {
        if (reqVO.getItems() == null || reqVO.getItems().isEmpty()) {
            return 0;
        }
        int success = 0;
        for (cn.iocoder.yudao.module.erp.controller.admin.sale.vo.order.ErpSaleOrderGenerateMpsReqVO.Item reqItem
                : reqVO.getItems()) {
            ErpSaleOrderItemDO item = saleOrderItemMapper.selectById(reqItem.getSaleOrderItemId());
            if (item == null) {
                log.warn("[generateMpsFromSaleOrder] 销售订单明细不存在,id={}", reqItem.getSaleOrderItemId());
                continue;
            }
            ErpSaleOrderDO order = saleOrderMapper.selectById(item.getOrderId());
            if (order == null || !ErpAuditStatus.APPROVE.getStatus().equals(order.getStatus())) {
                log.warn("[generateMpsFromSaleOrder] 销售订单未审核,跳过,itemId={}", item.getId());
                continue;
            }
            // 幂等保护:已有有效 MPS 则跳过,避免重复安排
            try {
                List<cn.iocoder.yudao.module.mes.api.mps.dto.MesProMpsRespDTO> existMps =
                        mpsApi.getMpsListBySaleOrderItemIds(Collections.singletonList(item.getId()));
                if (CollUtil.isNotEmpty(existMps)
                        && existMps.stream().anyMatch(m -> ObjectUtil.notEqual(m.getStatus(), MPS_STATUS_CANCELED))) {
                    log.info("[generateMpsFromSaleOrder] 明细({}) 已存在有效 MPS,跳过", item.getId());
                    continue;
                }
            } catch (Exception e) {
                log.warn("[generateMpsFromSaleOrder] 幂等校验失败,继续生成,itemId={}", item.getId(), e);
            }
            try {
                createMpsForSaleOrderItemWithQuantity(order, item, reqItem.getQuantity());
                success++;
            } catch (Exception e) {
                log.error("[generateMpsFromSaleOrder] 生成 MPS 失败,itemId={}", item.getId(), e);
            }
        }
        log.info("[generateMpsFromSaleOrder] 共生成 MPS {} 条", success);
        return success;
    }
    /**
     * 按指定数量为销售订单明细创建 MPS
     *
     * 与 createMpsForSaleOrderItem 的差别:数量由车间自行指定(通常是 订购数量 - 可用库存),
     * 而不是直接用订单数量
     */
    private Long createMpsForSaleOrderItemWithQuantity(ErpSaleOrderDO saleOrder, ErpSaleOrderItemDO item,
                                                       BigDecimal quantity) {
        String productName = null;
        try {
            MdmItemRespDTO mdmItem = mdmItemApi.getItem(item.getProductId()).getCheckedData();
            if (mdmItem != null) {
                productName = mdmItem.getName();
            }
        } catch (Exception e) {
            log.warn("[createMpsForSaleOrderItemWithQuantity] 获取物料信息失败,productId={}", item.getProductId(), e);
        }
        MesProMpsCreateReqDTO createReqDTO = new MesProMpsCreateReqDTO();
        createReqDTO.setSaleOrderId(saleOrder.getId());
        createReqDTO.setSaleOrderNo(saleOrder.getNo());
        createReqDTO.setSaleOrderItemId(item.getId());
        createReqDTO.setMdmItemId(item.getProductId());
        createReqDTO.setProductName(productName);
        createReqDTO.setQuantity(quantity);
        createReqDTO.setClientId(saleOrder.getCustomerId());
        createReqDTO.setRequestDate(formatRequestDate(saleOrder.getOrderTime()));
        createReqDTO.setRemark("由生产车间从销售订单生成:" + saleOrder.getNo());
        return mpsApi.createMps(createReqDTO);
    }
    /**
     * 取客户名称,失败时返回 null 不影响主流程
     */
    private String getCustomerName(Long customerId) {
        if (customerId == null) {
            return null;
        }
        try {
            // 该 API 直接返回 DTO,不是 CommonResult
            cn.iocoder.yudao.module.crm.api.customer.dto.CrmCustomerRespDTO customer =
                    customerApi.getCustomer(customerId);
            return customer == null ? null : customer.getName();
        } catch (Exception e) {
            log.warn("[getCustomerName] 获取客户失败,customerId={}", customerId, e);
            return null;
        }
    }
    /**
     * 格式化需求日期
     */