xiaoyi
21 小时以前 a57dceb3e6bc02e28a86f4de66f593706452c2b4
refactor: 需求10/23 改为基于 ERP 实现,修正此前 ERP 可用性误判

## 问题(重要更正)
此前判定「ERP 模块已禁用」,据此用 SRM 报价和 aftersales 受理单实现了
需求23 成本分析、需求10 退单管理。实测发现该判定错误:
1. 测试使用了错误路径 /erp/statistics/purchase/**,
真实映射为 /erp/purchase-statistics/**,错误路径落到 DefaultController
兜底返回 501「ERP 模块已禁用」,误判为模块未启用
2. 实际 ERP 已启用:/erp/sale-order、/erp/purchase-order、/erp/purchase-request、
/erp/finance-payment 均返回 code=0
3. /erp/purchase-statistics/price 本地可正常返回数据(15 条物料)
4. 线上仅缺采购统计接口(旧 jar 未包含该 Controller)与部分 ERP 表

## 修复
### 需求23 成本分析 改回 ERP 口径
- 前端改调 /erp/purchase-statistics/price(后端实现早已存在,基于
erp_purchase_order + erp_purchase_order_items 已审核订单聚合)
- 新增页面 erp/purchase/statistics/price-analysis.vue:
采购量、加权均价、最低/最高价、最近价/上次价、价格变动率,以及汇总指标
- 删除重复的 SRM 版成本分析(SrmCostAnalysisService / SrmCostAnalysisRespVO /
控制器端点 / 前端页面与 api),避免同一需求两套口径

### 需求10 退单管理 接 ERP 销售订单
- ErpSaleOrderApi 新增 cancelSaleOrder,复用 Service 的作废逻辑
(含状态校验、出库单校验、未取消 MPS 拦截、发货通知拦截)
- after_sale_cancellation 新增 sale_order_id 列与 SaveReqVO 字段
- 审批通过后按类型联动作废:
退货 → 生成退货单 +(可选)作废销售订单
合同终止 → 作废 CRM 合同 +(可选)作废销售订单
- 修正审批事务语义:原来「下游失败只记录不阻断」会导致
UnexpectedRollbackException(内层事务已标记 rollback-only)。
改为先执行下游、成功后再落审批结论;下游失败整体回滚,
受理单保持待审批并向用户返回可读原因,杜绝「审批通过但业务未执行」

### 菜单(撤销此前的错误调整)
- erp_menus_restore_20261107.sql:
恢复采购管理下 ERP 子菜单启用、恢复仓库大屏可见、
采购成本分析改挂到「采购管理」并指向 ERP 页面

## 验证(本地 mom_local)
需求23:/erp/purchase-statistics/price code=0,15 条物料
样本 电工圆铜杆 采购量60000 加权均价66.1867 最低65.28 最高68 变动率-4%
需求10:
✅ 成功路径:新建订单 1305 → 审批通过 → CRM 合同6 作废 + 销售订单作废(status=30)
✅ 阻断路径1:订单有 MPS 计划 → 拒绝「请先处理并取消生产计划」,单据保持待审批、订单状态不变
✅ 阻断路径2:订单有发货通知 → 拒绝「请先处理相关通知单」
✅ 已作废订单重复作废 → 拒绝
其他回归:主回归 21/21、16/22 通过
已添加2个文件
已删除2个文件
已修改6个文件
433 ■■■■■ 文件已修改
sql/mysql/after_sale_cancellation_order_link_20261107.sql 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/erp_menus_restore_20261107.sql 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/cancellation/vo/AfterSaleCancellationSaveReqVO.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/dataobject/cancellation/AfterSaleCancellationDO.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/cancellation/AfterSaleCancellationService.java 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp-api/src/main/java/cn/iocoder/yudao/module/erp/api/sale/ErpSaleOrderApi.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/api/sale/ErpSaleOrderApiImpl.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/controller/admin/statistics/SrmStatisticsController.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/controller/admin/statistics/vo/SrmCostAnalysisRespVO.java 84 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/service/statistics/SrmCostAnalysisService.java 183 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/after_sale_cancellation_order_link_20261107.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,14 @@
-- é€€å•受理单支持关联 ERP é”€å”®è®¢å•,审批通过联动作废
SET @c := (SELECT COUNT(*) FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='after_sale_cancellation' AND COLUMN_NAME='sale_order_id');
SET @s := IF(@c=0,
  'ALTER TABLE after_sale_cancellation ADD COLUMN sale_order_id BIGINT DEFAULT NULL COMMENT ''ERP é”€å”®è®¢å•编号'' AFTER contract_id',
  'SELECT 1');
PREPARE st FROM @s; EXECUTE st; DEALLOCATE PREPARE st;
SET @i := (SELECT COUNT(*) FROM information_schema.STATISTICS
  WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='after_sale_cancellation' AND INDEX_NAME='idx_after_sale_cancellation_order');
SET @s2 := IF(@i=0,
  'ALTER TABLE after_sale_cancellation ADD KEY idx_after_sale_cancellation_order (sale_order_id, deleted)',
  'SELECT 1');
PREPARE st2 FROM @s2; EXECUTE st2; DEALLOCATE PREPARE st2;
sql/mysql/erp_menus_restore_20261107.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,28 @@
-- ERP å®žé™…已启用(销售/采购/付款订单接口均可用),撤销此前的 ERP åœç”¨ä¸Žèœå•隐藏,
-- å¹¶æŠŠã€Œé‡‡è´­æˆæœ¬åˆ†æžã€æ”¹ä¸º ERP é‡‡è´­è®¢å•口径的页面。
-- 1) æ¢å¤é‡‡è´­ç®¡ç†ä¸‹ ERP å­èœå•启用(撤销误判停用)
UPDATE system_menu SET status = 0
WHERE CAST(deleted AS UNSIGNED)=0 AND parent_id = 2602 AND component LIKE 'erp/%';
-- 2) æ¢å¤ä»“库大屏(线上为启用状态)
UPDATE system_menu SET status = 0, visible = b'1'
WHERE CAST(deleted AS UNSIGNED)=0 AND component = 'bi/warehouse/index';
-- 3) é‡‡è´­æˆæœ¬åˆ†æžæ”¹æŒ‡å‘ ERP é¡µé¢ï¼Œå¹¶ä¸ŠæŒ‚到「采购管理」下(原挂在 SRM ä¸‹ï¼‰
UPDATE system_menu SET component = 'erp/purchase/statistics/price-analysis',
       component_name = 'ErpPurchasePriceAnalysis',
       name = '采购成本分析',
       parent_id = 2602, sort = 10,
       status = 0, visible = b'1'
WHERE CAST(deleted AS UNSIGNED)=0
  AND component IN ('srm/statistics/cost-analysis','erp/purchase/statistics/price-analysis');
-- 4) ç¡®è®¤èœå•授权
INSERT INTO system_role_menu (role_id, menu_id, creator, create_time, updater, update_time, deleted)
SELECT rm.role_id, m.id, '1', NOW(), '1', NOW(), b'0'
FROM (SELECT DISTINCT role_id FROM system_role_menu WHERE menu_id = 602524 AND CAST(deleted AS UNSIGNED)=0) rm
CROSS JOIN (SELECT id FROM system_menu
            WHERE component='erp/purchase/statistics/price-analysis' AND CAST(deleted AS UNSIGNED)=0) m
WHERE NOT EXISTS (SELECT 1 FROM system_role_menu x
                  WHERE x.role_id=rm.role_id AND x.menu_id=m.id AND CAST(x.deleted AS UNSIGNED)=0);
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/cancellation/vo/AfterSaleCancellationSaveReqVO.java
@@ -27,7 +27,11 @@
    @Schema(description = "CRM åˆåŒç¼–号")
    private Long contractId;
    @Schema(description = "销售订单号(无 ERP æ—¶å¯ä¸ºç©ºï¼‰")
    @Schema(description = "ERP é”€å”®è®¢å•编号;填写后审批通过会联动作废")
    @Positive
    private Long saleOrderId;
    @Schema(description = "销售订单号(仅展示,选填)")
    @Size(max = 64)
    private String saleOrderNo;
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/dataobject/cancellation/AfterSaleCancellationDO.java
@@ -32,6 +32,8 @@
    private Long ticketId;
    private Long customerId;
    private Long contractId;
    /** ERP é”€å”®è®¢å•编号;填写后审批通过会联动作废(受 MPS è®¡åˆ’拦截约束) */
    private Long saleOrderId;
    private String saleOrderNo;
    /** é€€è´§ç±»åž‹ï¼š1 é€€æ¬¾é€€è´§ 2 æ¢è´§ 3 ä»…退款 */
    private Integer returnType;
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/cancellation/AfterSaleCancellationService.java
@@ -12,6 +12,7 @@
import cn.iocoder.yudao.module.aftersales.dal.redis.no.AfterSaleNoRedisDAO;
import cn.iocoder.yudao.module.aftersales.service.returnobj.AfterSaleReturnService;
import cn.iocoder.yudao.module.crm.api.contract.CrmContractApi;
import cn.iocoder.yudao.module.erp.api.sale.ErpSaleOrderApi;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
@@ -22,6 +23,7 @@
import java.util.Objects;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
import static cn.iocoder.yudao.module.aftersales.enums.ErrorCodeConstants.*;
/**
@@ -53,6 +55,7 @@
    @Resource private AfterSaleNoRedisDAO noRedisDAO;
    @Resource private AfterSaleReturnService returnService;
    @Resource private CrmContractApi crmContractApi;
    @Resource private ErpSaleOrderApi erpSaleOrderApi;
    public PageResult<AfterSaleCancellationDO> page(PageParam page, String code, String type,
                                                    Integer status, Long customerId) {
@@ -133,7 +136,10 @@
    }
    /**
     * å®¡æ‰¹ï¼šé€šè¿‡åŽæ‰§è¡Œä¸‹æ¸¸åŠ¨ä½œï¼›é©³å›žå¿…é¡»å¡«æ„è§ã€‚
     * å®¡æ‰¹ï¼šé©³å›žå¿…须填意见;通过时先执行下游动作,全部成功才落审批结论。
     *
     * <p>下游失败(如销售订单仍有关联生产计划)会整体回滚,受理单保持「待审批」,
     * ç”±è°ƒç”¨æ–¹çœ‹åˆ°å…·ä½“原因,避免出现「审批通过了但业务没执行」的不一致状态。
     *
     * @return é€šè¿‡æ—¶ç”Ÿæˆçš„退货单编号(非退货类型为 null)
     */
