diff --git a/frontend/src/api/dispatchCommand.ts b/frontend/src/api/dispatchCommand.ts new file mode 100644 index 00000000..ca0717f9 --- /dev/null +++ b/frontend/src/api/dispatchCommand.ts @@ -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`) +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 996badad..8c76219f 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -11,6 +11,8 @@ const routes = [ { 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/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' } diff --git a/frontend/src/views/dispatch-command/CommandCreate.vue b/frontend/src/views/dispatch-command/CommandCreate.vue new file mode 100644 index 00000000..8a87315c --- /dev/null +++ b/frontend/src/views/dispatch-command/CommandCreate.vue @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + 低 + 普通 + 高 + 紧急 + + + + + + + + + + + + + + + + + + + + + + 输入用户ID,多个用逗号分隔 + + + + + + + + + 取消 + + 创建指令 + + + + + + + + diff --git a/frontend/src/views/dispatch-command/CommandDetail.vue b/frontend/src/views/dispatch-command/CommandDetail.vue new file mode 100644 index 00000000..56601ea0 --- /dev/null +++ b/frontend/src/views/dispatch-command/CommandDetail.vue @@ -0,0 +1,304 @@ + + + + + + 指令详情 + {{ statusLabel(detail.status) }} + + + + + + + + + {{ detail.command_title }} + {{ detail.command_no }} + + + + {{ detail.command_no }} + + {{ typeLabel(detail.command_type) }} + + + {{ priorityLabel(detail.priority) }} + + {{ detail.source || '-' }} + {{ detail.created_at }} + {{ detail.issued_at || '-' }} + {{ detail.completed_at || '-' }} + {{ detail.target_type || '-' }} + + + + 指令内容 + {{ detail.command_content }} + + + + + 状态流转 + + + + + + + + + + + + + + + 执行记录 + + + + + {{ exec.user_name || `用户${exec.user_id}` }} + + {{ executionStatusLabel(exec.execute_status) }} + + + + 反馈: {{ exec.feedback }} + + + 驳回原因: {{ exec.rejected_reason }} + + + 接收 + 开始执行 + 完成 + 驳回 + + + + + + + + + + + + 全过程追踪日志 + + + + {{ trackingActionLabel(log.action) }} + {{ log.operator_name || '' }} + + {{ log.from_status }} → {{ log.to_status }} + + {{ log.remark }} + + + + + + + + + + + + + + + + + + 取消 + 确认完成 + + + + + + + + diff --git a/frontend/src/views/dispatch-command/CommandList.vue b/frontend/src/views/dispatch-command/CommandList.vue new file mode 100644 index 00000000..a37e2118 --- /dev/null +++ b/frontend/src/views/dispatch-command/CommandList.vue @@ -0,0 +1,193 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 查询 + 重置 + + + + + + + + {{ stat.count }} + {{ statusLabel(stat.status) }} + + + + + + 创建指令 + 批量下发 + + + + + + + + + {{ typeLabel(row.command_type) }} + + + + + {{ priorityLabel(row.priority) }} + + + + + {{ statusLabel(row.status) }} + + + + + {{ row.completed_count || 0 }}/{{ row.total_executions || 0 }} + + + + + + 详情 + 下发 + 删除 + + + + + + + + + + + + + diff --git a/wm-production/src/main/java/com/water/production/controller/DispatchCommandController.java b/wm-production/src/main/java/com/water/production/controller/DispatchCommandController.java new file mode 100644 index 00000000..5707842e --- /dev/null +++ b/wm-production/src/main/java/com/water/production/controller/DispatchCommandController.java @@ -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 create(@RequestBody DispatchCommand command) { + return R.ok(commandService.createCommand(command)); + } + + @Operation(summary = "下发指令") + @PostMapping("/{id}/issue") + public R 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>> 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> detail(@PathVariable Long id) { + return R.ok(commandService.getCommandDetail(id)); + } + + @Operation(summary = "各状态统计") + @GetMapping("/stats") + public R>> stats() { + return R.ok(commandService.getStatusStats()); + } + + @Operation(summary = "接收确认") + @PostMapping("/{id}/receive") + public R 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 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 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 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> trackingLogs(@PathVariable Long id) { + return R.ok(trackingService.getTrackingLogs(id)); + } +} diff --git a/wm-production/src/main/java/com/water/production/entity/DispatchCommand.java b/wm-production/src/main/java/com/water/production/entity/DispatchCommand.java new file mode 100644 index 00000000..a33df3e3 --- /dev/null +++ b/wm-production/src/main/java/com/water/production/entity/DispatchCommand.java @@ -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; +} diff --git a/wm-production/src/main/java/com/water/production/entity/DispatchExecution.java b/wm-production/src/main/java/com/water/production/entity/DispatchExecution.java new file mode 100644 index 00000000..9baff94b --- /dev/null +++ b/wm-production/src/main/java/com/water/production/entity/DispatchExecution.java @@ -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; +} diff --git a/wm-production/src/main/java/com/water/production/entity/DispatchTracking.java b/wm-production/src/main/java/com/water/production/entity/DispatchTracking.java new file mode 100644 index 00000000..f12f2477 --- /dev/null +++ b/wm-production/src/main/java/com/water/production/entity/DispatchTracking.java @@ -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; +} diff --git a/wm-production/src/main/java/com/water/production/mapper/DispatchCommandMapper.java b/wm-production/src/main/java/com/water/production/mapper/DispatchCommandMapper.java new file mode 100644 index 00000000..ee5c2768 --- /dev/null +++ b/wm-production/src/main/java/com/water/production/mapper/DispatchCommandMapper.java @@ -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 { + + IPage> selectCommandPage( + Page> page, + @Param("status") String status, + @Param("commandType") String commandType, + @Param("keyword") String keyword, + @Param("startDate") String startDate, + @Param("endDate") String endDate + ); + + Map selectCommandDetail(@Param("commandId") Long commandId); + + List> selectStatusStats(); +} diff --git a/wm-production/src/main/java/com/water/production/mapper/DispatchExecutionMapper.java b/wm-production/src/main/java/com/water/production/mapper/DispatchExecutionMapper.java new file mode 100644 index 00000000..7768c90c --- /dev/null +++ b/wm-production/src/main/java/com/water/production/mapper/DispatchExecutionMapper.java @@ -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 { +} diff --git a/wm-production/src/main/java/com/water/production/mapper/DispatchTrackingMapper.java b/wm-production/src/main/java/com/water/production/mapper/DispatchTrackingMapper.java new file mode 100644 index 00000000..2aa2fc2c --- /dev/null +++ b/wm-production/src/main/java/com/water/production/mapper/DispatchTrackingMapper.java @@ -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 { +} diff --git a/wm-production/src/main/java/com/water/production/service/DispatchCommandService.java b/wm-production/src/main/java/com/water/production/service/DispatchCommandService.java new file mode 100644 index 00000000..82936773 --- /dev/null +++ b/wm-production/src/main/java/com/water/production/service/DispatchCommandService.java @@ -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> 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> 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 getCommandDetail(Long commandId) { + Map detail = commandMapper.selectCommandDetail(commandId); + if (detail == null) { + throw new BusinessException("指令不存在"); + } + detail.put("trackingLogs", trackingService.getTrackingLogs(commandId)); + return detail; + } + + public List> 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 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 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 w1 = new LambdaQueryWrapper<>(); + w1.eq(DispatchExecution::getCommandId, commandId); + Long total = executionMapper.selectCount(w1); + LambdaQueryWrapper 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 w1 = new LambdaQueryWrapper<>(); + w1.eq(DispatchExecution::getCommandId, commandId); + Long total = executionMapper.selectCount(w1); + LambdaQueryWrapper w2 = new LambdaQueryWrapper<>(); + w2.eq(DispatchExecution::getCommandId, commandId) + .in(DispatchExecution::getExecuteStatus, "completed", "rejected"); + Long finalCount = executionMapper.selectCount(w2); + return total > 0 && total.equals(finalCount); + } +} diff --git a/wm-production/src/main/java/com/water/production/service/DispatchTrackingService.java b/wm-production/src/main/java/com/water/production/service/DispatchTrackingService.java new file mode 100644 index 00000000..3d2423fe --- /dev/null +++ b/wm-production/src/main/java/com/water/production/service/DispatchTrackingService.java @@ -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 getTrackingLogs(Long commandId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(DispatchTracking::getCommandId, commandId) + .orderByAsc(DispatchTracking::getCreatedAt); + return trackingMapper.selectList(wrapper); + } + + public List getExecutionTrackingLogs(Long executionId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(DispatchTracking::getExecutionId, executionId) + .orderByAsc(DispatchTracking::getCreatedAt); + return trackingMapper.selectList(wrapper); + } +} diff --git a/wm-production/src/main/resources/db/V_dispatch_command.sql b/wm-production/src/main/resources/db/V_dispatch_command.sql new file mode 100644 index 00000000..c759c630 --- /dev/null +++ b/wm-production/src/main/resources/db/V_dispatch_command.sql @@ -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); diff --git a/wm-production/src/main/resources/mapper/DispatchCommandMapper.xml b/wm-production/src/main/resources/mapper/DispatchCommandMapper.xml new file mode 100644 index 00000000..8905e310 --- /dev/null +++ b/wm-production/src/main/resources/mapper/DispatchCommandMapper.xml @@ -0,0 +1,44 @@ + + + + + + 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 + AND c.status = #{status} + AND c.command_type = #{commandType} + + AND (c.command_no LIKE '%' || #{keyword} || '%' OR c.command_title LIKE '%' || #{keyword} || '%') + + AND c.created_at >= #{startDate}::timestamp + AND c.created_at <= #{endDate}::timestamp + GROUP BY c.id + ORDER BY c.created_at DESC + + + + 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 status, COUNT(*) AS count + FROM prod_dispatch_command WHERE deleted = 0 + GROUP BY status + + + diff --git a/wm-production/src/test/java/com/water/production/service/DispatchCommandServiceTest.java b/wm-production/src/test/java/com/water/production/service/DispatchCommandServiceTest.java new file mode 100644 index 00000000..9b4beec5 --- /dev/null +++ b/wm-production/src/test/java/com/water/production/service/DispatchCommandServiceTest.java @@ -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; + } +} diff --git a/wm-production/src/test/java/com/water/production/service/DispatchTrackingServiceTest.java b/wm-production/src/test/java/com/water/production/service/DispatchTrackingServiceTest.java new file mode 100644 index 00000000..3f6d84be --- /dev/null +++ b/wm-production/src/test/java/com/water/production/service/DispatchTrackingServiceTest.java @@ -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 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 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 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 logs = trackingService.getExecutionTrackingLogs(5L); + + assertEquals(1, logs.size()); + assertEquals(5L, logs.get(0).getExecutionId()); + } +}