瀏覽代碼

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个测试用例)
bot_dev2 4 天之前
父節點
當前提交
7cbc17c2c4
共有 23 個檔案被更改,包括 2644 行新增0 行删除
  1. 85
    0
      frontend/src/api/wx-hall.ts
  2. 5
    0
      frontend/src/router/index.ts
  3. 254
    0
      frontend/src/views/wxhall/InstallApplyView.vue
  4. 217
    0
      frontend/src/views/wxhall/NoticeView.vue
  5. 188
    0
      frontend/src/views/wxhall/UserBindView.vue
  6. 287
    0
      frontend/src/views/wxhall/WaterBillView.vue
  7. 51
    0
      wm-revenue/src/main/java/com/water/revenue/controller/BillQueryController.java
  8. 85
    0
      wm-revenue/src/main/java/com/water/revenue/controller/CsWorkbenchController.java
  9. 75
    0
      wm-revenue/src/main/java/com/water/revenue/controller/VoiceController.java
  10. 220
    0
      wm-revenue/src/main/java/com/water/revenue/controller/wxhall/WxHallApiController.java
  11. 46
    0
      wm-revenue/src/main/java/com/water/revenue/dto/BillQueryResult.java
  12. 34
    0
      wm-revenue/src/main/java/com/water/revenue/dto/CsWorkbenchStats.java
  13. 41
    0
      wm-revenue/src/main/java/com/water/revenue/dto/VoiceMenuResponse.java
  14. 54
    0
      wm-revenue/src/main/java/com/water/revenue/entity/CsWorkItem.java
  15. 52
    0
      wm-revenue/src/main/java/com/water/revenue/entity/VoiceCallRecord.java
  16. 25
    0
      wm-revenue/src/main/java/com/water/revenue/mapper/CsWorkItemMapper.java
  17. 13
    0
      wm-revenue/src/main/java/com/water/revenue/mapper/VoiceCallRecordMapper.java
  18. 134
    0
      wm-revenue/src/main/java/com/water/revenue/service/BillQueryService.java
  19. 144
    0
      wm-revenue/src/main/java/com/water/revenue/service/CsWorkbenchService.java
  20. 261
    0
      wm-revenue/src/main/java/com/water/revenue/service/VoiceQueryService.java
  21. 18
    0
      wm-revenue/src/main/resources/db/V6__wx_hall_user_bindng.sql
  22. 66
    0
      wm-revenue/src/main/resources/sql/V_cs_workbench.sql
  23. 289
    0
      wm-revenue/src/test/java/com/water/revenue/CsWorkbenchTest.java

+ 85
- 0
frontend/src/api/wx-hall.ts 查看文件

@@ -0,0 +1,85 @@
1
+import request from './request'
2
+
3
+const BASE = '/wx-hall'
4
+
5
+// ========== 水费查询缴费 ==========
6
+
7
+export function getBillList(params: {
8
+  customerNo: string
9
+  billPeriod?: string
10
+  status?: string
11
+  pageNum?: number
12
+  pageSize?: number
13
+}) {
14
+  return request.get(`${BASE}/bill/list`, { params })
15
+}
16
+
17
+export function getBillDetail(billId: number) {
18
+  return request.get(`${BASE}/bill/${billId}`)
19
+}
20
+
21
+export function payBill(data: { billId: number; amount: number }) {
22
+  return request.post(`${BASE}/bill/pay`, data)
23
+}
24
+
25
+export function getPaymentRecords(params: { customerNo: string; limit?: number }) {
26
+  return request.get(`${BASE}/bill/payment-records`, { params })
27
+}
28
+
29
+// ========== 报装申请 ==========
30
+
31
+export function submitInstallApply(data: {
32
+  name: string
33
+  phone: string
34
+  area: string
35
+  address: string
36
+  customerType?: string
37
+  caliber?: string
38
+}) {
39
+  return request.post(`${BASE}/install/apply`, data)
40
+}
41
+
42
+export function getInstallProgress(applyNo: string) {
43
+  return request.get(`${BASE}/install/progress`, { params: { applyNo } })
44
+}
45
+
46
+export function getInstallList(params: { phone: string; limit?: number }) {
47
+  return request.get(`${BASE}/install/list`, { params })
48
+}
49
+
50
+// ========== 停水公告 ==========
51
+
52
+export function getNoticeList(params: {
53
+  page?: number
54
+  size?: number
55
+  type?: string
56
+  keyword?: string
57
+}) {
58
+  return request.get(`${BASE}/notice/list`, { params })
59
+}
60
+
61
+export function getNoticeDetail(id: number) {
62
+  return request.get(`${BASE}/notice/${id}`)
63
+}
64
+
65
+export function getActiveNotices(areaCode?: string) {
66
+  return request.get(`${BASE}/notice/active`, { params: { areaCode } })
67
+}
68
+
69
+// ========== 用户绑定 ==========
70
+
71
+export function bindPhone(data: { openId: string; phone: string }) {
72
+  return request.post(`${BASE}/user/bindPhone`, data)
73
+}
74
+
75
+export function bindCustomer(data: { openId: string; customerNo: string }) {
76
+  return request.post(`${BASE}/user/bindCustomer`, data)
77
+}
78
+
79
+export function unbind(data: { openId: string; bindingId: string }) {
80
+  return request.post(`${BASE}/user/unbind`, data)
81
+}
82
+
83
+export function getBindings(openId: string) {
84
+  return request.get(`${BASE}/user/bindings`, { params: { openId } })
85
+}

+ 5
- 0
frontend/src/router/index.ts 查看文件

@@ -14,6 +14,11 @@ const routes = [
14 14
       { path: 'dispatch-command', name: 'dispatchCommandList', component: () => import('@/views/dispatch-command/CommandList.vue') },
15 15
       { path: 'dispatch-command/:id', name: 'dispatchCommandDetail', component: () => import('@/views/dispatch-command/CommandDetail.vue') },
16 16
       { path: 'service/workbench', name: 'serviceWorkbench', component: () => import('@/views/service/CustomerServiceWorkbench.vue') },
17
+      // 微信网厅
18
+      { path: 'wx-hall/water-bill', name: 'wxWaterBill', component: () => import('@/views/wxhall/WaterBillView.vue') },
19
+      { path: 'wx-hall/install-apply', name: 'wxInstallApply', component: () => import('@/views/wxhall/InstallApplyView.vue') },
20
+      { path: 'wx-hall/notice', name: 'wxNotice', component: () => import('@/views/wxhall/NoticeView.vue') },
21
+      { path: 'wx-hall/user-bind', name: 'wxUserBind', component: () => import('@/views/wxhall/UserBindView.vue') },
17 22
     ]
18 23
   },
19 24
   { path: '/:pathMatch(.*)*', redirect: '/dashboard' }

+ 254
- 0
frontend/src/views/wxhall/InstallApplyView.vue 查看文件

@@ -0,0 +1,254 @@
1
+<template>
2
+  <div class="wx-install-apply">
3
+    <el-page-header @back="$router.back()" title="返回" content="报装申请" />
4
+
5
+    <!-- 申请表单 -->
6
+    <el-card shadow="never" style="margin-top: 16px">
7
+      <template #header>
8
+        <span>新建报装申请</span>
9
+      </template>
10
+      <el-form ref="formRef" :model="form" :rules="rules" label-width="100px" style="max-width: 600px">
11
+        <el-form-item label="申请人" prop="name">
12
+          <el-input v-model="form.name" placeholder="请输入申请人姓名" />
13
+        </el-form-item>
14
+        <el-form-item label="联系电话" prop="phone">
15
+          <el-input v-model="form.phone" placeholder="请输入手机号" maxlength="11" />
16
+        </el-form-item>
17
+        <el-form-item label="所在区域" prop="area">
18
+          <el-select v-model="form.area" placeholder="请选择区域" style="width: 100%">
19
+            <el-option label="城东片区" value="城东片区" />
20
+            <el-option label="城西片区" value="城西片区" />
21
+            <el-option label="城南片区" value="城南片区" />
22
+            <el-option label="城北片区" value="城北片区" />
23
+            <el-option label="开发区" value="开发区" />
24
+            <el-option label="高新区" value="高新区" />
25
+          </el-select>
26
+        </el-form-item>
27
+        <el-form-item label="详细地址" prop="address">
28
+          <el-input v-model="form.address" type="textarea" :rows="2" placeholder="请输入详细地址" />
29
+        </el-form-item>
30
+        <el-form-item label="用水类型">
31
+          <el-select v-model="form.customerType" style="width: 100%">
32
+            <el-option label="居民用水" value="resident" />
33
+            <el-option label="商业用水" value="commercial" />
34
+            <el-option label="工业用水" value="industrial" />
35
+            <el-option label="行政事业" value="public" />
36
+          </el-select>
37
+        </el-form-item>
38
+        <el-form-item label="口径">
39
+          <el-select v-model="form.caliber" style="width: 100%">
40
+            <el-option label="DN15" value="DN15" />
41
+            <el-option label="DN20" value="DN20" />
42
+            <el-option label="DN25" value="DN25" />
43
+            <el-option label="DN32" value="DN32" />
44
+            <el-option label="DN40" value="DN40" />
45
+            <el-option label="DN50" value="DN50" />
46
+          </el-select>
47
+        </el-form-item>
48
+        <el-form-item>
49
+          <el-button type="primary" @click="handleSubmit" :loading="submitting">提交申请</el-button>
50
+          <el-button @click="resetForm">重置</el-button>
51
+        </el-form-item>
52
+      </el-form>
53
+    </el-card>
54
+
55
+    <!-- 进度查询 -->
56
+    <el-card shadow="never" style="margin-top: 12px">
57
+      <template #header>
58
+        <span>进度查询</span>
59
+      </template>
60
+      <el-form :inline="true">
61
+        <el-form-item label="申请编号">
62
+          <el-input v-model="progressQueryNo" placeholder="输入申请编号查询" clearable />
63
+        </el-form-item>
64
+        <el-form-item>
65
+          <el-button type="primary" @click="queryProgress" :disabled="!progressQueryNo">查询</el-button>
66
+        </el-form-item>
67
+      </el-form>
68
+
69
+      <el-steps :active="progressStep" finish-status="success" align-center v-if="progressData"
70
+        style="margin-top: 20px">
71
+        <el-step title="预受理" :description="progressData.status === 'pre_apply' ? '当前阶段' : ''" />
72
+        <el-step title="工程审核" :description="progressData.status === 'engineering' ? '当前阶段' : ''" />
73
+        <el-step title="派单施工" :description="progressData.status === 'pending_review' ? '当前阶段' : ''" />
74
+        <el-step title="已完成" :description="progressData.status === 'completed' ? '当前阶段' : ''" />
75
+      </el-steps>
76
+
77
+      <el-descriptions :column="2" border style="margin-top: 16px" v-if="progressData">
78
+        <el-descriptions-item label="申请编号">{{ progressData.application_no }}</el-descriptions-item>
79
+        <el-descriptions-item label="申请人">{{ progressData.applicant_name }}</el-descriptions-item>
80
+        <el-descriptions-item label="联系电话">{{ progressData.applicant_phone }}</el-descriptions-item>
81
+        <el-descriptions-item label="区域">{{ progressData.area }}</el-descriptions-item>
82
+        <el-descriptions-item label="地址" :span="2">{{ progressData.address }}</el-descriptions-item>
83
+        <el-descriptions-item label="用水类型">{{ progressData.customer_type }}</el-descriptions-item>
84
+        <el-descriptions-item label="口径">{{ progressData.caliber }}</el-descriptions-item>
85
+        <el-descriptions-item label="状态">
86
+          <el-tag :type="installStatusTag(progressData.status)">{{ installStatusLabel(progressData.status) }}</el-tag>
87
+        </el-descriptions-item>
88
+        <el-descriptions-item label="更新时间">{{ progressData.updated_at }}</el-descriptions-item>
89
+      </el-descriptions>
90
+    </el-card>
91
+
92
+    <!-- 申请记录 -->
93
+    <el-card shadow="never" style="margin-top: 12px">
94
+      <template #header>
95
+        <div class="card-header">
96
+          <span>申请记录</span>
97
+        </div>
98
+      </template>
99
+      <el-form :inline="true" style="margin-bottom: 12px">
100
+        <el-form-item label="手机号">
101
+          <el-input v-model="recordPhone" placeholder="输入手机号查询" clearable />
102
+        </el-form-item>
103
+        <el-form-item>
104
+          <el-button type="primary" @click="fetchRecords">查询</el-button>
105
+        </el-form-item>
106
+      </el-form>
107
+
108
+      <el-table :data="records" border stripe v-loading="recordLoading">
109
+        <el-table-column prop="application_no" label="申请编号" min-width="180" />
110
+        <el-table-column prop="applicant_name" label="申请人" width="100" />
111
+        <el-table-column prop="area" label="区域" width="100" />
112
+        <el-table-column prop="address" label="地址" min-width="200" show-overflow-tooltip />
113
+        <el-table-column prop="customer_type" label="用水类型" width="100" />
114
+        <el-table-column prop="caliber" label="口径" width="80" />
115
+        <el-table-column prop="status" label="状态" width="100">
116
+          <template #default="{ row }">
117
+            <el-tag :type="installStatusTag(row.status)" size="small">{{ installStatusLabel(row.status) }}</el-tag>
118
+          </template>
119
+        </el-table-column>
120
+        <el-table-column prop="created_at" label="申请时间" width="170" />
121
+        <el-table-column label="操作" width="100" fixed="right">
122
+          <template #default="{ row }">
123
+            <el-button link type="primary" @click="viewRecordProgress(row)">进度</el-button>
124
+          </template>
125
+        </el-table-column>
126
+      </el-table>
127
+    </el-card>
128
+  </div>
129
+</template>
130
+
131
+<script setup lang="ts">
132
+import { ref, reactive } from 'vue'
133
+import { ElMessage } from 'element-plus'
134
+import type { FormInstance } from 'element-plus'
135
+import { submitInstallApply, getInstallProgress, getInstallList } from '@/api/wx-hall'
136
+
137
+const formRef = ref<FormInstance>()
138
+const submitting = ref(false)
139
+const progressQueryNo = ref('')
140
+const progressData = ref<any>(null)
141
+const progressStep = ref(0)
142
+const recordPhone = ref('')
143
+const records = ref<any[]>([])
144
+const recordLoading = ref(false)
145
+
146
+const form = reactive({
147
+  name: '',
148
+  phone: '',
149
+  area: '',
150
+  address: '',
151
+  customerType: 'resident',
152
+  caliber: 'DN15'
153
+})
154
+
155
+const rules = {
156
+  name: [{ required: true, message: '请输入申请人姓名', trigger: 'blur' }],
157
+  phone: [
158
+    { required: true, message: '请输入手机号', trigger: 'blur' },
159
+    { pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }
160
+  ],
161
+  area: [{ required: true, message: '请选择区域', trigger: 'change' }],
162
+  address: [{ required: true, message: '请输入详细地址', trigger: 'blur' }]
163
+}
164
+
165
+async function handleSubmit() {
166
+  if (!formRef.value) return
167
+  await formRef.value.validate()
168
+  submitting.value = true
169
+  try {
170
+    const res = await submitInstallApply(form)
171
+    ElMessage.success(`申请提交成功!申请编号: ${res.data?.applicationNo}`)
172
+    resetForm()
173
+  } catch (e) { /* ignore */ } finally {
174
+    submitting.value = false
175
+  }
176
+}
177
+
178
+function resetForm() {
179
+  formRef.value?.resetFields()
180
+  Object.assign(form, { name: '', phone: '', area: '', address: '', customerType: 'resident', caliber: 'DN15' })
181
+}
182
+
183
+async function queryProgress() {
184
+  if (!progressQueryNo.value) return
185
+  try {
186
+    const res = await getInstallProgress(progressQueryNo.value)
187
+    progressData.value = res.data
188
+    progressStep.value = calcStep(res.data?.status)
189
+  } catch (e) {
190
+    progressData.value = null
191
+    progressStep.value = 0
192
+  }
193
+}
194
+
195
+async function fetchRecords() {
196
+  if (!recordPhone.value) {
197
+    ElMessage.warning('请输入手机号')
198
+    return
199
+  }
200
+  recordLoading.value = true
201
+  try {
202
+    const res = await getInstallList({ phone: recordPhone.value, limit: 50 })
203
+    records.value = res.data || []
204
+  } finally {
205
+    recordLoading.value = false
206
+  }
207
+}
208
+
209
+function viewRecordProgress(row: any) {
210
+  progressQueryNo.value = row.application_no
211
+  queryProgress()
212
+}
213
+
214
+function calcStep(status: string): number {
215
+  const map: Record<string, number> = {
216
+    pre_apply: 0,
217
+    engineering: 1,
218
+    pending_review: 2,
219
+    completed: 3
220
+  }
221
+  return map[status] ?? 0
222
+}
223
+
224
+function installStatusLabel(s: string): string {
225
+  const map: Record<string, string> = {
226
+    pre_apply: '预受理',
227
+    engineering: '工程审核',
228
+    pending_review: '派单施工',
229
+    completed: '已完成'
230
+  }
231
+  return map[s] || s
232
+}
233
+
234
+function installStatusTag(s: string): any {
235
+  const map: Record<string, string> = {
236
+    pre_apply: 'info',
237
+    engineering: 'warning',
238
+    pending_review: '',
239
+    completed: 'success'
240
+  }
241
+  return map[s] || 'info'
242
+}
243
+</script>
244
+
245
+<style scoped>
246
+.wx-install-apply {
247
+  padding: 20px;
248
+}
249
+.card-header {
250
+  display: flex;
251
+  justify-content: space-between;
252
+  align-items: center;
253
+}
254
+</style>

