xiaoyi
3 天以前 8782476e9cb4480be5da588888d08022b7942d0c
feat: 补齐需求10/15/16/17/18/21/23,改用现有模块替代 ERP 依赖

## 问题
按验收计划逐项复测时确认以下缺口:
1. 需求10 退单管理、需求23 成本分析依赖 ERP 模块,而该模块在构建中被排除
(yudao-server DefaultController 对 ERP 路径返回 501),导致无法验收
2. 需求15 执行反馈只有报工与任务列表,缺设备运行数据、物料消耗、交期偏差
3. 需求16 质量追溯缺原材料合规证书与售后故障关联
4. 需求17 故障报修缺远程诊断能力
5. 需求18 OEE 缺停机明细报表
6. 需求21 风险监测只有人工录入当前值,未接设备采集

## 修复
### 需求10 退单管理(去 ERP)
- 新增 after_sale_cancellation 表与完整受理流程:
退货 / 合同变更 / 合同终止统一受理,统一审批
- 审批通过后按类型执行下游动作:
退货 → 生成售后退货单;合同终止 → 作废 CRM 合同(status=50)
- 关键设计:状态流转用乐观锁;下游动作结果写入 executed/executeRemark,
失败不静默吞掉,便于人工补做;驳回强制填意见

### 需求23 成本分析(去 ERP)
- 新增 SrmCostAnalysisService,改用 SRM 招标需求量 + 供应商报价
- 加权均价 = Σ(报价单价 × 需求数量) / Σ(需求数量)
- 输出采购量、加权均价、最低/最高价、报价次数、供应商数、预估价偏差率、价格变动
- 另提供按月价格趋势接口

### 需求15 执行反馈
- 新增 MesProTaskExecutionService:汇总计划/报工/设备采集/物料消耗/交期偏差
- 延期原因只列可观测信号(告警、缺料、报工滞后、耗时超计划),不推断管理原因

### 需求16 质量追溯
- srm-api 新增 SrmSupplierCertificateApi:按供应商查询合规证书(含过期标记)
- 批次追溯新增 /certificates(原材料合规证书)与 /aftersales(售后故障记录)
- aftersales-api 新增 getTicketsBySaleOrderNo,供 MES 反查售后

### 需求17 故障报修
- 新增 MesDvRepairDiagnosisService:基于实时采集 + 状态历史 + 告警做远程诊断
- 输出风险等级、诊断结论、逐条诊断依据;不引入新外部接口

### 需求18 OEE
- 新增停机明细报表:按采集间隔推算停机区间与合计,复用 CollectOfflinePolicy

### 需求21 风险监测
- 危险源新增 machineryId + metricKey,从 mes_collect_realtime 取值
- 新增 /safety/risk-source/refresh:批量刷新并按阈值判定,返回报警清单
- 取不到采集值时置 UNKNOWN,不覆盖历史观测时间

## 数据库
- after_sale_cancellation_20261107.sql:退单受理表 + 菜单权限
- safety_risk_source_20261107.sql:危险源台账表 + 菜单
- safety_risk_source_collect_20261107.sql:危险源加设备绑定列
- safety_hazard_employee_link_20261107.sql:补隐患表缺失的
reporter_id/assignee_id/rechecker_id(修复创建隐患 500)
- mes_work_order_schedule_priority/market_type、mdm_item_outsource_lead_time:
排产约束字段
- purchase_order_srm_menu、bpm_invoice_legacy_derived_from:菜单与数据迁移

