Procházet zdrojové kódy

feat: 导入添加审批时间关联字段

wuwenyi před 6 dny
rodič
revize
6d6f66530a

+ 57 - 1
src/main/java/com/qqflow/engine/common/service/ExcelParseService.java

@@ -4,6 +4,8 @@ import com.alibaba.excel.EasyExcel;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.qqflow.engine.common.exception.BusinessException;
 import com.qqflow.engine.domain.flow.mapper.ProcessDefinitionMapper;
+import com.qqflow.engine.domain.flow.model.FlowModel;
+import com.qqflow.engine.domain.flow.model.FlowNode;
 import com.qqflow.engine.domain.flow.po.ProcessDefinition;
 import com.qqflow.engine.domain.system.entity.SysUser;
 import com.qqflow.engine.domain.system.mapper.SysUserMapper;
@@ -79,7 +81,9 @@ public class ExcelParseService {
 
                 for (Map.Entry<Integer, Object> entry : headerRow.entrySet()) {
                     int col = entry.getKey();
-                    String excelColumn = entry.getValue() != null ? String.valueOf(entry.getValue()).trim() : "";
+                    String excelColumn = entry.getValue() != null
+                            ? String.valueOf(entry.getValue()).replace("\r\n", " ").replace("\r", " ").replace("\n", " ").trim()
+                            : "";
                     if (excelColumn.isEmpty()) continue;
 
                     Object cellValue = dataRow.get(col);
@@ -109,12 +113,32 @@ public class ExcelParseService {
                     }
                 }
 
+                // 提取所有节点的审批时间字段(一行可能对应多个节点的时间)
+                Map<String, String> nodeApproveTimes = new LinkedHashMap<>();
+                FlowModel flowModel = parseFlowModel(definition);
+                if (flowModel != null && flowModel.getNodes() != null) {
+                    for (FlowNode n : flowModel.getNodes()) {
+                        if (n.getProperties() == null) continue;
+                        Object atf = n.getProperties().get("approveTimeField");
+                        if (atf == null || atf.toString().isBlank()) continue;
+                        for (Map.Entry<Integer, Object> entry : headerRow.entrySet()) {
+                            String col = normalizeHeader(entry.getValue());
+                            if (col.equals(atf.toString().trim())) {
+                                Object v = dataRow.get(entry.getKey());
+                                nodeApproveTimes.put(n.getName(), toDateString(v));
+                                break;
+                            }
+                        }
+                    }
+                }
+
                 Map<String, Object> rowResult = new LinkedHashMap<>();
                 rowResult.put("formData", formData);
                 rowResult.put("nodeName", nodeName);
                 rowResult.put("ownerId", ownerId);
                 rowResult.put("ownerName", ownerName);
                 rowResult.put("bizValue", bizValue);
+                rowResult.put("nodeApproveTimes", nodeApproveTimes);
                 results.add(rowResult);
             }
             return results;
@@ -169,6 +193,38 @@ public class ExcelParseService {
         }
     }
 
