ht 1 時間 前
コミット
620c86880b

+ 47 - 0
src/main/java/com/malk/minglei/controller/ApprovalTransferDepartmentController.java

@@ -0,0 +1,47 @@
+package com.malk.minglei.controller;
+
+import com.malk.minglei.dto.ApiResponse;
+import com.malk.minglei.dto.ApprovalDepartmentManagers;
+import com.malk.minglei.dto.ApprovalTransferDepartmentRequest;
+import com.malk.minglei.service.approval.ApprovalTransferDepartmentService;
+import org.springframework.http.ResponseEntity;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.validation.Valid;
+
+/**
+ * 钉钉审批实例转入部门主管查询接口。
+ */
+@Validated
+@RestController
+@RequestMapping("/api/dingtalk/approval")
+public class ApprovalTransferDepartmentController {
+
+    private final ApprovalTransferDepartmentService approvalTransferDepartmentService;
+
+    /**
+     * 创建审批转入部门主管查询控制器。
+     *
+     * @param approvalTransferDepartmentService 审批转入部门主管查询服务
+     */
+    public ApprovalTransferDepartmentController(ApprovalTransferDepartmentService approvalTransferDepartmentService) {
+        this.approvalTransferDepartmentService = approvalTransferDepartmentService;
+    }
+
+    /**
+     * 根据钉钉审批实例 ID 查询转入部门的主管。
+     *
+     * @param request 已通过字段校验的审批实例查询请求
+     * @return 包含转入部门和主管 userid 列表的统一响应
+     */
+    @PostMapping("/transfer-department/managers")
+    public ResponseEntity<ApiResponse<ApprovalDepartmentManagers>> listTransferDepartmentManagers(
+            @Valid @RequestBody ApprovalTransferDepartmentRequest request) {
+        return ResponseEntity.ok(ApiResponse.success(
+                approvalTransferDepartmentService.findTransferDepartmentManagers(request.getProcessInstanceId())));
+    }
+}

+ 176 - 0
src/main/java/com/malk/minglei/dto/ApprovalDepartmentManagers.java

