2 Комити 4b50f6a127 ... 0b55cb7aef

Аутор SHA1 Порука Датум
  malk 0b55cb7aef fix(workhours): 归属公司字段ID纠错 + delete-all bug 修复 + 新增分级验证入口 пре 1 недеља
  malk 75bf88694f feat(personnel-sync): 钉钉→宜搭人员档案单向同步 пре 2 недеља

+ 2 - 0
mjava-akdsbeisen/src/main/java/com/malk/BeisenApplication.java

@@ -20,10 +20,12 @@ import org.springframework.scheduling.annotation.EnableScheduling;
     "com.malk.service.aliwork",
     "com.malk.service.aliwork",
     "com.malk.service.dingtalk",
     "com.malk.service.dingtalk",
     "com.malk.service.workhours",
     "com.malk.service.workhours",
+    "com.malk.service.personnel",
     "com.malk.server.beisen",
     "com.malk.server.beisen",
     "com.malk.server.aliwork",
     "com.malk.server.aliwork",
     "com.malk.server.dingtalk",
     "com.malk.server.dingtalk",
     "com.malk.server.workhours",
     "com.malk.server.workhours",
+    "com.malk.server.personnel",
     "com.malk.server.common",
     "com.malk.server.common",
     "com.malk.controller",
     "com.malk.controller",
     "com.malk.delegate",
     "com.malk.delegate",

+ 133 - 0
mjava-akdsbeisen/src/main/java/com/malk/controller/PersonnelSyncController.java

@@ -0,0 +1,133 @@
+package com.malk.controller;
+
+import com.malk.service.personnel.PersonnelSyncService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 钉钉 -> 宜搭 人员档案同步接口
+ */
+@Slf4j
+@RestController
+@RequestMapping("/personnel-sync")
+public class PersonnelSyncController {
+
+    @Autowired
+    private PersonnelSyncService personnelSyncService;
+
+    /**
+     * 全量同步 POST /personnel-sync/full
+     */
+    @PostMapping("/full")
+    public Map<String, Object> full() {
+        Map<String, Object> result = new LinkedHashMap<>();
+        try {
+            Map<String, Object> stats = personnelSyncService.fullSync();
+            result.put("success", true);
+            result.put("stats", stats);
+        } catch (Exception e) {
+            log.error("[PersonnelSync] 全量同步失败", e);
+            result.put("success", false);
+            result.put("message", e.getMessage());
+        }
+        return result;
+    }
+
+    /**
+     * 干跑 GET /personnel-sync/dry-run
+     */
+    @GetMapping("/dry-run")
+    public Map<String, Object> dryRun() {
+        Map<String, Object> result = new LinkedHashMap<>();
+        try {
+            result.put("success", true);
+            result.put("stats", personnelSyncService.dryRun());
+        } catch (Exception e) {
+            log.error("[PersonnelSync] dry-run 失败", e);
+            result.put("success", false);
+            result.put("message", e.getMessage());
+        }
+        return result;
+    }
+
+    /**
+     * 单人 probe GET /personnel-sync/probe-single?userid=xxx
+     * 验证 topapi/v2/user/get 单人接口是否返回 extattr
+     */
+    @GetMapping("/probe-single")
+    public Map<String, Object> probeSingle(@RequestParam String userid) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        try {
+            result.put("success", true);
+            result.put("data", personnelSyncService.probeSingleUser(userid));
+        } catch (Exception e) {
+            log.error("[PersonnelSync] probe-single 失败", e);
+            result.put("success", false);
+            result.put("message", e.getMessage());
+        }
+        return result;
+    }
+
+    /**
+     * Diff 调试 GET /personnel-sync/probe-diff?userid=xxx
+     */
+    @GetMapping("/probe-diff")
+    public Map<String, Object> probeDiff(@RequestParam String userid) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        try {
+            com.malk.service.personnel.impl.PersonnelSyncServiceImpl impl =
+                    (com.malk.service.personnel.impl.PersonnelSyncServiceImpl) personnelSyncService;
+            result.put("success", true);
+            result.put("data", impl.probeDiff(userid));
+        } catch (Exception e) {
+            log.error("[PersonnelSync] probe-diff 失败", e);
+            result.put("success", false);
+            result.put("message", e.getMessage());
+        }
+        return result;
+    }
+
+    /**
+     * 全量统计 GET /personnel-sync/probe-stats
+     * 统计钉钉侧数据完整度 (active/dept/extattr/job_number 分布)
+     */
+    @GetMapping("/probe-stats")
+    public Map<String, Object> probeStats() {
+        Map<String, Object> result = new LinkedHashMap<>();
+        try {
+            result.put("success", true);
+            result.put("data", personnelSyncService.probeStats());
+        } catch (Exception e) {
+            log.error("[PersonnelSync] probe-stats 失败", e);
+            result.put("success", false);
+            result.put("message", e.getMessage());
+        }
+        return result;
+    }
+
+    /**
+     * Phase 0 探针 GET /personnel-sync/probe?sample=3
+     * 拉取钉钉全量员工并返回抽样 方便人工确认 extattr 真实结构
+     */
+    @GetMapping("/probe")
+    public Map<String, Object> probe(@RequestParam(defaultValue = "3") int sample) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        try {
+            result.put("success", true);
+            result.put("data", personnelSyncService.probeDingtalkUsers(sample));
+        } catch (Exception e) {
+            log.error("[PersonnelSync] probe 失败", e);
+            result.put("success", false);
+            result.put("message", e.getMessage());
+        }
+        return result;
+    }
+}

+ 54 - 0
mjava-akdsbeisen/src/main/java/com/malk/controller/WorkHoursController.java

@@ -45,6 +45,60 @@ public class WorkHoursController {
         return result;
         return result;
     }
     }
 
 
