| | |
| | | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | 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.*; |
| | |
| | | private CrmContractApi contractApi; |
| | | @Resource |
| | | private ErpAccountService accountService; |
| | | |
| | | @Resource |
| | | private cn.iocoder.yudao.module.mes.api.wms.MesWmStockApi mesWmStockApi; |
| | | |
| | | @Resource |
| | | private AdminUserApi adminUserApi; |
| | |
| | | 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) { |
| | |
| | | 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; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 格式化需求日期 |
| | | */ |