@@ -0,0 +1,176 @@
+package com.malk.minglei.dto;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * 审批实例转入部门及其各级别负责人的业务数据模型。
+ *
+ * <p>部门负责人按级别拆分成独立字段返回,分别为一级至四级部门主管、部门负责人与部门分管领导。
+ * 各级负责人均取审批矩阵表单中对应字段的第一位负责人钉钉 userid。
+ */
+public class ApprovalDepartmentManagers {
+
+    private final String departmentId;
+
+    private final String departmentName;
+
+    private final String firstLevelManagerUserId;
+
+    private final String secondLevelManagerUserId;
+
+    private final String thirdLevelManagerUserId;
+
+    private final String fourthLevelManagerUserId;
+
+    private final String departmentHeadUserId;
+
+    private final String departmentLeaderUserId;
+
+    private ApprovalDepartmentManagers(String departmentId,
+                                       String departmentName,
+                                       String firstLevelManagerUserId,
+                                       String secondLevelManagerUserId,
+                                       String thirdLevelManagerUserId,
+                                       String fourthLevelManagerUserId,
+                                       String departmentHeadUserId,
+                                       String departmentLeaderUserId) {
+        this.departmentId = departmentId;
+        this.departmentName = departmentName;
+        this.firstLevelManagerUserId = blankToNull(firstLevelManagerUserId);
+        this.secondLevelManagerUserId = blankToNull(secondLevelManagerUserId);
+        this.thirdLevelManagerUserId = blankToNull(thirdLevelManagerUserId);
+        this.fourthLevelManagerUserId = blankToNull(fourthLevelManagerUserId);
+        this.departmentHeadUserId = blankToNull(departmentHeadUserId);
+        this.departmentLeaderUserId = blankToNull(departmentLeaderUserId);
+    }
+
+    /**
+     * 创建转入部门各级别负责人查询结果。
+     *
+     * @param departmentId 转入部门的钉钉部门 ID
+     * @param departmentName 转入部门名称
+     * @param firstLevelManagerUserId 一级部门主管钉钉 userid
+     * @param secondLevelManagerUserId 二级部门主管钉钉 userid
+     * @param thirdLevelManagerUserId 三级部门主管钉钉 userid
+     * @param fourthLevelManagerUserId 四级部门主管钉钉 userid
+     * @param departmentHeadUserId 部门负责人钉钉 userid
+     * @param departmentLeaderUserId 部门分管领导钉钉 userid
+     * @return 转入部门各级别负责人查询结果
+     */
+    public static ApprovalDepartmentManagers of(String departmentId,
+                                                String departmentName,
+                                                String firstLevelManagerUserId,
+                                                String secondLevelManagerUserId,
+                                                String thirdLevelManagerUserId,
+                                                String fourthLevelManagerUserId,
+                                                String departmentHeadUserId,
+                                                String departmentLeaderUserId) {
+        return new ApprovalDepartmentManagers(departmentId, departmentName, firstLevelManagerUserId,
+                secondLevelManagerUserId, thirdLevelManagerUserId, fourthLevelManagerUserId,
+                departmentHeadUserId, departmentLeaderUserId);
+    }
+
+    /**
+     * 返回转入部门的钉钉部门 ID。
+     *
+     * @return 钉钉部门 ID
+     */
+    public String getDepartmentId() {
+        return departmentId;
+    }
+
+    /**
+     * 返回转入部门名称。
+     *
+     * @return 部门名称
+     */
+    public String getDepartmentName() {
+        return departmentName;
+    }
+
+    /**
+     * 返回一级部门主管的钉钉 userid。
+     *
+     * @return 一级部门主管 userid,未配置时为 null
+     */
+    public String getFirstLevelManagerUserId() {
+        return firstLevelManagerUserId;
+    }
+
+    /**
+     * 返回二级部门主管的钉钉 userid。
+     *
+     * @return 二级部门主管 userid,未配置时为 null
+     */
+    public String getSecondLevelManagerUserId() {
+        return secondLevelManagerUserId;
+    }
+
+    /**
+     * 返回三级部门主管的钉钉 userid。
+     *
+     * @return 三级部门主管 userid,未配置时为 null
+     */
+    public String getThirdLevelManagerUserId() {
+        return thirdLevelManagerUserId;
+    }
+
+    /**
+     * 返回四级部门主管的钉钉 userid。
+     *
+     * @return 四级部门主管 userid,未配置时为 null
+     */
+    public String getFourthLevelManagerUserId() {
+        return fourthLevelManagerUserId;
+    }
+
+    /**
+     * 返回部门负责人的钉钉 userid。
+     *
+     * @return 部门负责人 userid,未配置时为 null
+     */
+    public String getDepartmentHeadUserId() {
+        return departmentHeadUserId;
+    }
+
+    /**
+     * 返回部门分管领导的钉钉 userid。
+     *
+     * @return 部门分管领导 userid,未配置时为 null
+     */
+    public String getDepartmentLeaderUserId() {
+        return departmentLeaderUserId;
+    }
+
+    /**
+     * 按一级至四级部门主管、部门负责人、部门分管领导的顺序返回所有已配置负责人的 userid。
+     *
+     * @return 去重且非空的负责人 userid 列表,无负责人时返回空列表
+     */
+    public List<String> getAllManagerUserIds() {
+        List<String> userIds = new ArrayList<String>();
+        addIfPresent(userIds, firstLevelManagerUserId);
+        addIfPresent(userIds, secondLevelManagerUserId);
+        addIfPresent(userIds, thirdLevelManagerUserId);
+        addIfPresent(userIds, fourthLevelManagerUserId);
+        addIfPresent(userIds, departmentHeadUserId);
+        addIfPresent(userIds, departmentLeaderUserId);
+        return Collections.unmodifiableList(userIds);
+    }
+
+    private static void addIfPresent(List<String> userIds, String userId) {
+        if (userId != null && !userId.isEmpty() && !userIds.contains(userId)) {
+            userIds.add(userId);
+        }
+    }
+
+    private static String blankToNull(String value) {
+        if (value == null) {
+            return null;
+        }
+        String trimmed = value.trim();
+        return trimmed.isEmpty() ? null : trimmed;
+    }
+}

+ 30 - 0
src/main/java/com/malk/minglei/dto/ApprovalTransferDepartmentRequest.java

@@ -0,0 +1,30 @@
+package com.malk.minglei.dto;
+
+import javax.validation.constraints.NotBlank;
+
+/**
+ * 根据钉钉审批实例查询转入部门主管的请求模型。
+ */
+public class ApprovalTransferDepartmentRequest {
+
+    @NotBlank
+    private String processInstanceId;
+
+    /**
+     * 返回钉钉审批实例 ID。
+     *
+     * @return 钉钉审批实例 ID
+     */
+    public String getProcessInstanceId() {
+        return processInstanceId;
+    }
+
+    /**
+     * 设置钉钉审批实例 ID,由请求体中的 processInstanceId 字段绑定。
+     *
+     * @param processInstanceId 钉钉审批实例 ID
+     */
+    public void setProcessInstanceId(String processInstanceId) {
+        this.processInstanceId = processInstanceId;
+    }
+}

+ 19 - 0
src/main/java/com/malk/minglei/service/approval/ApprovalTransferDepartmentService.java

@@ -0,0 +1,19 @@
+package com.malk.minglei.service.approval;
+
+import com.malk.minglei.dto.ApprovalDepartmentManagers;
+
+/**
+ * 钉钉审批实例转入部门主管查询服务。
+ */
+public interface ApprovalTransferDepartmentService {
+
+    /**
+     * 根据钉钉审批实例 ID 查询转入部门的各级别负责人。
+     *
+     * <p>部门主管通过宜搭审批矩阵查询得到,按一级至四级部门主管与部门分管领导分字段返回。
+     *
+     * @param processInstanceId 钉钉审批实例 ID
+     * @return 转入部门及其各级别负责人 userid
+     */
+    ApprovalDepartmentManagers findTransferDepartmentManagers(String processInstanceId);
+}