+    private FlowModel parseFlowModel(ProcessDefinition definition) {
+        try {
+            return OBJECT_MAPPER.readValue(definition.getModelJson(), FlowModel.class);
+        } catch (Exception ignored) {
+            return null;
+        }
+    }
+
+    private String normalizeHeader(Object value) {
+        if (value == null) return "";
+        return String.valueOf(value).replace("\r\n", " ").replace("\r", " ").replace("\n", " ").trim();
+    }
+
+    /** 将 Excel 单元格值转为日期字符串(处理数值型日期) */
+    private String toDateString(Object cellValue) {
+        if (cellValue == null) return null;
+        if (cellValue instanceof java.util.Date) {
+            return java.time.LocalDateTime.ofInstant(((java.util.Date) cellValue).toInstant(),
+                    java.time.ZoneId.systemDefault()).format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
+        }
+        if (cellValue instanceof Number) {
+            // Excel 日期序列号(1900-01-01 = 1)
+            double days = ((Number) cellValue).doubleValue();
+            if (days > 1 && days < 100000) {
+                java.time.LocalDateTime base = java.time.LocalDateTime.of(1899, 12, 30, 0, 0, 0);
+                return base.plusDays((long) days).format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
+            }
+        }
+        String s = String.valueOf(cellValue).trim();
+        return s.isEmpty() ? null : s;
+    }
+
     @SuppressWarnings("unchecked")
     private List<Map<String, Object>> parseFormSchema(String formSchema) {
         if (formSchema == null || formSchema.isBlank()) {

+ 39 - 8
src/main/java/com/qqflow/engine/domain/flow/controller/FlowImportController.java

@@ -3,6 +3,7 @@ package com.qqflow.engine.domain.flow.controller;
 import com.qqflow.engine.common.Result;
 import com.qqflow.engine.common.service.ExcelParseService;
 import com.qqflow.engine.common.util.SecurityUtils;
+import com.qqflow.engine.domain.flow.service.impl.WeComNotificationService;
 import com.qqflow.engine.config.security.LoginUser;
 import com.qqflow.engine.domain.flow.dto.StartProcessDTO;
 import com.qqflow.engine.domain.flow.mapper.ProcessDefinitionMapper;
@@ -14,6 +15,7 @@ import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import jakarta.servlet.http.HttpServletResponse;
 import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.http.MediaType;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PathVariable;
@@ -29,6 +31,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 
+@Slf4j
 @Tag(name = "流程导入")
 @RestController
 @RequestMapping("/flow/import")
@@ -96,6 +99,7 @@ public class FlowImportController {
             String titlePrefix = definition.getProcessName() + "-"
                     + java.time.LocalDateTime.now().format(java.time.format.DateTimeFormatter.ofPattern("MMddHHmm"));
 
+            WeComNotificationService.suppress();
             List<Long> instanceIds = new ArrayList<>();
             List<String> failReasons = new ArrayList<>();
             int successCount = 0;
@@ -113,6 +117,8 @@ public class FlowImportController {
                     continue;
                 }
                 String bizValue = (String) row.get("bizValue");
+                @SuppressWarnings("unchecked")
+                Map<String, String> nodeApproveTimes = (Map<String, String>) row.get("nodeApproveTimes");
 
                 Long effectiveOwnerId = null;
                 if (isAdmin) {
@@ -126,15 +132,15 @@ public class FlowImportController {
                 }
 
                 try {
-                    if (bizValue != null && !bizValue.isBlank()) {
+                    if (bizValue != null && !bizValue.isBlank()
+                            && this.processInstanceMapper.existsByInstanceNo(bizValue)) {
                         ProcessInstance existing = this.processInstanceMapper.selectByInstanceNo(bizValue);
-                        if (existing != null) {
-                            this.processInstanceService.updateFormData(
-                                    existing.getId(), mapper.writeValueAsString(formData), nodeName);
-                            instanceIds.add(existing.getId());
-                            updateCount++;
-                            continue;
-                        }
+                        this.processInstanceService.updateFormData(
+                                existing.getId(), mapper.writeValueAsString(formData), nodeName);
+                        applyNodeApproveTimes(existing.getId(), nodeApproveTimes);
+                        instanceIds.add(existing.getId());
+                        updateCount++;
+                        continue;
                     }
 
                     StartProcessDTO dto = new StartProcessDTO();
@@ -152,10 +158,26 @@ public class FlowImportController {
                     } else {
                         instanceId = this.processInstanceService.startProcess(dto);
                     }
+                    applyNodeApproveTimes(instanceId, nodeApproveTimes);
                     instanceIds.add(instanceId);
                     successCount++;
+                } catch (org.springframework.dao.DuplicateKeyException e) {
+                    // 并发/隔离级别导致预检未命中,降级为更新
+                    if (bizValue != null && !bizValue.isBlank()) {
+                        ProcessInstance existing = this.processInstanceMapper.selectByInstanceNo(bizValue);
+                        if (existing != null) {
+                            this.processInstanceService.updateFormData(
+                                    existing.getId(), mapper.writeValueAsString(formData), nodeName);
+                            applyNodeApproveTimes(existing.getId(), nodeApproveTimes);
+                            instanceIds.add(existing.getId());
+                            updateCount++;
+                            continue;
+                        }
+                    }
+                    failReasons.add("第" + rowNo + "行:实例编号重复");
                 } catch (Exception e) {
                     String msg = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName();
+                    log.error("[导入] 第{}行失败: {}", rowNo, msg);
                     failReasons.add("第" + rowNo + "行:" + msg);
                 }
             }
@@ -172,6 +194,15 @@ public class FlowImportController {
             return Result.error("字段映射格式错误");
         } catch (Exception e) {
             return Result.error("导入失败: " + e.getMessage());
+        } finally {
+            WeComNotificationService.unsuppress();
+        }
+    }
+
+    private void applyNodeApproveTimes(Long instanceId, Map<String, String> nodeApproveTimes) {
+        if (nodeApproveTimes == null || nodeApproveTimes.isEmpty()) return;
+        for (Map.Entry<String, String> entry : nodeApproveTimes.entrySet()) {
+            this.processInstanceService.recordApprovalTime(instanceId, entry.getKey(), entry.getValue());
         }
     }
 }

+ 0 - 1
src/main/java/com/qqflow/engine/domain/flow/listener/NotificationEventListener.java

@@ -20,7 +20,6 @@ public class NotificationEventListener {
 
     private final NotificationService notificationService;
 
-    @Async("notificationExecutor")
     @EventListener
     public void onTaskAssigned(TaskAssignedEvent event) {
         try {

+ 7 - 1
src/main/java/com/qqflow/engine/domain/flow/mapper/ProcessInstanceMapper.java

@@ -24,6 +24,12 @@ public interface ProcessInstanceMapper extends BaseMapper<ProcessInstance> {
 
     default ProcessInstance selectByInstanceNo(String instanceNo) {
         return this.selectOne(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<ProcessInstance>()
-                .eq(ProcessInstance::getInstanceNo, instanceNo));
+                .eq(ProcessInstance::getInstanceNo, instanceNo)
+                .last("LIMIT 1"));
+    }
+
+    default boolean existsByInstanceNo(String instanceNo) {
+        return this.selectCount(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<ProcessInstance>()
+                .eq(ProcessInstance::getInstanceNo, instanceNo)) > 0;
     }
 }

+ 3 - 0
src/main/java/com/qqflow/engine/domain/flow/service/ProcessInstanceService.java

@@ -45,6 +45,9 @@ public interface ProcessInstanceService {
     /** 将实例迁移到新版本的流程定义(人工确认兼容后执行) */
     void migrateToDefinition(Long instanceId, Long targetDefinitionId);
 
+    /** 导入时为节点创建或更新审批记录(审批时间字段) */
+    void recordApprovalTime(Long instanceId, String nodeName, String approveTimeStr);
+
     /** 批量迁移指定定义下所有运行中的实例到新版本定义 */
     int migrateAllToDefinition(Long fromDefinitionId, Long toDefinitionId);
 }

+ 86 - 0
src/main/java/com/qqflow/engine/domain/flow/service/impl/ProcessInstanceServiceImpl.java

@@ -46,6 +46,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import java.io.File;
+import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
@@ -685,4 +686,89 @@ public class ProcessInstanceServiceImpl implements ProcessInstanceService {
                         .eq(SysUserRole::getUserId, userId)
                         .eq(SysUserRole::getRoleId, roleId));
     }
+
+    @Override
+    @Transactional(propagation = org.springframework.transaction.annotation.Propagation.REQUIRES_NEW)
+    public void recordApprovalTime(Long instanceId, String nodeName, String approveTimeStr) {
+        if (approveTimeStr == null || approveTimeStr.isBlank()) return;
+        ProcessInstance instance = this.processInstanceMapper.selectById(instanceId);
+        if (instance == null) return;
+        ProcessDefinition definition = this.processDefinitionMapper.selectById(instance.getProcessDefinitionId());
+        if (definition == null) return;
+        FlowModel model = this.flowEngineService.parseModel(definition.getModelJson());
+        FlowNode targetNode = model.getNodes().stream()
+                .filter(n -> nodeName.equals(n.getName()))
+                .findFirst().orElse(null);
+        if (targetNode == null) return;
+
+        LocalDateTime approveTime = parseTime(approveTimeStr);
+        if (approveTime == null) return;
+
+        // 优先取当前节点的任务负责人作为操作人
+        List<ApprovalTask> tasks = this.approvalTaskMapper.selectByInstanceId(instanceId).stream()
+                .filter(t -> t.getNodeId().equals(targetNode.getId()))
+                .collect(Collectors.toList());
+        Long operatorId = tasks.isEmpty() ? instance.getApplicantId() : tasks.get(0).getAssigneeId();
+        String operatorName = null;
+        if (operatorId != null) {
+            SysUser operator = this.sysUserMapper.selectById(operatorId);
+            if (operator != null) operatorName = operator.getRealName();
+        }
+
+        List<ApprovalRecord> existingRecords = this.approvalRecordMapper.selectList(
+                new LambdaQueryWrapper<ApprovalRecord>()
+                        .eq(ApprovalRecord::getInstanceId, instanceId)
+                        .eq(ApprovalRecord::getNodeId, targetNode.getId()));
+        if (!existingRecords.isEmpty()) {
+            for (ApprovalRecord r : existingRecords) {
+                r.setCreateTime(approveTime);
+                this.approvalRecordMapper.updateById(r);
+            }
+        } else {
+            Long taskId = tasks.isEmpty() ? null : tasks.get(0).getId();
+
+            ApprovalRecord record = new ApprovalRecord();
+            record.setTaskId(taskId);
+            record.setInstanceId(instanceId);
+            record.setNodeId(targetNode.getId());
+            record.setNodeName(targetNode.getName());
+            record.setOperatorId(operatorId);
+            record.setOperatorName(operatorName);
+            record.setActionType(com.qqflow.engine.domain.flow.enums.ApprovalAction.APPROVE.getCode());
+            record.setActionResult(com.qqflow.engine.domain.flow.enums.ApprovalResult.PASS.getCode());
+            record.setCreateTime(approveTime);
+            this.approvalRecordMapper.insert(record);
+        }
+    }
+
+    private static final java.time.format.DateTimeFormatter[] TIME_FORMATS = {
+            // java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"),
+            // java.time.format.DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss"),
+            // java.time.format.DateTimeFormatter.ofPattern("yyyy-M-d HH:mm:ss"),
+            // java.time.format.DateTimeFormatter.ofPattern("yyyy/M/d HH:mm:ss"),
+            java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd"),
+            java.time.format.DateTimeFormatter.ofPattern("yyyy/MM/dd"),
+            java.time.format.DateTimeFormatter.ofPattern("yyyy/M/d"),
+            java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME,
+    };
+
+    private LocalDateTime parseTime(String s) {
+        if (s == null || s.isBlank()) return null;
+        // 尝试 Excel 数值日期(如 46207.0)
+        try {
+            double days = Double.parseDouble(s);
+            if (days > 1 && days < 100000) {
+                return LocalDateTime.of(1899, 12, 30, 0, 0, 0).plusDays((long) days);
+            }
+        } catch (NumberFormatException ignored) {}
+        for (var fmt : TIME_FORMATS) {
+            try {
+                if (fmt.toString().contains("HH")) {
+                    return LocalDateTime.parse(s, fmt);
+                }
+                return LocalDate.parse(s, fmt).atStartOfDay();
+            } catch (Exception ignored) {}
+        }
+        return null;
+    }
 }

+ 10 - 3
src/main/java/com/qqflow/engine/domain/flow/service/impl/WeComNotificationService.java

@@ -21,6 +21,13 @@ import java.util.List;
 @RequiredArgsConstructor
 public class WeComNotificationService implements NotificationService {
 
+    private static final ThreadLocal<Boolean> SUPPRESS = ThreadLocal.withInitial(() -> false);
+
+    /** 导入时调用,抑制期间所有企微通知 */
+    public static void suppress() { SUPPRESS.set(true); }
+    public static void unsuppress() { SUPPRESS.remove(); }
+    private boolean isSuppressed() { return SUPPRESS.get(); }
+
     private static final String ASSIGNEE_TYPE_ROLE = "ROLE";
 
     private final SysUserMapper sysUserMapper;
@@ -30,7 +37,7 @@ public class WeComNotificationService implements NotificationService {
 
     @Override
     public void notifyTaskAssigned(List<Long> assigneeIds, String assigneeType, String processName, String instanceTitle, String nodeName) {
-        if (!this.isEnabled()) return;
+        if (isSuppressed() || !this.isEnabled()) return;
         for (Long assigneeId : assigneeIds) {
             String wecomUserId = resolveWeComUserId(assigneeId, assigneeType);
             if (wecomUserId == null) continue;
@@ -48,7 +55,7 @@ public class WeComNotificationService implements NotificationService {
 
     @Override
     public void notifyProcessCompleted(Long applicantId, String processName, String instanceTitle, String result) {
-        if (!this.isEnabled()) return;
+        if (isSuppressed() || !this.isEnabled()) return;
         SysUser applicant = sysUserMapper.selectById(applicantId);
         if (applicant != null && applicant.getWecomUserId() != null && !applicant.getWecomUserId().isBlank()) {
             String content = String.format("【流程结束】\n流程:%s\n标题:%s\n结果:%s",
@@ -59,7 +66,7 @@ public class WeComNotificationService implements NotificationService {
 
     @Override
     public void notifyTaskOverdue(ApprovalTask task, String processName, String instanceTitle, String nodeName, boolean overdue) {
-        if (!this.isEnabled()) return;
+        if (isSuppressed() || !this.isEnabled()) return;
         String type = overdue ? "已超时催办" : "即将超时提醒";
         String content = String.format("【%s】\n流程:%s\n标题:%s\n节点:%s\n请及时处理。",
                 type, processName, instanceTitle, nodeName);