+ 217
- 0
frontend/src/views/wxhall/NoticeView.vue 查看文件

@@ -0,0 +1,217 @@
1
+<template>
2
+  <div class="wx-notice">
3
+    <el-page-header @back="$router.back()" title="返回" content="停水公告" />
4
+
5
+    <!-- 搜索和筛选 -->
6
+    <el-card shadow="never" style="margin-top: 16px">
7
+      <el-form :inline="true" :model="queryForm">
8
+        <el-form-item label="关键词">
9
+          <el-input v-model="queryForm.keyword" placeholder="搜索公告标题/内容" clearable @clear="handleSearch" />
10
+        </el-form-item>
11
+        <el-form-item label="类型">
12
+          <el-select v-model="queryForm.type" placeholder="全部" clearable @change="handleSearch">
13
+            <el-option label="停水" value="water_outage" />
14
+            <el-option label="水质" value="water_quality" />
15
+            <el-option label="维修" value="maintenance" />
16
+            <el-option label="其他" value="other" />
17
+          </el-select>
18
+        </el-form-item>
19
+        <el-form-item>
20
+          <el-button type="primary" @click="handleSearch">搜索</el-button>
21
+        </el-form-item>
22
+      </el-form>
23
+    </el-card>
24
+
25
+    <!-- 公告列表 -->
26
+    <div class="notice-list" style="margin-top: 12px">
27
+      <el-card shadow="hover" v-for="item in noticeList" :key="item.id" class="notice-item"
28
+        @click="viewDetail(item)">
29
+        <div class="notice-header">
30
+          <div class="notice-title">
31
+            <el-tag :type="typeTag(item.type)" size="small" class="type-tag">{{ typeLabel(item.type) }}</el-tag>
32
+            <el-tag :type="priorityTag(item.priority)" size="small" v-if="item.priority === 'urgent' || item.priority === 'high'" class="priority-tag">
33
+              {{ priorityLabel(item.priority) }}
34
+            </el-tag>
35
+            <span class="title-text">{{ item.title }}</span>
36
+          </div>
37
+          <span class="notice-time">{{ item.publishTime }}</span>
38
+        </div>
39
+        <div class="notice-meta">
40
+          <span v-if="item.affectedArea">📍 {{ item.affectedArea }}</span>
41
+          <span v-if="item.plannedStart">🕐 {{ item.plannedStart }} ~ {{ item.plannedEnd }}</span>
42
+        </div>
43
+        <div class="notice-preview">{{ truncate(item.content, 100) }}</div>
44
+      </el-card>
45
+
46
+      <el-empty v-if="!loading && noticeList.length === 0" description="暂无公告" />
47
+    </div>
48
+
49
+    <!-- 分页 -->
50
+    <el-pagination style="margin-top: 16px; justify-content: flex-end"
51
+      v-model:current-page="pagination.page" v-model:page-size="pagination.size"
52
+      :total="pagination.total" :page-sizes="[10, 20, 50]"
53
+      layout="total, sizes, prev, pager, next" @change="fetchNotices" />
54
+
55
+    <!-- 详情弹窗 -->
56
+    <el-dialog v-model="showDetail" :title="detailItem?.title" width="650px" top="5vh">
57
+      <el-descriptions :column="2" border>
58
+        <el-descriptions-item label="类型">
59
+          <el-tag :type="typeTag(detailItem?.type)">{{ typeLabel(detailItem?.type) }}</el-tag>
60
+        </el-descriptions-item>
61
+        <el-descriptions-item label="优先级">
62
+          <el-tag :type="priorityTag(detailItem?.priority)">{{ priorityLabel(detailItem?.priority) }}</el-tag>
63
+        </el-descriptions-item>
64
+        <el-descriptions-item label="影响范围" :span="2">{{ detailItem?.affectedArea }}</el-descriptions-item>
65
+        <el-descriptions-item label="计划开始">{{ detailItem?.plannedStart }}</el-descriptions-item>
66
+        <el-descriptions-item label="计划结束">{{ detailItem?.plannedEnd }}</el-descriptions-item>
67
+        <el-descriptions-item label="发布时间">{{ detailItem?.publishTime }}</el-descriptions-item>
68
+        <el-descriptions-item label="发布人">{{ detailItem?.publisherName }}</el-descriptions-item>
69
+      </el-descriptions>
70
+      <el-divider />
71
+      <div class="detail-content">{{ detailItem?.content }}</div>
72
+    </el-dialog>
73
+  </div>
74
+</template>
75
+
76
+<script setup lang="ts">
77
+import { ref, reactive, onMounted } from 'vue'
78
+import { getNoticeList, getNoticeDetail } from '@/api/wx-hall'
79
+
80
+const loading = ref(false)
81
+const showDetail = ref(false)
82
+const noticeList = ref<any[]>([])
83
+const detailItem = ref<any>(null)
84
+
85
+const queryForm = reactive({
86
+  keyword: '',
87
+  type: ''
88
+})
89
+
90
+const pagination = reactive({
91
+  page: 1,
92
+  size: 10,
93
+  total: 0
94
+})
95
+
96
+onMounted(() => {
97
+  fetchNotices()
98
+})
99
+
100
+function handleSearch() {
101
+  pagination.page = 1
102
+  fetchNotices()
103
+}
104
+
105
+async function fetchNotices() {
106
+  loading.value = true
107
+  try {
108
+    const res = await getNoticeList({
109
+      page: pagination.page,
110
+      size: pagination.size,
111
+      type: queryForm.type || undefined,
112
+      keyword: queryForm.keyword || undefined
113
+    })
114
+    noticeList.value = res.data?.records || []
115
+    pagination.total = res.data?.total || 0
116
+  } finally {
117
+    loading.value = false
118
+  }
119
+}
120
+
121
+async function viewDetail(row: any) {
122
+  try {
123
+    const res = await getNoticeDetail(row.id)
124
+    detailItem.value = res.data
125
+    showDetail.value = true
126
+  } catch (e) {
127
+    // fallback to list item data
128
+    detailItem.value = row
129
+    showDetail.value = true
130
+  }
131
+}
132
+
133
+function truncate(text: string, maxLen: number) {
134
+  if (!text) return ''
135
+  return text.length > maxLen ? text.slice(0, maxLen) + '...' : text
136
+}
137
+
138
+const typeMap: Record<string, string> = {
139
+  water_outage: '停水',
140
+  water_quality: '水质',
141
+  maintenance: '维修',
142
+  other: '其他'
143
+}
144
+
145
+const typeTagMap: Record<string, string> = {
146
+  water_outage: 'danger',
147
+  water_quality: 'warning',
148
+  maintenance: '',
149
+  other: 'info'
150
+}
151
+
152
+function typeLabel(t: string) { return typeMap[t] || t }
153
+function typeTag(t: string): any { return typeTagMap[t] || 'info' }
154
+
155
+function priorityLabel(p: string) {
156
+  return { low: '低', medium: '中', high: '高', urgent: '紧急' }[p] || p
157
+}
158
+
159
+function priorityTag(p: string): any {
160
+  return { low: 'info', medium: '', high: 'warning', urgent: 'danger' }[p] || 'info'
161
+}
162
+</script>
163
+
164
+<style scoped>
165
+.wx-notice {
166
+  padding: 20px;
167
+}
168
+.notice-item {
169
+  margin-bottom: 10px;
170
+  cursor: pointer;
171
+  transition: box-shadow 0.2s;
172
+}
173
+.notice-item:hover {
174
+  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
175
+}
176
+.notice-header {
177
+  display: flex;
178
+  justify-content: space-between;
179
+  align-items: center;
180
+  margin-bottom: 8px;
181
+}
182
+.notice-title {
183
+  display: flex;
184
+  align-items: center;
185
+  gap: 6px;
186
+  flex: 1;
187
+}
188
+.title-text {
189
+  font-size: 16px;
190
+  font-weight: 600;
191
+  color: #303133;
192
+}
193
+.notice-time {
194
+  font-size: 12px;
195
+  color: #909399;
196
+  white-space: nowrap;
197
+  margin-left: 12px;
198
+}
199
+.notice-meta {
200
+  display: flex;
201
+  gap: 16px;
202
+  font-size: 13px;
203
+  color: #606266;
204
+  margin-bottom: 6px;
205
+}
206
+.notice-preview {
207
+  font-size: 13px;
208
+  color: #909399;
209
+  line-height: 1.6;
210
+}
211
+.detail-content {
212
+  white-space: pre-wrap;
213
+  line-height: 1.8;
214
+  font-size: 14px;
215
+  color: #303133;
216
+}
217
+</style>

+ 188
- 0
frontend/src/views/wxhall/UserBindView.vue 查看文件

