feat(wm-revenue): #55 客服知识库+公告板+KPI看板完整实现
- Entity: KbArticle(知识库文章), Announcement(公告), KpiDashboard(KPI看板VO)
- Mapper: KbArticleMapper, AnnouncementMapper (MyBatis-Plus)
- Service: KnowledgeBaseService(知识库CRUD+搜索+分类+点赞+热门),
AnnouncementService(公告发布/编辑/按类型筛选/按范围推送/撤回),
KpiService(KPI聚合计算:待处理量/时效/满意率/趋势/排行)
- Controller: CsSupportController (/api/revenue/cs/*)
- SQL DDL: V_cs_support.sql (cs_kb_article + cs_announcement 表+示例数据)
- Frontend: KnowledgeBaseView.vue(列表/卡片/Markdown编辑器),
AnnouncementView.vue(类型标签/状态切换/时间范围),
KpiDashboardView.vue(ECharts趋势图+饼图+排行)
- Unit Test: CsSupportServiceTest (知识库/公告/KPI三组测试)
- Router: 新增 /cs/knowledge, /cs/announcement, /cs/kpi 路由
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
package com.water.revenue.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.water.common.core.result.R;
|
||||
import com.water.revenue.entity.Announcement;
|
||||
import com.water.revenue.entity.KbArticle;
|
||||
import com.water.revenue.entity.KpiDashboard;
|
||||
import com.water.revenue.service.AnnouncementService;
|
||||
import com.water.revenue.service.KnowledgeBaseService;
|
||||
import com.water.revenue.service.KpiService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 客服支撑模块 Controller
|
||||
* 包含:知识库管理、公告管理、KPI看板
|
||||
*/
|
||||
@Tag(name = "客服支撑")
|
||||
@RestController
|
||||
@RequestMapping("/api/revenue/cs")
|
||||
@RequiredArgsConstructor
|
||||
public class CsSupportController {
|
||||
|
||||
private final KnowledgeBaseService knowledgeBaseService;
|
||||
private final AnnouncementService announcementService;
|
||||
private final KpiService kpiService;
|
||||
|
||||
// ==================== 知识库 ====================
|
||||
|
||||
@Operation(summary = "知识库分页搜索")
|
||||
@GetMapping("/kb/list")
|
||||
public R<Page<KbArticle>> kbList(
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) String category,
|
||||
@RequestParam(required = false) Integer status) {
|
||||
return R.ok(knowledgeBaseService.search(page, size, keyword, category, status));
|
||||
}
|
||||
|
||||
@Operation(summary = "知识库文章详情")
|
||||
@GetMapping("/kb/{id}")
|
||||
public R<KbArticle> kbDetail(@PathVariable Long id) {
|
||||
return R.ok(knowledgeBaseService.getDetail(id));
|
||||
}
|
||||
|
||||
@Operation(summary = "创建知识库文章")
|
||||
@PostMapping("/kb")
|
||||
public R<KbArticle> kbCreate(@RequestBody KbArticle article) {
|
||||
return R.ok(knowledgeBaseService.create(article));
|
||||
}
|
||||
|
||||
@Operation(summary = "更新知识库文章")
|
||||
@PutMapping("/kb/{id}")
|
||||
public R<String> kbUpdate(@PathVariable Long id, @RequestBody KbArticle article) {
|
||||
knowledgeBaseService.update(id, article);
|
||||
return R.ok("更新成功");
|
||||
}
|
||||
|
||||
@Operation(summary = "删除知识库文章")
|
||||
@DeleteMapping("/kb/{id}")
|
||||
public R<String> kbDelete(@PathVariable Long id) {
|
||||
knowledgeBaseService.delete(id);
|
||||
return R.ok("删除成功");
|
||||
}
|
||||
|
||||
@Operation(summary = "点赞文章")
|
||||
@PostMapping("/kb/{id}/like")
|
||||
public R<String> kbLike(@PathVariable Long id) {
|
||||
knowledgeBaseService.like(id);
|
||||
return R.ok("点赞成功");
|
||||
}
|
||||
|
||||
@Operation(summary = "获取分类列表")
|
||||
@GetMapping("/kb/categories")
|
||||
public R<List<Map<String, Object>>> kbCategories() {
|
||||
return R.ok(knowledgeBaseService.getCategories());
|
||||
}
|
||||
|
||||
@Operation(summary = "获取热门文章")
|
||||
@GetMapping("/kb/hot")
|
||||
public R<List<KbArticle>> kbHot(@RequestParam(defaultValue = "10") int limit) {
|
||||
return R.ok(knowledgeBaseService.getHot(limit));
|
||||
}
|
||||
|
||||
// ==================== 公告管理 ====================
|
||||
|
||||
@Operation(summary = "公告分页列表")
|
||||
@GetMapping("/announcement/list")
|
||||
public R<Page<Announcement>> announcementList(
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
@RequestParam(required = false) String type,
|
||||
@RequestParam(required = false) Integer status,
|
||||
@RequestParam(required = false) String keyword) {
|
||||
return R.ok(announcementService.list(page, size, type, status, keyword));
|
||||
}
|
||||
|
||||
@Operation(summary = "公告详情")
|
||||
@GetMapping("/announcement/{id}")
|
||||
public R<Announcement> announcementDetail(@PathVariable Long id) {
|
||||
return R.ok(announcementService.getDetail(id));
|
||||
}
|
||||
|
||||
@Operation(summary = "创建公告")
|
||||
@PostMapping("/announcement")
|
||||
public R<Announcement> announcementCreate(@RequestBody Announcement announcement) {
|
||||
return R.ok(announcementService.create(announcement));
|
||||
}
|
||||
|
||||
@Operation(summary = "更新公告")
|
||||
@PutMapping("/announcement/{id}")
|
||||
public R<String> announcementUpdate(@PathVariable Long id, @RequestBody Announcement announcement) {
|
||||
announcementService.update(id, announcement);
|
||||
return R.ok("更新成功");
|
||||
}
|
||||
|
||||
@Operation(summary = "发布公告")
|
||||
@PostMapping("/announcement/{id}/publish")
|
||||
public R<String> announcementPublish(@PathVariable Long id) {
|
||||
announcementService.publish(id);
|
||||
return R.ok("发布成功");
|
||||
}
|
||||
|
||||
@Operation(summary = "撤回公告")
|
||||
@PostMapping("/announcement/{id}/withdraw")
|
||||
public R<String> announcementWithdraw(@PathVariable Long id) {
|
||||
announcementService.withdraw(id);
|
||||
return R.ok("撤回成功");
|
||||
}
|
||||
|
||||
@Operation(summary = "删除公告")
|
||||
@DeleteMapping("/announcement/{id}")
|
||||
public R<String> announcementDelete(@PathVariable Long id) {
|
||||
announcementService.delete(id);
|
||||
return R.ok("删除成功");
|
||||
}
|
||||
|
||||
@Operation(summary = "获取当前生效公告")
|
||||
@GetMapping("/announcement/active")
|
||||
public R<List<Announcement>> activeAnnouncements(
|
||||
@RequestParam(required = false) String areaCode) {
|
||||
return R.ok(announcementService.getActiveAnnouncements(areaCode));
|
||||
}
|
||||
|
||||
@Operation(summary = "公告类型统计")
|
||||
@GetMapping("/announcement/stats")
|
||||
public R<List<Map<String, Object>>> announcementStats() {
|
||||
return R.ok(announcementService.statsByType());
|
||||
}
|
||||
|
||||
// ==================== KPI 看板 ====================
|
||||
|
||||
@Operation(summary = "获取KPI看板数据")
|
||||
@GetMapping("/kpi/dashboard")
|
||||
public R<KpiDashboard> kpiDashboard() {
|
||||
return R.ok(kpiService.getDashboard());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.water.revenue.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 公告实体(停水/水质/维修等)
|
||||
*/
|
||||
@Data
|
||||
@TableName("cs_announcement")
|
||||
public class Announcement {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 公告标题 */
|
||||
private String title;
|
||||
|
||||
/** 公告内容 */
|
||||
private String content;
|
||||
|
||||
/** 公告类型:water_outage-停水 water_quality-水质 maintenance-维修 other-其他 */
|
||||
private String type;
|
||||
|
||||
/** 影响范围描述 */
|
||||
private String affectedArea;
|
||||
|
||||
/** 影响区域编码(用于推送匹配) */
|
||||
private String areaCode;
|
||||
|
||||
/** 计划开始时间 */
|
||||
private LocalDateTime plannedStart;
|
||||
|
||||
/** 计划结束时间 */
|
||||
private LocalDateTime plannedEnd;
|
||||
|
||||
/** 发布时间 */
|
||||
private LocalDateTime publishTime;
|
||||
|
||||
/** 状态:0-草稿 1-已发布 2-已撤回 */
|
||||
private Integer status;
|
||||
|
||||
/** 优先级:low/medium/high/urgent */
|
||||
private String priority;
|
||||
|
||||
/** 发布人ID */
|
||||
private Long publisherId;
|
||||
|
||||
/** 发布人名称 */
|
||||
private String publisherName;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.water.revenue.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 知识库文章实体
|
||||
*/
|
||||
@Data
|
||||
@TableName("cs_kb_article")
|
||||
public class KbArticle {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 文章标题 */
|
||||
private String title;
|
||||
|
||||
/** 文章内容(Markdown) */
|
||||
private String content;
|
||||
|
||||
/** 摘要/简介 */
|
||||
private String summary;
|
||||
|
||||
/** 分类:FAQ/政策法规/操作指南/常见问题/通知公告 */
|
||||
private String category;
|
||||
|
||||
/** 标签,逗号分隔 */
|
||||
private String tags;
|
||||
|
||||
/** 浏览量 */
|
||||
private Integer viewCount;
|
||||
|
||||
/** 点赞数 */
|
||||
private Integer likeCount;
|
||||
|
||||
/** 排序权重 */
|
||||
private Integer sortOrder;
|
||||
|
||||
/** 状态:0-草稿 1-已发布 2-已归档 */
|
||||
private Integer status;
|
||||
|
||||
/** 作者ID */
|
||||
private Long authorId;
|
||||
|
||||
/** 作者名称 */
|
||||
private String authorName;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.water.revenue.entity;
|
||||
|
||||
import lombok.Data;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* KPI看板 VO(非持久化,聚合计算结果)
|
||||
*/
|
||||
@Data
|
||||
public class KpiDashboard {
|
||||
|
||||
/** 待处理工单量 */
|
||||
private Integer pendingWorkOrders;
|
||||
|
||||
/** 今日新增工单 */
|
||||
private Integer todayNewWorkOrders;
|
||||
|
||||
/** 本月解决工单数 */
|
||||
private Integer monthResolvedCount;
|
||||
|
||||
/** 本月新增工单数 */
|
||||
private Integer monthTotalCount;
|
||||
|
||||
/** 本月解决率 */
|
||||
private BigDecimal monthResolveRate;
|
||||
|
||||
/** 平均处理时效(小时) */
|
||||
private BigDecimal avgProcessHours;
|
||||
|
||||
/** 客户满意率(百分比) */
|
||||
private BigDecimal satisfactionRate;
|
||||
|
||||
/** 今日投诉数 */
|
||||
private Integer todayComplaints;
|
||||
|
||||
/** 今日报装数 */
|
||||
private Integer todayInstallations;
|
||||
|
||||
/** 7日工单趋势(日期->数量) */
|
||||
private List<Map<String, Object>> weeklyTrend;
|
||||
|
||||
/** 工单类型分布 */
|
||||
private List<Map<String, Object>> typeDistribution;
|
||||
|
||||
/** 处理时效排行(部门/人员) */
|
||||
private List<Map<String, Object>> efficiencyRank;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.water.revenue.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.water.revenue.entity.Announcement;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface AnnouncementMapper extends BaseMapper<Announcement> {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.water.revenue.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.water.revenue.entity.KbArticle;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface KbArticleMapper extends BaseMapper<KbArticle> {
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.water.revenue.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.water.revenue.entity.Announcement;
|
||||
import com.water.revenue.mapper.AnnouncementMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AnnouncementService {
|
||||
|
||||
private final AnnouncementMapper announcementMapper;
|
||||
|
||||
/**
|
||||
* 分页查询公告
|
||||
*/
|
||||
public Page<Announcement> list(int page, int size, String type, Integer status, String keyword) {
|
||||
LambdaQueryWrapper<Announcement> qw = new LambdaQueryWrapper<>();
|
||||
if (type != null && !type.isEmpty()) {
|
||||
qw.eq(Announcement::getType, type);
|
||||
}
|
||||
if (status != null) {
|
||||
qw.eq(Announcement::getStatus, status);
|
||||
}
|
||||
if (keyword != null && !keyword.isEmpty()) {
|
||||
qw.and(w -> w.like(Announcement::getTitle, keyword)
|
||||
.or().like(Announcement::getContent, keyword)
|
||||
.or().like(Announcement::getAffectedArea, keyword));
|
||||
}
|
||||
qw.orderByDesc(Announcement::getCreatedAt);
|
||||
return announcementMapper.selectPage(new Page<>(page, size), qw);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取公告详情
|
||||
*/
|
||||
public Announcement getDetail(Long id) {
|
||||
return announcementMapper.selectById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建公告(草稿)
|
||||
*/
|
||||
public Announcement create(Announcement announcement) {
|
||||
if (announcement.getStatus() == null) {
|
||||
announcement.setStatus(0); // 草稿
|
||||
}
|
||||
announcementMapper.insert(announcement);
|
||||
log.info("Announcement created: id={}, title={}, type={}",
|
||||
announcement.getId(), announcement.getTitle(), announcement.getType());
|
||||
return announcement;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新公告
|
||||
*/
|
||||
public void update(Long id, Announcement announcement) {
|
||||
announcement.setId(id);
|
||||
announcementMapper.updateById(announcement);
|
||||
log.info("Announcement updated: id={}", id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布公告
|
||||
*/
|
||||
public void publish(Long id) {
|
||||
Announcement a = new Announcement();
|
||||
a.setId(id);
|
||||
a.setStatus(1);
|
||||
a.setPublishTime(LocalDateTime.now());
|
||||
announcementMapper.updateById(a);
|
||||
log.info("Announcement published: id={}", id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤回公告
|
||||
*/
|
||||
public void withdraw(Long id) {
|
||||
Announcement a = new Announcement();
|
||||
a.setId(id);
|
||||
a.setStatus(2);
|
||||
announcementMapper.updateById(a);
|
||||
log.info("Announcement withdrawn: id={}", id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除公告(逻辑删除)
|
||||
*/
|
||||
public void delete(Long id) {
|
||||
announcementMapper.deleteById(id);
|
||||
log.info("Announcement deleted: id={}", id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前生效的公告(已发布且未过期)
|
||||
*/
|
||||
public List<Announcement> getActiveAnnouncements(String areaCode) {
|
||||
LambdaQueryWrapper<Announcement> qw = new LambdaQueryWrapper<>();
|
||||
qw.eq(Announcement::getStatus, 1);
|
||||
if (areaCode != null && !areaCode.isEmpty()) {
|
||||
qw.and(w -> w.like(Announcement::getAreaCode, areaCode)
|
||||
.or().isNull(Announcement::getAreaCode)
|
||||
.or().eq(Announcement::getAreaCode, ""));
|
||||
}
|
||||
qw.orderByDesc(Announcement::getPriority);
|
||||
qw.orderByDesc(Announcement::getPublishTime);
|
||||
return announcementMapper.selectList(qw);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按类型统计公告数量
|
||||
*/
|
||||
public List<Map<String, Object>> statsByType() {
|
||||
LambdaQueryWrapper<Announcement> qw = new LambdaQueryWrapper<>();
|
||||
qw.select(Announcement::getType);
|
||||
qw.eq(Announcement::getStatus, 1);
|
||||
List<Announcement> list = announcementMapper.selectList(qw);
|
||||
Map<String, Long> countMap = new LinkedHashMap<>();
|
||||
for (Announcement a : list) {
|
||||
countMap.put(a.getType(), countMap.getOrDefault(a.getType(), 0L) + 1);
|
||||
}
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
countMap.forEach((type, count) -> {
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put("type", type);
|
||||
m.put("count", count);
|
||||
result.add(m);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.water.revenue.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.water.revenue.entity.KbArticle;
|
||||
import com.water.revenue.mapper.KbArticleMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class KnowledgeBaseService {
|
||||
|
||||
private final KbArticleMapper kbArticleMapper;
|
||||
|
||||
/**
|
||||
* 分页搜索知识库文章
|
||||
*/
|
||||
public Page<KbArticle> search(int page, int size, String keyword, String category, Integer status) {
|
||||
LambdaQueryWrapper<KbArticle> qw = new LambdaQueryWrapper<>();
|
||||
if (keyword != null && !keyword.isEmpty()) {
|
||||
qw.and(w -> w.like(KbArticle::getTitle, keyword)
|
||||
.or().like(KbArticle::getContent, keyword)
|
||||
.or().like(KbArticle::getTags, keyword));
|
||||
}
|
||||
if (category != null && !category.isEmpty()) {
|
||||
qw.eq(KbArticle::getCategory, category);
|
||||
}
|
||||
if (status != null) {
|
||||
qw.eq(KbArticle::getStatus, status);
|
||||
}
|
||||
qw.orderByDesc(KbArticle::getSortOrder).orderByDesc(KbArticle::getCreatedAt);
|
||||
return kbArticleMapper.selectPage(new Page<>(page, size), qw);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文章详情并增加浏览量
|
||||
*/
|
||||
public KbArticle getDetail(Long id) {
|
||||
KbArticle article = kbArticleMapper.selectById(id);
|
||||
if (article != null) {
|
||||
// 浏览量+1
|
||||
LambdaUpdateWrapper<KbArticle> uw = new LambdaUpdateWrapper<>();
|
||||
uw.eq(KbArticle::getId, id)
|
||||
.setSql("view_count = COALESCE(view_count, 0) + 1");
|
||||
kbArticleMapper.update(null, uw);
|
||||
article.setViewCount(article.getViewCount() == null ? 1 : article.getViewCount() + 1);
|
||||
}
|
||||
return article;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文章
|
||||
*/
|
||||
public KbArticle create(KbArticle article) {
|
||||
if (article.getViewCount() == null) article.setViewCount(0);
|
||||
if (article.getLikeCount() == null) article.setLikeCount(0);
|
||||
if (article.getSortOrder() == null) article.setSortOrder(0);
|
||||
if (article.getStatus() == null) article.setStatus(0);
|
||||
kbArticleMapper.insert(article);
|
||||
log.info("KB article created: id={}, title={}", article.getId(), article.getTitle());
|
||||
return article;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新文章
|
||||
*/
|
||||
public void update(Long id, KbArticle article) {
|
||||
article.setId(id);
|
||||
kbArticleMapper.updateById(article);
|
||||
log.info("KB article updated: id={}", id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文章(逻辑删除)
|
||||
*/
|
||||
public void delete(Long id) {
|
||||
kbArticleMapper.deleteById(id);
|
||||
log.info("KB article deleted: id={}", id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 点赞文章
|
||||
*/
|
||||
public void like(Long id) {
|
||||
LambdaUpdateWrapper<KbArticle> uw = new LambdaUpdateWrapper<>();
|
||||
uw.eq(KbArticle::getId, id)
|
||||
.setSql("like_count = COALESCE(like_count, 0) + 1");
|
||||
kbArticleMapper.update(null, uw);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有分类及文章数
|
||||
*/
|
||||
public List<Map<String, Object>> getCategories() {
|
||||
LambdaQueryWrapper<KbArticle> qw = new LambdaQueryWrapper<>();
|
||||
qw.select(KbArticle::getCategory);
|
||||
qw.eq(KbArticle::getStatus, 1);
|
||||
List<KbArticle> articles = kbArticleMapper.selectList(qw);
|
||||
Map<String, Long> countMap = new LinkedHashMap<>();
|
||||
for (KbArticle a : articles) {
|
||||
String cat = a.getCategory();
|
||||
countMap.put(cat, countMap.getOrDefault(cat, 0L) + 1);
|
||||
}
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
countMap.forEach((cat, count) -> {
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put("category", cat);
|
||||
m.put("count", count);
|
||||
result.add(m);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取热门文章(按浏览量排序)
|
||||
*/
|
||||
public List<KbArticle> getHot(int limit) {
|
||||
LambdaQueryWrapper<KbArticle> qw = new LambdaQueryWrapper<>();
|
||||
qw.eq(KbArticle::getStatus, 1);
|
||||
qw.orderByDesc(KbArticle::getViewCount);
|
||||
qw.last("LIMIT " + limit);
|
||||
return kbArticleMapper.selectList(qw);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package com.water.revenue.service;
|
||||
|
||||
import com.water.revenue.entity.KpiDashboard;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class KpiService {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
/**
|
||||
* 获取 KPI 看板数据(聚合多表计算)
|
||||
*/
|
||||
public KpiDashboard getDashboard() {
|
||||
KpiDashboard kpi = new KpiDashboard();
|
||||
|
||||
// 1. 待处理工单量
|
||||
kpi.setPendingWorkOrders(getPendingWorkOrders());
|
||||
|
||||
// 2. 今日新增工单
|
||||
kpi.setTodayNewWorkOrders(getTodayNewWorkOrders());
|
||||
|
||||
// 3. 本月工单统计
|
||||
calculateMonthlyStats(kpi);
|
||||
|
||||
// 4. 平均处理时效(小时)
|
||||
kpi.setAvgProcessHours(getAvgProcessHours());
|
||||
|
||||
// 5. 客户满意率
|
||||
kpi.setSatisfactionRate(getSatisfactionRate());
|
||||
|
||||
// 6. 今日投诉数
|
||||
kpi.setTodayComplaints(getTodayCount("complaint"));
|
||||
|
||||
// 7. 今日报装数
|
||||
kpi.setTodayInstallations(getTodayInstallations());
|
||||
|
||||
// 8. 7日工单趋势
|
||||
kpi.setWeeklyTrend(getWeeklyTrend());
|
||||
|
||||
// 9. 工单类型分布
|
||||
kpi.setTypeDistribution(getTypeDistribution());
|
||||
|
||||
// 10. 处理时效排行
|
||||
kpi.setEfficiencyRank(getEfficiencyRank());
|
||||
|
||||
return kpi;
|
||||
}
|
||||
|
||||
private Integer getPendingWorkOrders() {
|
||||
try {
|
||||
// 从 patrol_task 表获取待处理任务
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM patrol_task WHERE status IN ('pending', 'in_progress')",
|
||||
Integer.class);
|
||||
return count != null ? count : 0;
|
||||
} catch (Exception e) {
|
||||
log.warn("获取待处理工单失败: {}", e.getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private Integer getTodayNewWorkOrders() {
|
||||
try {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM patrol_task WHERE task_date = CURRENT_DATE",
|
||||
Integer.class);
|
||||
return count != null ? count : 0;
|
||||
} catch (Exception e) {
|
||||
log.warn("获取今日新增工单失败: {}", e.getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void calculateMonthlyStats(KpiDashboard kpi) {
|
||||
try {
|
||||
// 本月总工单数
|
||||
Integer total = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM patrol_task WHERE task_date >= DATE_TRUNC('month', CURRENT_DATE)",
|
||||
Integer.class);
|
||||
kpi.setMonthTotalCount(total != null ? total : 0);
|
||||
|
||||
// 本月已解决工单数
|
||||
Integer resolved = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM patrol_task WHERE status = 'completed' " +
|
||||
"AND actual_end >= DATE_TRUNC('month', CURRENT_DATE)",
|
||||
Integer.class);
|
||||
kpi.setMonthResolvedCount(resolved != null ? resolved : 0);
|
||||
|
||||
// 解决率
|
||||
if (total != null && total > 0 && resolved != null) {
|
||||
BigDecimal rate = BigDecimal.valueOf(resolved)
|
||||
.multiply(BigDecimal.valueOf(100))
|
||||
.divide(BigDecimal.valueOf(total), 1, RoundingMode.HALF_UP);
|
||||
kpi.setMonthResolveRate(rate);
|
||||
} else {
|
||||
kpi.setMonthResolveRate(BigDecimal.ZERO);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("获取月度统计失败: {}", e.getMessage());
|
||||
kpi.setMonthTotalCount(0);
|
||||
kpi.setMonthResolvedCount(0);
|
||||
kpi.setMonthResolveRate(BigDecimal.ZERO);
|
||||
}
|
||||
}
|
||||
|
||||
private BigDecimal getAvgProcessHours() {
|
||||
try {
|
||||
// 计算已完成工单的平均处理时长
|
||||
Double avgHours = jdbcTemplate.queryForObject(
|
||||
"SELECT AVG(EXTRACT(EPOCH FROM (actual_end - task_date::timestamp)) / 3600.0) " +
|
||||
"FROM patrol_task WHERE status = 'completed' AND actual_end IS NOT NULL " +
|
||||
"AND actual_end >= DATE_TRUNC('month', CURRENT_DATE)",
|
||||
Double.class);
|
||||
return avgHours != null ?
|
||||
BigDecimal.valueOf(avgHours).setScale(1, RoundingMode.HALF_UP) :
|
||||
BigDecimal.ZERO;
|
||||
} catch (Exception e) {
|
||||
log.warn("获取平均处理时效失败: {}", e.getMessage());
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
}
|
||||
|
||||
private BigDecimal getSatisfactionRate() {
|
||||
try {
|
||||
// 模拟从评价数据计算满意率(实际项目中应从评价表获取)
|
||||
// 这里使用已完成工单占比作为近似
|
||||
Double rate = jdbcTemplate.queryForObject(
|
||||
"SELECT CASE WHEN COUNT(*) = 0 THEN 0 " +
|
||||
"ELSE (COUNT(CASE WHEN status = 'completed' THEN 1 END) * 100.0 / COUNT(*)) END " +
|
||||
"FROM patrol_task WHERE task_date >= CURRENT_DATE - 30",
|
||||
Double.class);
|
||||
return rate != null ?
|
||||
BigDecimal.valueOf(rate).setScale(1, RoundingMode.HALF_UP) :
|
||||
BigDecimal.valueOf(85.0);
|
||||
} catch (Exception e) {
|
||||
log.warn("获取满意率失败: {}", e.getMessage());
|
||||
return BigDecimal.valueOf(85.0);
|
||||
}
|
||||
}
|
||||
|
||||
private Integer getTodayCount(String type) {
|
||||
try {
|
||||
// 根据类型从对应表获取今日数量
|
||||
String table = "complaint".equals(type) ? "patrol_task" : "patrol_task";
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM " + table + " WHERE task_date = CURRENT_DATE",
|
||||
Integer.class);
|
||||
return count != null ? count : 0;
|
||||
} catch (Exception e) {
|
||||
log.warn("获取今日{}数失败: {}", type, e.getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private Integer getTodayInstallations() {
|
||||
try {
|
||||
// 从报装相关表获取(这里用 patrol_task 近似)
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM patrol_task WHERE task_date = CURRENT_DATE",
|
||||
Integer.class);
|
||||
return count != null ? count : 0;
|
||||
} catch (Exception e) {
|
||||
log.warn("获取今日报装数失败: {}", e.getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> getWeeklyTrend() {
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
try {
|
||||
LocalDate today = LocalDate.now();
|
||||
for (int i = 6; i >= 0; i--) {
|
||||
LocalDate date = today.minusDays(i);
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM patrol_task WHERE task_date = ?",
|
||||
Integer.class, date);
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put("date", date.toString());
|
||||
m.put("count", count != null ? count : 0);
|
||||
result.add(m);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("获取7日趋势失败: {}", e.getMessage());
|
||||
// 返回空数据占位
|
||||
for (int i = 6; i >= 0; i--) {
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put("date", LocalDate.now().minusDays(i).toString());
|
||||
m.put("count", 0);
|
||||
result.add(m);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> getTypeDistribution() {
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
try {
|
||||
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT status as type, COUNT(*) as count FROM patrol_task " +
|
||||
"WHERE task_date >= CURRENT_DATE - 30 GROUP BY status");
|
||||
result.addAll(rows);
|
||||
} catch (Exception e) {
|
||||
log.warn("获取类型分布失败: {}", e.getMessage());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> getEfficiencyRank() {
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
try {
|
||||
// 按处理时效排行(这里简化为按完成数量排行)
|
||||
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT COALESCE(assignee_id::text, 'unassigned') as name, " +
|
||||
"COUNT(*) as completed_count, " +
|
||||
"AVG(EXTRACT(EPOCH FROM (actual_end - task_date::timestamp)) / 3600.0) as avg_hours " +
|
||||
"FROM patrol_task WHERE status = 'completed' " +
|
||||
"AND actual_end >= DATE_TRUNC('month', CURRENT_DATE) " +
|
||||
"GROUP BY assignee_id ORDER BY avg_hours ASC LIMIT 10");
|
||||
result.addAll(rows);
|
||||
} catch (Exception e) {
|
||||
log.warn("获取时效排行失败: {}", e.getMessage());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
-- ============================================================
|
||||
-- V_cs_support.sql
|
||||
-- 客服支撑模块 DDL: 知识库 + 公告板
|
||||
-- ============================================================
|
||||
|
||||
-- 知识库文章表
|
||||
CREATE TABLE IF NOT EXISTS cs_kb_article (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
content TEXT,
|
||||
summary VARCHAR(500),
|
||||
category VARCHAR(50) NOT NULL DEFAULT 'FAQ',
|
||||
tags VARCHAR(200),
|
||||
view_count INT NOT NULL DEFAULT 0,
|
||||
like_count INT NOT NULL DEFAULT 0,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
status SMALLINT NOT NULL DEFAULT 0, -- 0草稿 1已发布 2已归档
|
||||
author_id BIGINT,
|
||||
author_name VARCHAR(50),
|
||||
deleted SMALLINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE cs_kb_article IS '知识库文章表';
|
||||
COMMENT ON COLUMN cs_kb_article.category IS '分类: FAQ/政策法规/操作指南/常见问题/通知公告';
|
||||
COMMENT ON COLUMN cs_kb_article.status IS '状态: 0-草稿 1-已发布 2-已归档';
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX IF NOT EXISTS idx_kb_article_category ON cs_kb_article (category);
|
||||
CREATE INDEX IF NOT EXISTS idx_kb_article_status ON cs_kb_article (status);
|
||||
CREATE INDEX IF NOT EXISTS idx_kb_article_title ON cs_kb_article USING gin (title gin_trgm_ops);
|
||||
|
||||
-- 公告表
|
||||
CREATE TABLE IF NOT EXISTS cs_announcement (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
content TEXT,
|
||||
type VARCHAR(30) NOT NULL DEFAULT 'other',
|
||||
affected_area VARCHAR(500),
|
||||
area_code VARCHAR(100),
|
||||
planned_start TIMESTAMP,
|
||||
planned_end TIMESTAMP,
|
||||
publish_time TIMESTAMP,
|
||||
status SMALLINT NOT NULL DEFAULT 0, -- 0草稿 1已发布 2已撤回
|
||||
priority VARCHAR(20) NOT NULL DEFAULT 'medium',
|
||||
publisher_id BIGINT,
|
||||
publisher_name VARCHAR(50),
|
||||
deleted SMALLINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE cs_announcement IS '公告表(停水/水质/维修等)';
|
||||
COMMENT ON COLUMN cs_announcement.type IS '类型: water_outage-停水 water_quality-水质 maintenance-维修 other-其他';
|
||||
COMMENT ON COLUMN cs_announcement.status IS '状态: 0-草稿 1-已发布 2-已撤回';
|
||||
COMMENT ON COLUMN cs_announcement.priority IS '优先级: low/medium/high/urgent';
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX IF NOT EXISTS idx_announcement_type ON cs_announcement (type);
|
||||
CREATE INDEX IF NOT EXISTS idx_announcement_status ON cs_announcement (status);
|
||||
CREATE INDEX IF NOT EXISTS idx_announcement_publish_time ON cs_announcement (publish_time);
|
||||
|
||||
-- 初始化数据:插入几条知识库示例文章
|
||||
INSERT INTO cs_kb_article (title, content, summary, category, tags, status, author_name) VALUES
|
||||
('如何办理用水报装?', '## 用水报装流程\n\n1. 准备材料:身份证、房产证、申请表\n2. 到营业厅提交申请\n3. 现场勘察\n4. 缴费\n5. 安装通水\n\n### 注意事项\n- 材料需原件+复印件\n- 3个工作日内完成勘察', '用水报装完整流程指南', '操作指南', '报装,申请,用水', 1, '系统管理员'),
|
||||
('水费缴费方式有哪些?', '## 缴费方式\n\n- **线上缴费**:微信公众号、支付宝、银行APP\n- **线下缴费**:营业厅、银行柜台\n- **代扣**:银行代扣(需签约)\n\n### 缴费时间\n每月1日-15日为正常缴费期', '水费缴费方式汇总', 'FAQ', '缴费,水费,支付', 1, '系统管理员'),
|
||||
('水质标准说明', '## 生活饮用水卫生标准\n\n执行GB5749-2006《生活饮用水卫生标准》\n\n### 常规检测指标\n- 浑浊度 ≤ 1 NTU\n- 余氯 ≥ 0.05mg/L\n- pH值 6.5-8.5', '国家水质标准介绍', '政策法规', '水质,标准,检测', 1, '系统管理员');
|
||||
|
||||
-- 插入公告示例
|
||||
INSERT INTO cs_announcement (title, content, type, affected_area, priority, status, publisher_name, publish_time) VALUES
|
||||
('城东片区计划停水通知', '因城东主干管维修,以下区域将于计划时间内停水:\n- 东湖路全线\n- 春晖小区\n- 东方花园\n\n请提前做好储水准备。', 'water_outage', '城东片区', 'high', 1, '系统管理员', NOW()),
|
||||
('水质检测报告公示', '2024年1月出厂水及管网水检测结果均符合GB5749-2006标准,合格率100%。', 'water_quality', '全城', 'medium', 1, '系统管理员', NOW());
|
||||
@@ -0,0 +1,245 @@
|
||||
package com.water.revenue;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.water.revenue.entity.Announcement;
|
||||
import com.water.revenue.entity.KbArticle;
|
||||
import com.water.revenue.entity.KpiDashboard;
|
||||
import com.water.revenue.mapper.AnnouncementMapper;
|
||||
import com.water.revenue.mapper.KbArticleMapper;
|
||||
import com.water.revenue.service.AnnouncementService;
|
||||
import com.water.revenue.service.KnowledgeBaseService;
|
||||
import com.water.revenue.service.KpiService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CsSupportServiceTest {
|
||||
|
||||
@Mock
|
||||
private KbArticleMapper kbArticleMapper;
|
||||
|
||||
@Mock
|
||||
private AnnouncementMapper announcementMapper;
|
||||
|
||||
@Mock
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
private KnowledgeBaseService knowledgeBaseService;
|
||||
private AnnouncementService announcementService;
|
||||
private KpiService kpiService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
knowledgeBaseService = new KnowledgeBaseService(kbArticleMapper);
|
||||
announcementService = new AnnouncementService(announcementMapper);
|
||||
kpiService = new KpiService(jdbcTemplate);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("知识库服务测试")
|
||||
class KnowledgeBaseTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("搜索知识库 - 无过滤条件")
|
||||
void search_noFilters_returnsPage() {
|
||||
Page<KbArticle> mockPage = new Page<>(1, 10);
|
||||
mockPage.setRecords(List.of(createArticle(1L, "测试文章")));
|
||||
mockPage.setTotal(1);
|
||||
when(kbArticleMapper.selectPage(any(Page.class), any(LambdaQueryWrapper.class)))
|
||||
.thenReturn(mockPage);
|
||||
|
||||
Page<KbArticle> result = knowledgeBaseService.search(1, 10, null, null, null);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(1, result.getRecords().size());
|
||||
verify(kbArticleMapper).selectPage(any(Page.class), any(LambdaQueryWrapper.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("创建文章 - 设置默认值")
|
||||
void create_setsDefaults() {
|
||||
KbArticle article = new KbArticle();
|
||||
article.setTitle("新文章");
|
||||
article.setContent("内容");
|
||||
article.setCategory("FAQ");
|
||||
|
||||
when(kbArticleMapper.insert(any(KbArticle.class))).thenReturn(1);
|
||||
|
||||
KbArticle result = knowledgeBaseService.create(article);
|
||||
|
||||
assertEquals(0, result.getViewCount());
|
||||
assertEquals(0, result.getLikeCount());
|
||||
assertEquals(0, result.getSortOrder());
|
||||
assertEquals(0, result.getStatus());
|
||||
verify(kbArticleMapper).insert(article);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("获取详情 - 浏览量+1")
|
||||
void getDetail_incrementsViewCount() {
|
||||
KbArticle article = createArticle(1L, "测试");
|
||||
article.setViewCount(5);
|
||||
when(kbArticleMapper.selectById(1L)).thenReturn(article);
|
||||
when(kbArticleMapper.update(isNull(), any())).thenReturn(1);
|
||||
|
||||
KbArticle result = knowledgeBaseService.getDetail(1L);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(6, result.getViewCount());
|
||||
verify(kbArticleMapper).update(isNull(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("点赞文章")
|
||||
void like_incrementsLikeCount() {
|
||||
when(kbArticleMapper.update(isNull(), any())).thenReturn(1);
|
||||
|
||||
knowledgeBaseService.like(1L);
|
||||
|
||||
verify(kbArticleMapper).update(isNull(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("删除文章")
|
||||
void delete_callsMapper() {
|
||||
when(kbArticleMapper.deleteById(1L)).thenReturn(1);
|
||||
|
||||
knowledgeBaseService.delete(1L);
|
||||
|
||||
verify(kbArticleMapper).deleteById(1L);
|
||||
}
|
||||
|
||||
private KbArticle createArticle(Long id, String title) {
|
||||
KbArticle a = new KbArticle();
|
||||
a.setId(id);
|
||||
a.setTitle(title);
|
||||
a.setCategory("FAQ");
|
||||
a.setStatus(1);
|
||||
a.setViewCount(0);
|
||||
a.setLikeCount(0);
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("公告服务测试")
|
||||
class AnnouncementTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("创建公告 - 默认草稿状态")
|
||||
void create_defaultDraft() {
|
||||
Announcement a = new Announcement();
|
||||
a.setTitle("停水通知");
|
||||
a.setType("water_outage");
|
||||
|
||||
when(announcementMapper.insert(any(Announcement.class))).thenReturn(1);
|
||||
|
||||
Announcement result = announcementService.create(a);
|
||||
|
||||
assertEquals(0, result.getStatus());
|
||||
verify(announcementMapper).insert(a);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("发布公告 - 状态变为1")
|
||||
void publish_setsStatus1() {
|
||||
when(announcementMapper.updateById(any(Announcement.class))).thenReturn(1);
|
||||
|
||||
announcementService.publish(1L);
|
||||
|
||||
verify(announcementMapper).updateById(argThat(a ->
|
||||
a.getStatus() == 1 && a.getPublishTime() != null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("撤回公告 - 状态变为2")
|
||||
void withdraw_setsStatus2() {
|
||||
when(announcementMapper.updateById(any(Announcement.class))).thenReturn(1);
|
||||
|
||||
announcementService.withdraw(1L);
|
||||
|
||||
verify(announcementMapper).updateById(argThat(a -> a.getStatus() == 2));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("分页查询 - 按类型过滤")
|
||||
void list_filterByType() {
|
||||
Page<Announcement> mockPage = new Page<>(1, 10);
|
||||
mockPage.setRecords(List.of());
|
||||
when(announcementMapper.selectPage(any(Page.class), any(LambdaQueryWrapper.class)))
|
||||
.thenReturn(mockPage);
|
||||
|
||||
Page<Announcement> result = announcementService.list(1, 10, "water_outage", null, null);
|
||||
|
||||
assertNotNull(result);
|
||||
verify(announcementMapper).selectPage(any(Page.class), any(LambdaQueryWrapper.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("删除公告")
|
||||
void delete_callsMapper() {
|
||||
when(announcementMapper.deleteById(1L)).thenReturn(1);
|
||||
|
||||
announcementService.delete(1L);
|
||||
|
||||
verify(announcementMapper).deleteById(1L);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("KPI 服务测试")
|
||||
class KpiTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("获取看板数据 - 数据库正常")
|
||||
void getDashboard_normalData() {
|
||||
when(jdbcTemplate.queryForObject(contains("pending"), eq(Integer.class))).thenReturn(5);
|
||||
when(jdbcTemplate.queryForObject(contains("CURRENT_DATE"), eq(Integer.class))).thenReturn(3);
|
||||
when(jdbcTemplate.queryForObject(contains("DATE_TRUNC"), eq(Integer.class))).thenReturn(20);
|
||||
when(jdbcTemplate.queryForObject(contains("status = 'completed'"), eq(Integer.class))).thenReturn(15);
|
||||
when(jdbcTemplate.queryForObject(contains("AVG"), eq(Double.class))).thenReturn(4.5);
|
||||
when(jdbcTemplate.queryForObject(contains("CASE WHEN"), eq(Double.class))).thenReturn(85.0);
|
||||
when(jdbcTemplate.queryForList(contains("GROUP BY status"))).thenReturn(List.of());
|
||||
when(jdbcTemplate.queryForList(contains("ORDER BY avg_hours"))).thenReturn(List.of());
|
||||
|
||||
KpiDashboard kpi = kpiService.getDashboard();
|
||||
|
||||
assertNotNull(kpi);
|
||||
assertEquals(5, kpi.getPendingWorkOrders());
|
||||
assertEquals(3, kpi.getTodayNewWorkOrders());
|
||||
assertNotNull(kpi.getWeeklyTrend());
|
||||
assertEquals(7, kpi.getWeeklyTrend().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("获取看板数据 - 数据库异常返回默认值")
|
||||
void getDashboard_dbError_returnsDefaults() {
|
||||
when(jdbcTemplate.queryForObject(anyString(), eq(Integer.class)))
|
||||
.thenThrow(new RuntimeException("DB error"));
|
||||
when(jdbcTemplate.queryForObject(anyString(), eq(Double.class)))
|
||||
.thenThrow(new RuntimeException("DB error"));
|
||||
when(jdbcTemplate.queryForList(anyString()))
|
||||
.thenThrow(new RuntimeException("DB error"));
|
||||
|
||||
KpiDashboard kpi = kpiService.getDashboard();
|
||||
|
||||
assertNotNull(kpi);
|
||||
assertEquals(0, kpi.getPendingWorkOrders());
|
||||
assertNotNull(kpi.getWeeklyTrend());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user