Просмотр исходного кода

feat(wm-dma): #59 DMA分区计量+漏损分析前端页面与SQL迁移

- 新增前端 API 模块 dma.ts(分区/漏损/流量/计量表/水平衡完整接口)
- PartitionView.vue — DMA分区管理(树形结构+CRUD+层级展示+详情面板)
- LeakageAnalysisView.vue — 漏损分析看板(供水量/售水量对比柱状图+漏损率趋势折线图+夜间最小流量+预警列表+分析操作)
- V_dma.sql — 模块级 DDL 迁移脚本
- 路由注册 dma/partition + dma/leakage
bot_dev2 5 дней назад
Родитель
Сommit
bdb977c598

+ 150
- 0
frontend/src/api/dma.ts Просмотреть файл

@@ -0,0 +1,150 @@
1
+import request from './request'
2
+
3
+const BASE_ZONE = '/dma/zone'
4
+const BASE_LEAKAGE = '/dma/leakage'
5
+const BASE_FLOW = '/dma/flow'
6
+const BASE_METER = '/dma/meter'
7
+const BASE_BALANCE = '/dma/balance'
8
+
9
+// ==================== DMA分区管理 ====================
10
+
11
+export function getZonePage(params: { pageNum?: number; pageSize?: number; zoneName?: string }) {
12
+  return request.get(`${BASE_ZONE}/page`, { params })
13
+}
14
+
15
+export function getZoneDetail(id: number) {
16
+  return request.get(`${BASE_ZONE}/${id}`)
17
+}
18
+
19
+export function createZone(data: any) {
20
+  return request.post(BASE_ZONE, data)
21
+}
22
+
23
+export function updateZone(id: number, data: any) {
24
+  return request.put(`${BASE_ZONE}/${id}`, data)
25
+}
26
+
27
+export function deleteZone(id: number) {
28
+  return request.delete(`${BASE_ZONE}/${id}`)
29
+}
30
+
31
+export function getZoneTree() {
32
+  return request.get(`${BASE_ZONE}/tree`)
33
+}
34
+
35
+export function getAllZones() {
36
+  return request.get(`${BASE_ZONE}/list`)
37
+}
38
+
39
+// ==================== 漏损分析 ====================
40
+
41
+export function getLeakagePage(params: {
42
+  pageNum?: number; pageSize?: number; zoneId?: number;
43
+  startDate?: string; endDate?: string
44
+}) {
45
+  return request.get(`${BASE_LEAKAGE}/page`, { params })
46
+}
47
+
48
+export function analyzeLeakage(params: {
49
+  zoneId: number; date: string; supplyVolume: number; saleVolume: number
50
+}) {
51
+  return request.post(`${BASE_LEAKAGE}/analyze`, null, { params })
52
+}
53
+
54
+export function getLeakageTrend(zoneId: number, days: number = 30) {
55
+  return request.get(`${BASE_LEAKAGE}/trend/${zoneId}`, { params: { days } })
56
+}
57
+
58
+export function getAlarms(alarmLevel?: string) {
59
+  return request.get(`${BASE_LEAKAGE}/alarms`, { params: { alarmLevel } })
60
+}
61
+
62
+export function getZoneSummary(zoneId: number) {
63
+  return request.get(`${BASE_LEAKAGE}/summary/${zoneId}`)
64
+}
65
+
66
+// ==================== 流量计量 ====================
67
+
68
+export function getFlowPage(params: {
69
+  pageNum?: number; pageSize?: number; zoneId?: number; meterId?: number;
70
+  startTime?: string; endTime?: string
71
+}) {
72
+  return request.get(`${BASE_FLOW}/page`, { params })
73
+}
74
+
75
+export function createFlowRecord(data: any) {
76
+  return request.post(BASE_FLOW, data)
77
+}
78
+
79
+export function batchCreateFlowRecords(data: any[]) {
80
+  return request.post(`${BASE_FLOW}/batch`, data)
81
+}
82
+
83
+export function getZoneFlowSummary(zoneId: number, startTime: string, endTime: string) {
84
+  return request.get(`${BASE_FLOW}/summary/${zoneId}`, { params: { startTime, endTime } })
85
+}
86
+
87
+export function getMNFAnalysis(zoneId: number, date: string) {
88
+  return request.get(`${BASE_FLOW}/mnf/${zoneId}`, { params: { date } })
89
+}
90
+
91
+export function getFlowTrend(zoneId: number, startTime: string, endTime: string) {
92
+  return request.get(`${BASE_FLOW}/trend/${zoneId}`, { params: { startTime, endTime } })
93
+}
94
+
95
+// ==================== 计量表管理 ====================
96
+
97
+export function getMeterPage(params: {
98
+  pageNum?: number; pageSize?: number; zoneId?: number; meterType?: string
99
+}) {
100
+  return request.get(`${BASE_METER}/page`, { params })
101
+}
102
+
103
+export function getMeterDetail(id: number) {
104
+  return request.get(`${BASE_METER}/${id}`)
105
+}
106
+
107
+export function createMeter(data: any) {
108
+  return request.post(BASE_METER, data)
109
+}
110
+
111
+export function updateMeter(id: number, data: any) {
112
+  return request.put(`${BASE_METER}/${id}`, data)
113
+}
114
+
115
+export function deleteMeter(id: number) {
116
+  return request.delete(`${BASE_METER}/${id}`)
117
+}
118
+
119
+export function getMetersByZone(zoneId: number) {
120
+  return request.get(`${BASE_METER}/zone/${zoneId}`)
121
+}
122
+
123
+export function bindMeterToZone(meterId: number, zoneId: number) {
124
+  return request.post(`${BASE_METER}/${meterId}/bind/${zoneId}`)
125
+}
126
+
127
+// ==================== 水平衡分析 ====================
128
+
129
+export function getBalancePage(params: {
130
+  pageNum?: number; pageSize?: number; zoneId?: number; period?: string;
131
+  startDate?: string; endDate?: string
132
+}) {
133
+  return request.get(`${BASE_BALANCE}/page`, { params })
134
+}
135
+
136
+export function createBalance(data: any) {
137
+  return request.post(BASE_BALANCE, data)
138
+}
139
+
140
+export function updateBalance(id: number, data: any) {
141
+  return request.put(`${BASE_BALANCE}/${id}`, data)
142
+}
143
+
144
+export function deleteBalance(id: number) {
145
+  return request.delete(`${BASE_BALANCE}/${id}`)
146
+}
147
+
148
+export function getBalanceReport(zoneId: number, period: string, startDate: string, endDate: string) {
149
+  return request.get(`${BASE_BALANCE}/report/${zoneId}`, { params: { period, startDate, endDate } })
150
+}

+ 3
- 1
frontend/src/router/index.ts Просмотреть файл