@@ -0,0 +1,188 @@
1
+<template>
2
+  <div class="wx-user-bind">
3
+    <el-page-header @back="$router.back()" title="返回" content="用户绑定" />
4
+
5
+    <!-- OpenID 输入(模拟微信环境) -->
6
+    <el-card shadow="never" style="margin-top: 16px">
7
+      <el-form :inline="true">
8
+        <el-form-item label="OpenID">
9
+          <el-input v-model="openId" placeholder="请输入微信 OpenID" clearable @clear="handleOpenIdChange" />
10
+        </el-form-item>
11
+        <el-form-item>
12
+          <el-button type="primary" @click="handleOpenIdChange" :disabled="!openId">确认</el-button>
13
+        </el-form-item>
14
+      </el-form>
15
+    </el-card>
16
+
17
+    <template v-if="openId">
18
+      <!-- 手机号绑定 -->
19
+      <el-card shadow="never" style="margin-top: 12px">
20
+        <template #header>
21
+          <div class="card-header">
22
+            <span>📱 手机号绑定</span>
23
+          </div>
24
+        </template>
25
+        <el-form :inline="true" :model="phoneForm">
26
+          <el-form-item label="手机号">
27
+            <el-input v-model="phoneForm.phone" placeholder="请输入手机号" maxlength="11" clearable />
28
+          </el-form-item>
29
+          <el-form-item>
30
+            <el-button type="primary" @click="handleBindPhone" :loading="phoneLoading"
31
+              :disabled="!phoneForm.phone">绑定手机号</el-button>
32
+          </el-form-item>
33
+        </el-form>
34
+      </el-card>
35
+
36
+      <!-- 户号绑定 -->
37
+      <el-card shadow="never" style="margin-top: 12px">
38
+        <template #header>
39
+          <div class="card-header">
40
+            <span>🏠 户号绑定</span>
41
+          </div>
42
+        </template>
43
+        <el-form :inline="true" :model="customerForm">
44
+          <el-form-item label="户号">
45
+            <el-input v-model="customerForm.customerNo" placeholder="请输入水费户号" clearable />
46
+          </el-form-item>
47
+          <el-form-item>
48
+            <el-button type="primary" @click="handleBindCustomer" :loading="customerLoading"
49
+              :disabled="!customerForm.customerNo">绑定户号</el-button>
50
+          </el-form-item>
51
+        </el-form>
52
+      </el-card>
53
+
54
+      <!-- 已绑定列表 -->
55
+      <el-card shadow="never" style="margin-top: 12px">
56
+        <template #header>
57
+          <div class="card-header">
58
+            <span>已绑定列表</span>
59
+            <el-button link type="primary" @click="fetchBindings">刷新</el-button>
60
+          </div>
61
+        </template>
62
+
63
+        <el-table :data="bindings" border stripe v-loading="bindingsLoading">
64
+          <el-table-column prop="id" label="ID" width="70" />
65
+          <el-table-column prop="bindingType" label="类型" width="100">
66
+            <template #default="{ row }">
67
+              <el-tag :type="row.binding_type === 'phone' ? '' : 'success'" size="small">
68
+                {{ row.binding_type === 'phone' ? '手机号' : '户号' }}
69
+              </el-tag>
70
+            </template>
71
+          </el-table-column>
72
+          <el-table-column prop="phone" label="手机号" width="140">
73
+            <template #default="{ row }">{{ row.phone || '-' }}</template>
74
+          </el-table-column>
75
+          <el-table-column prop="customerNo" label="户号" width="140">
76
+            <template #default="{ row }">{{ row.customer_no || '-' }}</template>
77
+          </el-table-column>
78
+          <el-table-column prop="customerName" label="客户名称" width="140">
79
+            <template #default="{ row }">{{ row.customer_name || '-' }}</template>
80
+          </el-table-column>
81
+          <el-table-column prop="createdAt" label="绑定时间" min-width="170">
82
+            <template #default="{ row }">{{ row.created_at }}</template>
83
+          </el-table-column>
84
+          <el-table-column label="操作" width="100" fixed="right">
85
+            <template #default="{ row }">
86
+              <el-popconfirm title="确定解绑吗?" @confirm="handleUnbind(row)">
87
+                <template #reference>
88
+                  <el-button link type="danger">解绑</el-button>
89
+                </template>
90
+              </el-popconfirm>
91
+            </template>
92
+          </el-table-column>
93
+        </el-table>
94
+
95
+        <el-empty v-if="!bindingsLoading && bindings.length === 0" description="暂无绑定记录" />
96
+      </el-card>
97
+    </template>
98
+  </div>
99
+</template>
100
+
101
+<script setup lang="ts">
102
+import { ref, reactive } from 'vue'
103
+import { ElMessage } from 'element-plus'
104
+import { bindPhone, bindCustomer, unbind, getBindings } from '@/api/wx-hall'
105
+
106
+const openId = ref('')
107
+const phoneLoading = ref(false)
108
+const customerLoading = ref(false)
109
+const bindingsLoading = ref(false)
110
+const bindings = ref<any[]>([])
111
+
112
+const phoneForm = reactive({ phone: '' })
113
+const customerForm = reactive({ customerNo: '' })
114
+
115
+function handleOpenIdChange() {
116
+  if (!openId.value) {
117
+    ElMessage.warning('请输入 OpenID')
118
+    return
119
+  }
120
+  fetchBindings()
121
+}
122
+
123
+async function handleBindPhone() {
124
+  if (!phoneForm.phone) {
125
+    ElMessage.warning('请输入手机号')
126
+    return
127
+  }
128
+  if (!/^1[3-9]\d{9}$/.test(phoneForm.phone)) {
129
+    ElMessage.warning('手机号格式不正确')
130
+    return
131
+  }
132
+  phoneLoading.value = true
133
+  try {
134
+    await bindPhone({ openId: openId.value, phone: phoneForm.phone })
135
+    ElMessage.success('手机号绑定成功')
136
+    phoneForm.phone = ''
137
+    fetchBindings()
138
+  } catch (e) { /* ignore */ } finally {
139
+    phoneLoading.value = false
140
+  }
141
+}
142
+
143
+async function handleBindCustomer() {
144
+  if (!customerForm.customerNo) {
145
+    ElMessage.warning('请输入户号')
146
+    return
147
+  }
148
+  customerLoading.value = true
149
+  try {
150
+    await bindCustomer({ openId: openId.value, customerNo: customerForm.customerNo })
151
+    ElMessage.success('户号绑定成功')
152
+    customerForm.customerNo = ''
153
+    fetchBindings()
154
+  } catch (e) { /* ignore */ } finally {
155
+    customerLoading.value = false
156
+  }
157
+}
158
+
159
+async function handleUnbind(row: any) {
160
+  try {
161
+    await unbind({ openId: openId.value, bindingId: String(row.id) })
162
+    ElMessage.success('解绑成功')
163
+    fetchBindings()
164
+  } catch (e) { /* ignore */ }
165
+}
166
+
167
+async function fetchBindings() {
168
+  if (!openId.value) return
169
+  bindingsLoading.value = true
170
+  try {
171
+    const res = await getBindings(openId.value)
172
+    bindings.value = res.data || []
173
+  } finally {
174
+    bindingsLoading.value = false
175
+  }
176
+}
177
+</script>
178
+
179
+<style scoped>
180
+.wx-user-bind {
181
+  padding: 20px;
182
+}
183
+.card-header {
184
+  display: flex;
185
+  justify-content: space-between;
186
+  align-items: center;
187
+}
188
+</style>

+ 287
- 0
frontend/src/views/wxhall/WaterBillView.vue 查看文件

@@ -0,0 +1,287 @@
1
+<template>
2
+  <div class="wx-water-bill">
3
+    <el-page-header @back="$router.back()" title="返回" content="水费查询缴费" />
4
+
5
+    <!-- 用户绑定区域 -->
6
+    <el-card shadow="never" style="margin-top: 16px">
7
+      <el-form :inline="true" :model="queryForm">
8
+        <el-form-item label="户号">
9
+          <el-input v-model="queryForm.customerNo" placeholder="请输入户号" clearable @clear="handleQuery" />
10
+        </el-form-item>
11
+        <el-form-item label="账单周期">
12
+          <el-date-picker v-model="queryForm.billPeriod" type="month" placeholder="选择月份"
13
+            value-format="YYYY-MM" clearable @change="handleQuery" />
14
+        </el-form-item>
15
+        <el-form-item label="状态">
16
+          <el-select v-model="queryForm.status" placeholder="全部" clearable @change="handleQuery">
17
+            <el-option label="待缴" value="pending" />
18
+            <el-option label="已缴" value="paid" />
19
+            <el-option label="部分" value="partial" />
20
+            <el-option label="逾期" value="overdue" />
21
+          </el-select>
22
+        </el-form-item>
23
+        <el-form-item>
24
+          <el-button type="primary" @click="handleQuery">查询</el-button>
25
+        </el-form-item>
26
+      </el-form>
27
+    </el-card>
28
+
29
+    <!-- 账单列表 -->
30
+    <el-card shadow="never" style="margin-top: 12px">
31
+      <template #header>
32
+        <div class="card-header">
33
+          <span>账单列表</span>
34
+          <el-tag v-if="totalUnpaid > 0" type="danger">待缴 ¥{{ totalUnpaid.toFixed(2) }}</el-tag>
35
+        </div>
36
+      </template>
37
+
38
+      <el-table :data="billList" v-loading="loading" border stripe>
39
+        <el-table-column prop="billNo" label="账单编号" min-width="180" show-overflow-tooltip />
40
+        <el-table-column prop="billPeriod" label="账单周期" width="110" />
41
+        <el-table-column prop="waterUsage" label="用水量(吨)" width="100" align="right">
42
+          <template #default="{ row }">{{ Number(row.waterUsage).toFixed(1) }}</template>
43
+        </el-table-column>
44
+        <el-table-column prop="totalAmount" label="应缴(元)" width="100" align="right">
45
+          <template #default="{ row }">{{ Number(row.totalAmount).toFixed(2) }}</template>
46
+        </el-table-column>
47
+        <el-table-column prop="paidAmount" label="已缴(元)" width="100" align="right">
48
+          <template #default="{ row }">{{ Number(row.paidAmount).toFixed(2) }}</template>
49
+        </el-table-column>
50
+        <el-table-column prop="status" label="状态" width="90" align="center">
51
+          <template #default="{ row }">
52
+            <el-tag :type="statusTag(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
53
+          </template>
54
+        </el-table-column>
55
+        <el-table-column prop="dueDate" label="到期日" width="110" />
56
+        <el-table-column label="操作" width="160" fixed="right">
57
+          <template #default="{ row }">
58
+            <el-button link type="primary" @click="viewDetail(row)">详情</el-button>
59
+            <el-button link type="success" v-if="row.status !== 'paid' && row.status !== 'cancelled'"
60
+              @click="openPayDialog(row)">缴费</el-button>
61
+          </template>
62
+        </el-table-column>
63
+      </el-table>
64
+
65
+      <el-pagination style="margin-top: 16px; justify-content: flex-end"
66
+        v-model:current-page="pagination.pageNum" v-model:page-size="pagination.pageSize"
67
+        :total="pagination.total" :page-sizes="[10, 20, 50]"
68
+        layout="total, sizes, prev, pager, next" @change="fetchBills" />
69
+    </el-card>
70
+
71
+    <!-- 缴费记录 -->
72
+    <el-card shadow="never" style="margin-top: 12px">
73
+      <template #header>
74
+        <div class="card-header">
75
+          <span>缴费记录</span>
76
+          <el-button link type="primary" @click="fetchPaymentRecords">刷新</el-button>
77
+        </div>
78
+      </template>
79
+      <el-table :data="paymentRecords" border stripe size="small">
80
+        <el-table-column prop="paymentNo" label="流水号" min-width="180" show-overflow-tooltip />
81
+        <el-table-column prop="billNo" label="账单编号" width="180" show-overflow-tooltip />
82
+        <el-table-column prop="amount" label="金额(元)" width="100" align="right">
83
+          <template #default="{ row }">{{ Number(row.amount).toFixed(2) }}</template>
84
+        </el-table-column>
85
+        <el-table-column prop="channelName" label="渠道" width="100" />
86
+        <el-table-column prop="status" label="状态" width="90">
87
+          <template #default="{ row }">
88
+            <el-tag :type="row.status === 'success' ? 'success' : 'info'" size="small">
89
+              {{ row.status === 'success' ? '成功' : row.status }}
90
+            </el-tag>
91
+          </template>
92
+        </el-table-column>
93
+        <el-table-column prop="payTime" label="支付时间" width="170" />
94
+      </el-table>
95
+    </el-card>
96
+
97
+    <!-- 账单详情弹窗 -->
98
+    <el-dialog v-model="showDetail" title="账单详情" width="550px">
99
+      <el-descriptions :column="2" border v-if="detailData">
100
+        <el-descriptions-item label="账单编号">{{ detailData.bill?.billNo }}</el-descriptions-item>
101
+        <el-descriptions-item label="账单周期">{{ detailData.bill?.billPeriod }}</el-descriptions-item>
102
+        <el-descriptions-item label="客户名称">{{ detailData.bill?.customerName }}</el-descriptions-item>
103
+        <el-descriptions-item label="户号">{{ detailData.bill?.customerNo }}</el-descriptions-item>
104
+        <el-descriptions-item label="水表编号">{{ detailData.bill?.meterNo }}</el-descriptions-item>
105
+        <el-descriptions-item label="用水量">{{ Number(detailData.bill?.waterUsage || 0).toFixed(1) }} 吨</el-descriptions-item>
106
+        <el-descriptions-item label="单价">¥{{ Number(detailData.bill?.unitPrice || 0).toFixed(2) }}/吨</el-descriptions-item>
107
+        <el-descriptions-item label="应缴金额">¥{{ Number(detailData.bill?.totalAmount || 0).toFixed(2) }}</el-descriptions-item>
108
+        <el-descriptions-item label="已缴金额">¥{{ Number(detailData.bill?.paidAmount || 0).toFixed(2) }}</el-descriptions-item>
109
+        <el-descriptions-item label="剩余金额">
110
+          <el-tag :type="detailData.remainingAmount > 0 ? 'danger' : 'success'">
111
+            ¥{{ Number(detailData.remainingAmount || 0).toFixed(2) }}
112
+          </el-tag>
113
+        </el-descriptions-item>
114
+        <el-descriptions-item label="抄表读数">
115
+          {{ detailData.bill?.prevReading }} → {{ detailData.bill?.currReading }}
116
+        </el-descriptions-item>
117
+        <el-descriptions-item label="状态">
118
+          <el-tag :type="statusTag(detailData.bill?.status)">{{ statusLabel(detailData.bill?.status) }}</el-tag>
119
+        </el-descriptions-item>
120
+      </el-descriptions>
121
+
122
+      <el-divider v-if="detailData?.payments?.length > 0">缴费记录</el-divider>
123
+      <el-table :data="detailData?.payments || []" size="small" border v-if="detailData?.payments?.length > 0">
124
+        <el-table-column prop="paymentNo" label="流水号" />
125
+        <el-table-column prop="amount" label="金额" width="100" />
126
+        <el-table-column prop="channelName" label="渠道" width="100" />
127
+        <el-table-column prop="payTime" label="时间" width="170" />
128
+      </el-table>
129
+    </el-dialog>
130
+
131
+    <!-- 缴费弹窗 -->
132
+    <el-dialog v-model="showPay" title="在线缴费" width="420px">
133
+      <div v-if="payingBill" class="pay-info">
134
+        <el-descriptions :column="1" border>
135
+          <el-descriptions-item label="账单编号">{{ payingBill.billNo }}</el-descriptions-item>
136
+          <el-descriptions-item label="账单周期">{{ payingBill.billPeriod }}</el-descriptions-item>
137
+          <el-descriptions-item label="应缴金额">¥{{ Number(payingBill.totalAmount).toFixed(2) }}</el-descriptions-item>
138
+          <el-descriptions-item label="已缴金额">¥{{ Number(payingBill.paidAmount).toFixed(2) }}</el-descriptions-item>
139
+        </el-descriptions>
140
+        <el-divider />
141
+        <el-form-item label="缴费金额">
142
+          <el-input-number v-model="payAmount" :min="0.01"
143
+            :max="Number(payingBill.totalAmount) - Number(payingBill.paidAmount)"
144
+            :precision="2" :step="1" style="width: 100%" />
145
+        </el-form-item>
146
+      </div>
147
+      <template #footer>
148
+        <el-button @click="showPay = false">取消</el-button>
149
+        <el-button type="primary" :loading="payLoading" @click="handlePay">确认缴费</el-button>
150
+      </template>
151
+    </el-dialog>
152
+  </div>
153
+</template>
154
+
155
+<script setup lang="ts">
156
+import { ref, reactive, computed, onMounted } from 'vue'
157
+import { ElMessage } from 'element-plus'
158
+import { getBillList, getBillDetail, payBill, getPaymentRecords } from '@/api/wx-hall'
159
+
160
+const loading = ref(false)
161
+const payLoading = ref(false)
162
+const showDetail = ref(false)
163
+const showPay = ref(false)
164
+const billList = ref<any[]>([])
165
+const paymentRecords = ref<any[]>([])
166
+const detailData = ref<any>(null)
167
+const payingBill = ref<any>(null)
168
+const payAmount = ref(0)
169
+
170
+const queryForm = reactive({
171
+  customerNo: '',
172
+  billPeriod: '',
173
+  status: ''
174
+})
175
+
176
+const pagination = reactive({
177
+  pageNum: 1,
178
+  pageSize: 10,
179
+  total: 0
180
+})
181
+
182
+const totalUnpaid = computed(() => {
183
+  return billList.value
184
+    .filter(b => b.status !== 'paid' && b.status !== 'cancelled')
185
+    .reduce((sum, b) => sum + (Number(b.totalAmount) - Number(b.paidAmount)), 0)
186
+})
187
+
188
+onMounted(() => {
189
+  // 自动加载(需要提供 customerNo)
190
+})
191
+
192
+function handleQuery() {
193
+  if (!queryForm.customerNo) {
194
+    ElMessage.warning('请输入户号')
195
+    return
196
+  }
197
+  pagination.pageNum = 1
198
+  fetchBills()
199
+  fetchPaymentRecords()
200
+}
201
+
202
+async function fetchBills() {
203
+  if (!queryForm.customerNo) return
204
+  loading.value = true
205
+  try {
206
+    const res = await getBillList({
207
+      customerNo: queryForm.customerNo,
208
+      billPeriod: queryForm.billPeriod || undefined,
209
+      status: queryForm.status || undefined,
210
+      pageNum: pagination.pageNum,
211
+      pageSize: pagination.pageSize
212
+    })
213
+    billList.value = res.data?.records || []
214
+    pagination.total = res.data?.total || 0
215
+  } finally {
216
+    loading.value = false
217
+  }
218
+}
219
+
220
+async function fetchPaymentRecords() {
221
+  if (!queryForm.customerNo) return
222
+  try {
223
+    const res = await getPaymentRecords({ customerNo: queryForm.customerNo, limit: 20 })
224
+    paymentRecords.value = res.data || []
225
+  } catch (e) { /* ignore */ }
226
+}
227
+
228
+async function viewDetail(row: any) {
229
+  try {
230
+    const res = await getBillDetail(row.id)
231
+    detailData.value = res.data
232
+    showDetail.value = true
233
+  } catch (e) { /* ignore */ }
234
+}
235
+
236
+function openPayDialog(row: any) {
237
+  payingBill.value = row
238
+  const remaining = Number(row.totalAmount) - Number(row.paidAmount)
239
+  payAmount.value = Math.round(remaining * 100) / 100
240
+  showPay.value = true
241
+}
242
+
243
+async function handlePay() {
244
+  if (!payingBill.value || payAmount.value <= 0) {
245
+    ElMessage.warning('请输入正确的缴费金额')
246
+    return
247
+  }
248
+  payLoading.value = true
249
+  try {
250
+    await payBill({ billId: payingBill.value.id, amount: payAmount.value })
251
+    ElMessage.success('缴费成功!')
252
+    showPay.value = false
253
+    fetchBills()
254
+    fetchPaymentRecords()
255
+  } catch (e) { /* ignore */ } finally {
256
+    payLoading.value = false
257
+  }
258
+}
259
+
260
+function statusLabel(s: string) {
261
+  const map: Record<string, string> = {
262
+    pending: '待缴', paid: '已缴', partial: '部分', overdue: '逾期', cancelled: '已作废'
263
+  }
264
+  return map[s] || s
265
+}
266
+
267
+function statusTag(s: string): any {
268
+  const map: Record<string, string> = {
269
+    pending: 'warning', paid: 'success', partial: '', overdue: 'danger', cancelled: 'info'
270
+  }
271
+  return map[s] || 'info'
272
+}
273
+</script>
274
+
275
+<style scoped>
276
+.wx-water-bill {
277
+  padding: 20px;
278
+}
279
+.card-header {
280
+  display: flex;
281
+  justify-content: space-between;
282
+  align-items: center;
283
+}
284
+.pay-info {
285
+  padding: 0 12px;
286
+}
287
+</style>