+    /**
+     * 单员工单日验证写入(清空后做一条验证,确认归属公司等字段正确带入)
+     * GET /workhours/sync-one?userId=xxx&date=2026-04-22
+     */
+    @GetMapping("/sync-one")
+    public Map<String, Object> syncOne(@RequestParam String userId,
+                                       @RequestParam String date) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        try {
+            long start = System.currentTimeMillis();
+            LocalDate workDay = LocalDate.parse(date);
+            Map<String, Object> data = workHoursCalcService.syncOneEmployeeOneDay(userId, workDay);
+            long cost = System.currentTimeMillis() - start;
+            Object ok = data.get("success");
+            result.put("success", Boolean.TRUE.equals(ok));
+            result.put("message", Boolean.TRUE.equals(ok) ? "单条验证写入完成" : "单条验证写入失败");
+            result.put("data", data);
+            result.put("costMs", cost);
+        } catch (Exception e) {
+            log.error("单条验证失败", e);
+            result.put("success", false);
+            result.put("message", e.getMessage());
+        }
+        return result;
+    }
+
+    /**
+     * 小批量同步:前 N 个内部员工 × 当月工作日(默认 count=5)
+     * GET /workhours/sync-batch?count=5&month=2026-04
+     */
+    @GetMapping("/sync-batch")
+    public Map<String, Object> syncBatch(@RequestParam(defaultValue = "5") int count,
+                                         @RequestParam(required = false) String month) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        try {
+            LocalDate targetMonth = null;
+            if (month != null && !month.isEmpty()) {
+                targetMonth = LocalDate.parse(month + "-01");
+            }
+            long start = System.currentTimeMillis();
+            Map<String, Object> stats = workHoursCalcService.syncBatchEmployees(count, targetMonth);
+            long cost = System.currentTimeMillis() - start;
+            result.put("success", true);
+            result.put("message", "小批量同步完成");
+            result.put("stats", stats);
+            result.put("costMs", cost);
+        } catch (Exception e) {
+            log.error("小批量同步失败", e);
+            result.put("success", false);
+            result.put("message", e.getMessage());
+        }
+        return result;
+    }
+
     /**
     /**
      * 全量同步当月应填报工时
      * 全量同步当月应填报工时
      * GET /workhours/sync?month=2026-04
      * GET /workhours/sync?month=2026-04

+ 43 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/personnel/PersonnelSyncConf.java

@@ -0,0 +1,43 @@
+package com.malk.server.personnel;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "personnel-sync")
+public class PersonnelSyncConf {
+
+    private String yidaAppType;
+    private String yidaSystemToken;
+
+    // 验证阶段指向测试表 formUuid 上线切换为生产 formUuid
+    private String formUuidPersonnel;
+
+    // 字段 ID 集合 (测试版/生产版按环境切换)
+    private String fieldEmployee;
+    private String fieldUserType;
+    private String fieldJobNumber;
+    private String fieldDepartment;
+    private String fieldStatus;
+    private String fieldHiredDate;
+
+    // extattr 属性判定: 钉钉 extattr 中用于标记内部/外部的 key
+    private String extAttrKeyUserType;
+    private String extAttrValueInternal = "内部";
+    private String extAttrValueExternal = "外部";
+
+    // 属性判定兜底: 当 extattr 读不到时 按部门 ID 白/黑名单判定
+    // externalDeptIds 里的部门视为"外部" 其它视为"内部"; 均为空则不写属性字段
+    private java.util.List<Long> externalDeptIds = new java.util.ArrayList<>();
+    private boolean fallbackInternalByDefault = false;
+
+    // 状态字段取值
+    private String statusValueActive = "在职";
+    private String statusValueInactive = "离职";
+
+    // 并发与重试
+    private int concurrency = 10;
+    private int maxRetry = 2;
+}

+ 41 - 0
mjava-akdsbeisen/src/main/java/com/malk/service/personnel/PersonnelSyncService.java

@@ -0,0 +1,41 @@
+package com.malk.service.personnel;
+
+import java.util.List;
+import java.util.Map;
+
+public interface PersonnelSyncService {
+
+    /**
+     * 全量同步: 钉钉 -> 宜搭人员档案
+     *
+     * @return 统计 {fetched, created, updated, markedInactive, skipped, failed, durationMs}
+     */
+    Map<String, Object> fullSync();
+
+    /**
+     * 干跑: 只计算 diff 不写入
+     *
+     * @return {fetched, actions:{create,update,markOff,skip}, durationMs}
+     */
+    Map<String, Object> dryRun();
+
+    /**
+     * Phase 0 探针: 返回钉钉全量员工抽样 [total, deptCount, durationMs, sample]
+     */
+    Map<String, Object> probeDingtalkUsers(int sampleSize);
+
+    /**
+     * 直接返回钉钉全量员工 (debug 用)
+     */
+    List<Map> fetchAllDingUsers();
+
+    /**
+     * 单人详情 probe: 用 topapi/v2/user/get 看 extattr 是否返回
+     */
+    Map probeSingleUser(String userid);
+
+    /**
+     * 统计钉钉侧数据完整度 (active/dept_id_list/extattr 分布)
+     */
+    Map<String, Object> probeStats();
+}

+ 553 - 0
mjava-akdsbeisen/src/main/java/com/malk/service/personnel/impl/PersonnelSyncServiceImpl.java

