云
2026-10-24 a4ed0f250009167453ca637ce042ef8e0b69f613
feat(aftersales): 增加售后退货流程管理功能

- 新增确认外部退款/换货完成接口和取消退货申请接口
- 扩展退货单数据对象增加外部退款流水号字段
- 实现退货申请状态流转控制和业务逻辑验证
- 添加MES系统退货单取消同步处理机制
- 优化工单与退货关联状态管理
- 增强退货流程权限控制和数据校验规则
- 添加API访问日志敏感参数过滤功能
- 更新CRM回款模块审批流程集成BPM工作流
- 修正数据库查询条件和本地环境配置参数
- 添加相关错误码定义和文档资源文件更新
已添加14个文件
已修改37个文件
1291 ■■■■■ 文件已修改
.gitignore 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/xgdl-business-flow-alignment-20261024.md 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/hrm_attendance_location_20261024.sql 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/hrm_module_init.sql 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/xgdl_aftersales_refund_confirm_20261024.sql 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/xgdl_aftersales_return_link_repair_20261024.sql 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/xgdl_receivable_legacy_repair_20261024.sql 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/xgdl_receivable_workflow_seed.sql 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/apilog/core/interceptor/ApiAccessLogInterceptor.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/returnobj/AfterSaleReturnController.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/returnobj/vo/AfterSaleRefundConfirmReqVO.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/returnobj/vo/AfterSaleReturnRespVO.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/dataobject/returnobj/AfterSaleReturnDO.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/mysql/returnobj/AfterSaleReturnMapper.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/enums/ErrorCodeConstants.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/returnobj/AfterSaleMesReturnProgressListener.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/returnobj/AfterSaleReturnService.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/returnobj/AfterSaleReturnServiceImpl.java 189 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/ticket/AfterSaleTicketServiceImpl.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/CrmReceivableController.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/receivable/CrmReceivableMapper.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/ErrorCodeConstants.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivableService.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivableServiceImpl.java 76 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/listener/CrmReceivableStatusListener.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/ErrorCodeConstants.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/HrmAttendanceRecordController.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceClockReqVO.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceClockRespVO.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceRecordRespVO.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceRuleRespVO.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceRuleSaveReqVO.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/attendance/HrmAttendanceRecordDO.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/attendance/HrmAttendanceRuleDO.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceClockResult.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRecordService.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRecordServiceImpl.java 130 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRuleServiceImpl.java 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/test/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceClockLocationTest.java 84 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes-api/src/main/java/cn/iocoder/yudao/module/mes/api/event/MesReturnSalesProgressEvent.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes-api/src/main/java/cn/iocoder/yudao/module/mes/api/returnsales/MesWmReturnSalesApi.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes-api/src/main/java/cn/iocoder/yudao/module/mes/api/returnsales/dto/MesWmReturnSalesCreateReqDTO.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/api/returnsales/MesWmReturnSalesApiImpl.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/returnsales/vo/MesWmReturnSalesSaveReqVO.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/returnsales/MesWmReturnSalesDO.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pro/task/MesProTaskMapper.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/task/MesProTaskServiceImpl.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/returnsales/MesWmReturnSalesLineServiceImpl.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/returnsales/MesWmReturnSalesServiceImpl.java 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/src/main/resources/application-local.yaml 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.gitignore
@@ -27,7 +27,13 @@
*.iml
*.ipr
*.sql
!sql/mysql/xgdl_receivable_workflow_seed.sql
!sql/mysql/xgdl_receivable_legacy_repair_20261024.sql
!sql/mysql/xgdl_aftersales_return_link_repair_20261024.sql
!sql/mysql/xgdl_aftersales_refund_confirm_20261024.sql
!sql/mysql/hrm_attendance_location_20261024.sql
*.md
!docs/xgdl-business-flow-alignment-20261024.md
*.xlsx
*.docx
*.txt
@@ -63,4 +69,4 @@
.claude/worktrees/
.claude/scheduled_tasks.json
/.export_tmp
/.export_tmp
docs/xgdl-business-flow-alignment-20261024.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,31 @@
# æ–°é«˜ç”µåŠ›å…±æ€§ä¸šåŠ¡æµç¨‹å¯¹ç…§ä¸Žæœ¬è½®ä¿®å¤
依据用户提供的业务需求截图和 `mom-pro2-after-test/docs/project-business` æµç¨‹å›¾æ ¸å¯¹å½“前实现。流程图只作为需求资料;以代码和 `mom-xgdl` å®žé™…数据为准。AI ç›¸å…³èƒ½åŠ›ä¸çº³å…¥æœ¬è½®ã€‚
| éœ€æ±‚域 | å…±æ€§æµç¨‹å¤„理 | æœ¬è½®ç»“论 |
| --- | --- | --- |
| äº§å“è®¾è®¡ã€å·¥è‰ºè®¾è®¡ | äº§å“æ¡£æ¡ˆã€å·¥è‰ºè·¯çº¿å±žäºŽçŽ°æœ‰åŸºç¡€èƒ½åŠ› | ç”µåŠ›äº§å“ç¼–ç ã€ç”µåŽ‹ç­‰çº§ã€ä¾›ç”µ/发电运维工艺属于定制需求,本轮未引入通用模型 |
| è¥é”€ç®¡ç† | åˆåŒã€é”€å”®è®¢å•、开票、回款 | å‰ä¸€è½®ä¿®å¤å›žæ¬¾æäº¤ BPM ä¸ŽçŠ¶æ€å›žå†™ï¼Œå¹¶è¡¥å¿…è¦å®¡æ‰¹é…ç½®ï¼›æœ¬è½®ä¸é‡å¤æ”¹åŠ¨ |
| å”®åŽæœåŠ¡ | å·¥å• â†’ é€€è´§ç”³è¯· â†’ MES é”€å”®é€€è´§ â†’ RQC â†’ å…¥åº“ â†’ é€€æ¬¾/换货确认 â†’ å·¥å•关闭 | ä¿®å¤ MES å•据头与行关联丢失、质检/入库回写缺口;补状态校验、取消回退和凭证确认入口 |
| è®¡åˆ’排程、生产管控 | MPS、工单、任务、报工 | å‰ä¸€è½®ä¿®å¤æŠ¥å·¥åŽçš„任务状态与暂停/恢复;行业调度算法未纳入共性改动 |
| è´¨é‡ç®¡ç† | RQC è´¨æ£€ç»“果回写售后 | MES è´¨æ£€å®Œæˆäº‹ä»¶çŽ°åœ¨æŽ¨è¿›å”®åŽçŠ¶æ€ï¼›è´¨æ£€å•ä»ç”±è´¨æ£€äººå‘˜æŒ‰çŽ°æœ‰æ¨¡æ¿æµç¨‹åˆ›å»ºå’Œå®Œæˆï¼Œä¸è‡ªåŠ¨ä¼ªé€ æ£€éªŒæ•°æ® |
| ä»“储物流 | é”€å”®é€€è´§ä¸Šæž¶äº§ç”Ÿå…¥åº“事务 | å…¥åº“完成事件现在推进售后到待退款;历史关联仅在 ID ä¸Žå•号双重一致时回填 |
| è®¾å¤‡ã€é‡‡è´­ã€äººåŠ›èµ„æºã€å†³ç­–æ”¯æŒ | çŽ°æœ‰é€šç”¨æ¨¡å— | æœ¬è½®æœªæ”¹å…¶ä¸šåŠ¡è§„åˆ™ï¼›æˆªå›¾ä¸­çš„ç”µç½‘èµ„äº§ã€æŠ¢ä¿®ã€è´Ÿè·é¢„æµ‹ç­‰åœºæ™¯éœ€å¦è¡Œå®šä¹‰å­—æ®µå’ŒéªŒæ”¶æ ‡å‡† |
## å·²è½åœ°çš„售后状态链
1. å”®åŽå·¥å•处于“处理中”,有销售订单、客户和有效物料明细时创建退货申请;页面可填写本次申请数量并校验订单明细,同一工单只允许一个未取消申请。
2. å®¡æ ¸æ™®é€šé€€è´§/换货时,MES å•据头和明细在同一事务创建;仅退款申请不产生实物入库单。
3. MES é”€å”®é€€è´§æäº¤åŽæŒ‰çŽ°æœ‰ RQC æ¨¡æ¿æ‰§è¡Œæ£€éªŒï¼›å…¨éƒ¨æ£€å®Œå›žå†™â€œæ£€éªŒå®Œæˆâ€ï¼Œå®Œæˆä¸Šæž¶å›žå†™â€œå·²å…¥åº“”。
4. è´¢åŠ¡åœ¨å¤–éƒ¨å®Œæˆé€€æ¬¾æˆ–æ¢è´§åŽï¼Œè¾“å…¥å®žé™…é‡‘é¢å’Œå¯è¿½æº¯å‡­è¯å·ç¡®è®¤ï¼Œå”®åŽç”³è¯·å˜ä¸ºâ€œé€€æ¬¾/换货完成”,工单变为“已完结”。
5. å·¥å•关闭同步关闭申请。入库前取消退货会同步取消 MES å•据、恢复工单“处理中”,允许重新申请。
MES æäº¤ã€æ£€éªŒæŽ¨è¿›ã€å–消与上架采用当前状态条件更新;并发重复上架只有一个请求可写库存流水。
## é…ç½®ä¸ŽéªŒæ”¶è¾¹ç•Œ
- `mom-xgdl` å·²éªŒè¯å­˜åœ¨å½“前历史退货物料的 RQC æ¨¡æ¿å…³è”;没有为其他物料臆造质检标准。新物料须先配置适用的退货检验模板。
- å·²æ ¸å¯¹æœ¬åœ°åº“有 8 ä¸ªä»“库、13 ä¸ªåº“区、13 ä¸ªåº“位、4 å¥—启用质检模板及 47 æ¡ç‰©æ–™å…³è”、6 æ¡å·¥è‰ºè·¯çº¿å’Œ 39 æ¡ BPM æµç¨‹å®šä¹‰ï¼ŒåŸºç¡€æ•°æ®å¹¶éžæ•´ä½“缺失;具体物料与具体单据仍需逐单校验。
- é€€æ¬¾/换货确认采用人工凭证登记;当前代码没有可直接关联售后申请的 ERP é€€æ¬¾å•自动回调。确认按钮只表示外部业务已完成,不执行转账。
- æœ¬è½® SQL ä½äºŽ `sql/mysql/xgdl_aftersales_return_link_repair_20261024.sql` å’Œ `sql/mysql/xgdl_aftersales_refund_confirm_20261024.sql`。在其他环境部署代码前须先执行字段与权限脚本。
- Maven ç¼–译通过;前端全量类型检查仍存在与本次售后文件无关的既有错误。尚未完成浏览器中的整链路业务验收。
sql/mysql/hrm_attendance_location_20261024.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,53 @@
-- è€ƒå‹¤å®šä½å­—段迁移。旧规则保持 location_required=0,先配置地点再启用范围限制。
DELIMITER $$
DROP PROCEDURE IF EXISTS xgdl_hrm_attendance_add_column_20261024$$
CREATE PROCEDURE xgdl_hrm_attendance_add_column_20261024(
    IN p_table_name VARCHAR(64), IN p_column_name VARCHAR(64), IN p_ddl_sql VARCHAR(1000))
BEGIN
    IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS
                   WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = p_table_name AND COLUMN_NAME = p_column_name) THEN
        SET @hrm_location_ddl = p_ddl_sql;
        PREPARE hrm_location_stmt FROM @hrm_location_ddl;
        EXECUTE hrm_location_stmt;
        DEALLOCATE PREPARE hrm_location_stmt;
    END IF;
END$$
DELIMITER ;
CALL xgdl_hrm_attendance_add_column_20261024('hrm_attendance_rule','location_name',
    'ALTER TABLE hrm_attendance_rule ADD COLUMN location_name varchar(100) NULL');
CALL xgdl_hrm_attendance_add_column_20261024('hrm_attendance_rule','location_address',
    'ALTER TABLE hrm_attendance_rule ADD COLUMN location_address varchar(255) NULL');
CALL xgdl_hrm_attendance_add_column_20261024('hrm_attendance_rule','longitude',
    'ALTER TABLE hrm_attendance_rule ADD COLUMN longitude decimal(10,7) NULL');
CALL xgdl_hrm_attendance_add_column_20261024('hrm_attendance_rule','latitude',
    'ALTER TABLE hrm_attendance_rule ADD COLUMN latitude decimal(10,7) NULL');
CALL xgdl_hrm_attendance_add_column_20261024('hrm_attendance_rule','radius_meters',
    'ALTER TABLE hrm_attendance_rule ADD COLUMN radius_meters int DEFAULT 200');
CALL xgdl_hrm_attendance_add_column_20261024('hrm_attendance_rule','location_required',
    'ALTER TABLE hrm_attendance_rule ADD COLUMN location_required bit(1) NOT NULL DEFAULT b''0''');
CALL xgdl_hrm_attendance_add_column_20261024('hrm_attendance_rule','coordinate_system',
    'ALTER TABLE hrm_attendance_rule ADD COLUMN coordinate_system varchar(20) DEFAULT ''WGS84''');
CALL xgdl_hrm_attendance_add_column_20261024('hrm_attendance_record','clock_in_longitude',
    'ALTER TABLE hrm_attendance_record ADD COLUMN clock_in_longitude decimal(10,7) NULL');
CALL xgdl_hrm_attendance_add_column_20261024('hrm_attendance_record','clock_in_latitude',
    'ALTER TABLE hrm_attendance_record ADD COLUMN clock_in_latitude decimal(10,7) NULL');
CALL xgdl_hrm_attendance_add_column_20261024('hrm_attendance_record','clock_in_accuracy',
    'ALTER TABLE hrm_attendance_record ADD COLUMN clock_in_accuracy decimal(8,2) NULL');
CALL xgdl_hrm_attendance_add_column_20261024('hrm_attendance_record','clock_in_distance',
    'ALTER TABLE hrm_attendance_record ADD COLUMN clock_in_distance decimal(10,2) NULL');
CALL xgdl_hrm_attendance_add_column_20261024('hrm_attendance_record','clock_in_address',
    'ALTER TABLE hrm_attendance_record ADD COLUMN clock_in_address varchar(255) NULL');
CALL xgdl_hrm_attendance_add_column_20261024('hrm_attendance_record','clock_out_longitude',
    'ALTER TABLE hrm_attendance_record ADD COLUMN clock_out_longitude decimal(10,7) NULL');
CALL xgdl_hrm_attendance_add_column_20261024('hrm_attendance_record','clock_out_latitude',
    'ALTER TABLE hrm_attendance_record ADD COLUMN clock_out_latitude decimal(10,7) NULL');
CALL xgdl_hrm_attendance_add_column_20261024('hrm_attendance_record','clock_out_accuracy',
    'ALTER TABLE hrm_attendance_record ADD COLUMN clock_out_accuracy decimal(8,2) NULL');
CALL xgdl_hrm_attendance_add_column_20261024('hrm_attendance_record','clock_out_distance',
    'ALTER TABLE hrm_attendance_record ADD COLUMN clock_out_distance decimal(10,2) NULL');
CALL xgdl_hrm_attendance_add_column_20261024('hrm_attendance_record','clock_out_address',
    'ALTER TABLE hrm_attendance_record ADD COLUMN clock_out_address varchar(255) NULL');