+ 51
- 0
wm-revenue/src/main/java/com/water/revenue/controller/BillQueryController.java 查看文件

@@ -0,0 +1,51 @@
1
+package com.water.revenue.controller;
2
+
3
+import com.water.common.core.result.R;
4
+import com.water.revenue.dto.BillQueryResult;
5
+import com.water.revenue.service.BillQueryService;
6
+import io.swagger.v3.oas.annotations.Operation;
7
+import io.swagger.v3.oas.annotations.tags.Tag;
8
+import lombok.RequiredArgsConstructor;
9
+import org.springframework.web.bind.annotation.*;
10
+
11
+import java.util.List;
12
+import java.util.Map;
13
+
14
+@Tag(name = "水费查询(客服工作台)")
15
+@RestController
16
+@RequestMapping("/api/revenue/cs/bill-query")
17
+@RequiredArgsConstructor
18
+public class BillQueryController {
19
+
20
+    private final BillQueryService billQueryService;
21
+
22
+    @GetMapping("/by-customer/{customerNo}")
23
+    @Operation(summary = "按户号查询水费")
24
+    public R<BillQueryResult> queryByCustomerNo(@PathVariable String customerNo) {
25
+        return R.ok(billQueryService.queryByCustomerNo(customerNo));
26
+    }
27
+
28
+    @GetMapping("/by-phone/{phone}")
29
+    @Operation(summary = "按手机号查询水费")
30
+    public R<BillQueryResult> queryByPhone(@PathVariable String phone) {
31
+        return R.ok(billQueryService.queryByPhone(phone));
32
+    }
33
+
34
+    @GetMapping("/by-address")
35
+    @Operation(summary = "按地址查询水费")
36
+    public R<BillQueryResult> queryByAddress(@RequestParam String address) {
37
+        return R.ok(billQueryService.queryByAddress(address));
38
+    }
39
+
40
+    @GetMapping("/detail/{billId}")
41
+    @Operation(summary = "账单明细(含缴费记录)")
42
+    public R<Map<String, Object>> getBillDetail(@PathVariable Long billId) {
43
+        return R.ok(billQueryService.getBillDetail(billId));
44
+    }
45
+
46
+    @GetMapping("/arrears/{customerNo}")
47
+    @Operation(summary = "欠费查询")
48
+    public R<List<BillQueryResult.BillSummary>> queryArrears(@PathVariable String customerNo) {
49
+        return R.ok(billQueryService.queryArrears(customerNo));
50
+    }
51
+}

+ 85
- 0
wm-revenue/src/main/java/com/water/revenue/controller/CsWorkbenchController.java 查看文件

@@ -0,0 +1,85 @@
1
+package com.water.revenue.controller;
2
+
3
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
4
+import com.water.common.core.result.R;
5
+import com.water.revenue.dto.CsWorkbenchStats;
6
+import com.water.revenue.entity.CsWorkItem;
7
+import com.water.revenue.service.CsWorkbenchService;
8
+import io.swagger.v3.oas.annotations.Operation;
9
+import io.swagger.v3.oas.annotations.tags.Tag;
10
+import lombok.RequiredArgsConstructor;
11
+import org.springframework.web.bind.annotation.*;
12
+
13
+import java.util.List;
14
+import java.util.Map;
15
+
16
+@Tag(name = "客服工作台")
17
+@RestController
18
+@RequestMapping("/api/revenue/cs/workbench")
19
+@RequiredArgsConstructor
20
+public class CsWorkbenchController {
21
+
22
+    private final CsWorkbenchService csWorkbenchService;
23
+
24
+    @GetMapping("/list")
25
+    @Operation(summary = "工单分页列表")
26
+    public R<Page<CsWorkItem>> listWorkItems(
27
+            @RequestParam(required = false) String workType,
28
+            @RequestParam(required = false) String status,
29
+            @RequestParam(required = false) String priority,
30
+            @RequestParam(required = false) String assignee,
31
+            @RequestParam(defaultValue = "1") int pageNum,
32
+            @RequestParam(defaultValue = "10") int pageSize) {
33
+        return R.ok(csWorkbenchService.listWorkItems(workType, status, priority, assignee, pageNum, pageSize));
34
+    }
35
+
36
+    @GetMapping("/pending-count")
37
+    @Operation(summary = "待处理工单数量")
38
+    public R<Integer> getPendingCount() {
39
+        return R.ok(csWorkbenchService.getPendingCount());
40
+    }
41
+
42
+    @GetMapping("/today-stats")
43
+    @Operation(summary = "今日统计数据")
44
+    public R<CsWorkbenchStats> getTodayStats() {
45
+        return R.ok(csWorkbenchService.getTodayStats());
46
+    }
47
+
48
+    @PostMapping("/create")
49
+    @Operation(summary = "创建工单")
50
+    public R<CsWorkItem> createWorkItem(@RequestBody CsWorkItem item) {
51
+        return R.ok(csWorkbenchService.createWorkItem(item));
52
+    }
53
+
54
+    @PutMapping("/{id}/status")
55
+    @Operation(summary = "更新工单状态")
56
+    public R<String> updateStatus(@PathVariable Long id, @RequestParam String status) {
57
+        csWorkbenchService.updateWorkItemStatus(id, status);
58
+        return R.ok("状态已更新");
59
+    }
60
+
61
+    @GetMapping("/{id}")
62
+    @Operation(summary = "工单详情")
63
+    public R<CsWorkItem> getWorkItemDetail(@PathVariable Long id) {
64
+        return R.ok(csWorkbenchService.getWorkItemDetail(id));
65
+    }
66
+
67
+    @PutMapping("/{id}/reassign")
68
+    @Operation(summary = "转派工单")
69
+    public R<String> reassignWorkItem(@PathVariable Long id, @RequestParam String assignee) {
70
+        csWorkbenchService.reassignWorkItem(id, assignee);
71
+        return R.ok("已转派");
72
+    }
73
+
74
+    @GetMapping("/work-type-stats")
75
+    @Operation(summary = "按类型统计")
76
+    public R<List<Map<String, Object>>> getWorkTypeStats() {
77
+        return R.ok(csWorkbenchService.getWorkTypeStats());
78
+    }
79
+
80
+    @GetMapping("/today-overview")
81
+    @Operation(summary = "今日概览")
82
+    public R<Map<String, Object>> getTodayOverview() {
83
+        return R.ok(csWorkbenchService.getTodayOverview());
84
+    }
85
+}

+ 75
- 0
wm-revenue/src/main/java/com/water/revenue/controller/VoiceController.java 查看文件

@@ -0,0 +1,75 @@
1
+package com.water.revenue.controller;
2
+
3
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
4
+import com.water.common.core.result.R;
5
+import com.water.revenue.dto.VoiceMenuResponse;
6
+import com.water.revenue.entity.VoiceCallRecord;
7
+import com.water.revenue.service.VoiceQueryService;
8
+import io.swagger.v3.oas.annotations.Operation;
9
+import io.swagger.v3.oas.annotations.tags.Tag;
10
+import lombok.RequiredArgsConstructor;
11
+import org.springframework.web.bind.annotation.*;
12
+
13
+import java.util.Map;
14
+
15
+@Tag(name = "TTS语音自助查询")
16
+@RestController
17
+@RequestMapping("/api/revenue/cs/voice")
18
+@RequiredArgsConstructor
19
+public class VoiceController {
20
+
21
+    private final VoiceQueryService voiceQueryService;
22
+
23
+    @PostMapping("/start")
24
+    @Operation(summary = "开始通话 - 语音菜单导航")
25
+    public R<VoiceMenuResponse> startCall(@RequestParam String callerNumber) {
26
+        return R.ok(voiceQueryService.startCall(callerNumber));
27
+    }
28
+
29
+    @PostMapping("/key-press")
30
+    @Operation(summary = "按键选择")
31
+    public R<VoiceMenuResponse> handleKeyPress(
32
+            @RequestParam String callId,
33
+            @RequestParam String key) {
34
+        return R.ok(voiceQueryService.handleKeyPress(callId, key));
35
+    }
36
+
37
+    @PostMapping("/bill-query")
38
+    @Operation(summary = "语音账单查询")
39
+    public R<Map<String, Object>> voiceBillQuery(
40
+            @RequestParam String callId,
41
+            @RequestParam String customerNo) {
42
+        return R.ok(voiceQueryService.voiceBillQuery(callId, customerNo));
43
+    }
44
+
45
+    @PostMapping("/payment")
46
+    @Operation(summary = "语音缴费")
47
+    public R<Map<String, Object>> voicePayment(
48
+            @RequestParam String callId,
49
+            @RequestParam String customerNo,
50
+            @RequestParam String billId) {
51
+        return R.ok(voiceQueryService.voicePayment(callId, customerNo, billId));
52
+    }
53
+
54
+    @PostMapping("/end")
55
+    @Operation(summary = "结束通话")
56
+    public R<Map<String, Object>> endCall(@RequestParam String callId) {
57
+        return R.ok(voiceQueryService.endCall(callId));
58
+    }
59
+
60
+    @GetMapping("/records")
61
+    @Operation(summary = "通话记录查询")
62
+    public R<Page<VoiceCallRecord>> getCallRecords(
63
+            @RequestParam(required = false) String callerNumber,
64
+            @RequestParam(required = false) String status,
65
+            @RequestParam(defaultValue = "1") int pageNum,
66
+            @RequestParam(defaultValue = "10") int pageSize) {
67
+        return R.ok(voiceQueryService.getCallRecords(callerNumber, status, pageNum, pageSize));
68
+    }
69
+
70
+    @GetMapping("/detail/{callId}")
71
+    @Operation(summary = "通话详情")
72
+    public R<VoiceCallRecord> getCallDetail(@PathVariable String callId) {
73
+        return R.ok(voiceQueryService.getCallDetail(callId));
74
+    }
75
+}

+ 220
- 0
wm-revenue/src/main/java/com/water/revenue/controller/wxhall/WxHallApiController.java 查看文件

