瀏覽代碼

feat(personnel-sync): 扩展人员档案同步至13字段+定时任务

- PersonnelSyncConf 新增 7 个 fieldXxx(姓名/员工工号/北森编号/Manager/归属公司/是否CF/成本中心)、4 个 extAttrKey、limitFirstN 测试开关
- toYidaFormData 扩展到 13 字段映射;readExtAttr 兼容 extension(JSON 串)与旧式 extattr Map;批量接口不返 manager_userid,enrichManagers 按需逐人 user/get 补
- 在职判定改为存在性(钉钉里查到→在职、不在→离职),不再读 active 布尔
- limitFirstN>0(或 ?limit=N)时:钉钉全量按 userid 升序取前 N 条 + 跳过 MARK_OFF
- 内外判定改为部门含 1066052389→外部,否则内部(externalDeptIds)
- Controller /full、/dry-run 支持 ?limit=;Service fullSync/dryRun 加 Integer limit 参数
- 新增 PersonnelSyncTimer:cron 0 0 3,13 * * ?(每天 03:00/13:00 各一次 fullSync)
- application-dev.yml 指向测试表 FORM-6889...(limitFirstN:10);application-prod.yml 指向生产表 FORM-CCEEE...(limitFirstN:0),两环境字段 ID 一致

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
malk 1 月之前
父節點
當前提交
35114afa68

+ 7 - 6
mjava-akdsbeisen/src/main/java/com/malk/controller/PersonnelSyncController.java

