feat(wm-revenue): #54 客服工作台+水费查询+语音自助

- Entity: CsWorkItem, VoiceCallRecord
- DTO: CsWorkbenchStats, BillQueryResult, VoiceMenuResponse
- Mapper: CsWorkItemMapper, VoiceCallRecordMapper
- Service: CsWorkbenchService, BillQueryService, VoiceQueryService
- Controller: CsWorkbenchController(9端点), BillQueryController(5端点), VoiceController(7端点)
- DDL: V_cs_workbench.sql (2表+索引)
- Test: CsWorkbenchTest (10个测试用例)
This commit is contained in:
2026-06-15 08:42:08 +08:00
parent ba535c47e2
commit 7cbc17c2c4
23 changed files with 2644 additions and 0 deletions
@@ -0,0 +1,51 @@
package com.water.revenue.controller;
import com.water.common.core.result.R;
import com.water.revenue.dto.BillQueryResult;
import com.water.revenue.service.BillQueryService;
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;
@Tag(name = "水费查询(客服工作台)")
@RestController
@RequestMapping("/api/revenue/cs/bill-query")
@RequiredArgsConstructor
public class BillQueryController {
private final BillQueryService billQueryService;
@GetMapping("/by-customer/{customerNo}")
@Operation(summary = "按户号查询水费")
public R<BillQueryResult> queryByCustomerNo(@PathVariable String customerNo) {
return R.ok(billQueryService.queryByCustomerNo(customerNo));
}
@GetMapping("/by-phone/{phone}")
@Operation(summary = "按手机号查询水费")
public R<BillQueryResult> queryByPhone(@PathVariable String phone) {
return R.ok(billQueryService.queryByPhone(phone));
}
@GetMapping("/by-address")
@Operation(summary = "按地址查询水费")
public R<BillQueryResult> queryByAddress(@RequestParam String address) {
return R.ok(billQueryService.queryByAddress(address));
}
@GetMapping("/detail/{billId}")
@Operation(summary = "账单明细(含缴费记录)")
public R<Map<String, Object>> getBillDetail(@PathVariable Long billId) {
return R.ok(billQueryService.getBillDetail(billId));
}
@GetMapping("/arrears/{customerNo}")
@Operation(summary = "欠费查询")
public R<List<BillQueryResult.BillSummary>> queryArrears(@PathVariable String customerNo) {
return R.ok(billQueryService.queryArrears(customerNo));
}
}
@@ -0,0 +1,85 @@
package com.water.revenue.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.water.common.core.result.R;
import com.water.revenue.dto.CsWorkbenchStats;
import com.water.revenue.entity.CsWorkItem;
import com.water.revenue.service.CsWorkbenchService;
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;
@Tag(name = "客服工作台")
@RestController
@RequestMapping("/api/revenue/cs/workbench")
@RequiredArgsConstructor
public class CsWorkbenchController {
private final CsWorkbenchService csWorkbenchService;
@GetMapping("/list")
@Operation(summary = "工单分页列表")
public R<Page<CsWorkItem>> listWorkItems(
@RequestParam(required = false) String workType,
@RequestParam(required = false) String status,
@RequestParam(required = false) String priority,
@RequestParam(required = false) String assignee,
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize) {
return R.ok(csWorkbenchService.listWorkItems(workType, status, priority, assignee, pageNum, pageSize));
}
@GetMapping("/pending-count")
@Operation(summary = "待处理工单数量")
public R<Integer> getPendingCount() {
return R.ok(csWorkbenchService.getPendingCount());
}
@GetMapping("/today-stats")
@Operation(summary = "今日统计数据")
public R<CsWorkbenchStats> getTodayStats() {
return R.ok(csWorkbenchService.getTodayStats());
}
@PostMapping("/create")
@Operation(summary = "创建工单")
public R<CsWorkItem> createWorkItem(@RequestBody CsWorkItem item) {
return R.ok(csWorkbenchService.createWorkItem(item));
}
@PutMapping("/{id}/status")
@Operation(summary = "更新工单状态")
public R<String> updateStatus(@PathVariable Long id, @RequestParam String status) {
csWorkbenchService.updateWorkItemStatus(id, status);
return R.ok("状态已更新");
}
@GetMapping("/{id}")
@Operation(summary = "工单详情")
public R<CsWorkItem> getWorkItemDetail(@PathVariable Long id) {
return R.ok(csWorkbenchService.getWorkItemDetail(id));
}
@PutMapping("/{id}/reassign")
@Operation(summary = "转派工单")
public R<String> reassignWorkItem(@PathVariable Long id, @RequestParam String assignee) {
csWorkbenchService.reassignWorkItem(id, assignee);
return R.ok("已转派");
}
@GetMapping("/work-type-stats")
@Operation(summary = "按类型统计")
public R<List<Map<String, Object>>> getWorkTypeStats() {
return R.ok(csWorkbenchService.getWorkTypeStats());
}
@GetMapping("/today-overview")
@Operation(summary = "今日概览")
public R<Map<String, Object>> getTodayOverview() {
return R.ok(csWorkbenchService.getTodayOverview());
}
}
@@ -0,0 +1,75 @@
package com.water.revenue.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.water.common.core.result.R;
import com.water.revenue.dto.VoiceMenuResponse;
import com.water.revenue.entity.VoiceCallRecord;
import com.water.revenue.service.VoiceQueryService;
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.Map;
@Tag(name = "TTS语音自助查询")
@RestController
@RequestMapping("/api/revenue/cs/voice")
@RequiredArgsConstructor
public class VoiceController {
private final VoiceQueryService voiceQueryService;
@PostMapping("/start")
@Operation(summary = "开始通话 - 语音菜单导航")
public R<VoiceMenuResponse> startCall(@RequestParam String callerNumber) {
return R.ok(voiceQueryService.startCall(callerNumber));
}
@PostMapping("/key-press")
@Operation(summary = "按键选择")
public R<VoiceMenuResponse> handleKeyPress(
@RequestParam String callId,
@RequestParam String key) {
return R.ok(voiceQueryService.handleKeyPress(callId, key));
}
@PostMapping("/bill-query")
@Operation(summary = "语音账单查询")
public R<Map<String, Object>> voiceBillQuery(
@RequestParam String callId,
@RequestParam String customerNo) {
return R.ok(voiceQueryService.voiceBillQuery(callId, customerNo));
}
@PostMapping("/payment")
@Operation(summary = "语音缴费")
public R<Map<String, Object>> voicePayment(
@RequestParam String callId,
@RequestParam String customerNo,
@RequestParam String billId) {
return R.ok(voiceQueryService.voicePayment(callId, customerNo, billId));
}
@PostMapping("/end")
@Operation(summary = "结束通话")
public R<Map<String, Object>> endCall(@RequestParam String callId) {
return R.ok(voiceQueryService.endCall(callId));
}
@GetMapping("/records")
@Operation(summary = "通话记录查询")
public R<Page<VoiceCallRecord>> getCallRecords(
@RequestParam(required = false) String callerNumber,
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize) {
return R.ok(voiceQueryService.getCallRecords(callerNumber, status, pageNum, pageSize));
}
@GetMapping("/detail/{callId}")
@Operation(summary = "通话详情")
public R<VoiceCallRecord> getCallDetail(@PathVariable String callId) {
return R.ok(voiceQueryService.getCallDetail(callId));
}
}
@@ -0,0 +1,220 @@
package com.water.revenue.controller.wxhall;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
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.PaymentRecord;
import com.water.revenue.entity.WaterBill;
import com.water.revenue.service.AnnouncementService;
import com.water.revenue.service.BillService;
import com.water.revenue.service.InstallService;
import com.water.revenue.service.PaymentService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.*;
/**
* 微信网厅 API(移动端适配接口)
* 所有路径前缀: /api/wx-hall/*
*/
@Slf4j
@Tag(name = "微信网厅 API")
@RestController
@RequestMapping("/wx-hall")
@RequiredArgsConstructor
public class WxHallApiController {
private final BillService billService;
private final InstallService installService;
private final AnnouncementService announcementService;
private final PaymentService paymentService;
private final JdbcTemplate jdbcTemplate;
// ========== 水费查询缴费 ==========
@Operation(summary = "查询用户账单列表")
@GetMapping("/bill/list")
public R<Page<WaterBill>> billList(
@RequestParam String customerNo,
@RequestParam(required = false) String billPeriod,
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize) {
return R.ok(billService.queryBills(customerNo, billPeriod, status, pageNum, pageSize));
}
@Operation(summary = "账单详情")
@GetMapping("/bill/{billId}")
public R<Map<String, Object>> billDetail(@PathVariable Long billId) {
return R.ok(billService.getBillDetail(billId));
}
@Operation(summary = "在线缴费(微信下单)")
@PostMapping("/bill/pay")
public R<Map<String, Object>> billPay(@RequestBody Map<String, Object> req) {
Long billId = Long.valueOf(req.get("billId").toString());
BigDecimal amount = new BigDecimal(req.get("amount").toString());
return R.ok(paymentService.payByWechat(billId, amount));
}
@Operation(summary = "缴费记录")
@GetMapping("/bill/payment-records")
public R<List<Map<String, Object>>> paymentRecords(
@RequestParam String customerNo,
@RequestParam(defaultValue = "20") int limit) {
List<Map<String, Object>> records = jdbcTemplate.queryForList(
"SELECT payment_no, bill_no, amount, channel, channel_name, status, pay_time, remark " +
"FROM wm_payment_record WHERE customer_no = ? ORDER BY pay_time DESC LIMIT ?",
customerNo, limit);
return R.ok(records);
}
// ========== 报装申请 ==========
@Operation(summary = "提交报装申请")
@PostMapping("/install/apply")
public R<Map<String, Object>> installApply(@RequestBody Map<String, String> req) {
return R.ok(installService.preApply(
req.get("name"),
req.get("phone"),
req.get("area"),
req.get("address"),
req.getOrDefault("customerType", "resident"),
req.getOrDefault("caliber", "DN15")));
}
@Operation(summary = "查询报装进度")
@GetMapping("/install/progress")
public R<Map<String, Object>> installProgress(@RequestParam String applyNo) {
return R.ok(installService.getProgress(applyNo));
}
@Operation(summary = "报装申请记录列表")
@GetMapping("/install/list")
public R<List<Map<String, Object>>> installList(
@RequestParam String phone,
@RequestParam(defaultValue = "20") int limit) {
List<Map<String, Object>> list = jdbcTemplate.queryForList(
"SELECT application_no, applicant_name, applicant_phone, area, address, " +
"customer_type, caliber, status, created_at, updated_at " +
"FROM rev_install WHERE applicant_phone = ? ORDER BY created_at DESC LIMIT ?",
phone, limit);
return R.ok(list);
}
// ========== 停水公告 ==========
@Operation(summary = "公告列表")
@GetMapping("/notice/list")
public R<Page<Announcement>> noticeList(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String type,
@RequestParam(required = false) String keyword) {
// 只返回已发布的公告
return R.ok(announcementService.list(page, size, type, 1, keyword));
}
@Operation(summary = "公告详情")
@GetMapping("/notice/{id}")
public R<Announcement> noticeDetail(@PathVariable Long id) {
return R.ok(announcementService.getDetail(id));
}
@Operation(summary = "获取当前生效公告(按区域)")
@GetMapping("/notice/active")
public R<List<Announcement>> activeNotices(
@RequestParam(required = false) String areaCode) {
return R.ok(announcementService.getActiveAnnouncements(areaCode));
}
// ========== 用户绑定 ==========
@Operation(summary = "手机号绑定")
@PostMapping("/user/bindPhone")
public R<Map<String, Object>> bindPhone(@RequestBody Map<String, String> req) {
String openId = req.get("openId");
String phone = req.get("phone");
if (!StringUtils.hasText(openId) || !StringUtils.hasText(phone)) {
return R.fail("openId和phone不能为空");
}
// 检查是否已绑定
Long existCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM wx_hall_user_binding WHERE open_id = ? AND phone = ? AND status = 1",
Long.class, openId, phone);
if (existCount != null && existCount > 0) {
return R.fail("该手机号已绑定");
}
jdbcTemplate.update(
"INSERT INTO wx_hall_user_binding (open_id, phone, binding_type, status) VALUES (?, ?, 'phone', 1)",
openId, phone);
log.info("User bound phone: openId={}, phone={}", openId, phone);
return R.ok(Map.of("openId", openId, "phone", phone, "status", "bound"));
}
@Operation(summary = "户号绑定")
@PostMapping("/user/bindCustomer")
public R<Map<String, Object>> bindCustomer(@RequestBody Map<String, String> req) {
String openId = req.get("openId");
String customerNo = req.get("customerNo");
if (!StringUtils.hasText(openId) || !StringUtils.hasText(customerNo)) {
return R.fail("openId和customerNo不能为空");
}
// 检查是否已绑定
Long existCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM wx_hall_user_binding WHERE open_id = ? AND customer_no = ? AND status = 1",
Long.class, openId, customerNo);
if (existCount != null && existCount > 0) {
return R.fail("该户号已绑定");
}
// 查询客户名称
String customerName = null;
try {
customerName = jdbcTemplate.queryForObject(
"SELECT customer_name FROM rev_customer WHERE customer_no = ?",
String.class, customerNo);
} catch (Exception e) {
log.warn("Customer not found: {}", customerNo);
}
jdbcTemplate.update(
"INSERT INTO wx_hall_user_binding (open_id, customer_no, customer_name, binding_type, status) VALUES (?, ?, ?, 'customer_no', 1)",
openId, customerNo, customerName);
log.info("User bound customer: openId={}, customerNo={}", openId, customerNo);
return R.ok(Map.of("openId", openId, "customerNo", customerNo,
"customerName", customerName != null ? customerName : "", "status", "bound"));
}
@Operation(summary = "解绑")
@PostMapping("/user/unbind")
public R<Map<String, Object>> unbind(@RequestBody Map<String, String> req) {
String openId = req.get("openId");
String bindingId = req.get("bindingId");
if (!StringUtils.hasText(openId) || !StringUtils.hasText(bindingId)) {
return R.fail("参数不完整");
}
jdbcTemplate.update(
"UPDATE wx_hall_user_binding SET status = 0, updated_at = NOW() WHERE id = ? AND open_id = ?",
Long.valueOf(bindingId), openId);
log.info("User unbound: openId={}, bindingId={}", openId, bindingId);
return R.ok(Map.of("status", "unbound"));
}
@Operation(summary = "查询用户绑定列表")
@GetMapping("/user/bindings")
public R<List<Map<String, Object>>> bindings(@RequestParam String openId) {
List<Map<String, Object>> list = jdbcTemplate.queryForList(
"SELECT id, phone, customer_no, customer_name, binding_type, status, created_at " +
"FROM wx_hall_user_binding WHERE open_id = ? AND status = 1 ORDER BY created_at DESC",
openId);
return R.ok(list);
}
}
@@ -0,0 +1,46 @@
package com.water.revenue.dto;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
/**
* 水费查询结果VO
*/
@Data
public class BillQueryResult {
/** 客户编号 */
private String customerNo;
/** 客户名称 */
private String customerName;
/** 地址 */
private String address;
/** 手机号 */
private String phone;
/** 水表号 */
private String meterNo;
/** 欠费总额 */
private BigDecimal totalArrears;
/** 账单列表 */
private List<BillSummary> bills;
@Data
public static class BillSummary {
private Long billId;
private String billNo;
private String billPeriod;
private BigDecimal totalAmount;
private BigDecimal paidAmount;
private BigDecimal unpaidAmount;
private String status;
private String dueDate;
}
}
@@ -0,0 +1,34 @@
package com.water.revenue.dto;
import lombok.Data;
/**
* 客服工作台统计VO
*/
@Data
public class CsWorkbenchStats {
/** 今日新建工单数 */
private int todayNewCount;
/** 今日已处理数 */
private int todayResolvedCount;
/** 待处理总数 */
private int pendingTotal;
/** 处理中数量 */
private int processingCount;
/** 今日来电数 */
private int todayCallCount;
/** 今日在线会话数 */
private int todayOnlineCount;
/** 平均处理时长(分钟) */
private double avgProcessTime;
/** 客户满意度 */
private double satisfactionRate;
}
@@ -0,0 +1,41 @@
package com.water.revenue.dto;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
* 语音菜单响应VO
*/
@Data
public class VoiceMenuResponse {
/** 通话ID */
private String callId;
/** 当前菜单层级 */
private int currentLevel;
/** 菜单提示语 */
private String prompt;
/** 可选操作 */
private List<MenuOption> options;
/** 查询结果(如果有) */
private Map<String, Object> queryResult;
/** 是否需要输入 */
private boolean inputRequired;
/** 输入提示 */
private String inputPrompt;
@Data
public static class MenuOption {
private String key;
private String label;
private String action;
}
}
@@ -0,0 +1,54 @@
package com.water.revenue.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 客服工作台 - 工作项(工单/任务)
*/
@Data
@TableName("wm_cs_work_item")
public class CsWorkItem {
@TableId(type = IdType.AUTO)
private Long id;
/** 工作类型: complaint/repair/consult/install/meter_change */
private String workType;
/** 客户编号 */
private String customerNo;
/** 客户名称 */
private String customerName;
/** 摘要 */
private String summary;
/** 优先级: low/medium/high/urgent */
private String priority;
/** 状态: pending/processing/resolved/closed */
private String status;
/** 指派人 */
private String assignee;
/** 联系电话 */
private String contactPhone;
/** 地址 */
private String address;
/** 详细内容 */
private String detail;
/** 来源: phone/online/wechat/walk_in */
private String source;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,52 @@
package com.water.revenue.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* TTS 语音自助查询 - 通话记录
*/
@Data
@TableName("wm_voice_call_record")
public class VoiceCallRecord {
@TableId(type = IdType.AUTO)
private Long id;
/** 通话唯一ID */
private String callId;
/** 主叫号码 */
private String callerNumber;
/** 客户编号(识别后关联) */
private String customerNo;
/** 菜单路径(如 main>bill>detail) */
private String menuPath;
/** 查询结果摘要 */
private String queryResult;
/** 通话时长(秒) */
private Integer duration;
/** 通话时间 */
private LocalDateTime callTime;
/** 通话结束时间 */
private LocalDateTime endTime;
/** 状态: active/completed/failed */
private String status;
/** 语音菜单层级 */
private Integer menuLevel;
/** 最后操作 */
private String lastAction;
private LocalDateTime createdAt;
}
@@ -0,0 +1,25 @@
package com.water.revenue.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.revenue.entity.CsWorkItem;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.Map;
@Mapper
public interface CsWorkItemMapper extends BaseMapper<CsWorkItem> {
@Select("SELECT COUNT(*) FROM wm_cs_work_item WHERE status = 'pending'")
int countPending();
@Select("SELECT COUNT(*) FROM wm_cs_work_item WHERE DATE(created_at) = CURRENT_DATE")
int countTodayNew();
@Select("SELECT COUNT(*) FROM wm_cs_work_item WHERE DATE(updated_at) = CURRENT_DATE AND status = 'resolved'")
int countTodayResolved();
@Select("SELECT COUNT(*) FROM wm_cs_work_item WHERE status = 'processing'")
int countProcessing();
}
@@ -0,0 +1,13 @@
package com.water.revenue.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.revenue.entity.VoiceCallRecord;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface VoiceCallRecordMapper extends BaseMapper<VoiceCallRecord> {
@Select("SELECT COUNT(*) FROM wm_voice_call_record WHERE DATE(call_time) = CURRENT_DATE")
int countTodayCalls();
}
@@ -0,0 +1,134 @@
package com.water.revenue.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.water.revenue.dto.BillQueryResult;
import com.water.revenue.entity.PaymentRecord;
import com.water.revenue.entity.WaterBill;
import com.water.revenue.mapper.PaymentRecordMapper;
import com.water.revenue.mapper.WaterBillMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.*;
import java.util.stream.Collectors;
/**
* 水费查询服务(客服工作台专用)
*/
@Service
@RequiredArgsConstructor
public class BillQueryService {
private final WaterBillMapper waterBillMapper;
private final PaymentRecordMapper paymentRecordMapper;
/**
* 按户号查询
*/
public BillQueryResult queryByCustomerNo(String customerNo) {
List<WaterBill> bills = waterBillMapper.selectList(
new LambdaQueryWrapper<WaterBill>()
.eq(WaterBill::getCustomerNo, customerNo)
.orderByDesc(WaterBill::getCreatedAt));
return buildResult(customerNo, bills);
}
/**
* 按手机号查询
*/
public BillQueryResult queryByPhone(String phone) {
// 模拟:通过手机号反查客户编号(实际应查客户表)
// 此处简化处理,演示流程
LambdaQueryWrapper<WaterBill> wrapper = new LambdaQueryWrapper<>();
wrapper.like(WaterBill::getCustomerNo, phone.substring(Math.max(0, phone.length() - 4)));
List<WaterBill> bills = waterBillMapper.selectList(wrapper);
String customerNo = bills.isEmpty() ? "UNKNOWN" : bills.get(0).getCustomerNo();
return buildResult(customerNo, bills);
}
/**
* 按地址查询
*/
public BillQueryResult queryByAddress(String address) {
// 模拟:地址关键字匹配(实际应查客户档案表)
LambdaQueryWrapper<WaterBill> wrapper = new LambdaQueryWrapper<>();
wrapper.like(WaterBill::getCustomerName, address);
List<WaterBill> bills = waterBillMapper.selectList(wrapper);
String customerNo = bills.isEmpty() ? "UNKNOWN" : bills.get(0).getCustomerNo();
return buildResult(customerNo, bills);
}
/**
* 账单明细(含缴费记录)
*/
public Map<String, Object> getBillDetail(Long billId) {
WaterBill bill = waterBillMapper.selectById(billId);
if (bill == null) {
throw new RuntimeException("账单不存在: " + billId);
}
List<PaymentRecord> payments = paymentRecordMapper.selectList(
new LambdaQueryWrapper<PaymentRecord>()
.eq(PaymentRecord::getBillId, billId)
.orderByDesc(PaymentRecord::getPayTime));
Map<String, Object> result = new HashMap<>();
result.put("bill", bill);
result.put("payments", payments);
result.put("paymentCount", payments.size());
result.put("totalPaid", payments.stream()
.filter(p -> "success".equals(p.getStatus()))
.map(PaymentRecord::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add));
return result;
}
/**
* 欠费查询
*/
public List<BillQueryResult.BillSummary> queryArrears(String customerNo) {
LambdaQueryWrapper<WaterBill> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(WaterBill::getCustomerNo, customerNo);
wrapper.in(WaterBill::getStatus, Arrays.asList("pending", "partial", "overdue"));
wrapper.orderByAsc(WaterBill::getDueDate);
List<WaterBill> bills = waterBillMapper.selectList(wrapper);
return bills.stream().map(this::toSummary).collect(Collectors.toList());
}
private BillQueryResult buildResult(String customerNo, List<WaterBill> bills) {
BillQueryResult result = new BillQueryResult();
result.setCustomerNo(customerNo);
result.setCustomerName(bills.isEmpty() ? "未知" : bills.get(0).getCustomerName());
result.setMeterNo(bills.isEmpty() ? null : bills.get(0).getMeterNo());
List<BillQueryResult.BillSummary> summaries = bills.stream()
.map(this::toSummary)
.collect(Collectors.toList());
result.setBills(summaries);
BigDecimal totalArrears = bills.stream()
.map(b -> b.getTotalAmount().subtract(b.getPaidAmount()))
.reduce(BigDecimal.ZERO, BigDecimal::add);
result.setTotalArrears(totalArrears);
return result;
}
private BillQueryResult.BillSummary toSummary(WaterBill bill) {
BillQueryResult.BillSummary summary = new BillQueryResult.BillSummary();
summary.setBillId(bill.getId());
summary.setBillNo(bill.getBillNo());
summary.setBillPeriod(bill.getBillPeriod());
summary.setTotalAmount(bill.getTotalAmount());
summary.setPaidAmount(bill.getPaidAmount());
summary.setUnpaidAmount(bill.getTotalAmount().subtract(bill.getPaidAmount()));
summary.setStatus(bill.getStatus());
summary.setDueDate(bill.getDueDate() != null ? bill.getDueDate().toString() : null);
return summary;
}
}
@@ -0,0 +1,144 @@
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.dto.CsWorkbenchStats;
import com.water.revenue.entity.CsWorkItem;
import com.water.revenue.mapper.CsWorkItemMapper;
import com.water.revenue.mapper.VoiceCallRecordMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.*;
/**
* 客服工作台服务
*/
@Service
@RequiredArgsConstructor
public class CsWorkbenchService {
private final CsWorkItemMapper csWorkItemMapper;
private final VoiceCallRecordMapper voiceCallRecordMapper;
/**
* 工单分页列表
*/
public Page<CsWorkItem> listWorkItems(String workType, String status, String priority,
String assignee, int pageNum, int pageSize) {
LambdaQueryWrapper<CsWorkItem> wrapper = new LambdaQueryWrapper<>();
if (workType != null && !workType.isEmpty()) {
wrapper.eq(CsWorkItem::getWorkType, workType);
}
if (status != null && !status.isEmpty()) {
wrapper.eq(CsWorkItem::getStatus, status);
}
if (priority != null && !priority.isEmpty()) {
wrapper.eq(CsWorkItem::getPriority, priority);
}
if (assignee != null && !assignee.isEmpty()) {
wrapper.eq(CsWorkItem::getAssignee, assignee);
}
wrapper.orderByDesc(CsWorkItem::getCreatedAt);
return csWorkItemMapper.selectPage(new Page<>(pageNum, pageSize), wrapper);
}
/**
* 待处理数量
*/
public int getPendingCount() {
return csWorkItemMapper.countPending();
}
/**
* 今日统计
*/
public CsWorkbenchStats getTodayStats() {
CsWorkbenchStats stats = new CsWorkbenchStats();
stats.setTodayNewCount(csWorkItemMapper.countTodayNew());
stats.setTodayResolvedCount(csWorkItemMapper.countTodayResolved());
stats.setPendingTotal(csWorkItemMapper.countPending());
stats.setProcessingCount(csWorkItemMapper.countProcessing());
stats.setTodayCallCount(voiceCallRecordMapper.countTodayCalls());
stats.setTodayOnlineCount(csWorkItemMapper.countTodayNew()); // 模拟在线数
stats.setAvgProcessTime(15.5); // 模拟值
stats.setSatisfactionRate(96.2); // 模拟值
return stats;
}
/**
* 创建工单
*/
public CsWorkItem createWorkItem(CsWorkItem item) {
item.setStatus("pending");
item.setCreatedAt(LocalDateTime.now());
item.setUpdatedAt(LocalDateTime.now());
csWorkItemMapper.insert(item);
return item;
}
/**
* 更新工单状态
*/
public void updateWorkItemStatus(Long id, String status) {
LambdaUpdateWrapper<CsWorkItem> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(CsWorkItem::getId, id)
.set(CsWorkItem::getStatus, status)
.set(CsWorkItem::getUpdatedAt, LocalDateTime.now());
csWorkItemMapper.update(null, wrapper);
}
/**
* 工单详情
*/
public CsWorkItem getWorkItemDetail(Long id) {
return csWorkItemMapper.selectById(id);
}
/**
* 快捷操作 - 转派
*/
public void reassignWorkItem(Long id, String newAssignee) {
LambdaUpdateWrapper<CsWorkItem> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(CsWorkItem::getId, id)
.set(CsWorkItem::getAssignee, newAssignee)
.set(CsWorkItem::getStatus, "processing")
.set(CsWorkItem::getUpdatedAt, LocalDateTime.now());
csWorkItemMapper.update(null, wrapper);
}
/**
* 快捷操作 - 按类型统计
*/
public List<Map<String, Object>> getWorkTypeStats() {
List<String> types = Arrays.asList("complaint", "repair", "consult", "install", "meter_change");
List<Map<String, Object>> result = new ArrayList<>();
for (String type : types) {
LambdaQueryWrapper<CsWorkItem> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(CsWorkItem::getWorkType, type);
long count = csWorkItemMapper.selectCount(wrapper);
Map<String, Object> item = new HashMap<>();
item.put("workType", type);
item.put("count", count);
result.add(item);
}
return result;
}
/**
* 快捷操作 - 今日概览
*/
public Map<String, Object> getTodayOverview() {
Map<String, Object> overview = new HashMap<>();
overview.put("pendingCount", csWorkItemMapper.countPending());
overview.put("processingCount", csWorkItemMapper.countProcessing());
overview.put("todayNew", csWorkItemMapper.countTodayNew());
overview.put("todayResolved", csWorkItemMapper.countTodayResolved());
overview.put("todayCalls", voiceCallRecordMapper.countTodayCalls());
overview.put("date", LocalDate.now().toString());
return overview;
}
}
@@ -0,0 +1,261 @@
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.dto.BillQueryResult;
import com.water.revenue.dto.VoiceMenuResponse;
import com.water.revenue.entity.VoiceCallRecord;
import com.water.revenue.mapper.VoiceCallRecordMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.*;
/**
* TTS 语音自助查询服务(模拟)
*/
@Service
@RequiredArgsConstructor
public class VoiceQueryService {
private final VoiceCallRecordMapper voiceCallRecordMapper;
private final BillQueryService billQueryService;
/**
* 语音菜单导航 - 开始通话
*/
public VoiceMenuResponse startCall(String callerNumber) {
// 创建通话记录
VoiceCallRecord record = new VoiceCallRecord();
record.setCallId(UUID.randomUUID().toString().replace("-", ""));
record.setCallerNumber(callerNumber);
record.setMenuPath("main");
record.setMenuLevel(1);
record.setStatus("active");
record.setCallTime(LocalDateTime.now());
voiceCallRecordMapper.insert(record);
// 构建主菜单
VoiceMenuResponse response = new VoiceMenuResponse();
response.setCallId(record.getCallId());
response.setCurrentLevel(1);
response.setPrompt("欢迎致电XX水务客服热线,请按提示操作:");
response.setInputRequired(true);
response.setInputPrompt("请输入您的客户编号或手机号");
List<VoiceMenuResponse.MenuOption> options = new ArrayList<>();
options.add(createOption("1", "水费查询", "bill_query"));
options.add(createOption("2", "水费缴纳", "bill_payment"));
options.add(createOption("3", "报修服务", "repair"));
options.add(createOption("4", "业务咨询", "consult"));
options.add(createOption("0", "人工服务", "manual"));
response.setOptions(options);
return response;
}
/**
* 语音菜单导航 - 按键选择
*/
public VoiceMenuResponse handleKeyPress(String callId, String key) {
VoiceCallRecord record = getActiveCall(callId);
if (record == null) {
throw new RuntimeException("通话不存在或已结束: " + callId);
}
String currentMenu = record.getMenuPath();
String newMenuPath = currentMenu + ">" + key;
VoiceMenuResponse response = new VoiceMenuResponse();
response.setCallId(callId);
if ("main".equals(currentMenu)) {
switch (key) {
case "1":
response.setCurrentLevel(2);
response.setPrompt("水费查询,请输入您的客户编号,按#号键结束:");
response.setInputRequired(true);
response.setInputPrompt("请输入客户编号");
record.setMenuPath(newMenuPath);
record.setLastAction("bill_query_input");
break;
case "2":
response.setCurrentLevel(2);
response.setPrompt("水费缴纳,请输入您的客户编号,按#号键结束:");
response.setInputRequired(true);
response.setInputPrompt("请输入客户编号");
record.setMenuPath(newMenuPath);
record.setLastAction("bill_payment_input");
break;
case "3":
response.setCurrentLevel(2);
response.setPrompt("报修服务已记录,我们将在24小时内安排人员处理。");
response.setInputRequired(false);
record.setMenuPath(newMenuPath);
record.setLastAction("repair_submitted");
break;
case "4":
response.setCurrentLevel(2);
response.setPrompt("业务咨询:您可前往最近营业厅办理业务,地址为...");
response.setInputRequired(false);
record.setMenuPath(newMenuPath);
record.setLastAction("consult_info");
break;
case "0":
response.setCurrentLevel(2);
response.setPrompt("正在为您转接人工客服,请稍候...");
response.setInputRequired(false);
record.setMenuPath(newMenuPath);
record.setLastAction("transfer_manual");
break;
default:
response.setCurrentLevel(1);
response.setPrompt("输入有误,请重新选择:");
response.setInputRequired(true);
break;
}
} else {
// 子菜单处理:输入客户编号后查询
response.setCurrentLevel(3);
response.setPrompt("查询结果播报中...");
response.setInputRequired(false);
Map<String, Object> queryResult = new HashMap<>();
queryResult.put("inputValue", key);
queryResult.put("action", record.getLastAction());
response.setQueryResult(queryResult);
record.setMenuPath(newMenuPath);
record.setQueryResult("查询: " + key);
record.setCustomerNo(key);
record.setLastAction("query_completed");
}
record.setUpdatedAt(LocalDateTime.now());
voiceCallRecordMapper.updateById(record);
return response;
}
/**
* 语音账单查询
*/
public Map<String, Object> voiceBillQuery(String callId, String customerNo) {
BillQueryResult billResult = billQueryService.queryByCustomerNo(customerNo);
Map<String, Object> result = new HashMap<>();
result.put("customerNo", customerNo);
result.put("customerName", billResult.getCustomerName());
result.put("totalArrears", billResult.getTotalArrears());
result.put("billCount", billResult.getBills() != null ? billResult.getBills().size() : 0);
result.put("ttsText", buildTtsText(billResult));
// 更新通话记录
VoiceCallRecord record = getActiveCall(callId);
if (record != null) {
record.setCustomerNo(customerNo);
record.setQueryResult(String.valueOf(result.get("ttsText")));
voiceCallRecordMapper.updateById(record);
}
return result;
}
/**
* 语音缴费(模拟)
*/
public Map<String, Object> voicePayment(String callId, String customerNo, String billId) {
Map<String, Object> result = new HashMap<>();
result.put("success", true);
result.put("message", "缴费请求已提交,请通过微信/支付宝完成支付");
result.put("paymentUrl", "https://pay.example.com/water/" + billId);
result.put("ttsText", "缴费请求已提交,请您通过短信链接完成支付,感谢您的来电。");
VoiceCallRecord record = getActiveCall(callId);
if (record != null) {
record.setCustomerNo(customerNo);
record.setLastAction("payment_initiated");
record.setQueryResult("缴费:" + billId);
voiceCallRecordMapper.updateById(record);
}
return result;
}
/**
* 结束通话
*/
public Map<String, Object> endCall(String callId) {
VoiceCallRecord record = getActiveCall(callId);
Map<String, Object> result = new HashMap<>();
if (record != null) {
LocalDateTime now = LocalDateTime.now();
record.setEndTime(now);
record.setStatus("completed");
int duration = (int) java.time.Duration.between(record.getCallTime(), now).getSeconds();
record.setDuration(duration);
voiceCallRecordMapper.updateById(record);
result.put("callId", callId);
result.put("duration", duration);
result.put("status", "completed");
result.put("ttsText", "感谢致电XX水务,再见!");
} else {
result.put("callId", callId);
result.put("status", "not_found");
}
return result;
}
/**
* 通话记录查询
*/
public Page<VoiceCallRecord> getCallRecords(String callerNumber, String status,
int pageNum, int pageSize) {
LambdaQueryWrapper<VoiceCallRecord> wrapper = new LambdaQueryWrapper<>();
if (callerNumber != null && !callerNumber.isEmpty()) {
wrapper.eq(VoiceCallRecord::getCallerNumber, callerNumber);
}
if (status != null && !status.isEmpty()) {
wrapper.eq(VoiceCallRecord::getStatus, status);
}
wrapper.orderByDesc(VoiceCallRecord::getCallTime);
return voiceCallRecordMapper.selectPage(new Page<>(pageNum, pageSize), wrapper);
}
/**
* 获取通话详情
*/
public VoiceCallRecord getCallDetail(String callId) {
return voiceCallRecordMapper.selectOne(
new LambdaQueryWrapper<VoiceCallRecord>()
.eq(VoiceCallRecord::getCallId, callId));
}
private VoiceCallRecord getActiveCall(String callId) {
return voiceCallRecordMapper.selectOne(
new LambdaQueryWrapper<VoiceCallRecord>()
.eq(VoiceCallRecord::getCallId, callId)
.eq(VoiceCallRecord::getStatus, "active"));
}
private String buildTtsText(BillQueryResult result) {
if (result == null || result.getBills() == null || result.getBills().isEmpty()) {
return "未查询到相关账单信息。";
}
return String.format("尊敬的%s,您当前欠费金额为%s元,共%d笔未缴账单,请及时缴纳。",
result.getCustomerName(),
result.getTotalArrears(),
result.getBills().size());
}
private VoiceMenuResponse.MenuOption createOption(String key, String label, String action) {
VoiceMenuResponse.MenuOption option = new VoiceMenuResponse.MenuOption();
option.setKey(key);
option.setLabel(label);
option.setAction(action);
return option;
}
}
@@ -0,0 +1,18 @@
-- 微信网厅用户绑定表(手机号/户号绑定)
CREATE TABLE IF NOT EXISTS wx_hall_user_binding (
id BIGSERIAL PRIMARY KEY,
open_id VARCHAR(64) NOT NULL,
phone VARCHAR(20),
customer_no VARCHAR(32),
customer_name VARCHAR(100),
binding_type VARCHAR(20) NOT NULL DEFAULT 'phone', -- phone / customer_no
status INTEGER NOT NULL DEFAULT 1, -- 1-有效 0-已解绑
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_wx_binding_open_id ON wx_hall_user_binding(open_id);
CREATE INDEX IF NOT EXISTS idx_wx_binding_phone ON wx_hall_user_binding(phone);
CREATE INDEX IF NOT EXISTS idx_wx_binding_customer ON wx_hall_user_binding(customer_no);
CREATE INDEX IF NOT EXISTS idx_wx_binding_status ON wx_hall_user_binding(status);
@@ -0,0 +1,66 @@
-- ============================================================
-- 客服工作台 + 语音自助查询 DDL
-- 版本: V_cs_workbench
-- 作者: bot_dev2
-- 关联 Issue: #54
-- ============================================================
-- 1. 客服工作项表
CREATE TABLE IF NOT EXISTS wm_cs_work_item (
id BIGSERIAL PRIMARY KEY,
work_type VARCHAR(32) NOT NULL, -- complaint/repair/consult/install/meter_change
customer_no VARCHAR(32), -- 客户编号
customer_name VARCHAR(100), -- 客户名称
summary VARCHAR(500), -- 摘要
priority VARCHAR(16) DEFAULT 'medium', -- low/medium/high/urgent
status VARCHAR(16) DEFAULT 'pending', -- pending/processing/resolved/closed
assignee VARCHAR(64), -- 指派人
contact_phone VARCHAR(20), -- 联系电话
address VARCHAR(200), -- 地址
detail TEXT, -- 详细内容
source VARCHAR(16), -- phone/online/wechat/walk_in
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE wm_cs_work_item IS '客服工作台-工作项';
COMMENT ON COLUMN wm_cs_work_item.work_type IS '工作类型: complaint/repair/consult/install/meter_change';
COMMENT ON COLUMN wm_cs_work_item.priority IS '优先级: low/medium/high/urgent';
COMMENT ON COLUMN wm_cs_work_item.status IS '状态: pending/processing/resolved/closed';
COMMENT ON COLUMN wm_cs_work_item.source IS '来源: phone/online/wechat/walk_in';
-- 索引
CREATE INDEX IF NOT EXISTS idx_cs_work_item_status ON wm_cs_work_item (status);
CREATE INDEX IF NOT EXISTS idx_cs_work_item_customer ON wm_cs_work_item (customer_no);
CREATE INDEX IF NOT EXISTS idx_cs_work_item_assignee ON wm_cs_work_item (assignee);
CREATE INDEX IF NOT EXISTS idx_cs_work_item_type ON wm_cs_work_item (work_type);
CREATE INDEX IF NOT EXISTS idx_cs_work_item_created ON wm_cs_work_item (created_at);
-- 2. 语音通话记录表
CREATE TABLE IF NOT EXISTS wm_voice_call_record (
id BIGSERIAL PRIMARY KEY,
call_id VARCHAR(64) NOT NULL UNIQUE, -- 通话唯一ID
caller_number VARCHAR(20), -- 主叫号码
customer_no VARCHAR(32), -- 客户编号
menu_path VARCHAR(200), -- 菜单路径
query_result TEXT, -- 查询结果摘要
duration INTEGER, -- 通话时长(秒)
call_time TIMESTAMP, -- 通话时间
end_time TIMESTAMP, -- 通话结束时间
status VARCHAR(16) DEFAULT 'active', -- active/completed/failed
menu_level INTEGER DEFAULT 1, -- 菜单层级
last_action VARCHAR(64), -- 最后操作
created_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE wm_voice_call_record IS '语音自助查询-通话记录';
COMMENT ON COLUMN wm_voice_call_record.call_id IS '通话唯一ID';
COMMENT ON COLUMN wm_voice_call_record.menu_path IS '菜单路径(如 main>1>customerNo)';
COMMENT ON COLUMN wm_voice_call_record.status IS '状态: active/completed/failed';
-- 索引
CREATE INDEX IF NOT EXISTS idx_voice_call_call_id ON wm_voice_call_record (call_id);
CREATE INDEX IF NOT EXISTS idx_voice_call_caller ON wm_voice_call_record (caller_number);
CREATE INDEX IF NOT EXISTS idx_voice_call_customer ON wm_voice_call_record (customer_no);
CREATE INDEX IF NOT EXISTS idx_voice_call_time ON wm_voice_call_record (call_time);
CREATE INDEX IF NOT EXISTS idx_voice_call_status ON wm_voice_call_record (status);
@@ -0,0 +1,289 @@
package com.water.revenue;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.water.revenue.dto.BillQueryResult;
import com.water.revenue.dto.CsWorkbenchStats;
import com.water.revenue.dto.VoiceMenuResponse;
import com.water.revenue.entity.CsWorkItem;
import com.water.revenue.entity.VoiceCallRecord;
import com.water.revenue.entity.WaterBill;
import com.water.revenue.mapper.CsWorkItemMapper;
import com.water.revenue.mapper.PaymentRecordMapper;
import com.water.revenue.mapper.VoiceCallRecordMapper;
import com.water.revenue.mapper.WaterBillMapper;
import com.water.revenue.service.BillQueryService;
import com.water.revenue.service.CsWorkbenchService;
import com.water.revenue.service.VoiceQueryService;
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.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class CsWorkbenchTest {
@Mock
private CsWorkItemMapper csWorkItemMapper;
@Mock
private VoiceCallRecordMapper voiceCallRecordMapper;
@Mock
private WaterBillMapper waterBillMapper;
@Mock
private PaymentRecordMapper paymentRecordMapper;
private CsWorkbenchService csWorkbenchService;
private BillQueryService billQueryService;
private VoiceQueryService voiceQueryService;
@BeforeEach
void setUp() {
csWorkbenchService = new CsWorkbenchService(csWorkItemMapper, voiceCallRecordMapper);
billQueryService = new BillQueryService(waterBillMapper, paymentRecordMapper);
voiceQueryService = new VoiceQueryService(voiceCallRecordMapper, billQueryService);
}
// ====== 客服工作台测试 ======
@Nested
@DisplayName("客服工作台服务测试")
class WorkbenchTests {
@Test
@DisplayName("获取待处理数量")
void getPendingCount_returnsCorrectCount() {
when(csWorkItemMapper.countPending()).thenReturn(5);
int count = csWorkbenchService.getPendingCount();
assertEquals(5, count);
verify(csWorkItemMapper).countPending();
}
@Test
@DisplayName("获取今日统计")
void getTodayStats_returnsAllFields() {
when(csWorkItemMapper.countTodayNew()).thenReturn(10);
when(csWorkItemMapper.countTodayResolved()).thenReturn(6);
when(csWorkItemMapper.countPending()).thenReturn(4);
when(csWorkItemMapper.countProcessing()).thenReturn(3);
when(voiceCallRecordMapper.countTodayCalls()).thenReturn(20);
CsWorkbenchStats stats = csWorkbenchService.getTodayStats();
assertNotNull(stats);
assertEquals(10, stats.getTodayNewCount());
assertEquals(6, stats.getTodayResolvedCount());
assertEquals(4, stats.getPendingTotal());
assertEquals(3, stats.getProcessingCount());
assertEquals(20, stats.getTodayCallCount());
assertEquals(15.5, stats.getAvgProcessTime());
}
@Test
@DisplayName("创建工单 - 默认状态为pending")
void createWorkItem_setsDefaultStatus() {
CsWorkItem item = new CsWorkItem();
item.setWorkType("complaint");
item.setCustomerNo("C001");
item.setCustomerName("张三");
item.setSummary("水压过低");
item.setPriority("high");
when(csWorkItemMapper.insert(any(CsWorkItem.class))).thenReturn(1);
CsWorkItem result = csWorkbenchService.createWorkItem(item);
assertEquals("pending", result.getStatus());
assertNotNull(result.getCreatedAt());
assertNotNull(result.getUpdatedAt());
verify(csWorkItemMapper).insert(item);
}
@Test
@DisplayName("工单分页列表 - 带条件过滤")
void listWorkItems_withFilters() {
Page<CsWorkItem> mockPage = new Page<>(1, 10);
mockPage.setRecords(List.of(createWorkItem(1L, "complaint", "pending")));
when(csWorkItemMapper.selectPage(any(Page.class), any(LambdaQueryWrapper.class)))
.thenReturn(mockPage);
Page<CsWorkItem> result = csWorkbenchService.listWorkItems(
"complaint", "pending", null, null, 1, 10);
assertNotNull(result);
assertEquals(1, result.getRecords().size());
assertEquals("complaint", result.getRecords().get(0).getWorkType());
}
@Test
@DisplayName("转派工单 - 状态变为processing")
void reassignWorkItem_updatesStatusAndAssignee() {
csWorkbenchService.reassignWorkItem(1L, "李四");
verify(csWorkItemMapper).update(isNull(), any());
}
}
// ====== 水费查询测试 ======
@Nested
@DisplayName("水费查询服务测试")
class BillQueryTests {
@Test
@DisplayName("按户号查询 - 返回账单列表")
void queryByCustomerNo_returnsBills() {
List<WaterBill> mockBills = List.of(createWaterBill(1L, "C001"));
when(waterBillMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(mockBills);
BillQueryResult result = billQueryService.queryByCustomerNo("C001");
assertNotNull(result);
assertEquals("C001", result.getCustomerNo());
assertEquals(1, result.getBills().size());
assertNotNull(result.getTotalArrears());
}
@Test
@DisplayName("欠费查询 - 仅返回未缴账单")
void queryArrears_returnsUnpaidBills() {
List<WaterBill> mockBills = List.of(
createWaterBillWithStatus(1L, "C001", "pending"),
createWaterBillWithStatus(2L, "C001", "overdue"));
when(waterBillMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(mockBills);
List<BillQueryResult.BillSummary> result = billQueryService.queryArrears("C001");
assertNotNull(result);
assertEquals(2, result.size());
}
}
// ====== 语音自助查询测试 ======
@Nested
@DisplayName("语音自助查询服务测试")
class VoiceQueryTests {
@Test
@DisplayName("开始通话 - 返回主菜单")
void startCall_returnsMainMenu() {
when(voiceCallRecordMapper.insert(any(VoiceCallRecord.class))).thenReturn(1);
VoiceMenuResponse response = voiceQueryService.startCall("13800138000");
assertNotNull(response);
assertNotNull(response.getCallId());
assertEquals(1, response.getCurrentLevel());
assertTrue(response.isInputRequired());
assertEquals(5, response.getOptions().size());
assertEquals("1", response.getOptions().get(0).getKey());
verify(voiceCallRecordMapper).insert(any(VoiceCallRecord.class));
}
@Test
@DisplayName("按键选择 - 水费查询")
void handleKeyPress_billQuery() {
VoiceCallRecord mockRecord = createActiveCallRecord();
when(voiceCallRecordMapper.selectOne(any(LambdaQueryWrapper.class)))
.thenReturn(mockRecord);
when(voiceCallRecordMapper.updateById(any(VoiceCallRecord.class))).thenReturn(1);
VoiceMenuResponse response = voiceQueryService.handleKeyPress(
mockRecord.getCallId(), "1");
assertNotNull(response);
assertEquals(2, response.getCurrentLevel());
assertTrue(response.isInputRequired());
verify(voiceCallRecordMapper).updateById(any(VoiceCallRecord.class));
}
@Test
@DisplayName("结束通话 - 记录通话时长")
void endCall_recordsDuration() {
VoiceCallRecord mockRecord = createActiveCallRecord();
when(voiceCallRecordMapper.selectOne(any(LambdaQueryWrapper.class)))
.thenReturn(mockRecord);
when(voiceCallRecordMapper.updateById(any(VoiceCallRecord.class))).thenReturn(1);
Map<String, Object> result = voiceQueryService.endCall(mockRecord.getCallId());
assertNotNull(result);
assertEquals("completed", result.get("status"));
assertNotNull(result.get("duration"));
assertEquals("completed", mockRecord.getStatus());
}
}
// ====== Helper Methods ======
private CsWorkItem createWorkItem(Long id, String workType, String status) {
CsWorkItem item = new CsWorkItem();
item.setId(id);
item.setWorkType(workType);
item.setCustomerNo("C001");
item.setCustomerName("测试用户");
item.setSummary("测试工单");
item.setPriority("medium");
item.setStatus(status);
item.setAssignee("客服A");
item.setCreatedAt(LocalDateTime.now());
item.setUpdatedAt(LocalDateTime.now());
return item;
}
private WaterBill createWaterBill(Long id, String customerNo) {
WaterBill bill = new WaterBill();
bill.setId(id);
bill.setBillNo("BILL-2026-001");
bill.setCustomerNo(customerNo);
bill.setCustomerName("测试用户");
bill.setBillPeriod("2026-06");
bill.setWaterUsage(new BigDecimal("15.5"));
bill.setUnitPrice(new BigDecimal("3.85"));
bill.setTotalAmount(new BigDecimal("59.68"));
bill.setPaidAmount(BigDecimal.ZERO);
bill.setStatus("pending");
bill.setIssueDate(LocalDate.of(2026, 6, 1));
bill.setDueDate(LocalDate.of(2026, 6, 30));
bill.setMeterNo("M001");
bill.setCreatedAt(LocalDateTime.now());
bill.setUpdatedAt(LocalDateTime.now());
return bill;
}
private WaterBill createWaterBillWithStatus(Long id, String customerNo, String status) {
WaterBill bill = createWaterBill(id, customerNo);
bill.setStatus(status);
return bill;
}
private VoiceCallRecord createActiveCallRecord() {
VoiceCallRecord record = new VoiceCallRecord();
record.setId(1L);
record.setCallId("test-call-001");
record.setCallerNumber("13800138000");
record.setMenuPath("main");
record.setMenuLevel(1);
record.setStatus("active");
record.setCallTime(LocalDateTime.now().minusSeconds(30));
record.setCreatedAt(LocalDateTime.now());
return record;
}
}