Procházet zdrojové kódy

feat(wm-bi+frontend): #40 自动运营报告生成完整实现

- 后端: ReportTemplate/GeneratedReport 实体+CRUD+分页
- 服务: ReportTemplateService(模板管理/变量解析/渲染引擎)
- 服务: ReportGeneratorService(数据采集聚合/HTML生成/推送调度)
- 定时: ReportScheduler(日报08:00/周报周一/月报1号)
- 推送: 邮件(SimpleMailMessage) + 企微webhook(HTTP POST)
- 前端: ReportTemplateView.vue(模板管理+预览)
- 前端: GeneratedReportView.vue(报告列表+详情+推送记录)
- SQL: V_bi_report.sql + 3套默认模板
- 测试: ReportTemplateServiceTest + ReportGeneratorServiceTest
- 路由: /bi/template + /bi/report
bot_dev2 před 5 dny
rodič
revize
0ea67868fa

+ 80
- 0
frontend/src/api/reportApi.ts Zobrazit soubor

@@ -0,0 +1,80 @@
1
+import request from './request'
2
+
3
+// ==================== 报告模板 API ====================
4
+
5
+/** 分页查询模板 */
6
+export function getReportTemplates(params: {
7
+  pageNum?: number; pageSize?: number; reportType?: string; status?: string
8
+}) {
9
+  return request.get('/bi/report-template/page', { params })
10
+}
11
+
12
+/** 获取模板详情 */
13
+export function getReportTemplate(id: number) {
14
+  return request.get(`/bi/report-template/${id}`)
15
+}
16
+
17
+/** 创建模板 */
18
+export function createReportTemplate(data: any) {
19
+  return request.post('/bi/report-template', data)
20
+}
21
+
22
+/** 更新模板 */
23
+export function updateReportTemplate(id: number, data: any) {
24
+  return request.put(`/bi/report-template/${id}`, data)
25
+}
26
+
27
+/** 删除模板 */
28
+export function deleteReportTemplate(id: number) {
29
+  return request.delete(`/bi/report-template/${id}`)
30
+}
31
+
32
+/** 启用/停用模板 */
33
+export function toggleTemplateStatus(id: number, status: string) {
34
+  return request.patch(`/bi/report-template/${id}/status`, null, { params: { status } })
35
+}
36
+
37
+/** 复制模板 */
38
+export function copyReportTemplate(id: number) {
39
+  return request.post(`/bi/report-template/${id}/copy`)
40
+}
41
+
42
+/** 预览模板变量 */
43
+export function previewTemplate(id: number) {
44
+  return request.get(`/bi/report-template/${id}/preview`)
45
+}
46
+
47
+// ==================== 生成报告 API ====================
48
+
49
+/** 分页查询报告 */
50
+export function getGeneratedReports(params: {
51
+  pageNum?: number; pageSize?: number; reportType?: string;
52
+  pushStatus?: string; startTime?: string; endTime?: string
53
+}) {
54
+  return request.get('/bi/report/page', { params })
55
+}
56
+
57
+/** 获取报告详情 */
58
+export function getGeneratedReport(id: number) {
59
+  return request.get(`/bi/report/${id}`)
60
+}
61
+
62
+/** 删除报告 */
63
+export function deleteGeneratedReport(id: number) {
64
+  return request.delete(`/bi/report/${id}`)
65
+}
66
+
67
+/** 重新生成报告 */
68
+export function regenerateReport(id: number) {
69
+  return request.post(`/bi/report/${id}/regenerate`)
70
+}
71
+
72
+/** 手动推送报告 */
73
+export function manualPushReport(id: number) {
74
+  return request.post(`/bi/report/${id}/push`)
75
+}
76
+
77
+/** 手动触发生成报告 */
78
+export function generateReport(reportType: string) {
79
+  return request.post('/bi/report/generate', null, { params: { reportType } })
80
+}

+ 2
- 0
frontend/src/router/index.ts Zobrazit soubor

@@ -16,6 +16,8 @@ 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: 'bi/template', name: 'biTemplate', component: () => import('@/views/bi/ReportTemplateView.vue') },
20
+      { path: 'bi/report', name: 'biReport', component: () => import('@/views/bi/GeneratedReportView.vue') },
19 21
     ]
20 22
   },
21 23
   { path: '/:pathMatch(.*)*', redirect: '/dashboard' }

+ 263
- 0
frontend/src/views/bi/GeneratedReportView.vue Zobrazit soubor

@@ -0,0 +1,263 @@
1
+<template>
2
+  <div class="generated-report-mgmt">
3
+    <!-- 搜索区 -->
4
+    <el-card shadow="never">
5
+      <el-form :inline="true" :model="filterForm">
6
+        <el-form-item label="报告类型">
7
+          <el-select v-model="filterForm.reportType" placeholder="全部" clearable @change="handleSearch">
8
+            <el-option label="日报" value="daily" />
9
+            <el-option label="周报" value="weekly" />
10
+            <el-option label="月报" value="monthly" />
11
+          </el-select>
12
+        </el-form-item>
13
+        <el-form-item label="推送状态">
14
+          <el-select v-model="filterForm.pushStatus" placeholder="全部" clearable @change="handleSearch">
15
+            <el-option label="待推送" value="pending" />
16
+            <el-option label="推送中" value="pushing" />
17
+            <el-option label="成功" value="success" />
18
+            <el-option label="部分失败" value="partial_fail" />
19
+            <el-option label="失败" value="fail" />
20
+          </el-select>
21
+        </el-form-item>
22
+        <el-form-item label="生成时间">
23
+          <el-date-picker v-model="timeRange" type="daterange"
24
+            range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期"
25
+            value-format="YYYY-MM-DD HH:mm:ss" @change="handleTimeChange" />
26
+        </el-form-item>
27
+        <el-form-item>
28
+          <el-button type="primary" @click="handleSearch"><el-icon><Search /></el-icon> 查询</el-button>
29
+          <el-button @click="handleReset">重置</el-button>
30
+        </el-form-item>
31
+      </el-form>
32
+    </el-card>
33
+
34
+    <!-- 操作栏 -->
35
+    <div style="margin-top: 16px">
36
+      <el-dropdown @command="handleGenerate" style="margin-right: 12px">
37
+        <el-button type="primary">
38
+          <el-icon><Plus /></el-icon> 手动生成 <el-icon class="el-icon--right"><ArrowDown /></el-icon>
39
+        </el-button>
40
+        <template #dropdown>
41
+          <el-dropdown-menu>
42
+            <el-dropdown-item command="daily">生成日报</el-dropdown-item>
43
+            <el-dropdown-item command="weekly">生成周报</el-dropdown-item>
44
+            <el-dropdown-item command="monthly">生成月报</el-dropdown-item>
45
+          </el-dropdown-menu>
46
+        </template>
47
+      </el-dropdown>
48
+    </div>
49
+
50
+    <!-- 列表 -->
51
+    <el-table :data="tableData" border style="margin-top: 12px" v-loading="loading">
52
+      <el-table-column prop="id" label="ID" width="70" />
53
+      <el-table-column prop="reportTitle" label="报告标题" min-width="280" show-overflow-tooltip />
54
+      <el-table-column prop="reportType" label="类型" width="90">
55
+        <template #default="{ row }">
56
+          <el-tag :type="typeTag(row.reportType)" size="small">{{ typeLabel(row.reportType) }}</el-tag>
57
+        </template>
58
+      </el-table-column>
59
+      <el-table-column prop="pushStatus" label="推送状态" width="110">
60
+        <template #default="{ row }">
61
+          <el-tag :type="pushStatusTag(row.pushStatus)" size="small">{{ pushStatusLabel(row.pushStatus) }}</el-tag>
62
+        </template>
63
+      </el-table-column>
64
+      <el-table-column prop="generatedAt" label="生成时间" width="170" />
65
+      <el-table-column prop="periodStart" label="报告周期" width="200">
66
+        <template #default="{ row }">
67
+          <span style="font-size: 12px">{{ formatPeriod(row) }}</span>
68
+        </template>
69
+      </el-table-column>
70
+      <el-table-column label="操作" width="280" fixed="right">
71
+        <template #default="{ row }">
72
+          <el-button link type="primary" @click="viewDetail(row)">查看</el-button>
73
+          <el-button link type="warning" @click="handleRegenerate(row)">重新生成</el-button>
74
+          <el-button link type="success" @click="handlePush(row)">推送</el-button>
75
+          <el-button link type="info" @click="viewPushLog(row)">推送记录</el-button>
76
+          <el-button link type="danger" @click="handleDelete(row)">删除</el-button>
77
+        </template>
78
+      </el-table-column>
79
+    </el-table>
80
+
81
+    <!-- 分页 -->
82
+    <el-pagination style="margin-top: 16px; justify-content: flex-end"
83
+      v-model:current-page="pagination.page" v-model:page-size="pagination.size"
84
+      :total="pagination.total" :page-sizes="[10, 20, 50]"
85
+      layout="total, sizes, prev, pager, next" @change="fetchData" />
86
+
87
+    <!-- 详情对话框 -->
88
+    <el-dialog v-model="showDetail" :title="detailItem?.reportTitle" width="900px" destroy-on-close>
89
+      <el-descriptions :column="2" border>
90
+        <el-descriptions-item label="报告类型">
91
+          <el-tag :type="typeTag(detailItem?.reportType)">{{ typeLabel(detailItem?.reportType) }}</el-tag>
92
+        </el-descriptions-item>
93
+        <el-descriptions-item label="推送状态">
94
+          <el-tag :type="pushStatusTag(detailItem?.pushStatus)">{{ pushStatusLabel(detailItem?.pushStatus) }}</el-tag>
95
+        </el-descriptions-item>
96
+        <el-descriptions-item label="生成时间">{{ detailItem?.generatedAt }}</el-descriptions-item>
97
+        <el-descriptions-item label="报告周期">{{ formatPeriod(detailItem) }}</el-descriptions-item>
98
+      </el-descriptions>
99
+      <el-divider />
100
+      <div class="report-content" v-html="detailItem?.contentHtml"></div>
101
+    </el-dialog>
102
+
103
+    <!-- 推送记录对话框 -->
104
+    <el-dialog v-model="showPushLog" title="推送记录" width="700px">
105
+      <el-table :data="pushLogData" border size="small">
106
+        <el-table-column prop="channel" label="渠道" width="100" />
107
+        <el-table-column prop="target" label="目标" min-width="200" />
108
+        <el-table-column prop="status" label="状态" width="100">
109
+          <template #default="{ row }">
110
+            <el-tag :type="row.status === 'success' ? 'success' : row.status === 'fail' ? 'danger' : 'info'" size="small">
111
+              {{ row.status }}
112
+            </el-tag>
113
+          </template>
114
+        </el-table-column>
115
+        <el-table-column prop="time" label="时间" width="170" />
116
+        <el-table-column prop="error" label="错误信息" min-width="200" />
117
+      </el-table>
118
+      <template #footer>
119
+        <el-button @click="showPushLog = false">关闭</el-button>
120
+      </template>
121
+    </el-dialog>
122
+  </div>
123
+</template>
124
+
125
+<script setup lang="ts">
126
+import { ref, reactive, onMounted } from 'vue'
127
+import { ElMessage, ElMessageBox } from 'element-plus'
128
+import { Search, Plus, ArrowDown } from '@element-plus/icons-vue'
129
+import {
130
+  getGeneratedReports, getGeneratedReport, deleteGeneratedReport,
131
+  regenerateReport, manualPushReport, generateReport
132
+} from '@/api/reportApi'
133
+
134
+const loading = ref(false)
135
+const showDetail = ref(false)
136
+const showPushLog = ref(false)
137
+const tableData = ref<any[]>([])
138
+const detailItem = ref<any>(null)
139
+const pushLogData = ref<any[]>([])
140
+const timeRange = ref<string[]>([])
141
+
142
+const filterForm = reactive({ reportType: '', pushStatus: '', startTime: '', endTime: '' })
143
+const pagination = reactive({ page: 1, size: 10, total: 0 })
144
+
145
+onMounted(() => fetchData())
146
+
147
+async function fetchData() {
148
+  loading.value = true
149
+  try {
150
+    const res = await getGeneratedReports({
151
+      pageNum: pagination.page, pageSize: pagination.size, ...filterForm
152
+    })
153
+    tableData.value = res.data?.records || []
154
+    pagination.total = res.data?.total || 0
155
+  } finally { loading.value = false }
156
+}
157
+
158
+function handleSearch() { pagination.page = 1; fetchData() }
159
+function handleReset() {
160
+  filterForm.reportType = ''; filterForm.pushStatus = ''
161
+  filterForm.startTime = ''; filterForm.endTime = ''
162
+  timeRange.value = []
163
+  handleSearch()
164
+}
165
+
166
+function handleTimeChange(val: string[] | null) {
167
+  if (val && val.length === 2) {
168
+    filterForm.startTime = val[0]
169
+    filterForm.endTime = val[1]
170
+  } else {
171
+    filterForm.startTime = ''
172
+    filterForm.endTime = ''
173
+  }
174
+}
175
+
176
+async function handleGenerate(type: string) {
177
+  const label = typeLabel(type)
178
+  await ElMessageBox.confirm(`确定手动生成${label}?`, `生成${label}`)
179
+  try {
180
+    const res = await generateReport(type)
181
+    const count = Array.isArray(res.data) ? res.data.length : 0
182
+    ElMessage.success(`${label}生成完成,共 ${count} 份`)
183
+    fetchData()
184
+  } catch (e) { ElMessage.error('生成失败') }
185
+}
186
+
187
+async function viewDetail(row: any) {
188
+  try {
189
+    const res = await getGeneratedReport(row.id)
190
+    detailItem.value = res.data
191
+    showDetail.value = true
192
+  } catch (e) { ElMessage.error('获取详情失败') }
193
+}
194
+
195
+async function handleRegenerate(row: any) {
196
+  await ElMessageBox.confirm(`确定重新生成「${row.reportTitle}」?`, '确认重新生成')
197
+  try {
198
+    await regenerateReport(row.id)
199
+    ElMessage.success('重新生成成功')
200
+    fetchData()
201
+  } catch (e) { ElMessage.error('重新生成失败') }
202
+}
203
+
204
+async function handlePush(row: any) {
205
+  await ElMessageBox.confirm(`确定推送「${row.reportTitle}」?`, '确认推送')
206
+  try {
207
+    await manualPushReport(row.id)
208
+    ElMessage.success('推送完成')
209
+    fetchData()
210
+  } catch (e) { ElMessage.error('推送失败') }
211
+}
212
+
213
+function viewPushLog(row: any) {
214
+  try {
215
+    const logStr = row.pushLog || '[]'
216
+    pushLogData.value = JSON.parse(logStr)
217
+    showPushLog.value = true
218
+  } catch {
219
+    pushLogData.value = []
220
+    showPushLog.value = true
221
+  }
222
+}
223
+
224
+async function handleDelete(row: any) {
225
+  await ElMessageBox.confirm(`确定删除「${row.reportTitle}」?`, '确认删除', { type: 'warning' })
226
+  await deleteGeneratedReport(row.id)
227
+  ElMessage.success('删除成功')
228
+  fetchData()
229
+}
230
+
231
+function formatPeriod(row: any): string {
232
+  if (!row) return '-'
233
+  const start = row.periodStart || ''
234
+  const end = row.periodEnd || ''
235
+  if (!start && !end) return '-'
236
+  return `${start.substring(0, 10)} ~ ${end.substring(0, 10)}`
237
+}
238
+
239
+const typeMap: Record<string, string> = { daily: '日报', weekly: '周报', monthly: '月报' }
240
+const typeTagMap: Record<string, string> = { daily: 'success', weekly: 'warning', monthly: 'danger' }
241
+function typeLabel(t: string) { return typeMap[t] || t }
242
+function typeTag(t: string) { return (typeTagMap[t] || 'info') as any }
243
+
244
+const pushStatusMap: Record<string, string> = {
245
+  pending: '待推送', pushing: '推送中', success: '成功', partial_fail: '部分失败', fail: '失败'
246
+}
247
+const pushStatusTagMap: Record<string, string> = {
248
+  pending: 'info', pushing: 'warning', success: 'success', partial_fail: 'warning', fail: 'danger'
249
+}
250
+function pushStatusLabel(s: string) { return pushStatusMap[s] || s }
251
+function pushStatusTag(s: string) { return (pushStatusTagMap[s] || 'info') as any }
252
+</script>
253
+
254
+<style scoped>
255
+.report-content {
256
+  border: 1px solid #ddd;
257
+  padding: 16px;
258
+  border-radius: 6px;
259
+  max-height: 500px;
260
+  overflow-y: auto;
261
+  background: #fafafa;
262
+}
263
+</style>

