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:
@@ -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`)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
+106
@@ -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 >= #{startDate}::timestamp</if>
|
||||||
|
<if test="endDate != null and endDate != ''">AND c.created_at <= #{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>
|
||||||
+223
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+97
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user