@@ -0,0 +1,553 @@
+package com.malk.service.personnel.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.server.personnel.PersonnelSyncConf;
+import com.malk.service.aliwork.YDClient;
+import com.malk.service.dingtalk.DDClient;
+import com.malk.service.dingtalk.DDClient_Contacts;
+import com.malk.service.personnel.PersonnelSyncService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+@Slf4j
+@Service
+public class PersonnelSyncServiceImpl implements PersonnelSyncService {
+
+    @Autowired
+    private YDClient ydClient;
+
+    @Autowired
+    private DDClient ddClient;
+
+    @Autowired
+    private DDClient_Contacts ddClient_contacts;
+
+    @Autowired
+    private PersonnelSyncConf conf;
+
+    private static final String ACTION_CREATE = "CREATE";
+    private static final String ACTION_UPDATE = "UPDATE";
+    private static final String ACTION_MARK_OFF = "MARK_OFF";
+
+    @Override
+    public Map<String, Object> fullSync() {
+        long start = System.currentTimeMillis();
+        log.info("[PersonnelSync] 全量同步开始");
+
+        List<Map> dingUsers = fetchAllDingUsers();
+        Map<String, Map> dingUserMap = indexByUserid(dingUsers);
+        log.info("[PersonnelSync] 钉钉全量拉取 {} 人 (去重后)", dingUserMap.size());
+
+        Map<String, YidaRecord> yidaMap = fetchAllYidaPersonnel();
+        log.info("[PersonnelSync] 宜搭人员档案 {} 条", yidaMap.size());
+
+        List<Action> actions = diff(dingUserMap, yidaMap);
+        Map<String, Long> actionStats = countActions(actions);
+        log.info("[PersonnelSync] diff 完成: create={}, update={}, markOff={}, skip={}",
+                actionStats.getOrDefault(ACTION_CREATE, 0L),
+                actionStats.getOrDefault(ACTION_UPDATE, 0L),
+                actionStats.getOrDefault(ACTION_MARK_OFF, 0L),
+                (long) dingUserMap.size() + yidaMap.size() - actions.size());
+
+        WriteStats writeStats = concurrentWrite(actions);
+
+        long cost = System.currentTimeMillis() - start;
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("fetched", dingUserMap.size());
+        result.put("yidaExisting", yidaMap.size());
+        result.put("created", writeStats.created.get());
+        result.put("updated", writeStats.updated.get());
+        result.put("markedInactive", writeStats.markedInactive.get());
+        result.put("failed", writeStats.failed.get());
+        result.put("durationMs", cost);
+        log.info("[PersonnelSync] 全量同步完成 {}", result);
+        return result;
+    }
+
+    @Override
+    public Map<String, Object> dryRun() {
+        long start = System.currentTimeMillis();
+        List<Map> dingUsers = fetchAllDingUsers();
+        Map<String, Map> dingUserMap = indexByUserid(dingUsers);
+        Map<String, YidaRecord> yidaMap = fetchAllYidaPersonnel();
+        List<Action> actions = diff(dingUserMap, yidaMap);
+
+        Map<String, Long> stats = countActions(actions);
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("fetched", dingUserMap.size());
+        result.put("yidaExisting", yidaMap.size());
+        Map<String, Object> actionCounts = new LinkedHashMap<>();
+        actionCounts.put("create", stats.getOrDefault(ACTION_CREATE, 0L));
+        actionCounts.put("update", stats.getOrDefault(ACTION_UPDATE, 0L));
+        actionCounts.put("markOff", stats.getOrDefault(ACTION_MARK_OFF, 0L));
+        result.put("actions", actionCounts);
+        result.put("durationMs", System.currentTimeMillis() - start);
+        // 抽样 10 条展示预期动作
+        List<Map<String, Object>> sample = new ArrayList<>();
+        for (int i = 0; i < Math.min(10, actions.size()); i++) {
+            Action a = actions.get(i);
+            Map<String, Object> s = new LinkedHashMap<>();
+            s.put("action", a.type);
+            s.put("userid", a.userid);
+            s.put("formData", a.formData);
+            sample.add(s);
+        }
+        result.put("sample", sample);
+        return result;
+    }
+
+    @Override
+    public Map<String, Object> probeDingtalkUsers(int sampleSize) {
+        long start = System.currentTimeMillis();
+        String token = ddClient.getAccessToken();
+        List<Long> deptIds = ddClient_contacts.getDepartmentId_all(token, true);
+        List<Map> users = ddClient_contacts.getAllUserDetails(token, true);
+        Map<String, Map> byUserid = indexByUserid(users);
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("total", byUserid.size());
+        result.put("deptCount", deptIds.size());
+        result.put("durationMs", System.currentTimeMillis() - start);
+
+        List<Map> sample = new ArrayList<>();
+        int n = Math.min(sampleSize <= 0 ? 3 : sampleSize, users.size());
+        for (int i = 0; i < n; i++) sample.add(users.get(i));
+        result.put("sample", sample);
+        return result;
+    }
+
+    @Override
+    public List<Map> fetchAllDingUsers() {
+        String token = ddClient.getAccessToken();
+        return ddClient_contacts.getAllUserDetails(token, true);
+    }
+
+    @Override
+    public Map probeSingleUser(String userid) {
+        String token = ddClient.getAccessToken();
+        return ddClient_contacts.getUserInfoById(token, userid);
+    }
+
+    @SuppressWarnings("unchecked")
+    public Map<String, Object> probeDiff(String userid) {
+        Map<String, Object> r = new LinkedHashMap<>();
+        // 钉钉
+        String token = ddClient.getAccessToken();
+        Map ding = ddClient_contacts.getUserInfoById(token, userid);
+        r.put("dingUser", ding);
+        // 宜搭
+        DDR_New result = ydClient.queryData(YDParam.builder()
+                .appType(conf.getYidaAppType())
+                .systemToken(conf.getYidaSystemToken())
+                .formUuid(conf.getFormUuidPersonnel())
+                .searchFieldJson("{\"" + conf.getFieldEmployee() + "\":\"" + userid + "\"}")
+                .build(), YDConf.FORM_QUERY.retrieve_search_form);
+        List<Map> list = (List<Map>) result.getData();
+        Map<String, Object> yidaFormData = (list != null && !list.isEmpty()) ? (Map<String, Object>) list.get(0).get("formData") : null;
+        r.put("yidaFormData", yidaFormData);
+        // 新 formData
+        Map<String, Object> newData = toYidaFormData(userid, ding, isActive(ding) ? ACTION_UPDATE : ACTION_MARK_OFF);
+        r.put("newFormData", newData);
+        // 字段对比
+        Map<String, Object> diff = new LinkedHashMap<>();
+        if (yidaFormData != null) {
+            for (Map.Entry<String, Object> e : newData.entrySet()) {
+                String f = e.getKey();
+                Object nv = e.getValue();
+                Object ov = yidaFormData.get(f);
+                Object ovId = yidaFormData.get(f + "_id");
+                Map<String, Object> d = new LinkedHashMap<>();
+                d.put("new", nv);
+                d.put("newClass", nv == null ? null : nv.getClass().getSimpleName());
+                d.put("old", ov);
+                d.put("oldClass", ov == null ? null : ov.getClass().getSimpleName());
+                d.put("oldId", ovId);
+                d.put("oldIdClass", ovId == null ? null : ovId.getClass().getSimpleName());
+                diff.put(f, d);
+            }
+        }
+        r.put("diff", diff);
+        return r;
+    }
+
+    @Override
+    public Map<String, Object> probeStats() {
+        long start = System.currentTimeMillis();
+        List<Map> users = fetchAllDingUsers();
+        int total = users.size();
+        int active = 0, inactive = 0, emptyDept = 0, hasExtattr = 0, emptyJobNumber = 0;
+        for (Map u : users) {
+            if (isActive(u)) active++; else inactive++;
+            Object dept = u.get("dept_id_list");
+            if (!(dept instanceof List) || ((List) dept).isEmpty()) emptyDept++;
+            Object ext = u.get("extattr");
+            if (ext instanceof Map && !((Map) ext).isEmpty()) hasExtattr++;
+            Object job = u.get("job_number");
+            if (job == null || String.valueOf(job).isEmpty()) emptyJobNumber++;
+        }
+        Map<String, Object> res = new LinkedHashMap<>();
+        res.put("total", total);
+        res.put("active", active);
+        res.put("inactive", inactive);
+        res.put("emptyDeptIdList", emptyDept);
+        res.put("hasExtattr", hasExtattr);
+        res.put("emptyJobNumber", emptyJobNumber);
+        res.put("durationMs", System.currentTimeMillis() - start);
+        return res;
+    }
+
+    // ==================== 内部: 数据抓取 ====================
+
+    @SuppressWarnings("unchecked")
+    private Map<String, YidaRecord> fetchAllYidaPersonnel() {
+        Map<String, YidaRecord> yidaMap = new LinkedHashMap<>();
+        int currentPage = 1;
+        long totalCount;
+        do {
+            DDR_New result = ydClient.queryData(YDParam.builder()
+                    .appType(conf.getYidaAppType())
+                    .systemToken(conf.getYidaSystemToken())
+                    .formUuid(conf.getFormUuidPersonnel())
+                    .currentPage(currentPage)
+                    .pageSize(YDConf.PAGE_SIZE_LIMIT)
+                    .build(), YDConf.FORM_QUERY.retrieve_search_form);
+            totalCount = result.getTotalCount();
+            List<Map> dataList = (List<Map>) result.getData();
+            if (dataList == null || dataList.isEmpty()) break;
+
+            for (Map item : dataList) {
+                Map<String, Object> formData = (Map<String, Object>) item.get("formData");
+                if (formData == null) continue;
+                String userid = extractEmployeeId(formData, conf.getFieldEmployee());
+                if (userid == null || userid.isEmpty()) continue;
+                YidaRecord rec = new YidaRecord();
+                rec.instanceId = String.valueOf(item.get("formInstanceId"));
+                rec.formData = formData;
+                yidaMap.put(userid, rec);
+            }
+            currentPage++;
+        } while ((long) (currentPage - 1) * YDConf.PAGE_SIZE_LIMIT < totalCount);
+        return yidaMap;
+    }
+
+    // ==================== 内部: 差异计算 ====================
+
+    private List<Action> diff(Map<String, Map> dingUserMap, Map<String, YidaRecord> yidaMap) {
+        List<Action> actions = new ArrayList<>();
+
+        for (Map.Entry<String, Map> e : dingUserMap.entrySet()) {
+            String userid = e.getKey();
+            Map ding = e.getValue();
+            YidaRecord yida = yidaMap.get(userid);
+            boolean active = isActive(ding);
+
+            if (yida == null) {
+                Map<String, Object> formData = toYidaFormData(userid, ding, active ? ACTION_CREATE : ACTION_MARK_OFF);
+                actions.add(new Action(ACTION_CREATE, userid, null, formData));
+            } else {
+                String actionType = active ? ACTION_UPDATE : ACTION_MARK_OFF;
+                Map<String, Object> formData = toYidaFormData(userid, ding, actionType);
+                if (isSameAsYida(formData, yida.formData)) {
+                    // 幂等跳过 (UPDATE/MARK_OFF 都适用)
+                    continue;
+                }
+                actions.add(new Action(actionType, userid, yida.instanceId, formData));
+            }
+        }
+
+        // 钉钉已无, 宜搭还有 -> 标记离职
+        for (Map.Entry<String, YidaRecord> e : yidaMap.entrySet()) {
+            if (dingUserMap.containsKey(e.getKey())) continue;
+            YidaRecord yida = e.getValue();
+            // 已是离职状态则跳过
+            Object currentStatus = yida.formData.get(conf.getFieldStatus());
+            if (conf.getStatusValueInactive().equals(String.valueOf(currentStatus))) continue;
+            Map<String, Object> formData = new LinkedHashMap<>();
+            formData.put(conf.getFieldStatus(), conf.getStatusValueInactive());
+            actions.add(new Action(ACTION_MARK_OFF, e.getKey(), yida.instanceId, formData));
+        }
+        return actions;
+    }
+
+    // ==================== 内部: 字段映射 ====================
+
+    @SuppressWarnings("unchecked")
+    private Map<String, Object> toYidaFormData(String userid, Map ding, String action) {
+        Map<String, Object> formData = new LinkedHashMap<>();
+
+        // 人员 (唯一键, 永远写入)
+        formData.put(conf.getFieldEmployee(), Collections.singletonList(userid));
+
+        // 在职状态
+        String statusValue = ACTION_MARK_OFF.equals(action) ? conf.getStatusValueInactive() : conf.getStatusValueActive();
+        formData.put(conf.getFieldStatus(), statusValue);
+
+        // 离职软标记只更新状态字段, 保留其他原值
+        if (ACTION_MARK_OFF.equals(action)) return formData;
+
+        // 员工编号 <- job_number
+        Object jobNumber = ding.get("job_number");
+        if (jobNumber != null && !String.valueOf(jobNumber).isEmpty()) {
+            formData.put(conf.getFieldJobNumber(), String.valueOf(jobNumber));
+        }
+
+        // 员工部门 <- dept_id_list 稳定排序后取首个 (钉钉返回顺序不固定, 避免 diff 抖动)
+        Object deptObj = ding.get("dept_id_list");
+        if (deptObj instanceof List && !((List) deptObj).isEmpty()) {
+            List<Long> sorted = new ArrayList<>();
+            for (Object d : (List) deptObj) {
+                if (d != null) sorted.add(((Number) d).longValue());
+            }
+            Collections.sort(sorted);
+            if (!sorted.isEmpty()) {
+                formData.put(conf.getFieldDepartment(), Collections.singletonList(String.valueOf(sorted.get(0))));
+            }
+        }
+
+        // 入职时间 <- hired_date (毫秒时间戳, 需要钉钉花名册权限才返回)
+        if (conf.getFieldHiredDate() != null && !conf.getFieldHiredDate().isEmpty()) {
+            Object hired = ding.get("hired_date");
+            if (hired instanceof Number) {
+                formData.put(conf.getFieldHiredDate(), ((Number) hired).longValue());
+            }
+        }
+
+        // 人员属性 <- extattr.<key> 或 部门白名单兜底
+        String userType = resolveUserType(ding);
+        if (userType != null) {
+            formData.put(conf.getFieldUserType(), userType);
+        }
+
+        return formData;
+    }
+
+    @SuppressWarnings("unchecked")
+    private String resolveUserType(Map ding) {
+        // 1. 优先读 extattr
+        String key = conf.getExtAttrKeyUserType();
+        if (key != null && !key.isEmpty()) {
+            Object extattrObj = ding.get("extattr");
+            if (extattrObj instanceof Map) {
+                Map extattr = (Map) extattrObj;
+                Object attr = extattr.get(key);
+                String raw = null;
+                if (attr instanceof Map) {
+                    Map am = (Map) attr;
+                    Object value = am.get("value");
+                    if (value == null) value = am.get("text");
+                    if (value != null) raw = String.valueOf(value);
+                } else if (attr != null) {
+                    raw = String.valueOf(attr);
+                }
+                if (raw != null && !raw.isEmpty()) {
+                    if (conf.getExtAttrValueInternal().equals(raw)) return conf.getExtAttrValueInternal();
+                    if (conf.getExtAttrValueExternal().equals(raw)) return conf.getExtAttrValueExternal();
+                }
+            }
+        }
+        // 2. 兜底: 部门白名单 (外部部门列表 + 默认规则)
+        List<Long> externalDepts = conf.getExternalDeptIds();
+        Object deptObj = ding.get("dept_id_list");
+        if (deptObj instanceof List && !((List) deptObj).isEmpty()) {
+            if (externalDepts != null && !externalDepts.isEmpty()) {
+                boolean isExternal = false;
+                for (Object d : (List) deptObj) {
+                    if (d == null) continue;
+                    long deptId = ((Number) d).longValue();
+                    if (externalDepts.contains(deptId)) { isExternal = true; break; }
+                }
+                return isExternal ? conf.getExtAttrValueExternal() : conf.getExtAttrValueInternal();
+            }
+            if (conf.isFallbackInternalByDefault()) {
+                return conf.getExtAttrValueInternal();
+            }
+        }
+        return null;
+    }
+
+    private boolean isActive(Map ding) {
+        Object active = ding.get("active");
+        if (active instanceof Boolean) return (Boolean) active;
+        if (active == null) return true;
+        return Boolean.parseBoolean(String.valueOf(active));
+    }
+
+    // 比较钉钉侧构造的 formData 与宜搭已有 formData 是否所有字段都相等
+    @SuppressWarnings("unchecked")
+    private boolean isSameAsYida(Map<String, Object> newData, Map<String, Object> yidaData) {
+        for (Map.Entry<String, Object> e : newData.entrySet()) {
+            String fieldId = e.getKey();
+            Object newVal = e.getValue();
+            Object oldVal = yidaData.get(fieldId);
+            if (newVal instanceof List) {
+                // 成员/部门类字段: 宜搭返回时用 _id 后缀取 id 列表
+                Object oldIdList = yidaData.get(fieldId + "_id");
+                if (oldIdList != null) oldVal = oldIdList;
+                if (!listEquals((List) newVal, oldVal)) return false;
+            } else {
+                if (!Objects.equals(String.valueOf(newVal), String.valueOf(oldVal))) return false;
+            }
+        }
+        return true;
+    }
+
+    @SuppressWarnings("unchecked")
+    private boolean listEquals(List newList, Object oldObj) {
+        List<String> newStr = new ArrayList<>();
+        for (Object o : newList) newStr.add(String.valueOf(o));
+        List<String> oldStr = new ArrayList<>();
+        if (oldObj instanceof List) {
+            for (Object o : (List) oldObj) oldStr.add(String.valueOf(o));
+        } else if (oldObj != null) {
+            oldStr.add(String.valueOf(oldObj));
+        }
+        if (newStr.size() != oldStr.size()) return false;
+        for (String s : newStr) if (!oldStr.contains(s)) return false;
+        return true;
+    }
+
+    // ==================== 内部: 写入 ====================
+
+    private WriteStats concurrentWrite(List<Action> actions) {
+        WriteStats stats = new WriteStats();
+        if (actions.isEmpty()) return stats;
+
+        ExecutorService pool = Executors.newFixedThreadPool(Math.max(1, conf.getConcurrency()));
+        List<Future<?>> futures = new ArrayList<>();
+        for (Action a : actions) {
+            futures.add(pool.submit(() -> executeAction(a, stats)));
+        }
+        for (Future<?> f : futures) {
+            try { f.get(); } catch (Exception ex) { log.warn("[PersonnelSync] future 异常", ex); }
+        }
+        pool.shutdown();
+        try { pool.awaitTermination(10, TimeUnit.MINUTES); } catch (InterruptedException ignored) {}
+        return stats;
+    }
+
+    private void executeAction(Action a, WriteStats stats) {
+        int attempt = 0;
+        while (true) {
+            try {
+                if (ACTION_CREATE.equals(a.type)) {
+                    ydClient.operateData(YDParam.builder()
+                            .appType(conf.getYidaAppType())
+                            .systemToken(conf.getYidaSystemToken())
+                            .formUuid(conf.getFormUuidPersonnel())
+                            .formDataJson(JSON.toJSONString(a.formData))
+                            .build(), YDConf.FORM_OPERATION.create);
+                    stats.created.incrementAndGet();
+                } else {
+                    ydClient.operateData(YDParam.builder()
+                            .appType(conf.getYidaAppType())
+                            .systemToken(conf.getYidaSystemToken())
+                            .formUuid(conf.getFormUuidPersonnel())
+                            .formInstanceId(a.instanceId)
+                            .updateFormDataJson(JSON.toJSONString(a.formData))
+                            .ignoreEmpty(false)
+                            .useLatestVersion(true)
+                            .build(), YDConf.FORM_OPERATION.update);
+                    if (ACTION_MARK_OFF.equals(a.type)) {
+                        stats.markedInactive.incrementAndGet();
+                    } else {
+                        stats.updated.incrementAndGet();
+                    }
+                }
+                return;
+            } catch (Exception ex) {
+                attempt++;
+                if (attempt > conf.getMaxRetry()) {
+                    log.warn("[PersonnelSync] 写入失败 userid={} action={} err={}", a.userid, a.type, ex.getMessage());
+                    stats.failed.incrementAndGet();
+                    return;
+                }
+            }
+        }
+    }
+
+    // ==================== 内部: 工具 ====================
+
+    @SuppressWarnings("unchecked")
+    private String extractEmployeeId(Map<String, Object> formData, String fieldId) {
+        Object raw = formData.get(fieldId + "_id");
+        if (raw == null) raw = formData.get(fieldId);
+        if (raw == null) return null;
+        if (raw instanceof List) {
+            List list = (List) raw;
+            return list.isEmpty() ? null : String.valueOf(list.get(0));
+        }
+        String str = String.valueOf(raw).trim();
+        if (str.startsWith("[") && str.endsWith("]")) {
+            Object parsed = JSON.parse(str);
+            if (parsed instanceof List) {
+                List pl = (List) parsed;
+                return pl.isEmpty() ? null : String.valueOf(pl.get(0));
+            }
+        }
+        return str.isEmpty() ? null : str;
+    }
+
+    private Map<String, Map> indexByUserid(List<Map> users) {
+        Map<String, Map> map = new LinkedHashMap<>();
+        for (Map u : users) {
+            Object uid = u.get("userid");
+            if (uid != null) map.putIfAbsent(String.valueOf(uid), u);
+        }
+        return map;
+    }
+
+    private Map<String, Long> countActions(List<Action> actions) {
+        Map<String, Long> stats = new HashMap<>();
+        for (Action a : actions) {
+            stats.merge(a.type, 1L, Long::sum);
+        }
+        return stats;
+    }
+
+    // ==================== 内部: 数据结构 ====================
+
+    private static class YidaRecord {
+        String instanceId;
+        Map<String, Object> formData;
+    }
+
+    private static class Action {
+        final String type;
+        final String userid;
+        final String instanceId;
+        final Map<String, Object> formData;
+
+        Action(String type, String userid, String instanceId, Map<String, Object> formData) {
+            this.type = type;
+            this.userid = userid;
+            this.instanceId = instanceId;
+            this.formData = formData;
+        }
+    }
+
+    private static class WriteStats {
+        AtomicInteger created = new AtomicInteger();
+        AtomicInteger updated = new AtomicInteger();
+        AtomicInteger markedInactive = new AtomicInteger();
+        AtomicInteger failed = new AtomicInteger();
+    }
+}