@@ -0,0 +1,220 @@
1
+package com.water.revenue.controller.wxhall;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
5
+import com.water.common.core.result.R;
6
+import com.water.revenue.entity.Announcement;
7
+import com.water.revenue.entity.PaymentRecord;
8
+import com.water.revenue.entity.WaterBill;
9
+import com.water.revenue.service.AnnouncementService;
10
+import com.water.revenue.service.BillService;
11
+import com.water.revenue.service.InstallService;
12
+import com.water.revenue.service.PaymentService;
13
+import io.swagger.v3.oas.annotations.Operation;
14
+import io.swagger.v3.oas.annotations.tags.Tag;
15
+import lombok.RequiredArgsConstructor;
16
+import lombok.extern.slf4j.Slf4j;
17
+import org.springframework.jdbc.core.JdbcTemplate;
18
+import org.springframework.util.StringUtils;
19
+import org.springframework.web.bind.annotation.*;
20
+
21
+import java.math.BigDecimal;
22
+import java.time.LocalDateTime;
23
+import java.util.*;
24
+
25
+/**
26
+ * 微信网厅 API(移动端适配接口)
27
+ * 所有路径前缀: /api/wx-hall/*
28
+ */
29
+@Slf4j
30
+@Tag(name = "微信网厅 API")
31
+@RestController
32
+@RequestMapping("/wx-hall")
33
+@RequiredArgsConstructor
34
+public class WxHallApiController {
35
+
36
+    private final BillService billService;
37
+    private final InstallService installService;
38
+    private final AnnouncementService announcementService;
39
+    private final PaymentService paymentService;
40
+    private final JdbcTemplate jdbcTemplate;
41
+
42
+    // ========== 水费查询缴费 ==========
43
+
44
+    @Operation(summary = "查询用户账单列表")
45
+    @GetMapping("/bill/list")
46
+    public R<Page<WaterBill>> billList(
47
+            @RequestParam String customerNo,
48
+            @RequestParam(required = false) String billPeriod,
49
+            @RequestParam(required = false) String status,
50
+            @RequestParam(defaultValue = "1") int pageNum,
51
+            @RequestParam(defaultValue = "10") int pageSize) {
52
+        return R.ok(billService.queryBills(customerNo, billPeriod, status, pageNum, pageSize));
53
+    }
54
+
55
+    @Operation(summary = "账单详情")
56
+    @GetMapping("/bill/{billId}")
57
+    public R<Map<String, Object>> billDetail(@PathVariable Long billId) {
58
+        return R.ok(billService.getBillDetail(billId));
59
+    }
60
+
61
+    @Operation(summary = "在线缴费(微信下单)")
62
+    @PostMapping("/bill/pay")
63
+    public R<Map<String, Object>> billPay(@RequestBody Map<String, Object> req) {
64
+        Long billId = Long.valueOf(req.get("billId").toString());
65
+        BigDecimal amount = new BigDecimal(req.get("amount").toString());
66
+        return R.ok(paymentService.payByWechat(billId, amount));
67
+    }
68
+
69
+    @Operation(summary = "缴费记录")
70
+    @GetMapping("/bill/payment-records")
71
+    public R<List<Map<String, Object>>> paymentRecords(
72
+            @RequestParam String customerNo,
73
+            @RequestParam(defaultValue = "20") int limit) {
74
+        List<Map<String, Object>> records = jdbcTemplate.queryForList(
75
+                "SELECT payment_no, bill_no, amount, channel, channel_name, status, pay_time, remark " +
76
+                "FROM wm_payment_record WHERE customer_no = ? ORDER BY pay_time DESC LIMIT ?",
77
+                customerNo, limit);
78
+        return R.ok(records);
79
+    }
80
+
81
+    // ========== 报装申请 ==========
82
+
83
+    @Operation(summary = "提交报装申请")
84
+    @PostMapping("/install/apply")
85
+    public R<Map<String, Object>> installApply(@RequestBody Map<String, String> req) {
86
+        return R.ok(installService.preApply(
87
+                req.get("name"),
88
+                req.get("phone"),
89
+                req.get("area"),
90
+                req.get("address"),
91
+                req.getOrDefault("customerType", "resident"),
92
+                req.getOrDefault("caliber", "DN15")));
93
+    }
94
+
95
+    @Operation(summary = "查询报装进度")
96
+    @GetMapping("/install/progress")
97
+    public R<Map<String, Object>> installProgress(@RequestParam String applyNo) {
98
+        return R.ok(installService.getProgress(applyNo));
99
+    }
100
+
101
+    @Operation(summary = "报装申请记录列表")
102
+    @GetMapping("/install/list")
103
+    public R<List<Map<String, Object>>> installList(
104
+            @RequestParam String phone,
105
+            @RequestParam(defaultValue = "20") int limit) {
106
+        List<Map<String, Object>> list = jdbcTemplate.queryForList(
107
+                "SELECT application_no, applicant_name, applicant_phone, area, address, " +
108
+                "customer_type, caliber, status, created_at, updated_at " +
109
+                "FROM rev_install WHERE applicant_phone = ? ORDER BY created_at DESC LIMIT ?",
110
+                phone, limit);
111
+        return R.ok(list);
112
+    }
113
+
114
+    // ========== 停水公告 ==========
115
+
116
+    @Operation(summary = "公告列表")
117
+    @GetMapping("/notice/list")
118
+    public R<Page<Announcement>> noticeList(
119
+            @RequestParam(defaultValue = "1") int page,
120
+            @RequestParam(defaultValue = "10") int size,
121
+            @RequestParam(required = false) String type,
122
+            @RequestParam(required = false) String keyword) {
123
+        // 只返回已发布的公告
124
+        return R.ok(announcementService.list(page, size, type, 1, keyword));
125
+    }
126
+
127
+    @Operation(summary = "公告详情")
128
+    @GetMapping("/notice/{id}")
129
+    public R<Announcement> noticeDetail(@PathVariable Long id) {
130
+        return R.ok(announcementService.getDetail(id));
131
+    }
132
+
133
+    @Operation(summary = "获取当前生效公告(按区域)")
134
+    @GetMapping("/notice/active")
135
+    public R<List<Announcement>> activeNotices(
136
+            @RequestParam(required = false) String areaCode) {
137
+        return R.ok(announcementService.getActiveAnnouncements(areaCode));
138
+    }
139
+
140
+    // ========== 用户绑定 ==========
141
+
142
+    @Operation(summary = "手机号绑定")
143
+    @PostMapping("/user/bindPhone")
144
+    public R<Map<String, Object>> bindPhone(@RequestBody Map<String, String> req) {
145
+        String openId = req.get("openId");
146
+        String phone = req.get("phone");
147
+        if (!StringUtils.hasText(openId) || !StringUtils.hasText(phone)) {
148
+            return R.fail("openId和phone不能为空");
149
+        }
150
+        // 检查是否已绑定
151
+        Long existCount = jdbcTemplate.queryForObject(
152
+                "SELECT COUNT(*) FROM wx_hall_user_binding WHERE open_id = ? AND phone = ? AND status = 1",
153
+                Long.class, openId, phone);
154
+        if (existCount != null && existCount > 0) {
155
+            return R.fail("该手机号已绑定");
156
+        }
157
+        jdbcTemplate.update(
158
+                "INSERT INTO wx_hall_user_binding (open_id, phone, binding_type, status) VALUES (?, ?, 'phone', 1)",
159
+                openId, phone);
160
+        log.info("User bound phone: openId={}, phone={}", openId, phone);
161
+        return R.ok(Map.of("openId", openId, "phone", phone, "status", "bound"));
162
+    }
163
+
164
+    @Operation(summary = "户号绑定")
165
+    @PostMapping("/user/bindCustomer")
166
+    public R<Map<String, Object>> bindCustomer(@RequestBody Map<String, String> req) {
167
+        String openId = req.get("openId");
168
+        String customerNo = req.get("customerNo");
169
+        if (!StringUtils.hasText(openId) || !StringUtils.hasText(customerNo)) {
170
+            return R.fail("openId和customerNo不能为空");
171
+        }
172
+        // 检查是否已绑定
173
+        Long existCount = jdbcTemplate.queryForObject(
174
+                "SELECT COUNT(*) FROM wx_hall_user_binding WHERE open_id = ? AND customer_no = ? AND status = 1",
175
+                Long.class, openId, customerNo);
176
+        if (existCount != null && existCount > 0) {
177
+            return R.fail("该户号已绑定");
178
+        }
179
+        // 查询客户名称
180
+        String customerName = null;
181
+        try {
182
+            customerName = jdbcTemplate.queryForObject(
183
+                    "SELECT customer_name FROM rev_customer WHERE customer_no = ?",
184
+                    String.class, customerNo);
185
+        } catch (Exception e) {
186
+            log.warn("Customer not found: {}", customerNo);
187
+        }
188
+        jdbcTemplate.update(
189
+                "INSERT INTO wx_hall_user_binding (open_id, customer_no, customer_name, binding_type, status) VALUES (?, ?, ?, 'customer_no', 1)",
190
+                openId, customerNo, customerName);
191
+        log.info("User bound customer: openId={}, customerNo={}", openId, customerNo);
192
+        return R.ok(Map.of("openId", openId, "customerNo", customerNo,
193
+                "customerName", customerName != null ? customerName : "", "status", "bound"));
194
+    }
195
+
196
+    @Operation(summary = "解绑")
197
+    @PostMapping("/user/unbind")
198
+    public R<Map<String, Object>> unbind(@RequestBody Map<String, String> req) {
199
+        String openId = req.get("openId");
200
+        String bindingId = req.get("bindingId");
201
+        if (!StringUtils.hasText(openId) || !StringUtils.hasText(bindingId)) {
202
+            return R.fail("参数不完整");
203
+        }
204
+        jdbcTemplate.update(
205
+                "UPDATE wx_hall_user_binding SET status = 0, updated_at = NOW() WHERE id = ? AND open_id = ?",
206
+                Long.valueOf(bindingId), openId);
207
+        log.info("User unbound: openId={}, bindingId={}", openId, bindingId);
208
+        return R.ok(Map.of("status", "unbound"));
209
+    }
210
+
211
+    @Operation(summary = "查询用户绑定列表")
212
+    @GetMapping("/user/bindings")
213
+    public R<List<Map<String, Object>>> bindings(@RequestParam String openId) {
214
+        List<Map<String, Object>> list = jdbcTemplate.queryForList(
215
+                "SELECT id, phone, customer_no, customer_name, binding_type, status, created_at " +
216
+                "FROM wx_hall_user_binding WHERE open_id = ? AND status = 1 ORDER BY created_at DESC",
217
+                openId);
218
+        return R.ok(list);
219
+    }
220
+}

+ 46
- 0
wm-revenue/src/main/java/com/water/revenue/dto/BillQueryResult.java 查看文件

@@ -0,0 +1,46 @@
1
+package com.water.revenue.dto;
2
+
3
+import lombok.Data;
4
+
5
+import java.math.BigDecimal;
6
+import java.util.List;
7
+
8
+/**
9
+ * 水费查询结果VO
10
+ */
11
+@Data
12
+public class BillQueryResult {
13
+
14
+    /** 客户编号 */
15
+    private String customerNo;
16
+
17
+    /** 客户名称 */
18
+    private String customerName;
19
+
20
+    /** 地址 */
21
+    private String address;
22
+
23
+    /** 手机号 */
24
+    private String phone;
25
+
26
+    /** 水表号 */
27
+    private String meterNo;
28
+
29
+    /** 欠费总额 */
30
+    private BigDecimal totalArrears;
31
+
32
+    /** 账单列表 */
33
+    private List<BillSummary> bills;
34
+
35
+    @Data
36
+    public static class BillSummary {
37
+        private Long billId;
38
+        private String billNo;
39
+        private String billPeriod;
40
+        private BigDecimal totalAmount;
41
+        private BigDecimal paidAmount;
42
+        private BigDecimal unpaidAmount;
43
+        private String status;
44
+        private String dueDate;
45
+    }
46
+}

+ 34
- 0
wm-revenue/src/main/java/com/water/revenue/dto/CsWorkbenchStats.java 查看文件

@@ -0,0 +1,34 @@
1
+package com.water.revenue.dto;
2
+
3
+import lombok.Data;
4
+
5
+/**
6
+ * 客服工作台统计VO
7
+ */
8
+@Data
9
+public class CsWorkbenchStats {
10
+
11
+    /** 今日新建工单数 */
12
+    private int todayNewCount;
13
+
14
+    /** 今日已处理数 */
15
+    private int todayResolvedCount;
16
+
17
+    /** 待处理总数 */
18
+    private int pendingTotal;
19
+
20
+    /** 处理中数量 */
21
+    private int processingCount;
22
+
23
+    /** 今日来电数 */
24
+    private int todayCallCount;
25
+
26
+    /** 今日在线会话数 */
27
+    private int todayOnlineCount;
28
+
29
+    /** 平均处理时长(分钟) */
30
+    private double avgProcessTime;
31
+
32
+    /** 客户满意度 */
33
+    private double satisfactionRate;
34
+}

+ 41
- 0
wm-revenue/src/main/java/com/water/revenue/dto/VoiceMenuResponse.java 查看文件

@@ -0,0 +1,41 @@
1
+package com.water.revenue.dto;
2
+
3
+import lombok.Data;
4
+
5
+import java.util.List;
6
+import java.util.Map;
7
+
8
+/**
9
+ * 语音菜单响应VO
10
+ */
11
+@Data
12
+public class VoiceMenuResponse {
13
+
14
+    /** 通话ID */
15
+    private String callId;
16
+
17
+    /** 当前菜单层级 */
18
+    private int currentLevel;
19
+
20
+    /** 菜单提示语 */
21
+    private String prompt;
22
+
23
+    /** 可选操作 */
24
+    private List<MenuOption> options;
25
+
26
+    /** 查询结果(如果有) */
27
+    private Map<String, Object> queryResult;
28
+
29
+    /** 是否需要输入 */
30
+    private boolean inputRequired;
31
+
32
+    /** 输入提示 */
33
+    private String inputPrompt;
34
+
35
+    @Data
36
+    public static class MenuOption {
37
+        private String key;
38
+        private String label;
39
+        private String action;
40
+    }
41
+}

+ 54
- 0
wm-revenue/src/main/java/com/water/revenue/entity/CsWorkItem.java 查看文件

@@ -0,0 +1,54 @@
1
+package com.water.revenue.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+
6
+import java.time.LocalDateTime;
7
+
8
+/**
9
+ * 客服工作台 - 工作项(工单/任务)
10
+ */
11
+@Data
12
+@TableName("wm_cs_work_item")
13
+public class CsWorkItem {
14
+
15
+    @TableId(type = IdType.AUTO)
16
+    private Long id;
17
+
18
+    /** 工作类型: complaint/repair/consult/install/meter_change */
19
+    private String workType;
20
+
21
+    /** 客户编号 */
22
+    private String customerNo;
23
+
24
+    /** 客户名称 */
25
+    private String customerName;
26
+
27
+    /** 摘要 */
28
+    private String summary;
29
+
30
+    /** 优先级: low/medium/high/urgent */
31
+    private String priority;
32
+
33
+    /** 状态: pending/processing/resolved/closed */
34
+    private String status;
35
+
36
+    /** 指派人 */
37
+    private String assignee;
38
+
39
+    /** 联系电话 */
40
+    private String contactPhone;
41
+
42
+    /** 地址 */
43
+    private String address;
44
+
45
+    /** 详细内容 */
46
+    private String detail;
47
+
48
+    /** 来源: phone/online/wechat/walk_in */
49
+    private String source;
50
+
51
+    private LocalDateTime createdAt;
52
+
53
+    private LocalDateTime updatedAt;
54
+}

+ 52
- 0
wm-revenue/src/main/java/com/water/revenue/entity/VoiceCallRecord.java 查看文件

@@ -0,0 +1,52 @@
1
+package com.water.revenue.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+
6
+import java.time.LocalDateTime;
7
+
8
+/**
9
+ * TTS 语音自助查询 - 通话记录
10
+ */
11
+@Data
12
+@TableName("wm_voice_call_record")
13
+public class VoiceCallRecord {
14
+
15
+    @TableId(type = IdType.AUTO)
16
+    private Long id;
17
+
18
+    /** 通话唯一ID */
19
+    private String callId;
20
+
21
+    /** 主叫号码 */
22
+    private String callerNumber;
23
+
24
+    /** 客户编号(识别后关联) */
25
+    private String customerNo;
26
+
27
+    /** 菜单路径(如 main>bill>detail) */
28
+    private String menuPath;
29
+
30
+    /** 查询结果摘要 */
31
+    private String queryResult;
32
+
33
+    /** 通话时长(秒) */
34
+    private Integer duration;
35
+
36
+    /** 通话时间 */
37
+    private LocalDateTime callTime;
38
+
39
+    /** 通话结束时间 */
40
+    private LocalDateTime endTime;
41
+
42
+    /** 状态: active/completed/failed */
43
+    private String status;
44
+
45
+    /** 语音菜单层级 */
46
+    private Integer menuLevel;
47
+
48
+    /** 最后操作 */
49
+    private String lastAction;
50
+
51
+    private LocalDateTime createdAt;
52
+}

+ 25
- 0
wm-revenue/src/main/java/com/water/revenue/mapper/CsWorkItemMapper.java 查看文件

