Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/LogisticsSurveyController.java
T
QiufengandClaude Opus 4.8 5e51dc3f56 SNAPSHOT W7 已部署稳定态 — 凯迪ERP+OA一体化平台 (MET 73.3%)
恢复点(restore point)。别人改崩后可 git reset --hard 回到此提交。

== 此快照内容 ==
- 后端 oa-backend: 734 控制器 / 711 实体 (Spring Boot 3.2.5 + SQLite, 端口8091)
- 前端 modern-ui/app: Vue3+Vite, 约700页 (构建产物已在 oa-backend/src/main/resources/static)
- 数据库 oa-backend/data/oa.db: 含全部演示数据 (强制入库, 6.6MB)
- 交接文档 go.md + go-code-reference/endpoints/entities/database.md
- 多代理建设脚本 .claude/wf-*.js

== 状态 ==
- 对 凯迪科技ERP_20260507.xlsx 合规 MET ~73.3% (PARTIAL 75: 34可建+6种子/bug+35外部硬天花板)
- 安全: 5轮红队+5轮复检, default-deny分级鉴权, 连续零可利用
- W3~W7 累计补完436缺口; W8末轮(40缺口)为半成品(源码树可编译但未集成)
- 运行: cd oa-backend; java -jar build/libs/oa-backend-0.1.0.jar --server.port=8091; admin/123456

== 排除(gitignore, 可再生) ==
node_modules / oa-backend/build / .jdks / *.log / Backup-ERP-* / 弃用的OFBiz核心(只保留modern-ui)
完整文件夹备份见同目录 Backup-ERP-20260615-191517/ (含上述全部, 仅缺 node_modules)

时间戳: 20260615-191517

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:19:15 +08:00

371 lines
16 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<LogisticsSurvey>> 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<LogisticsSurvey> 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<LogisticsSurvey> 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<LogisticsSurvey> 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<Void> 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<LogisticsSurvey> 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<LogisticsSurvey> 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<List<LogisticsSurveyResponse>> 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<LogisticsSurveyResponse> 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<LogisticsSurveyResponse> 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<String> feedbackList) {
}
@GetMapping("/{id}/stats")
public ApiResp<SurveyStats> stats(@PathVariable Long id) {
LogisticsSurvey s = surveyRepo.findById(id)
.orElseThrow(() -> new NotFoundException("survey not found: " + id));
List<LogisticsSurveyResponse> responses = responseRepo.findBySurveyId(id);
int[] cnt = new int[6]; // index 1-5
List<String> 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<TrendPoint> points, List<String> categories) {
}
/**
* 满意度趋势:按问卷 period 字段聚合,每个 period × category 一个数据点,
* 输出时序折线图所需数据(各季度/月各类别平均分变化)。
* 这是「缺季度满意度得分趋势报表(时序分析)」的专项端点。
*/
@GetMapping("/trends")
public ApiResp<TrendReport> trends(@RequestParam(required = false) String category) {
List<LogisticsSurvey> surveys = category != null && !category.isBlank()
? surveyRepo.findByCategory(category)
: surveyRepo.findAllByOrderByCreatedAtDesc();
// key = period + "||" + category
Map<String, double[]> 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<TrendPoint> points = new ArrayList<>();
java.util.Set<String> categories = new java.util.LinkedHashSet<>();
for (Map.Entry<String, double[]> 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<TicketTrendPoint> points, List<String> serviceTypes) {
}
/**
* 服务工单投诉分类时序统计:按工单创建月份 + 服务类型聚合工单量、完成率、平均满意度,
* 供自动闭环改进报告使用。读取 LogisticsTicket(由 LogisticsTicketController 管理),
* 此处通过仓库聚合计算,无独立数据,属于跨资源读聚合端点。
* monthParam:可选,格式 YYYY-MM,为空则返回全量按月汇总。
*/
@GetMapping("/ticket-trends")
public ApiResp<TicketTrendReport> ticketTrends(
@RequestParam(required = false) String month) {
// key = month(YYYY-MM) + "||" + serviceType
Map<String, double[]> 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<TicketTrendPoint> points = new ArrayList<>();
java.util.Set<String> serviceTypes = new java.util.LinkedHashSet<>();
for (Map.Entry<String, double[]> 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)));
}
}