|
|
@@ -0,0 +1,210 @@
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.entity.ButlerQualityInspection;
|
|
|
+import com.etotem.cfc.entity.ButlerSatisfaction;
|
|
|
+import com.etotem.cfc.mapper.ButlerQualityInspectionMapper;
|
|
|
+import com.etotem.cfc.mapper.ButlerSatisfactionMapper;
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+import org.springframework.transaction.annotation.Transactional;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Collections;
|
|
|
+import java.util.Date;
|
|
|
+import java.util.LinkedHashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 真人管家服务质检与差评复盘(能力键 {@code butler.quality.qa},SC-H-07)。
|
|
|
+ *
|
|
|
+ * <p>正常流:抽样/全量质检真人管家会话与工单(SLA、合规、态度)→ 质检报告逐项给出通过/不通过与依据;
|
|
|
+ * 边界流:质检报告不展示家长隐私对话原文,仅展示脱敏摘要与判定依据;
|
|
|
+ * 差评自动触发复盘并关联 SC-H-04 升级应急流程(复盘入口 {@link #triggerRetrospect})。</p>
|
|
|
+ *
|
|
|
+ * <p>质检项以 JSON 数组存 {@link ButlerQualityInspection#getInspectionItems()},
|
|
|
+ * 每项形如 {@code {"item":"SLA","passed":true,"basis":"首次响应 5 分钟内达标"}}。</p>
|
|
|
+ */
|
|
|
+@Slf4j
|
|
|
+@Service
|
|
|
+public class ButlerQualityService {
|
|
|
+
|
|
|
+ /** 差评评分阈值:评分 ≤ 该值视为差评,自动触发复盘(SC-H-07 异常流) */
|
|
|
+ public static final int BAD_REVIEW_RATING_THRESHOLD = 2;
|
|
|
+
|
|
|
+ /** 复盘关联引用前缀(关联 SC-H-04 升级应急流程) */
|
|
|
+ private static final String RETROSPECT_REF_PREFIX = "SC-H-04-RETROSPECT-";
|
|
|
+
|
|
|
+ private final ObjectMapper objectMapper = new ObjectMapper();
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ButlerQualityInspectionMapper qualityInspectionMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ButlerSatisfactionMapper satisfactionMapper;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 质检执行入口(SC-H-07 正常流步骤1):对指定管家会话/工单执行抽样或全量质检。
|
|
|
+ *
|
|
|
+ * <p>逐项给出通过/不通过与依据并落库;仅保存脱敏摘要与判定依据,
|
|
|
+ * <b>不写入家长隐私对话原文</b>(SC-H-07 边界)。</p>
|
|
|
+ *
|
|
|
+ * @param butlerUserId 被质检管家用户 ID
|
|
|
+ * @param sessionId 质检对象会话 ID(可空)
|
|
|
+ * @param serviceRecordId 质检对象工单/服务记录 ID(可空)
|
|
|
+ * @param inspectionType 质检方式:sample(抽样)/full(全量),空则按 sample
|
|
|
+ * @param inspectionItems 逐项质检结果,每项含 item(维度)/passed(通过与否)/basis(依据)
|
|
|
+ * @param desensitizedSummary 脱敏摘要(留空则自动按质检项生成)
|
|
|
+ */
|
|
|
+ @Transactional
|
|
|
+ public Result<ButlerQualityInspection> inspect(Long butlerUserId,
|
|
|
+ Long sessionId,
|
|
|
+ Long serviceRecordId,
|
|
|
+ String inspectionType,
|
|
|
+ List<Map<String, Object>> inspectionItems,
|
|
|
+ String desensitizedSummary) {
|
|
|
+ if (butlerUserId == null) {
|
|
|
+ return Result.badRequest("被质检管家不能为空");
|
|
|
+ }
|
|
|
+ if (sessionId == null && serviceRecordId == null) {
|
|
|
+ return Result.badRequest("质检对象不能为空(会话或工单至少提供一个)");
|
|
|
+ }
|
|
|
+
|
|
|
+ List<Map<String, Object>> items = inspectionItems != null ? inspectionItems : Collections.emptyList();
|
|
|
+ int passedCount = 0;
|
|
|
+ for (Map<String, Object> item : items) {
|
|
|
+ if (Boolean.TRUE.equals(toBoolean(item.get("passed")))) {
|
|
|
+ passedCount++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ int failedCount = items.size() - passedCount;
|
|
|
+
|
|
|
+ ButlerQualityInspection inspection = new ButlerQualityInspection();
|
|
|
+ inspection.setButlerUserId(butlerUserId);
|
|
|
+ inspection.setSessionId(sessionId);
|
|
|
+ inspection.setServiceRecordId(serviceRecordId);
|
|
|
+ inspection.setInspectionType(inspectionType != null && !inspectionType.trim().isEmpty()
|
|
|
+ ? inspectionType.trim() : "sample");
|
|
|
+ inspection.setInspectionItems(toJson(items));
|
|
|
+ inspection.setPassedCount(passedCount);
|
|
|
+ inspection.setFailedCount(failedCount);
|
|
|
+ inspection.setDesensitizedSummary(desensitizedSummary != null && !desensitizedSummary.trim().isEmpty()
|
|
|
+ ? desensitizedSummary.trim()
|
|
|
+ : buildDefaultSummary(items, passedCount, failedCount));
|
|
|
+ inspection.setStatus(failedCount == 0 ? "passed" : "failed");
|
|
|
+ Date now = new Date();
|
|
|
+ inspection.setInspectedAt(now);
|
|
|
+ inspection.setCreatedAt(now);
|
|
|
+ inspection.setUpdatedAt(now);
|
|
|
+ qualityInspectionMapper.insert(inspection);
|
|
|
+ return Result.success(inspection);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 差评触发复盘入口(SC-H-07 异常流步骤2):差评自动触发复盘并关联 SC-H-04 升级应急流程。
|
|
|
+ *
|
|
|
+ * <p>幂等:同一条满意度评分仅触发一次复盘({@code badReviewTriggered} 标记),
|
|
|
+ * 重复调用不重复计数、不重复生成复盘引用。</p>
|
|
|
+ *
|
|
|
+ * @param satisfactionId 满意度评分 ID
|
|
|
+ * @return 复盘关联引用(未触发或非差评时返回 null)
|
|
|
+ */
|
|
|
+ @Transactional
|
|
|
+ public Result<String> triggerRetrospect(Long satisfactionId) {
|
|
|
+ if (satisfactionId == null) {
|
|
|
+ return Result.badRequest("满意度评分 ID 不能为空");
|
|
|
+ }
|
|
|
+ ButlerSatisfaction satisfaction = satisfactionMapper.selectById(satisfactionId);
|
|
|
+ if (satisfaction == null) {
|
|
|
+ return Result.notFound("满意度评分不存在");
|
|
|
+ }
|
|
|
+ // 非差评不触发复盘
|
|
|
+ if (satisfaction.getRating() == null
|
|
|
+ || satisfaction.getRating() > BAD_REVIEW_RATING_THRESHOLD) {
|
|
|
+ return Result.success(null);
|
|
|
+ }
|
|
|
+ // 幂等:已触发过复盘则直接返回既有引用,不重复计数
|
|
|
+ if (satisfaction.getBadReviewTriggered() != null && satisfaction.getBadReviewTriggered() == 1) {
|
|
|
+ return Result.success(satisfaction.getRetrospectRef());
|
|
|
+ }
|
|
|
+ String retrospectRef = RETROSPECT_REF_PREFIX + satisfactionId;
|
|
|
+ satisfaction.setBadReviewTriggered(1);
|
|
|
+ satisfaction.setRetrospectRef(retrospectRef);
|
|
|
+ satisfaction.setUpdatedAt(new Date());
|
|
|
+ satisfactionMapper.updateById(satisfaction);
|
|
|
+ log.info("差评触发复盘:satisfactionId={}, butlerUserId={}, ref={}",
|
|
|
+ satisfactionId, satisfaction.getButlerUserId(), retrospectRef);
|
|
|
+ return Result.success(retrospectRef);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 查询某管家的质检记录(仅返回脱敏摘要与逐项依据,不含隐私对话原文)。
|
|
|
+ */
|
|
|
+ public Result<List<ButlerQualityInspection>> listInspections(Long butlerUserId, Long sessionId) {
|
|
|
+ LambdaQueryWrapper<ButlerQualityInspection> wrapper = new LambdaQueryWrapper<ButlerQualityInspection>()
|
|
|
+ .eq(ButlerQualityInspection::getButlerUserId, butlerUserId)
|
|
|
+ .orderByDesc(ButlerQualityInspection::getInspectedAt);
|
|
|
+ if (sessionId != null) {
|
|
|
+ wrapper.eq(ButlerQualityInspection::getSessionId, sessionId);
|
|
|
+ }
|
|
|
+ return Result.success(qualityInspectionMapper.selectList(wrapper));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+ // 内部辅助
|
|
|
+ // ------------------------------------------------------------------
|
|
|
+
|
|
|
+ /** 默认脱敏摘要:仅汇总质检维度与判定结果,不含任何对话原文。 */
|
|
|
+ private String buildDefaultSummary(List<Map<String, Object>> items, int passedCount, int failedCount) {
|
|
|
+ List<String> failedDimensions = new ArrayList<>();
|
|
|
+ for (Map<String, Object> item : items) {
|
|
|
+ if (!Boolean.TRUE.equals(toBoolean(item.get("passed")))) {
|
|
|
+ String dimension = asString(item.get("item"));
|
|
|
+ failedDimensions.add(dimension != null ? dimension : "未命名维度");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ StringBuilder summary = new StringBuilder("质检共 ").append(items.size())
|
|
|
+ .append(" 项,通过 ").append(passedCount).append(" 项,未通过 ").append(failedCount).append(" 项");
|
|
|
+ if (!failedDimensions.isEmpty()) {
|
|
|
+ summary.append(";未通过维度:").append(String.join("、", failedDimensions));
|
|
|
+ }
|
|
|
+ summary.append("(仅展示脱敏摘要与判定依据)");
|
|
|
+ return summary.toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ private String toJson(Object value) {
|
|
|
+ try {
|
|
|
+ return objectMapper.writeValueAsString(value);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("质检项序列化失败,回退为空数组: {}", e.getMessage());
|
|
|
+ return "[]";
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private Boolean toBoolean(Object value) {
|
|
|
+ if (value instanceof Boolean) {
|
|
|
+ return (Boolean) value;
|
|
|
+ }
|
|
|
+ if (value instanceof Number) {
|
|
|
+ return ((Number) value).intValue() != 0;
|
|
|
+ }
|
|
|
+ return value != null && "true".equalsIgnoreCase(value.toString().trim());
|
|
|
+ }
|
|
|
+
|
|
|
+ private String asString(Object value) {
|
|
|
+ return value != null ? value.toString() : null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 质检维度常量(SLA/合规/态度),供上层构造质检项使用。 */
|
|
|
+ public static Map<String, Object> buildInspectionItem(String dimension, boolean passed, String basis) {
|
|
|
+ Map<String, Object> item = new LinkedHashMap<>();
|
|
|
+ item.put("item", dimension);
|
|
|
+ item.put("passed", passed);
|
|
|
+ item.put("basis", basis);
|
|
|
+ return item;
|
|
|
+ }
|
|
|
+}
|