@@ -0,0 +1,25 @@
1
+package com.water.revenue.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.revenue.entity.CsWorkItem;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+import org.apache.ibatis.annotations.Select;
8
+
9
+import java.util.Map;
10
+
11
+@Mapper
12
+public interface CsWorkItemMapper extends BaseMapper<CsWorkItem> {
13
+
14
+    @Select("SELECT COUNT(*) FROM wm_cs_work_item WHERE status = 'pending'")
15
+    int countPending();
16
+
17
+    @Select("SELECT COUNT(*) FROM wm_cs_work_item WHERE DATE(created_at) = CURRENT_DATE")
18
+    int countTodayNew();
19
+
20
+    @Select("SELECT COUNT(*) FROM wm_cs_work_item WHERE DATE(updated_at) = CURRENT_DATE AND status = 'resolved'")
21
+    int countTodayResolved();
22
+
23
+    @Select("SELECT COUNT(*) FROM wm_cs_work_item WHERE status = 'processing'")
24
+    int countProcessing();
25
+}

+ 13
- 0
wm-revenue/src/main/java/com/water/revenue/mapper/VoiceCallRecordMapper.java 查看文件

@@ -0,0 +1,13 @@
1
+package com.water.revenue.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.revenue.entity.VoiceCallRecord;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Select;
7
+
8
+@Mapper
9
+public interface VoiceCallRecordMapper extends BaseMapper<VoiceCallRecord> {
10
+
11
+    @Select("SELECT COUNT(*) FROM wm_voice_call_record WHERE DATE(call_time) = CURRENT_DATE")
12
+    int countTodayCalls();
13
+}

+ 134
- 0
wm-revenue/src/main/java/com/water/revenue/service/BillQueryService.java 查看文件

@@ -0,0 +1,134 @@
1
+package com.water.revenue.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.water.revenue.dto.BillQueryResult;
5
+import com.water.revenue.entity.PaymentRecord;
6
+import com.water.revenue.entity.WaterBill;
7
+import com.water.revenue.mapper.PaymentRecordMapper;
8
+import com.water.revenue.mapper.WaterBillMapper;
9
+import lombok.RequiredArgsConstructor;
10
+import org.springframework.stereotype.Service;
11
+
12
+import java.math.BigDecimal;
13
+import java.util.*;
14
+import java.util.stream.Collectors;
15
+
16
+/**
17
+ * 水费查询服务(客服工作台专用)
18
+ */
19
+@Service
20
+@RequiredArgsConstructor
21
+public class BillQueryService {
22
+
23
+    private final WaterBillMapper waterBillMapper;
24
+    private final PaymentRecordMapper paymentRecordMapper;
25
+
26
+    /**
27
+     * 按户号查询
28
+     */
29
+    public BillQueryResult queryByCustomerNo(String customerNo) {
30
+        List<WaterBill> bills = waterBillMapper.selectList(
31
+                new LambdaQueryWrapper<WaterBill>()
32
+                        .eq(WaterBill::getCustomerNo, customerNo)
33
+                        .orderByDesc(WaterBill::getCreatedAt));
34
+
35
+        return buildResult(customerNo, bills);
36
+    }
37
+
38
+    /**
39
+     * 按手机号查询
40
+     */
41
+    public BillQueryResult queryByPhone(String phone) {
42
+        // 模拟:通过手机号反查客户编号(实际应查客户表)
43
+        // 此处简化处理,演示流程
44
+        LambdaQueryWrapper<WaterBill> wrapper = new LambdaQueryWrapper<>();
45
+        wrapper.like(WaterBill::getCustomerNo, phone.substring(Math.max(0, phone.length() - 4)));
46
+        List<WaterBill> bills = waterBillMapper.selectList(wrapper);
47
+
48
+        String customerNo = bills.isEmpty() ? "UNKNOWN" : bills.get(0).getCustomerNo();
49
+        return buildResult(customerNo, bills);
50
+    }
51
+
52
+    /**
53
+     * 按地址查询
54
+     */
55
+    public BillQueryResult queryByAddress(String address) {
56
+        // 模拟:地址关键字匹配(实际应查客户档案表)
57
+        LambdaQueryWrapper<WaterBill> wrapper = new LambdaQueryWrapper<>();
58
+        wrapper.like(WaterBill::getCustomerName, address);
59
+        List<WaterBill> bills = waterBillMapper.selectList(wrapper);
60
+
61
+        String customerNo = bills.isEmpty() ? "UNKNOWN" : bills.get(0).getCustomerNo();
62
+        return buildResult(customerNo, bills);
63
+    }
64
+
65
+    /**
66
+     * 账单明细(含缴费记录)
67
+     */
68
+    public Map<String, Object> getBillDetail(Long billId) {
69
+        WaterBill bill = waterBillMapper.selectById(billId);
70
+        if (bill == null) {
71
+            throw new RuntimeException("账单不存在: " + billId);
72
+        }
73
+
74
+        List<PaymentRecord> payments = paymentRecordMapper.selectList(
75
+                new LambdaQueryWrapper<PaymentRecord>()
76
+                        .eq(PaymentRecord::getBillId, billId)
77
+                        .orderByDesc(PaymentRecord::getPayTime));
78
+
79
+        Map<String, Object> result = new HashMap<>();
80
+        result.put("bill", bill);
81
+        result.put("payments", payments);
82
+        result.put("paymentCount", payments.size());
83
+        result.put("totalPaid", payments.stream()
84
+                .filter(p -> "success".equals(p.getStatus()))
85
+                .map(PaymentRecord::getAmount)
86
+                .reduce(BigDecimal.ZERO, BigDecimal::add));
87
+        return result;
88
+    }
89
+
90
+    /**
91
+     * 欠费查询
92
+     */
93
+    public List<BillQueryResult.BillSummary> queryArrears(String customerNo) {
94
+        LambdaQueryWrapper<WaterBill> wrapper = new LambdaQueryWrapper<>();
95
+        wrapper.eq(WaterBill::getCustomerNo, customerNo);
96
+        wrapper.in(WaterBill::getStatus, Arrays.asList("pending", "partial", "overdue"));
97
+        wrapper.orderByAsc(WaterBill::getDueDate);
98
+
99
+        List<WaterBill> bills = waterBillMapper.selectList(wrapper);
100
+        return bills.stream().map(this::toSummary).collect(Collectors.toList());
101
+    }
102
+
103
+    private BillQueryResult buildResult(String customerNo, List<WaterBill> bills) {
104
+        BillQueryResult result = new BillQueryResult();
105
+        result.setCustomerNo(customerNo);
106
+        result.setCustomerName(bills.isEmpty() ? "未知" : bills.get(0).getCustomerName());
107
+        result.setMeterNo(bills.isEmpty() ? null : bills.get(0).getMeterNo());
108
+
109
+        List<BillQueryResult.BillSummary> summaries = bills.stream()
110
+                .map(this::toSummary)
111
+                .collect(Collectors.toList());
112
+        result.setBills(summaries);
113
+
114
+        BigDecimal totalArrears = bills.stream()
115
+                .map(b -> b.getTotalAmount().subtract(b.getPaidAmount()))
116
+                .reduce(BigDecimal.ZERO, BigDecimal::add);
117
+        result.setTotalArrears(totalArrears);
118
+
119
+        return result;
120
+    }
121
+
122
+    private BillQueryResult.BillSummary toSummary(WaterBill bill) {
123
+        BillQueryResult.BillSummary summary = new BillQueryResult.BillSummary();
124
+        summary.setBillId(bill.getId());
125
+        summary.setBillNo(bill.getBillNo());
126
+        summary.setBillPeriod(bill.getBillPeriod());
127
+        summary.setTotalAmount(bill.getTotalAmount());
128
+        summary.setPaidAmount(bill.getPaidAmount());
129
+        summary.setUnpaidAmount(bill.getTotalAmount().subtract(bill.getPaidAmount()));
130
+        summary.setStatus(bill.getStatus());
131
+        summary.setDueDate(bill.getDueDate() != null ? bill.getDueDate().toString() : null);
132
+        return summary;
133
+    }
134
+}

+ 144
- 0
wm-revenue/src/main/java/com/water/revenue/service/CsWorkbenchService.java 查看文件

@@ -0,0 +1,144 @@
1
+package com.water.revenue.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
5
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
6
+import com.water.revenue.dto.CsWorkbenchStats;
7
+import com.water.revenue.entity.CsWorkItem;
8
+import com.water.revenue.mapper.CsWorkItemMapper;
9
+import com.water.revenue.mapper.VoiceCallRecordMapper;
10
+import lombok.RequiredArgsConstructor;
11
+import org.springframework.stereotype.Service;
12
+
13
+import java.time.LocalDate;
14
+import java.time.LocalDateTime;
15
+import java.util.*;
16
+
17
+/**
18
+ * 客服工作台服务
19
+ */
20
+@Service
21
+@RequiredArgsConstructor
22
+public class CsWorkbenchService {
23
+
24
+    private final CsWorkItemMapper csWorkItemMapper;
25
+    private final VoiceCallRecordMapper voiceCallRecordMapper;
26
+
27
+    /**
28
+     * 工单分页列表
29
+     */
30
+    public Page<CsWorkItem> listWorkItems(String workType, String status, String priority,
31
+                                          String assignee, int pageNum, int pageSize) {
32
+        LambdaQueryWrapper<CsWorkItem> wrapper = new LambdaQueryWrapper<>();
33
+        if (workType != null && !workType.isEmpty()) {
34
+            wrapper.eq(CsWorkItem::getWorkType, workType);
35
+        }
36
+        if (status != null && !status.isEmpty()) {
37
+            wrapper.eq(CsWorkItem::getStatus, status);
38
+        }
39
+        if (priority != null && !priority.isEmpty()) {
40
+            wrapper.eq(CsWorkItem::getPriority, priority);
41
+        }
42
+        if (assignee != null && !assignee.isEmpty()) {
43
+            wrapper.eq(CsWorkItem::getAssignee, assignee);
44
+        }
45
+        wrapper.orderByDesc(CsWorkItem::getCreatedAt);
46
+        return csWorkItemMapper.selectPage(new Page<>(pageNum, pageSize), wrapper);
47
+    }
48
+
49
+    /**
50
+     * 待处理数量
51
+     */
52
+    public int getPendingCount() {
53
+        return csWorkItemMapper.countPending();
54
+    }
55
+
56
+    /**
57
+     * 今日统计
58
+     */
59
+    public CsWorkbenchStats getTodayStats() {
60
+        CsWorkbenchStats stats = new CsWorkbenchStats();
61
+        stats.setTodayNewCount(csWorkItemMapper.countTodayNew());
62
+        stats.setTodayResolvedCount(csWorkItemMapper.countTodayResolved());
63
+        stats.setPendingTotal(csWorkItemMapper.countPending());
64
+        stats.setProcessingCount(csWorkItemMapper.countProcessing());
65
+        stats.setTodayCallCount(voiceCallRecordMapper.countTodayCalls());
66
+        stats.setTodayOnlineCount(csWorkItemMapper.countTodayNew()); // 模拟在线数
67
+        stats.setAvgProcessTime(15.5); // 模拟值
68
+        stats.setSatisfactionRate(96.2); // 模拟值
69
+        return stats;
70
+    }
71
+
72
+    /**
73
+     * 创建工单
74
+     */
75
+    public CsWorkItem createWorkItem(CsWorkItem item) {
76
+        item.setStatus("pending");
77
+        item.setCreatedAt(LocalDateTime.now());
78
+        item.setUpdatedAt(LocalDateTime.now());
79
+        csWorkItemMapper.insert(item);
80
+        return item;
81
+    }
82
+
83
+    /**
84
+     * 更新工单状态
85
+     */
86
+    public void updateWorkItemStatus(Long id, String status) {
87
+        LambdaUpdateWrapper<CsWorkItem> wrapper = new LambdaUpdateWrapper<>();
88
+        wrapper.eq(CsWorkItem::getId, id)
89
+                .set(CsWorkItem::getStatus, status)
90
+                .set(CsWorkItem::getUpdatedAt, LocalDateTime.now());
91
+        csWorkItemMapper.update(null, wrapper);
92
+    }
93
+
94
+    /**
95
+     * 工单详情
96
+     */
97
+    public CsWorkItem getWorkItemDetail(Long id) {
98
+        return csWorkItemMapper.selectById(id);
99
+    }
100
+
101
+    /**
102
+     * 快捷操作 - 转派
103
+     */
104
+    public void reassignWorkItem(Long id, String newAssignee) {
105
+        LambdaUpdateWrapper<CsWorkItem> wrapper = new LambdaUpdateWrapper<>();
106
+        wrapper.eq(CsWorkItem::getId, id)
107
+                .set(CsWorkItem::getAssignee, newAssignee)
108
+                .set(CsWorkItem::getStatus, "processing")
109
+                .set(CsWorkItem::getUpdatedAt, LocalDateTime.now());
110
+        csWorkItemMapper.update(null, wrapper);
111
+    }
112
+
113
+    /**
114
+     * 快捷操作 - 按类型统计
115
+     */
116
+    public List<Map<String, Object>> getWorkTypeStats() {
117
+        List<String> types = Arrays.asList("complaint", "repair", "consult", "install", "meter_change");
118
+        List<Map<String, Object>> result = new ArrayList<>();
119
+        for (String type : types) {
120
+            LambdaQueryWrapper<CsWorkItem> wrapper = new LambdaQueryWrapper<>();
121
+            wrapper.eq(CsWorkItem::getWorkType, type);
122
+            long count = csWorkItemMapper.selectCount(wrapper);
123
+            Map<String, Object> item = new HashMap<>();
124
+            item.put("workType", type);
125
+            item.put("count", count);
126
+            result.add(item);
127
+        }
128
+        return result;
129
+    }
130
+
131
+    /**
132
+     * 快捷操作 - 今日概览
133
+     */
134
+    public Map<String, Object> getTodayOverview() {
135
+        Map<String, Object> overview = new HashMap<>();
136
+        overview.put("pendingCount", csWorkItemMapper.countPending());
137
+        overview.put("processingCount", csWorkItemMapper.countProcessing());
138
+        overview.put("todayNew", csWorkItemMapper.countTodayNew());
139
+        overview.put("todayResolved", csWorkItemMapper.countTodayResolved());
140
+        overview.put("todayCalls", voiceCallRecordMapper.countTodayCalls());
141
+        overview.put("date", LocalDate.now().toString());
142
+        return overview;
143
+    }
144
+}

+ 261
- 0
wm-revenue/src/main/java/com/water/revenue/service/VoiceQueryService.java 查看文件

