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
@@ -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());
}
}