liu
21 小时以前 46287520465b5664a1ddb0256537857cfd1cfc15
feat(aftersales): 售后新增回访、问题归档与问题库

- 工单完结后进入待回访,登记「满意且未复发」回访后方可归档;不满意/复发自动回退处理中
- closeTicket 语义改为归档,归档时自动沉淀一条问题库草稿(幂等)
- 新增 after_sale_visit / after_sale_problem 表;after_sale_ticket 加 visit_status/problem_id 等 6 列
- 新增回访与问题库的 Controller/Service/DAL/VO/枚举;问题库支持发布/停用/参考检索
- StorageRecordTypeEnum 登记回访/问题库附件类型;SaveReqVO 兼容 handlerUserId

Co-Authored-By: Claude <noreply@anthropic.com>
已添加20个文件
已修改12个文件
1672 ■■■■■ 文件已修改
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/problem/AfterSaleProblemController.java 141 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/problem/vo/AfterSaleProblemPageReqVO.java 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/problem/vo/AfterSaleProblemRespVO.java 104 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/problem/vo/AfterSaleProblemSaveReqVO.java 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/vo/ticket/AfterSaleTicketPageReqVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/vo/ticket/AfterSaleTicketRespVO.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/vo/ticket/AfterSaleTicketSaveReqVO.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/visit/AfterSaleVisitController.java 96 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/visit/vo/AfterSaleVisitCreateReqVO.java 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/visit/vo/AfterSaleVisitPageReqVO.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/visit/vo/AfterSaleVisitRespVO.java 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/dataobject/problem/AfterSaleProblemDO.java 118 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/dataobject/ticket/AfterSaleTicketDO.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/dataobject/visit/AfterSaleVisitDO.java 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/mysql/problem/AfterSaleProblemMapper.java 101 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/mysql/ticket/AfterSaleTicketMapper.java 3 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/mysql/visit/AfterSaleVisitMapper.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/redis/no/AfterSaleNoRedisDAO.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/enums/AfterSaleProblemStatusEnum.java 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/enums/AfterSaleRootCauseCategoryEnum.java 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/enums/AfterSaleTicketStatusEnum.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/enums/AfterSaleVisitStatusEnum.java 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/enums/AfterSaleVisitTypeEnum.java 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/enums/ErrorCodeConstants.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/enums/LogRecordConstants.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/problem/AfterSaleProblemService.java 95 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/problem/AfterSaleProblemServiceImpl.java 211 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/repair/AfterSaleRepairServiceImpl.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/ticket/AfterSaleTicketServiceImpl.java 24 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/visit/AfterSaleVisitService.java 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/visit/AfterSaleVisitServiceImpl.java 106 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/storage/StorageRecordTypeEnum.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/problem/AfterSaleProblemController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,141 @@
package cn.iocoder.yudao.module.aftersales.controller.admin.problem;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.aftersales.controller.admin.problem.vo.AfterSaleProblemPageReqVO;
import cn.iocoder.yudao.module.aftersales.controller.admin.problem.vo.AfterSaleProblemRespVO;
import cn.iocoder.yudao.module.aftersales.controller.admin.problem.vo.AfterSaleProblemSaveReqVO;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.problem.AfterSaleProblemDO;
import cn.iocoder.yudao.module.aftersales.service.problem.AfterSaleProblemService;
import cn.iocoder.yudao.module.crm.api.customer.CrmCustomerApi;
import cn.iocoder.yudao.module.crm.api.customer.dto.CrmCustomerRespDTO;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.*;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
@Tag(name = "管理后台 - å”®åŽé—®é¢˜åº“")
@RestController
@RequestMapping("/aftersales/problem")
@Validated
public class AfterSaleProblemController {
    @Resource
    private AfterSaleProblemService problemService;
    @Resource
    private CrmCustomerApi customerApi;
    @Resource
    private AdminUserApi adminUserApi;
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
    @PostMapping("/create")
    @Operation(summary = "创建售后问题")
    @PreAuthorize("@ss.hasPermission('aftersales:problem:create')")
    public CommonResult<Long> createProblem(@Valid @RequestBody AfterSaleProblemSaveReqVO createReqVO) {
        return success(problemService.createProblem(createReqVO, getLoginUserId()));
    }
    @PutMapping("/update")
    @Operation(summary = "更新售后问题")
    @PreAuthorize("@ss.hasPermission('aftersales:problem:update')")
    public CommonResult<Boolean> updateProblem(@Valid @RequestBody AfterSaleProblemSaveReqVO updateReqVO) {
        problemService.updateProblem(updateReqVO);
        return success(true);
    }
    @DeleteMapping("/delete")
    @Operation(summary = "删除售后问题")
    @Parameter(name = "id", description = "问题编号", required = true, example = "1")
    @PreAuthorize("@ss.hasPermission('aftersales:problem:delete')")
    public CommonResult<Boolean> deleteProblem(@RequestParam("id") Long id) {
        problemService.deleteProblem(id);
        return success(true);
    }
    @GetMapping("/get")
    @Operation(summary = "获得售后问题")
    @Parameter(name = "id", description = "问题编号", required = true, example = "1")
    @PreAuthorize("@ss.hasPermission('aftersales:problem:query')")
    public CommonResult<AfterSaleProblemRespVO> getProblem(@RequestParam("id") Long id) {
        AfterSaleProblemDO problemDO = problemService.getProblem(id);
        AfterSaleProblemRespVO respVO = buildProblemDetailList(List.of(problemDO)).get(0);
        respVO.setAttachmentList(storageAttachmentApi.listAttachments("aftersales_after_sale_problem", id));
        return success(respVO);
    }
    @GetMapping("/page")
    @Operation(summary = "获得售后问题分页")
    @PreAuthorize("@ss.hasPermission('aftersales:problem:query')")
    public CommonResult<PageResult<AfterSaleProblemRespVO>> getProblemPage(@Valid AfterSaleProblemPageReqVO pageReqVO) {
        PageResult<AfterSaleProblemDO> pageResult = problemService.getProblemPage(pageReqVO);
        return success(new PageResult<>(buildProblemDetailList(pageResult.getList()), pageResult.getTotal()));
    }
    @PutMapping("/publish")
    @Operation(summary = "发布售后问题")
    @Parameter(name = "id", description = "问题编号", required = true, example = "1")
    @PreAuthorize("@ss.hasPermission('aftersales:problem:publish')")
    public CommonResult<Boolean> publishProblem(@RequestParam("id") Long id) {
        problemService.publishProblem(id);
        return success(true);
    }
    @PutMapping("/disable")
    @Operation(summary = "停用售后问题")
    @Parameter(name = "id", description = "问题编号", required = true, example = "1")
    @PreAuthorize("@ss.hasPermission('aftersales:problem:disable')")
    public CommonResult<Boolean> disableProblem(@RequestParam("id") Long id) {
        problemService.disableProblem(id);
        return success(true);
    }
    @GetMapping("/reference")
    @Operation(summary = "新工单受理参考检索(已发布问题)")
    @PreAuthorize("@ss.hasPermission('aftersales:problem:query')")
    public CommonResult<List<AfterSaleProblemRespVO>> getReferenceList(@Valid AfterSaleProblemPageReqVO pageReqVO) {
        return success(buildProblemDetailList(problemService.getReferenceList(pageReqVO)));
    }
    // ========== å†…部方法:批量拼装关联名称 ==========
    private List<AfterSaleProblemRespVO> buildProblemDetailList(List<AfterSaleProblemDO> problemList) {
        if (CollUtil.isEmpty(problemList)) {
            return Collections.emptyList();
        }
        // 1. æ‰¹é‡åŠ è½½å®¢æˆ·åç§°
        Set<Long> customerIds = convertSet(problemList, AfterSaleProblemDO::getCustomerId);
        Map<Long, CrmCustomerRespDTO> customerMap = customerApi.getCustomerMap(customerIds);
        // 2. æ‰¹é‡åŠ è½½æ²‰æ·€äººåç§°
        Set<Long> userIds = convertSet(problemList, AfterSaleProblemDO::getOwnerUserId);
        Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(userIds);
        // 3. æ‹¼è£…名称字段
        return BeanUtils.toBean(problemList, AfterSaleProblemRespVO.class, vo -> {
            CrmCustomerRespDTO customer = customerMap.get(vo.getCustomerId());
            if (customer != null) {
                vo.setCustomerName(customer.getName());
            }
            AdminUserRespDTO ownerUser = userMap.get(vo.getOwnerUserId());
            if (ownerUser != null) {
                vo.setOwnerUserName(ownerUser.getNickname());
            }
        });
    }
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/problem/vo/AfterSaleProblemPageReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,45 @@
package cn.iocoder.yudao.module.aftersales.controller.admin.problem.vo;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
/**
 * å”®åŽé—®é¢˜åº“ - åˆ†é¡µæŸ¥è¯¢ Request VO
 */
@Schema(description = "售后问题库 - åˆ†é¡µæŸ¥è¯¢ Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class AfterSaleProblemPageReqVO extends PageParam {
    @Schema(description = "问题单号", example = "AP20260813000001")
    private String no;
    @Schema(description = "关键字(单号/标题/描述/解决方案)", example = "传感器")
    private String keyword;
    @Schema(description = "来源工单编号", example = "1")
    private Long sourceTicketId;
    @Schema(description = "物料编号", example = "1")
    private Long itemId;
    @Schema(description = "物料编码(模糊)", example = "MAT-001")
    private String itemCode;
    @Schema(description = "问题分类", example = "功能故障")
    private String issueCategory;
    @Schema(description = "问题类型", example = "1")
    private Integer issueType;
    @Schema(description = "根因分类", example = "3")
    private Integer rootCauseCategory;
    @Schema(description = "状态: 0-草稿 1-已发布 2-已停用", example = "1")
    private Integer status;
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/problem/vo/AfterSaleProblemRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,104 @@
package cn.iocoder.yudao.module.aftersales.controller.admin.problem.vo;
import cn.iocoder.yudao.module.system.api.storage.dto.StorageBlobRespDTO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
 * å”®åŽé—®é¢˜åº“ - å“åº” VO
 */
@Schema(description = "售后问题库 - å“åº” VO")
@Data
public class AfterSaleProblemRespVO {
    @Schema(description = "问题编号", example = "1")
    private Long id;
    @Schema(description = "问题单号", example = "AP20260813000001")
    private String no;
    @Schema(description = "问题标题", example = "拉力机传感器无输出")
    private String title;
    @Schema(description = "来源工单编号", example = "1")
    private Long sourceTicketId;
    @Schema(description = "来源工单单号(冗余)", example = "AS20260813000001")
    private String sourceTicketNo;
    @Schema(description = "客户编号", example = "1")
    private Long customerId;
    @Schema(description = "客户名称")
    private String customerName;
    @Schema(description = "物料编号", example = "1")
    private Long itemId;
    @Schema(description = "物料编码", example = "MAT-001")
    private String itemCode;
    @Schema(description = "物料名称", example = "电子元器件")
    private String itemName;
    @Schema(description = "生产批次编号", example = "1")
    private Long batchId;
    @Schema(description = "生产批次号", example = "BATCH-2024-001")
    private String batchCode;
    @Schema(description = "问题类型", example = "1")
    private Integer issueType;
    @Schema(description = "问题分类", example = "功能故障")
    private String issueCategory;
    @Schema(description = "严重程度", example = "2")
    private Integer severityLevel;
    @Schema(description = "问题描述")
    private String description;
    @Schema(description = "根因分类", example = "3")
    private Integer rootCauseCategory;
    @Schema(description = "根因分析")
    private String rootCause;
    @Schema(description = "解决方案")
    private String solution;
    @Schema(description = "状态: 0-草稿 1-已发布 2-已停用", example = "1")
    private Integer status;
    @Schema(description = "沉淀人用户编号", example = "1")
    private Long ownerUserId;
    @Schema(description = "沉淀人名称")
    private String ownerUserName;
    @Schema(description = "发布时间")
    private LocalDateTime publishTime;
    @Schema(description = "备注")
    private String remark;
    @Schema(description = "创建者")
    private String creator;
    @Schema(description = "创建时间")
    private LocalDateTime createTime;
    @Schema(description = "更新者")
    private String updater;
    @Schema(description = "更新时间")
    private LocalDateTime updateTime;
    @Schema(description = "附件列表")
    private List<StorageBlobRespDTO> attachmentList;
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/problem/vo/AfterSaleProblemSaveReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,71 @@
package cn.iocoder.yudao.module.aftersales.controller.admin.problem.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import java.util.List;
/**
 * å”®åŽé—®é¢˜åº“ - ä¿å­˜ Request VO
 */
@Schema(description = "售后问题库 - ä¿å­˜ Request VO")
@Data
public class AfterSaleProblemSaveReqVO {
    @Schema(description = "问题编号", example = "1")
    private Long id;
    @Schema(description = "问题标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "拉力机传感器无输出")
    @NotEmpty(message = "问题标题不能为空")
    private String title;
    @Schema(description = "来源工单编号(可选,便于追溯)", example = "1")
    private Long sourceTicketId;
    @Schema(description = "客户编号", example = "1")
    private Long customerId;
    @Schema(description = "物料编号", example = "1")
    private Long itemId;
    @Schema(description = "物料编码", example = "MAT-001")
    private String itemCode;
    @Schema(description = "物料名称", example = "电子元器件")
    private String itemName;
    @Schema(description = "生产批次编号", example = "1")
    private Long batchId;
    @Schema(description = "生产批次号", example = "BATCH-2024-001")
    private String batchCode;
    @Schema(description = "问题类型: 0-未判定 1-一般问题 2-维修问题 3-退货问题", example = "1")
    private Integer issueType;
    @Schema(description = "问题分类", example = "功能故障")
    private String issueCategory;
    @Schema(description = "严重程度: 0-轻微 1-一般 2-严重 3-致命", example = "2")
    private Integer severityLevel;
    @Schema(description = "问题描述")
    private String description;
    @Schema(description = "根因分类: 1-设计 2-物料 3-工艺 4-装配 5-运输 6-包装 7-使用 8-软件 9-其他", example = "3")
    private Integer rootCauseCategory;
    @Schema(description = "根因分析")
    private String rootCause;
    @Schema(description = "解决方案")
    private String solution;
    @Schema(description = "备注")
    private String remark;
    @Schema(description = "附件 blobId åˆ—表")
    private List<Long> blobIds;
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/vo/ticket/AfterSaleTicketPageReqVO.java
@@ -33,6 +33,9 @@
    @Schema(description = "问题类型", example = "1")
    private Integer issueType;
    @Schema(description = "回访状态: 0-待回访 1-已回访通过", example = "0")
    private Integer visitStatus;
    @Schema(description = "场景类型", example = "1")
    private Integer sceneType;
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/vo/ticket/AfterSaleTicketRespVO.java
@@ -87,6 +87,24 @@
    @Schema(description = "关闭时间")
    private LocalDateTime closedTime;
    @Schema(description = "回访状态: 0-待回访 1-已回访通过", example = "0")
    private Integer visitStatus;
    @Schema(description = "最近一次回访时间")
    private LocalDateTime lastVisitTime;
    @Schema(description = "最近一次回访是否满意", example = "true")
    private Boolean lastVisitSatisfied;
    @Schema(description = "最近一次回访问题是否复发", example = "false")
    private Boolean lastVisitRecurred;
    @Schema(description = "沉淀问题编号", example = "1")
    private Long problemId;
    @Schema(description = "沉淀问题单号", example = "AP20260813000001")
    private String problemNo;
    @Schema(description = "备注")
    private String remark;
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/ticket/vo/ticket/AfterSaleTicketSaveReqVO.java
@@ -38,6 +38,9 @@
    @Schema(description = "合同编号", example = "1")
    private Long contractId;
    @Schema(description = "处理人用户编号(可选,仅草稿态有效;处理人分配建议走 assign æŽ¥å£)", example = "2")
    private Long handlerUserId;
    @Schema(description = "问题类型: 1-一般问题 2-维修问题 3-退货问题", example = "1")
    private Integer issueType;
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/visit/AfterSaleVisitController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,96 @@
package cn.iocoder.yudao.module.aftersales.controller.admin.visit;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.aftersales.controller.admin.visit.vo.AfterSaleVisitCreateReqVO;
import cn.iocoder.yudao.module.aftersales.controller.admin.visit.vo.AfterSaleVisitPageReqVO;
import cn.iocoder.yudao.module.aftersales.controller.admin.visit.vo.AfterSaleVisitRespVO;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.visit.AfterSaleVisitDO;
import cn.iocoder.yudao.module.aftersales.service.visit.AfterSaleVisitService;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.*;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
@Tag(name = "管理后台 - å”®åŽå›žè®¿è®°å½•")
@RestController
@RequestMapping("/aftersales/visit")
@Validated
public class AfterSaleVisitController {
    @Resource
    private AfterSaleVisitService visitService;
    @Resource
    private AdminUserApi adminUserApi;
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
    @PostMapping("/create")
    @Operation(summary = "登记售后回访")
    @PreAuthorize("@ss.hasPermission('aftersales:visit:create')")
    public CommonResult<Long> createVisit(@Valid @RequestBody AfterSaleVisitCreateReqVO createReqVO) {
        return success(visitService.createVisit(createReqVO, getLoginUserId()));
    }
    @GetMapping("/get")
    @Operation(summary = "获得售后回访记录")
    @Parameter(name = "id", description = "回访记录编号", required = true, example = "1")
    @PreAuthorize("@ss.hasPermission('aftersales:visit:query')")
    public CommonResult<AfterSaleVisitRespVO> getVisit(@RequestParam("id") Long id) {
        AfterSaleVisitDO visitDO = visitService.getVisit(id);
        AfterSaleVisitRespVO respVO = buildVisitDetailList(List.of(visitDO)).get(0);
        respVO.setAttachmentList(storageAttachmentApi.listAttachments("aftersales_after_sale_visit", id));
        return success(respVO);
    }
    @GetMapping("/list")
    @Operation(summary = "获得某工单的回访记录列表")
    @Parameter(name = "ticketId", description = "工单编号", required = true, example = "1")
    @PreAuthorize("@ss.hasPermission('aftersales:visit:query')")
    public CommonResult<List<AfterSaleVisitRespVO>> getVisitListByTicketId(@RequestParam("ticketId") Long ticketId) {
        List<AfterSaleVisitDO> visitList = visitService.getVisitListByTicketId(ticketId);
        return success(buildVisitDetailList(visitList));
    }
    @GetMapping("/page")
    @Operation(summary = "获得售后回访记录分页")
    @PreAuthorize("@ss.hasPermission('aftersales:visit:query')")
    public CommonResult<PageResult<AfterSaleVisitRespVO>> getVisitPage(@Valid AfterSaleVisitPageReqVO pageReqVO) {
        PageResult<AfterSaleVisitDO> pageResult = visitService.getVisitPage(pageReqVO);
        return success(new PageResult<>(buildVisitDetailList(pageResult.getList()), pageResult.getTotal()));
    }
    // ========== å†…部方法:批量拼装关联名称 ==========
    private List<AfterSaleVisitRespVO> buildVisitDetailList(List<AfterSaleVisitDO> visitList) {
        if (CollUtil.isEmpty(visitList)) {
            return Collections.emptyList();
        }
        // æ‰¹é‡åŠ è½½å›žè®¿äººåç§°
        Set<Long> userIds = convertSet(visitList, AfterSaleVisitDO::getVisitorUserId);
        Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(userIds);
        return BeanUtils.toBean(visitList, AfterSaleVisitRespVO.class, vo -> {
            AdminUserRespDTO visitorUser = userMap.get(vo.getVisitorUserId());
            if (visitorUser != null) {
                vo.setVisitorUserName(visitorUser.getNickname());
            }
        });
    }
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/visit/vo/AfterSaleVisitCreateReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,41 @@
package cn.iocoder.yudao.module.aftersales.controller.admin.visit.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
/**
 * å”®åŽå›žè®¿è®°å½• - ç™»è®° Request VO
 */
@Schema(description = "售后回访记录 - ç™»è®° Request VO")
@Data
public class AfterSaleVisitCreateReqVO {
    @Schema(description = "工单编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "工单不能为空")
    private Long ticketId;
    @Schema(description = "回访方式: 0-电话 1-上门 2-远程 3-其他", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
    @NotNull(message = "回访方式不能为空")
    private Integer visitType;
    @Schema(description = "回访内容", example = "客户反馈已正常使用")
    private String content;
    @Schema(description = "是否满意", requiredMode = Schema.RequiredMode.REQUIRED, example = "true")
    @NotNull(message = "是否满意不能为空")
    private Boolean satisfied;
    @Schema(description = "问题是否复发", requiredMode = Schema.RequiredMode.REQUIRED, example = "false")
    @NotNull(message = "问题是否复发不能为空")
    private Boolean recurred;
    @Schema(description = "备注")
    private String remark;
    @Schema(description = "附件 blobId åˆ—表")
    private List<Long> blobIds;
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/visit/vo/AfterSaleVisitPageReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,27 @@
package cn.iocoder.yudao.module.aftersales.controller.admin.visit.vo;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
/**
 * å”®åŽå›žè®¿è®°å½• - åˆ†é¡µæŸ¥è¯¢ Request VO
 */
@Schema(description = "售后回访记录 - åˆ†é¡µæŸ¥è¯¢ Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class AfterSaleVisitPageReqVO extends PageParam {
    @Schema(description = "工单编号", example = "1")
    private Long ticketId;
    @Schema(description = "回访方式", example = "0")
    private Integer visitType;
    @Schema(description = "是否满意", example = "true")
    private Boolean satisfied;
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/controller/admin/visit/vo/AfterSaleVisitRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,56 @@
package cn.iocoder.yudao.module.aftersales.controller.admin.visit.vo;
import cn.iocoder.yudao.module.system.api.storage.dto.StorageBlobRespDTO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
 * å”®åŽå›žè®¿è®°å½• - å“åº” VO
 */
@Schema(description = "售后回访记录 - å“åº” VO")
@Data
public class AfterSaleVisitRespVO {
    @Schema(description = "回访记录编号", example = "1")
    private Long id;
    @Schema(description = "工单编号", example = "1")
    private Long ticketId;
    @Schema(description = "回访方式: 0-电话 1-上门 2-远程 3-其他", example = "0")
    private Integer visitType;
    @Schema(description = "回访人用户编号", example = "1")
    private Long visitorUserId;
    @Schema(description = "回访人名称")
    private String visitorUserName;
    @Schema(description = "回访内容")
    private String content;
    @Schema(description = "是否满意", example = "true")
    private Boolean satisfied;
    @Schema(description = "问题是否复发", example = "false")
    private Boolean recurred;
    @Schema(description = "回访时间")
    private LocalDateTime visitTime;
    @Schema(description = "备注")
    private String remark;
    @Schema(description = "创建者")
    private String creator;
    @Schema(description = "创建时间")
    private LocalDateTime createTime;
    @Schema(description = "附件列表")
    private List<StorageBlobRespDTO> attachmentList;
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/dataobject/problem/AfterSaleProblemDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,118 @@
package cn.iocoder.yudao.module.aftersales.dal.dataobject.problem;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import cn.iocoder.yudao.module.aftersales.enums.AfterSaleProblemStatusEnum;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import java.time.LocalDateTime;
/**
 * å”®åŽé—®é¢˜åº“ DO
 * <p>
 * ç‹¬ç«‹çš„知识沉淀实体:工单归档时按工单快照自动生成草稿,人工补充根因/方案后发布;
 * æ–°å·¥å•受理时可按物料/分类/关键字命中已发布问题作参考。
 */
@TableName("after_sale_problem")
@KeySequence("after_sale_problem_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AfterSaleProblemDO extends BaseDO {
    /**
     * é—®é¢˜ç¼–号
     */
    @TableId
    private Long id;
    /**
     * é—®é¢˜å•号,格式 AP+yyyyMMdd+6位
     */
    private String no;
    /**
     * é—®é¢˜æ ‡é¢˜
     */
    private String title;
    /**
     * æ¥æºå·¥å•编号,关联 after_sale_ticket.id
     */
    private Long sourceTicketId;
    /**
     * æ¥æºå·¥å•单号(冗余)
     */
    private String sourceTicketNo;
    /**
     * å®¢æˆ·ç¼–号(来源工单带入)
     */
    private Long customerId;
    /**
     * ç‰©æ–™ç¼–号(默认取来源工单首条明细)
     */
    private Long itemId;
    /**
     * ç‰©æ–™ç¼–码(冗余)
     */
    private String itemCode;
    /**
     * ç‰©æ–™åç§°(冗余)
     */
    private String itemName;
    /**
     * ç”Ÿäº§æ‰¹æ¬¡ç¼–号(可选)
     */
    private Long batchId;
    /**
     * ç”Ÿäº§æ‰¹æ¬¡å·(可选)
     */
    private String batchCode;
    /**
     * é—®é¢˜ç±»åž‹ï¼Œæžšä¸¾ {@link cn.iocoder.yudao.module.aftersales.enums.AfterSaleIssueTypeEnum}
     */
    private Integer issueType;
    /**
     * é—®é¢˜åˆ†ç±»
     */
    private String issueCategory;
    /**
     * ä¸¥é‡ç¨‹åº¦: 0-轻微 1-一般 2-严重 3-致命
     */
    private Integer severityLevel;
    /**
     * é—®é¢˜æè¿°
     */
    private String description;
    /**
     * æ ¹å› åˆ†ç±»ï¼Œæžšä¸¾ {@link cn.iocoder.yudao.module.aftersales.enums.AfterSaleRootCauseCategoryEnum}
     */
    private Integer rootCauseCategory;
    /**
     * æ ¹å› åˆ†æž
     */
    private String rootCause;
    /**
     * è§£å†³æ–¹æ¡ˆ
     */
    private String solution;
    /**
     * çŠ¶æ€ï¼Œæžšä¸¾ {@link AfterSaleProblemStatusEnum}
     */
    private Integer status;
    /**
     * æ²‰æ·€äººç”¨æˆ·ç¼–号
     */
    private Long ownerUserId;
    /**
     * å‘布时间
     */
    private LocalDateTime publishTime;
    /**
     * å¤‡æ³¨
     */
    private String remark;
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/dataobject/ticket/AfterSaleTicketDO.java
@@ -109,6 +109,32 @@
     */
    private LocalDateTime closedTime;
    /**
     * å›žè®¿çŠ¶æ€: 0-待回访 1-已回访通过
     *
     * å®Œç»“(COMPLETED)后进入待回访,登记「满意且未复发」的回访后置为已回访通过,方可归档
     */
    private Integer visitStatus;
    /**
     * æœ€è¿‘一次回访时间
     */
    private LocalDateTime lastVisitTime;
    /**
     * æœ€è¿‘一次回访是否满意
     */
    private Boolean lastVisitSatisfied;
    /**
     * æœ€è¿‘一次回访问题是否复发
     */
    private Boolean lastVisitRecurred;
    /**
     * æ²‰æ·€é—®é¢˜ç¼–号,关联 after_sale_problem.id
     */
    private Long problemId;
    /**
     * æ²‰æ·€é—®é¢˜å•号(冗余)
     */
    private String problemNo;
    /**
     * å¤‡æ³¨
     */
    private String remark;
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/dataobject/visit/AfterSaleVisitDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,67 @@
package cn.iocoder.yudao.module.aftersales.dal.dataobject.visit;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import java.time.LocalDateTime;
/**
 * å”®åŽå›žè®¿è®°å½• DO
 * <p>
 * æŒ‚在售后工单下的回访子记录,可多条;登记回访时同步推进工单回访状态。
 */
@TableName("after_sale_visit")
@KeySequence("after_sale_visit_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AfterSaleVisitDO extends BaseDO {
    /**
     * å›žè®¿è®°å½•编号
     */
    @TableId
    private Long id;
    /**
     * å·¥å•编号,关联 after_sale_ticket.id
     */
    private Long ticketId;
    /**
     * å›žè®¿æ–¹å¼: 0-电话 1-上门 2-远程 3-其他
     */
    private Integer visitType;
    /**
     * å›žè®¿äººç”¨æˆ·ç¼–号
     */
    private Long visitorUserId;
    /**
     * å›žè®¿å†…容
     */
    private String content;
    /**
     * æ˜¯å¦æ»¡æ„: 0-否 1-是
     */
    @TableField("is_satisfied")
    private Boolean satisfied;
    /**
     * é—®é¢˜æ˜¯å¦å¤å‘: 0-否 1-是
     */
    @TableField("is_recurred")
    private Boolean recurred;
    /**
     * å›žè®¿æ—¶é—´(登记即当时)
     */
    private LocalDateTime visitTime;
    /**
     * å¤‡æ³¨
     */
    private String remark;
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/mysql/problem/AfterSaleProblemMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,101 @@
package cn.iocoder.yudao.module.aftersales.dal.mysql.problem;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.problem.AfterSaleProblemDO;
import cn.iocoder.yudao.module.aftersales.enums.AfterSaleProblemStatusEnum;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
/**
 * å”®åŽé—®é¢˜åº“ Mapper
 */
@Mapper
public interface AfterSaleProblemMapper extends BaseMapperX<AfterSaleProblemDO> {
    default AfterSaleProblemDO selectByNo(String no) {
        return selectOne(AfterSaleProblemDO::getNo, no);
    }
    default AfterSaleProblemDO selectBySourceTicketId(Long sourceTicketId) {
        return selectOne(AfterSaleProblemDO::getSourceTicketId, sourceTicketId);
    }
    default PageResult<AfterSaleProblemDO> selectPage(PageParam pageParam, String no, String keyword,
                                                      Long sourceTicketId, Long itemId, String itemCode,
                                                      String issueCategory,
                                                      Integer issueType, Integer rootCauseCategory, Integer status) {
        LambdaQueryWrapperX<AfterSaleProblemDO> query = new LambdaQueryWrapperX<AfterSaleProblemDO>()
                .eqIfPresent(AfterSaleProblemDO::getNo, no)
                .eqIfPresent(AfterSaleProblemDO::getSourceTicketId, sourceTicketId)
                .eqIfPresent(AfterSaleProblemDO::getItemId, itemId)
                .likeIfPresent(AfterSaleProblemDO::getItemCode, itemCode)
                .eqIfPresent(AfterSaleProblemDO::getIssueCategory, issueCategory)
                .eqIfPresent(AfterSaleProblemDO::getIssueType, issueType)
                .eqIfPresent(AfterSaleProblemDO::getRootCauseCategory, rootCauseCategory)
                .eqIfPresent(AfterSaleProblemDO::getStatus, status);
        // å…³é”®å­—检索:单号/标题/描述/解决方案
        if (StrUtil.isNotBlank(keyword)) {
            query.and(w -> w.like(AfterSaleProblemDO::getNo, keyword)
                    .or().like(AfterSaleProblemDO::getTitle, keyword)
                    .or().like(AfterSaleProblemDO::getDescription, keyword)
                    .or().like(AfterSaleProblemDO::getSolution, keyword));
        }
        query.orderByDesc(AfterSaleProblemDO::getUpdateTime);
        return selectPage(pageParam, query);
    }
    /**
     * æ–°å·¥å•受理参考检索:只返回已发布,按物料/分类/关键字命中,取最近更新 10 æ¡
     */
    default List<AfterSaleProblemDO> selectReferenceList(Long itemId, String issueCategory, Integer issueType,
                                                         Integer rootCauseCategory, String keyword) {
        LambdaQueryWrapperX<AfterSaleProblemDO> query = new LambdaQueryWrapperX<AfterSaleProblemDO>()
                .eq(AfterSaleProblemDO::getStatus, AfterSaleProblemStatusEnum.PUBLISHED.getStatus());
        query.and(w -> {
                    boolean hasCond = false;
                    if (itemId != null) {
                        w.eq(AfterSaleProblemDO::getItemId, itemId);
                        hasCond = true;
                    }
                    if (StrUtil.isNotBlank(issueCategory)) {
                        if (hasCond) {
                            w.or();
                        }
                        w.eq(AfterSaleProblemDO::getIssueCategory, issueCategory);
                        hasCond = true;
                    }
                    if (issueType != null) {
                        if (hasCond) {
                            w.or();
                        }
                        w.eq(AfterSaleProblemDO::getIssueType, issueType);
                        hasCond = true;
                    }
                    if (rootCauseCategory != null) {
                        if (hasCond) {
                            w.or();
                        }
                        w.eq(AfterSaleProblemDO::getRootCauseCategory, rootCauseCategory);
                        hasCond = true;
                    }
                    if (StrUtil.isNotBlank(keyword)) {
                        if (hasCond) {
                            w.or();
                        }
                        w.like(AfterSaleProblemDO::getTitle, keyword);
                        hasCond = true;
                    }
                    if (!hasCond) {
                        w.eq(AfterSaleProblemDO::getId, -1L); // æ— æ¡ä»¶æ—¶è¿”回空,避免全表拉取
                    }
                });
        query.orderByDesc(AfterSaleProblemDO::getUpdateTime).last("LIMIT 10");
        return selectList(query);
    }
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/mysql/ticket/AfterSaleTicketMapper.java
@@ -25,7 +25,7 @@
    default PageResult<AfterSaleTicketDO> selectPage(PageParam pageParam, Long userId, Integer sceneType,
                                                     String no, String name,
                                                     Long customerId, Long saleOrderId,
                                                     Integer status, Integer issueType) {
                                                     Integer status, Integer issueType, Integer visitStatus) {
        MPJLambdaWrapperX<AfterSaleTicketDO> query = new MPJLambdaWrapperX<>();
        // æ•°æ®æƒé™
        AfterSalePermissionUtils.appendPermissionCondition(query, AfterSaleBizTypeEnum.AFTER_SALE_TICKET.getType(),
@@ -38,6 +38,7 @@
                .eqIfPresent(AfterSaleTicketDO::getSaleOrderId, saleOrderId)
                .eqIfPresent(AfterSaleTicketDO::getStatus, status)
                .eqIfPresent(AfterSaleTicketDO::getIssueType, issueType)
                .eqIfPresent(AfterSaleTicketDO::getVisitStatus, visitStatus)
                .orderByDesc(AfterSaleTicketDO::getId);
        return selectJoinPage(pageParam, AfterSaleTicketDO.class, query);
    }
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/mysql/visit/AfterSaleVisitMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,33 @@
package cn.iocoder.yudao.module.aftersales.dal.mysql.visit;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.visit.AfterSaleVisitDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
/**
 * å”®åŽå›žè®¿è®°å½• Mapper
 */
@Mapper
public interface AfterSaleVisitMapper extends BaseMapperX<AfterSaleVisitDO> {
    default List<AfterSaleVisitDO> selectListByTicketId(Long ticketId) {
        return selectList(new LambdaQueryWrapperX<AfterSaleVisitDO>()
                .eq(AfterSaleVisitDO::getTicketId, ticketId)
                .orderByDesc(AfterSaleVisitDO::getVisitTime));
    }
    default PageResult<AfterSaleVisitDO> selectPage(PageParam pageParam, Long ticketId, Integer visitType,
                                                    Boolean satisfied) {
        return selectPage(pageParam, new LambdaQueryWrapperX<AfterSaleVisitDO>()
                .eqIfPresent(AfterSaleVisitDO::getTicketId, ticketId)
                .eqIfPresent(AfterSaleVisitDO::getVisitType, visitType)
                .eqIfPresent(AfterSaleVisitDO::getSatisfied, satisfied)
                .orderByDesc(AfterSaleVisitDO::getVisitTime));
    }
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/dal/redis/no/AfterSaleNoRedisDAO.java
@@ -26,6 +26,11 @@
     */
    public static final String RETURN_NO_PREFIX = "RT";
    /**
     * å”®åŽé—®é¢˜åº“ {@link cn.iocoder.yudao.module.aftersales.dal.dataobject.problem.AfterSaleProblemDO}
     */
    public static final String PROBLEM_NO_PREFIX = "AP";
    @Resource
    private StringRedisTemplate stringRedisTemplate;
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/enums/AfterSaleProblemStatusEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,43 @@
package cn.iocoder.yudao.module.aftersales.enums;
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
/**
 * å”®åŽé—®é¢˜åº“状态枚举
 */
@Getter
@AllArgsConstructor
public enum AfterSaleProblemStatusEnum implements ArrayValuable<Integer> {
    DRAFT(0, "草稿"),
    PUBLISHED(1, "已发布"),
    DISABLED(2, "已停用");
    public static final Integer[] ARRAYS = Arrays.stream(values()).map(AfterSaleProblemStatusEnum::getStatus).toArray(Integer[]::new);
    /**
     * é—®é¢˜çŠ¶æ€
     */
    private final Integer status;
    /**
     * é—®é¢˜çŠ¶æ€åç§°
     */
    private final String name;
    @Override
    public Integer[] array() {
        return ARRAYS;
    }
    public static String getNameByStatus(Integer status) {
        return Arrays.stream(values())
                .filter(item -> item.getStatus().equals(status))
                .map(AfterSaleProblemStatusEnum::getName)
                .findFirst().orElse(null);
    }
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/enums/AfterSaleRootCauseCategoryEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,49 @@
package cn.iocoder.yudao.module.aftersales.enums;
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
/**
 * å”®åŽé—®é¢˜æ ¹å› åˆ†ç±»æžšä¸¾
 */
@Getter
@AllArgsConstructor
public enum AfterSaleRootCauseCategoryEnum implements ArrayValuable<Integer> {
    DESIGN(1, "设计缺陷"),
    MATERIAL(2, "物料缺陷"),
    PROCESS(3, "工艺缺陷"),
    ASSEMBLY(4, "装配缺陷"),
    TRANSPORT(5, "运输损坏"),
    PACKAGING(6, "包装缺陷"),
    USAGE(7, "使用不当"),
    SOFTWARE(8, "软件缺陷"),
    OTHER(9, "其他");
    public static final Integer[] ARRAYS = Arrays.stream(values()).map(AfterSaleRootCauseCategoryEnum::getType).toArray(Integer[]::new);
    /**
     * æ ¹å› åˆ†ç±»
     */
    private final Integer type;
    /**
     * æ ¹å› åˆ†ç±»åç§°
     */
    private final String name;
    @Override
    public Integer[] array() {
        return ARRAYS;
    }
    public static String getNameByType(Integer type) {
        return Arrays.stream(values())
                .filter(item -> item.getType().equals(type))
                .map(AfterSaleRootCauseCategoryEnum::getName)
                .findFirst().orElse(null);
    }
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/enums/AfterSaleTicketStatusEnum.java
@@ -17,7 +17,9 @@
    PROCESSING(10, "处理中"),
    WAITING_RETURN(30, "待退货"),
    WAITING_REFUND(40, "待退款/换货"),
    // 50 å·²å®Œç»“:语义=待回访,登记「满意且未复发」的回访通过后方可归档
    COMPLETED(50, "已完结"),
    // 60 å·²å…³é—­ï¼šè§†ä¸ºå½’档终态(只读),归档时自动沉淀一条问题库草稿
    CLOSED(60, "已关闭"),
    CANCELED(99, "已取消");
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/enums/AfterSaleVisitStatusEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,44 @@
package cn.iocoder.yudao.module.aftersales.enums;
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
/**
 * å”®åŽå·¥å•回访状态枚举
 * <p>
 * å·¥å•完结(COMPLETED)后进入「待回访」;登记「满意且未复发」的回访后置为「已回访通过」,方可归档。
 */
@Getter
@AllArgsConstructor
public enum AfterSaleVisitStatusEnum implements ArrayValuable<Integer> {
    WAITING(0, "待回访"),
    PASSED(1, "已回访通过");
    public static final Integer[] ARRAYS = Arrays.stream(values()).map(AfterSaleVisitStatusEnum::getStatus).toArray(Integer[]::new);
    /**
     * å›žè®¿çŠ¶æ€
     */
    private final Integer status;
    /**
     * å›žè®¿çŠ¶æ€åç§°
     */
    private final String name;
    @Override
    public Integer[] array() {
        return ARRAYS;
    }
    public static String getNameByStatus(Integer status) {
        return Arrays.stream(values())
                .filter(item -> item.getStatus().equals(status))
                .map(AfterSaleVisitStatusEnum::getName)
                .findFirst().orElse(null);
    }
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/enums/AfterSaleVisitTypeEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,44 @@
package cn.iocoder.yudao.module.aftersales.enums;
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
/**
 * å”®åŽå›žè®¿æ–¹å¼æžšä¸¾
 */
@Getter
@AllArgsConstructor
public enum AfterSaleVisitTypeEnum implements ArrayValuable<Integer> {
    PHONE(0, "电话回访"),
    ON_SITE(1, "上门回访"),
    REMOTE(2, "远程回访"),
    OTHER(3, "其他");
    public static final Integer[] ARRAYS = Arrays.stream(values()).map(AfterSaleVisitTypeEnum::getType).toArray(Integer[]::new);
    /**
     * å›žè®¿æ–¹å¼
     */
    private final Integer type;
    /**
     * å›žè®¿æ–¹å¼åç§°
     */
    private final String name;
    @Override
    public Integer[] array() {
        return ARRAYS;
    }
    public static String getNameByType(Integer type) {
        return Arrays.stream(values())
                .filter(item -> item.getType().equals(type))
                .map(AfterSaleVisitTypeEnum::getName)
                .findFirst().orElse(null);
    }
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/enums/ErrorCodeConstants.java
@@ -19,6 +19,7 @@
    ErrorCode TICKET_SUBMIT_FAIL_NOT_DRAFT = new ErrorCode(1_022_000_006, "提交工单审批失败,原因:工单不是草稿状态");
    ErrorCode TICKET_BPM_PROCESS_DEFINITION_NOT_EXISTS = new ErrorCode(1_022_000_007, "售后工单审批流程不存在或未激活,请检查 BPM é…ç½®");
    ErrorCode TICKET_STATUS_ERROR = new ErrorCode(1_022_000_008, "工单当前状态不允许此操作");
    ErrorCode TICKET_ARCHIVE_FAIL_NOT_VISITED = new ErrorCode(1_022_000_009, "归档失败,原因:工单尚未回访通过,请先登记满意的回访记录");
    // ========== å”®åŽå·¥å•明细行 1-022-001-000 ==========
    ErrorCode TICKET_ITEM_NOT_EXISTS = new ErrorCode(1_022_001_000, "工单明细行不存在");
@@ -35,4 +36,16 @@
    ErrorCode REPAIR_NOT_EXISTS = new ErrorCode(1_022_003_000, "维修记录不存在");
    ErrorCode REPAIR_UPDATE_FAIL_STATUS = new ErrorCode(1_022_003_001, "更新维修状态失败,原因:已经是该状态");
    // ========== å”®åŽå›žè®¿ 1-022-004-000 ==========
    ErrorCode VISIT_NOT_EXISTS = new ErrorCode(1_022_004_000, "回访记录不存在");
    ErrorCode VISIT_CREATE_FAIL_STATUS = new ErrorCode(1_022_004_001, "登记回访失败,原因:工单不是已完结(待回访)状态");
    // ========== å”®åŽé—®é¢˜åº“ 1-022-005-000 ==========
    ErrorCode PROBLEM_NOT_EXISTS = new ErrorCode(1_022_005_000, "问题记录不存在");
    ErrorCode PROBLEM_NO_EXISTS = new ErrorCode(1_022_005_001, "问题单号重复,请重试");
    ErrorCode PROBLEM_UPDATE_FAIL_STATUS = new ErrorCode(1_022_005_002, "问题修改失败,原因:仅草稿状态可编辑");
    ErrorCode PROBLEM_DELETE_FAIL_STATUS = new ErrorCode(1_022_005_003, "问题删除失败,原因:仅草稿状态可删除");
    ErrorCode PROBLEM_PUBLISH_FAIL_STATUS = new ErrorCode(1_022_005_004, "问题发布失败,原因:当前状态不可发布");
    ErrorCode PROBLEM_DISABLE_FAIL_STATUS = new ErrorCode(1_022_005_005, "问题停用失败,原因:当前状态不可停用");
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/enums/LogRecordConstants.java
@@ -45,4 +45,24 @@
    String AFTER_SALE_REPAIR_COMPLETE_SUB_TYPE = "完成维修";
    String AFTER_SALE_REPAIR_COMPLETE_SUCCESS = "完成了维修记录【{{#repairId}}】,维修结果:【{{#repairResult}}】";
    // ======================= å”®åŽå›žè®¿ =======================
    String AFTER_SALE_VISIT_TYPE = "售后回访";
    String AFTER_SALE_VISIT_CREATE_SUB_TYPE = "登记回访";
    String AFTER_SALE_VISIT_CREATE_SUCCESS = "登记了售后工单【{{#ticketNo}}】的回访:满意【{{#satisfied}}】/复发【{{#recurred}}】";
    // ======================= å”®åŽé—®é¢˜åº“ =======================
    String AFTER_SALE_PROBLEM_TYPE = "售后问题库";
    String AFTER_SALE_PROBLEM_CREATE_SUB_TYPE = "创建问题";
    String AFTER_SALE_PROBLEM_CREATE_SUCCESS = "创建了售后问题【{{#problem.title}}】";
    String AFTER_SALE_PROBLEM_UPDATE_SUB_TYPE = "更新问题";
    String AFTER_SALE_PROBLEM_UPDATE_SUCCESS = "更新了售后问题【{{#problemTitle}}】: {_DIFF{#updateReqVO}}";
    String AFTER_SALE_PROBLEM_DELETE_SUB_TYPE = "删除问题";
    String AFTER_SALE_PROBLEM_DELETE_SUCCESS = "删除了售后问题【{{#problemTitle}}】";
    String AFTER_SALE_PROBLEM_PUBLISH_SUB_TYPE = "发布问题";
    String AFTER_SALE_PROBLEM_PUBLISH_SUCCESS = "发布了售后问题【{{#problemNo}}】";
    String AFTER_SALE_PROBLEM_DISABLE_SUB_TYPE = "停用问题";
    String AFTER_SALE_PROBLEM_DISABLE_SUCCESS = "停用了售后问题【{{#problemNo}}】";
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/problem/AfterSaleProblemService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,95 @@
package cn.iocoder.yudao.module.aftersales.service.problem;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.aftersales.controller.admin.problem.vo.AfterSaleProblemPageReqVO;
import cn.iocoder.yudao.module.aftersales.controller.admin.problem.vo.AfterSaleProblemSaveReqVO;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.problem.AfterSaleProblemDO;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.ticket.AfterSaleTicketDO;
import jakarta.validation.Valid;
import java.util.Collection;
import java.util.List;
/**
 * å”®åŽé—®é¢˜åº“ Service æŽ¥å£
 */
public interface AfterSaleProblemService {
    /**
     * åˆ›å»ºé—®é¢˜(人工建档),初始状态草稿
     *
     * @param createReqVO åˆ›å»ºä¿¡æ¯
     * @param userId      å½“前用户编号(沉淀人)
     * @return é—®é¢˜ç¼–号
     */
    Long createProblem(@Valid AfterSaleProblemSaveReqVO createReqVO, Long userId);
    /**
     * æ›´æ–°é—®é¢˜
     *
     * @param updateReqVO æ›´æ–°ä¿¡æ¯
     */
    void updateProblem(@Valid AfterSaleProblemSaveReqVO updateReqVO);
    /**
     * åˆ é™¤é—®é¢˜(仅草稿)
     *
     * @param id é—®é¢˜ç¼–号
     */
    void deleteProblem(Long id);
    /**
     * èŽ·å¾—é—®é¢˜
     *
     * @param id é—®é¢˜ç¼–号
     * @return é—®é¢˜
     */
    AfterSaleProblemDO getProblem(Long id);
    /**
     * èŽ·å¾—é—®é¢˜åˆ—è¡¨
     *
     * @param ids ç¼–号列表
     * @return é—®é¢˜åˆ—表
     */
    List<AfterSaleProblemDO> getProblemList(Collection<Long> ids);
    /**
     * èŽ·å¾—é—®é¢˜åˆ†é¡µ
     *
     * @param pageReqVO åˆ†é¡µæŸ¥è¯¢
     * @return é—®é¢˜åˆ†é¡µ
     */
    PageResult<AfterSaleProblemDO> getProblemPage(AfterSaleProblemPageReqVO pageReqVO);
    /**
     * å‘布问题
     *
     * @param id é—®é¢˜ç¼–号
     */
    void publishProblem(Long id);
    /**
     * åœç”¨é—®é¢˜
     *
     * @param id é—®é¢˜ç¼–号
     */
    void disableProblem(Long id);
    /**
     * æ–°å·¥å•受理参考检索:命中已发布历史问题
     *
     * @param pageReqVO å‚考检索条件(取 itemId/issueCategory/issueType/rootCauseCategory/keyword)
     * @return å·²å‘布问题列表(至多10条)
     */
    List<AfterSaleProblemDO> getReferenceList(AfterSaleProblemPageReqVO pageReqVO);
    /**
     * å·¥å•归档时自动沉淀问题(草稿态);同一工单仅沉淀一次(幂等)
     *
     * @param ticket å”®åŽå·¥å•
     * @return æ²‰æ·€å‡ºçš„问题
     */
    AfterSaleProblemDO createProblemFromTicket(AfterSaleTicketDO ticket);
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/problem/AfterSaleProblemServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,211 @@
package cn.iocoder.yudao.module.aftersales.service.problem;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.aftersales.controller.admin.problem.vo.AfterSaleProblemPageReqVO;
import cn.iocoder.yudao.module.aftersales.controller.admin.problem.vo.AfterSaleProblemSaveReqVO;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.problem.AfterSaleProblemDO;
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.problem.AfterSaleProblemMapper;
import cn.iocoder.yudao.module.aftersales.dal.mysql.ticket.AfterSaleTicketItemMapper;
import cn.iocoder.yudao.module.aftersales.dal.mysql.ticket.AfterSaleTicketMapper;
import cn.iocoder.yudao.module.aftersales.dal.redis.no.AfterSaleNoRedisDAO;
import cn.iocoder.yudao.module.aftersales.enums.AfterSaleProblemStatusEnum;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
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.aftersales.enums.ErrorCodeConstants.*;
/**
 * å”®åŽé—®é¢˜åº“ Service å®žçŽ°ç±»
 */
@Service
@Validated
@Slf4j
public class AfterSaleProblemServiceImpl implements AfterSaleProblemService {
    @Resource
    private AfterSaleProblemMapper problemMapper;
    @Resource
    private AfterSaleTicketMapper ticketMapper;
    @Resource
    private AfterSaleTicketItemMapper ticketItemMapper;
    @Resource
    private AfterSaleNoRedisDAO noRedisDAO;
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createProblem(AfterSaleProblemSaveReqVO createReqVO, Long userId) {
        AfterSaleProblemDO problem = BeanUtils.toBean(createReqVO, AfterSaleProblemDO.class);
        // æ¥æºå·¥å•存在时补全冗余单号与默认客户
        if (createReqVO.getSourceTicketId() != null) {
            AfterSaleTicketDO ticket = ticketMapper.selectById(createReqVO.getSourceTicketId());
            if (ticket == null) {
                throw exception(TICKET_NOT_EXISTS);
            }
            problem.setSourceTicketNo(ticket.getNo());
            if (problem.getCustomerId() == null) {
                problem.setCustomerId(ticket.getCustomerId());
            }
        }
        problem.setNo(generateNo())
                .setStatus(AfterSaleProblemStatusEnum.DRAFT.getStatus())
                .setOwnerUserId(userId);
        problemMapper.insert(problem);
        // ç»‘定附件
        if (CollUtil.isNotEmpty(createReqVO.getBlobIds())) {
            storageAttachmentApi.bindAttachments("file", "aftersales_after_sale_problem",
                    problem.getId(), createReqVO.getBlobIds());
        }
        return problem.getId();
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateProblem(AfterSaleProblemSaveReqVO updateReqVO) {
        AfterSaleProblemDO problem = validateProblemExists(updateReqVO.getId());
        if (!AfterSaleProblemStatusEnum.DRAFT.getStatus().equals(problem.getStatus())) {
            throw exception(PROBLEM_UPDATE_FAIL_STATUS);
        }
        AfterSaleProblemDO update = BeanUtils.toBean(updateReqVO, AfterSaleProblemDO.class);
        problemMapper.updateById(update);
        if (CollUtil.isNotEmpty(updateReqVO.getBlobIds())) {
            storageAttachmentApi.updateAttachments("file", "aftersales_after_sale_problem",
                    updateReqVO.getId(), updateReqVO.getBlobIds());
        }
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteProblem(Long id) {
        AfterSaleProblemDO problem = validateProblemExists(id);
        if (!AfterSaleProblemStatusEnum.DRAFT.getStatus().equals(problem.getStatus())) {
            throw exception(PROBLEM_DELETE_FAIL_STATUS);
        }
        problemMapper.deleteById(id);
        storageAttachmentApi.deleteAttachmentsByRecord("aftersales_after_sale_problem", id);
        // è§£é™¤å·¥å•对问题的关联
        ticketMapper.update(null, new LambdaUpdateWrapper<AfterSaleTicketDO>()
                .eq(AfterSaleTicketDO::getProblemId, id)
                .set(AfterSaleTicketDO::getProblemId, null)
                .set(AfterSaleTicketDO::getProblemNo, null));
    }
    @Override
    public AfterSaleProblemDO getProblem(Long id) {
        return validateProblemExists(id);
    }
    @Override
    public List<AfterSaleProblemDO> getProblemList(Collection<Long> ids) {
        if (CollUtil.isEmpty(ids)) {
            return List.of();
        }
        return problemMapper.selectBatchIds(ids);
    }
    @Override
    public PageResult<AfterSaleProblemDO> getProblemPage(AfterSaleProblemPageReqVO pageReqVO) {
        return problemMapper.selectPage(pageReqVO, pageReqVO.getNo(), pageReqVO.getKeyword(),
                pageReqVO.getSourceTicketId(), pageReqVO.getItemId(), pageReqVO.getItemCode(),
                pageReqVO.getIssueCategory(),
                pageReqVO.getIssueType(), pageReqVO.getRootCauseCategory(), pageReqVO.getStatus());
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void publishProblem(Long id) {
        AfterSaleProblemDO problem = validateProblemExists(id);
        if (!(AfterSaleProblemStatusEnum.DRAFT.getStatus().equals(problem.getStatus())
                || AfterSaleProblemStatusEnum.DISABLED.getStatus().equals(problem.getStatus()))) {
            throw exception(PROBLEM_PUBLISH_FAIL_STATUS);
        }
        problemMapper.updateById(new AfterSaleProblemDO().setId(id)
                .setStatus(AfterSaleProblemStatusEnum.PUBLISHED.getStatus())
                .setPublishTime(LocalDateTime.now()));
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void disableProblem(Long id) {
        AfterSaleProblemDO problem = validateProblemExists(id);
        if (!AfterSaleProblemStatusEnum.PUBLISHED.getStatus().equals(problem.getStatus())) {
            throw exception(PROBLEM_DISABLE_FAIL_STATUS);
        }
        problemMapper.updateById(new AfterSaleProblemDO().setId(id)
                .setStatus(AfterSaleProblemStatusEnum.DISABLED.getStatus()));
    }
    @Override
    public List<AfterSaleProblemDO> getReferenceList(AfterSaleProblemPageReqVO pageReqVO) {
        return problemMapper.selectReferenceList(pageReqVO.getItemId(), pageReqVO.getIssueCategory(),
                pageReqVO.getIssueType(), pageReqVO.getRootCauseCategory(), pageReqVO.getKeyword());
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public AfterSaleProblemDO createProblemFromTicket(AfterSaleTicketDO ticket) {
        // å¹‚等:同一工单已沉淀过则直接复用
        AfterSaleProblemDO exist = problemMapper.selectBySourceTicketId(ticket.getId());
        if (exist != null) {
            return exist;
        }
        AfterSaleProblemDO problem = new AfterSaleProblemDO()
                .setNo(generateNo())
                .setTitle(ticket.getName())
                .setSourceTicketId(ticket.getId())
                .setSourceTicketNo(ticket.getNo())
                .setCustomerId(ticket.getCustomerId())
                .setIssueType(ticket.getIssueType())
                .setIssueCategory(ticket.getIssueCategory())
                .setSeverityLevel(ticket.getSeverityLevel())
                .setDescription(ticket.getIssueDescription())
                .setStatus(AfterSaleProblemStatusEnum.DRAFT.getStatus())
                .setOwnerUserId(getLoginUserId());
        // å–工单首条明细作为物料快照(允许无明细行)
        AfterSaleTicketItemDO firstItem = CollUtil.getFirst(ticketItemMapper.selectListByTicketId(ticket.getId()));
        if (firstItem != null) {
            problem.setItemId(firstItem.getItemId())
                    .setItemCode(firstItem.getItemCode())
                    .setItemName(firstItem.getItemName())
                    .setBatchId(firstItem.getBatchId())
                    .setBatchCode(firstItem.getBatchCode());
        }
        problemMapper.insert(problem);
        return problem;
    }
    // ========== å†…部方法 ==========
    private AfterSaleProblemDO validateProblemExists(Long id) {
        AfterSaleProblemDO problem = problemMapper.selectById(id);
        if (problem == null) {
            throw exception(PROBLEM_NOT_EXISTS);
        }
        return problem;
    }
    private String generateNo() {
        String no = noRedisDAO.generate(AfterSaleNoRedisDAO.PROBLEM_NO_PREFIX);
        if (problemMapper.selectByNo(no) != null) {
            throw exception(PROBLEM_NO_EXISTS);
        }
        return no;
    }
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/repair/AfterSaleRepairServiceImpl.java
@@ -8,6 +8,8 @@
import cn.iocoder.yudao.module.aftersales.dal.mysql.repair.AfterSaleRepairMapper;
import cn.iocoder.yudao.module.aftersales.dal.mysql.ticket.AfterSaleTicketMapper;
import cn.iocoder.yudao.module.aftersales.enums.AfterSaleRepairStatusEnum;
import cn.iocoder.yudao.module.aftersales.enums.AfterSaleTicketStatusEnum;
import cn.iocoder.yudao.module.aftersales.enums.AfterSaleVisitStatusEnum;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -67,12 +69,13 @@
        repairMapper.updateById(new AfterSaleRepairDO().setId(id)
                .setStatus(AfterSaleRepairStatusEnum.COMPLETED.getStatus())
                .setFinishTime(LocalDateTime.now()));
        // æ›´æ–°å·¥å•状态
        // æ›´æ–°å·¥å•状态:完结后进入待回访
        AfterSaleTicketDO ticket = ticketMapper.selectById(repair.getTicketId());
        if (ticket != null) {
            ticketMapper.updateById(new AfterSaleTicketDO().setId(ticket.getId())
                    .setStatus(cn.iocoder.yudao.module.aftersales.enums.AfterSaleTicketStatusEnum.COMPLETED.getStatus())
                    .setResolvedTime(LocalDateTime.now()));
                    .setStatus(AfterSaleTicketStatusEnum.COMPLETED.getStatus())
                    .setResolvedTime(LocalDateTime.now())
                    .setVisitStatus(AfterSaleVisitStatusEnum.WAITING.getStatus()));
        }
    }
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/ticket/AfterSaleTicketServiceImpl.java
@@ -5,6 +5,7 @@
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
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.problem.AfterSaleProblemDO;
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.ticket.AfterSaleTicketItemMapper;
@@ -12,9 +13,11 @@
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.AfterSaleTicketStatusEnum;
import cn.iocoder.yudao.module.aftersales.enums.AfterSaleVisitStatusEnum;
import cn.iocoder.yudao.module.aftersales.enums.permission.AfterSalePermissionLevelEnum;
import cn.iocoder.yudao.module.aftersales.service.permission.AfterSalePermissionService;
import cn.iocoder.yudao.module.aftersales.service.permission.bo.AfterSalePermissionCreateReqBO;
import cn.iocoder.yudao.module.aftersales.service.problem.AfterSaleProblemService;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
@@ -47,6 +50,9 @@
    @Resource
    private AfterSalePermissionService permissionService;
    @Resource
    private AfterSaleProblemService problemService;
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
@@ -146,7 +152,7 @@
        return ticketMapper.selectPage(pageReqVO, userId, pageReqVO.getSceneType(),
                pageReqVO.getNo(), pageReqVO.getName(),
                pageReqVO.getCustomerId(), pageReqVO.getSaleOrderId(),
                pageReqVO.getStatus(), pageReqVO.getIssueType());
                pageReqVO.getStatus(), pageReqVO.getIssueType(), pageReqVO.getVisitStatus());
    }
    @Override
@@ -178,19 +184,31 @@
        AfterSaleTicketDO ticket = validateTicketExists(id);
        validateTicketStatus(ticket, AfterSaleTicketStatusEnum.PROCESSING,
                AfterSaleTicketStatusEnum.WAITING_RETURN, AfterSaleTicketStatusEnum.WAITING_REFUND);
        // å®Œç»“后进入待回访,需登记满意的回访后方可归档
        ticketMapper.updateById(new AfterSaleTicketDO().setId(id)
                .setStatus(AfterSaleTicketStatusEnum.COMPLETED.getStatus())
                .setResolvedTime(LocalDateTime.now()));
                .setResolvedTime(LocalDateTime.now())
                .setVisitStatus(AfterSaleVisitStatusEnum.WAITING.getStatus()));
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void closeTicket(Long id) {
        // 1. æ ¡éªŒå·¥å•存在且处于已完结状态
        AfterSaleTicketDO ticket = validateTicketExists(id);
        validateTicketStatus(ticket, AfterSaleTicketStatusEnum.COMPLETED);
        // 2. æ ¡éªŒå·²å›žè®¿é€šè¿‡ï¼Œæœªå›žè®¿é€šè¿‡ä¸å…è®¸å½’æ¡£
        if (!AfterSaleVisitStatusEnum.PASSED.getStatus().equals(ticket.getVisitStatus())) {
            throw exception(TICKET_ARCHIVE_FAIL_NOT_VISITED);
        }
        // 3. è‡ªåŠ¨æ²‰æ·€é—®é¢˜(幂等:同一工单仅沉淀一条),返回草稿态问题
        AfterSaleProblemDO problem = problemService.createProblemFromTicket(ticket);
        // 4. ç½®å½’档终态(只读)
        ticketMapper.updateById(new AfterSaleTicketDO().setId(id)
                .setStatus(AfterSaleTicketStatusEnum.CLOSED.getStatus())
                .setClosedTime(LocalDateTime.now()));
                .setClosedTime(LocalDateTime.now())
                .setProblemId(problem.getId())
                .setProblemNo(problem.getNo()));
    }
    @Override
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/visit/AfterSaleVisitService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,52 @@
package cn.iocoder.yudao.module.aftersales.service.visit;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.aftersales.controller.admin.visit.vo.AfterSaleVisitCreateReqVO;
import cn.iocoder.yudao.module.aftersales.controller.admin.visit.vo.AfterSaleVisitPageReqVO;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.visit.AfterSaleVisitDO;
import jakarta.validation.Valid;
import java.util.List;
/**
 * å”®åŽå›žè®¿è®°å½• Service æŽ¥å£
 */
public interface AfterSaleVisitService {
    /**
     * ç™»è®°å›žè®¿
     * <p>
     * å·¥å•须处于已完结(待回访)状态。登记时同步推进工单:
     * æ»¡æ„ä¸”未复发 â†’ å·¥å•回访通过(可归档);不满意或复发 â†’ å·¥å•回退处理中重新处理。
     *
     * @param createReqVO ç™»è®°ä¿¡æ¯
     * @param userId      å½“前用户编号(回访人)
     * @return å›žè®¿è®°å½•编号
     */
    Long createVisit(@Valid AfterSaleVisitCreateReqVO createReqVO, Long userId);
    /**
     * èŽ·å¾—å›žè®¿è®°å½•
     *
     * @param id ç¼–号
     * @return å›žè®¿è®°å½•
     */
    AfterSaleVisitDO getVisit(Long id);
    /**
     * èŽ·å¾—æŸå·¥å•çš„å›žè®¿è®°å½•åˆ—è¡¨
     *
     * @param ticketId å·¥å•编号
     * @return å›žè®¿è®°å½•列表
     */
    List<AfterSaleVisitDO> getVisitListByTicketId(Long ticketId);
    /**
     * èŽ·å¾—å›žè®¿è®°å½•åˆ†é¡µ
     *
     * @param pageReqVO åˆ†é¡µæŸ¥è¯¢
     * @return å›žè®¿è®°å½•分页
     */
    PageResult<AfterSaleVisitDO> getVisitPage(AfterSaleVisitPageReqVO pageReqVO);
}
yudao-module-aftersales/src/main/java/cn/iocoder/yudao/module/aftersales/service/visit/AfterSaleVisitServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,106 @@
package cn.iocoder.yudao.module.aftersales.service.visit;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.aftersales.controller.admin.visit.vo.AfterSaleVisitCreateReqVO;
import cn.iocoder.yudao.module.aftersales.controller.admin.visit.vo.AfterSaleVisitPageReqVO;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.ticket.AfterSaleTicketDO;
import cn.iocoder.yudao.module.aftersales.dal.dataobject.visit.AfterSaleVisitDO;
import cn.iocoder.yudao.module.aftersales.dal.mysql.ticket.AfterSaleTicketMapper;
import cn.iocoder.yudao.module.aftersales.dal.mysql.visit.AfterSaleVisitMapper;
import cn.iocoder.yudao.module.aftersales.enums.AfterSaleTicketStatusEnum;
import cn.iocoder.yudao.module.aftersales.enums.AfterSaleVisitStatusEnum;
import cn.iocoder.yudao.module.system.api.storage.StorageAttachmentApi;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.time.LocalDateTime;
import java.util.List;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.aftersales.enums.ErrorCodeConstants.TICKET_NOT_EXISTS;
import static cn.iocoder.yudao.module.aftersales.enums.ErrorCodeConstants.VISIT_CREATE_FAIL_STATUS;
import static cn.iocoder.yudao.module.aftersales.enums.ErrorCodeConstants.VISIT_NOT_EXISTS;
/**
 * å”®åŽå›žè®¿è®°å½• Service å®žçŽ°ç±»
 */
@Service
@Validated
@Slf4j
public class AfterSaleVisitServiceImpl implements AfterSaleVisitService {
    @Resource
    private AfterSaleVisitMapper visitMapper;
    @Resource
    private AfterSaleTicketMapper ticketMapper;
    @Resource
    private StorageAttachmentApi storageAttachmentApi;
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createVisit(AfterSaleVisitCreateReqVO createReqVO, Long userId) {
        // 1. æ ¡éªŒå·¥å•:必须存在且处于已完结(待回访)状态
        AfterSaleTicketDO ticket = ticketMapper.selectById(createReqVO.getTicketId());
        if (ticket == null) {
            throw exception(TICKET_NOT_EXISTS);
        }
        if (!AfterSaleTicketStatusEnum.COMPLETED.getStatus().equals(ticket.getStatus())) {
            throw exception(VISIT_CREATE_FAIL_STATUS);
        }
        // 2. æ’入回访记录
        AfterSaleVisitDO visitDO = BeanUtils.toBean(createReqVO, AfterSaleVisitDO.class)
                .setVisitorUserId(userId)
                .setVisitTime(LocalDateTime.now());
        visitMapper.insert(visitDO);
        // 3. ç»‘定附件
        if (CollUtil.isNotEmpty(createReqVO.getBlobIds())) {
            storageAttachmentApi.bindAttachments("file", "aftersales_after_sale_visit",
                    visitDO.getId(), createReqVO.getBlobIds());
        }
        // 4. æŽ¨è¿›å·¥å•回访状态:满意且未复发 â†’ é€šè¿‡ï¼›ä¸æ»¡æ„æˆ–复发 â†’ å›žé€€å¤„理中重新处理
        boolean satisfied = Boolean.TRUE.equals(createReqVO.getSatisfied());
        boolean recurred = Boolean.TRUE.equals(createReqVO.getRecurred());
        AfterSaleTicketDO update = new AfterSaleTicketDO().setId(ticket.getId())
                .setLastVisitTime(visitDO.getVisitTime())
                .setLastVisitSatisfied(satisfied)
                .setLastVisitRecurred(recurred);
        if (satisfied && !recurred) {
            update.setVisitStatus(AfterSaleVisitStatusEnum.PASSED.getStatus());
        } else {
            update.setStatus(AfterSaleTicketStatusEnum.PROCESSING.getStatus())
                    .setResolvedTime(null)
                    .setVisitStatus(AfterSaleVisitStatusEnum.WAITING.getStatus());
        }
        ticketMapper.updateById(update);
        return visitDO.getId();
    }
    @Override
    public AfterSaleVisitDO getVisit(Long id) {
        AfterSaleVisitDO visitDO = visitMapper.selectById(id);
        if (visitDO == null) {
            throw exception(VISIT_NOT_EXISTS);
        }
        return visitDO;
    }
    @Override
    public List<AfterSaleVisitDO> getVisitListByTicketId(Long ticketId) {
        return visitMapper.selectListByTicketId(ticketId);
    }
    @Override
    public PageResult<AfterSaleVisitDO> getVisitPage(AfterSaleVisitPageReqVO pageReqVO) {
        return visitMapper.selectPage(pageReqVO, pageReqVO.getTicketId(),
                pageReqVO.getVisitType(), pageReqVO.getSatisfied());
    }
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/storage/StorageRecordTypeEnum.java
@@ -59,6 +59,8 @@
    // ===== å”®åŽ =====
    AFTER_SALE_TICKET("aftersales_after_sale_ticket", "售后工单"),
    AFTER_SALE_RETURN("aftersales_after_sale_return", "售后退货申请"),
    AFTER_SALE_VISIT("aftersales_after_sale_visit", "售后回访"),
    AFTER_SALE_PROBLEM("aftersales_after_sale_problem", "售后问题库"),
    // ===== MES =====
    MES_PD_DOCUMENT("mes_pd_document", "MES è®¾è®¡èµ„æ–™"),