@@ -146,27 +152,47 @@
        AfterSaleCancellationDO task = get(req.getId());
        if (!Objects.equals(task.getStatus(), ST_PENDING)) throw exception(CANCELLATION_APPROVE_FAIL_STATUS);
        int target = Boolean.TRUE.equals(req.getApproved()) ? ST_APPROVED : ST_REJECTED;
        // é©³å›žï¼šåªæ”¹çŠ¶æ€ï¼Œä¸æ‰§è¡Œä¸‹æ¸¸
        if (!Boolean.TRUE.equals(req.getApproved())) {
            if (mapper.update(null, new LambdaUpdateWrapper<AfterSaleCancellationDO>()
                    .eq(AfterSaleCancellationDO::getId, task.getId())
                    .eq(AfterSaleCancellationDO::getStatus, ST_PENDING)
                    .set(AfterSaleCancellationDO::getStatus, ST_REJECTED)
                    .set(AfterSaleCancellationDO::getApproverId, userId)
                    .set(AfterSaleCancellationDO::getApprovalTime, LocalDateTime.now())
                    .set(AfterSaleCancellationDO::getApprovalRemark, req.getRemark())) != 1) {
                throw exception(CANCELLATION_APPROVE_FAIL_STATUS);
            }
            return null;
        }
        // é€šè¿‡ï¼šå…ˆæ‰§è¡Œä¸‹æ¸¸åŠ¨ä½œï¼ˆå¼‚å¸¸ç›´æŽ¥æŠ›å‡ºï¼Œäº‹åŠ¡å›žæ»šï¼Œå•æ®ä»ä¸ºå¾…å®¡æ‰¹ï¼‰
        Long returnId = executeDownstream(task, userId);
        // ä¸‹æ¸¸æˆåŠŸåŽå†è½å®¡æ‰¹ç»“è®º
        if (mapper.update(null, new LambdaUpdateWrapper<AfterSaleCancellationDO>()
                .eq(AfterSaleCancellationDO::getId, task.getId())
                .eq(AfterSaleCancellationDO::getStatus, ST_PENDING)
                .set(AfterSaleCancellationDO::getStatus, target)
                .set(AfterSaleCancellationDO::getStatus, ST_APPROVED)
                .set(AfterSaleCancellationDO::getApproverId, userId)
                .set(AfterSaleCancellationDO::getApprovalTime, LocalDateTime.now())
                .set(AfterSaleCancellationDO::getApprovalRemark, req.getRemark())) != 1) {
                .set(AfterSaleCancellationDO::getApprovalRemark, req.getRemark())
                .set(AfterSaleCancellationDO::getExecuted, 1)) != 1) {
            throw exception(CANCELLATION_APPROVE_FAIL_STATUS);
        }
        if (!Boolean.TRUE.equals(req.getApproved())) return null;
        return executeDownstream(task, userId);
        return returnId;
    }
    /** å®¡æ‰¹é€šè¿‡åŽçš„下游动作;失败时记录原因但不回滚审批结论,便于人工补做。 */
    /**
     * å®¡æ‰¹é€šè¿‡åŽçš„下游动作。
     *
     * <p>不吞异常:任一步失败直接抛出,由 {@link #approve} çš„事务整体回滚,
     * ä¿è¯ã€Œå®¡æ‰¹é€šè¿‡ã€ä¸Žã€Œä¸‹æ¸¸å·²æ‰§è¡Œã€æ°¸è¿œä¸€è‡´ã€‚
     */
    private Long executeDownstream(AfterSaleCancellationDO task, Long userId) {
        Long returnId = null;
        String remark;
        boolean ok = true;
        try {
        {
            switch (task.getType()) {
                case "RETURN" -> {
                    var vo = new AfterSaleReturnSaveReqVO();
@@ -175,23 +201,31 @@
                    vo.setReturnType(task.getReturnType() == null ? 1 : task.getReturnType());
                    vo.setReturnReason(task.getReason());
                    returnId = returnService.createReturn(vo, userId);
                    remark = "已生成退货单 " + returnId;
                    if (task.getSaleOrderId() != null) {
                        erpSaleOrderApi.cancelSaleOrder(task.getSaleOrderId()).getCheckedData();
                        remark = "已生成退货单 " + returnId
                                + ",并作废销售订单 " + task.getSaleOrderId();
                    } else {
                        remark = "已生成退货单 " + returnId;
                    }
                }
                case "CONTRACT_TERMINATE" -> {
                    crmContractApi.updateContractStatus(task.getContractId(), CRM_CONTRACT_INVALID).getCheckedData();
                    remark = "已作废 CRM åˆåŒ " + task.getContractId();
                    // è‹¥å…³è” ERP é”€å”®è®¢å•,一并作废(会被未取消的 MPS è®¡åˆ’拦截)
                    if (task.getSaleOrderId() != null) {
                        erpSaleOrderApi.cancelSaleOrder(task.getSaleOrderId()).getCheckedData();
                        remark = "已作废 CRM åˆåŒ " + task.getContractId()
                                + ",并作废销售订单 " + task.getSaleOrderId();
                    } else {
                        remark = "已作废 CRM åˆåŒ " + task.getContractId();
                    }
                }
                case "CONTRACT_CHANGE" -> remark = "变更受理已通过;CRM åˆåŒå†…容请按附件同步维护";
                default -> { ok = false; remark = "未知受理类型:" + task.getType(); }
                default -> throw invalidParamException("未知受理类型:" + task.getType());
            }
        } catch (Exception e) {
            ok = false;
            remark = "下游执行失败:" + e.getMessage();
            log.warn("退单受理[{}]下游动作失败:{}", task.getCode(), e.getMessage());
        }
        var update = new AfterSaleCancellationDO();
        update.setId(task.getId());
        update.setExecuted(ok ? 1 : 0);
        update.setExecuteRemark(remark);
        if (returnId != null) update.setReturnId(returnId);
        mapper.updateById(update);
yudao-module-erp-api/src/main/java/cn/iocoder/yudao/module/erp/api/sale/ErpSaleOrderApi.java
@@ -83,4 +83,13 @@
     */
    CommonResult<Boolean> updateOrderOutStatusTo(Long orderId, Integer outStatus);
    /**
     * ä½œåºŸé”€å”®è®¢å•(供售后/退单流程联动)
     *
     * <p>订单已存在未取消的 MPS è®¡åˆ’时会抛出业务异常,由调用方提示用户先处理计划。
     *
     * @param orderId è®¢å•编号
     */
    CommonResult<Boolean> cancelSaleOrder(Long orderId);
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/api/sale/ErpSaleOrderApiImpl.java
@@ -138,4 +138,14 @@
        return success(true);
    }
    @Override
    public CommonResult<Boolean> cancelSaleOrder(Long orderId) {
        if (orderId == null) {
            return success(false);
        }
        // å¤ç”¨ Service çš„作废逻辑:含状态校验、出库单校验、未取消 MPS è®¡åˆ’拦截
        saleOrderService.cancelSaleOrder(orderId);
        return success(true);
    }
}
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/controller/admin/statistics/SrmStatisticsController.java
@@ -1,13 +1,8 @@
package cn.iocoder.yudao.module.srm.controller.admin.statistics;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.srm.controller.admin.statistics.vo.SrmCostAnalysisRespVO;
import cn.iocoder.yudao.module.srm.controller.admin.statistics.vo.SrmTenderStatisticsRespVO;
import cn.iocoder.yudao.module.srm.service.statistics.SrmCostAnalysisService;
import cn.iocoder.yudao.module.srm.service.statistics.SrmStatisticsService;
import jakarta.validation.Valid;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
@@ -25,8 +20,6 @@
    @Resource
    private SrmStatisticsService statisticsService;
    @Resource
    private SrmCostAnalysisService costAnalysisService;
    @GetMapping("/tender")
    @Operation(summary = "获取招标统计数据")
