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:
+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