Sfoglia il codice sorgente

feat(workhours): add change request project manager lookup

malk 1 mese fa
parent
commit
9c799d90b0

+ 27 - 0
mjava-akdsbeisen/src/main/java/com/malk/controller/ApprovalController.java

@@ -5,11 +5,18 @@ import com.malk.server.workhours.ApprovalWriteBackResult;
 import com.malk.server.workhours.ReSubmitApprovalResult;
 import com.malk.server.workhours.TimeCardApprovalStartRequest;
 import com.malk.service.workhours.ApprovalWriteBackService;
+import com.malk.service.workhours.ChangeProjectManagerLookupService;
 import com.malk.service.workhours.ReSubmitApprovalService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.*;
 
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
 /**
  * 审批结果回写工时汇总表 — 接口
  * <p>
@@ -21,12 +28,18 @@ import org.springframework.web.bind.annotation.*;
 @RequestMapping("/approval")
 public class ApprovalController {
 
+    private static final Pattern PROCESS_INSTANCE_ID = Pattern.compile(
+            "(?:^|[\\{,\\s])[\\\"']?processInstanceId[\\\"']?\\s*[:=]\\s*[\\\"']?([A-Za-z0-9_-]+)");
+
     @Autowired
     private ApprovalWriteBackService approvalWriteBackService;
 
     @Autowired
     private ReSubmitApprovalService reSubmitApprovalService;
 
+    @Autowired
+    private ChangeProjectManagerLookupService changeProjectManagerLookupService;
+
     /**
      * 审批结果回写工时汇总表
      * GET /approval/writeback?instanceId=xxx&result=1
@@ -87,4 +100,18 @@ public class ApprovalController {
     public McR<Boolean> start(@RequestBody TimeCardApprovalStartRequest request) {
         return McR.success(reSubmitApprovalService.startApproval(request));
     }
+
+    /** 根据工时变更单实时查询变更项目的审批经理 userId。 */
+    @PostMapping("/change-project-managers")
+    public McR<Map<String, List<String>>> changeProjectManagers(@RequestBody String requestBody) {
+        List<String> userIds = changeProjectManagerLookupService.lookup(parseProcessInstanceId(requestBody));
+        return McR.success(Collections.singletonMap("userIds", userIds));
+    }
+
+    // fixme 宜搭 HTTP 连接器会把 Body 序列化成 {processInstanceId:"..."},兼容该格式和标准 JSON。
+    static String parseProcessInstanceId(String requestBody) {
+        if (requestBody == null) return null;
+        Matcher matcher = PROCESS_INSTANCE_ID.matcher(requestBody);
+        return matcher.find() ? matcher.group(1) : null;
+    }
 }

+ 196 - 0
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/ChangeProjectManagerLookupService.java

