package cn.iocoder.yudao.module.erp.service.purchase; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.module.bpm.enums.task.BpmTaskStatusEnum; import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.plan.ErpPurchasePlanPageReqVO; import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.plan.ErpPurchasePlanRespVO; import cn.iocoder.yudao.module.erp.controller.admin.purchase.vo.plan.ErpPurchasePlanSaveReqVO; import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchasePlanDO; import cn.iocoder.yudao.module.erp.dal.dataobject.purchase.ErpPurchasePlanItemDO; 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.ErpPurchasePlanItemMapper; import cn.iocoder.yudao.module.erp.dal.mysql.purchase.ErpPurchasePlanMapper; 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.redis.no.ErpNoRedisDAO; import cn.iocoder.yudao.module.erp.enums.ErpPurchasePlanStatusEnum; 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.mes.api.wms.MesWmStockApi; import cn.iocoder.yudao.module.system.api.approval.ApprovalConfigApi; import cn.iocoder.yudao.module.system.api.user.AdminUserApi; import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.*; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; import static cn.iocoder.yudao.module.erp.enums.ErrorCodeConstants.*; /** * ERP 采购计划 Service 实现类 * * @author xiaoyi */ @Service @Validated @Slf4j public class ErpPurchasePlanServiceImpl implements ErpPurchasePlanService { /** * 通用审批配置的业务类型编码(对应 system_approval_config.biz_type) */ private static final String PURCHASE_PLAN_APPROVE_BIZ_TYPE = "purchase_plan_approve"; /** * 需求来源:库存预警 */ public static final Integer DEMAND_SOURCE_STOCK_ALERT = 1; /** * 需求来源:运维/生产计划 */ public static final Integer DEMAND_SOURCE_PLAN = 2; @Resource private ErpPurchasePlanMapper purchasePlanMapper; @Resource private ErpPurchasePlanItemMapper purchasePlanItemMapper; @Resource private ErpPurchaseRequestMapper purchaseRequestMapper; @Resource private ErpPurchaseRequestItemMapper purchaseRequestItemMapper; @Resource private ErpNoRedisDAO noRedisDAO; @Resource private ApprovalConfigApi approvalConfigApi; @Resource private AdminUserApi adminUserApi; @Resource private MdmItemApi mdmItemApi; @Resource private MesWmStockApi mesWmStockApi; @Override @Transactional(rollbackFor = Exception.class) public Long createPurchasePlan(ErpPurchasePlanSaveReqVO createReqVO) { // 1. 校验明细 List planItems = convertList(createReqVO.getItems(), item -> BeanUtils.toBean(item, ErpPurchasePlanItemDO.class)); if (CollUtil.isEmpty(planItems)) { throw exception(PURCHASE_PLAN_ITEM_EMPTY); } // 2. 生成计划单号 String no = noRedisDAO.generate(ErpNoRedisDAO.PURCHASE_PLAN_NO_PREFIX); if (purchasePlanMapper.selectByNo(no) != null) { throw exception(PURCHASE_PLAN_NO_EXISTS); } // 3. 获取当前用户信息 Long userId = Long.parseLong(getCurrentUserId()); AdminUserRespDTO user = adminUserApi.getUser(userId); Long deptId = user != null ? user.getDeptId() : null; // 4. 插入采购计划 ErpPurchasePlanDO plan = BeanUtils.toBean(createReqVO, ErpPurchasePlanDO.class, req -> req .setNo(no) .setStatus(ErpPurchasePlanStatusEnum.DRAFT.getStatus()) .setPlanUserId(userId) .setPlanDeptId(deptId) .setGeneratedFlag(false) .setTotalCount(planItems.stream() .map(ErpPurchasePlanItemDO::getCount).filter(Objects::nonNull) .reduce(BigDecimal.ZERO, BigDecimal::add))); purchasePlanMapper.insert(plan); // 5. 插入明细 purchasePlanItemMapper.insertBatch(convertList(planItems, item -> item.setPlanId(plan.getId()))); return plan.getId(); } @Override @Transactional(rollbackFor = Exception.class) public void updatePurchasePlan(ErpPurchasePlanSaveReqVO updateReqVO) { // 1.1 校验存在 + 草稿状态 ErpPurchasePlanDO oldPlan = validatePurchasePlanCanModify(updateReqVO.getId()); // 1.2 校验明细 List planItems = convertList(updateReqVO.getItems(), item -> BeanUtils.toBean(item, ErpPurchasePlanItemDO.class)); if (CollUtil.isEmpty(planItems)) { throw exception(PURCHASE_PLAN_ITEM_EMPTY); } // 2. 更新主表 ErpPurchasePlanDO updateObj = BeanUtils.toBean(updateReqVO, ErpPurchasePlanDO.class, req -> req .setNo(oldPlan.getNo()) .setStatus(oldPlan.getStatus()) .setPlanUserId(oldPlan.getPlanUserId()) .setPlanDeptId(oldPlan.getPlanDeptId()) .setGeneratedFlag(oldPlan.getGeneratedFlag()) .setTotalCount(planItems.stream() .map(ErpPurchasePlanItemDO::getCount).filter(Objects::nonNull) .reduce(BigDecimal.ZERO, BigDecimal::add))); purchasePlanMapper.updateById(updateObj); // 3. 更新明细:先删后插 purchasePlanItemMapper.deleteByPlanId(updateReqVO.getId()); purchasePlanItemMapper.insertBatch(convertList(planItems, item -> item.setPlanId(updateReqVO.getId()))); } @Override @Transactional(rollbackFor = Exception.class) public void deletePurchasePlan(Collection ids) { if (CollUtil.isEmpty(ids)) { return; } for (Long id : ids) { // 1. 校验存在 + 草稿状态 validatePurchasePlanCanModify(id); // 2. 删除明细 purchasePlanItemMapper.deleteByPlanId(id); // 3. 删除主表 purchasePlanMapper.deleteById(id); } } @Override public ErpPurchasePlanDO getPurchasePlan(Long id) { return purchasePlanMapper.selectById(id); } @Override public List getPurchasePlanItemListByPlanId(Long planId) { return purchasePlanItemMapper.selectListByPlanId(planId); } @Override public List getPurchasePlanItemListByPlanIds(Collection planIds) { return purchasePlanItemMapper.selectListByPlanIds(planIds); } @Override public PageResult getPurchasePlanPage(ErpPurchasePlanPageReqVO pageReqVO) { return purchasePlanMapper.selectPage(pageReqVO); } @Override @Transactional(rollbackFor = Exception.class) public void submitPurchasePlan(Long id) { // 1. 校验存在 + 可提交状态(草稿 / 审核不通过) ErpPurchasePlanDO plan = validatePurchasePlanExists(id); if (!ErpPurchasePlanStatusEnum.DRAFT.getStatus().equals(plan.getStatus()) && !ErpPurchasePlanStatusEnum.REJECT.getStatus().equals(plan.getStatus())) { throw exception(PURCHASE_PLAN_SUBMIT_FAIL_NOT_ALLOWED); } // 2. 校验是否有明细 List items = purchasePlanItemMapper.selectListByPlanId(id); if (CollUtil.isEmpty(items)) { throw exception(PURCHASE_PLAN_ITEM_EMPTY); } // 3. 校验审批配置已启用且配置了有效审批人(未配置时抛出带明确提示的业务异常) approvalConfigApi.validateApprovalEnabledAndGetApprovers(PURCHASE_PLAN_APPROVE_BIZ_TYPE); // 4. 状态置为审批中,并清空上一轮审核结果,避免残留「审核不通过」的原因 purchasePlanMapper.submitAndResetAuditInfo(id, ErpPurchasePlanStatusEnum.PROCESS.getStatus()); } @Override public Set getPurchasePlanApproverUserIds() { return approvalConfigApi.getApproverUserIds(PURCHASE_PLAN_APPROVE_BIZ_TYPE); } @Override @Transactional(rollbackFor = Exception.class) public void auditPurchasePlan(Long id, Boolean pass, String reviewRemark) { // 1. 校验存在 ErpPurchasePlanDO plan = validatePurchasePlanExists(id); // 2. 校验处于审批中状态 if (!ErpPurchasePlanStatusEnum.PROCESS.getStatus().equals(plan.getStatus())) { throw exception(PURCHASE_PLAN_AUDIT_FAIL_NOT_PROCESS); } // 3. 校验当前登录用户是该业务类型的审批人(或签:任一人均可审核) Long userId = getLoginUserId(); approvalConfigApi.validateApprover(PURCHASE_PLAN_APPROVE_BIZ_TYPE, userId); // 4. 审核不通过:必须填写原因,状态置为审核不通过,退回编制人修改后可重新提交 if (!Boolean.TRUE.equals(pass)) { if (StrUtil.isBlank(reviewRemark)) { throw exception(PURCHASE_PLAN_AUDIT_REJECT_REASON_REQUIRED); } purchasePlanMapper.auditPurchasePlan(id, ErpPurchasePlanStatusEnum.REJECT.getStatus(), userId, getUserNickname(userId), reviewRemark); return; } // 5. 审核通过:记录审核人与审核意见,状态置为审核通过 purchasePlanMapper.auditPurchasePlan(id, ErpPurchasePlanStatusEnum.APPROVE.getStatus(), userId, getUserNickname(userId), reviewRemark); // 6. 审核通过且未生成申请,自动生成采购申请 if (!Boolean.TRUE.equals(plan.getGeneratedFlag())) { try { List requestIds = doGeneratePurchaseRequests(plan); log.info("[auditPurchasePlan] 采购计划({}) 自动生成采购申请({})", id, requestIds); } catch (Exception e) { // 不抛出异常,避免影响审核结果落库;生成失败可由用户手动触发 log.error("[auditPurchasePlan] 采购计划({}) 自动生成采购申请失败", id, e); } } } /** * 获取用户昵称 * * @param userId 用户编号 * @return 昵称,用户不存在时返回 null */ private String getUserNickname(Long userId) { if (userId == null) { return null; } AdminUserRespDTO user = adminUserApi.getUser(userId); return user == null ? null : user.getNickname(); } @Override @Transactional(rollbackFor = Exception.class) public void updatePurchasePlanAuditStatus(Long id, Integer bpmResult) { // 1. 校验存在 ErpPurchasePlanDO plan = validatePurchasePlanExists(id); if (!ErpPurchasePlanStatusEnum.PROCESS.getStatus().equals(plan.getStatus())) { log.warn("[updatePurchasePlanAuditStatus] 采购计划({}) 不处于审批中状态", id); return; } // 2. 根据审批结果更新状态 Integer newStatus = convertBpmResultToStatus(bpmResult); if (newStatus != null) { purchasePlanMapper.updateById(new ErpPurchasePlanDO().setId(id).setStatus(newStatus)); } // 3. 审批通过且未生成申请,自动生成采购申请 if (ErpPurchasePlanStatusEnum.APPROVE.getStatus().equals(newStatus) && !Boolean.TRUE.equals(plan.getGeneratedFlag())) { try { List requestIds = doGeneratePurchaseRequests(plan); log.info("[updatePurchasePlanAuditStatus] 采购计划({}) 自动生成采购申请({})", id, requestIds); } catch (Exception e) { log.error("[updatePurchasePlanAuditStatus] 采购计划({}) 自动生成采购申请失败", id, e); // 不抛出异常,避免影响状态更新 } } } @Override public List getStockAlertSuggestItems() { // 1. 获取启用的物料基础数据(包含默认仓库、安全/最低/最高库存) List items = mdmItemApi.getItemListByStatus(0).getCheckedData(); if (CollUtil.isEmpty(items)) { throw exception(PURCHASE_PLAN_STOCK_ALERT_NO_ITEM); } // 2. 按默认仓库库存规划建议采购量 List planItems = new ArrayList<>(); for (MdmItemRespDTO item : items) { ErpPurchasePlanRespVO.Item planItem = buildStockAlertSuggestItem(item); if (planItem != null) { planItems.add(planItem); } } if (CollUtil.isEmpty(planItems)) { throw exception(PURCHASE_PLAN_STOCK_ALERT_NO_ITEM); } return planItems; } @Override @Transactional(rollbackFor = Exception.class) public ErpPurchasePlanDO generatePlanFromStockAlert(String name, Long supplierId) { // 1. 获取库存预警建议采购明细 List suggestItems = getStockAlertSuggestItems(); if (CollUtil.isEmpty(suggestItems)) { throw exception(PURCHASE_PLAN_STOCK_ALERT_NO_ITEM); } // 2. 转换为保存明细 List planItems = convertList(suggestItems, item -> BeanUtils.toBean(item, ErpPurchasePlanSaveReqVO.Item.class)); // 3. 创建草稿采购计划 ErpPurchasePlanSaveReqVO createReqVO = new ErpPurchasePlanSaveReqVO(); createReqVO.setName(name != null ? name : "库存预警采购计划"); createReqVO.setDemandSource(DEMAND_SOURCE_STOCK_ALERT); createReqVO.setPlanDate(LocalDateTime.now()); createReqVO.setSupplierId(supplierId); createReqVO.setItems(planItems); Long planId = createPurchasePlan(createReqVO); return purchasePlanMapper.selectById(planId); } /** * 根据单个物料的默认仓库库存构建建议采购明细 * * @param item 物料基础数据 * @return 建议采购明细;无需补货时返回 null */ private ErpPurchasePlanRespVO.Item buildStockAlertSuggestItem(MdmItemRespDTO item) { // 1. 未配置默认仓库或最低库存(无补货基准)的物料不参与规划 if (item.getWarehouseId() == null || item.getMinStock() == null) { return null; } // 2. 查询默认仓库的当前库存 BigDecimal currentStock = mesWmStockApi.getStockQuantity(item.getId(), item.getWarehouseId()); if (currentStock == null) { currentStock = BigDecimal.ZERO; } // 3. 默认仓库库存不低于最低库存时不触发补货 if (currentStock.compareTo(item.getMinStock()) >= 0) { return null; } // 4. 补货目标:优先补到安全库存,未配置则回落最高库存,最后取最低库存 BigDecimal targetStock = item.getSafetyStock() != null ? item.getSafetyStock() : (item.getMaxStock() != null ? item.getMaxStock() : item.getMinStock()); BigDecimal count = targetStock.subtract(currentStock); if (count.compareTo(BigDecimal.ZERO) <= 0) { return null; } ErpPurchasePlanRespVO.Item planItem = new ErpPurchasePlanRespVO.Item(); planItem.setProductId(item.getId()); planItem.setProductName(item.getName()); planItem.setProductUnitId(item.getUnitMeasureId()); planItem.setCount(count); planItem.setDemandTime(LocalDate.now()); planItem.setRemark(String.format("默认仓库库存%s,安全库存%s,最低库存%s,最高库存%s,建议采购量%s", currentStock, item.getSafetyStock(), item.getMinStock(), item.getMaxStock(), count)); return planItem; } @Override @Transactional(rollbackFor = Exception.class) public List generatePurchaseRequests(Long id) { // 1. 校验存在 + 审核通过状态 ErpPurchasePlanDO plan = validatePurchasePlanExists(id); if (!ErpPurchasePlanStatusEnum.APPROVE.getStatus().equals(plan.getStatus())) { throw exception(PURCHASE_PLAN_NOT_APPROVE); } // 2. 校验是否已生成申请 if (Boolean.TRUE.equals(plan.getGeneratedFlag())) { throw exception(PURCHASE_PLAN_REQUEST_EXISTS); } // 3. 生成采购申请 return doGeneratePurchaseRequests(plan); } /** * 执行生成采购申请(一个计划生成一张申请单) * * @param plan 采购计划 * @return 采购申请编号列表 */ private List doGeneratePurchaseRequests(ErpPurchasePlanDO plan) { // 1. 获取计划明细 List planItems = purchasePlanItemMapper.selectListByPlanId(plan.getId()); if (CollUtil.isEmpty(planItems)) { throw exception(PURCHASE_PLAN_ITEM_EMPTY); } // 2. 生成申请单号 String requestNo = noRedisDAO.generate(ErpNoRedisDAO.PURCHASE_REQUEST_NO_PREFIX); if (purchaseRequestMapper.selectByNo(requestNo) != null) { throw exception(PURCHASE_REQUEST_NO_EXISTS); } // 3. 创建采购申请 ErpPurchaseRequestDO request = new ErpPurchaseRequestDO(); request.setNo(requestNo); request.setStatus(ErpPurchaseRequestStatusEnum.DRAFT.getStatus()); request.setRequestUserId(plan.getPlanUserId()); request.setRequestDeptId(plan.getPlanDeptId()); request.setRequestTime(LocalDateTime.now()); request.setSupplierId(plan.getSupplierId()); request.setTotalCount(planItems.stream() .map(ErpPurchasePlanItemDO::getCount).filter(Objects::nonNull) .reduce(BigDecimal.ZERO, BigDecimal::add)); request.setTotalPrice(BigDecimal.ZERO); request.setTotalProductPrice(BigDecimal.ZERO); request.setTotalTaxPrice(BigDecimal.ZERO); request.setRemark(plan.getRemark()); request.setInStatus(0); purchaseRequestMapper.insert(request); // 4. 创建采购申请明细 List requestItems = convertList(planItems, item -> { ErpPurchaseRequestItemDO requestItem = new ErpPurchaseRequestItemDO(); requestItem.setRequestId(request.getId()); requestItem.setProductId(item.getProductId()); requestItem.setProductUnitId(item.getProductUnitId()); requestItem.setCount(item.getCount()); requestItem.setDemandTime(item.getDemandTime()); requestItem.setRemark(item.getRemark()); requestItem.setInCount(BigDecimal.ZERO); return requestItem; }); purchaseRequestItemMapper.insertBatch(requestItems); // 5. 回写计划已生成标记 purchasePlanMapper.updateById(new ErpPurchasePlanDO() .setId(plan.getId()).setGeneratedFlag(true)); return List.of(request.getId()); } private Integer convertBpmResultToStatus(Integer bpmResult) { if (BpmTaskStatusEnum.APPROVE.getStatus().equals(bpmResult)) { return ErpPurchasePlanStatusEnum.APPROVE.getStatus(); } if (BpmTaskStatusEnum.REJECT.getStatus().equals(bpmResult)) { return ErpPurchasePlanStatusEnum.REJECT.getStatus(); } if (BpmTaskStatusEnum.CANCEL.getStatus().equals(bpmResult)) { return ErpPurchasePlanStatusEnum.CANCEL.getStatus(); } return null; } private ErpPurchasePlanDO validatePurchasePlanExists(Long id) { ErpPurchasePlanDO plan = purchasePlanMapper.selectById(id); if (plan == null) { throw exception(PURCHASE_PLAN_NOT_EXISTS); } return plan; } private ErpPurchasePlanDO validatePurchasePlanCanModify(Long id) { ErpPurchasePlanDO plan = validatePurchasePlanExists(id); if (!ErpPurchasePlanStatusEnum.DRAFT.getStatus().equals(plan.getStatus())) { throw exception(PURCHASE_PLAN_UPDATE_FAIL_NOT_DRAFT, plan.getNo()); } return plan; } private String getCurrentUserId() { return cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId().toString(); } }