Просмотр исходного кода

fix(workhours): 修复工时补漏及内部员工同步

malk недель назад: 3
Родитель
Сommit
a0b0cceb0f

+ 3 - 0
mjava-akdsbeisen/pom.xml

@@ -15,6 +15,9 @@
     <properties>
         <maven.compiler.source>8</maven.compiler.source>
         <maven.compiler.target>8</maven.compiler.target>
+        <!-- prd 当前模块的工时同步变更必须默认编译并执行测试,覆盖父 POM 的全局跳过配置 -->
+        <skipTests>false</skipTests>
+        <maven.test.skip>false</maven.test.skip>
     </properties>
 
     <dependencies>

+ 196 - 93
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/WorkHoursCalcService.java

@@ -5,16 +5,16 @@ import com.alibaba.fastjson.JSONObject;
 import com.google.common.util.concurrent.RateLimiter;
 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 com.malk.service.dingtalk.DDClient;
-import com.malk.service.dingtalk.DDClient_Contacts;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
+import java.io.IOException;
 import java.time.DayOfWeek;
 import java.time.Instant;
 import java.time.LocalDate;
@@ -37,36 +37,34 @@ public class WorkHoursCalcService {
     @Autowired
     private WHConf whConf;
 
-    @Autowired
-    private DDClient ddClient;
-
-    @Autowired
-    private DDClient_Contacts ddClient_contacts;
-
     private static final int DAILY_HOURS = 8;
     private static final int THREAD_POOL_SIZE = 10;
     private static final int MAX_RETRY = 2;
+    private static final int EXISTING_QUERY_MAX_ATTEMPTS = 3;
+    private static final long[] EXISTING_QUERY_RETRY_DELAYS_MS = {2_000L, 5_000L};
+    private RetrySleeper existingQueryRetrySleeper = Thread::sleep;
 
     // prd 增量同步只覆盖最近 N 天工作日窗口 (含 today), 避免服务器停机/单次失败导致数据永久丢失
     // fixme 窗口内已存在的记录一律 skip (补漏语义, 不刷新已写入字段); 字段刷新走独立接口 (backfill / cleanup)
-    private static final int INCREMENTAL_WINDOW_DAYS = 3;
+    private static final int INCREMENTAL_WINDOW_DAYS = 7;
 
     // prd 离职时间字段(人员档案侧, 用于过滤离职后不再生成/清理历史应报工时)
     private static final String PERSONNEL_OFFLINE_DATE = "dateField_mh8xhqc7";
     // prd 入职时间字段(以人员档案创建日期为准,入职日前不生成应报工时,入职当天保留)
     private static final String PERSONNEL_HIRED_DATE = "dateField_mh8xhqc6";
+    private static final String PERSONNEL_MANAGER = "employeeField_mh8xhqc3";
     // prd 在职状态字段 + 离职取值 (防御开关: status=离职 且 offlineDate=空 → 数据不一致, 该员工不写工时)
     private static final String PERSONNEL_STATUS = "radioField_mp1sngq1";
     private static final String STATUS_INACTIVE = "离职";
     // prd 应填报人显示名称文本,用于报表按姓名筛选;姓名取自人员档案成员组件显示值
     private static final String REQUIRED_HOURS_EMPLOYEE_NAME = "textField_ms4bzjkm";
 
-    // prd 全员项目参与关系 + 外部员工项目经理:项目档案字段
+    // prd 外部员工项目参与关系 + 项目经理:项目档案字段
     private static final String PROJECT_SUB_TABLE = "tableField_mkowyn6d";      // 成员子表(人员明细)
     private static final String PROJECT_SUB_MEMBER = "employeeField_mmbfe0ij";  // 子表-员工(成员)
     private static final String PROJECT_SUB_PM = "employeeField_mkoxpswf";      // 子表-项目经理
     private static final String PROJECT_SUB_OFFLINE = "dateField_mo6s11tc";     // 子表-下线时间
-    // prd 仅启用项目参与人员需要统计应填报工时
+    // prd 外部员工仅在参与启用项目且存在有效 PM 时统计应填报工时
     private static final String PROJECT_STATUS = "textField_mpwc5r0q";          // 主表-项目状态
     private static final String PROJECT_STATUS_ACTIVE = "启用";
 
@@ -96,11 +94,10 @@ public class WorkHoursCalcService {
             return stats;
         }
 
-        // 2. 预取 Manager + 启用项目参与关系;所有员工均须命中当天参与项目
+        // 2. 预取 Manager:内部员工取直属主管,外部员工取启用项目 assignments
         ManagerData managerData = queryManagerData(personnelMap);
         stats.put("internalMgrCount", managerData.internal.size());
         stats.put("externalWithProjects", managerData.external.size());
-        stats.put("projectParticipantCount", managerData.projects.size());
 
         // 3. 查询节假日规则
         Map<LocalDate, String> holidayRules = queryHolidayRules(String.valueOf(year));
@@ -131,7 +128,7 @@ public class WorkHoursCalcService {
      * 增量同步:全员 × 近 N 天工作日窗口(含 today), 已存在的记录一律 skip (补漏语义)
      * ppExt: 用途 = 服务器停机/单次失败 补齐; 姓名/部门等字段刷新走 backfill 接口(不在增量做)
      *
-     * @param daysBack 窗口天数(含 today), 默认 3
+     * @param daysBack 自然日窗口天数(含 today), 默认 7
      */
     public void incrementalSync(int daysBack) {
         if (daysBack <= 0) daysBack = INCREMENTAL_WINDOW_DAYS;
@@ -148,7 +145,7 @@ public class WorkHoursCalcService {
             return;
         }
 
-        // 2. 预取 Manager + 全员启用项目 assignments
+        // 2. 预取 Manager:内部员工取直属主管,外部员工取启用项目 assignments
         ManagerData managerData = queryManagerData(personnelMap);
 
         // 3~4. 节假日 + 收窄到窗口内工作日 (跨月边界: 若窗口跨 6-30/7-1, 分别按各自月份的节假日规则)
@@ -239,33 +236,23 @@ public class WorkHoursCalcService {
             return result;
         }
 
-        // 2. 所有员工都必须参与启用项目;内部取直属主管,外部取当天参与项目的 PM 合并
-        Map<String, List<Assignment>> projectMap = queryProjectAssignments(Collections.singleton(userId));
-        List<Assignment> assignments = projectMap.get(userId);
-        if (!hasActiveProject(assignments, workDay)) {
-            result.put("success", false);
-            result.put("error", "员工当日未参与启用项目, 按业务规则跳过写入");
-            return result;
-        }
-
-        boolean isInternal = "内部".equals(String.valueOf(info.get("radioField_mkow4ydo")));
+        // 2. 内部员工不校验项目,外部员工取当天参与启用项目的 PM 合并
+        boolean isInternal = isInternalEmployee(info);
         List<String> managerIds;
         if (isInternal) {
-            String mgrId = null;
-            try {
-                String accessToken = ddClient.getAccessToken();
-                Map userInfo = ddClient_contacts.getUserInfoById(accessToken, userId);
-                if (userInfo != null && userInfo.get("manager_userid") != null) {
-                    String candidate = String.valueOf(userInfo.get("manager_userid"));
-                    if (!candidate.isEmpty() && !"null".equals(candidate)) {
-                        mgrId = candidate;
-                    }
-                }
-            } catch (Exception e) {
-                log.warn("获取员工{}直属主管失败: {}", userId, e.getMessage());
-            }
-            managerIds = mgrId == null ? Collections.emptyList() : Collections.singletonList(mgrId);
+            String mgrId = personnelManagerId(info);
+            // prd 内部员工直接使用人员档案已同步 Manager,不再重复查询通讯录;Manager 为空仍写入
+            managerIds = mgrId == null
+                    ? Collections.emptyList()
+                    : Collections.singletonList(mgrId);
         } else {
+            Map<String, List<Assignment>> projectMap = queryProjectAssignments(Collections.singleton(userId));
+            List<Assignment> assignments = projectMap.get(userId);
+            if (!hasActiveProject(assignments, workDay)) {
+                result.put("success", false);
+                result.put("error", "外部员工当日未参与启用项目, 按业务规则跳过写入");
+                return result;
+            }
             // 外部员工: 按 workDay 算当天活跃项目 PM; 全空则按业务规则不写入
             managerIds = computeDailyPms(assignments, workDay);
             if (managerIds.isEmpty()) {
@@ -318,7 +305,7 @@ public class WorkHoursCalcService {
         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;
+            if (!isInternalEmployee(info)) continue;
             subset.put(entry.getKey(), info);
             Map<String, Object> s = new LinkedHashMap<>();
             s.put("userId", entry.getKey());
@@ -339,7 +326,6 @@ public class WorkHoursCalcService {
         ManagerData managerData = queryManagerData(subset);
         stats.put("internalMgrCount", managerData.internal.size());
         stats.put("externalWithProjects", managerData.external.size());
-        stats.put("projectParticipantCount", managerData.projects.size());
 
         // 4. 节假日 + 工作日
         Map<LocalDate, String> rules = queryHolidayRules(String.valueOf(year));
@@ -857,10 +843,10 @@ public class WorkHoursCalcService {
             for (Map.Entry<String, Map<String, Object>> entry : personnelMap.entrySet()) {
                 String empId = entry.getKey();
                 Map<String, Object> info = entry.getValue();
-                boolean isInternal = "内部".equals(String.valueOf(info.get("radioField_mkow4ydo")));
-                // 内部: 单值直属主管; 外部: assignments 交给内层按 workDay 算 PM
+                boolean isInternal = isInternalEmployee(info);
+                // 内部: 单值直属主管且不校验项目; 外部: assignments 交给内层按 workDay 算 PM
                 String internalMgrId = isInternal ? managerData.internal.get(empId) : null;
-                List<Assignment> projectAssignments = managerData.projects.get(empId);
+                List<Assignment> externalAssignments = isInternal ? null : managerData.external.get(empId);
                 // prd 离职时间: 若有离职时间, 该日之后的应报工时不再生成
                 LocalDate offlineDate = parseToLocalDate(info.get(PERSONNEL_OFFLINE_DATE));
                 // prd 入职时间: 入职日前不生成, 入职当天保留
@@ -894,19 +880,19 @@ public class WorkHoursCalcService {
                             skippedBeforeHired.incrementAndGet();
                             continue;
                         }
-                        // prd 所有员工均须在启用项目中且当日未下线;无项目不统计应填报工时
-                        if (!hasActiveProject(projectAssignments, workDay)) {
+                        // prd 外部员工须在启用项目中且当日未下线;内部员工不校验项目
+                        if (!isInternal && !hasActiveProject(externalAssignments, workDay)) {
                             skippedNoProject.incrementAndGet();
                             continue;
                         }
-                        // prd 外部员工: 当天参与项目的 PM 集合为空(无项目/PM 全空/项目全下线) → 不生成记录
+                        // prd 内部员工允许空 Manager;外部员工仍必须存在启用项目及有效项目 PM
                         List<String> managerIds;
                         if (isInternal) {
                             managerIds = (internalMgrId == null || internalMgrId.isEmpty())
                                     ? Collections.emptyList()
                                     : Collections.singletonList(internalMgrId);
                         } else {
-                            managerIds = computeDailyPms(projectAssignments, workDay);
+                            managerIds = computeDailyPms(externalAssignments, workDay);
                             if (managerIds.isEmpty()) {
                                 skippedNoPm.incrementAndGet();
                                 continue;
@@ -962,7 +948,7 @@ public class WorkHoursCalcService {
                 log.info("入职日期过滤: 跳过{}条 workDay < hiredDate 的记录", skippedBeforeHired.get());
             }
             if (skippedNoProject.get() > 0) {
-                log.info("启用项目参与过滤: 跳过{}条 当天未参与启用项目的记录", skippedNoProject.get());
+                log.info("外部员工启用项目参与过滤: 跳过{}条 当天未参与启用项目的记录", skippedNoProject.get());
             }
             if (skippedNoPm.get() > 0) {
                 log.info("外部员工无 PM 过滤: 跳过{}条 当天无活跃项目 PM 的记录", skippedNoPm.get());
@@ -1540,14 +1526,8 @@ public class WorkHoursCalcService {
         int currentPage = 1;
         long totalCount;
         do {
-            DDR_New result = ydClient.queryData(YDParam.builder()
-                    .appType(appType)
-                    .systemToken(systemToken)
-                    .formUuid(whConf.getFormUuidRequiredHours())
-                    .searchFieldJson(searchFieldJson)
-                    .currentPage(currentPage)
-                    .pageSize(pageSize)
-                    .build(), YDConf.FORM_QUERY.retrieve_search_form);
+            DDR_New result = queryExistingHoursPage(
+                    appType, systemToken, searchFieldJson, fromDate, toDate, currentPage, pageSize);
 
             totalCount = result.getTotalCount();
             List<Map> dataList = (List<Map>) result.getData();
@@ -1568,6 +1548,104 @@ public class WorkHoursCalcService {
         return keys;
     }
 
+    /**
+     * 查询一页已存在工时;宜搭临时服务异常时仅重试当前页,避免前面已完成的分页重复执行。
+     */
+    private DDR_New queryExistingHoursPage(String appType,
+                                           String systemToken,
+                                           String searchFieldJson,
+                                           LocalDate fromDate,
+                                           LocalDate toDate,
+                                           int currentPage,
+                                           int pageSize) {
+        for (int attempt = 1; attempt <= EXISTING_QUERY_MAX_ATTEMPTS; attempt++) {
+            try {
+                DDR_New result = ydClient.queryData(YDParam.builder()
+                        .appType(appType)
+                        .systemToken(systemToken)
+                        .formUuid(whConf.getFormUuidRequiredHours())
+                        .searchFieldJson(searchFieldJson)
+                        .currentPage(currentPage)
+                        .pageSize(pageSize)
+                        .build(), YDConf.FORM_QUERY.retrieve_search_form);
+                if (attempt > 1) {
+                    log.info("查询已有工时重试成功: range=[{} ~ {}], page={}, attempt={}/{}",
+                            fromDate, toDate, currentPage, attempt, EXISTING_QUERY_MAX_ATTEMPTS);
+                }
+                return result;
+            } catch (Exception e) {
+                boolean retryable = isRetryableExistingQueryError(e);
+                log.warn("查询已有工时失败: range=[{} ~ {}], page={}, pageSize={}, attempt={}/{}, retryable={}, code={}, source={}, message={}",
+                        fromDate, toDate, currentPage, pageSize, attempt, EXISTING_QUERY_MAX_ATTEMPTS,
+                        retryable, queryErrorCode(e), queryErrorSource(e), e.getMessage());
+                if (!retryable || attempt >= EXISTING_QUERY_MAX_ATTEMPTS) throw e;
+                sleepBeforeExistingQueryRetry(EXISTING_QUERY_RETRY_DELAYS_MS[attempt - 1]);
+            }
+        }
+        throw new IllegalStateException("查询已有工时重试流程异常结束");
+    }
+
+    private static String queryErrorCode(Exception e) {
+        return e instanceof McException ? ((McException) e).getCode() : e.getClass().getSimpleName();
+    }
+
+    private static String queryErrorSource(Exception e) {
+        return e instanceof McException ? ((McException) e).getSource() : "runtime";
+    }
+
+    /**
+     * 仅重试网络异常、服务端临时错误和明确限流;权限、参数、配置及程序错误立即失败。
+     */
+    private static boolean isRetryableExistingQueryError(Exception e) {
+        for (Throwable cause = e; cause != null; cause = cause.getCause()) {
+            if (cause instanceof IOException) return true;
+        }
+        if (!(e instanceof McException)) return false;
+
+        McException mcException = (McException) e;
+        return containsRetryableQueryMarker(mcException.getCode())
+                || containsRetryableQueryMarker(mcException.getMessage());
+    }
+
+    private static boolean containsRetryableQueryMarker(String value) {
+        if (value == null || value.trim().isEmpty()) return false;
+        String normalized = value.trim().toLowerCase(Locale.ROOT);
+        return "429".equals(normalized)
+                || "500".equals(normalized)
+                || "502".equals(normalized)
+                || "503".equals(normalized)
+                || "504".equals(normalized)
+                || "90002".equals(normalized)
+                || normalized.contains("temporary")
+                || normalized.contains("temporarily")
+                || normalized.contains("timeout")
+                || normalized.contains("timed out")
+                || normalized.contains("service unavailable")
+                || normalized.contains("system busy")
+                || normalized.contains("too many request")
+                || normalized.contains("rate limit")
+                || normalized.contains("throttl")
+                || normalized.contains("请求过于频繁")
+                || normalized.contains("系统繁忙")
+                || normalized.contains("服务暂时")
+                || normalized.contains("服务端临时")
+                || normalized.contains("限流");
+    }
+
+    private void sleepBeforeExistingQueryRetry(long delayMs) {
+        try {
+            existingQueryRetrySleeper.sleep(delayMs);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new IllegalStateException("查询已有工时重试等待被中断", e);
+        }
+    }
+
+    @FunctionalInterface
+    interface RetrySleeper {
+        void sleep(long delayMs) throws InterruptedException;
+    }
+
     // ==================== 数据查询 ====================
 
     /**
@@ -1685,6 +1763,11 @@ public class WorkHoursCalcService {
                     info.put(PERSONNEL_HIRED_DATE, formData.get(PERSONNEL_HIRED_DATE));
                     // prd 在职状态: 携带供 concurrentUpsert 防御 (status=离职 且 offlineDate=空 时跳过写入)
                     info.put(PERSONNEL_STATUS, formData.get(PERSONNEL_STATUS));
+                    // prd 内部员工主管单一来源:直接使用人员档案最近一次同步的 Manager
+                    List<String> personnelManagers = extractIdList(formData, PERSONNEL_MANAGER);
+                    if (!personnelManagers.isEmpty()) {
+                        info.put(PERSONNEL_MANAGER, personnelManagers.get(0));
+                    }
                     personnelMap.put(empId, info);
                 }
             }
@@ -1696,55 +1779,40 @@ public class WorkHoursCalcService {
 
     /**
      * 预取 Manager 数据:
-     * - 内部员工: 钉钉 API 取 manager_userid (直属主管, 单值)
-     * - 全体员工: 匹配启用项目 assignments,作为应填报工时准入条件
-     * - 外部员工: 再按天从 assignments 计算 PM,多项目合并去重
+     * - 内部员工: 直接使用人员档案已同步 Manager (单值,允许为空)
+     * - 外部员工: 匹配启用项目 assignments,再按天计算 PM,多项目合并去重
+     * - 内部员工: 不以项目参与关系作为应填报工时准入条件
      * ppExt: 与前端 TimeCard 刻意不同 - 不做 PM 离职探活、不兜底 Raymond (后端只记数据, 非审批找活人)
      *
      * @param personnelMap 全量人员档案
-     * @return ManagerData: internal=内部主管, projects=全员启用项目参与关系, external=外部员工项目参与关系
+     * @return ManagerData: internal=内部主管, external=外部员工启用项目参与关系
      */
-    @SuppressWarnings("unchecked")
     private ManagerData queryManagerData(Map<String, Map<String, Object>> personnelMap) {
         ManagerData md = new ManagerData();
+        Set<String> internalIds = new HashSet<>();
         Set<String> externalIds = new HashSet<>();
-        String accessToken = ddClient.getAccessToken();
-        int internalCount = 0;
 
-        // 内部员工取钉钉直属主管;同时记录外部员工集合,供项目匹配统计与 PM 计算
+        // 先分类;内部员工直接使用人员档案 Manager,外部员工再匹配项目
         for (Map.Entry<String, Map<String, Object>> entry : personnelMap.entrySet()) {
             String empId = entry.getKey();
-            Object attr = entry.getValue().get("radioField_mkow4ydo");
-
-            if ("内部".equals(String.valueOf(attr))) {
-                internalCount++;
-                try {
-                    Map userInfo = ddClient_contacts.getUserInfoById(accessToken, empId);
-                    if (userInfo != null && userInfo.get("manager_userid") != null) {
-                        String mgrId = String.valueOf(userInfo.get("manager_userid"));
-                        if (!mgrId.isEmpty() && !"null".equals(mgrId)) {
-                            md.internal.put(empId, mgrId);
-                        }
-                    }
-                } catch (Exception e) {
-                    log.warn("获取员工{}直属主管失败: {}", empId, e.getMessage());
+            if (isInternalEmployee(entry.getValue())) {
+                internalIds.add(empId);
+                String fallbackManagerId = personnelManagerId(entry.getValue());
+                if (fallbackManagerId != null) {
+                    md.internal.put(empId, fallbackManagerId);
                 }
             } else {
                 externalIds.add(empId);
             }
         }
 
-        // prd 全体员工都必须匹配启用项目;外部员工另保留子集用于兼容现有统计字段
-        md.projects = queryProjectAssignments(personnelMap.keySet());
-        for (String externalId : externalIds) {
-            List<Assignment> assignments = md.projects.get(externalId);
-            if (assignments != null && !assignments.isEmpty()) {
-                md.external.put(externalId, assignments);
-            }
+        // prd 仅外部员工匹配启用项目;内部员工只按人员在职及日期边界生成应填报工时
+        if (!externalIds.isEmpty()) {
+            md.external = queryProjectAssignments(externalIds);
         }
 
-        log.info("Manager 预取完成: 内部{}人取直属主管落地{}, 全员启用项目参与者{}, 外部{}人中{}人有启用项目",
-                internalCount, md.internal.size(), md.projects.size(), externalIds.size(), md.external.size());
+        log.info("Manager 预取完成: 内部{}人使用人员档案主管{}, 外部{}人中{}人有启用项目",
+                internalIds.size(), md.internal.size(), externalIds.size(), md.external.size());
         return md;
     }
 
@@ -1842,10 +1910,9 @@ public class WorkHoursCalcService {
         return new ArrayList<>(pms);
     }
 
-    /** 内部员工 → 直属主管;projects → 全员启用项目;external → 外部员工启用项目子集。 */
+    /** 内部员工 → 直属主管;外部员工 → 启用项目 assignments。 */
     private static class ManagerData {
         Map<String, String> internal = new HashMap<>();
-        Map<String, List<Assignment>> projects = new HashMap<>();
         Map<String, List<Assignment>> external = new HashMap<>();
     }
 
@@ -1926,7 +1993,23 @@ public class WorkHoursCalcService {
             return stats;
         }
 
-        // 2. 拉这些员工的项目 assignments (基于最新项目档案)
+        // prd 项目变更只处理外部员工;内部员工 Manager 始终来自钉钉直属主管,不读取项目 PM
+        Map<String, Map<String, Object>> personnelMap = queryAllPersonnelDetails();
+        int affectedBeforeInternalFilter = memberEffectiveDate.size();
+        memberEffectiveDate.entrySet().removeIf(entry -> isInternalEmployee(personnelMap.get(entry.getKey())));
+        int skippedInternalMembers = affectedBeforeInternalFilter - memberEffectiveDate.size();
+        stats.put("skippedInternalMembers", skippedInternalMembers);
+        stats.put("externalAffectedMembers", memberEffectiveDate.size());
+        if (memberEffectiveDate.isEmpty()) {
+            stats.put("updated", 0);
+            stats.put("skippedSame", 0);
+            stats.put("skippedNoPm", 0);
+            stats.put("fail", 0);
+            log.info("[项目变更同步] 涉及成员均为内部员工,跳过项目 PM 回写: {}人", skippedInternalMembers);
+            return stats;
+        }
+
+        // 2. 仅拉外部员工的项目 assignments (基于最新项目档案)
         Map<String, List<Assignment>> assignmentsMap = queryProjectAssignments(memberEffectiveDate.keySet());
 
         // 3. 对每员工扫其应报工时 (workDay >= effectiveDate), 逐条重算 Manager 并对比
@@ -1934,6 +2017,7 @@ public class WorkHoursCalcService {
         AtomicInteger updated = new AtomicInteger(0);
         AtomicInteger skippedSame = new AtomicInteger(0);
         AtomicInteger skippedNoPm = new AtomicInteger(0);
+        AtomicInteger skippedInternalRecords = new AtomicInteger(0);
         AtomicInteger fail = new AtomicInteger(0);
         ZoneId zone = ZoneId.systemDefault();
         RateLimiter yidaLimiter = RateLimiter.create(20.0);
@@ -1967,6 +2051,11 @@ public class WorkHoursCalcService {
                     Object instId = rec.get("formInstanceId");
                     Map<String, Object> fd = (Map<String, Object>) rec.get("formData");
                     if (instId == null || fd == null) continue;
+                    // fixme 人员档案缺失或属性变化时再做一次记录级防御,内部工时绝不按项目 PM 更新
+                    if (isInternalEmployee(fd)) {
+                        skippedInternalRecords.incrementAndGet();
+                        continue;
+                    }
                     LocalDate workDay = parseToLocalDate(fd.get("dateField_mmd8onl5"));
                     if (workDay == null || workDay.isBefore(effectiveDate)) continue;
 
@@ -2018,12 +2107,26 @@ public class WorkHoursCalcService {
         stats.put("updated", updated.get());
         stats.put("skippedSame", skippedSame.get());
         stats.put("skippedNoPm", skippedNoPm.get());
+        stats.put("skippedInternalRecords", skippedInternalRecords.get());
         stats.put("fail", fail.get());
-        log.info("[项目变更同步] 完成: 扫描应报工时{}条, 更新{}, 值相同跳过{}, 无PM跳过{}, 失败{}",
-                totalScanned.get(), updated.get(), skippedSame.get(), skippedNoPm.get(), fail.get());
+        log.info("[项目变更同步] 完成: 扫描应报工时{}条, 更新{}, 值相同跳过{}, 无PM跳过{}, 内部员工记录跳过{}, 失败{}",
+                totalScanned.get(), updated.get(), skippedSame.get(), skippedNoPm.get(),
+                skippedInternalRecords.get(), fail.get());
         return stats;
     }
 
+    private static boolean isInternalEmployee(Map<String, Object> info) {
+        return info != null && "内部".equals(String.valueOf(info.get("radioField_mkow4ydo")));
+    }
+
+    private static String personnelManagerId(Map<String, Object> info) {
+        if (info == null) return null;
+        Object value = info.get(PERSONNEL_MANAGER);
+        if (value == null) return null;
+        String managerId = String.valueOf(value).trim();
+        return managerId.isEmpty() || "null".equalsIgnoreCase(managerId) ? null : managerId;
+    }
+
     /** 从 formData 里提取员工/成员字段 id 列表 (优先 _id 后缀; 字符串形态返回单元素列表) */
     @SuppressWarnings("unchecked")
     private List<String> extractIdList(Map<String, Object> data, String fieldId) {

+ 2 - 2
mjava-akdsbeisen/src/main/java/com/malk/timer/WorkHoursCalcTimer.java

@@ -30,7 +30,7 @@ public class WorkHoursCalcTimer {
         }
     }
 
-    // prd: 增量同步改为每天 2 次(03:30 / 12:45),全员 × 近 3 天工作日窗口 补漏
+    // prd: 增量同步每天 2 次(03:30 / 12:45),全员 × 近 7 个自然日内工作日窗口补漏
     // fixme: 已存在记录一律 skip (补漏语义, 字段刷新走 /workhours/backfill-* 接口);
     //        单条 cron 的时/分字段相互独立无法表达多个离散时点, 用 @Scheduled 可重复注解挂 2 条独立 cron
     @Scheduled(cron = "0 30 3 * * ?")
@@ -38,7 +38,7 @@ public class WorkHoursCalcTimer {
     public void calcDailyIncrementalSync() {
         log.info("开始执行应填报工时【增量】同步任务");
         try {
-            workHoursCalcService.incrementalSync(3);
+            workHoursCalcService.incrementalSync(7);
             log.info("应填报工时增量同步任务执行完成");
         } catch (Exception e) {
             log.error("应填报工时增量同步任务执行失败", e);

+ 289 - 0
mjava-akdsbeisen/src/test/java/com/malk/service/workhours/WorkHoursCalcServiceTest.java

@@ -2,6 +2,7 @@ package com.malk.service.workhours;
 
 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;
@@ -15,6 +16,7 @@ import java.time.ZoneId;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
 import java.util.LinkedHashMap;
 import java.util.Map;
@@ -22,11 +24,13 @@ import java.util.concurrent.atomic.AtomicBoolean;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.fail;
 import static org.junit.Assert.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
@@ -60,6 +64,240 @@ public class WorkHoursCalcServiceTest {
         assertEquals("workDay 在 today 之后, 按业务规则不写入未来数据", result.get("error"));
     }
 
+    @Test
+    public void syncOneInternalEmployeeShouldNotRequireProject() {
+        YDClient ydClient = mock(YDClient.class);
+        WHConf conf = requiredHoursConf();
+        Map<String, Object> personnel = personnelRecord("employee-1", "在职", null);
+        Map<String, Object> formData = (Map<String, Object>) personnel.get("formData");
+        formData.put("radioField_mkow4ydo", "内部");
+        formData.put("employeeField_mh8xhqc3_id", Collections.singletonList("manager-1"));
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(pageOf(Collections.singletonList(personnel)));
+
+        WorkHoursCalcService service = new WorkHoursCalcService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", conf);
+
+        Map<String, Object> result = service.syncOneEmployeeOneDay(
+                "employee-1", LocalDate.now().minusDays(1));
+
+        assertEquals(true, result.get("success"));
+        assertEquals(Collections.singletonList("manager-1"), result.get("managerIds"));
+        verify(ydClient).operateData(any(YDParam.class), eq(YDConf.FORM_OPERATION.upsert));
+    }
+
+    @Test
+    public void syncOneInternalEmployeeShouldUsePersonnelManagerWithoutAddressBookLookup() {
+        YDClient ydClient = mock(YDClient.class);
+        WHConf conf = requiredHoursConf();
+        Map<String, Object> personnel = personnelRecord("employee-1", "在职", null);
+        Map<String, Object> formData = (Map<String, Object>) personnel.get("formData");
+        formData.put("radioField_mkow4ydo", "内部");
+        formData.put("employeeField_mh8xhqc3_id", Collections.singletonList("manager-from-personnel"));
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(pageOf(Collections.singletonList(personnel)));
+
+        WorkHoursCalcService service = new WorkHoursCalcService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", conf);
+
+        Map<String, Object> result = service.syncOneEmployeeOneDay(
+                "employee-1", LocalDate.now().minusDays(1));
+
+        assertEquals(true, result.get("success"));
+        assertEquals(Collections.singletonList("manager-from-personnel"), result.get("managerIds"));
+        verify(ydClient).operateData(any(YDParam.class), eq(YDConf.FORM_OPERATION.upsert));
+    }
+
+    @Test
+    public void syncOneInternalEmployeeShouldWriteEmptyManagerWhenPersonnelManagerIsEmpty() {
+        YDClient ydClient = mock(YDClient.class);
+        WHConf conf = requiredHoursConf();
+        Map<String, Object> personnel = personnelRecord("employee-1", "在职", null);
+        Map<String, Object> formData = (Map<String, Object>) personnel.get("formData");
+        formData.put("radioField_mkow4ydo", "内部");
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(pageOf(Collections.singletonList(personnel)));
+
+        WorkHoursCalcService service = new WorkHoursCalcService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", conf);
+
+        Map<String, Object> result = service.syncOneEmployeeOneDay(
+                "employee-1", LocalDate.now().minusDays(1));
+
+        assertEquals(true, result.get("success"));
+        assertEquals(Collections.emptyList(), result.get("managerIds"));
+        verify(ydClient).operateData(any(YDParam.class), eq(YDConf.FORM_OPERATION.upsert));
+    }
+
+    @Test
+    public void concurrentUpsertShouldWriteInternalEmployeeWithoutManager() {
+        YDClient ydClient = mock(YDClient.class);
+
+        Map<String, Object> internal = new HashMap<>();
+        internal.put("radioField_mkow4ydo", "内部");
+        internal.put("radioField_mp1sngq1", "在职");
+        Map<String, Map<String, Object>> personnelMap = new HashMap<>();
+        personnelMap.put("employee-1", internal);
+
+        WorkHoursCalcService service = new WorkHoursCalcService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", requiredHoursConf());
+        Object managerData = ReflectionTestUtils.invokeMethod(service, "queryManagerData", personnelMap);
+
+        int[] result = ReflectionTestUtils.invokeMethod(
+                service,
+                "concurrentUpsert",
+                personnelMap,
+                managerData,
+                Collections.singletonList(LocalDate.now().minusDays(1)),
+                Collections.emptySet());
+
+        assertEquals(1, result[0]);
+        assertEquals(0, result[1]);
+        verify(ydClient).operateData(any(YDParam.class), eq(YDConf.FORM_OPERATION.upsert));
+    }
+
+    @Test
+    public void concurrentUpsertShouldUsePersonnelManager() {
+        YDClient ydClient = mock(YDClient.class);
+        WHConf conf = requiredHoursConf();
+
+        Map<String, Object> internal = new HashMap<>();
+        internal.put("radioField_mkow4ydo", "内部");
+        internal.put("radioField_mp1sngq1", "在职");
+        internal.put("employeeField_mh8xhqc3", "manager-from-personnel");
+        Map<String, Map<String, Object>> personnelMap = new HashMap<>();
+        personnelMap.put("employee-1", internal);
+
+        WorkHoursCalcService service = new WorkHoursCalcService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", conf);
+        Object managerData = ReflectionTestUtils.invokeMethod(service, "queryManagerData", personnelMap);
+
+        int[] result = ReflectionTestUtils.invokeMethod(
+                service,
+                "concurrentUpsert",
+                personnelMap,
+                managerData,
+                Collections.singletonList(LocalDate.now().minusDays(1)),
+                Collections.emptySet());
+
+        assertEquals(1, result[0]);
+        assertEquals(0, result[1]);
+        verify(ydClient).operateData(any(YDParam.class), eq(YDConf.FORM_OPERATION.upsert));
+    }
+
+    @Test
+    public void syncOneExternalEmployeeShouldStillRequireEnabledProject() {
+        YDClient ydClient = mock(YDClient.class);
+        WHConf conf = requiredHoursConf();
+        Map<String, Object> personnel = personnelRecord("employee-1", "在职", null);
+        Map<String, Object> formData = (Map<String, Object>) personnel.get("formData");
+        formData.put("radioField_mkow4ydo", "外部");
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(
+                        pageOf(Collections.singletonList(personnel)),
+                        pageOf(Collections.emptyList()));
+
+        WorkHoursCalcService service = new WorkHoursCalcService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", conf);
+
+        Map<String, Object> result = service.syncOneEmployeeOneDay(
+                "employee-1", LocalDate.now().minusDays(1));
+
+        assertEquals(false, result.get("success"));
+        assertEquals("外部员工当日未参与启用项目, 按业务规则跳过写入", result.get("error"));
+        verify(ydClient, never()).operateData(any(YDParam.class), eq(YDConf.FORM_OPERATION.upsert));
+    }
+
+    @Test
+    public void queryManagerDataShouldNotQueryProjectsForInternalEmployees() {
+        YDClient ydClient = mock(YDClient.class);
+
+        Map<String, Object> internal = new HashMap<>();
+        internal.put("radioField_mkow4ydo", "内部");
+        internal.put("employeeField_mh8xhqc3", "manager-from-personnel");
+        Map<String, Map<String, Object>> personnelMap = new HashMap<>();
+        personnelMap.put("employee-1", internal);
+
+        WorkHoursCalcService service = new WorkHoursCalcService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+
+        ReflectionTestUtils.invokeMethod(service, "queryManagerData", personnelMap);
+
+        verify(ydClient, never()).queryData(
+                any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form));
+    }
+
+    @Test
+    public void queryManagerDataShouldQueryExternalProjectsWithoutAddressBookDependency() {
+        YDClient ydClient = mock(YDClient.class);
+        WHConf conf = requiredHoursConf();
+        conf.setFormUuidProject("projects");
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(pageOf(Collections.emptyList()));
+
+        Map<String, Object> internal = new HashMap<>();
+        internal.put("radioField_mkow4ydo", "内部");
+        Map<String, Object> external = new HashMap<>();
+        external.put("radioField_mkow4ydo", "外部");
+        Map<String, Map<String, Object>> personnelMap = new LinkedHashMap<>();
+        personnelMap.put("internal-1", internal);
+        personnelMap.put("external-1", external);
+
+        WorkHoursCalcService service = new WorkHoursCalcService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", conf);
+
+        ReflectionTestUtils.invokeMethod(service, "queryManagerData", personnelMap);
+
+        verify(ydClient).queryData(
+                any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form));
+    }
+
+    @Test
+    public void syncProjectChangesShouldExcludeInternalEmployeesBeforeProjectLookup() {
+        YDClient ydClient = mock(YDClient.class);
+        WHConf conf = requiredHoursConf();
+        conf.setFormUuidProjectChange("project-change");
+        conf.setFormUuidProject("projects");
+
+        Map<String, Object> member = new LinkedHashMap<>();
+        member.put("employeeField_mmbfe0ij_id", Collections.singletonList("internal-1"));
+        Map<String, Object> changeFormData = new LinkedHashMap<>();
+        changeFormData.put("tableField_mkowyn6d", Collections.singletonList(member));
+        Map<String, Object> changeRecord = new LinkedHashMap<>();
+        changeRecord.put("formInstanceId", "change-1");
+        changeRecord.put("gmtModified", LocalDate.now().atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli());
+        changeRecord.put("formData", changeFormData);
+
+        Map<String, Object> personnel = personnelRecord("internal-1", "在职", null);
+        Map<String, Object> personnelFormData = (Map<String, Object>) personnel.get("formData");
+        personnelFormData.put("radioField_mkow4ydo", "内部");
+
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(
+                        pageOf(Collections.singletonList(changeRecord)),
+                        pageOf(Collections.singletonList(personnel)));
+
+        WorkHoursCalcService service = new WorkHoursCalcService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", conf);
+
+        Map<String, Object> stats = service.syncProjectChanges(7);
+
+        assertEquals(1, stats.get("skippedInternalMembers"));
+        assertEquals(0, stats.get("externalAffectedMembers"));
+        assertEquals(0, stats.get("updated"));
+        verify(ydClient, times(2)).queryData(
+                any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form));
+        verify(ydClient, never()).operateData(any(YDParam.class), eq(YDConf.FORM_OPERATION.update));
+    }
+
     @Test
     public void isAfterOfflineDateShouldKeepOfflineDayAndRejectFollowingDay() {
         LocalDate offlineDate = LocalDate.of(2026, 7, 15);
@@ -322,6 +560,57 @@ public class WorkHoursCalcServiceTest {
         assertFalse(withoutProject);
     }
 
+    @Test
+    public void queryExistingHoursKeysShouldRetryCurrentPageAfterTemporaryFailures() {
+        YDClient ydClient = mock(YDClient.class);
+        WHConf conf = requiredHoursConf();
+        LocalDate workDay = LocalDate.of(2026, 8, 26);
+        DDR_New<Object> successPage = pageOf(Collections.singletonList(
+                requiredHoursRecord("instance-1", "employee-1", workDay)));
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenThrow(new McException("TEMPORARY_FAILURE", "temporary failure"))
+                .thenThrow(new McException("TEMPORARY_FAILURE", "temporary failure"))
+                .thenReturn(successPage);
+
+        WorkHoursCalcService service = new WorkHoursCalcService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", conf);
+        List<Long> retryDelays = new ArrayList<>();
+        ReflectionTestUtils.setField(service, "existingQueryRetrySleeper",
+                (WorkHoursCalcService.RetrySleeper) retryDelays::add);
+
+        java.util.Set<String> keys = ReflectionTestUtils.invokeMethod(
+                service, "queryExistingHoursKeys", workDay, workDay);
+
+        assertEquals(Collections.singleton("employee-1|2026-08-26"), keys);
+        verify(ydClient, times(3)).queryData(
+                any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form));
+        assertEquals(Arrays.asList(2_000L, 5_000L), retryDelays);
+    }
+
+    @Test
+    public void queryExistingHoursKeysShouldNotRetryPermanentFailure() {
+        YDClient ydClient = mock(YDClient.class);
+        WHConf conf = requiredHoursConf();
+        LocalDate workDay = LocalDate.of(2026, 8, 26);
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenThrow(new McException("INVALID_PARAMETER", "formUuid 参数错误"));
+
+        WorkHoursCalcService service = new WorkHoursCalcService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", conf);
+
+        try {
+            ReflectionTestUtils.invokeMethod(service, "queryExistingHoursKeys", workDay, workDay);
+            fail("永久性错误应立即抛出");
+        } catch (McException e) {
+            assertEquals("INVALID_PARAMETER", e.getCode());
+        }
+
+        verify(ydClient, times(1)).queryData(
+                any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form));
+    }
+
     private static Object assignment(String projectInstanceId,
                                      LocalDate offlineDate,
                                      String managerId) throws Exception {

+ 15 - 0
mjava-akdsbeisen/src/test/java/com/malk/timer/WorkHoursTimerScheduleTest.java

@@ -1,8 +1,10 @@
 package com.malk.timer;
 
+import com.malk.service.workhours.WorkHoursCalcService;
 import org.junit.Test;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.test.util.ReflectionTestUtils;
 
 import java.lang.reflect.Method;
 import java.util.Arrays;
@@ -14,6 +16,8 @@ import java.util.Set;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
 
 public class WorkHoursTimerScheduleTest {
 
@@ -24,6 +28,17 @@ public class WorkHoursTimerScheduleTest {
         assertEquals(setOf("0 30 3 * * ?", "0 45 12 * * ?"), scheduledCrons(method));
     }
 
+    @Test
+    public void workHoursIncrementalTimerShouldUseSevenDayRecoveryWindow() {
+        WorkHoursCalcService service = mock(WorkHoursCalcService.class);
+        WorkHoursCalcTimer timer = new WorkHoursCalcTimer();
+        ReflectionTestUtils.setField(timer, "workHoursCalcService", service);
+
+        timer.calcDailyIncrementalSync();
+
+        verify(service).incrementalSync(7);
+    }
+
     @Test
     public void workHoursTimerShouldRespectGlobalSchedulingSwitch() {
         ConditionalOnProperty condition = WorkHoursCalcTimer.class.getAnnotation(ConditionalOnProperty.class);