@@ -8,11 +8,13 @@ const routes = [
8 8
     children: [
9 9
       { path: 'dashboard', name: 'dashboard', component: () => import('@/views/dashboard/DashboardView.vue') },
10 10
       { path: 'system/user', name: 'user', component: () => import('@/views/system/user/UserList.vue') },
11
-      { path: 'system/role', name: 'role', component: () => import('@/views/system/role/RoleList.vue') },
11
+      { path: 'system/role', name: 'role', component: () => import('@/views/system/user/RoleList.vue') },
12 12
       { path: 'system/menu', name: 'menu', component: () => import('@/views/system/menu/MenuList.vue') },
13 13
       { path: 'system/dept', name: 'dept', component: () => import('@/views/system/dept/DeptList.vue') },
14 14
       { path: 'dispatch-command', name: 'dispatchCommandList', component: () => import('@/views/dispatch-command/CommandList.vue') },
15 15
       { path: 'dispatch-command/:id', name: 'dispatchCommandDetail', component: () => import('@/views/dispatch-command/CommandDetail.vue') },
16
+      { path: 'dma/partition', name: 'dmaPartition', component: () => import('@/views/dma/PartitionView.vue') },
17
+      { path: 'dma/leakage', name: 'dmaLeakage', component: () => import('@/views/dma/LeakageAnalysisView.vue') },
16 18
     ]
17 19
   },
18 20
   { path: '/:pathMatch(.*)*', redirect: '/dashboard' }

+ 471
- 0
frontend/src/views/dma/LeakageAnalysisView.vue Просмотреть файл