DROP PROCEDURE xgdl_hrm_attendance_add_column_20261024;
sql/mysql/hrm_module_init.sql
@@ -202,6 +202,13 @@
    `late_allow_minutes` tinyint DEFAULT 0 COMMENT '迟到允许分钟数',
    `early_allow_minutes` tinyint DEFAULT 0 COMMENT '早退允许分钟数',
    `overtime_threshold` decimal(4,2) DEFAULT 1 COMMENT '加班起算时长(小时)',
    `location_name` varchar(100) DEFAULT NULL COMMENT '打卡地点名称',
    `location_address` varchar(255) DEFAULT NULL COMMENT '打卡地点详细地址',
    `longitude` decimal(10,7) DEFAULT NULL COMMENT '打卡地点经度(WGS84)',
    `latitude` decimal(10,7) DEFAULT NULL COMMENT '打卡地点纬度(WGS84)',
    `radius_meters` int DEFAULT 200 COMMENT '允许打卡半径(米)',
    `location_required` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否启用位置限制',
    `coordinate_system` varchar(20) DEFAULT 'WGS84' COMMENT '坐标系',
    `status` tinyint NOT NULL DEFAULT 0 COMMENT '状态:0-启用,1-禁用',
    `remark` varchar(500) DEFAULT NULL COMMENT '备注',
    `creator` varchar(64) DEFAULT '' COMMENT '创建者',
@@ -226,6 +233,16 @@
    `work_hours` decimal(4,2) DEFAULT NULL COMMENT '工作时长(小时)',
    `overtime_hours` decimal(4,2) DEFAULT 0 COMMENT '加班时长(小时)',
    `location` varchar(200) DEFAULT NULL COMMENT '打卡地点',
    `clock_in_longitude` decimal(10,7) DEFAULT NULL COMMENT '上班打卡经度(WGS84)',
    `clock_in_latitude` decimal(10,7) DEFAULT NULL COMMENT '上班打卡纬度(WGS84)',
    `clock_in_accuracy` decimal(8,2) DEFAULT NULL COMMENT '上班定位精度(米)',
    `clock_in_distance` decimal(10,2) DEFAULT NULL COMMENT '上班距规则地点距离(米)',
    `clock_in_address` varchar(255) DEFAULT NULL COMMENT '上班规则地点地址快照',
    `clock_out_longitude` decimal(10,7) DEFAULT NULL COMMENT '下班打卡经度(WGS84)',
    `clock_out_latitude` decimal(10,7) DEFAULT NULL COMMENT '下班打卡纬度(WGS84)',
    `clock_out_accuracy` decimal(8,2) DEFAULT NULL COMMENT '下班定位精度(米)',
    `clock_out_distance` decimal(10,2) DEFAULT NULL COMMENT '下班距规则地点距离(米)',
    `clock_out_address` varchar(255) DEFAULT NULL COMMENT '下班规则地点地址快照',
    `remark` varchar(500) DEFAULT NULL COMMENT '备注',
    `data_source` tinyint DEFAULT 1 COMMENT '数据来源:1-打卡,2-导入',
    `creator` varchar(64) DEFAULT '' COMMENT '创建者',
@@ -586,4 +603,4 @@
-- =============================================
-- æ‰§è¡Œå®Œæˆæç¤º
-- =============================================
SELECT 'HRM äººåŠ›èµ„æºæ¨¡å— SQL åˆå§‹åŒ–完成!' AS result;
SELECT 'HRM äººåŠ›èµ„æºæ¨¡å— SQL åˆå§‹åŒ–完成!' AS result;
sql/mysql/xgdl_aftersales_refund_confirm_20261024.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,27 @@
-- å”®åŽé€€æ¬¾/换货人工确认需要可追溯凭证及独立权限。
SET @has_refund_voucher := (SELECT COUNT(*) FROM information_schema.COLUMNS
  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'after_sale_return' AND COLUMN_NAME = 'refund_voucher_no');
SET @ddl := IF(@has_refund_voucher = 0,
  'ALTER TABLE after_sale_return ADD COLUMN refund_voucher_no varchar(100) NULL COMMENT ''Refund or exchange voucher number'' AFTER actual_refund_price',
  'SELECT 1');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
INSERT INTO system_menu (name, permission, type, sort, parent_id, path, icon, component,
                         component_name, status, visible, keep_alive, always_show, creator, updater)
SELECT CONVERT(0xE7A1AEE8AEA4E98080E6ACBE2FE68DA2E8B4A7 USING utf8mb4), 'aftersales:return:refund', 3, 3, 602589, '', '#', NULL,
       NULL, 0, b'1', b'0', b'0', 'system', 'system'
WHERE EXISTS (SELECT 1 FROM system_menu WHERE id = 602589 AND deleted = b'0')
  AND NOT EXISTS (SELECT 1 FROM system_menu WHERE permission = 'aftersales:return:refund' AND deleted = b'0');
UPDATE system_menu
SET name = CONVERT(0xE7A1AEE8AEA4E98080E6ACBE2FE68DA2E8B4A7 USING utf8mb4)
WHERE permission = 'aftersales:return:refund' AND deleted = b'0';
INSERT INTO system_menu (name, permission, type, sort, parent_id, path, icon, component,
                         component_name, status, visible, keep_alive, always_show, creator, updater)
SELECT CONVERT(0xE58F96E6B688E98080E8B4A7 USING utf8mb4), 'aftersales:return:cancel', 3, 4, 602589, '', '#', NULL,
       NULL, 0, b'1', b'0', b'0', 'system', 'system'
WHERE EXISTS (SELECT 1 FROM system_menu WHERE id = 602589 AND deleted = b'0')
  AND NOT EXISTS (SELECT 1 FROM system_menu WHERE permission = 'aftersales:return:cancel' AND deleted = b'0');
sql/mysql/xgdl_aftersales_return_link_repair_20261024.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,10 @@
-- ä»…修复已由售后申请记录了 MES å•据 ID、单号且双方一致的历史关联。
-- ä¸ä¿®æ”¹ä¸šåŠ¡çŠ¶æ€ï¼›é‡å¤æ‰§è¡Œä¸ä¼šäº§ç”Ÿæ–°å˜åŒ–ã€‚
UPDATE mes_wm_return_sales AS mes
JOIN after_sale_return AS after_sale
  ON after_sale.mes_return_id = mes.id
 AND after_sale.mes_return_code = mes.code
SET mes.after_sale_return_id = after_sale.id
WHERE mes.after_sale_return_id IS NULL
  AND mes.deleted = 0
  AND after_sale.deleted = 0;
sql/mysql/xgdl_receivable_legacy_repair_20261024.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,19 @@
-- mom-xgdl:修复用户明确批准的单条历史回款 HK20261016001(ID 5)。
-- æ—§ç‰ˆåˆ›å»ºå³è¿›å…¥å®¡æ‰¹ä¸­ï¼Œä½†æ­¤å•无开票、无流程实例、无 BPM åŽ†å²ã€‚
-- å·²äºŽ 2026-10-24 æ‰§è¡Œï¼Œå½±å“ 1 è¡Œï¼›é‡å¤æ‰§è¡Œä¸ä¼šå†æ¬¡ä¿®æ”¹ã€‚
START TRANSACTION;
UPDATE crm_receivable r
SET r.audit_status = 0, r.update_time = NOW()
WHERE r.id = 5 AND r.no = 'HK20261016001' AND r.audit_status = 10
  AND r.invoice_id IS NULL
  AND (r.process_instance_id IS NULL OR r.process_instance_id = '')
  AND r.deleted = b'0'
  AND NOT EXISTS (
      SELECT 1 FROM ACT_HI_PROCINST h
      WHERE h.BUSINESS_KEY_ = CAST(r.id AS CHAR)
        AND h.PROC_DEF_ID_ LIKE 'crm-receivable-audit:%'
  );
COMMIT;
SELECT id, no, audit_status, invoice_id, process_instance_id
FROM crm_receivable WHERE id = 5;
sql/mysql/xgdl_receivable_workflow_seed.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,42 @@
-- mom-xgdl:回款 BPM å¿…要配置。仅在现有开票审批配置可用时复用同一财务审批人。
-- å¹‚等执行;不会插入业务回款、伪造审批结果或改动定制流程定义。
START TRANSACTION;
INSERT INTO system_approval_config
    (biz_type, biz_name, approval_enabled, remark, creator, create_time, updater, update_time, deleted)
SELECT 'crm_receivable_approve', '回款审批', i.approval_enabled,
       '回款审批复用开票财务审批人', '1', NOW(), '1', NOW(), b'0'
FROM system_approval_config i
WHERE i.biz_type = 'crm_invoice_approve' AND i.deleted = b'0'
  AND NOT EXISTS (
      SELECT 1 FROM system_approval_config c
      WHERE c.biz_type = 'crm_receivable_approve' AND c.deleted = b'0'
  )
LIMIT 1;
INSERT INTO system_approval_config_user
    (config_id, user_id, sort, creator, create_time, updater, update_time, deleted)
SELECT r.id, iu.user_id, iu.sort, '1', NOW(), '1', NOW(), b'0'
FROM system_approval_config r
JOIN system_approval_config i ON i.biz_type = 'crm_invoice_approve' AND i.deleted = b'0'
JOIN system_approval_config_user iu ON iu.config_id = i.id AND iu.deleted = b'0'
WHERE r.biz_type = 'crm_receivable_approve' AND r.deleted = b'0'
  AND r.remark = '回款审批复用开票财务审批人'
  AND NOT EXISTS (
      SELECT 1 FROM system_approval_config_user existing
      WHERE existing.config_id = r.id AND existing.deleted = b'0'
  )
  AND NOT EXISTS (
      SELECT 1 FROM system_approval_config_user ru
      WHERE ru.config_id = r.id AND ru.user_id = iu.user_id AND ru.deleted = b'0'
  );