+ 304
- 0
frontend/src/views/bi/ReportTemplateView.vue Zobrazit soubor

@@ -0,0 +1,304 @@
1
+<template>
2
+  <div class="report-template-mgmt">
3
+    <!-- 搜索区 -->
4
+    <el-card shadow="never">
5
+      <el-form :inline="true" :model="filterForm">
6
+        <el-form-item label="报告类型">
7
+          <el-select v-model="filterForm.reportType" placeholder="全部" clearable @change="handleSearch">
8
+            <el-option label="日报" value="daily" />
9
+            <el-option label="周报" value="weekly" />
10
+            <el-option label="月报" value="monthly" />
11
+          </el-select>
12
+        </el-form-item>
13
+        <el-form-item label="状态">
14
+          <el-select v-model="filterForm.status" placeholder="全部" clearable @change="handleSearch">
15
+            <el-option label="启用" value="active" />
16
+            <el-option label="停用" value="inactive" />
17
+          </el-select>
18
+        </el-form-item>
19
+        <el-form-item>
20
+          <el-button type="primary" @click="handleSearch"><el-icon><Search /></el-icon> 查询</el-button>
21
+          <el-button @click="handleReset">重置</el-button>
22
+        </el-form-item>
23
+      </el-form>
24
+    </el-card>
25
+
26
+    <!-- 操作栏 -->
27
+    <div style="margin-top: 16px">
28
+      <el-button type="primary" @click="openCreateDialog"><el-icon><Plus /></el-icon> 创建模板</el-button>
29
+    </div>
30
+
31
+    <!-- 列表 -->
32
+    <el-table :data="tableData" border style="margin-top: 12px" v-loading="loading">
33
+      <el-table-column prop="id" label="ID" width="70" />
34
+      <el-table-column prop="templateName" label="模板名称" min-width="200" show-overflow-tooltip />
35
+      <el-table-column prop="reportType" label="报告类型" width="100">
36
+        <template #default="{ row }">
37
+          <el-tag :type="typeTag(row.reportType)" size="small">{{ typeLabel(row.reportType) }}</el-tag>
38
+        </template>
39
+      </el-table-column>
40
+      <el-table-column prop="status" label="状态" width="90">
41
+        <template #default="{ row }">
42
+          <el-tag :type="row.status === 'active' ? 'success' : 'info'" size="small">
43
+            {{ row.status === 'active' ? '启用' : '停用' }}
44
+          </el-tag>
45
+        </template>
46
+      </el-table-column>
47
+      <el-table-column prop="pushChannels" label="推送方式" width="150">
48
+        <template #default="{ row }">
49
+          <span>{{ formatChannels(row.pushChannels) }}</span>
50
+        </template>
51
+      </el-table-column>
52
+      <el-table-column prop="createdAt" label="创建时间" width="170" />
53
+      <el-table-column label="操作" width="320" fixed="right">
54
+        <template #default="{ row }">
55
+          <el-button link type="primary" @click="openEditDialog(row)">编辑</el-button>
56
+          <el-button link type="info" @click="handlePreview(row)">预览</el-button>
57
+          <el-button link :type="row.status === 'active' ? 'warning' : 'success'"
58
+            @click="handleToggleStatus(row)">
59
+            {{ row.status === 'active' ? '停用' : '启用' }}
60
+          </el-button>
61
+          <el-button link type="success" @click="handleCopy(row)">复制</el-button>
62
+          <el-button link type="danger" @click="handleDelete(row)">删除</el-button>
63
+        </template>
64
+      </el-table-column>
65
+    </el-table>
66
+
67
+    <!-- 分页 -->
68
+    <el-pagination style="margin-top: 16px; justify-content: flex-end"
69
+      v-model:current-page="pagination.page" v-model:page-size="pagination.size"
70
+      :total="pagination.total" :page-sizes="[10, 20, 50]"
71
+      layout="total, sizes, prev, pager, next" @change="fetchData" />
72
+
73
+    <!-- 编辑对话框 -->
74
+    <el-dialog v-model="showEditor" :title="isEdit ? '编辑模板' : '创建模板'" width="800px" destroy-on-close>
75
+      <el-form :model="form" label-width="120px">
76
+        <el-form-item label="模板名称" required>
77
+          <el-input v-model="form.templateName" placeholder="如: 水务运营日报" />
78
+        </el-form-item>
79
+        <el-row :gutter="16">
80
+          <el-col :span="12">
81
+            <el-form-item label="报告类型" required>
82
+              <el-select v-model="form.reportType" style="width: 100%">
83
+                <el-option label="日报" value="daily" />
84
+                <el-option label="周报" value="weekly" />
85
+                <el-option label="月报" value="monthly" />
86
+              </el-select>
87
+            </el-form-item>
88
+          </el-col>
89
+          <el-col :span="12">
90
+            <el-form-item label="状态">
91
+              <el-select v-model="form.status" style="width: 100%">
92
+                <el-option label="启用" value="active" />
93
+                <el-option label="停用" value="inactive" />
94
+              </el-select>
95
+            </el-form-item>
96
+          </el-col>
97
+        </el-row>
98
+        <el-form-item label="模板内容">
99
+          <el-input v-model="form.templateContent" type="textarea" :rows="10"
100
+            placeholder="HTML模板,使用 {{variable}} 插入变量,如 {{waterVolume.totalSupply}}" />
101
+        </el-form-item>
102
+        <el-form-item label="数据源配置">
103
+          <el-input v-model="form.dataSourceConfig" type="textarea" :rows="2"
104
+            placeholder='JSON格式,如: {"modules":["waterVolume","waterQuality","revenue","energy"]}' />
105
+        </el-form-item>
106
+        <el-form-item label="推送渠道">
107
+          <el-checkbox-group v-model="pushChannelList">
108
+            <el-checkbox label="email">邮件</el-checkbox>
109
+            <el-checkbox label="wecom">企微</el-checkbox>
110
+          </el-checkbox-group>
111
+        </el-form-item>
112
+        <el-form-item label="推送目标">
113
+          <el-input v-model="form.pushTargets" type="textarea" :rows="2"
114
+            placeholder='JSON格式,如: {"emails":["admin@example.com"],"webhookUrls":["https://..."]}' />
115
+        </el-form-item>
116
+        <el-form-item label="备注">
117
+          <el-input v-model="form.remark" placeholder="备注说明" />
118
+        </el-form-item>
119
+      </el-form>
120
+      <template #footer>
121
+        <el-button @click="showEditor = false">取消</el-button>
122
+        <el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
123
+      </template>
124
+    </el-dialog>
125
+
126
+    <!-- 预览对话框 -->
127
+    <el-dialog v-model="showPreview" title="模板预览" width="900px" destroy-on-close>
128
+      <el-tabs v-model="previewTab">
129
+        <el-tab-pane label="变量列表" name="variables">
130
+          <el-descriptions :column="1" border>
131
+            <el-descriptions-item v-for="v in previewData?.variables" :key="v" :label="v">
132
+              {{ getSampleValue(v) }}
133
+            </el-descriptions-item>
134
+          </el-descriptions>
135
+        </el-tab-pane>
136
+        <el-tab-pane label="渲染预览" name="rendered">
137
+          <div class="preview-frame" v-html="previewData?.renderedPreview"></div>
138
+        </el-tab-pane>
139
+      </el-tabs>
140
+    </el-dialog>
141
+  </div>
142
+</template>
143
+
144
+<script setup lang="ts">
145
+import { ref, reactive, computed, onMounted } from 'vue'
146
+import { ElMessage, ElMessageBox } from 'element-plus'
147
+import { Search, Plus } from '@element-plus/icons-vue'
148
+import {
149
+  getReportTemplates, createReportTemplate, updateReportTemplate,
150
+  deleteReportTemplate, toggleTemplateStatus, copyReportTemplate, previewTemplate
151
+} from '@/api/reportApi'
152
+
153
+const loading = ref(false)
154
+const saving = ref(false)
155
+const showEditor = ref(false)
156
+const showPreview = ref(false)
157
+const isEdit = ref(false)
158
+const editId = ref<number | null>(null)
159
+const tableData = ref<any[]>([])
160
+const previewData = ref<any>(null)
161
+const previewTab = ref('variables')
162
+
163
+const filterForm = reactive({ reportType: '', status: '' })
164
+const pagination = reactive({ page: 1, size: 10, total: 0 })
165
+
166
+const form = reactive({
167
+  templateName: '', reportType: 'daily', templateContent: '',
168
+  dataSourceConfig: '', pushChannels: '', pushTargets: '',
169
+  status: 'active', remark: ''
170
+})
171
+
172
+const pushChannelList = ref<string[]>([])
173
+
174
+// Sync pushChannelList with form.pushChannels
175
+const syncChannels = () => {
176
+  try {
177
+    pushChannelList.value = JSON.parse(form.pushChannels || '[]')
178
+  } catch { pushChannelList.value = [] }
179
+}
180
+const syncPushChannels = () => {
181
+  form.pushChannels = JSON.stringify(pushChannelList.value)
182
+}
183
+
184
+onMounted(() => fetchData())
185
+
186
+async function fetchData() {
187
+  loading.value = true
188
+  try {
189
+    const res = await getReportTemplates({
190
+      pageNum: pagination.page, pageSize: pagination.size, ...filterForm
191
+    })
192
+    tableData.value = res.data?.records || []
193
+    pagination.total = res.data?.total || 0
194
+  } finally { loading.value = false }
195
+}
196
+
197
+function handleSearch() { pagination.page = 1; fetchData() }
198
+function handleReset() {
199
+  filterForm.reportType = ''; filterForm.status = ''; handleSearch()
200
+}
201
+
202
+function openCreateDialog() {
203
+  isEdit.value = false; editId.value = null
204
+  Object.assign(form, {
205
+    templateName: '', reportType: 'daily', templateContent: '',
206
+    dataSourceConfig: '{"modules":["waterVolume","waterQuality","revenue","energy"]}',
207
+    pushChannels: '["email"]', pushTargets: '{"emails":[],"webhookUrls":[]}',
208
+    status: 'active', remark: ''
209
+  })
210
+  pushChannelList.value = ['email']
211
+  showEditor.value = true
212
+}
213
+
214
+function openEditDialog(row: any) {
215
+  isEdit.value = true; editId.value = row.id
216
+  Object.assign(form, row)
217
+  syncChannels()
218
+  showEditor.value = true
219
+}
220
+
221
+async function handleSave() {
222
+  if (!form.templateName) { ElMessage.warning('请输入模板名称'); return }
223
+  syncPushChannels()
224
+  saving.value = true
225
+  try {
226
+    if (isEdit.value && editId.value) {
227
+      await updateReportTemplate(editId.value, form)
228
+    } else {
229
+      await createReportTemplate(form)
230
+    }
231
+    ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
232
+    showEditor.value = false
233
+    fetchData()
234
+  } finally { saving.value = false }
235
+}
236
+
237
+async function handleToggleStatus(row: any) {
238
+  const newStatus = row.status === 'active' ? 'inactive' : 'active'
239
+  const action = newStatus === 'active' ? '启用' : '停用'
240
+  await ElMessageBox.confirm(`确定${action}「${row.templateName}」?`, `确认${action}`)
241
+  await toggleTemplateStatus(row.id, newStatus)
242
+  ElMessage.success(`${action}成功`)
243
+  fetchData()
244
+}
245
+
246
+async function handleCopy(row: any) {
247
+  await ElMessageBox.confirm(`确定复制「${row.templateName}」?`, '确认复制')
248
+  await copyReportTemplate(row.id)
249
+  ElMessage.success('复制成功')
250
+  fetchData()
251
+}
252
+
253
+async function handleDelete(row: any) {
254
+  await ElMessageBox.confirm(`确定删除「${row.templateName}」?`, '确认删除', { type: 'warning' })
255
+  await deleteReportTemplate(row.id)
256
+  ElMessage.success('删除成功')
257
+  fetchData()
258
+}
259
+
260
+async function handlePreview(row: any) {
261
+  try {
262
+    const res = await previewTemplate(row.id)
263
+    previewData.value = res.data
264
+    previewTab.value = 'variables'
265
+    showPreview.value = true
266
+  } catch (e) { ElMessage.error('预览失败') }
267
+}
268
+
269
+function getSampleValue(varName: string): string {
270
+  const data = previewData.value?.sampleData
271
+  if (!data) return '-'
272
+  const parts = varName.split('.')
273
+  let val: any = data
274
+  for (const p of parts) {
275
+    val = val?.[p]
276
+    if (val === undefined) return '-'
277
+  }
278
+  return String(val)
279
+}
280
+
281
+function formatChannels(channels: string): string {
282
+  try {
283
+    const arr = JSON.parse(channels || '[]')
284
+    const map: Record<string, string> = { email: '邮件', wecom: '企微' }
285
+    return arr.map((c: string) => map[c] || c).join(', ') || '-'
286
+  } catch { return '-' }
287
+}
288
+
289
+const typeMap: Record<string, string> = { daily: '日报', weekly: '周报', monthly: '月报' }
290
+const typeTagMap: Record<string, string> = { daily: 'success', weekly: 'warning', monthly: 'danger' }
291
+function typeLabel(t: string) { return typeMap[t] || t }
292
+function typeTag(t: string) { return (typeTagMap[t] || 'info') as any }
293
+</script>
294
+
295
+<style scoped>
296
+.preview-frame {
297
+  border: 1px solid #ddd;
298
+  padding: 16px;
299
+  border-radius: 6px;
300
+  max-height: 500px;
301
+  overflow-y: auto;
302
+  background: #fafafa;
303
+}
304
+</style>