@@ -35,22 +28,4 @@
        return success(statisticsService.getTenderStatistics(year));
    }
    @GetMapping("/cost-analysis")
    @Operation(summary = "采购成本分析",
            description = "基于 SRM æ‹›æ ‡éœ€æ±‚量与供应商报价聚合:采购量、加权均价、最低/最高价、价格变动;不依赖 ERP")
    @PreAuthorize("@ss.hasPermission('srm:statistics:query')")
    public CommonResult<PageResult<SrmCostAnalysisRespVO>> getCostAnalysis(
            @Valid PageParam page,
            @RequestParam(value = "productName", required = false) String productName) {
        return success(costAnalysisService.page(page, productName));
    }
    @GetMapping("/cost-trend")
    @Operation(summary = "物料价格趋势", description = "按月汇总该物料的平均报价")
    @PreAuthorize("@ss.hasPermission('srm:statistics:query')")
    public CommonResult<java.util.List<SrmCostAnalysisRespVO.TrendPoint>> getCostTrend(
            @RequestParam("productId") Long productId) {
        return success(costAnalysisService.trend(productId));
    }
}
}
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/controller/admin/statistics/vo/SrmCostAnalysisRespVO.java
ÎļþÒÑɾ³ý
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/service/statistics/SrmCostAnalysisService.java
ÎļþÒÑɾ³ý