COMMIT;
-- åªè¯»æ ¸éªŒï¼šå›žæ¬¾æµç¨‹å®šä¹‰åŠå®¡æ‰¹é…ç½®å‡åº”存在且启用。
SELECT KEY_, SUSPENSION_STATE_ FROM ACT_RE_PROCDEF WHERE KEY_ = 'crm-receivable-audit';
SELECT c.biz_type, c.approval_enabled, COUNT(u.id) AS approver_count
FROM system_approval_config c
LEFT JOIN system_approval_config_user u ON u.config_id = c.id AND u.deleted = b'0'
WHERE c.biz_type = 'crm_receivable_approve' AND c.deleted = b'0'
GROUP BY c.id, c.biz_type, c.approval_enabled;
yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/apilog/core/interceptor/ApiAccessLogInterceptor.java
@@ -6,6 +6,7 @@
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.util.servlet.ServletUtils;
import cn.iocoder.yudao.framework.common.util.spring.SpringUtils;
import cn.iocoder.yudao.framework.apilog.core.annotation.ApiAccessLog;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
@@ -43,13 +44,18 @@
        // æ‰“印 request æ—¥å¿—
        if (!SpringUtils.isProd()) {
            Map<String, String> queryString = ServletUtils.getParamMap(request);
            String requestBody = ServletUtils.getBody(request);
            if (CollUtil.isEmpty(queryString) && StrUtil.isEmpty(requestBody)) {
                log.info("[preHandle][开始请求 URL({}) æ— å‚æ•°]", request.getRequestURI());
            ApiAccessLog accessLog = handlerMethod != null ? handlerMethod.getMethodAnnotation(ApiAccessLog.class) : null;
            if (accessLog != null && !accessLog.requestEnable()) {
                log.info("[preHandle][开始请求 URL({}) æ•æ„Ÿè¯·æ±‚参数已隐藏]", request.getRequestURI());
            } else {
                log.info("[preHandle][开始请求 URL({}) å‚æ•°({})]", request.getRequestURI(),
                        StrUtil.blankToDefault(requestBody, queryString.toString()));
                Map<String, String> queryString = ServletUtils.getParamMap(request);
                String requestBody = ServletUtils.getBody(request);
                if (CollUtil.isEmpty(queryString) && StrUtil.isEmpty(requestBody)) {
                    log.info("[preHandle][开始请求 URL({}) æ— å‚æ•°]", request.getRequestURI());
                } else {
                    log.info("[preHandle][开始请求 URL({}) å‚æ•°({})]", request.getRequestURI(),
                            StrUtil.blankToDefault(requestBody, queryString.toString()));
                }
            }
            // è®¡æ—¶
            StopWatch stopWatch = new StopWatch();
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/returnobj/AfterSaleReturnController.java
@@ -7,6 +7,7 @@
import cn.iocoder.yudao.module.aftersales.controller.admin.returnobj.vo.AfterSaleReturnPageReqVO;
import cn.iocoder.yudao.module.aftersales.controller.admin.returnobj.vo.AfterSaleReturnRespVO;
import cn.iocoder.yudao.module.aftersales.controller.admin.returnobj.vo.AfterSaleReturnSaveReqVO;
import cn.iocoder.yudao.module.aftersales.controller.admin.returnobj.vo.AfterSaleRefundConfirmReqVO;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.returnobj.AfterSaleReturnDO;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.returnobj.AfterSaleReturnItemDO;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.ticket.AfterSaleTicketDO;
@@ -60,6 +61,22 @@
        return success(true);
    }
    @PutMapping("/complete-refund")
    @Operation(summary = "确认外部退款/换货完成")
    @PreAuthorize("@ss.hasPermission('aftersales:return:refund')")
    public CommonResult<Boolean> completeRefund(@Valid @RequestBody AfterSaleRefundConfirmReqVO reqVO) {
        returnService.confirmRefundCompleted(reqVO.getId(), reqVO.getActualRefundPrice(), reqVO.getVoucherNo());
        return success(true);
    }
    @PutMapping("/cancel")
    @Operation(summary = "取消尚未入库或退款的退货申请")
    @PreAuthorize("@ss.hasPermission('aftersales:return:cancel')")
    public CommonResult<Boolean> cancelReturn(@RequestParam("id") Long id) {
        returnService.cancelReturn(id);
        return success(true);
    }
    @GetMapping("/get")
    @Operation(summary = "获得退货申请")
    @Parameter(name = "id", description = "退货申请编号", required = true, example = "1")
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/returnobj/vo/AfterSaleRefundConfirmReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,22 @@
package cn.iocoder.yudao.module.aftersales.controller.admin.returnobj.vo;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.math.BigDecimal;
/** å¤–部退款/换货完成确认;必须提供可追溯的业务凭证。 */
@Data
public class AfterSaleRefundConfirmReqVO {
    @NotNull(message = "退货申请 ID ä¸èƒ½ä¸ºç©º")
    private Long id;
    @NotNull(message = "实际退款金额不能为空,换货可填 0")
    @DecimalMin(value = "0", message = "实际退款金额不能为负数")
    private BigDecimal actualRefundPrice;
    @NotBlank(message = "退款流水号或换货单号不能为空")
    @Size(max = 100, message = "凭证号不能超过 100 ä¸ªå­—符")
    private String voucherNo;
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/returnobj/vo/AfterSaleReturnRespVO.java
@@ -49,6 +49,7 @@
    @Schema(description = "实退金额", example = "950.00")
    private BigDecimal actualRefundPrice;
    private String refundVoucherNo;
    @Schema(description = "状态", example = "10")
    private Integer status;
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/dataobject/returnobj/AfterSaleReturnDO.java
@@ -66,6 +66,8 @@
     * å®žé€€é‡‘额
     */
    private BigDecimal actualRefundPrice;
    /** å¤–部退款流水号或换货单号,供人工完成确认时追溯。 */
    private String refundVoucherNo;
    /**
     * é€€è´§çŠ¶æ€
     *
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/mysql/returnobj/AfterSaleReturnMapper.java
@@ -6,6 +6,7 @@
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.MPJLambdaWrapperX;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.returnobj.AfterSaleReturnDO;
import cn.iocoder.yudao.module.aftersales.enums.AfterSaleReturnStatusEnum;
import cn.iocoder.yudao.module.aftersales.enums.AfterSaleBizTypeEnum;
import cn.iocoder.yudao.module.aftersales.util.AfterSalePermissionUtils;
import org.apache.ibatis.annotations.Mapper;
@@ -24,6 +25,12 @@
        return selectOne(AfterSaleReturnDO::getTicketId, ticketId);
    }
    default boolean existsActiveByTicketId(Long ticketId) {
        return selectCount(new LambdaQueryWrapperX<AfterSaleReturnDO>()
                .eq(AfterSaleReturnDO::getTicketId, ticketId)
                .ne(AfterSaleReturnDO::getStatus, AfterSaleReturnStatusEnum.CANCELED.getStatus())) > 0;
    }
    default PageResult<AfterSaleReturnDO> selectPage(PageParam pageParam, Long userId, Integer sceneType,
                                                     String no, Long ticketId,
                                                     Long customerId, Integer status) {
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/enums/ErrorCodeConstants.java
@@ -31,6 +31,12 @@
    ErrorCode RETURN_ITEM_EMPTY = new ErrorCode(1_022_002_003, "退货明细行不能为空");
    ErrorCode RETURN_DELETE_FAIL_NOT_DRAFT = new ErrorCode(1_022_002_004, "退货申请不是草稿状态,无法删除");
    ErrorCode RETURN_TICKET_SALE_ORDER_REQUIRED = new ErrorCode(1_022_002_005, "创建退货申请失败,原因:售后工单未关联销售订单");
    ErrorCode RETURN_TICKET_INVALID = new ErrorCode(1_022_002_006, "退货申请与工单的客户或状态不一致,或该工单已有退货申请");
    ErrorCode RETURN_ITEM_INVALID = new ErrorCode(1_022_002_007, "退货明细与工单物料不一致,或退货数量、金额无效");
    ErrorCode RETURN_STATUS_ERROR = new ErrorCode(1_022_002_008, "退货申请当前状态不允许此操作");
    ErrorCode RETURN_TICKET_NOT_FINISHED = new ErrorCode(1_022_002_009, "退货尚未完成入库和退款/换货,工单不能完结");
    ErrorCode RETURN_REFUND_VOUCHER_REQUIRED = new ErrorCode(1_022_002_010, "请填写退款流水号或换货单号");
    ErrorCode RETURN_REFUND_EXCEEDS_TOTAL = new ErrorCode(1_022_002_011, "实际退款金额不能超过应退总金额");
    // ========== ç»´ä¿®è®°å½• 1-022-003-000 ==========
    ErrorCode REPAIR_NOT_EXISTS = new ErrorCode(1_022_003_000, "维修记录不存在");
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/returnobj/AfterSaleMesReturnProgressListener.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,33 @@
package cn.iocoder.yudao.module.aftersales.service.returnobj;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.returnobj.AfterSaleReturnDO;
import cn.iocoder.yudao.module.aftersales.dal.mysql.returnobj.AfterSaleReturnMapper;
import cn.iocoder.yudao.module.mes.api.event.MesReturnSalesProgressEvent;
import jakarta.annotation.Resource;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
/** å°† MES è´¨æ£€ã€å…¥åº“结果同步到售后申请。同步监听保证任一步失败时整体回滚。 */
@Component
public class AfterSaleMesReturnProgressListener {
    @Resource
    private AfterSaleReturnMapper returnMapper;
    @Resource
    private AfterSaleReturnService returnService;
    @EventListener
    public void onProgress(MesReturnSalesProgressEvent event) {
        AfterSaleReturnDO returnDO = returnMapper.selectByMesReturnId(event.mesReturnId());
        if (returnDO == null) {
            return;
        }
        if (event.stage() == MesReturnSalesProgressEvent.Stage.QUALITY_FINISHED) {
            returnService.onQualityFinished(returnDO.getId(), event.qualityResult(), null);
        } else if (event.stage() == MesReturnSalesProgressEvent.Stage.INBOUND_FINISHED) {
            returnService.onInboundFinished(returnDO.getId());
        } else if (event.stage() == MesReturnSalesProgressEvent.Stage.CANCELED) {
            returnService.onMesCanceled(returnDO.getId());
        }
    }
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/returnobj/AfterSaleReturnService.java
@@ -32,6 +32,12 @@
     */
    void approveReturn(Long id);
    /** å–消尚未入库或退款的申请。 */
    void cancelReturn(Long id);
    /** MES ä¾§å–消时同步回退售后工单。 */
    void onMesCanceled(Long returnId);
    /**
     * èŽ·å¾—é€€è´§ç”³è¯·
     *
@@ -89,4 +95,7 @@
     */
    void onRefundCompleted(Long returnId, BigDecimal actualRefundPrice);
    /** ç¡®è®¤å¤–部退款或换货已完成,凭证号必填。 */
    void confirmRefundCompleted(Long returnId, BigDecimal actualRefundPrice, String voucherNo);
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/returnobj/AfterSaleReturnServiceImpl.java
@@ -8,9 +8,11 @@
import cn.iocoder.yudao.module.aftersales.dal.dataobject.returnobj.AfterSaleReturnDO;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.returnobj.AfterSaleReturnItemDO;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.ticket.AfterSaleTicketDO;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.ticket.AfterSaleTicketItemDO;
import cn.iocoder.yudao.module.aftersales.dal.mysql.returnobj.AfterSaleReturnItemMapper;
import cn.iocoder.yudao.module.aftersales.dal.mysql.returnobj.AfterSaleReturnMapper;
import cn.iocoder.yudao.module.aftersales.dal.mysql.ticket.AfterSaleTicketMapper;
import cn.iocoder.yudao.module.aftersales.dal.mysql.ticket.AfterSaleTicketItemMapper;
import cn.iocoder.yudao.module.aftersales.dal.redis.no.AfterSaleNoRedisDAO;
import cn.iocoder.yudao.module.aftersales.enums.AfterSaleBizTypeEnum;
import cn.iocoder.yudao.module.aftersales.enums.AfterSaleReturnStatusEnum;
@@ -31,6 +33,14 @@
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.Objects;
import java.util.HashSet;
import java.util.function.Function;
import java.util.stream.Collectors;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.aftersales.enums.ErrorCodeConstants.*;
@@ -49,6 +59,8 @@
    private AfterSaleReturnItemMapper returnItemMapper;
    @Resource
    private AfterSaleTicketMapper ticketMapper;
    @Resource
    private AfterSaleTicketItemMapper ticketItemMapper;
    @Resource
    private AfterSaleNoRedisDAO noRedisDAO;
@@ -69,6 +81,37 @@
        }
        if (ticket.getSaleOrderId() == null) {
            throw exception(RETURN_TICKET_SALE_ORDER_REQUIRED);
        }
        if (!AfterSaleTicketStatusEnum.PROCESSING.getStatus().equals(ticket.getStatus())
                || !Objects.equals(ticket.getCustomerId(), createReqVO.getCustomerId())
                || ticket.getReturnId() != null || returnMapper.existsActiveByTicketId(ticket.getId())) {
            throw exception(RETURN_TICKET_INVALID);
        }
        if (CollUtil.isEmpty(createReqVO.getItems())) {
            throw exception(RETURN_ITEM_EMPTY);
        }
        if (createReqVO.getReturnType() == null || !List.of(1, 2, 3).contains(createReqVO.getReturnType())) {
            throw exception(RETURN_ITEM_INVALID);
        }
        Map<Long, AfterSaleTicketItemDO> ticketItems = ticketItemMapper.selectListByTicketId(ticket.getId())
                .stream().collect(Collectors.toMap(AfterSaleTicketItemDO::getId, Function.identity()));
        Set<Long> usedItems = new HashSet<>();
        for (AfterSaleReturnSaveReqVO.Item item : createReqVO.getItems()) {
            AfterSaleTicketItemDO ticketItem = ticketItems.get(item.getTicketItemId());
            if (ticketItem == null || !usedItems.add(item.getTicketItemId())
                    || item.getItemId() == null || !item.getItemId().equals(ticketItem.getItemId())
                    || (!Integer.valueOf(3).equals(createReqVO.getReturnType())
                        && !Boolean.TRUE.equals(ticketItem.getNeedReturn()))
                    || (ticketItem.getBatchId() != null && !Objects.equals(item.getBatchId(), ticketItem.getBatchId()))
                    || item.getReturnQuantity() == null || item.getReturnQuantity().signum() <= 0
                    || ticketItem.getSaleQuantity() == null
                    || item.getReturnQuantity().compareTo(ticketItem.getSaleQuantity()) > 0
                    || (ticketItem.getReturnQuantity() != null && ticketItem.getReturnQuantity().signum() > 0
                        && item.getReturnQuantity().compareTo(ticketItem.getReturnQuantity()) > 0)
                    || item.getUnitPrice() == null || item.getUnitPrice().signum() < 0
                    || item.getReturnPrice() == null || item.getReturnPrice().signum() < 0) {
                throw exception(RETURN_ITEM_INVALID);
            }
        }
        // é”€å”®è®¢å•以工单为准,保证退货单与工单关联同一订单
        createReqVO.setSaleOrderId(ticket.getSaleOrderId());
@@ -112,9 +155,15 @@
        permissionService.createPermission(permBO);
        // 7. æ›´æ–°å·¥å•状态为待退货
        ticketMapper.updateById(new AfterSaleTicketDO().setId(ticket.getId())
                .setReturnId(returnDO.getId())
                .setStatus(AfterSaleTicketStatusEnum.WAITING_RETURN.getStatus()));
        int ticketUpdated = ticketMapper.update(null, new LambdaUpdateWrapper<AfterSaleTicketDO>()
                .eq(AfterSaleTicketDO::getId, ticket.getId())
                .eq(AfterSaleTicketDO::getStatus, AfterSaleTicketStatusEnum.PROCESSING.getStatus())
                .isNull(AfterSaleTicketDO::getReturnId)
                .set(AfterSaleTicketDO::getReturnId, returnDO.getId())
                .set(AfterSaleTicketDO::getStatus, AfterSaleTicketStatusEnum.WAITING_RETURN.getStatus()));
        if (ticketUpdated != 1) {
            throw exception(RETURN_TICKET_INVALID);
        }
        return returnDO.getId();
    }
@@ -131,7 +180,20 @@
        // 2. èŽ·å–å…³è”å·¥å•ï¼ˆç”¨äºŽèŽ·å–é”€å”®è®¢å•å·ç­‰å†—ä½™ä¿¡æ¯ï¼‰
        AfterSaleTicketDO ticket = ticketMapper.selectById(returnDO.getTicketId());
        // 3. è°ƒç”¨ MES åˆ›å»ºé”€å”®é€€è´§å•
        // 3. ä»…退款不产生实物入库;其余类型将明细完整带入 MES
        if (Integer.valueOf(3).equals(returnDO.getReturnType())) {
            returnMapper.updateById(new AfterSaleReturnDO().setId(id)
                    .setStatus(AfterSaleReturnStatusEnum.APPROVED.getStatus())
                    .setAuditTime(LocalDateTime.now()));
            if (ticketMapper.update(null, new LambdaUpdateWrapper<AfterSaleTicketDO>()
                    .eq(AfterSaleTicketDO::getId, returnDO.getTicketId())
                    .eq(AfterSaleTicketDO::getReturnId, id)
                    .eq(AfterSaleTicketDO::getStatus, AfterSaleTicketStatusEnum.WAITING_RETURN.getStatus())
                    .set(AfterSaleTicketDO::getStatus, AfterSaleTicketStatusEnum.WAITING_REFUND.getStatus())) != 1) {
                throw exception(RETURN_TICKET_INVALID);
            }
            return;
        }
        MesWmReturnSalesCreateReqDTO mesReqDTO = new MesWmReturnSalesCreateReqDTO();
        mesReqDTO.setCode(returnDO.getNo());
        mesReqDTO.setName("售后退货-" + returnDO.getNo());
@@ -142,6 +204,15 @@
        mesReqDTO.setReturnReason(returnDO.getReturnReason());
        mesReqDTO.setRemark("售后工单退货 - å·¥å•号: " + (ticket != null ? ticket.getNo() : ""));
        mesReqDTO.setAfterSaleReturnId(returnDO.getId());
        mesReqDTO.setItems(returnItemMapper.selectListByReturnId(id).stream().map(item -> {
            MesWmReturnSalesCreateReqDTO.Item line = new MesWmReturnSalesCreateReqDTO.Item();
            line.setItemId(item.getItemId());
            line.setQuantity(item.getReturnQuantity());
            line.setBatchId(item.getBatchId());
            line.setBatchCode(item.getBatchCode());
            line.setRemark(item.getRemark());
            return line;
        }).toList());
        Long mesReturnId = mesWmReturnSalesApi.createReturnSales(mesReqDTO);
        // 4. æ›´æ–°é€€è´§ç”³è¯·çŠ¶æ€
@@ -150,6 +221,45 @@
                .setMesReturnId(mesReturnId)
                .setMesReturnCode(returnDO.getNo())
                .setAuditTime(LocalDateTime.now()));
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void cancelReturn(Long id) {
        AfterSaleReturnDO returnDO = validateReturnExists(id);
        if (!List.of(AfterSaleReturnStatusEnum.DRAFT.getStatus(), AfterSaleReturnStatusEnum.APPROVED.getStatus(),
                AfterSaleReturnStatusEnum.INSPECTING.getStatus(), AfterSaleReturnStatusEnum.INSPECTED.getStatus())
                .contains(returnDO.getStatus())) {
            throw exception(RETURN_STATUS_ERROR);
        }
        if (returnDO.getMesReturnId() != null) {
            mesWmReturnSalesApi.cancelReturnSales(returnDO.getMesReturnId());
        } else {
            onMesCanceled(id);
        }
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void onMesCanceled(Long returnId) {
        AfterSaleReturnDO returnDO = validateReturnExists(returnId);
        if (AfterSaleReturnStatusEnum.CANCELED.getStatus().equals(returnDO.getStatus())) {
            return;
        }
        if (!List.of(AfterSaleReturnStatusEnum.DRAFT.getStatus(), AfterSaleReturnStatusEnum.APPROVED.getStatus(),
                AfterSaleReturnStatusEnum.INSPECTING.getStatus(), AfterSaleReturnStatusEnum.INSPECTED.getStatus())
                .contains(returnDO.getStatus())) {
            throw exception(RETURN_STATUS_ERROR);
        }
        returnMapper.updateById(new AfterSaleReturnDO().setId(returnId)
                .setStatus(AfterSaleReturnStatusEnum.CANCELED.getStatus()));
        if (ticketMapper.update(null, new LambdaUpdateWrapper<AfterSaleTicketDO>()
                .eq(AfterSaleTicketDO::getId, returnDO.getTicketId())
                .eq(AfterSaleTicketDO::getReturnId, returnId)
                .set(AfterSaleTicketDO::getReturnId, null)
                .set(AfterSaleTicketDO::getStatus, AfterSaleTicketStatusEnum.PROCESSING.getStatus())) != 1) {
            throw exception(RETURN_TICKET_INVALID);
        }
    }
    @Override
@@ -181,6 +291,15 @@
    @Transactional(rollbackFor = Exception.class)
    public void onQualityFinished(Long returnId, String qualityResult, String reportUrl) {
        AfterSaleReturnDO returnDO = validateReturnExists(returnId);
        if (List.of(AfterSaleReturnStatusEnum.INSPECTED.getStatus(), AfterSaleReturnStatusEnum.INBOUND.getStatus(),
                AfterSaleReturnStatusEnum.REFUNDED.getStatus(), AfterSaleReturnStatusEnum.CLOSED.getStatus())
                .contains(returnDO.getStatus())) {
            return;
        }
        if (!AfterSaleReturnStatusEnum.APPROVED.getStatus().equals(returnDO.getStatus())
                && !AfterSaleReturnStatusEnum.INSPECTING.getStatus().equals(returnDO.getStatus())) {
            throw exception(RETURN_STATUS_ERROR);
        }
        returnMapper.updateById(new AfterSaleReturnDO().setId(returnId)
                .setQualityResult(qualityResult)
                .setQualityReportUrl(reportUrl)
@@ -191,25 +310,77 @@
    @Transactional(rollbackFor = Exception.class)
    public void onInboundFinished(Long returnId) {
        AfterSaleReturnDO returnDO = validateReturnExists(returnId);
        if (List.of(AfterSaleReturnStatusEnum.INBOUND.getStatus(), AfterSaleReturnStatusEnum.REFUNDED.getStatus(),
                AfterSaleReturnStatusEnum.CLOSED.getStatus()).contains(returnDO.getStatus())) {
            return;
        }
        if (!AfterSaleReturnStatusEnum.INSPECTED.getStatus().equals(returnDO.getStatus())) {
            throw exception(RETURN_STATUS_ERROR);
        }
        returnMapper.updateById(new AfterSaleReturnDO().setId(returnId)
                .setStatus(AfterSaleReturnStatusEnum.INBOUND.getStatus())
                .setReturnInboundTime(LocalDateTime.now()));
        if (ticketMapper.update(null, new LambdaUpdateWrapper<AfterSaleTicketDO>()
                .eq(AfterSaleTicketDO::getId, returnDO.getTicketId())
                .eq(AfterSaleTicketDO::getReturnId, returnId)
                .eq(AfterSaleTicketDO::getStatus, AfterSaleTicketStatusEnum.WAITING_RETURN.getStatus())
                .set(AfterSaleTicketDO::getStatus, AfterSaleTicketStatusEnum.WAITING_REFUND.getStatus())) != 1) {
            throw exception(RETURN_TICKET_INVALID);
        }
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void onRefundCompleted(Long returnId, BigDecimal actualRefundPrice) {
        completeRefund(returnId, actualRefundPrice, null, false);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void confirmRefundCompleted(Long returnId, BigDecimal actualRefundPrice, String voucherNo) {
        if (StrUtil.isBlank(voucherNo)) {
            throw exception(RETURN_REFUND_VOUCHER_REQUIRED);
        }
        completeRefund(returnId, actualRefundPrice, voucherNo.trim(), true);
    }
    private void completeRefund(Long returnId, BigDecimal actualRefundPrice, String voucherNo, boolean manual) {
        AfterSaleReturnDO returnDO = validateReturnExists(returnId);
        if (List.of(AfterSaleReturnStatusEnum.REFUNDED.getStatus(), AfterSaleReturnStatusEnum.CLOSED.getStatus())
                .contains(returnDO.getStatus())) {
            return;
        }
        boolean refundOnly = Integer.valueOf(3).equals(returnDO.getReturnType());
        if (!(refundOnly && AfterSaleReturnStatusEnum.APPROVED.getStatus().equals(returnDO.getStatus()))
                && !AfterSaleReturnStatusEnum.INBOUND.getStatus().equals(returnDO.getStatus())) {
            throw exception(RETURN_STATUS_ERROR);
        }
        if (actualRefundPrice == null || actualRefundPrice.signum() < 0) {
            throw exception(RETURN_ITEM_INVALID);
        }
        if (manual && returnDO.getTotalReturnPrice() != null
                && actualRefundPrice.compareTo(returnDO.getTotalReturnPrice()) > 0) {
            throw exception(RETURN_REFUND_EXCEEDS_TOTAL);
        }
        if (manual && !Integer.valueOf(2).equals(returnDO.getReturnType())
                && returnDO.getTotalReturnPrice() != null && returnDO.getTotalReturnPrice().signum() > 0
                && actualRefundPrice.signum() == 0) {
            throw exception(RETURN_ITEM_INVALID);
        }
        returnMapper.updateById(new AfterSaleReturnDO().setId(returnId)
                .setActualRefundPrice(actualRefundPrice)
                .setRefundVoucherNo(voucherNo)
                .setStatus(AfterSaleReturnStatusEnum.REFUNDED.getStatus())
                .setRefundTime(LocalDateTime.now()));
        // æ›´æ–°å…³è”工单状态为待退款/换货完成
        AfterSaleTicketDO ticket = ticketMapper.selectById(returnDO.getTicketId());
        if (ticket != null) {
            ticketMapper.updateById(new AfterSaleTicketDO().setId(ticket.getId())
                    .setStatus(AfterSaleTicketStatusEnum.WAITING_REFUND.getStatus()));
        // é€€æ¬¾æˆ–换货完成后工单才能完结
        if (ticketMapper.update(null, new LambdaUpdateWrapper<AfterSaleTicketDO>()
                .eq(AfterSaleTicketDO::getId, returnDO.getTicketId())
                .eq(AfterSaleTicketDO::getReturnId, returnId)
                .eq(AfterSaleTicketDO::getStatus, AfterSaleTicketStatusEnum.WAITING_REFUND.getStatus())
                .set(AfterSaleTicketDO::getStatus, AfterSaleTicketStatusEnum.COMPLETED.getStatus())
                .set(AfterSaleTicketDO::getResolvedTime, LocalDateTime.now())) != 1) {
            throw exception(RETURN_TICKET_INVALID);
        }
    }
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/ticket/AfterSaleTicketServiceImpl.java
@@ -6,6 +6,9 @@
import cn.iocoder.yudao.module.aftersales.controller.admin.ticket.vo.ticket.AfterSaleTicketPageReqVO;
import cn.iocoder.yudao.module.aftersales.controller.admin.ticket.vo.ticket.AfterSaleTicketSaveReqVO;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.ticket.AfterSaleTicketDO;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.returnobj.AfterSaleReturnDO;
import cn.iocoder.yudao.module.aftersales.dal.mysql.returnobj.AfterSaleReturnMapper;
import cn.iocoder.yudao.module.aftersales.enums.AfterSaleReturnStatusEnum;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.ticket.AfterSaleTicketItemDO;
import cn.iocoder.yudao.module.aftersales.dal.mysql.ticket.AfterSaleTicketItemMapper;
import cn.iocoder.yudao.module.aftersales.dal.mysql.ticket.AfterSaleTicketMapper;
@@ -41,6 +44,8 @@
    private AfterSaleTicketMapper ticketMapper;
    @Resource
    private AfterSaleTicketItemMapper ticketItemMapper;
    @Resource
    private AfterSaleReturnMapper returnMapper;
    @Resource
    private AfterSaleNoRedisDAO noRedisDAO;
@@ -178,6 +183,12 @@
        AfterSaleTicketDO ticket = validateTicketExists(id);
        validateTicketStatus(ticket, AfterSaleTicketStatusEnum.PROCESSING,
                AfterSaleTicketStatusEnum.WAITING_RETURN, AfterSaleTicketStatusEnum.WAITING_REFUND);
        if (ticket.getReturnId() != null) {
            AfterSaleReturnDO returnDO = returnMapper.selectById(ticket.getReturnId());
            if (returnDO == null || !AfterSaleReturnStatusEnum.REFUNDED.getStatus().equals(returnDO.getStatus())) {
                throw exception(RETURN_TICKET_NOT_FINISHED);
            }
        }
        ticketMapper.updateById(new AfterSaleTicketDO().setId(id)
                .setStatus(AfterSaleTicketStatusEnum.COMPLETED.getStatus())
                .setResolvedTime(LocalDateTime.now()));
@@ -188,6 +199,14 @@
    public void closeTicket(Long id) {
        AfterSaleTicketDO ticket = validateTicketExists(id);
        validateTicketStatus(ticket, AfterSaleTicketStatusEnum.COMPLETED);
        if (ticket.getReturnId() != null) {
            AfterSaleReturnDO returnDO = returnMapper.selectById(ticket.getReturnId());
            if (returnDO == null || !AfterSaleReturnStatusEnum.REFUNDED.getStatus().equals(returnDO.getStatus())) {
                throw exception(RETURN_TICKET_NOT_FINISHED);
            }
            returnMapper.updateById(new AfterSaleReturnDO().setId(returnDO.getId())
                    .setStatus(AfterSaleReturnStatusEnum.CLOSED.getStatus()));
        }
        ticketMapper.updateById(new AfterSaleTicketDO().setId(id)
                .setStatus(AfterSaleTicketStatusEnum.CLOSED.getStatus())
                .setClosedTime(LocalDateTime.now()));
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/receivable/CrmReceivableController.java
@@ -176,12 +176,11 @@
        });
    }
    @PutMapping("/update-status")
    @Operation(summary = "更新回款的状态(审核/反审核)")
    @PutMapping("/submit")
    @Operation(summary = "提交回款审批(草稿/驳回→审批中)")
    @PreAuthorize("@ss.hasPermission('crm:receivable:update')")
    public CommonResult<Boolean> updateReceivableStatus(@RequestParam("id") Long id,
                                                        @RequestParam("status") Integer status) {
        receivableService.updateReceivableStatus(id, status);
    public CommonResult<Boolean> submitReceivable(@RequestParam("id") Long id) {
        receivableService.submitReceivable(id);
        return success(true);
    }
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/dal/mysql/receivable/CrmReceivableMapper.java
@@ -6,7 +6,6 @@
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.MPJLambdaWrapperX;
import cn.iocoder.yudao.module.crm.controller.admin.receivable.vo.receivable.CrmReceivablePageReqVO;
import cn.iocoder.yudao.module.crm.dal.dataobject.contract.CrmContractDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivableDO;
import cn.iocoder.yudao.module.crm.enums.common.CrmAuditStatusEnum;
import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum;
@@ -71,7 +70,7 @@
        CrmPermissionUtils.appendPermissionCondition(query, CrmBizTypeEnum.CRM_RECEIVABLE.getType(),
                CrmReceivableDO::getId, userId, CrmSceneTypeEnum.OWNER.getType());
        // æœªå®¡æ ¸
        query.eq(CrmContractDO::getAuditStatus, CrmAuditStatusEnum.PROCESS.getStatus());
        query.eq(CrmReceivableDO::getAuditStatus, CrmAuditStatusEnum.PROCESS.getStatus());
        return selectCount(query);
    }
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/enums/ErrorCodeConstants.java
@@ -55,7 +55,7 @@
    ErrorCode RECEIVABLE_NOT_EXISTS = new ErrorCode(1_020_004_000, "回款不存在");
    ErrorCode RECEIVABLE_UPDATE_FAIL_EDITING_PROHIBITED = new ErrorCode(1_020_004_001, "更新回款失败,原因:禁止编辑");
    ErrorCode RECEIVABLE_DELETE_FAIL = new ErrorCode(1_020_004_002, "删除回款失败,原因: è¢«å›žæ¬¾è®¡åˆ’所使用,不允许删除");
    ErrorCode RECEIVABLE_SUBMIT_FAIL_NOT_DRAFT = new ErrorCode(1_020_004_003, "回款提交审核失败,原因:回款没处在未提交状态");
    ErrorCode RECEIVABLE_SUBMIT_FAIL_NOT_DRAFT = new ErrorCode(1_020_004_003, "回款提交审核失败,原因:回款须处于草稿或审核不通过状态");
    ErrorCode RECEIVABLE_UPDATE_AUDIT_STATUS_FAIL_NOT_PROCESS = new ErrorCode(1_020_004_004, "更新回款审核状态失败,原因:回款不是审核中状态");
    ErrorCode RECEIVABLE_NO_EXISTS = new ErrorCode(1_020_004_005, "生成回款序列号重复,请重试");
    ErrorCode RECEIVABLE_CREATE_FAIL_CONTRACT_NOT_APPROVE = new ErrorCode(1_020_004_006, "创建回款失败,原因:合同不是审核通过状态");
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivableService.java
@@ -36,13 +36,11 @@
     */
    void updateReceivable(@Valid CrmReceivableSaveReqVO updateReqVO);
    /**
     * æ›´æ–°å›žæ¬¾çš„状态(审核/反审核)
     *
     * @param id     ç¼–号
     * @param status çŠ¶æ€
     */
    void updateReceivableStatus(Long id, Integer status);
    /** æäº¤å›žæ¬¾å®¡æ‰¹ã€‚ */
    void submitReceivable(Long id);
    /** BPM å®¡æ‰¹ç»“束时回写回款状态。 */
    void updateReceivableAuditStatus(Long id, Integer bpmStatus);
    /**
     * åˆ é™¤å›žæ¬¾
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/CrmReceivableServiceImpl.java
@@ -24,13 +24,18 @@
import cn.iocoder.yudao.module.crm.service.invoice.CrmInvoiceService;
import cn.iocoder.yudao.module.crm.service.permission.CrmPermissionService;
import cn.iocoder.yudao.module.crm.service.permission.bo.CrmPermissionCreateReqBO;
import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi;
import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO;
import cn.iocoder.yudao.module.bpm.enums.task.BpmTaskStatusEnum;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
import cn.iocoder.yudao.module.system.api.approval.ApprovalConfigApi;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.enums.storage.StorageRecordTypeEnum;
import com.mzt.logapi.context.LogRecordContext;
import com.mzt.logapi.service.impl.DiffParseFunction;
import com.mzt.logapi.starter.annotation.LogRecord;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -40,6 +45,7 @@
import java.util.*;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
import static cn.iocoder.yudao.module.crm.enums.ErrorCodeConstants.*;
import static cn.iocoder.yudao.module.crm.enums.LogRecordConstants.*;
@@ -50,7 +56,11 @@
 */
@Service
@Validated
@Slf4j
public class CrmReceivableServiceImpl implements CrmReceivableService {
    private static final String RECEIVABLE_PROCESS_KEY = "crm-receivable-audit";
    private static final String RECEIVABLE_APPROVE_BIZ_TYPE = "crm_receivable_approve";
    @Resource
    private CrmReceivableMapper receivableMapper;
@@ -72,6 +82,10 @@
    private AdminUserApi adminUserApi;
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
    @Resource
    private BpmProcessInstanceApi bpmProcessInstanceApi;
    @Resource
    private ApprovalConfigApi approvalConfigApi;
    @Override
    @Transactional(rollbackFor = Exception.class)
@@ -92,9 +106,9 @@
            throw exception(RECEIVABLE_NO_EXISTS);
        }
        // 2.1 æ’入回款(创建后即为未审核状态)
        // 2.1 æ’入草稿;提交时再创建 BPM æµç¨‹
        CrmReceivableDO receivable = BeanUtils.toBean(createReqVO, CrmReceivableDO.class)
                .setNo(no).setAuditStatus(CrmAuditStatusEnum.PROCESS.getStatus());
                .setNo(no).setAuditStatus(CrmAuditStatusEnum.DRAFT.getStatus());
        receivableMapper.insert(receivable);
        // 2.2
@@ -227,8 +241,9 @@
        // 1.5 æ ¡éªŒå¯å›žæ¬¾é‡‘额不超过该开票剩余可回款金额
        validateReceivablePriceExceedsInvoiceRemaining(updateReqVO);
        // 1.6 åªæœ‰æœªå®¡æ ¸ï¼Œå¯ä»¥ç¼–辑;
        if (ObjectUtil.notEqual(oldReceivable.getAuditStatus(), CrmAuditStatusEnum.PROCESS.getStatus())) {
        // 1.6 åªæœ‰è‰ç¨¿æˆ–驳回后可以编辑
        if (ObjectUtil.notEqual(oldReceivable.getAuditStatus(), CrmAuditStatusEnum.DRAFT.getStatus())
                && ObjectUtil.notEqual(oldReceivable.getAuditStatus(), CrmAuditStatusEnum.REJECT.getStatus())) {
            throw exception(RECEIVABLE_UPDATE_FAIL_EDITING_PROHIBITED);
        }
@@ -253,20 +268,48 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateReceivableStatus(Long id, Integer status) {
        boolean approve = CrmAuditStatusEnum.APPROVE.getStatus().equals(status);
        // 1.1 æ ¡éªŒå­˜åœ¨
    @CrmPermission(bizType = CrmBizTypeEnum.CRM_RECEIVABLE, bizId = "#id", level = CrmPermissionLevelEnum.WRITE)
    public void submitReceivable(Long id) {
        CrmReceivableDO receivable = validateReceivableExists(id);
        // 1.2 æ ¡éªŒçŠ¶æ€
        if (receivable.getAuditStatus().equals(status)) {
            throw exception(approve ? RECEIVABLE_APPROVE_FAIL : RECEIVABLE_PROCESS_FAIL);
        if (ObjectUtil.notEqual(receivable.getAuditStatus(), CrmAuditStatusEnum.DRAFT.getStatus())
                && ObjectUtil.notEqual(receivable.getAuditStatus(), CrmAuditStatusEnum.REJECT.getStatus())) {
            throw exception(RECEIVABLE_SUBMIT_FAIL_NOT_DRAFT);
        }
        // 2. æ›´æ–°çŠ¶æ€
        // è‰ç¨¿æœŸå¯èƒ½å·²äº§ç”Ÿå…¶ä»–回款,提交时重新校验合同及开票额度。
        CrmReceivableSaveReqVO req = new CrmReceivableSaveReqVO().setId(id)
                .setContractId(receivable.getContractId()).setInvoiceId(receivable.getInvoiceId())
                .setCustomerId(receivable.getCustomerId()).setPrice(receivable.getPrice());
        validateInvoiceForReceivable(req);
        validateReceivablePriceExceedsLimit(req);
        validateReceivablePriceExceedsInvoiceRemaining(req);
        approvalConfigApi.validateApprovalEnabledAndGetApprovers(RECEIVABLE_APPROVE_BIZ_TYPE);
        int updateCount = receivableMapper.updateByIdAndStatus(id, receivable.getAuditStatus(),
                new CrmReceivableDO().setAuditStatus(status));
                new CrmReceivableDO().setAuditStatus(CrmAuditStatusEnum.PROCESS.getStatus()));
        if (updateCount == 0) {
            throw exception(approve ? RECEIVABLE_APPROVE_FAIL : RECEIVABLE_PROCESS_FAIL);
            throw exception(RECEIVABLE_SUBMIT_FAIL_NOT_DRAFT);
        }
        String processInstanceId = bpmProcessInstanceApi.createProcessInstance(getLoginUserId(),
                new BpmProcessInstanceCreateReqDTO().setProcessDefinitionKey(RECEIVABLE_PROCESS_KEY)
                        .setBusinessKey(String.valueOf(id)));
        receivableMapper.updateById(new CrmReceivableDO().setId(id).setProcessInstanceId(processInstanceId));
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateReceivableAuditStatus(Long id, Integer bpmStatus) {
        Integer auditStatus;
        if (BpmTaskStatusEnum.APPROVE.getStatus().equals(bpmStatus)) {
            auditStatus = CrmAuditStatusEnum.APPROVE.getStatus();
        } else if (BpmTaskStatusEnum.REJECT.getStatus().equals(bpmStatus)) {
            auditStatus = CrmAuditStatusEnum.REJECT.getStatus();
        } else if (BpmTaskStatusEnum.CANCEL.getStatus().equals(bpmStatus)) {
            auditStatus = CrmAuditStatusEnum.CANCEL.getStatus();
        } else {
            return;
        }
        if (receivableMapper.updateByIdAndStatus(id, CrmAuditStatusEnum.PROCESS.getStatus(),
                new CrmReceivableDO().setAuditStatus(auditStatus)) != 1) {
            log.warn("[updateReceivableAuditStatus] å›žæ¬¾({}) å·²ä¸å¤„于审批中,忽略 BPM çŠ¶æ€({})", id, bpmStatus);
        }
    }
@@ -282,8 +325,9 @@
        if (receivable.getPlanId() != null && receivablePlanService.getReceivablePlan(receivable.getPlanId()) != null) {
            throw exception(RECEIVABLE_DELETE_FAIL);
        }
        // 1.3 å®¡æ‰¹é€šè¿‡æ—¶ï¼Œä¸å…è®¸åˆ é™¤
        if (ObjUtil.equal(receivable.getAuditStatus(), CrmAuditStatusEnum.APPROVE.getStatus())) {
        // å®¡æ‰¹ä¸­ã€å®¡æ ¸é€šè¿‡ä¸å¯åˆ é™¤
        if (ObjUtil.equal(receivable.getAuditStatus(), CrmAuditStatusEnum.APPROVE.getStatus())
                || ObjUtil.equal(receivable.getAuditStatus(), CrmAuditStatusEnum.PROCESS.getStatus())) {
            throw exception(RECEIVABLE_DELETE_FAIL_IS_APPROVE);
        }
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/service/receivable/listener/CrmReceivableStatusListener.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,31 @@
package cn.iocoder.yudao.module.crm.service.receivable.listener;
import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent;
import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEventListener;
import cn.iocoder.yudao.module.crm.service.receivable.CrmReceivableService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/** å›žæ¬¾å®¡æ‰¹ç»“果回写。 */
@Component
@Slf4j
public class CrmReceivableStatusListener extends BpmProcessInstanceStatusEventListener {
    @Resource
    private CrmReceivableService receivableService;
    @Override
    protected String getProcessDefinitionKey() {
        return "crm-receivable-audit";
    }
    @Override
    protected void onEvent(BpmProcessInstanceStatusEvent event) {
        try {
            receivableService.updateReceivableAuditStatus(Long.parseLong(event.getBusinessKey()), event.getStatus());
        } catch (NumberFormatException e) {
            log.warn("[onEvent] æ— æ•ˆçš„回款业务编号: {}", event.getBusinessKey());
        }
    }
}
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/ErrorCodeConstants.java
@@ -95,6 +95,12 @@
    ErrorCode ATTENDANCE_RULE_EXISTS = new ErrorCode(1_030_004_024, "已存在考勤规则【{}】,且未开启覆盖!");
    ErrorCode ATTENDANCE_RULE_DEPT_NOT_EXISTS = new ErrorCode(1_030_004_025, "部门【{}】不存在");
    ErrorCode ATTENDANCE_EXCEPTION_TIME_REQUIRED = new ErrorCode(1_030_004_026, "补卡申请必须填写补卡时间");
    ErrorCode ATTENDANCE_LOCATION_RULE_INVALID = new ErrorCode(1_030_004_050, "考勤规则地点配置不完整,请联系管理员");
    ErrorCode ATTENDANCE_LOCATION_REQUIRED = new ErrorCode(1_030_004_051, "请开启定位并提供经纬度、精度和坐标系后打卡");
    ErrorCode ATTENDANCE_LOCATION_COORDINATE_INVALID = new ErrorCode(1_030_004_052, "打卡经纬度超出有效范围");
    ErrorCode ATTENDANCE_LOCATION_ACCURACY_INVALID = new ErrorCode(1_030_004_053, "定位精度须大于 0 ä¸”不超过 100 ç±³ï¼Œè¯·åˆ°å¼€é˜”位置重试");
    ErrorCode ATTENDANCE_LOCATION_SYSTEM_INVALID = new ErrorCode(1_030_004_054, "打卡坐标系必须为 WGS84");
    ErrorCode ATTENDANCE_LOCATION_OUT_OF_RANGE = new ErrorCode(1_030_004_055, "当前位置距“{}”{}米,超过允许范围{}ç±³");
    // ========== ç»©æ•ˆè€ƒæ ¸ 1-030-008-000 ==========
    ErrorCode PERFORMANCE_APPRAISE_NOT_EXISTS = new ErrorCode(1_030_008_000, "绩效考核方案不存在");
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/HrmAttendanceRecordController.java
@@ -8,6 +8,8 @@
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceMyStatusRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceClockReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceClockRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceRecordImportExcelVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceRecordImportReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceRecordPageReqVO;
@@ -16,6 +18,7 @@
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeDO;
import cn.iocoder.yudao.module.hrm.enums.HrmAttendanceClockTypeEnum;
import cn.iocoder.yudao.module.hrm.service.attendance.HrmAttendanceRecordService;
import cn.iocoder.yudao.module.hrm.service.attendance.HrmAttendanceClockResult;
import cn.iocoder.yudao.module.hrm.service.employee.HrmEmployeeService;
import cn.iocoder.yudao.module.system.api.dept.DeptApi;
import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO;
@@ -61,28 +64,34 @@
    @PostMapping("/clock-in")
    @Operation(summary = "上班打卡")
    public CommonResult<HrmAttendanceRecordRespVO> clockIn(@RequestParam("location") String location) {
    @ApiAccessLog(requestEnable = false)
    public CommonResult<HrmAttendanceClockRespVO> clockIn(
            @Valid @RequestBody(required = false) HrmAttendanceClockReqVO reqVO,
            @RequestParam(value = "location", required = false) String legacyLocation) {
        Long loginUserId = getLoginUserId();
        HrmEmployeeDO employee = employeeService.getEmployeeByUserId(loginUserId);
        if (employee == null) {
            throw exception(EMPLOYEE_NOT_EXISTS,
                    "当前登录账号未关联员工档案,无法打卡。请联系管理员在【员工管理】中将该登录账号绑定到员工。");
        }
        HrmAttendanceRecordDO record = attendanceRecordService.clockIn(employee.getId(), LocalDateTime.now(), location);
        return success(buildClockRespVO(record, employee));
        HrmAttendanceClockResult result = attendanceRecordService.clockIn(employee.getId(), LocalDateTime.now(), reqVO, legacyLocation);
        return success(buildClockResultVO(result, employee));
    }
    @PostMapping("/clock-out")
    @Operation(summary = "下班打卡")
    public CommonResult<HrmAttendanceRecordRespVO> clockOut(@RequestParam("location") String location) {
    @ApiAccessLog(requestEnable = false)
    public CommonResult<HrmAttendanceClockRespVO> clockOut(
            @Valid @RequestBody(required = false) HrmAttendanceClockReqVO reqVO,
            @RequestParam(value = "location", required = false) String legacyLocation) {
        Long loginUserId = getLoginUserId();
        HrmEmployeeDO employee = employeeService.getEmployeeByUserId(loginUserId);
        if (employee == null) {
            throw exception(EMPLOYEE_NOT_EXISTS,
                    "当前登录账号未关联员工档案,无法打卡。请联系管理员在【员工管理】中将该登录账号绑定到员工。");
        }
        HrmAttendanceRecordDO record = attendanceRecordService.clockOut(employee.getId(), LocalDateTime.now(), location);
        return success(buildClockRespVO(record, employee));
        HrmAttendanceClockResult result = attendanceRecordService.clockOut(employee.getId(), LocalDateTime.now(), reqVO, legacyLocation);
        return success(buildClockResultVO(result, employee));
    }
    @GetMapping("/my-status")
@@ -107,6 +116,18 @@
            if (dept != null) {
                respVO.setDeptName(dept.getName());
            }
        }
        return respVO;
    }
    private HrmAttendanceClockRespVO buildClockResultVO(HrmAttendanceClockResult result, HrmEmployeeDO employee) {
        HrmAttendanceClockRespVO respVO = new HrmAttendanceClockRespVO();
        respVO.setRecord(buildClockRespVO(result.record(), employee));
        respVO.setLocationName(result.rule().getLocationName());
        if (Boolean.TRUE.equals(result.rule().getLocationRequired())) {
            respVO.setDistanceMeters(result.distanceMeters());
            respVO.setRadiusMeters(result.rule().getRadiusMeters());
            respVO.setWithinRange(true);
        }
        return respVO;
    }
@@ -312,4 +333,4 @@
        ExcelUtils.write(response, "考勤记录.xls", "数据", HrmAttendanceRecordRespVO.class, list);
    }
}
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceClockReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,19 @@
package cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
/** App/Web æ‰“卡定位原始数据;是否必填由当前生效规则决定。 */
@Data
public class HrmAttendanceClockReqVO {
    @Schema(description = "设备经度(WGS84)")
    private BigDecimal longitude;
    @Schema(description = "设备纬度(WGS84)")
    private BigDecimal latitude;
    @Schema(description = "定位精度,米")
    private BigDecimal accuracy;
    @Schema(description = "坐标系,仅支持 WGS84")
    private String coordinateSystem;
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceClockRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,20 @@
package cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
@Data
public class HrmAttendanceClockRespVO {
    @Schema(description = "考勤记录")
    private HrmAttendanceRecordRespVO record;
    @Schema(description = "规则地点名称")
    private String locationName;
    @Schema(description = "距规则地点距离,米;未启用位置限制时为空")
    private BigDecimal distanceMeters;
    @Schema(description = "允许半径,米;未启用位置限制时为空")
    private Integer radiusMeters;
    @Schema(description = "是否在允许范围;未启用位置限制时为空")
    private Boolean withinRange;
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceRecordRespVO.java
@@ -78,6 +78,27 @@
    @ExcelProperty("打卡地点")
    private String location;
    @Schema(description = "上班打卡经度(WGS84)")
    private BigDecimal clockInLongitude;
    @Schema(description = "上班打卡纬度(WGS84)")
    private BigDecimal clockInLatitude;
    @Schema(description = "上班定位精度,米")
    private BigDecimal clockInAccuracy;
    @Schema(description = "上班距规则地点距离,米")
    private BigDecimal clockInDistance;
    @Schema(description = "上班规则地点地址快照")
    private String clockInAddress;
    @Schema(description = "下班打卡经度(WGS84)")
    private BigDecimal clockOutLongitude;
    @Schema(description = "下班打卡纬度(WGS84)")
    private BigDecimal clockOutLatitude;
    @Schema(description = "下班定位精度,米")
    private BigDecimal clockOutAccuracy;
    @Schema(description = "下班距规则地点距离,米")
    private BigDecimal clockOutDistance;
    @Schema(description = "下班规则地点地址快照")
    private String clockOutAddress;
    @Schema(description = "备注")
    @ExcelProperty("备注")
    private String remark;
@@ -91,4 +112,4 @@
    @ExcelProperty("创建时间")
    private LocalDateTime createTime;
}
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceRuleRespVO.java
@@ -51,6 +51,21 @@
    @ExcelProperty("加班起算时长")
    private BigDecimal overtimeThreshold;
    @Schema(description = "打卡地点名称")
    private String locationName;
    @Schema(description = "打卡地点地址")
    private String locationAddress;
    @Schema(description = "地点经度(WGS84)")
    private BigDecimal longitude;
    @Schema(description = "地点纬度(WGS84)")
    private BigDecimal latitude;
    @Schema(description = "允许打卡半径,单位米")
    private Integer radiusMeters;
    @Schema(description = "是否启用位置限制")
    private Boolean locationRequired;
    @Schema(description = "坐标系,仅支持 WGS84")
    private String coordinateSystem;
    @Schema(description = "状态:0-启用,1-禁用")
    @ExcelProperty(value = "状态", converter = DictConvert.class)
    @DictFormat("hrm_rule_status")
@@ -64,4 +79,4 @@
    @ExcelProperty("创建时间")
    private LocalDateTime createTime;
}
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/controller/admin/attendance/vo/HrmAttendanceRuleSaveReqVO.java
@@ -39,10 +39,25 @@
    @Schema(description = "加班起算时长(小时)", example = "1")
    private BigDecimal overtimeThreshold;
    @Schema(description = "打卡地点名称")
    private String locationName;
    @Schema(description = "打卡地点地址")
    private String locationAddress;
    @Schema(description = "地点经度(WGS84)")
    private BigDecimal longitude;
    @Schema(description = "地点纬度(WGS84)")
    private BigDecimal latitude;
    @Schema(description = "允许打卡半径,单位米")
    private Integer radiusMeters;
    @Schema(description = "是否启用位置限制")
    private Boolean locationRequired;
    @Schema(description = "坐标系,仅支持 WGS84")
    private String coordinateSystem;
    @Schema(description = "状态:0-启用,1-禁用", example = "0")
    private Integer status;
    @Schema(description = "备注", example = "适用于研发部门")
    private String remark;
}
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/attendance/HrmAttendanceRecordDO.java
@@ -83,6 +83,17 @@
     */
    private String location;
    private BigDecimal clockInLongitude;
    private BigDecimal clockInLatitude;
    private BigDecimal clockInAccuracy;
    private BigDecimal clockInDistance;
    private String clockInAddress;
    private BigDecimal clockOutLongitude;
    private BigDecimal clockOutLatitude;
    private BigDecimal clockOutAccuracy;
    private BigDecimal clockOutDistance;
    private String clockOutAddress;
    /**
     * å¤‡æ³¨
     */
@@ -93,4 +104,4 @@
     */
    private Integer dataSource;
}
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/attendance/HrmAttendanceRuleDO.java
@@ -67,6 +67,15 @@
     */
    private BigDecimal overtimeThreshold;
    /** è€ƒå‹¤åœ°ç‚¹ä¸Žå¯æ‰“卡范围;旧规则默认不启用位置限制。 */
    private String locationName;
    private String locationAddress;
    private BigDecimal longitude;
    private BigDecimal latitude;
    private Integer radiusMeters;
    private Boolean locationRequired;
    private String coordinateSystem;
    /**
     * çŠ¶æ€ï¼š0-启用,1-禁用
     */
@@ -77,4 +86,4 @@
     */
    private String remark;
}
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceClockResult.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,11 @@
package cn.iocoder.yudao.module.hrm.service.attendance;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceRecordDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceRuleDO;
import java.math.BigDecimal;
/** æ‰“卡保存结果和本次校验使用的规则快照。 */
public record HrmAttendanceClockResult(HrmAttendanceRecordDO record, HrmAttendanceRuleDO rule,
                                       BigDecimal distanceMeters) {
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRecordService.java
@@ -3,6 +3,7 @@
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceMyStatusRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceRecordPageReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceClockReqVO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceRecordDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceExceptionDO;
import jakarta.validation.Valid;
@@ -61,7 +62,8 @@
     * @param location æ‰“卡地点
     * @return è€ƒå‹¤è®°å½•
     */
    HrmAttendanceRecordDO clockIn(Long userId, LocalDateTime clockTime, String location);
    HrmAttendanceClockResult clockIn(Long userId, LocalDateTime clockTime,
                                    HrmAttendanceClockReqVO reqVO, String legacyLocation);
    /**
     * æ‰“卡(下班打卡)
@@ -71,7 +73,8 @@
     * @param location æ‰“卡地点
     * @return è€ƒå‹¤è®°å½•
     */
    HrmAttendanceRecordDO clockOut(Long userId, LocalDateTime clockTime, String location);
    HrmAttendanceClockResult clockOut(Long userId, LocalDateTime clockTime,
                                     HrmAttendanceClockReqVO reqVO, String legacyLocation);
    /**
     * è¡¥å¡å®¡æ‰¹é€šè¿‡åŽæ›´æ–°è€ƒå‹¤è®°å½•
@@ -87,4 +90,4 @@
     */
    void recordOvertime(HrmAttendanceExceptionDO exception);
}
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRecordServiceImpl.java
@@ -6,6 +6,7 @@
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceRecordPageReqVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceRecordRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceRuleRespVO;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceClockReqVO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceHolidayDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceRecordDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceRuleDO;
@@ -33,6 +34,7 @@
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.List;
import cn.hutool.core.util.StrUtil;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.hrm.enums.ErrorCodeConstants.*;
@@ -316,7 +318,8 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public HrmAttendanceRecordDO clockIn(Long userId, LocalDateTime clockTime, String location) {
    public HrmAttendanceClockResult clockIn(Long userId, LocalDateTime clockTime,
                                            HrmAttendanceClockReqVO reqVO, String legacyLocation) {
        LocalDate clockDate = clockTime.toLocalDate();
        // 1. æ ¡éªŒæ˜¯å¦å·¥ä½œæ—¥
        if (!attendanceHolidayService.isWorkDay(clockDate)) {
@@ -336,7 +339,9 @@
        if (record != null && record.getClockInTime() != null) {
            throw exception(ATTENDANCE_CLOCK_ALREADY_CLOCKED);
        }
        // 4. åˆ¤æ–­æ‰“卡类型
        // 4. åœ¨ä»»ä½•写库操作前校验位置;不能用旧版 location å­—符串绕过范围限制。
        ClockLocation clockLocation = validateClockLocation(rule, reqVO);
        // 5. åˆ¤æ–­æ‰“卡类型
        LocalTime workStartTime = rule.getWorkStartTime();
        int lateAllowMinutes = rule.getLateAllowMinutes() != null ? rule.getLateAllowMinutes() : 0;
        LocalTime lateThreshold = workStartTime.plusMinutes(lateAllowMinutes);
@@ -344,7 +349,7 @@
        Integer clockInType = clockTimeOnly.isBefore(lateThreshold) || clockTimeOnly.equals(lateThreshold)
                ? HrmAttendanceClockTypeEnum.NORMAL.getType()
                : HrmAttendanceClockTypeEnum.LATE.getType();
        // 5. åˆ›å»ºæˆ–更新考勤记录
        // 6. åˆ›å»ºæˆ–更新考勤记录
        if (record == null) {
            record = HrmAttendanceRecordDO.builder()
                    .userId(userId)
@@ -352,24 +357,24 @@
                    .date(clockDate)
                    .clockInTime(clockTime)
                    .clockInType(clockInType)
                    .location(location)
                    .dataSource(HrmAttendanceDataSourceEnum.CLOCK.getType())
                    .build();
            applyClockLocation(record, rule, clockLocation, legacyLocation, true);
            attendanceRecordMapper.insert(record);
        } else {
            record.setClockInTime(clockTime);
            record.setClockInType(clockInType);
            if (location != null) {
                record.setLocation(location);
            }
            applyClockLocation(record, rule, clockLocation, legacyLocation, true);
            attendanceRecordMapper.updateById(record);
        }
        return record;
        return new HrmAttendanceClockResult(record, rule,
                clockLocation != null ? clockLocation.distanceMeters() : null);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public HrmAttendanceRecordDO clockOut(Long userId, LocalDateTime clockTime, String location) {
    public HrmAttendanceClockResult clockOut(Long userId, LocalDateTime clockTime,
                                             HrmAttendanceClockReqVO reqVO, String legacyLocation) {
        LocalDate clockDate = clockTime.toLocalDate();
        // 1. æ ¡éªŒæ˜¯å¦å·¥ä½œæ—¥
        if (!attendanceHolidayService.isWorkDay(clockDate)) {
@@ -386,8 +391,12 @@
        }
        // 3. æŸ¥è¯¢å½“日考勤记录
        HrmAttendanceRecordDO record = attendanceRecordMapper.selectByUserIdAndDate(userId, clockDate);
        if (record != null && record.getClockOutTime() != null) {
            throw exception(ATTENDANCE_CLOCK_ALREADY_CLOCKED);
        }
        ClockLocation clockLocation = validateClockLocation(rule, reqVO);
        // 4. æ— ä¸Šç­å¡æ—¶ä¹Ÿå…ˆå®Œæˆä½ç½®æ ¡éªŒï¼Œå†åˆ›å»ºç¼ºå¡è®°å½•。
        if (record == null) {
            // å¦‚果没有上班打卡记录,创建一条缺卡的记录
            record = HrmAttendanceRecordDO.builder()
                    .userId(userId)
                    .deptId(employee.getDeptId())
@@ -395,12 +404,8 @@
                    .clockInType(HrmAttendanceClockTypeEnum.MISSING.getType())
                    .dataSource(HrmAttendanceDataSourceEnum.CLOCK.getType())
                    .build();
            attendanceRecordMapper.insert(record);
        }
        if (record.getClockOutTime() != null) {
            throw exception(ATTENDANCE_CLOCK_ALREADY_CLOCKED);
        }
        // 4. åˆ¤æ–­æ‰“卡类型
        // 5. åˆ¤æ–­æ‰“卡类型
        LocalTime workEndTime = rule.getWorkEndTime();
        int earlyAllowMinutes = rule.getEarlyAllowMinutes() != null ? rule.getEarlyAllowMinutes() : 0;
        LocalTime earlyThreshold = workEndTime.minusMinutes(earlyAllowMinutes);
@@ -408,12 +413,10 @@
        Integer clockOutType = clockTimeOnly.isAfter(earlyThreshold) || clockTimeOnly.equals(earlyThreshold)
                ? HrmAttendanceClockTypeEnum.NORMAL.getType()
                : HrmAttendanceClockTypeEnum.EARLY.getType();
        // 5. æ›´æ–°è€ƒå‹¤è®°å½•
        // 6. æ›´æ–°è€ƒå‹¤è®°å½•
        record.setClockOutTime(clockTime);
        record.setClockOutType(clockOutType);
        if (location != null) {
            record.setLocation(location);
        }
        applyClockLocation(record, rule, clockLocation, legacyLocation, false);
        // 6. è®¡ç®—工作时长
        if (record.getClockInTime() != null) {
            Duration duration = Duration.between(record.getClockInTime(), clockTime);
@@ -423,8 +426,91 @@
        }
        // 7. è®¡ç®—加班时长:超过规则下班时间的部分
        record.setOvertimeHours(computeOvertimeHours(rule.getWorkEndTime(), clockTimeOnly));
        attendanceRecordMapper.updateById(record);
        return record;
        if (record.getId() == null) {
            attendanceRecordMapper.insert(record);
        } else {
            attendanceRecordMapper.updateById(record);
        }
        return new HrmAttendanceClockResult(record, rule,
                clockLocation != null ? clockLocation.distanceMeters() : null);
    }
    record ClockLocation(BigDecimal longitude, BigDecimal latitude, BigDecimal accuracy,
                         BigDecimal distanceMeters) { }
    ClockLocation validateClockLocation(HrmAttendanceRuleDO rule, HrmAttendanceClockReqVO reqVO) {
        boolean required = Boolean.TRUE.equals(rule.getLocationRequired());
        if (required && (StrUtil.isBlank(rule.getLocationName()) || rule.getLongitude() == null
                || rule.getLatitude() == null || rule.getRadiusMeters() == null
                || rule.getRadiusMeters() < 50 || rule.getRadiusMeters() > 5000
                || !"WGS84".equalsIgnoreCase(rule.getCoordinateSystem()))) {
            throw exception(ATTENDANCE_LOCATION_RULE_INVALID);
        }
        if (reqVO == null || reqVO.getLongitude() == null || reqVO.getLatitude() == null
                || reqVO.getAccuracy() == null || StrUtil.isBlank(reqVO.getCoordinateSystem())) {
            if (required) throw exception(ATTENDANCE_LOCATION_REQUIRED);
            return null;
        }
        boolean coordinatesValid = reqVO.getLongitude().compareTo(BigDecimal.valueOf(-180)) >= 0
                && reqVO.getLongitude().compareTo(BigDecimal.valueOf(180)) <= 0
                && reqVO.getLatitude().compareTo(BigDecimal.valueOf(-90)) >= 0
                && reqVO.getLatitude().compareTo(BigDecimal.valueOf(90)) <= 0;
        if (!coordinatesValid) {
            if (required) throw exception(ATTENDANCE_LOCATION_COORDINATE_INVALID);
            return null;
        }
        if (reqVO.getAccuracy().signum() <= 0 || reqVO.getAccuracy().compareTo(BigDecimal.valueOf(100)) > 0) {
            if (required) throw exception(ATTENDANCE_LOCATION_ACCURACY_INVALID);
            return null;
        }
        if (!"WGS84".equalsIgnoreCase(reqVO.getCoordinateSystem().trim())) {
            if (required) throw exception(ATTENDANCE_LOCATION_SYSTEM_INVALID);
            return null;
        }
        if (!required) {
            return new ClockLocation(reqVO.getLongitude(), reqVO.getLatitude(), reqVO.getAccuracy(), null);
        }
        double rawDistance = haversineMeters(rule.getLongitude().doubleValue(), rule.getLatitude().doubleValue(),
                reqVO.getLongitude().doubleValue(), reqVO.getLatitude().doubleValue());
        BigDecimal roundedDistance = BigDecimal.valueOf(rawDistance).setScale(2, RoundingMode.HALF_UP);
        if (rawDistance > rule.getRadiusMeters()) {
            throw exception(ATTENDANCE_LOCATION_OUT_OF_RANGE, rule.getLocationName(),
                    roundedDistance.toPlainString(), rule.getRadiusMeters());
        }
        return new ClockLocation(reqVO.getLongitude(), reqVO.getLatitude(), reqVO.getAccuracy(), roundedDistance);
    }
    static double haversineMeters(double lon1, double lat1, double lon2, double lat2) {
        double latDelta = Math.toRadians(lat2 - lat1);
        double lonDelta = Math.toRadians(lon2 - lon1);
        double a = Math.pow(Math.sin(latDelta / 2), 2)
                + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
                * Math.pow(Math.sin(lonDelta / 2), 2);
        double bounded = Math.max(0D, Math.min(1D, a));
        return 6_371_000D * 2D * Math.atan2(Math.sqrt(bounded), Math.sqrt(1D - bounded));
    }
    private void applyClockLocation(HrmAttendanceRecordDO record, HrmAttendanceRuleDO rule,
                                    ClockLocation location, String legacyLocation, boolean clockIn) {
        if (StrUtil.isNotBlank(rule.getLocationName())) {
            record.setLocation(rule.getLocationName());
        } else if (!Boolean.TRUE.equals(rule.getLocationRequired()) && StrUtil.isNotBlank(legacyLocation)) {
            record.setLocation(legacyLocation);
        }
        if (location == null) return;
        if (clockIn) {
            record.setClockInLongitude(location.longitude());
            record.setClockInLatitude(location.latitude());
            record.setClockInAccuracy(location.accuracy());
            record.setClockInDistance(location.distanceMeters());
            record.setClockInAddress(rule.getLocationAddress());
        } else {
            record.setClockOutLongitude(location.longitude());
            record.setClockOutLatitude(location.latitude());
            record.setClockOutAccuracy(location.accuracy());
            record.setClockOutDistance(location.distanceMeters());
            record.setClockOutAddress(rule.getLocationAddress());
        }
    }
    /**
@@ -531,4 +617,4 @@
        attendanceRecordMapper.updateById(record);
    }
}
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceRuleServiceImpl.java
@@ -47,6 +47,7 @@
    @Override
    public Long createAttendanceRule(HrmAttendanceRuleSaveReqVO createReqVO) {
        validateLocation(createReqVO);
        // æ ¡éªŒè§„则名称是否重复
        validateAttendanceRuleNameUnique(null, createReqVO.getName());
        // æ ¡éªŒéƒ¨é—¨æ˜¯å¦å·²é…ç½®è§„则(如果指定了部门)
@@ -73,7 +74,21 @@
    @Override
    public void updateAttendanceRule(HrmAttendanceRuleSaveReqVO updateReqVO) {
        validateAttendanceRuleExists(updateReqVO.getId());
        HrmAttendanceRuleDO currentRule = attendanceRuleMapper.selectById(updateReqVO.getId());
        if (currentRule == null) {
            throw exception(ATTENDANCE_RULE_NOT_EXISTS);
        }
        // å…¼å®¹å°šæœªä¼ åœ°ç‚¹å­—段的旧管理端,避免编辑班次时意外关闭已配置的位置限制。
        if (updateReqVO.getLocationRequired() == null) {
            updateReqVO.setLocationRequired(currentRule.getLocationRequired());
            if (updateReqVO.getLocationName() == null) updateReqVO.setLocationName(currentRule.getLocationName());
            if (updateReqVO.getLocationAddress() == null) updateReqVO.setLocationAddress(currentRule.getLocationAddress());
            if (updateReqVO.getLongitude() == null) updateReqVO.setLongitude(currentRule.getLongitude());
            if (updateReqVO.getLatitude() == null) updateReqVO.setLatitude(currentRule.getLatitude());
            if (updateReqVO.getRadiusMeters() == null) updateReqVO.setRadiusMeters(currentRule.getRadiusMeters());
            if (updateReqVO.getCoordinateSystem() == null) updateReqVO.setCoordinateSystem(currentRule.getCoordinateSystem());
        }
        validateLocation(updateReqVO);
        // æ ¡éªŒè§„则名称是否重复
        validateAttendanceRuleNameUnique(updateReqVO.getId(), updateReqVO.getName());
        // æ ¡éªŒéƒ¨é—¨æ˜¯å¦å·²é…ç½®è§„则(如果指定了部门)
@@ -93,6 +108,39 @@
    private void validateAttendanceRuleExists(Long id) {
        if (attendanceRuleMapper.selectById(id) == null) {
            throw exception(ATTENDANCE_RULE_NOT_EXISTS);
        }
    }
    private void validateLocation(HrmAttendanceRuleSaveReqVO reqVO) {
        if (reqVO.getLocationRequired() == null) {
            reqVO.setLocationRequired(false);
        }
        if (reqVO.getRadiusMeters() == null) {
            reqVO.setRadiusMeters(200);
        }
        if (StrUtil.isBlank(reqVO.getCoordinateSystem())) {
            reqVO.setCoordinateSystem("WGS84");
        } else {
            reqVO.setCoordinateSystem(reqVO.getCoordinateSystem().trim().toUpperCase(java.util.Locale.ROOT));
        }
        if (!"WGS84".equals(reqVO.getCoordinateSystem())) {
            throw exception(ATTENDANCE_LOCATION_SYSTEM_INVALID);
        }
        if (reqVO.getLongitude() != null && (reqVO.getLongitude().compareTo(BigDecimal.valueOf(-180)) < 0
                || reqVO.getLongitude().compareTo(BigDecimal.valueOf(180)) > 0)) {
            throw exception(ATTENDANCE_LOCATION_COORDINATE_INVALID);
        }
        if (reqVO.getLatitude() != null && (reqVO.getLatitude().compareTo(BigDecimal.valueOf(-90)) < 0
                || reqVO.getLatitude().compareTo(BigDecimal.valueOf(90)) > 0)) {
            throw exception(ATTENDANCE_LOCATION_COORDINATE_INVALID);
        }
        if (reqVO.getRadiusMeters() < 50 || reqVO.getRadiusMeters() > 5000) {
            throw exception(ATTENDANCE_LOCATION_RULE_INVALID);
        }
        if (Boolean.TRUE.equals(reqVO.getLocationRequired())
                && (StrUtil.isBlank(reqVO.getLocationName()) || reqVO.getLongitude() == null
                    || reqVO.getLatitude() == null || StrUtil.isBlank(reqVO.getCoordinateSystem()))) {
            throw exception(ATTENDANCE_LOCATION_RULE_INVALID);
        }
    }
@@ -235,4 +283,4 @@
        }
    }
}
}
yudao-module-hrm/src/test/java/cn/iocoder/yudao/module/hrm/service/attendance/HrmAttendanceClockLocationTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,84 @@
package cn.iocoder.yudao.module.hrm.service.attendance;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.hrm.controller.admin.attendance.vo.HrmAttendanceClockReqVO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceRuleDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.attendance.HrmAttendanceRecordDO;
import cn.iocoder.yudao.module.hrm.dal.dataobject.employee.HrmEmployeeDO;
import cn.iocoder.yudao.module.hrm.dal.mysql.attendance.HrmAttendanceRecordMapper;
import cn.iocoder.yudao.module.hrm.service.employee.HrmEmployeeService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class HrmAttendanceClockLocationTest {
    private final HrmAttendanceRecordServiceImpl service = new HrmAttendanceRecordServiceImpl();
    private HrmAttendanceRuleDO requiredRule() {
        return HrmAttendanceRuleDO.builder().locationRequired(true).locationName("公司前台")
                .longitude(BigDecimal.ZERO).latitude(BigDecimal.ZERO)
                .radiusMeters(100).coordinateSystem("WGS84").build();
    }
    private HrmAttendanceClockReqVO position(String latitude, String accuracy) {
        HrmAttendanceClockReqVO reqVO = new HrmAttendanceClockReqVO();
        reqVO.setLongitude(BigDecimal.ZERO);
        reqVO.setLatitude(new BigDecimal(latitude));
        reqVO.setAccuracy(new BigDecimal(accuracy));
        reqVO.setCoordinateSystem("WGS84");
        return reqVO;
    }
    @Test
    void oldRuleWithoutLocationStillAcceptsClock() {
        HrmAttendanceRuleDO rule = HrmAttendanceRuleDO.builder().locationRequired(false).build();
        assertNull(service.validateClockLocation(rule, null));
        assertNull(service.validateClockLocation(rule, position("0.0001", "20")).distanceMeters());
    }
    @Test
    void requiredLocationValidatesCompletenessCoordinatesAndAccuracy() {
        HrmAttendanceRuleDO rule = requiredRule();
        assertThrows(ServiceException.class, () -> service.validateClockLocation(rule, null));
        assertThrows(ServiceException.class, () -> service.validateClockLocation(rule, position("91", "20")));
        assertThrows(ServiceException.class, () -> service.validateClockLocation(rule, position("0", "100.01")));
        assertNotNull(service.validateClockLocation(rule, position("0", "100")));
    }
    @Test
    void comparesUnroundedDistanceWithRadius() {
        HrmAttendanceRuleDO rule = requiredRule();
        assertTrue(HrmAttendanceRecordServiceImpl.haversineMeters(0, 0, 0, 0.00089) < 100);
        assertNotNull(service.validateClockLocation(rule, position("0.00089", "20")));
        assertThrows(ServiceException.class, () -> service.validateClockLocation(rule, position("0.00091", "20")));
    }
    @Test
    void invalidClockOutLocationDoesNotCreateMissingClockInRecord() {
        HrmAttendanceHolidayService holidayService = mock(HrmAttendanceHolidayService.class);
        HrmEmployeeService employeeService = mock(HrmEmployeeService.class);
        HrmAttendanceRuleService ruleService = mock(HrmAttendanceRuleService.class);
        HrmAttendanceRecordMapper mapper = mock(HrmAttendanceRecordMapper.class);
        ReflectionTestUtils.setField(service, "attendanceHolidayService", holidayService);
        ReflectionTestUtils.setField(service, "employeeService", employeeService);
        ReflectionTestUtils.setField(service, "attendanceRuleService", ruleService);
        ReflectionTestUtils.setField(service, "attendanceRecordMapper", mapper);
        LocalDateTime clockTime = LocalDateTime.of(2026, 10, 22, 18, 56);
        when(holidayService.isWorkDay(any(LocalDate.class))).thenReturn(true);
        HrmEmployeeDO employee = HrmEmployeeDO.builder().id(1L).deptId(2L).build();
        when(employeeService.getEmployee(1L)).thenReturn(employee);
        when(ruleService.getAttendanceRuleByDeptId(2L)).thenReturn(requiredRule());
        assertThrows(ServiceException.class,
                () -> service.clockOut(1L, clockTime, position("0.00091", "20"), null));
        verify(mapper, never()).insert(any(HrmAttendanceRecordDO.class));
        verify(mapper, never()).updateById(any(HrmAttendanceRecordDO.class));
    }
}
yudao-module-mes-api/src/main/java/cn/iocoder/yudao/module/mes/api/event/MesReturnSalesProgressEvent.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,8 @@
package cn.iocoder.yudao.module.mes.api.event;
/** MES é”€å”®é€€è´§çš„业务进度;监听器与 MES æ“ä½œå¤„于同一事务。 */
public record MesReturnSalesProgressEvent(Long mesReturnId, Stage stage, String qualityResult) {
    public enum Stage { QUALITY_FINISHED, INBOUND_FINISHED, CANCELED }
}
yudao-module-mes-api/src/main/java/cn/iocoder/yudao/module/mes/api/returnsales/MesWmReturnSalesApi.java
@@ -17,4 +17,7 @@
     */
    Long createReturnSales(MesWmReturnSalesCreateReqDTO reqDTO);
    /** å–消尚未完成入库的销售退货单。 */
    void cancelReturnSales(Long id);
}
yudao-module-mes-api/src/main/java/cn/iocoder/yudao/module/mes/api/returnsales/dto/MesWmReturnSalesCreateReqDTO.java
@@ -4,6 +4,8 @@
import lombok.Data;
import java.time.LocalDate;
import java.math.BigDecimal;
import java.util.List;
/**
 * MES é”€å”®é€€è´§å•创建 Request DTO
@@ -41,4 +43,16 @@
    @Schema(description = "售后退货申请编号(关联 after_sale_return.id)", example = "1")
    private Long afterSaleReturnId;
    /** å®¡æ ¸é€šè¿‡çš„售后退货明细,创建 MES å•据时一并带入。 */
    private List<Item> items;
    @Data
    public static class Item {
        private Long itemId;
        private BigDecimal quantity;
        private Long batchId;
        private String batchCode;
        private String remark;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/api/returnsales/MesWmReturnSalesApiImpl.java
@@ -3,9 +3,14 @@
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.mes.api.returnsales.dto.MesWmReturnSalesCreateReqDTO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.returnsales.vo.MesWmReturnSalesSaveReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.returnsales.vo.line.MesWmReturnSalesLineSaveReqVO;
import cn.iocoder.yudao.module.mes.service.wm.returnsales.MesWmReturnSalesService;
import cn.iocoder.yudao.module.mes.service.wm.returnsales.MesWmReturnSalesLineService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
 * MES é”€å”®é€€è´§å• API å®žçŽ°ç±»
@@ -17,11 +22,31 @@
    @Resource
    private MesWmReturnSalesService returnSalesService;
    @Resource
    private MesWmReturnSalesLineService returnSalesLineService;
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createReturnSales(MesWmReturnSalesCreateReqDTO reqDTO) {
        MesWmReturnSalesSaveReqVO createReqVO = BeanUtils.toBean(reqDTO, MesWmReturnSalesSaveReqVO.class);
        return returnSalesService.createReturnSales(createReqVO);
        createReqVO.setAfterSaleReturnId(reqDTO.getAfterSaleReturnId());
        Long returnId = returnSalesService.createReturnSales(createReqVO);
        List<MesWmReturnSalesCreateReqDTO.Item> items = reqDTO.getItems();
        if (items != null) {
            for (MesWmReturnSalesCreateReqDTO.Item item : items) {
                MesWmReturnSalesLineSaveReqVO line = new MesWmReturnSalesLineSaveReqVO()
                        .setReturnId(returnId).setItemId(item.getItemId()).setQuantity(item.getQuantity())
                        .setBatchId(item.getBatchId()).setBatchCode(item.getBatchCode())
                        .setRqcCheckFlag(true).setRemark(item.getRemark());
                returnSalesLineService.createReturnSalesLine(line);
            }
        }
        return returnId;
    }
    @Override
    public void cancelReturnSales(Long id) {
        returnSalesService.cancelReturnSales(id);
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/wm/returnsales/vo/MesWmReturnSalesSaveReqVO.java
@@ -1,6 +1,7 @@
package cn.iocoder.yudao.module.mes.controller.admin.wm.returnsales.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@@ -19,6 +20,10 @@
    @Schema(description = "编号", example = "1024")
    private Long id;
    /** ä»…内部售后 API å†™å…¥ï¼Œå‰ç«¯ä¸èƒ½æŒ‡å®šæ¥æºç”³è¯·ã€‚ */
    @JsonIgnore
    private Long afterSaleReturnId;
    @Schema(description = "退货单编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "RS202603010001")
    @NotBlank(message = "退货单编号不能为空")
    private String code;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/wm/returnsales/MesWmReturnSalesDO.java
@@ -30,6 +30,8 @@
     */
    @TableId
    private Long id;
    /** å…³è”的售后退货申请 ID;手工销售退货单为空。 */
    private Long afterSaleReturnId;
    /**
     * é€€è´§å•编号
     */
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pro/task/MesProTaskMapper.java
@@ -52,7 +52,9 @@
        MPJLambdaWrapperX<MesProTaskDO> query = new MPJLambdaWrapperX<>();
        query.selectAll(MesProTaskDO.class);
        // å¾…报工台账:进行中的任务(排除已完成/已取消),且仍有待报工量
        query.in(MesProTaskDO::getStatus, MesProTaskStatusEnum.PREPARE.getStatus(),MesProTaskStatusEnum.PAUSED.getStatus());
        query.in(MesProTaskDO::getStatus, MesProTaskStatusEnum.PREPARE.getStatus(),
                MesProTaskStatusEnum.PRODUCING.getStatus(), MesProTaskStatusEnum.PARTIAL_FINISHED.getStatus(),
                MesProTaskStatusEnum.PAUSED.getStatus());
        query.apply("(quantity - IFNULL(produced_quantity, 0)) > 0");
        query.likeIfPresent(MesProTaskDO::getCode, reqVO.getCode())
                .likeIfPresent(MesProTaskDO::getName, reqVO.getName())
@@ -107,7 +109,12 @@
                .eq(MesProTaskDO::getId, id)
                .setSql("produced_quantity = IFNULL(produced_quantity, 0) + " + incrProducedQuantity)
                .setSql("qualify_quantity = IFNULL(qualify_quantity, 0) + " + incrQualifyQuantity)
                .setSql("unqualify_quantity = IFNULL(unqualify_quantity, 0) + " + incrUnqualifyQuantity));
                .setSql("unqualify_quantity = IFNULL(unqualify_quantity, 0) + " + incrUnqualifyQuantity)
                .setSql("status = CASE WHEN IFNULL(produced_quantity, 0) > 0 THEN "
                        + MesProTaskStatusEnum.PARTIAL_FINISHED.getStatus() + " ELSE "
                        + MesProTaskStatusEnum.PRODUCING.getStatus() + " END")
                .setSql("first_feedback_time = IFNULL(first_feedback_time, NOW())")
                .setSql("last_feedback_time = NOW()"));
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java
@@ -700,6 +700,8 @@
    ErrorCode WM_RETURN_SALES_NO_LINE = new ErrorCode(1_040_713_005, "销售退货单至少需要一条行数据");
    ErrorCode WM_RETURN_SALES_DETAIL_QUANTITY_MISMATCH = new ErrorCode(1_040_713_006, "销售退货单行数量与明细数量不一致");
    ErrorCode WM_RETURN_SALES_CANCEL_NOT_ALLOWED = new ErrorCode(1_040_713_007, "销售退货单已完成或已取消,无法取消");
    ErrorCode WM_RETURN_SALES_AFTERSALE_LINKED = new ErrorCode(1_040_713_008, "该销售退货单关联售后申请,不能直接修改单头或删除;需要取消时请走取消流程");
    ErrorCode WM_RETURN_SALES_RQC_SOURCE_INVALID = new ErrorCode(1_040_713_009, "退货检验单来源行与销售退货单不一致,或该行已完成检验");
    ErrorCode WM_RETURN_SALES_LINE_NOT_EXISTS = new ErrorCode(1_040_713_100, "销售退货单行不存在");
    ErrorCode WM_RETURN_SALES_DETAIL_NOT_EXISTS = new ErrorCode(1_040_713_200, "销售退货单明细不存在");
    ErrorCode WM_RETURN_SALES_DETAIL_QUANTITY_EXCEED = new ErrorCode(1_040_713_201, "上架明细总数量不能超过退货行数量");
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/task/MesProTaskServiceImpl.java
@@ -470,7 +470,8 @@
        // 1. æ ¡éªŒä»»åŠ¡å­˜åœ¨
        MesProTaskDO task = validateTaskExists(pauseReqVO.getId());
        // 2. æ ¡éªŒçŠ¶æ€æ˜¯å¦å…è®¸åœå·¥ï¼ˆå¦‚å¿…é¡»æ˜¯ ç”Ÿäº§ä¸­ æˆ– éƒ¨åˆ†å®Œæˆï¼‰
        if (!ObjectUtils.equalsAny(task.getStatus(), MesProTaskStatusEnum.PREPARE.getStatus())) {
        if (!ObjectUtils.equalsAny(task.getStatus(), MesProTaskStatusEnum.PRODUCING.getStatus(),
                MesProTaskStatusEnum.PARTIAL_FINISHED.getStatus())) {
            throw exception(PRO_TASK_CANNOT_PAUSE);
        }
        
@@ -500,7 +501,10 @@
        }
        // 3. æ¢å¤ä»»åŠ¡çŠ¶æ€
        Integer resumeStatus = MesProTaskStatusEnum.PREPARE.getStatus();
        Integer resumeStatus = task.getProducedQuantity() != null
                && task.getProducedQuantity().signum() > 0
                ? MesProTaskStatusEnum.PARTIAL_FINISHED.getStatus()
                : MesProTaskStatusEnum.PRODUCING.getStatus();
        taskMapper.updateById(new MesProTaskDO().setId(task.getId()).setStatus(resumeStatus));
        // 4. æ›´æ–°æœ€æ–°çš„一条停工记录
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/returnsales/MesWmReturnSalesLineServiceImpl.java
@@ -6,6 +6,7 @@
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.mes.controller.admin.wm.returnsales.vo.line.MesWmReturnSalesLinePageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.returnsales.vo.line.MesWmReturnSalesLineSaveReqVO;
import cn.iocoder.yudao.module.mes.api.event.MesReturnSalesProgressEvent;
import cn.iocoder.yudao.module.mes.dal.dataobject.mdm.MesMdmItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.returnsales.MesWmReturnSalesLineDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.returnsales.MesWmReturnSalesDO;
@@ -16,6 +17,7 @@
import cn.iocoder.yudao.module.mes.service.wm.batch.MesWmBatchService;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
@@ -26,6 +28,7 @@
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.MD_ITEM_BATCH_REQUIRED;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.WM_RETURN_SALES_LINE_NOT_EXISTS;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.WM_RETURN_SALES_RQC_SOURCE_INVALID;
/**
 * MES é”€å”®é€€è´§å•行 Service å®žçŽ°ç±»
@@ -49,6 +52,8 @@
    @Resource
    @Lazy
    private MesWmReturnSalesDetailService returnSalesDetailService;
    @Resource
    private ApplicationEventPublisher eventPublisher;
    @Override
    public Long createReturnSalesLine(MesWmReturnSalesLineSaveReqVO createReqVO) {
@@ -172,6 +177,14 @@
    public void updateReturnSalesLineWhenRqcFinish(Long sourceLineId, Long sourceDocId, Integer checkResult,
                                                    BigDecimal qualifiedQuantity, BigDecimal unqualifiedQuantity) {
        MesWmReturnSalesLineDO sourceLine = validateReturnSalesLineExists(sourceLineId);
        if (sourceDocId != null) {
            MesWmReturnSalesDO sourceDoc = returnSalesService.validateReturnSalesExists(sourceDocId);
            if (!sourceDocId.equals(sourceLine.getReturnId())
                    || !MesWmReturnSalesStatusEnum.CONFIRMED.getStatus().equals(sourceDoc.getStatus())
                    || !MesWmQualityStatusEnum.PENDING.getStatus().equals(sourceLine.getQualityStatus())) {
                throw exception(WM_RETURN_SALES_RQC_SOURCE_INVALID);
            }
        }
        boolean hasUnqualified = unqualifiedQuantity != null && unqualifiedQuantity.compareTo(BigDecimal.ZERO) > 0;
        boolean hasQualified = qualifiedQuantity != null && qualifiedQuantity.compareTo(BigDecimal.ZERO) > 0;
@@ -210,7 +223,16 @@
            boolean allInspected = !CollUtil.contains(allLines,
                    line -> Objects.equals(line.getQualityStatus(), MesWmQualityStatusEnum.PENDING.getStatus()));
            if (allInspected) {
                returnSalesService.updateReturnSalesStatus(sourceDocId, MesWmReturnSalesStatusEnum.APPROVING.getStatus());
                MesWmReturnSalesDO returnSales = returnSalesService.validateReturnSalesExists(sourceDocId);
                if (MesWmReturnSalesStatusEnum.CONFIRMED.getStatus().equals(returnSales.getStatus())) {
                    returnSalesService.updateReturnSalesStatus(sourceDocId, MesWmReturnSalesStatusEnum.APPROVING.getStatus());
                    if (returnSales.getAfterSaleReturnId() != null) {
                        boolean failed = CollUtil.contains(allLines,
                                line -> MesWmQualityStatusEnum.FAIL.getStatus().equals(line.getQualityStatus()));
                        eventPublisher.publishEvent(new MesReturnSalesProgressEvent(sourceDocId,
                                MesReturnSalesProgressEvent.Stage.QUALITY_FINISHED, failed ? "含不合格品" : "合格"));
                    }
                }
            }
        }
    }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/returnsales/MesWmReturnSalesServiceImpl.java
@@ -1,6 +1,7 @@
package cn.iocoder.yudao.module.mes.service.wm.returnsales;
import cn.hutool.core.collection.CollUtil;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.StrUtil;
@@ -9,6 +10,7 @@
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.common.util.object.ObjectUtils;
import cn.iocoder.yudao.module.mes.controller.admin.wm.returnsales.vo.MesWmReturnSalesImportExcelVO;
import cn.iocoder.yudao.module.mes.api.event.MesReturnSalesProgressEvent;
import cn.iocoder.yudao.module.mes.controller.admin.wm.returnsales.vo.MesWmReturnSalesImportRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.returnsales.vo.MesWmReturnSalesPageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.wm.returnsales.vo.MesWmReturnSalesSaveReqVO;
@@ -27,6 +29,7 @@
import cn.iocoder.yudao.module.mes.service.wm.transaction.dto.MesWmTransactionSaveReqDTO;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
@@ -64,6 +67,8 @@
    private MesMdmItemService mdmItemService;
    @Resource
    private MesWmTransactionService wmTransactionService;
    @Resource
    private ApplicationEventPublisher eventPublisher;
    @Override
    @Transactional(rollbackFor = Exception.class)
@@ -73,6 +78,7 @@
        // 2. æ’入主表
        MesWmReturnSalesDO returnSales = BeanUtils.toBean(createReqVO, MesWmReturnSalesDO.class);
        returnSales.setAfterSaleReturnId(createReqVO.getAfterSaleReturnId());
        returnSales.setStatus(MesWmReturnSalesStatusEnum.PREPARE.getStatus());
        returnSalesMapper.insert(returnSales);
        return returnSales.getId();
@@ -82,12 +88,16 @@
    @Transactional(rollbackFor = Exception.class)
    public void updateReturnSales(MesWmReturnSalesSaveReqVO updateReqVO) {
        // 1.1 æ ¡éªŒå­˜åœ¨ + å‡†å¤‡ä¸­çŠ¶æ€
        validateReturnSalesExistsAndPrepare(updateReqVO.getId());
        MesWmReturnSalesDO existing = validateReturnSalesExistsAndPrepare(updateReqVO.getId());
        if (existing.getAfterSaleReturnId() != null) {
            throw exception(WM_RETURN_SALES_AFTERSALE_LINKED);
        }
        // 1.2 æ ¡éªŒå…³è”数据
        validateSaveData(updateReqVO);
        // 2. æ›´æ–°ä¸»è¡¨
        MesWmReturnSalesDO updateObj = BeanUtils.toBean(updateReqVO, MesWmReturnSalesDO.class);
        updateObj.setAfterSaleReturnId(null); // æ¥æºç”³è¯·ä¸å¯é€šè¿‡ç¼–辑接口改绑
        returnSalesMapper.updateById(updateObj);
    }
@@ -95,7 +105,10 @@
    @Transactional(rollbackFor = Exception.class)
    public void deleteReturnSales(Long id) {
        // 1. æ ¡éªŒå­˜åœ¨ + å‡†å¤‡ä¸­çŠ¶æ€
        validateReturnSalesExistsAndPrepare(id);
        MesWmReturnSalesDO returnSales = validateReturnSalesExistsAndPrepare(id);
        if (returnSales.getAfterSaleReturnId() != null) {
            throw exception(WM_RETURN_SALES_AFTERSALE_LINKED);
        }
        // 2.1 çº§è”删除明细
        returnSalesDetailService.deleteReturnSalesDetailByReturnId(id);
@@ -133,8 +146,13 @@
        Integer targetStatus = hasPendingQc ? MesWmReturnSalesStatusEnum.CONFIRMED.getStatus()
                : MesWmReturnSalesStatusEnum.APPROVING.getStatus();
        // 2.2 æäº¤ï¼ˆè‰ç¨¿ -> å¾…检验/待执行)
        returnSalesMapper.updateById(new MesWmReturnSalesDO()
                .setId(id).setStatus(targetStatus));
        if (updateStatusIfCurrent(id, MesWmReturnSalesStatusEnum.PREPARE.getStatus(), targetStatus) != 1) {
            throw exception(WM_RETURN_SALES_STATUS_NOT_PREPARE);
        }
        if (!hasPendingQc && validateReturnSalesExists(id).getAfterSaleReturnId() != null) {
            eventPublisher.publishEvent(new MesReturnSalesProgressEvent(id,
                    MesReturnSalesProgressEvent.Stage.QUALITY_FINISHED, "免检"));
        }
    }
    @Override
@@ -151,8 +169,10 @@
            throw exception(WM_RETURN_SALES_NO_LINE);
        }
        // 2. æ‰§è¡Œé€€è´§ï¼ˆå¾…执行 -> å¾…上架)
        returnSalesMapper.updateById(new MesWmReturnSalesDO()
                .setId(id).setStatus(MesWmReturnSalesStatusEnum.APPROVED.getStatus()));
        if (updateStatusIfCurrent(id, MesWmReturnSalesStatusEnum.APPROVING.getStatus(),
                MesWmReturnSalesStatusEnum.APPROVED.getStatus()) != 1) {
            throw exception(WM_RETURN_SALES_STATUS_NOT_APPROVING);
        }
    }
    @Override
@@ -184,12 +204,16 @@
            }
        }
        // 2. éåŽ†æ‰€æœ‰æ˜Žç»†ï¼Œåˆ›å»ºåº“å­˜äº‹åŠ¡ï¼ˆå¢žåŠ åº“å­˜ + è®°å½•流水)
        // æŠ¢å çŠ¶æ€åŽå†è®°åº“å­˜æµæ°´ï¼Œé¿å…ä¸¤ä¸ªå¹¶å‘ä¸Šæž¶è¯·æ±‚é‡å¤å…¥åº“ï¼›äº‹åŠ¡å¤±è´¥ä¼šå›žæ»šçŠ¶æ€ã€‚
        if (updateStatusIfCurrent(id, MesWmReturnSalesStatusEnum.APPROVED.getStatus(),
                MesWmReturnSalesStatusEnum.FINISHED.getStatus()) != 1) {
            throw exception(WM_RETURN_SALES_STATUS_NOT_APPROVED);
        }
        createTransactionList(returnSales);
        // 3. æ›´æ–°é€€è´§å•状态
        returnSalesMapper.updateById(new MesWmReturnSalesDO()
                .setId(id).setStatus(MesWmReturnSalesStatusEnum.FINISHED.getStatus()));
        if (returnSales.getAfterSaleReturnId() != null) {
            eventPublisher.publishEvent(new MesReturnSalesProgressEvent(id,
                    MesReturnSalesProgressEvent.Stage.INBOUND_FINISHED, null));
        }
    }
    private void createTransactionList(MesWmReturnSalesDO returnSales) {
@@ -216,8 +240,13 @@
        }
        // å–消
        returnSalesMapper.updateById(new MesWmReturnSalesDO()
                .setId(id).setStatus(MesWmReturnSalesStatusEnum.CANCELED.getStatus()));
        if (updateStatusIfCurrent(id, returnSales.getStatus(), MesWmReturnSalesStatusEnum.CANCELED.getStatus()) != 1) {
            throw exception(WM_RETURN_SALES_CANCEL_NOT_ALLOWED);
        }
        if (returnSales.getAfterSaleReturnId() != null) {
            eventPublisher.publishEvent(new MesReturnSalesProgressEvent(id,
                    MesReturnSalesProgressEvent.Stage.CANCELED, null));
        }
    }
    @Override
@@ -257,8 +286,16 @@
    @Override
    public void updateReturnSalesStatus(Long id, Integer status) {
        validateReturnSalesExists(id);
        returnSalesMapper.updateById(new MesWmReturnSalesDO().setId(id).setStatus(status));
        if (updateStatusIfCurrent(id, MesWmReturnSalesStatusEnum.CONFIRMED.getStatus(), status) != 1) {
            throw exception(WM_RETURN_SALES_RQC_SOURCE_INVALID);
        }
    }
    private int updateStatusIfCurrent(Long id, Integer currentStatus, Integer targetStatus) {
        return returnSalesMapper.update(null, new LambdaUpdateWrapper<MesWmReturnSalesDO>()
                .eq(MesWmReturnSalesDO::getId, id)
                .eq(MesWmReturnSalesDO::getStatus, currentStatus)
                .set(MesWmReturnSalesDO::getStatus, targetStatus));
    }
    /**
yudao-server/src/main/resources/application-local.yaml
@@ -65,17 +65,17 @@
      primary: master
      datasource:
        master:
          url: jdbc:mysql://192.168.197.128:3306/mom-xgdl?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X è¿žæŽ¥çš„示例
          url: jdbc:mysql://localhost:3306/mom-xgdl?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X è¿žæŽ¥çš„示例
          username: root
          password: sugaozhixing.cn
          password: 123456
  # Redis é…ç½®ã€‚Redisson é»˜è®¤çš„配置足够使用,一般不需要进行调优
  data:
    redis:
      host: 192.168.197.128 # åœ°å€
      host: localhost # åœ°å€
      port: 6379 # ç«¯å£
      database: 0 # æ•°æ®åº“索引
      password: sugaozhixing.cn
#      password: sugaozhixing.cn
--- #################### å®šæ—¶ä»»åŠ¡ç›¸å…³é…ç½® ####################