@@ -0,0 +1,471 @@
1
+<template>
2
+  <div class="leakage-analysis">
3
+    <!-- 顶部筛选区 -->
4
+    <el-card shadow="never" class="filter-card">
5
+      <el-form :inline="true" :model="filterForm">
6
+        <el-form-item label="选择分区">
7
+          <el-select v-model="filterForm.zoneId" placeholder="全部分区" clearable @change="handleFilterChange" style="width: 220px">
8
+            <el-option v-for="z in zoneList" :key="z.id" :label="z.zoneName" :value="z.id" />
9
+          </el-select>
10
+        </el-form-item>
11
+        <el-form-item label="时间范围">
12
+          <el-date-picker v-model="dateRange" type="daterange" range-separator="至"
13
+            start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD"
14
+            @change="handleFilterChange" />
15
+        </el-form-item>
16
+        <el-form-item>
17
+          <el-button type="primary" @click="handleFilterChange">
18
+            <el-icon><Search /></el-icon> 查询
19
+          </el-button>
20
+          <el-button @click="handleReset">重置</el-button>
21
+        </el-form-item>
22
+      </el-form>
23
+    </el-card>
24
+
25
+    <!-- 统计卡片 -->
26
+    <el-row :gutter="12" style="margin-top: 12px">
27
+      <el-col :span="6">
28
+        <el-card shadow="hover" class="stat-card">
29
+          <div class="stat-value" style="color: #409eff">{{ summaryStats.totalSupply }}</div>
30
+          <div class="stat-label">总供水量 (m³)</div>
31
+        </el-card>
32
+      </el-col>
33
+      <el-col :span="6">
34
+        <el-card shadow="hover" class="stat-card">
35
+          <div class="stat-value" style="color: #67c23a">{{ summaryStats.totalSale }}</div>
36
+          <div class="stat-label">总售水量 (m³)</div>
37
+        </el-card>
38
+      </el-col>
39
+      <el-col :span="6">
40
+        <el-card shadow="hover" class="stat-card">
41
+          <div class="stat-value" style="color: #e6a23c">{{ summaryStats.avgNrwRate }}%</div>
42
+          <div class="stat-label">平均产销差率</div>
43
+        </el-card>
44
+      </el-col>
45
+      <el-col :span="6">
46
+        <el-card shadow="hover" class="stat-card">
47
+          <div class="stat-value" style="color: #f56c6c">{{ summaryStats.alarmCount }}</div>
48
+          <div class="stat-label">报警次数</div>
49
+        </el-card>
50
+      </el-col>
51
+    </el-row>
52
+
53
+    <!-- 图表区 -->
54
+    <el-row :gutter="12" style="margin-top: 12px">
55
+      <!-- 供水量/售水量对比柱状图 -->
56
+      <el-col :span="12">
57
+        <el-card shadow="never">
58
+          <template #header>供水量/售水量对比</template>
59
+          <div ref="barChartRef" style="height: 350px"></div>
60
+        </el-card>
61
+      </el-col>
62
+      <!-- 漏损率趋势折线图 -->
63
+      <el-col :span="12">
64
+        <el-card shadow="never">
65
+          <template #header>漏损率趋势</template>
66
+          <div ref="lineChartRef" style="height: 350px"></div>
67
+        </el-card>
68
+      </el-col>
69
+    </el-row>
70
+
71
+    <!-- 夜间最小流量 + 漏损分析操作 -->
72
+    <el-row :gutter="12" style="margin-top: 12px">
73
+      <el-col :span="8">
74
+        <el-card shadow="never">
75
+          <template #header>夜间最小流量 (MNF)</template>
76
+          <div style="text-align: center; padding: 20px 0">
77
+            <div style="font-size: 48px; font-weight: bold; color: #409eff">{{ mnfData.mnf }}</div>
78
+            <div style="font-size: 14px; color: #999; margin-top: 8px">m³/h (02:00-04:00)</div>
79
+            <el-tag :type="mnfData.analysisResult === '正常' ? 'success' : 'danger'" size="large" style="margin-top: 12px">
80
+              {{ mnfData.analysisResult }}
81
+            </el-tag>
82
+            <div style="margin-top: 16px">
83
+              <el-date-picker v-model="mnfDate" type="date" placeholder="选择日期" value-format="YYYY-MM-DD"
84
+                @change="loadMNF" style="width: 160px" />
85
+            </div>
86
+          </div>
87
+        </el-card>
88
+      </el-col>
89
+      <el-col :span="16">
90
+        <el-card shadow="never">
91
+          <template #header>
92
+            <div style="display: flex; justify-content: space-between; align-items: center">
93
+              <span>执行漏损分析</span>
94
+            </div>
95
+          </template>
96
+          <el-form :inline="true" :model="analyzeForm">
97
+            <el-form-item label="分区">
98
+              <el-select v-model="analyzeForm.zoneId" placeholder="选择分区" style="width: 160px">
99
+                <el-option v-for="z in zoneList" :key="z.id" :label="z.zoneName" :value="z.id" />
100
+              </el-select>
101
+            </el-form-item>
102
+            <el-form-item label="日期">
103
+              <el-date-picker v-model="analyzeForm.date" type="date" value-format="YYYY-MM-DD" style="width: 150px" />
104
+            </el-form-item>
105
+            <el-form-item label="供水量(m³)">
106
+              <el-input-number v-model="analyzeForm.supplyVolume" :min="0" :precision="2" controls-position="right" style="width: 130px" />
107
+            </el-form-item>
108
+            <el-form-item label="售水量(m³)">
109
+              <el-input-number v-model="analyzeForm.saleVolume" :min="0" :precision="2" controls-position="right" style="width: 130px" />
110
+            </el-form-item>
111
+            <el-form-item>
112
+              <el-button type="primary" @click="handleAnalyze" :loading="analyzing">执行分析</el-button>
113
+            </el-form-item>
114
+          </el-form>
115
+
116
+          <!-- 最近分析结果 -->
117
+          <div v-if="lastResult" style="margin-top: 12px; padding: 12px; background: #f5f7fa; border-radius: 4px">
118
+            <el-descriptions :column="4" size="small" border>
119
+              <el-descriptions-item label="漏损量">{{ lastResult.leakageVolume }} m³</el-descriptions-item>
120
+              <el-descriptions-item label="产销差率">{{ lastResult.nrwRate }}%</el-descriptions-item>
121
+              <el-descriptions-item label="漏损率">{{ lastResult.leakageRate }}%</el-descriptions-item>
122
+              <el-descriptions-item label="报警级别">
123
+                <el-tag :type="alarmTagType(lastResult.alarmLevel)" size="small">
124
+                  {{ alarmLabel(lastResult.alarmLevel) }}
125
+                </el-tag>
126
+              </el-descriptions-item>
127
+            </el-descriptions>
128
+          </div>
129
+        </el-card>
130
+      </el-col>
131
+    </el-row>
132
+
133
+    <!-- 报警列表 -->
134
+    <el-card shadow="never" style="margin-top: 12px">
135
+      <template #header>
136
+        <div style="display: flex; justify-content: space-between; align-items: center">
137
+          <span>漏损报警列表</span>
138
+          <el-radio-group v-model="alarmFilter" size="small" @change="loadAlarms">
139
+            <el-radio-button value="">全部报警</el-radio-button>
140
+            <el-radio-button value="warning">预警</el-radio-button>
141
+            <el-radio-button value="critical">严重</el-radio-button>
142
+          </el-radio-group>
143
+        </div>
144
+      </template>
145
+      <el-table :data="alarmList" border stripe v-loading="alarmLoading">
146
+        <el-table-column prop="analysisDate" label="日期" width="120" />
147
+        <el-table-column label="分区" width="160">
148
+          <template #default="{ row }">{{ getZoneName(row.zoneId) }}</template>
149
+        </el-table-column>
150
+        <el-table-column prop="supplyVolume" label="供水量(m³)" width="120" align="right" />
151
+        <el-table-column prop="saleVolume" label="售水量(m³)" width="120" align="right" />
152
+        <el-table-column prop="leakageVolume" label="漏损量(m³)" width="120" align="right" />
153
+        <el-table-column prop="nrwRate" label="产销差率(%)" width="120" align="right">
154
+          <template #default="{ row }">
155
+            <span :style="{ color: row.nrwRate > 20 ? '#f56c6c' : row.nrwRate > 12 ? '#e6a23c' : '#67c23a' }">
156
+              {{ row.nrwRate }}%
157
+            </span>
158
+          </template>
159
+        </el-table-column>
160
+        <el-table-column prop="mnf" label="MNF(m³/h)" width="110" align="right" />
161
+        <el-table-column prop="alarmLevel" label="报警级别" width="100" align="center">
162
+          <template #default="{ row }">
163
+            <el-tag :type="alarmTagType(row.alarmLevel)" size="small">{{ alarmLabel(row.alarmLevel) }}</el-tag>
164
+          </template>
165
+        </el-table-column>
166
+      </el-table>
167
+    </el-card>
168
+
169
+    <!-- 漏损数据分页列表 -->
170
+    <el-card shadow="never" style="margin-top: 12px">
171
+      <template #header>漏损分析记录</template>
172
+      <el-table :data="leakageList" border stripe v-loading="leakageLoading">
173
+        <el-table-column prop="analysisDate" label="日期" width="120" />
174
+        <el-table-column label="分区" width="160">
175
+          <template #default="{ row }">{{ getZoneName(row.zoneId) }}</template>
176
+        </el-table-column>
177
+        <el-table-column prop="supplyVolume" label="供水量(m³)" width="120" align="right" />
178
+        <el-table-column prop="saleVolume" label="售水量(m³)" width="120" align="right" />
179
+        <el-table-column prop="leakageVolume" label="漏损量(m³)" width="120" align="right" />
180
+        <el-table-column prop="nrwRate" label="产销差率(%)" width="110" align="right" />
181
+        <el-table-column prop="leakageRate" label="漏损率(%)" width="100" align="right" />
182
+        <el-table-column prop="mnf" label="MNF(m³/h)" width="100" align="right" />
183
+        <el-table-column prop="alarmLevel" label="报警级别" width="100" align="center">
184
+          <template #default="{ row }">
185
+            <el-tag :type="alarmTagType(row.alarmLevel)" size="small">{{ alarmLabel(row.alarmLevel) }}</el-tag>
186
+          </template>
187
+        </el-table-column>
188
+      </el-table>
189
+      <el-pagination
190
+        style="margin-top: 12px; justify-content: flex-end"
191
+        :current-page="leakagePage"
192
+        :page-size="leakagePageSize"
193
+        :total="leakageTotal"
194
+        layout="total, prev, pager, next"
195
+        @current-change="handleLeakagePageChange"
196
+      />
197
+    </el-card>
198
+  </div>
199
+</template>
200
+
201
+<script setup lang="ts">
202
+import { ref, reactive, onMounted, nextTick } from 'vue'
203
+import { ElMessage } from 'element-plus'
204
+import { Search } from '@element-plus/icons-vue'
205
+import * as echarts from 'echarts'
206
+import {
207
+  getLeakagePage, analyzeLeakage, getLeakageTrend, getAlarms,
208
+  getZoneSummary, getAllZones, getMNFAnalysis
209
+} from '@/api/dma'
210
+
211
+// 分区列表
212
+const zoneList = ref<any[]>([])
213
+
214
+// 筛选
215
+const filterForm = reactive({ zoneId: null as number | null })
216
+const dateRange = ref<[string, string] | null>(null)
217
+
218
+// 统计卡片
219
+const summaryStats = reactive({ totalSupply: '0', totalSale: '0', avgNrwRate: '0', alarmCount: 0 })
220
+
221
+// 图表
222
+const barChartRef = ref<HTMLElement>()
223
+const lineChartRef = ref<HTMLElement>()
224
+let barChart: echarts.ECharts | null = null
225
+let lineChart: echarts.ECharts | null = null
226
+
227
+// MNF
228
+const mnfDate = ref('')
229
+const mnfData = reactive({ mnf: '0', analysisResult: '正常' })
230
+
231
+// 漏损分析操作
232
+const analyzeForm = reactive({
233
+  zoneId: null as number | null, date: '', supplyVolume: 0, saleVolume: 0
234
+})
235
+const analyzing = ref(false)
236
+const lastResult = ref<any>(null)
237
+
238
+// 报警
239
+const alarmFilter = ref('')
240
+const alarmList = ref<any[]>([])
241
+const alarmLoading = ref(false)
242
+
243
+// 漏损记录列表
244
+const leakageList = ref<any[]>([])
245
+const leakageLoading = ref(false)
246
+const leakagePage = ref(1)
247
+const leakagePageSize = ref(10)
248
+const leakageTotal = ref(0)
249
+
250
+function alarmTagType(level: string) {
251
+  return level === 'critical' ? 'danger' : level === 'warning' ? 'warning' : 'success'
252
+}
253
+function alarmLabel(level: string) {
254
+  return level === 'critical' ? '严重' : level === 'warning' ? '预警' : '正常'
255
+}
256
+function getZoneName(zoneId: number) {
257
+  return zoneList.value.find(z => z.id === zoneId)?.zoneName || String(zoneId)
258
+}
259
+
260
+async function loadZones() {
261
+  try {
262
+    const res = await getAllZones()
263
+    zoneList.value = res.data || []
264
+  } catch (e) { /* ignore */ }
265
+}
266
+
267
+async function loadTrend() {
268
+  if (!filterForm.zoneId) {
269
+    initEmptyCharts()
270
+    return
271
+  }
272
+  try {
273
+    const days = 30
274
+    const res = await getLeakageTrend(filterForm.zoneId, days)
275
+    const data = res.data || []
276
+    renderBarChart(data)
277
+    renderLineChart(data)
278
+    updateSummaryStats(data)
279
+  } catch (e) {
280
+    initEmptyCharts()
281
+  }
282
+}
283
+
284
+function updateSummaryStats(data: any[]) {
285
+  if (!data.length) {
286
+    summaryStats.totalSupply = '0'
287
+    summaryStats.totalSale = '0'
288
+    summaryStats.avgNrwRate = '0'
289
+    summaryStats.alarmCount = 0
290
+    return
291
+  }
292
+  let totalSupply = 0, totalSale = 0, totalNrw = 0, alarmCount = 0
293
+  for (const d of data) {
294
+    totalSupply += Number(d.supplyVolume || 0)
295
+    totalSale += Number(d.saleVolume || 0)
296
+    totalNrw += Number(d.nrwRate || 0)
297
+    if (d.alarmLevel && d.alarmLevel !== 'normal') alarmCount++
298
+  }
299
+  summaryStats.totalSupply = totalSupply.toFixed(0)
300
+  summaryStats.totalSale = totalSale.toFixed(0)
301
+  summaryStats.avgNrwRate = (totalNrw / data.length).toFixed(2)
302
+  summaryStats.alarmCount = alarmCount
303
+}
304
+
305
+function renderBarChart(data: any[]) {
306
+  if (!barChartRef.value) return
307
+  if (!barChart) barChart = echarts.init(barChartRef.value)
308
+  const dates = data.map(d => d.date)
309
+  barChart.setOption({
310
+    tooltip: { trigger: 'axis' },
311
+    legend: { data: ['供水量', '售水量'], bottom: 0 },
312
+    grid: { left: '3%', right: '4%', bottom: '12%', containLabel: true },
313
+    xAxis: { type: 'category', data: dates, axisLabel: { rotate: 45, fontSize: 10 } },
314
+    yAxis: { type: 'value', name: 'm³' },
315
+    series: [
316
+      { name: '供水量', type: 'bar', data: data.map(d => d.supplyVolume), itemStyle: { color: '#409eff' } },
317
+      { name: '售水量', type: 'bar', data: data.map(d => d.saleVolume), itemStyle: { color: '#67c23a' } }
318
+    ]
319
+  })
320
+}
321
+
322
+function renderLineChart(data: any[]) {
323
+  if (!lineChartRef.value) return
324
+  if (!lineChart) lineChart = echarts.init(lineChartRef.value)
325
+  const dates = data.map(d => d.date)
326
+  lineChart.setOption({
327
+    tooltip: { trigger: 'axis', formatter: '{b}<br/>{a}: {c}%' },
328
+    legend: { data: ['产销差率', '漏损率'], bottom: 0 },
329
+    grid: { left: '3%', right: '4%', bottom: '12%', containLabel: true },
330
+    xAxis: { type: 'category', data: dates, axisLabel: { rotate: 45, fontSize: 10 } },
331
+    yAxis: { type: 'value', name: '%', axisLabel: { formatter: '{value}%' } },
332
+    series: [
333
+      {
334
+        name: '产销差率', type: 'line', data: data.map(d => d.nrwRate), smooth: true,
335
+        itemStyle: { color: '#e6a23c' },
336
+        markLine: {
337
+          silent: true,
338
+          data: [
339
+            { yAxis: 12, lineStyle: { color: '#e6a23c', type: 'dashed' }, label: { formatter: '预警线 12%' } },
340
+            { yAxis: 20, lineStyle: { color: '#f56c6c', type: 'dashed' }, label: { formatter: '严重线 20%' } }
341
+          ]
342
+        }
343
+      },
344
+      {
345
+        name: '漏损率', type: 'line', data: data.map(d => d.leakageRate || d.nrwRate), smooth: true,
346
+        itemStyle: { color: '#f56c6c' }
347
+      }
348
+    ]
349
+  })
350
+}
351
+
352
+function initEmptyCharts() {
353
+  if (barChartRef.value) {
354
+    if (!barChart) barChart = echarts.init(barChartRef.value)
355
+    barChart.setOption({
356
+      title: { text: '请选择分区查看数据', left: 'center', top: 'center', textStyle: { color: '#999', fontSize: 14 } },
357
+      xAxis: { type: 'category', data: [] }, yAxis: { type: 'value' }, series: []
358
+    })
359
+  }
360
+  if (lineChartRef.value) {
361
+    if (!lineChart) lineChart = echarts.init(lineChartRef.value)
362
+    lineChart.setOption({
363
+      title: { text: '请选择分区查看数据', left: 'center', top: 'center', textStyle: { color: '#999', fontSize: 14 } },
364
+      xAxis: { type: 'category', data: [] }, yAxis: { type: 'value' }, series: []
365
+    })
366
+  }
367
+}
368
+
369
+async function loadMNF() {
370
+  if (!filterForm.zoneId || !mnfDate.value) return
371
+  try {
372
+    const res = await getMNFAnalysis(filterForm.zoneId, mnfDate.value)
373
+    const d = res.data
374
+    mnfData.mnf = String(d.mnf || '0')
375
+    mnfData.analysisResult = d.analysisResult || '正常'
376
+  } catch (e) { /* ignore */ }
377
+}
378
+
379
+async function handleAnalyze() {
380
+  if (!analyzeForm.zoneId || !analyzeForm.date) {
381
+    ElMessage.warning('请选择分区和日期')
382
+    return
383
+  }
384
+  analyzing.value = true
385
+  try {
386
+    const res = await analyzeLeakage({
387
+      zoneId: analyzeForm.zoneId,
388
+      date: analyzeForm.date,
389
+      supplyVolume: analyzeForm.supplyVolume,
390
+      saleVolume: analyzeForm.saleVolume
391
+    })
392
+    lastResult.value = res.data
393
+    ElMessage.success('分析完成')
394
+    loadLeakagePage()
395
+    loadTrend()
396
+  } catch (e: any) {
397
+    ElMessage.error(e.message || '分析失败')
398
+  } finally {
399
+    analyzing.value = false
400
+  }
401
+}
402
+
403
+async function loadAlarms() {
404
+  alarmLoading.value = true
405
+  try {
406
+    const res = await getAlarms(alarmFilter.value || undefined)
407
+    alarmList.value = res.data || []
408
+  } finally {
409
+    alarmLoading.value = false
410
+  }
411
+}
412
+
413
+async function loadLeakagePage() {
414
+  leakageLoading.value = true
415
+  try {
416
+    const params: any = { pageNum: leakagePage.value, pageSize: leakagePageSize.value }
417
+    if (filterForm.zoneId) params.zoneId = filterForm.zoneId
418
+    if (dateRange.value) {
419
+      params.startDate = dateRange.value[0]
420
+      params.endDate = dateRange.value[1]
421
+    }
422
+    const res = await getLeakagePage(params)
423
+    leakageList.value = res.data?.records || []
424
+    leakageTotal.value = res.data?.total || 0
425
+  } finally {
426
+    leakageLoading.value = false
427
+  }
428
+}
429
+
430
+function handleFilterChange() {
431
+  loadTrend()
432
+  loadLeakagePage()
433
+  if (filterForm.zoneId) {
434
+    const today = new Date().toISOString().split('T')[0]
435
+    mnfDate.value = today
436
+    loadMNF()
437
+  }
438
+}
439
+
440
+function handleReset() {
441
+  filterForm.zoneId = null
442
+  dateRange.value = null
443
+  handleFilterChange()
444
+}
445
+
446
+function handleLeakagePageChange(p: number) {
447
+  leakagePage.value = p
448
+  loadLeakagePage()
449
+}
450
+
451
+onMounted(async () => {
452
+  await loadZones()
453
+  await nextTick()
454
+  initEmptyCharts()
455
+  loadAlarms()
456
+  loadLeakagePage()
457
+
458
+  window.addEventListener('resize', () => {
459
+    barChart?.resize()
460
+    lineChart?.resize()
461
+  })
462
+})
463
+</script>
464
+
465
+<style scoped>
466
+.leakage-analysis { padding: 0; }
467
+.filter-card :deep(.el-card__body) { padding-bottom: 2px; }
468
+.stat-card { text-align: center; cursor: default; }
469
+.stat-value { font-size: 28px; font-weight: bold; }
470
+.stat-label { font-size: 13px; color: #999; margin-top: 4px; }
471
+</style>

+ 375
- 0
frontend/src/views/dma/PartitionView.vue Просмотреть файл

@@ -0,0 +1,375 @@
1
+<template>
2
+  <div class="dma-partition">
3
+    <el-row :gutter="16">
4
+      <!-- 左侧树形分区结构 -->
5
+      <el-col :span="8">
6
+        <el-card shadow="never">
7
+          <template #header>
8
+            <div style="display: flex; justify-content: space-between; align-items: center">
9
+              <span>DMA分区树</span>
10
+              <el-button type="primary" size="small" @click="handleAddRoot">
11
+                <el-icon><Plus /></el-icon> 新增顶级分区
12
+              </el-button>
13
+            </div>
14
+          </template>
15
+          <el-tree
16
+            :data="treeData"
17
+            :props="treeProps"
18
+            node-key="id"
19
+            highlight-current
20
+            default-expand-all
21
+            @node-click="handleNodeClick"
22
+            :expand-on-click-node="false"
23
+          >
24
+            <template #default="{ node, data }">
25
+              <div class="tree-node">
26
+                <el-icon v-if="data.zoneLevel === 1" style="color: #409eff"><OfficeBuilding /></el-icon>
27
+                <el-icon v-else-if="data.zoneLevel === 2" style="color: #67c23a"><Grid /></el-icon>
28
+                <el-icon v-else style="color: #e6a23c"><Position /></el-icon>
29
+                <span style="margin-left: 6px">{{ data.zoneName }}</span>
30
+                <span class="tree-node-level">
31
+                  <el-tag size="small" :type="levelTagType(data.zoneLevel)">
32
+                    {{ levelLabel(data.zoneLevel) }}
33
+                  </el-tag>
34
+                </span>
35
+                <span class="tree-node-actions">
36
+                  <el-button link type="primary" size="small" @click.stop="handleAddChild(data)">
37
+                    <el-icon><Plus /></el-icon>
38
+                  </el-button>
39
+                  <el-button link type="warning" size="small" @click.stop="handleEdit(data)">
40
+                    <el-icon><Edit /></el-icon>
41
+                  </el-button>
42
+                  <el-button link type="danger" size="small" @click.stop="handleDelete(data)">
43
+                    <el-icon><Delete /></el-icon>
44
+                  </el-button>
45
+                </span>
46
+              </div>
47
+            </template>
48
+          </el-tree>
49
+        </el-card>
50
+      </el-col>
51
+
52
+      <!-- 右侧详情与分区列表 -->
53
+      <el-col :span="16">
54
+        <!-- 分区详情卡片 -->
55
+        <el-card shadow="never" v-if="selectedZone" style="margin-bottom: 16px">
56
+          <template #header>
57
+            <span>分区详情 - {{ selectedZone.zoneName }}</span>
58
+          </template>
59
+          <el-descriptions :column="3" border>
60
+            <el-descriptions-item label="分区编码">{{ selectedZone.zoneCode }}</el-descriptions-item>
61
+            <el-descriptions-item label="分区名称">{{ selectedZone.zoneName }}</el-descriptions-item>
62
+            <el-descriptions-item label="分区层级">
63
+              <el-tag :type="levelTagType(selectedZone.zoneLevel)" size="small">
64
+                {{ levelLabel(selectedZone.zoneLevel) }}
65
+              </el-tag>
66
+            </el-descriptions-item>
67
+            <el-descriptions-item label="所属区域">{{ selectedZone.area || '-' }}</el-descriptions-item>
68
+            <el-descriptions-item label="分区面积">{{ selectedZone.areaSize ? selectedZone.areaSize + ' km²' : '-' }}</el-descriptions-item>
69
+            <el-descriptions-item label="服务人口">{{ selectedZone.population ? selectedZone.population + ' 人' : '-' }}</el-descriptions-item>
70
+            <el-descriptions-item label="管网长度">{{ selectedZone.pipeLength ? selectedZone.pipeLength + ' km' : '-' }}</el-descriptions-item>
71
+            <el-descriptions-item label="状态">
72
+              <el-tag :type="selectedZone.status === 'active' ? 'success' : 'info'" size="small">
73
+                {{ selectedZone.status === 'active' ? '启用' : '停用' }}
74
+              </el-tag>
75
+            </el-descriptions-item>
76
+            <el-descriptions-item label="备注">{{ selectedZone.remark || '-' }}</el-descriptions-item>
77
+          </el-descriptions>
78
+        </el-card>
79
+
80
+        <!-- 分区列表 -->
81
+        <el-card shadow="never">
82
+          <template #header>
83
+            <div style="display: flex; justify-content: space-between; align-items: center">
84
+              <span>分区列表</span>
85
+              <el-form :inline="true" style="margin: 0">
86
+                <el-form-item style="margin: 0">
87
+                  <el-input v-model="searchName" placeholder="搜索分区名称" clearable @clear="loadPage" @keyup.enter="loadPage" />
88
+                </el-form-item>
89
+                <el-form-item style="margin: 0 0 0 8px">
90
+                  <el-button type="primary" @click="loadPage">查询</el-button>
91
+                </el-form-item>
92
+              </el-form>
93
+            </div>
94
+          </template>
95
+
96
+          <el-table :data="tableData" border v-loading="loading" stripe>
97
+            <el-table-column prop="zoneCode" label="分区编码" width="140" />
98
+            <el-table-column prop="zoneName" label="分区名称" min-width="160" />
99
+            <el-table-column prop="zoneLevel" label="层级" width="90" align="center">
100
+              <template #default="{ row }">
101
+                <el-tag :type="levelTagType(row.zoneLevel)" size="small">{{ levelLabel(row.zoneLevel) }}</el-tag>
102
+              </template>
103
+            </el-table-column>
104
+            <el-table-column prop="area" label="所属区域" width="120" />
105
+            <el-table-column prop="areaSize" label="面积(km²)" width="100" align="right" />
106
+            <el-table-column prop="population" label="服务人口" width="100" align="right" />
107
+            <el-table-column prop="status" label="状态" width="80" align="center">
108
+              <template #default="{ row }">
109
+                <el-tag :type="row.status === 'active' ? 'success' : 'info'" size="small">
110
+                  {{ row.status === 'active' ? '启用' : '停用' }}
111
+                </el-tag>
112
+              </template>
113
+            </el-table-column>
114
+            <el-table-column label="操作" width="160" fixed="right">
115
+              <template #default="{ row }">
116
+                <el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
117
+                <el-button link type="danger" @click="handleDelete(row)">删除</el-button>
118
+              </template>
119
+            </el-table-column>
120
+          </el-table>
121
+
122
+          <el-pagination
123
+            style="margin-top: 12px; justify-content: flex-end"
124
+            :current-page="pageNum"
125
+            :page-size="pageSize"
126
+            :total="total"
127
+            :page-sizes="[10, 20, 50]"
128
+            layout="total, sizes, prev, pager, next"
129
+            @current-change="handlePageChange"
130
+            @size-change="handleSizeChange"
131
+          />
132
+        </el-card>
133
+      </el-col>
134
+    </el-row>
135
+
136
+    <!-- 新增/编辑分区对话框 -->
137
+    <el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px" destroy-on-close>
138
+      <el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
139
+        <el-form-item label="分区编码" prop="zoneCode">
140
+          <el-input v-model="form.zoneCode" placeholder="如 DMA-001" />
141
+        </el-form-item>
142
+        <el-form-item label="分区名称" prop="zoneName">
143
+          <el-input v-model="form.zoneName" placeholder="如 城北区一级分区" />
144
+        </el-form-item>
145
+        <el-form-item label="分区层级" prop="zoneLevel">
146
+          <el-radio-group v-model="form.zoneLevel">
147
+            <el-radio-button :value="1">一级分区</el-radio-button>
148
+            <el-radio-button :value="2">二级分区</el-radio-button>
149
+            <el-radio-button :value="3">三级分区</el-radio-button>
150
+          </el-radio-group>
151
+        </el-form-item>
152
+        <el-form-item label="父级分区" v-if="form.parentId">
153
+          <el-select v-model="form.parentId" placeholder="无(顶级)" clearable @clear="form.parentId = null">
154
+            <el-option v-for="z in allZones" :key="z.id" :label="z.zoneName" :value="z.id" />
155
+          </el-select>
156
+        </el-form-item>
157
+        <el-form-item label="所属区域" prop="area">
158
+          <el-input v-model="form.area" placeholder="如 城北区" />
159
+        </el-form-item>
160
+        <el-row :gutter="16">
161
+          <el-col :span="8">
162
+            <el-form-item label="面积(km²)">
163
+              <el-input-number v-model="form.areaSize" :min="0" :precision="2" controls-position="right" style="width: 100%" />
164
+            </el-form-item>
165
+          </el-col>
166
+          <el-col :span="8">
167
+            <el-form-item label="服务人口">
168
+              <el-input-number v-model="form.population" :min="0" controls-position="right" style="width: 100%" />
169
+            </el-form-item>
170
+          </el-col>
171
+          <el-col :span="8">
172
+            <el-form-item label="管网长度(km)">
173
+              <el-input-number v-model="form.pipeLength" :min="0" :precision="2" controls-position="right" style="width: 100%" />
174
+            </el-form-item>
175
+          </el-col>
176
+        </el-row>
177
+        <el-form-item label="状态">
178
+          <el-radio-group v-model="form.status">
179
+            <el-radio value="active">启用</el-radio>
180
+            <el-radio value="inactive">停用</el-radio>
181
+          </el-radio-group>
182
+        </el-form-item>
183
+        <el-form-item label="备注">
184
+          <el-input v-model="form.remark" type="textarea" :rows="2" />
185
+        </el-form-item>
186
+      </el-form>
187
+      <template #footer>
188
+        <el-button @click="dialogVisible = false">取消</el-button>
189
+        <el-button type="primary" @click="submitForm" :loading="submitting">确定</el-button>
190
+      </template>
191
+    </el-dialog>
192
+  </div>
193
+</template>
194
+
195
+<script setup lang="ts">
196
+import { ref, reactive, onMounted } from 'vue'
197
+import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus'
198
+import { Plus, Edit, Delete, OfficeBuilding, Grid, Position } from '@element-plus/icons-vue'
199
+import { getZonePage, getZoneTree, getAllZones, createZone, updateZone, deleteZone } from '@/api/dma'
200
+
201
+// 树形配置
202
+const treeProps = { children: 'children', label: 'zoneName' }
203
+const treeData = ref<any[]>([])
204
+
205
+// 列表
206
+const tableData = ref<any[]>([])
207
+const loading = ref(false)
208
+const pageNum = ref(1)
209
+const pageSize = ref(10)
210
+const total = ref(0)
211
+const searchName = ref('')
212
+
213
+// 选中分区
214
+const selectedZone = ref<any>(null)
215
+
216
+// 对话框
217
+const dialogVisible = ref(false)
218
+const dialogTitle = ref('新增分区')
219
+const submitting = ref(false)
220
+const formRef = ref<FormInstance>()
221
+const allZones = ref<any[]>([])
222
+const isEdit = ref(false)
223
+
224
+const form = reactive({
225
+  id: null as number | null,
226
+  zoneCode: '',
227
+  zoneName: '',
228
+  zoneLevel: 1,
229
+  parentId: null as number | null,
230
+  area: '',
231
+  areaSize: null as number | null,
232
+  population: null as number | null,
233
+  pipeLength: null as number | null,
234
+  status: 'active',
235
+  remark: ''
236
+})
237
+
238
+const rules = {
239
+  zoneCode: [{ required: true, message: '请输入分区编码', trigger: 'blur' }],
240
+  zoneName: [{ required: true, message: '请输入分区名称', trigger: 'blur' }],
241
+  zoneLevel: [{ required: true, message: '请选择分区层级', trigger: 'change' }]
242
+}
243
+
244
+function levelLabel(level: number) {
245
+  return level === 1 ? '一级' : level === 2 ? '二级' : '三级'
246
+}
247
+
248
+function levelTagType(level: number) {
249
+  return level === 1 ? '' : level === 2 ? 'success' : 'warning'
250
+}
251
+
252
+async function loadTree() {
253
+  try {
254
+    const res = await getZoneTree()
255
+    treeData.value = res.data || []
256
+  } catch (e) { /* ignore */ }
257
+}
258
+
259
+async function loadPage() {
260
+  loading.value = true
261
+  try {
262
+    const res = await getZonePage({ pageNum: pageNum.value, pageSize: pageSize.value, zoneName: searchName.value })
263
+    tableData.value = res.data?.records || []
264
+    total.value = res.data?.total || 0
265
+  } finally {
266
+    loading.value = false
267
+  }
268
+}
269
+
270
+async function loadAllZones() {
271
+  try {
272
+    const res = await getAllZones()
273
+    allZones.value = res.data || []
274
+  } catch (e) { /* ignore */ }
275
+}
276
+
277
+function handleNodeClick(data: any) {
278
+  selectedZone.value = data
279
+}
280
+
281
+function handleAddRoot() {
282
+  resetForm()
283
+  dialogTitle.value = '新增顶级分区'
284
+  isEdit.value = false
285
+  form.parentId = null
286
+  dialogVisible.value = true
287
+}
288
+
289
+function handleAddChild(parent: any) {
290
+  resetForm()
291
+  dialogTitle.value = `新增子分区 (父: ${parent.zoneName})`
292
+  isEdit.value = false
293
+  form.parentId = parent.id
294
+  form.zoneLevel = Math.min((parent.zoneLevel || 1) + 1, 3)
295
+  dialogVisible.value = true
296
+}
297
+
298
+function handleEdit(row: any) {
299
+  resetForm()
300
+  dialogTitle.value = '编辑分区'
301
+  isEdit.value = true
302
+  Object.assign(form, {
303
+    id: row.id,
304
+    zoneCode: row.zoneCode,
305
+    zoneName: row.zoneName,
306
+    zoneLevel: row.zoneLevel,
307
+    parentId: row.parentId,
308
+    area: row.area || '',
309
+    areaSize: row.areaSize,
310
+    population: row.population,
311
+    pipeLength: row.pipeLength,
312
+    status: row.status || 'active',
313
+    remark: row.remark || ''
314
+  })
315
+  loadAllZones()
316
+  dialogVisible.value = true
317
+}
318
+
319
+async function handleDelete(row: any) {
320
+  await ElMessageBox.confirm(`确认删除分区「${row.zoneName}」?`, '提示', { type: 'warning' })
321
+  try {
322
+    await deleteZone(row.id)
323
+    ElMessage.success('删除成功')
324
+    loadTree()
325
+    loadPage()
326
+    if (selectedZone.value?.id === row.id) selectedZone.value = null
327
+  } catch (e: any) {
328
+    ElMessage.error(e.message || '删除失败')
329
+  }
330
+}
331
+
332
+function resetForm() {
333
+  Object.assign(form, {
334
+    id: null, zoneCode: '', zoneName: '', zoneLevel: 1, parentId: null,
335
+    area: '', areaSize: null, population: null, pipeLength: null, status: 'active', remark: ''
336
+  })
337
+}
338
+
339
+async function submitForm() {
340
+  if (!formRef.value) return
341
+  await formRef.value.validate()
342
+  submitting.value = true
343
+  try {
344
+    if (isEdit.value && form.id) {
345
+      await updateZone(form.id, form)
346
+      ElMessage.success('更新成功')
347
+    } else {
348
+      await createZone(form)
349
+      ElMessage.success('创建成功')
350
+    }
351
+    dialogVisible.value = false
352
+    loadTree()
353
+    loadPage()
354
+  } catch (e: any) {
355
+    ElMessage.error(e.message || '操作失败')
356
+  } finally {
357
+    submitting.value = false
358
+  }
359
+}
360
+
361
+function handlePageChange(p: number) { pageNum.value = p; loadPage() }
362
+function handleSizeChange(s: number) { pageSize.value = s; pageNum.value = 1; loadPage() }
363
+
364
+onMounted(() => { loadTree(); loadPage() })
365
+</script>
366
+
367
+<style scoped>
368
+.dma-partition { padding: 0; }
369
+.tree-node {
370
+  display: flex; align-items: center; width: 100%; padding-right: 8px;
371
+}
372
+.tree-node-level { margin-left: auto; margin-right: 8px; }
373
+.tree-node-actions { display: none; }
374
+.tree-node:hover .tree-node-actions { display: inline-flex; }
375
+</style>

+ 136
- 0
wm-dma/src/main/resources/db/V_dma.sql Просмотреть файл

@@ -0,0 +1,136 @@
1
+-- =====================================================
2
+-- DMA分区计量与漏损分析 DDL
3
+-- 数据库: PostgreSQL
4
+-- =====================================================
5
+
6
+-- DMA分区表
7
+CREATE TABLE IF NOT EXISTS dma_zone (
8
+    id              BIGSERIAL PRIMARY KEY,
9
+    zone_name       VARCHAR(100) NOT NULL,
10
+    zone_code       VARCHAR(50) NOT NULL UNIQUE,
11
+    parent_id       BIGINT REFERENCES dma_zone(id),
12
+    zone_level      INTEGER NOT NULL DEFAULT 1,
13
+    area            VARCHAR(100),
14
+    area_size       NUMERIC(10, 2),
15
+    population      INTEGER,
16
+    pipe_length     NUMERIC(10, 2),
17
+    status          VARCHAR(20) DEFAULT 'active',
18
+    remark          VARCHAR(500),
19
+    deleted         INTEGER DEFAULT 0,
20
+    created_at      TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
21
+    updated_at      TIMESTAMP DEFAULT CURRENT_TIMESTAMP
22
+);
23
+
24
+COMMENT ON TABLE dma_zone IS 'DMA分区表';
25
+COMMENT ON COLUMN dma_zone.zone_level IS '分区层级: 1=一级/2=二级/3=三级';
26
+COMMENT ON COLUMN dma_zone.status IS '状态: active/inactive';
27
+
28
+-- DMA计量表
29
+CREATE TABLE IF NOT EXISTS dma_meter (
30
+    id              BIGSERIAL PRIMARY KEY,
31
+    zone_id         BIGINT REFERENCES dma_zone(id),
32
+    meter_code      VARCHAR(50) NOT NULL UNIQUE,
33
+    meter_name      VARCHAR(100),
34
+    meter_type      VARCHAR(20) NOT NULL,
35
+    location        VARCHAR(200),
36
+    longitude       NUMERIC(12, 8),
37
+    latitude        NUMERIC(12, 8),
38
+    caliber         INTEGER,
39
+    brand           VARCHAR(100),
40
+    status          VARCHAR(20) DEFAULT 'online',
41
+    remark          VARCHAR(500),
42
+    deleted         INTEGER DEFAULT 0,
43
+    created_at      TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
44
+    updated_at      TIMESTAMP DEFAULT CURRENT_TIMESTAMP
45
+);
46
+
47
+COMMENT ON TABLE dma_meter IS 'DMA计量表';
48
+COMMENT ON COLUMN dma_meter.meter_type IS '表计类型: inlet=进水表/outlet=出水表/boundary=边界表';
49
+COMMENT ON COLUMN dma_meter.status IS '状态: online/offline/fault';
50
+
51
+CREATE INDEX IF NOT EXISTS idx_meter_zone ON dma_meter(zone_id);
52
+
53
+-- DMA流量记录表
54
+CREATE TABLE IF NOT EXISTS dma_flow_record (
55
+    id              BIGSERIAL PRIMARY KEY,
56
+    zone_id         BIGINT NOT NULL REFERENCES dma_zone(id),
57
+    meter_id        BIGINT NOT NULL REFERENCES dma_meter(id),
58
+    instant_flow    NUMERIC(12, 4),
59
+    total_flow      NUMERIC(14, 4),
60
+    pressure        NUMERIC(8, 4),
61
+    collect_time    TIMESTAMP NOT NULL,
62
+    data_quality    VARCHAR(20) DEFAULT 'good',
63
+    deleted         INTEGER DEFAULT 0,
64
+    created_at      TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
65
+    updated_at      TIMESTAMP DEFAULT CURRENT_TIMESTAMP
66
+);
67
+
68
+COMMENT ON TABLE dma_flow_record IS 'DMA流量记录表';
69
+COMMENT ON COLUMN dma_flow_record.instant_flow IS '瞬时流量(m³/h)';
70
+COMMENT ON COLUMN dma_flow_record.total_flow IS '累计流量(m³)';
71
+COMMENT ON COLUMN dma_flow_record.pressure IS '压力(MPa)';
72
+COMMENT ON COLUMN dma_flow_record.data_quality IS '数据质量: good/bad/missing';
73
+
74
+CREATE INDEX IF NOT EXISTS idx_flow_zone_time ON dma_flow_record(zone_id, collect_time);
75
+CREATE INDEX IF NOT EXISTS idx_flow_meter_time ON dma_flow_record(meter_id, collect_time);
76
+
77
+-- DMA漏损分析表
78
+CREATE TABLE IF NOT EXISTS dma_leakage_analysis (
79
+    id                  BIGSERIAL PRIMARY KEY,
80
+    zone_id             BIGINT NOT NULL REFERENCES dma_zone(id),
81
+    analysis_date       DATE NOT NULL,
82
+    supply_volume       NUMERIC(14, 4),
83
+    sale_volume         NUMERIC(14, 4),
84
+    leakage_volume      NUMERIC(14, 4),
85
+    nrw_rate            NUMERIC(8, 2),
86
+    leakage_rate        NUMERIC(8, 2),
87
+    mnf                 NUMERIC(10, 4),
88
+    mnf_time            VARCHAR(20),
89
+    background_leakage  NUMERIC(10, 4),
90
+    burst_leakage       NUMERIC(10, 4),
91
+    alarm_level         VARCHAR(20) DEFAULT 'normal',
92
+    remark              VARCHAR(500),
93
+    deleted             INTEGER DEFAULT 0,
94
+    created_at          TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
95
+    updated_at          TIMESTAMP DEFAULT CURRENT_TIMESTAMP
96
+);
97
+
98
+COMMENT ON TABLE dma_leakage_analysis IS 'DMA漏损分析表';
99
+COMMENT ON COLUMN dma_leakage_analysis.nrw_rate IS '产销差率(%)';
100
+COMMENT ON COLUMN dma_leakage_analysis.leakage_rate IS '漏损率(%)';
101
+COMMENT ON COLUMN dma_leakage_analysis.mnf IS '最小夜间流量(m³/h)';
102
+COMMENT ON COLUMN dma_leakage_analysis.alarm_level IS '报警级别: normal/warning/critical';
103
+
104
+CREATE INDEX IF NOT EXISTS idx_leakage_zone_date ON dma_leakage_analysis(zone_id, analysis_date);
105
+CREATE UNIQUE INDEX IF NOT EXISTS uk_leakage_zone_date ON dma_leakage_analysis(zone_id, analysis_date) WHERE deleted = 0;
106
+
107
+-- 水平衡表
108
+CREATE TABLE IF NOT EXISTS dma_water_balance (
109
+    id                  BIGSERIAL PRIMARY KEY,
110
+    zone_id             BIGINT NOT NULL REFERENCES dma_zone(id),
111
+    period              VARCHAR(20) NOT NULL,
112
+    stat_date           DATE NOT NULL,
113
+    total_supply        NUMERIC(14, 4),
114
+    total_sale          NUMERIC(14, 4),
115
+    billing_sale        NUMERIC(14, 4),
116
+    free_supply         NUMERIC(14, 4),
117
+    apparent_loss       NUMERIC(14, 4),
118
+    real_loss           NUMERIC(14, 4),
119
+    background_loss     NUMERIC(14, 4),
120
+    burst_loss          NUMERIC(14, 4),
121
+    total_loss          NUMERIC(14, 4),
122
+    nrw_rate            NUMERIC(8, 2),
123
+    leakage_rate        NUMERIC(8, 2),
124
+    remark              VARCHAR(500),
125
+    deleted             INTEGER DEFAULT 0,
126
+    created_at          TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
127
+    updated_at          TIMESTAMP DEFAULT CURRENT_TIMESTAMP
128
+);
129
+
130
+COMMENT ON TABLE dma_water_balance IS '水平衡表';
131
+COMMENT ON COLUMN dma_water_balance.period IS '统计周期: daily/monthly/yearly';
132
+COMMENT ON COLUMN dma_water_balance.apparent_loss IS '表观漏损(m³) - 计量误差+偷水';
133
+COMMENT ON COLUMN dma_water_balance.real_loss IS '实际漏损(m³) - 物理漏损';
134
+
135
+CREATE INDEX IF NOT EXISTS idx_balance_zone_date ON dma_water_balance(zone_id, stat_date);
136
+CREATE INDEX IF NOT EXISTS idx_balance_period ON dma_water_balance(period);