@@ -24,13 +24,14 @@ public class PersonnelSyncController {
     private PersonnelSyncService personnelSyncService;
 
     /**
-     * 全量同步 POST /personnel-sync/full
+     * 全量同步 POST /personnel-sync/full[?limit=N]
+     * limit>0 时只同步钉钉全量按 userid 升序的前 N 条且跳过 MARK_OFF; 不传则用配置 limitFirstN
      */
     @PostMapping("/full")
-    public Map<String, Object> full() {
+    public Map<String, Object> full(@RequestParam(required = false) Integer limit) {
         Map<String, Object> result = new LinkedHashMap<>();
         try {
-            Map<String, Object> stats = personnelSyncService.fullSync();
+            Map<String, Object> stats = personnelSyncService.fullSync(limit);
             result.put("success", true);
             result.put("stats", stats);
         } catch (Exception e) {
@@ -42,14 +43,14 @@ public class PersonnelSyncController {
     }
 
     /**
-     * 干跑 GET /personnel-sync/dry-run
+     * 干跑 GET /personnel-sync/dry-run[?limit=N]
      */
     @GetMapping("/dry-run")
-    public Map<String, Object> dryRun() {
+    public Map<String, Object> dryRun(@RequestParam(required = false) Integer limit) {
         Map<String, Object> result = new LinkedHashMap<>();
         try {
             result.put("success", true);
-            result.put("stats", personnelSyncService.dryRun());
+            result.put("stats", personnelSyncService.dryRun(limit));
         } catch (Exception e) {
             log.error("[PersonnelSync] dry-run 失败", e);
             result.put("success", false);

+ 24 - 8
mjava-akdsbeisen/src/main/java/com/malk/server/personnel/PersonnelSyncConf.java

@@ -16,18 +16,31 @@ public class PersonnelSyncConf {
     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 fieldEmployee;        // 人员 EmployeeField <- userid
+    private String fieldName;            // 员工姓名 TextField <- name (目标表 READONLY, 强写覆盖)
+    private String fieldJobNumber;       // 员工编号 TextField <- job_number
+    private String fieldJobNumber2;      // 员工工号 TextField <- job_number (目标表 READONLY, 强写覆盖)
+    private String fieldDepartment;      // 员工部门 DepartmentSelectField <- dept_id_list 排序后首个
+    private String fieldHiredDate;       // 入职时间 DateField <- hired_date
+    private String fieldBeisenJobNo;     // 北森编号 TextField <- extattr[北森工号]
+    private String fieldManager;         // Manager EmployeeField <- manager_userid
+    private String fieldUserType;        // 属性 RadioField <- 部门含 externalDeptIds ? 外部 : 内部
+    private String fieldCompany;         // 归属公司 SelectField <- extattr[company]
+    private String fieldIsCf;            // 是否CF TextField <- extattr[是否CF员工]
+    private String fieldCostCenter;      // 成本中心 TextField <- extattr[成本中心]
+    private String fieldStatus;          // 是否在职 RadioField <- 钉钉存在性
+
+    // extattr 属性判定: 钉钉 extattr 中用于标记内部/外部的 key (留空则内外判定走部门白名单)
     private String extAttrKeyUserType;
     private String extAttrValueInternal = "内部";
     private String extAttrValueExternal = "外部";
 
+    // extattr 自定义字段 key (钉钉后台配置的字段名, probe 真实用户后确认)
+    private String extAttrKeyBeisen;
+    private String extAttrKeyCompany;
+    private String extAttrKeyIsCf;
+    private String extAttrKeyCostCenter;
+
     // 属性判定兜底: 当 extattr 读不到时 按部门 ID 白/黑名单判定
     // externalDeptIds 里的部门视为"外部" 其它视为"内部"; 均为空则不写属性字段
     private java.util.List<Long> externalDeptIds = new java.util.ArrayList<>();
@@ -37,6 +50,9 @@ public class PersonnelSyncConf {
     private String statusValueActive = "在职";
     private String statusValueInactive = "离职";
 
+    // 测试开关: >0 时 钉钉全量按 userid 升序取前 N 条, 且本轮跳过 MARK_OFF (避免误标未覆盖记录离职)
+    private int limitFirstN = 0;
+
     // 并发与重试
     private int concurrency = 10;
     private int maxRetry = 2;

+ 6 - 4
mjava-akdsbeisen/src/main/java/com/malk/service/personnel/PersonnelSyncService.java

@@ -8,16 +8,18 @@ public interface PersonnelSyncService {
     /**
      * 全量同步: 钉钉 -> 宜搭人员档案
      *
-     * @return 统计 {fetched, created, updated, markedInactive, skipped, failed, durationMs}
+     * @param limit 可选: >0 时只同步钉钉全量按 userid 升序的前 N 条且跳过 MARK_OFF; null 则用配置 limitFirstN
+     * @return 统计 {fetched, created, updated, markedInactive, failed, durationMs}
      */
-    Map<String, Object> fullSync();
+    Map<String, Object> fullSync(Integer limit);
 
     /**
      * 干跑: 只计算 diff 不写入
      *
-     * @return {fetched, actions:{create,update,markOff,skip}, durationMs}
+     * @param limit 同 {@link #fullSync(Integer)}
+     * @return {fetched, actions:{create,update,markOff}, durationMs}
      */
-    Map<String, Object> dryRun();
+    Map<String, Object> dryRun(Integer limit);
 
     /**
      * Phase 0 探针: 返回钉钉全量员工抽样 [total, deptCount, durationMs, sample]

+ 142 - 36
mjava-akdsbeisen/src/main/java/com/malk/service/personnel/impl/PersonnelSyncServiceImpl.java

@@ -49,18 +49,22 @@ public class PersonnelSyncServiceImpl implements PersonnelSyncService {
     private static final String ACTION_MARK_OFF = "MARK_OFF";
 
     @Override
-    public Map<String, Object> fullSync() {
+    public Map<String, Object> fullSync(Integer limitOverride) {
         long start = System.currentTimeMillis();
-        log.info("[PersonnelSync] 全量同步开始");
+        int limit = effectiveLimit(limitOverride);
+        boolean limited = limit > 0;
+        log.info("[PersonnelSync] 全量同步开始 limit={}", limited ? limit : "none");
 
         List<Map> dingUsers = fetchAllDingUsers();
-        Map<String, Map> dingUserMap = indexByUserid(dingUsers);
-        log.info("[PersonnelSync] 钉钉全量拉取 {} 人 (去重后)", dingUserMap.size());
+        Map<String, Map> dingUserMap = applyLimit(indexByUserid(dingUsers), limit);
+        enrichManagers(dingUserMap);
+        log.info("[PersonnelSync] 钉钉拉取 {} 人 (去重后{})", dingUserMap.size(), limited ? ", 已截断前" + limit + "条" : "");
 
         Map<String, YidaRecord> yidaMap = fetchAllYidaPersonnel();
         log.info("[PersonnelSync] 宜搭人员档案 {} 条", yidaMap.size());
 
-        List<Action> actions = diff(dingUserMap, yidaMap);
+        if (limited) log.info("[PersonnelSync] limit 模式: 本轮跳过 MARK_OFF (拉取非全量)");
+        List<Action> actions = diff(dingUserMap, yidaMap, limited);
         Map<String, Long> actionStats = countActions(actions);
         log.info("[PersonnelSync] diff 完成: create={}, update={}, markOff={}, skip={}",
                 actionStats.getOrDefault(ACTION_CREATE, 0L),
@@ -84,12 +88,15 @@ public class PersonnelSyncServiceImpl implements PersonnelSyncService {
     }
 
     @Override
-    public Map<String, Object> dryRun() {
+    public Map<String, Object> dryRun(Integer limitOverride) {
         long start = System.currentTimeMillis();
+        int limit = effectiveLimit(limitOverride);
+        boolean limited = limit > 0;
         List<Map> dingUsers = fetchAllDingUsers();
-        Map<String, Map> dingUserMap = indexByUserid(dingUsers);
+        Map<String, Map> dingUserMap = applyLimit(indexByUserid(dingUsers), limit);
+        enrichManagers(dingUserMap);
         Map<String, YidaRecord> yidaMap = fetchAllYidaPersonnel();
-        List<Action> actions = diff(dingUserMap, yidaMap);
+        List<Action> actions = diff(dingUserMap, yidaMap, limited);
 
         Map<String, Long> stats = countActions(actions);
         Map<String, Object> result = new LinkedHashMap<>();
@@ -164,8 +171,8 @@ public class PersonnelSyncServiceImpl implements PersonnelSyncService {
         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);
+        // 新 formData (在职判定改为存在性, probe 统一用 UPDATE)
+        Map<String, Object> newData = toYidaFormData(userid, ding, ACTION_UPDATE);
         r.put("newFormData", newData);
         // 字段对比
         Map<String, Object> diff = new LinkedHashMap<>();
@@ -251,39 +258,35 @@ public class PersonnelSyncServiceImpl implements PersonnelSyncService {
 
     // ==================== 内部: 差异计算 ====================
 
-    private List<Action> diff(Map<String, Map> dingUserMap, Map<String, YidaRecord> yidaMap) {
+    private List<Action> diff(Map<String, Map> dingUserMap, Map<String, YidaRecord> yidaMap, boolean skipMarkOff) {
         List<Action> actions = new ArrayList<>();
 
+        // 钉钉里查到的 -> 一律按"在职"写 (active 布尔不再参与)
         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));
+                actions.add(new Action(ACTION_CREATE, userid, null, toYidaFormData(userid, ding, ACTION_CREATE)));
             } 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));
+                Map<String, Object> formData = toYidaFormData(userid, ding, ACTION_UPDATE);
+                if (isSameAsYida(formData, yida.formData)) continue;   // 幂等跳过
+                actions.add(new Action(ACTION_UPDATE, 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));
+        // 宜搭里有、钉钉里没有 -> 标记离职 (limit 模式下拉取非全量, 跳过此步)
+        if (!skipMarkOff) {
+            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;
     }
@@ -304,10 +307,18 @@ public class PersonnelSyncServiceImpl implements PersonnelSyncService {
         // 离职软标记只更新状态字段, 保留其他原值
         if (ACTION_MARK_OFF.equals(action)) return formData;
 
-        // 员工编号 <- job_number
+        // 员工姓名 <- name (目标表该字段 READONLY, 仍按需求强写覆盖)
+        Object name = ding.get("name");
+        if (notBlank(conf.getFieldName()) && name != null && notBlank(String.valueOf(name))) {
+            formData.put(conf.getFieldName(), String.valueOf(name).trim());
+        }
+
+        // 员工编号 / 员工工号 <- job_number (员工工号在目标表 READONLY, 按需求强写覆盖)
         Object jobNumber = ding.get("job_number");
-        if (jobNumber != null && !String.valueOf(jobNumber).isEmpty()) {
-            formData.put(conf.getFieldJobNumber(), String.valueOf(jobNumber));
+        if (jobNumber != null && notBlank(String.valueOf(jobNumber))) {
+            String jn = String.valueOf(jobNumber).trim();
+            if (notBlank(conf.getFieldJobNumber())) formData.put(conf.getFieldJobNumber(), jn);
+            if (notBlank(conf.getFieldJobNumber2())) formData.put(conf.getFieldJobNumber2(), jn);
         }
 
         // 员工部门 <- dept_id_list 稳定排序后取首个 (钉钉返回顺序不固定, 避免 diff 抖动)
@@ -324,14 +335,26 @@ public class PersonnelSyncServiceImpl implements PersonnelSyncService {
         }
 
         // 入职时间 <- hired_date (毫秒时间戳, 需要钉钉花名册权限才返回)
-        if (conf.getFieldHiredDate() != null && !conf.getFieldHiredDate().isEmpty()) {
+        if (notBlank(conf.getFieldHiredDate())) {
             Object hired = ding.get("hired_date");
             if (hired instanceof Number) {
                 formData.put(conf.getFieldHiredDate(), ((Number) hired).longValue());
             }
         }
 
-        // 人员属性 <- extattr.<key> 或 部门白名单兜底
+        // Manager <- manager_userid (EmployeeField, 数组格式)
+        Object mgr = ding.get("manager_userid");
+        if (notBlank(conf.getFieldManager()) && mgr != null && notBlank(String.valueOf(mgr))) {
+            formData.put(conf.getFieldManager(), Collections.singletonList(String.valueOf(mgr).trim()));
+        }
+
+        // 北森编号 / 归属公司 / 是否CF / 成本中心 <- 钉钉 extattr 自定义字段
+        putExtAttr(formData, ding, conf.getFieldBeisenJobNo(), conf.getExtAttrKeyBeisen());
+        putExtAttr(formData, ding, conf.getFieldCompany(), conf.getExtAttrKeyCompany());
+        putExtAttr(formData, ding, conf.getFieldIsCf(), conf.getExtAttrKeyIsCf());
+        putExtAttr(formData, ding, conf.getFieldCostCenter(), conf.getExtAttrKeyCostCenter());
+
+        // 属性 <- 部门含 externalDeptIds ? 外部 : 内部 (extAttrKeyUserType 留空时走部门白名单)
         String userType = resolveUserType(ding);
         if (userType != null) {
             formData.put(conf.getFieldUserType(), userType);
@@ -340,6 +363,49 @@ public class PersonnelSyncServiceImpl implements PersonnelSyncService {
         return formData;
     }
 
+    /** extattr 自定义字段取值并写入 (fieldId 或 extKey 为空 / 取不到值 则不写, 保留宜搭原值) */
+    private void putExtAttr(Map<String, Object> formData, Map ding, String fieldId, String extKey) {
+        if (!notBlank(fieldId) || !notBlank(extKey)) return;
+        String v = readExtAttr(ding, extKey);
+        if (notBlank(v)) formData.put(fieldId, v.trim());
+    }
+
+    /**
+     * 读钉钉自定义字段[key]:
+     * - topapi/v2/user/list & user/get 把自定义字段放在 extension (JSON 字符串 {key: value}) 里
+     * - 兼容旧式 extattr (Map, 值可能是纯字符串或 {text, value} 枚举)
+     */
+    @SuppressWarnings("unchecked")
+    private String readExtAttr(Map ding, String key) {
+        Object ext = ding.get("extension");
+        if (ext instanceof String && !((String) ext).trim().isEmpty()) {
+            try {
+                Object parsed = JSON.parse((String) ext);
+                if (parsed instanceof Map) {
+                    Object v = ((Map) parsed).get(key);
+                    if (v != null && !String.valueOf(v).trim().isEmpty()) return String.valueOf(v);
+                }
+            } catch (Exception ignored) {}
+        }
+        Object extattr = ding.get("extattr");
+        if (extattr instanceof Map) {
+            Object attr = ((Map) extattr).get(key);
+            if (attr instanceof Map) {
+                Map am = (Map) attr;
+                Object v = am.get("value");
+                if (v == null) v = am.get("text");
+                return v == null ? null : String.valueOf(v);
+            } else if (attr != null && !String.valueOf(attr).trim().isEmpty()) {
+                return String.valueOf(attr);
+            }
+        }
+        return null;
+    }
+
+    private boolean notBlank(String s) {
+        return s != null && !s.trim().isEmpty();
+    }
+
     @SuppressWarnings("unchecked")
     private String resolveUserType(Map ding) {
         // 1. 优先读 extattr
@@ -515,6 +581,46 @@ public class PersonnelSyncServiceImpl implements PersonnelSyncService {
         return map;
     }
 
+    /** 解析生效的 limit: 入参优先, 否则用配置 limitFirstN; <=0 表示不限 */
+    private int effectiveLimit(Integer override) {
+        if (override != null) return Math.max(0, override);
+        return Math.max(0, conf.getLimitFirstN());
+    }
+
+    /** limit>0 时按 userid 升序取前 N 条 (排序保证幂等可复现) */
+    private Map<String, Map> applyLimit(Map<String, Map> byUserid, int limit) {
+        if (limit <= 0 || byUserid.size() <= limit) return byUserid;
+        List<String> keys = new ArrayList<>(byUserid.keySet());
+        Collections.sort(keys);
+        Map<String, Map> limited = new LinkedHashMap<>();
+        for (int i = 0; i < limit; i++) limited.put(keys.get(i), byUserid.get(keys.get(i)));
+        return limited;
+    }
+
+    /** topapi/v2/user/list 不返回 manager_userid, 按需逐人补 (仅当 fieldManager 已配置; 单人失败不影响整体) */
+    @SuppressWarnings("unchecked")
+    private void enrichManagers(Map<String, Map> dingUserMap) {
+        if (!notBlank(conf.getFieldManager()) || dingUserMap.isEmpty()) return;
+        String token = ddClient.getAccessToken();
+        int filled = 0;
+        for (Map u : dingUserMap.values()) {
+            if (u.get("manager_userid") != null) { filled++; continue; }
+            Object uid = u.get("userid");
+            if (uid == null) continue;
+            try {
+                Map detail = ddClient_contacts.getUserInfoById(token, String.valueOf(uid));
+                Object mgr = detail == null ? null : detail.get("manager_userid");
+                if (mgr != null && notBlank(String.valueOf(mgr))) {
+                    u.put("manager_userid", String.valueOf(mgr).trim());
+                    filled++;
+                }
+            } catch (Exception ex) {
+                log.warn("[PersonnelSync] 取 manager_userid 失败 userid={} err={}", uid, ex.getMessage());
+            }
+        }
+        log.info("[PersonnelSync] manager_userid 已就绪 {}/{} 人", filled, dingUserMap.size());
+    }
+
     private Map<String, Long> countActions(List<Action> actions) {
         Map<String, Long> stats = new HashMap<>();
         for (Action a : actions) {

+ 32 - 0
mjava-akdsbeisen/src/main/java/com/malk/timer/PersonnelSyncTimer.java

@@ -0,0 +1,32 @@
+package com.malk.timer;
+
+import com.malk.service.personnel.PersonnelSyncService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.scheduling.annotation.Scheduled;
+
+/**
+ * 钉钉 -> 宜搭 人员档案 定时全量增量同步
+ * 每天 03:00 与 13:00 各跑一次 fullSync (limit 取配置 personnel-sync.limitFirstN, 生产为 0 即真·全量)
+ */
+@Slf4j
+@Configuration
+@EnableScheduling
+public class PersonnelSyncTimer {
+
+    @Autowired
+    private PersonnelSyncService personnelSyncService;
+
+    @Scheduled(cron = "0 0 3,13 * * ?")
+    public void dailyFullSync() {
+        log.info("[PersonnelSync] 定时同步任务开始");
+        try {
+            java.util.Map<String, Object> stats = personnelSyncService.fullSync(null);
+            log.info("[PersonnelSync] 定时同步任务完成 {}", stats);
+        } catch (Exception e) {
+            log.error("[PersonnelSync] 定时同步任务失败", e);
+        }
+    }
+}

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

@@ -73,27 +73,39 @@ workhours:
   yidaAppType: "APP_ZQ3I7XO2RSHDJ4QDEVNB"
   yidaSystemToken: "FOD66381NOS25MERLN2UK92FY96Y21UMHD7LM36S"
 
-# 钉钉 -> 宜搭 人员档案同步 (验证阶段指向测试表)
+# 钉钉 -> 宜搭 人员档案同步 (验证阶段指向测试表 FORM-6889...)
 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 (当前企业未开通该权限 读不到 使用兜底规则)
+  formUuidPersonnel: "FORM-6889D4A0CE1E4E239E6E6809391E21E6XF1Y"  # 测试表 上线切为生产 formUuid
+  # 字段 ID (与生产人员档案表同结构)
+  fieldEmployee: "employeeField_mkow4ydp"          # 人员 <- userid
+  fieldName: "textField_mkox0j8v"                  # 员工姓名 <- name (READONLY, 强写覆盖)
+  fieldJobNumber: "textField_mh8xhqc1"             # 员工编号 <- job_number
+  fieldJobNumber2: "textField_mkox0j8w"            # 员工工号 <- job_number (READONLY, 强写覆盖)
+  fieldDepartment: "departmentSelectField_mkow4ydr" # 员工部门 <- dept_id_list 排序后首个
+  fieldHiredDate: "dateField_mh8xhqc6"             # 入职时间 <- hired_date
+  fieldBeisenJobNo: "textField_mh8xhqc2"           # 北森编号 <- extattr[北森工号]
+  fieldManager: "employeeField_mh8xhqc3"           # Manager <- manager_userid
+  fieldUserType: "radioField_mkow4ydo"             # 属性 <- 部门含 externalDeptIds ? 外部 : 内部
+  fieldCompany: "selectField_mh8xhqc4"             # 归属公司 <- extattr[company]
+  fieldIsCf: "textField_mow9w7d8"                  # 是否CF <- extattr[是否CF员工]
+  fieldCostCenter: "textField_mow9w7d9"            # 成本中心 <- extattr[成本中心]
+  fieldStatus: "radioField_mp1sngq1"               # 是否在职 <- 钉钉存在性
+  # 内外判定: extAttrKeyUserType 留空 -> 走部门白名单; 部门 ID 命中 externalDeptIds 标"外部" 否则"内部"
   extAttrKeyUserType: ""
   extAttrValueInternal: "内部"
   extAttrValueExternal: "外部"
-  # 属性判定兜底: 部门 ID 属于 externalDeptIds 则标"外部" 否则标"内部"
-  # 两者都为空时默认全部"内部" (fallbackInternalByDefault=true)
-  externalDeptIds: []
+  externalDeptIds: [1066052389]
   fallbackInternalByDefault: true
+  # 钉钉 extattr 自定义字段 key (probe 真实用户后按实际后台字段名校正)
+  extAttrKeyBeisen: "北森工号"
+  extAttrKeyCompany: "company"
+  extAttrKeyIsCf: "是否CF员工"
+  extAttrKeyCostCenter: "成本中心"
   statusValueActive: "在职"
   statusValueInactive: "离职"
+  # 测试开关: >0 时钉钉全量按 userid 升序取前 N 条且跳过 MARK_OFF; 验证完置 0 恢复全量
+  limitFirstN: 10
   concurrency: 10
   maxRetry: 2

+ 19 - 5
mjava-akdsbeisen/src/main/resources/application-prod.yml

@@ -52,22 +52,36 @@ beisen:
   yidaSystemToken: "FOD66381NOS25MERLN2UK92FY96Y21UMHD7LM36S"
 
 # 钉钉 -> 宜搭 人员档案同步 (生产)
-# 切换前需要:
-# 1. 在生产人员档案表新增"在职状态"字段 (RadioField 选项 在职/离职) 拿到 fieldId 回填
-# 2. 通过 /personnel-sync/probe 确认 extAttrKeyUserType 实际 key
+# 切换前需要: 1. 确认生产表是否与测试表 FORM-6889... 同 fieldId  2. probe 确认 extattr 各 key 实际值
+# 测试通过后, formUuidPersonnel 视情况切到 FORM-6889... 或保持生产表; limitFirstN 保持 0
 personnel-sync:
   yidaAppType: "APP_ZQ3I7XO2RSHDJ4QDEVNB"
   yidaSystemToken: "FOD66381NOS25MERLN2UK92FY96Y21UMHD7LM36S"
   formUuidPersonnel: "FORM-CCEEE5D461694CBAB5999A8C2926D0C1RXQP"
   fieldEmployee: "employeeField_mkow4ydp"
-  fieldUserType: "radioField_mkow4ydo"
+  fieldName: "textField_mkox0j8v"
   fieldJobNumber: "textField_mh8xhqc1"
+  fieldJobNumber2: "textField_mkox0j8w"
   fieldDepartment: "departmentSelectField_mkow4ydr"
-  fieldStatus: ""   # 待生产表新增"在职状态"字段后填入
+  fieldHiredDate: "dateField_mh8xhqc6"
+  fieldBeisenJobNo: "textField_mh8xhqc2"
+  fieldManager: "employeeField_mh8xhqc3"
+  fieldUserType: "radioField_mkow4ydo"
+  fieldCompany: "selectField_mh8xhqc4"
+  fieldIsCf: "textField_mow9w7d8"
+  fieldCostCenter: "textField_mow9w7d9"
+  fieldStatus: "radioField_mp1sngq1"
   extAttrKeyUserType: ""
   extAttrValueInternal: "内部"
   extAttrValueExternal: "外部"
+  externalDeptIds: [1066052389]
+  fallbackInternalByDefault: true
+  extAttrKeyBeisen: "北森工号"
+  extAttrKeyCompany: "company"
+  extAttrKeyIsCf: "是否CF员工"
+  extAttrKeyCostCenter: "成本中心"
   statusValueActive: "在职"
   statusValueInactive: "离职"
+  limitFirstN: 0
   concurrency: 10
   maxRetry: 2