+ 134 - 4
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/WorkHoursCalcService.java

@@ -129,6 +129,130 @@ public class WorkHoursCalcService {
                 counts[0], counts[1], personnelMap.size(), workingDays.size());
                 counts[0], counts[1], personnelMap.size(), workingDays.size());
     }
     }
 
 
+    /**
+     * 单员工单日 upsert 验证入口(清空后做一条验证用,非日常流程)
+     * ppExt: 复用 queryAllPersonnelDetails + 钉钉主管查询 + upsertDailyHours;
+     *        返回体回显 personnelInfo(含 textField_mmekrcji 归属公司)供核对
+     *
+     * @param userId  员工钉钉 userId
+     * @param workDay 目标日期
+     * @return Map{userId, workDay, personnelInfo, managerId, success, error?}
+     */
+    public Map<String, Object> syncOneEmployeeOneDay(String userId, LocalDate workDay) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("userId", userId);
+        result.put("workDay", workDay == null ? null : workDay.toString());
+
+        if (userId == null || userId.isEmpty() || workDay == null) {
+            result.put("success", false);
+            result.put("error", "userId 和 workDay 不能为空");
+            return result;
+        }
+
+        // 1. 全量查人员档案,取目标员工信息
+        Map<String, Map<String, Object>> personnelMap = queryAllPersonnelDetails();
+        Map<String, Object> info = personnelMap.get(userId);
+        if (info == null) {
+            result.put("success", false);
+            result.put("error", "人员档案未找到该 userId: " + userId);
+            return result;
+        }
+        result.put("personnelInfo", info);
+
+        // 2. 内部员工查主管(外部员工跳过)
+        String managerId = null;
+        if ("内部".equals(String.valueOf(info.get("radioField_mkow4ydo")))) {
+            try {
+                String accessToken = ddClient.getAccessToken();
+                Map userInfo = ddClient_contacts.getUserInfoById(accessToken, userId);
+                if (userInfo != null && userInfo.get("manager_userid") != null) {
+                    String mgrId = String.valueOf(userInfo.get("manager_userid"));
+                    if (!mgrId.isEmpty() && !"null".equals(mgrId)) {
+                        managerId = mgrId;
+                    }
+                }
+            } catch (Exception e) {
+                log.warn("获取员工{}直属主管失败: {}", userId, e.getMessage());
+            }
+        }
+        result.put("managerId", managerId);
+
+        // 3. upsert 写一条
+        try {
+            upsertDailyHours(userId, managerId, workDay, info);
+            result.put("success", true);
+            log.info("单条验证写入成功: userId={}, workDay={}, 归属公司={}",
+                    userId, workDay, info.get("textField_mmekrcji"));
+        } catch (Exception e) {
+            log.error("单条验证写入失败: userId={}, workDay={}", userId, workDay, e);
+            result.put("success", false);
+            result.put("error", e.getMessage());
+        }
+        return result;
+    }
+
+    /**
+     * 小批量同步验证入口(清空后取前 N 个内部员工写入当月数据,便于核对归属公司与 Manager)
+     * ppExt: 走完整流程(人员档案→主管→节假日→工作日→并发写入),仅人员集合缩成前 N 个内部员工
+     *
+     * @param limit       前 N 个内部员工
+     * @param targetMonth 目标月份,null 则默认当前月
+     * @return Map{employeeCount, workingDays, success, fail, sampleEmployees}
+     */
+    public Map<String, Object> syncBatchEmployees(int limit, LocalDate targetMonth) {
+        Map<String, Object> stats = new LinkedHashMap<>();
+        if (targetMonth == null) {
+            targetMonth = LocalDate.now();
+        }
+        if (limit <= 0) {
+            limit = 5;
+        }
+        int year = targetMonth.getYear();
+        int month = targetMonth.getMonthValue();
+        log.info("开始小批量同步: 前{}个内部员工, {}年{}月", limit, year, month);
+
+        // 1. 全量查人员档案
+        Map<String, Map<String, Object>> all = queryAllPersonnelDetails();
+
+        // 2. 截取前 limit 个内部员工(保持档案原始顺序)
+        Map<String, Map<String, Object>> subset = new LinkedHashMap<>();
+        List<Map<String, Object>> sample = new ArrayList<>();
+        for (Map.Entry<String, Map<String, Object>> entry : all.entrySet()) {
+            Map<String, Object> info = entry.getValue();
+            if (!"内部".equals(String.valueOf(info.get("radioField_mkow4ydo")))) continue;
+            subset.put(entry.getKey(), info);
+            Map<String, Object> s = new LinkedHashMap<>();
+            s.put("userId", entry.getKey());
+            s.put("textField_mh8xhqc1", info.get("textField_mh8xhqc1"));
+            s.put("textField_mmekrcji", info.get("textField_mmekrcji"));
+            sample.add(s);
+            if (subset.size() >= limit) break;
+        }
+        stats.put("employeeCount", subset.size());
+        stats.put("sampleEmployees", sample);
+        if (subset.isEmpty()) {
+            log.warn("未取到任何内部员工,跳过");
+            return stats;
+        }
+
+        // 3. 主管
+        Map<String, String> managerMap = queryManagerMap(subset);
+        stats.put("managerCount", managerMap.size());
+
+        // 4. 节假日 + 工作日
+        Map<LocalDate, String> rules = queryHolidayRules(String.valueOf(year));
+        List<LocalDate> workingDays = getWorkingDays(year, month, rules);
+        stats.put("workingDays", workingDays.size());
+
+        // 5. 并发写入
+        int[] counts = concurrentUpsert(subset, managerMap, workingDays);
+        stats.put("success", counts[0]);
+        stats.put("fail", counts[1]);
+        log.info("小批量同步完成: 成功{}条, 失败{}条({}名员工 × {}个工作日)",
+                counts[0], counts[1], subset.size(), workingDays.size());
+        return stats;
+    }
+
     /**
     /**
      * 批量删除应填报工时全部数据(验证时使用,非日常流程)
      * 批量删除应填报工时全部数据(验证时使用,非日常流程)
      */
      */