@@ -0,0 +1,196 @@
+package com.malk.service.workhours;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+import com.malk.server.common.McException;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.server.workhours.WHConf;
+import com.malk.service.aliwork.YDClient;
+import com.malk.service.aliwork.YDService;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/** 按工时变更单中的实际变更项目,实时读取项目成员行的审批经理。 */
+@Service
+public class ChangeProjectManagerLookupService {
+
+    private static final String CHANGE_FORM = "FORM-1C9C7E3981CC4D32A4404D457E766DFCNBYD";
+    private static final String SUBMITTER = "employeeField_mmacxew4";
+    private static final String SUBMITTER_TEXT_ID = "textField_mmbffvd9";
+    private static final String BILLABLE_TABLE = "tableField_mmczo634";
+    private static final String BILLABLE_CODE = "textField_mmd25ngw";
+    private static final String BILLABLE_DELTA = "numberField_mmd25ngz";
+    private static final String NON_BILLABLE_TABLE = "tableField_mmecojwk";
+    private static final String NON_BILLABLE_CODE = "textField_mmecojwe";
+    private static final String NON_BILLABLE_DELTA = "numberField_mmecojwh";
+    private static final String PROJECT_CODE = "textField_mkov5ozb";
+    private static final String PROJECT_MEMBERS = "tableField_mkowyn6d";
+    private static final String PROJECT_MEMBER = "employeeField_mmbfe0ij";
+    private static final String PROJECT_MANAGER = "employeeField_mkoxpswf";
+
+    private final YDClient ydClient;
+    private final YDService ydService;
+    private final WHConf conf;
+
+    public ChangeProjectManagerLookupService(YDClient ydClient, YDService ydService, WHConf conf) {
+        this.ydClient = ydClient;
+        this.ydService = ydService;
+        this.conf = conf;
+    }
+
+    public List<String> lookup(String processInstanceId) {
+        McException.assertParamException(StringUtils.isBlank(processInstanceId), "工时变更单流程实例 ID 不能为空");
+        DDR_New detail = ydClient.queryData(YDParam.builder()
+                .appType(conf.getYidaAppType()).systemToken(conf.getYidaSystemToken())
+                .processInstanceId(processInstanceId).build(), YDConf.FORM_QUERY.retrieve_search_process_id);
+        Map formData = ApprovalWriteBackService.extractProcessFormData(detail);
+        // fixme 流程详情的 data 也可能直接是表单字段 Map,审批回写工具只识别审批单子表。
+        if (formData == null && detail != null && detail.getData() instanceof Map) {
+            formData = (Map) detail.getData();
+        }
+        McException.assertAccessException(formData == null || !formData.containsKey(SUBMITTER),
+                "未查询到工时变更单详情,或实例 ID 不属于工时变更单");
+        String submitterId = firstId(formData, SUBMITTER);
+        if (StringUtils.isBlank(submitterId)) {
+            submitterId = StringUtils.trimToEmpty(String.valueOf(formData.get(SUBMITTER_TEXT_ID)));
+        }
+        McException.assertAccessException(StringUtils.isBlank(submitterId) || "null".equals(submitterId),
+                "工时变更单填报人 ID 为空");
+
+        Set<String> projectCodes = new LinkedHashSet<>();
+        collectProjectCodes(projectCodes, formData, processInstanceId,
+                BILLABLE_TABLE, BILLABLE_CODE, BILLABLE_DELTA);
+        collectProjectCodes(projectCodes, formData, processInstanceId,
+                NON_BILLABLE_TABLE, NON_BILLABLE_CODE, NON_BILLABLE_DELTA);
+        Set<String> managerIds = new LinkedHashSet<>();
+        for (String code : projectCodes) {
+            managerIds.addAll(findProjectManagers(code, submitterId));
+        }
+        return new ArrayList<>(managerIds);
+    }
+
+    private void collectProjectCodes(Set<String> codes, Map formData, String instanceId,
+                                     String tableId, String codeField, String hoursField) {
+        List<Map> rows = rows(formData.get(tableId));
+        if (rows.size() >= 50) {
+            List<Map> full = ydService.queryDetails(YDParam.builder()
+                    .appType(conf.getYidaAppType()).systemToken(conf.getYidaSystemToken())
+                    .formUuid(CHANGE_FORM).formInstanceId(instanceId)
+                    .tableFieldId(tableId).pageNumber(1).build());
+            McException.assertAccessException(full == null || full.size() < rows.size(),
+                    "工时变更单子表完整数据获取失败: " + tableId);
+            rows = full;
+        }
+        for (Map row : rows) {
+            if (changedHours(row, hoursField).compareTo(BigDecimal.ZERO) == 0) continue;
+            String code = StringUtils.trimToEmpty(String.valueOf(row.get(codeField)));
+            McException.assertAccessException(code.isEmpty() || "null".equals(code),
+                    "变更工时不为 0,但项目编号为空: " + tableId);
+            codes.add(code);
+        }
+    }
+
+    private List<String> findProjectManagers(String code, String submitterId) {
+        List<Map> exactProjects = new ArrayList<>();
+        int page = 1;
+        long total;
+        do {
+            DDR_New result = ydClient.queryData(YDParam.builder()
+                    .appType(conf.getYidaAppType()).systemToken(conf.getYidaSystemToken())
+                    .formUuid(conf.getFormUuidProject())
+                    .searchFieldJson(JSON.toJSONString(Collections.singletonMap(PROJECT_CODE, code)))
+                    .currentPage(page).pageSize(100).build(), YDConf.FORM_QUERY.retrieve_search_form);
+            List<Map> data = result == null ? null : (List<Map>) result.getData();
+            McException.assertAccessException(data == null, "项目档案查询失败: " + code);
+            total = result.getTotalCount();
+            for (Map item : data) {
+                Map formData = (Map) item.get("formData");
+                if (formData != null && code.equals(formData.get(PROJECT_CODE))) exactProjects.add(item);
+            }
+            if (data.isEmpty()) break;
+            page++;
+        } while ((long) (page - 1) * 100 < total);
+        McException.assertAccessException(exactProjects.size() != 1,
+                "项目编号未唯一命中项目档案: " + code);
+        return managersInProject(exactProjects.get(0), submitterId, code);
+    }
+
+    private List<String> managersInProject(Map project, String submitterId, String code) {
+        Map formData = (Map) project.get("formData");
+        List<Map> members = rows(formData.get(PROJECT_MEMBERS));
+        if (members.size() >= 50) {
+            Object instanceId = project.get("formInstanceId");
+            if (instanceId == null) instanceId = project.get("formInstId");
+            McException.assertAccessException(instanceId == null, "项目档案实例 ID 为空: " + code);
+            List<Map> full = ydService.queryDetails(YDParam.builder()
+                    .appType(conf.getYidaAppType()).systemToken(conf.getYidaSystemToken())
+                    .formUuid(conf.getFormUuidProject()).formInstanceId(String.valueOf(instanceId))
+                    .tableFieldId(PROJECT_MEMBERS).pageNumber(1).build());
+            McException.assertAccessException(full == null || full.size() < members.size(),
+                    "项目人员明细完整数据获取失败: " + code);
+            members = full;
+        }
+        Set<String> managerIds = new LinkedHashSet<>();
+        boolean foundMember = false;
+        for (Map member : members) {
+            if (!ids(member, PROJECT_MEMBER).contains(submitterId)) continue;
+            foundMember = true;
+            managerIds.addAll(ids(member, PROJECT_MANAGER));
+        }
+        McException.assertAccessException(!foundMember || managerIds.isEmpty(),
+                "项目人员明细未找到填报人或项目经理: " + code);
+        return new ArrayList<>(managerIds);
+    }
+
+    private static BigDecimal changedHours(Map row, String fieldId) {
+        Object value = row.get(fieldId);
+        if (value == null || StringUtils.isBlank(String.valueOf(value))) value = row.get(fieldId + "_value");
+        if (value == null || StringUtils.isBlank(String.valueOf(value))) return BigDecimal.ZERO;
+        try {
+            return new BigDecimal(String.valueOf(value));
+        } catch (NumberFormatException ex) {
+            throw new McException("INVALID_HOURS", "变更工时不是有效数字: " + fieldId);
+        }
+    }
+
+    private static String firstId(Map row, String fieldId) {
+        List<String> values = ids(row, fieldId);
+        return values.isEmpty() ? null : values.get(0);
+    }
+
+    private static List<String> ids(Map row, String fieldId) {
+        Object value = row.get(fieldId + "_id");
+        if (value == null) value = row.get(fieldId);
+        List values = value instanceof List ? (List) value : Collections.singletonList(value);
+        List<String> result = new ArrayList<>();
+        for (Object item : values) {
+            if (item instanceof Map) {
+                Map person = (Map) item;
+                for (String key : new String[]{"value", "userId", "key", "workNo", "employeeId"}) {
+                    Object id = person.get(key);
+                    if (id != null && StringUtils.isNotBlank(String.valueOf(id))) {
+                        result.add(String.valueOf(id));
+                        break;
+                    }
+                }
+            } else if (row.containsKey(fieldId + "_id") && item != null
+                    && StringUtils.isNotBlank(String.valueOf(item))) {
+                result.add(String.valueOf(item));
+            }
+        }
+        return result;
+    }
+
+    private static List<Map> rows(Object value) {
+        return value instanceof List ? (List<Map>) value : Collections.emptyList();
+    }
+}