@@ -0,0 +1,261 @@
1
+package com.water.revenue.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
5
+import com.water.revenue.dto.BillQueryResult;
6
+import com.water.revenue.dto.VoiceMenuResponse;
7
+import com.water.revenue.entity.VoiceCallRecord;
8
+import com.water.revenue.mapper.VoiceCallRecordMapper;
9
+import lombok.RequiredArgsConstructor;
10
+import org.springframework.stereotype.Service;
11
+
12
+import java.time.LocalDateTime;
13
+import java.util.*;
14
+
15
+/**
16
+ * TTS 语音自助查询服务(模拟)
17
+ */
18
+@Service
19
+@RequiredArgsConstructor
20
+public class VoiceQueryService {
21
+
22
+    private final VoiceCallRecordMapper voiceCallRecordMapper;
23
+    private final BillQueryService billQueryService;
24
+
25
+    /**
26
+     * 语音菜单导航 - 开始通话
27
+     */
28
+    public VoiceMenuResponse startCall(String callerNumber) {
29
+        // 创建通话记录
30
+        VoiceCallRecord record = new VoiceCallRecord();
31
+        record.setCallId(UUID.randomUUID().toString().replace("-", ""));
32
+        record.setCallerNumber(callerNumber);
33
+        record.setMenuPath("main");
34
+        record.setMenuLevel(1);
35
+        record.setStatus("active");
36
+        record.setCallTime(LocalDateTime.now());
37
+        voiceCallRecordMapper.insert(record);
38
+
39
+        // 构建主菜单
40
+        VoiceMenuResponse response = new VoiceMenuResponse();
41
+        response.setCallId(record.getCallId());
42
+        response.setCurrentLevel(1);
43
+        response.setPrompt("欢迎致电XX水务客服热线,请按提示操作:");
44
+        response.setInputRequired(true);
45
+        response.setInputPrompt("请输入您的客户编号或手机号");
46
+
47
+        List<VoiceMenuResponse.MenuOption> options = new ArrayList<>();
48
+        options.add(createOption("1", "水费查询", "bill_query"));
49
+        options.add(createOption("2", "水费缴纳", "bill_payment"));
50
+        options.add(createOption("3", "报修服务", "repair"));
51
+        options.add(createOption("4", "业务咨询", "consult"));
52
+        options.add(createOption("0", "人工服务", "manual"));
53
+        response.setOptions(options);
54
+
55
+        return response;
56
+    }
57
+
58
+    /**
59
+     * 语音菜单导航 - 按键选择
60
+     */
61
+    public VoiceMenuResponse handleKeyPress(String callId, String key) {
62
+        VoiceCallRecord record = getActiveCall(callId);
63
+        if (record == null) {
64
+            throw new RuntimeException("通话不存在或已结束: " + callId);
65
+        }
66
+
67
+        String currentMenu = record.getMenuPath();
68
+        String newMenuPath = currentMenu + ">" + key;
69
+
70
+        VoiceMenuResponse response = new VoiceMenuResponse();
71
+        response.setCallId(callId);
72
+
73
+        if ("main".equals(currentMenu)) {
74
+            switch (key) {
75
+                case "1":
76
+                    response.setCurrentLevel(2);
77
+                    response.setPrompt("水费查询,请输入您的客户编号,按#号键结束:");
78
+                    response.setInputRequired(true);
79
+                    response.setInputPrompt("请输入客户编号");
80
+                    record.setMenuPath(newMenuPath);
81
+                    record.setLastAction("bill_query_input");
82
+                    break;
83
+                case "2":
84
+                    response.setCurrentLevel(2);
85
+                    response.setPrompt("水费缴纳,请输入您的客户编号,按#号键结束:");
86
+                    response.setInputRequired(true);
87
+                    response.setInputPrompt("请输入客户编号");
88
+                    record.setMenuPath(newMenuPath);
89
+                    record.setLastAction("bill_payment_input");
90
+                    break;
91
+                case "3":
92
+                    response.setCurrentLevel(2);
93
+                    response.setPrompt("报修服务已记录,我们将在24小时内安排人员处理。");
94
+                    response.setInputRequired(false);
95
+                    record.setMenuPath(newMenuPath);
96
+                    record.setLastAction("repair_submitted");
97
+                    break;
98
+                case "4":
99
+                    response.setCurrentLevel(2);
100
+                    response.setPrompt("业务咨询:您可前往最近营业厅办理业务,地址为...");
101
+                    response.setInputRequired(false);
102
+                    record.setMenuPath(newMenuPath);
103
+                    record.setLastAction("consult_info");
104
+                    break;
105
+                case "0":
106
+                    response.setCurrentLevel(2);
107
+                    response.setPrompt("正在为您转接人工客服,请稍候...");
108
+                    response.setInputRequired(false);
109
+                    record.setMenuPath(newMenuPath);
110
+                    record.setLastAction("transfer_manual");
111
+                    break;
112
+                default:
113
+                    response.setCurrentLevel(1);
114
+                    response.setPrompt("输入有误,请重新选择:");
115
+                    response.setInputRequired(true);
116
+                    break;
117
+            }
118
+        } else {
119
+            // 子菜单处理:输入客户编号后查询
120
+            response.setCurrentLevel(3);
121
+            response.setPrompt("查询结果播报中...");
122
+            response.setInputRequired(false);
123
+
124
+            Map<String, Object> queryResult = new HashMap<>();
125
+            queryResult.put("inputValue", key);
126
+            queryResult.put("action", record.getLastAction());
127
+            response.setQueryResult(queryResult);
128
+
129
+            record.setMenuPath(newMenuPath);
130
+            record.setQueryResult("查询: " + key);
131
+            record.setCustomerNo(key);
132
+            record.setLastAction("query_completed");
133
+        }
134
+
135
+        record.setUpdatedAt(LocalDateTime.now());
136
+        voiceCallRecordMapper.updateById(record);
137
+        return response;
138
+    }
139
+
140
+    /**
141
+     * 语音账单查询
142
+     */
143
+    public Map<String, Object> voiceBillQuery(String callId, String customerNo) {
144
+        BillQueryResult billResult = billQueryService.queryByCustomerNo(customerNo);
145
+
146
+        Map<String, Object> result = new HashMap<>();
147
+        result.put("customerNo", customerNo);
148
+        result.put("customerName", billResult.getCustomerName());
149
+        result.put("totalArrears", billResult.getTotalArrears());
150
+        result.put("billCount", billResult.getBills() != null ? billResult.getBills().size() : 0);
151
+        result.put("ttsText", buildTtsText(billResult));
152
+
153
+        // 更新通话记录
154
+        VoiceCallRecord record = getActiveCall(callId);
155
+        if (record != null) {
156
+            record.setCustomerNo(customerNo);
157
+            record.setQueryResult(String.valueOf(result.get("ttsText")));
158
+            voiceCallRecordMapper.updateById(record);
159
+        }
160
+
161
+        return result;
162
+    }
163
+
164
+    /**
165
+     * 语音缴费(模拟)
166
+     */
167
+    public Map<String, Object> voicePayment(String callId, String customerNo, String billId) {
168
+        Map<String, Object> result = new HashMap<>();
169
+        result.put("success", true);
170
+        result.put("message", "缴费请求已提交,请通过微信/支付宝完成支付");
171
+        result.put("paymentUrl", "https://pay.example.com/water/" + billId);
172
+        result.put("ttsText", "缴费请求已提交,请您通过短信链接完成支付,感谢您的来电。");
173
+
174
+        VoiceCallRecord record = getActiveCall(callId);
175
+        if (record != null) {
176
+            record.setCustomerNo(customerNo);
177
+            record.setLastAction("payment_initiated");
178
+            record.setQueryResult("缴费:" + billId);
179
+            voiceCallRecordMapper.updateById(record);
180
+        }
181
+
182
+        return result;
183
+    }
184
+
185
+    /**
186
+     * 结束通话
187
+     */
188
+    public Map<String, Object> endCall(String callId) {
189
+        VoiceCallRecord record = getActiveCall(callId);
190
+        Map<String, Object> result = new HashMap<>();
191
+
192
+        if (record != null) {
193
+            LocalDateTime now = LocalDateTime.now();
194
+            record.setEndTime(now);
195
+            record.setStatus("completed");
196
+            int duration = (int) java.time.Duration.between(record.getCallTime(), now).getSeconds();
197
+            record.setDuration(duration);
198
+            voiceCallRecordMapper.updateById(record);
199
+
200
+            result.put("callId", callId);
201
+            result.put("duration", duration);
202
+            result.put("status", "completed");
203
+            result.put("ttsText", "感谢致电XX水务,再见!");
204
+        } else {
205
+            result.put("callId", callId);
206
+            result.put("status", "not_found");
207
+        }
208
+
209
+        return result;
210
+    }
211
+
212
+    /**
213
+     * 通话记录查询
214
+     */
215
+    public Page<VoiceCallRecord> getCallRecords(String callerNumber, String status,
216
+                                                int pageNum, int pageSize) {
217
+        LambdaQueryWrapper<VoiceCallRecord> wrapper = new LambdaQueryWrapper<>();
218
+        if (callerNumber != null && !callerNumber.isEmpty()) {
219
+            wrapper.eq(VoiceCallRecord::getCallerNumber, callerNumber);
220
+        }
221
+        if (status != null && !status.isEmpty()) {
222
+            wrapper.eq(VoiceCallRecord::getStatus, status);
223
+        }
224
+        wrapper.orderByDesc(VoiceCallRecord::getCallTime);
225
+        return voiceCallRecordMapper.selectPage(new Page<>(pageNum, pageSize), wrapper);
226
+    }
227
+
228
+    /**
229
+     * 获取通话详情
230
+     */
231
+    public VoiceCallRecord getCallDetail(String callId) {
232
+        return voiceCallRecordMapper.selectOne(
233
+                new LambdaQueryWrapper<VoiceCallRecord>()
234
+                        .eq(VoiceCallRecord::getCallId, callId));
235
+    }
236
+
237
+    private VoiceCallRecord getActiveCall(String callId) {
238
+        return voiceCallRecordMapper.selectOne(
239
+                new LambdaQueryWrapper<VoiceCallRecord>()
240
+                        .eq(VoiceCallRecord::getCallId, callId)
241
+                        .eq(VoiceCallRecord::getStatus, "active"));
242
+    }
243
+
244
+    private String buildTtsText(BillQueryResult result) {
245
+        if (result == null || result.getBills() == null || result.getBills().isEmpty()) {
246
+            return "未查询到相关账单信息。";
247
+        }
248
+        return String.format("尊敬的%s,您当前欠费金额为%s元,共%d笔未缴账单,请及时缴纳。",
249
+                result.getCustomerName(),
250
+                result.getTotalArrears(),
251
+                result.getBills().size());
252
+    }
253
+
254
+    private VoiceMenuResponse.MenuOption createOption(String key, String label, String action) {
255
+        VoiceMenuResponse.MenuOption option = new VoiceMenuResponse.MenuOption();
256
+        option.setKey(key);
257
+        option.setLabel(label);
258
+        option.setAction(action);
259
+        return option;
260
+    }
261
+}

+ 18
- 0
wm-revenue/src/main/resources/db/V6__wx_hall_user_bindng.sql 查看文件

@@ -0,0 +1,18 @@
1
+-- 微信网厅用户绑定表(手机号/户号绑定)
2
+
3
+CREATE TABLE IF NOT EXISTS wx_hall_user_binding (
4
+    id              BIGSERIAL PRIMARY KEY,
5
+    open_id         VARCHAR(64)     NOT NULL,
6
+    phone           VARCHAR(20),
7
+    customer_no     VARCHAR(32),
8
+    customer_name   VARCHAR(100),
9
+    binding_type    VARCHAR(20)     NOT NULL DEFAULT 'phone',  -- phone / customer_no
10
+    status          INTEGER         NOT NULL DEFAULT 1,        -- 1-有效 0-已解绑
11
+    created_at      TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
12
+    updated_at      TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP
13
+);
14
+
15
+CREATE INDEX IF NOT EXISTS idx_wx_binding_open_id ON wx_hall_user_binding(open_id);
16
+CREATE INDEX IF NOT EXISTS idx_wx_binding_phone ON wx_hall_user_binding(phone);
17
+CREATE INDEX IF NOT EXISTS idx_wx_binding_customer ON wx_hall_user_binding(customer_no);
18
+CREATE INDEX IF NOT EXISTS idx_wx_binding_status ON wx_hall_user_binding(status);

+ 66
- 0
wm-revenue/src/main/resources/sql/V_cs_workbench.sql 查看文件

@@ -0,0 +1,66 @@
1
+-- ============================================================
2
+-- 客服工作台 + 语音自助查询 DDL
3
+-- 版本: V_cs_workbench
4
+-- 作者: bot_dev2
5
+-- 关联 Issue: #54
6
+-- ============================================================
7
+
8
+-- 1. 客服工作项表
9
+CREATE TABLE IF NOT EXISTS wm_cs_work_item (
10
+    id              BIGSERIAL       PRIMARY KEY,
11
+    work_type       VARCHAR(32)     NOT NULL,           -- complaint/repair/consult/install/meter_change
12
+    customer_no     VARCHAR(32),                        -- 客户编号
13
+    customer_name   VARCHAR(100),                       -- 客户名称
14
+    summary         VARCHAR(500),                       -- 摘要
15
+    priority        VARCHAR(16)     DEFAULT 'medium',   -- low/medium/high/urgent
16
+    status          VARCHAR(16)     DEFAULT 'pending',  -- pending/processing/resolved/closed
17
+    assignee        VARCHAR(64),                        -- 指派人
18
+    contact_phone   VARCHAR(20),                        -- 联系电话
19
+    address         VARCHAR(200),                       -- 地址
20
+    detail          TEXT,                               -- 详细内容
21
+    source          VARCHAR(16),                        -- phone/online/wechat/walk_in
22
+    created_at      TIMESTAMP       DEFAULT NOW(),
23
+    updated_at      TIMESTAMP       DEFAULT NOW()
24
+);
25
+
26
+COMMENT ON TABLE wm_cs_work_item IS '客服工作台-工作项';
27
+COMMENT ON COLUMN wm_cs_work_item.work_type IS '工作类型: complaint/repair/consult/install/meter_change';
28
+COMMENT ON COLUMN wm_cs_work_item.priority IS '优先级: low/medium/high/urgent';
29
+COMMENT ON COLUMN wm_cs_work_item.status IS '状态: pending/processing/resolved/closed';
30
+COMMENT ON COLUMN wm_cs_work_item.source IS '来源: phone/online/wechat/walk_in';
31
+
32
+-- 索引
33
+CREATE INDEX IF NOT EXISTS idx_cs_work_item_status ON wm_cs_work_item (status);
34
+CREATE INDEX IF NOT EXISTS idx_cs_work_item_customer ON wm_cs_work_item (customer_no);
35
+CREATE INDEX IF NOT EXISTS idx_cs_work_item_assignee ON wm_cs_work_item (assignee);
36
+CREATE INDEX IF NOT EXISTS idx_cs_work_item_type ON wm_cs_work_item (work_type);
37
+CREATE INDEX IF NOT EXISTS idx_cs_work_item_created ON wm_cs_work_item (created_at);
38
+
39
+-- 2. 语音通话记录表
40
+CREATE TABLE IF NOT EXISTS wm_voice_call_record (
41
+    id              BIGSERIAL       PRIMARY KEY,
42
+    call_id         VARCHAR(64)     NOT NULL UNIQUE,    -- 通话唯一ID
43
+    caller_number   VARCHAR(20),                        -- 主叫号码
44
+    customer_no     VARCHAR(32),                        -- 客户编号
45
+    menu_path       VARCHAR(200),                       -- 菜单路径
46
+    query_result    TEXT,                               -- 查询结果摘要
47
+    duration        INTEGER,                            -- 通话时长(秒)
48
+    call_time       TIMESTAMP,                          -- 通话时间
49
+    end_time        TIMESTAMP,                          -- 通话结束时间
50
+    status          VARCHAR(16)     DEFAULT 'active',   -- active/completed/failed
51
+    menu_level      INTEGER         DEFAULT 1,          -- 菜单层级
52
+    last_action     VARCHAR(64),                        -- 最后操作
53
+    created_at      TIMESTAMP       DEFAULT NOW()
54
+);
55
+
56
+COMMENT ON TABLE wm_voice_call_record IS '语音自助查询-通话记录';
57
+COMMENT ON COLUMN wm_voice_call_record.call_id IS '通话唯一ID';
58
+COMMENT ON COLUMN wm_voice_call_record.menu_path IS '菜单路径(如 main>1>customerNo)';
59
+COMMENT ON COLUMN wm_voice_call_record.status IS '状态: active/completed/failed';
60
+
61
+-- 索引
62
+CREATE INDEX IF NOT EXISTS idx_voice_call_call_id ON wm_voice_call_record (call_id);
63
+CREATE INDEX IF NOT EXISTS idx_voice_call_caller ON wm_voice_call_record (caller_number);
64
+CREATE INDEX IF NOT EXISTS idx_voice_call_customer ON wm_voice_call_record (customer_no);
65
+CREATE INDEX IF NOT EXISTS idx_voice_call_time ON wm_voice_call_record (call_time);
66
+CREATE INDEX IF NOT EXISTS idx_voice_call_status ON wm_voice_call_record (status);