@@ -185,8 +309,12 @@ public class WorkHoursCalcService {
                 }
                 }
             }
             }
 
 
-            // 删除后总数变化,始终查第1页
-            if (totalDeleted >= totalCount) break;
+            // fixme: 原判断 `totalDeleted >= totalCount` 会提前 break(累计已删 vs 当次剩余总数语义不同)
+            //        改为仅靠下一轮 dataList.isEmpty() 判空;加安全阈值防死循环
+            if (totalDeleted > 200000) {
+                log.warn("已删除{}条超过安全阈值,终止批量删除", totalDeleted);
+                break;
+            }
         }
         }
         log.info("应填报工时数据清空完成, 共删除{}条", totalDeleted);
         log.info("应填报工时数据清空完成, 共删除{}条", totalDeleted);
     }
     }
@@ -355,7 +483,8 @@ public class WorkHoursCalcService {
                     info.put("radioField_mkow4ydo", formData.get("radioField_mkow4ydo"));
                     info.put("radioField_mkow4ydo", formData.get("radioField_mkow4ydo"));
                     info.put("textField_mh8xhqc1", formData.get("textField_mh8xhqc1"));
                     info.put("textField_mh8xhqc1", formData.get("textField_mh8xhqc1"));
                     info.put("departmentSelectField_mkow4ydr", formData.get("departmentSelectField_mkow4ydr_id"));
                     info.put("departmentSelectField_mkow4ydr", formData.get("departmentSelectField_mkow4ydr_id"));
-                    info.put("textField_mmekrcji", formData.get("textField_mmekrcji"));
+                    // 归属公司:人员档案是 SelectField 下拉,直接取字符串;目标表单写入仍用 textField_mmekrcji
+                    info.put("textField_mmekrcji", formData.get("selectField_mh8xhqc4"));
                     personnelMap.put(empId, info);
                     personnelMap.put(empId, info);
                 }
                 }
             }
             }
