Parcourir la source

feat(wm-revenue): #49 报装进度查询+统计报表+首页概览完整实现

bot_dev2 il y a 5 jours
Parent
révision
408bc5c809

+ 99
- 0
frontend/src/api/install.ts Voir le fichier

@@ -0,0 +1,99 @@
1
+import request from './request'
2
+
3
+const API = '/api/revenue/install'
4
+
5
+export interface InstallApplication {
6
+  id?: number
7
+  applicationNo?: string
8
+  applicantName: string
9
+  applicantPhone: string
10
+  applicantIdCard?: string
11
+  customerType: string
12
+  address: string
13
+  area: string
14
+  areaCode?: string
15
+  waterUsageType?: string
16
+  caliber?: string
17
+  status?: string
18
+  currentStep?: number
19
+  totalSteps?: number
20
+  applyTime?: string
21
+  expectedComplete?: string
22
+  actualComplete?: string
23
+  rejectReason?: string
24
+  assigneeId?: number
25
+  assigneeName?: string
26
+  remark?: string
27
+}
28
+
29
+export interface InstallProgress {
30
+  id: number
31
+  applicationId: number
32
+  stepNumber: number
33
+  stepName: string
34
+  stepStatus: string
35
+  operatorId?: number
36
+  operatorName?: string
37
+  completeTime?: string
38
+  remark?: string
39
+}
40
+
41
+// 列表查询
42
+export function getInstallList(params: any) {
43
+  return request.get(`${API}/list`, { params })
44
+}
45
+
46
+// 获取详情
47
+export function getInstallDetail(id: number) {
48
+  return request.get(`${API}/${id}`)
49
+}
50
+
51
+// 创建申请
52
+export function createInstall(data: InstallApplication) {
53
+  return request.post(API, data)
54
+}
55
+
56
+// 更新申请
57
+export function updateInstall(id: number, data: InstallApplication) {
58
+  return request.put(`${API}/${id}`, data)
59
+}
60
+
61
+// 更新状态
62
+export function updateInstallStatus(id: number, status: string, remark?: string) {
63
+  return request.post(`${API}/${id}/status`, { status, remark })
64
+}
65
+
66
+// 删除
67
+export function deleteInstall(id: number) {
68
+  return request.delete(`${API}/${id}`)
69
+}
70
+
71
+// 获取进度时间线
72
+export function getInstallTimeline(id: number) {
73
+  return request.get(`${API}/${id}/timeline`)
74
+}
75
+
76
+// 获取进度概览
77
+export function getInstallProgress(id: number) {
78
+  return request.get(`${API}/${id}/progress`)
79
+}
80
+
81
+// 更新进度节点
82
+export function updateInstallProgress(id: number, stepNumber: number, data: any) {
83
+  return request.post(`${API}/${id}/progress/${stepNumber}`, data)
84
+}
85
+
86
+// 获取统计数据
87
+export function getInstallStats() {
88
+  return request.get(`${API}/stats`)
89
+}
90
+
91
+// 获取首页概览
92
+export function getInstallDashboard() {
93
+  return request.get(`${API}/dashboard`)
94
+}
95
+
96
+// 获取区域列表
97
+export function getInstallAreas() {
98
+  return request.get(`${API}/areas`)
99
+}

+ 3
- 0
frontend/src/router/index.ts Voir le fichier

@@ -16,6 +16,9 @@ const routes = [
16 16
       { path: 'cs/knowledge', name: 'csKnowledge', component: () => import('@/views/cs/KnowledgeBaseView.vue') },
17 17
       { path: 'cs/announcement', name: 'csAnnouncement', component: () => import('@/views/cs/AnnouncementView.vue') },
18 18
       { path: 'cs/kpi', name: 'csKpi', component: () => import('@/views/cs/KpiDashboardView.vue') },
19
+      { path: 'install/dashboard', name: 'installDashboard', component: () => import('@/views/install/InstallDashboard.vue') },
20
+      { path: 'install/list', name: 'installList', component: () => import('@/views/install/InstallListView.vue') },
21
+      { path: 'install/stats', name: 'installStats', component: () => import('@/views/install/InstallStatsView.vue') },
19 22
     ]
20 23
   },
21 24
   { path: '/:pathMatch(.*)*', redirect: '/dashboard' }

+ 183
- 0
frontend/src/views/install/InstallDashboard.vue Voir le fichier

@@ -0,0 +1,183 @@
1
+<template>
2
+  <div class="install-dashboard">
3
+    <h2>报装概览</h2>
4
+
5
+    <el-row :gutter="20">
6
+      <el-col :span="6" v-for="card in dashboardCards" :key="card.title">
7
+        <el-card shadow="hover" class="dash-card">
8
+          <div class="dash-icon" :style="{ background: card.bg }">
9
+            <el-icon :size="28" :color="card.color">
10
+              <component :is="card.icon" />
11
+            </el-icon>
12
+          </div>
13
+          <div class="dash-info">
14
+            <div class="dash-value" :style="{ color: card.color }">{{ card.value }}</div>
15
+            <div class="dash-title">{{ card.title }}</div>
16
+          </div>
17
+        </el-card>
18
+      </el-col>
19
+    </el-row>
20
+
21
+    <!-- 补充信息 -->
22
+    <el-row :gutter="16" style="margin-top:20px">
23
+      <el-col :span="12">
24
+        <el-card shadow="never">
25
+          <template #header>快速统计</template>
26
+          <el-descriptions :column="2" border>
27
+            <el-descriptions-item label="总申请数">{{ dashData.totalCount || 0 }}</el-descriptions-item>
28
+            <el-descriptions-item label="本月新增">{{ dashData.monthCount || 0 }}</el-descriptions-item>
29
+            <el-descriptions-item label="今日新增">{{ dashData.todayCount || 0 }}</el-descriptions-item>
30
+            <el-descriptions-item label="平均处理天数">{{ dashData.avgProcessDays || 0 }} 天</el-descriptions-item>
31
+          </el-descriptions>
32
+        </el-card>
33
+      </el-col>
34
+      <el-col :span="12">
35
+        <el-card shadow="never">
36
+          <template #header>处理效率</template>
37
+          <div class="efficiency-bar">
38
+            <div class="eff-label">平均处理天数</div>
39
+            <el-progress
40
+              :percentage="efficiencyPercent"
41
+              :stroke-width="20"
42
+              :color="efficiencyColor"
43
+              :format="() => `${dashData.avgProcessDays || 0}天`"
44
+            />
45
+            <div class="eff-hint">
46
+              <span style="color:#67c23a">◀ 高效</span>
47
+              <span style="color:#f56c6c">低效 ▶</span>
48
+            </div>
49
+          </div>
50
+        </el-card>
51
+      </el-col>
52
+    </el-row>
53
+  </div>
54
+</template>
55
+
56
+<script setup lang="ts">
57
+import { ref, computed, onMounted } from 'vue'
58
+import { Calendar, Document, CircleCheck, Clock } from '@element-plus/icons-vue'
59
+
60
+const dashData = ref<any>({})
61
+
62
+const dashboardCards = computed(() => [
63
+  {
64
+    title: '今日新增',
65
+    value: dashData.value.todayCount || 0,
66
+    color: '#409eff',
67
+    bg: 'rgba(64,158,255,0.1)',
68
+    icon: Calendar
69
+  },
70
+  {
71
+    title: '在办数量',
72
+    value: dashData.value.inProgressCount || 0,
73
+    color: '#e6a23c',
74
+    bg: 'rgba(230,162,60,0.1)',
75
+    icon: Document
76
+  },
77
+  {
78
+    title: '完工数量',
79
+    value: dashData.value.finishedCount || 0,
80
+    color: '#67c23a',
81
+    bg: 'rgba(103,194,58,0.1)',
82
+    icon: CircleCheck
83
+  },
84
+  {
85
+    title: '平均处理天数',
86
+    value: `${dashData.value.avgProcessDays || 0}天`,
87
+    color: '#909399',
88
+    bg: 'rgba(144,147,153,0.1)',
89
+    icon: Clock
90
+  }
91
+])
92
+
93
+const efficiencyPercent = computed(() => {
94
+  const days = dashData.value.avgProcessDays || 0
95
+  // 30天以内算高效,超过60天算低效
96
+  if (days <= 30) return Math.min(100, Math.round((days / 30) * 50))
97
+  if (days <= 60) return 50 + Math.round(((days - 30) / 30) * 50)
98
+  return 100
99
+})
100
+
101
+const efficiencyColor = computed(() => {
102
+  const days = dashData.value.avgProcessDays || 0
103
+  if (days <= 15) return '#67c23a'
104
+  if (days <= 30) return '#409eff'
105
+  if (days <= 45) return '#e6a23c'
106
+  return '#f56c6c'
107
+})
108
+
109
+async function loadDashboard() {
110
+  try {
111
+    const res = await fetch('/api/revenue/install/dashboard')
112
+    const json = await res.json()
113
+    if (json.code === 200) {
114
+      dashData.value = json.data
115
+    }
116
+  } catch (e) {
117
+    console.error('加载概览数据失败', e)
118
+  }
119
+}
120
+
121
+onMounted(() => {
122
+  loadDashboard()
123
+})
124
+</script>
125
+
126
+<style scoped>
127
+.install-dashboard { padding: 0; }
128
+
129
+.dash-card {
130
+  display: flex;
131
+  align-items: center;
132
+}
133
+.dash-card :deep(.el-card__body) {
134
+  display: flex;
135
+  align-items: center;
136
+  gap: 16px;
137
+  padding: 20px;
138
+  width: 100%;
139
+}
140
+
141
+.dash-icon {
142
+  width: 56px;
143
+  height: 56px;
144
+  border-radius: 12px;
145
+  display: flex;
146
+  align-items: center;
147
+  justify-content: center;
148
+  flex-shrink: 0;
149
+}
150
+
151
+.dash-info {
152
+  flex: 1;
153
+}
154
+
155
+.dash-value {
156
+  font-size: 28px;
157
+  font-weight: bold;
158
+  line-height: 1.2;
159
+}
160
+
161
+.dash-title {
162
+  color: #666;
163
+  margin-top: 4px;
164
+  font-size: 13px;
165
+}
166
+
167
+.efficiency-bar {
168
+  padding: 10px 0;
169
+}
170
+
171
+.eff-label {
172
+  margin-bottom: 12px;
173
+  font-size: 14px;
174
+  color: #333;
175
+}
176
+
177
+.eff-hint {
178
+  display: flex;
179
+  justify-content: space-between;
180
+  margin-top: 8px;
181
+  font-size: 12px;
182
+}
183
+</style>