+ 16
- 1
wm-bi/pom.xml Zobrazit soubor

@@ -5,6 +5,8 @@
5 5
     <modelVersion>4.0.0</modelVersion>
6 6
     <parent><groupId>com.water</groupId><artifactId>wm-parent</artifactId><version>1.0.0-SNAPSHOT</version></parent>
7 7
     <artifactId>wm-bi</artifactId>
8
+    <name>wm-bi</name>
9
+    <description>BI报表与运营报告模块</description>
8 10
     <dependencies>
9 11
         <dependency><groupId>com.water</groupId><artifactId>wm-common</artifactId></dependency>
10 12
         <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
@@ -12,5 +14,18 @@
12 14
         <dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-spring-boot3-starter</artifactId></dependency>
13 15
         <dependency><groupId>cn.dev33</groupId><artifactId>sa-token-spring-boot3-starter</artifactId></dependency>
14 16
         <dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId></dependency>
17
+        <dependency><groupId>cn.hutool</groupId><artifactId>hutool-all</artifactId></dependency>
18
+        <dependency><groupId>com.github.xiaoymin</groupId><artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId></dependency>
19
+        <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-mail</artifactId></dependency>
20
+        <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
21
+        <dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>test</scope></dependency>
15 22
     </dependencies>
16
-</project>
23
+    <build>
24
+        <plugins>
25
+            <plugin>
26
+                <groupId>org.springframework.boot</groupId>
27
+                <artifactId>spring-boot-maven-plugin</artifactId>
28
+            </plugin>
29
+        </plugins>
30
+    </build>
31
+</project>

+ 2
- 0
wm-bi/src/main/java/com/water/bi/BiApplication.java Zobrazit soubor

@@ -2,8 +2,10 @@ package com.water.bi;
2 2
 
3 3
 import org.springframework.boot.SpringApplication;
4 4
 import org.springframework.boot.autoconfigure.SpringBootApplication;
5
+import org.springframework.scheduling.annotation.EnableScheduling;
5 6
 
6 7
 @SpringBootApplication
8
+@EnableScheduling
7 9
 public class BiApplication {
8 10
     public static void main(String[] args) {
9 11
         SpringApplication.run(BiApplication.class, args);

+ 33
- 0
wm-bi/src/main/java/com/water/bi/config/MyBatisPlusConfig.java Zobrazit soubor

@@ -0,0 +1,33 @@
1
+package com.water.bi.config;
2
+
3
+import com.baomidou.mybatisplus.annotation.DbType;
4
+import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
5
+import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
6
+import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
7
+import org.apache.ibatis.reflection.MetaObject;
8
+import org.springframework.context.annotation.Bean;
9
+import org.springframework.context.annotation.Configuration;
10
+
11
+import java.time.LocalDateTime;
12
+
13
+@Configuration
14
+public class MyBatisPlusConfig implements MetaObjectHandler {
15
+
16
+    @Bean
17
+    public MybatisPlusInterceptor mybatisPlusInterceptor() {
18
+        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
19
+        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.POSTGRE_SQL));
20
+        return interceptor;
21
+    }
22
+
23
+    @Override
24
+    public void insertFill(MetaObject metaObject) {
25
+        this.strictInsertFill(metaObject, "createdAt", LocalDateTime::now, LocalDateTime.class);
26
+        this.strictUpdateFill(metaObject, "updatedAt", LocalDateTime::now, LocalDateTime.class);
27
+    }
28
+
29
+    @Override
30
+    public void updateFill(MetaObject metaObject) {
31
+        this.strictUpdateFill(metaObject, "updatedAt", LocalDateTime::now, LocalDateTime.class);
32
+    }
33
+}

+ 23
- 0
wm-bi/src/main/java/com/water/bi/config/RestTemplateConfig.java Zobrazit soubor

@@ -0,0 +1,23 @@
1
+package com.water.bi.config;
2
+
3
+import com.fasterxml.jackson.databind.ObjectMapper;
4
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
5
+import org.springframework.context.annotation.Bean;
6
+import org.springframework.context.annotation.Configuration;
7
+import org.springframework.web.client.RestTemplate;
8
+
9
+@Configuration
10
+public class RestTemplateConfig {
11
+
12
+    @Bean
13
+    public RestTemplate restTemplate() {
14
+        return new RestTemplate();
15
+    }
16
+
17
+    @Bean
18
+    public ObjectMapper objectMapper() {
19
+        ObjectMapper mapper = new ObjectMapper();
20
+        mapper.registerModule(new JavaTimeModule());
21
+        return mapper;
22
+    }
23
+}

+ 69
- 0
wm-bi/src/main/java/com/water/bi/controller/GeneratedReportController.java Zobrazit soubor

@@ -0,0 +1,69 @@
1
+package com.water.bi.controller;
2
+
3
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
4
+import com.water.bi.entity.GeneratedReport;
5
+import com.water.bi.service.ReportGeneratorService;
6
+import com.water.common.core.result.R;
7
+import io.swagger.v3.oas.annotations.Operation;
8
+import io.swagger.v3.oas.annotations.tags.Tag;
9
+import lombok.RequiredArgsConstructor;
10
+import org.springframework.format.annotation.DateTimeFormat;
11
+import org.springframework.web.bind.annotation.*;
12
+
13
+import java.time.LocalDateTime;
14
+
15
+/**
16
+ * 生成报告管理控制器
17
+ */
18
+@Tag(name = "报告管理")
19
+@RestController
20
+@RequestMapping("/api/bi/report")
21
+@RequiredArgsConstructor
22
+public class GeneratedReportController {
23
+
24
+    private final ReportGeneratorService generatorService;
25
+
26
+    @Operation(summary = "分页查询报告")
27
+    @GetMapping("/page")
28
+    public R<Page<GeneratedReport>> page(
29
+            @RequestParam(defaultValue = "1") int pageNum,
30
+            @RequestParam(defaultValue = "10") int pageSize,
31
+            @RequestParam(required = false) String reportType,
32
+            @RequestParam(required = false) String pushStatus,
33
+            @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime,
34
+            @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime) {
35
+        return R.ok(generatorService.page(pageNum, pageSize, reportType, pushStatus, startTime, endTime));
36
+    }
37
+
38
+    @Operation(summary = "获取报告详情")
39
+    @GetMapping("/{id}")
40
+    public R<GeneratedReport> getById(@PathVariable Long id) {
41
+        return R.ok(generatorService.getById(id));
42
+    }
43
+
44
+    @Operation(summary = "删除报告")
45
+    @DeleteMapping("/{id}")
46
+    public R<String> delete(@PathVariable Long id) {
47
+        generatorService.delete(id);
48
+        return R.ok("删除成功");
49
+    }
50
+
51
+    @Operation(summary = "重新生成报告")
52
+    @PostMapping("/{id}/regenerate")
53
+    public R<GeneratedReport> regenerate(@PathVariable Long id) {
54
+        return R.ok(generatorService.regenerate(id));
55
+    }
56
+
57
+    @Operation(summary = "手动推送报告")
58
+    @PostMapping("/{id}/push")
59
+    public R<String> manualPush(@PathVariable Long id) {
60
+        generatorService.manualPush(id);
61
+        return R.ok("推送完成");
62
+    }
63
+
64
+    @Operation(summary = "手动触发生成报告")
65
+    @PostMapping("/generate")
66
+    public R<?> generate(@RequestParam String reportType) {
67
+        return R.ok(generatorService.generateByType(reportType));
68
+    }
69
+}

+ 80
- 0
wm-bi/src/main/java/com/water/bi/controller/ReportTemplateController.java Zobrazit soubor

@@ -0,0 +1,80 @@
1
+package com.water.bi.controller;
2
+
3
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
4
+import com.water.bi.entity.ReportTemplate;
5
+import com.water.bi.service.ReportTemplateService;
6
+import com.water.common.core.result.R;
7
+import io.swagger.v3.oas.annotations.Operation;
8
+import io.swagger.v3.oas.annotations.tags.Tag;
9
+import lombok.RequiredArgsConstructor;
10
+import org.springframework.web.bind.annotation.*;
11
+
12
+import java.util.Map;
13
+
14
+/**
15
+ * 报告模板管理控制器
16
+ */
17
+@Tag(name = "报告模板管理")
18
+@RestController
19
+@RequestMapping("/api/bi/report-template")
20
+@RequiredArgsConstructor
21
+public class ReportTemplateController {
22
+
23
+    private final ReportTemplateService templateService;
24
+
25
+    @Operation(summary = "分页查询模板")
26
+    @GetMapping("/page")
27
+    public R<Page<ReportTemplate>> page(
28
+            @RequestParam(defaultValue = "1") int pageNum,
29
+            @RequestParam(defaultValue = "10") int pageSize,
30
+            @RequestParam(required = false) String reportType,
31
+            @RequestParam(required = false) String status) {
32
+        return R.ok(templateService.page(pageNum, pageSize, reportType, status));
33
+    }
34
+
35
+    @Operation(summary = "获取模板详情")
36
+    @GetMapping("/{id}")
37
+    public R<ReportTemplate> getById(@PathVariable Long id) {
38
+        return R.ok(templateService.getById(id));
39
+    }
40
+
41
+    @Operation(summary = "创建模板")
42
+    @PostMapping
43
+    public R<ReportTemplate> create(@RequestBody ReportTemplate template) {
44
+        return R.ok(templateService.create(template));
45
+    }
46
+
47
+    @Operation(summary = "更新模板")
48
+    @PutMapping("/{id}")
49
+    public R<String> update(@PathVariable Long id, @RequestBody ReportTemplate template) {
50
+        template.setId(id);
51
+        templateService.update(template);
52
+        return R.ok("更新成功");
53
+    }
54
+
55
+    @Operation(summary = "删除模板")
56
+    @DeleteMapping("/{id}")
57
+    public R<String> delete(@PathVariable Long id) {
58
+        templateService.delete(id);
59
+        return R.ok("删除成功");
60
+    }
61
+
62
+    @Operation(summary = "启用/停用模板")
63
+    @PatchMapping("/{id}/status")
64
+    public R<String> toggleStatus(@PathVariable Long id, @RequestParam String status) {
65
+        templateService.toggleStatus(id, status);
66
+        return R.ok("状态变更成功");
67
+    }
68
+
69
+    @Operation(summary = "复制模板")
70
+    @PostMapping("/{id}/copy")
71
+    public R<ReportTemplate> copy(@PathVariable Long id) {
72
+        return R.ok(templateService.copy(id));
73
+    }
74
+
75
+    @Operation(summary = "预览模板变量")
76
+    @GetMapping("/{id}/preview")
77
+    public R<Map<String, Object>> preview(@PathVariable Long id) {
78
+        return R.ok(templateService.previewVariables(id));
79
+    }
80
+}

+ 50
- 0
wm-bi/src/main/java/com/water/bi/entity/GeneratedReport.java Zobrazit soubor

@@ -0,0 +1,50 @@
1
+package com.water.bi.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import com.water.common.core.entity.BaseEntity;
5
+import lombok.Data;
6
+import lombok.EqualsAndHashCode;
7
+
8
+import java.time.LocalDateTime;
9
+
10
+/**
11
+ * 生成报告实体
12
+ */
13
+@Data
14
+@EqualsAndHashCode(callSuper = true)
15
+@TableName("bi_generated_report")
16
+public class GeneratedReport extends BaseEntity {
17
+
18
+    /** 关联模板ID */
19
+    private Long templateId;
20
+
21
+    /** 报告标题 */
22
+    private String reportTitle;
23
+
24
+    /** 报告类型: daily/weekly/monthly */
25
+    private String reportType;
26
+
27
+    /** 报告内容(HTML) */
28
+    private String contentHtml;
29
+
30
+    /** 报告数据(JSON) */
31
+    private String dataJson;
32
+
33
+    /** 报告周期开始 */
34
+    private LocalDateTime periodStart;
35
+
36
+    /** 报告周期结束 */
37
+    private LocalDateTime periodEnd;
38
+
39
+    /** 生成时间 */
40
+    private LocalDateTime generatedAt;
41
+
42
+    /** 推送状态: pending/pushing/success/partial_fail/fail */
43
+    private String pushStatus;
44
+
45
+    /** 推送记录(JSON): [{"channel":"email","target":"xx","status":"success","time":"..."}] */
46
+    private String pushLog;
47
+
48
+    /** 备注 */
49
+    private String remark;
50
+}