@@ -409,7 +538,8 @@ public class WorkHoursCalcService {
                     info.put("radioField_mkow4ydo", formData.get("radioField_mkow4ydo"));
                     info.put("radioField_mkow4ydo", formData.get("radioField_mkow4ydo"));
                     info.put("textField_mh8xhqc1", formData.get("textField_mh8xhqc1"));
                     info.put("textField_mh8xhqc1", formData.get("textField_mh8xhqc1"));
                     info.put("departmentSelectField_mkow4ydr", formData.get("departmentSelectField_mkow4ydr_id"));
                     info.put("departmentSelectField_mkow4ydr", formData.get("departmentSelectField_mkow4ydr_id"));
-                    info.put("textField_mmekrcji", formData.get("textField_mmekrcji"));
+                    // 归属公司:人员档案是 SelectField 下拉,直接取字符串;目标表单写入仍用 textField_mmekrcji
+                    info.put("textField_mmekrcji", formData.get("selectField_mh8xhqc4"));
                     personnelMap.put(empId, info);
                     personnelMap.put(empId, info);
                 }
                 }
             }
             }

+ 25 - 0
mjava-akdsbeisen/src/main/resources/application-dev.yml

@@ -72,3 +72,28 @@ workhours:
   formUuidRequiredHours: "FORM-D9144092C2C24C93A1B02A8EEED0663FFD8G"
   formUuidRequiredHours: "FORM-D9144092C2C24C93A1B02A8EEED0663FFD8G"
   yidaAppType: "APP_ZQ3I7XO2RSHDJ4QDEVNB"
   yidaAppType: "APP_ZQ3I7XO2RSHDJ4QDEVNB"
   yidaSystemToken: "FOD66381NOS25MERLN2UK92FY96Y21UMHD7LM36S"
   yidaSystemToken: "FOD66381NOS25MERLN2UK92FY96Y21UMHD7LM36S"