+ 18 - 0
src/main/java/com/malk/minglei/service/approval/DingTalkApprovalClient.java

@@ -0,0 +1,18 @@
+package com.malk.minglei.service.approval;
+
+import java.util.Map;
+
+/**
+ * 钉钉审批开放接口客户端。
+ */
+public interface DingTalkApprovalClient {
+
+    /**
+     * 查询单个审批实例详情。
+     *
+     * @param accessToken 钉钉访问令牌
+     * @param processInstanceId 钉钉审批实例 ID
+     * @return 审批实例详情响应,包含 success 和 result 节点
+     */
+    Map<String, Object> getProcessInstanceDetail(String accessToken, String processInstanceId);
+}

+ 217 - 0
src/main/java/com/malk/minglei/service/impl/approval/ApprovalTransferDepartmentServiceImpl.java

@@ -0,0 +1,217 @@
+package com.malk.minglei.service.impl.approval;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.malk.minglei.dto.ApprovalDepartmentManagers;
+import com.malk.minglei.service.approval.ApprovalTransferDepartmentService;
+import com.malk.minglei.service.approval.DingTalkApprovalClient;
+import com.malk.minglei.service.yida.MingleiDocumentService;
+import com.malk.service.dingtalk.DDClient;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 钉钉审批实例转入部门负责人查询服务实现。
+ *
+ * <p>处理链路:审批实例 ID → 审批实例详情 → 转入部门 ID → 宜搭审批矩阵(按"部门"字段匹配)→ 各级别负责人 userid。
+ *
+ * <p>部门主管不再从钉钉通讯录接口获取,而是复用 {@link MingleiDocumentService} 对宜搭审批矩阵表单的查询能力。
+ * 审批矩阵按"部门"选择字段({@code departmentSelectField_mszdqrks})内嵌的部门 ID 精确匹配,
+ * 返回一级至四级部门主管、部门负责人与部门分管领导的钉钉 userid。
+ */
+@Service
+public class ApprovalTransferDepartmentServiceImpl implements ApprovalTransferDepartmentService {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(ApprovalTransferDepartmentServiceImpl.class);
+
+    private static final String TRANSFER_DEPARTMENT_FIELD_NAME = "转入部门";
+
+    private static final String TRANSFER_DEPARTMENT_FIELD_ALIAS = "transferDepartment";
+
+    /** 审批矩阵按部门字段查询时命中的语义化结果 key。 */
+    private static final String FIRST_LEVEL_KEY = "firstLevelManagerUserId";
+    private static final String SECOND_LEVEL_KEY = "secondLevelManagerUserId";
+    private static final String THIRD_LEVEL_KEY = "thirdLevelManagerUserId";
+    private static final String FOURTH_LEVEL_KEY = "fourthLevelManagerUserId";
+    private static final String DEPARTMENT_HEAD_KEY = "departmentHeadUserId";
+    private static final String DEPARTMENT_LEADER_KEY = "departmentLeaderUserId";
+
+    private final DDClient dingTalkClient;
+    private final DingTalkApprovalClient approvalClient;
+    private final MingleiDocumentService mingleiDocumentService;
+    private final ObjectMapper objectMapper;
+
+    /**
+     * 创建审批转入部门主管查询服务实现。
+     *
+     * @param dingTalkClient 钉钉基础客户端,用于获取应用凭证
+     * @param approvalClient 钉钉审批客户端,用于查询审批实例详情
+     * @param mingleiDocumentService 明磊宜搭主数据查询服务,用于查询审批矩阵中的部门主管
+     * @param objectMapper JSON 解析器,用于解析表单组件扩展值
+     */
+    public ApprovalTransferDepartmentServiceImpl(DDClient dingTalkClient,
+                                                 DingTalkApprovalClient approvalClient,
+                                                 MingleiDocumentService mingleiDocumentService,
+                                                 ObjectMapper objectMapper) {
+        this.dingTalkClient = dingTalkClient;
+        this.approvalClient = approvalClient;
+        this.mingleiDocumentService = mingleiDocumentService;
+        this.objectMapper = objectMapper;
+    }
+
+    /**
+     * 根据钉钉审批实例 ID 查询转入部门的各级别负责人。
+     *
+     * @param processInstanceId 钉钉审批实例 ID
+     * @return 转入部门及其各级别负责人 userid
+     */
+    @Override
+    public ApprovalDepartmentManagers findTransferDepartmentManagers(String processInstanceId) {
+        String instanceId = requireText(processInstanceId, "processInstanceId must not be blank");
+
+        String accessToken = dingTalkClient.getAccessToken();
+        if (accessToken == null || accessToken.trim().isEmpty()) {
+            throw new IllegalStateException("Unable to obtain DingTalk access token");
+        }
+
+        Map<String, Object> instanceDetail = approvalClient.getProcessInstanceDetail(accessToken, instanceId);
+        Map<?, ?> result = asMap(instanceDetail.get("result"));
+        if (result == null) {
+            throw new IllegalArgumentException("Approval instance " + instanceId + " has no form data");
+        }
+
+        Map<?, ?> component = findTransferDepartmentComponent(asList(result.get("formComponentValues")));
+        if (component == null) {
+            throw new IllegalArgumentException("Approval instance " + instanceId
+                    + " does not contain the transfer department field: " + TRANSFER_DEPARTMENT_FIELD_NAME);
+        }
+
+        JsonNode department = parseDepartmentExtValue(toStringValue(component.get("extValue")));
+        if (department == null) {
+            throw new IllegalArgumentException("Transfer department field of approval instance "
+                    + instanceId + " does not contain a department id");
+        }
+
+        String departmentId = firstText(department, "id", "itemId", "deptId");
+        if (departmentId == null) {
+            throw new IllegalArgumentException("Transfer department field of approval instance "
+                    + instanceId + " does not contain a department id");
+        }
+        String departmentName = firstText(department, "deptName", "name");
+
+        ApprovalDepartmentManagers managers = queryDepartmentManagers(departmentId, departmentName);
+        LOGGER.info("Resolved transfer department managers instanceId={} departmentId={} firstLevel={} leader={}",
+                instanceId, departmentId,
+                managers.getFirstLevelManagerUserId(), managers.getDepartmentLeaderUserId());
+
+        return managers;
+    }
+
+    private ApprovalDepartmentManagers queryDepartmentManagers(String departmentId, String departmentName) {
+        Map<String, Object> matrixManagers = mingleiDocumentService.queryDepartmentManagersByDepartmentId(departmentId);
+        if (matrixManagers == null || matrixManagers.isEmpty()) {
+            LOGGER.warn("No approval matrix record matched department {}", departmentId);
+            return ApprovalDepartmentManagers.of(departmentId, departmentName, null, null, null, null, null, null);
+        }
+
+        return ApprovalDepartmentManagers.of(
+                departmentId,
+                departmentName,
+                toStringValue(matrixManagers.get(FIRST_LEVEL_KEY)),
+                toStringValue(matrixManagers.get(SECOND_LEVEL_KEY)),
+                toStringValue(matrixManagers.get(THIRD_LEVEL_KEY)),
+                toStringValue(matrixManagers.get(FOURTH_LEVEL_KEY)),
+                toStringValue(matrixManagers.get(DEPARTMENT_HEAD_KEY)),
+                toStringValue(matrixManagers.get(DEPARTMENT_LEADER_KEY)));
+    }
+
+    private Map<?, ?> findTransferDepartmentComponent(List<?> formComponentValues) {
+        Map<?, ?> aliasMatch = null;
+        for (Object item : formComponentValues) {
+            Map<?, ?> component = asMap(item);
+            if (component == null) {
+                continue;
+            }
+            if (TRANSFER_DEPARTMENT_FIELD_NAME.equals(toStringValue(component.get("name")))) {
+                return component;
+            }
+            if (aliasMatch == null && TRANSFER_DEPARTMENT_FIELD_ALIAS.equals(toStringValue(component.get("bizAlias")))) {
+                aliasMatch = component;
+            }
+        }
+        return aliasMatch;
+    }
+
+    private JsonNode parseDepartmentExtValue(String extValue) {
+        if (extValue == null) {
+            return null;
+        }
+        try {
+            JsonNode root = objectMapper.readTree(extValue);
+            if (root == null || root.isNull()) {
+                return null;
+            }
+            if (root.isArray()) {
+                return root.size() == 0 ? null : root.get(0);
+            }
+            return root.isObject() ? root : null;
+        } catch (IOException exception) {
+            LOGGER.warn("Unable to parse transfer department extValue", exception);
+            return null;
+        }
+    }
+
+    private String firstText(JsonNode node, String firstKey, String... otherKeys) {
+        String value = jsonText(node, firstKey);
+        if (value != null) {
+            return value;
+        }
+        for (String key : otherKeys) {
+            value = jsonText(node, key);
+            if (value != null) {
+                return value;
+            }
+        }
+        return null;
+    }
+
+    private String jsonText(JsonNode node, String key) {
+        JsonNode value = node.get(key);
+        if (value == null || value.isNull()) {
+            return null;
+        }
+        String text = value.asText();
+        return text == null || text.trim().isEmpty() ? null : text.trim();
+    }
+
+    private Map<?, ?> asMap(Object value) {
+        return value instanceof Map ? (Map<?, ?>) value : null;
+    }
+
+    private List<?> asList(Object value) {
+        return value instanceof List ? (List<?>) value : Collections.emptyList();
+    }
+
+    private String toStringValue(Object value) {
+        if (value == null) {
+            return null;
+        }
+        String text = String.valueOf(value).trim();
+        return text.isEmpty() ? null : text;
+    }
+
+    private String requireText(String value, String message) {
+        String text = toStringValue(value);
+        if (text == null) {
+            throw new IllegalArgumentException(message);
+        }
+        return text;
+    }
+}