## 验证
mvn compile EXIT=0;mvn test-compile EXIT=0。
本地复测(mom_local):
- 需求10 11/11 通过(含退货生成、合同作废、驳回路径)
- 需求23 6/6 通过(样本:钢木电缆盘 加权均价659.6)
- 需求13/15/17/18/21 21/21 通过
- 需求16/22 通过;需求20 未绑定账号场景 6/6 通过
已添加32个文件
已修改17个文件
2439 ■■■■■ 文件已修改
sql/mysql/after_sale_cancellation_20261107.sql 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/bpm_invoice_legacy_derived_from_20261106.sql 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/mdm_item_outsource_lead_time_20261107.sql 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/mes_work_order_market_type_20261107.sql 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/mes_work_order_schedule_priority_20261107.sql 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/purchase_order_srm_menu_20261107.sql 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/safety_hazard_employee_link_20261107.sql 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/safety_risk_source_20261107.sql 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/safety_risk_source_collect_20261107.sql 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales-api/src/main/java/cn/iocoder/yudao/module/aftersales/api/AfterSaleTicketApi.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/api/AfterSaleTicketApiImpl.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/cancellation/AfterSaleCancellationController.java 100 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/cancellation/vo/AfterSaleCancellationApproveReqVO.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/cancellation/vo/AfterSaleCancellationSaveReqVO.java 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/dataobject/cancellation/AfterSaleCancellationDO.java 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/mysql/cancellation/AfterSaleCancellationMapper.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/mysql/ticket/AfterSaleTicketMapper.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/redis/no/AfterSaleNoRedisDAO.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/enums/ErrorCodeConstants.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/cancellation/AfterSaleCancellationService.java 213 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/ticket/AfterSaleTicketService.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/ticket/AfterSaleTicketServiceImpl.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/test/java/cn/iocoder/yudao/module/aftersales/service/collaboration/AfterSaleCollaborationServiceTest.java 204 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/test/java/cn/iocoder/yudao/module/aftersales/service/returnobj/AfterSaleReturnMesLinkTest.java 104 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/test/java/cn/iocoder/yudao/module/aftersales/service/ticket/AfterSaleLedgerServiceTest.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/test/java/cn/iocoder/yudao/module/erp/api/sale/ErpSaleOrderNoticeLockTest.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/test/java/cn/iocoder/yudao/module/erp/service/finance/ErpAfterSaleFinanceDraftServiceTest.java 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/test/java/cn/iocoder/yudao/module/erp/service/sale/ErpSaleOrderCancellationGuardTest.java 96 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/test/java/cn/iocoder/yudao/module/erp/service/statistics/PurchasePriceMetricsTest.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/collect/vo/MesCollectDowntimeDetailRespVO.java 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/dv/repair/MesDvRepairController.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/dv/repair/vo/MesDvRepairDiagnosisRespVO.java 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/dv/statistics/MesDvStatisticsController.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/feedback/MesProFeedbackController.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/feedback/vo/MesProTaskExecutionRespVO.java 100 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/safety/SafetyRiskSourceController.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/safety/vo/risk/SafetyRiskSourceSaveReqVO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/batch/MesWmBatchController.java 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/safety/SafetyRiskSourceDO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/collect/MesCollectQueryService.java 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/dv/repair/MesDvRepairDiagnosisService.java 163 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/feedback/MesProTaskExecutionService.java 180 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/safety/SafetyRiskSourceService.java 98 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-srm-api/src/main/java/cn/iocoder/yudao/module/srm/api/certificate/SrmSupplierCertificateApi.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-srm-api/src/main/java/cn/iocoder/yudao/module/srm/api/certificate/dto/SrmSupplierCertificateRespDTO.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/api/certificate/SrmSupplierCertificateApiImpl.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/controller/admin/statistics/SrmStatisticsController.java 25 ●●●●● 补丁 | 查看 | 原始文档 | 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_20261107.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,49 @@
-- ç»Ÿä¸€é€€å•受理单:退货 / åˆåŒå˜æ›´ / åˆåŒç»ˆæ­¢ï¼Œèµ°å®¡æ‰¹é—­çŽ¯ã€‚
-- è¯´æ˜Žï¼šä¸ä¾èµ– ERP é”€å”®è®¢å•;用 CRM åˆåŒä¸Žå”®åŽé€€è´§å•作为执行对象。
CREATE TABLE IF NOT EXISTS after_sale_cancellation (
  id BIGINT NOT NULL AUTO_INCREMENT COMMENT '编号',
  code VARCHAR(64) NOT NULL COMMENT '受理单号',
  type VARCHAR(32) NOT NULL COMMENT 'RETURN é€€è´§ / CONTRACT_CHANGE åˆåŒå˜æ›´ / CONTRACT_TERMINATE åˆåŒç»ˆæ­¢',
  status TINYINT NOT NULL DEFAULT 0 COMMENT '0 è‰ç¨¿ 1 å¾…审批 2 å·²é€šè¿‡ 3 å·²é©³å›ž 4 å·²å–消',
  ticket_id BIGINT DEFAULT NULL COMMENT '关联售后工单',
  customer_id BIGINT DEFAULT NULL COMMENT '客户编号',
  contract_id BIGINT DEFAULT NULL COMMENT 'CRM åˆåŒç¼–号',
  sale_order_no VARCHAR(64) DEFAULT NULL COMMENT '销售订单号(有则记录,无 ERP æ—¶å¯ä¸ºç©ºï¼‰',
  return_type TINYINT DEFAULT NULL COMMENT '退货类型:1 é€€æ¬¾é€€è´§ 2 æ¢è´§ 3 ä»…退款',
  reason VARCHAR(500) NOT NULL COMMENT '申请原因',
  expected_amount DECIMAL(20,6) DEFAULT NULL COMMENT '预计金额',
  applicant_id BIGINT DEFAULT NULL COMMENT '申请人',
  approver_id BIGINT DEFAULT NULL COMMENT '审批人',
  approval_time DATETIME DEFAULT NULL COMMENT '审批时间',
  approval_remark VARCHAR(500) DEFAULT NULL COMMENT '审批意见',
  return_id BIGINT DEFAULT NULL COMMENT '审批通过后生成的退货单编号',
  executed TINYINT NOT NULL DEFAULT 0 COMMENT '是否已执行下游动作:0 æœªæ‰§è¡Œ 1 å·²æ‰§è¡Œ',
  execute_remark VARCHAR(500) DEFAULT NULL COMMENT '下游执行说明或失败原因',
  remark VARCHAR(500) DEFAULT NULL,
  tenant_id BIGINT NOT NULL DEFAULT 0,
  creator VARCHAR(64) DEFAULT '', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updater VARCHAR(64) DEFAULT '', update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  deleted BIT(1) NOT NULL DEFAULT b'0',
  PRIMARY KEY (id),
  UNIQUE KEY uk_after_sale_cancellation_code (code),
  KEY idx_after_sale_cancellation_status (status, deleted),
  KEY idx_after_sale_cancellation_ticket (ticket_id, deleted),
  KEY idx_after_sale_cancellation_contract (contract_id, deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='售后统一退单/变更受理';
-- èœå•:挂在售后服务下
SET @as_parent := (SELECT id FROM system_menu WHERE name='售后服务' AND parent_id=0 AND CAST(deleted AS UNSIGNED)=0 LIMIT 1);
INSERT INTO system_menu (name,permission,type,sort,parent_id,path,icon,component,component_name,status,visible,keep_alive,always_show,creator,updater)
SELECT '退单管理','',2,90,@as_parent,'cancellation','lucide:undo-2','aftersales/cancellation/index','AfterSaleCancellation',0,b'1',b'1',b'1','1','1'
WHERE @as_parent IS NOT NULL AND NOT EXISTS (SELECT 1 FROM system_menu WHERE component='aftersales/cancellation/index' AND CAST(deleted AS UNSIGNED)=0);
SET @pg := (SELECT id FROM system_menu WHERE component='aftersales/cancellation/index' AND CAST(deleted AS UNSIGNED)=0 LIMIT 1);
INSERT INTO system_menu (name,permission,type,sort,parent_id,path,icon,status,visible,keep_alive,always_show,creator,updater)
SELECT p.name,p.permission,3,p.sort_no,@pg,'','',0,b'1',b'1',b'1','1','1'
FROM (SELECT '查询' name,'aftersales:cancellation:query' permission,1 sort_no
      UNION ALL SELECT '新增','aftersales:cancellation:create',2
      UNION ALL SELECT '提交','aftersales:cancellation:submit',3
      UNION ALL SELECT '审批','aftersales:cancellation:approve',4
      UNION ALL SELECT '取消','aftersales:cancellation:cancel',5
      UNION ALL SELECT '删除','aftersales:cancellation:delete',6) p
WHERE @pg IS NOT NULL AND NOT EXISTS (SELECT 1 FROM system_menu m WHERE m.parent_id=@pg AND m.permission=p.permission AND CAST(m.deleted AS UNSIGNED)=0);
sql/mysql/bpm_invoice_legacy_derived_from_20261106.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,9 @@
-- ä»…修正旧版来票审批定义中以空字符串保存的派生来源。
-- ç©ºå­—符串不是有效的派生流程编号;Flowable æŸ¥è¯¢æ™®é€šæµç¨‹æ—¶è¦æ±‚该字段为 NULL。
-- æ‰§è¡Œå‰åº”先备份并确认目标库、记录以及历史流程的业务归属。
UPDATE act_re_procdef
SET DERIVED_FROM_ = NULL
WHERE KEY_ = 'erp-purchase-invoice-audit'
  AND VERSION_ = 1
  AND DERIVED_VERSION_ = 0
  AND DERIVED_FROM_ = '';
sql/mysql/mdm_item_outsource_lead_time_20261107.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,9 @@
-- ç‰©æ–™å¤–协提前期由业务人员维护,空值表示未配置。
SET @has_outsource_lead_time := (SELECT COUNT(*) FROM information_schema.COLUMNS
 WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='mdm_item' AND COLUMN_NAME='outsource_lead_time');
SET @lead_sql := IF(@has_outsource_lead_time=0,
 'ALTER TABLE mdm_item ADD COLUMN outsource_lead_time INT NULL COMMENT ''外协提前期(天)''',
 'SELECT 1');
PREPARE lead_stmt FROM @lead_sql;
EXECUTE lead_stmt;
DEALLOCATE PREPARE lead_stmt;
sql/mysql/mes_work_order_market_type_20261107.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,9 @@
-- å†…外销分类由业务人员维护,不根据客户名称推断。
SET @has_market_type := (SELECT COUNT(*) FROM information_schema.COLUMNS
 WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='mes_pro_work_order' AND COLUMN_NAME='market_type');
SET @market_sql := IF(@has_market_type=0,
 'ALTER TABLE mes_pro_work_order ADD COLUMN market_type VARCHAR(16) NULL COMMENT ''市场类型 DOMESTIC/EXPORT''',
 'SELECT 1');
PREPARE market_stmt FROM @market_sql;
EXECUTE market_stmt;
DEALLOCATE PREPARE market_stmt;
sql/mysql/mes_work_order_schedule_priority_20261107.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,9 @@
-- æŒ‰å®žé™…业务维护排产优先级;数字越大越先排,历史工单保持 0。
SET @has_schedule_priority := (SELECT COUNT(*) FROM information_schema.COLUMNS
 WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='mes_pro_work_order' AND COLUMN_NAME='schedule_priority');
SET @priority_sql := IF(@has_schedule_priority=0,
 'ALTER TABLE mes_pro_work_order ADD COLUMN schedule_priority INT NOT NULL DEFAULT 0 COMMENT ''排产优先级,数字越大越优先''',
 'SELECT 1');
PREPARE priority_stmt FROM @priority_sql;
EXECUTE priority_stmt;
DEALLOCATE PREPARE priority_stmt;
sql/mysql/purchase_order_srm_menu_20261107.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,14 @@
-- åŽŸé‡‡è´­ä¸€çº§èœå•åœç”¨æ—¶ï¼Œå°†åŽŸé‡‡è´­è®¢å•åŠå…¶æŒ‰é’®å½’å…¥çŽ°æœ‰ä»“å‚¨ç‰©æµ/SRM ç›®å½•。
-- ä¸åˆ›å»ºä¸€çº§èœå•,不授予新的采购业务权限;目标不存在或路径冲突时不迁移。
SET @old_purchase := (SELECT id FROM system_menu WHERE parent_id=0 AND path='/purchase' AND type=1 AND status=1 AND deleted=b'0' LIMIT 1);
SET @warehouse := (SELECT id FROM system_menu WHERE parent_id=0 AND path='/wls' AND type=1 AND status=0 AND deleted=b'0' LIMIT 1);
SET @srm := (SELECT id FROM system_menu WHERE parent_id=@warehouse AND path='srm' AND type=1 AND status=0 AND deleted=b'0' LIMIT 1);
SET @order := (SELECT id FROM system_menu WHERE parent_id=@old_purchase AND component='erp/purchase/order/index' AND type=2 AND deleted=b'0' LIMIT 1);
SET @conflict := (SELECT COUNT(*) FROM system_menu WHERE parent_id=@srm AND path='order' AND deleted=b'0' AND id<>IFNULL(@order,0));
UPDATE system_menu SET parent_id=@srm, updater='1', update_time=NOW()
WHERE id=@order AND @srm IS NOT NULL AND @conflict=0;
INSERT INTO system_role_menu (role_id,menu_id,creator,updater,tenant_id)
SELECT DISTINCT rm.role_id, parents.id, '1','1',rm.tenant_id
FROM system_role_menu rm JOIN system_menu parents ON parents.id IN (@warehouse,@srm)
WHERE rm.menu_id=@order AND rm.deleted=b'0' AND @srm IS NOT NULL AND @conflict=0
  AND NOT EXISTS (SELECT 1 FROM system_role_menu e WHERE e.role_id=rm.role_id AND e.tenant_id=rm.tenant_id AND e.menu_id=parents.id AND e.deleted=b'0');
sql/mysql/safety_hazard_employee_link_20261107.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,23 @@
-- é𐿂£å°è´¦è¡¥é½å‘˜å·¥å…³è”列(reporter_id / assignee_id / rechecker_id)
-- èƒŒæ™¯ï¼šsafety_hazard æ—©æœŸå»ºè¡¨é—漏了这三个关联列,导致隐患创建/分派接口 500。
-- é‡‡ç”¨ DO å­—段 -> åˆ—名映射,幂等执行。
SET @c1 := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='safety_hazard' AND COLUMN_NAME='reporter_id');
SET @s1 := IF(@c1=0, 'ALTER TABLE safety_hazard ADD COLUMN reporter_id BIGINT DEFAULT NULL COMMENT ''上报人(hrm_employee.id)'' AFTER photo_urls', 'SELECT 1');
PREPARE a1 FROM @s1; EXECUTE a1; DEALLOCATE PREPARE a1;
SET @c2 := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='safety_hazard' AND COLUMN_NAME='assignee_id');
SET @s2 := IF(@c2=0, 'ALTER TABLE safety_hazard ADD COLUMN assignee_id BIGINT DEFAULT NULL COMMENT ''整改责任人(hrm_employee.id)'' AFTER report_time', 'SELECT 1');
PREPARE a2 FROM @s2; EXECUTE a2; DEALLOCATE PREPARE a2;
SET @c3 := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='safety_hazard' AND COLUMN_NAME='rechecker_id');
SET @s3 := IF(@c3=0, 'ALTER TABLE safety_hazard ADD COLUMN rechecker_id BIGINT DEFAULT NULL COMMENT ''复查人(hrm_employee.id)'' AFTER rectify_time', 'SELECT 1');
PREPARE a3 FROM @s3; EXECUTE a3; DEALLOCATE PREPARE a3;
SET @i1 := (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='safety_hazard' AND INDEX_NAME='idx_safety_hazard_reporter');
SET @s4 := IF(@i1=0, 'ALTER TABLE safety_hazard ADD KEY idx_safety_hazard_reporter (reporter_id, deleted)', 'SELECT 1');
PREPARE a4 FROM @s4; EXECUTE a4; DEALLOCATE PREPARE a4;
SET @i2 := (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='safety_hazard' AND INDEX_NAME='idx_safety_hazard_assignee');
SET @s5 := IF(@i2=0, 'ALTER TABLE safety_hazard ADD KEY idx_safety_hazard_assignee (assignee_id, status, deleted)', 'SELECT 1');
PREPARE a5 FROM @s5; EXECUTE a5; DEALLOCATE PREPARE a5;
sql/mysql/safety_risk_source_20261107.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,22 @@
-- äººå·¥ç»´æŠ¤å±é™©æºå½“前值;不表示已接入自动数采,也不保留历史观测序列。
CREATE TABLE IF NOT EXISTS safety_risk_source (
 id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
 name VARCHAR(120) NOT NULL, category VARCHAR(24) NOT NULL, location VARCHAR(160) DEFAULT NULL,
 metric_name VARCHAR(80) NOT NULL, unit VARCHAR(32) DEFAULT NULL,
 lower_limit DECIMAL(20,6) DEFAULT NULL, upper_limit DECIMAL(20,6) DEFAULT NULL,
 current_value DECIMAL(20,6) DEFAULT NULL, observed_at DATETIME DEFAULT NULL,
 risk_status VARCHAR(16) NOT NULL DEFAULT 'UNKNOWN', remark VARCHAR(500) DEFAULT NULL,
 tenant_id BIGINT NOT NULL DEFAULT 0,
 creator VARCHAR(64) DEFAULT '', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
 updater VARCHAR(64) DEFAULT '', update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
 deleted BIT(1) NOT NULL DEFAULT b'0', KEY idx_safety_risk_status (risk_status,deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='危险源人工监测台账';
SET @risk_parent := (SELECT id FROM system_menu WHERE parent_id=0 AND path='/safety' AND type=1 AND deleted=b'0' LIMIT 1);
INSERT INTO system_menu (name,permission,type,sort,parent_id,path,icon,component,component_name,status,visible,keep_alive,always_show,creator,updater)
SELECT '危险源台账','',2,36,@risk_parent,'risk-source','lucide:triangle-alert','safety/risk-source/index','SafetyRiskSource',0,b'1',b'1',b'1','1','1'
WHERE @risk_parent IS NOT NULL AND NOT EXISTS (SELECT 1 FROM system_menu WHERE component='safety/risk-source/index' AND deleted=b'0');
SET @risk_page := (SELECT id FROM system_menu WHERE component='safety/risk-source/index' AND deleted=b'0' LIMIT 1);
INSERT INTO system_menu (name,permission,type,sort,parent_id,path,icon,status,visible,keep_alive,always_show,creator,updater)
SELECT p.name,p.permission,3,p.sort_no,@risk_page,'','',0,b'1',b'1',b'1','1','1'
FROM (SELECT '查询' name,'safety:risk:query' permission,1 sort_no UNION ALL SELECT '新增','safety:risk:create',2 UNION ALL SELECT '修改','safety:risk:update',3 UNION ALL SELECT '删除','safety:risk:delete',4) p
WHERE @risk_page IS NOT NULL AND NOT EXISTS (SELECT 1 FROM system_menu m WHERE m.parent_id=@risk_page AND m.permission=p.permission AND m.deleted=b'0');
sql/mysql/safety_risk_source_collect_20261107.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,24 @@
-- å±é™©æºæŽ¥å…¥è®¾å¤‡é‡‡é›†ï¼šç»‘定设备台账与数采指标键
-- ç»‘定后由 POST /safety/risk-source/refresh ä»Ž mes_collect_realtime å–值并按阈值判定,
-- ä¸è¦†ç›–人工录入模式(machinery_id ä¸ºç©ºæ—¶ä»ä»¥ current_value ä¸ºå‡†ï¼‰ã€‚
SET @has_machinery := (SELECT COUNT(*) FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='safety_risk_source' AND COLUMN_NAME='machinery_id');
SET @s := IF(@has_machinery=0,
  'ALTER TABLE safety_risk_source ADD COLUMN machinery_id BIGINT DEFAULT NULL COMMENT ''关联设备台账编号;为空表示人工录入'' AFTER metric_name',
  'SELECT 1');
PREPARE st FROM @s; EXECUTE st; DEALLOCATE PREPARE st;
SET @has_metric_key := (SELECT COUNT(*) FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='safety_risk_source' AND COLUMN_NAME='metric_key');
SET @s2 := IF(@has_metric_key=0,
  'ALTER TABLE safety_risk_source ADD COLUMN metric_key VARCHAR(64) DEFAULT NULL COMMENT ''数采指标键(data_json å­—段名)'' AFTER machinery_id',
  'SELECT 1');
PREPARE st2 FROM @s2; EXECUTE st2; DEALLOCATE PREPARE st2;
SET @has_idx := (SELECT COUNT(*) FROM information_schema.STATISTICS
  WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='safety_risk_source' AND INDEX_NAME='idx_safety_risk_machinery');
SET @s3 := IF(@has_idx=0,
  'ALTER TABLE safety_risk_source ADD KEY idx_safety_risk_machinery (machinery_id, deleted)',
  'SELECT 1');
PREPARE st3 FROM @s3; EXECUTE st3; DEALLOCATE PREPARE st3;
yudao-module-aftersales-api/src/main/java/cn/iocoder/yudao/module/aftersales/api/AfterSaleTicketApi.java
@@ -5,6 +5,7 @@
import cn.iocoder.yudao.module.aftersales.api.dto.AfterSaleTicketRespDTO;
import java.math.BigDecimal;
import java.util.List;
/**
 * å”®åŽå·¥å• API æŽ¥å£ï¼ˆä¾› MES/ERP è·¨æ¨¡å—调用)
@@ -51,4 +52,12 @@
     */
    CommonResult<AfterSaleReturnRespDTO> getReturn(Long id);
    /**
     * æŒ‰é”€å”®è®¢å•号查询售后工单(供 MES è´¨é‡è¿½æº¯å…³è”售后故障记录)
     *
     * @param saleOrderNo é”€å”®è®¢å•号
     * @return å·¥å•列表;无关联时为空列表
     */
    CommonResult<List<AfterSaleTicketRespDTO>> getTicketsBySaleOrderNo(String saleOrderNo);
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/api/AfterSaleTicketApiImpl.java
@@ -12,6 +12,7 @@
import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;
import java.util.List;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@@ -56,4 +57,11 @@
        return success(BeanUtils.toBean(returnDO, AfterSaleReturnRespDTO.class));
    }
    @Override
    public CommonResult<List<AfterSaleTicketRespDTO>> getTicketsBySaleOrderNo(String saleOrderNo) {
        if (saleOrderNo == null || saleOrderNo.isBlank()) return success(List.of());
        List<AfterSaleTicketDO> list = ticketService.getTicketListBySaleOrderNo(saleOrderNo);
        return success(list.stream().map(t -> BeanUtils.toBean(t, AfterSaleTicketRespDTO.class)).toList());
    }
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/cancellation/AfterSaleCancellationController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,100 @@
package cn.iocoder.yudao.module.aftersales.controller.admin.cancellation;
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.aftersales.controller.admin.cancellation.vo.AfterSaleCancellationApproveReqVO;
import cn.iocoder.yudao.module.aftersales.controller.admin.cancellation.vo.AfterSaleCancellationSaveReqVO;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.cancellation.AfterSaleCancellationDO;
import cn.iocoder.yudao.module.aftersales.service.cancellation.AfterSaleCancellationService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
@Tag(name = "管理后台 - å”®åŽé€€å•/变更受理")
@RestController
@RequestMapping("/aftersales/cancellation")
@Validated
public class AfterSaleCancellationController {
    @Resource
    private AfterSaleCancellationService service;
    @GetMapping("/page")
    @Operation(summary = "获得退单/变更受理分页")
    @PreAuthorize("@ss.hasPermission('aftersales:cancellation:query')")
    public CommonResult<PageResult<AfterSaleCancellationDO>> page(
            @Valid PageParam page,
            @RequestParam(value = "code", required = false) String code,
            @RequestParam(value = "type", required = false) String type,
            @RequestParam(value = "status", required = false) Integer status,
            @RequestParam(value = "customerId", required = false) Long customerId) {
        return success(service.page(page, code, type, status, customerId));
    }
    @GetMapping("/get")
    @Operation(summary = "获得退单/变更受理")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('aftersales:cancellation:query')")
    public CommonResult<AfterSaleCancellationDO> get(@RequestParam("id") Long id) {
        return success(service.get(id));
    }
    @PostMapping("/create")
    @Operation(summary = "创建退单/变更受理")
    @PreAuthorize("@ss.hasPermission('aftersales:cancellation:create')")
    public CommonResult<Long> create(@Valid @RequestBody AfterSaleCancellationSaveReqVO req) {
        req.setId(null);
        return success(service.create(req, getLoginUserId()));
    }
    @PutMapping("/update")
    @Operation(summary = "修改退单/变更受理(仅草稿)")
    @PreAuthorize("@ss.hasPermission('aftersales:cancellation:update')")
    public CommonResult<Boolean> update(@Valid @RequestBody AfterSaleCancellationSaveReqVO req) {
        service.update(req);
        return success(true);
    }
    @PutMapping("/submit")
    @Operation(summary = "提交审批")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('aftersales:cancellation:submit')")
    public CommonResult<Boolean> submit(@RequestParam("id") Long id) {
        service.submit(id);
        return success(true);
    }
    @PutMapping("/approve")
    @Operation(summary = "审批(通过后执行下游动作)")
    @PreAuthorize("@ss.hasPermission('aftersales:cancellation:approve')")
    public CommonResult<Long> approve(@Valid @RequestBody AfterSaleCancellationApproveReqVO req) {
        return success(service.approve(req, getLoginUserId()));
    }
    @PutMapping("/cancel")
    @Operation(summary = "取消受理")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('aftersales:cancellation:cancel')")
    public CommonResult<Boolean> cancel(@RequestParam("id") Long id) {
        service.cancel(id);
        return success(true);
    }
    @DeleteMapping("/delete")
    @Operation(summary = "删除退单/变更受理(仅草稿)")
    @Parameter(name = "id", description = "编号", required = true)
    @PreAuthorize("@ss.hasPermission('aftersales:cancellation:delete')")
    public CommonResult<Boolean> delete(@RequestParam("id") Long id) {
        service.delete(id);
        return success(true);
    }
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/cancellation/vo/AfterSaleCancellationApproveReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,23 @@
package cn.iocoder.yudao.module.aftersales.controller.admin.cancellation.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Schema(description = "管理后台 - å”®åŽé€€å•/变更受理 å®¡æ‰¹ Request VO")
@Data
public class AfterSaleCancellationApproveReqVO {
    @Schema(description = "受理单编号", requiredMode = Schema.RequiredMode.REQUIRED)
    @NotNull(message = "受理单编号不能为空")
    private Long id;
    @Schema(description = "是否通过", requiredMode = Schema.RequiredMode.REQUIRED)
    @NotNull(message = "审批结论不能为空")
    private Boolean approved;
    @Schema(description = "审批意见(驳回时必填)")
    @Size(max = 500)
    private String remark;
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/cancellation/vo/AfterSaleCancellationSaveReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,49 @@
package cn.iocoder.yudao.module.aftersales.controller.admin.cancellation.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.*;
import lombok.Data;
import java.math.BigDecimal;
@Schema(description = "管理后台 - å”®åŽé€€å•/变更受理 ä¿å­˜ Request VO")
@Data
public class AfterSaleCancellationSaveReqVO {
    private Long id;
    @Schema(description = "类型:RETURN é€€è´§ / CONTRACT_CHANGE åˆåŒå˜æ›´ / CONTRACT_TERMINATE åˆåŒç»ˆæ­¢",
            requiredMode = Schema.RequiredMode.REQUIRED)
    @NotBlank
    @Pattern(regexp = "RETURN|CONTRACT_CHANGE|CONTRACT_TERMINATE", message = "受理类型不合法")
    private String type;
    @Schema(description = "关联售后工单编号")
    private Long ticketId;
    @Schema(description = "客户编号")
    private Long customerId;
    @Schema(description = "CRM åˆåŒç¼–号")
    private Long contractId;
    @Schema(description = "销售订单号(无 ERP æ—¶å¯ä¸ºç©ºï¼‰")
    @Size(max = 64)
    private String saleOrderNo;
    @Schema(description = "退货类型:1 é€€æ¬¾é€€è´§ 2 æ¢è´§ 3 ä»…退款")
    private Integer returnType;
    @Schema(description = "申请原因", requiredMode = Schema.RequiredMode.REQUIRED)
    @NotBlank(message = "申请原因不能为空")
    @Size(max = 500)
    private String reason;
    @Schema(description = "预计金额")
    @Digits(integer = 14, fraction = 6)
    private BigDecimal expectedAmount;
    @Schema(description = "备注")
    @Size(max = 500)
    private String remark;
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/dataobject/cancellation/AfterSaleCancellationDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,51 @@
package cn.iocoder.yudao.module.aftersales.dal.dataobject.cancellation;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
 * å”®åŽç»Ÿä¸€é€€å•/变更受理 DO。
 *
 * <p>把「客户退货、合同变更、合同终止」收敛到同一张受理单,
 * ç»Ÿä¸€èµ°å®¡æ‰¹ï¼›å®¡æ‰¹é€šè¿‡åŽæŒ‰ç±»åž‹æ‰§è¡Œä¸‹æ¸¸åŠ¨ä½œï¼š
 * é€€è´§ç”Ÿæˆå”®åŽé€€è´§å•,合同终止作废 CRM åˆåŒã€‚
 */
@TableName("after_sale_cancellation")
@Data
@EqualsAndHashCode(callSuper = true)
public class AfterSaleCancellationDO extends BaseDO {
    @TableId
    private Long id;
    /** å—理单号 */
    private String code;
    /** ç±»åž‹ï¼šRETURN / CONTRACT_CHANGE / CONTRACT_TERMINATE */
    private String type;
    /** çŠ¶æ€ï¼š0 è‰ç¨¿ 1 å¾…审批 2 å·²é€šè¿‡ 3 å·²é©³å›ž 4 å·²å–消 */
    private Integer status;
    private Long ticketId;
    private Long customerId;
    private Long contractId;
    private String saleOrderNo;
    /** é€€è´§ç±»åž‹ï¼š1 é€€æ¬¾é€€è´§ 2 æ¢è´§ 3 ä»…退款 */
    private Integer returnType;
    private String reason;
    private BigDecimal expectedAmount;
    private Long applicantId;
    private Long approverId;
    private LocalDateTime approvalTime;
    private String approvalRemark;
    /** å®¡æ‰¹é€šè¿‡åŽç”Ÿæˆçš„退货单编号 */
    private Long returnId;
    /** ä¸‹æ¸¸åŠ¨ä½œæ˜¯å¦å·²æ‰§è¡Œï¼š0 æœªæ‰§è¡Œ 1 å·²æ‰§è¡Œ */
    private Integer executed;
    /** ä¸‹æ¸¸æ‰§è¡Œè¯´æ˜Žæˆ–失败原因 */
    private String executeRemark;
    private String remark;
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/mysql/cancellation/AfterSaleCancellationMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
package cn.iocoder.yudao.module.aftersales.dal.mysql.cancellation;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.cancellation.AfterSaleCancellationDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AfterSaleCancellationMapper extends BaseMapperX<AfterSaleCancellationDO> {
    default AfterSaleCancellationDO selectByCode(String code) {
        return selectOne(AfterSaleCancellationDO::getCode, code);
    }
    default PageResult<AfterSaleCancellationDO> selectPage(PageParam pageParam, String code, String type,
                                                           Integer status, Long customerId) {
        return selectPage(pageParam, new LambdaQueryWrapperX<AfterSaleCancellationDO>()
                .likeIfPresent(AfterSaleCancellationDO::getCode, code)
                .eqIfPresent(AfterSaleCancellationDO::getType, type)
                .eqIfPresent(AfterSaleCancellationDO::getStatus, status)
                .eqIfPresent(AfterSaleCancellationDO::getCustomerId, customerId)
                .orderByDesc(AfterSaleCancellationDO::getId));
    }
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/mysql/ticket/AfterSaleTicketMapper.java
@@ -56,4 +56,11 @@
        return selectCount(AfterSaleTicketDO::getOwnerUserId, ownerUserId);
    }
    /** æŒ‰é”€å”®è®¢å•号查询工单(质量追溯关联售后故障记录) */
    default List<AfterSaleTicketDO> selectListBySaleOrderNo(String saleOrderNo) {
        return selectList(new LambdaQueryWrapperX<AfterSaleTicketDO>()
                .eq(AfterSaleTicketDO::getSaleOrderNo, saleOrderNo)
                .orderByDesc(AfterSaleTicketDO::getId));
    }
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/redis/no/AfterSaleNoRedisDAO.java
@@ -27,6 +27,11 @@
    public static final String RETURN_NO_PREFIX = "RT";
    /**
     * é€€å•/变更受理 {@link cn.iocoder.yudao.module.aftersales.dal.dataobject.cancellation.AfterSaleCancellationDO}
     */
    public static final String CANCELLATION_NO_PREFIX = "CX";
    /**
     * å”®åŽé—®é¢˜åº“ {@link cn.iocoder.yudao.module.aftersales.dal.dataobject.problem.AfterSaleProblemDO}
     */
    public static final String PROBLEM_NO_PREFIX = "AP";
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/enums/ErrorCodeConstants.java
@@ -48,4 +48,14 @@
    ErrorCode PROBLEM_PUBLISH_FAIL_STATUS = new ErrorCode(1_022_005_004, "问题发布失败,原因:当前状态不可发布");
    ErrorCode PROBLEM_DISABLE_FAIL_STATUS = new ErrorCode(1_022_005_005, "问题停用失败,原因:当前状态不可停用");
    // ========== å”®åŽç»Ÿä¸€é€€å•/变更受理 1-022-006-000 ==========
    ErrorCode CANCELLATION_NOT_EXISTS = new ErrorCode(1_022_006_000, "受理单不存在");
    ErrorCode CANCELLATION_UPDATE_FAIL_STATUS = new ErrorCode(1_022_006_001, "受理单修改失败,原因:仅草稿状态可编辑");
    ErrorCode CANCELLATION_SUBMIT_FAIL_STATUS = new ErrorCode(1_022_006_002, "受理单提交失败,原因:仅草稿状态可提交");
    ErrorCode CANCELLATION_APPROVE_FAIL_STATUS = new ErrorCode(1_022_006_003, "受理单审批失败,原因:仅待审批状态可审批");
    ErrorCode CANCELLATION_CANCEL_FAIL_STATUS = new ErrorCode(1_022_006_004, "受理单取消失败,原因:当前状态不可取消");
    ErrorCode CANCELLATION_APPROVE_REMARK_REQUIRED = new ErrorCode(1_022_006_005, "驳回时必须填写审批意见");
    ErrorCode CANCELLATION_RETURN_TICKET_REQUIRED = new ErrorCode(1_022_006_006, "退货类型受理单必须关联售后工单");
    ErrorCode CANCELLATION_CONTRACT_REQUIRED = new ErrorCode(1_022_006_007, "合同变更/终止必须选择 CRM åˆåŒ");
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/cancellation/AfterSaleCancellationService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,213 @@
package cn.iocoder.yudao.module.aftersales.service.cancellation;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import cn.iocoder.yudao.module.aftersales.controller.admin.cancellation.vo.AfterSaleCancellationApproveReqVO;
import cn.iocoder.yudao.module.aftersales.controller.admin.cancellation.vo.AfterSaleCancellationSaveReqVO;
import cn.iocoder.yudao.module.aftersales.controller.admin.returnobj.vo.AfterSaleReturnSaveReqVO;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.cancellation.AfterSaleCancellationDO;
import cn.iocoder.yudao.module.aftersales.dal.mysql.cancellation.AfterSaleCancellationMapper;
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 jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Objects;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.aftersales.enums.ErrorCodeConstants.*;
/**
 * å”®åŽç»Ÿä¸€é€€å•/变更受理。
 *
 * <p>把「客户退货、合同变更、合同终止」收敛为同一受理流程,不依赖 ERP é”€å”®è®¢å•:
 * <ul>
 *   <li>退货:审批通过后生成售后退货单(复用 {@link AfterSaleReturnService});</li>
 *   <li>合同终止:审批通过后作废 CRM åˆåŒï¼ˆstatus=50);</li>
 *   <li>合同变更:CRM æ— ã€Œå˜æ›´ä¸­ã€çŠ¶æ€ï¼Œå®¡æ‰¹é€šè¿‡ä»…è½åœ°å—ç†è®°å½•ï¼Œå˜æ›´å†…å®¹åœ¨åˆåŒå¤‡æ³¨æˆ–é™„ä»¶ä½“çŽ°ã€‚</li>
 * </ul>
 * ä¸‹æ¸¸åŠ¨ä½œæ‰§è¡Œç»“æžœå†™å…¥ executed / executeRemark,便于失败后人工补做,不静默吞掉错误。
 */
@Slf4j
@Service
public class AfterSaleCancellationService {
    /** çŠ¶æ€ï¼š0 è‰ç¨¿ 1 å¾…审批 2 å·²é€šè¿‡ 3 å·²é©³å›ž 4 å·²å–消 */
    private static final int ST_DRAFT = 0;
    private static final int ST_PENDING = 1;
    private static final int ST_APPROVED = 2;
    private static final int ST_REJECTED = 3;
    private static final int ST_CANCELLED = 4;
    /** CRM åˆåŒä½œåºŸçŠ¶æ€ */
    private static final int CRM_CONTRACT_INVALID = 50;
    @Resource private AfterSaleCancellationMapper mapper;
    @Resource private AfterSaleNoRedisDAO noRedisDAO;
    @Resource private AfterSaleReturnService returnService;
    @Resource private CrmContractApi crmContractApi;
    public PageResult<AfterSaleCancellationDO> page(PageParam page, String code, String type,
                                                    Integer status, Long customerId) {
        return mapper.selectPage(page, code, type, status, customerId);
    }
    public AfterSaleCancellationDO get(Long id) {
        AfterSaleCancellationDO row = id == null ? null : mapper.selectById(id);
        if (row == null) throw exception(CANCELLATION_NOT_EXISTS);
        return row;
    }
    @Transactional(rollbackFor = Exception.class)
    public Long create(AfterSaleCancellationSaveReqVO req, Long userId) {
        validateByType(req);
        var row = BeanUtils.toBean(req, AfterSaleCancellationDO.class);
        row.setStatus(ST_DRAFT);
        row.setApplicantId(userId);
        row.setExecuted(0);
        row.setCode(noRedisDAO.generate(AfterSaleNoRedisDAO.CANCELLATION_NO_PREFIX));
        try {
            mapper.insert(row);
        } catch (DuplicateKeyException e) {
            throw exception(CANCELLATION_NOT_EXISTS);
        }
        return row.getId();
    }
    @Transactional(rollbackFor = Exception.class)
    public void update(AfterSaleCancellationSaveReqVO req) {
        AfterSaleCancellationDO old = get(req.getId());
        if (!Objects.equals(old.getStatus(), ST_DRAFT)) throw exception(CANCELLATION_UPDATE_FAIL_STATUS);
        validateByType(req);
        var update = new AfterSaleCancellationDO();
        BeanUtils.copyProperties(req, update);
        update.setId(old.getId());
        update.setCode(old.getCode());
        update.setStatus(ST_DRAFT);
        mapper.updateById(update);
    }
    @Transactional(rollbackFor = Exception.class)
    public void submit(Long id) {
        AfterSaleCancellationDO old = get(id);
        if (!Objects.equals(old.getStatus(), ST_DRAFT)) throw exception(CANCELLATION_SUBMIT_FAIL_STATUS);
        if (mapper.update(null, new LambdaUpdateWrapper<AfterSaleCancellationDO>()
                .eq(AfterSaleCancellationDO::getId, id)
                .eq(AfterSaleCancellationDO::getStatus, ST_DRAFT)
                .set(AfterSaleCancellationDO::getStatus, ST_PENDING)) != 1) {
            throw exception(CANCELLATION_SUBMIT_FAIL_STATUS);
        }
    }
    @Transactional(rollbackFor = Exception.class)
    public void cancel(Long id) {
        AfterSaleCancellationDO old = get(id);
        if (!Objects.equals(old.getStatus(), ST_DRAFT) && !Objects.equals(old.getStatus(), ST_PENDING)) {
            throw exception(CANCELLATION_CANCEL_FAIL_STATUS);
        }
        if (mapper.update(null, new LambdaUpdateWrapper<AfterSaleCancellationDO>()
                .eq(AfterSaleCancellationDO::getId, id)
                .in(AfterSaleCancellationDO::getStatus, ST_DRAFT, ST_PENDING)
                .set(AfterSaleCancellationDO::getStatus, ST_CANCELLED)) != 1) {
            throw exception(CANCELLATION_CANCEL_FAIL_STATUS);
        }
    }
    public void delete(Long id) {
        AfterSaleCancellationDO old = get(id);
        if (!Objects.equals(old.getStatus(), ST_DRAFT)) throw exception(CANCELLATION_UPDATE_FAIL_STATUS);
        mapper.deleteById(id);
    }
    @javax.annotation.Nullable
    public Long getReturnId(Long id) {
        AfterSaleCancellationDO row = mapper.selectById(id);
        return row == null ? null : row.getReturnId();
    }
    /**
     * å®¡æ‰¹ï¼šé€šè¿‡åŽæ‰§è¡Œä¸‹æ¸¸åŠ¨ä½œï¼›é©³å›žå¿…é¡»å¡«æ„è§ã€‚
     *
     * @return é€šè¿‡æ—¶ç”Ÿæˆçš„退货单编号(非退货类型为 null)
     */
    @Transactional(rollbackFor = Exception.class)
    public Long approve(AfterSaleCancellationApproveReqVO req, Long userId) {
        if (!Boolean.TRUE.equals(req.getApproved())
                && (req.getRemark() == null || req.getRemark().isBlank())) {
            throw exception(CANCELLATION_APPROVE_REMARK_REQUIRED);
        }
        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 (mapper.update(null, new LambdaUpdateWrapper<AfterSaleCancellationDO>()
                .eq(AfterSaleCancellationDO::getId, task.getId())
                .eq(AfterSaleCancellationDO::getStatus, ST_PENDING)
                .set(AfterSaleCancellationDO::getStatus, target)
                .set(AfterSaleCancellationDO::getApproverId, userId)
                .set(AfterSaleCancellationDO::getApprovalTime, LocalDateTime.now())
                .set(AfterSaleCancellationDO::getApprovalRemark, req.getRemark())) != 1) {
            throw exception(CANCELLATION_APPROVE_FAIL_STATUS);
        }
        if (!Boolean.TRUE.equals(req.getApproved())) return null;
        return executeDownstream(task, userId);
    }
    /** å®¡æ‰¹é€šè¿‡åŽçš„下游动作;失败时记录原因但不回滚审批结论,便于人工补做。 */
    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();
                    vo.setTicketId(task.getTicketId());
                    vo.setCustomerId(task.getCustomerId());
                    vo.setReturnType(task.getReturnType() == null ? 1 : task.getReturnType());
                    vo.setReturnReason(task.getReason());
                    returnId = returnService.createReturn(vo, userId);
                    remark = "已生成退货单 " + returnId;
                }
                case "CONTRACT_TERMINATE" -> {
                    crmContractApi.updateContractStatus(task.getContractId(), CRM_CONTRACT_INVALID).getCheckedData();
                    remark = "已作废 CRM åˆåŒ " + task.getContractId();
                }
                case "CONTRACT_CHANGE" -> remark = "变更受理已通过;CRM åˆåŒå†…容请按附件同步维护";
                default -> { ok = false; remark = "未知受理类型:" + 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);
        return returnId;
    }
    /** æŒ‰ç±»åž‹æ ¡éªŒå¿…填:退货需工单;合同变更/终止需合同。 */
    private void validateByType(AfterSaleCancellationSaveReqVO req) {
        switch (req.getType()) {
            case "RETURN" -> {
                if (req.getTicketId() == null) throw exception(CANCELLATION_RETURN_TICKET_REQUIRED);
            }
            case "CONTRACT_CHANGE", "CONTRACT_TERMINATE" -> {
                if (req.getContractId() == null) throw exception(CANCELLATION_CONTRACT_REQUIRED);
            }
            default -> { }
        }
    }
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/ticket/AfterSaleTicketService.java
@@ -47,6 +47,14 @@
    AfterSaleTicketDO getTicket(Long id);
    /**
     * æŒ‰é”€å”®è®¢å•号获得工单列表(供质量追溯关联售后故障记录)
     *
     * @param saleOrderNo é”€å”®è®¢å•号
     * @return å·¥å•列表
     */
    List<AfterSaleTicketDO> getTicketListBySaleOrderNo(String saleOrderNo);
    /**
     * èŽ·å¾—å·¥å•åˆ—è¡¨
     *
     * @param ids ç¼–号列表
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/ticket/AfterSaleTicketServiceImpl.java
@@ -140,6 +140,14 @@
    }
    @Override
    public List<AfterSaleTicketDO> getTicketListBySaleOrderNo(String saleOrderNo) {
        if (saleOrderNo == null || saleOrderNo.isBlank()) {
            return List.of();
        }
        return ticketMapper.selectListBySaleOrderNo(saleOrderNo);
    }
    @Override
    public List<AfterSaleTicketDO> getTicketList(Collection<Long> ids) {
        if (CollUtil.isEmpty(ids)) {
            return List.of();
yudao-module-aftersales/src/test/java/cn/iocoder/yudao/module/aftersales/service/collaboration/AfterSaleCollaborationServiceTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,204 @@
package cn.iocoder.yudao.module.aftersales.service.collaboration;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.collaboration.AfterSaleCollaborationDO;
import cn.iocoder.yudao.module.aftersales.dal.mysql.collaboration.AfterSaleCollaborationMapper;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.ticket.AfterSaleTicketDO;
import cn.iocoder.yudao.module.aftersales.service.ticket.AfterSaleTicketService;
import cn.iocoder.yudao.module.aftersales.service.permission.AfterSalePermissionService;
import cn.iocoder.yudao.framework.common.biz.system.permission.PermissionCommonApi;
import cn.iocoder.yudao.module.erp.api.finance.AfterSaleFinanceDraftApi;
import cn.iocoder.yudao.module.erp.api.finance.dto.AfterSaleFinanceDraftCreateDTO;
import cn.iocoder.yudao.module.erp.api.finance.dto.AfterSaleFinanceReviewDTO;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.srm.api.supplier.SrmSupplierApi;
import cn.iocoder.yudao.module.srm.api.supplier.dto.SrmSupplierRespDTO;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.crm.api.customer.CrmCustomerApi;
import cn.iocoder.yudao.module.crm.api.customer.dto.CrmCustomerRespDTO;
import java.math.BigDecimal;
import java.util.Map;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.mockito.ArgumentCaptor;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
class AfterSaleCollaborationServiceTest {
    @Test
    void warrantyUsesTicketCustomerInsteadOfSubmittedName() {
        var tickets = mock(AfterSaleTicketService.class);
        var ticket = new AfterSaleTicketDO(); ticket.setId(8L); ticket.setOwnerUserId(10L); ticket.setStatus(10); ticket.setCustomerId(99L);
        when(tickets.getTicket(8L)).thenReturn(ticket);
        var customers = mock(CrmCustomerApi.class);
        var customer = new CrmCustomerRespDTO(); customer.setId(99L); customer.setName("工单客户");
        when(customers.getCustomer(99L)).thenReturn(customer);
        var mapper = mock(AfterSaleCollaborationMapper.class);
        var service = new AfterSaleCollaborationService();
        ReflectionTestUtils.setField(service, "ticketService", tickets);
        ReflectionTestUtils.setField(service, "mapper", mapper);
        ReflectionTestUtils.setField(service, "customerApi", customers);
        ReflectionTestUtils.setField(service, "userApi", mock(AdminUserApi.class));
        var input = new AfterSaleCollaborationService.CreateRequest();
        input.setTicketId(8L); input.setAssigneeUserId(20L); input.setCategory("WARRANTY");
        input.setTitle("三包"); input.setContent("核查"); input.setAmount(BigDecimal.TEN);
        input.setDirection("PAYABLE"); input.setPartyName("伪造名称");
        service.create(input, 10L);
        var created = ArgumentCaptor.forClass(AfterSaleCollaborationDO.class);
        verify(mapper).insert(created.capture());
        assertEquals("工单客户", created.getValue().getPartyName());
        assertEquals("CUSTOMER", created.getValue().getPartyType());
        assertEquals(99L, created.getValue().getPartyId());
    }
    @Test
    void supplierClaimRequiresMasterSupplier() {
        var tickets = mock(AfterSaleTicketService.class);
        var ticket = new AfterSaleTicketDO(); ticket.setId(8L); ticket.setOwnerUserId(10L); ticket.setStatus(10);
        when(tickets.getTicket(8L)).thenReturn(ticket);
        var service = new AfterSaleCollaborationService();
        ReflectionTestUtils.setField(service, "ticketService", tickets);
        var mapper = mock(AfterSaleCollaborationMapper.class);
        ReflectionTestUtils.setField(service, "mapper", mapper);
        ReflectionTestUtils.setField(service, "userApi", mock(AdminUserApi.class));
        var input = new AfterSaleCollaborationService.CreateRequest();
        input.setTicketId(8L); input.setAssigneeUserId(20L); input.setCategory("SUPPLIER");
        input.setTitle("索赔"); input.setContent("核查"); input.setAmount(BigDecimal.TEN);
        input.setDirection("RECEIVABLE"); input.setPartyName("任意填写的供应商");
        assertThrows(RuntimeException.class, () -> service.create(input, 10L));
        verifyNoInteractions(mapper);
    }
    @Test
    void rejectedFinanceDraftCanBeRetriedWithTraceableNewTask() {
        var mapper = mock(AfterSaleCollaborationMapper.class);
        var tickets = mock(AfterSaleTicketService.class);
        var finance = mock(AfterSaleFinanceDraftApi.class);
        var previous = new AfterSaleCollaborationDO(); previous.setId(1L); previous.setTicketId(8L);
        previous.setRequestedBy(10L); previous.setCategory("SUPPLIER"); previous.setStatus(3); previous.setFinanceDraftId(50L);
        when(mapper.selectById(1L)).thenReturn(previous);
        var ticket = new AfterSaleTicketDO(); ticket.setId(8L); ticket.setOwnerUserId(10L); ticket.setStatus(10);
        when(tickets.getTicket(8L)).thenReturn(ticket);
        var review = new AfterSaleFinanceReviewDTO(); review.setStatus(2);
        when(finance.getReviewResults(List.of(50L))).thenReturn(Map.of(50L, review));
        when(mapper.insert(any(AfterSaleCollaborationDO.class))).thenAnswer(call -> {
            AfterSaleCollaborationDO created = call.getArgument(0); created.setId(2L); return 1;
        });
        var service = new AfterSaleCollaborationService();
        ReflectionTestUtils.setField(service, "mapper", mapper); ReflectionTestUtils.setField(service, "ticketService", tickets);
        ReflectionTestUtils.setField(service, "financeDraftApi", finance); ReflectionTestUtils.setField(service, "userApi", mock(AdminUserApi.class));
        var suppliers = mock(SrmSupplierApi.class);
        var supplier = new SrmSupplierRespDTO(); supplier.setId(600L); supplier.setName("主数据供应商"); supplier.setStatus(0);
        when(suppliers.getSupplier(600L)).thenReturn(CommonResult.success(supplier));
        ReflectionTestUtils.setField(service, "supplierApi", suppliers);
        var input = new AfterSaleCollaborationService.CreateRequest(); input.setPreviousId(1L); input.setTicketId(8L);
        input.setAssigneeUserId(20L); input.setCategory("SUPPLIER"); input.setTitle("重办"); input.setContent("补充材料");
        input.setAmount(new BigDecimal("100.00")); input.setDirection("RECEIVABLE"); input.setPartyName("伪造名称"); input.setPartyId(600L);
        assertEquals(2L, service.create(input, 10L));
        verify(mapper).insert((AfterSaleCollaborationDO) argThat(value -> Long.valueOf(1L).equals(((AfterSaleCollaborationDO) value).getPreviousId())));
        var created = ArgumentCaptor.forClass(AfterSaleCollaborationDO.class);
        verify(mapper).insert(created.capture());
        assertEquals("主数据供应商", created.getValue().getPartyName());
        assertEquals("SUPPLIER", created.getValue().getPartyType());
        assertEquals(600L, created.getValue().getPartyId());
    }
    @Test
    void businessApprovalCreatesOneFinanceDraftFromSubmittedAmount() {
        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), AfterSaleCollaborationDO.class);
        var mapper = mock(AfterSaleCollaborationMapper.class);
        var tickets = mock(AfterSaleTicketService.class);
        var finance = mock(AfterSaleFinanceDraftApi.class);
        var task = new AfterSaleCollaborationDO(); task.setId(1L); task.setTicketId(8L); task.setStatus(1);
        task.setAmount(new BigDecimal("123.45")); task.setCategory("SUPPLIER"); task.setDirection("RECEIVABLE");
        task.setPartyName("供应商"); task.setResult("核查完成");
        task.setPartyType("SUPPLIER"); task.setPartyId(600L);
        when(mapper.selectById(1L)).thenReturn(task);
        var ticket = new AfterSaleTicketDO(); ticket.setId(8L); ticket.setOwnerUserId(10L);
        when(tickets.getTicket(8L)).thenReturn(ticket);
        when(mapper.update(any(), any())).thenReturn(1);
        when(mapper.updateById(any(AfterSaleCollaborationDO.class))).thenReturn(1);
        when(finance.createDraft(any())).thenReturn(50L);
        var service = new AfterSaleCollaborationService();
        ReflectionTestUtils.setField(service, "mapper", mapper); ReflectionTestUtils.setField(service, "ticketService", tickets);
        ReflectionTestUtils.setField(service, "financeDraftApi", finance);
        service.reviewFinancial(1L, 10L, true, "同意");
        var capture = ArgumentCaptor.forClass(AfterSaleFinanceDraftCreateDTO.class);
        verify(finance).createDraft(capture.capture());
        assertEquals(new BigDecimal("123.45"), capture.getValue().getAmount());
        assertEquals(10L, capture.getValue().getBusinessApproverId());
        assertEquals(600L, capture.getValue().getPartyId());
        verify(mapper).updateById((AfterSaleCollaborationDO) argThat(value -> Long.valueOf(50L).equals(((AfterSaleCollaborationDO) value).getFinanceDraftId())));
    }
    @Test
    void onlyRequesterCanCancelPendingTask() {
        var mapper = mock(AfterSaleCollaborationMapper.class);
        var task = new AfterSaleCollaborationDO(); task.setId(2L); task.setRequestedBy(10L); task.setStatus(0);
        when(mapper.selectById(2L)).thenReturn(task);
        var service = new AfterSaleCollaborationService();
        ReflectionTestUtils.setField(service, "mapper", mapper);
        assertThrows(RuntimeException.class, () -> service.cancel(2L, 20L));
        verify(mapper, never()).update(any(), any());
    }
    @Test
    void unrelatedUserCannotCreateTasksForSomeoneElsesTicket() {
        var mapper = mock(AfterSaleCollaborationMapper.class);
        var tickets = mock(AfterSaleTicketService.class);
        var ticket = new AfterSaleTicketDO(); ticket.setId(8L); ticket.setOwnerUserId(10L); ticket.setStatus(10);
        when(tickets.getTicket(8L)).thenReturn(ticket);
        var service = new AfterSaleCollaborationService();
        ReflectionTestUtils.setField(service, "mapper", mapper);
        ReflectionTestUtils.setField(service, "ticketService", tickets);
        ReflectionTestUtils.setField(service, "permissionService", mock(AfterSalePermissionService.class));
        ReflectionTestUtils.setField(service, "permissionApi", mock(PermissionCommonApi.class));
        var input = new AfterSaleCollaborationService.CreateRequest();
        input.setTicketId(8L); input.setAssigneeUserId(20L); input.setCategory("FINANCE");
        input.setAmount(new BigDecimal("100.00")); input.setDirection("INTERNAL");
        input.setTitle("核查"); input.setContent("核查处理情况");
        assertThrows(RuntimeException.class, () -> service.create(input, 30L));
        verifyNoInteractions(mapper);
    }
    @Test
    void unauthorizedBusinessReviewerCannotGenerateFinanceDraft() {
        var mapper = mock(AfterSaleCollaborationMapper.class);
        var tickets = mock(AfterSaleTicketService.class);
        var finance = mock(AfterSaleFinanceDraftApi.class);
        var task = new AfterSaleCollaborationDO(); task.setId(1L); task.setTicketId(8L); task.setStatus(1); task.setAmount(BigDecimal.TEN);
        when(mapper.selectById(1L)).thenReturn(task);
        var ticket = new AfterSaleTicketDO(); ticket.setId(8L); ticket.setOwnerUserId(10L);
        when(tickets.getTicket(8L)).thenReturn(ticket);
        var service = new AfterSaleCollaborationService();
        ReflectionTestUtils.setField(service, "mapper", mapper); ReflectionTestUtils.setField(service, "ticketService", tickets);
        ReflectionTestUtils.setField(service, "financeDraftApi", finance);
        ReflectionTestUtils.setField(service, "permissionService", mock(AfterSalePermissionService.class));
        ReflectionTestUtils.setField(service, "permissionApi", mock(PermissionCommonApi.class));
        assertThrows(RuntimeException.class, () -> service.reviewFinancial(1L, 30L, true, null));
        verifyNoInteractions(finance);
        verify(mapper, never()).update(any(), any());
    }
    @Test
    void onlyAssigneeCanCompleteTask() {
        var mapper = mock(AfterSaleCollaborationMapper.class);
        var task = new AfterSaleCollaborationDO(); task.setId(1L); task.setAssigneeUserId(10L); task.setStatus(0);
        when(mapper.selectById(1L)).thenReturn(task);
        var service = new AfterSaleCollaborationService();
        ReflectionTestUtils.setField(service, "mapper", mapper);
        assertThrows(RuntimeException.class, () -> service.complete(1L, 20L, "已处理"));
        verify(mapper, never()).update(any(), any());
    }
    @Test
    void completionRequiresResult() {
        var mapper = mock(AfterSaleCollaborationMapper.class);
        var service = new AfterSaleCollaborationService();
        ReflectionTestUtils.setField(service, "mapper", mapper);
        assertThrows(RuntimeException.class, () -> service.complete(1L, 10L, " "));
        verifyNoInteractions(mapper);
    }
}
yudao-module-aftersales/src/test/java/cn/iocoder/yudao/module/aftersales/service/returnobj/AfterSaleReturnMesLinkTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,104 @@
package cn.iocoder.yudao.module.aftersales.service.returnobj;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.returnobj.AfterSaleReturnDO;
import cn.iocoder.yudao.module.aftersales.controller.admin.returnobj.vo.AfterSaleReturnSaveReqVO;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.returnobj.AfterSaleReturnItemDO;
import cn.iocoder.yudao.module.aftersales.dal.mysql.returnobj.AfterSaleReturnMapper;
import cn.iocoder.yudao.module.aftersales.dal.mysql.returnobj.AfterSaleReturnItemMapper;
import cn.iocoder.yudao.module.aftersales.dal.mysql.ticket.AfterSaleTicketMapper;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.ticket.AfterSaleTicketDO;
import cn.iocoder.yudao.module.aftersales.dal.redis.no.AfterSaleNoRedisDAO;
import cn.iocoder.yudao.module.aftersales.service.permission.AfterSalePermissionService;
import cn.iocoder.yudao.module.mes.api.returnsales.MesWmReturnSalesApi;
import cn.iocoder.yudao.module.mes.api.returnsales.dto.MesWmReturnSalesLineCreateDTO;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class AfterSaleReturnMesLinkTest {
    @Test
    void refundOnlyDoesNotCreatePhysicalReturnDraft() {
        var mapper = mock(AfterSaleReturnMapper.class);
        var service = new AfterSaleReturnServiceImpl();
        ReflectionTestUtils.setField(service, "returnMapper", mapper);
        var tickets = mock(AfterSaleTicketMapper.class);
        when(tickets.selectById(8L)).thenReturn(new AfterSaleTicketDO().setId(8L));
        ReflectionTestUtils.setField(service, "ticketMapper", tickets);
        ReflectionTestUtils.setField(service, "noRedisDAO", mock(AfterSaleNoRedisDAO.class));
        ReflectionTestUtils.setField(service, "permissionService", mock(AfterSalePermissionService.class));
        var input = new AfterSaleReturnSaveReqVO(); input.setReturnType(3); input.setTicketId(8L);
        assertThrows(RuntimeException.class, () -> service.createReturn(input, 1L));
        verifyNoInteractions(mapper);
    }
    @Test
    void noPhysicalLinesDoesNotCreateMesDocument() {
        var mapper = mock(AfterSaleReturnMapper.class);
        var items = mock(AfterSaleReturnItemMapper.class);
        var api = mock(MesWmReturnSalesApi.class);
        when(mapper.selectById(1L)).thenReturn(new AfterSaleReturnDO().setId(1L).setStatus(0).setReturnType(1));
        when(items.selectListByReturnId(1L)).thenReturn(List.of());
        var service = new AfterSaleReturnServiceImpl();
        ReflectionTestUtils.setField(service, "returnMapper", mapper);
        ReflectionTestUtils.setField(service, "returnItemMapper", items);
        ReflectionTestUtils.setField(service, "mesWmReturnSalesApi", api);
        assertThrows(RuntimeException.class, () -> service.approveReturn(1L));
        verifyNoInteractions(api);
    }
    @Test
    void lostApprovalRaceDoesNotCreateSecondMesDocument() {
        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), AfterSaleReturnDO.class);
        var mapper = mock(AfterSaleReturnMapper.class);
        var items = mock(AfterSaleReturnItemMapper.class);
        var api = mock(MesWmReturnSalesApi.class);
        when(mapper.selectById(1L)).thenReturn(new AfterSaleReturnDO().setId(1L).setStatus(0).setReturnType(1));
        var line = new AfterSaleReturnItemDO(); line.setItemId(4L); line.setReturnQuantity(BigDecimal.ONE);
        when(items.selectListByReturnId(1L)).thenReturn(List.of(line));
        when(mapper.update(any(), any())).thenReturn(0);
        var service = new AfterSaleReturnServiceImpl();
        ReflectionTestUtils.setField(service, "returnMapper", mapper);
        ReflectionTestUtils.setField(service, "returnItemMapper", items);
        ReflectionTestUtils.setField(service, "mesWmReturnSalesApi", api);
        assertThrows(RuntimeException.class, () -> service.approveReturn(1L));
        verifyNoInteractions(api);
    }
    @Test
    void approvalCopiesReturnLinesAndPersistsTheirMesIds() {
        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), AfterSaleReturnDO.class);
        var mapper = mock(AfterSaleReturnMapper.class);
        var items = mock(AfterSaleReturnItemMapper.class);
        var api = mock(MesWmReturnSalesApi.class);
        var source = new AfterSaleReturnDO().setId(1L).setTicketId(2L).setStatus(0).setNo("RET-1").setReturnType(1);
        when(mapper.selectById(1L)).thenReturn(source);
        when(mapper.update(any(), any())).thenReturn(1);
        when(mapper.updateById(any(AfterSaleReturnDO.class))).thenReturn(1);
        when(items.updateById(any(AfterSaleReturnItemDO.class))).thenReturn(1);
        var line = new AfterSaleReturnItemDO(); line.setId(3L); line.setReturnId(1L); line.setItemId(4L);
        line.setReturnQuantity(new BigDecimal("2.50")); line.setBatchId(5L); line.setBatchCode("B-1");
        when(items.selectListByReturnId(1L)).thenReturn(List.of(line));
        when(api.createReturnSales(any())).thenReturn(6L);
        when(api.createReturnSalesLine(any())).thenReturn(7L);
        var service = new AfterSaleReturnServiceImpl();
        ReflectionTestUtils.setField(service, "returnMapper", mapper);
        ReflectionTestUtils.setField(service, "returnItemMapper", items);
        ReflectionTestUtils.setField(service, "ticketMapper", mock(AfterSaleTicketMapper.class));
        ReflectionTestUtils.setField(service, "mesWmReturnSalesApi", api);
        service.approveReturn(1L);
        var capture = ArgumentCaptor.forClass(MesWmReturnSalesLineCreateDTO.class);
        verify(api).createReturnSalesLine(capture.capture());
        assertEquals(6L, capture.getValue().getReturnId());
        assertEquals(4L, capture.getValue().getItemId());
        assertEquals(new BigDecimal("2.50"), capture.getValue().getQuantity());
        assertEquals(5L, capture.getValue().getBatchId());
        var updated = ArgumentCaptor.forClass(AfterSaleReturnItemDO.class);
        verify(items).updateById(updated.capture());
        assertEquals(7L, updated.getValue().getMesReturnLineId());
    }
}
yudao-module-aftersales/src/test/java/cn/iocoder/yudao/module/aftersales/service/ticket/AfterSaleLedgerServiceTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,30 @@
package cn.iocoder.yudao.module.aftersales.service.ticket;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.aftersales.controller.admin.ticket.vo.ticket.AfterSaleTicketPageReqVO;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
class AfterSaleLedgerServiceTest {
    @Test
    void countsUseTheSameUserAndSceneAsTheTicketList() {
        var tickets = mock(AfterSaleTicketService.class);
        when(tickets.getTicketPage(any(), eq(99L))).thenAnswer(call -> {
            AfterSaleTicketPageReqVO request = call.getArgument(0);
            assertEquals(2, request.getSceneType());
            assertEquals(1, request.getPageSize());
            return new PageResult<>(List.of(), request.getStatus().longValue());
        });
        var service = new AfterSaleLedgerService();
        ReflectionTestUtils.setField(service, "ticketService", tickets);
        var result = service.overview(99L, 2);
        assertEquals(10L, result.get(10));
        assertEquals(60L, result.get(60));
        verify(tickets, times(7)).getTicketPage(any(), eq(99L));
    }
}
yudao-module-erp/src/test/java/cn/iocoder/yudao/module/erp/api/sale/ErpSaleOrderNoticeLockTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
package cn.iocoder.yudao.module.erp.api.sale;
import cn.iocoder.yudao.module.erp.dal.dataobject.sale.ErpSaleOrderDO;
import cn.iocoder.yudao.module.erp.dal.mysql.sale.ErpSaleOrderMapper;
import cn.iocoder.yudao.module.erp.enums.ErpAuditStatus;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class ErpSaleOrderNoticeLockTest {
    @Test
    void cancelledOrderCannotBeUsedForNotice() {
        var mapper = mock(ErpSaleOrderMapper.class);
        when(mapper.selectByIdForUpdate(1L)).thenReturn(new ErpSaleOrderDO().setId(1L).setNo("SO-1").setStatus(ErpAuditStatus.CANCEL.getStatus()));
        var api = new ErpSaleOrderApiImpl(); ReflectionTestUtils.setField(api, "saleOrderMapper", mapper);
        assertThrows(RuntimeException.class, () -> api.lockSaleOrderForNotice(1L, "SO-1"));
    }
    @Test
    void orderCodeMustMatchLockedOrder() {
        var mapper = mock(ErpSaleOrderMapper.class);
        when(mapper.selectByIdForUpdate(1L)).thenReturn(new ErpSaleOrderDO().setId(1L).setNo("SO-1").setStatus(ErpAuditStatus.APPROVE.getStatus()));
        var api = new ErpSaleOrderApiImpl(); ReflectionTestUtils.setField(api, "saleOrderMapper", mapper);
        assertThrows(RuntimeException.class, () -> api.lockSaleOrderForNotice(1L, "SO-2"));
    }
}
yudao-module-erp/src/test/java/cn/iocoder/yudao/module/erp/service/finance/ErpAfterSaleFinanceDraftServiceTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,53 @@
package cn.iocoder.yudao.module.erp.service.finance;
import cn.iocoder.yudao.module.erp.api.finance.dto.AfterSaleFinanceDraftCreateDTO;
import cn.iocoder.yudao.module.erp.dal.dataobject.finance.ErpAfterSaleFinanceDraftDO;
import cn.iocoder.yudao.module.erp.dal.mysql.finance.ErpAfterSaleFinanceDraftMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.*;
class ErpAfterSaleFinanceDraftServiceTest {
    @Test
    void exposesReviewResultByDraftIdWithoutChangingIt() {
        var mapper = mock(ErpAfterSaleFinanceDraftMapper.class);
        var draft = new ErpAfterSaleFinanceDraftDO(); draft.setId(4L); draft.setStatus(2); draft.setReviewRemark("材料不全");
        when(mapper.selectBatchIds(List.of(4L))).thenReturn(List.of(draft));
        var service = new ErpAfterSaleFinanceDraftService();
        ReflectionTestUtils.setField(service, "mapper", mapper);
        var result = service.getReviewResults(List.of(4L));
        assertEquals(2, result.get(4L).getStatus());
        assertEquals("材料不全", result.get(4L).getRemark());
    }
    @Test
    void rejectsNonPositiveAmountBeforeInsert() {
        var mapper = mock(ErpAfterSaleFinanceDraftMapper.class);
        var service = new ErpAfterSaleFinanceDraftService();
        ReflectionTestUtils.setField(service, "mapper", mapper);
        var input = new AfterSaleFinanceDraftCreateDTO(); input.setCollaborationId(1L); input.setTicketId(2L);
        input.setBusinessApproverId(3L); input.setCategory("QUALITY"); input.setDirection("INTERNAL");
        input.setDescription("费用说明"); input.setAmount(BigDecimal.ZERO);
        assertThrows(RuntimeException.class, () -> service.createDraft(input));
        input.setAmount(new BigDecimal("1.001"));
        assertThrows(RuntimeException.class, () -> service.createDraft(input));
        input.setAmount(new BigDecimal("10000000000000000.00"));
        assertThrows(RuntimeException.class, () -> service.createDraft(input));
        verifyNoInteractions(mapper);
    }
    @Test
    void onlyPendingDraftCanBeReviewed() {
        var mapper = mock(ErpAfterSaleFinanceDraftMapper.class);
        var draft = new ErpAfterSaleFinanceDraftDO(); draft.setId(4L); draft.setStatus(1);
        when(mapper.selectById(4L)).thenReturn(draft);
        var service = new ErpAfterSaleFinanceDraftService();
        ReflectionTestUtils.setField(service, "mapper", mapper);
        assertThrows(RuntimeException.class, () -> service.review(4L, 9L, true, "确认"));
        verify(mapper, never()).update(any(), any());
    }
}
yudao-module-erp/src/test/java/cn/iocoder/yudao/module/erp/service/sale/ErpSaleOrderCancellationGuardTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,96 @@
package cn.iocoder.yudao.module.erp.service.sale;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
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.ErpSaleOrderMapper;
import cn.iocoder.yudao.module.erp.dal.mysql.sale.ErpSaleOrderItemMapper;
import cn.iocoder.yudao.module.erp.enums.ErpAuditStatus;
import cn.iocoder.yudao.module.mes.api.mps.MesProMpsApi;
import cn.iocoder.yudao.module.mes.api.mps.dto.MesProMpsRespDTO;
import cn.iocoder.yudao.module.mes.api.salesnotice.MesWmSalesNoticeApi;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class ErpSaleOrderCancellationGuardTest {
    @Test
    void missingDeliveryNoticeResultFailsClosed() {
        var orders = mock(ErpSaleOrderMapper.class);
        var items = mock(ErpSaleOrderItemMapper.class);
        var notices = mock(MesWmSalesNoticeApi.class);
        when(orders.selectByIdForUpdate(1L)).thenReturn(new ErpSaleOrderDO().setId(1L).setNo("TEST").setStatus(ErpAuditStatus.APPROVE.getStatus()).setOutCount(BigDecimal.ZERO));
        when(items.selectListByOrderId(1L)).thenReturn(List.of());
        when(notices.checkSalesNoticeBySaleOrderIds(List.of(1L))).thenReturn(Map.of());
        var service = new ErpSaleOrderServiceImpl();
        ReflectionTestUtils.setField(service, "saleOrderMapper", orders);
        ReflectionTestUtils.setField(service, "saleOrderItemMapper", items);
        ReflectionTestUtils.setField(service, "salesNoticeApi", notices);
        assertThrows(ServiceException.class, () -> service.cancelSaleOrder(1L));
        verify(orders, never()).updateByIdAndStatus(anyLong(), anyInt(), any());
    }
    @Test
    void existingDeliveryNoticeBlocksOrderCancellation() {
        var orderMapper = mock(ErpSaleOrderMapper.class);
        var itemMapper = mock(ErpSaleOrderItemMapper.class);
        var mpsApi = mock(MesProMpsApi.class);
        var noticeApi = mock(MesWmSalesNoticeApi.class);
        var order = new ErpSaleOrderDO().setId(1L).setNo("TEST").setStatus(ErpAuditStatus.APPROVE.getStatus()).setOutCount(BigDecimal.ZERO);
        when(orderMapper.selectByIdForUpdate(1L)).thenReturn(order);
        when(itemMapper.selectListByOrderId(1L)).thenReturn(List.of());
        when(noticeApi.checkSalesNoticeBySaleOrderIds(List.of(1L))).thenReturn(Map.of(1L, true));
        var service = new ErpSaleOrderServiceImpl();
        ReflectionTestUtils.setField(service, "saleOrderMapper", orderMapper);
        ReflectionTestUtils.setField(service, "saleOrderItemMapper", itemMapper);
        ReflectionTestUtils.setField(service, "mpsApi", mpsApi);
        ReflectionTestUtils.setField(service, "salesNoticeApi", noticeApi);
        assertThrows(ServiceException.class, () -> service.cancelSaleOrder(1L));
        verify(orderMapper, never()).updateByIdAndStatus(anyLong(), anyInt(), any());
    }
    @Test
    void canceledPlansAllowOrderCancellation() {
        var orderMapper = mock(ErpSaleOrderMapper.class);
        var itemMapper = mock(ErpSaleOrderItemMapper.class);
        var mpsApi = mock(MesProMpsApi.class);
        var noticeApi = mock(MesWmSalesNoticeApi.class);
        when(noticeApi.checkSalesNoticeBySaleOrderIds(List.of(1L))).thenReturn(Map.of(1L, false));
        var order = new ErpSaleOrderDO().setId(1L).setNo("TEST").setStatus(ErpAuditStatus.APPROVE.getStatus()).setOutCount(BigDecimal.ZERO);
        when(orderMapper.selectByIdForUpdate(1L)).thenReturn(order);
        when(orderMapper.updateByIdAndStatus(eq(1L), anyInt(), any())).thenReturn(1);
        when(itemMapper.selectListByOrderId(1L)).thenReturn(List.of(new ErpSaleOrderItemDO().setId(8L)));
        var plan = new MesProMpsRespDTO(); plan.setStatus(3);
        when(mpsApi.getMpsListBySaleOrderItemIds(List.of(8L))).thenReturn(List.of(plan));
        var service = new ErpSaleOrderServiceImpl();
        ReflectionTestUtils.setField(service, "saleOrderMapper", orderMapper);
        ReflectionTestUtils.setField(service, "saleOrderItemMapper", itemMapper);
        ReflectionTestUtils.setField(service, "mpsApi", mpsApi);
        ReflectionTestUtils.setField(service, "salesNoticeApi", noticeApi);
        assertDoesNotThrow(() -> service.cancelSaleOrder(1L));
        verify(orderMapper).updateByIdAndStatus(eq(1L), anyInt(), any());
    }
    @Test
    void activeProductionPlanBlocksOrderCancellation() {
        var orderMapper = mock(ErpSaleOrderMapper.class);
        var itemMapper = mock(ErpSaleOrderItemMapper.class);
        var mpsApi = mock(MesProMpsApi.class);
        var order = new ErpSaleOrderDO().setId(1L).setNo("TEST").setStatus(ErpAuditStatus.APPROVE.getStatus()).setOutCount(BigDecimal.ZERO);
        when(orderMapper.selectByIdForUpdate(1L)).thenReturn(order);
        var item = new ErpSaleOrderItemDO().setId(8L);
        when(itemMapper.selectListByOrderId(1L)).thenReturn(List.of(item));
        var plan = new MesProMpsRespDTO(); plan.setStatus(1);
        when(mpsApi.getMpsListBySaleOrderItemIds(List.of(8L))).thenReturn(List.of(plan));
        var service = new ErpSaleOrderServiceImpl();
        ReflectionTestUtils.setField(service, "saleOrderMapper", orderMapper);
        ReflectionTestUtils.setField(service, "saleOrderItemMapper", itemMapper);
        ReflectionTestUtils.setField(service, "mpsApi", mpsApi);
        assertThrows(ServiceException.class, () -> service.cancelSaleOrder(1L));
        verify(orderMapper, never()).updateByIdAndStatus(anyLong(), anyInt(), any());
    }
}
yudao-module-erp/src/test/java/cn/iocoder/yudao/module/erp/service/statistics/PurchasePriceMetricsTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,15 @@
package cn.iocoder.yudao.module.erp.service.statistics;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;
class PurchasePriceMetricsTest {
    @Test
    void comparesLatestPriceToPreviousAndAvoidsDivisionByZero() {
        assertEquals(new BigDecimal("25.00"), PurchasePriceMetrics.changeRate(new BigDecimal("10"), new BigDecimal("8")));
        assertNull(PurchasePriceMetrics.changeRate(new BigDecimal("10"), BigDecimal.ZERO));
        assertNull(PurchasePriceMetrics.changeRate(new BigDecimal("10"), null));
        assertEquals(new BigDecimal("-20.00"), PurchasePriceMetrics.changeRate(new BigDecimal("8"), new BigDecimal("10")));
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/collect/vo/MesCollectDowntimeDetailRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,71 @@
package cn.iocoder.yudao.module.mes.controller.admin.collect.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
 * è®¾å¤‡åœæœºæ˜Žç»†ï¼ˆæŒ‰é‡‡é›†é—´éš”æŽ¨ç®—)。
 *
 * <p>口径:两次采样间隔超过在线超时阈值即视为离线停机;
 * é¦–次采样之前不作推断;通信中断同样计入停机,但不标记为机械故障。
 */
@Schema(description = "管理后台 - è®¾å¤‡åœæœºæ˜Žç»† Response VO")
@Data
public class MesCollectDowntimeDetailRespVO {
    @Schema(description = "统计开始时间")
    private LocalDateTime startTime;
    @Schema(description = "统计结束时间")
    private LocalDateTime endTime;
    @Schema(description = "在线超时阈值(分钟)")
    private Integer timeoutMinutes;
    @Schema(description = "统计区间总时长(分钟)")
    private Long totalMinutes;
    @Schema(description = "停机合计(分钟)")
    private Long downtimeMinutes;
    @Schema(description = "停机率(%)")
    private Double downtimeRate;
    @Schema(description = "按设备明细")
    private List<Item> items;
    @Schema(description = "单设备停机明细")
    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public static class Item {
        @Schema(description = "设备台账编号")
        private Long machineryId;
        @Schema(description = "设备编码")
        private String machineryCode;
        @Schema(description = "设备名称")
        private String machineryName;
        @Schema(description = "数采设备编码")
        private String deviceCode;
        @Schema(description = "采样数")
        private Integer sampleCount;
        @Schema(description = "停机次数")
        private Integer downtimeCount;
        @Schema(description = "停机合计(分钟)")
        private Long downtimeMinutes;
        @Schema(description = "停机区间明细")
        private List<Interval> intervals;
    }
    @Schema(description = "停机区间")
    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public static class Interval {
        @Schema(description = "停机开始")
        private LocalDateTime start;
        @Schema(description = "停机结束")
        private LocalDateTime end;
        @Schema(description = "持续秒数")
        private Long seconds;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/dv/repair/MesDvRepairController.java
@@ -8,12 +8,14 @@
import cn.iocoder.yudao.framework.common.util.collection.MapUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils;
import cn.iocoder.yudao.module.mes.controller.admin.dv.repair.vo.MesDvRepairDiagnosisRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.dv.repair.vo.MesDvRepairPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.dv.repair.vo.MesDvRepairRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.dv.repair.vo.MesDvRepairSaveReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.dv.machinery.MesDvMachineryDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.dv.repair.MesDvRepairDO;
import cn.iocoder.yudao.module.mes.service.dv.machinery.MesDvMachineryService;
import cn.iocoder.yudao.module.mes.service.dv.repair.MesDvRepairDiagnosisService;
import cn.iocoder.yudao.module.mes.service.dv.repair.MesDvRepairService;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
@@ -53,6 +55,8 @@
    @Resource
    private AdminUserApi adminUserApi;
    @Resource
    private MesDvRepairDiagnosisService diagnosisService;
    @PostMapping("/create")
    @Operation(summary = "创建维修工单")
@@ -98,6 +102,15 @@
        return success(new PageResult<>(buildRepairRespVOList(pageResult.getList()), pageResult.getTotal()));
    }
    @GetMapping("/diagnose")
    @Operation(summary = "设备远程诊断(基于现有设备采集数据)",
            description = "读取实时采集、状态历史与告警,给出诊断建议;结论仅作维修参考,不替代现场确认")
    @Parameter(name = "machineryId", description = "设备台账编号", required = true, example = "1")
    @PreAuthorize("@ss.hasPermission('mes:dv-repair:query')")
    public CommonResult<MesDvRepairDiagnosisRespVO> diagnose(@RequestParam("machineryId") Long machineryId) {
        return success(diagnosisService.diagnose(machineryId));
    }
    @GetMapping("/export-excel")
    @Operation(summary = "导出维修工单 Excel")
    @PreAuthorize("@ss.hasPermission('mes:dv-repair:export')")
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/dv/repair/vo/MesDvRepairDiagnosisRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,69 @@
package cn.iocoder.yudao.module.mes.controller.admin.dv.repair.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
 * ç®¡ç†åŽå° - è®¾å¤‡è¿œç¨‹è¯Šæ–­ Response VO
 *
 * <p>基于现有设备采集数据(实时状态 + æœ€è¿‘告警 + çŠ¶æ€åŽ†å²ï¼‰ç”Ÿæˆï¼Œ
 * ä¸ä¾èµ–额外的外部诊断接口。
 */
@Schema(description = "管理后台 - è®¾å¤‡è¿œç¨‹è¯Šæ–­ Response VO")
@Data
public class MesDvRepairDiagnosisRespVO {
    @Schema(description = "设备台账编号")
    private Long machineryId;
    @Schema(description = "设备编码")
    private String machineryCode;
    @Schema(description = "设备名称")
    private String machineryName;
    @Schema(description = "是否有采集数据")
    private Boolean hasCollectData;
    @Schema(description = "最近采集时间")
    private LocalDateTime lastCollectTime;
    @Schema(description = "采集数据是否超时(超过 5 åˆ†é’Ÿæ— ä¸ŠæŠ¥ï¼‰")
    private Boolean collectTimeout;
    @Schema(description = "最近运行状态(数采原值)")
    private Integer runStatus;
    @Schema(description = "最近运行模式(数采原值)")
    private Integer operateMode;
    @Schema(description = "最近运行时长")
    private BigDecimal runTime;
    @Schema(description = "关键指标快照:指标名 -> å€¼")
    private Map<String, BigDecimal> metrics;
    @Schema(description = "近 N å¤©å‘Šè­¦æ•°")
    private Long recentAlarmCount;
    @Schema(description = "未清除告警数")
    private Long activeAlarmCount;
    @Schema(description = "最近告警明细")
    private List<AlarmItem> recentAlarms;
    @Schema(description = "诊断结论(供维修工单参考,不替代现场确认)")
    private String diagnosis;
    @Schema(description = "风险等级:NORMAL / WARNING / CRITICAL / UNKNOWN")
    private String riskLevel;
    @Schema(description = "诊断依据(逐条说明取数口径)")
    private List<String> evidence;
    @Schema(description = "告警明细项")
    @Data
    public static class AlarmItem {
        @Schema(description = "告警编号")
        private Long id;
        @Schema(description = "告警信息")
        private String message;
        @Schema(description = "告警时间")
        private LocalDateTime alarmTime;
        @Schema(description = "是否已清除")
        private Boolean cleared;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/dv/statistics/MesDvStatisticsController.java
@@ -2,6 +2,7 @@
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.mes.controller.admin.collect.vo.MesCollectDowntimeDetailRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.dv.statistics.vo.MesDvOeeRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.dv.statistics.vo.MesDvStatisticsRespVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.dv.machinery.MesDvMachineryDO;
@@ -14,6 +15,7 @@
import cn.iocoder.yudao.module.mes.dal.mysql.dv.maintenrecord.MesDvMaintenRecordMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.dv.repair.MesDvRepairMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.task.MesProTaskMapper;
import cn.iocoder.yudao.module.mes.service.collect.MesCollectQueryService;
import cn.iocoder.yudao.module.mes.enums.dv.MesDvMachineryStatusEnum;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -53,6 +55,8 @@
    private MesDvMaintenRecordMapper maintenRecordMapper;
    @Resource
    private MesProTaskMapper taskMapper;
    @Resource
    private MesCollectQueryService collectQueryService;
    @GetMapping("/summary")
    @Operation(summary = "设备统计汇总(台账/ç»´ä¿®/保养/类型分布)")
@@ -112,6 +116,19 @@
        return success(vo);
    }
    @GetMapping("/downtime")
    @Operation(summary = "设备停机明细报表",
            description = "按采集间隔推算停机区间与合计;通信中断同样计入停机,但不标记为机械故障")
    public CommonResult<MesCollectDowntimeDetailRespVO> getDowntimeDetail(
            @RequestParam(value = "startTime", required = false)
            @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime,
            @RequestParam(value = "endTime", required = false)
            @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime) {
        LocalDateTime start = startTime != null ? startTime : LocalDateTime.now().minusDays(7);
        LocalDateTime end = endTime != null ? endTime : LocalDateTime.now();
        return success(collectQueryService.getDowntimeDetail(start, end));
    }
    @GetMapping("/oee")
    @Operation(summary = "设备 OEE ç»Ÿè®¡ï¼ˆç¨¼åŠ¨çŽ‡Ã—æ€§èƒ½å¼€åŠ¨çŽ‡Ã—è‰¯å“çŽ‡ï¼‰")
    public CommonResult<MesDvOeeRespVO> getOee(
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/feedback/MesProFeedbackController.java
@@ -119,6 +119,9 @@
    @Resource
    private MesProWorkOrderParamRecordDetailMapper paramRecordDetailMapper;
    @Resource
    private cn.iocoder.yudao.module.mes.service.pro.feedback.MesProTaskExecutionService taskExecutionService;
    @PostMapping("/create")
    @Operation(summary = "创建生产报工")
    @PreAuthorize("@ss.hasPermission('mes:pro-feedback:create')")
@@ -163,6 +166,16 @@
        return success(new PageResult<>(buildFeedbackRespVOList(pageResult.getList()), pageResult.getTotal()));
    }
    @GetMapping("/execution")
    @Operation(summary = "生产任务执行反馈分析",
            description = "汇总计划、报工、设备采集、物料消耗与交期偏差;延期原因仅列可观测信号,需人工确认")
    @Parameter(name = "taskId", description = "任务编号", required = true, example = "1024")
    @PreAuthorize("@ss.hasPermission('mes:pro-feedback:query')")
    public CommonResult<cn.iocoder.yudao.module.mes.controller.admin.pro.feedback.vo.MesProTaskExecutionRespVO>
            getTaskExecution(@RequestParam("taskId") Long taskId) {
        return success(taskExecutionService.getExecution(taskId));
    }
    @GetMapping("/list-by-task")
    @Operation(summary = "按任务获得报工历史列表")
    @Parameter(name = "taskId", description = "任务编号", required = true, example = "1024")
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/feedback/vo/MesProTaskExecutionRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,100 @@
package cn.iocoder.yudao.module.mes.controller.admin.pro.feedback.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
 * ç®¡ç†åŽå° - ç”Ÿäº§ä»»åŠ¡æ‰§è¡Œåé¦ˆåˆ†æž Response VO
 *
 * <p>把计划、实际报工、设备采集、物料消耗、交期偏差汇总到一处,
 * ä¾›è½¦é—´ä¸Žè®¡åˆ’员定位延期原因。所有数据均来自现有表,不做推断性补数。
 */
@Schema(description = "管理后台 - ç”Ÿäº§ä»»åŠ¡æ‰§è¡Œåé¦ˆåˆ†æž Response VO")
@Data
public class MesProTaskExecutionRespVO {
    @Schema(description = "任务编号")
    private Long taskId;
    @Schema(description = "任务编码")
    private String taskCode;
    @Schema(description = "任务名称")
    private String taskName;
    @Schema(description = "生产工单编号")
    private Long workOrderId;
    @Schema(description = "任务状态")
    private Integer status;
    @Schema(description = "计划数量")
    private BigDecimal plannedQuantity;
    @Schema(description = "实际报工数量")
    private BigDecimal producedQuantity;
    @Schema(description = "合格数量")
    private BigDecimal qualifyQuantity;
    @Schema(description = "报废数量")
    private BigDecimal unqualifyQuantity;
    @Schema(description = "完成率(%)")
    private BigDecimal completionRate;
    @Schema(description = "计划开始时间")
    private LocalDateTime startTime;
    @Schema(description = "实际首次报工时间")
    private LocalDateTime firstFeedbackTime;
    @Schema(description = "实际最后报工时间")
    private LocalDateTime lastFeedbackTime;
    @Schema(description = "计划完成时间")
    private LocalDateTime finishDate;
    @Schema(description = "计划工时(分钟)")
    private Integer plannedDuration;
    @Schema(description = "实际耗时(分钟,首次到末次报工)")
    private Long actualDuration;
    @Schema(description = "是否延期")
    private Boolean delayed;
    @Schema(description = "延期天数(正数表示超期)")
    private Long delayDays;
    @Schema(description = "延期原因(按可观测信号给出的候选,需人工确认)")
    private List<String> delayCauses;
    @Schema(description = "设备运行数据(按设备汇总)")
    private List<DeviceRun> deviceRuns;
    @Schema(description = "物料消耗(按物料汇总)")
    private List<MaterialConsumption> materialConsumptions;
    @Schema(description = "设备运行汇总项")
    @Data
    public static class DeviceRun {
        @Schema(description = "设备台账编号")
        private Long machineryId;
        @Schema(description = "设备编码")
        private String deviceCode;
        @Schema(description = "采集次数")
        private Long sampleCount;
        @Schema(description = "累计运行时长")
        private BigDecimal totalRunTime;
        @Schema(description = "最近采集时间")
        private LocalDateTime lastCollectTime;
        @Schema(description = "最近运行状态")
        private Integer lastRunStatus;
        @Schema(description = "告警数")
        private Long alarmCount;
    }
    @Schema(description = "物料消耗项")
    @Data
    public static class MaterialConsumption {
        @Schema(description = "物料编号")
        private Long itemId;
        @Schema(description = "物料编码")
        private String itemCode;
        @Schema(description = "物料名称")
        private String itemName;
        @Schema(description = "消耗数量")
        private BigDecimal quantity;
        @Schema(description = "单位")
        private String unit;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/safety/SafetyRiskSourceController.java
@@ -8,6 +8,7 @@
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@RestController @RequestMapping("/safety/risk-source") @Validated
@@ -21,4 +22,8 @@
    public CommonResult<Boolean> update(@Valid @RequestBody SafetyRiskSourceSaveReqVO req){if(req.getId()==null)throw cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException("危险源编号不能为空");service.save(req);return success(true);}
    @DeleteMapping("/delete") @PreAuthorize("@ss.hasPermission('safety:risk:delete')")
    public CommonResult<Boolean> delete(@RequestParam("id") Long id){service.delete(id);return success(true);}
    /** ç”¨è®¾å¤‡é‡‡é›†æ•°æ®åˆ·æ–°å·²ç»‘定设备的危险源,返回本次报警清单。 */
    @PostMapping("/refresh") @PreAuthorize("@ss.hasPermission('safety:risk:update')")
    public CommonResult<List<SafetyRiskSourceDO>> refreshFromCollect(){return success(service.refreshFromCollect());}
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/safety/vo/risk/SafetyRiskSourceSaveReqVO.java
@@ -9,6 +9,10 @@
    @NotBlank @Pattern(regexp="MAJOR|HIGH_VOLTAGE|OTHER") private String category;
    @Size(max=160) private String location;
    @NotBlank @Size(max=80) private String metricName;
    /** å…³è”设备编号;填写后由数采自动刷新当前值。 */
    @Positive private Long machineryId;
    /** æ•°é‡‡æŒ‡æ ‡é”®ï¼ˆdata_json ä¸­çš„字段名),与设备编号配合使用。 */
    @Size(max=64) private String metricKey;
    @Size(max=32) private String unit;
    @Digits(integer=14,fraction=6) private BigDecimal lowerLimit;
    @Digits(integer=14,fraction=6) private BigDecimal upperLimit;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/batch/MesWmBatchController.java
@@ -12,6 +12,10 @@
import cn.iocoder.yudao.module.mes.controller.admin.wm.batch.vo.MesWmBatchSalesTargetRespVO;
import cn.iocoder.yudao.module.crm.api.customer.CrmCustomerApi;
import cn.iocoder.yudao.module.crm.api.customer.dto.CrmCustomerRespDTO;
import cn.iocoder.yudao.module.aftersales.api.AfterSaleTicketApi;
import cn.iocoder.yudao.module.aftersales.api.dto.AfterSaleTicketRespDTO;
import cn.iocoder.yudao.module.srm.api.certificate.SrmSupplierCertificateApi;
import cn.iocoder.yudao.module.srm.api.certificate.dto.SrmSupplierCertificateRespDTO;
import cn.iocoder.yudao.module.srm.api.supplier.SrmSupplierApi;
import cn.iocoder.yudao.module.srm.api.supplier.dto.SrmSupplierRespDTO;
import cn.iocoder.yudao.module.mdm.api.item.MdmItemApi;
@@ -44,9 +48,12 @@
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.Collections;
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.pojo.CommonResult.success;
@@ -66,6 +73,10 @@
    private MdmUnitMeasureApi mdmUnitMeasureApi;
    @Resource
    private SrmSupplierApi srmSupplierApi;
    @Resource
    private SrmSupplierCertificateApi srmSupplierCertificateApi;
    @Resource
    private AfterSaleTicketApi afterSaleTicketApi;
    @Resource
    private CrmCustomerApi customerApi;
    @Resource
@@ -132,6 +143,42 @@
        return success(buildSalesTargetRespVOList(list));
    }
    @GetMapping("/certificates")
    @Operation(summary = "批次原材料合规证书",
            description = "按批次的采购来源供应商查询合规证书;证书过期标记由 SRM åˆ¤å®š")
    @Parameter(name = "code", description = "批次编码", required = true, example = "BATCH20250314001")
    @PreAuthorize("@ss.hasPermission('mes:wm-batch:query')")
    public CommonResult<List<SrmSupplierCertificateRespDTO>> getCertificateList(@RequestParam("code") @Valid String code) {
        List<SrmSupplierCertificateRespDTO> result = new ArrayList<>();
        Set<Long> vendorIds = batchService.getPurchaseSourceList(code).stream()
                .map(MesWmBatchPurchaseSourceRespVO::getVendorId)
                .filter(Objects::nonNull).collect(Collectors.toSet());
        for (Long vendorId : vendorIds) {
            List<SrmSupplierCertificateRespDTO> list =
                    srmSupplierCertificateApi.getCertificatesBySupplierId(vendorId).getCheckedData();
            if (list != null) result.addAll(list);
        }
        return success(result);
    }
    @GetMapping("/aftersales")
    @Operation(summary = "批次售后故障记录",
            description = "按批次销售去向关联的销售订单号查询售后工单,用于质量追溯延伸到售后环节")
    @Parameter(name = "code", description = "批次编码", required = true, example = "BATCH20250314001")
    @PreAuthorize("@ss.hasPermission('mes:wm-batch:query')")
    public CommonResult<List<AfterSaleTicketRespDTO>> getAfterSaleList(@RequestParam("code") @Valid String code) {
        List<AfterSaleTicketRespDTO> result = new ArrayList<>();
        Set<String> saleOrderNos = batchService.getSalesTargetList(code).stream()
                .map(MesWmBatchSalesTargetRespVO::getSalesOrderCode)
                .filter(Objects::nonNull).filter(s -> !s.isBlank()).collect(Collectors.toSet());
        for (String saleOrderNo : saleOrderNos) {
            List<AfterSaleTicketRespDTO> list =
                    afterSaleTicketApi.getTicketsBySaleOrderNo(saleOrderNo).getCheckedData();
            if (list != null) result.addAll(list);
        }
        return success(result);
    }
    @GetMapping("/process-trace")
    @Operation(summary = "批次工序流转(产出/投入事件)")
    @Parameter(name = "code", description = "批次编码", required = true, example = "BATCH20250314001")
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/safety/SafetyRiskSourceDO.java
@@ -14,6 +14,10 @@
    private String category;
    private String location;
    private String metricName;
    /** å…³è”设备台账编号;为空表示仅人工录入,不接数采。 */
    private Long machineryId;
    /** æ•°é‡‡æŒ‡æ ‡é”®ï¼šå– mes_collect_realtime.data_json ä¸­çš„字段名,如 current、speed。 */
    private String metricKey;
    private String unit;
    @TableField(updateStrategy = FieldStrategy.ALWAYS) private BigDecimal lowerLimit;
    @TableField(updateStrategy = FieldStrategy.ALWAYS) private BigDecimal upperLimit;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/collect/MesCollectQueryService.java
@@ -99,6 +99,68 @@
    }
    /**
     * è®¾å¤‡åœæœºæ˜Žç»†æŠ¥è¡¨ï¼šæŒ‰é‡‡é›†é—´éš”æŽ¨ç®—每台设备的停机区间与合计。
     *
     * <p>与单设备离线查询保持同一口径(在线超时阈值、保留期、七天上限),
     * ä¾› OEE æŠ¥è¡¨å®šä½åœæœºçŸ­æ¿ä½¿ç”¨ã€‚
     */
    public cn.iocoder.yudao.module.mes.controller.admin.collect.vo.MesCollectDowntimeDetailRespVO
            getDowntimeDetail(LocalDateTime start, LocalDateTime end) {
        LocalDateTime now = LocalDateTime.now();
        if (start == null || end == null || !start.isBefore(end) || end.isAfter(now)
                || Duration.between(start, end).compareTo(Duration.ofDays(31)) > 0) {
            throw invalidParamException("请选择有效的时间范围,单次最多查询三十一天,结束时间不能晚于当前时间");
        }
        if (start.isBefore(now.minusDays(collectProperties.getRetention().getRealtimeDays()))) {
            throw invalidParamException("查询时间超出数采历史保留期,无法可靠计算停机记录");
        }
        var result = new cn.iocoder.yudao.module.mes.controller.admin.collect.vo.MesCollectDowntimeDetailRespVO();
        result.setStartTime(start);
        result.setEndTime(end);
        result.setTimeoutMinutes((int) ONLINE_TIMEOUT_MINUTES);
        long totalMinutes = Duration.between(start, end).toMinutes();
        result.setTotalMinutes(totalMinutes);
        var machineries = machineryMapper.selectListWithCollectDeviceCode();
        List<cn.iocoder.yudao.module.mes.controller.admin.collect.vo.MesCollectDowntimeDetailRespVO.Item> items = new ArrayList<>();
        long sumDowntime = 0L;
        for (var machinery : machineries) {
            String deviceCode = machinery.getCollectDeviceCode();
            var prior = realtimeHistoryMapper.selectOne(new LambdaQueryWrapperX<MesCollectRealtimeHistoryDO>()
                    .eq(MesCollectRealtimeHistoryDO::getDeviceCode, deviceCode)
                    .ge(MesCollectRealtimeHistoryDO::getCollectTime, now.minusDays(collectProperties.getRetention().getRealtimeDays()))
                    .le(MesCollectRealtimeHistoryDO::getCollectTime, start)
                    .orderByDesc(MesCollectRealtimeHistoryDO::getCollectTime).last("LIMIT 1"));
            var history = realtimeHistoryMapper.selectList(new LambdaQueryWrapperX<MesCollectRealtimeHistoryDO>()
                    .eq(MesCollectRealtimeHistoryDO::getDeviceCode, deviceCode)
                    .gt(MesCollectRealtimeHistoryDO::getCollectTime, start)
                    .le(MesCollectRealtimeHistoryDO::getCollectTime, end)
                    .orderByAsc(MesCollectRealtimeHistoryDO::getCollectTime).last("LIMIT 100001"));
            if (history.size() > 100000) throw invalidParamException("采样数据过多,请缩短查询时间范围");
            List<LocalDateTime> samples = new ArrayList<>();
            int count = 0;
            if (prior != null) { samples.add(prior.getCollectTime()); count++; }
            history.forEach(item -> samples.add(item.getCollectTime()));
            count += history.size();
            var intervals = CollectOfflinePolicy.calculate(samples, start, end, Duration.ofMinutes(ONLINE_TIMEOUT_MINUTES)).stream()
                    .map(i -> new cn.iocoder.yudao.module.mes.controller.admin.collect.vo.MesCollectDowntimeDetailRespVO.Interval(
                            i.start(), i.end(), Duration.between(i.start(), i.end()).getSeconds()))
                    .toList();
            long minutes = intervals.stream().mapToLong(i -> i.getSeconds() / 60).sum();
            sumDowntime += minutes;
            items.add(new cn.iocoder.yudao.module.mes.controller.admin.collect.vo.MesCollectDowntimeDetailRespVO.Item(
                    machinery.getId(), machinery.getCode(), machinery.getName(), deviceCode,
                    count, intervals.size(), minutes, intervals));
        }
        items.sort((a, b) -> Long.compare(b.getDowntimeMinutes(), a.getDowntimeMinutes()));
        result.setItems(items);
        result.setDowntimeMinutes(sumDowntime);
        result.setDowntimeRate(totalMinutes > 0 && !items.isEmpty()
                ? Math.round(sumDowntime * 10000D / (totalMinutes * items.size())) / 100D : 0D);
        return result;
    }
    /**
     * èŽ·å–å·²é…ç½®æ•°é‡‡ç¼–ç çš„è®¾å¤‡å®žæ—¶çŠ¶æ€åˆ—è¡¨
     */
    public List<MesCollectDeviceRespVO> getDeviceList() {
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/dv/repair/MesDvRepairDiagnosisService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,163 @@
package cn.iocoder.yudao.module.mes.service.dv.repair;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.mes.controller.admin.dv.repair.vo.MesDvRepairDiagnosisRespVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.collect.MesCollectAlarmDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.collect.MesCollectRealtimeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.collect.MesCollectRealtimeHistoryDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.dv.machinery.MesDvMachineryDO;
import cn.iocoder.yudao.module.mes.dal.mysql.collect.MesCollectAlarmMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.collect.MesCollectRealtimeHistoryMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.collect.MesCollectRealtimeMapper;
import cn.iocoder.yudao.module.mes.service.dv.machinery.MesDvMachineryService;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
import tools.jackson.core.type.TypeReference;
/**
 * è®¾å¤‡è¿œç¨‹è¯Šæ–­ã€‚
 *
 * <p>口径说明:本服务只读取现有数采数据(实时表、状态历史、告警),
 * é€šè¿‡é‡‡é›†è¶…时、告警密集度、运行状态异常等信号给出诊断建议;
 * ç»“论用于维修工单参考,不替代现场确认,也不推断机械故障原因。
 */
@Slf4j
@Service
public class MesDvRepairDiagnosisService {
    /** é‡‡é›†è¶…时阈值(分钟):超过该时长无上报视为通信中断。 */
    private static final long COLLECT_TIMEOUT_MINUTES = 5;
    /** è¯Šæ–­å›žçœ‹å¤©æ•°ã€‚ */
    private static final int LOOKBACK_DAYS = 7;
    /** å¯†é›†å‘Šè­¦é˜ˆå€¼ã€‚ */
    private static final long ALARM_DENSE_THRESHOLD = 3;
    @Resource private MesDvMachineryService machineryService;
    @Resource private MesCollectRealtimeMapper realtimeMapper;
    @Resource private MesCollectRealtimeHistoryMapper historyMapper;
    @Resource private MesCollectAlarmMapper alarmMapper;
    public MesDvRepairDiagnosisRespVO diagnose(Long machineryId) {
        MesDvMachineryDO machinery = machineryService.getMachinery(machineryId);
        if (machinery == null) throw cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil
                .invalidParamException("设备不存在");
        var resp = new MesDvRepairDiagnosisRespVO();
        resp.setMachineryId(machineryId);
        resp.setMachineryCode(machinery.getCode());
        resp.setMachineryName(machinery.getName());
        List<String> evidence = new ArrayList<>();
        resp.setEvidence(evidence);
        // 1. å®žæ—¶é‡‡é›†çŠ¶æ€
        MesCollectRealtimeDO realtime = realtimeMapper.selectOne(new LambdaQueryWrapperX<MesCollectRealtimeDO>()
                .eq(MesCollectRealtimeDO::getMachineryId, machineryId)
                .orderByDesc(MesCollectRealtimeDO::getCollectTime)
                .last("LIMIT 1"));
        boolean hasData = realtime != null;
        resp.setHasCollectData(hasData);
        if (hasData) {
            resp.setLastCollectTime(realtime.getCollectTime());
            resp.setRunStatus(realtime.getSts());
            resp.setOperateMode(realtime.getOpm());
            resp.setRunTime(realtime.getOt());
            resp.setMetrics(parseMetrics(realtime.getDataJson()));
            boolean timeout = realtime.getCollectTime() == null
                    || Duration.between(realtime.getCollectTime(), LocalDateTime.now()).toMinutes() >= COLLECT_TIMEOUT_MINUTES;
            resp.setCollectTimeout(timeout);
            evidence.add(String.format("实时采集:最近上报 %s,状态码 %s,运行模式 %s%s",
                    realtime.getCollectTime(), realtime.getSts(), realtime.getOpm(),
                    timeout ? String.format("(已超过 %d åˆ†é’Ÿæ— æ›´æ–°ï¼Œåˆ¤å®šé€šä¿¡ä¸­æ–­ï¼‰", COLLECT_TIMEOUT_MINUTES) : ""));
        } else {
            resp.setCollectTimeout(true);
            evidence.add("实时采集:该设备无任何采集记录,无法远程判断运行状态");
        }
        // 2. æœ€è¿‘告警
        LocalDateTime from = LocalDateTime.now().minusDays(LOOKBACK_DAYS);
        List<MesCollectAlarmDO> alarms = alarmMapper.selectList(new LambdaQueryWrapperX<MesCollectAlarmDO>()
                .eq(MesCollectAlarmDO::getMachineryId, machineryId)
                .ge(MesCollectAlarmDO::getWmTime, from)
                .orderByDesc(MesCollectAlarmDO::getWmTime)
                .last("LIMIT 20"));
        resp.setRecentAlarmCount((long) alarms.size());
        long active = alarms.stream().filter(a -> a.getClearTime() == null).count();
        resp.setActiveAlarmCount(active);
        resp.setRecentAlarms(alarms.stream().map(a -> {
            var item = new MesDvRepairDiagnosisRespVO.AlarmItem();
            item.setId(a.getId());
            item.setMessage(a.getWmMsg());
            item.setAlarmTime(a.getWmTime());
            item.setCleared(a.getClearTime() != null);
            return item;
        }).collect(Collectors.toList()));
        if (!alarms.isEmpty()) {
            evidence.add(String.format("告警:近 %d å¤© %d æ¡ï¼Œå…¶ä¸­æœªæ¸…除 %d æ¡", LOOKBACK_DAYS, alarms.size(), active));
        } else {
            evidence.add(String.format("告警:近 %d å¤©æ— å‘Šè­¦è®°å½•", LOOKBACK_DAYS));
        }
        // 3. çŠ¶æ€åŽ†å²ï¼ˆåˆ¤æ–­æ˜¯å¦é¢‘ç¹å¯åœï¼‰
        List<MesCollectRealtimeHistoryDO> history = historyMapper.selectList(
                new LambdaQueryWrapperX<MesCollectRealtimeHistoryDO>()
                        .eq(MesCollectRealtimeHistoryDO::getMachineryId, machineryId)
                        .ge(MesCollectRealtimeHistoryDO::getCollectTime, from));
        if (!history.isEmpty()) {
            long distinctStatus = history.stream().map(MesCollectRealtimeHistoryDO::getSts)
                    .filter(Objects::nonNull).distinct().count();
            evidence.add(String.format("状态历史:近 %d å¤© %d æ¡é‡‡æ ·ï¼Œå‡ºçް %d ç§è¿è¡ŒçŠ¶æ€", LOOKBACK_DAYS, history.size(), distinctStatus));
            if (distinctStatus >= 3) evidence.add("状态频繁切换,建议检查启停控制与工艺稳定性");
        }
        // 4. ç»¼åˆåˆ¤å®š
        String level;
        String diagnosis;
        if (!hasData) {
            level = "UNKNOWN";
            diagnosis = "无采集数据,无法远程诊断。建议现场核查数采网关供电与通讯链路。";
        } else if (Boolean.TRUE.equals(resp.getCollectTimeout())) {
            level = "CRITICAL";
            diagnosis = "采集数据已超时,设备可能停机或通讯中断。建议先确认设备是否运行、网关是否在线,再判断是否需要报修。";
        } else if (active > 0) {
            level = "CRITICAL";
            diagnosis = String.format("存在 %d æ¡æœªæ¸…除告警,设备处于异常运行状态。建议按告警信息排查后,再决定维修方案。", active);
        } else if (alarms.size() >= ALARM_DENSE_THRESHOLD) {
            level = "WARNING";
            diagnosis = String.format("近 %d å¤©å‘Šè­¦ %d æ¡ï¼Œè™½å·²æ¸…除但发生频繁。建议安排预防性保养,重点检查告警关联部件。", LOOKBACK_DAYS, alarms.size());
        } else {
            level = "NORMAL";
            diagnosis = "采集正常且无明显告警,设备运行状态平稳。如现场仍反馈异常,请补充具体现象后再次诊断。";
        }
        resp.setRiskLevel(level);
        resp.setDiagnosis(diagnosis);
        return resp;
    }
    /** è§£æž data_json ä¸­çš„æ•°å€¼åž‹æŒ‡æ ‡ï¼Œæœ€å¤šä¿ç•™ 20 é¡¹ï¼Œé¿å…å“åº”过大。 */
    Map<String, BigDecimal> parseMetrics(String dataJson) {
        if (dataJson == null || dataJson.isBlank()) return Map.of();
        try {
            Map<String, Object> raw = JsonUtils.parseObjectQuietly(dataJson, new TypeReference<Map<String, Object>>() {});
            if (raw == null) return Map.of();
            Map<String, BigDecimal> result = new LinkedHashMap<>();
            for (var e : raw.entrySet()) {
                if (result.size() >= 20) break;
                Object v = e.getValue();
                if (v instanceof Number n) result.put(e.getKey(), new BigDecimal(n.toString()));
                else if (v instanceof String s && s.matches("-?\\d+(\\.\\d+)?")) result.put(e.getKey(), new BigDecimal(s));
            }
            return result;
        } catch (Exception e) {
            log.debug("解析设备指标失败:{}", e.getMessage());
            return Map.of();
        }
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/feedback/MesProTaskExecutionService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,180 @@
package cn.iocoder.yudao.module.mes.service.pro.feedback;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.mes.controller.admin.pro.feedback.vo.MesProTaskExecutionRespVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.collect.MesCollectAlarmDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.collect.MesCollectRealtimeHistoryDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.unitmeasure.MesMdUnitMeasureDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.task.MesProTaskDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.task.MesProTaskIssueDO;
import cn.iocoder.yudao.module.mes.dal.mysql.collect.MesCollectAlarmMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.collect.MesCollectRealtimeHistoryMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.md.item.MesMdItemMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.md.unitmeasure.MesMdUnitMeasureMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.task.MesProTaskIssueMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.task.MesProTaskMapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
/**
 * ç”Ÿäº§ä»»åŠ¡æ‰§è¡Œåé¦ˆåˆ†æžã€‚
 *
 * <p>把计划、报工、设备采集、物料消耗、交期偏差汇总,用于定位延期原因。
 * å£å¾„说明:
 * <ul>
 *   <li>实际耗时 = é¦–次报工到末次报工(无报工则为空,不用计划值代替);</li>
 *   <li>延期判定 = æœ«æ¬¡æŠ¥å·¥æ—¶é—´(或当前时间) æ™šäºŽè®¡åˆ’完成时间;</li>
 *   <li>延期原因只列可观测信号(缺料、告警、设备离线、报工滞后),不推断管理原因。</li>
 * </ul>
 */
@Slf4j
@Service
public class MesProTaskExecutionService {
    @Resource private MesProTaskMapper taskMapper;
    @Resource private MesProTaskIssueMapper taskIssueMapper;
    @Resource private MesCollectRealtimeHistoryMapper historyMapper;
    @Resource private MesCollectAlarmMapper alarmMapper;
    @Resource private MesMdItemMapper itemMapper;
    @Resource private MesMdUnitMeasureMapper unitMeasureMapper;
    public MesProTaskExecutionRespVO getExecution(Long taskId) {
        MesProTaskDO task = taskId == null ? null : taskMapper.selectById(taskId);
        if (task == null) throw invalidParamException("生产任务不存在");
        var resp = new MesProTaskExecutionRespVO();
        resp.setTaskId(task.getId());
        resp.setTaskCode(task.getCode());
        resp.setTaskName(task.getName());
        resp.setWorkOrderId(task.getWorkOrderId());
        resp.setStatus(task.getStatus());
        resp.setPlannedQuantity(task.getQuantity());
        resp.setProducedQuantity(task.getProducedQuantity());
        resp.setQualifyQuantity(task.getQualifyQuantity());
        resp.setUnqualifyQuantity(task.getUnqualifyQuantity());
        resp.setStartTime(task.getStartTime());
        resp.setFirstFeedbackTime(task.getFirstFeedbackTime());
        resp.setLastFeedbackTime(task.getLastFeedbackTime());
        resp.setFinishDate(task.getFinishDate());
        resp.setPlannedDuration(task.getDuration());
        // å®Œæˆçއ
        if (task.getQuantity() != null && task.getQuantity().signum() > 0 && task.getProducedQuantity() != null) {
            resp.setCompletionRate(task.getProducedQuantity()
                    .multiply(BigDecimal.valueOf(100))
                    .divide(task.getQuantity(), 2, RoundingMode.HALF_UP));
        }
        // å®žé™…耗时
        if (task.getFirstFeedbackTime() != null && task.getLastFeedbackTime() != null) {
            resp.setActualDuration(Duration.between(task.getFirstFeedbackTime(), task.getLastFeedbackTime()).toMinutes());
        }
        // è®¾å¤‡è¿è¡Œæ•°æ®
        List<MesCollectRealtimeHistoryDO> history = historyMapper.selectList(
                new LambdaQueryWrapperX<MesCollectRealtimeHistoryDO>()
                        .eq(MesCollectRealtimeHistoryDO::getTaskId, taskId));
        List<MesProTaskExecutionRespVO.DeviceRun> deviceRuns = new ArrayList<>();
        Map<Long, List<MesCollectRealtimeHistoryDO>> byMachinery = history.stream()
                .filter(h -> h.getMachineryId() != null)
                .collect(Collectors.groupingBy(MesCollectRealtimeHistoryDO::getMachineryId));
        for (var e : byMachinery.entrySet()) {
            List<MesCollectRealtimeHistoryDO> list = e.getValue();
            var run = new MesProTaskExecutionRespVO.DeviceRun();
            run.setMachineryId(e.getKey());
            run.setDeviceCode(list.get(0).getDeviceCode());
            run.setSampleCount((long) list.size());
            run.setTotalRunTime(list.stream().map(MesCollectRealtimeHistoryDO::getOt)
                    .filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add));
            MesCollectRealtimeHistoryDO last = list.stream()
                    .max(Comparator.comparing(MesCollectRealtimeHistoryDO::getCollectTime,
                            Comparator.nullsFirst(Comparator.naturalOrder()))).orElse(null);
            if (last != null) {
                run.setLastCollectTime(last.getCollectTime());
                run.setLastRunStatus(last.getSts());
            }
            run.setAlarmCount(alarmMapper.selectCount(new LambdaQueryWrapperX<MesCollectAlarmDO>()
                    .eq(MesCollectAlarmDO::getTaskId, taskId)
                    .eq(MesCollectAlarmDO::getMachineryId, e.getKey())));
            deviceRuns.add(run);
        }
        resp.setDeviceRuns(deviceRuns);
        // ç‰©æ–™æ¶ˆè€—
        List<MesProTaskIssueDO> issues = taskIssueMapper.selectList(
                new LambdaQueryWrapperX<MesProTaskIssueDO>().eq(MesProTaskIssueDO::getTaskId, taskId));
        Set<Long> itemIds = issues.stream().map(MesProTaskIssueDO::getItemId)
                .filter(Objects::nonNull).collect(Collectors.toSet());
        Map<Long, MesMdItemDO> itemMap = itemIds.isEmpty() ? Map.of()
                : itemMapper.selectBatchIds(itemIds).stream()
                    .collect(Collectors.toMap(MesMdItemDO::getId, i -> i, (a, b) -> a));
        Set<Long> unitIds = itemMap.values().stream().map(MesMdItemDO::getUnitMeasureId)
                .filter(Objects::nonNull).collect(Collectors.toSet());
        Map<Long, String> unitNameMap = unitIds.isEmpty() ? Map.of()
                : unitMeasureMapper.selectBatchIds(unitIds).stream()
                    .collect(Collectors.toMap(MesMdUnitMeasureDO::getId, MesMdUnitMeasureDO::getName, (a, b) -> a));
        Map<Long, MesProTaskExecutionRespVO.MaterialConsumption> consumptionMap = new LinkedHashMap<>();
        for (MesProTaskIssueDO issue : issues) {
            if (issue.getItemId() == null) continue;
            var mc = consumptionMap.computeIfAbsent(issue.getItemId(), id -> {
                var item = new MesProTaskExecutionRespVO.MaterialConsumption();
                item.setItemId(id);
                MesMdItemDO md = itemMap.get(id);
                if (md != null) {
                    item.setItemCode(md.getCode());
                    item.setItemName(md.getName());
                    if (md.getUnitMeasureId() != null) item.setUnit(unitNameMap.get(md.getUnitMeasureId()));
                }
                item.setQuantity(BigDecimal.ZERO);
                return item;
            });
            BigDecimal used = issue.getUsedQuantity() != null ? issue.getUsedQuantity()
                    : (issue.getIssuedQuantity() != null ? issue.getIssuedQuantity() : BigDecimal.ZERO);
            mc.setQuantity(mc.getQuantity().add(used));
        }
        resp.setMaterialConsumptions(new ArrayList<>(consumptionMap.values()));
        // äº¤æœŸåå·®
        boolean delayed = false;
        Long delayDays = null;
        LocalDateTime reference = task.getLastFeedbackTime() != null ? task.getLastFeedbackTime() : LocalDateTime.now();
        if (task.getFinishDate() != null && reference.isAfter(task.getFinishDate())) {
            delayed = true;
            delayDays = Duration.between(task.getFinishDate(), reference).toDays();
        }
        resp.setDelayed(delayed);
        resp.setDelayDays(delayDays);
        // å»¶æœŸåŽŸå› ï¼ˆå¯è§‚æµ‹ä¿¡å·ï¼‰
        List<String> causes = new ArrayList<>();
        if (delayed) {
            if (deviceRuns.stream().anyMatch(r -> r.getAlarmCount() != null && r.getAlarmCount() > 0))
                causes.add("执行期间存在设备告警,可能影响进度");
            if (task.getFirstFeedbackTime() == null)
                causes.add("任务尚未首次报工,实际开工时间缺失");
            else if (task.getStartTime() != null && task.getFirstFeedbackTime().isAfter(task.getStartTime().plusDays(1)))
                causes.add("首次报工时间明显晚于计划开始时间");
            boolean hasShortage = issues.stream().anyMatch(i ->
                    i.getIssuedQuantity() != null && i.getUsedQuantity() != null
                            && i.getUsedQuantity().compareTo(i.getIssuedQuantity()) >= 0);
            if (hasShortage) causes.add("投料量已用尽,存在缺料停工可能");
            if (deviceRuns.isEmpty()) causes.add("无设备采集数据,无法判断设备侧影响");
            if (task.getDuration() != null && resp.getActualDuration() != null
                    && resp.getActualDuration() > task.getDuration())
                causes.add(String.format("实际耗时 %d åˆ†é’Ÿè¶…过计划 %d åˆ†é’Ÿ", resp.getActualDuration(), task.getDuration()));
        }
        resp.setDelayCauses(causes);
        return resp;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/safety/SafetyRiskSourceService.java
@@ -3,24 +3,47 @@
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.mes.controller.admin.safety.vo.risk.SafetyRiskSourceSaveReqVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.collect.MesCollectRealtimeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.safety.SafetyRiskSourceDO;
import cn.iocoder.yudao.module.mes.dal.mysql.collect.MesCollectRealtimeMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.safety.SafetyRiskSourceMapper;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Objects;
import java.util.*;
import tools.jackson.core.type.TypeReference;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
/**
 * å®‰å…¨ç”Ÿäº§ - å±é™©æºç›‘测。
 *
 * <p>支持两种数据来源:
 * <ol>
 *   <li>人工录入 currentValue:仅表示人工抄录的当前值;</li>
 *   <li>绑定设备(machineryId + metricKey):由数采实时表自动取值,复用现有设备采集,不引入新接口。</li>
 * </ol>
 */
@Slf4j
@Service public class SafetyRiskSourceService {
    @Resource private SafetyRiskSourceMapper mapper;
    @Resource private MesCollectRealtimeMapper realtimeMapper;
    public PageResult<SafetyRiskSourceDO> page(PageParam page, String name) {
        return mapper.selectPage(page, new LambdaQueryWrapperX<SafetyRiskSourceDO>().likeIfPresent(SafetyRiskSourceDO::getName,name).orderByDesc(SafetyRiskSourceDO::getId));
    }
    @Transactional(rollbackFor=Exception.class)
    public Long save(SafetyRiskSourceSaveReqVO req) {
        SafetyRiskSourceDO old = req.getId()==null ? null : mapper.selectById(req.getId());
        if (req.getId()!=null && old==null) throw invalidParamException("危险源不存在");
        if (req.getMachineryId()!=null && (req.getMetricKey()==null || req.getMetricKey().isBlank()))
            throw invalidParamException("绑定设备后必须填写数采指标键");
        if (req.getMachineryId()==null && req.getMetricKey()!=null && !req.getMetricKey().isBlank())
            throw invalidParamException("填写数采指标键前请先绑定设备");
        String status;
        try { status=SafetyRiskThreshold.evaluate(req.getCurrentValue(),req.getLowerLimit(),req.getUpperLimit()); }
        catch (IllegalArgumentException e) { throw invalidParamException(e.getMessage()); }
@@ -30,5 +53,78 @@
        if (old==null) mapper.insert(row); else mapper.updateById(row);
        return row.getId();
    }
    public void delete(Long id) { if (mapper.selectById(id)==null) throw invalidParamException("危险源不存在"); mapper.deleteById(id); }
    /**
     * ç”¨è®¾å¤‡é‡‡é›†æ•°æ®åˆ·æ–°æ‰€æœ‰å·²ç»‘定设备的危险源。
     *
     * <p>取值顺序:data_json ä¸­ metricKey å¯¹åº”字段;未取到时置 UNKNOWN å¹¶ä¿ç•™ä¸Šæ¬¡è§‚测时间不变。
     *
     * @return æœ¬æ¬¡åˆ·æ–°åŽå¤„于 ALARM çŠ¶æ€çš„å±é™©æº
     */
    @Transactional(rollbackFor=Exception.class)
    public List<SafetyRiskSourceDO> refreshFromCollect() {
        List<SafetyRiskSourceDO> bound = mapper.selectList(new LambdaQueryWrapperX<SafetyRiskSourceDO>()
                .isNotNull(SafetyRiskSourceDO::getMachineryId));
        List<SafetyRiskSourceDO> alarms = new ArrayList<>();
        Map<Long, MesCollectRealtimeDO> cache = new HashMap<>();
        for (SafetyRiskSourceDO source : bound) {
            MesCollectRealtimeDO realtime = cache.computeIfAbsent(source.getMachineryId(), id ->
                    realtimeMapper.selectOne(new LambdaQueryWrapperX<MesCollectRealtimeDO>()
                            .eq(MesCollectRealtimeDO::getMachineryId, id)
                            .orderByDesc(MesCollectRealtimeDO::getCollectTime).last("LIMIT 1")));
            BigDecimal value = realtime == null ? null : extractMetric(realtime, source.getMetricKey());
            String status;
            try { status = SafetyRiskThreshold.evaluate(value, source.getLowerLimit(), source.getUpperLimit()); }
            catch (IllegalArgumentException e) { log.warn("危险源[{}]阈值配置非法:{}", source.getName(), e.getMessage()); continue; }
            if (value == null) {
                // å–不到采集值:不覆盖历史观测时间,避免伪造数据
                if (!"UNKNOWN".equals(source.getRiskStatus())) {
                    var upd = new SafetyRiskSourceDO();
                    upd.setId(source.getId()); upd.setRiskStatus(status);
                    mapper.updateById(upd);
                    source.setRiskStatus(status);
                }
                continue;
            }
            var upd = new SafetyRiskSourceDO();
            upd.setId(source.getId());
            upd.setCurrentValue(value);
            upd.setObservedAt(realtime.getCollectTime() == null ? LocalDateTime.now() : realtime.getCollectTime());
            upd.setRiskStatus(status);
            mapper.updateById(upd);
            source.setCurrentValue(value);
            source.setObservedAt(upd.getObservedAt());
            source.setRiskStatus(status);
            if ("ALARM".equals(status)) alarms.add(source);
        }
        return alarms;
    }
    /** ä»Žå®žæ—¶é‡‡é›†è®°å½•中提取指标值;优先 data_json,其次复用实时表的标准字段。 */
    BigDecimal extractMetric(MesCollectRealtimeDO realtime, String metricKey) {
        if (metricKey == null || metricKey.isBlank() || realtime == null) return null;
        String json = realtime.getDataJson();
        if (json != null && !json.isBlank()) {
            try {
                Map<String, Object> map = JsonUtils.parseObjectQuietly(json, new TypeReference<Map<String, Object>>() {});
                Object raw = map == null ? null : map.get(metricKey);
                if (raw != null) {
                    if (raw instanceof Number n) return new BigDecimal(n.toString());
                    String text = raw.toString().trim();
                    if (!text.isEmpty()) return new BigDecimal(text);
                }
            } catch (Exception e) {
                log.debug("解析数采 data_json å¤±è´¥ï¼ŒmachineryId={}:{}", realtime.getMachineryId(), e.getMessage());
            }
        }
        // å…¼å®¹ï¼šmetricKey ç›´æŽ¥å–实时表字段(ot è¿è¡Œæ—¶é•¿ç­‰ï¼‰
        return switch (metricKey) {
            case "ot" -> realtime.getOt();
            case "sts" -> realtime.getSts() == null ? null : BigDecimal.valueOf(realtime.getSts());
            case "opm" -> realtime.getOpm() == null ? null : BigDecimal.valueOf(realtime.getOpm());
            default -> null;
        };
    }
}
yudao-module-srm-api/src/main/java/cn/iocoder/yudao/module/srm/api/certificate/SrmSupplierCertificateApi.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,22 @@
package cn.iocoder.yudao.module.srm.api.certificate;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.srm.api.certificate.dto.SrmSupplierCertificateRespDTO;
import java.util.List;
/**
 * ä¾›åº”商合规证书 API(供 MES è´¨é‡è¿½æº¯ç­‰è·¨æ¨¡å—只读调用)
 */
public interface SrmSupplierCertificateApi {
    String PREFIX = "/rpc-api/srm/supplier-certificate";
    /**
     * æŒ‰ä¾›åº”商查询证书列表
     *
     * @param supplierId ä¾›åº”商编号
     * @return è¯ä¹¦åˆ—表(含是否过期标记)
     */
    CommonResult<List<SrmSupplierCertificateRespDTO>> getCertificatesBySupplierId(Long supplierId);
}
yudao-module-srm-api/src/main/java/cn/iocoder/yudao/module/srm/api/certificate/dto/SrmSupplierCertificateRespDTO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,24 @@
package cn.iocoder.yudao.module.srm.api.certificate.dto;
import lombok.Data;
import java.time.LocalDate;
/**
 * ä¾›åº”商合规证书(跨模块只读视图)
 */
@Data
public class SrmSupplierCertificateRespDTO {
    private Long id;
    private Long supplierId;
    /** è¯ä¹¦ç±»åž‹ï¼ˆå­—å…¸ srm_supplier_certificate_type) */
    private Integer certificateType;
    private String certificateName;
    private String certificateNo;
    private String issuingAuthority;
    private LocalDate validStartDate;
    private LocalDate validEndDate;
    /** 0 æœ‰æ•ˆ 1 åœç”¨ */
    private Integer status;
    private Boolean expired;
}
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/api/certificate/SrmSupplierCertificateApiImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,42 @@
package cn.iocoder.yudao.module.srm.api.certificate;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.srm.api.certificate.dto.SrmSupplierCertificateRespDTO;
import cn.iocoder.yudao.module.srm.dal.dataobject.SrmSupplierCertificateDO;
import cn.iocoder.yudao.module.srm.dal.mysql.SrmSupplierCertificateMapper;
import jakarta.annotation.Resource;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDate;
import java.util.List;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
/**
 * ä¾›åº”商合规证书 API å®žçް
 */
@RestController
@Validated
public class SrmSupplierCertificateApiImpl implements SrmSupplierCertificateApi {
    @Resource
    private SrmSupplierCertificateMapper certificateMapper;
    @Override
    public CommonResult<List<SrmSupplierCertificateRespDTO>> getCertificatesBySupplierId(Long supplierId) {
        if (supplierId == null) return success(List.of());
        List<SrmSupplierCertificateDO> list = certificateMapper.selectList(
                new LambdaQueryWrapperX<SrmSupplierCertificateDO>()
                        .eq(SrmSupplierCertificateDO::getSupplierId, supplierId)
                        .orderByDesc(SrmSupplierCertificateDO::getId));
        LocalDate today = LocalDate.now();
        return success(list.stream().map(d -> {
            var dto = BeanUtils.toBean(d, SrmSupplierCertificateRespDTO.class);
            dto.setExpired(d.getValidEndDate() != null && d.getValidEndDate().isBefore(today));
            return dto;
        }).toList());
    }
}
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/controller/admin/statistics/SrmStatisticsController.java
@@ -1,8 +1,13 @@
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;
@@ -20,6 +25,8 @@
    @Resource
    private SrmStatisticsService statisticsService;
    @Resource
    private SrmCostAnalysisService costAnalysisService;
    @GetMapping("/tender")
    @Operation(summary = "获取招标统计数据")
@@ -28,4 +35,22 @@
        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
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,84 @@
package cn.iocoder.yudao.module.srm.controller.admin.statistics.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
 * é‡‡è´­æˆæœ¬åˆ†æž Response VO。
 *
 * <p>数据来源:SRM æ‹›æ ‡ç‰©æ–™éœ€æ±‚(数量、预估单价)+ ä¾›åº”商报价(含税单价、税率)。
 * ä¸ä¾èµ– ERP é‡‡è´­è®¢å•,适用于未启用 ERP çš„项目。
 */
@Schema(description = "管理后台 - é‡‡è´­æˆæœ¬åˆ†æž Response VO")
@Data
public class SrmCostAnalysisRespVO {
    @Schema(description = "物料编号")
    private Long productId;
    @Schema(description = "物料编码")
    private String productCode;
    @Schema(description = "物料名称")
    private String productName;
    @Schema(description = "规格")
    private String productSpec;
    @Schema(description = "单位")
    private String unit;
    @Schema(description = "采购需求量")
    private BigDecimal totalQuantity;
    @Schema(description = "报价次数")
    private Integer quoteCount;
    @Schema(description = "参与报价的供应商数")
    private Integer supplierCount;
    @Schema(description = "加权平均报价(按需求量加权)")
    private BigDecimal weightedAvgPrice;
    @Schema(description = "最低报价")
    private BigDecimal minPrice;
    @Schema(description = "最高报价")
    private BigDecimal maxPrice;
    @Schema(description = "预估单价")
    private BigDecimal estimatedPrice;
    @Schema(description = "相对预估价的偏差率(%)")
    private BigDecimal deviationRate;
    @Schema(description = "最近报价时间")
    private LocalDateTime lastQuoteTime;
    @Schema(description = "价格变动(最近两次报价差)")
    private BigDecimal priceChange;
    @Schema(description = "报价明细")
    private List<QuoteItem> quotes;
    @Schema(description = "报价明细项")
    @Data
    public static class QuoteItem {
        @Schema(description = "供应商编号")
        private Long supplierId;
        @Schema(description = "供应商名称")
        private String supplierName;
        @Schema(description = "报价单价")
        private BigDecimal quotePrice;
        @Schema(description = "税率(%)")
        private BigDecimal taxRate;
        @Schema(description = "交期(天)")
        private Integer deliveryCycle;
        @Schema(description = "报价时间")
        private LocalDateTime quoteTime;
    }
    @Schema(description = "价格趋势点")
    @Data
    public static class TrendPoint {
        @Schema(description = "月份 yyyy-MM")
        private String month;
        @Schema(description = "平均报价")
        private BigDecimal avgPrice;
        @Schema(description = "报价次数")
        private Integer count;
    }
}
yudao-module-srm/src/main/java/cn/iocoder/yudao/module/srm/service/statistics/SrmCostAnalysisService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,183 @@
package cn.iocoder.yudao.module.srm.service.statistics;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.srm.controller.admin.statistics.vo.SrmCostAnalysisRespVO;
import cn.iocoder.yudao.module.srm.dal.dataobject.SrmSupplierDO;
import cn.iocoder.yudao.module.srm.dal.dataobject.SrmSupplierQuoteDO;
import cn.iocoder.yudao.module.srm.dal.dataobject.SrmTenderMaterialDO;
import cn.iocoder.yudao.module.srm.dal.mysql.SrmSupplierMapper;
import cn.iocoder.yudao.module.srm.dal.mysql.SrmSupplierQuoteMapper;
import cn.iocoder.yudao.module.srm.dal.mysql.SrmTenderMaterialMapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.util.*;
import java.util.stream.Collectors;
/**
 * é‡‡è´­æˆæœ¬åˆ†æžï¼ˆåŸºäºŽ SRM æ‹›æ ‡éœ€æ±‚与供应商报价)。
 *
 * <p>口径说明:
 * <ul>
 *   <li>采购需求量取招标物料需求数量合计;</li>
 *   <li>加权均价 = Î£(报价单价 Ã— éœ€æ±‚数量) / Î£(需求数量),多供应商报价一并计入;</li>
 *   <li>价格变动 = æœ€è¿‘两次报价之差;不足两次时为空;</li>
 *   <li>不依赖 ERP é‡‡è´­è®¢å•,未启用 ERP çš„项目同样可用。</li>
 * </ul>
 */
@Slf4j
@Service
public class SrmCostAnalysisService {
    @Resource private SrmTenderMaterialMapper tenderMaterialMapper;
    @Resource private SrmSupplierQuoteMapper supplierQuoteMapper;
    @Resource private SrmSupplierMapper supplierMapper;
    public PageResult<SrmCostAnalysisRespVO> page(PageParam page, String productName) {
        List<SrmTenderMaterialDO> materials = tenderMaterialMapper.selectList(
                new LambdaQueryWrapperX<SrmTenderMaterialDO>()
                        .likeIfPresent(SrmTenderMaterialDO::getProductName, productName));
        if (materials.isEmpty()) return PageResult.empty();
        // æŒ‰ç‰©æ–™èšåˆï¼ˆåŒä¸€ç‰©æ–™å¯èƒ½å‡ºçŽ°åœ¨å¤šä¸ªæ‹›æ ‡é¡¹ç›®ï¼‰
        Map<String, List<SrmTenderMaterialDO>> grouped = materials.stream()
                .filter(m -> m.getProductId() != null || m.getProductName() != null)
                .collect(Collectors.groupingBy(
                        m -> m.getProductId() != null ? "ID:" + m.getProductId() : "NAME:" + m.getProductName(),
                        LinkedHashMap::new, Collectors.toList()));
        List<SrmCostAnalysisRespVO> rows = new ArrayList<>();
        for (var entry : grouped.entrySet()) {
            List<SrmTenderMaterialDO> group = entry.getValue();
            SrmTenderMaterialDO first = group.get(0);
            Set<Long> materialIds = group.stream().map(SrmTenderMaterialDO::getId)
                    .filter(Objects::nonNull).collect(Collectors.toSet());
            if (materialIds.isEmpty()) continue;
            List<SrmSupplierQuoteDO> quotes = supplierQuoteMapper.selectList(
                    new LambdaQueryWrapperX<SrmSupplierQuoteDO>()
                            .in(SrmSupplierQuoteDO::getTenderMaterialId, materialIds));
            if (quotes.isEmpty()) continue;
            // éœ€æ±‚数量映射
            Map<Long, BigDecimal> qtyMap = group.stream()
                    .filter(m -> m.getId() != null)
                    .collect(Collectors.toMap(SrmTenderMaterialDO::getId,
                            m -> m.getQuantity() == null ? BigDecimal.ZERO : m.getQuantity(), (a, b) -> a));
            BigDecimal totalQty = qtyMap.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add);
            BigDecimal weightedSum = BigDecimal.ZERO;
            BigDecimal weightQty = BigDecimal.ZERO;
            for (SrmSupplierQuoteDO q : quotes) {
                if (q.getQuotePrice() == null) continue;
                BigDecimal qty = qtyMap.getOrDefault(q.getTenderMaterialId(), BigDecimal.ZERO);
                if (qty.signum() <= 0) continue;
                weightedSum = weightedSum.add(q.getQuotePrice().multiply(qty));
                weightQty = weightQty.add(qty);
            }
            var row = new SrmCostAnalysisRespVO();
            row.setProductId(first.getProductId());
            row.setProductCode(first.getProductCode());
            row.setProductName(first.getProductName());
            row.setProductSpec(first.getProductSpec());
            row.setUnit(first.getUnit());
            row.setTotalQuantity(totalQty);
            row.setEstimatedPrice(first.getEstimatedPrice());
            row.setQuoteCount(quotes.size());
            row.setSupplierCount((int) quotes.stream().map(SrmSupplierQuoteDO::getSupplierId)
                    .filter(Objects::nonNull).distinct().count());
            row.setWeightedAvgPrice(weightQty.signum() > 0
                    ? weightedSum.divide(weightQty, 4, RoundingMode.HALF_UP) : null);
            row.setMinPrice(quotes.stream().map(SrmSupplierQuoteDO::getQuotePrice)
                    .filter(Objects::nonNull).min(Comparator.naturalOrder()).orElse(null));
            row.setMaxPrice(quotes.stream().map(SrmSupplierQuoteDO::getQuotePrice)
                    .filter(Objects::nonNull).max(Comparator.naturalOrder()).orElse(null));
            if (first.getEstimatedPrice() != null && first.getEstimatedPrice().signum() > 0
                    && row.getWeightedAvgPrice() != null) {
                row.setDeviationRate(row.getWeightedAvgPrice().subtract(first.getEstimatedPrice())
                        .multiply(BigDecimal.valueOf(100))
                        .divide(first.getEstimatedPrice(), 2, RoundingMode.HALF_UP));
            }
            // æŠ¥ä»·æŽ’序(按创建时间)
            List<SrmSupplierQuoteDO> ordered = quotes.stream()
                    .filter(q -> q.getQuotePrice() != null)
                    .sorted(Comparator.comparing(SrmSupplierQuoteDO::getCreateTime,
                            Comparator.nullsFirst(Comparator.naturalOrder())))
                    .toList();
            if (!ordered.isEmpty()) {
                row.setLastQuoteTime(ordered.get(ordered.size() - 1).getCreateTime());
                if (ordered.size() >= 2) {
                    row.setPriceChange(ordered.get(ordered.size() - 1).getQuotePrice()
                            .subtract(ordered.get(ordered.size() - 2).getQuotePrice()));
                }
            }
            // ä¾›åº”商名称
            Set<Long> supplierIds = quotes.stream().map(SrmSupplierQuoteDO::getSupplierId)
                    .filter(Objects::nonNull).collect(Collectors.toSet());
            Map<Long, String> supplierNames = supplierIds.isEmpty() ? Map.of()
                    : supplierMapper.selectByIds(supplierIds).stream()
                        .collect(Collectors.toMap(SrmSupplierDO::getId, SrmSupplierDO::getName, (a, b) -> a));
            row.setQuotes(quotes.stream().map(q -> {
                var item = new SrmCostAnalysisRespVO.QuoteItem();
                item.setSupplierId(q.getSupplierId());
                item.setSupplierName(supplierNames.get(q.getSupplierId()));
                item.setQuotePrice(q.getQuotePrice());
                item.setTaxRate(q.getTaxRate());
                item.setDeliveryCycle(q.getDeliveryCycle());
                item.setQuoteTime(q.getCreateTime());
                return item;
            }).toList());
            rows.add(row);
        }
        // æ‰‹åŠ¨åˆ†é¡µ
        rows.sort((a, b) -> {
            BigDecimal pa = a.getWeightedAvgPrice() == null ? BigDecimal.ZERO : a.getWeightedAvgPrice();
            BigDecimal pb = b.getWeightedAvgPrice() == null ? BigDecimal.ZERO : b.getWeightedAvgPrice();
            return pb.compareTo(pa);
        });
        int total = rows.size();
        int from = Math.min((page.getPageNo() - 1) * page.getPageSize(), total);
        int to = Math.min(from + page.getPageSize(), total);
        return new PageResult<>(rows.subList(from, to), (long) total);
    }
    /** å•物料报价趋势(按月平均报价) */
    public List<SrmCostAnalysisRespVO.TrendPoint> trend(Long productId) {
        if (productId == null) return List.of();
        List<SrmTenderMaterialDO> materials = tenderMaterialMapper.selectList(
                new LambdaQueryWrapperX<SrmTenderMaterialDO>().eq(SrmTenderMaterialDO::getProductId, productId));
        Set<Long> materialIds = materials.stream().map(SrmTenderMaterialDO::getId)
                .filter(Objects::nonNull).collect(Collectors.toSet());
        if (materialIds.isEmpty()) return List.of();
        List<SrmSupplierQuoteDO> quotes = supplierQuoteMapper.selectList(
                new LambdaQueryWrapperX<SrmSupplierQuoteDO>()
                        .in(SrmSupplierQuoteDO::getTenderMaterialId, materialIds));
        Map<YearMonth, List<BigDecimal>> byMonth = new TreeMap<>();
        for (SrmSupplierQuoteDO q : quotes) {
            if (q.getQuotePrice() == null || q.getCreateTime() == null) continue;
            byMonth.computeIfAbsent(YearMonth.from(q.getCreateTime()), k -> new ArrayList<>())
                    .add(q.getQuotePrice());
        }
        List<SrmCostAnalysisRespVO.TrendPoint> result = new ArrayList<>();
        byMonth.forEach((month, prices) -> {
            var p = new SrmCostAnalysisRespVO.TrendPoint();
            p.setMonth(month.toString());
            p.setCount(prices.size());
            p.setAvgPrice(prices.stream().reduce(BigDecimal.ZERO, BigDecimal::add)
                    .divide(BigDecimal.valueOf(prices.size()), 4, RoundingMode.HALF_UP));
            result.add(p);
        });
        return result;
    }
}