+
+# 钉钉 -> 宜搭 人员档案同步 (验证阶段指向测试表)
+personnel-sync:
+  yidaAppType: "APP_ZQ3I7XO2RSHDJ4QDEVNB"
+  yidaSystemToken: "FOD66381NOS25MERLN2UK92FY96Y21UMHD7LM36S"
+  formUuidPersonnel: "FORM-5D6C2D1BE7A0430AA8D2E604BB97A9B2BN64"  # 测试表 上线切为生产 formUuid
+  # 字段 ID (测试版)
+  fieldEmployee: "employeeField_yt3j1muz8"
+  fieldUserType: "radioField_yt3j2rwjh"
+  fieldJobNumber: "textField_yt3j3u5i0"
+  fieldDepartment: "departmentSelectField_yt3j4h3dr"
+  fieldStatus: "radioField_yt3j52vog"
+  fieldHiredDate: "dateField_6gth31wa9"
+  # 钉钉 extattr 中标记内部/外部的 key (当前企业未开通该权限 读不到 使用兜底规则)
+  extAttrKeyUserType: ""
+  extAttrValueInternal: "内部"
+  extAttrValueExternal: "外部"
+  # 属性判定兜底: 部门 ID 属于 externalDeptIds 则标"外部" 否则标"内部"
+  # 两者都为空时默认全部"内部" (fallbackInternalByDefault=true)
+  externalDeptIds: []
+  fallbackInternalByDefault: true
+  statusValueActive: "在职"
+  statusValueInactive: "离职"
+  concurrency: 10
+  maxRetry: 2

