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
This commit is contained in:
2026-06-14 15:30:52 +08:00
parent 21fa7cffd2
commit 6c6db59ba9
18 changed files with 1715 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
import request from './request'
const BASE = '/api/production/dispatch-command'
// 创建指令
export function createCommand(data: any) {
return request.post(BASE, data)
}
// 下发指令
export function issueCommand(id: number, issuedBy: number, operatorName?: string) {
return request.post(`${BASE}/${id}/issue`, null, {
params: { issuedBy, operatorName: operatorName || 'system' }
})
}
// 指令台账
export function listCommands(params: {
page?: number; size?: number; status?: string;
commandType?: string; keyword?: string; startDate?: string; endDate?: string
}) {
return request.get(BASE, { params })
}
// 指令详情
export function getCommandDetail(id: number) {
return request.get(`${BASE}/${id}`)
}
// 状态统计
export function getCommandStats() {
return request.get(`${BASE}/stats`)
}
// 接收确认
export function receiveCommand(id: number, userId: number, userName?: string) {
return request.post(`${BASE}/${id}/receive`, null, {
params: { userId, userName: userName || '' }
})
}
// 开始执行
export function startExecute(id: number, userId: number, userName?: string) {
return request.post(`${BASE}/${id}/start-execute`, null, {
params: { userId, userName: userName || '' }
})
}
// 完成执行
export function completeExecution(id: number, userId: number, data: {
userName?: string; feedback?: string; feedbackImages?: string
}) {
return request.post(`${BASE}/${id}/complete`, null, {
params: { userId, ...data }
})
}
// 驳回
export function rejectExecution(id: number, userId: number, reason: string, userName?: string) {
return request.post(`${BASE}/${id}/reject`, null, {
params: { userId, userName: userName || '', reason }
})
}
// 追踪日志
export function getTrackingLogs(id: number) {
return request.get(`${BASE}/${id}/tracking`)
}
+2
View File
@@ -11,6 +11,8 @@ const routes = [
{ path: 'system/role', name: 'role', component: () => import('@/views/system/role/RoleList.vue') }, { path: 'system/role', name: 'role', component: () => import('@/views/system/role/RoleList.vue') },
{ path: 'system/menu', name: 'menu', component: () => import('@/views/system/menu/MenuList.vue') }, { path: 'system/menu', name: 'menu', component: () => import('@/views/system/menu/MenuList.vue') },
{ path: 'system/dept', name: 'dept', component: () => import('@/views/system/dept/DeptList.vue') }, { path: 'system/dept', name: 'dept', component: () => import('@/views/system/dept/DeptList.vue') },
{ path: 'dispatch-command', name: 'dispatchCommandList', component: () => import('@/views/dispatch-command/CommandList.vue') },
{ path: 'dispatch-command/:id', name: 'dispatchCommandDetail', component: () => import('@/views/dispatch-command/CommandDetail.vue') },
] ]
}, },
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' } { path: '/:pathMatch(.*)*', redirect: '/dashboard' }
@@ -0,0 +1,135 @@
<template>
<el-dialog
:model-value="visible"
@update:model-value="$emit('update:visible', $event)"
title="创建调度指令"
width="600"
:close-on-click-modal="false">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="指令标题" prop="commandTitle">
<el-input v-model="form.commandTitle" placeholder="请输入指令标题" maxlength="200" show-word-limit />
</el-form-item>
<el-form-item label="指令类型" prop="commandType">
<el-select v-model="form.commandType" placeholder="请选择">
<el-option label="常规" value="normal" />
<el-option label="应急" value="emergency" />
<el-option label="维护" value="maintenance" />
<el-option label="巡检" value="inspection" />
</el-select>
</el-form-item>
<el-form-item label="优先级" prop="priority">
<el-radio-group v-model="form.priority">
<el-radio value="low">低</el-radio>
<el-radio value="normal">普通</el-radio>
<el-radio value="high">高</el-radio>
<el-radio value="urgent">紧急</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="来源" prop="source">
<el-input v-model="form.source" placeholder="手动/系统/报警联动" />
</el-form-item>
<el-form-item label="指令内容" prop="commandContent">
<el-input v-model="form.commandContent" type="textarea" :rows="5"
placeholder="请输入指令详细内容" maxlength="2000" show-word-limit />
</el-form-item>
<el-form-item label="目标类型" prop="targetType">
<el-select v-model="form.targetType" placeholder="请选择">
<el-option label="指定人员" value="user" />
<el-option label="部门" value="dept" />
<el-option label="角色" value="role" />
</el-select>
</el-form-item>
<el-form-item label="目标人员" prop="targetIds">
<el-input v-model="form.targetIds" placeholder="目标ID列表,多个用逗号分隔,如: 1,2,3" />
<div class="form-tip">输入用户ID,多个用逗号分隔</div>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="备注信息(可选)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="$emit('update:visible', false)">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">
创建指令
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { createCommand } from '@/api/dispatchCommand'
const props = defineProps<{ visible: boolean }>()
const emit = defineEmits<{
'update:visible': [value: boolean]
'created': []
}>()
const formRef = ref<FormInstance>()
const submitting = ref(false)
const defaultForm = () => ({
commandTitle: '',
commandType: 'normal',
priority: 'normal',
source: '手动',
commandContent: '',
targetType: 'user',
targetIds: '',
remark: ''
})
const form = reactive(defaultForm())
const rules: FormRules = {
commandTitle: [{ required: true, message: '请输入指令标题', trigger: 'blur' }],
commandType: [{ required: true, message: '请选择指令类型', trigger: 'change' }],
priority: [{ required: true, message: '请选择优先级', trigger: 'change' }],
commandContent: [{ required: true, message: '请输入指令内容', trigger: 'blur' }],
targetType: [{ required: true, message: '请选择目标类型', trigger: 'change' }],
targetIds: [{ required: true, message: '请输入目标ID', trigger: 'blur' }]
}
watch(() => props.visible, (val) => {
if (val) {
Object.assign(form, defaultForm())
formRef.value?.resetFields()
}
})
async function handleSubmit() {
if (!formRef.value) return
await formRef.value.validate()
submitting.value = true
try {
// 将 targetIds 转为 JSON 数组格式
const targetIdsArr = form.targetIds.split(',').map((s: string) => s.trim()).filter(Boolean)
await createCommand({
...form,
targetIds: JSON.stringify(targetIdsArr.map(Number))
})
ElMessage.success('指令创建成功')
emit('update:visible', false)
emit('created')
} catch (e: any) {
ElMessage.error(e.message || '创建失败')
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.form-tip { font-size: 12px; color: #909399; margin-top: 4px; }
</style>
@@ -0,0 +1,304 @@
<template>
<div class="command-detail" v-loading="loading">
<!-- 返回按钮 -->
<el-page-header @back="router.back()" :title="'返回'" style="margin-bottom: 16px">
<template #content>
<span class="page-title">指令详情</span>
<el-tag :type="statusTag(detail.status)" style="margin-left: 12px">{{ statusLabel(detail.status) }}</el-tag>
</template>
</el-page-header>
<el-row :gutter="16">
<!-- 左侧:基本信息 + 状态流转图 -->
<el-col :span="14">
<el-card>
<template #header>
<span>{{ detail.command_title }}</span>
<el-tag size="small" style="margin-left: 8px">{{ detail.command_no }}</el-tag>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="指令编号">{{ detail.command_no }}</el-descriptions-item>
<el-descriptions-item label="类型">
<el-tag size="small">{{ typeLabel(detail.command_type) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="优先级">
<el-tag :type="priorityTag(detail.priority)" size="small">{{ priorityLabel(detail.priority) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="来源">{{ detail.source || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ detail.created_at }}</el-descriptions-item>
<el-descriptions-item label="下发时间">{{ detail.issued_at || '-' }}</el-descriptions-item>
<el-descriptions-item label="完成时间">{{ detail.completed_at || '-' }}</el-descriptions-item>
<el-descriptions-item label="目标类型">{{ detail.target_type || '-' }}</el-descriptions-item>
</el-descriptions>
<div style="margin-top: 16px">
<h4>指令内容</h4>
<div class="content-block">{{ detail.command_content }}</div>
</div>
<!-- 状态流转图 -->
<div style="margin-top: 24px">
<h4>状态流转</h4>
<el-steps :active="statusStep(detail.status)" finish-status="success" align-center>
<el-step title="草稿" description="创建指令" />
<el-step title="已下发" description="下发给执行人" />
<el-step title="已接收" description="执行人确认" />
<el-step title="执行中" description="正在执行" />
<el-step title="完成/驳回" description="归档" />
</el-steps>
</div>
</el-card>
</el-col>
<!-- 右侧:执行记录列表 -->
<el-col :span="10">
<el-card>
<template #header>执行记录</template>
<el-timeline v-if="executions.length">
<el-timeline-item
v-for="exec in executions" :key="exec.id"
:type="executionTimelineType(exec.execute_status)"
:timestamp="exec.received_at || exec.created_at"
placement="top">
<div class="exec-card">
<div class="exec-header">
<span class="exec-user">{{ exec.user_name || `用户${exec.user_id}` }}</span>
<el-tag :type="executionStatusTag(exec.execute_status)" size="small">
{{ executionStatusLabel(exec.execute_status) }}
</el-tag>
</div>
<div v-if="exec.feedback" class="exec-feedback">
反馈: {{ exec.feedback }}
</div>
<div v-if="exec.rejected_reason" class="exec-reject">
驳回原因: {{ exec.rejected_reason }}
</div>
<div class="exec-actions" v-if="canOperate(exec)">
<el-button size="small" type="primary"
v-if="exec.execute_status === 'pending'"
@click="handleReceive(exec)">接收</el-button>
<el-button size="small" type="success"
v-if="exec.execute_status === 'received'"
@click="handleStartExecute(exec)">开始执行</el-button>
<el-button size="small" type="success"
v-if="exec.execute_status === 'executing'"
@click="showCompleteDialog(exec)">完成</el-button>
<el-button size="small" type="danger"
v-if="exec.execute_status !== 'completed' && exec.execute_status !== 'rejected'"
@click="handleReject(exec)">驳回</el-button>
</div>
</div>
</el-timeline-item>
</el-timeline>
<el-empty v-else description="暂无执行记录" />
</el-card>
</el-col>
</el-row>
<!-- 追踪日志 -->
<el-card style="margin-top: 16px">
<template #header>全过程追踪日志</template>
<el-timeline>
<el-timeline-item
v-for="log in trackingLogs" :key="log.id"
:timestamp="log.created_at" placement="top"
:type="trackingType(log.action)">
<div>
<el-tag size="small" :type="trackingType(log.action)">{{ trackingActionLabel(log.action) }}</el-tag>
<span style="margin-left: 8px">{{ log.operator_name || '' }}</span>
<span v-if="log.from_status" style="margin-left: 8px; color: #909399">
{{ log.from_status }} → {{ log.to_status }}
</span>
<div v-if="log.remark" style="color: #606266; margin-top: 4px">{{ log.remark }}</div>
</div>
</el-timeline-item>
</el-timeline>
<el-empty v-if="!trackingLogs.length" description="暂无追踪日志" />
</el-card>
<!-- 完成弹窗 -->
<el-dialog v-model="completeVisible" title="完成执行" width="500">
<el-form label-width="80px">
<el-form-item label="反馈说明">
<el-input v-model="completeForm.feedback" type="textarea" :rows="3" placeholder="请输入执行反馈" />
</el-form-item>
<el-form-item label="反馈图片">
<el-input v-model="completeForm.feedbackImages" placeholder="图片URL,多个用逗号分隔" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="completeVisible = false">取消</el-button>
<el-button type="primary" @click="handleComplete">确认完成</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getCommandDetail, receiveCommand, startExecute, completeExecution, rejectExecution } from '@/api/dispatchCommand'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const detail = ref<any>({})
const executions = ref<any[]>([])
const trackingLogs = ref<any[]>([])
const completeVisible = ref(false)
const currentExec = ref<any>(null)
const completeForm = reactive({ feedback: '', feedbackImages: '' })
const commandId = Number(route.params.id)
// 模拟当前用户ID(实际应从登录态获取)
const currentUserId = 1
const currentUserName = 'admin'
const statusMap: Record<string, { label: string; type: string; step: number }> = {
draft: { label: '草稿', type: 'info', step: 0 },
issued: { label: '已下发', type: 'warning', step: 1 },
received: { label: '已接收', type: '', step: 2 },
executing: { label: '执行中', type: 'primary', step: 3 },
completed: { label: '已完成', type: 'success', step: 4 },
rejected: { label: '已驳回', type: 'danger', step: 4 }
}
const statusLabel = (s: string) => statusMap[s]?.label || s
const statusTag = (s: string) => (statusMap[s]?.type || 'info') as any
const statusStep = (s: string) => statusMap[s]?.step || 0
const typeMap: Record<string, string> = { normal: '常规', emergency: '应急', maintenance: '维护', inspection: '巡检' }
const typeLabel = (t: string) => typeMap[t] || t
const priorityMap: Record<string, { label: string; type: string }> = {
low: { label: '低', type: 'info' }, normal: { label: '普通', type: '' },
high: { label: '高', type: 'warning' }, urgent: { label: '紧急', type: 'danger' }
}
const priorityLabel = (p: string) => priorityMap[p]?.label || p
const priorityTag = (p: string) => (priorityMap[p]?.type || 'info') as any
const executionStatusLabel = (s: string) => {
const map: Record<string, string> = {
pending: '待接收', received: '已接收', executing: '执行中', completed: '已完成', rejected: '已驳回'
}
return map[s] || s
}
const executionStatusTag = (s: string) => {
const map: Record<string, string> = {
pending: 'info', received: '', executing: 'primary', completed: 'success', rejected: 'danger'
}
return (map[s] || 'info') as any
}
const executionTimelineType = (s: string) => {
const map: Record<string, string> = {
pending: 'info', received: 'primary', executing: 'primary', completed: 'success', rejected: 'danger'
}
return (map[s] || 'info') as any
}
const trackingActionLabel = (a: string) => {
const map: Record<string, string> = {
create: '创建', issue: '下发', receive: '接收', start_execute: '开始执行',
complete: '完成', reject: '驳回', cancel: '取消'
}
return map[a] || a
}
const trackingType = (a: string) => {
const map: Record<string, string> = {
create: 'info', issue: 'warning', receive: 'primary', start_execute: 'primary',
complete: 'success', reject: 'danger', cancel: 'danger'
}
return (map[a] || 'info') as any
}
function canOperate(exec: any) {
return exec.user_id === currentUserId || true // 简化:所有人可操作
}
async function fetchDetail() {
loading.value = true
try {
const res = await getCommandDetail(commandId)
detail.value = res.data || {}
executions.value = res.data?.executions || []
trackingLogs.value = res.data?.tracking_logs || res.data?.trackingLogs || []
} finally {
loading.value = false
}
}
async function handleReceive(exec: any) {
try {
await receiveCommand(commandId, exec.user_id, exec.user_name)
ElMessage.success('接收成功')
fetchDetail()
} catch (e: any) {
ElMessage.error(e.message || '操作失败')
}
}
async function handleStartExecute(exec: any) {
try {
await startExecute(commandId, exec.user_id, exec.user_name)
ElMessage.success('已开始执行')
fetchDetail()
} catch (e: any) {
ElMessage.error(e.message || '操作失败')
}
}
function showCompleteDialog(exec: any) {
currentExec.value = exec
completeForm.feedback = ''
completeForm.feedbackImages = ''
completeVisible.value = true
}
async function handleComplete() {
try {
await completeExecution(commandId, currentExec.value.user_id, {
userName: currentExec.value.user_name,
feedback: completeForm.feedback,
feedbackImages: completeForm.feedbackImages
})
ElMessage.success('执行完成')
completeVisible.value = false
fetchDetail()
} catch (e: any) {
ElMessage.error(e.message || '操作失败')
}
}
async function handleReject(exec: any) {
try {
const { value } = await ElMessageBox.prompt('请输入驳回原因', '驳回', {
confirmButtonText: '确认驳回',
cancelButtonText: '取消',
inputPattern: /.+/,
inputErrorMessage: '驳回原因不能为空'
})
await rejectExecution(commandId, exec.user_id, value, exec.user_name)
ElMessage.success('已驳回')
fetchDetail()
} catch { /* cancel */ }
}
onMounted(fetchDetail)
</script>
<style scoped>
.page-title { font-size: 16px; font-weight: 600; }
.content-block {
padding: 12px; background: #f5f7fa; border-radius: 4px;
white-space: pre-wrap; line-height: 1.6;
}
.exec-card { padding: 4px 0; }
.exec-header { display: flex; justify-content: space-between; align-items: center; }
.exec-user { font-weight: 600; }
.exec-feedback { margin-top: 6px; color: #606266; font-size: 13px; }
.exec-reject { margin-top: 6px; color: #f56c6c; font-size: 13px; }
.exec-actions { margin-top: 8px; }
</style>
@@ -0,0 +1,193 @@
<template>
<div class="command-list">
<el-card shadow="never" class="filter-card">
<el-form :inline="true" :model="filterForm">
<el-form-item label="关键词">
<el-input v-model="filterForm.keyword" placeholder="编号/标题" clearable @clear="handleSearch" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filterForm.status" placeholder="全部" clearable @change="handleSearch">
<el-option label="草稿" value="draft" />
<el-option label="已下发" value="issued" />
<el-option label="已接收" value="received" />
<el-option label="执行中" value="executing" />
<el-option label="已完成" value="completed" />
<el-option label="已驳回" value="rejected" />
</el-select>
</el-form-item>
<el-form-item label="类型">
<el-select v-model="filterForm.commandType" placeholder="全部" clearable @change="handleSearch">
<el-option label="常规" value="normal" />
<el-option label="应急" value="emergency" />
<el-option label="维护" value="maintenance" />
<el-option label="巡检" value="inspection" />
</el-select>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker v-model="dateRange" type="daterange" range-separator="至"
start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD"
@change="handleSearch" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch"><el-icon><Search /></el-icon> 查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-row :gutter="12" style="margin-top: 12px">
<el-col :span="4" v-for="stat in stats" :key="stat.status">
<el-card shadow="hover" class="stat-card" @click="filterByStatus(stat.status)">
<div class="stat-value">{{ stat.count }}</div>
<div class="stat-label">{{ statusLabel(stat.status) }}</div>
</el-card>
</el-col>
</el-row>
<div style="margin-top: 16px; display: flex; justify-content: space-between; align-items: center">
<el-button type="primary" @click="showCreateDialog = true"><el-icon><Plus /></el-icon> 创建指令</el-button>
<el-button @click="handleBatchIssue" :disabled="!selectedIds.length">批量下发</el-button>
</div>
<el-table :data="tableData" border style="margin-top: 10px"
@selection-change="handleSelectionChange" v-loading="loading">
<el-table-column type="selection" width="50" />
<el-table-column prop="command_no" label="指令编号" width="220" />
<el-table-column prop="command_title" label="标题" min-width="200" show-overflow-tooltip />
<el-table-column prop="command_type" label="类型" width="80">
<template #default="{ row }">
<el-tag :type="typeTag(row.command_type)" size="small">{{ typeLabel(row.command_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="priority" label="优先级" width="80">
<template #default="{ row }">
<el-tag :type="priorityTag(row.priority)" size="small">{{ priorityLabel(row.priority) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="statusTag(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="执行进度" width="120">
<template #default="{ row }">
<span>{{ row.completed_count || 0 }}/{{ row.total_executions || 0 }}</span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="170" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="viewDetail(row)">详情</el-button>
<el-button link type="success" v-if="row.status === 'draft'" @click="handleIssue(row)">下发</el-button>
<el-button link type="danger" v-if="row.status === 'draft'" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination style="margin-top: 16px; justify-content: flex-end"
v-model:current-page="pagination.page" v-model:page-size="pagination.size"
:total="pagination.total" :page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next" @change="fetchData" />
<CommandCreate v-model:visible="showCreateDialog" @created="handleCreated" />
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus } from '@element-plus/icons-vue'
import { listCommands, issueCommand, getCommandStats } from '@/api/dispatchCommand'
import CommandCreate from './CommandCreate.vue'
const router = useRouter()
const loading = ref(false)
const tableData = ref<any[]>([])
const stats = ref<any[]>([])
const selectedIds = ref<number[]>([])
const showCreateDialog = ref(false)
const dateRange = ref<[string, string] | null>(null)
const filterForm = reactive({ keyword: '', status: '', commandType: '' })
const pagination = reactive({ page: 1, size: 10, total: 0 })
const statusMap: Record<string, { label: string; type: string }> = {
draft: { label: '草稿', type: 'info' },
issued: { label: '已下发', type: 'warning' },
received: { label: '已接收', type: '' },
executing: { label: '执行中', type: 'primary' },
completed: { label: '已完成', type: 'success' },
rejected: { label: '已驳回', type: 'danger' }
}
const typeMap: Record<string, string> = { normal: '常规', emergency: '应急', maintenance: '维护', inspection: '巡检' }
const priorityMap: Record<string, { label: string; type: string }> = {
low: { label: '低', type: 'info' }, normal: { label: '普通', type: '' },
high: { label: '高', type: 'warning' }, urgent: { label: '紧急', type: 'danger' }
}
function statusLabel(s: string) { return statusMap[s]?.label || s }
function statusTag(s: string) { return (statusMap[s]?.type || 'info') as any }
function typeLabel(t: string) { return typeMap[t] || t }
function typeTag(t: string) { return t === 'emergency' ? 'danger' : t === 'maintenance' ? 'warning' : '' }
function priorityLabel(p: string) { return priorityMap[p]?.label || p }
function priorityTag(p: string) { return (priorityMap[p]?.type || 'info') as any }
async function fetchData() {
loading.value = true
try {
const res = await listCommands({
page: pagination.page, size: pagination.size,
status: filterForm.status || undefined,
commandType: filterForm.commandType || undefined,
keyword: filterForm.keyword || undefined,
startDate: dateRange.value?.[0], endDate: dateRange.value?.[1]
})
tableData.value = res.data?.records || []
pagination.total = res.data?.total || 0
} finally { loading.value = false }
}
async function fetchStats() {
try { const res = await getCommandStats(); stats.value = res.data || [] } catch { /* ignore */ }
}
function handleSearch() { pagination.page = 1; fetchData() }
function handleReset() {
filterForm.keyword = ''; filterForm.status = ''; filterForm.commandType = ''; dateRange.value = null; handleSearch()
}
function filterByStatus(status: string) { filterForm.status = status; handleSearch() }
function handleSelectionChange(rows: any[]) { selectedIds.value = rows.map((r: any) => r.id) }
function viewDetail(row: any) { router.push({ path: `/dispatch-command/${row.id}` }) }
async function handleIssue(row: any) {
try {
await ElMessageBox.confirm(`确认下发指令 "${row.command_title}" ?`, '下发确认')
await issueCommand(row.id, 1, 'admin'); ElMessage.success('指令已下发'); fetchData(); fetchStats()
} catch { /* cancel */ }
}
async function handleBatchIssue() {
try {
await ElMessageBox.confirm(`确认批量下发 ${selectedIds.value.length} 条指令?`, '批量下发')
for (const id of selectedIds.value) { await issueCommand(id, 1, 'admin') }
ElMessage.success('批量下发完成'); fetchData(); fetchStats()
} catch { /* cancel */ }
}
function handleDelete(row: any) {
ElMessageBox.confirm(`确认删除指令 "${row.command_title}" ?`, '删除确认', { type: 'warning' })
.then(() => { ElMessage.info('删除功能待实现(逻辑删除)') }).catch(() => { /* cancel */ })
}
function handleCreated() { showCreateDialog.value = false; fetchData(); fetchStats() }
onMounted(() => { fetchData(); fetchStats() })
</script>
<style scoped>
.filter-card :deep(.el-form-item) { margin-bottom: 0; }
.stat-card { cursor: pointer; text-align: center; }
.stat-value { font-size: 28px; font-weight: bold; color: #409eff; }
.stat-label { font-size: 13px; color: #909399; margin-top: 4px; }
</style>
@@ -0,0 +1,106 @@
package com.water.production.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.water.common.core.result.R;
import com.water.production.entity.DispatchCommand;
import com.water.production.entity.DispatchExecution;
import com.water.production.entity.DispatchTracking;
import com.water.production.service.DispatchCommandService;
import com.water.production.service.DispatchTrackingService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@Tag(name = "调度指令管理")
@RestController
@RequestMapping("/api/production/dispatch-command")
@RequiredArgsConstructor
public class DispatchCommandController {
private final DispatchCommandService commandService;
private final DispatchTrackingService trackingService;
@Operation(summary = "创建指令")
@PostMapping
public R<DispatchCommand> create(@RequestBody DispatchCommand command) {
return R.ok(commandService.createCommand(command));
}
@Operation(summary = "下发指令")
@PostMapping("/{id}/issue")
public R<DispatchCommand> issue(@PathVariable Long id,
@RequestParam Long issuedBy,
@RequestParam(required = false, defaultValue = "system") String operatorName) {
return R.ok(commandService.issueCommand(id, issuedBy, operatorName));
}
@Operation(summary = "指令台账(分页)")
@GetMapping
public R<IPage<Map<String, Object>>> list(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String status,
@RequestParam(required = false) String commandType,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String startDate,
@RequestParam(required = false) String endDate) {
return R.ok(commandService.listCommands(page, size, status, commandType, keyword, startDate, endDate));
}
@Operation(summary = "指令详情")
@GetMapping("/{id}")
public R<Map<String, Object>> detail(@PathVariable Long id) {
return R.ok(commandService.getCommandDetail(id));
}
@Operation(summary = "各状态统计")
@GetMapping("/stats")
public R<List<Map<String, Object>>> stats() {
return R.ok(commandService.getStatusStats());
}
@Operation(summary = "接收确认")
@PostMapping("/{id}/receive")
public R<DispatchExecution> receive(@PathVariable Long id,
@RequestParam Long userId,
@RequestParam(required = false, defaultValue = "") String userName) {
return R.ok(commandService.receiveCommand(id, userId, userName));
}
@Operation(summary = "开始执行")
@PostMapping("/{id}/start-execute")
public R<DispatchExecution> startExecute(@PathVariable Long id,
@RequestParam Long userId,
@RequestParam(required = false, defaultValue = "") String userName) {
return R.ok(commandService.startExecution(id, userId, userName));
}
@Operation(summary = "完成执行")
@PostMapping("/{id}/complete")
public R<DispatchExecution> complete(@PathVariable Long id,
@RequestParam Long userId,
@RequestParam(required = false, defaultValue = "") String userName,
@RequestParam(required = false) String feedback,
@RequestParam(required = false) String feedbackImages) {
return R.ok(commandService.completeExecution(id, userId, userName, feedback, feedbackImages));
}
@Operation(summary = "驳回")
@PostMapping("/{id}/reject")
public R<DispatchExecution> reject(@PathVariable Long id,
@RequestParam Long userId,
@RequestParam(required = false, defaultValue = "") String userName,
@RequestParam String reason) {
return R.ok(commandService.rejectExecution(id, userId, userName, reason));
}
@Operation(summary = "查询追踪日志")
@GetMapping("/{id}/tracking")
public R<List<DispatchTracking>> trackingLogs(@PathVariable Long id) {
return R.ok(trackingService.getTrackingLogs(id));
}
}
@@ -0,0 +1,64 @@
package com.water.production.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 调度指令主表
*/
@Data
@TableName("prod_dispatch_command")
public class DispatchCommand {
@TableId(type = IdType.AUTO)
private Long id;
/** 指令编号 CMD-yyyyMMddHHmmss-xxxx */
private String commandNo;
/** 指令标题 */
private String commandTitle;
/** 指令内容 */
private String commandContent;
/** 类型: normal/emergency/maintenance/inspection */
private String commandType;
/** 来源 */
private String source;
/** 优先级: low/normal/high/urgent */
private String priority;
/** 目标类型: user/dept/role */
private String targetType;
/** 目标ID列表 JSON数组 */
private String targetIds;
/** 状态: draft/issued/received/executing/completed/rejected */
private String status;
/** 下发时间 */
private LocalDateTime issuedAt;
/** 下发人 */
private Long issuedBy;
/** 完成归档时间 */
private LocalDateTime completedAt;
/** 备注 */
private String remark;
@TableLogic
private Integer deleted;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
}
@@ -0,0 +1,52 @@
package com.water.production.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 调度指令执行记录表
*/
@Data
@TableName("prod_dispatch_execution")
public class DispatchExecution {
@TableId(type = IdType.AUTO)
private Long id;
/** 关联指令ID */
private Long commandId;
/** 接收/执行人 */
private Long userId;
/** 执行人姓名 */
private String userName;
/** 接收确认时间 */
private LocalDateTime receivedAt;
/** 执行状态: pending/received/executing/completed/rejected */
private String executeStatus;
/** 执行反馈 */
private String feedback;
/** 反馈图片JSON数组 */
private String feedbackImages;
/** 完成时间 */
private LocalDateTime completedAt;
/** 驳回原因 */
private String rejectedReason;
@TableLogic
private Integer deleted;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
}
@@ -0,0 +1,43 @@
package com.water.production.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 调度指令过程追踪日志表
*/
@Data
@TableName("prod_dispatch_tracking")
public class DispatchTracking {
@TableId(type = IdType.AUTO)
private Long id;
/** 关联指令ID */
private Long commandId;
/** 关联执行记录ID(可选) */
private Long executionId;
/** 操作类型: create/issue/receive/start_execute/complete/reject/cancel */
private String action;
/** 操作人 */
private Long operatorId;
/** 操作人姓名 */
private String operatorName;
/** 原状态 */
private String fromStatus;
/** 新状态 */
private String toStatus;
/** 备注 */
private String remark;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
}
@@ -0,0 +1,28 @@
package com.water.production.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.water.production.entity.DispatchCommand;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
@Mapper
public interface DispatchCommandMapper extends BaseMapper<DispatchCommand> {
IPage<Map<String, Object>> selectCommandPage(
Page<?> page,
@Param("status") String status,
@Param("commandType") String commandType,
@Param("keyword") String keyword,
@Param("startDate") String startDate,
@Param("endDate") String endDate
);
Map<String, Object> selectCommandDetail(@Param("commandId") Long commandId);
List<Map<String, Object>> selectStatusStats();
}
@@ -0,0 +1,9 @@
package com.water.production.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.production.entity.DispatchExecution;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface DispatchExecutionMapper extends BaseMapper<DispatchExecution> {
}
@@ -0,0 +1,9 @@
package com.water.production.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.production.entity.DispatchTracking;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface DispatchTrackingMapper extends BaseMapper<DispatchTracking> {
}
@@ -0,0 +1,228 @@
package com.water.production.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.water.common.core.exception.BusinessException;
import com.water.production.entity.DispatchCommand;
import com.water.production.entity.DispatchExecution;
import com.water.production.entity.DispatchTracking;
import com.water.production.mapper.DispatchCommandMapper;
import com.water.production.mapper.DispatchExecutionMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
@Slf4j
@Service
@RequiredArgsConstructor
public class DispatchCommandService {
private final DispatchCommandMapper commandMapper;
private final DispatchExecutionMapper executionMapper;
private final DispatchTrackingService trackingService;
private static final Map<String, Set<String>> STATE_TRANSITIONS = new LinkedHashMap<>();
static {
STATE_TRANSITIONS.put("draft", Set.of("issued"));
STATE_TRANSITIONS.put("issued", Set.of("received", "rejected"));
STATE_TRANSITIONS.put("received", Set.of("executing", "rejected"));
STATE_TRANSITIONS.put("executing", Set.of("completed", "rejected"));
STATE_TRANSITIONS.put("completed", Set.of());
STATE_TRANSITIONS.put("rejected", Set.of());
}
@Transactional
public DispatchCommand createCommand(DispatchCommand command) {
String cmdNo = "CMD-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))
+ "-" + String.format("%04d", new Random().nextInt(10000));
command.setCommandNo(cmdNo);
command.setStatus("draft");
commandMapper.insert(command);
trackingService.log(command.getId(), null, "create", null, null, "draft", "创建指令");
log.info("创建调度指令: {}", cmdNo);
return command;
}
@Transactional
public DispatchCommand issueCommand(Long commandId, Long issuedBy, String operatorName) {
DispatchCommand cmd = getCommandOrThrow(commandId);
validateTransition(cmd.getStatus(), "issued");
cmd.setStatus("issued");
cmd.setIssuedAt(LocalDateTime.now());
cmd.setIssuedBy(issuedBy);
commandMapper.updateById(cmd);
createExecutionRecords(cmd);
trackingService.log(commandId, null, "issue", issuedBy, operatorName, "draft", "issued", "指令下发");
log.info("下发调度指令: {}", cmd.getCommandNo());
return cmd;
}
@Transactional
public DispatchExecution receiveCommand(Long commandId, Long userId, String userName) {
DispatchCommand cmd = getCommandOrThrow(commandId);
validateTransition(cmd.getStatus(), "received");
DispatchExecution exec = getExecutionOrThrow(commandId, userId);
if (!Objects.equals(exec.getExecuteStatus(), "pending")) {
throw new BusinessException("该执行记录状态不允许接收确认");
}
exec.setExecuteStatus("received");
exec.setReceivedAt(LocalDateTime.now());
executionMapper.updateById(exec);
if (allExecutionsInStatus(commandId, "received")) {
cmd.setStatus("received");
commandMapper.updateById(cmd);
}
trackingService.log(commandId, exec.getId(), "receive", userId, userName, "pending", "received", "接收确认");
return exec;
}
@Transactional
public DispatchExecution startExecution(Long commandId, Long userId, String userName) {
DispatchCommand cmd = getCommandOrThrow(commandId);
validateTransition(cmd.getStatus(), "executing");
DispatchExecution exec = getExecutionOrThrow(commandId, userId);
if (!Objects.equals(exec.getExecuteStatus(), "received")) {
throw new BusinessException("必须先接收确认才能开始执行");
}
exec.setExecuteStatus("executing");
executionMapper.updateById(exec);
if (Objects.equals(cmd.getStatus(), "received")) {
cmd.setStatus("executing");
commandMapper.updateById(cmd);
}
trackingService.log(commandId, exec.getId(), "start_execute", userId, userName, "received", "executing", "开始执行");
return exec;
}
@Transactional
public DispatchExecution completeExecution(Long commandId, Long userId, String userName,
String feedback, String feedbackImages) {
DispatchCommand cmd = getCommandOrThrow(commandId);
validateTransition(cmd.getStatus(), "completed");
DispatchExecution exec = getExecutionOrThrow(commandId, userId);
if (!Objects.equals(exec.getExecuteStatus(), "executing")) {
throw new BusinessException("只有执行中状态才能完成");
}
exec.setExecuteStatus("completed");
exec.setFeedback(feedback);
exec.setFeedbackImages(feedbackImages);
exec.setCompletedAt(LocalDateTime.now());
executionMapper.updateById(exec);
if (allExecutionsFinal(commandId)) {
cmd.setStatus("completed");
cmd.setCompletedAt(LocalDateTime.now());
commandMapper.updateById(cmd);
trackingService.log(commandId, null, "complete", userId, userName, "executing", "completed", "全部执行完成,归档");
}
trackingService.log(commandId, exec.getId(), "complete", userId, userName, "executing", "completed", "执行完成");
return exec;
}
@Transactional
public DispatchExecution rejectExecution(Long commandId, Long userId, String userName, String reason) {
DispatchCommand cmd = getCommandOrThrow(commandId);
DispatchExecution exec = getExecutionOrThrow(commandId, userId);
String prevStatus = exec.getExecuteStatus();
if (Objects.equals(prevStatus, "completed") || Objects.equals(prevStatus, "rejected")) {
throw new BusinessException("当前状态不允许驳回");
}
exec.setExecuteStatus("rejected");
exec.setRejectedReason(reason);
exec.setCompletedAt(LocalDateTime.now());
executionMapper.updateById(exec);
if (allExecutionsFinal(commandId)) {
cmd.setStatus("rejected");
commandMapper.updateById(cmd);
trackingService.log(commandId, null, "reject", userId, userName, cmd.getStatus(), "rejected", "全部驳回/终止");
}
trackingService.log(commandId, exec.getId(), "reject", userId, userName, prevStatus, "rejected", "驳回原因: " + reason);
return exec;
}
public IPage<Map<String, Object>> listCommands(int page, int size, String status, String commandType,
String keyword, String startDate, String endDate) {
return commandMapper.selectCommandPage(new Page<>(page, size), status, commandType, keyword, startDate, endDate);
}
public Map<String, Object> getCommandDetail(Long commandId) {
Map<String, Object> detail = commandMapper.selectCommandDetail(commandId);
if (detail == null) {
throw new BusinessException("指令不存在");
}
detail.put("trackingLogs", trackingService.getTrackingLogs(commandId));
return detail;
}
public List<Map<String, Object>> getStatusStats() {
return commandMapper.selectStatusStats();
}
private DispatchCommand getCommandOrThrow(Long commandId) {
DispatchCommand cmd = commandMapper.selectById(commandId);
if (cmd == null) throw new BusinessException("指令不存在");
return cmd;
}
private DispatchExecution getExecutionOrThrow(Long commandId, Long userId) {
LambdaQueryWrapper<DispatchExecution> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DispatchExecution::getCommandId, commandId)
.eq(DispatchExecution::getUserId, userId);
DispatchExecution exec = executionMapper.selectOne(wrapper);
if (exec == null) throw new BusinessException("执行记录不存在");
return exec;
}
private void validateTransition(String currentStatus, String targetStatus) {
Set<String> allowed = STATE_TRANSITIONS.get(currentStatus);
if (allowed == null || !allowed.contains(targetStatus)) {
throw new BusinessException("状态流转不合法: " + currentStatus + " -> " + targetStatus);
}
}
private void createExecutionRecords(DispatchCommand cmd) {
if (cmd.getTargetIds() == null || cmd.getTargetIds().isBlank()) return;
String cleaned = cmd.getTargetIds().replaceAll("[\\[\\]\"]", "");
for (String idStr : cleaned.split(",")) {
String trimmed = idStr.trim();
if (trimmed.isEmpty()) continue;
try {
Long userId = Long.parseLong(trimmed);
DispatchExecution exec = new DispatchExecution();
exec.setCommandId(cmd.getId());
exec.setUserId(userId);
exec.setExecuteStatus("pending");
executionMapper.insert(exec);
} catch (NumberFormatException e) {
log.warn("跳过无效目标ID: {}", trimmed);
}
}
}
private boolean allExecutionsInStatus(Long commandId, String status) {
LambdaQueryWrapper<DispatchExecution> w1 = new LambdaQueryWrapper<>();
w1.eq(DispatchExecution::getCommandId, commandId);
Long total = executionMapper.selectCount(w1);
LambdaQueryWrapper<DispatchExecution> w2 = new LambdaQueryWrapper<>();
w2.eq(DispatchExecution::getCommandId, commandId)
.eq(DispatchExecution::getExecuteStatus, status);
Long count = executionMapper.selectCount(w2);
return total > 0 && total.equals(count);
}
private boolean allExecutionsFinal(Long commandId) {
LambdaQueryWrapper<DispatchExecution> w1 = new LambdaQueryWrapper<>();
w1.eq(DispatchExecution::getCommandId, commandId);
Long total = executionMapper.selectCount(w1);
LambdaQueryWrapper<DispatchExecution> w2 = new LambdaQueryWrapper<>();
w2.eq(DispatchExecution::getCommandId, commandId)
.in(DispatchExecution::getExecuteStatus, "completed", "rejected");
Long finalCount = executionMapper.selectCount(w2);
return total > 0 && total.equals(finalCount);
}
}
@@ -0,0 +1,53 @@
package com.water.production.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.water.production.entity.DispatchTracking;
import com.water.production.mapper.DispatchTrackingMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class DispatchTrackingService {
private final DispatchTrackingMapper trackingMapper;
public void log(Long commandId, Long executionId, String action,
Long operatorId, String operatorName,
String fromStatus, String toStatus, String remark) {
DispatchTracking tracking = new DispatchTracking();
tracking.setCommandId(commandId);
tracking.setExecutionId(executionId);
tracking.setAction(action);
tracking.setOperatorId(operatorId);
tracking.setOperatorName(operatorName);
tracking.setFromStatus(fromStatus);
tracking.setToStatus(toStatus);
tracking.setRemark(remark);
trackingMapper.insert(tracking);
}
public void log(Long commandId, Long executionId, String action,
Long operatorId, String operatorName,
String toStatus, String remark) {
log(commandId, executionId, action, operatorId, operatorName, null, toStatus, remark);
}
public List<DispatchTracking> getTrackingLogs(Long commandId) {
LambdaQueryWrapper<DispatchTracking> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DispatchTracking::getCommandId, commandId)
.orderByAsc(DispatchTracking::getCreatedAt);
return trackingMapper.selectList(wrapper);
}
public List<DispatchTracking> getExecutionTrackingLogs(Long executionId) {
LambdaQueryWrapper<DispatchTracking> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DispatchTracking::getExecutionId, executionId)
.orderByAsc(DispatchTracking::getCreatedAt);
return trackingMapper.selectList(wrapper);
}
}
@@ -0,0 +1,57 @@
-- 调度指令管理模块 DDL
CREATE TABLE IF NOT EXISTS prod_dispatch_command (
id BIGSERIAL PRIMARY KEY,
command_no VARCHAR(64) NOT NULL UNIQUE,
command_title VARCHAR(200) NOT NULL,
command_content TEXT NOT NULL,
command_type VARCHAR(32) NOT NULL,
source VARCHAR(100),
priority VARCHAR(16) DEFAULT 'normal',
target_type VARCHAR(32),
target_ids TEXT,
status VARCHAR(32) NOT NULL DEFAULT 'draft',
issued_at TIMESTAMP,
issued_by BIGINT,
completed_at TIMESTAMP,
remark TEXT,
deleted INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS prod_dispatch_execution (
id BIGSERIAL PRIMARY KEY,
command_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
user_name VARCHAR(64),
received_at TIMESTAMP,
execute_status VARCHAR(32) DEFAULT 'pending',
feedback TEXT,
feedback_images TEXT,
completed_at TIMESTAMP,
rejected_reason TEXT,
deleted INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS prod_dispatch_tracking (
id BIGSERIAL PRIMARY KEY,
command_id BIGINT NOT NULL,
execution_id BIGINT,
action VARCHAR(32) NOT NULL,
operator_id BIGINT,
operator_name VARCHAR(64),
from_status VARCHAR(32),
to_status VARCHAR(32),
remark TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_cmd_status ON prod_dispatch_command(status);
CREATE INDEX IF NOT EXISTS idx_cmd_type ON prod_dispatch_command(command_type);
CREATE INDEX IF NOT EXISTS idx_cmd_created ON prod_dispatch_command(created_at);
CREATE INDEX IF NOT EXISTS idx_exec_cmd ON prod_dispatch_execution(command_id);
CREATE INDEX IF NOT EXISTS idx_exec_user ON prod_dispatch_execution(user_id);
CREATE INDEX IF NOT EXISTS idx_track_cmd ON prod_dispatch_tracking(command_id);
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.water.production.mapper.DispatchCommandMapper">
<select id="selectCommandPage" resultType="java.util.Map">
SELECT
c.id, c.command_no, c.command_title, c.command_type,
c.source, c.priority, c.status, c.issued_at, c.created_at,
COUNT(e.id) AS total_executions,
COUNT(CASE WHEN e.execute_status = 'completed' THEN 1 END) AS completed_count,
COUNT(CASE WHEN e.execute_status = 'rejected' THEN 1 END) AS rejected_count
FROM prod_dispatch_command c
LEFT JOIN prod_dispatch_execution e ON e.command_id = c.id AND e.deleted = 0
WHERE c.deleted = 0
<if test="status != null and status != ''">AND c.status = #{status}</if>
<if test="commandType != null and commandType != ''">AND c.command_type = #{commandType}</if>
<if test="keyword != null and keyword != ''">
AND (c.command_no LIKE '%' || #{keyword} || '%' OR c.command_title LIKE '%' || #{keyword} || '%')
</if>
<if test="startDate != null and startDate != ''">AND c.created_at &gt;= #{startDate}::timestamp</if>
<if test="endDate != null and endDate != ''">AND c.created_at &lt;= #{endDate}::timestamp</if>
GROUP BY c.id
ORDER BY c.created_at DESC
</select>
<select id="selectCommandDetail" resultType="java.util.Map">
SELECT c.*,
(SELECT json_agg(json_build_object(
'id', e.id, 'userId', e.user_id, 'userName', e.user_name,
'executeStatus', e.execute_status, 'receivedAt', e.received_at,
'feedback', e.feedback, 'completedAt', e.completed_at,
'rejectedReason', e.rejected_reason
)) FROM prod_dispatch_execution e WHERE e.command_id = c.id AND e.deleted = 0) AS executions
FROM prod_dispatch_command c
WHERE c.id = #{commandId} AND c.deleted = 0
</select>
<select id="selectStatusStats" resultType="java.util.Map">
SELECT status, COUNT(*) AS count
FROM prod_dispatch_command WHERE deleted = 0
GROUP BY status
</select>
</mapper>
@@ -0,0 +1,223 @@
package com.water.production.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.water.common.core.exception.BusinessException;
import com.water.production.entity.DispatchCommand;
import com.water.production.entity.DispatchExecution;
import com.water.production.mapper.DispatchCommandMapper;
import com.water.production.mapper.DispatchExecutionMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class DispatchCommandServiceTest {
@Mock
private DispatchCommandMapper commandMapper;
@Mock
private DispatchExecutionMapper executionMapper;
@Mock
private DispatchTrackingService trackingService;
@InjectMocks
private DispatchCommandService commandService;
@Test
void testCreateCommand() {
when(commandMapper.insert(any())).thenReturn(1);
DispatchCommand cmd = new DispatchCommand();
cmd.setCommandTitle("测试调度指令");
cmd.setCommandContent("请检查A区域管网压力");
cmd.setCommandType("normal");
cmd.setPriority("high");
cmd.setSource("手动");
cmd.setTargetType("user");
cmd.setTargetIds("[1,2]");
DispatchCommand result = commandService.createCommand(cmd);
assertNotNull(result.getCommandNo());
assertTrue(result.getCommandNo().startsWith("CMD-"));
assertEquals("draft", result.getStatus());
assertEquals("测试调度指令", result.getCommandTitle());
verify(commandMapper).insert(any());
verify(trackingService).log(any(), isNull(), eq("create"), isNull(), isNull(), eq("draft"), any());
}
@Test
void testIssueCommand() {
DispatchCommand cmd = buildCommand("draft");
when(commandMapper.selectById(1L)).thenReturn(cmd);
when(commandMapper.updateById(any())).thenReturn(1);
when(executionMapper.insert(any())).thenReturn(1);
DispatchCommand result = commandService.issueCommand(1L, 100L, "admin");
assertEquals("issued", result.getStatus());
assertNotNull(result.getIssuedAt());
assertEquals(100L, result.getIssuedBy());
verify(executionMapper, times(2)).insert(any()); // 2 target users
verify(trackingService).log(eq(1L), isNull(), eq("issue"), eq(100L), eq("admin"),
eq("draft"), eq("issued"), any());
}
@Test
void testIssueCommand_invalidTransition() {
DispatchCommand cmd = buildCommand("issued");
when(commandMapper.selectById(1L)).thenReturn(cmd);
assertThrows(BusinessException.class, () -> {
commandService.issueCommand(1L, 100L, "admin");
});
}
@Test
void testReceiveCommand() {
DispatchCommand cmd = buildCommand("issued");
when(commandMapper.selectById(1L)).thenReturn(cmd);
DispatchExecution exec = buildExecution("pending");
when(executionMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(exec);
when(executionMapper.updateById(any())).thenReturn(1);
when(executionMapper.selectCount(any(LambdaQueryWrapper.class)))
.thenReturn(2L) // total
.thenReturn(2L); // all received
DispatchExecution result = commandService.receiveCommand(1L, 1L, "张三");
assertEquals("received", result.getExecuteStatus());
assertNotNull(result.getReceivedAt());
}
@Test
void testStartExecution() {
DispatchCommand cmd = buildCommand("received");
when(commandMapper.selectById(1L)).thenReturn(cmd);
when(commandMapper.updateById(any())).thenReturn(1);
DispatchExecution exec = buildExecution("received");
when(executionMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(exec);
when(executionMapper.updateById(any())).thenReturn(1);
DispatchExecution result = commandService.startExecution(1L, 1L, "张三");
assertEquals("executing", result.getExecuteStatus());
}
@Test
void testStartExecution_wrongStatus() {
DispatchCommand cmd = buildCommand("received");
when(commandMapper.selectById(1L)).thenReturn(cmd);
DispatchExecution exec = buildExecution("pending");
when(executionMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(exec);
assertThrows(BusinessException.class, () -> {
commandService.startExecution(1L, 1L, "张三");
});
}
@Test
void testCompleteExecution() {
DispatchCommand cmd = buildCommand("executing");
when(commandMapper.selectById(1L)).thenReturn(cmd);
DispatchExecution exec = buildExecution("executing");
when(executionMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(exec);
when(executionMapper.updateById(any())).thenReturn(1);
when(executionMapper.selectCount(any(LambdaQueryWrapper.class)))
.thenReturn(1L) // total
.thenReturn(1L); // all final
DispatchExecution result = commandService.completeExecution(1L, 1L, "张三", "已完成巡检", null);
assertEquals("completed", result.getExecuteStatus());
assertEquals("已完成巡检", result.getFeedback());
assertNotNull(result.getCompletedAt());
}
@Test
void testCompleteExecution_wrongStatus() {
DispatchCommand cmd = buildCommand("executing");
when(commandMapper.selectById(1L)).thenReturn(cmd);
DispatchExecution exec = buildExecution("received");
when(executionMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(exec);
assertThrows(BusinessException.class, () -> {
commandService.completeExecution(1L, 1L, "张三", "反馈", null);
});
}
@Test
void testRejectExecution() {
DispatchCommand cmd = buildCommand("issued");
when(commandMapper.selectById(1L)).thenReturn(cmd);
DispatchExecution exec = buildExecution("pending");
when(executionMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(exec);
when(executionMapper.updateById(any())).thenReturn(1);
when(executionMapper.selectCount(any(LambdaQueryWrapper.class)))
.thenReturn(1L) // total
.thenReturn(1L); // all final
DispatchExecution result = commandService.rejectExecution(1L, 1L, "张三", "人手不足");
assertEquals("rejected", result.getExecuteStatus());
assertEquals("人手不足", result.getRejectedReason());
}
@Test
void testRejectExecution_alreadyCompleted() {
DispatchCommand cmd = buildCommand("executing");
when(commandMapper.selectById(1L)).thenReturn(cmd);
DispatchExecution exec = buildExecution("completed");
when(executionMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(exec);
assertThrows(BusinessException.class, () -> {
commandService.rejectExecution(1L, 1L, "张三", "原因");
});
}
@Test
void testCommandNotFound() {
when(commandMapper.selectById(999L)).thenReturn(null);
assertThrows(BusinessException.class, () -> {
commandService.issueCommand(999L, 1L, "admin");
});
}
// ==================== Helper ====================
private DispatchCommand buildCommand(String status) {
DispatchCommand cmd = new DispatchCommand();
cmd.setId(1L);
cmd.setCommandNo("CMD-20260614150000-0001");
cmd.setCommandTitle("测试指令");
cmd.setCommandContent("测试内容");
cmd.setCommandType("normal");
cmd.setStatus(status);
cmd.setTargetIds("[1,2]");
return cmd;
}
private DispatchExecution buildExecution(String status) {
DispatchExecution exec = new DispatchExecution();
exec.setId(1L);
exec.setCommandId(1L);
exec.setUserId(1L);
exec.setUserName("张三");
exec.setExecuteStatus(status);
return exec;
}
}
@@ -0,0 +1,97 @@
package com.water.production.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.water.production.entity.DispatchTracking;
import com.water.production.mapper.DispatchTrackingMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class DispatchTrackingServiceTest {
@Mock
private DispatchTrackingMapper trackingMapper;
@InjectMocks
private DispatchTrackingService trackingService;
@Test
void testLog() {
when(trackingMapper.insert(any())).thenReturn(1);
trackingService.log(1L, null, "create", null, null, "draft", "创建指令");
ArgumentCaptor<DispatchTracking> captor = ArgumentCaptor.forClass(DispatchTracking.class);
verify(trackingMapper).insert(captor.capture());
DispatchTracking saved = captor.getValue();
assertEquals(1L, saved.getCommandId());
assertNull(saved.getExecutionId());
assertEquals("create", saved.getAction());
assertEquals("draft", saved.getToStatus());
assertEquals("创建指令", saved.getRemark());
}
@Test
void testLogWithExecution() {
when(trackingMapper.insert(any())).thenReturn(1);
trackingService.log(1L, 5L, "receive", 10L, "张三", "pending", "received", "接收确认");
ArgumentCaptor<DispatchTracking> captor = ArgumentCaptor.forClass(DispatchTracking.class);
verify(trackingMapper).insert(captor.capture());
DispatchTracking saved = captor.getValue();
assertEquals(5L, saved.getExecutionId());
assertEquals(10L, saved.getOperatorId());
assertEquals("张三", saved.getOperatorName());
assertEquals("pending", saved.getFromStatus());
assertEquals("received", saved.getToStatus());
}
@Test
void testGetTrackingLogs() {
DispatchTracking t1 = new DispatchTracking();
t1.setId(1L);
t1.setCommandId(1L);
t1.setAction("create");
DispatchTracking t2 = new DispatchTracking();
t2.setId(2L);
t2.setCommandId(1L);
t2.setAction("issue");
when(trackingMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(t1, t2));
List<DispatchTracking> logs = trackingService.getTrackingLogs(1L);
assertEquals(2, logs.size());
assertEquals("create", logs.get(0).getAction());
assertEquals("issue", logs.get(1).getAction());
}
@Test
void testGetExecutionTrackingLogs() {
DispatchTracking t1 = new DispatchTracking();
t1.setId(3L);
t1.setExecutionId(5L);
t1.setAction("receive");
when(trackingMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(t1));
List<DispatchTracking> logs = trackingService.getExecutionTrackingLogs(5L);
assertEquals(1, logs.size());
assertEquals(5L, logs.get(0).getExecutionId());
}
}