+ 289
- 0
wm-revenue/src/test/java/com/water/revenue/CsWorkbenchTest.java 查看文件

@@ -0,0 +1,289 @@
1
+package com.water.revenue;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
5
+import com.water.revenue.dto.BillQueryResult;
6
+import com.water.revenue.dto.CsWorkbenchStats;
7
+import com.water.revenue.dto.VoiceMenuResponse;
8
+import com.water.revenue.entity.CsWorkItem;
9
+import com.water.revenue.entity.VoiceCallRecord;
10
+import com.water.revenue.entity.WaterBill;
11
+import com.water.revenue.mapper.CsWorkItemMapper;
12
+import com.water.revenue.mapper.PaymentRecordMapper;
13
+import com.water.revenue.mapper.VoiceCallRecordMapper;
14
+import com.water.revenue.mapper.WaterBillMapper;
15
+import com.water.revenue.service.BillQueryService;
16
+import com.water.revenue.service.CsWorkbenchService;
17
+import com.water.revenue.service.VoiceQueryService;
18
+import org.junit.jupiter.api.BeforeEach;
19
+import org.junit.jupiter.api.DisplayName;
20
+import org.junit.jupiter.api.Nested;
21
+import org.junit.jupiter.api.Test;
22
+import org.junit.jupiter.api.extension.ExtendWith;
23
+import org.mockito.ArgumentCaptor;
24
+import org.mockito.Mock;
25
+import org.mockito.junit.jupiter.MockitoExtension;
26
+
27
+import java.math.BigDecimal;
28
+import java.time.LocalDate;
29
+import java.time.LocalDateTime;
30
+import java.util.*;
31
+
32
+import static org.junit.jupiter.api.Assertions.*;
33
+import static org.mockito.ArgumentMatchers.*;
34
+import static org.mockito.Mockito.*;
35
+
36
+@ExtendWith(MockitoExtension.class)
37
+class CsWorkbenchTest {
38
+
39
+    @Mock
40
+    private CsWorkItemMapper csWorkItemMapper;
41
+
42
+    @Mock
43
+    private VoiceCallRecordMapper voiceCallRecordMapper;
44
+
45
+    @Mock
46
+    private WaterBillMapper waterBillMapper;
47
+
48
+    @Mock
49
+    private PaymentRecordMapper paymentRecordMapper;
50
+
51
+    private CsWorkbenchService csWorkbenchService;
52
+    private BillQueryService billQueryService;
53
+    private VoiceQueryService voiceQueryService;
54
+
55
+    @BeforeEach
56
+    void setUp() {
57
+        csWorkbenchService = new CsWorkbenchService(csWorkItemMapper, voiceCallRecordMapper);
58
+        billQueryService = new BillQueryService(waterBillMapper, paymentRecordMapper);
59
+        voiceQueryService = new VoiceQueryService(voiceCallRecordMapper, billQueryService);
60
+    }
61
+
62
+    // ====== 客服工作台测试 ======
63
+
64
+    @Nested
65
+    @DisplayName("客服工作台服务测试")
66
+    class WorkbenchTests {
67
+
68
+        @Test
69
+        @DisplayName("获取待处理数量")
70
+        void getPendingCount_returnsCorrectCount() {
71
+            when(csWorkItemMapper.countPending()).thenReturn(5);
72
+            int count = csWorkbenchService.getPendingCount();
73
+            assertEquals(5, count);
74
+            verify(csWorkItemMapper).countPending();
75
+        }
76
+
77
+        @Test
78
+        @DisplayName("获取今日统计")
79
+        void getTodayStats_returnsAllFields() {
80
+            when(csWorkItemMapper.countTodayNew()).thenReturn(10);
81
+            when(csWorkItemMapper.countTodayResolved()).thenReturn(6);
82
+            when(csWorkItemMapper.countPending()).thenReturn(4);
83
+            when(csWorkItemMapper.countProcessing()).thenReturn(3);
84
+            when(voiceCallRecordMapper.countTodayCalls()).thenReturn(20);
85
+
86
+            CsWorkbenchStats stats = csWorkbenchService.getTodayStats();
87
+
88
+            assertNotNull(stats);
89
+            assertEquals(10, stats.getTodayNewCount());
90
+            assertEquals(6, stats.getTodayResolvedCount());
91
+            assertEquals(4, stats.getPendingTotal());
92
+            assertEquals(3, stats.getProcessingCount());
93
+            assertEquals(20, stats.getTodayCallCount());
94
+            assertEquals(15.5, stats.getAvgProcessTime());
95
+        }
96
+
97
+        @Test
98
+        @DisplayName("创建工单 - 默认状态为pending")
99
+        void createWorkItem_setsDefaultStatus() {
100
+            CsWorkItem item = new CsWorkItem();
101
+            item.setWorkType("complaint");
102
+            item.setCustomerNo("C001");
103
+            item.setCustomerName("张三");
104
+            item.setSummary("水压过低");
105
+            item.setPriority("high");
106
+
107
+            when(csWorkItemMapper.insert(any(CsWorkItem.class))).thenReturn(1);
108
+
109
+            CsWorkItem result = csWorkbenchService.createWorkItem(item);
110
+
111
+            assertEquals("pending", result.getStatus());
112
+            assertNotNull(result.getCreatedAt());
113
+            assertNotNull(result.getUpdatedAt());
114
+            verify(csWorkItemMapper).insert(item);
115
+        }
116
+
117
+        @Test
118
+        @DisplayName("工单分页列表 - 带条件过滤")
119
+        void listWorkItems_withFilters() {
120
+            Page<CsWorkItem> mockPage = new Page<>(1, 10);
121
+            mockPage.setRecords(List.of(createWorkItem(1L, "complaint", "pending")));
122
+
123
+            when(csWorkItemMapper.selectPage(any(Page.class), any(LambdaQueryWrapper.class)))
124
+                    .thenReturn(mockPage);
125
+
126
+            Page<CsWorkItem> result = csWorkbenchService.listWorkItems(
127
+                    "complaint", "pending", null, null, 1, 10);
128
+
129
+            assertNotNull(result);
130
+            assertEquals(1, result.getRecords().size());
131
+            assertEquals("complaint", result.getRecords().get(0).getWorkType());
132
+        }
133
+
134
+        @Test
135
+        @DisplayName("转派工单 - 状态变为processing")
136
+        void reassignWorkItem_updatesStatusAndAssignee() {
137
+            csWorkbenchService.reassignWorkItem(1L, "李四");
138
+
139
+            verify(csWorkItemMapper).update(isNull(), any());
140
+        }
141
+    }
142
+
143
+    // ====== 水费查询测试 ======
144
+
145
+    @Nested
146
+    @DisplayName("水费查询服务测试")
147
+    class BillQueryTests {
148
+
149
+        @Test
150
+        @DisplayName("按户号查询 - 返回账单列表")
151
+        void queryByCustomerNo_returnsBills() {
152
+            List<WaterBill> mockBills = List.of(createWaterBill(1L, "C001"));
153
+            when(waterBillMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(mockBills);
154
+
155
+            BillQueryResult result = billQueryService.queryByCustomerNo("C001");
156
+
157
+            assertNotNull(result);
158
+            assertEquals("C001", result.getCustomerNo());
159
+            assertEquals(1, result.getBills().size());
160
+            assertNotNull(result.getTotalArrears());
161
+        }
162
+
163
+        @Test
164
+        @DisplayName("欠费查询 - 仅返回未缴账单")
165
+        void queryArrears_returnsUnpaidBills() {
166
+            List<WaterBill> mockBills = List.of(
167
+                    createWaterBillWithStatus(1L, "C001", "pending"),
168
+                    createWaterBillWithStatus(2L, "C001", "overdue"));
169
+            when(waterBillMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(mockBills);
170
+
171
+            List<BillQueryResult.BillSummary> result = billQueryService.queryArrears("C001");
172
+
173
+            assertNotNull(result);
174
+            assertEquals(2, result.size());
175
+        }
176
+    }
177
+
178
+    // ====== 语音自助查询测试 ======
179
+
180
+    @Nested
181
+    @DisplayName("语音自助查询服务测试")
182
+    class VoiceQueryTests {
183
+
184
+        @Test
185
+        @DisplayName("开始通话 - 返回主菜单")
186
+        void startCall_returnsMainMenu() {
187
+            when(voiceCallRecordMapper.insert(any(VoiceCallRecord.class))).thenReturn(1);
188
+
189
+            VoiceMenuResponse response = voiceQueryService.startCall("13800138000");
190
+
191
+            assertNotNull(response);
192
+            assertNotNull(response.getCallId());
193
+            assertEquals(1, response.getCurrentLevel());
194
+            assertTrue(response.isInputRequired());
195
+            assertEquals(5, response.getOptions().size());
196
+            assertEquals("1", response.getOptions().get(0).getKey());
197
+            verify(voiceCallRecordMapper).insert(any(VoiceCallRecord.class));
198
+        }
199
+
200
+        @Test
201
+        @DisplayName("按键选择 - 水费查询")
202
+        void handleKeyPress_billQuery() {
203
+            VoiceCallRecord mockRecord = createActiveCallRecord();
204
+            when(voiceCallRecordMapper.selectOne(any(LambdaQueryWrapper.class)))
205
+                    .thenReturn(mockRecord);
206
+            when(voiceCallRecordMapper.updateById(any(VoiceCallRecord.class))).thenReturn(1);
207
+
208
+            VoiceMenuResponse response = voiceQueryService.handleKeyPress(
209
+                    mockRecord.getCallId(), "1");
210
+
211
+            assertNotNull(response);
212
+            assertEquals(2, response.getCurrentLevel());
213
+            assertTrue(response.isInputRequired());
214
+            verify(voiceCallRecordMapper).updateById(any(VoiceCallRecord.class));
215
+        }
216
+
217
+        @Test
218
+        @DisplayName("结束通话 - 记录通话时长")
219
+        void endCall_recordsDuration() {
220
+            VoiceCallRecord mockRecord = createActiveCallRecord();
221
+            when(voiceCallRecordMapper.selectOne(any(LambdaQueryWrapper.class)))
222
+                    .thenReturn(mockRecord);
223
+            when(voiceCallRecordMapper.updateById(any(VoiceCallRecord.class))).thenReturn(1);
224
+
225
+            Map<String, Object> result = voiceQueryService.endCall(mockRecord.getCallId());
226
+
227
+            assertNotNull(result);
228
+            assertEquals("completed", result.get("status"));
229
+            assertNotNull(result.get("duration"));
230
+            assertEquals("completed", mockRecord.getStatus());
231
+        }
232
+    }
233
+
234
+    // ====== Helper Methods ======
235
+
236
+    private CsWorkItem createWorkItem(Long id, String workType, String status) {
237
+        CsWorkItem item = new CsWorkItem();
238
+        item.setId(id);
239
+        item.setWorkType(workType);
240
+        item.setCustomerNo("C001");
241
+        item.setCustomerName("测试用户");
242
+        item.setSummary("测试工单");
243
+        item.setPriority("medium");
244
+        item.setStatus(status);
245
+        item.setAssignee("客服A");
246
+        item.setCreatedAt(LocalDateTime.now());
247
+        item.setUpdatedAt(LocalDateTime.now());
248
+        return item;
249
+    }
250
+
251
+    private WaterBill createWaterBill(Long id, String customerNo) {
252
+        WaterBill bill = new WaterBill();
253
+        bill.setId(id);
254
+        bill.setBillNo("BILL-2026-001");
255
+        bill.setCustomerNo(customerNo);
256
+        bill.setCustomerName("测试用户");
257
+        bill.setBillPeriod("2026-06");
258
+        bill.setWaterUsage(new BigDecimal("15.5"));
259
+        bill.setUnitPrice(new BigDecimal("3.85"));
260
+        bill.setTotalAmount(new BigDecimal("59.68"));
261
+        bill.setPaidAmount(BigDecimal.ZERO);
262
+        bill.setStatus("pending");
263
+        bill.setIssueDate(LocalDate.of(2026, 6, 1));
264
+        bill.setDueDate(LocalDate.of(2026, 6, 30));
265
+        bill.setMeterNo("M001");
266
+        bill.setCreatedAt(LocalDateTime.now());
267
+        bill.setUpdatedAt(LocalDateTime.now());
268
+        return bill;
269
+    }
270
+
271
+    private WaterBill createWaterBillWithStatus(Long id, String customerNo, String status) {
272
+        WaterBill bill = createWaterBill(id, customerNo);
273
+        bill.setStatus(status);
274
+        return bill;
275
+    }
276
+
277
+    private VoiceCallRecord createActiveCallRecord() {
278
+        VoiceCallRecord record = new VoiceCallRecord();
279
+        record.setId(1L);
280
+        record.setCallId("test-call-001");
281
+        record.setCallerNumber("13800138000");
282
+        record.setMenuPath("main");
283
+        record.setMenuLevel(1);
284
+        record.setStatus("active");
285
+        record.setCallTime(LocalDateTime.now().minusSeconds(30));
286
+        record.setCreatedAt(LocalDateTime.now());
287
+        return record;
288
+    }
289
+}