+ 284
- 0
frontend/src/views/install/InstallListView.vue Voir le fichier

@@ -0,0 +1,284 @@
1
+<template>
2
+  <div class="install-list">
3
+    <h2>报装申请管理</h2>
4
+
5
+    <!-- 筛选栏 -->
6
+    <el-card shadow="never" class="filter-card">
7
+      <el-form :inline="true" :model="filters">
8
+        <el-form-item label="状态">
9
+          <el-select v-model="filters.status" placeholder="全部状态" clearable style="width:140px">
10
+            <el-option label="待审核" value="pending_review" />
11
+            <el-option label="审批中" value="reviewing" />
12
+            <el-option label="施工中" value="constructing" />
13
+            <el-option label="已完工" value="completed" />
14
+            <el-option label="已通水" value="connected" />
15
+            <el-option label="已驳回" value="rejected" />
16
+          </el-select>
17
+        </el-form-item>
18
+        <el-form-item label="类型">
19
+          <el-select v-model="filters.customerType" placeholder="全部类型" clearable style="width:120px">
20
+            <el-option label="居民" value="residential" />
21
+            <el-option label="商业" value="commercial" />
22
+            <el-option label="工业" value="industrial" />
23
+          </el-select>
24
+        </el-form-item>
25
+        <el-form-item label="区域">
26
+          <el-input v-model="filters.area" placeholder="区域" clearable style="width:120px" />
27
+        </el-form-item>
28
+        <el-form-item label="搜索">
29
+          <el-input v-model="filters.keyword" placeholder="姓名/编号/地址/电话" clearable style="width:200px" />
30
+        </el-form-item>
31
+        <el-form-item>
32
+          <el-button type="primary" @click="loadData">查询</el-button>
33
+          <el-button @click="resetFilters">重置</el-button>
34
+        </el-form-item>
35
+      </el-form>
36
+    </el-card>
37
+
38
+    <!-- 列表 -->
39
+    <el-card shadow="never" style="margin-top:16px">
40
+      <el-table :data="tableData" v-loading="loading" stripe style="width:100%">
41
+        <el-table-column prop="applicationNo" label="申请编号" width="160" />
42
+        <el-table-column prop="applicantName" label="申请人" width="100" />
43
+        <el-table-column prop="applicantPhone" label="电话" width="130" />
44
+        <el-table-column prop="customerType" label="类型" width="80">
45
+          <template #default="{ row }">
46
+            <el-tag :type="typeTagMap[row.customerType]">{{ typeNameMap[row.customerType] }}</el-tag>
47
+          </template>
48
+        </el-table-column>
49
+        <el-table-column prop="area" label="区域" width="100" />
50
+        <el-table-column prop="address" label="地址" min-width="180" show-overflow-tooltip />
51
+        <el-table-column prop="status" label="状态" width="100">
52
+          <template #default="{ row }">
53
+            <el-tag :type="statusTagMap[row.status]">{{ statusNameMap[row.status] }}</el-tag>
54
+          </template>
55
+        </el-table-column>
56
+        <el-table-column prop="applyTime" label="申请时间" width="170">
57
+          <template #default="{ row }">
58
+            {{ formatDate(row.applyTime) }}
59
+          </template>
60
+        </el-table-column>
61
+        <el-table-column label="进度" width="100">
62
+          <template #default="{ row }">
63
+            <el-progress :percentage="calcProgress(row)" :stroke-width="6" :show-text="true" />
64
+          </template>
65
+        </el-table-column>
66
+        <el-table-column label="操作" width="200" fixed="right">
67
+          <template #default="{ row }">
68
+            <el-button link type="primary" @click="showDetail(row)">详情</el-button>
69
+            <el-button link type="primary" @click="showTimeline(row)">进度</el-button>
70
+            <el-button link type="success" v-if="row.status==='pending_review'" @click="handleApprove(row)">通过</el-button>
71
+            <el-button link type="danger" v-if="row.status==='pending_review'" @click="handleReject(row)">驳回</el-button>
72
+          </template>
73
+        </el-table-column>
74
+      </el-table>
75
+
76
+      <el-pagination
77
+        style="margin-top:16px; justify-content:flex-end"
78
+        v-model:current-page="currentPage"
79
+        v-model:page-size="pageSize"
80
+        :total="total"
81
+        :page-sizes="[10, 20, 50]"
82
+        layout="total, sizes, prev, pager, next"
83
+        @size-change="loadData"
84
+        @current-change="loadData"
85
+      />
86
+    </el-card>
87
+
88
+    <!-- 详情弹窗 -->
89
+    <el-dialog v-model="detailVisible" title="报装申请详情" width="600px">
90
+      <el-descriptions :column="2" border v-if="currentRow">
91
+        <el-descriptions-item label="申请编号">{{ currentRow.applicationNo }}</el-descriptions-item>
92
+        <el-descriptions-item label="申请人">{{ currentRow.applicantName }}</el-descriptions-item>
93
+        <el-descriptions-item label="电话">{{ currentRow.applicantPhone }}</el-descriptions-item>
94
+        <el-descriptions-item label="身份证">{{ currentRow.applicantIdCard || '-' }}</el-descriptions-item>
95
+        <el-descriptions-item label="类型">{{ typeNameMap[currentRow.customerType] }}</el-descriptions-item>
96
+        <el-descriptions-item label="状态">
97
+          <el-tag :type="statusTagMap[currentRow.status]">{{ statusNameMap[currentRow.status] }}</el-tag>
98
+        </el-descriptions-item>
99
+        <el-descriptions-item label="区域">{{ currentRow.area || '-' }}</el-descriptions-item>
100
+        <el-descriptions-item label="口径">{{ currentRow.caliber || '-' }}</el-descriptions-item>
101
+        <el-descriptions-item label="地址" :span="2">{{ currentRow.address }}</el-descriptions-item>
102
+        <el-descriptions-item label="申请时间">{{ formatDate(currentRow.applyTime) }}</el-descriptions-item>
103
+        <el-descriptions-item label="预计完工">{{ currentRow.expectedComplete || '-' }}</el-descriptions-item>
104
+        <el-descriptions-item label="备注" :span="2">{{ currentRow.remark || '-' }}</el-descriptions-item>
105
+        <el-descriptions-item label="驳回原因" :span="2" v-if="currentRow.rejectReason">
106
+          <span style="color:#f56c6c">{{ currentRow.rejectReason }}</span>
107
+        </el-descriptions-item>
108
+      </el-descriptions>
109
+    </el-dialog>
110
+
111
+    <!-- 进度时间线弹窗 -->
112
+    <el-dialog v-model="timelineVisible" title="进度追踪" width="600px">
113
+      <div v-if="timelineData.length === 0" style="text-align:center;color:#999;padding:20px">暂无进度数据</div>
114
+      <el-timeline v-else>
115
+        <el-timeline-item
116
+          v-for="item in timelineData"
117
+          :key="item.id"
118
+          :type="timelineItemType(item.stepStatus)"
119
+          :timestamp="item.completeTime ? formatDate(item.completeTime) : '未完成'"
120
+          :hollow="item.stepStatus === 'pending'"
121
+        >
122
+          <h4>{{ item.stepName }}</h4>
123
+          <p v-if="item.operatorName">操作人: {{ item.operatorName }}</p>
124
+          <p v-if="item.remark">备注: {{ item.remark }}</p>
125
+        </el-timeline-item>
126
+      </el-timeline>
127
+    </el-dialog>
128
+  </div>
129
+</template>
130
+
131
+<script setup lang="ts">
132
+import { ref, reactive, onMounted } from 'vue'
133
+import { ElMessage, ElMessageBox } from 'element-plus'
134
+
135
+const loading = ref(false)
136
+const tableData = ref<any[]>([])
137
+const currentPage = ref(1)
138
+const pageSize = ref(10)
139
+const total = ref(0)
140
+
141
+const filters = reactive({
142
+  status: '',
143
+  customerType: '',
144
+  area: '',
145
+  keyword: ''
146
+})
147
+
148
+const statusNameMap: Record<string, string> = {
149
+  pending_review: '待审核', reviewing: '审批中', constructing: '施工中',
150
+  completed: '已完工', connected: '已通水', rejected: '已驳回'
151
+}
152
+const statusTagMap: Record<string, string> = {
153
+  pending_review: 'warning', reviewing: 'primary', constructing: 'info',
154
+  completed: 'success', connected: 'success', rejected: 'danger'
155
+}
156
+const typeNameMap: Record<string, string> = {
157
+  residential: '居民', commercial: '商业', industrial: '工业'
158
+}
159
+const typeTagMap: Record<string, string> = {
160
+  residential: 'primary', commercial: 'success', industrial: 'warning'
161
+}
162
+
163
+function formatDate(dt: string) {
164
+  if (!dt) return '-'
165
+  return new Date(dt).toLocaleString('zh-CN')
166
+}
167
+
168
+function calcProgress(row: any) {
169
+  if (!row.totalSteps || row.totalSteps === 0) return 0
170
+  return Math.round((row.currentStep / row.totalSteps) * 100)
171
+}
172
+
173
+async function loadData() {
174
+  loading.value = true
175
+  try {
176
+    const params = new URLSearchParams()
177
+    params.set('page', String(currentPage.value))
178
+    params.set('size', String(pageSize.value))
179
+    if (filters.status) params.set('status', filters.status)
180
+    if (filters.customerType) params.set('customerType', filters.customerType)
181
+    if (filters.area) params.set('area', filters.area)
182
+    if (filters.keyword) params.set('keyword', filters.keyword)
183
+
184
+    const res = await fetch(`/api/revenue/install/list?${params}`)
185
+    const json = await res.json()
186
+    if (json.code === 200) {
187
+      tableData.value = json.data.records || []
188
+      total.value = json.data.total || 0
189
+    } else {
190
+      ElMessage.error(json.message || '查询失败')
191
+    }
192
+  } catch (e) {
193
+    ElMessage.error('网络错误')
194
+  } finally {
195
+    loading.value = false
196
+  }
197
+}
198
+
199
+function resetFilters() {
200
+  filters.status = ''
201
+  filters.customerType = ''
202
+  filters.area = ''
203
+  filters.keyword = ''
204
+  currentPage.value = 1
205
+  loadData()
206
+}
207
+
208
+// 详情
209
+const detailVisible = ref(false)
210
+const currentRow = ref<any>(null)
211
+
212
+function showDetail(row: any) {
213
+  currentRow.value = row
214
+  detailVisible.value = true
215
+}
216
+
217
+// 进度时间线
218
+const timelineVisible = ref(false)
219
+const timelineData = ref<any[]>([])
220
+
221
+function timelineItemType(status: string) {
222
+  switch (status) {
223
+    case 'completed': return 'success'
224
+    case 'in_progress': return 'primary'
225
+    case 'skipped': return 'info'
226
+    default: return 'info'
227
+  }
228
+}
229
+
230
+async function showTimeline(row: any) {
231
+  try {
232
+    const res = await fetch(`/api/revenue/install/${row.id}/progress/overview`)
233
+    const json = await res.json()
234
+    if (json.code === 200) {
235
+      timelineData.value = json.data.timeline || []
236
+    } else {
237
+      timelineData.value = []
238
+    }
239
+    timelineVisible.value = true
240
+  } catch (e) {
241
+    ElMessage.error('获取进度失败')
242
+  }
243
+}
244
+
245
+async function handleApprove(row: any) {
246
+  try {
247
+    await ElMessageBox.confirm(`确认通过申请 ${row.applicationNo}?`, '审批确认')
248
+    const res = await fetch(`/api/revenue/install/${row.id}/approve`, { method: 'POST' })
249
+    const json = await res.json()
250
+    if (json.code === 200) {
251
+      ElMessage.success('审批通过')
252
+      loadData()
253
+    } else {
254
+      ElMessage.error(json.message || '操作失败')
255
+    }
256
+  } catch { /* cancelled */ }
257
+}
258
+
259
+async function handleReject(row: any) {
260
+  try {
261
+    const { value: reason } = await ElMessageBox.prompt('请输入驳回原因', '驳回申请', {
262
+      inputPattern: /.+/,
263
+      inputErrorMessage: '驳回原因不能为空'
264
+    })
265
+    const res = await fetch(`/api/revenue/install/${row.id}/reject?reason=${encodeURIComponent(reason)}`, { method: 'POST' })
266
+    const json = await res.json()
267
+    if (json.code === 200) {
268
+      ElMessage.success('已驳回')
269
+      loadData()
270
+    } else {
271
+      ElMessage.error(json.message || '操作失败')
272
+    }
273
+  } catch { /* cancelled */ }
274
+}
275
+
276
+onMounted(() => {
277
+  loadData()
278
+})
279
+</script>
280
+
281
+<style scoped>
282
+.install-list { padding: 0; }
283
+.filter-card { margin-bottom: 0; }
284
+</style>

