恢复点(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>
371 lines
16 KiB
Java
371 lines
16 KiB
Java
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)));
|
||
}
|
||
}
|