+ 21 - 0
mjava-akdsbeisen/src/main/resources/application-prod.yml

@@ -50,3 +50,24 @@ beisen:
   formUuidLeave: "FORM-D15D90EA85234A77B94564110F5D24424ERL"
   formUuidLeave: "FORM-D15D90EA85234A77B94564110F5D24424ERL"
   yidaAppType: "APP_ZQ3I7XO2RSHDJ4QDEVNB"
   yidaAppType: "APP_ZQ3I7XO2RSHDJ4QDEVNB"
   yidaSystemToken: "FOD66381NOS25MERLN2UK92FY96Y21UMHD7LM36S"
   yidaSystemToken: "FOD66381NOS25MERLN2UK92FY96Y21UMHD7LM36S"
+
+# 钉钉 -> 宜搭 人员档案同步 (生产)
+# 切换前需要:
+# 1. 在生产人员档案表新增"在职状态"字段 (RadioField 选项 在职/离职) 拿到 fieldId 回填
+# 2. 通过 /personnel-sync/probe 确认 extAttrKeyUserType 实际 key
+personnel-sync:
+  yidaAppType: "APP_ZQ3I7XO2RSHDJ4QDEVNB"
+  yidaSystemToken: "FOD66381NOS25MERLN2UK92FY96Y21UMHD7LM36S"
+  formUuidPersonnel: "FORM-CCEEE5D461694CBAB5999A8C2926D0C1RXQP"
+  fieldEmployee: "employeeField_mkow4ydp"
+  fieldUserType: "radioField_mkow4ydo"
+  fieldJobNumber: "textField_mh8xhqc1"
+  fieldDepartment: "departmentSelectField_mkow4ydr"
+  fieldStatus: ""   # 待生产表新增"在职状态"字段后填入
+  extAttrKeyUserType: ""
+  extAttrValueInternal: "内部"
+  extAttrValueExternal: "外部"
+  statusValueActive: "在职"
+  statusValueInactive: "离职"
+  concurrency: 10
+  maxRetry: 2

+ 19 - 0
mjava/src/main/java/com/malk/service/dingtalk/DDClient_Contacts.java

@@ -38,6 +38,25 @@ public interface DDClient_Contacts {
      */
      */
     List<String> listDepartmentUserId(String access_token, long dept_id);
     List<String> listDepartmentUserId(String access_token, long dept_id);
 
 
+    /**
+     * 分页查询部门下用户详情列表 [单次最多 100 条]
+     * -
+     * ppExt: 返回结构 { has_more:Boolean, next_cursor:Long, list:List<Map> }
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/queries-the-complete-information-of-a-department-user
+     */
+    Map listDepartmentUserDetail(String access_token, long dept_id, long cursor, long size);
+
+    /**
+     * 获取部门下全部用户详情 [自动分页拼接, 内部循环调用 listDepartmentUserDetail]
+     */
+    List<Map> listDepartmentUserDetail_all(String access_token, long dept_id);
+
+    /**
+     * 获取企业全量用户详情 [遍历全部部门 + 按 userid 去重]
+     */
+    List<Map> getAllUserDetails(String access_token, boolean containsTop);
+
     /**
     /**
      * 查询用户详情
      * 查询用户详情
      * -
      * -

+ 54 - 0
mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient_Contacts.java

@@ -14,6 +14,7 @@ import org.springframework.stereotype.Service;
 
 
 import java.util.ArrayList;
 import java.util.ArrayList;
 import java.util.Date;
 import java.util.Date;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.List;
 import java.util.Map;
 import java.util.Map;
 import java.util.stream.Collectors;
 import java.util.stream.Collectors;
@@ -101,6 +102,59 @@ public class DDImplClient_Contacts implements DDClient_Contacts {
         return (List<String>) rsp.get("userid_list");
         return (List<String>) rsp.get("userid_list");
     }
     }
 
 
+    /**
+     * 分页查询部门下用户详情列表
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/queries-the-complete-information-of-a-department-user
+     */
+    @Override
+    public Map listDepartmentUserDetail(String access_token, long dept_id, long cursor, long size) {
+        Map param = UtilMap.map("access_token", access_token);
+        Map body = UtilMap.map("dept_id, cursor, size", dept_id, cursor, size);
+        return (Map) DDR.doPost("https://oapi.dingtalk.com/topapi/v2/user/list", null, param, body).getResult();
+    }
+
+    /**
+     * 获取部门下全部用户详情 [自动分页]
+     */
+    @Override
+    public List<Map> listDepartmentUserDetail_all(String access_token, long dept_id) {
+        List<Map> all = new ArrayList<>();
+        long cursor = 0;
+        while (true) {
+            Map rsp = listDepartmentUserDetail(access_token, dept_id, cursor, 100);
+            List<Map> list = (List<Map>) rsp.get("list");
+            if (ObjectUtil.isNotNull(list) && !list.isEmpty()) {
+                all.addAll(list);
+            }
+            Object hasMore = rsp.get("has_more");
+            if (!Boolean.TRUE.equals(hasMore)) break;
+            Object next = rsp.get("next_cursor");
+            if (ObjectUtil.isNull(next)) break;
+            cursor = ((Number) next).longValue();
+        }
+        return all;
+    }
+
+    /**
+     * 获取企业全量用户详情 [遍历全部部门 + 按 userid 去重]
+     */
+    @Override
+    public List<Map> getAllUserDetails(String access_token, boolean containsTop) {
+        List<Long> deptIds = getDepartmentId_all(access_token, containsTop);
+        Map<String, Map> byUserid = new LinkedHashMap<>();
+        for (Long deptId : deptIds) {
+            List<Map> users = listDepartmentUserDetail_all(access_token, deptId);
+            for (Map user : users) {
+                Object useridObj = user.get("userid");
+                if (ObjectUtil.isNotNull(useridObj)) {
+                    byUserid.putIfAbsent(String.valueOf(useridObj), user);
+                }
+            }
+        }
+        return new ArrayList<>(byUserid.values());
+    }
+
     /**
     /**
      * 查询用户详情
      * 查询用户详情
      *
      *