+ 190
- 0
frontend/src/views/install/InstallStatsView.vue Voir le fichier

@@ -0,0 +1,190 @@
1
+<template>
2
+  <div class="install-stats">
3
+    <h2>报装统计报表</h2>
4
+
5
+    <!-- 统计卡片 -->
6
+    <el-row :gutter="16" style="margin-bottom:16px">
7
+      <el-col :span="4" v-for="card in summaryCards" :key="card.title">
8
+        <el-card shadow="hover">
9
+          <div class="stat-card">
10
+            <div class="stat-value" :style="{ color: card.color }">{{ card.value }}</div>
11
+            <div class="stat-title">{{ card.title }}</div>
12
+          </div>
13
+        </el-card>
14
+      </el-col>
15
+    </el-row>
16
+
17
+    <!-- 图表区域 -->
18
+    <el-row :gutter="16">
19
+      <el-col :span="12">
20
+        <el-card shadow="never">
21
+          <template #header>区域分布</template>
22
+          <div ref="areaChartRef" style="height:350px" />
23
+        </el-card>
24
+      </el-col>
25
+      <el-col :span="12">
26
+        <el-card shadow="never">
27
+          <template #header>类型分布</template>
28
+          <div ref="typeChartRef" style="height:350px" />
29
+        </el-card>
30
+      </el-col>
31
+    </el-row>
32
+
33
+    <el-row :gutter="16" style="margin-top:16px">
34
+      <el-col :span="12">
35
+        <el-card shadow="never">
36
+          <template #header>状态统计</template>
37
+          <div ref="statusChartRef" style="height:350px" />
38
+        </el-card>
39
+      </el-col>
40
+      <el-col :span="12">
41
+        <el-card shadow="never">
42
+          <template #header>月度趋势(最近12个月)</template>
43
+          <div ref="trendChartRef" style="height:350px" />
44
+        </el-card>
45
+      </el-col>
46
+    </el-row>
47
+  </div>
48
+</template>
49
+
50
+<script setup lang="ts">
51
+import { ref, onMounted, computed } from 'vue'
52
+import * as echarts from 'echarts'
53
+
54
+const statsData = ref<any>({})
55
+const areaChartRef = ref()
56
+const typeChartRef = ref()
57
+const statusChartRef = ref()
58
+const trendChartRef = ref()
59
+
60
+const summaryCards = computed(() => [
61
+  { title: '总申请数', value: statsData.value.totalCount || 0, color: '#2a5298' },
62
+  { title: '待审核', value: statsData.value.pendingCount || 0, color: '#e6a23c' },
63
+  { title: '审批中', value: statsData.value.reviewingCount || 0, color: '#409eff' },
64
+  { title: '施工中', value: statsData.value.constructingCount || 0, color: '#909399' },
65
+  { title: '已通水', value: statsData.value.connectedCount || 0, color: '#67c23a' },
66
+  { title: '已驳回', value: statsData.value.rejectedCount || 0, color: '#f56c6c' },
67
+])
68
+
69
+async function loadStats() {
70
+  try {
71
+    const res = await fetch('/api/revenue/install/stats')
72
+    const json = await res.json()
73
+    if (json.code === 200) {
74
+      statsData.value = json.data
75
+      renderCharts()
76
+    }
77
+  } catch (e) {
78
+    console.error('加载统计数据失败', e)
79
+  }
80
+}
81
+
82
+function renderCharts() {
83
+  // 区域分布饼图
84
+  const areaChart = echarts.init(areaChartRef.value)
85
+  areaChart.setOption({
86
+    tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
87
+    legend: { orient: 'vertical', left: 'left' },
88
+    series: [{
89
+      name: '区域',
90
+      type: 'pie',
91
+      radius: ['40%', '70%'],
92
+      avoidLabelOverlap: false,
93
+      itemStyle: { borderRadius: 10, borderColor: '#fff', borderWidth: 2 },
94
+      label: { show: true, formatter: '{b}\n{d}%' },
95
+      data: statsData.value.areaDistribution || []
96
+    }]
97
+  })
98
+
99
+  // 类型柱状图
100
+  const typeChart = echarts.init(typeChartRef.value)
101
+  const typeData = statsData.value.typeDistribution || []
102
+  typeChart.setOption({
103
+    tooltip: { trigger: 'axis' },
104
+    xAxis: { type: 'category', data: typeData.map((d: any) => d.name) },
105
+    yAxis: { type: 'value', name: '数量' },
106
+    series: [{
107
+      type: 'bar',
108
+      data: typeData.map((d: any) => d.value),
109
+      barWidth: '40%',
110
+      itemStyle: {
111
+        borderRadius: [8, 8, 0, 0],
112
+        color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
113
+          { offset: 0, color: '#83bff6' },
114
+          { offset: 0.5, color: '#188df0' },
115
+          { offset: 1, color: '#188df0' }
116
+        ])
117
+      }
118
+    }]
119
+  })
120
+
121
+  // 状态统计饼图
122
+  const statusChart = echarts.init(statusChartRef.value)
123
+  const statusColors: Record<string, string> = {
124
+    '待审核': '#e6a23c', '审批中': '#409eff', '施工中': '#909399',
125
+    '已完工': '#67c23a', '已通水': '#529b2e', '已驳回': '#f56c6c'
126
+  }
127
+  const statusData = (statsData.value.statusDistribution || []).map((d: any) => ({
128
+    ...d,
129
+    itemStyle: { color: statusColors[d.name] || '#ccc' }
130
+  }))
131
+  statusChart.setOption({
132
+    tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
133
+    legend: { bottom: 0 },
134
+    series: [{
135
+      name: '状态',
136
+      type: 'pie',
137
+      radius: '65%',
138
+      data: statusData,
139
+      emphasis: { itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0, 0, 0, 0.5)' } }
140
+    }]
141
+  })
142
+
143
+  // 月度趋势折线图
144
+  const trendChart = echarts.init(trendChartRef.value)
145
+  const trendData = statsData.value.monthlyTrend || []
146
+  trendChart.setOption({
147
+    tooltip: { trigger: 'axis' },
148
+    legend: { data: ['总申请', '已通水'] },
149
+    xAxis: { type: 'category', data: trendData.map((d: any) => d.month), boundaryGap: false },
150
+    yAxis: { type: 'value', name: '数量' },
151
+    series: [
152
+      {
153
+        name: '总申请', type: 'line', smooth: true,
154
+        data: trendData.map((d: any) => d.total),
155
+        areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
156
+          { offset: 0, color: 'rgba(64,158,255,0.3)' },
157
+          { offset: 1, color: 'rgba(64,158,255,0.05)' }
158
+        ])}
159
+      },
160
+      {
161
+        name: '已通水', type: 'line', smooth: true,
162
+        data: trendData.map((d: any) => d.completed),
163
+        areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
164
+          { offset: 0, color: 'rgba(103,194,58,0.3)' },
165
+          { offset: 1, color: 'rgba(103,194,58,0.05)' }
166
+        ])}
167
+      }
168
+    ]
169
+  })
170
+
171
+  // 响应式
172
+  window.addEventListener('resize', () => {
173
+    areaChart.resize()
174
+    typeChart.resize()
175
+    statusChart.resize()
176
+    trendChart.resize()
177
+  })
178
+}
179
+
180
+onMounted(() => {
181
+  loadStats()
182
+})
183
+</script>
184
+
185
+<style scoped>
186
+.install-stats { padding: 0; }
187
+.stat-card { text-align: center; padding: 8px; }
188
+.stat-value { font-size: 28px; font-weight: bold; }
189
+.stat-title { color: #666; margin-top: 6px; font-size: 13px; }
190
+</style>

+ 137
- 0
wm-revenue/src/main/java/com/water/revenue/controller/InstallController.java Voir le fichier

@@ -0,0 +1,137 @@
1
+package com.water.revenue.controller;
2
+
3
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
4
+import com.water.common.core.result.R;
5
+import com.water.revenue.entity.InstallApplication;
6
+import com.water.revenue.entity.InstallProgress;
7
+import com.water.revenue.entity.InstallStats;
8
+import com.water.revenue.service.InstallProgressService;
9
+import com.water.revenue.service.InstallService;
10
+import com.water.revenue.service.InstallStatsService;
11
+import io.swagger.v3.oas.annotations.Operation;
12
+import io.swagger.v3.oas.annotations.tags.Tag;
13
+import lombok.RequiredArgsConstructor;
14
+import org.springframework.web.bind.annotation.*;
15
+
16
+import java.util.List;
17
+import java.util.Map;
18
+
19
+/**
20
+ * 报装管理 Controller
21
+ * 包含:报装申请、进度追踪、统计报表、首页概览
22
+ */
23
+@Tag(name = "报装管理")
24
+@RestController
25
+@RequestMapping("/api/revenue/install")
26
+@RequiredArgsConstructor
27
+public class InstallController {
28
+
29
+    private final InstallService installService;
30
+    private final InstallProgressService progressService;
31
+    private final InstallStatsService statsService;
32
+
33
+    // ==================== 报装申请列表 ====================
34
+
35
+    @Operation(summary = "报装申请分页列表")
36
+    @GetMapping("/list")
37
+    public R<Page<InstallApplication>> list(
38
+            @RequestParam(defaultValue = "1") int page,
39
+            @RequestParam(defaultValue = "10") int size,
40
+            @RequestParam(required = false) String status,
41
+            @RequestParam(required = false) String customerType,
42
+            @RequestParam(required = false) String area,
43
+            @RequestParam(required = false) String keyword) {
44
+        return R.ok(installService.list(page, size, status, customerType, area, keyword));
45
+    }
46
+
47
+    @Operation(summary = "报装申请详情")
48
+    @GetMapping("/{id}")
49
+    public R<InstallApplication> detail(@PathVariable Long id) {
50
+        return R.ok(installService.getById(id));
51
+    }
52
+
53
+    @Operation(summary = "创建报装申请")
54
+    @PostMapping
55
+    public R<InstallApplication> create(@RequestBody InstallApplication application) {
56
+        return R.ok(installService.create(application));
57
+    }
58
+
59
+    @Operation(summary = "更新报装申请")
60
+    @PutMapping("/{id}")
61
+    public R<String> update(@PathVariable Long id, @RequestBody InstallApplication application) {
62
+        installService.update(id, application);
63
+        return R.ok("更新成功");
64
+    }
65
+
66
+    @Operation(summary = "删除报装申请")
67
+    @DeleteMapping("/{id}")
68
+    public R<String> delete(@PathVariable Long id) {
69
+        installService.delete(id);
70
+        return R.ok("删除成功");
71
+    }
72
+
73
+    @Operation(summary = "审批通过")
74
+    @PostMapping("/{id}/approve")
75
+    public R<String> approve(@PathVariable Long id, @RequestParam(required = false) String remark) {
76
+        installService.approve(id, remark);
77
+        return R.ok("审批通过");
78
+    }
79
+
80
+    @Operation(summary = "审批驳回")
81
+    @PostMapping("/{id}/reject")
82
+    public R<String> reject(@PathVariable Long id, @RequestParam String reason) {
83
+        installService.reject(id, reason);
84
+        return R.ok("已驳回");
85
+    }
86
+
87
+    // ==================== 进度追踪 ====================
88
+
89
+    @Operation(summary = "查询进度时间线")
90
+    @GetMapping("/{id}/progress")
91
+    public R<List<InstallProgress>> progressTimeline(@PathVariable Long id) {
92
+        return R.ok(progressService.getTimeline(id));
93
+    }
94
+
95
+    @Operation(summary = "进度概览(含时间线+百分比)")
96
+    @GetMapping("/{id}/progress/overview")
97
+    public R<Map<String, Object>> progressOverview(@PathVariable Long id) {
98
+        return R.ok(progressService.getProgressOverview(id));
99
+    }
100
+
101
+    @Operation(summary = "更新进度节点")
102
+    @PutMapping("/{id}/progress/{step}")
103
+    public R<InstallProgress> updateProgress(
104
+            @PathVariable Long id,
105
+            @PathVariable Integer step,
106
+            @RequestBody Map<String, Object> data) {
107
+        String stepStatus = (String) data.get("stepStatus");
108
+        Long operatorId = data.get("operatorId") != null ? ((Number) data.get("operatorId")).longValue() : null;
109
+        String operatorName = (String) data.get("operatorName");
110
+        String remark = (String) data.get("remark");
111
+        return R.ok(progressService.updateProgress(id, step, stepStatus, operatorId, operatorName, remark));
112
+    }
113
+
114
+    @Operation(summary = "添加进度节点")
115
+    @PostMapping("/{id}/progress")
116
+    public R<InstallProgress> addProgress(
117
+            @PathVariable Long id,
118
+            @RequestBody Map<String, Object> data) {
119
+        String stepName = (String) data.get("stepName");
120
+        Integer stepNumber = data.get("stepNumber") != null ? ((Number) data.get("stepNumber")).intValue() : null;
121
+        return R.ok(progressService.addProgress(id, stepName, stepNumber));
122
+    }
123
+
124
+    // ==================== 统计报表 ====================
125
+
126
+    @Operation(summary = "获取完整统计数据")
127
+    @GetMapping("/stats")
128
+    public R<InstallStats> stats() {
129
+        return R.ok(statsService.getStats());
130
+    }
131
+
132
+    @Operation(summary = "首页概览数据")
133
+    @GetMapping("/dashboard")
134
+    public R<Map<String, Object>> dashboard() {
135
+        return R.ok(statsService.getDashboardData());
136
+    }
137
+}

+ 84
- 0
wm-revenue/src/main/java/com/water/revenue/entity/InstallApplication.java Voir le fichier

@@ -0,0 +1,84 @@
1
+package com.water.revenue.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+import java.time.LocalDate;
6
+import java.time.LocalDateTime;
7
+
8
+/**
9
+ * 报装申请实体
10
+ */
11
+@Data
12
+@TableName("rev_install_application")
13
+public class InstallApplication {
14
+
15
+    @TableId(type = IdType.AUTO)
16
+    private Long id;
17
+
18
+    /** 申请编号 */
19
+    private String applicationNo;
20
+
21
+    /** 申请人姓名 */
22
+    private String applicantName;
23
+
24
+    /** 申请人电话 */
25
+    private String applicantPhone;
26
+
27
+    /** 申请人身份证 */
28
+    private String applicantIdCard;
29
+
30
+    /** 客户类型:residential-居民 commercial-商业 industrial-工业 */
31
+    private String customerType;
32
+
33
+    /** 地址 */
34
+    private String address;
35
+
36
+    /** 区域 */
37
+    private String area;
38
+
39
+    /** 区域编码 */
40
+    private String areaCode;
41
+
42
+    /** 用水类型 */
43
+    private String waterUsageType;
44
+
45
+    /** 口径 */
46
+    private String caliber;
47
+
48
+    /** 状态:pending_review-待审核 reviewing-审批中 constructing-施工中 completed-已完工 connected-已通水 rejected-已驳回 */
49
+    private String status;
50
+
51
+    /** 当前步骤 */
52
+    private Integer currentStep;
53
+
54
+    /** 总步骤数 */
55
+    private Integer totalSteps;
56
+
57
+    /** 申请时间 */
58
+    private LocalDateTime applyTime;
59
+
60
+    /** 预计完工时间 */
61
+    private LocalDate expectedComplete;
62
+
63
+    /** 实际完工时间 */
64
+    private LocalDateTime actualComplete;
65
+
66
+    /** 驳回原因 */
67
+    private String rejectReason;
68
+
69
+    /** 经办人ID */
70
+    private Long assigneeId;
71
+
72
+    /** 经办人姓名 */
73
+    private String assigneeName;
74
+
75
+    /** 备注 */
76
+    private String remark;
77
+
78
+    @TableLogic
79
+    private Integer deleted;
80
+
81
+    private LocalDateTime createdAt;
82
+
83
+    private LocalDateTime updatedAt;
84
+}

+ 47
- 0
wm-revenue/src/main/java/com/water/revenue/entity/InstallProgress.java Voir le fichier

@@ -0,0 +1,47 @@
1
+package com.water.revenue.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+import java.time.LocalDateTime;
6
+
7
+/**
8
+ * 报装进度节点实体
9
+ */
10
+@Data
11
+@TableName("rev_install_progress")
12
+public class InstallProgress {
13
+
14
+    @TableId(type = IdType.AUTO)
15
+    private Long id;
16
+
17
+    /** 关联申请ID */
18
+    private Long applicationId;
19
+
20
+    /** 步骤序号 */
21
+    private Integer stepNumber;
22
+
23
+    /** 步骤名称 */
24
+    private String stepName;
25
+
26
+    /** 步骤状态:pending-待处理 in_progress-处理中 completed-已完成 skipped-已跳过 */
27
+    private String stepStatus;
28
+
29
+    /** 操作人ID */
30
+    private Long operatorId;
31
+
32
+    /** 操作人姓名 */
33
+    private String operatorName;
34
+
35
+    /** 完成时间 */
36
+    private LocalDateTime completeTime;
37
+
38
+    /** 备注 */
39
+    private String remark;
40
+
41
+    /** 附件URL列表(JSON数组) */
42
+    private String attachmentUrls;
43
+
44
+    private LocalDateTime createdAt;
45
+
46
+    private LocalDateTime updatedAt;
47
+}

+ 55
- 0
wm-revenue/src/main/java/com/water/revenue/entity/InstallStats.java Voir le fichier

@@ -0,0 +1,55 @@
1
+package com.water.revenue.entity;
2
+
3
+import lombok.Data;
4
+
5
+import java.util.List;
6
+import java.util.Map;
7
+
8
+/**
9
+ * 报装统计VO
10
+ */
11
+@Data
12
+public class InstallStats {
13
+
14
+    /** 总数 */
15
+    private Long totalCount;
16
+
17
+    /** 待审核数 */
18
+    private Long pendingCount;
19
+
20
+    /** 审批中数 */
21
+    private Long reviewingCount;
22
+
23
+    /** 施工中数 */
24
+    private Long constructingCount;
25
+
26
+    /** 已完工数 */
27
+    private Long completedCount;
28
+
29
+    /** 已通水数 */
30
+    private Long connectedCount;
31
+
32
+    /** 已驳回数 */
33
+    private Long rejectedCount;
34
+
35
+    /** 今日新增 */
36
+    private Long todayCount;
37
+
38
+    /** 本月新增 */
39
+    private Long monthCount;
40
+
41
+    /** 平均处理天数 */
42
+    private Double avgProcessDays;
43
+
44
+    /** 按区域分布 */
45
+    private List<Map<String, Object>> areaDistribution;
46
+
47
+    /** 按类型分布 */
48
+    private List<Map<String, Object>> typeDistribution;
49
+
50
+    /** 按状态分布 */
51
+    private List<Map<String, Object>> statusDistribution;
52
+
53
+    /** 月度趋势 */
54
+    private List<Map<String, Object>> monthlyTrend;
55
+}

+ 34
- 0
wm-revenue/src/main/java/com/water/revenue/mapper/InstallApplicationMapper.java Voir le fichier

@@ -0,0 +1,34 @@
1
+package com.water.revenue.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.revenue.entity.InstallApplication;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+
8
+import java.util.List;
9
+import java.util.Map;
10
+
11
+@Mapper
12
+public interface InstallApplicationMapper extends BaseMapper<InstallApplication> {
13
+
14
+    /** 按区域统计 */
15
+    List<Map<String, Object>> countByArea();
16
+
17
+    /** 按类型统计 */
18
+    List<Map<String, Object>> countByType();
19
+
20
+    /** 按状态统计 */
21
+    List<Map<String, Object>> countByStatus();
22
+
23
+    /** 月度趋势(最近12个月) */
24
+    List<Map<String, Object>> monthlyTrend();
25
+
26
+    /** 今日新增 */
27
+    Long countToday();
28
+
29
+    /** 本月新增 */
30
+    Long countThisMonth();
31
+
32
+    /** 平均处理天数 */
33
+    Double avgProcessDays();
34
+}

+ 15
- 0
wm-revenue/src/main/java/com/water/revenue/mapper/InstallProgressMapper.java Voir le fichier

@@ -0,0 +1,15 @@
1
+package com.water.revenue.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.revenue.entity.InstallProgress;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+
8
+import java.util.List;
9
+
10
+@Mapper
11
+public interface InstallProgressMapper extends BaseMapper<InstallProgress> {
12
+
13
+    /** 查询申请的进度时间线 */
14
+    List<InstallProgress> selectTimelineByApplicationId(@Param("applicationId") Long applicationId);
15
+}

+ 127
- 0
wm-revenue/src/main/java/com/water/revenue/service/InstallProgressService.java Voir le fichier

@@ -0,0 +1,127 @@
1
+package com.water.revenue.service;
2
+
3
+import com.water.revenue.entity.InstallApplication;
4
+import com.water.revenue.entity.InstallProgress;
5
+import com.water.revenue.mapper.InstallApplicationMapper;
6
+import com.water.revenue.mapper.InstallProgressMapper;
7
+import lombok.RequiredArgsConstructor;
8
+import lombok.extern.slf4j.Slf4j;
9
+import org.springframework.stereotype.Service;
10
+import org.springframework.transaction.annotation.Transactional;
11
+
12
+import java.time.LocalDateTime;
13
+import java.util.*;
14
+
15
+/**
16
+ * 报装进度服务
17
+ */
18
+@Slf4j
19
+@Service
20
+@RequiredArgsConstructor
21
+public class InstallProgressService {
22
+
23
+    private final InstallProgressMapper progressMapper;
24
+    private final InstallApplicationMapper applicationMapper;
25
+
26
+    /**
27
+     * 查询进度时间线
28
+     */
29
+    public List<InstallProgress> getTimeline(Long applicationId) {
30
+        return progressMapper.selectTimelineByApplicationId(applicationId);
31
+    }
32
+
33
+    /**
34
+     * 更新进度节点
35
+     */
36
+    @Transactional
37
+    public InstallProgress updateProgress(Long applicationId, Integer stepNumber, String stepStatus,
38
+                                          Long operatorId, String operatorName, String remark) {
39
+        // 查找节点
40
+        InstallProgress progress = progressMapper.selectOne(
41
+            new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<InstallProgress>()
42
+                .eq(InstallProgress::getApplicationId, applicationId)
43
+                .eq(InstallProgress::getStepNumber, stepNumber));
44
+
45
+        if (progress == null) {
46
+            throw new RuntimeException("进度节点不存在");
47
+        }
48
+
49
+        progress.setStepStatus(stepStatus);
50
+        progress.setOperatorId(operatorId);
51
+        progress.setOperatorName(operatorName);
52
+        progress.setRemark(remark);
53
+        progress.setUpdatedAt(LocalDateTime.now());
54
+
55
+        if ("completed".equals(stepStatus)) {
56
+            progress.setCompleteTime(LocalDateTime.now());
57
+        }
58
+
59
+        progressMapper.updateById(progress);
60
+
61
+        // 更新申请表的当前步骤
62
+        InstallApplication app = applicationMapper.selectById(applicationId);
63
+        if (app != null) {
64
+            app.setCurrentStep(stepNumber);
65
+            app.setUpdatedAt(LocalDateTime.now());
66
+            applicationMapper.updateById(app);
67
+        }
68
+
69
+        log.info("更新进度: applicationId={}, step={}, status={}", applicationId, stepNumber, stepStatus);
70
+        return progress;
71
+    }
72
+
73
+    /**
74
+     * 添加进度节点
75
+     */
76
+    @Transactional
77
+    public InstallProgress addProgress(Long applicationId, String stepName, Integer stepNumber) {
78
+        InstallProgress progress = new InstallProgress();
79
+        progress.setApplicationId(applicationId);
80
+        progress.setStepName(stepName);
81
+        progress.setStepNumber(stepNumber);
82
+        progress.setStepStatus("pending");
83
+        progress.setCreatedAt(LocalDateTime.now());
84
+        progress.setUpdatedAt(LocalDateTime.now());
85
+
86
+        progressMapper.insert(progress);
87
+        log.info("添加进度节点: applicationId={}, step={}, name={}", applicationId, stepNumber, stepName);
88
+        return progress;
89
+    }
90
+
91
+    /**
92
+     * 获取进度概览(带时间线)
93
+     */
94
+    public Map<String, Object> getProgressOverview(Long applicationId) {
95
+        InstallApplication app = applicationMapper.selectById(applicationId);
96
+        if (app == null) {
97
+            throw new RuntimeException("申请不存在");
98
+        }
99
+
100
+        List<InstallProgress> timeline = getTimeline(applicationId);
101
+
102
+        // 计算进度百分比
103
+        long completedSteps = timeline.stream()
104
+            .filter(p -> "completed".equals(p.getStepStatus()))
105
+            .count();
106
+
107
+        double progressPercent = app.getTotalSteps() > 0
108
+            ? (double) completedSteps / app.getTotalSteps() * 100
109
+            : 0;
110
+
111
+        // 找出当前步骤
112
+        InstallProgress currentStep = timeline.stream()
113
+            .filter(p -> "in_progress".equals(p.getStepStatus()) || "pending".equals(p.getStepStatus()))
114
+            .findFirst()
115
+            .orElse(null);
116
+
117
+        Map<String, Object> result = new HashMap<>();
118
+        result.put("application", app);
119
+        result.put("timeline", timeline);
120
+        result.put("progressPercent", Math.round(progressPercent));
121
+        result.put("completedSteps", completedSteps);
122
+        result.put("totalSteps", app.getTotalSteps());
123
+        result.put("currentStep", currentStep);
124
+
125
+        return result;
126
+    }
127
+}

+ 114
- 33
wm-revenue/src/main/java/com/water/revenue/service/InstallService.java Voir le fichier

@@ -1,56 +1,137 @@
1 1
 package com.water.revenue.service;
2 2
 
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
5
+import com.water.revenue.entity.InstallApplication;
6
+import com.water.revenue.mapper.InstallApplicationMapper;
3 7
 import lombok.RequiredArgsConstructor;
4 8
 import lombok.extern.slf4j.Slf4j;
5
-import org.springframework.jdbc.core.JdbcTemplate;
6 9
 import org.springframework.stereotype.Service;
10
+import org.springframework.transaction.annotation.Transactional;
11
+import org.springframework.util.StringUtils;
7 12
 
8
-import java.time.LocalDate;
9
-import java.util.*;
13
+import java.time.LocalDateTime;
10 14
 
15
+/**
16
+ * 报装申请服务
17
+ */
11 18
 @Slf4j
12 19
 @Service
13 20
 @RequiredArgsConstructor
14 21
 public class InstallService {
15 22
 
16
-    private final JdbcTemplate jdbcTemplate;
23
+    private final InstallApplicationMapper applicationMapper;
17 24
 
18
-    /** 预受理申请 */
19
-    public Map<String, Object> preApply(String name, String phone, String area, String address, String customerType, String caliber) {
20
-        String appNo = "INS-" + System.currentTimeMillis();
21
-        jdbcTemplate.update(
22
-            "INSERT INTO rev_install (application_no, applicant_name, applicant_phone, area, address, customer_type, caliber, status) VALUES (?,?,?,?,?,?,?,?)",
23
-            appNo, name, phone, area, address, customerType, caliber, "pre_apply");
24
-        return Map.of("applicationNo", appNo, "status", "pre_apply");
25
+    /**
26
+     * 分页列表(支持筛选和搜索)
27
+     */
28
+    public Page<InstallApplication> list(int page, int size, String status, String customerType,
29
+                                          String area, String keyword) {
30
+        LambdaQueryWrapper<InstallApplication> wrapper = new LambdaQueryWrapper<>();
31
+
32
+        if (StringUtils.hasText(status)) {
33
+            wrapper.eq(InstallApplication::getStatus, status);
34
+        }
35
+        if (StringUtils.hasText(customerType)) {
36
+            wrapper.eq(InstallApplication::getCustomerType, customerType);
37
+        }
38
+        if (StringUtils.hasText(area)) {
39
+            wrapper.like(InstallApplication::getArea, area);
40
+        }
41
+        if (StringUtils.hasText(keyword)) {
42
+            wrapper.and(w -> w.like(InstallApplication::getApplicantName, keyword)
43
+                    .or().like(InstallApplication::getApplicationNo, keyword)
44
+                    .or().like(InstallApplication::getAddress, keyword)
45
+                    .or().like(InstallApplication::getApplicantPhone, keyword));
46
+        }
47
+
48
+        wrapper.orderByDesc(InstallApplication::getApplyTime);
49
+
50
+        return applicationMapper.selectPage(new Page<>(page, size), wrapper);
25 51
     }
26 52
 
27
-    /** 工程申请 */
28
-    public Map<String, Object> engineeringApply(String appNo, Map<String, Object> engData) {
29
-        jdbcTemplate.update(
30
-            "UPDATE rev_install SET status = 'engineering', updated_at = NOW() WHERE application_no = ?",
31
-            appNo);
32
-        return Map.of("applicationNo", appNo, "status", "engineering");
53
+    /**
54
+     * 根据ID获取
55
+     */
56
+    public InstallApplication getById(Long id) {
57
+        return applicationMapper.selectById(id);
58
+    }
59
+
60
+    /**
61
+     * 创建报装申请
62
+     */
63
+    @Transactional
64
+    public InstallApplication create(InstallApplication application) {
65
+        // 生成申请编号
66
+        application.setApplicationNo("INS-" + System.currentTimeMillis());
67
+        application.setStatus("pending_review");
68
+        application.setCurrentStep(0);
69
+        application.setTotalSteps(5);
70
+        application.setApplyTime(LocalDateTime.now());
71
+        application.setCreatedAt(LocalDateTime.now());
72
+        application.setUpdatedAt(LocalDateTime.now());
73
+        application.setDeleted(0);
74
+
75
+        applicationMapper.insert(application);
76
+
77
+        log.info("创建报装申请: no={}, applicant={}", application.getApplicationNo(), application.getApplicantName());
78
+        return application;
33 79
     }
34 80
 
35
-    /** 派单到施工 */
36
-    public Map<String, Object> assignTask(String appNo, Long assigneeId) {
37
-        jdbcTemplate.update(
38
-            "UPDATE rev_install SET status = 'pending_review', updated_at = NOW() WHERE application_no = ?",
39
-            appNo);
40
-        return Map.of("applicationNo", appNo, "status", "pending_review", "assigneeId", assigneeId);
81
+    /**
82
+     * 更新报装申请
83
+     */
84
+    @Transactional
85
+    public void update(Long id, InstallApplication application) {
86
+        application.setId(id);
87
+        application.setUpdatedAt(LocalDateTime.now());
88
+        applicationMapper.updateById(application);
89
+        log.info("更新报装申请: id={}", id);
41 90
     }
42 91
 
43
-    /** 查询报装进度 */
44
-    public Map<String, Object> getProgress(String appNo) {
45
-        return jdbcTemplate.queryForMap(
46
-            "SELECT application_no, applicant_name, applicant_phone, area, address, customer_type, caliber, status, created_at, updated_at FROM rev_install WHERE application_no = ?",
47
-            appNo);
92
+    /**
93
+     * 删除报装申请(逻辑删除)
94
+     */
95
+    @Transactional
96
+    public void delete(Long id) {
97
+        applicationMapper.deleteById(id);
98
+        log.info("删除报装申请: id={}", id);
48 99
     }
49 100
 
50
-    /** 报装统计报表 */
51
-    public List<Map<String, Object>> getStatsReport(String area, LocalDate start, LocalDate end) {
52
-        return jdbcTemplate.queryForList(
53
-            "SELECT area, customer_type, status, COUNT(*) as count FROM rev_install WHERE created_at BETWEEN ? AND ? GROUP BY area, customer_type, status",
54
-            start, end);
101
+    /**
102
+     * 审批通过
103
+     */
104
+    @Transactional
105
+    public void approve(Long id, String remark) {
106
+        InstallApplication app = applicationMapper.selectById(id);
107
+        if (app == null) {
108
+            throw new RuntimeException("申请不存在");
109
+        }
110
+
111
+        app.setStatus("reviewing");
112
+        app.setCurrentStep(1);
113
+        app.setRemark(remark);
114
+        app.setUpdatedAt(LocalDateTime.now());
115
+        applicationMapper.updateById(app);
116
+
117
+        log.info("审批通过: id={}, no={}", id, app.getApplicationNo());
118
+    }
119
+
120
+    /**
121
+     * 审批驳回
122
+     */
123
+    @Transactional
124
+    public void reject(Long id, String reason) {
125
+        InstallApplication app = applicationMapper.selectById(id);
126
+        if (app == null) {
127
+            throw new RuntimeException("申请不存在");
128
+        }
129
+
130
+        app.setStatus("rejected");
131
+        app.setRejectReason(reason);
132
+        app.setUpdatedAt(LocalDateTime.now());
133
+        applicationMapper.updateById(app);
134
+
135
+        log.info("审批驳回: id={}, reason={}", id, reason);
55 136
     }
56 137
 }

+ 92
- 0
wm-revenue/src/main/java/com/water/revenue/service/InstallStatsService.java Voir le fichier

@@ -0,0 +1,92 @@
1
+package com.water.revenue.service;
2
+
3
+import com.water.revenue.entity.InstallStats;
4
+import com.water.revenue.mapper.InstallApplicationMapper;
5
+import lombok.RequiredArgsConstructor;
6
+import lombok.extern.slf4j.Slf4j;
7
+import org.springframework.stereotype.Service;
8
+
9
+import java.util.List;
10
+import java.util.Map;
11
+
12
+/**
13
+ * 报装统计服务
14
+ */
15
+@Slf4j
16
+@Service
17
+@RequiredArgsConstructor
18
+public class InstallStatsService {
19
+
20
+    private final InstallApplicationMapper applicationMapper;
21
+
22
+    /**
23
+     * 获取完整统计数据
24
+     */
25
+    public InstallStats getStats() {
26
+        InstallStats stats = new InstallStats();
27
+
28
+        // 基础统计
29
+        stats.setAreaDistribution(applicationMapper.countByArea());
30
+        stats.setTypeDistribution(applicationMapper.countByType());
31
+        stats.setStatusDistribution(applicationMapper.countByStatus());
32
+        stats.setMonthlyTrend(applicationMapper.monthlyTrend());
33
+
34
+        // 计算总数
35
+        Long totalCount = 0L;
36
+        for (Map<String, Object> item : stats.getStatusDistribution()) {
37
+            totalCount += ((Number) item.get("value")).longValue();
38
+        }
39
+        stats.setTotalCount(totalCount);
40
+
41
+        // 按状态分类计数
42
+        for (Map<String, Object> item : stats.getStatusDistribution()) {
43
+            String name = (String) item.get("name");
44
+            Long value = ((Number) item.get("value")).longValue();
45
+            switch (name) {
46
+                case "待审核": stats.setPendingCount(value); break;
47
+                case "审批中": stats.setReviewingCount(value); break;
48
+                case "施工中": stats.setConstructingCount(value); break;
49
+                case "已完工": stats.setCompletedCount(value); break;
50
+                case "已通水": stats.setConnectedCount(value); break;
51
+                case "已驳回": stats.setRejectedCount(value); break;
52
+            }
53
+        }
54
+
55
+        // 今日新增
56
+        stats.setTodayCount(applicationMapper.countToday());
57
+
58
+        // 本月新增
59
+        stats.setMonthCount(applicationMapper.countThisMonth());
60
+
61
+        // 平均处理天数
62
+        Double avgDays = applicationMapper.avgProcessDays();
63
+        stats.setAvgProcessDays(avgDays != null ? Math.round(avgDays * 10.0) / 10.0 : 0.0);
64
+
65
+        return stats;
66
+    }
67
+
68
+    /**
69
+     * 首页概览数据
70
+     */
71
+    public Map<String, Object> getDashboardData() {
72
+        InstallStats stats = getStats();
73
+
74
+        // 在办数量(待审核+审批中+施工中)
75
+        long inProgressCount = (stats.getPendingCount() != null ? stats.getPendingCount() : 0)
76
+            + (stats.getReviewingCount() != null ? stats.getReviewingCount() : 0)
77
+            + (stats.getConstructingCount() != null ? stats.getConstructingCount() : 0);
78
+
79
+        // 完工数量(已完工+已通水)
80
+        long finishedCount = (stats.getCompletedCount() != null ? stats.getCompletedCount() : 0)
81
+            + (stats.getConnectedCount() != null ? stats.getConnectedCount() : 0);
82
+
83
+        return Map.of(
84
+            "todayCount", stats.getTodayCount() != null ? stats.getTodayCount() : 0,
85
+            "inProgressCount", inProgressCount,
86
+            "finishedCount", finishedCount,
87
+            "avgProcessDays", stats.getAvgProcessDays() != null ? stats.getAvgProcessDays() : 0,
88
+            "totalCount", stats.getTotalCount() != null ? stats.getTotalCount() : 0,
89
+            "monthCount", stats.getMonthCount() != null ? stats.getMonthCount() : 0
90
+        );
91
+    }
92
+}

+ 56
- 0
wm-revenue/src/main/resources/db/V_install.sql Voir le fichier

@@ -0,0 +1,56 @@
1
+-- 报装申请表
2
+CREATE TABLE IF NOT EXISTS rev_install_application (
3
+    id              BIGSERIAL PRIMARY KEY,
4
+    application_no  VARCHAR(50) NOT NULL UNIQUE,
5
+    applicant_name  VARCHAR(100) NOT NULL,
6
+    applicant_phone VARCHAR(20) NOT NULL,
7
+    applicant_id_card VARCHAR(20),
8
+    customer_type   VARCHAR(20) NOT NULL DEFAULT 'residential', -- residential/commercial/industrial
9
+    address         VARCHAR(500) NOT NULL,
10
+    area            VARCHAR(100),
11
+    area_code       VARCHAR(20),
12
+    water_usage_type VARCHAR(50),
13
+    caliber         VARCHAR(20),
14
+    status          VARCHAR(30) NOT NULL DEFAULT 'pending_review', -- pending_review/reviewing/constructing/completed/connected/rejected
15
+    current_step    INTEGER DEFAULT 0,
16
+    total_steps     INTEGER DEFAULT 5,
17
+    apply_time      TIMESTAMP DEFAULT NOW(),
18
+    expected_complete DATE,
19
+    actual_complete TIMESTAMP,
20
+    reject_reason   TEXT,
21
+    assignee_id     BIGINT,
22
+    assignee_name   VARCHAR(100),
23
+    remark          TEXT,
24
+    deleted         INTEGER DEFAULT 0,
25
+    created_at      TIMESTAMP DEFAULT NOW(),
26
+    updated_at      TIMESTAMP DEFAULT NOW()
27
+);
28
+
29
+CREATE INDEX IF NOT EXISTS idx_install_app_status ON rev_install_application(status);
30
+CREATE INDEX IF NOT EXISTS idx_install_app_area ON rev_install_application(area);
31
+CREATE INDEX IF NOT EXISTS idx_install_app_type ON rev_install_application(customer_type);
32
+CREATE INDEX IF NOT EXISTS idx_install_app_time ON rev_install_application(apply_time);
33
+CREATE INDEX IF NOT EXISTS idx_install_app_no ON rev_install_application(application_no);
34
+
35
+-- 报装进度节点表
36
+CREATE TABLE IF NOT EXISTS rev_install_progress (
37
+    id              BIGSERIAL PRIMARY KEY,
38
+    application_id  BIGINT NOT NULL REFERENCES rev_install_application(id),
39
+    step_number     INTEGER NOT NULL,
40
+    step_name       VARCHAR(100) NOT NULL,
41
+    step_status     VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending/in_progress/completed/skipped
42
+    operator_id     BIGINT,
43
+    operator_name   VARCHAR(100),
44
+    complete_time   TIMESTAMP,
45
+    remark          TEXT,
46
+    attachment_urls TEXT,
47
+    created_at      TIMESTAMP DEFAULT NOW(),
48
+    updated_at      TIMESTAMP DEFAULT NOW()
49
+);
50
+
51
+CREATE INDEX IF NOT EXISTS idx_install_progress_app ON rev_install_progress(application_id);
52
+CREATE INDEX IF NOT EXISTS idx_install_progress_step ON rev_install_progress(application_id, step_number);
53
+
54
+-- 默认进度节点模板数据(申请提交时自动创建)
55
+COMMENT ON TABLE rev_install_application IS '报装申请表';
56
+COMMENT ON TABLE rev_install_progress IS '报装进度节点表';

+ 115
- 0
wm-revenue/src/main/resources/mapper/InstallApplicationMapper.xml Voir le fichier

@@ -0,0 +1,115 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
3
+<mapper namespace="com.water.revenue.mapper.InstallApplicationMapper">
4
+
5
+    <resultMap id="BaseResultMap" type="com.water.revenue.entity.InstallApplication">
6
+        <id column="id" property="id" />
7
+        <result column="application_no" property="applicationNo" />
8
+        <result column="applicant_name" property="applicantName" />
9
+        <result column="applicant_phone" property="applicantPhone" />
10
+        <result column="applicant_id_card" property="applicantIdCard" />
11
+        <result column="customer_type" property="customerType" />
12
+        <result column="address" property="address" />
13
+        <result column="area" property="area" />
14
+        <result column="area_code" property="areaCode" />
15
+        <result column="water_usage_type" property="waterUsageType" />
16
+        <result column="caliber" property="caliber" />
17
+        <result column="status" property="status" />
18
+        <result column="current_step" property="currentStep" />
19
+        <result column="total_steps" property="totalSteps" />
20
+        <result column="apply_time" property="applyTime" />
21
+        <result column="expected_complete" property="expectedComplete" />
22
+        <result column="actual_complete" property="actualComplete" />
23
+        <result column="reject_reason" property="rejectReason" />
24
+        <result column="assignee_id" property="assigneeId" />
25
+        <result column="assignee_name" property="assigneeName" />
26
+        <result column="remark" property="remark" />
27
+        <result column="deleted" property="deleted" />
28
+        <result column="created_at" property="createdAt" />
29
+        <result column="updated_at" property="updatedAt" />
30
+    </resultMap>
31
+
32
+    <!-- 按区域统计 -->
33
+    <select id="countByArea" resultType="java.util.Map">
34
+        SELECT
35
+            area AS "name",
36
+            COUNT(*) AS "value"
37
+        FROM rev_install_application
38
+        WHERE deleted = 0
39
+        GROUP BY area
40
+        ORDER BY COUNT(*) DESC
41
+    </select>
42
+
43
+    <!-- 按类型统计 -->
44
+    <select id="countByType" resultType="java.util.Map">
45
+        SELECT
46
+            CASE customer_type
47
+                WHEN 'residential' THEN '居民'
48
+                WHEN 'commercial' THEN '商业'
49
+                WHEN 'industrial' THEN '工业'
50
+                ELSE '其他'
51
+            END AS "name",
52
+            COUNT(*) AS "value"
53
+        FROM rev_install_application
54
+        WHERE deleted = 0
55
+        GROUP BY customer_type
56
+        ORDER BY COUNT(*) DESC
57
+    </select>
58
+
59
+    <!-- 按状态统计 -->
60
+    <select id="countByStatus" resultType="java.util.Map">
61
+        SELECT
62
+            CASE status
63
+                WHEN 'pending_review' THEN '待审核'
64
+                WHEN 'reviewing' THEN '审批中'
65
+                WHEN 'constructing' THEN '施工中'
66
+                WHEN 'completed' THEN '已完工'
67
+                WHEN 'connected' THEN '已通水'
68
+                WHEN 'rejected' THEN '已驳回'
69
+                ELSE '未知'
70
+            END AS "name",
71
+            COUNT(*) AS "value"
72
+        FROM rev_install_application
73
+        WHERE deleted = 0
74
+        GROUP BY status
75
+        ORDER BY COUNT(*) DESC
76
+    </select>
77
+
78
+    <!-- 月度趋势(最近12个月) -->
79
+    <select id="monthlyTrend" resultType="java.util.Map">
80
+        SELECT
81
+            TO_CHAR(DATE_TRUNC('month', apply_time), 'YYYY-MM') AS "month",
82
+            COUNT(*) AS "total",
83
+            COUNT(CASE WHEN status = 'connected' THEN 1 END) AS "completed"
84
+        FROM rev_install_application
85
+        WHERE deleted = 0
86
+          AND apply_time >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
87
+        GROUP BY DATE_TRUNC('month', apply_time)
88
+        ORDER BY DATE_TRUNC('month', apply_time)
89
+    </select>
90
+
91
+    <!-- 今日新增 -->
92
+    <select id="countToday" resultType="java.lang.Long">
93
+        SELECT COUNT(*)
94
+        FROM rev_install_application
95
+        WHERE deleted = 0
96
+          AND DATE(apply_time) = CURRENT_DATE
97
+    </select>
98
+
99
+    <!-- 本月新增 -->
100
+    <select id="countThisMonth" resultType="java.lang.Long">
101
+        SELECT COUNT(*)
102
+        FROM rev_install_application
103
+        WHERE deleted = 0
104
+          AND DATE_TRUNC('month', apply_time) = DATE_TRUNC('month', NOW())
105
+    </select>
106
+
107
+    <!-- 平均处理天数 -->
108
+    <select id="avgProcessDays" resultType="java.lang.Double">
109
+        SELECT AVG(EXTRACT(EPOCH FROM (actual_complete - DATE(apply_time))) / 86400.0)
110
+        FROM rev_install_application
111
+        WHERE deleted = 0
112
+          AND actual_complete IS NOT NULL
113
+    </select>
114
+
115
+</mapper>

+ 28
- 0
wm-revenue/src/main/resources/mapper/InstallProgressMapper.xml Voir le fichier

@@ -0,0 +1,28 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
3
+<mapper namespace="com.water.revenue.mapper.InstallProgressMapper">
4
+
5
+    <resultMap id="BaseResultMap" type="com.water.revenue.entity.InstallProgress">
6
+        <id column="id" property="id" />
7
+        <result column="application_id" property="applicationId" />
8
+        <result column="step_number" property="stepNumber" />
9
+        <result column="step_name" property="stepName" />
10
+        <result column="step_status" property="stepStatus" />
11
+        <result column="operator_id" property="operatorId" />
12
+        <result column="operator_name" property="operatorName" />
13
+        <result column="complete_time" property="completeTime" />
14
+        <result column="remark" property="remark" />
15
+        <result column="attachment_urls" property="attachmentUrls" />
16
+        <result column="created_at" property="createdAt" />
17
+        <result column="updated_at" property="updatedAt" />
18
+    </resultMap>
19
+
20
+    <!-- 查询申请的进度时间线 -->
21
+    <select id="selectTimelineByApplicationId" resultMap="BaseResultMap">
22
+        SELECT *
23
+        FROM rev_install_progress
24
+        WHERE application_id = #{applicationId}
25
+        ORDER BY step_number ASC
26
+    </select>
27
+
28
+</mapper>

+ 203
- 0
wm-revenue/src/test/java/com/water/revenue/InstallServiceTest.java Voir le fichier

@@ -0,0 +1,203 @@
1
+package com.water.revenue;
2
+
3
+import com.water.revenue.entity.InstallApplication;
4
+import com.water.revenue.entity.InstallProgress;
5
+import com.water.revenue.entity.InstallStats;
6
+import com.water.revenue.mapper.InstallApplicationMapper;
7
+import com.water.revenue.mapper.InstallProgressMapper;
8
+import com.water.revenue.service.InstallProgressService;
9
+import com.water.revenue.service.InstallStatsService;
10
+import org.junit.jupiter.api.BeforeEach;
11
+import org.junit.jupiter.api.DisplayName;
12
+import org.junit.jupiter.api.Nested;
13
+import org.junit.jupiter.api.Test;
14
+import org.junit.jupiter.api.extension.ExtendWith;
15
+import org.mockito.Mock;
16
+import org.mockito.junit.jupiter.MockitoExtension;
17
+
18
+import java.util.*;
19
+
20
+import static org.junit.jupiter.api.Assertions.*;
21
+import static org.mockito.ArgumentMatchers.*;
22
+import static org.mockito.Mockito.*;
23
+
24
+@ExtendWith(MockitoExtension.class)
25
+class InstallServiceTest {
26
+
27
+    @Mock
28
+    private InstallApplicationMapper applicationMapper;
29
+
30
+    @Mock
31
+    private InstallProgressMapper progressMapper;
32
+
33
+    private InstallProgressService progressService;
34
+    private InstallStatsService statsService;
35
+
36
+    @BeforeEach
37
+    void setUp() {
38
+        progressService = new InstallProgressService(progressMapper, applicationMapper);
39
+        statsService = new InstallStatsService(applicationMapper);
40
+    }
41
+
42
+    @Nested
43
+    @DisplayName("进度查询测试")
44
+    class ProgressTests {
45
+
46
+        @Test
47
+        @DisplayName("查询进度时间线 - 正常返回")
48
+        void getTimeline_returnsList() {
49
+            Long appId = 1L;
50
+            List<InstallProgress> timeline = new ArrayList<>();
51
+            InstallProgress p1 = new InstallProgress();
52
+            p1.setStepNumber(1);
53
+            p1.setStepName("提交申请");
54
+            p1.setStepStatus("completed");
55
+            timeline.add(p1);
56
+
57
+            InstallProgress p2 = new InstallProgress();
58
+            p2.setStepNumber(2);
59
+            p2.setStepName("审核");
60
+            p2.setStepStatus("in_progress");
61
+            timeline.add(p2);
62
+
63
+            when(progressMapper.selectTimelineByApplicationId(appId)).thenReturn(timeline);
64
+
65
+            List<InstallProgress> result = progressService.getTimeline(appId);
66
+
67
+            assertNotNull(result);
68
+            assertEquals(2, result.size());
69
+            assertEquals("提交申请", result.get(0).getStepName());
70
+            assertEquals("审核", result.get(1).getStepName());
71
+            verify(progressMapper).selectTimelineByApplicationId(appId);
72
+        }
73
+
74
+        @Test
75
+        @DisplayName("获取进度概览 - 计算百分比")
76
+        void getProgressOverview_calculatesPercent() {
77
+            Long appId = 1L;
78
+
79
+            InstallApplication app = new InstallApplication();
80
+            app.setId(appId);
81
+            app.setApplicationNo("INS-001");
82
+            app.setTotalSteps(5);
83
+            app.setCurrentStep(3);
84
+
85
+            when(applicationMapper.selectById(appId)).thenReturn(app);
86
+
87
+            List<InstallProgress> timeline = new ArrayList<>();
88
+            for (int i = 1; i <= 5; i++) {
89
+                InstallProgress p = new InstallProgress();
90
+                p.setStepNumber(i);
91
+                p.setStepStatus(i <= 3 ? "completed" : "pending");
92
+                timeline.add(p);
93
+            }
94
+            when(progressMapper.selectTimelineByApplicationId(appId)).thenReturn(timeline);
95
+
96
+            Map<String, Object> result = progressService.getProgressOverview(appId);
97
+
98
+            assertNotNull(result);
99
+            assertEquals(app, result.get("application"));
100
+            assertEquals(timeline, result.get("timeline"));
101
+            assertEquals(60L, result.get("progressPercent")); // 3/5 = 60%
102
+            assertEquals(3L, result.get("completedSteps"));
103
+            assertEquals(5, result.get("totalSteps"));
104
+        }
105
+    }
106
+
107
+    @Nested
108
+    @DisplayName("统计报表测试")
109
+    class StatsTests {
110
+
111
+        @Test
112
+        @DisplayName("获取统计数据 - 完整数据")
113
+        void getStats_completeData() {
114
+            // Mock 区域分布
115
+            List<Map<String, Object>> areaDist = new ArrayList<>();
116
+            areaDist.add(Map.of("name", "城东片区", "value", 10L));
117
+            when(applicationMapper.countByArea()).thenReturn(areaDist);
118
+
119
+            // Mock 类型分布
120
+            List<Map<String, Object>> typeDist = new ArrayList<>();
121
+            typeDist.add(Map.of("name", "居民", "value", 8L));
122
+            when(applicationMapper.countByType()).thenReturn(typeDist);
123
+
124
+            // Mock 状态分布
125
+            List<Map<String, Object>> statusDist = new ArrayList<>();
126
+            statusDist.add(Map.of("name", "待审核", "value", 3L));
127
+            statusDist.add(Map.of("name", "审批中", "value", 2L));
128
+            statusDist.add(Map.of("name", "施工中", "value", 4L));
129
+            statusDist.add(Map.of("name", "已完工", "value", 5L));
130
+            statusDist.add(Map.of("name", "已通水", "value", 6L));
131
+            statusDist.add(Map.of("name", "已驳回", "value", 1L));
132
+            when(applicationMapper.countByStatus()).thenReturn(statusDist);
133
+
134
+            // Mock 月度趋势
135
+            when(applicationMapper.monthlyTrend()).thenReturn(new ArrayList<>());
136
+
137
+            // Mock 其他
138
+            when(applicationMapper.countToday()).thenReturn(2L);
139
+            when(applicationMapper.countThisMonth()).thenReturn(15L);
140
+            when(applicationMapper.avgProcessDays()).thenReturn(12.5);
141
+
142
+            InstallStats stats = statsService.getStats();
143
+
144
+            assertNotNull(stats);
145
+            assertEquals(21L, stats.getTotalCount()); // 3+2+4+5+6+1
146
+            assertEquals(3L, stats.getPendingCount());
147
+            assertEquals(2L, stats.getReviewingCount());
148
+            assertEquals(4L, stats.getConstructingCount());
149
+            assertEquals(5L, stats.getCompletedCount());
150
+            assertEquals(6L, stats.getConnectedCount());
151
+            assertEquals(1L, stats.getRejectedCount());
152
+            assertEquals(2L, stats.getTodayCount());
153
+            assertEquals(15L, stats.getMonthCount());
154
+            assertEquals(12.5, stats.getAvgProcessDays());
155
+        }
156
+
157
+        @Test
158
+        @DisplayName("获取首页概览 - 计算在办和完工数")
159
+        void getDashboardData_calculatesCorrectly() {
160
+            List<Map<String, Object>> statusDist = new ArrayList<>();
161
+            statusDist.add(Map.of("name", "待审核", "value", 3L));
162
+            statusDist.add(Map.of("name", "审批中", "value", 2L));
163
+            statusDist.add(Map.of("name", "施工中", "value", 4L));
164
+            statusDist.add(Map.of("name", "已完工", "value", 5L));
165
+            statusDist.add(Map.of("name", "已通水", "value", 6L));
166
+
167
+            when(applicationMapper.countByArea()).thenReturn(new ArrayList<>());
168
+            when(applicationMapper.countByType()).thenReturn(new ArrayList<>());
169
+            when(applicationMapper.countByStatus()).thenReturn(statusDist);
170
+            when(applicationMapper.monthlyTrend()).thenReturn(new ArrayList<>());
171
+            when(applicationMapper.countToday()).thenReturn(1L);
172
+            when(applicationMapper.countThisMonth()).thenReturn(10L);
173
+            when(applicationMapper.avgProcessDays()).thenReturn(10.0);
174
+
175
+            Map<String, Object> dashboard = statsService.getDashboardData();
176
+
177
+            assertNotNull(dashboard);
178
+            assertEquals(1L, dashboard.get("todayCount"));
179
+            assertEquals(9L, dashboard.get("inProgressCount")); // 3+2+4
180
+            assertEquals(11L, dashboard.get("finishedCount")); // 5+6
181
+            assertEquals(10.0, dashboard.get("avgProcessDays"));
182
+            assertEquals(20L, dashboard.get("totalCount")); // 3+2+4+5+6
183
+        }
184
+
185
+        @Test
186
+        @DisplayName("统计数据 - 空数据时正常处理")
187
+        void getStats_emptyData() {
188
+            when(applicationMapper.countByArea()).thenReturn(new ArrayList<>());
189
+            when(applicationMapper.countByType()).thenReturn(new ArrayList<>());
190
+            when(applicationMapper.countByStatus()).thenReturn(new ArrayList<>());
191
+            when(applicationMapper.monthlyTrend()).thenReturn(new ArrayList<>());
192
+            when(applicationMapper.countToday()).thenReturn(0L);
193
+            when(applicationMapper.countThisMonth()).thenReturn(0L);
194
+            when(applicationMapper.avgProcessDays()).thenReturn(null);
195
+
196
+            InstallStats stats = statsService.getStats();
197
+
198
+            assertNotNull(stats);
199
+            assertEquals(0L, stats.getTotalCount());
200
+            assertEquals(0.0, stats.getAvgProcessDays());
201
+        }
202
+    }
203
+}