diff --git a/frontend/src/api/wx-hall.ts b/frontend/src/api/wx-hall.ts new file mode 100644 index 00000000..78f9578a --- /dev/null +++ b/frontend/src/api/wx-hall.ts @@ -0,0 +1,85 @@ +import request from './request' + +const BASE = '/wx-hall' + +// ========== 水费查询缴费 ========== + +export function getBillList(params: { + customerNo: string + billPeriod?: string + status?: string + pageNum?: number + pageSize?: number +}) { + return request.get(`${BASE}/bill/list`, { params }) +} + +export function getBillDetail(billId: number) { + return request.get(`${BASE}/bill/${billId}`) +} + +export function payBill(data: { billId: number; amount: number }) { + return request.post(`${BASE}/bill/pay`, data) +} + +export function getPaymentRecords(params: { customerNo: string; limit?: number }) { + return request.get(`${BASE}/bill/payment-records`, { params }) +} + +// ========== 报装申请 ========== + +export function submitInstallApply(data: { + name: string + phone: string + area: string + address: string + customerType?: string + caliber?: string +}) { + return request.post(`${BASE}/install/apply`, data) +} + +export function getInstallProgress(applyNo: string) { + return request.get(`${BASE}/install/progress`, { params: { applyNo } }) +} + +export function getInstallList(params: { phone: string; limit?: number }) { + return request.get(`${BASE}/install/list`, { params }) +} + +// ========== 停水公告 ========== + +export function getNoticeList(params: { + page?: number + size?: number + type?: string + keyword?: string +}) { + return request.get(`${BASE}/notice/list`, { params }) +} + +export function getNoticeDetail(id: number) { + return request.get(`${BASE}/notice/${id}`) +} + +export function getActiveNotices(areaCode?: string) { + return request.get(`${BASE}/notice/active`, { params: { areaCode } }) +} + +// ========== 用户绑定 ========== + +export function bindPhone(data: { openId: string; phone: string }) { + return request.post(`${BASE}/user/bindPhone`, data) +} + +export function bindCustomer(data: { openId: string; customerNo: string }) { + return request.post(`${BASE}/user/bindCustomer`, data) +} + +export function unbind(data: { openId: string; bindingId: string }) { + return request.post(`${BASE}/user/unbind`, data) +} + +export function getBindings(openId: string) { + return request.get(`${BASE}/user/bindings`, { params: { openId } }) +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 8794d207..e7b26954 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -14,6 +14,11 @@ const routes = [ { path: 'dispatch-command', name: 'dispatchCommandList', component: () => import('@/views/dispatch-command/CommandList.vue') }, { path: 'dispatch-command/:id', name: 'dispatchCommandDetail', component: () => import('@/views/dispatch-command/CommandDetail.vue') }, { path: 'service/workbench', name: 'serviceWorkbench', component: () => import('@/views/service/CustomerServiceWorkbench.vue') }, + // 微信网厅 + { path: 'wx-hall/water-bill', name: 'wxWaterBill', component: () => import('@/views/wxhall/WaterBillView.vue') }, + { path: 'wx-hall/install-apply', name: 'wxInstallApply', component: () => import('@/views/wxhall/InstallApplyView.vue') }, + { path: 'wx-hall/notice', name: 'wxNotice', component: () => import('@/views/wxhall/NoticeView.vue') }, + { path: 'wx-hall/user-bind', name: 'wxUserBind', component: () => import('@/views/wxhall/UserBindView.vue') }, ] }, { path: '/:pathMatch(.*)*', redirect: '/dashboard' } diff --git a/frontend/src/views/wxhall/InstallApplyView.vue b/frontend/src/views/wxhall/InstallApplyView.vue new file mode 100644 index 00000000..5330c8fe --- /dev/null +++ b/frontend/src/views/wxhall/InstallApplyView.vue @@ -0,0 +1,254 @@ + + + + + diff --git a/frontend/src/views/wxhall/NoticeView.vue b/frontend/src/views/wxhall/NoticeView.vue new file mode 100644 index 00000000..181cab5b --- /dev/null +++ b/frontend/src/views/wxhall/NoticeView.vue @@ -0,0 +1,217 @@ + + + + + diff --git a/frontend/src/views/wxhall/UserBindView.vue b/frontend/src/views/wxhall/UserBindView.vue new file mode 100644 index 00000000..d41dd314 --- /dev/null +++ b/frontend/src/views/wxhall/UserBindView.vue @@ -0,0 +1,188 @@ + + + + + diff --git a/frontend/src/views/wxhall/WaterBillView.vue b/frontend/src/views/wxhall/WaterBillView.vue new file mode 100644 index 00000000..f48ae9f3 --- /dev/null +++ b/frontend/src/views/wxhall/WaterBillView.vue @@ -0,0 +1,287 @@ + + + + + diff --git a/wm-revenue/src/main/java/com/water/revenue/controller/BillQueryController.java b/wm-revenue/src/main/java/com/water/revenue/controller/BillQueryController.java new file mode 100644 index 00000000..06122afb --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/controller/BillQueryController.java @@ -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 queryByCustomerNo(@PathVariable String customerNo) { + return R.ok(billQueryService.queryByCustomerNo(customerNo)); + } + + @GetMapping("/by-phone/{phone}") + @Operation(summary = "按手机号查询水费") + public R queryByPhone(@PathVariable String phone) { + return R.ok(billQueryService.queryByPhone(phone)); + } + + @GetMapping("/by-address") + @Operation(summary = "按地址查询水费") + public R queryByAddress(@RequestParam String address) { + return R.ok(billQueryService.queryByAddress(address)); + } + + @GetMapping("/detail/{billId}") + @Operation(summary = "账单明细(含缴费记录)") + public R> getBillDetail(@PathVariable Long billId) { + return R.ok(billQueryService.getBillDetail(billId)); + } + + @GetMapping("/arrears/{customerNo}") + @Operation(summary = "欠费查询") + public R> queryArrears(@PathVariable String customerNo) { + return R.ok(billQueryService.queryArrears(customerNo)); + } +} diff --git a/wm-revenue/src/main/java/com/water/revenue/controller/CsWorkbenchController.java b/wm-revenue/src/main/java/com/water/revenue/controller/CsWorkbenchController.java new file mode 100644 index 00000000..4f325289 --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/controller/CsWorkbenchController.java @@ -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> 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 getPendingCount() { + return R.ok(csWorkbenchService.getPendingCount()); + } + + @GetMapping("/today-stats") + @Operation(summary = "今日统计数据") + public R getTodayStats() { + return R.ok(csWorkbenchService.getTodayStats()); + } + + @PostMapping("/create") + @Operation(summary = "创建工单") + public R createWorkItem(@RequestBody CsWorkItem item) { + return R.ok(csWorkbenchService.createWorkItem(item)); + } + + @PutMapping("/{id}/status") + @Operation(summary = "更新工单状态") + public R updateStatus(@PathVariable Long id, @RequestParam String status) { + csWorkbenchService.updateWorkItemStatus(id, status); + return R.ok("状态已更新"); + } + + @GetMapping("/{id}") + @Operation(summary = "工单详情") + public R getWorkItemDetail(@PathVariable Long id) { + return R.ok(csWorkbenchService.getWorkItemDetail(id)); + } + + @PutMapping("/{id}/reassign") + @Operation(summary = "转派工单") + public R reassignWorkItem(@PathVariable Long id, @RequestParam String assignee) { + csWorkbenchService.reassignWorkItem(id, assignee); + return R.ok("已转派"); + } + + @GetMapping("/work-type-stats") + @Operation(summary = "按类型统计") + public R>> getWorkTypeStats() { + return R.ok(csWorkbenchService.getWorkTypeStats()); + } + + @GetMapping("/today-overview") + @Operation(summary = "今日概览") + public R> getTodayOverview() { + return R.ok(csWorkbenchService.getTodayOverview()); + } +} diff --git a/wm-revenue/src/main/java/com/water/revenue/controller/VoiceController.java b/wm-revenue/src/main/java/com/water/revenue/controller/VoiceController.java new file mode 100644 index 00000000..dcdb8b93 --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/controller/VoiceController.java @@ -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 startCall(@RequestParam String callerNumber) { + return R.ok(voiceQueryService.startCall(callerNumber)); + } + + @PostMapping("/key-press") + @Operation(summary = "按键选择") + public R handleKeyPress( + @RequestParam String callId, + @RequestParam String key) { + return R.ok(voiceQueryService.handleKeyPress(callId, key)); + } + + @PostMapping("/bill-query") + @Operation(summary = "语音账单查询") + public R> voiceBillQuery( + @RequestParam String callId, + @RequestParam String customerNo) { + return R.ok(voiceQueryService.voiceBillQuery(callId, customerNo)); + } + + @PostMapping("/payment") + @Operation(summary = "语音缴费") + public R> voicePayment( + @RequestParam String callId, + @RequestParam String customerNo, + @RequestParam String billId) { + return R.ok(voiceQueryService.voicePayment(callId, customerNo, billId)); + } + + @PostMapping("/end") + @Operation(summary = "结束通话") + public R> endCall(@RequestParam String callId) { + return R.ok(voiceQueryService.endCall(callId)); + } + + @GetMapping("/records") + @Operation(summary = "通话记录查询") + public R> 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 getCallDetail(@PathVariable String callId) { + return R.ok(voiceQueryService.getCallDetail(callId)); + } +} diff --git a/wm-revenue/src/main/java/com/water/revenue/controller/wxhall/WxHallApiController.java b/wm-revenue/src/main/java/com/water/revenue/controller/wxhall/WxHallApiController.java new file mode 100644 index 00000000..99d44ed6 --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/controller/wxhall/WxHallApiController.java @@ -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> 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> billDetail(@PathVariable Long billId) { + return R.ok(billService.getBillDetail(billId)); + } + + @Operation(summary = "在线缴费(微信下单)") + @PostMapping("/bill/pay") + public R> billPay(@RequestBody Map 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>> paymentRecords( + @RequestParam String customerNo, + @RequestParam(defaultValue = "20") int limit) { + List> 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> installApply(@RequestBody Map 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> installProgress(@RequestParam String applyNo) { + return R.ok(installService.getProgress(applyNo)); + } + + @Operation(summary = "报装申请记录列表") + @GetMapping("/install/list") + public R>> installList( + @RequestParam String phone, + @RequestParam(defaultValue = "20") int limit) { + List> 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> 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 noticeDetail(@PathVariable Long id) { + return R.ok(announcementService.getDetail(id)); + } + + @Operation(summary = "获取当前生效公告(按区域)") + @GetMapping("/notice/active") + public R> activeNotices( + @RequestParam(required = false) String areaCode) { + return R.ok(announcementService.getActiveAnnouncements(areaCode)); + } + + // ========== 用户绑定 ========== + + @Operation(summary = "手机号绑定") + @PostMapping("/user/bindPhone") + public R> bindPhone(@RequestBody Map 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> bindCustomer(@RequestBody Map 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> unbind(@RequestBody Map 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>> bindings(@RequestParam String openId) { + List> 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); + } +} diff --git a/wm-revenue/src/main/java/com/water/revenue/dto/BillQueryResult.java b/wm-revenue/src/main/java/com/water/revenue/dto/BillQueryResult.java new file mode 100644 index 00000000..8a5f20dd --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/dto/BillQueryResult.java @@ -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 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; + } +} diff --git a/wm-revenue/src/main/java/com/water/revenue/dto/CsWorkbenchStats.java b/wm-revenue/src/main/java/com/water/revenue/dto/CsWorkbenchStats.java new file mode 100644 index 00000000..50c245d3 --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/dto/CsWorkbenchStats.java @@ -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; +} diff --git a/wm-revenue/src/main/java/com/water/revenue/dto/VoiceMenuResponse.java b/wm-revenue/src/main/java/com/water/revenue/dto/VoiceMenuResponse.java new file mode 100644 index 00000000..e86e0e9c --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/dto/VoiceMenuResponse.java @@ -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 options; + + /** 查询结果(如果有) */ + private Map queryResult; + + /** 是否需要输入 */ + private boolean inputRequired; + + /** 输入提示 */ + private String inputPrompt; + + @Data + public static class MenuOption { + private String key; + private String label; + private String action; + } +} diff --git a/wm-revenue/src/main/java/com/water/revenue/entity/CsWorkItem.java b/wm-revenue/src/main/java/com/water/revenue/entity/CsWorkItem.java new file mode 100644 index 00000000..f44c01f3 --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/entity/CsWorkItem.java @@ -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; +} diff --git a/wm-revenue/src/main/java/com/water/revenue/entity/VoiceCallRecord.java b/wm-revenue/src/main/java/com/water/revenue/entity/VoiceCallRecord.java new file mode 100644 index 00000000..50655bb1 --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/entity/VoiceCallRecord.java @@ -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; +} diff --git a/wm-revenue/src/main/java/com/water/revenue/mapper/CsWorkItemMapper.java b/wm-revenue/src/main/java/com/water/revenue/mapper/CsWorkItemMapper.java new file mode 100644 index 00000000..fe63ca10 --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/mapper/CsWorkItemMapper.java @@ -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 { + + @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(); +} diff --git a/wm-revenue/src/main/java/com/water/revenue/mapper/VoiceCallRecordMapper.java b/wm-revenue/src/main/java/com/water/revenue/mapper/VoiceCallRecordMapper.java new file mode 100644 index 00000000..409779c4 --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/mapper/VoiceCallRecordMapper.java @@ -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 { + + @Select("SELECT COUNT(*) FROM wm_voice_call_record WHERE DATE(call_time) = CURRENT_DATE") + int countTodayCalls(); +} diff --git a/wm-revenue/src/main/java/com/water/revenue/service/BillQueryService.java b/wm-revenue/src/main/java/com/water/revenue/service/BillQueryService.java new file mode 100644 index 00000000..5ceb4023 --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/service/BillQueryService.java @@ -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 bills = waterBillMapper.selectList( + new LambdaQueryWrapper() + .eq(WaterBill::getCustomerNo, customerNo) + .orderByDesc(WaterBill::getCreatedAt)); + + return buildResult(customerNo, bills); + } + + /** + * 按手机号查询 + */ + public BillQueryResult queryByPhone(String phone) { + // 模拟:通过手机号反查客户编号(实际应查客户表) + // 此处简化处理,演示流程 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.like(WaterBill::getCustomerNo, phone.substring(Math.max(0, phone.length() - 4))); + List bills = waterBillMapper.selectList(wrapper); + + String customerNo = bills.isEmpty() ? "UNKNOWN" : bills.get(0).getCustomerNo(); + return buildResult(customerNo, bills); + } + + /** + * 按地址查询 + */ + public BillQueryResult queryByAddress(String address) { + // 模拟:地址关键字匹配(实际应查客户档案表) + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.like(WaterBill::getCustomerName, address); + List bills = waterBillMapper.selectList(wrapper); + + String customerNo = bills.isEmpty() ? "UNKNOWN" : bills.get(0).getCustomerNo(); + return buildResult(customerNo, bills); + } + + /** + * 账单明细(含缴费记录) + */ + public Map getBillDetail(Long billId) { + WaterBill bill = waterBillMapper.selectById(billId); + if (bill == null) { + throw new RuntimeException("账单不存在: " + billId); + } + + List payments = paymentRecordMapper.selectList( + new LambdaQueryWrapper() + .eq(PaymentRecord::getBillId, billId) + .orderByDesc(PaymentRecord::getPayTime)); + + Map 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 queryArrears(String customerNo) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(WaterBill::getCustomerNo, customerNo); + wrapper.in(WaterBill::getStatus, Arrays.asList("pending", "partial", "overdue")); + wrapper.orderByAsc(WaterBill::getDueDate); + + List bills = waterBillMapper.selectList(wrapper); + return bills.stream().map(this::toSummary).collect(Collectors.toList()); + } + + private BillQueryResult buildResult(String customerNo, List 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 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; + } +} diff --git a/wm-revenue/src/main/java/com/water/revenue/service/CsWorkbenchService.java b/wm-revenue/src/main/java/com/water/revenue/service/CsWorkbenchService.java new file mode 100644 index 00000000..92173a53 --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/service/CsWorkbenchService.java @@ -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 listWorkItems(String workType, String status, String priority, + String assignee, int pageNum, int pageSize) { + LambdaQueryWrapper 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 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 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> getWorkTypeStats() { + List types = Arrays.asList("complaint", "repair", "consult", "install", "meter_change"); + List> result = new ArrayList<>(); + for (String type : types) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(CsWorkItem::getWorkType, type); + long count = csWorkItemMapper.selectCount(wrapper); + Map item = new HashMap<>(); + item.put("workType", type); + item.put("count", count); + result.add(item); + } + return result; + } + + /** + * 快捷操作 - 今日概览 + */ + public Map getTodayOverview() { + Map 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; + } +} diff --git a/wm-revenue/src/main/java/com/water/revenue/service/VoiceQueryService.java b/wm-revenue/src/main/java/com/water/revenue/service/VoiceQueryService.java new file mode 100644 index 00000000..8d1c9c74 --- /dev/null +++ b/wm-revenue/src/main/java/com/water/revenue/service/VoiceQueryService.java @@ -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 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 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 voiceBillQuery(String callId, String customerNo) { + BillQueryResult billResult = billQueryService.queryByCustomerNo(customerNo); + + Map 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 voicePayment(String callId, String customerNo, String billId) { + Map 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 endCall(String callId) { + VoiceCallRecord record = getActiveCall(callId); + Map 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 getCallRecords(String callerNumber, String status, + int pageNum, int pageSize) { + LambdaQueryWrapper 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() + .eq(VoiceCallRecord::getCallId, callId)); + } + + private VoiceCallRecord getActiveCall(String callId) { + return voiceCallRecordMapper.selectOne( + new LambdaQueryWrapper() + .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; + } +} diff --git a/wm-revenue/src/main/resources/db/V6__wx_hall_user_bindng.sql b/wm-revenue/src/main/resources/db/V6__wx_hall_user_bindng.sql new file mode 100644 index 00000000..620969b1 --- /dev/null +++ b/wm-revenue/src/main/resources/db/V6__wx_hall_user_bindng.sql @@ -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); diff --git a/wm-revenue/src/main/resources/sql/V_cs_workbench.sql b/wm-revenue/src/main/resources/sql/V_cs_workbench.sql new file mode 100644 index 00000000..f2c93f37 --- /dev/null +++ b/wm-revenue/src/main/resources/sql/V_cs_workbench.sql @@ -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); diff --git a/wm-revenue/src/test/java/com/water/revenue/CsWorkbenchTest.java b/wm-revenue/src/test/java/com/water/revenue/CsWorkbenchTest.java new file mode 100644 index 00000000..8d20487e --- /dev/null +++ b/wm-revenue/src/test/java/com/water/revenue/CsWorkbenchTest.java @@ -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 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 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 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 mockBills = List.of( + createWaterBillWithStatus(1L, "C001", "pending"), + createWaterBillWithStatus(2L, "C001", "overdue")); + when(waterBillMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(mockBills); + + List 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 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; + } +}