+ 103 - 0
src/main/java/com/malk/minglei/service/impl/approval/DingTalkApprovalClientImpl.java

@@ -0,0 +1,103 @@
+package com.malk.minglei.service.impl.approval;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.malk.minglei.service.approval.DingTalkApprovalClient;
+import com.malk.server.common.McException;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.HttpStatusCodeException;
+import org.springframework.web.client.RestTemplate;
+
+import java.util.Collections;
+import java.util.Map;
+
+/**
+ * 基于钉钉审批开放接口 v1.0 的客户端实现。
+ */
+@Service
+public class DingTalkApprovalClientImpl implements DingTalkApprovalClient {
+
+    private static final String PROCESS_INSTANCE_DETAIL_URL =
+            "https://api.dingtalk.com/v1.0/workflow/processInstances?processInstanceId={processInstanceId}";
+
+    private static final String ACCESS_TOKEN_HEADER = "x-acs-dingtalk-access-token";
+
+    private static final String ERROR_CODE = "DINGTALK_APPROVAL_CALL_FAILED";
+
+    private final RestTemplate restTemplate;
+    private final ObjectMapper objectMapper;
+
+    /**
+     * 创建钉钉审批客户端实现。
+     *
+     * @param restTemplate 钉钉接口专用 HTTP 客户端
+     * @param objectMapper JSON 解析器,用于解析钉钉错误响应
+     */
+    public DingTalkApprovalClientImpl(@Qualifier("dingTalkRestTemplate") RestTemplate restTemplate,
+                                      ObjectMapper objectMapper) {
+        this.restTemplate = restTemplate;
+        this.objectMapper = objectMapper;
+    }
+
+    /**
+     * 查询单个审批实例详情,包含 result.formComponentValues 表单明细。
+     *
+     * @param accessToken 钉钉访问令牌
+     * @param processInstanceId 钉钉审批实例 ID
+     * @return 审批实例详情响应,包含 success 和 result 节点
+     */
+    @Override
+    public Map<String, Object> getProcessInstanceDetail(String accessToken, String processInstanceId) {
+        HttpHeaders headers = new HttpHeaders();
+        headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
+        headers.set(ACCESS_TOKEN_HEADER, accessToken);
+
+        try {
+            ResponseEntity<Map<String, Object>> response = restTemplate.exchange(
+                    PROCESS_INSTANCE_DETAIL_URL,
+                    HttpMethod.GET,
+                    new HttpEntity<Map<String, Object>>(headers),
+                    new ParameterizedTypeReference<Map<String, Object>>() {
+                    },
+                    processInstanceId);
+
+            Map<String, Object> responseBody = response.getBody();
+            if (responseBody == null) {
+                throw new McException(ERROR_CODE, "DingTalk approval instance response is empty");
+            }
+            return responseBody;
+        } catch (HttpStatusCodeException exception) {
+            throw new McException(ERROR_CODE, extractErrorMessage(exception));
+        }
+    }
+
+    private String extractErrorMessage(HttpStatusCodeException exception) {
+        String message = parseDingTalkMessage(exception.getResponseBodyAsString());
+        if (message != null) {
+            return message;
+        }
+        return "DingTalk approval instance request failed with status " + exception.getStatusCode();
+    }
+
+    private String parseDingTalkMessage(String responseBody) {
+        if (responseBody == null || responseBody.trim().isEmpty()) {
+            return null;
+        }
+        try {
+            Map<?, ?> body = objectMapper.readValue(responseBody, Map.class);
+            Object message = body.get("message");
+            if (message == null) {
+                message = body.get("errmsg");
+            }
+            return message == null ? null : String.valueOf(message);
+        } catch (Exception parseException) {
+            return null;
+        }
+    }
+}

