package com.kaidi.oa.web; import com.kaidi.oa.common.ApiException; import com.kaidi.oa.common.ApiResp; import com.kaidi.oa.common.NotFoundException; import com.kaidi.oa.domain.LogisticsSurvey; import com.kaidi.oa.domain.LogisticsSurveyResponse; import com.kaidi.oa.domain.LogisticsTicket; import com.kaidi.oa.repository.LogisticsSurveyRepository; import com.kaidi.oa.repository.LogisticsSurveyResponseRepository; import com.kaidi.oa.repository.LogisticsTicketRepository; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.time.Instant; import java.time.LocalDate; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * 行政/后勤·服务满意度与改进·满意度问卷系统。 * * 补足缺口:「缺专项定期满意度问卷派发机制;缺季度满意度得分趋势报表(时序分析)」。 * - /api/oa/logistics-surveys:问卷管理(创建/发放/截止); * - /…/{id}/responses:员工提交回复(score 1-5),提交后回写问卷 responseCount/avgScore(@Transactional); * - /…/{id}/stats:单份问卷结果统计(分数分布 + 文字反馈汇总); * - /trends:按场景分类的时序满意度趋势(季度/月)——跨问卷聚合 avgScore 时间轴; * - /ticket-trends:服务工单投诉分类时序统计(按月/季/分类汇总)——自动闭环改进报告数据。 * * 问卷不含金额,写口受 default-deny 即可。 */ @RestController @RequestMapping("/api/oa/logistics-surveys") public class LogisticsSurveyController { private final LogisticsSurveyRepository surveyRepo; private final LogisticsSurveyResponseRepository responseRepo; private final LogisticsTicketRepository ticketRepo; public LogisticsSurveyController(LogisticsSurveyRepository surveyRepo, LogisticsSurveyResponseRepository responseRepo, LogisticsTicketRepository ticketRepo) { this.surveyRepo = surveyRepo; this.responseRepo = responseRepo; this.ticketRepo = ticketRepo; } // ========== 问卷 CRUD ========== @GetMapping public ApiResp> list(@RequestParam(required = false) String status, @RequestParam(required = false) String category) { if (status != null && !status.isBlank()) { return ApiResp.ok(surveyRepo.findByStatus(status)); } if (category != null && !category.isBlank()) { return ApiResp.ok(surveyRepo.findByCategory(category)); } return ApiResp.ok(surveyRepo.findAllByOrderByCreatedAtDesc()); } @GetMapping("/{id}") public ApiResp get(@PathVariable Long id) { return ApiResp.ok(surveyRepo.findById(id) .orElseThrow(() -> new NotFoundException("survey not found: " + id))); } public record SurveyRequest( String title, String category, String description, String period, String startDate, String endDate, String creator) { } @PostMapping public ApiResp create(@RequestBody SurveyRequest req) { if (req.title() == null || req.title().isBlank()) { throw new ApiException(400, "问卷标题(title) 不能为空"); } LogisticsSurvey s = new LogisticsSurvey(); s.setTitle(req.title()); s.setCategory(req.category() == null ? "综合" : req.category()); s.setDescription(req.description()); s.setPeriod(req.period()); s.setStartDate(req.startDate()); s.setEndDate(req.endDate()); s.setStatus("草稿"); s.setCreator(req.creator()); s.setResponseCount(0); s.setAvgScore(0d); s.setCreatedAt(Instant.now()); s.setUpdatedAt(Instant.now()); return ApiResp.ok(surveyRepo.save(s)); } @PatchMapping("/{id}") public ApiResp update(@PathVariable Long id, @RequestBody SurveyRequest req) { LogisticsSurvey s = surveyRepo.findById(id) .orElseThrow(() -> new NotFoundException("survey not found: " + id)); if (!"草稿".equals(s.getStatus())) { throw new ApiException(409, "仅「草稿」状态可修改,当前:" + s.getStatus()); } if (req.title() != null && !req.title().isBlank()) s.setTitle(req.title()); if (req.category() != null) s.setCategory(req.category()); if (req.description() != null) s.setDescription(req.description()); if (req.period() != null) s.setPeriod(req.period()); if (req.startDate() != null) s.setStartDate(req.startDate()); if (req.endDate() != null) s.setEndDate(req.endDate()); s.setUpdatedAt(Instant.now()); return ApiResp.ok(surveyRepo.save(s)); } @DeleteMapping("/{id}") public ApiResp delete(@PathVariable Long id) { LogisticsSurvey s = surveyRepo.findById(id) .orElseThrow(() -> new NotFoundException("survey not found: " + id)); if (!"草稿".equals(s.getStatus())) { throw new ApiException(409, "仅「草稿」可删除,当前:" + s.getStatus()); } surveyRepo.deleteById(id); return ApiResp.ok(null); } // ========== 状态流转 ========== /** 发放问卷:草稿 → 已发放。 */ @PostMapping("/{id}/publish") @Transactional public ApiResp publish(@PathVariable Long id) { LogisticsSurvey s = surveyRepo.findById(id) .orElseThrow(() -> new NotFoundException("survey not found: " + id)); if (!"草稿".equals(s.getStatus())) { throw new ApiException(409, "仅「草稿」可发放,当前:" + s.getStatus()); } s.setStatus("已发放"); s.setUpdatedAt(Instant.now()); return ApiResp.ok(surveyRepo.save(s)); } /** 截止问卷:已发放 → 已截止。 */ @PostMapping("/{id}/close") @Transactional public ApiResp closeSurvey(@PathVariable Long id) { LogisticsSurvey s = surveyRepo.findById(id) .orElseThrow(() -> new NotFoundException("survey not found: " + id)); if (!"已发放".equals(s.getStatus())) { throw new ApiException(409, "仅「已发放」可截止,当前:" + s.getStatus()); } s.setStatus("已截止"); s.setUpdatedAt(Instant.now()); return ApiResp.ok(surveyRepo.save(s)); } // ========== 员工填写回复 ========== @GetMapping("/{id}/responses") public ApiResp> responses(@PathVariable Long id) { return ApiResp.ok(responseRepo.findBySurveyId(id)); } public record ResponseRequest(String respondent, String deptName, Integer score, String feedback) { } /** * 员工提交满意度评分;仅「已发放」且在有效期内(今天在 startDate..endDate 内)的问卷可填写。 * 提交后自动重算问卷平均分(@Transactional)。 */ @PostMapping("/{id}/responses") @Transactional public ApiResp submitResponse(@PathVariable Long id, @RequestBody ResponseRequest req) { LogisticsSurvey survey = surveyRepo.findById(id) .orElseThrow(() -> new NotFoundException("survey not found: " + id)); if (!"已发放".equals(survey.getStatus())) { throw new ApiException(409, "问卷当前状态「" + survey.getStatus() + "」,不接受填写"); } // 检查有效期。 LocalDate today = LocalDate.now(); if (survey.getEndDate() != null && !survey.getEndDate().isBlank()) { try { LocalDate end = LocalDate.parse(survey.getEndDate().substring(0, 10)); if (today.isAfter(end)) { throw new ApiException(409, "问卷已过截止日期(" + survey.getEndDate() + "),无法填写"); } } catch (ApiException e) { throw e; } catch (Exception ignored) { // 日期格式错误,不做日期限制。 } } if (req.score() == null || req.score() < 1 || req.score() > 5) { throw new ApiException(400, "评分(score) 须为 1-5"); } if (req.respondent() == null || req.respondent().isBlank()) { throw new ApiException(400, "填写人(respondent) 不能为空"); } LogisticsSurveyResponse r = new LogisticsSurveyResponse(); r.setSurveyId(id); r.setSurveyTitle(survey.getTitle()); r.setRespondent(req.respondent()); r.setDeptName(req.deptName()); r.setScore(req.score()); r.setFeedback(req.feedback()); r.setSubmittedAt(Instant.now()); responseRepo.save(r); // 重算问卷平均分。 List allResp = responseRepo.findBySurveyId(id); int sum = 0; for (LogisticsSurveyResponse rr : allResp) { sum += rr.getScore() == null ? 0 : rr.getScore(); } double avg = allResp.isEmpty() ? 0 : (double) sum / allResp.size(); survey.setResponseCount(allResp.size()); survey.setAvgScore(Math.round(avg * 100d) / 100d); survey.setUpdatedAt(Instant.now()); surveyRepo.save(survey); return ApiResp.ok(r); } // ========== 单份问卷统计 ========== public record ScoreDistribution(int score1, int score2, int score3, int score4, int score5) { } public record SurveyStats( Long surveyId, String title, String category, int totalResponses, double avgScore, ScoreDistribution distribution, List feedbackList) { } @GetMapping("/{id}/stats") public ApiResp stats(@PathVariable Long id) { LogisticsSurvey s = surveyRepo.findById(id) .orElseThrow(() -> new NotFoundException("survey not found: " + id)); List responses = responseRepo.findBySurveyId(id); int[] cnt = new int[6]; // index 1-5 List feedbacks = new ArrayList<>(); for (LogisticsSurveyResponse r : responses) { int sc = r.getScore() == null ? 0 : r.getScore(); if (sc >= 1 && sc <= 5) { cnt[sc]++; } if (r.getFeedback() != null && !r.getFeedback().isBlank()) { feedbacks.add(r.getFeedback()); } } ScoreDistribution dist = new ScoreDistribution(cnt[1], cnt[2], cnt[3], cnt[4], cnt[5]); return ApiResp.ok(new SurveyStats( id, s.getTitle(), s.getCategory(), responses.size(), s.getAvgScore() == null ? 0 : s.getAvgScore(), dist, feedbacks)); } // ========== 满意度趋势(时序分析)========== public record TrendPoint(String period, String category, double avgScore, int responseCount) { } public record TrendReport(List points, List categories) { } /** * 满意度趋势:按问卷 period 字段聚合,每个 period × category 一个数据点, * 输出时序折线图所需数据(各季度/月各类别平均分变化)。 * 这是「缺季度满意度得分趋势报表(时序分析)」的专项端点。 */ @GetMapping("/trends") public ApiResp trends(@RequestParam(required = false) String category) { List surveys = category != null && !category.isBlank() ? surveyRepo.findByCategory(category) : surveyRepo.findAllByOrderByCreatedAtDesc(); // key = period + "||" + category Map agg = new LinkedHashMap<>(); // [scoreSum, count] for (LogisticsSurvey s : surveys) { if (s.getPeriod() == null || s.getPeriod().isBlank()) { continue; } String key = s.getPeriod() + "||" + (s.getCategory() == null ? "综合" : s.getCategory()); double[] acc = agg.computeIfAbsent(key, k -> new double[2]); int cnt = s.getResponseCount() == null ? 0 : s.getResponseCount(); double avg = s.getAvgScore() == null ? 0 : s.getAvgScore(); acc[0] += avg * cnt; acc[1] += cnt; } List points = new ArrayList<>(); java.util.Set categories = new java.util.LinkedHashSet<>(); for (Map.Entry e : agg.entrySet()) { String[] parts = e.getKey().split("\\|\\|", 2); String period = parts[0]; String cat = parts.length > 1 ? parts[1] : "综合"; categories.add(cat); double[] acc = e.getValue(); double avg = acc[1] == 0 ? 0 : acc[0] / acc[1]; points.add(new TrendPoint(period, cat, Math.round(avg * 100d) / 100d, (int) acc[1])); } // 按 period 正序排列便于时序折线图。 points.sort((a, b) -> a.period().compareTo(b.period())); return ApiResp.ok(new TrendReport(points, new ArrayList<>(categories))); } // ========== 工单投诉分类统计(闭环改进报告数据)========== public record TicketTrendPoint(String month, String serviceType, int count, double finishRate, double avgSatisfaction) { } public record TicketTrendReport(List points, List serviceTypes) { } /** * 服务工单投诉分类时序统计:按工单创建月份 + 服务类型聚合工单量、完成率、平均满意度, * 供自动闭环改进报告使用。读取 LogisticsTicket(由 LogisticsTicketController 管理), * 此处通过仓库聚合计算,无独立数据,属于跨资源读聚合端点。 * monthParam:可选,格式 YYYY-MM,为空则返回全量按月汇总。 */ @GetMapping("/ticket-trends") public ApiResp ticketTrends( @RequestParam(required = false) String month) { // key = month(YYYY-MM) + "||" + serviceType Map agg = new LinkedHashMap<>(); // [0]=total, [1]=finished, [2]=ratedCnt, [3]=satSum for (LogisticsTicket t : ticketRepo.findAll()) { String createdStr = t.getCreatedAt() == null ? "" : t.getCreatedAt().toString(); String ym = createdStr.length() >= 7 ? createdStr.substring(0, 7) : "unknown"; if (month != null && !month.isBlank() && !ym.equals(month)) { continue; } String stype = t.getServiceType() == null ? "其他" : t.getServiceType(); String key = ym + "||" + stype; double[] a = agg.computeIfAbsent(key, k -> new double[4]); a[0]++; boolean done = "已完成".equals(t.getStatus()) || "已评价".equals(t.getStatus()); if (done) { a[1]++; } if (t.getSatisfaction() != null && t.getSatisfaction() >= 1) { a[2]++; a[3] += t.getSatisfaction(); } } List points = new ArrayList<>(); java.util.Set serviceTypes = new java.util.LinkedHashSet<>(); for (Map.Entry e : agg.entrySet()) { String[] parts = e.getKey().split("\\|\\|", 2); String ym = parts[0]; String stype = parts.length > 1 ? parts[1] : "其他"; serviceTypes.add(stype); double[] a = e.getValue(); double finishRate = a[0] == 0 ? 0 : Math.round(a[1] / a[0] * 10000d) / 100d; double avgSat = a[2] == 0 ? 0 : Math.round(a[3] / a[2] * 100d) / 100d; points.add(new TicketTrendPoint(ym, stype, (int) a[0], finishRate, avgSat)); } points.sort((a, b) -> a.month().compareTo(b.month())); return ApiResp.ok(new TicketTrendReport(points, new ArrayList<>(serviceTypes))); } }