+ 39
- 0
wm-bi/src/main/java/com/water/bi/entity/ReportTemplate.java Zobrazit soubor

@@ -0,0 +1,39 @@
1
+package com.water.bi.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import com.water.common.core.entity.BaseEntity;
5
+import lombok.Data;
6
+import lombok.EqualsAndHashCode;
7
+
8
+/**
9
+ * 报告模板实体
10
+ */
11
+@Data
12
+@EqualsAndHashCode(callSuper = true)
13
+@TableName("bi_report_template")
14
+public class ReportTemplate extends BaseEntity {
15
+
16
+    /** 模板名称 */
17
+    private String templateName;
18
+
19
+    /** 报告类型: daily/weekly/monthly */
20
+    private String reportType;
21
+
22
+    /** 模板内容(HTML模板,支持{{variable}}变量) */
23
+    private String templateContent;
24
+
25
+    /** 数据源配置(JSON): 水量/水质/营收/能耗 */
26
+    private String dataSourceConfig;
27
+
28
+    /** 推送方式(JSON数组): ["email","wecom"] */
29
+    private String pushChannels;
30
+
31
+    /** 推送目标(JSON): {"emails":[],"webhookUrls":[]} */
32
+    private String pushTargets;
33
+
34
+    /** 状态: active/inactive */
35
+    private String status;
36
+
37
+    /** 备注 */
38
+    private String remark;
39
+}

+ 12
- 0
wm-bi/src/main/java/com/water/bi/mapper/GeneratedReportMapper.java Zobrazit soubor

@@ -0,0 +1,12 @@
1
+package com.water.bi.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.bi.entity.GeneratedReport;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+/**
8
+ * 生成报告Mapper
9
+ */
10
+@Mapper
11
+public interface GeneratedReportMapper extends BaseMapper<GeneratedReport> {
12
+}

+ 12
- 0
wm-bi/src/main/java/com/water/bi/mapper/ReportTemplateMapper.java Zobrazit soubor

@@ -0,0 +1,12 @@
1
+package com.water.bi.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.bi.entity.ReportTemplate;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+/**
8
+ * 报告模板Mapper
9
+ */
10
+@Mapper
11
+public interface ReportTemplateMapper extends BaseMapper<ReportTemplate> {
12
+}

+ 66
- 0
wm-bi/src/main/java/com/water/bi/scheduler/ReportScheduler.java Zobrazit soubor

@@ -0,0 +1,66 @@
1
+package com.water.bi.scheduler;
2
+
3
+import com.water.bi.entity.GeneratedReport;
4
+import com.water.bi.service.ReportGeneratorService;
5
+import lombok.RequiredArgsConstructor;
6
+import lombok.extern.slf4j.Slf4j;
7
+import org.springframework.scheduling.annotation.Scheduled;
8
+import org.springframework.stereotype.Component;
9
+
10
+import java.util.List;
11
+
12
+/**
13
+ * 报告定时调度器
14
+ * 日报:每天 08:00
15
+ * 周报:每周一 08:00
16
+ * 月报:每月1号 08:00
17
+ */
18
+@Slf4j
19
+@Component
20
+@RequiredArgsConstructor
21
+public class ReportScheduler {
22
+
23
+    private final ReportGeneratorService generatorService;
24
+
25
+    /**
26
+     * 日报 - 每天08:00生成
27
+     */
28
+    @Scheduled(cron = "0 0 8 * * ?")
29
+    public void generateDailyReport() {
30
+        log.info("========== 定时任务:生成日报 ==========");
31
+        try {
32
+            List<GeneratedReport> reports = generatorService.generateByType("daily");
33
+            log.info("日报生成完成,共生成 {} 份报告", reports.size());
34
+        } catch (Exception e) {
35
+            log.error("日报生成失败: {}", e.getMessage(), e);
36
+        }
37
+    }
38
+
39
+    /**
40
+     * 周报 - 每周一08:00生成
41
+     */
42
+    @Scheduled(cron = "0 0 8 ? * MON")
43
+    public void generateWeeklyReport() {
44
+        log.info("========== 定时任务:生成周报 ==========");
45
+        try {
46
+            List<GeneratedReport> reports = generatorService.generateByType("weekly");
47
+            log.info("周报生成完成,共生成 {} 份报告", reports.size());
48
+        } catch (Exception e) {
49
+            log.error("周报生成失败: {}", e.getMessage(), e);
50
+        }
51
+    }
52
+
53
+    /**
54
+     * 月报 - 每月1号08:00生成
55
+     */
56
+    @Scheduled(cron = "0 0 8 1 * ?")
57
+    public void generateMonthlyReport() {
58
+        log.info("========== 定时任务:生成月报 ==========");
59
+        try {
60
+            List<GeneratedReport> reports = generatorService.generateByType("monthly");
61
+            log.info("月报生成完成,共生成 {} 份报告", reports.size());
62
+        } catch (Exception e) {
63
+            log.error("月报生成失败: {}", e.getMessage(), e);
64
+        }
65
+    }
66
+}

+ 476
- 0
wm-bi/src/main/java/com/water/bi/service/ReportGeneratorService.java Zobrazit soubor