+ 14 - 0
mjava-akdsbeisen/src/test/java/com/malk/controller/WorkHoursControllerTest.java

@@ -31,6 +31,20 @@ public class WorkHoursControllerTest {
         assertEquals("true", requestParam.defaultValue());
     }
 
+    @Test
+    public void changeProjectManagersShouldParseStandardJsonBody() {
+        assertEquals("6524abdd-44a7-47c9-8583-fe62ecb0c53b",
+                ApprovalController.parseProcessInstanceId(
+                        "{\"processInstanceId\":\"6524abdd-44a7-47c9-8583-fe62ecb0c53b\"}"));
+    }
+
+    @Test
+    public void changeProjectManagersShouldParseYidaConnectorBody() {
+        assertEquals("6524abdd-44a7-47c9-8583-fe62ecb0c53b",
+                ApprovalController.parseProcessInstanceId(
+                        "{processInstanceId:\"6524abdd-44a7-47c9-8583-fe62ecb0c53b\"}"));
+    }
+
     @Test
     public void cleanupAfterOfflineShouldDefaultToDryRun() throws Exception {
         Method method = WorkHoursController.class.getMethod("cleanupAfterOffline", boolean.class);

+ 143 - 0
mjava-akdsbeisen/src/test/java/com/malk/service/workhours/ChangeProjectManagerLookupServiceTest.java

@@ -0,0 +1,143 @@
+package com.malk.service.workhours;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+import com.malk.server.common.McException;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.server.workhours.WHConf;
+import com.malk.service.aliwork.YDClient;
+import com.malk.service.aliwork.YDService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class ChangeProjectManagerLookupServiceTest {
+
+    private YDClient ydClient;
+    private YDService ydService;
+    private ChangeProjectManagerLookupService service;
+
+    @BeforeEach
+    void setUp() {
+        ydClient = mock(YDClient.class);
+        ydService = mock(YDService.class);
+        WHConf conf = new WHConf();
+        conf.setYidaAppType("APP_TEST");
+        conf.setYidaSystemToken("test-token");
+        conf.setFormUuidProject("FORM_PROJECT");
+        service = new ChangeProjectManagerLookupService(ydClient, ydService, conf);
+    }
+
+    @Test
+    void mergesManagersFromChangedProjectsAndSkipsZeroHourRows() {
+        Map<String, Object> change = new HashMap<>();
+        change.put("employeeField_mmacxew4_id", Collections.singletonList("worker"));
+        change.put("employeeField_mmacxew4", Collections.singletonList("Worker"));
+        change.put("tableField_mmczo634", Arrays.asList(
+                row("textField_mmd25ngw", "P1", "numberField_mmd25ngz", 2),
+                row("textField_mmd25ngw", "P0", "numberField_mmd25ngz", 0)));
+        change.put("tableField_mmecojwk", Collections.singletonList(
+                row("textField_mmecojwe", "P2", "numberField_mmecojwh", -1)));
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_process_id)))
+                .thenReturn(process(change));
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenAnswer(invocation -> {
+                    YDParam param = invocation.getArgument(0);
+                    String code = JSON.parseObject(param.getSearchFieldJson()).getString("textField_mkov5ozb");
+                    if ("P1".equals(code)) return project("P1", "worker", "manager-a");
+                    if ("P2".equals(code)) return project("P2", "worker", "manager-a", "manager-b");
+                    throw new AssertionError("不应查询 0 工时项目: " + code);
+                });
+
+        assertEquals(Arrays.asList("manager-a", "manager-b"), service.lookup("process-1"));
+    }
+
+    @Test
+    void readsMemberBeyondInlineFiftyRows() {
+        Map<String, Object> change = new HashMap<>();
+        change.put("employeeField_mmacxew4_id", Collections.singletonList("worker"));
+        change.put("employeeField_mmacxew4", Collections.singletonList("Worker"));
+        change.put("tableField_mmczo634", Collections.singletonList(
+                row("textField_mmd25ngw", "P1", "numberField_mmd25ngz", 3)));
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_process_id)))
+                .thenReturn(process(change));
+        DDR_New project = project("P1", "other", "manager-other");
+        Map projectRow = (Map) ((List) project.getData()).get(0);
+        Map projectData = (Map) projectRow.get("formData");
+        List<Map> inline = new ArrayList<>();
+        for (int i = 0; i < 50; i++) inline.add(member("other-" + i, "manager-other"));
+        projectData.put("tableField_mkowyn6d", inline);
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(project);
+        List<Map> full = new ArrayList<>(inline);
+        full.add(member("worker", "manager-a"));
+        when(ydService.queryDetails(any(YDParam.class))).thenReturn(full);
+
+        assertEquals(Collections.singletonList("manager-a"), service.lookup("process-2"));
+        verify(ydService).queryDetails(any(YDParam.class));
+    }
+
+    @Test
+    void rejectsChangedProjectWithoutSubmitterMember() {
+        Map<String, Object> change = new HashMap<>();
+        change.put("employeeField_mmacxew4_id", Collections.singletonList("worker"));
+        change.put("employeeField_mmacxew4", Collections.singletonList("Worker"));
+        change.put("tableField_mmczo634", Collections.singletonList(
+                row("textField_mmd25ngw", "P1", "numberField_mmd25ngz", 1)));
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_process_id)))
+                .thenReturn(process(change));
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(project("P1", "other", "manager-other"));
+
+        assertThrows(McException.class, () -> service.lookup("process-3"));
+    }
+
+    private static DDR_New process(Map<String, Object> data) {
+        DDR_New result = new DDR_New();
+        result.setData(data);
+        return result;
+    }
+
+    private static DDR_New project(String code, String memberId, String... managerIds) {
+        Map<String, Object> formData = new HashMap<>();
+        formData.put("textField_mkov5ozb", code);
+        formData.put("tableField_mkowyn6d", Collections.singletonList(member(memberId, managerIds)));
+        Map<String, Object> item = new HashMap<>();
+        item.put("formInstanceId", "FINST-1");
+        item.put("formData", formData);
+        DDR_New result = new DDR_New();
+        result.setData(Collections.singletonList(item));
+        result.setTotalCount(1);
+        return result;
+    }
+
+    private static Map<String, Object> member(String memberId, String... managerIds) {
+        Map<String, Object> row = new HashMap<>();
+        row.put("employeeField_mmbfe0ij_id", Collections.singletonList(memberId));
+        row.put("employeeField_mkoxpswf_id", Arrays.asList(managerIds));
+        return row;
+    }
+
+    private static Map<String, Object> row(String firstKey, Object firstValue,
+                                           String secondKey, Object secondValue) {
+        Map<String, Object> row = new HashMap<>();
+        row.put(firstKey, firstValue);
+        row.put(secondKey, secondValue);
+        return row;
+    }
+}