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

feat(wm-production): #69 调度指令管理完整实现

- 实体: DispatchCommand/DispatchExecution/DispatchTracking
- Mapper: MyBatis-Plus + XML (含台账分页/详情/统计)
- Service: 完整状态机 (draft→issued→received→executing→completed/rejected)
- Controller: /api/production/dispatch-command (全生命周期API)
- SQL DDL: 三表+索引
- 前端: CommandList/CommandDetail/CommandCreate (Vue3+TS+Element Plus)
- 单元测试: DispatchCommandServiceTest + DispatchTrackingServiceTest
bot_dev2 5 дней назад
Родитель
Сommit
6c6db59ba9
18 измененных файлов: 1715 добавлений и 0 удалений
  1. 68
    0
      frontend/src/api/dispatchCommand.ts
  2. 2
    0
      frontend/src/router/index.ts
  3. 135
    0
      frontend/src/views/dispatch-command/CommandCreate.vue
  4. 304
    0
      frontend/src/views/dispatch-command/CommandDetail.vue
  5. 193
    0
      frontend/src/views/dispatch-command/CommandList.vue
  6. 106
    0
      wm-production/src/main/java/com/water/production/controller/DispatchCommandController.java
  7. 64
    0
      wm-production/src/main/java/com/water/production/entity/DispatchCommand.java
  8. 52
    0
      wm-production/src/main/java/com/water/production/entity/DispatchExecution.java
  9. 43
    0
      wm-production/src/main/java/com/water/production/entity/DispatchTracking.java
  10. 28
    0
      wm-production/src/main/java/com/water/production/mapper/DispatchCommandMapper.java
  11. 9
    0
      wm-production/src/main/java/com/water/production/mapper/DispatchExecutionMapper.java
  12. 9
    0
      wm-production/src/main/java/com/water/production/mapper/DispatchTrackingMapper.java
  13. 228
    0
      wm-production/src/main/java/com/water/production/service/DispatchCommandService.java
  14. 53
    0
      wm-production/src/main/java/com/water/production/service/DispatchTrackingService.java
  15. 57
    0
      wm-production/src/main/resources/db/V_dispatch_command.sql
  16. 44
    0
      wm-production/src/main/resources/mapper/DispatchCommandMapper.xml
  17. 223
    0
      wm-production/src/test/java/com/water/production/service/DispatchCommandServiceTest.java
  18. 97
    0
      wm-production/src/test/java/com/water/production/service/DispatchTrackingServiceTest.java

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

@@ -0,0 +1,68 @@
1
+import request from './request'
2
+
3
+const BASE = '/api/production/dispatch-command'
4
+
5
+// 创建指令
6
+export function createCommand(data: any) {
7
+  return request.post(BASE, data)
8
+}
9
+
10
+// 下发指令
11
+export function issueCommand(id: number, issuedBy: number, operatorName?: string) {
12
+  return request.post(`${BASE}/${id}/issue`, null, {
13
+    params: { issuedBy, operatorName: operatorName || 'system' }
14
+  })
15
+}
16
+
17
+// 指令台账
18
+export function listCommands(params: {
19
+  page?: number; size?: number; status?: string;
20
+  commandType?: string; keyword?: string; startDate?: string; endDate?: string
21
+}) {
22
+  return request.get(BASE, { params })
23
+}
24
+
25
+// 指令详情
26
+export function getCommandDetail(id: number) {
27
+  return request.get(`${BASE}/${id}`)
28
+}
29
+
30
+// 状态统计
31
+export function getCommandStats() {
32
+  return request.get(`${BASE}/stats`)
33
+}
34
+
35
+// 接收确认
36
+export function receiveCommand(id: number, userId: number, userName?: string) {
37
+  return request.post(`${BASE}/${id}/receive`, null, {
38
+    params: { userId, userName: userName || '' }
39
+  })
40
+}
41
+
42
+// 开始执行
43
+export function startExecute(id: number, userId: number, userName?: string) {
44
+  return request.post(`${BASE}/${id}/start-execute`, null, {
45
+    params: { userId, userName: userName || '' }
46
+  })
47
+}
48
+
49
+// 完成执行
50
+export function completeExecution(id: number, userId: number, data: {
51
+  userName?: string; feedback?: string; feedbackImages?: string
52
+}) {
53
+  return request.post(`${BASE}/${id}/complete`, null, {
54
+    params: { userId, ...data }
55
+  })
56
+}
57
+
58
+// 驳回
59
+export function rejectExecution(id: number, userId: number, reason: string, userName?: string) {
60
+  return request.post(`${BASE}/${id}/reject`, null, {
61
+    params: { userId, userName: userName || '', reason }
62
+  })
63
+}
64
+
65
+// 追踪日志
66
+export function getTrackingLogs(id: number) {
67
+  return request.get(`${BASE}/${id}/tracking`)
68
+}

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