+ 53 - 0
src/test/java/com/malk/minglei/controller/ApprovalTransferDepartmentControllerTest.java

@@ -0,0 +1,53 @@
+package com.malk.minglei.controller;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.malk.minglei.dto.ApiResponse;
+import com.malk.minglei.dto.ApprovalDepartmentManagers;
+import com.malk.minglei.dto.ApprovalTransferDepartmentRequest;
+import com.malk.minglei.service.approval.ApprovalTransferDepartmentService;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+
+import java.util.Arrays;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+
+class ApprovalTransferDepartmentControllerTest {
+
+    @Test
+    void listTransferDepartmentManagersReturnsManagerUserIds() throws Exception {
+        ApprovalTransferDepartmentService service = mock(ApprovalTransferDepartmentService.class);
+        given(service.findTransferDepartmentManagers("YuRRlAutRFi4CcsrPkqvQg05031789886060"))
+                .willReturn(ApprovalDepartmentManagers.of("972414534", "合作伙伴",
+                        "manager-1", "manager-2", "manager-3", "manager-4", "manager-head", "manager-5"));
+
+        ApprovalTransferDepartmentRequest request = new ApprovalTransferDepartmentRequest();
+        request.setProcessInstanceId("YuRRlAutRFi4CcsrPkqvQg05031789886060");
+
+        ResponseEntity<ApiResponse<ApprovalDepartmentManagers>> response =
+                new ApprovalTransferDepartmentController(service).listTransferDepartmentManagers(request);
+
+        ObjectMapper objectMapper = new ObjectMapper();
+        Map<String, Object> body = objectMapper.readValue(objectMapper.writeValueAsString(response.getBody()),
+                new TypeReference<Map<String, Object>>() {
+                });
+        Map<?, ?> result = (Map<?, ?>) body.get("result");
+
+        assertEquals(HttpStatus.OK, response.getStatusCode());
+        assertEquals(200, body.get("code"));
+        assertEquals(true, body.get("isSuccess"));
+        assertEquals("972414534", result.get("departmentId"));
+        assertEquals("合作伙伴", result.get("departmentName"));
+        assertEquals("manager-1", result.get("firstLevelManagerUserId"));
+        assertEquals("manager-2", result.get("secondLevelManagerUserId"));
+        assertEquals("manager-3", result.get("thirdLevelManagerUserId"));
+        assertEquals("manager-4", result.get("fourthLevelManagerUserId"));
+        assertEquals("manager-head", result.get("departmentHeadUserId"));
+        assertEquals("manager-5", result.get("departmentLeaderUserId"));
+    }
+}