@@ -0,0 +1,476 @@
1
+package com.water.bi.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
5
+import com.fasterxml.jackson.core.type.TypeReference;
6
+import com.fasterxml.jackson.databind.ObjectMapper;
7
+import com.water.bi.entity.GeneratedReport;
8
+import com.water.bi.entity.ReportTemplate;
9
+import com.water.bi.mapper.GeneratedReportMapper;
10
+import lombok.RequiredArgsConstructor;
11
+import lombok.extern.slf4j.Slf4j;
12
+import org.springframework.http.*;
13
+import org.springframework.mail.SimpleMailMessage;
14
+import org.springframework.mail.javamail.JavaMailSender;
15
+import org.springframework.stereotype.Service;
16
+import org.springframework.web.client.RestTemplate;
17
+
18
+import java.time.LocalDate;
19
+import java.time.LocalDateTime;
20
+import java.time.LocalTime;
21
+import java.time.format.DateTimeFormatter;
22
+import java.util.*;
23
+
24
+/**
25
+ * 报告生成服务
26
+ */
27
+@Slf4j
28
+@Service
29
+@RequiredArgsConstructor
30
+public class ReportGeneratorService {
31
+
32
+    private final GeneratedReportMapper reportMapper;
33
+    private final ReportTemplateService templateService;
34
+    private final JavaMailSender mailSender;
35
+    private final RestTemplate restTemplate;
36
+    private final ObjectMapper objectMapper;
37
+
38
+    /**
39
+     * 按报告类型触发定时生成
40
+     */
41
+    public List<GeneratedReport> generateByType(String reportType) {
42
+        List<ReportTemplate> templates = templateService.listActiveByType(reportType);
43
+        if (templates.isEmpty()) {
44
+            log.info("没有启用的 {} 类型模板,跳过生成", reportType);
45
+            return Collections.emptyList();
46
+        }
47
+
48
+        List<GeneratedReport> reports = new ArrayList<>();
49
+        for (ReportTemplate template : templates) {
50
+            try {
51
+                GeneratedReport report = generateFromTemplate(template);
52
+                reports.add(report);
53
+            } catch (Exception e) {
54
+                log.error("从模板 #{} 生成报告失败: {}", template.getId(), e.getMessage(), e);
55
+            }
56
+        }
57
+        return reports;
58
+    }
59
+
60
+    /**
61
+     * 从模板生成报告
62
+     */
63
+    public GeneratedReport generateFromTemplate(ReportTemplate template) {
64
+        log.info("开始从模板 #{} ({}) 生成报告", template.getId(), template.getTemplateName());
65
+
66
+        // 1. 数据采集聚合
67
+        Map<String, Object> reportData = collectData(template);
68
+
69
+        // 2. 计算报告周期
70
+        LocalDateTime[] period = calculatePeriod(template.getReportType());
71
+
72
+        // 3. 模板渲染生成HTML
73
+        String htmlContent = templateService.renderTemplate(template.getTemplateContent(), reportData);
74
+
75
+        // 4. 生成报告标题
76
+        String title = generateTitle(template);
77
+
78
+        // 5. 存储报告
79
+        GeneratedReport report = new GeneratedReport();
80
+        report.setTemplateId(template.getId());
81
+        report.setReportTitle(title);
82
+        report.setReportType(template.getReportType());
83
+        report.setContentHtml(htmlContent);
84
+        report.setPeriodStart(period[0]);
85
+        report.setPeriodEnd(period[1]);
86
+        report.setGeneratedAt(LocalDateTime.now());
87
+        report.setPushStatus("pending");
88
+
89
+        try {
90
+            report.setDataJson(objectMapper.writeValueAsString(reportData));
91
+        } catch (Exception e) {
92
+            log.error("序列化报告数据失败", e);
93
+            report.setDataJson("{}");
94
+        }
95
+
96
+        reportMapper.insert(report);
97
+        log.info("报告 #{} 生成成功: {}", report.getId(), title);
98
+
99
+        // 6. 推送调度
100
+        dispatchPush(report, template);
101
+
102
+        return report;
103
+    }
104
+
105
+    /**
106
+     * 重新生成报告
107
+     */
108
+    public GeneratedReport regenerate(Long reportId) {
109
+        GeneratedReport existing = reportMapper.selectById(reportId);
110
+        if (existing == null) {
111
+            throw new RuntimeException("报告不存在");
112
+        }
113
+        ReportTemplate template = templateService.getById(existing.getTemplateId());
114
+        if (template == null) {
115
+            throw new RuntimeException("关联模板不存在");
116
+        }
117
+        return generateFromTemplate(template);
118
+    }
119
+
120
+    /**
121
+     * 分页查询报告
122
+     */
123
+    public Page<GeneratedReport> page(int pageNum, int pageSize, String reportType,
124
+                                       String pushStatus, LocalDateTime startTime, LocalDateTime endTime) {
125
+        LambdaQueryWrapper<GeneratedReport> wrapper = new LambdaQueryWrapper<>();
126
+        if (reportType != null && !reportType.isEmpty()) {
127
+            wrapper.eq(GeneratedReport::getReportType, reportType);
128
+        }
129
+        if (pushStatus != null && !pushStatus.isEmpty()) {
130
+            wrapper.eq(GeneratedReport::getPushStatus, pushStatus);
131
+        }
132
+        if (startTime != null) {
133
+            wrapper.ge(GeneratedReport::getGeneratedAt, startTime);
134
+        }
135
+        if (endTime != null) {
136
+            wrapper.le(GeneratedReport::getGeneratedAt, endTime);
137
+        }
138
+        wrapper.orderByDesc(GeneratedReport::getGeneratedAt);
139
+        return reportMapper.selectPage(new Page<>(pageNum, pageSize), wrapper);
140
+    }
141
+
142
+    /**
143
+     * 获取报告详情
144
+     */
145
+    public GeneratedReport getById(Long id) {
146
+        return reportMapper.selectById(id);
147
+    }
148
+
149
+    /**
150
+     * 删除报告
151
+     */
152
+    public void delete(Long id) {
153
+        reportMapper.deleteById(id);
154
+    }
155
+
156
+    /**
157
+     * 手动推送报告
158
+     */
159
+    public void manualPush(Long reportId) {
160
+        GeneratedReport report = reportMapper.selectById(reportId);
161
+        if (report == null) {
162
+            throw new RuntimeException("报告不存在");
163
+        }
164
+        ReportTemplate template = templateService.getById(report.getTemplateId());
165
+        if (template == null) {
166
+            throw new RuntimeException("关联模板不存在");
167
+        }
168
+        dispatchPush(report, template);
169
+    }
170
+
171
+    // ==================== 内部方法 ====================
172
+
173
+    /**
174
+     * 数据采集聚合 - 模拟从各模块采集数据
175
+     */
176
+    private Map<String, Object> collectData(ReportTemplate template) {
177
+        Map<String, Object> data = new LinkedHashMap<>();
178
+        LocalDate today = LocalDate.now();
179
+
180
+        data.put("reportTitle", generateTitle(template));
181
+        data.put("reportDate", today.format(DateTimeFormatter.ISO_LOCAL_DATE));
182
+        data.put("reportPeriod", formatPeriod(template.getReportType()));
183
+
184
+        // 水量数据 - 模拟采集
185
+        Map<String, Object> waterVolume = new LinkedHashMap<>();
186
+        Random rand = new Random();
187
+        double totalSupply = 10000 + rand.nextDouble() * 5000;
188
+        double lossRate = 15 + rand.nextDouble() * 10;
189
+        double totalSales = totalSupply * (1 - lossRate / 100);
190
+        waterVolume.put("totalSupply", String.format("%.0f m³", totalSupply));
191
+        waterVolume.put("totalSales", String.format("%.0f m³", totalSales));
192
+        waterVolume.put("lossRate", String.format("%.1f%%", lossRate));
193
+        waterVolume.put("peakHour", "10:00-12:00");
194
+        waterVolume.put("avgPressure", String.format("%.2f MPa", 0.3 + rand.nextDouble() * 0.1));
195
+        data.put("waterVolume", waterVolume);
196
+
197
+        // 水质数据
198
+        Map<String, Object> waterQuality = new LinkedHashMap<>();
199
+        waterQuality.put("turbidityAvg", String.format("%.1f NTU", 0.5 + rand.nextDouble() * 0.8));
200
+        waterQuality.put("residualChlorine", String.format("%.2f mg/L", 0.3 + rand.nextDouble() * 0.4));
201
+        waterQuality.put("phValue", String.format("%.1f", 6.8 + rand.nextDouble() * 0.8));
202
+        waterQuality.put("qualifiedRate", String.format("%.1f%%", 98.5 + rand.nextDouble() * 1.5));
203
+        waterQuality.put("sampleCount", String.valueOf(20 + rand.nextInt(10)));
204
+        data.put("waterQuality", waterQuality);
205
+
206
+        // 营收数据
207
+        Map<String, Object> revenue = new LinkedHashMap<>();
208
+        double dailyRev = 70000 + rand.nextDouble() * 30000;
209
+        revenue.put("dailyRevenue", String.format("¥%.0f", dailyRev));
210
+        revenue.put("monthlyCumulative", String.format("¥%.0f", dailyRev * today.getDayOfMonth()));
211
+        revenue.put("collectionRate", String.format("%.1f%%", 94 + rand.nextDouble() * 5));
212
+        revenue.put("newUsers", String.valueOf(rand.nextInt(20) + 5));
213
+        revenue.put("totalUsers", String.valueOf(28000 + rand.nextInt(1000)));
214
+        data.put("revenue", revenue);
215
+
216
+        // 能耗数据
217
+        Map<String, Object> energy = new LinkedHashMap<>();
218
+        double totalPower = 2800 + rand.nextDouble() * 800;
219
+        energy.put("totalPower", String.format("%.0f kWh", totalPower));
220
+        energy.put("unitConsumption", String.format("%.3f kWh/m³", totalPower / totalSupply));
221
+        energy.put("pumpEfficiency", String.format("%.1f%%", 72 + rand.nextDouble() * 12));
222
+        energy.put("costPerM3", String.format("¥%.2f", totalPower * 0.65 / totalSupply));
223
+        energy.put("solarGeneration", String.format("%.0f kWh", 300 + rand.nextDouble() * 300));
224
+        data.put("energy", energy);
225
+
226
+        return data;
227
+    }
228
+
229
+    /**
230
+     * 计算报告周期
231
+     */
232
+    private LocalDateTime[] calculatePeriod(String reportType) {
233
+        LocalDate today = LocalDate.now();
234
+        return switch (reportType) {
235
+            case "daily" -> new LocalDateTime[]{
236
+                today.minusDays(1).atStartOfDay(),
237
+                today.minusDays(1).atTime(LocalTime.MAX)
238
+            };
239
+            case "weekly" -> new LocalDateTime[]{
240
+                today.minusWeeks(1).with(java.time.DayOfWeek.MONDAY).atStartOfDay(),
241
+                today.minusWeeks(1).with(java.time.DayOfWeek.SUNDAY).atTime(LocalTime.MAX)
242
+            };
243
+            case "monthly" -> new LocalDateTime[]{
244
+                today.minusMonths(1).withDayOfMonth(1).atStartOfDay(),
245
+                today.minusMonths(1).withDayOfMonth(today.minusMonths(1).lengthOfMonth()).atTime(LocalTime.MAX)
246
+            };
247
+            default -> new LocalDateTime[]{today.atStartOfDay(), today.atTime(LocalTime.MAX)};
248
+        };
249
+    }
250
+
251
+    /**
252
+     * 格式化报告周期显示
253
+     */
254
+    private String formatPeriod(String reportType) {
255
+        LocalDate today = LocalDate.now();
256
+        DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd");
257
+        return switch (reportType) {
258
+            case "daily" -> today.minusDays(1).format(fmt) + " 00:00 ~ 23:59";
259
+            case "weekly" -> {
260
+                LocalDate weekStart = today.minusWeeks(1).with(java.time.DayOfWeek.MONDAY);
261
+                LocalDate weekEnd = today.minusWeeks(1).with(java.time.DayOfWeek.SUNDAY);
262
+                yield weekStart.format(fmt) + " ~ " + weekEnd.format(fmt);
263
+            }
264
+            case "monthly" -> {
265
+                LocalDate monthStart = today.minusMonths(1).withDayOfMonth(1);
266
+                LocalDate monthEnd = today.minusMonths(1).withDayOfMonth(today.minusMonths(1).lengthOfMonth());
267
+                yield monthStart.format(fmt) + " ~ " + monthEnd.format(fmt);
268
+            }
269
+            default -> today.format(fmt);
270
+        };
271
+    }
272
+
273
+    /**
274
+     * 生成报告标题
275
+     */
276
+    private String generateTitle(ReportTemplate template) {
277
+        LocalDate today = LocalDate.now();
278
+        String typeLabel = switch (template.getReportType()) {
279
+            case "daily" -> "日报";
280
+            case "weekly" -> "周报";
281
+            case "monthly" -> "月报";
282
+            default -> "报告";
283
+        };
284
+        return template.getTemplateName() + " - " + typeLabel + " " + today.format(DateTimeFormatter.ISO_LOCAL_DATE);
285
+    }
286
+
287
+    /**
288
+     * 推送调度
289
+     */
290
+    private void dispatchPush(GeneratedReport report, ReportTemplate template) {
291
+        try {
292
+            report.setPushStatus("pushing");
293
+            reportMapper.updateById(report);
294
+
295
+            List<String> pushLog = new ArrayList<>();
296
+
297
+            // 解析推送渠道
298
+            List<String> channels = parseChannels(template.getPushChannels());
299
+            Map<String, Object> targets = parseTargets(template.getPushTargets());
300
+
301
+            for (String channel : channels) {
302
+                try {
303
+                    switch (channel) {
304
+                        case "email" -> pushEmail(report, targets, pushLog);
305
+                        case "wecom" -> pushWecom(report, targets, pushLog);
306
+                        default -> log.warn("未知推送渠道: {}", channel);
307
+                    }
308
+                } catch (Exception e) {
309
+                    log.error("推送渠道 {} 失败: {}", channel, e.getMessage());
310
+                    pushLog.add(String.format("{\"channel\":\"%s\",\"status\":\"fail\",\"error\":\"%s\",\"time\":\"%s\"}",
311
+                            channel, e.getMessage(), LocalDateTime.now()));
312
+                }
313
+            }
314
+
315
+            // 更新推送状态
316
+            report.setPushLog("[" + String.join(",", pushLog) + "]");
317
+            boolean allSuccess = pushLog.stream().allMatch(l -> l.contains("\"status\":\"success\""));
318
+            boolean anySuccess = pushLog.stream().anyMatch(l -> l.contains("\"status\":\"success\""));
319
+            if (pushLog.isEmpty()) {
320
+                report.setPushStatus("pending");
321
+            } else if (allSuccess) {
322
+                report.setPushStatus("success");
323
+            } else if (anySuccess) {
324
+                report.setPushStatus("partial_fail");
325
+            } else {
326
+                report.setPushStatus("fail");
327
+            }
328
+            reportMapper.updateById(report);
329
+
330
+        } catch (Exception e) {
331
+            log.error("推送调度失败: {}", e.getMessage(), e);
332
+            report.setPushStatus("fail");
333
+            report.setPushLog("[{\"error\":\"" + e.getMessage() + "\"}]");
334
+            reportMapper.updateById(report);
335
+        }
336
+    }
337
+
338
+    /**
339
+     * 邮件推送
340
+     */
341
+    private void pushEmail(GeneratedReport report, Map<String, Object> targets, List<String> pushLog) {
342
+        @SuppressWarnings("unchecked")
343
+        List<String> emails = (List<String>) targets.getOrDefault("emails", Collections.emptyList());
344
+        if (emails.isEmpty()) {
345
+            pushLog.add("{\"channel\":\"email\",\"status\":\"skip\",\"reason\":\"no_targets\"}");
346
+            return;
347
+        }
348
+
349
+        for (String email : emails) {
350
+            try {
351
+                SimpleMailMessage message = new SimpleMailMessage();
352
+                message.setFrom("water-system@xayunmei.com");
353
+                message.setTo(email);
354
+                message.setSubject(report.getReportTitle());
355
+                message.setText("报告内容请查看附件或系统页面。\n\n报告摘要:\n" + extractSummary(report));
356
+                mailSender.send(message);
357
+                pushLog.add(String.format("{\"channel\":\"email\",\"target\":\"%s\",\"status\":\"success\",\"time\":\"%s\"}",
358
+                        email, LocalDateTime.now()));
359
+                log.info("邮件推送成功: {}", email);
360
+            } catch (Exception e) {
361
+                pushLog.add(String.format("{\"channel\":\"email\",\"target\":\"%s\",\"status\":\"fail\",\"error\":\"%s\"}",
362
+                        email, e.getMessage()));
363
+                log.error("邮件推送失败 {}: {}", email, e.getMessage());
364
+            }
365
+        }
366
+    }
367
+
368
+    /**
369
+     * 企微webhook推送
370
+     */
371
+    private void pushWecom(GeneratedReport report, Map<String, Object> targets, List<String> pushLog) {
372
+        @SuppressWarnings("unchecked")
373
+        List<String> webhooks = (List<String>) targets.getOrDefault("webhookUrls", Collections.emptyList());
374
+        if (webhooks.isEmpty()) {
375
+            pushLog.add("{\"channel\":\"wecom\",\"status\":\"skip\",\"reason\":\"no_targets\"}");
376
+            return;
377
+        }
378
+
379
+        for (String webhookUrl : webhooks) {
380
+            try {
381
+                Map<String, Object> payload = new LinkedHashMap<>();
382
+                payload.put("msgtype", "markdown");
383
+                Map<String, String> markdown = new LinkedHashMap<>();
384
+                markdown.put("content", buildWecomMarkdown(report));
385
+                payload.put("markdown", markdown);
386
+
387
+                HttpHeaders headers = new HttpHeaders();
388
+                headers.setContentType(MediaType.APPLICATION_JSON);
389
+                HttpEntity<String> entity = new HttpEntity<>(objectMapper.writeValueAsString(payload), headers);
390
+                ResponseEntity<String> response = restTemplate.exchange(webhookUrl, HttpMethod.POST, entity, String.class);
391
+
392
+                if (response.getStatusCode().is2xxSuccessful()) {
393
+                    pushLog.add(String.format("{\"channel\":\"wecom\",\"target\":\"%s\",\"status\":\"success\",\"time\":\"%s\"}",
394
+                            webhookUrl, LocalDateTime.now()));
395
+                    log.info("企微推送成功: {}", webhookUrl);
396
+                } else {
397
+                    pushLog.add(String.format("{\"channel\":\"wecom\",\"target\":\"%s\",\"status\":\"fail\",\"httpStatus\":%d}",
398
+                            webhookUrl, response.getStatusCode().value()));
399
+                }
400
+            } catch (Exception e) {
401
+                pushLog.add(String.format("{\"channel\":\"wecom\",\"target\":\"%s\",\"status\":\"fail\",\"error\":\"%s\"}",
402
+                        webhookUrl, e.getMessage()));
403
+                log.error("企微推送失败 {}: {}", webhookUrl, e.getMessage());
404
+            }
405
+        }
406
+    }
407
+
408
+    /**
409
+     * 构建企微Markdown消息
410
+     */
411
+    private String buildWecomMarkdown(GeneratedReport report) {
412
+        StringBuilder sb = new StringBuilder();
413
+        sb.append("## ").append(report.getReportTitle()).append("\n\n");
414
+        sb.append("> 报告周期: ").append(report.getPeriodStart()).append(" ~ ").append(report.getPeriodEnd()).append("\n\n");
415
+        sb.append(extractSummary(report));
416
+        sb.append("\n\n[查看详情](/bi/report/").append(report.getId()).append(")");
417
+        return sb.toString();
418
+    }
419
+
420
+    /**
421
+     * 提取报告摘要
422
+     */
423
+    private String extractSummary(GeneratedReport report) {
424
+        try {
425
+            Map<String, Object> data = objectMapper.readValue(report.getDataJson(),
426
+                    new TypeReference<Map<String, Object>>() {});
427
+            StringBuilder sb = new StringBuilder();
428
+
429
+            appendMetric(sb, data, "waterVolume", "totalSupply", "供水量");
430
+            appendMetric(sb, data, "waterQuality", "qualifiedRate", "水质合格率");
431
+            appendMetric(sb, data, "revenue", "dailyRevenue", "营收");
432
+            appendMetric(sb, data, "energy", "unitConsumption", "单位能耗");
433
+
434
+            return sb.toString();
435
+        } catch (Exception e) {
436
+            return "报告数据解析失败";
437
+        }
438
+    }
439
+
440
+    @SuppressWarnings("unchecked")
441
+    private void appendMetric(StringBuilder sb, Map<String, Object> data, String category, String key, String label) {
442
+        Object cat = data.get(category);
443
+        if (cat instanceof Map) {
444
+            Object value = ((Map<String, Object>) cat).get(key);
445
+            if (value != null) {
446
+                sb.append("- ").append(label).append(": ").append(value).append("\n");
447
+            }
448
+        }
449
+    }
450
+
451
+    /**
452
+     * 解析推送渠道
453
+     */
454
+    private List<String> parseChannels(String pushChannels) {
455
+        if (pushChannels == null || pushChannels.isEmpty()) return Collections.emptyList();
456
+        try {
457
+            return objectMapper.readValue(pushChannels, new TypeReference<List<String>>() {});
458
+        } catch (Exception e) {
459
+            log.error("解析推送渠道失败: {}", e.getMessage());
460
+            return Collections.emptyList();
461
+        }
462
+    }
463
+
464
+    /**
465
+     * 解析推送目标
466
+     */
467
+    private Map<String, Object> parseTargets(String pushTargets) {
468
+        if (pushTargets == null || pushTargets.isEmpty()) return Collections.emptyMap();
469
+        try {
470
+            return objectMapper.readValue(pushTargets, new TypeReference<Map<String, Object>>() {});
471
+        } catch (Exception e) {
472
+            log.error("解析推送目标失败: {}", e.getMessage());
473
+            return Collections.emptyMap();
474
+        }
475
+    }
476
+}

+ 238
- 0
wm-bi/src/main/java/com/water/bi/service/ReportTemplateService.java Zobrazit soubor

