feat(wm-revenue): #54 客服工作台+水费查询+语音自助
- Entity: CsWorkItem, VoiceCallRecord - DTO: CsWorkbenchStats, BillQueryResult, VoiceMenuResponse - Mapper: CsWorkItemMapper, VoiceCallRecordMapper - Service: CsWorkbenchService, BillQueryService, VoiceQueryService - Controller: CsWorkbenchController(9端点), BillQueryController(5端点), VoiceController(7端点) - DDL: V_cs_workbench.sql (2表+索引) - Test: CsWorkbenchTest (10个测试用例)
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import request from './request'
|
||||
|
||||
const BASE = '/wx-hall'
|
||||
|
||||
// ========== 水费查询缴费 ==========
|
||||
|
||||
export function getBillList(params: {
|
||||
customerNo: string
|
||||
billPeriod?: string
|
||||
status?: string
|
||||
pageNum?: number
|
||||
pageSize?: number
|
||||
}) {
|
||||
return request.get(`${BASE}/bill/list`, { params })
|
||||
}
|
||||
|
||||
export function getBillDetail(billId: number) {
|
||||
return request.get(`${BASE}/bill/${billId}`)
|
||||
}
|
||||
|
||||
export function payBill(data: { billId: number; amount: number }) {
|
||||
return request.post(`${BASE}/bill/pay`, data)
|
||||
}
|
||||
|
||||
export function getPaymentRecords(params: { customerNo: string; limit?: number }) {
|
||||
return request.get(`${BASE}/bill/payment-records`, { params })
|
||||
}
|
||||
|
||||
// ========== 报装申请 ==========
|
||||
|
||||
export function submitInstallApply(data: {
|
||||
name: string
|
||||
phone: string
|
||||
area: string
|
||||
address: string
|
||||
customerType?: string
|
||||
caliber?: string
|
||||
}) {
|
||||
return request.post(`${BASE}/install/apply`, data)
|
||||
}
|
||||
|
||||
export function getInstallProgress(applyNo: string) {
|
||||
return request.get(`${BASE}/install/progress`, { params: { applyNo } })
|
||||
}
|
||||
|
||||
export function getInstallList(params: { phone: string; limit?: number }) {
|
||||
return request.get(`${BASE}/install/list`, { params })
|
||||
}
|
||||
|
||||
// ========== 停水公告 ==========
|
||||
|
||||
export function getNoticeList(params: {
|
||||
page?: number
|
||||
size?: number
|
||||
type?: string
|
||||
keyword?: string
|
||||
}) {
|
||||
return request.get(`${BASE}/notice/list`, { params })
|
||||
}
|
||||
|
||||
export function getNoticeDetail(id: number) {
|
||||
return request.get(`${BASE}/notice/${id}`)
|
||||
}
|
||||
|
||||
export function getActiveNotices(areaCode?: string) {
|
||||
return request.get(`${BASE}/notice/active`, { params: { areaCode } })
|
||||
}
|
||||
|
||||
// ========== 用户绑定 ==========
|
||||
|
||||
export function bindPhone(data: { openId: string; phone: string }) {
|
||||
return request.post(`${BASE}/user/bindPhone`, data)
|
||||
}
|
||||
|
||||
export function bindCustomer(data: { openId: string; customerNo: string }) {
|
||||
return request.post(`${BASE}/user/bindCustomer`, data)
|
||||
}
|
||||
|
||||
export function unbind(data: { openId: string; bindingId: string }) {
|
||||
return request.post(`${BASE}/user/unbind`, data)
|
||||
}
|
||||
|
||||
export function getBindings(openId: string) {
|
||||
return request.get(`${BASE}/user/bindings`, { params: { openId } })
|
||||
}
|
||||
@@ -14,6 +14,11 @@ const routes = [
|
||||
{ path: 'dispatch-command', name: 'dispatchCommandList', component: () => import('@/views/dispatch-command/CommandList.vue') },
|
||||
{ path: 'dispatch-command/:id', name: 'dispatchCommandDetail', component: () => import('@/views/dispatch-command/CommandDetail.vue') },
|
||||
{ path: 'service/workbench', name: 'serviceWorkbench', component: () => import('@/views/service/CustomerServiceWorkbench.vue') },
|
||||
// 微信网厅
|
||||
{ path: 'wx-hall/water-bill', name: 'wxWaterBill', component: () => import('@/views/wxhall/WaterBillView.vue') },
|
||||
{ path: 'wx-hall/install-apply', name: 'wxInstallApply', component: () => import('@/views/wxhall/InstallApplyView.vue') },
|
||||
{ path: 'wx-hall/notice', name: 'wxNotice', component: () => import('@/views/wxhall/NoticeView.vue') },
|
||||
{ path: 'wx-hall/user-bind', name: 'wxUserBind', component: () => import('@/views/wxhall/UserBindView.vue') },
|
||||
]
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' }
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<div class="wx-install-apply">
|
||||
<el-page-header @back="$router.back()" title="返回" content="报装申请" />
|
||||
|
||||
<!-- 申请表单 -->
|
||||
<el-card shadow="never" style="margin-top: 16px">
|
||||
<template #header>
|
||||
<span>新建报装申请</span>
|
||||
</template>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" style="max-width: 600px">
|
||||
<el-form-item label="申请人" prop="name">
|
||||
<el-input v-model="form.name" placeholder="请输入申请人姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="联系电话" prop="phone">
|
||||
<el-input v-model="form.phone" placeholder="请输入手机号" maxlength="11" />
|
||||
</el-form-item>
|
||||
<el-form-item label="所在区域" prop="area">
|
||||
<el-select v-model="form.area" placeholder="请选择区域" style="width: 100%">
|
||||
<el-option label="城东片区" value="城东片区" />
|
||||
<el-option label="城西片区" value="城西片区" />
|
||||
<el-option label="城南片区" value="城南片区" />
|
||||
<el-option label="城北片区" value="城北片区" />
|
||||
<el-option label="开发区" value="开发区" />
|
||||
<el-option label="高新区" value="高新区" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="详细地址" prop="address">
|
||||
<el-input v-model="form.address" type="textarea" :rows="2" placeholder="请输入详细地址" />
|
||||
</el-form-item>
|
||||
<el-form-item label="用水类型">
|
||||
<el-select v-model="form.customerType" style="width: 100%">
|
||||
<el-option label="居民用水" value="resident" />
|
||||
<el-option label="商业用水" value="commercial" />
|
||||
<el-option label="工业用水" value="industrial" />
|
||||
<el-option label="行政事业" value="public" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="口径">
|
||||
<el-select v-model="form.caliber" style="width: 100%">
|
||||
<el-option label="DN15" value="DN15" />
|
||||
<el-option label="DN20" value="DN20" />
|
||||
<el-option label="DN25" value="DN25" />
|
||||
<el-option label="DN32" value="DN32" />
|
||||
<el-option label="DN40" value="DN40" />
|
||||
<el-option label="DN50" value="DN50" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="submitting">提交申请</el-button>
|
||||
<el-button @click="resetForm">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 进度查询 -->
|
||||
<el-card shadow="never" style="margin-top: 12px">
|
||||
<template #header>
|
||||
<span>进度查询</span>
|
||||
</template>
|
||||
<el-form :inline="true">
|
||||
<el-form-item label="申请编号">
|
||||
<el-input v-model="progressQueryNo" placeholder="输入申请编号查询" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="queryProgress" :disabled="!progressQueryNo">查询</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-steps :active="progressStep" finish-status="success" align-center v-if="progressData"
|
||||
style="margin-top: 20px">
|
||||
<el-step title="预受理" :description="progressData.status === 'pre_apply' ? '当前阶段' : ''" />
|
||||
<el-step title="工程审核" :description="progressData.status === 'engineering' ? '当前阶段' : ''" />
|
||||
<el-step title="派单施工" :description="progressData.status === 'pending_review' ? '当前阶段' : ''" />
|
||||
<el-step title="已完成" :description="progressData.status === 'completed' ? '当前阶段' : ''" />
|
||||
</el-steps>
|
||||
|
||||
<el-descriptions :column="2" border style="margin-top: 16px" v-if="progressData">
|
||||
<el-descriptions-item label="申请编号">{{ progressData.application_no }}</el-descriptions-item>
|
||||
<el-descriptions-item label="申请人">{{ progressData.applicant_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="联系电话">{{ progressData.applicant_phone }}</el-descriptions-item>
|
||||
<el-descriptions-item label="区域">{{ progressData.area }}</el-descriptions-item>
|
||||
<el-descriptions-item label="地址" :span="2">{{ progressData.address }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用水类型">{{ progressData.customer_type }}</el-descriptions-item>
|
||||
<el-descriptions-item label="口径">{{ progressData.caliber }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="installStatusTag(progressData.status)">{{ installStatusLabel(progressData.status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ progressData.updated_at }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<!-- 申请记录 -->
|
||||
<el-card shadow="never" style="margin-top: 12px">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>申请记录</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-form :inline="true" style="margin-bottom: 12px">
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="recordPhone" placeholder="输入手机号查询" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="fetchRecords">查询</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="records" border stripe v-loading="recordLoading">
|
||||
<el-table-column prop="application_no" label="申请编号" min-width="180" />
|
||||
<el-table-column prop="applicant_name" label="申请人" width="100" />
|
||||
<el-table-column prop="area" label="区域" width="100" />
|
||||
<el-table-column prop="address" label="地址" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="customer_type" label="用水类型" width="100" />
|
||||
<el-table-column prop="caliber" label="口径" width="80" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="installStatusTag(row.status)" size="small">{{ installStatusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="申请时间" width="170" />
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="viewRecordProgress(row)">进度</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { FormInstance } from 'element-plus'
|
||||
import { submitInstallApply, getInstallProgress, getInstallList } from '@/api/wx-hall'
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
const progressQueryNo = ref('')
|
||||
const progressData = ref<any>(null)
|
||||
const progressStep = ref(0)
|
||||
const recordPhone = ref('')
|
||||
const records = ref<any[]>([])
|
||||
const recordLoading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
phone: '',
|
||||
area: '',
|
||||
address: '',
|
||||
customerType: 'resident',
|
||||
caliber: 'DN15'
|
||||
})
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入申请人姓名', trigger: 'blur' }],
|
||||
phone: [
|
||||
{ required: true, message: '请输入手机号', trigger: 'blur' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }
|
||||
],
|
||||
area: [{ required: true, message: '请选择区域', trigger: 'change' }],
|
||||
address: [{ required: true, message: '请输入详细地址', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formRef.value) return
|
||||
await formRef.value.validate()
|
||||
submitting.value = true
|
||||
try {
|
||||
const res = await submitInstallApply(form)
|
||||
ElMessage.success(`申请提交成功!申请编号: ${res.data?.applicationNo}`)
|
||||
resetForm()
|
||||
} catch (e) { /* ignore */ } finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formRef.value?.resetFields()
|
||||
Object.assign(form, { name: '', phone: '', area: '', address: '', customerType: 'resident', caliber: 'DN15' })
|
||||
}
|
||||
|
||||
async function queryProgress() {
|
||||
if (!progressQueryNo.value) return
|
||||
try {
|
||||
const res = await getInstallProgress(progressQueryNo.value)
|
||||
progressData.value = res.data
|
||||
progressStep.value = calcStep(res.data?.status)
|
||||
} catch (e) {
|
||||
progressData.value = null
|
||||
progressStep.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRecords() {
|
||||
if (!recordPhone.value) {
|
||||
ElMessage.warning('请输入手机号')
|
||||
return
|
||||
}
|
||||
recordLoading.value = true
|
||||
try {
|
||||
const res = await getInstallList({ phone: recordPhone.value, limit: 50 })
|
||||
records.value = res.data || []
|
||||
} finally {
|
||||
recordLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function viewRecordProgress(row: any) {
|
||||
progressQueryNo.value = row.application_no
|
||||
queryProgress()
|
||||
}
|
||||
|
||||
function calcStep(status: string): number {
|
||||
const map: Record<string, number> = {
|
||||
pre_apply: 0,
|
||||
engineering: 1,
|
||||
pending_review: 2,
|
||||
completed: 3
|
||||
}
|
||||
return map[status] ?? 0
|
||||
}
|
||||
|
||||
function installStatusLabel(s: string): string {
|
||||
const map: Record<string, string> = {
|
||||
pre_apply: '预受理',
|
||||
engineering: '工程审核',
|
||||
pending_review: '派单施工',
|
||||
completed: '已完成'
|
||||
}
|
||||
return map[s] || s
|
||||
}
|
||||
|
||||
function installStatusTag(s: string): any {
|
||||
const map: Record<string, string> = {
|
||||
pre_apply: 'info',
|
||||
engineering: 'warning',
|
||||
pending_review: '',
|
||||
completed: 'success'
|
||||
}
|
||||
return map[s] || 'info'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.wx-install-apply {
|
||||
padding: 20px;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,217 @@
|
||||
<template>
|
||||
<div class="wx-notice">
|
||||
<el-page-header @back="$router.back()" title="返回" content="停水公告" />
|
||||
|
||||
<!-- 搜索和筛选 -->
|
||||
<el-card shadow="never" style="margin-top: 16px">
|
||||
<el-form :inline="true" :model="queryForm">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="queryForm.keyword" placeholder="搜索公告标题/内容" clearable @clear="handleSearch" />
|
||||
</el-form-item>
|
||||
<el-form-item label="类型">
|
||||
<el-select v-model="queryForm.type" placeholder="全部" clearable @change="handleSearch">
|
||||
<el-option label="停水" value="water_outage" />
|
||||
<el-option label="水质" value="water_quality" />
|
||||
<el-option label="维修" value="maintenance" />
|
||||
<el-option label="其他" value="other" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 公告列表 -->
|
||||
<div class="notice-list" style="margin-top: 12px">
|
||||
<el-card shadow="hover" v-for="item in noticeList" :key="item.id" class="notice-item"
|
||||
@click="viewDetail(item)">
|
||||
<div class="notice-header">
|
||||
<div class="notice-title">
|
||||
<el-tag :type="typeTag(item.type)" size="small" class="type-tag">{{ typeLabel(item.type) }}</el-tag>
|
||||
<el-tag :type="priorityTag(item.priority)" size="small" v-if="item.priority === 'urgent' || item.priority === 'high'" class="priority-tag">
|
||||
{{ priorityLabel(item.priority) }}
|
||||
</el-tag>
|
||||
<span class="title-text">{{ item.title }}</span>
|
||||
</div>
|
||||
<span class="notice-time">{{ item.publishTime }}</span>
|
||||
</div>
|
||||
<div class="notice-meta">
|
||||
<span v-if="item.affectedArea">📍 {{ item.affectedArea }}</span>
|
||||
<span v-if="item.plannedStart">🕐 {{ item.plannedStart }} ~ {{ item.plannedEnd }}</span>
|
||||
</div>
|
||||
<div class="notice-preview">{{ truncate(item.content, 100) }}</div>
|
||||
</el-card>
|
||||
|
||||
<el-empty v-if="!loading && noticeList.length === 0" description="暂无公告" />
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<el-pagination style="margin-top: 16px; justify-content: flex-end"
|
||||
v-model:current-page="pagination.page" v-model:page-size="pagination.size"
|
||||
:total="pagination.total" :page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next" @change="fetchNotices" />
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<el-dialog v-model="showDetail" :title="detailItem?.title" width="650px" top="5vh">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="类型">
|
||||
<el-tag :type="typeTag(detailItem?.type)">{{ typeLabel(detailItem?.type) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="优先级">
|
||||
<el-tag :type="priorityTag(detailItem?.priority)">{{ priorityLabel(detailItem?.priority) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="影响范围" :span="2">{{ detailItem?.affectedArea }}</el-descriptions-item>
|
||||
<el-descriptions-item label="计划开始">{{ detailItem?.plannedStart }}</el-descriptions-item>
|
||||
<el-descriptions-item label="计划结束">{{ detailItem?.plannedEnd }}</el-descriptions-item>
|
||||
<el-descriptions-item label="发布时间">{{ detailItem?.publishTime }}</el-descriptions-item>
|
||||
<el-descriptions-item label="发布人">{{ detailItem?.publisherName }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-divider />
|
||||
<div class="detail-content">{{ detailItem?.content }}</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { getNoticeList, getNoticeDetail } from '@/api/wx-hall'
|
||||
|
||||
const loading = ref(false)
|
||||
const showDetail = ref(false)
|
||||
const noticeList = ref<any[]>([])
|
||||
const detailItem = ref<any>(null)
|
||||
|
||||
const queryForm = reactive({
|
||||
keyword: '',
|
||||
type: ''
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
size: 10,
|
||||
total: 0
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchNotices()
|
||||
})
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
fetchNotices()
|
||||
}
|
||||
|
||||
async function fetchNotices() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getNoticeList({
|
||||
page: pagination.page,
|
||||
size: pagination.size,
|
||||
type: queryForm.type || undefined,
|
||||
keyword: queryForm.keyword || undefined
|
||||
})
|
||||
noticeList.value = res.data?.records || []
|
||||
pagination.total = res.data?.total || 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function viewDetail(row: any) {
|
||||
try {
|
||||
const res = await getNoticeDetail(row.id)
|
||||
detailItem.value = res.data
|
||||
showDetail.value = true
|
||||
} catch (e) {
|
||||
// fallback to list item data
|
||||
detailItem.value = row
|
||||
showDetail.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function truncate(text: string, maxLen: number) {
|
||||
if (!text) return ''
|
||||
return text.length > maxLen ? text.slice(0, maxLen) + '...' : text
|
||||
}
|
||||
|
||||
const typeMap: Record<string, string> = {
|
||||
water_outage: '停水',
|
||||
water_quality: '水质',
|
||||
maintenance: '维修',
|
||||
other: '其他'
|
||||
}
|
||||
|
||||
const typeTagMap: Record<string, string> = {
|
||||
water_outage: 'danger',
|
||||
water_quality: 'warning',
|
||||
maintenance: '',
|
||||
other: 'info'
|
||||
}
|
||||
|
||||
function typeLabel(t: string) { return typeMap[t] || t }
|
||||
function typeTag(t: string): any { return typeTagMap[t] || 'info' }
|
||||
|
||||
function priorityLabel(p: string) {
|
||||
return { low: '低', medium: '中', high: '高', urgent: '紧急' }[p] || p
|
||||
}
|
||||
|
||||
function priorityTag(p: string): any {
|
||||
return { low: 'info', medium: '', high: 'warning', urgent: 'danger' }[p] || 'info'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.wx-notice {
|
||||
padding: 20px;
|
||||
}
|
||||
.notice-item {
|
||||
margin-bottom: 10px;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
.notice-item:hover {
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.notice-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.notice-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
.title-text {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
.notice-time {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
white-space: nowrap;
|
||||
margin-left: 12px;
|
||||
}
|
||||
.notice-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.notice-preview {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.detail-content {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.8;
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<div class="wx-user-bind">
|
||||
<el-page-header @back="$router.back()" title="返回" content="用户绑定" />
|
||||
|
||||
<!-- OpenID 输入(模拟微信环境) -->
|
||||
<el-card shadow="never" style="margin-top: 16px">
|
||||
<el-form :inline="true">
|
||||
<el-form-item label="OpenID">
|
||||
<el-input v-model="openId" placeholder="请输入微信 OpenID" clearable @clear="handleOpenIdChange" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleOpenIdChange" :disabled="!openId">确认</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<template v-if="openId">
|
||||
<!-- 手机号绑定 -->
|
||||
<el-card shadow="never" style="margin-top: 12px">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>📱 手机号绑定</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-form :inline="true" :model="phoneForm">
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="phoneForm.phone" placeholder="请输入手机号" maxlength="11" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleBindPhone" :loading="phoneLoading"
|
||||
:disabled="!phoneForm.phone">绑定手机号</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 户号绑定 -->
|
||||
<el-card shadow="never" style="margin-top: 12px">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>🏠 户号绑定</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-form :inline="true" :model="customerForm">
|
||||
<el-form-item label="户号">
|
||||
<el-input v-model="customerForm.customerNo" placeholder="请输入水费户号" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleBindCustomer" :loading="customerLoading"
|
||||
:disabled="!customerForm.customerNo">绑定户号</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 已绑定列表 -->
|
||||
<el-card shadow="never" style="margin-top: 12px">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>已绑定列表</span>
|
||||
<el-button link type="primary" @click="fetchBindings">刷新</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="bindings" border stripe v-loading="bindingsLoading">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="bindingType" label="类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.binding_type === 'phone' ? '' : 'success'" size="small">
|
||||
{{ row.binding_type === 'phone' ? '手机号' : '户号' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="phone" label="手机号" width="140">
|
||||
<template #default="{ row }">{{ row.phone || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="customerNo" label="户号" width="140">
|
||||
<template #default="{ row }">{{ row.customer_no || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="customerName" label="客户名称" width="140">
|
||||
<template #default="{ row }">{{ row.customer_name || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="绑定时间" min-width="170">
|
||||
<template #default="{ row }">{{ row.created_at }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-popconfirm title="确定解绑吗?" @confirm="handleUnbind(row)">
|
||||
<template #reference>
|
||||
<el-button link type="danger">解绑</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-empty v-if="!bindingsLoading && bindings.length === 0" description="暂无绑定记录" />
|
||||
</el-card>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { bindPhone, bindCustomer, unbind, getBindings } from '@/api/wx-hall'
|
||||
|
||||
const openId = ref('')
|
||||
const phoneLoading = ref(false)
|
||||
const customerLoading = ref(false)
|
||||
const bindingsLoading = ref(false)
|
||||
const bindings = ref<any[]>([])
|
||||
|
||||
const phoneForm = reactive({ phone: '' })
|
||||
const customerForm = reactive({ customerNo: '' })
|
||||
|
||||
function handleOpenIdChange() {
|
||||
if (!openId.value) {
|
||||
ElMessage.warning('请输入 OpenID')
|
||||
return
|
||||
}
|
||||
fetchBindings()
|
||||
}
|
||||
|
||||
async function handleBindPhone() {
|
||||
if (!phoneForm.phone) {
|
||||
ElMessage.warning('请输入手机号')
|
||||
return
|
||||
}
|
||||
if (!/^1[3-9]\d{9}$/.test(phoneForm.phone)) {
|
||||
ElMessage.warning('手机号格式不正确')
|
||||
return
|
||||
}
|
||||
phoneLoading.value = true
|
||||
try {
|
||||
await bindPhone({ openId: openId.value, phone: phoneForm.phone })
|
||||
ElMessage.success('手机号绑定成功')
|
||||
phoneForm.phone = ''
|
||||
fetchBindings()
|
||||
} catch (e) { /* ignore */ } finally {
|
||||
phoneLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBindCustomer() {
|
||||
if (!customerForm.customerNo) {
|
||||
ElMessage.warning('请输入户号')
|
||||
return
|
||||
}
|
||||
customerLoading.value = true
|
||||
try {
|
||||
await bindCustomer({ openId: openId.value, customerNo: customerForm.customerNo })
|
||||
ElMessage.success('户号绑定成功')
|
||||
customerForm.customerNo = ''
|
||||
fetchBindings()
|
||||
} catch (e) { /* ignore */ } finally {
|
||||
customerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnbind(row: any) {
|
||||
try {
|
||||
await unbind({ openId: openId.value, bindingId: String(row.id) })
|
||||
ElMessage.success('解绑成功')
|
||||
fetchBindings()
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
async function fetchBindings() {
|
||||
if (!openId.value) return
|
||||
bindingsLoading.value = true
|
||||
try {
|
||||
const res = await getBindings(openId.value)
|
||||
bindings.value = res.data || []
|
||||
} finally {
|
||||
bindingsLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.wx-user-bind {
|
||||
padding: 20px;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,287 @@
|
||||
<template>
|
||||
<div class="wx-water-bill">
|
||||
<el-page-header @back="$router.back()" title="返回" content="水费查询缴费" />
|
||||
|
||||
<!-- 用户绑定区域 -->
|
||||
<el-card shadow="never" style="margin-top: 16px">
|
||||
<el-form :inline="true" :model="queryForm">
|
||||
<el-form-item label="户号">
|
||||
<el-input v-model="queryForm.customerNo" placeholder="请输入户号" clearable @clear="handleQuery" />
|
||||
</el-form-item>
|
||||
<el-form-item label="账单周期">
|
||||
<el-date-picker v-model="queryForm.billPeriod" type="month" placeholder="选择月份"
|
||||
value-format="YYYY-MM" clearable @change="handleQuery" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="queryForm.status" placeholder="全部" clearable @change="handleQuery">
|
||||
<el-option label="待缴" value="pending" />
|
||||
<el-option label="已缴" value="paid" />
|
||||
<el-option label="部分" value="partial" />
|
||||
<el-option label="逾期" value="overdue" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleQuery">查询</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 账单列表 -->
|
||||
<el-card shadow="never" style="margin-top: 12px">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>账单列表</span>
|
||||
<el-tag v-if="totalUnpaid > 0" type="danger">待缴 ¥{{ totalUnpaid.toFixed(2) }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="billList" v-loading="loading" border stripe>
|
||||
<el-table-column prop="billNo" label="账单编号" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="billPeriod" label="账单周期" width="110" />
|
||||
<el-table-column prop="waterUsage" label="用水量(吨)" width="100" align="right">
|
||||
<template #default="{ row }">{{ Number(row.waterUsage).toFixed(1) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="totalAmount" label="应缴(元)" width="100" align="right">
|
||||
<template #default="{ row }">{{ Number(row.totalAmount).toFixed(2) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="paidAmount" label="已缴(元)" width="100" align="right">
|
||||
<template #default="{ row }">{{ Number(row.paidAmount).toFixed(2) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTag(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="dueDate" label="到期日" width="110" />
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="viewDetail(row)">详情</el-button>
|
||||
<el-button link type="success" v-if="row.status !== 'paid' && row.status !== 'cancelled'"
|
||||
@click="openPayDialog(row)">缴费</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination style="margin-top: 16px; justify-content: flex-end"
|
||||
v-model:current-page="pagination.pageNum" v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total" :page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next" @change="fetchBills" />
|
||||
</el-card>
|
||||
|
||||
<!-- 缴费记录 -->
|
||||
<el-card shadow="never" style="margin-top: 12px">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>缴费记录</span>
|
||||
<el-button link type="primary" @click="fetchPaymentRecords">刷新</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="paymentRecords" border stripe size="small">
|
||||
<el-table-column prop="paymentNo" label="流水号" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="billNo" label="账单编号" width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="amount" label="金额(元)" width="100" align="right">
|
||||
<template #default="{ row }">{{ Number(row.amount).toFixed(2) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="channelName" label="渠道" width="100" />
|
||||
<el-table-column prop="status" label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'success' ? 'success' : 'info'" size="small">
|
||||
{{ row.status === 'success' ? '成功' : row.status }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="payTime" label="支付时间" width="170" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 账单详情弹窗 -->
|
||||
<el-dialog v-model="showDetail" title="账单详情" width="550px">
|
||||
<el-descriptions :column="2" border v-if="detailData">
|
||||
<el-descriptions-item label="账单编号">{{ detailData.bill?.billNo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="账单周期">{{ detailData.bill?.billPeriod }}</el-descriptions-item>
|
||||
<el-descriptions-item label="客户名称">{{ detailData.bill?.customerName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="户号">{{ detailData.bill?.customerNo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="水表编号">{{ detailData.bill?.meterNo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用水量">{{ Number(detailData.bill?.waterUsage || 0).toFixed(1) }} 吨</el-descriptions-item>
|
||||
<el-descriptions-item label="单价">¥{{ Number(detailData.bill?.unitPrice || 0).toFixed(2) }}/吨</el-descriptions-item>
|
||||
<el-descriptions-item label="应缴金额">¥{{ Number(detailData.bill?.totalAmount || 0).toFixed(2) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已缴金额">¥{{ Number(detailData.bill?.paidAmount || 0).toFixed(2) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="剩余金额">
|
||||
<el-tag :type="detailData.remainingAmount > 0 ? 'danger' : 'success'">
|
||||
¥{{ Number(detailData.remainingAmount || 0).toFixed(2) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="抄表读数">
|
||||
{{ detailData.bill?.prevReading }} → {{ detailData.bill?.currReading }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="statusTag(detailData.bill?.status)">{{ statusLabel(detailData.bill?.status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-divider v-if="detailData?.payments?.length > 0">缴费记录</el-divider>
|
||||
<el-table :data="detailData?.payments || []" size="small" border v-if="detailData?.payments?.length > 0">
|
||||
<el-table-column prop="paymentNo" label="流水号" />
|
||||
<el-table-column prop="amount" label="金额" width="100" />
|
||||
<el-table-column prop="channelName" label="渠道" width="100" />
|
||||
<el-table-column prop="payTime" label="时间" width="170" />
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 缴费弹窗 -->
|
||||
<el-dialog v-model="showPay" title="在线缴费" width="420px">
|
||||
<div v-if="payingBill" class="pay-info">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="账单编号">{{ payingBill.billNo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="账单周期">{{ payingBill.billPeriod }}</el-descriptions-item>
|
||||
<el-descriptions-item label="应缴金额">¥{{ Number(payingBill.totalAmount).toFixed(2) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已缴金额">¥{{ Number(payingBill.paidAmount).toFixed(2) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-divider />
|
||||
<el-form-item label="缴费金额">
|
||||
<el-input-number v-model="payAmount" :min="0.01"
|
||||
:max="Number(payingBill.totalAmount) - Number(payingBill.paidAmount)"
|
||||
:precision="2" :step="1" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="showPay = false">取消</el-button>
|
||||
<el-button type="primary" :loading="payLoading" @click="handlePay">确认缴费</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getBillList, getBillDetail, payBill, getPaymentRecords } from '@/api/wx-hall'
|
||||
|
||||
const loading = ref(false)
|
||||
const payLoading = ref(false)
|
||||
const showDetail = ref(false)
|
||||
const showPay = ref(false)
|
||||
const billList = ref<any[]>([])
|
||||
const paymentRecords = ref<any[]>([])
|
||||
const detailData = ref<any>(null)
|
||||
const payingBill = ref<any>(null)
|
||||
const payAmount = ref(0)
|
||||
|
||||
const queryForm = reactive({
|
||||
customerNo: '',
|
||||
billPeriod: '',
|
||||
status: ''
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
})
|
||||
|
||||
const totalUnpaid = computed(() => {
|
||||
return billList.value
|
||||
.filter(b => b.status !== 'paid' && b.status !== 'cancelled')
|
||||
.reduce((sum, b) => sum + (Number(b.totalAmount) - Number(b.paidAmount)), 0)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// 自动加载(需要提供 customerNo)
|
||||
})
|
||||
|
||||
function handleQuery() {
|
||||
if (!queryForm.customerNo) {
|
||||
ElMessage.warning('请输入户号')
|
||||
return
|
||||
}
|
||||
pagination.pageNum = 1
|
||||
fetchBills()
|
||||
fetchPaymentRecords()
|
||||
}
|
||||
|
||||
async function fetchBills() {
|
||||
if (!queryForm.customerNo) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getBillList({
|
||||
customerNo: queryForm.customerNo,
|
||||
billPeriod: queryForm.billPeriod || undefined,
|
||||
status: queryForm.status || undefined,
|
||||
pageNum: pagination.pageNum,
|
||||
pageSize: pagination.pageSize
|
||||
})
|
||||
billList.value = res.data?.records || []
|
||||
pagination.total = res.data?.total || 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPaymentRecords() {
|
||||
if (!queryForm.customerNo) return
|
||||
try {
|
||||
const res = await getPaymentRecords({ customerNo: queryForm.customerNo, limit: 20 })
|
||||
paymentRecords.value = res.data || []
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
async function viewDetail(row: any) {
|
||||
try {
|
||||
const res = await getBillDetail(row.id)
|
||||
detailData.value = res.data
|
||||
showDetail.value = true
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function openPayDialog(row: any) {
|
||||
payingBill.value = row
|
||||
const remaining = Number(row.totalAmount) - Number(row.paidAmount)
|
||||
payAmount.value = Math.round(remaining * 100) / 100
|
||||
showPay.value = true
|
||||
}
|
||||
|
||||
async function handlePay() {
|
||||
if (!payingBill.value || payAmount.value <= 0) {
|
||||
ElMessage.warning('请输入正确的缴费金额')
|
||||
return
|
||||
}
|
||||
payLoading.value = true
|
||||
try {
|
||||
await payBill({ billId: payingBill.value.id, amount: payAmount.value })
|
||||
ElMessage.success('缴费成功!')
|
||||
showPay.value = false
|
||||
fetchBills()
|
||||
fetchPaymentRecords()
|
||||
} catch (e) { /* ignore */ } finally {
|
||||
payLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(s: string) {
|
||||
const map: Record<string, string> = {
|
||||
pending: '待缴', paid: '已缴', partial: '部分', overdue: '逾期', cancelled: '已作废'
|
||||
}
|
||||
return map[s] || s
|
||||
}
|
||||
|
||||
function statusTag(s: string): any {
|
||||
const map: Record<string, string> = {
|
||||
pending: 'warning', paid: 'success', partial: '', overdue: 'danger', cancelled: 'info'
|
||||
}
|
||||
return map[s] || 'info'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.wx-water-bill {
|
||||
padding: 20px;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.pay-info {
|
||||
padding: 0 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.water.revenue.controller;
|
||||
|
||||
import com.water.common.core.result.R;
|
||||
import com.water.revenue.dto.BillQueryResult;
|
||||
import com.water.revenue.service.BillQueryService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Tag(name = "水费查询(客服工作台)")
|
||||
@RestController
|
||||
@RequestMapping("/api/revenue/cs/bill-query")
|
||||
@RequiredArgsConstructor
|
||||
public class BillQueryController {
|
||||
|
||||
private final BillQueryService billQueryService;
|
||||
|
||||
@GetMapping("/by-customer/{customerNo}")
|
||||
@Operation(summary = "按户号查询水费")
|
||||
public R<BillQueryResult> queryByCustomerNo(@PathVariable String customerNo) {
|
||||
return R.ok(billQueryService.queryByCustomerNo(customerNo));
|
||||
}
|
||||
|
||||
@GetMapping("/by-phone/{phone}")
|
||||
@Operation(summary = "按手机号查询水费")
|
||||
public R<BillQueryResult> queryByPhone(@PathVariable String phone) {
|
||||
return R.ok(billQueryService.queryByPhone(phone));
|
||||
}
|
||||
|
||||
@GetMapping("/by-address")
|
||||
@Operation(summary = "按地址查询水费")
|
||||
public R<BillQueryResult> queryByAddress(@RequestParam String address) {
|
||||
return R.ok(billQueryService.queryByAddress(address));
|
||||
}
|
||||
|
||||
@GetMapping("/detail/{billId}")
|
||||
@Operation(summary = "账单明细(含缴费记录)")
|
||||
public R<Map<String, Object>> getBillDetail(@PathVariable Long billId) {
|
||||
return R.ok(billQueryService.getBillDetail(billId));
|
||||
}
|
||||
|
||||
@GetMapping("/arrears/{customerNo}")
|
||||
@Operation(summary = "欠费查询")
|
||||
public R<List<BillQueryResult.BillSummary>> queryArrears(@PathVariable String customerNo) {
|
||||
return R.ok(billQueryService.queryArrears(customerNo));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.water.revenue.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.water.common.core.result.R;
|
||||
import com.water.revenue.dto.CsWorkbenchStats;
|
||||
import com.water.revenue.entity.CsWorkItem;
|
||||
import com.water.revenue.service.CsWorkbenchService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Tag(name = "客服工作台")
|
||||
@RestController
|
||||
@RequestMapping("/api/revenue/cs/workbench")
|
||||
@RequiredArgsConstructor
|
||||
public class CsWorkbenchController {
|
||||
|
||||
private final CsWorkbenchService csWorkbenchService;
|
||||
|
||||
@GetMapping("/list")
|
||||
@Operation(summary = "工单分页列表")
|
||||
public R<Page<CsWorkItem>> listWorkItems(
|
||||
@RequestParam(required = false) String workType,
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(required = false) String priority,
|
||||
@RequestParam(required = false) String assignee,
|
||||
@RequestParam(defaultValue = "1") int pageNum,
|
||||
@RequestParam(defaultValue = "10") int pageSize) {
|
||||
return R.ok(csWorkbenchService.listWorkItems(workType, status, priority, assignee, pageNum, pageSize));
|
||||
}
|
||||
|
||||
@GetMapping("/pending-count")
|
||||
@Operation(summary = "待处理工单数量")
|
||||
public R<Integer> getPendingCount() {
|
||||
return R.ok(csWorkbenchService.getPendingCount());
|
||||
}
|
||||
|
||||
@GetMapping("/today-stats")
|
||||
@Operation(summary = "今日统计数据")
|
||||
public R<CsWorkbenchStats> getTodayStats() {
|
||||
return R.ok(csWorkbenchService.getTodayStats());
|
||||
}
|
||||
|
||||
@PostMapping("/create")
|
||||
@Operation(summary = "创建工单")
|
||||
public R<CsWorkItem> createWorkItem(@RequestBody CsWorkItem item) {
|
||||
return R.ok(csWorkbenchService.createWorkItem(item));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/status")
|
||||
@Operation(summary = "更新工单状态")
|
||||
public R<String> updateStatus(@PathVariable Long id, @RequestParam String status) {
|
||||
csWorkbenchService.updateWorkItemStatus(id, status);
|
||||
return R.ok("状态已更新");
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@Operation(summary = "工单详情")
|
||||
public R<CsWorkItem> getWorkItemDetail(@PathVariable Long id) {
|
||||
return R.ok(csWorkbenchService.getWorkItemDetail(id));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/reassign")
|
||||
@Operation(summary = "转派工单")
|
||||
public R<String> reassignWorkItem(@PathVariable Long id, @RequestParam String assignee) {
|
||||
csWorkbenchService.reassignWorkItem(id, assignee);
|
||||
return R.ok("已转派");
|
||||
}
|
||||
|
||||
@GetMapping("/work-type-stats")
|
||||
@Operation(summary = "按类型统计")
|
||||
public R<List<Map<String, Object>>> getWorkTypeStats() {
|
||||
return R.ok(csWorkbenchService.getWorkTypeStats());
|
||||
}
|
||||
|
||||
@GetMapping("/today-overview")
|
||||
@Operation(summary = "今日概览")
|
||||
public R<Map<String, Object>> getTodayOverview() {
|
||||
return R.ok(csWorkbenchService.getTodayOverview());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.water.revenue.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.water.common.core.result.R;
|
||||
import com.water.revenue.dto.VoiceMenuResponse;
|
||||
import com.water.revenue.entity.VoiceCallRecord;
|
||||
import com.water.revenue.service.VoiceQueryService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Tag(name = "TTS语音自助查询")
|
||||
@RestController
|
||||
@RequestMapping("/api/revenue/cs/voice")
|
||||
@RequiredArgsConstructor
|
||||
public class VoiceController {
|
||||
|
||||
private final VoiceQueryService voiceQueryService;
|
||||
|
||||
@PostMapping("/start")
|
||||
@Operation(summary = "开始通话 - 语音菜单导航")
|
||||
public R<VoiceMenuResponse> startCall(@RequestParam String callerNumber) {
|
||||
return R.ok(voiceQueryService.startCall(callerNumber));
|
||||
}
|
||||
|
||||
@PostMapping("/key-press")
|
||||
@Operation(summary = "按键选择")
|
||||
public R<VoiceMenuResponse> handleKeyPress(
|
||||
@RequestParam String callId,
|
||||
@RequestParam String key) {
|
||||
return R.ok(voiceQueryService.handleKeyPress(callId, key));
|
||||
}
|
||||
|
||||
@PostMapping("/bill-query")
|
||||
@Operation(summary = "语音账单查询")
|
||||
public R<Map<String, Object>> voiceBillQuery(
|
||||
@RequestParam String callId,
|
||||
@RequestParam String customerNo) {
|
||||
return R.ok(voiceQueryService.voiceBillQuery(callId, customerNo));
|
||||
}
|
||||
|
||||
@PostMapping("/payment")
|
||||
@Operation(summary = "语音缴费")
|
||||
public R<Map<String, Object>> voicePayment(
|
||||
@RequestParam String callId,
|
||||
@RequestParam String customerNo,
|
||||
@RequestParam String billId) {
|
||||
return R.ok(voiceQueryService.voicePayment(callId, customerNo, billId));
|
||||
}
|
||||
|
||||
@PostMapping("/end")
|
||||
@Operation(summary = "结束通话")
|
||||
public R<Map<String, Object>> endCall(@RequestParam String callId) {
|
||||
return R.ok(voiceQueryService.endCall(callId));
|
||||
}
|
||||
|
||||
@GetMapping("/records")
|
||||
@Operation(summary = "通话记录查询")
|
||||
public R<Page<VoiceCallRecord>> getCallRecords(
|
||||
@RequestParam(required = false) String callerNumber,
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(defaultValue = "1") int pageNum,
|
||||
@RequestParam(defaultValue = "10") int pageSize) {
|
||||
return R.ok(voiceQueryService.getCallRecords(callerNumber, status, pageNum, pageSize));
|
||||
}
|
||||
|
||||
@GetMapping("/detail/{callId}")
|
||||
@Operation(summary = "通话详情")
|
||||
public R<VoiceCallRecord> getCallDetail(@PathVariable String callId) {
|
||||
return R.ok(voiceQueryService.getCallDetail(callId));
|
||||
}
|
||||
}
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
package com.water.revenue.controller.wxhall;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.water.common.core.result.R;
|
||||
import com.water.revenue.entity.Announcement;
|
||||
import com.water.revenue.entity.PaymentRecord;
|
||||
import com.water.revenue.entity.WaterBill;
|
||||
import com.water.revenue.service.AnnouncementService;
|
||||
import com.water.revenue.service.BillService;
|
||||
import com.water.revenue.service.InstallService;
|
||||
import com.water.revenue.service.PaymentService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 微信网厅 API(移动端适配接口)
|
||||
* 所有路径前缀: /api/wx-hall/*
|
||||
*/
|
||||
@Slf4j
|
||||
@Tag(name = "微信网厅 API")
|
||||
@RestController
|
||||
@RequestMapping("/wx-hall")
|
||||
@RequiredArgsConstructor
|
||||
public class WxHallApiController {
|
||||
|
||||
private final BillService billService;
|
||||
private final InstallService installService;
|
||||
private final AnnouncementService announcementService;
|
||||
private final PaymentService paymentService;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
// ========== 水费查询缴费 ==========
|
||||
|
||||
@Operation(summary = "查询用户账单列表")
|
||||
@GetMapping("/bill/list")
|
||||
public R<Page<WaterBill>> billList(
|
||||
@RequestParam String customerNo,
|
||||
@RequestParam(required = false) String billPeriod,
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(defaultValue = "1") int pageNum,
|
||||
@RequestParam(defaultValue = "10") int pageSize) {
|
||||
return R.ok(billService.queryBills(customerNo, billPeriod, status, pageNum, pageSize));
|
||||
}
|
||||
|
||||
@Operation(summary = "账单详情")
|
||||
@GetMapping("/bill/{billId}")
|
||||
public R<Map<String, Object>> billDetail(@PathVariable Long billId) {
|
||||
return R.ok(billService.getBillDetail(billId));
|
||||
}
|
||||
|
||||
@Operation(summary = "在线缴费(微信下单)")
|
||||
@PostMapping("/bill/pay")
|
||||
public R<Map<String, Object>> billPay(@RequestBody Map<String, Object> req) {
|
||||
Long billId = Long.valueOf(req.get("billId").toString());
|
||||
BigDecimal amount = new BigDecimal(req.get("amount").toString());
|
||||
return R.ok(paymentService.payByWechat(billId, amount));
|
||||
}
|
||||
|
||||
@Operation(summary = "缴费记录")
|
||||
@GetMapping("/bill/payment-records")
|
||||
public R<List<Map<String, Object>>> paymentRecords(
|
||||
@RequestParam String customerNo,
|
||||
@RequestParam(defaultValue = "20") int limit) {
|
||||
List<Map<String, Object>> records = jdbcTemplate.queryForList(
|
||||
"SELECT payment_no, bill_no, amount, channel, channel_name, status, pay_time, remark " +
|
||||
"FROM wm_payment_record WHERE customer_no = ? ORDER BY pay_time DESC LIMIT ?",
|
||||
customerNo, limit);
|
||||
return R.ok(records);
|
||||
}
|
||||
|
||||
// ========== 报装申请 ==========
|
||||
|
||||
@Operation(summary = "提交报装申请")
|
||||
@PostMapping("/install/apply")
|
||||
public R<Map<String, Object>> installApply(@RequestBody Map<String, String> req) {
|
||||
return R.ok(installService.preApply(
|
||||
req.get("name"),
|
||||
req.get("phone"),
|
||||
req.get("area"),
|
||||
req.get("address"),
|
||||
req.getOrDefault("customerType", "resident"),
|
||||
req.getOrDefault("caliber", "DN15")));
|
||||
}
|
||||
|
||||
@Operation(summary = "查询报装进度")
|
||||
@GetMapping("/install/progress")
|
||||
public R<Map<String, Object>> installProgress(@RequestParam String applyNo) {
|
||||
return R.ok(installService.getProgress(applyNo));
|
||||
}
|
||||
|
||||
@Operation(summary = "报装申请记录列表")
|
||||
@GetMapping("/install/list")
|
||||
public R<List<Map<String, Object>>> installList(
|
||||
@RequestParam String phone,
|
||||
@RequestParam(defaultValue = "20") int limit) {
|
||||
List<Map<String, Object>> list = jdbcTemplate.queryForList(
|
||||
"SELECT application_no, applicant_name, applicant_phone, area, address, " +
|
||||
"customer_type, caliber, status, created_at, updated_at " +
|
||||
"FROM rev_install WHERE applicant_phone = ? ORDER BY created_at DESC LIMIT ?",
|
||||
phone, limit);
|
||||
return R.ok(list);
|
||||
}
|
||||
|
||||
// ========== 停水公告 ==========
|
||||
|
||||
@Operation(summary = "公告列表")
|
||||
@GetMapping("/notice/list")
|
||||
public R<Page<Announcement>> noticeList(
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
@RequestParam(required = false) String type,
|
||||
@RequestParam(required = false) String keyword) {
|
||||
// 只返回已发布的公告
|
||||
return R.ok(announcementService.list(page, size, type, 1, keyword));
|
||||
}
|
||||
|
||||
@Operation(summary = "公告详情")
|
||||
@GetMapping("/notice/{id}")
|
||||
public R<Announcement> noticeDetail(@PathVariable Long id) {
|
||||
return R.ok(announcementService.getDetail(id));
|
||||
}
|
||||
|
||||
@Operation(summary = "获取当前生效公告(按区域)")
|
||||
@GetMapping("/notice/active")
|
||||
public R<List<Announcement>> activeNotices(
|
||||
@RequestParam(required = false) String areaCode) {
|
||||
return R.ok(announcementService.getActiveAnnouncements(areaCode));
|
||||
}
|
||||
|
||||
// ========== 用户绑定 ==========
|
||||
|
||||
@Operation(summary = "手机号绑定")
|
||||
@PostMapping("/user/bindPhone")
|
||||
public R<Map<String, Object>> bindPhone(@RequestBody Map<String, String> req) {
|
||||
String openId = req.get("openId");
|
||||
String phone = req.get("phone");
|
||||
if (!StringUtils.hasText(openId) || !StringUtils.hasText(phone)) {
|
||||
return R.fail("openId和phone不能为空");
|
||||
}
|
||||
// 检查是否已绑定
|
||||
Long existCount = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM wx_hall_user_binding WHERE open_id = ? AND phone = ? AND status = 1",
|
||||
Long.class, openId, phone);
|
||||
if (existCount != null && existCount > 0) {
|
||||
return R.fail("该手机号已绑定");
|
||||
}
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO wx_hall_user_binding (open_id, phone, binding_type, status) VALUES (?, ?, 'phone', 1)",
|
||||
openId, phone);
|
||||
log.info("User bound phone: openId={}, phone={}", openId, phone);
|
||||
return R.ok(Map.of("openId", openId, "phone", phone, "status", "bound"));
|
||||
}
|
||||
|
||||
@Operation(summary = "户号绑定")
|
||||
@PostMapping("/user/bindCustomer")
|
||||
public R<Map<String, Object>> bindCustomer(@RequestBody Map<String, String> req) {
|
||||
String openId = req.get("openId");
|
||||
String customerNo = req.get("customerNo");
|
||||
if (!StringUtils.hasText(openId) || !StringUtils.hasText(customerNo)) {
|
||||
return R.fail("openId和customerNo不能为空");
|
||||
}
|
||||
// 检查是否已绑定
|
||||
Long existCount = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM wx_hall_user_binding WHERE open_id = ? AND customer_no = ? AND status = 1",
|
||||
Long.class, openId, customerNo);
|
||||
if (existCount != null && existCount > 0) {
|
||||
return R.fail("该户号已绑定");
|
||||
}
|
||||
// 查询客户名称
|
||||
String customerName = null;
|
||||
try {
|
||||
customerName = jdbcTemplate.queryForObject(
|
||||
"SELECT customer_name FROM rev_customer WHERE customer_no = ?",
|
||||
String.class, customerNo);
|
||||
} catch (Exception e) {
|
||||
log.warn("Customer not found: {}", customerNo);
|
||||
}
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO wx_hall_user_binding (open_id, customer_no, customer_name, binding_type, status) VALUES (?, ?, ?, 'customer_no', 1)",
|
||||
openId, customerNo, customerName);
|
||||
log.info("User bound customer: openId={}, customerNo={}", openId, customerNo);
|
||||
return R.ok(Map.of("openId", openId, "customerNo", customerNo,
|
||||
"customerName", customerName != null ? customerName : "", "status", "bound"));
|
||||
}
|
||||
|
||||
@Operation(summary = "解绑")
|
||||
@PostMapping("/user/unbind")
|
||||
public R<Map<String, Object>> unbind(@RequestBody Map<String, String> req) {
|
||||
String openId = req.get("openId");
|
||||
String bindingId = req.get("bindingId");
|
||||
if (!StringUtils.hasText(openId) || !StringUtils.hasText(bindingId)) {
|
||||
return R.fail("参数不完整");
|
||||
}
|
||||
jdbcTemplate.update(
|
||||
"UPDATE wx_hall_user_binding SET status = 0, updated_at = NOW() WHERE id = ? AND open_id = ?",
|
||||
Long.valueOf(bindingId), openId);
|
||||
log.info("User unbound: openId={}, bindingId={}", openId, bindingId);
|
||||
return R.ok(Map.of("status", "unbound"));
|
||||
}
|
||||
|
||||
@Operation(summary = "查询用户绑定列表")
|
||||
@GetMapping("/user/bindings")
|
||||
public R<List<Map<String, Object>>> bindings(@RequestParam String openId) {
|
||||
List<Map<String, Object>> list = jdbcTemplate.queryForList(
|
||||
"SELECT id, phone, customer_no, customer_name, binding_type, status, created_at " +
|
||||
"FROM wx_hall_user_binding WHERE open_id = ? AND status = 1 ORDER BY created_at DESC",
|
||||
openId);
|
||||
return R.ok(list);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.water.revenue.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 水费查询结果VO
|
||||
*/
|
||||
@Data
|
||||
public class BillQueryResult {
|
||||
|
||||
/** 客户编号 */
|
||||
private String customerNo;
|
||||
|
||||
/** 客户名称 */
|
||||
private String customerName;
|
||||
|
||||
/** 地址 */
|
||||
private String address;
|
||||
|
||||
/** 手机号 */
|
||||
private String phone;
|
||||
|
||||
/** 水表号 */
|
||||
private String meterNo;
|
||||
|
||||
/** 欠费总额 */
|
||||
private BigDecimal totalArrears;
|
||||
|
||||
/** 账单列表 */
|
||||
private List<BillSummary> bills;
|
||||
|
||||
@Data
|
||||
public static class BillSummary {
|
||||
private Long billId;
|
||||
private String billNo;
|
||||
private String billPeriod;
|
||||
private BigDecimal totalAmount;
|
||||
private BigDecimal paidAmount;
|
||||
private BigDecimal unpaidAmount;
|
||||
private String status;
|
||||
private String dueDate;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.water.revenue.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 客服工作台统计VO
|
||||
*/
|
||||
@Data
|
||||
public class CsWorkbenchStats {
|
||||
|
||||
/** 今日新建工单数 */
|
||||
private int todayNewCount;
|
||||
|
||||
/** 今日已处理数 */
|
||||
private int todayResolvedCount;
|
||||
|
||||
/** 待处理总数 */
|
||||
private int pendingTotal;
|
||||
|
||||
/** 处理中数量 */
|
||||
private int processingCount;
|
||||
|
||||
/** 今日来电数 */
|
||||
private int todayCallCount;
|
||||
|
||||
/** 今日在线会话数 */
|
||||
private int todayOnlineCount;
|
||||
|
||||
/** 平均处理时长(分钟) */
|
||||
private double avgProcessTime;
|
||||
|
||||
/** 客户满意度 */
|
||||
private double satisfactionRate;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.water.revenue.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 语音菜单响应VO
|
||||
*/
|
||||
@Data
|
||||
public class VoiceMenuResponse {
|
||||
|
||||
/** 通话ID */
|
||||
private String callId;
|
||||
|
||||
/** 当前菜单层级 */
|
||||
private int currentLevel;
|
||||
|
||||
/** 菜单提示语 */
|
||||
private String prompt;
|
||||
|
||||
/** 可选操作 */
|
||||
private List<MenuOption> options;
|
||||
|
||||
/** 查询结果(如果有) */
|
||||
private Map<String, Object> queryResult;
|
||||
|
||||
/** 是否需要输入 */
|
||||
private boolean inputRequired;
|
||||
|
||||
/** 输入提示 */
|
||||
private String inputPrompt;
|
||||
|
||||
@Data
|
||||
public static class MenuOption {
|
||||
private String key;
|
||||
private String label;
|
||||
private String action;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.water.revenue.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 客服工作台 - 工作项(工单/任务)
|
||||
*/
|
||||
@Data
|
||||
@TableName("wm_cs_work_item")
|
||||
public class CsWorkItem {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 工作类型: complaint/repair/consult/install/meter_change */
|
||||
private String workType;
|
||||
|
||||
/** 客户编号 */
|
||||
private String customerNo;
|
||||
|
||||
/** 客户名称 */
|
||||
private String customerName;
|
||||
|
||||
/** 摘要 */
|
||||
private String summary;
|
||||
|
||||
/** 优先级: low/medium/high/urgent */
|
||||
private String priority;
|
||||
|
||||
/** 状态: pending/processing/resolved/closed */
|
||||
private String status;
|
||||
|
||||
/** 指派人 */
|
||||
private String assignee;
|
||||
|
||||
/** 联系电话 */
|
||||
private String contactPhone;
|
||||
|
||||
/** 地址 */
|
||||
private String address;
|
||||
|
||||
/** 详细内容 */
|
||||
private String detail;
|
||||
|
||||
/** 来源: phone/online/wechat/walk_in */
|
||||
private String source;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.water.revenue.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* TTS 语音自助查询 - 通话记录
|
||||
*/
|
||||
@Data
|
||||
@TableName("wm_voice_call_record")
|
||||
public class VoiceCallRecord {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 通话唯一ID */
|
||||
private String callId;
|
||||
|
||||
/** 主叫号码 */
|
||||
private String callerNumber;
|
||||
|
||||
/** 客户编号(识别后关联) */
|
||||
private String customerNo;
|
||||
|
||||
/** 菜单路径(如 main>bill>detail) */
|
||||
private String menuPath;
|
||||
|
||||
/** 查询结果摘要 */
|
||||
private String queryResult;
|
||||
|
||||
/** 通话时长(秒) */
|
||||
private Integer duration;
|
||||
|
||||
/** 通话时间 */
|
||||
private LocalDateTime callTime;
|
||||
|
||||
/** 通话结束时间 */
|
||||
private LocalDateTime endTime;
|
||||
|
||||
/** 状态: active/completed/failed */
|
||||
private String status;
|
||||
|
||||
/** 语音菜单层级 */
|
||||
private Integer menuLevel;
|
||||
|
||||
/** 最后操作 */
|
||||
private String lastAction;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.water.revenue.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.water.revenue.entity.CsWorkItem;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Mapper
|
||||
public interface CsWorkItemMapper extends BaseMapper<CsWorkItem> {
|
||||
|
||||
@Select("SELECT COUNT(*) FROM wm_cs_work_item WHERE status = 'pending'")
|
||||
int countPending();
|
||||
|
||||
@Select("SELECT COUNT(*) FROM wm_cs_work_item WHERE DATE(created_at) = CURRENT_DATE")
|
||||
int countTodayNew();
|
||||
|
||||
@Select("SELECT COUNT(*) FROM wm_cs_work_item WHERE DATE(updated_at) = CURRENT_DATE AND status = 'resolved'")
|
||||
int countTodayResolved();
|
||||
|
||||
@Select("SELECT COUNT(*) FROM wm_cs_work_item WHERE status = 'processing'")
|
||||
int countProcessing();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.water.revenue.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.water.revenue.entity.VoiceCallRecord;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
@Mapper
|
||||
public interface VoiceCallRecordMapper extends BaseMapper<VoiceCallRecord> {
|
||||
|
||||
@Select("SELECT COUNT(*) FROM wm_voice_call_record WHERE DATE(call_time) = CURRENT_DATE")
|
||||
int countTodayCalls();
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.water.revenue.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.water.revenue.dto.BillQueryResult;
|
||||
import com.water.revenue.entity.PaymentRecord;
|
||||
import com.water.revenue.entity.WaterBill;
|
||||
import com.water.revenue.mapper.PaymentRecordMapper;
|
||||
import com.water.revenue.mapper.WaterBillMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 水费查询服务(客服工作台专用)
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class BillQueryService {
|
||||
|
||||
private final WaterBillMapper waterBillMapper;
|
||||
private final PaymentRecordMapper paymentRecordMapper;
|
||||
|
||||
/**
|
||||
* 按户号查询
|
||||
*/
|
||||
public BillQueryResult queryByCustomerNo(String customerNo) {
|
||||
List<WaterBill> bills = waterBillMapper.selectList(
|
||||
new LambdaQueryWrapper<WaterBill>()
|
||||
.eq(WaterBill::getCustomerNo, customerNo)
|
||||
.orderByDesc(WaterBill::getCreatedAt));
|
||||
|
||||
return buildResult(customerNo, bills);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按手机号查询
|
||||
*/
|
||||
public BillQueryResult queryByPhone(String phone) {
|
||||
// 模拟:通过手机号反查客户编号(实际应查客户表)
|
||||
// 此处简化处理,演示流程
|
||||
LambdaQueryWrapper<WaterBill> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.like(WaterBill::getCustomerNo, phone.substring(Math.max(0, phone.length() - 4)));
|
||||
List<WaterBill> bills = waterBillMapper.selectList(wrapper);
|
||||
|
||||
String customerNo = bills.isEmpty() ? "UNKNOWN" : bills.get(0).getCustomerNo();
|
||||
return buildResult(customerNo, bills);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按地址查询
|
||||
*/
|
||||
public BillQueryResult queryByAddress(String address) {
|
||||
// 模拟:地址关键字匹配(实际应查客户档案表)
|
||||
LambdaQueryWrapper<WaterBill> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.like(WaterBill::getCustomerName, address);
|
||||
List<WaterBill> bills = waterBillMapper.selectList(wrapper);
|
||||
|
||||
String customerNo = bills.isEmpty() ? "UNKNOWN" : bills.get(0).getCustomerNo();
|
||||
return buildResult(customerNo, bills);
|
||||
}
|
||||
|
||||
/**
|
||||
* 账单明细(含缴费记录)
|
||||
*/
|
||||
public Map<String, Object> getBillDetail(Long billId) {
|
||||
WaterBill bill = waterBillMapper.selectById(billId);
|
||||
if (bill == null) {
|
||||
throw new RuntimeException("账单不存在: " + billId);
|
||||
}
|
||||
|
||||
List<PaymentRecord> payments = paymentRecordMapper.selectList(
|
||||
new LambdaQueryWrapper<PaymentRecord>()
|
||||
.eq(PaymentRecord::getBillId, billId)
|
||||
.orderByDesc(PaymentRecord::getPayTime));
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("bill", bill);
|
||||
result.put("payments", payments);
|
||||
result.put("paymentCount", payments.size());
|
||||
result.put("totalPaid", payments.stream()
|
||||
.filter(p -> "success".equals(p.getStatus()))
|
||||
.map(PaymentRecord::getAmount)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add));
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 欠费查询
|
||||
*/
|
||||
public List<BillQueryResult.BillSummary> queryArrears(String customerNo) {
|
||||
LambdaQueryWrapper<WaterBill> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(WaterBill::getCustomerNo, customerNo);
|
||||
wrapper.in(WaterBill::getStatus, Arrays.asList("pending", "partial", "overdue"));
|
||||
wrapper.orderByAsc(WaterBill::getDueDate);
|
||||
|
||||
List<WaterBill> bills = waterBillMapper.selectList(wrapper);
|
||||
return bills.stream().map(this::toSummary).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private BillQueryResult buildResult(String customerNo, List<WaterBill> bills) {
|
||||
BillQueryResult result = new BillQueryResult();
|
||||
result.setCustomerNo(customerNo);
|
||||
result.setCustomerName(bills.isEmpty() ? "未知" : bills.get(0).getCustomerName());
|
||||
result.setMeterNo(bills.isEmpty() ? null : bills.get(0).getMeterNo());
|
||||
|
||||
List<BillQueryResult.BillSummary> summaries = bills.stream()
|
||||
.map(this::toSummary)
|
||||
.collect(Collectors.toList());
|
||||
result.setBills(summaries);
|
||||
|
||||
BigDecimal totalArrears = bills.stream()
|
||||
.map(b -> b.getTotalAmount().subtract(b.getPaidAmount()))
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
result.setTotalArrears(totalArrears);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private BillQueryResult.BillSummary toSummary(WaterBill bill) {
|
||||
BillQueryResult.BillSummary summary = new BillQueryResult.BillSummary();
|
||||
summary.setBillId(bill.getId());
|
||||
summary.setBillNo(bill.getBillNo());
|
||||
summary.setBillPeriod(bill.getBillPeriod());
|
||||
summary.setTotalAmount(bill.getTotalAmount());
|
||||
summary.setPaidAmount(bill.getPaidAmount());
|
||||
summary.setUnpaidAmount(bill.getTotalAmount().subtract(bill.getPaidAmount()));
|
||||
summary.setStatus(bill.getStatus());
|
||||
summary.setDueDate(bill.getDueDate() != null ? bill.getDueDate().toString() : null);
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.water.revenue.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.water.revenue.dto.CsWorkbenchStats;
|
||||
import com.water.revenue.entity.CsWorkItem;
|
||||
import com.water.revenue.mapper.CsWorkItemMapper;
|
||||
import com.water.revenue.mapper.VoiceCallRecordMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 客服工作台服务
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CsWorkbenchService {
|
||||
|
||||
private final CsWorkItemMapper csWorkItemMapper;
|
||||
private final VoiceCallRecordMapper voiceCallRecordMapper;
|
||||
|
||||
/**
|
||||
* 工单分页列表
|
||||
*/
|
||||
public Page<CsWorkItem> listWorkItems(String workType, String status, String priority,
|
||||
String assignee, int pageNum, int pageSize) {
|
||||
LambdaQueryWrapper<CsWorkItem> wrapper = new LambdaQueryWrapper<>();
|
||||
if (workType != null && !workType.isEmpty()) {
|
||||
wrapper.eq(CsWorkItem::getWorkType, workType);
|
||||
}
|
||||
if (status != null && !status.isEmpty()) {
|
||||
wrapper.eq(CsWorkItem::getStatus, status);
|
||||
}
|
||||
if (priority != null && !priority.isEmpty()) {
|
||||
wrapper.eq(CsWorkItem::getPriority, priority);
|
||||
}
|
||||
if (assignee != null && !assignee.isEmpty()) {
|
||||
wrapper.eq(CsWorkItem::getAssignee, assignee);
|
||||
}
|
||||
wrapper.orderByDesc(CsWorkItem::getCreatedAt);
|
||||
return csWorkItemMapper.selectPage(new Page<>(pageNum, pageSize), wrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* 待处理数量
|
||||
*/
|
||||
public int getPendingCount() {
|
||||
return csWorkItemMapper.countPending();
|
||||
}
|
||||
|
||||
/**
|
||||
* 今日统计
|
||||
*/
|
||||
public CsWorkbenchStats getTodayStats() {
|
||||
CsWorkbenchStats stats = new CsWorkbenchStats();
|
||||
stats.setTodayNewCount(csWorkItemMapper.countTodayNew());
|
||||
stats.setTodayResolvedCount(csWorkItemMapper.countTodayResolved());
|
||||
stats.setPendingTotal(csWorkItemMapper.countPending());
|
||||
stats.setProcessingCount(csWorkItemMapper.countProcessing());
|
||||
stats.setTodayCallCount(voiceCallRecordMapper.countTodayCalls());
|
||||
stats.setTodayOnlineCount(csWorkItemMapper.countTodayNew()); // 模拟在线数
|
||||
stats.setAvgProcessTime(15.5); // 模拟值
|
||||
stats.setSatisfactionRate(96.2); // 模拟值
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建工单
|
||||
*/
|
||||
public CsWorkItem createWorkItem(CsWorkItem item) {
|
||||
item.setStatus("pending");
|
||||
item.setCreatedAt(LocalDateTime.now());
|
||||
item.setUpdatedAt(LocalDateTime.now());
|
||||
csWorkItemMapper.insert(item);
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新工单状态
|
||||
*/
|
||||
public void updateWorkItemStatus(Long id, String status) {
|
||||
LambdaUpdateWrapper<CsWorkItem> wrapper = new LambdaUpdateWrapper<>();
|
||||
wrapper.eq(CsWorkItem::getId, id)
|
||||
.set(CsWorkItem::getStatus, status)
|
||||
.set(CsWorkItem::getUpdatedAt, LocalDateTime.now());
|
||||
csWorkItemMapper.update(null, wrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* 工单详情
|
||||
*/
|
||||
public CsWorkItem getWorkItemDetail(Long id) {
|
||||
return csWorkItemMapper.selectById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷操作 - 转派
|
||||
*/
|
||||
public void reassignWorkItem(Long id, String newAssignee) {
|
||||
LambdaUpdateWrapper<CsWorkItem> wrapper = new LambdaUpdateWrapper<>();
|
||||
wrapper.eq(CsWorkItem::getId, id)
|
||||
.set(CsWorkItem::getAssignee, newAssignee)
|
||||
.set(CsWorkItem::getStatus, "processing")
|
||||
.set(CsWorkItem::getUpdatedAt, LocalDateTime.now());
|
||||
csWorkItemMapper.update(null, wrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷操作 - 按类型统计
|
||||
*/
|
||||
public List<Map<String, Object>> getWorkTypeStats() {
|
||||
List<String> types = Arrays.asList("complaint", "repair", "consult", "install", "meter_change");
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (String type : types) {
|
||||
LambdaQueryWrapper<CsWorkItem> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(CsWorkItem::getWorkType, type);
|
||||
long count = csWorkItemMapper.selectCount(wrapper);
|
||||
Map<String, Object> item = new HashMap<>();
|
||||
item.put("workType", type);
|
||||
item.put("count", count);
|
||||
result.add(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷操作 - 今日概览
|
||||
*/
|
||||
public Map<String, Object> getTodayOverview() {
|
||||
Map<String, Object> overview = new HashMap<>();
|
||||
overview.put("pendingCount", csWorkItemMapper.countPending());
|
||||
overview.put("processingCount", csWorkItemMapper.countProcessing());
|
||||
overview.put("todayNew", csWorkItemMapper.countTodayNew());
|
||||
overview.put("todayResolved", csWorkItemMapper.countTodayResolved());
|
||||
overview.put("todayCalls", voiceCallRecordMapper.countTodayCalls());
|
||||
overview.put("date", LocalDate.now().toString());
|
||||
return overview;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package com.water.revenue.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.water.revenue.dto.BillQueryResult;
|
||||
import com.water.revenue.dto.VoiceMenuResponse;
|
||||
import com.water.revenue.entity.VoiceCallRecord;
|
||||
import com.water.revenue.mapper.VoiceCallRecordMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* TTS 语音自助查询服务(模拟)
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class VoiceQueryService {
|
||||
|
||||
private final VoiceCallRecordMapper voiceCallRecordMapper;
|
||||
private final BillQueryService billQueryService;
|
||||
|
||||
/**
|
||||
* 语音菜单导航 - 开始通话
|
||||
*/
|
||||
public VoiceMenuResponse startCall(String callerNumber) {
|
||||
// 创建通话记录
|
||||
VoiceCallRecord record = new VoiceCallRecord();
|
||||
record.setCallId(UUID.randomUUID().toString().replace("-", ""));
|
||||
record.setCallerNumber(callerNumber);
|
||||
record.setMenuPath("main");
|
||||
record.setMenuLevel(1);
|
||||
record.setStatus("active");
|
||||
record.setCallTime(LocalDateTime.now());
|
||||
voiceCallRecordMapper.insert(record);
|
||||
|
||||
// 构建主菜单
|
||||
VoiceMenuResponse response = new VoiceMenuResponse();
|
||||
response.setCallId(record.getCallId());
|
||||
response.setCurrentLevel(1);
|
||||
response.setPrompt("欢迎致电XX水务客服热线,请按提示操作:");
|
||||
response.setInputRequired(true);
|
||||
response.setInputPrompt("请输入您的客户编号或手机号");
|
||||
|
||||
List<VoiceMenuResponse.MenuOption> options = new ArrayList<>();
|
||||
options.add(createOption("1", "水费查询", "bill_query"));
|
||||
options.add(createOption("2", "水费缴纳", "bill_payment"));
|
||||
options.add(createOption("3", "报修服务", "repair"));
|
||||
options.add(createOption("4", "业务咨询", "consult"));
|
||||
options.add(createOption("0", "人工服务", "manual"));
|
||||
response.setOptions(options);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 语音菜单导航 - 按键选择
|
||||
*/
|
||||
public VoiceMenuResponse handleKeyPress(String callId, String key) {
|
||||
VoiceCallRecord record = getActiveCall(callId);
|
||||
if (record == null) {
|
||||
throw new RuntimeException("通话不存在或已结束: " + callId);
|
||||
}
|
||||
|
||||
String currentMenu = record.getMenuPath();
|
||||
String newMenuPath = currentMenu + ">" + key;
|
||||
|
||||
VoiceMenuResponse response = new VoiceMenuResponse();
|
||||
response.setCallId(callId);
|
||||
|
||||
if ("main".equals(currentMenu)) {
|
||||
switch (key) {
|
||||
case "1":
|
||||
response.setCurrentLevel(2);
|
||||
response.setPrompt("水费查询,请输入您的客户编号,按#号键结束:");
|
||||
response.setInputRequired(true);
|
||||
response.setInputPrompt("请输入客户编号");
|
||||
record.setMenuPath(newMenuPath);
|
||||
record.setLastAction("bill_query_input");
|
||||
break;
|
||||
case "2":
|
||||
response.setCurrentLevel(2);
|
||||
response.setPrompt("水费缴纳,请输入您的客户编号,按#号键结束:");
|
||||
response.setInputRequired(true);
|
||||
response.setInputPrompt("请输入客户编号");
|
||||
record.setMenuPath(newMenuPath);
|
||||
record.setLastAction("bill_payment_input");
|
||||
break;
|
||||
case "3":
|
||||
response.setCurrentLevel(2);
|
||||
response.setPrompt("报修服务已记录,我们将在24小时内安排人员处理。");
|
||||
response.setInputRequired(false);
|
||||
record.setMenuPath(newMenuPath);
|
||||
record.setLastAction("repair_submitted");
|
||||
break;
|
||||
case "4":
|
||||
response.setCurrentLevel(2);
|
||||
response.setPrompt("业务咨询:您可前往最近营业厅办理业务,地址为...");
|
||||
response.setInputRequired(false);
|
||||
record.setMenuPath(newMenuPath);
|
||||
record.setLastAction("consult_info");
|
||||
break;
|
||||
case "0":
|
||||
response.setCurrentLevel(2);
|
||||
response.setPrompt("正在为您转接人工客服,请稍候...");
|
||||
response.setInputRequired(false);
|
||||
record.setMenuPath(newMenuPath);
|
||||
record.setLastAction("transfer_manual");
|
||||
break;
|
||||
default:
|
||||
response.setCurrentLevel(1);
|
||||
response.setPrompt("输入有误,请重新选择:");
|
||||
response.setInputRequired(true);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// 子菜单处理:输入客户编号后查询
|
||||
response.setCurrentLevel(3);
|
||||
response.setPrompt("查询结果播报中...");
|
||||
response.setInputRequired(false);
|
||||
|
||||
Map<String, Object> queryResult = new HashMap<>();
|
||||
queryResult.put("inputValue", key);
|
||||
queryResult.put("action", record.getLastAction());
|
||||
response.setQueryResult(queryResult);
|
||||
|
||||
record.setMenuPath(newMenuPath);
|
||||
record.setQueryResult("查询: " + key);
|
||||
record.setCustomerNo(key);
|
||||
record.setLastAction("query_completed");
|
||||
}
|
||||
|
||||
record.setUpdatedAt(LocalDateTime.now());
|
||||
voiceCallRecordMapper.updateById(record);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 语音账单查询
|
||||
*/
|
||||
public Map<String, Object> voiceBillQuery(String callId, String customerNo) {
|
||||
BillQueryResult billResult = billQueryService.queryByCustomerNo(customerNo);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("customerNo", customerNo);
|
||||
result.put("customerName", billResult.getCustomerName());
|
||||
result.put("totalArrears", billResult.getTotalArrears());
|
||||
result.put("billCount", billResult.getBills() != null ? billResult.getBills().size() : 0);
|
||||
result.put("ttsText", buildTtsText(billResult));
|
||||
|
||||
// 更新通话记录
|
||||
VoiceCallRecord record = getActiveCall(callId);
|
||||
if (record != null) {
|
||||
record.setCustomerNo(customerNo);
|
||||
record.setQueryResult(String.valueOf(result.get("ttsText")));
|
||||
voiceCallRecordMapper.updateById(record);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 语音缴费(模拟)
|
||||
*/
|
||||
public Map<String, Object> voicePayment(String callId, String customerNo, String billId) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("success", true);
|
||||
result.put("message", "缴费请求已提交,请通过微信/支付宝完成支付");
|
||||
result.put("paymentUrl", "https://pay.example.com/water/" + billId);
|
||||
result.put("ttsText", "缴费请求已提交,请您通过短信链接完成支付,感谢您的来电。");
|
||||
|
||||
VoiceCallRecord record = getActiveCall(callId);
|
||||
if (record != null) {
|
||||
record.setCustomerNo(customerNo);
|
||||
record.setLastAction("payment_initiated");
|
||||
record.setQueryResult("缴费:" + billId);
|
||||
voiceCallRecordMapper.updateById(record);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束通话
|
||||
*/
|
||||
public Map<String, Object> endCall(String callId) {
|
||||
VoiceCallRecord record = getActiveCall(callId);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
|
||||
if (record != null) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
record.setEndTime(now);
|
||||
record.setStatus("completed");
|
||||
int duration = (int) java.time.Duration.between(record.getCallTime(), now).getSeconds();
|
||||
record.setDuration(duration);
|
||||
voiceCallRecordMapper.updateById(record);
|
||||
|
||||
result.put("callId", callId);
|
||||
result.put("duration", duration);
|
||||
result.put("status", "completed");
|
||||
result.put("ttsText", "感谢致电XX水务,再见!");
|
||||
} else {
|
||||
result.put("callId", callId);
|
||||
result.put("status", "not_found");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通话记录查询
|
||||
*/
|
||||
public Page<VoiceCallRecord> getCallRecords(String callerNumber, String status,
|
||||
int pageNum, int pageSize) {
|
||||
LambdaQueryWrapper<VoiceCallRecord> wrapper = new LambdaQueryWrapper<>();
|
||||
if (callerNumber != null && !callerNumber.isEmpty()) {
|
||||
wrapper.eq(VoiceCallRecord::getCallerNumber, callerNumber);
|
||||
}
|
||||
if (status != null && !status.isEmpty()) {
|
||||
wrapper.eq(VoiceCallRecord::getStatus, status);
|
||||
}
|
||||
wrapper.orderByDesc(VoiceCallRecord::getCallTime);
|
||||
return voiceCallRecordMapper.selectPage(new Page<>(pageNum, pageSize), wrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取通话详情
|
||||
*/
|
||||
public VoiceCallRecord getCallDetail(String callId) {
|
||||
return voiceCallRecordMapper.selectOne(
|
||||
new LambdaQueryWrapper<VoiceCallRecord>()
|
||||
.eq(VoiceCallRecord::getCallId, callId));
|
||||
}
|
||||
|
||||
private VoiceCallRecord getActiveCall(String callId) {
|
||||
return voiceCallRecordMapper.selectOne(
|
||||
new LambdaQueryWrapper<VoiceCallRecord>()
|
||||
.eq(VoiceCallRecord::getCallId, callId)
|
||||
.eq(VoiceCallRecord::getStatus, "active"));
|
||||
}
|
||||
|
||||
private String buildTtsText(BillQueryResult result) {
|
||||
if (result == null || result.getBills() == null || result.getBills().isEmpty()) {
|
||||
return "未查询到相关账单信息。";
|
||||
}
|
||||
return String.format("尊敬的%s,您当前欠费金额为%s元,共%d笔未缴账单,请及时缴纳。",
|
||||
result.getCustomerName(),
|
||||
result.getTotalArrears(),
|
||||
result.getBills().size());
|
||||
}
|
||||
|
||||
private VoiceMenuResponse.MenuOption createOption(String key, String label, String action) {
|
||||
VoiceMenuResponse.MenuOption option = new VoiceMenuResponse.MenuOption();
|
||||
option.setKey(key);
|
||||
option.setLabel(label);
|
||||
option.setAction(action);
|
||||
return option;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
-- 微信网厅用户绑定表(手机号/户号绑定)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wx_hall_user_binding (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
open_id VARCHAR(64) NOT NULL,
|
||||
phone VARCHAR(20),
|
||||
customer_no VARCHAR(32),
|
||||
customer_name VARCHAR(100),
|
||||
binding_type VARCHAR(20) NOT NULL DEFAULT 'phone', -- phone / customer_no
|
||||
status INTEGER NOT NULL DEFAULT 1, -- 1-有效 0-已解绑
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_wx_binding_open_id ON wx_hall_user_binding(open_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_wx_binding_phone ON wx_hall_user_binding(phone);
|
||||
CREATE INDEX IF NOT EXISTS idx_wx_binding_customer ON wx_hall_user_binding(customer_no);
|
||||
CREATE INDEX IF NOT EXISTS idx_wx_binding_status ON wx_hall_user_binding(status);
|
||||
@@ -0,0 +1,66 @@
|
||||
-- ============================================================
|
||||
-- 客服工作台 + 语音自助查询 DDL
|
||||
-- 版本: V_cs_workbench
|
||||
-- 作者: bot_dev2
|
||||
-- 关联 Issue: #54
|
||||
-- ============================================================
|
||||
|
||||
-- 1. 客服工作项表
|
||||
CREATE TABLE IF NOT EXISTS wm_cs_work_item (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
work_type VARCHAR(32) NOT NULL, -- complaint/repair/consult/install/meter_change
|
||||
customer_no VARCHAR(32), -- 客户编号
|
||||
customer_name VARCHAR(100), -- 客户名称
|
||||
summary VARCHAR(500), -- 摘要
|
||||
priority VARCHAR(16) DEFAULT 'medium', -- low/medium/high/urgent
|
||||
status VARCHAR(16) DEFAULT 'pending', -- pending/processing/resolved/closed
|
||||
assignee VARCHAR(64), -- 指派人
|
||||
contact_phone VARCHAR(20), -- 联系电话
|
||||
address VARCHAR(200), -- 地址
|
||||
detail TEXT, -- 详细内容
|
||||
source VARCHAR(16), -- phone/online/wechat/walk_in
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE wm_cs_work_item IS '客服工作台-工作项';
|
||||
COMMENT ON COLUMN wm_cs_work_item.work_type IS '工作类型: complaint/repair/consult/install/meter_change';
|
||||
COMMENT ON COLUMN wm_cs_work_item.priority IS '优先级: low/medium/high/urgent';
|
||||
COMMENT ON COLUMN wm_cs_work_item.status IS '状态: pending/processing/resolved/closed';
|
||||
COMMENT ON COLUMN wm_cs_work_item.source IS '来源: phone/online/wechat/walk_in';
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX IF NOT EXISTS idx_cs_work_item_status ON wm_cs_work_item (status);
|
||||
CREATE INDEX IF NOT EXISTS idx_cs_work_item_customer ON wm_cs_work_item (customer_no);
|
||||
CREATE INDEX IF NOT EXISTS idx_cs_work_item_assignee ON wm_cs_work_item (assignee);
|
||||
CREATE INDEX IF NOT EXISTS idx_cs_work_item_type ON wm_cs_work_item (work_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_cs_work_item_created ON wm_cs_work_item (created_at);
|
||||
|
||||
-- 2. 语音通话记录表
|
||||
CREATE TABLE IF NOT EXISTS wm_voice_call_record (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
call_id VARCHAR(64) NOT NULL UNIQUE, -- 通话唯一ID
|
||||
caller_number VARCHAR(20), -- 主叫号码
|
||||
customer_no VARCHAR(32), -- 客户编号
|
||||
menu_path VARCHAR(200), -- 菜单路径
|
||||
query_result TEXT, -- 查询结果摘要
|
||||
duration INTEGER, -- 通话时长(秒)
|
||||
call_time TIMESTAMP, -- 通话时间
|
||||
end_time TIMESTAMP, -- 通话结束时间
|
||||
status VARCHAR(16) DEFAULT 'active', -- active/completed/failed
|
||||
menu_level INTEGER DEFAULT 1, -- 菜单层级
|
||||
last_action VARCHAR(64), -- 最后操作
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE wm_voice_call_record IS '语音自助查询-通话记录';
|
||||
COMMENT ON COLUMN wm_voice_call_record.call_id IS '通话唯一ID';
|
||||
COMMENT ON COLUMN wm_voice_call_record.menu_path IS '菜单路径(如 main>1>customerNo)';
|
||||
COMMENT ON COLUMN wm_voice_call_record.status IS '状态: active/completed/failed';
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX IF NOT EXISTS idx_voice_call_call_id ON wm_voice_call_record (call_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_voice_call_caller ON wm_voice_call_record (caller_number);
|
||||
CREATE INDEX IF NOT EXISTS idx_voice_call_customer ON wm_voice_call_record (customer_no);
|
||||
CREATE INDEX IF NOT EXISTS idx_voice_call_time ON wm_voice_call_record (call_time);
|
||||
CREATE INDEX IF NOT EXISTS idx_voice_call_status ON wm_voice_call_record (status);
|
||||
@@ -0,0 +1,289 @@
|
||||
package com.water.revenue;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.water.revenue.dto.BillQueryResult;
|
||||
import com.water.revenue.dto.CsWorkbenchStats;
|
||||
import com.water.revenue.dto.VoiceMenuResponse;
|
||||
import com.water.revenue.entity.CsWorkItem;
|
||||
import com.water.revenue.entity.VoiceCallRecord;
|
||||
import com.water.revenue.entity.WaterBill;
|
||||
import com.water.revenue.mapper.CsWorkItemMapper;
|
||||
import com.water.revenue.mapper.PaymentRecordMapper;
|
||||
import com.water.revenue.mapper.VoiceCallRecordMapper;
|
||||
import com.water.revenue.mapper.WaterBillMapper;
|
||||
import com.water.revenue.service.BillQueryService;
|
||||
import com.water.revenue.service.CsWorkbenchService;
|
||||
import com.water.revenue.service.VoiceQueryService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CsWorkbenchTest {
|
||||
|
||||
@Mock
|
||||
private CsWorkItemMapper csWorkItemMapper;
|
||||
|
||||
@Mock
|
||||
private VoiceCallRecordMapper voiceCallRecordMapper;
|
||||
|
||||
@Mock
|
||||
private WaterBillMapper waterBillMapper;
|
||||
|
||||
@Mock
|
||||
private PaymentRecordMapper paymentRecordMapper;
|
||||
|
||||
private CsWorkbenchService csWorkbenchService;
|
||||
private BillQueryService billQueryService;
|
||||
private VoiceQueryService voiceQueryService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
csWorkbenchService = new CsWorkbenchService(csWorkItemMapper, voiceCallRecordMapper);
|
||||
billQueryService = new BillQueryService(waterBillMapper, paymentRecordMapper);
|
||||
voiceQueryService = new VoiceQueryService(voiceCallRecordMapper, billQueryService);
|
||||
}
|
||||
|
||||
// ====== 客服工作台测试 ======
|
||||
|
||||
@Nested
|
||||
@DisplayName("客服工作台服务测试")
|
||||
class WorkbenchTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("获取待处理数量")
|
||||
void getPendingCount_returnsCorrectCount() {
|
||||
when(csWorkItemMapper.countPending()).thenReturn(5);
|
||||
int count = csWorkbenchService.getPendingCount();
|
||||
assertEquals(5, count);
|
||||
verify(csWorkItemMapper).countPending();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("获取今日统计")
|
||||
void getTodayStats_returnsAllFields() {
|
||||
when(csWorkItemMapper.countTodayNew()).thenReturn(10);
|
||||
when(csWorkItemMapper.countTodayResolved()).thenReturn(6);
|
||||
when(csWorkItemMapper.countPending()).thenReturn(4);
|
||||
when(csWorkItemMapper.countProcessing()).thenReturn(3);
|
||||
when(voiceCallRecordMapper.countTodayCalls()).thenReturn(20);
|
||||
|
||||
CsWorkbenchStats stats = csWorkbenchService.getTodayStats();
|
||||
|
||||
assertNotNull(stats);
|
||||
assertEquals(10, stats.getTodayNewCount());
|
||||
assertEquals(6, stats.getTodayResolvedCount());
|
||||
assertEquals(4, stats.getPendingTotal());
|
||||
assertEquals(3, stats.getProcessingCount());
|
||||
assertEquals(20, stats.getTodayCallCount());
|
||||
assertEquals(15.5, stats.getAvgProcessTime());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("创建工单 - 默认状态为pending")
|
||||
void createWorkItem_setsDefaultStatus() {
|
||||
CsWorkItem item = new CsWorkItem();
|
||||
item.setWorkType("complaint");
|
||||
item.setCustomerNo("C001");
|
||||
item.setCustomerName("张三");
|
||||
item.setSummary("水压过低");
|
||||
item.setPriority("high");
|
||||
|
||||
when(csWorkItemMapper.insert(any(CsWorkItem.class))).thenReturn(1);
|
||||
|
||||
CsWorkItem result = csWorkbenchService.createWorkItem(item);
|
||||
|
||||
assertEquals("pending", result.getStatus());
|
||||
assertNotNull(result.getCreatedAt());
|
||||
assertNotNull(result.getUpdatedAt());
|
||||
verify(csWorkItemMapper).insert(item);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("工单分页列表 - 带条件过滤")
|
||||
void listWorkItems_withFilters() {
|
||||
Page<CsWorkItem> mockPage = new Page<>(1, 10);
|
||||
mockPage.setRecords(List.of(createWorkItem(1L, "complaint", "pending")));
|
||||
|
||||
when(csWorkItemMapper.selectPage(any(Page.class), any(LambdaQueryWrapper.class)))
|
||||
.thenReturn(mockPage);
|
||||
|
||||
Page<CsWorkItem> result = csWorkbenchService.listWorkItems(
|
||||
"complaint", "pending", null, null, 1, 10);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(1, result.getRecords().size());
|
||||
assertEquals("complaint", result.getRecords().get(0).getWorkType());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("转派工单 - 状态变为processing")
|
||||
void reassignWorkItem_updatesStatusAndAssignee() {
|
||||
csWorkbenchService.reassignWorkItem(1L, "李四");
|
||||
|
||||
verify(csWorkItemMapper).update(isNull(), any());
|
||||
}
|
||||
}
|
||||
|
||||
// ====== 水费查询测试 ======
|
||||
|
||||
@Nested
|
||||
@DisplayName("水费查询服务测试")
|
||||
class BillQueryTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("按户号查询 - 返回账单列表")
|
||||
void queryByCustomerNo_returnsBills() {
|
||||
List<WaterBill> mockBills = List.of(createWaterBill(1L, "C001"));
|
||||
when(waterBillMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(mockBills);
|
||||
|
||||
BillQueryResult result = billQueryService.queryByCustomerNo("C001");
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("C001", result.getCustomerNo());
|
||||
assertEquals(1, result.getBills().size());
|
||||
assertNotNull(result.getTotalArrears());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("欠费查询 - 仅返回未缴账单")
|
||||
void queryArrears_returnsUnpaidBills() {
|
||||
List<WaterBill> mockBills = List.of(
|
||||
createWaterBillWithStatus(1L, "C001", "pending"),
|
||||
createWaterBillWithStatus(2L, "C001", "overdue"));
|
||||
when(waterBillMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(mockBills);
|
||||
|
||||
List<BillQueryResult.BillSummary> result = billQueryService.queryArrears("C001");
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(2, result.size());
|
||||
}
|
||||
}
|
||||
|
||||
// ====== 语音自助查询测试 ======
|
||||
|
||||
@Nested
|
||||
@DisplayName("语音自助查询服务测试")
|
||||
class VoiceQueryTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("开始通话 - 返回主菜单")
|
||||
void startCall_returnsMainMenu() {
|
||||
when(voiceCallRecordMapper.insert(any(VoiceCallRecord.class))).thenReturn(1);
|
||||
|
||||
VoiceMenuResponse response = voiceQueryService.startCall("13800138000");
|
||||
|
||||
assertNotNull(response);
|
||||
assertNotNull(response.getCallId());
|
||||
assertEquals(1, response.getCurrentLevel());
|
||||
assertTrue(response.isInputRequired());
|
||||
assertEquals(5, response.getOptions().size());
|
||||
assertEquals("1", response.getOptions().get(0).getKey());
|
||||
verify(voiceCallRecordMapper).insert(any(VoiceCallRecord.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("按键选择 - 水费查询")
|
||||
void handleKeyPress_billQuery() {
|
||||
VoiceCallRecord mockRecord = createActiveCallRecord();
|
||||
when(voiceCallRecordMapper.selectOne(any(LambdaQueryWrapper.class)))
|
||||
.thenReturn(mockRecord);
|
||||
when(voiceCallRecordMapper.updateById(any(VoiceCallRecord.class))).thenReturn(1);
|
||||
|
||||
VoiceMenuResponse response = voiceQueryService.handleKeyPress(
|
||||
mockRecord.getCallId(), "1");
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(2, response.getCurrentLevel());
|
||||
assertTrue(response.isInputRequired());
|
||||
verify(voiceCallRecordMapper).updateById(any(VoiceCallRecord.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("结束通话 - 记录通话时长")
|
||||
void endCall_recordsDuration() {
|
||||
VoiceCallRecord mockRecord = createActiveCallRecord();
|
||||
when(voiceCallRecordMapper.selectOne(any(LambdaQueryWrapper.class)))
|
||||
.thenReturn(mockRecord);
|
||||
when(voiceCallRecordMapper.updateById(any(VoiceCallRecord.class))).thenReturn(1);
|
||||
|
||||
Map<String, Object> result = voiceQueryService.endCall(mockRecord.getCallId());
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("completed", result.get("status"));
|
||||
assertNotNull(result.get("duration"));
|
||||
assertEquals("completed", mockRecord.getStatus());
|
||||
}
|
||||
}
|
||||
|
||||
// ====== Helper Methods ======
|
||||
|
||||
private CsWorkItem createWorkItem(Long id, String workType, String status) {
|
||||
CsWorkItem item = new CsWorkItem();
|
||||
item.setId(id);
|
||||
item.setWorkType(workType);
|
||||
item.setCustomerNo("C001");
|
||||
item.setCustomerName("测试用户");
|
||||
item.setSummary("测试工单");
|
||||
item.setPriority("medium");
|
||||
item.setStatus(status);
|
||||
item.setAssignee("客服A");
|
||||
item.setCreatedAt(LocalDateTime.now());
|
||||
item.setUpdatedAt(LocalDateTime.now());
|
||||
return item;
|
||||
}
|
||||
|
||||
private WaterBill createWaterBill(Long id, String customerNo) {
|
||||
WaterBill bill = new WaterBill();
|
||||
bill.setId(id);
|
||||
bill.setBillNo("BILL-2026-001");
|
||||
bill.setCustomerNo(customerNo);
|
||||
bill.setCustomerName("测试用户");
|
||||
bill.setBillPeriod("2026-06");
|
||||
bill.setWaterUsage(new BigDecimal("15.5"));
|
||||
bill.setUnitPrice(new BigDecimal("3.85"));
|
||||
bill.setTotalAmount(new BigDecimal("59.68"));
|
||||
bill.setPaidAmount(BigDecimal.ZERO);
|
||||
bill.setStatus("pending");
|
||||
bill.setIssueDate(LocalDate.of(2026, 6, 1));
|
||||
bill.setDueDate(LocalDate.of(2026, 6, 30));
|
||||
bill.setMeterNo("M001");
|
||||
bill.setCreatedAt(LocalDateTime.now());
|
||||
bill.setUpdatedAt(LocalDateTime.now());
|
||||
return bill;
|
||||
}
|
||||
|
||||
private WaterBill createWaterBillWithStatus(Long id, String customerNo, String status) {
|
||||
WaterBill bill = createWaterBill(id, customerNo);
|
||||
bill.setStatus(status);
|
||||
return bill;
|
||||
}
|
||||
|
||||
private VoiceCallRecord createActiveCallRecord() {
|
||||
VoiceCallRecord record = new VoiceCallRecord();
|
||||
record.setId(1L);
|
||||
record.setCallId("test-call-001");
|
||||
record.setCallerNumber("13800138000");
|
||||
record.setMenuPath("main");
|
||||
record.setMenuLevel(1);
|
||||
record.setStatus("active");
|
||||
record.setCallTime(LocalDateTime.now().minusSeconds(30));
|
||||
record.setCreatedAt(LocalDateTime.now());
|
||||
return record;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user