+ 217 - 0
src/test/java/com/malk/minglei/service/impl/approval/ApprovalTransferDepartmentServiceImplTest.java

@@ -0,0 +1,217 @@
+package com.malk.minglei.service.impl.approval;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.malk.minglei.dto.ApprovalDepartmentManagers;
+import com.malk.minglei.service.approval.DingTalkApprovalClient;
+import com.malk.minglei.service.yida.MingleiDocumentService;
+import com.malk.service.dingtalk.DDClient;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+class ApprovalTransferDepartmentServiceImplTest {
+
+    private static final String TRANSFER_DEPARTMENT_EXT_VALUE =
+            "[{\"deptName\":\"合作伙伴\",\"itemId\":\"972414534\",\"name\":\"合作伙伴\",\"id\":\"972414534\"}]";
+
+    @Test
+    void findTransferDepartmentManagersResolvesDepartmentManagers() {
+        DingTalkApprovalClient approvalClient = mock(DingTalkApprovalClient.class);
+        MingleiDocumentService documentService = mock(MingleiDocumentService.class);
+        given(approvalClient.getProcessInstanceDetail("access-token", "instance-001"))
+                .willReturn(instanceDetail(transferDepartmentComponent(TRANSFER_DEPARTMENT_EXT_VALUE)));
+        given(documentService.queryDepartmentManagersByDepartmentId(anyString()))
+                .willReturn(matrix("manager-1", "manager-2", "manager-3", "manager-4", "manager-head", "manager-5"));
+
+        ApprovalDepartmentManagers managers = service(approvalClient, documentService)
+                .findTransferDepartmentManagers("instance-001");
+
+        assertEquals("972414534", managers.getDepartmentId());
+        assertEquals("合作伙伴", managers.getDepartmentName());
+        assertEquals("manager-1", managers.getFirstLevelManagerUserId());
+        assertEquals("manager-2", managers.getSecondLevelManagerUserId());
+        assertEquals("manager-3", managers.getThirdLevelManagerUserId());
+        assertEquals("manager-4", managers.getFourthLevelManagerUserId());
+        assertEquals("manager-head", managers.getDepartmentHeadUserId());
+        assertEquals("manager-5", managers.getDepartmentLeaderUserId());
+        assertEquals(Arrays.asList("manager-1", "manager-2", "manager-3", "manager-4", "manager-head", "manager-5"),
+                managers.getAllManagerUserIds());
+    }
+
+    @Test
+    void findTransferDepartmentManagersQueriesMatrixByTransferDepartmentId() {
+        DingTalkApprovalClient approvalClient = mock(DingTalkApprovalClient.class);
+        MingleiDocumentService documentService = mock(MingleiDocumentService.class);
+        given(approvalClient.getProcessInstanceDetail("access-token", "instance-001"))
+                .willReturn(instanceDetail(transferDepartmentComponent(TRANSFER_DEPARTMENT_EXT_VALUE)));
+        given(documentService.queryDepartmentManagersByDepartmentId(anyString()))
+                .willReturn(matrix("manager-1", "manager-2", "manager-3", "manager-4", "manager-head", "manager-5"));
+
+        service(approvalClient, documentService).findTransferDepartmentManagers("instance-001");
+
+        verify(documentService).queryDepartmentManagersByDepartmentId("972414534");
+    }
+
+    @Test
+    void findTransferDepartmentManagersAcceptsNumericDepartmentId() {
+        DingTalkApprovalClient approvalClient = mock(DingTalkApprovalClient.class);
+        MingleiDocumentService documentService = mock(MingleiDocumentService.class);
+        given(approvalClient.getProcessInstanceDetail("access-token", "instance-002"))
+                .willReturn(instanceDetail(transferDepartmentComponent(
+                        "[{\"itemId\":1003297198,\"name\":\"钉钉顾问\",\"id\":1003297198}]")));
+        given(documentService.queryDepartmentManagersByDepartmentId(anyString()))
+                .willReturn(matrix("manager-3", null, null, null, null, null));
+
+        ApprovalDepartmentManagers managers = service(approvalClient, documentService)
+                .findTransferDepartmentManagers("instance-002");
+
+        assertEquals("1003297198", managers.getDepartmentId());
+        assertEquals("钉钉顾问", managers.getDepartmentName());
+        assertEquals("manager-3", managers.getFirstLevelManagerUserId());
+        assertNull(managers.getSecondLevelManagerUserId());
+        assertEquals(Collections.singletonList("manager-3"), managers.getAllManagerUserIds());
+    }
+
+    @Test
+    void findTransferDepartmentManagersFallsBackToDepartmentAlias() {
+        DingTalkApprovalClient approvalClient = mock(DingTalkApprovalClient.class);
+        MingleiDocumentService documentService = mock(MingleiDocumentService.class);
+        Map<String, Object> component = new LinkedHashMap<String, Object>();
+        component.put("componentType", "DepartmentField");
+        component.put("name", "调动部门");
+        component.put("bizAlias", "transferDepartment");
+        component.put("extValue", TRANSFER_DEPARTMENT_EXT_VALUE);
+        given(approvalClient.getProcessInstanceDetail("access-token", "instance-003"))
+                .willReturn(instanceDetail(component));
+        given(documentService.queryDepartmentManagersByDepartmentId(anyString()))
+                .willReturn(matrix("manager-1", "manager-2", "manager-3", "manager-4", "manager-head", "manager-5"));
+
+        ApprovalDepartmentManagers managers = service(approvalClient, documentService)
+                .findTransferDepartmentManagers("instance-003");
+
+        assertEquals("972414534", managers.getDepartmentId());
+        assertEquals("manager-1", managers.getFirstLevelManagerUserId());
+    }
+
+    @Test
+    void findTransferDepartmentManagersReturnsNullFieldsWhenMatrixHasNoMatch() {
+        DingTalkApprovalClient approvalClient = mock(DingTalkApprovalClient.class);
+        MingleiDocumentService documentService = mock(MingleiDocumentService.class);
+        given(approvalClient.getProcessInstanceDetail("access-token", "instance-004"))
+                .willReturn(instanceDetail(transferDepartmentComponent(TRANSFER_DEPARTMENT_EXT_VALUE)));
+        given(documentService.queryDepartmentManagersByDepartmentId(anyString()))
+                .willReturn(Collections.<String, Object>emptyMap());
+
+        ApprovalDepartmentManagers managers = service(approvalClient, documentService)
+                .findTransferDepartmentManagers("instance-004");
+
+        assertEquals("972414534", managers.getDepartmentId());
+        assertNull(managers.getFirstLevelManagerUserId());
+        assertNull(managers.getDepartmentLeaderUserId());
+        assertTrue(managers.getAllManagerUserIds().isEmpty());
+    }
+
+    @Test
+    void findTransferDepartmentManagersRejectsBlankProcessInstanceId() {
+        ApprovalTransferDepartmentServiceImpl service = service(mock(DingTalkApprovalClient.class), mock(MingleiDocumentService.class));
+
+        assertThrows(IllegalArgumentException.class, () -> service.findTransferDepartmentManagers("  "));
+    }
+
+    @Test
+    void findTransferDepartmentManagersRejectsMissingTransferDepartmentField() {
+        DingTalkApprovalClient approvalClient = mock(DingTalkApprovalClient.class);
+        Map<String, Object> component = new LinkedHashMap<String, Object>();
+        component.put("name", "原部门");
+        component.put("value", "钉钉顾问");
+        given(approvalClient.getProcessInstanceDetail("access-token", "instance-005"))
+                .willReturn(instanceDetail(component));
+
+        ApprovalTransferDepartmentServiceImpl service = service(approvalClient, mock(MingleiDocumentService.class));
+
+        assertThrows(IllegalArgumentException.class, () -> service.findTransferDepartmentManagers("instance-005"));
+    }
+
+    @Test
+    void findTransferDepartmentManagersRejectsExtValueWithoutDepartmentId() {
+        DingTalkApprovalClient approvalClient = mock(DingTalkApprovalClient.class);
+        given(approvalClient.getProcessInstanceDetail("access-token", "instance-006"))
+                .willReturn(instanceDetail(transferDepartmentComponent("[{\"name\":\"合作伙伴\"}]")));
+
+        ApprovalTransferDepartmentServiceImpl service = service(approvalClient, mock(MingleiDocumentService.class));
+
+        assertThrows(IllegalArgumentException.class, () -> service.findTransferDepartmentManagers("instance-006"));
+    }
+
+    @Test
+    void findTransferDepartmentManagersReturnsNullFieldsForUnmatchedDepartmentId() {
+        DingTalkApprovalClient approvalClient = mock(DingTalkApprovalClient.class);
+        MingleiDocumentService documentService = mock(MingleiDocumentService.class);
+        given(approvalClient.getProcessInstanceDetail("access-token", "instance-007"))
+                .willReturn(instanceDetail(transferDepartmentComponent("[{\"id\":\"NOT_A_DEPARTMENT\"}]")));
+
+        ApprovalDepartmentManagers managers = service(approvalClient, documentService)
+                .findTransferDepartmentManagers("instance-007");
+
+        assertEquals("NOT_A_DEPARTMENT", managers.getDepartmentId());
+        assertNull(managers.getFirstLevelManagerUserId());
+        assertNull(managers.getDepartmentLeaderUserId());
+        assertTrue(managers.getAllManagerUserIds().isEmpty());
+    }
+
+    private ApprovalTransferDepartmentServiceImpl service(DingTalkApprovalClient approvalClient,
+                                                          MingleiDocumentService documentService) {
+        DDClient dingTalkClient = mock(DDClient.class);
+        given(dingTalkClient.getAccessToken()).willReturn("access-token");
+        return new ApprovalTransferDepartmentServiceImpl(dingTalkClient, approvalClient, documentService, new ObjectMapper());
+    }
+
+    private static Map<String, Object> matrix(String first, String second, String third, String fourth, String head, String leader) {
+        Map<String, Object> managers = new LinkedHashMap<String, Object>();
+        managers.put("firstLevelManagerUserId", first);
+        managers.put("secondLevelManagerUserId", second);
+        managers.put("thirdLevelManagerUserId", third);
+        managers.put("fourthLevelManagerUserId", fourth);
+        managers.put("departmentHeadUserId", head);
+        managers.put("departmentLeaderUserId", leader);
+        return managers;
+    }
+
+    private static Map<String, Object> instanceDetail(Map<String, Object> component) {
+        return mapOf("success", Boolean.TRUE,
+                "result", mapOf("title", "刘飞翔提交的公司内调动流程",
+                        "formComponentValues", Collections.singletonList(component)));
+    }
+
+    private static Map<String, Object> transferDepartmentComponent(String extValue) {
+        Map<String, Object> component = new LinkedHashMap<String, Object>();
+        component.put("componentType", "DepartmentField");
+        component.put("name", "转入部门");
+        component.put("bizAlias", "transferDepartment");
+        component.put("value", "合作伙伴");
+        component.put("extValue", extValue);
+        return component;
+    }
+
+    private static Map<String, Object> mapOf(Object... keyValues) {
+        Map<String, Object> map = new LinkedHashMap<String, Object>();
+        for (int index = 0; index < keyValues.length; index += 2) {
+            map.put((String) keyValues[index], keyValues[index + 1]);
+        }
+        return map;
+    }
+}