@@ -0,0 +1,238 @@
1
+package com.water.bi.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
5
+import com.water.bi.entity.ReportTemplate;
6
+import com.water.bi.mapper.ReportTemplateMapper;
7
+import lombok.RequiredArgsConstructor;
8
+import lombok.extern.slf4j.Slf4j;
9
+import org.springframework.stereotype.Service;
10
+
11
+import java.util.*;
12
+
13
+/**
14
+ * 报告模板服务
15
+ */
16
+@Slf4j
17
+@Service
18
+@RequiredArgsConstructor
19
+public class ReportTemplateService {
20
+
21
+    private final ReportTemplateMapper templateMapper;
22
+
23
+    /**
24
+     * 分页查询模板
25
+     */
26
+    public Page<ReportTemplate> page(int pageNum, int pageSize, String reportType, String status) {
27
+        LambdaQueryWrapper<ReportTemplate> wrapper = new LambdaQueryWrapper<>();
28
+        if (reportType != null && !reportType.isEmpty()) {
29
+            wrapper.eq(ReportTemplate::getReportType, reportType);
30
+        }
31
+        if (status != null && !status.isEmpty()) {
32
+            wrapper.eq(ReportTemplate::getStatus, status);
33
+        }
34
+        wrapper.orderByDesc(ReportTemplate::getCreatedAt);
35
+        return templateMapper.selectPage(new Page<>(pageNum, pageSize), wrapper);
36
+    }
37
+
38
+    /**
39
+     * 获取模板详情
40
+     */
41
+    public ReportTemplate getById(Long id) {
42
+        return templateMapper.selectById(id);
43
+    }
44
+
45
+    /**
46
+     * 创建模板
47
+     */
48
+    public ReportTemplate create(ReportTemplate template) {
49
+        if (template.getStatus() == null) {
50
+            template.setStatus("active");
51
+        }
52
+        templateMapper.insert(template);
53
+        return template;
54
+    }
55
+
56
+    /**
57
+     * 更新模板
58
+     */
59
+    public void update(ReportTemplate template) {
60
+        templateMapper.updateById(template);
61
+    }
62
+
63
+    /**
64
+     * 删除模板
65
+     */
66
+    public void delete(Long id) {
67
+        templateMapper.deleteById(id);
68
+    }
69
+
70
+    /**
71
+     * 启用/停用模板
72
+     */
73
+    public void toggleStatus(Long id, String status) {
74
+        ReportTemplate template = templateMapper.selectById(id);
75
+        if (template == null) {
76
+            throw new RuntimeException("模板不存在");
77
+        }
78
+        template.setStatus(status);
79
+        templateMapper.updateById(template);
80
+        log.info("模板 {} 状态变更为: {}", id, status);
81
+    }
82
+
83
+    /**
84
+     * 复制模板
85
+     */
86
+    public ReportTemplate copy(Long id) {
87
+        ReportTemplate source = templateMapper.selectById(id);
88
+        if (source == null) {
89
+            throw new RuntimeException("模板不存在");
90
+        }
91
+        ReportTemplate copy = new ReportTemplate();
92
+        copy.setTemplateName(source.getTemplateName() + " (副本)");
93
+        copy.setReportType(source.getReportType());
94
+        copy.setTemplateContent(source.getTemplateContent());
95
+        copy.setDataSourceConfig(source.getDataSourceConfig());
96
+        copy.setPushChannels(source.getPushChannels());
97
+        copy.setPushTargets(source.getPushTargets());
98
+        copy.setStatus("inactive");
99
+        copy.setRemark("从模板 #" + id + " 复制");
100
+        templateMapper.insert(copy);
101
+        return copy;
102
+    }
103
+
104
+    /**
105
+     * 预览模板变量 - 返回模板中的变量列表及示例值
106
+     */
107
+    public Map<String, Object> previewVariables(Long id) {
108
+        ReportTemplate template = templateMapper.selectById(id);
109
+        if (template == null) {
110
+            throw new RuntimeException("模板不存在");
111
+        }
112
+
113
+        // 解析模板中的 {{variable}} 变量
114
+        String content = template.getTemplateContent();
115
+        Set<String> variables = new LinkedHashSet<>();
116
+        if (content != null) {
117
+            int idx = 0;
118
+            while (idx < content.length()) {
119
+                int start = content.indexOf("{{", idx);
120
+                if (start < 0) break;
121
+                int end = content.indexOf("}}", start + 2);
122
+                if (end < 0) break;
123
+                String varName = content.substring(start + 2, end).trim();
124
+                if (!varName.isEmpty()) {
125
+                    variables.add(varName);
126
+                }
127
+                idx = end + 2;
128
+            }
129
+        }
130
+
131
+        // 生成示例数据
132
+        Map<String, Object> sampleData = generateSampleData();
133
+        Map<String, Object> result = new LinkedHashMap<>();
134
+        result.put("variables", variables);
135
+        result.put("sampleData", sampleData);
136
+        result.put("renderedPreview", renderTemplate(content, sampleData));
137
+        return result;
138
+    }
139
+
140
+    /**
141
+     * 获取所有启用的模板
142
+     */
143
+    public List<ReportTemplate> listActive() {
144
+        LambdaQueryWrapper<ReportTemplate> wrapper = new LambdaQueryWrapper<>();
145
+        wrapper.eq(ReportTemplate::getStatus, "active");
146
+        return templateMapper.selectList(wrapper);
147
+    }
148
+
149
+    /**
150
+     * 按类型获取启用模板
151
+     */
152
+    public List<ReportTemplate> listActiveByType(String reportType) {
153
+        LambdaQueryWrapper<ReportTemplate> wrapper = new LambdaQueryWrapper<>();
154
+        wrapper.eq(ReportTemplate::getStatus, "active");
155
+        wrapper.eq(ReportTemplate::getReportType, reportType);
156
+        return templateMapper.selectList(wrapper);
157
+    }
158
+
159
+    /**
160
+     * 生成示例数据(用于预览)
161
+     */
162
+    public Map<String, Object> generateSampleData() {
163
+        Map<String, Object> data = new LinkedHashMap<>();
164
+        data.put("reportTitle", "运营日报示例");
165
+        data.put("reportDate", "2026-06-14");
166
+        data.put("reportPeriod", "2026-06-14 00:00 ~ 23:59");
167
+
168
+        // 水量数据
169
+        Map<String, Object> waterVolume = new LinkedHashMap<>();
170
+        waterVolume.put("totalSupply", "12580 m³");
171
+        waterVolume.put("totalSales", "10250 m³");
172
+        waterVolume.put("lossRate", "18.5%");
173
+        waterVolume.put("peakHour", "10:00-12:00");
174
+        waterVolume.put("avgPressure", "0.35 MPa");
175
+        data.put("waterVolume", waterVolume);
176
+
177
+        // 水质数据
178
+        Map<String, Object> waterQuality = new LinkedHashMap<>();
179
+        waterQuality.put("turbidityAvg", "0.8 NTU");
180
+        waterQuality.put("residualChlorine", "0.5 mg/L");
181
+        waterQuality.put("phValue", "7.2");
182
+        waterQuality.put("qualifiedRate", "99.8%");
183
+        waterQuality.put("sampleCount", "24");
184
+        data.put("waterQuality", waterQuality);
185
+
186
+        // 营收数据
187
+        Map<String, Object> revenue = new LinkedHashMap<>();
188
+        revenue.put("dailyRevenue", "¥85,600");
189
+        revenue.put("monthlyCumulative", "¥1,125,800");
190
+        revenue.put("collectionRate", "96.5%");
191
+        revenue.put("newUsers", "15");
192
+        revenue.put("totalUsers", "28,560");
193
+        data.put("revenue", revenue);
194
+
195
+        // 能耗数据
196
+        Map<String, Object> energy = new LinkedHashMap<>();
197
+        energy.put("totalPower", "3,250 kWh");
198
+        energy.put("unitConsumption", "0.258 kWh/m³");
199
+        energy.put("pumpEfficiency", "78.5%");
200
+        energy.put("costPerM3", "¥0.18");
201
+        energy.put("solarGeneration", "450 kWh");
202
+        data.put("energy", energy);
203
+
204
+        return data;
205
+    }
206
+
207
+    /**
208
+     * 渲染模板 - 将变量替换为实际数据
209
+     */
210
+    public String renderTemplate(String templateContent, Map<String, Object> data) {
211
+        if (templateContent == null) return "";
212
+        String rendered = templateContent;
213
+        for (Map.Entry<String, Object> entry : flattenMap("", data).entrySet()) {
214
+            String placeholder = "{{" + entry.getKey() + "}}";
215
+            String value = entry.getValue() != null ? entry.getValue().toString() : "";
216
+            rendered = rendered.replace(placeholder, value);
217
+        }
218
+        return rendered;
219
+    }
220
+
221
+    /**
222
+     * 将嵌套Map扁平化为点分隔的key
223
+     */
224
+    @SuppressWarnings("unchecked")
225
+    private Map<String, Object> flattenMap(String prefix, Map<String, Object> map) {
226
+        Map<String, Object> result = new LinkedHashMap<>();
227
+        for (Map.Entry<String, Object> entry : map.entrySet()) {
228
+            String key = prefix.isEmpty() ? entry.getKey() : prefix + "." + entry.getKey();
229
+            Object value = entry.getValue();
230
+            if (value instanceof Map) {
231
+                result.putAll(flattenMap(key, (Map<String, Object>) value));
232
+            } else {
233
+                result.put(key, value);
234
+            }
235
+        }
236
+        return result;
237
+    }
238
+}

+ 11
- 0
wm-bi/src/main/resources/application.yml Zobrazit soubor

@@ -12,6 +12,17 @@ spring:
12 12
     nacos:
13 13
       discovery:
14 14
         server-addr: ${NACOS_HOST:127.0.0.1}:8848
15
+  mail:
16
+    host: ${MAIL_HOST:smtp.exmail.qq.com}
17
+    port: ${MAIL_PORT:465}
18
+    username: ${MAIL_USER:water-system@xayunmei.com}
19
+    password: ${MAIL_PASS:}
20
+    properties:
21
+      mail:
22
+        smtp:
23
+          auth: true
24
+          ssl:
25
+            enable: true
15 26
 
16 27
 mybatis-plus:
17 28
   mapper-locations: classpath*:/mapper/**/*.xml

+ 228
- 0
wm-bi/src/main/resources/db/V_bi_report.sql Zobrazit soubor