@@ -11,6 +11,8 @@ const routes = [
11 11
       { path: 'system/role', name: 'role', component: () => import('@/views/system/role/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
+      { path: 'dispatch-command', name: 'dispatchCommandList', component: () => import('@/views/dispatch-command/CommandList.vue') },
15
+      { path: 'dispatch-command/:id', name: 'dispatchCommandDetail', component: () => import('@/views/dispatch-command/CommandDetail.vue') },
14 16
     ]
15 17
   },
16 18
   { path: '/:pathMatch(.*)*', redirect: '/dashboard' }

+ 135
- 0
frontend/src/views/dispatch-command/CommandCreate.vue Просмотреть файл

@@ -0,0 +1,135 @@
1
+<template>
2
+  <el-dialog
3
+    :model-value="visible"
4
+    @update:model-value="$emit('update:visible', $event)"
5
+    title="创建调度指令"
6
+    width="600"
7
+    :close-on-click-modal="false">
8
+    <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
9
+      <el-form-item label="指令标题" prop="commandTitle">
10
+        <el-input v-model="form.commandTitle" placeholder="请输入指令标题" maxlength="200" show-word-limit />
11
+      </el-form-item>
12
+
13
+      <el-form-item label="指令类型" prop="commandType">
14
+        <el-select v-model="form.commandType" placeholder="请选择">
15
+          <el-option label="常规" value="normal" />
16
+          <el-option label="应急" value="emergency" />
17
+          <el-option label="维护" value="maintenance" />
18
+          <el-option label="巡检" value="inspection" />
19
+        </el-select>
20
+      </el-form-item>
21
+
22
+      <el-form-item label="优先级" prop="priority">
23
+        <el-radio-group v-model="form.priority">
24
+          <el-radio value="low">低</el-radio>
25
+          <el-radio value="normal">普通</el-radio>
26
+          <el-radio value="high">高</el-radio>
27
+          <el-radio value="urgent">紧急</el-radio>
28
+        </el-radio-group>
29
+      </el-form-item>
30
+
31
+      <el-form-item label="来源" prop="source">
32
+        <el-input v-model="form.source" placeholder="手动/系统/报警联动" />
33
+      </el-form-item>
34
+
35
+      <el-form-item label="指令内容" prop="commandContent">
36
+        <el-input v-model="form.commandContent" type="textarea" :rows="5"
37
+          placeholder="请输入指令详细内容" maxlength="2000" show-word-limit />
38
+      </el-form-item>
39
+
40
+      <el-form-item label="目标类型" prop="targetType">
41
+        <el-select v-model="form.targetType" placeholder="请选择">
42
+          <el-option label="指定人员" value="user" />
43
+          <el-option label="部门" value="dept" />
44
+          <el-option label="角色" value="role" />
45
+        </el-select>
46
+      </el-form-item>
47
+
48
+      <el-form-item label="目标人员" prop="targetIds">
49
+        <el-input v-model="form.targetIds" placeholder="目标ID列表,多个用逗号分隔,如: 1,2,3" />
50
+        <div class="form-tip">输入用户ID,多个用逗号分隔</div>
51
+      </el-form-item>
52
+
53
+      <el-form-item label="备注">
54
+        <el-input v-model="form.remark" type="textarea" :rows="2" placeholder="备注信息(可选)" />
55
+      </el-form-item>
56
+    </el-form>
57
+
58
+    <template #footer>
59
+      <el-button @click="$emit('update:visible', false)">取消</el-button>
60
+      <el-button type="primary" :loading="submitting" @click="handleSubmit">
61
+        创建指令
62
+      </el-button>
63
+    </template>
64
+  </el-dialog>
65
+</template>
66
+
67
+<script setup lang="ts">
68
+import { ref, reactive, watch } from 'vue'
69
+import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
70
+import { createCommand } from '@/api/dispatchCommand'
71
+
72
+const props = defineProps<{ visible: boolean }>()
73
+const emit = defineEmits<{
74
+  'update:visible': [value: boolean]
75
+  'created': []
76
+}>()
77
+
78
+const formRef = ref<FormInstance>()
79
+const submitting = ref(false)
80
+
81
+const defaultForm = () => ({
82
+  commandTitle: '',
83
+  commandType: 'normal',
84
+  priority: 'normal',
85
+  source: '手动',
86
+  commandContent: '',
87
+  targetType: 'user',
88
+  targetIds: '',
89
+  remark: ''
90
+})
91
+
92
+const form = reactive(defaultForm())
93
+
94
+const rules: FormRules = {
95
+  commandTitle: [{ required: true, message: '请输入指令标题', trigger: 'blur' }],
96
+  commandType: [{ required: true, message: '请选择指令类型', trigger: 'change' }],
97
+  priority: [{ required: true, message: '请选择优先级', trigger: 'change' }],
98
+  commandContent: [{ required: true, message: '请输入指令内容', trigger: 'blur' }],
99
+  targetType: [{ required: true, message: '请选择目标类型', trigger: 'change' }],
100
+  targetIds: [{ required: true, message: '请输入目标ID', trigger: 'blur' }]
101
+}
102
+
103
+watch(() => props.visible, (val) => {
104
+  if (val) {
105
+    Object.assign(form, defaultForm())
106
+    formRef.value?.resetFields()
107
+  }
108
+})
109
+
110
+async function handleSubmit() {
111
+  if (!formRef.value) return
112
+  await formRef.value.validate()
113
+
114
+  submitting.value = true
115
+  try {
116
+    // 将 targetIds 转为 JSON 数组格式
117
+    const targetIdsArr = form.targetIds.split(',').map((s: string) => s.trim()).filter(Boolean)
118
+    await createCommand({
119
+      ...form,
120
+      targetIds: JSON.stringify(targetIdsArr.map(Number))
121
+    })
122
+    ElMessage.success('指令创建成功')
123
+    emit('update:visible', false)
124
+    emit('created')
125
+  } catch (e: any) {
126
+    ElMessage.error(e.message || '创建失败')
127
+  } finally {
128
+    submitting.value = false
129
+  }
130
+}
131
+</script>
132
+
133
+<style scoped>
134
+.form-tip { font-size: 12px; color: #909399; margin-top: 4px; }
135
+</style>

+ 304
- 0
frontend/src/views/dispatch-command/CommandDetail.vue Просмотреть файл

@@ -0,0 +1,304 @@
1
+<template>
2
+  <div class="command-detail" v-loading="loading">
3
+    <!-- 返回按钮 -->
4
+    <el-page-header @back="router.back()" :title="'返回'" style="margin-bottom: 16px">
5
+      <template #content>
6
+        <span class="page-title">指令详情</span>
7
+        <el-tag :type="statusTag(detail.status)" style="margin-left: 12px">{{ statusLabel(detail.status) }}</el-tag>
8
+      </template>
9
+    </el-page-header>
10
+
11
+    <el-row :gutter="16">
12
+      <!-- 左侧:基本信息 + 状态流转图 -->
13
+      <el-col :span="14">
14
+        <el-card>
15
+          <template #header>
16
+            <span>{{ detail.command_title }}</span>
17
+            <el-tag size="small" style="margin-left: 8px">{{ detail.command_no }}</el-tag>
18
+          </template>
19
+
20
+          <el-descriptions :column="2" border>
21
+            <el-descriptions-item label="指令编号">{{ detail.command_no }}</el-descriptions-item>
22
+            <el-descriptions-item label="类型">
23
+              <el-tag size="small">{{ typeLabel(detail.command_type) }}</el-tag>
24
+            </el-descriptions-item>
25
+            <el-descriptions-item label="优先级">
26
+              <el-tag :type="priorityTag(detail.priority)" size="small">{{ priorityLabel(detail.priority) }}</el-tag>
27
+            </el-descriptions-item>
28
+            <el-descriptions-item label="来源">{{ detail.source || '-' }}</el-descriptions-item>
29
+            <el-descriptions-item label="创建时间">{{ detail.created_at }}</el-descriptions-item>
30
+            <el-descriptions-item label="下发时间">{{ detail.issued_at || '-' }}</el-descriptions-item>
31
+            <el-descriptions-item label="完成时间">{{ detail.completed_at || '-' }}</el-descriptions-item>
32
+            <el-descriptions-item label="目标类型">{{ detail.target_type || '-' }}</el-descriptions-item>
33
+          </el-descriptions>
34
+
35
+          <div style="margin-top: 16px">
36
+            <h4>指令内容</h4>
37
+            <div class="content-block">{{ detail.command_content }}</div>
38
+          </div>
39
+
40
+          <!-- 状态流转图 -->
41
+          <div style="margin-top: 24px">
42
+            <h4>状态流转</h4>
43
+            <el-steps :active="statusStep(detail.status)" finish-status="success" align-center>
44
+              <el-step title="草稿" description="创建指令" />
45
+              <el-step title="已下发" description="下发给执行人" />
46
+              <el-step title="已接收" description="执行人确认" />
47
+              <el-step title="执行中" description="正在执行" />
48
+              <el-step title="完成/驳回" description="归档" />
49
+            </el-steps>
50
+          </div>
51
+        </el-card>
52
+      </el-col>
53
+
54
+      <!-- 右侧:执行记录列表 -->
55
+      <el-col :span="10">
56
+        <el-card>
57
+          <template #header>执行记录</template>
58
+          <el-timeline v-if="executions.length">
59
+            <el-timeline-item
60
+              v-for="exec in executions" :key="exec.id"
61
+              :type="executionTimelineType(exec.execute_status)"
62
+              :timestamp="exec.received_at || exec.created_at"
63
+              placement="top">
64
+              <div class="exec-card">
65
+                <div class="exec-header">
66
+                  <span class="exec-user">{{ exec.user_name || `用户${exec.user_id}` }}</span>
67
+                  <el-tag :type="executionStatusTag(exec.execute_status)" size="small">
68
+                    {{ executionStatusLabel(exec.execute_status) }}
69
+                  </el-tag>
70
+                </div>
71
+                <div v-if="exec.feedback" class="exec-feedback">
72
+                  反馈: {{ exec.feedback }}
73
+                </div>
74
+                <div v-if="exec.rejected_reason" class="exec-reject">
75
+                  驳回原因: {{ exec.rejected_reason }}
76
+                </div>
77
+                <div class="exec-actions" v-if="canOperate(exec)">
78
+                  <el-button size="small" type="primary"
79
+                    v-if="exec.execute_status === 'pending'"
80
+                    @click="handleReceive(exec)">接收</el-button>
81
+                  <el-button size="small" type="success"
82
+                    v-if="exec.execute_status === 'received'"
83
+                    @click="handleStartExecute(exec)">开始执行</el-button>
84
+                  <el-button size="small" type="success"
85
+                    v-if="exec.execute_status === 'executing'"
86
+                    @click="showCompleteDialog(exec)">完成</el-button>
87
+                  <el-button size="small" type="danger"
88
+                    v-if="exec.execute_status !== 'completed' && exec.execute_status !== 'rejected'"
89
+                    @click="handleReject(exec)">驳回</el-button>
90
+                </div>
91
+              </div>
92
+            </el-timeline-item>
93
+          </el-timeline>
94
+          <el-empty v-else description="暂无执行记录" />
95
+        </el-card>
96
+      </el-col>
97
+    </el-row>
98
+
99
+    <!-- 追踪日志 -->
100
+    <el-card style="margin-top: 16px">
101
+      <template #header>全过程追踪日志</template>
102
+      <el-timeline>
103
+        <el-timeline-item
104
+          v-for="log in trackingLogs" :key="log.id"
105
+          :timestamp="log.created_at" placement="top"
106
+          :type="trackingType(log.action)">
107
+          <div>
108
+            <el-tag size="small" :type="trackingType(log.action)">{{ trackingActionLabel(log.action) }}</el-tag>
109
+            <span style="margin-left: 8px">{{ log.operator_name || '' }}</span>
110
+            <span v-if="log.from_status" style="margin-left: 8px; color: #909399">
111
+              {{ log.from_status }} → {{ log.to_status }}
112
+            </span>
113
+            <div v-if="log.remark" style="color: #606266; margin-top: 4px">{{ log.remark }}</div>
114
+          </div>
115
+        </el-timeline-item>
116
+      </el-timeline>
117
+      <el-empty v-if="!trackingLogs.length" description="暂无追踪日志" />
118
+    </el-card>
119
+
120
+    <!-- 完成弹窗 -->
121
+    <el-dialog v-model="completeVisible" title="完成执行" width="500">
122
+      <el-form label-width="80px">
123
+        <el-form-item label="反馈说明">
124
+          <el-input v-model="completeForm.feedback" type="textarea" :rows="3" placeholder="请输入执行反馈" />
125
+        </el-form-item>
126
+        <el-form-item label="反馈图片">
127
+          <el-input v-model="completeForm.feedbackImages" placeholder="图片URL,多个用逗号分隔" />
128
+        </el-form-item>
129
+      </el-form>
130
+      <template #footer>
131
+        <el-button @click="completeVisible = false">取消</el-button>
132
+        <el-button type="primary" @click="handleComplete">确认完成</el-button>
133
+      </template>
134
+    </el-dialog>
135
+  </div>
136
+</template>
137
+
138
+<script setup lang="ts">
139
+import { ref, reactive, onMounted } from 'vue'
140
+import { useRoute, useRouter } from 'vue-router'
141
+import { ElMessage, ElMessageBox } from 'element-plus'
142
+import { getCommandDetail, receiveCommand, startExecute, completeExecution, rejectExecution } from '@/api/dispatchCommand'
143
+
144
+const route = useRoute()
145
+const router = useRouter()
146
+const loading = ref(false)
147
+const detail = ref<any>({})
148
+const executions = ref<any[]>([])
149
+const trackingLogs = ref<any[]>([])
150
+const completeVisible = ref(false)
151
+const currentExec = ref<any>(null)
152
+const completeForm = reactive({ feedback: '', feedbackImages: '' })
153
+
154
+const commandId = Number(route.params.id)
155
+
156
+// 模拟当前用户ID(实际应从登录态获取)
157
+const currentUserId = 1
158
+const currentUserName = 'admin'
159
+
160
+const statusMap: Record<string, { label: string; type: string; step: number }> = {
161
+  draft: { label: '草稿', type: 'info', step: 0 },
162
+  issued: { label: '已下发', type: 'warning', step: 1 },
163
+  received: { label: '已接收', type: '', step: 2 },
164
+  executing: { label: '执行中', type: 'primary', step: 3 },
165
+  completed: { label: '已完成', type: 'success', step: 4 },
166
+  rejected: { label: '已驳回', type: 'danger', step: 4 }
167
+}
168
+
169
+const statusLabel = (s: string) => statusMap[s]?.label || s
170
+const statusTag = (s: string) => (statusMap[s]?.type || 'info') as any
171
+const statusStep = (s: string) => statusMap[s]?.step || 0
172
+
173
+const typeMap: Record<string, string> = { normal: '常规', emergency: '应急', maintenance: '维护', inspection: '巡检' }
174
+const typeLabel = (t: string) => typeMap[t] || t
175
+
176
+const priorityMap: Record<string, { label: string; type: string }> = {
177
+  low: { label: '低', type: 'info' }, normal: { label: '普通', type: '' },
178
+  high: { label: '高', type: 'warning' }, urgent: { label: '紧急', type: 'danger' }
179
+}
180
+const priorityLabel = (p: string) => priorityMap[p]?.label || p
181
+const priorityTag = (p: string) => (priorityMap[p]?.type || 'info') as any
182
+
183
+const executionStatusLabel = (s: string) => {
184
+  const map: Record<string, string> = {
185
+    pending: '待接收', received: '已接收', executing: '执行中', completed: '已完成', rejected: '已驳回'
186
+  }
187
+  return map[s] || s
188
+}
189
+const executionStatusTag = (s: string) => {
190
+  const map: Record<string, string> = {
191
+    pending: 'info', received: '', executing: 'primary', completed: 'success', rejected: 'danger'
192
+  }
193
+  return (map[s] || 'info') as any
194
+}
195
+const executionTimelineType = (s: string) => {
196
+  const map: Record<string, string> = {
197
+    pending: 'info', received: 'primary', executing: 'primary', completed: 'success', rejected: 'danger'
198
+  }
199
+  return (map[s] || 'info') as any
200
+}
201
+
202
+const trackingActionLabel = (a: string) => {
203
+  const map: Record<string, string> = {
204
+    create: '创建', issue: '下发', receive: '接收', start_execute: '开始执行',
205
+    complete: '完成', reject: '驳回', cancel: '取消'
206
+  }
207
+  return map[a] || a
208
+}
209
+const trackingType = (a: string) => {
210
+  const map: Record<string, string> = {
211
+    create: 'info', issue: 'warning', receive: 'primary', start_execute: 'primary',
212
+    complete: 'success', reject: 'danger', cancel: 'danger'
213
+  }
214
+  return (map[a] || 'info') as any
215
+}
216
+
217
+function canOperate(exec: any) {
218
+  return exec.user_id === currentUserId || true // 简化:所有人可操作
219
+}
220
+
221
+async function fetchDetail() {
222
+  loading.value = true
223
+  try {
224
+    const res = await getCommandDetail(commandId)
225
+    detail.value = res.data || {}
226
+    executions.value = res.data?.executions || []
227
+    trackingLogs.value = res.data?.tracking_logs || res.data?.trackingLogs || []
228
+  } finally {
229
+    loading.value = false
230
+  }
231
+}
232
+
233
+async function handleReceive(exec: any) {
234
+  try {
235
+    await receiveCommand(commandId, exec.user_id, exec.user_name)
236
+    ElMessage.success('接收成功')
237
+    fetchDetail()
238
+  } catch (e: any) {
239
+    ElMessage.error(e.message || '操作失败')
240
+  }
241
+}
242
+
243
+async function handleStartExecute(exec: any) {
244
+  try {
245
+    await startExecute(commandId, exec.user_id, exec.user_name)
246
+    ElMessage.success('已开始执行')
247
+    fetchDetail()
248
+  } catch (e: any) {
249
+    ElMessage.error(e.message || '操作失败')
250
+  }
251
+}
252
+
253
+function showCompleteDialog(exec: any) {
254
+  currentExec.value = exec
255
+  completeForm.feedback = ''
256
+  completeForm.feedbackImages = ''
257
+  completeVisible.value = true
258
+}
259
+
260
+async function handleComplete() {
261
+  try {
262
+    await completeExecution(commandId, currentExec.value.user_id, {
263
+      userName: currentExec.value.user_name,
264
+      feedback: completeForm.feedback,
265
+      feedbackImages: completeForm.feedbackImages
266
+    })
267
+    ElMessage.success('执行完成')
268
+    completeVisible.value = false
269
+    fetchDetail()
270
+  } catch (e: any) {
271
+    ElMessage.error(e.message || '操作失败')
272
+  }
273
+}
274
+
275
+async function handleReject(exec: any) {
276
+  try {
277
+    const { value } = await ElMessageBox.prompt('请输入驳回原因', '驳回', {
278
+      confirmButtonText: '确认驳回',
279
+      cancelButtonText: '取消',
280
+      inputPattern: /.+/,
281
+      inputErrorMessage: '驳回原因不能为空'
282
+    })
283
+    await rejectExecution(commandId, exec.user_id, value, exec.user_name)
284
+    ElMessage.success('已驳回')
285
+    fetchDetail()
286
+  } catch { /* cancel */ }
287
+}
288
+
289
+onMounted(fetchDetail)
290
+</script>
291
+
292
+<style scoped>
293
+.page-title { font-size: 16px; font-weight: 600; }
294
+.content-block {
295
+  padding: 12px; background: #f5f7fa; border-radius: 4px;
296
+  white-space: pre-wrap; line-height: 1.6;
297
+}
298
+.exec-card { padding: 4px 0; }
299
+.exec-header { display: flex; justify-content: space-between; align-items: center; }
300
+.exec-user { font-weight: 600; }
301
+.exec-feedback { margin-top: 6px; color: #606266; font-size: 13px; }
302
+.exec-reject { margin-top: 6px; color: #f56c6c; font-size: 13px; }
303
+.exec-actions { margin-top: 8px; }
304
+</style>

+ 193
- 0
frontend/src/views/dispatch-command/CommandList.vue Просмотреть файл

@@ -0,0 +1,193 @@
1
+<template>
2
+  <div class="command-list">
3
+    <el-card shadow="never" class="filter-card">
4
+      <el-form :inline="true" :model="filterForm">
5
+        <el-form-item label="关键词">
6
+          <el-input v-model="filterForm.keyword" placeholder="编号/标题" clearable @clear="handleSearch" />
7
+        </el-form-item>
8
+        <el-form-item label="状态">
9
+          <el-select v-model="filterForm.status" placeholder="全部" clearable @change="handleSearch">
10
+            <el-option label="草稿" value="draft" />
11
+            <el-option label="已下发" value="issued" />
12
+            <el-option label="已接收" value="received" />
13
+            <el-option label="执行中" value="executing" />
14
+            <el-option label="已完成" value="completed" />
15
+            <el-option label="已驳回" value="rejected" />
16
+          </el-select>
17
+        </el-form-item>
18
+        <el-form-item label="类型">
19
+          <el-select v-model="filterForm.commandType" placeholder="全部" clearable @change="handleSearch">
20
+            <el-option label="常规" value="normal" />
21
+            <el-option label="应急" value="emergency" />
22
+            <el-option label="维护" value="maintenance" />
23
+            <el-option label="巡检" value="inspection" />
24
+          </el-select>
25
+        </el-form-item>
26
+        <el-form-item label="时间范围">
27
+          <el-date-picker v-model="dateRange" type="daterange" range-separator="至"
28
+            start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD"
29
+            @change="handleSearch" />
30
+        </el-form-item>
31
+        <el-form-item>
32
+          <el-button type="primary" @click="handleSearch"><el-icon><Search /></el-icon> 查询</el-button>
33
+          <el-button @click="handleReset">重置</el-button>
34
+        </el-form-item>
35
+      </el-form>
36
+    </el-card>
37
+
38
+    <el-row :gutter="12" style="margin-top: 12px">
39
+      <el-col :span="4" v-for="stat in stats" :key="stat.status">
40
+        <el-card shadow="hover" class="stat-card" @click="filterByStatus(stat.status)">
41
+          <div class="stat-value">{{ stat.count }}</div>
42
+          <div class="stat-label">{{ statusLabel(stat.status) }}</div>
43
+        </el-card>
44
+      </el-col>
45
+    </el-row>
46
+
47
+    <div style="margin-top: 16px; display: flex; justify-content: space-between; align-items: center">
48
+      <el-button type="primary" @click="showCreateDialog = true"><el-icon><Plus /></el-icon> 创建指令</el-button>
49
+      <el-button @click="handleBatchIssue" :disabled="!selectedIds.length">批量下发</el-button>
50
+    </div>
51
+
52
+    <el-table :data="tableData" border style="margin-top: 10px"
53
+      @selection-change="handleSelectionChange" v-loading="loading">
54
+      <el-table-column type="selection" width="50" />
55
+      <el-table-column prop="command_no" label="指令编号" width="220" />
56
+      <el-table-column prop="command_title" label="标题" min-width="200" show-overflow-tooltip />
57
+      <el-table-column prop="command_type" label="类型" width="80">
58
+        <template #default="{ row }">
59
+          <el-tag :type="typeTag(row.command_type)" size="small">{{ typeLabel(row.command_type) }}</el-tag>
60
+        </template>
61
+      </el-table-column>
62
+      <el-table-column prop="priority" label="优先级" width="80">
63
+        <template #default="{ row }">
64
+          <el-tag :type="priorityTag(row.priority)" size="small">{{ priorityLabel(row.priority) }}</el-tag>
65
+        </template>
66
+      </el-table-column>
67
+      <el-table-column prop="status" label="状态" width="90">
68
+        <template #default="{ row }">
69
+          <el-tag :type="statusTag(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
70
+        </template>
71
+      </el-table-column>
72
+      <el-table-column label="执行进度" width="120">
73
+        <template #default="{ row }">
74
+          <span>{{ row.completed_count || 0 }}/{{ row.total_executions || 0 }}</span>
75
+        </template>
76
+      </el-table-column>
77
+      <el-table-column prop="created_at" label="创建时间" width="170" />
78
+      <el-table-column label="操作" width="200" fixed="right">
79
+        <template #default="{ row }">
80
+          <el-button link type="primary" @click="viewDetail(row)">详情</el-button>
81
+          <el-button link type="success" v-if="row.status === 'draft'" @click="handleIssue(row)">下发</el-button>
82
+          <el-button link type="danger" v-if="row.status === 'draft'" @click="handleDelete(row)">删除</el-button>
83
+        </template>
84
+      </el-table-column>
85
+    </el-table>
86
+
87
+    <el-pagination style="margin-top: 16px; justify-content: flex-end"
88
+      v-model:current-page="pagination.page" v-model:page-size="pagination.size"
89
+      :total="pagination.total" :page-sizes="[10, 20, 50]"
90
+      layout="total, sizes, prev, pager, next" @change="fetchData" />
91
+
92
+    <CommandCreate v-model:visible="showCreateDialog" @created="handleCreated" />
93
+  </div>
94
+</template>
95
+
96
+<script setup lang="ts">
97
+import { ref, reactive, onMounted } from 'vue'
98
+import { useRouter } from 'vue-router'
99
+import { ElMessage, ElMessageBox } from 'element-plus'
100
+import { Search, Plus } from '@element-plus/icons-vue'
101
+import { listCommands, issueCommand, getCommandStats } from '@/api/dispatchCommand'
102
+import CommandCreate from './CommandCreate.vue'
103
+
104
+const router = useRouter()
105
+const loading = ref(false)
106
+const tableData = ref<any[]>([])
107
+const stats = ref<any[]>([])
108
+const selectedIds = ref<number[]>([])
109
+const showCreateDialog = ref(false)
110
+const dateRange = ref<[string, string] | null>(null)
111
+
112
+const filterForm = reactive({ keyword: '', status: '', commandType: '' })
113
+const pagination = reactive({ page: 1, size: 10, total: 0 })
114
+
115
+const statusMap: Record<string, { label: string; type: string }> = {
116
+  draft: { label: '草稿', type: 'info' },
117
+  issued: { label: '已下发', type: 'warning' },
118
+  received: { label: '已接收', type: '' },
119
+  executing: { label: '执行中', type: 'primary' },
120
+  completed: { label: '已完成', type: 'success' },
121
+  rejected: { label: '已驳回', type: 'danger' }
122
+}
123
+const typeMap: Record<string, string> = { normal: '常规', emergency: '应急', maintenance: '维护', inspection: '巡检' }
124
+const priorityMap: Record<string, { label: string; type: string }> = {
125
+  low: { label: '低', type: 'info' }, normal: { label: '普通', type: '' },
126
+  high: { label: '高', type: 'warning' }, urgent: { label: '紧急', type: 'danger' }
127
+}
128
+
129
+function statusLabel(s: string) { return statusMap[s]?.label || s }
130
+function statusTag(s: string) { return (statusMap[s]?.type || 'info') as any }
131
+function typeLabel(t: string) { return typeMap[t] || t }
132
+function typeTag(t: string) { return t === 'emergency' ? 'danger' : t === 'maintenance' ? 'warning' : '' }
133
+function priorityLabel(p: string) { return priorityMap[p]?.label || p }
134
+function priorityTag(p: string) { return (priorityMap[p]?.type || 'info') as any }
135
+
136
+async function fetchData() {
137
+  loading.value = true
138
+  try {
139
+    const res = await listCommands({
140
+      page: pagination.page, size: pagination.size,
141
+      status: filterForm.status || undefined,
142
+      commandType: filterForm.commandType || undefined,
143
+      keyword: filterForm.keyword || undefined,
144
+      startDate: dateRange.value?.[0], endDate: dateRange.value?.[1]
145
+    })
146
+    tableData.value = res.data?.records || []
147
+    pagination.total = res.data?.total || 0
148
+  } finally { loading.value = false }
149
+}
150
+
151
+async function fetchStats() {
152
+  try { const res = await getCommandStats(); stats.value = res.data || [] } catch { /* ignore */ }
153
+}
154
+
155
+function handleSearch() { pagination.page = 1; fetchData() }
156
+function handleReset() {
157
+  filterForm.keyword = ''; filterForm.status = ''; filterForm.commandType = ''; dateRange.value = null; handleSearch()
158
+}
159
+function filterByStatus(status: string) { filterForm.status = status; handleSearch() }
160
+function handleSelectionChange(rows: any[]) { selectedIds.value = rows.map((r: any) => r.id) }
161
+function viewDetail(row: any) { router.push({ path: `/dispatch-command/${row.id}` }) }
162
+
163
+async function handleIssue(row: any) {
164
+  try {
165
+    await ElMessageBox.confirm(`确认下发指令 "${row.command_title}" ?`, '下发确认')
166
+    await issueCommand(row.id, 1, 'admin'); ElMessage.success('指令已下发'); fetchData(); fetchStats()
167
+  } catch { /* cancel */ }
168
+}
169
+
170
+async function handleBatchIssue() {
171
+  try {
172
+    await ElMessageBox.confirm(`确认批量下发 ${selectedIds.value.length} 条指令?`, '批量下发')
173
+    for (const id of selectedIds.value) { await issueCommand(id, 1, 'admin') }
174
+    ElMessage.success('批量下发完成'); fetchData(); fetchStats()
175
+  } catch { /* cancel */ }
176
+}
177
+
178
+function handleDelete(row: any) {
179
+  ElMessageBox.confirm(`确认删除指令 "${row.command_title}" ?`, '删除确认', { type: 'warning' })
180
+    .then(() => { ElMessage.info('删除功能待实现(逻辑删除)') }).catch(() => { /* cancel */ })
181
+}
182
+
183
+function handleCreated() { showCreateDialog.value = false; fetchData(); fetchStats() }
184
+
185
+onMounted(() => { fetchData(); fetchStats() })
186
+</script>
187
+
188
+<style scoped>
189
+.filter-card :deep(.el-form-item) { margin-bottom: 0; }
190
+.stat-card { cursor: pointer; text-align: center; }
191
+.stat-value { font-size: 28px; font-weight: bold; color: #409eff; }
192
+.stat-label { font-size: 13px; color: #909399; margin-top: 4px; }
193
+</style>

+ 106
- 0
wm-production/src/main/java/com/water/production/controller/DispatchCommandController.java Просмотреть файл

@@ -0,0 +1,106 @@
1
+package com.water.production.controller;
2
+
3
+import com.baomidou.mybatisplus.core.metadata.IPage;
4
+import com.water.common.core.result.R;
5
+import com.water.production.entity.DispatchCommand;
6
+import com.water.production.entity.DispatchExecution;
7
+import com.water.production.entity.DispatchTracking;
8
+import com.water.production.service.DispatchCommandService;
9
+import com.water.production.service.DispatchTrackingService;
10
+import io.swagger.v3.oas.annotations.Operation;
11
+import io.swagger.v3.oas.annotations.tags.Tag;
12
+import lombok.RequiredArgsConstructor;
13
+import org.springframework.web.bind.annotation.*;
14
+
15
+import java.util.List;
16
+import java.util.Map;
17
+
18
+@Tag(name = "调度指令管理")
19
+@RestController
20
+@RequestMapping("/api/production/dispatch-command")
21
+@RequiredArgsConstructor
22
+public class DispatchCommandController {
23
+
24
+    private final DispatchCommandService commandService;
25
+    private final DispatchTrackingService trackingService;
26
+
27
+    @Operation(summary = "创建指令")
28
+    @PostMapping
29
+    public R<DispatchCommand> create(@RequestBody DispatchCommand command) {
30
+        return R.ok(commandService.createCommand(command));
31
+    }
32
+
33
+    @Operation(summary = "下发指令")
34
+    @PostMapping("/{id}/issue")
35
+    public R<DispatchCommand> issue(@PathVariable Long id,
36
+                                     @RequestParam Long issuedBy,
37
+                                     @RequestParam(required = false, defaultValue = "system") String operatorName) {
38
+        return R.ok(commandService.issueCommand(id, issuedBy, operatorName));
39
+    }
40
+
41
+    @Operation(summary = "指令台账(分页)")
42
+    @GetMapping
43
+    public R<IPage<Map<String, Object>>> list(
44
+            @RequestParam(defaultValue = "1") int page,
45
+            @RequestParam(defaultValue = "10") int size,
46
+            @RequestParam(required = false) String status,
47
+            @RequestParam(required = false) String commandType,
48
+            @RequestParam(required = false) String keyword,
49
+            @RequestParam(required = false) String startDate,
50
+            @RequestParam(required = false) String endDate) {
51
+        return R.ok(commandService.listCommands(page, size, status, commandType, keyword, startDate, endDate));
52
+    }
53
+
54
+    @Operation(summary = "指令详情")
55
+    @GetMapping("/{id}")
56
+    public R<Map<String, Object>> detail(@PathVariable Long id) {
57
+        return R.ok(commandService.getCommandDetail(id));
58
+    }
59
+
60
+    @Operation(summary = "各状态统计")
61
+    @GetMapping("/stats")
62
+    public R<List<Map<String, Object>>> stats() {
63
+        return R.ok(commandService.getStatusStats());
64
+    }
65
+
66
+    @Operation(summary = "接收确认")
67
+    @PostMapping("/{id}/receive")
68
+    public R<DispatchExecution> receive(@PathVariable Long id,
69
+                                         @RequestParam Long userId,
70
+                                         @RequestParam(required = false, defaultValue = "") String userName) {
71
+        return R.ok(commandService.receiveCommand(id, userId, userName));
72
+    }
73
+
74
+    @Operation(summary = "开始执行")
75
+    @PostMapping("/{id}/start-execute")
76
+    public R<DispatchExecution> startExecute(@PathVariable Long id,
77
+                                              @RequestParam Long userId,
78
+                                              @RequestParam(required = false, defaultValue = "") String userName) {
79
+        return R.ok(commandService.startExecution(id, userId, userName));
80
+    }
81
+
82
+    @Operation(summary = "完成执行")
83
+    @PostMapping("/{id}/complete")
84
+    public R<DispatchExecution> complete(@PathVariable Long id,
85
+                                          @RequestParam Long userId,
86
+                                          @RequestParam(required = false, defaultValue = "") String userName,
87
+                                          @RequestParam(required = false) String feedback,
88
+                                          @RequestParam(required = false) String feedbackImages) {
89
+        return R.ok(commandService.completeExecution(id, userId, userName, feedback, feedbackImages));
90
+    }
91
+
92
+    @Operation(summary = "驳回")
93
+    @PostMapping("/{id}/reject")
94
+    public R<DispatchExecution> reject(@PathVariable Long id,
95
+                                        @RequestParam Long userId,
96
+                                        @RequestParam(required = false, defaultValue = "") String userName,
97
+                                        @RequestParam String reason) {
98
+        return R.ok(commandService.rejectExecution(id, userId, userName, reason));
99
+    }
100
+
101
+    @Operation(summary = "查询追踪日志")
102
+    @GetMapping("/{id}/tracking")
103
+    public R<List<DispatchTracking>> trackingLogs(@PathVariable Long id) {
104
+        return R.ok(trackingService.getTrackingLogs(id));
105
+    }
106
+}

+ 64
- 0
wm-production/src/main/java/com/water/production/entity/DispatchCommand.java Просмотреть файл

@@ -0,0 +1,64 @@
1
+package com.water.production.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+import java.time.LocalDateTime;
6
+
7
+/**
8
+ * 调度指令主表
9
+ */
10
+@Data
11
+@TableName("prod_dispatch_command")
12
+public class DispatchCommand {
13
+
14
+    @TableId(type = IdType.AUTO)
15
+    private Long id;
16
+
17
+    /** 指令编号 CMD-yyyyMMddHHmmss-xxxx */
18
+    private String commandNo;
19
+
20
+    /** 指令标题 */
21
+    private String commandTitle;
22
+
23
+    /** 指令内容 */
24
+    private String commandContent;
25
+
26
+    /** 类型: normal/emergency/maintenance/inspection */
27
+    private String commandType;
28
+
29
+    /** 来源 */
30
+    private String source;
31
+
32
+    /** 优先级: low/normal/high/urgent */
33
+    private String priority;
34
+
35
+    /** 目标类型: user/dept/role */
36
+    private String targetType;
37
+
38
+    /** 目标ID列表 JSON数组 */
39
+    private String targetIds;
40
+
41
+    /** 状态: draft/issued/received/executing/completed/rejected */
42
+    private String status;
43
+
44
+    /** 下发时间 */
45
+    private LocalDateTime issuedAt;
46
+
47
+    /** 下发人 */
48
+    private Long issuedBy;
49
+
50
+    /** 完成归档时间 */
51
+    private LocalDateTime completedAt;
52
+
53
+    /** 备注 */
54
+    private String remark;
55
+
56
+    @TableLogic
57
+    private Integer deleted;
58
+
59
+    @TableField(fill = FieldFill.INSERT)
60
+    private LocalDateTime createdAt;
61
+
62
+    @TableField(fill = FieldFill.INSERT_UPDATE)
63
+    private LocalDateTime updatedAt;
64
+}

+ 52
- 0
wm-production/src/main/java/com/water/production/entity/DispatchExecution.java Просмотреть файл

@@ -0,0 +1,52 @@
1
+package com.water.production.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+import java.time.LocalDateTime;
6
+
7
+/**
8
+ * 调度指令执行记录表
9
+ */
10
+@Data
11
+@TableName("prod_dispatch_execution")
12
+public class DispatchExecution {
13
+
14
+    @TableId(type = IdType.AUTO)
15
+    private Long id;
16
+
17
+    /** 关联指令ID */
18
+    private Long commandId;
19
+
20
+    /** 接收/执行人 */
21
+    private Long userId;
22
+
23
+    /** 执行人姓名 */
24
+    private String userName;
25
+
26
+    /** 接收确认时间 */
27
+    private LocalDateTime receivedAt;
28
+
29
+    /** 执行状态: pending/received/executing/completed/rejected */
30
+    private String executeStatus;
31
+
32
+    /** 执行反馈 */
33
+    private String feedback;
34
+
35
+    /** 反馈图片JSON数组 */
36
+    private String feedbackImages;
37
+
38
+    /** 完成时间 */
39
+    private LocalDateTime completedAt;
40
+
41
+    /** 驳回原因 */
42
+    private String rejectedReason;
43
+
44
+    @TableLogic
45
+    private Integer deleted;
46
+
47
+    @TableField(fill = FieldFill.INSERT)
48
+    private LocalDateTime createdAt;
49
+
50
+    @TableField(fill = FieldFill.INSERT_UPDATE)
51
+    private LocalDateTime updatedAt;
52
+}

+ 43
- 0
wm-production/src/main/java/com/water/production/entity/DispatchTracking.java Просмотреть файл

@@ -0,0 +1,43 @@
1
+package com.water.production.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+import java.time.LocalDateTime;
6
+
7
+/**
8
+ * 调度指令过程追踪日志表
9
+ */
10
+@Data
11
+@TableName("prod_dispatch_tracking")
12
+public class DispatchTracking {
13
+
14
+    @TableId(type = IdType.AUTO)
15
+    private Long id;
16
+
17
+    /** 关联指令ID */
18
+    private Long commandId;
19
+
20
+    /** 关联执行记录ID(可选) */
21
+    private Long executionId;
22
+
23
+    /** 操作类型: create/issue/receive/start_execute/complete/reject/cancel */
24
+    private String action;
25
+
26
+    /** 操作人 */
27
+    private Long operatorId;
28
+
29
+    /** 操作人姓名 */
30
+    private String operatorName;
31
+
32
+    /** 原状态 */
33
+    private String fromStatus;
34
+
35
+    /** 新状态 */
36
+    private String toStatus;
37
+
38
+    /** 备注 */
39
+    private String remark;
40
+
41
+    @TableField(fill = FieldFill.INSERT)
42
+    private LocalDateTime createdAt;
43
+}

+ 28
- 0
wm-production/src/main/java/com/water/production/mapper/DispatchCommandMapper.java Просмотреть файл

@@ -0,0 +1,28 @@
1
+package com.water.production.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.baomidou.mybatisplus.core.metadata.IPage;
5
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
6
+import com.water.production.entity.DispatchCommand;
7
+import org.apache.ibatis.annotations.Mapper;
8
+import org.apache.ibatis.annotations.Param;
9
+
10
+import java.util.List;
11
+import java.util.Map;
12
+
13
+@Mapper
14
+public interface DispatchCommandMapper extends BaseMapper<DispatchCommand> {
15
+
16
+    IPage<Map<String, Object>> selectCommandPage(
17
+        Page<?> page,
18
+        @Param("status") String status,
19
+        @Param("commandType") String commandType,
20
+        @Param("keyword") String keyword,
21
+        @Param("startDate") String startDate,
22
+        @Param("endDate") String endDate
23
+    );
24
+
25
+    Map<String, Object> selectCommandDetail(@Param("commandId") Long commandId);
26
+
27
+    List<Map<String, Object>> selectStatusStats();
28
+}

+ 9
- 0
wm-production/src/main/java/com/water/production/mapper/DispatchExecutionMapper.java Просмотреть файл

@@ -0,0 +1,9 @@
1
+package com.water.production.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.production.entity.DispatchExecution;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+@Mapper
8
+public interface DispatchExecutionMapper extends BaseMapper<DispatchExecution> {
9
+}

+ 9
- 0
wm-production/src/main/java/com/water/production/mapper/DispatchTrackingMapper.java Просмотреть файл

@@ -0,0 +1,9 @@
1
+package com.water.production.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.production.entity.DispatchTracking;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+@Mapper
8
+public interface DispatchTrackingMapper extends BaseMapper<DispatchTracking> {
9
+}

+ 228
- 0
wm-production/src/main/java/com/water/production/service/DispatchCommandService.java Просмотреть файл

@@ -0,0 +1,228 @@
1
+package com.water.production.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.core.metadata.IPage;
5
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
6
+import com.water.common.core.exception.BusinessException;
7
+import com.water.production.entity.DispatchCommand;
8
+import com.water.production.entity.DispatchExecution;
9
+import com.water.production.entity.DispatchTracking;
10
+import com.water.production.mapper.DispatchCommandMapper;
11
+import com.water.production.mapper.DispatchExecutionMapper;
12
+import lombok.RequiredArgsConstructor;
13
+import lombok.extern.slf4j.Slf4j;
14
+import org.springframework.stereotype.Service;
15
+import org.springframework.transaction.annotation.Transactional;
16
+
17
+import java.time.LocalDateTime;
18
+import java.time.format.DateTimeFormatter;
19
+import java.util.*;
20
+
21
+@Slf4j
22
+@Service
23
+@RequiredArgsConstructor
24
+public class DispatchCommandService {
25
+
26
+    private final DispatchCommandMapper commandMapper;
27
+    private final DispatchExecutionMapper executionMapper;
28
+    private final DispatchTrackingService trackingService;
29
+
30
+    private static final Map<String, Set<String>> STATE_TRANSITIONS = new LinkedHashMap<>();
31
+    static {
32
+        STATE_TRANSITIONS.put("draft", Set.of("issued"));
33
+        STATE_TRANSITIONS.put("issued", Set.of("received", "rejected"));
34
+        STATE_TRANSITIONS.put("received", Set.of("executing", "rejected"));
35
+        STATE_TRANSITIONS.put("executing", Set.of("completed", "rejected"));
36
+        STATE_TRANSITIONS.put("completed", Set.of());
37
+        STATE_TRANSITIONS.put("rejected", Set.of());
38
+    }
39
+
40
+    @Transactional
41
+    public DispatchCommand createCommand(DispatchCommand command) {
42
+        String cmdNo = "CMD-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
43
+                + "-" + String.format("%04d", new Random().nextInt(10000));
44
+        command.setCommandNo(cmdNo);
45
+        command.setStatus("draft");
46
+        commandMapper.insert(command);
47
+        trackingService.log(command.getId(), null, "create", null, null, "draft", "创建指令");
48
+        log.info("创建调度指令: {}", cmdNo);
49
+        return command;
50
+    }
51
+
52
+    @Transactional
53
+    public DispatchCommand issueCommand(Long commandId, Long issuedBy, String operatorName) {
54
+        DispatchCommand cmd = getCommandOrThrow(commandId);
55
+        validateTransition(cmd.getStatus(), "issued");
56
+        cmd.setStatus("issued");
57
+        cmd.setIssuedAt(LocalDateTime.now());
58
+        cmd.setIssuedBy(issuedBy);
59
+        commandMapper.updateById(cmd);
60
+        createExecutionRecords(cmd);
61
+        trackingService.log(commandId, null, "issue", issuedBy, operatorName, "draft", "issued", "指令下发");
62
+        log.info("下发调度指令: {}", cmd.getCommandNo());
63
+        return cmd;
64
+    }
65
+
66
+    @Transactional
67
+    public DispatchExecution receiveCommand(Long commandId, Long userId, String userName) {
68
+        DispatchCommand cmd = getCommandOrThrow(commandId);
69
+        validateTransition(cmd.getStatus(), "received");
70
+        DispatchExecution exec = getExecutionOrThrow(commandId, userId);
71
+        if (!Objects.equals(exec.getExecuteStatus(), "pending")) {
72
+            throw new BusinessException("该执行记录状态不允许接收确认");
73
+        }
74
+        exec.setExecuteStatus("received");
75
+        exec.setReceivedAt(LocalDateTime.now());
76
+        executionMapper.updateById(exec);
77
+        if (allExecutionsInStatus(commandId, "received")) {
78
+            cmd.setStatus("received");
79
+            commandMapper.updateById(cmd);
80
+        }
81
+        trackingService.log(commandId, exec.getId(), "receive", userId, userName, "pending", "received", "接收确认");
82
+        return exec;
83
+    }
84
+
85
+    @Transactional
86
+    public DispatchExecution startExecution(Long commandId, Long userId, String userName) {
87
+        DispatchCommand cmd = getCommandOrThrow(commandId);
88
+        validateTransition(cmd.getStatus(), "executing");
89
+        DispatchExecution exec = getExecutionOrThrow(commandId, userId);
90
+        if (!Objects.equals(exec.getExecuteStatus(), "received")) {
91
+            throw new BusinessException("必须先接收确认才能开始执行");
92
+        }
93
+        exec.setExecuteStatus("executing");
94
+        executionMapper.updateById(exec);
95
+        if (Objects.equals(cmd.getStatus(), "received")) {
96
+            cmd.setStatus("executing");
97
+            commandMapper.updateById(cmd);
98
+        }
99
+        trackingService.log(commandId, exec.getId(), "start_execute", userId, userName, "received", "executing", "开始执行");
100
+        return exec;
101
+    }
102
+
103
+    @Transactional
104
+    public DispatchExecution completeExecution(Long commandId, Long userId, String userName,
105
+                                                String feedback, String feedbackImages) {
106
+        DispatchCommand cmd = getCommandOrThrow(commandId);
107
+        validateTransition(cmd.getStatus(), "completed");
108
+        DispatchExecution exec = getExecutionOrThrow(commandId, userId);
109
+        if (!Objects.equals(exec.getExecuteStatus(), "executing")) {
110
+            throw new BusinessException("只有执行中状态才能完成");
111
+        }
112
+        exec.setExecuteStatus("completed");
113
+        exec.setFeedback(feedback);
114
+        exec.setFeedbackImages(feedbackImages);
115
+        exec.setCompletedAt(LocalDateTime.now());
116
+        executionMapper.updateById(exec);
117
+        if (allExecutionsFinal(commandId)) {
118
+            cmd.setStatus("completed");
119
+            cmd.setCompletedAt(LocalDateTime.now());
120
+            commandMapper.updateById(cmd);
121
+            trackingService.log(commandId, null, "complete", userId, userName, "executing", "completed", "全部执行完成,归档");
122
+        }
123
+        trackingService.log(commandId, exec.getId(), "complete", userId, userName, "executing", "completed", "执行完成");
124
+        return exec;
125
+    }
126
+
127
+    @Transactional
128
+    public DispatchExecution rejectExecution(Long commandId, Long userId, String userName, String reason) {
129
+        DispatchCommand cmd = getCommandOrThrow(commandId);
130
+        DispatchExecution exec = getExecutionOrThrow(commandId, userId);
131
+        String prevStatus = exec.getExecuteStatus();
132
+        if (Objects.equals(prevStatus, "completed") || Objects.equals(prevStatus, "rejected")) {
133
+            throw new BusinessException("当前状态不允许驳回");
134
+        }
135
+        exec.setExecuteStatus("rejected");
136
+        exec.setRejectedReason(reason);
137
+        exec.setCompletedAt(LocalDateTime.now());
138
+        executionMapper.updateById(exec);
139
+        if (allExecutionsFinal(commandId)) {
140
+            cmd.setStatus("rejected");
141
+            commandMapper.updateById(cmd);
142
+            trackingService.log(commandId, null, "reject", userId, userName, cmd.getStatus(), "rejected", "全部驳回/终止");
143
+        }
144
+        trackingService.log(commandId, exec.getId(), "reject", userId, userName, prevStatus, "rejected", "驳回原因: " + reason);
145
+        return exec;
146
+    }
147
+
148
+    public IPage<Map<String, Object>> listCommands(int page, int size, String status, String commandType,
149
+                                                     String keyword, String startDate, String endDate) {
150
+        return commandMapper.selectCommandPage(new Page<>(page, size), status, commandType, keyword, startDate, endDate);
151
+    }
152
+
153
+    public Map<String, Object> getCommandDetail(Long commandId) {
154
+        Map<String, Object> detail = commandMapper.selectCommandDetail(commandId);
155
+        if (detail == null) {
156
+            throw new BusinessException("指令不存在");
157
+        }
158
+        detail.put("trackingLogs", trackingService.getTrackingLogs(commandId));
159
+        return detail;
160
+    }
161
+
162
+    public List<Map<String, Object>> getStatusStats() {
163
+        return commandMapper.selectStatusStats();
164
+    }
165
+
166
+    private DispatchCommand getCommandOrThrow(Long commandId) {
167
+        DispatchCommand cmd = commandMapper.selectById(commandId);
168
+        if (cmd == null) throw new BusinessException("指令不存在");
169
+        return cmd;
170
+    }
171
+
172
+    private DispatchExecution getExecutionOrThrow(Long commandId, Long userId) {
173
+        LambdaQueryWrapper<DispatchExecution> wrapper = new LambdaQueryWrapper<>();
174
+        wrapper.eq(DispatchExecution::getCommandId, commandId)
175
+               .eq(DispatchExecution::getUserId, userId);
176
+        DispatchExecution exec = executionMapper.selectOne(wrapper);
177
+        if (exec == null) throw new BusinessException("执行记录不存在");
178
+        return exec;
179
+    }
180
+
181
+    private void validateTransition(String currentStatus, String targetStatus) {
182
+        Set<String> allowed = STATE_TRANSITIONS.get(currentStatus);
183
+        if (allowed == null || !allowed.contains(targetStatus)) {
184
+            throw new BusinessException("状态流转不合法: " + currentStatus + " -> " + targetStatus);
185
+        }
186
+    }
187
+
188
+    private void createExecutionRecords(DispatchCommand cmd) {
189
+        if (cmd.getTargetIds() == null || cmd.getTargetIds().isBlank()) return;
190
+        String cleaned = cmd.getTargetIds().replaceAll("[\\[\\]\"]", "");
191
+        for (String idStr : cleaned.split(",")) {
192
+            String trimmed = idStr.trim();
193
+            if (trimmed.isEmpty()) continue;
194
+            try {
195
+                Long userId = Long.parseLong(trimmed);
196
+                DispatchExecution exec = new DispatchExecution();
197
+                exec.setCommandId(cmd.getId());
198
+                exec.setUserId(userId);
199
+                exec.setExecuteStatus("pending");
200
+                executionMapper.insert(exec);
201
+            } catch (NumberFormatException e) {
202
+                log.warn("跳过无效目标ID: {}", trimmed);
203
+            }
204
+        }
205
+    }
206
+
207
+    private boolean allExecutionsInStatus(Long commandId, String status) {
208
+        LambdaQueryWrapper<DispatchExecution> w1 = new LambdaQueryWrapper<>();
209
+        w1.eq(DispatchExecution::getCommandId, commandId);
210
+        Long total = executionMapper.selectCount(w1);
211
+        LambdaQueryWrapper<DispatchExecution> w2 = new LambdaQueryWrapper<>();
212
+        w2.eq(DispatchExecution::getCommandId, commandId)
213
+          .eq(DispatchExecution::getExecuteStatus, status);
214
+        Long count = executionMapper.selectCount(w2);
215
+        return total > 0 && total.equals(count);
216
+    }
217
+
218
+    private boolean allExecutionsFinal(Long commandId) {
219
+        LambdaQueryWrapper<DispatchExecution> w1 = new LambdaQueryWrapper<>();
220
+        w1.eq(DispatchExecution::getCommandId, commandId);
221
+        Long total = executionMapper.selectCount(w1);
222
+        LambdaQueryWrapper<DispatchExecution> w2 = new LambdaQueryWrapper<>();
223
+        w2.eq(DispatchExecution::getCommandId, commandId)
224
+          .in(DispatchExecution::getExecuteStatus, "completed", "rejected");
225
+        Long finalCount = executionMapper.selectCount(w2);
226
+        return total > 0 && total.equals(finalCount);
227
+    }
228
+}

+ 53
- 0
wm-production/src/main/java/com/water/production/service/DispatchTrackingService.java Просмотреть файл

@@ -0,0 +1,53 @@
1
+package com.water.production.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.water.production.entity.DispatchTracking;
5
+import com.water.production.mapper.DispatchTrackingMapper;
6
+import lombok.RequiredArgsConstructor;
7
+import lombok.extern.slf4j.Slf4j;
8
+import org.springframework.stereotype.Service;
9
+
10
+import java.util.List;
11
+
12
+@Slf4j
13
+@Service
14
+@RequiredArgsConstructor
15
+public class DispatchTrackingService {
16
+
17
+    private final DispatchTrackingMapper trackingMapper;
18
+
19
+    public void log(Long commandId, Long executionId, String action,
20
+                    Long operatorId, String operatorName,
21
+                    String fromStatus, String toStatus, String remark) {
22
+        DispatchTracking tracking = new DispatchTracking();
23
+        tracking.setCommandId(commandId);
24
+        tracking.setExecutionId(executionId);
25
+        tracking.setAction(action);
26
+        tracking.setOperatorId(operatorId);
27
+        tracking.setOperatorName(operatorName);
28
+        tracking.setFromStatus(fromStatus);
29
+        tracking.setToStatus(toStatus);
30
+        tracking.setRemark(remark);
31
+        trackingMapper.insert(tracking);
32
+    }
33
+
34
+    public void log(Long commandId, Long executionId, String action,
35
+                    Long operatorId, String operatorName,
36
+                    String toStatus, String remark) {
37
+        log(commandId, executionId, action, operatorId, operatorName, null, toStatus, remark);
38
+    }
39
+
40
+    public List<DispatchTracking> getTrackingLogs(Long commandId) {
41
+        LambdaQueryWrapper<DispatchTracking> wrapper = new LambdaQueryWrapper<>();
42
+        wrapper.eq(DispatchTracking::getCommandId, commandId)
43
+               .orderByAsc(DispatchTracking::getCreatedAt);
44
+        return trackingMapper.selectList(wrapper);
45
+    }
46
+
47
+    public List<DispatchTracking> getExecutionTrackingLogs(Long executionId) {
48
+        LambdaQueryWrapper<DispatchTracking> wrapper = new LambdaQueryWrapper<>();
49
+        wrapper.eq(DispatchTracking::getExecutionId, executionId)
50
+               .orderByAsc(DispatchTracking::getCreatedAt);
51
+        return trackingMapper.selectList(wrapper);
52
+    }
53
+}

+ 57
- 0
wm-production/src/main/resources/db/V_dispatch_command.sql Просмотреть файл

@@ -0,0 +1,57 @@
1
+-- 调度指令管理模块 DDL
2
+
3
+CREATE TABLE IF NOT EXISTS prod_dispatch_command (
4
+    id              BIGSERIAL PRIMARY KEY,
5
+    command_no      VARCHAR(64)  NOT NULL UNIQUE,
6
+    command_title   VARCHAR(200) NOT NULL,
7
+    command_content TEXT         NOT NULL,
8
+    command_type    VARCHAR(32)  NOT NULL,
9
+    source          VARCHAR(100),
10
+    priority        VARCHAR(16)  DEFAULT 'normal',
11
+    target_type     VARCHAR(32),
12
+    target_ids      TEXT,
13
+    status          VARCHAR(32)  NOT NULL DEFAULT 'draft',
14
+    issued_at       TIMESTAMP,
15
+    issued_by       BIGINT,
16
+    completed_at    TIMESTAMP,
17
+    remark          TEXT,
18
+    deleted         INTEGER      DEFAULT 0,
19
+    created_at      TIMESTAMP    DEFAULT CURRENT_TIMESTAMP,
20
+    updated_at      TIMESTAMP    DEFAULT CURRENT_TIMESTAMP
21
+);
22
+
23
+CREATE TABLE IF NOT EXISTS prod_dispatch_execution (
24
+    id              BIGSERIAL PRIMARY KEY,
25
+    command_id      BIGINT       NOT NULL,
26
+    user_id         BIGINT       NOT NULL,
27
+    user_name       VARCHAR(64),
28
+    received_at     TIMESTAMP,
29
+    execute_status  VARCHAR(32)  DEFAULT 'pending',
30
+    feedback        TEXT,
31
+    feedback_images TEXT,
32
+    completed_at    TIMESTAMP,
33
+    rejected_reason TEXT,
34
+    deleted         INTEGER      DEFAULT 0,
35
+    created_at      TIMESTAMP    DEFAULT CURRENT_TIMESTAMP,
36
+    updated_at      TIMESTAMP    DEFAULT CURRENT_TIMESTAMP
37
+);
38
+
39
+CREATE TABLE IF NOT EXISTS prod_dispatch_tracking (
40
+    id              BIGSERIAL PRIMARY KEY,
41
+    command_id      BIGINT       NOT NULL,
42
+    execution_id    BIGINT,
43
+    action          VARCHAR(32)  NOT NULL,
44
+    operator_id     BIGINT,
45
+    operator_name   VARCHAR(64),
46
+    from_status     VARCHAR(32),
47
+    to_status       VARCHAR(32),
48
+    remark          TEXT,
49
+    created_at      TIMESTAMP    DEFAULT CURRENT_TIMESTAMP
50
+);
51
+
52
+CREATE INDEX IF NOT EXISTS idx_cmd_status ON prod_dispatch_command(status);
53
+CREATE INDEX IF NOT EXISTS idx_cmd_type ON prod_dispatch_command(command_type);
54
+CREATE INDEX IF NOT EXISTS idx_cmd_created ON prod_dispatch_command(created_at);
55
+CREATE INDEX IF NOT EXISTS idx_exec_cmd ON prod_dispatch_execution(command_id);
56
+CREATE INDEX IF NOT EXISTS idx_exec_user ON prod_dispatch_execution(user_id);
57
+CREATE INDEX IF NOT EXISTS idx_track_cmd ON prod_dispatch_tracking(command_id);

+ 44
- 0
wm-production/src/main/resources/mapper/DispatchCommandMapper.xml Просмотреть файл

@@ -0,0 +1,44 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
3
+<mapper namespace="com.water.production.mapper.DispatchCommandMapper">
4
+
5
+    <select id="selectCommandPage" resultType="java.util.Map">
6
+        SELECT
7
+            c.id, c.command_no, c.command_title, c.command_type,
8
+            c.source, c.priority, c.status, c.issued_at, c.created_at,
9
+            COUNT(e.id) AS total_executions,
10
+            COUNT(CASE WHEN e.execute_status = 'completed' THEN 1 END) AS completed_count,
11
+            COUNT(CASE WHEN e.execute_status = 'rejected' THEN 1 END) AS rejected_count
12
+        FROM prod_dispatch_command c
13
+        LEFT JOIN prod_dispatch_execution e ON e.command_id = c.id AND e.deleted = 0
14
+        WHERE c.deleted = 0
15
+        <if test="status != null and status != ''">AND c.status = #{status}</if>
16
+        <if test="commandType != null and commandType != ''">AND c.command_type = #{commandType}</if>
17
+        <if test="keyword != null and keyword != ''">
18
+            AND (c.command_no LIKE '%' || #{keyword} || '%' OR c.command_title LIKE '%' || #{keyword} || '%')
19
+        </if>
20
+        <if test="startDate != null and startDate != ''">AND c.created_at &gt;= #{startDate}::timestamp</if>
21
+        <if test="endDate != null and endDate != ''">AND c.created_at &lt;= #{endDate}::timestamp</if>
22
+        GROUP BY c.id
23
+        ORDER BY c.created_at DESC
24
+    </select>
25
+
26
+    <select id="selectCommandDetail" resultType="java.util.Map">
27
+        SELECT c.*,
28
+            (SELECT json_agg(json_build_object(
29
+                'id', e.id, 'userId', e.user_id, 'userName', e.user_name,
30
+                'executeStatus', e.execute_status, 'receivedAt', e.received_at,
31
+                'feedback', e.feedback, 'completedAt', e.completed_at,
32
+                'rejectedReason', e.rejected_reason
33
+            )) FROM prod_dispatch_execution e WHERE e.command_id = c.id AND e.deleted = 0) AS executions
34
+        FROM prod_dispatch_command c
35
+        WHERE c.id = #{commandId} AND c.deleted = 0
36
+    </select>
37
+
38
+    <select id="selectStatusStats" resultType="java.util.Map">
39
+        SELECT status, COUNT(*) AS count
40
+        FROM prod_dispatch_command WHERE deleted = 0
41
+        GROUP BY status
42
+    </select>
43
+
44
+</mapper>

+ 223
- 0
wm-production/src/test/java/com/water/production/service/DispatchCommandServiceTest.java Просмотреть файл

@@ -0,0 +1,223 @@
1
+package com.water.production.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.water.common.core.exception.BusinessException;
5
+import com.water.production.entity.DispatchCommand;
6
+import com.water.production.entity.DispatchExecution;
7
+import com.water.production.mapper.DispatchCommandMapper;
8
+import com.water.production.mapper.DispatchExecutionMapper;
9
+import org.junit.jupiter.api.Test;
10
+import org.junit.jupiter.api.extension.ExtendWith;
11
+import org.mockito.InjectMocks;
12
+import org.mockito.Mock;
13
+import org.mockito.junit.jupiter.MockitoExtension;
14
+
15
+import static org.junit.jupiter.api.Assertions.*;
16
+import static org.mockito.ArgumentMatchers.any;
17
+import static org.mockito.Mockito.*;
18
+
19
+@ExtendWith(MockitoExtension.class)
20
+class DispatchCommandServiceTest {
21
+
22
+    @Mock
23
+    private DispatchCommandMapper commandMapper;
24
+    @Mock
25
+    private DispatchExecutionMapper executionMapper;
26
+    @Mock
27
+    private DispatchTrackingService trackingService;
28
+
29
+    @InjectMocks
30
+    private DispatchCommandService commandService;
31
+
32
+    @Test
33
+    void testCreateCommand() {
34
+        when(commandMapper.insert(any())).thenReturn(1);
35
+
36
+        DispatchCommand cmd = new DispatchCommand();
37
+        cmd.setCommandTitle("测试调度指令");
38
+        cmd.setCommandContent("请检查A区域管网压力");
39
+        cmd.setCommandType("normal");
40
+        cmd.setPriority("high");
41
+        cmd.setSource("手动");
42
+        cmd.setTargetType("user");
43
+        cmd.setTargetIds("[1,2]");
44
+
45
+        DispatchCommand result = commandService.createCommand(cmd);
46
+
47
+        assertNotNull(result.getCommandNo());
48
+        assertTrue(result.getCommandNo().startsWith("CMD-"));
49
+        assertEquals("draft", result.getStatus());
50
+        assertEquals("测试调度指令", result.getCommandTitle());
51
+        verify(commandMapper).insert(any());
52
+        verify(trackingService).log(any(), isNull(), eq("create"), isNull(), isNull(), eq("draft"), any());
53
+    }
54
+
55
+    @Test
56
+    void testIssueCommand() {
57
+        DispatchCommand cmd = buildCommand("draft");
58
+        when(commandMapper.selectById(1L)).thenReturn(cmd);
59
+        when(commandMapper.updateById(any())).thenReturn(1);
60
+        when(executionMapper.insert(any())).thenReturn(1);
61
+
62
+        DispatchCommand result = commandService.issueCommand(1L, 100L, "admin");
63
+
64
+        assertEquals("issued", result.getStatus());
65
+        assertNotNull(result.getIssuedAt());
66
+        assertEquals(100L, result.getIssuedBy());
67
+        verify(executionMapper, times(2)).insert(any()); // 2 target users
68
+        verify(trackingService).log(eq(1L), isNull(), eq("issue"), eq(100L), eq("admin"),
69
+                eq("draft"), eq("issued"), any());
70
+    }
71
+
72
+    @Test
73
+    void testIssueCommand_invalidTransition() {
74
+        DispatchCommand cmd = buildCommand("issued");
75
+        when(commandMapper.selectById(1L)).thenReturn(cmd);
76
+
77
+        assertThrows(BusinessException.class, () -> {
78
+            commandService.issueCommand(1L, 100L, "admin");
79
+        });
80
+    }
81
+
82
+    @Test
83
+    void testReceiveCommand() {
84
+        DispatchCommand cmd = buildCommand("issued");
85
+        when(commandMapper.selectById(1L)).thenReturn(cmd);
86
+
87
+        DispatchExecution exec = buildExecution("pending");
88
+        when(executionMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(exec);
89
+        when(executionMapper.updateById(any())).thenReturn(1);
90
+        when(executionMapper.selectCount(any(LambdaQueryWrapper.class)))
91
+                .thenReturn(2L)  // total
92
+                .thenReturn(2L); // all received
93
+
94
+        DispatchExecution result = commandService.receiveCommand(1L, 1L, "张三");
95
+
96
+        assertEquals("received", result.getExecuteStatus());
97
+        assertNotNull(result.getReceivedAt());
98
+    }
99
+
100
+    @Test
101
+    void testStartExecution() {
102
+        DispatchCommand cmd = buildCommand("received");
103
+        when(commandMapper.selectById(1L)).thenReturn(cmd);
104
+        when(commandMapper.updateById(any())).thenReturn(1);
105
+
106
+        DispatchExecution exec = buildExecution("received");
107
+        when(executionMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(exec);
108
+        when(executionMapper.updateById(any())).thenReturn(1);
109
+
110
+        DispatchExecution result = commandService.startExecution(1L, 1L, "张三");
111
+
112
+        assertEquals("executing", result.getExecuteStatus());
113
+    }
114
+
115
+    @Test
116
+    void testStartExecution_wrongStatus() {
117
+        DispatchCommand cmd = buildCommand("received");
118
+        when(commandMapper.selectById(1L)).thenReturn(cmd);
119
+
120
+        DispatchExecution exec = buildExecution("pending");
121
+        when(executionMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(exec);
122
+
123
+        assertThrows(BusinessException.class, () -> {
124
+            commandService.startExecution(1L, 1L, "张三");
125
+        });
126
+    }
127
+
128
+    @Test
129
+    void testCompleteExecution() {
130
+        DispatchCommand cmd = buildCommand("executing");
131
+        when(commandMapper.selectById(1L)).thenReturn(cmd);
132
+
133
+        DispatchExecution exec = buildExecution("executing");
134
+        when(executionMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(exec);
135
+        when(executionMapper.updateById(any())).thenReturn(1);
136
+        when(executionMapper.selectCount(any(LambdaQueryWrapper.class)))
137
+                .thenReturn(1L)  // total
138
+                .thenReturn(1L); // all final
139
+
140
+        DispatchExecution result = commandService.completeExecution(1L, 1L, "张三", "已完成巡检", null);
141
+
142
+        assertEquals("completed", result.getExecuteStatus());
143
+        assertEquals("已完成巡检", result.getFeedback());
144
+        assertNotNull(result.getCompletedAt());
145
+    }
146
+
147
+    @Test
148
+    void testCompleteExecution_wrongStatus() {
149
+        DispatchCommand cmd = buildCommand("executing");
150
+        when(commandMapper.selectById(1L)).thenReturn(cmd);
151
+
152
+        DispatchExecution exec = buildExecution("received");
153
+        when(executionMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(exec);
154
+
155
+        assertThrows(BusinessException.class, () -> {
156
+            commandService.completeExecution(1L, 1L, "张三", "反馈", null);
157
+        });
158
+    }
159
+
160
+    @Test
161
+    void testRejectExecution() {
162
+        DispatchCommand cmd = buildCommand("issued");
163
+        when(commandMapper.selectById(1L)).thenReturn(cmd);
164
+
165
+        DispatchExecution exec = buildExecution("pending");
166
+        when(executionMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(exec);
167
+        when(executionMapper.updateById(any())).thenReturn(1);
168
+        when(executionMapper.selectCount(any(LambdaQueryWrapper.class)))
169
+                .thenReturn(1L)  // total
170
+                .thenReturn(1L); // all final
171
+
172
+        DispatchExecution result = commandService.rejectExecution(1L, 1L, "张三", "人手不足");
173
+
174
+        assertEquals("rejected", result.getExecuteStatus());
175
+        assertEquals("人手不足", result.getRejectedReason());
176
+    }
177
+
178
+    @Test
179
+    void testRejectExecution_alreadyCompleted() {
180
+        DispatchCommand cmd = buildCommand("executing");
181
+        when(commandMapper.selectById(1L)).thenReturn(cmd);
182
+
183
+        DispatchExecution exec = buildExecution("completed");
184
+        when(executionMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(exec);
185
+
186
+        assertThrows(BusinessException.class, () -> {
187
+            commandService.rejectExecution(1L, 1L, "张三", "原因");
188
+        });
189
+    }
190
+
191
+    @Test
192
+    void testCommandNotFound() {
193
+        when(commandMapper.selectById(999L)).thenReturn(null);
194
+
195
+        assertThrows(BusinessException.class, () -> {
196
+            commandService.issueCommand(999L, 1L, "admin");
197
+        });
198
+    }
199
+
200
+    // ==================== Helper ====================
201
+
202
+    private DispatchCommand buildCommand(String status) {
203
+        DispatchCommand cmd = new DispatchCommand();
204
+        cmd.setId(1L);
205
+        cmd.setCommandNo("CMD-20260614150000-0001");
206
+        cmd.setCommandTitle("测试指令");
207
+        cmd.setCommandContent("测试内容");
208
+        cmd.setCommandType("normal");
209
+        cmd.setStatus(status);
210
+        cmd.setTargetIds("[1,2]");
211
+        return cmd;
212
+    }
213
+
214
+    private DispatchExecution buildExecution(String status) {
215
+        DispatchExecution exec = new DispatchExecution();
216
+        exec.setId(1L);
217
+        exec.setCommandId(1L);
218
+        exec.setUserId(1L);
219
+        exec.setUserName("张三");
220
+        exec.setExecuteStatus(status);
221
+        return exec;
222
+    }
223
+}

+ 97
- 0
wm-production/src/test/java/com/water/production/service/DispatchTrackingServiceTest.java Просмотреть файл

@@ -0,0 +1,97 @@
1
+package com.water.production.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.water.production.entity.DispatchTracking;
5
+import com.water.production.mapper.DispatchTrackingMapper;
6
+import org.junit.jupiter.api.Test;
7
+import org.junit.jupiter.api.extension.ExtendWith;
8
+import org.mockito.ArgumentCaptor;
9
+import org.mockito.InjectMocks;
10
+import org.mockito.Mock;
11
+import org.mockito.junit.jupiter.MockitoExtension;
12
+
13
+import java.util.List;
14
+
15
+import static org.junit.jupiter.api.Assertions.*;
16
+import static org.mockito.ArgumentMatchers.any;
17
+import static org.mockito.Mockito.*;
18
+
19
+@ExtendWith(MockitoExtension.class)
20
+class DispatchTrackingServiceTest {
21
+
22
+    @Mock
23
+    private DispatchTrackingMapper trackingMapper;
24
+
25
+    @InjectMocks
26
+    private DispatchTrackingService trackingService;
27
+
28
+    @Test
29
+    void testLog() {
30
+        when(trackingMapper.insert(any())).thenReturn(1);
31
+
32
+        trackingService.log(1L, null, "create", null, null, "draft", "创建指令");
33
+
34
+        ArgumentCaptor<DispatchTracking> captor = ArgumentCaptor.forClass(DispatchTracking.class);
35
+        verify(trackingMapper).insert(captor.capture());
36
+
37
+        DispatchTracking saved = captor.getValue();
38
+        assertEquals(1L, saved.getCommandId());
39
+        assertNull(saved.getExecutionId());
40
+        assertEquals("create", saved.getAction());
41
+        assertEquals("draft", saved.getToStatus());
42
+        assertEquals("创建指令", saved.getRemark());
43
+    }
44
+
45
+    @Test
46
+    void testLogWithExecution() {
47
+        when(trackingMapper.insert(any())).thenReturn(1);
48
+
49
+        trackingService.log(1L, 5L, "receive", 10L, "张三", "pending", "received", "接收确认");
50
+
51
+        ArgumentCaptor<DispatchTracking> captor = ArgumentCaptor.forClass(DispatchTracking.class);
52
+        verify(trackingMapper).insert(captor.capture());
53
+
54
+        DispatchTracking saved = captor.getValue();
55
+        assertEquals(5L, saved.getExecutionId());
56
+        assertEquals(10L, saved.getOperatorId());
57
+        assertEquals("张三", saved.getOperatorName());
58
+        assertEquals("pending", saved.getFromStatus());
59
+        assertEquals("received", saved.getToStatus());
60
+    }
61
+
62
+    @Test
63
+    void testGetTrackingLogs() {
64
+        DispatchTracking t1 = new DispatchTracking();
65
+        t1.setId(1L);
66
+        t1.setCommandId(1L);
67
+        t1.setAction("create");
68
+
69
+        DispatchTracking t2 = new DispatchTracking();
70
+        t2.setId(2L);
71
+        t2.setCommandId(1L);
72
+        t2.setAction("issue");
73
+
74
+        when(trackingMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(t1, t2));
75
+
76
+        List<DispatchTracking> logs = trackingService.getTrackingLogs(1L);
77
+
78
+        assertEquals(2, logs.size());
79
+        assertEquals("create", logs.get(0).getAction());
80
+        assertEquals("issue", logs.get(1).getAction());
81
+    }
82
+
83
+    @Test
84
+    void testGetExecutionTrackingLogs() {
85
+        DispatchTracking t1 = new DispatchTracking();
86
+        t1.setId(3L);
87
+        t1.setExecutionId(5L);
88
+        t1.setAction("receive");
89
+
90
+        when(trackingMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(t1));
91
+
92
+        List<DispatchTracking> logs = trackingService.getExecutionTrackingLogs(5L);
93
+
94
+        assertEquals(1, logs.size());
95
+        assertEquals(5L, logs.get(0).getExecutionId());
96
+    }
97
+}