+ 72 - 0
src/test/java/com/malk/minglei/service/impl/approval/DingTalkApprovalClientImplTest.java

@@ -0,0 +1,72 @@
+package com.malk.minglei.service.impl.approval;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.malk.server.common.McException;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.client.MockRestServiceServer;
+import org.springframework.web.client.RestTemplate;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
+import static org.springframework.test.web.client.response.MockRestResponseCreators.withBadRequest;
+import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
+
+class DingTalkApprovalClientImplTest {
+
+    private static final String EXPECTED_URL =
+            "https://api.dingtalk.com/v1.0/workflow/processInstances?processInstanceId=instance-001";
+
+    @Test
+    void getProcessInstanceDetailSendsAccessTokenHeader() {
+        RestTemplate restTemplate = new RestTemplate();
+        MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build();
+        server.expect(requestTo(EXPECTED_URL))
+                .andExpect(method(HttpMethod.GET))
+                .andExpect(header("x-acs-dingtalk-access-token", "access-token"))
+                .andRespond(withSuccess("{\"success\":true,\"result\":{\"title\":\"调动流程\"}}",
+                        MediaType.APPLICATION_JSON));
+
+        Map<String, Object> detail = new DingTalkApprovalClientImpl(restTemplate, new ObjectMapper())
+                .getProcessInstanceDetail("access-token", "instance-001");
+
+        server.verify();
+        assertEquals(Boolean.TRUE, detail.get("success"));
+    }
+
+    @Test
+    void getProcessInstanceDetailTranslatesDingTalkError() {
+        RestTemplate restTemplate = new RestTemplate();
+        MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build();
+        server.expect(requestTo(EXPECTED_URL))
+                .andRespond(withBadRequest()
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .body("{\"code\":\"processInstanceIdError\",\"message\":\"processInstanceId参数无效\"}"));
+
+        DingTalkApprovalClientImpl client = new DingTalkApprovalClientImpl(restTemplate, new ObjectMapper());
+        McException exception = assertThrows(McException.class,
+                () -> client.getProcessInstanceDetail("access-token", "instance-001"));
+
+        assertEquals("DINGTALK_APPROVAL_CALL_FAILED", exception.getCode());
+        assertEquals("processInstanceId参数无效", exception.getMessage());
+    }
+
+    @Test
+    void getProcessInstanceDetailFailsWhenResponseBodyIsEmpty() {
+        RestTemplate restTemplate = new RestTemplate();
+        MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build();
+        server.expect(requestTo(EXPECTED_URL))
+                .andRespond(withSuccess("{}", MediaType.APPLICATION_JSON));
+
+        DingTalkApprovalClientImpl client = new DingTalkApprovalClientImpl(restTemplate, new ObjectMapper());
+
+        Map<String, Object> detail = client.getProcessInstanceDetail("access-token", "instance-001");
+        assertEquals(0, detail.size());
+    }
+}