@@ -0,0 +1,228 @@
1
+-- =============================================
2
+-- BI 运营报告模块 DDL
3
+-- =============================================
4
+
5
+-- 报告模板表
6
+CREATE TABLE IF NOT EXISTS bi_report_template (
7
+    id              BIGSERIAL PRIMARY KEY,
8
+    template_name   VARCHAR(200) NOT NULL COMMENT '模板名称',
9
+    report_type     VARCHAR(20) NOT NULL COMMENT '报告类型: daily/weekly/monthly',
10
+    template_content TEXT COMMENT '模板内容(HTML,支持{{variable}}变量)',
11
+    data_source_config TEXT COMMENT '数据源配置(JSON)',
12
+    push_channels   VARCHAR(500) COMMENT '推送方式(JSON数组): ["email","wecom"]',
13
+    push_targets    TEXT COMMENT '推送目标(JSON)',
14
+    status          VARCHAR(20) DEFAULT 'active' COMMENT '状态: active/inactive',
15
+    remark          VARCHAR(500) COMMENT '备注',
16
+    deleted         INTEGER DEFAULT 0,
17
+    created_at      TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
18
+    updated_at      TIMESTAMP DEFAULT CURRENT_TIMESTAMP
19
+);
20
+
21
+COMMENT ON TABLE bi_report_template IS 'BI报告模板表';
22
+
23
+-- 生成报告表
24
+CREATE TABLE IF NOT EXISTS bi_generated_report (
25
+    id              BIGSERIAL PRIMARY KEY,
26
+    template_id     BIGINT COMMENT '关联模板ID',
27
+    report_title    VARCHAR(500) NOT NULL COMMENT '报告标题',
28
+    report_type     VARCHAR(20) NOT NULL COMMENT '报告类型: daily/weekly/monthly',
29
+    content_html    TEXT COMMENT '报告内容(HTML)',
30
+    data_json       TEXT COMMENT '报告数据(JSON)',
31
+    period_start    TIMESTAMP COMMENT '报告周期开始',
32
+    period_end      TIMESTAMP COMMENT '报告周期结束',
33
+    generated_at    TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '生成时间',
34
+    push_status     VARCHAR(20) DEFAULT 'pending' COMMENT '推送状态: pending/pushing/success/partial_fail/fail',
35
+    push_log        TEXT COMMENT '推送记录(JSON)',
36
+    remark          VARCHAR(500) COMMENT '备注',
37
+    deleted         INTEGER DEFAULT 0,
38
+    created_at      TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
39
+    updated_at      TIMESTAMP DEFAULT CURRENT_TIMESTAMP
40
+);
41
+
42
+COMMENT ON TABLE bi_generated_report IS 'BI生成报告表';
43
+
44
+-- 索引
45
+CREATE INDEX IF NOT EXISTS idx_report_template_type ON bi_report_template(report_type);
46
+CREATE INDEX IF NOT EXISTS idx_report_template_status ON bi_report_template(status);
47
+CREATE INDEX IF NOT EXISTS idx_generated_report_type ON bi_generated_report(report_type);
48
+CREATE INDEX IF NOT EXISTS idx_generated_report_push_status ON bi_generated_report(push_status);
49
+CREATE INDEX IF NOT EXISTS idx_generated_report_generated_at ON bi_generated_report(generated_at);
50
+CREATE INDEX IF NOT EXISTS idx_generated_report_template_id ON bi_generated_report(template_id);
51
+
52
+-- 插入默认模板
53
+INSERT INTO bi_report_template (template_name, report_type, template_content, data_source_config, push_channels, push_targets, status, remark)
54
+VALUES (
55
+    '水务运营日报',
56
+    'daily',
57
+    '<html><head><meta charset="UTF-8"><title>{{reportTitle}}</title>
58
+<style>
59
+body{font-family:Arial,sans-serif;margin:20px;background:#f5f5f5}
60
+.container{max-width:800px;margin:0 auto;background:#fff;padding:30px;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,0.1)}
61
+h1{color:#1890ff;border-bottom:2px solid #1890ff;padding-bottom:10px}
62
+h2{color:#333;margin-top:24px}
63
+.metric{display:inline-block;width:22%;margin:10px 1%;padding:15px;background:#f0f8ff;border-radius:6px;text-align:center}
64
+.metric .value{font-size:24px;font-weight:bold;color:#1890ff}
65
+.metric .label{font-size:12px;color:#666;margin-top:4px}
66
+table{width:100%;border-collapse:collapse;margin:16px 0}
67
+th,td{padding:10px;border:1px solid #ddd;text-align:left}
68
+th{background:#1890ff;color:#fff}
69
+.footer{margin-top:30px;padding-top:16px;border-top:1px solid #ddd;color:#999;font-size:12px}
70
+</style></head><body>
71
+<div class="container">
72
+<h1>💧 {{reportTitle}}</h1>
73
+<p>报告周期: {{reportPeriod}}</p>
74
+
75
+<h2>📊 水量数据</h2>
76
+<div class="metric"><div class="value">{{waterVolume.totalSupply}}</div><div class="label">总供水量</div></div>
77
+<div class="metric"><div class="value">{{waterVolume.totalSales}}</div><div class="label">总售水量</div></div>
78
+<div class="metric"><div class="value">{{waterVolume.lossRate}}</div><div class="label">产销差率</div></div>
79
+<div class="metric"><div class="value">{{waterVolume.avgPressure}}</div><div class="label">平均压力</div></div>
80
+
81
+<h2>🧪 水质数据</h2>
82
+<div class="metric"><div class="value">{{waterQuality.qualifiedRate}}</div><div class="label">合格率</div></div>
83
+<div class="metric"><div class="value">{{waterQuality.turbidityAvg}}</div><div class="label">平均浊度</div></div>
84
+<div class="metric"><div class="value">{{waterQuality.residualChlorine}}</div><div class="label">余氯</div></div>
85
+<div class="metric"><div class="value">{{waterQuality.phValue}}</div><div class="label">pH值</div></div>
86
+
87
+<h2>💰 营收数据</h2>
88
+<div class="metric"><div class="value">{{revenue.dailyRevenue}}</div><div class="label">日营收</div></div>
89
+<div class="metric"><div class="value">{{revenue.monthlyCumulative}}</div><div class="label">月累计</div></div>
90
+<div class="metric"><div class="value">{{revenue.collectionRate}}</div><div class="label">回收率</div></div>
91
+<div class="metric"><div class="value">{{revenue.totalUsers}}</div><div class="label">用户总数</div></div>
92
+
93
+<h2>⚡ 能耗数据</h2>
94
+<div class="metric"><div class="value">{{energy.totalPower}}</div><div class="label">总用电量</div></div>
95
+<div class="metric"><div class="value">{{energy.unitConsumption}}</div><div class="label">单位能耗</div></div>
96
+<div class="metric"><div class="value">{{energy.pumpEfficiency}}</div><div class="label">泵站效率</div></div>
97
+<div class="metric"><div class="value">{{energy.solarGeneration}}</div><div class="label">光伏发电</div></div>
98
+
99
+<div class="footer">
100
+<p>本报告由智慧水务管理系统自动生成 | {{reportDate}}</p>
101
+</div>
102
+</div></body></html>',
103
+    '{"modules":["waterVolume","waterQuality","revenue","energy"]}',
104
+    '["email"]',
105
+    '{"emails":["admin@xayunmei.com"]}',
106
+    'active',
107
+    '默认水务运营日报模板'
108
+);
109
+
110
+INSERT INTO bi_report_template (template_name, report_type, template_content, data_source_config, push_channels, push_targets, status, remark)
111
+VALUES (
112
+    '水务运营周报',
113
+    'weekly',
114
+    '<html><head><meta charset="UTF-8"><title>{{reportTitle}}</title>
115
+<style>
116
+body{font-family:Arial,sans-serif;margin:20px;background:#f5f5f5}
117
+.container{max-width:800px;margin:0 auto;background:#fff;padding:30px;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,0.1)}
118
+h1{color:#52c41a;border-bottom:2px solid #52c41a;padding-bottom:10px}
119
+h2{color:#333;margin-top:24px}
120
+.summary{background:#f6ffed;padding:16px;border-radius:6px;margin:16px 0;border-left:4px solid #52c41a}
121
+table{width:100%;border-collapse:collapse;margin:16px 0}
122
+th,td{padding:10px;border:1px solid #ddd;text-align:center}
123
+th{background:#52c41a;color:#fff}
124
+.footer{margin-top:30px;padding-top:16px;border-top:1px solid #ddd;color:#999;font-size:12px}
125
+</style></head><body>
126
+<div class="container">
127
+<h1>📈 {{reportTitle}}</h1>
128
+<p>报告周期: {{reportPeriod}}</p>
129
+
130
+<div class="summary">
131
+<strong>本周概要:</strong> 供水 {{waterVolume.totalSupply}} | 水质合格率 {{waterQuality.qualifiedRate}} | 营收 {{revenue.dailyRevenue}} | 能耗 {{energy.unitConsumption}}
132
+</div>
133
+
134
+<h2>水量分析</h2>
135
+<table><tr><th>指标</th><th>数值</th></tr>
136
+<tr><td>总供水量</td><td>{{waterVolume.totalSupply}}</td></tr>
137
+<tr><td>总售水量</td><td>{{waterVolume.totalSales}}</td></tr>
138
+<tr><td>产销差率</td><td>{{waterVolume.lossRate}}</td></tr>
139
+<tr><td>高峰时段</td><td>{{waterVolume.peakHour}}</td></tr></table>
140
+
141
+<h2>水质分析</h2>
142
+<table><tr><th>指标</th><th>数值</th></tr>
143
+<tr><td>平均浊度</td><td>{{waterQuality.turbidityAvg}}</td></tr>
144
+<tr><td>余氯</td><td>{{waterQuality.residualChlorine}}</td></tr>
145
+<tr><td>pH值</td><td>{{waterQuality.phValue}}</td></tr>
146
+<tr><td>合格率</td><td>{{waterQuality.qualifiedRate}}</td></tr></table>
147
+
148
+<h2>营收分析</h2>
149
+<table><tr><th>指标</th><th>数值</th></tr>
150
+<tr><td>日营收</td><td>{{revenue.dailyRevenue}}</td></tr>
151
+<tr><td>月累计</td><td>{{revenue.monthlyCumulative}}</td></tr>
152
+<tr><td>回收率</td><td>{{revenue.collectionRate}}</td></tr>
153
+<tr><td>新增用户</td><td>{{revenue.newUsers}}</td></tr></table>
154
+
155
+<h2>能耗分析</h2>
156
+<table><tr><th>指标</th><th>数值</th></tr>
157
+<tr><td>总用电量</td><td>{{energy.totalPower}}</td></tr>
158
+<tr><td>单位能耗</td><td>{{energy.unitConsumption}}</td></tr>
159
+<tr><td>泵站效率</td><td>{{energy.pumpEfficiency}}</td></tr>
160
+<tr><td>光伏发电</td><td>{{energy.solarGeneration}}</td></tr></table>
161
+
162
+<div class="footer"><p>本报告由智慧水务管理系统自动生成 | {{reportDate}}</p></div>
163
+</div></body></html>',
164
+    '{"modules":["waterVolume","waterQuality","revenue","energy"]}',
165
+    '["email","wecom"]',
166
+    '{"emails":["admin@xayunmei.com"],"webhookUrls":[]}',
167
+    'active',
168
+    '默认水务运营周报模板'
169
+);
170
+
171
+INSERT INTO bi_report_template (template_name, report_type, template_content, data_source_config, push_channels, push_targets, status, remark)
172
+VALUES (
173
+    '水务运营月报',
174
+    'monthly',
175
+    '<html><head><meta charset="UTF-8"><title>{{reportTitle}}</title>
176
+<style>
177
+body{font-family:Arial,sans-serif;margin:20px;background:#f5f5f5}
178
+.container{max-width:900px;margin:0 auto;background:#fff;padding:30px;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,0.1)}
179
+h1{color:#722ed1;border-bottom:2px solid #722ed1;padding-bottom:10px}
180
+h2{color:#333;margin-top:24px}
181
+.kpi-row{display:flex;gap:16px;margin:16px 0}
182
+.kpi-card{flex:1;padding:20px;border-radius:8px;text-align:center;color:#fff}
183
+.kpi-card.blue{background:linear-gradient(135deg,#1890ff,#096dd9)}
184
+.kpi-card.green{background:linear-gradient(135deg,#52c41a,#389e0d)}
185
+.kpi-card.orange{background:linear-gradient(135deg,#fa8c16,#d46b08)}
186
+.kpi-card.purple{background:linear-gradient(135deg,#722ed1,#531dab)}
187
+.kpi-card .value{font-size:28px;font-weight:bold}
188
+.kpi-card .label{font-size:13px;opacity:0.9;margin-top:4px}
189
+table{width:100%;border-collapse:collapse;margin:16px 0}
190
+th,td{padding:10px;border:1px solid #ddd;text-align:center}
191
+th{background:#722ed1;color:#fff}
192
+.footer{margin-top:30px;padding-top:16px;border-top:1px solid #ddd;color:#999;font-size:12px}
193
+</style></head><body>
194
+<div class="container">
195
+<h1>📊 {{reportTitle}}</h1>
196
+<p>报告周期: {{reportPeriod}}</p>
197
+
198
+<div class="kpi-row">
199
+<div class="kpi-card blue"><div class="value">{{waterVolume.totalSupply}}</div><div class="label">月总供水量</div></div>
200
+<div class="kpi-card green"><div class="value">{{waterQuality.qualifiedRate}}</div><div class="label">水质合格率</div></div>
201
+<div class="kpi-card orange"><div class="value">{{revenue.monthlyCumulative}}</div><div class="label">月营收</div></div>
202
+<div class="kpi-card purple"><div class="value">{{energy.unitConsumption}}</div><div class="label">单位能耗</div></div>
203
+</div>
204
+
205
+<h2>水量月度统计</h2>
206
+<table><tr><th>总供水量</th><th>总售水量</th><th>产销差率</th><th>平均压力</th></tr>
207
+<tr><td>{{waterVolume.totalSupply}}</td><td>{{waterVolume.totalSales}}</td><td>{{waterVolume.lossRate}}</td><td>{{waterVolume.avgPressure}}</td></tr></table>
208
+
209
+<h2>水质月度统计</h2>
210
+<table><tr><th>平均浊度</th><th>余氯</th><th>pH值</th><th>合格率</th><th>采样次数</th></tr>
211
+<tr><td>{{waterQuality.turbidityAvg}}</td><td>{{waterQuality.residualChlorine}}</td><td>{{waterQuality.phValue}}</td><td>{{waterQuality.qualifiedRate}}</td><td>{{waterQuality.sampleCount}}</td></tr></table>
212
+
213
+<h2>营收月度统计</h2>
214
+<table><tr><th>日营收</th><th>月累计</th><th>回收率</th><th>新增用户</th><th>用户总数</th></tr>
215
+<tr><td>{{revenue.dailyRevenue}}</td><td>{{revenue.monthlyCumulative}}</td><td>{{revenue.collectionRate}}</td><td>{{revenue.newUsers}}</td><td>{{revenue.totalUsers}}</td></tr></table>
216
+
217
+<h2>能耗月度统计</h2>
218
+<table><tr><th>总用电量</th><th>单位能耗</th><th>泵站效率</th><th>吨水成本</th><th>光伏发电</th></tr>
219
+<tr><td>{{energy.totalPower}}</td><td>{{energy.unitConsumption}}</td><td>{{energy.pumpEfficiency}}</td><td>{{energy.costPerM3}}</td><td>{{energy.solarGeneration}}</td></tr></table>
220
+
221
+<div class="footer"><p>本报告由智慧水务管理系统自动生成 | {{reportDate}}</p></div>
222
+</div></body></html>',
223
+    '{"modules":["waterVolume","waterQuality","revenue","energy"]}',
224
+    '["email","wecom"]',
225
+    '{"emails":["admin@xayunmei.com"],"webhookUrls":[]}',
226
+    'active',
227
+    '默认水务运营月报模板'
228
+);

+ 161
- 0
wm-bi/src/test/java/com/water/bi/service/ReportGeneratorServiceTest.java Zobrazit soubor

@@ -0,0 +1,161 @@
1
+package com.water.bi.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.fasterxml.jackson.databind.ObjectMapper;
5
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
6
+import com.water.bi.entity.GeneratedReport;
7
+import com.water.bi.entity.ReportTemplate;
8
+import com.water.bi.mapper.GeneratedReportMapper;
9
+import org.junit.jupiter.api.BeforeEach;
10
+import org.junit.jupiter.api.DisplayName;
11
+import org.junit.jupiter.api.Test;
12
+import org.junit.jupiter.api.extension.ExtendWith;
13
+import org.mockito.Mock;
14
+import org.mockito.junit.jupiter.MockitoExtension;
15
+import org.springframework.mail.javamail.JavaMailSender;
16
+import org.springframework.web.client.RestTemplate;
17
+
18
+import java.util.Collections;
19
+import java.util.List;
20
+
21
+import static org.junit.jupiter.api.Assertions.*;
22
+import static org.mockito.ArgumentMatchers.*;
23
+import static org.mockito.Mockito.*;
24
+
25
+/**
26
+ * 报告生成服务测试
27
+ */
28
+@ExtendWith(MockitoExtension.class)
29
+class ReportGeneratorServiceTest {
30
+
31
+    @Mock
32
+    private GeneratedReportMapper reportMapper;
33
+
34
+    @Mock
35
+    private JavaMailSender mailSender;
36
+
37
+    @Mock
38
+    private RestTemplate restTemplate;
39
+
40
+    private ReportTemplateService templateService;
41
+    private ReportGeneratorService generatorService;
42
+
43
+    @BeforeEach
44
+    void setUp() {
45
+        ReportTemplateMapper templateMapper = mock(ReportTemplateMapper.class);
46
+        templateService = new ReportTemplateService(templateMapper);
47
+        ObjectMapper objectMapper = new ObjectMapper();
48
+        objectMapper.registerModule(new JavaTimeModule());
49
+        generatorService = new ReportGeneratorService(reportMapper, templateService, mailSender, restTemplate, objectMapper);
50
+    }
51
+
52
+    @Test
53
+    @DisplayName("按类型生成报告 - 无模板返回空列表")
54
+    void testGenerateByTypeNoTemplates() {
55
+        // templateService.listActiveByType returns empty
56
+        ReportGeneratorService spyService = spy(generatorService);
57
+        ReportTemplateService mockTemplateService = mock(ReportTemplateService.class);
58
+
59
+        // We need to re-construct with the mock
60
+        ObjectMapper objectMapper = new ObjectMapper();
61
+        objectMapper.registerModule(new JavaTimeModule());
62
+        ReportGeneratorService testService = new ReportGeneratorService(
63
+                reportMapper, mockTemplateService, mailSender, restTemplate, objectMapper);
64
+
65
+        when(mockTemplateService.listActiveByType("daily")).thenReturn(Collections.emptyList());
66
+
67
+        List<GeneratedReport> result = testService.generateByType("daily");
68
+        assertTrue(result.isEmpty());
69
+    }
70
+
71
+    @Test
72
+    @DisplayName("从模板生成报告 - 完整流程")
73
+    void testGenerateFromTemplate() {
74
+        ReportTemplate template = new ReportTemplate();
75
+        template.setId(1L);
76
+        template.setTemplateName("测试日报");
77
+        template.setReportType("daily");
78
+        template.setTemplateContent("<h1>{{reportTitle}}</h1><p>{{waterVolume.totalSupply}}</p>");
79
+        template.setPushChannels("[]");  // no push
80
+        template.setPushTargets("{}");
81
+
82
+        ReportTemplateService mockTemplateService = mock(ReportTemplateService.class);
83
+        when(mockTemplateService.listActiveByType(anyString())).thenReturn(List.of(template));
84
+        when(mockTemplateService.renderTemplate(anyString(), anyMap())).thenReturn("<h1>标题</h1><p>10000 m³</p>");
85
+
86
+        ObjectMapper objectMapper = new ObjectMapper();
87
+        objectMapper.registerModule(new JavaTimeModule());
88
+        ReportGeneratorService testService = new ReportGeneratorService(
89
+                reportMapper, mockTemplateService, mailSender, restTemplate, objectMapper);
90
+
91
+        when(reportMapper.insert(any(GeneratedReport.class))).thenReturn(1);
92
+        when(reportMapper.updateById(any(GeneratedReport.class))).thenReturn(1);
93
+
94
+        GeneratedReport report = testService.generateFromTemplate(template);
95
+
96
+        assertNotNull(report);
97
+        assertEquals(1L, report.getTemplateId());
98
+        assertTrue(report.getReportTitle().contains("测试日报"));
99
+        assertEquals("daily", report.getReportType());
100
+        assertNotNull(report.getContentHtml());
101
+        assertNotNull(report.getDataJson());
102
+        assertNotNull(report.getGeneratedAt());
103
+        verify(reportMapper).insert(any(GeneratedReport.class));
104
+    }
105
+
106
+    @Test
107
+    @DisplayName("重新生成报告 - 报告不存在抛异常")
108
+    void testRegenerateNotFound() {
109
+        when(reportMapper.selectById(999L)).thenReturn(null);
110
+
111
+        assertThrows(RuntimeException.class, () -> {
112
+            generatorService.regenerate(999L);
113
+        });
114
+    }
115
+
116
+    @Test
117
+    @DisplayName("删除报告")
118
+    void testDeleteReport() {
119
+        when(reportMapper.deleteById(1L)).thenReturn(1);
120
+        generatorService.delete(1L);
121
+        verify(reportMapper).deleteById(1L);
122
+    }
123
+
124
+    @Test
125
+    @DisplayName("手动推送 - 报告不存在抛异常")
126
+    void testManualPushNotFound() {
127
+        when(reportMapper.selectById(999L)).thenReturn(null);
128
+
129
+        assertThrows(RuntimeException.class, () -> {
130
+            generatorService.manualPush(999L);
131
+        });
132
+    }
133
+
134
+    @Test
135
+    @DisplayName("分页查询报告")
136
+    void testPage() {
137
+        var page = new com.baomidou.mybatisplus.extension.plugins.pagination.Page<GeneratedReport>(1, 10);
138
+        page.setRecords(List.of(new GeneratedReport()));
139
+        page.setTotal(1);
140
+
141
+        when(reportMapper.selectPage(any(), any(LambdaQueryWrapper.class))).thenReturn(page);
142
+
143
+        var result = generatorService.page(1, 10, "daily", null, null, null);
144
+        assertNotNull(result);
145
+        assertEquals(1, result.getRecords().size());
146
+    }
147
+
148
+    @Test
149
+    @DisplayName("获取报告详情")
150
+    void testGetById() {
151
+        GeneratedReport report = new GeneratedReport();
152
+        report.setId(1L);
153
+        report.setReportTitle("测试报告");
154
+
155
+        when(reportMapper.selectById(1L)).thenReturn(report);
156
+
157
+        GeneratedReport result = generatorService.getById(1L);
158
+        assertNotNull(result);
159
+        assertEquals("测试报告", result.getReportTitle());
160
+    }
161
+}

+ 228
- 0
wm-bi/src/test/java/com/water/bi/service/ReportTemplateServiceTest.java Zobrazit soubor

@@ -0,0 +1,228 @@
1
+package com.water.bi.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.water.bi.entity.ReportTemplate;
5
+import com.water.bi.mapper.ReportTemplateMapper;
6
+import org.junit.jupiter.api.BeforeEach;
7
+import org.junit.jupiter.api.DisplayName;
8
+import org.junit.jupiter.api.Test;
9
+import org.junit.jupiter.api.extension.ExtendWith;
10
+import org.mockito.Mock;
11
+import org.mockito.junit.jupiter.MockitoExtension;
12
+
13
+import java.util.Arrays;
14
+import java.util.List;
15
+import java.util.Map;
16
+
17
+import static org.junit.jupiter.api.Assertions.*;
18
+import static org.mockito.ArgumentMatchers.*;
19
+import static org.mockito.Mockito.*;
20
+
21
+/**
22
+ * 报告模板服务测试
23
+ */
24
+@ExtendWith(MockitoExtension.class)
25
+class ReportTemplateServiceTest {
26
+
27
+    @Mock
28
+    private ReportTemplateMapper templateMapper;
29
+
30
+    private ReportTemplateService templateService;
31
+
32
+    @BeforeEach
33
+    void setUp() {
34
+        templateService = new ReportTemplateService(templateMapper);
35
+    }
36
+
37
+    @Test
38
+    @DisplayName("创建模板 - 默认状态为active")
39
+    void testCreateTemplate() {
40
+        ReportTemplate template = new ReportTemplate();
41
+        template.setTemplateName("测试日报模板");
42
+        template.setReportType("daily");
43
+        template.setTemplateContent("<h1>{{reportTitle}}</h1>");
44
+
45
+        when(templateMapper.insert(any(ReportTemplate.class))).thenReturn(1);
46
+
47
+        ReportTemplate created = templateService.create(template);
48
+        assertNotNull(created);
49
+        assertEquals("active", created.getStatus());
50
+        verify(templateMapper).insert(any(ReportTemplate.class));
51
+    }
52
+
53
+    @Test
54
+    @DisplayName("创建模板 - 指定状态")
55
+    void testCreateTemplateWithStatus() {
56
+        ReportTemplate template = new ReportTemplate();
57
+        template.setTemplateName("测试模板");
58
+        template.setReportType("weekly");
59
+        template.setStatus("inactive");
60
+
61
+        when(templateMapper.insert(any(ReportTemplate.class))).thenReturn(1);
62
+
63
+        ReportTemplate created = templateService.create(template);
64
+        assertEquals("inactive", created.getStatus());
65
+    }
66
+
67
+    @Test
68
+    @DisplayName("启用/停用模板")
69
+    void testToggleStatus() {
70
+        ReportTemplate template = new ReportTemplate();
71
+        template.setId(1L);
72
+        template.setTemplateName("测试模板");
73
+        template.setStatus("active");
74
+
75
+        when(templateMapper.selectById(1L)).thenReturn(template);
76
+        when(templateMapper.updateById(any(ReportTemplate.class))).thenReturn(1);
77
+
78
+        templateService.toggleStatus(1L, "inactive");
79
+
80
+        verify(templateMapper).updateById(argThat(t -> "inactive".equals(t.getStatus())));
81
+    }
82
+
83
+    @Test
84
+    @DisplayName("启用/停用模板 - 模板不存在抛异常")
85
+    void testToggleStatusNotFound() {
86
+        when(templateMapper.selectById(999L)).thenReturn(null);
87
+
88
+        assertThrows(RuntimeException.class, () -> {
89
+            templateService.toggleStatus(999L, "active");
90
+        });
91
+    }
92
+
93
+    @Test
94
+    @DisplayName("复制模板")
95
+    void testCopyTemplate() {
96
+        ReportTemplate source = new ReportTemplate();
97
+        source.setId(1L);
98
+        source.setTemplateName("原始模板");
99
+        source.setReportType("daily");
100
+        source.setTemplateContent("<h1>{{reportTitle}}</h1>");
101
+        source.setDataSourceConfig("{\"modules\":[\"waterVolume\"]}");
102
+        source.setPushChannels("[\"email\"]");
103
+        source.setPushTargets("{\"emails\":[\"test@example.com\"]}");
104
+        source.setStatus("active");
105
+
106
+        when(templateMapper.selectById(1L)).thenReturn(source);
107
+        when(templateMapper.insert(any(ReportTemplate.class))).thenReturn(1);
108
+
109
+        ReportTemplate copy = templateService.copy(1L);
110
+
111
+        assertNotNull(copy);
112
+        assertEquals("原始模板 (副本)", copy.getTemplateName());
113
+        assertEquals("daily", copy.getReportType());
114
+        assertEquals("inactive", copy.getStatus());
115
+        assertEquals("<h1>{{reportTitle}}</h1>", copy.getTemplateContent());
116
+        verify(templateMapper).insert(any(ReportTemplate.class));
117
+    }
118
+
119
+    @Test
120
+    @DisplayName("复制模板 - 源不存在抛异常")
121
+    void testCopyTemplateNotFound() {
122
+        when(templateMapper.selectById(999L)).thenReturn(null);
123
+
124
+        assertThrows(RuntimeException.class, () -> {
125
+            templateService.copy(999L);
126
+        });
127
+    }
128
+
129
+    @Test
130
+    @DisplayName("预览模板变量 - 解析变量列表")
131
+    void testPreviewVariables() {
132
+        ReportTemplate template = new ReportTemplate();
133
+        template.setId(1L);
134
+        template.setTemplateName("测试模板");
135
+        template.setTemplateContent(
136
+                "<h1>{{reportTitle}}</h1>" +
137
+                "<p>{{waterVolume.totalSupply}}</p>" +
138
+                "<p>{{waterQuality.qualifiedRate}}</p>" +
139
+                "<p>{{reportTitle}}</p>"  // duplicate should be deduplicated
140
+        );
141
+
142
+        when(templateMapper.selectById(1L)).thenReturn(template);
143
+
144
+        Map<String, Object> result = templateService.previewVariables(1L);
145
+
146
+        assertNotNull(result);
147
+        assertTrue(result.containsKey("variables"));
148
+        assertTrue(result.containsKey("sampleData"));
149
+        assertTrue(result.containsKey("renderedPreview"));
150
+
151
+        @SuppressWarnings("unchecked")
152
+        java.util.Set<String> variables = (java.util.Set<String>) result.get("variables");
153
+        assertEquals(3, variables.size()); // reportTitle, waterVolume.totalSupply, waterQuality.qualifiedRate
154
+        assertTrue(variables.contains("reportTitle"));
155
+        assertTrue(variables.contains("waterVolume.totalSupply"));
156
+        assertTrue(variables.contains("waterQuality.qualifiedRate"));
157
+
158
+        // Preview should have variables replaced
159
+        String preview = (String) result.get("renderedPreview");
160
+        assertFalse(preview.contains("{{reportTitle}}"));
161
+    }
162
+
163
+    @Test
164
+    @DisplayName("渲染模板 - 变量替换")
165
+    void testRenderTemplate() {
166
+        String template = "<h1>{{title}}</h1><p>{{data.value}}</p>";
167
+        Map<String, Object> data = Map.of(
168
+                "title", "测试报告",
169
+                "data", Map.of("value", "100")
170
+        );
171
+
172
+        String result = templateService.renderTemplate(template, data);
173
+        assertEquals("<h1>测试报告</h1><p>100</p>", result);
174
+    }
175
+
176
+    @Test
177
+    @DisplayName("渲染模板 - null内容返回空串")
178
+    void testRenderTemplateNull() {
179
+        String result = templateService.renderTemplate(null, Map.of());
180
+        assertEquals("", result);
181
+    }
182
+
183
+    @Test
184
+    @DisplayName("生成示例数据 - 包含所有4类数据")
185
+    void testGenerateSampleData() {
186
+        Map<String, Object> data = templateService.generateSampleData();
187
+
188
+        assertNotNull(data);
189
+        assertTrue(data.containsKey("waterVolume"));
190
+        assertTrue(data.containsKey("waterQuality"));
191
+        assertTrue(data.containsKey("revenue"));
192
+        assertTrue(data.containsKey("energy"));
193
+        assertTrue(data.containsKey("reportTitle"));
194
+        assertTrue(data.containsKey("reportDate"));
195
+    }
196
+
197
+    @Test
198
+    @DisplayName("获取所有启用模板")
199
+    void testListActive() {
200
+        ReportTemplate t1 = new ReportTemplate();
201
+        t1.setStatus("active");
202
+        when(templateMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(t1));
203
+
204
+        List<ReportTemplate> result = templateService.listActive();
205
+        assertEquals(1, result.size());
206
+    }
207
+
208
+    @Test
209
+    @DisplayName("按类型获取启用模板")
210
+    void testListActiveByType() {
211
+        ReportTemplate t1 = new ReportTemplate();
212
+        t1.setReportType("daily");
213
+        t1.setStatus("active");
214
+        when(templateMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(t1));
215
+
216
+        List<ReportTemplate> result = templateService.listActiveByType("daily");
217
+        assertEquals(1, result.size());
218
+        assertEquals("daily", result.get(0).getReportType());
219
+    }
220
+
221
+    @Test
222
+    @DisplayName("删除模板")
223
+    void testDeleteTemplate() {
224
+        when(templateMapper.deleteById(1L)).thenReturn(1);
225
+        templateService.delete(1L);
226
+        verify(templateMapper).deleteById(1L);
227
+    }
228
+}