瀏覽代碼

fix(workhours): 完成工时补漏清理与历史回填

malk 3 周之前
父節點
當前提交
ec39476819

+ 5 - 0
mjava-akdsbeisen/pom.xml

@@ -36,6 +36,11 @@
             <artifactId>spring-boot-configuration-processor</artifactId>
             <optional>true</optional>
         </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 
     <build>

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

@@ -123,6 +123,31 @@ public class WorkHoursController {
         return result;
     }
 
+    /**
+     * 回填存量应填报工时的「归属公司」+「属性」两字段, 值取自人员档案
+     * ppExt: 一次性接口, 不设定时; 主流程 upsertDailyHours 已带最新两字段, 后续变动自动同步, 老记录靠本接口一次对齐
+     * GET /workhours/backfill-company-attr            (实际更新)
+     * GET /workhours/backfill-company-attr?dryRun=true(仅扫描预览, 不更新)
+     */
+    @GetMapping("/backfill-company-attr")
+    public Map<String, Object> backfillCompanyAttr(@RequestParam(defaultValue = "false") boolean dryRun) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        try {
+            long start = System.currentTimeMillis();
+            Map<String, Object> stats = workHoursCalcService.backfillCompanyAndAttribute(dryRun);
+            long cost = System.currentTimeMillis() - start;
+            result.put("success", true);
+            result.put("message", dryRun ? "公司+属性回填预览完成(未更新)" : "公司+属性回填完成");
+            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/cleanup-after-offline            (实际删除)
@@ -147,6 +172,33 @@ public class WorkHoursController {
         return result;
     }
 
+    /**
+     * 清理已写入的"未来"应报工时 (workDay > cutoff, cutoff 缺省 today)
+     * GET /workhours/cleanup-future                    (实际删除, cutoff=today)
+     * GET /workhours/cleanup-future?dryRun=true        (仅预览)
+     * GET /workhours/cleanup-future?cutoff=2026-07-15  (指定保留截止日)
+     */
+    @GetMapping("/cleanup-future")
+    public Map<String, Object> cleanupFuture(@RequestParam(required = false) String cutoff,
+                                             @RequestParam(defaultValue = "false") boolean dryRun) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        try {
+            LocalDate cutoffDate = (cutoff == null || cutoff.isEmpty()) ? null : LocalDate.parse(cutoff);
+            long start = System.currentTimeMillis();
+            Map<String, Object> stats = workHoursCalcService.cleanupFutureHours(cutoffDate, dryRun);
+            long cost = System.currentTimeMillis() - start;
+            result.put("success", true);
+            result.put("message", dryRun ? "未来工时清理预览完成(未删除)" : "未来工时清理完成");
+            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;
+    }
+
     /**
      * 项目变更审批增量同步(手动触发;定时器 ProjectChangeSyncTimer 定期跑)
      * GET /workhours/sync-project-changes?daysBack=7

+ 467 - 87
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/WorkHoursCalcService.java

@@ -47,6 +47,10 @@ public class WorkHoursCalcService {
     private static final int THREAD_POOL_SIZE = 10;
     private static final int MAX_RETRY = 2;
 
+    // prd 增量同步只覆盖最近 N 天工作日窗口 (含 today), 避免服务器停机/单次失败导致数据永久丢失
+    // fixme 窗口内已存在的记录一律 skip (补漏语义, 不刷新已写入字段); 字段刷新走独立接口 (backfill / cleanup)
+    private static final int INCREMENTAL_WINDOW_DAYS = 3;
+
     // prd 离职时间字段(人员档案侧, 用于过滤离职后不再生成/清理历史应报工时)
     private static final String PERSONNEL_OFFLINE_DATE = "dateField_mh8xhqc7";
     // prd 在职状态字段 + 离职取值 (防御开关: status=离职 且 offlineDate=空 → 数据不一致, 该员工不写工时)
@@ -99,51 +103,75 @@ public class WorkHoursCalcService {
         log.info("{}年{}月工作日{}天", year, month, workingDays.size());
         stats.put("workingDays", workingDays.size());
 
-        // 5. 多线程并发写入:按员工维度分任务
-        int[] counts = concurrentUpsert(personnelMap, managerData, workingDays);
+        // 5. 预查当月已存在记录 (empId|yyyy-MM-dd 集合), 已存在则 skip (补漏语义)
+        LocalDate monthStart = LocalDate.of(year, month, 1);
+        LocalDate monthEnd = monthStart.plusMonths(1).minusDays(1);
+        Set<String> existKeys = queryExistingHoursKeys(monthStart, monthEnd);
+        log.info("全量: 当月已存在记录{}条", existKeys.size());
+        stats.put("existingCount", existKeys.size());
+
+        // 6. 多线程并发写入:按员工维度分任务
+        int[] counts = concurrentUpsert(personnelMap, managerData, workingDays, existKeys);
         stats.put("success", counts[0]);
         stats.put("fail", counts[1]);
-        log.info("应填报工时写入完成: 成功{}条, 失败{}条({}名员工 × {}个工作日)",
-                counts[0], counts[1], personnelMap.size(), workingDays.size());
+        log.info("应填报工时写入完成: 成功{}条, 失败{}条({}名员工 × {}个工作日, 已存在{}条 skip)",
+                counts[0], counts[1], personnelMap.size(), workingDays.size(), existKeys.size());
         return stats;
     }
 
     /**
-     * 增量同步:查询最近 N 天内修改过的人员档案,仅同步变动员工的当月数据
+     * 增量同步:全员 × 近 N 天工作日窗口(含 today), 已存在的记录一律 skip (补漏语义)
+     * ppExt: 用途 = 服务器停机/单次失败 补齐; 姓名/部门等字段刷新走 backfill 接口(不在增量做)
      *
-     * @param daysBack 回溯天数(默认2天)
+     * @param daysBack 窗口天数(含 today), 默认 3
      */
     public void incrementalSync(int daysBack) {
+        if (daysBack <= 0) daysBack = INCREMENTAL_WINDOW_DAYS;
         LocalDate today = LocalDate.now();
+        LocalDate fromDate = today.minusDays(daysBack - 1);
         int year = today.getYear();
         int month = today.getMonthValue();
-        LocalDate fromDate = today.minusDays(daysBack);
-        // modifiedToTimeGMT 默认0点,需要加1天确保包含当天
-        LocalDate toDate = today.plusDays(1);
-
-        log.info("开始增量同步: 查询{}~{}修改的人员档案, 同步{}年{}月数据",
-                fromDate, toDate, year, month);
+        log.info("开始增量同步: 全员 × [{} ~ {}] {}天工作日窗口, existKeys 命中则 skip", fromDate, today, daysBack);
 
-        // 1. 查询最近修改的人员档案
-        Map<String, Map<String, Object>> personnelMap = queryRecentPersonnelDetails(fromDate, toDate);
-        log.info("增量: 最近修改的人员档案{}条", personnelMap.size());
+        // 1. 全量人员档案 (增量不再按 gmtModified 过滤; 新员工/离职员工都靠全体扫 + skip 自然收敛)
+        Map<String, Map<String, Object>> personnelMap = queryAllPersonnelDetails();
         if (personnelMap.isEmpty()) {
-            log.info("无人员档案变动,增量同步跳过");
+            log.warn("增量: 人员档案为空, 跳过");
             return;
         }
 
         // 2. 预取 Manager (内部=钉钉主管, 外部=项目 assignments)
         ManagerData managerData = queryManagerData(personnelMap);
 
-        // 3~4 查询节假日和工作日
+        // 3~4. 节假日 + 收窄到窗口内工作日 (跨月边界: 若窗口跨 6-30/7-1, 分别按各自月份的节假日规则)
         Map<LocalDate, String> holidayRules = queryHolidayRules(String.valueOf(year));
-        List<LocalDate> workingDays = getWorkingDays(year, month, holidayRules);
-        log.info("{}年{}月工作日{}天", year, month, workingDays.size());
+        List<LocalDate> monthWorkingDays = getWorkingDays(year, month, holidayRules);
+        List<LocalDate> windowDays = new ArrayList<>();
+        for (LocalDate d : monthWorkingDays) {
+            if (!d.isBefore(fromDate) && !d.isAfter(today)) windowDays.add(d);
+        }
+        // fixme 跨月场景 (如 daysBack=3 today=7-2): 补上 fromDate 所在月的工作日 (6-30 等)
+        if (fromDate.getMonthValue() != month) {
+            Map<LocalDate, String> prevRules = queryHolidayRules(String.valueOf(fromDate.getYear()));
+            List<LocalDate> prevMonthWorkingDays = getWorkingDays(fromDate.getYear(), fromDate.getMonthValue(), prevRules);
+            for (LocalDate d : prevMonthWorkingDays) {
+                if (!d.isBefore(fromDate) && !d.isAfter(today)) windowDays.add(d);
+            }
+        }
+        log.info("增量窗口工作日: {}天 ({})", windowDays.size(), windowDays);
+        if (windowDays.isEmpty()) {
+            log.info("增量: 窗口内无工作日, 跳过");
+            return;
+        }
 
-        // 5. 多线程并发写入
-        int[] counts = concurrentUpsert(personnelMap, managerData, workingDays);
-        log.info("增量同步完成: 成功{}条, 失败{}条({}名员工 × {}个工作日)",
-                counts[0], counts[1], personnelMap.size(), workingDays.size());
+        // 5. 预查窗口内已存在记录 (empId|yyyy-MM-dd 集合), 传入 concurrentUpsert 命中即 skip
+        Set<String> existKeys = queryExistingHoursKeys(fromDate, today);
+        log.info("增量: 窗口内已存在记录{}条", existKeys.size());
+
+        // 6. 并发写入
+        int[] counts = concurrentUpsert(personnelMap, managerData, windowDays, existKeys);
+        log.info("增量同步完成: 成功{}条, 失败{}条 ({}名员工 × {}天工作日, 已存在{}条 skip)",
+                counts[0], counts[1], personnelMap.size(), windowDays.size(), existKeys.size());
     }
 
     /**
@@ -165,6 +193,12 @@ public class WorkHoursCalcService {
             result.put("error", "userId 和 workDay 不能为空");
             return result;
         }
+        // prd 只写到 today: 单条验证入口也拦截, 保持与主流程语义一致
+        if (workDay.isAfter(LocalDate.now())) {
+            result.put("success", false);
+            result.put("error", "workDay 在 today 之后, 按业务规则不写入未来数据");
+            return result;
+        }
 
         // 1. 全量查人员档案,取目标员工信息
         Map<String, Map<String, Object>> personnelMap = queryAllPersonnelDetails();
@@ -289,8 +323,8 @@ public class WorkHoursCalcService {
         List<LocalDate> workingDays = getWorkingDays(year, month, rules);
         stats.put("workingDays", workingDays.size());
 
-        // 5. 并发写入
-        int[] counts = concurrentUpsert(subset, managerData, workingDays);
+        // 5. 并发写入 (小批量验证入口: existKeys 传空集, 保持"必写"语义便于回归验证)
+        int[] counts = concurrentUpsert(subset, managerData, workingDays, Collections.emptySet());
         stats.put("success", counts[0]);
         stats.put("fail", counts[1]);
         log.info("小批量同步完成: 成功{}条, 失败{}条({}名员工 × {}个工作日)",
@@ -532,25 +566,256 @@ public class WorkHoursCalcService {
         return stats;
     }
 
+    /**
+     * 回填存量应填报工时记录的「归属公司」(textField_mmekrcji) + 「属性」(radioField_mkow4ydo)
+     * ppExt: 与 backfillCfEmployee 同款分月扫 + 20 QPS 限流 + 重试退避骨架; 仅字段清单与跳过逻辑不同;
+     *        主流程 upsertDailyHours 已带最新两字段, 本接口只对齐历史存量 (人员档案变更前已写入的记录)。
+     *        两字段独立比对: 只要一个不一致就纳入更新, JSON 仅写差异字段避免无谓覆盖。
+     *
+     * @param dryRun true 仅扫描预览不实际更新
+     * @return Map{total, toUpdate, updated, skippedNoInfo, skippedSame, fail, dryRun?}
+     */
+    @SuppressWarnings("unchecked")
+    public Map<String, Object> backfillCompanyAndAttribute(boolean dryRun) {
+        Map<String, Object> stats = new LinkedHashMap<>();
+        String appType = whConf.getYidaAppType();
+        String systemToken = whConf.getYidaSystemToken();
+
+        // 1. 预加载人员档案 (queryAllPersonnelDetails 已把 selectField_mh8xhqc4 归属公司塞到 textField_mmekrcji)
+        Map<String, Map<String, Object>> personnelMap = queryAllPersonnelDetails();
+        log.info("回填公司+属性: 人员档案共{}条", personnelMap.size());
+        if (personnelMap.isEmpty()) {
+            log.warn("回填公司+属性: 人员档案为空, 跳过");
+            stats.put("total", 0);
+            return stats;
+        }
+
+        // 2. 分月扫应报工时, 收集待更新 [instId, targetCompany, targetAttr]
+        //    fixme: 宜搭 search 仅支持前 30000 条, 按【应填报日期】逐月分区绕过
+        List<String[]> toUpdate = new ArrayList<>();
+        int total = 0, skippedNoInfo = 0, skippedSame = 0, skippedEmptySource = 0;
+        int pageSize = YDConf.PAGE_SIZE_LIMIT;
+        ZoneId zone = ZoneId.systemDefault();
+        LocalDate monthCursor = LocalDate.of(2026, 4, 1);
+        LocalDate scanEnd = LocalDate.now().withDayOfMonth(1).plusMonths(1);
+        while (monthCursor.isBefore(scanEnd)) {
+            LocalDate nextMonth = monthCursor.plusMonths(1);
+            long startMs = monthCursor.atStartOfDay(zone).toInstant().toEpochMilli();
+            long endMs = nextMonth.atStartOfDay(zone).toInstant().toEpochMilli() - 1;
+            Map<String, Object> dateRange = new HashMap<>();
+            dateRange.put("dateField_mmd8onl5", Arrays.asList(startMs, endMs));
+            String searchFieldJson = JSON.toJSONString(dateRange);
+
+            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);
+
+                totalCount = result.getTotalCount();
+                List<Map> dataList = (List<Map>) result.getData();
+                if (dataList == null || dataList.isEmpty()) break;
+
+                for (Map item : dataList) {
+                    total++;
+                    Object instId = item.get("formInstanceId");
+                    Map<String, Object> formData = (Map<String, Object>) item.get("formData");
+                    if (instId == null || formData == null) {
+                        skippedNoInfo++;
+                        continue;
+                    }
+                    String empId = extractEmployeeId(formData, "employeeField_mmd8onl4");
+                    Map<String, Object> info = empId == null ? null : personnelMap.get(empId);
+                    if (info == null) {
+                        skippedNoInfo++;
+                        continue;
+                    }
+                    Object targetCompany = info.get("textField_mmekrcji");
+                    Object targetAttr = info.get("radioField_mkow4ydo");
+                    Object curCompany = formData.get("textField_mmekrcji");
+                    Object curAttr = formData.get("radioField_mkow4ydo");
+
+                    boolean companyEq = isFieldEqual(curCompany, targetCompany);
+                    boolean attrEq = isFieldEqual(curAttr, targetAttr);
+                    Map<String, String> updateFields = buildCompanyAttributeUpdate(
+                            curCompany, targetCompany, curAttr, targetAttr);
+                    if (updateFields.isEmpty()) {
+                        if (companyEq && attrEq) {
+                            skippedSame++;
+                        } else {
+                            // fixme 人员档案源值为空时不清空历史值, 仅统计并跳过
+                            skippedEmptySource++;
+                        }
+                        continue;
+                    }
+                    // 记录待更新: 只携带“目标非空且与当前值不同”的字段
+                    toUpdate.add(new String[]{
+                            String.valueOf(instId),
+                            updateFields.get("textField_mmekrcji"),
+                            updateFields.get("radioField_mkow4ydo")
+                    });
+                }
+                currentPage++;
+            } while ((long) (currentPage - 1) * pageSize < totalCount);
+
+            log.info("回填公司+属性扫描[{}]: 累计扫描{}, 待更新{}", monthCursor, total, toUpdate.size());
+            monthCursor = nextMonth;
+        }
+
+        log.info("回填公司+属性扫描完成: 共{}条, 待更新{}, 无档案跳过{}, 已一致跳过{}, 空源值跳过{}",
+                total, toUpdate.size(), skippedNoInfo, skippedSame, skippedEmptySource);
+
+        stats.put("total", total);
+        stats.put("toUpdate", toUpdate.size());
+        stats.put("skippedNoInfo", skippedNoInfo);
+        stats.put("skippedSame", skippedSame);
+        stats.put("skippedEmptySource", skippedEmptySource);
+        if (dryRun) {
+            log.info("回填公司+属性: dryRun 模式, 仅预览不更新");
+            stats.put("updated", 0);
+            stats.put("fail", 0);
+            stats.put("dryRun", true);
+            return stats;
+        }
+
+        // 3. 并发更新 (10 线程 + 20 QPS 限流 + 重试退避)
+        AtomicInteger updated = new AtomicInteger(0);
+        AtomicInteger fail = new AtomicInteger(0);
+        RateLimiter yidaLimiter = RateLimiter.create(20.0);
+        ExecutorService executor = Executors.newFixedThreadPool(THREAD_POOL_SIZE);
+        try {
+            List<Future<?>> futures = new ArrayList<>();
+            for (String[] tri : toUpdate) {
+                futures.add(executor.submit(() -> {
+                    JSONObject upd = new JSONObject();
+                    if (tri[1] != null) upd.put("textField_mmekrcji", tri[1]);
+                    if (tri[2] != null) upd.put("radioField_mkow4ydo", tri[2]);
+                    if (upd.isEmpty()) return;      // fixme: 理论不会命中 (scan 阶段已过滤 companyEq && attrEq)
+                    boolean ok = false;
+                    for (int retry = 0; retry <= MAX_RETRY && !ok; retry++) {
+                        try {
+                            yidaLimiter.acquire();
+                            ydClient.operateData(YDParam.builder()
+                                    .appType(appType)
+                                    .systemToken(systemToken)
+                                    .formUuid(whConf.getFormUuidRequiredHours())
+                                    .formInstanceId(tri[0])
+                                    .updateFormDataJson(upd.toJSONString())
+                                    .ignoreEmpty(false)
+                                    .useLatestVersion(true)
+                                    .build(), YDConf.FORM_OPERATION.update);
+                            ok = true;
+                        } catch (Exception e) {
+                            if (retry < MAX_RETRY) {
+                                try {
+                                    Thread.sleep(1000L * (retry + 1));
+                                } catch (InterruptedException ie) {
+                                    Thread.currentThread().interrupt();
+                                }
+                            } else {
+                                fail.incrementAndGet();
+                                log.error("回填公司+属性失败: instId={}", tri[0], e);
+                            }
+                        }
+                    }
+                    if (ok) {
+                        int n = updated.incrementAndGet();
+                        if (n % 500 == 0) {
+                            log.info("回填公司+属性进度: 已更新{}/{}", n, toUpdate.size());
+                        }
+                    }
+                }));
+            }
+            for (Future<?> f : futures) {
+                try {
+                    f.get();
+                } catch (Exception e) {
+                    log.error("回填公司+属性线程执行异常", e);
+                }
+            }
+        } finally {
+            executor.shutdown();
+        }
+
+        stats.put("updated", updated.get());
+        stats.put("fail", fail.get());
+        log.info("回填公司+属性完成: 扫描{}, 更新{}, 失败{}, 无档案跳过{}, 已一致跳过{}, 空源值跳过{}",
+                total, updated.get(), fail.get(), skippedNoInfo, skippedSame, skippedEmptySource);
+        return stats;
+    }
+
+    /**
+     * 构造公司与属性字段更新: 仅更新人员档案中非空且与历史值不同的字段。
+     *
+     * @param currentCompany 当前归属公司
+     * @param targetCompany 人员档案归属公司
+     * @param currentAttribute 当前人员属性
+     * @param targetAttribute 人员档案人员属性
+     * @return 需要更新的字段和值
+     */
+    static Map<String, String> buildCompanyAttributeUpdate(Object currentCompany,
+                                                            Object targetCompany,
+                                                            Object currentAttribute,
+                                                            Object targetAttribute) {
+        Map<String, String> update = new LinkedHashMap<>();
+        putChangedNonEmptyValue(update, "textField_mmekrcji", currentCompany, targetCompany);
+        putChangedNonEmptyValue(update, "radioField_mkow4ydo", currentAttribute, targetAttribute);
+        return update;
+    }
+
+    private static void putChangedNonEmptyValue(Map<String, String> update,
+                                                String fieldId,
+                                                Object currentValue,
+                                                Object targetValue) {
+        String target = targetValue == null ? "" : String.valueOf(targetValue).trim();
+        if (!target.isEmpty() && !isFieldEqual(currentValue, target)) {
+            update.put(fieldId, target);
+        }
+    }
+
+    /**
+     * 字段等值比对: null/空白视为相等, 其余用 toString 精确比较
+     * ppExt: 供 backfillCompanyAndAttribute 判断"已一致跳过", 避免把空字符串 vs null 误判为差异
+     */
+    private static boolean isFieldEqual(Object a, Object b) {
+        String sa = a == null ? "" : String.valueOf(a).trim();
+        String sb = b == null ? "" : String.valueOf(b).trim();
+        return sa.equals(sb);
+    }
+
     // ==================== 多线程并发写入 ====================
 
     /**
      * 按员工维度多线程并发 upsert,每个线程处理一个员工的所有工作日
+     * ppExt: existKeys 传入调用方预查的 "empId|yyyy-MM-dd" 集合, 命中的直接跳过 (补漏语义, 不刷新已写入字段);
+     *        workDay > today 也跳过 (业务规则: 只写到当天为止)
      *
+     * @param existKeys 已存在的 empId|yyyy-MM-dd 键集合 (nullable, null 视为空集)
      * @return int[]{successCount, failCount}
      */
     private int[] concurrentUpsert(Map<String, Map<String, Object>> personnelMap,
                                    ManagerData managerData,
-                                   List<LocalDate> workingDays) {
+                                   List<LocalDate> workingDays,
+                                   Set<String> existKeys) {
         AtomicInteger successCount = new AtomicInteger(0);
         AtomicInteger failCount = new AtomicInteger(0);
         // fixme: 宜搭写接口有突发 QPS 上限,10 线程裸跑会零星触发「请求过于频繁」导致单条记录被丢(按人散落缺日,
         //        如吕加冕缺 6-16/6-25)。与 backfillCfEmployee 同款处方:20 QPS 全局限流 + 重试退避,补齐丢失记录。
         RateLimiter yidaLimiter = RateLimiter.create(20.0);
         ExecutorService executor = Executors.newFixedThreadPool(THREAD_POOL_SIZE);
+        final Set<String> existSet = existKeys == null ? Collections.emptySet() : existKeys;
+        final LocalDate today = LocalDate.now();
         try {
             List<Future<?>> futures = new ArrayList<>();
 
+            AtomicInteger skippedFuture = new AtomicInteger(0);
+            AtomicInteger skippedExisting = new AtomicInteger(0);
             AtomicInteger skippedOffline = new AtomicInteger(0);
             AtomicInteger skippedNoPm = new AtomicInteger(0);
             AtomicInteger skippedInconsistent = new AtomicInteger(0);
@@ -574,6 +839,16 @@ public class WorkHoursCalcService {
 
                 futures.add(executor.submit(() -> {
                     for (LocalDate workDay : workingDays) {
+                        // prd 只写到 today: 未来日期由未来那天的定时任务写入, 避免"未来数据存在→字段/离职/PM 变更需级联刷新"复杂化
+                        if (workDay.isAfter(today)) {
+                            skippedFuture.incrementAndGet();
+                            continue;
+                        }
+                        // prd 补漏语义: 已存在记录直接 skip, 不刷新字段. 字段刷新走独立 backfill 接口 (/workhours/backfill-*)
+                        if (existSet.contains(empId + "|" + workDay)) {
+                            skippedExisting.incrementAndGet();
+                            continue;
+                        }
                         if (offlineDate != null && workDay.isAfter(offlineDate)) {
                             skippedOffline.incrementAndGet();
                             continue;
@@ -628,6 +903,12 @@ public class WorkHoursCalcService {
                     log.error("线程执行异常", e);
                 }
             }
+            if (skippedFuture.get() > 0) {
+                log.info("未来日期过滤: 跳过{}条 workDay > today({}) 的记录", skippedFuture.get(), today);
+            }
+            if (skippedExisting.get() > 0) {
+                log.info("已存在过滤: 跳过{}条 empId|workDay 已存在的记录(补漏语义, 字段刷新走 backfill 接口)", skippedExisting.get());
+            }
             if (skippedOffline.get() > 0) {
                 log.info("离职员工过滤: 跳过{}条 workDay > offlineDate 的记录", skippedOffline.get());
             }
@@ -775,6 +1056,166 @@ public class WorkHoursCalcService {
         return stats;
     }
 
+    /**
+     * 清理已写入的"未来"应报工时 (workDay > cutoff), 一次性接口
+     * ppExt: 与 cleanupAfterOffline 分月扫 + delete_batch 100 同款结构, 仅过滤条件不同
+     *
+     * @param cutoff 保留 workDay <= cutoff 的记录; null 默认 today
+     * @param dryRun true 仅统计不删除
+     * @return Map{cutoff, total, toDelete, deleted, fail, samples}
+     */
+    @SuppressWarnings("unchecked")
+    public Map<String, Object> cleanupFutureHours(LocalDate cutoff, boolean dryRun) {
+        Map<String, Object> stats = new LinkedHashMap<>();
+        if (cutoff == null) cutoff = LocalDate.now();
+        stats.put("cutoff", cutoff.toString());
+        String appType = whConf.getYidaAppType();
+        String systemToken = whConf.getYidaSystemToken();
+
+        List<String> toDelete = new ArrayList<>();
+        List<Map<String, Object>> samples = new ArrayList<>();
+        int total = 0;
+        int pageSize = YDConf.PAGE_SIZE_LIMIT;
+        ZoneId zone = ZoneId.systemDefault();
+        // 扫 cutoff 所在月起, 覆盖已写入的最远未来月份 (+6 个月足够, 主流程每月 1 号才生成下月记录)
+        LocalDate monthCursor = cutoff.withDayOfMonth(1);
+        LocalDate scanEnd = LocalDate.now().withDayOfMonth(1).plusMonths(6);
+        while (monthCursor.isBefore(scanEnd)) {
+            LocalDate nextMonth = monthCursor.plusMonths(1);
+            long startMs = monthCursor.atStartOfDay(zone).toInstant().toEpochMilli();
+            long endMs = nextMonth.atStartOfDay(zone).toInstant().toEpochMilli() - 1;
+            Map<String, Object> dateRange = new HashMap<>();
+            dateRange.put("dateField_mmd8onl5", Arrays.asList(startMs, endMs));
+            String searchFieldJson = JSON.toJSONString(dateRange);
+
+            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);
+
+                totalCount = result.getTotalCount();
+                List<Map> dataList = (List<Map>) result.getData();
+                if (dataList == null || dataList.isEmpty()) break;
+
+                for (Map item : dataList) {
+                    total++;
+                    Object instId = item.get("formInstanceId");
+                    Map<String, Object> formData = (Map<String, Object>) item.get("formData");
+                    if (instId == null || formData == null) continue;
+                    LocalDate workDay = parseToLocalDate(formData.get("dateField_mmd8onl5"));
+                    if (workDay != null && workDay.isAfter(cutoff)) {
+                        toDelete.add(String.valueOf(instId));
+                        if (samples.size() < 5) {
+                            Map<String, Object> s = new LinkedHashMap<>();
+                            s.put("instanceId", String.valueOf(instId));
+                            s.put("empId", extractEmployeeId(formData, "employeeField_mmd8onl4"));
+                            s.put("workDay", workDay.toString());
+                            samples.add(s);
+                        }
+                    }
+                }
+                currentPage++;
+            } while ((long) (currentPage - 1) * pageSize < totalCount);
+            log.info("清理未来工时扫描[{}]: 累计扫描{}, 待删除{}", monthCursor, total, toDelete.size());
+            monthCursor = nextMonth;
+        }
+
+        stats.put("total", total);
+        stats.put("toDelete", toDelete.size());
+        stats.put("samples", samples);
+        if (dryRun) {
+            stats.put("deleted", 0);
+            stats.put("fail", 0);
+            stats.put("dryRun", true);
+            log.info("清理未来工时: dryRun 模式, 仅预览; cutoff={}, 扫描{}, 待删除{}", cutoff, total, toDelete.size());
+            return stats;
+        }
+
+        int batchSize = 100;
+        int deleted = 0;
+        int fail = 0;
+        for (int i = 0; i < toDelete.size(); i += batchSize) {
+            List<String> batch = new ArrayList<>(toDelete.subList(i, Math.min(i + batchSize, toDelete.size())));
+            try {
+                ydClient.operateData(YDParam.builder()
+                        .appType(appType)
+                        .systemToken(systemToken)
+                        .formUuid(whConf.getFormUuidRequiredHours())
+                        .formInstanceIdList(batch)
+                        .build(), YDConf.FORM_OPERATION.delete_batch);
+                deleted += batch.size();
+                log.info("清理未来工时批次: 累计{}/{}", deleted, toDelete.size());
+            } catch (Exception e) {
+                fail += batch.size();
+                log.error("清理未来工时批次失败, offset={}, size={}", i, batch.size(), e);
+            }
+        }
+        stats.put("deleted", deleted);
+        stats.put("fail", fail);
+        log.info("清理未来工时完成: cutoff={}, 扫描{}, 删除{}, 失败{}", cutoff, total, deleted, fail);
+        return stats;
+    }
+
+    /**
+     * 预查指定日期范围内已存在的应报工时记录, 返回 "empId|yyyy-MM-dd" 键集合
+     * ppExt: 供 concurrentUpsert 补漏语义使用 (命中即 skip); 范围内不含重复员工+日期 (upsert 唯一键)
+     *
+     * @param fromDate 含
+     * @param toDate   含
+     */
+    @SuppressWarnings("unchecked")
+    private Set<String> queryExistingHoursKeys(LocalDate fromDate, LocalDate toDate) {
+        Set<String> keys = new HashSet<>();
+        if (fromDate == null || toDate == null || fromDate.isAfter(toDate)) return keys;
+
+        String appType = whConf.getYidaAppType();
+        String systemToken = whConf.getYidaSystemToken();
+        int pageSize = YDConf.PAGE_SIZE_LIMIT;
+        ZoneId zone = ZoneId.systemDefault();
+        long startMs = fromDate.atStartOfDay(zone).toInstant().toEpochMilli();
+        long endMs = toDate.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli() - 1;
+        Map<String, Object> dateRange = new HashMap<>();
+        dateRange.put("dateField_mmd8onl5", Arrays.asList(startMs, endMs));
+        String searchFieldJson = JSON.toJSONString(dateRange);
+
+        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);
+
+            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 empId = extractEmployeeId(formData, "employeeField_mmd8onl4");
+                LocalDate workDay = parseToLocalDate(formData.get("dateField_mmd8onl5"));
+                if (empId != null && workDay != null) {
+                    keys.add(empId + "|" + workDay);
+                }
+            }
+            currentPage++;
+        } while ((long) (currentPage - 1) * pageSize < totalCount);
+
+        return keys;
+    }
+
     // ==================== 数据查询 ====================
 
     /**
@@ -896,67 +1337,6 @@ public class WorkHoursCalcService {
         return personnelMap;
     }
 
-    /**
-     * 查询指定时间范围内修改过的人员档案(增量用)
-     *
-     * @param fromDate 修改开始日期(含)
-     * @param toDate   修改结束日期(不含,API 默认0点,需+1天)
-     */
-    @SuppressWarnings("unchecked")
-    private Map<String, Map<String, Object>> queryRecentPersonnelDetails(LocalDate fromDate, LocalDate toDate) {
-        Map<String, Map<String, Object>> personnelMap = new HashMap<>();
-        String appType = whConf.getYidaAppType();
-        String systemToken = whConf.getYidaSystemToken();
-        DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd");
-
-        int currentPage = 1;
-        int pageSize = YDConf.PAGE_SIZE_LIMIT;
-        long totalCount;
-
-        do {
-            DDR_New result = ydClient.queryData(YDParam.builder()
-                    .appType(appType)
-                    .systemToken(systemToken)
-                    .formUuid(whConf.getFormUuidPersonnel())
-                    .currentPage(currentPage)
-                    .pageSize(pageSize)
-                    .modifiedFromTimeGMT(fromDate.format(fmt))
-                    .modifiedToTimeGMT(toDate.format(fmt))
-                    .build(), YDConf.FORM_QUERY.retrieve_search_form);
-
-            totalCount = result.getTotalCount();
-            List<Map> dataList = (List<Map>) result.getData();
-            if (dataList == null || dataList.isEmpty()) break;
-
-            log.info("增量: 第{}页查到{}条修改的人员档案", currentPage, dataList.size());
-
-            for (Map item : dataList) {
-                Map<String, Object> formData = (Map<String, Object>) item.get("formData");
-                if (formData == null) continue;
-
-                String empId = extractEmployeeId(formData, "employeeField_mkow4ydp");
-                if (empId != null && !empId.isEmpty()) {
-                    Map<String, Object> info = new HashMap<>();
-                    info.put("radioField_mkow4ydo", formData.get("radioField_mkow4ydo"));
-                    info.put("textField_mh8xhqc1", formData.get("textField_mh8xhqc1"));
-                    info.put("departmentSelectField_mkow4ydr", formData.get("departmentSelectField_mkow4ydr_id"));
-                    // 归属公司:人员档案是 SelectField 下拉,直接取字符串;目标表单写入仍用 textField_mmekrcji
-                    info.put("textField_mmekrcji", formData.get("selectField_mh8xhqc4"));
-                    // prd: 是否cf员工,源 textField_mow9w7d8(人员档案)→ 目标 textField_mpp7a2k7(应填报工时)
-                    info.put("textField_mpp7a2k7", formData.get("textField_mow9w7d8"));
-                    // prd 离职时间: 携带原值供 concurrentUpsert 过滤 workDay > offlineDate
-                    info.put(PERSONNEL_OFFLINE_DATE, formData.get(PERSONNEL_OFFLINE_DATE));
-                    // prd 在职状态: 携带供 concurrentUpsert 防御 (status=离职 且 offlineDate=空 时跳过写入)
-                    info.put(PERSONNEL_STATUS, formData.get(PERSONNEL_STATUS));
-                    personnelMap.put(empId, info);
-                }
-            }
-            currentPage++;
-        } while ((long) (currentPage - 1) * pageSize < totalCount);
-
-        return personnelMap;
-    }
-
     /**
      * 预取 Manager 数据:
      * - 内部员工: 钉钉 API 取 manager_userid (直属主管, 单值)

+ 8 - 6
mjava-akdsbeisen/src/main/java/com/malk/timer/ProjectChangeSyncTimer.java

@@ -3,6 +3,7 @@ package com.malk.timer;
 import com.malk.service.workhours.WorkHoursCalcService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.scheduling.annotation.EnableScheduling;
 import org.springframework.scheduling.annotation.Scheduled;
@@ -10,23 +11,24 @@ import org.springframework.scheduling.annotation.Scheduled;
 /**
  * 项目变更审批增量同步定时器
  * <p>
- * 每天 3 次错峰扫描项目变更审批表单最近 7 天 gmtModified 的实例,
+ * 启用后每天 2 次错峰扫描项目变更审批表单最近 7 天 gmtModified 的实例,
  * 对涉及员工的应报工时按「变更生效日之后」重算 Manager (仅外部员工受影响; 内部员工 Manager 走钉钉主管不受本任务影响)。
  * <p>
- * cron 时点故意错开 WorkHoursCalcTimer 的 07:00/12:15/18:30, 避免宜搭 QPS 突发叠加。
+ * cron 时点故意错开 WorkHoursCalcTimer 的 03:30/12:45, 避免宜搭 QPS 突发叠加。
  */
 @Slf4j
 @Configuration
 @EnableScheduling
+// fixme 审批表 Schema 未核验前默认不注册定时器,避免按错误字段映射回写 Manager
+@ConditionalOnProperty(prefix = "workhours", name = "projectChangeSyncEnabled", havingValue = "true")
 public class ProjectChangeSyncTimer {
 
     @Autowired
     private WorkHoursCalcService workHoursCalcService;
 
-    // 每天 06:45 / 12:45 / 18:45 三次错峰
-    @Scheduled(cron = "0 45 6 * * ?")
-    @Scheduled(cron = "0 45 12 * * ?")
-    @Scheduled(cron = "0 45 18 * * ?")
+    // 每天 03:15 / 12:30 两次错峰 (均早于 WorkHoursCalcTimer 的 03:30 / 12:45 15 分钟, 避免 QPS 叠加)
+    @Scheduled(cron = "0 15 3 * * ?")
+    @Scheduled(cron = "0 30 12 * * ?")
     public void syncProjectChanges() {
         log.info("开始执行项目变更审批增量同步任务");
         try {

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

@@ -3,6 +3,7 @@ package com.malk.timer;
 import com.malk.service.workhours.WorkHoursCalcService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.scheduling.annotation.EnableScheduling;
 import org.springframework.scheduling.annotation.Scheduled;
@@ -10,6 +11,8 @@ import org.springframework.scheduling.annotation.Scheduled;
 @Slf4j
 @Configuration
 @EnableScheduling
+// fixme 遵循各 profile 的 enable.scheduling 总开关,dev=false 时不注册定时任务
+@ConditionalOnProperty(prefix = "enable", name = "scheduling", havingValue = "true")
 public class WorkHoursCalcTimer {
 
     @Autowired
@@ -27,16 +30,15 @@ public class WorkHoursCalcTimer {
         }
     }
 
-    // prd: 增量同步改为每天 3 次(07:00 / 12:15 / 18:30),查询人员档案最近2天修改的
-    // fixme: 单条 cron 的时/分字段相互独立,无法表达「7:00 与 12:15 与 18:30」三个离散时点
-    //        (会变成时×分笛卡尔积),故用 @Scheduled 可重复注解挂 3 条独立 cron
-    @Scheduled(cron = "0 0 7 * * ?")
-    @Scheduled(cron = "0 15 12 * * ?")
-    @Scheduled(cron = "0 30 18 * * ?")
+    // prd: 增量同步改为每天 2 次(03:30 / 12:45),全员 × 近 3 天工作日窗口 补漏
+    // fixme: 已存在记录一律 skip (补漏语义, 字段刷新走 /workhours/backfill-* 接口);
+    //        单条 cron 的时/分字段相互独立无法表达多个离散时点, 用 @Scheduled 可重复注解挂 2 条独立 cron
+    @Scheduled(cron = "0 30 3 * * ?")
+    @Scheduled(cron = "0 45 12 * * ?")
     public void calcDailyIncrementalSync() {
         log.info("开始执行应填报工时【增量】同步任务");
         try {
-            workHoursCalcService.incrementalSync(2);
+            workHoursCalcService.incrementalSync(3);
             log.info("应填报工时增量同步任务执行完成");
         } catch (Exception e) {
             log.error("应填报工时增量同步任务执行失败", e);

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

@@ -67,6 +67,8 @@ beisen:
   yidaSystemToken: "FOD66381NOS25MERLN2UK92FY96Y21UMHD7LM36S"
 
 workhours:
+  # fixme 项目变更审批表 Schema 核验通过后才允许开启自动回写
+  projectChangeSyncEnabled: false
   formUuidHoliday: "FORM-BMG66GA16LC4CNCZLUUTKAX0G27U3YTMC27NM1"
   formUuidPersonnel: "FORM-CCEEE5D461694CBAB5999A8C2926D0C1RXQP"
   formUuidRequiredHours: "FORM-D9144092C2C24C93A1B02A8EEED0663FFD8G"

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

@@ -51,6 +51,8 @@ beisen:
   yidaSystemToken: "FOD66381NOS25MERLN2UK92FY96Y21UMHD7LM36S"
 
 workhours:
+  # fixme 项目变更审批表 Schema 核验通过后才允许开启自动回写
+  projectChangeSyncEnabled: false
   formUuidHoliday: "FORM-BMG66GA16LC4CNCZLUUTKAX0G27U3YTMC27NM1"
   formUuidPersonnel: "FORM-CCEEE5D461694CBAB5999A8C2926D0C1RXQP"
   formUuidRequiredHours: "FORM-D9144092C2C24C93A1B02A8EEED0663FFD8G"

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

@@ -0,0 +1,143 @@
+package com.malk.service.workhours;
+
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.server.workhours.WHConf;
+import com.malk.service.aliwork.YDClient;
+import org.junit.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.lang.reflect.Constructor;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+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.verify;
+import static org.mockito.Mockito.when;
+
+public class WorkHoursCalcServiceTest {
+
+    @Test
+    public void buildCompanyAttributeUpdateShouldNotClearHistoryWhenSourceIsEmpty() {
+        Map<String, String> update = WorkHoursCalcService.buildCompanyAttributeUpdate(
+                "上海", null, "外部", "内部");
+
+        assertFalse(update.containsKey("textField_mmekrcji"));
+        assertEquals("内部", update.get("radioField_mkow4ydo"));
+    }
+
+    @Test
+    public void buildCompanyAttributeUpdateShouldOnlyContainChangedNonEmptyValues() {
+        Map<String, String> update = WorkHoursCalcService.buildCompanyAttributeUpdate(
+                "上海", "上海", "外部", "内部");
+
+        assertEquals(1, update.size());
+        assertEquals("内部", update.get("radioField_mkow4ydo"));
+    }
+
+    @Test
+    public void syncOneEmployeeOneDayShouldRejectFutureDateBeforeCallingExternalServices() {
+        WorkHoursCalcService service = new WorkHoursCalcService();
+
+        Map<String, Object> result = service.syncOneEmployeeOneDay("employee-1", LocalDate.now().plusDays(1));
+
+        assertEquals(false, result.get("success"));
+        assertEquals("workDay 在 today 之后, 按业务规则不写入未来数据", result.get("error"));
+    }
+
+    @Test
+    public void cleanupFutureHoursDryRunShouldReportFutureRecordsWithoutDeleting() {
+        YDClient ydClient = mock(YDClient.class);
+        WHConf conf = new WHConf();
+        conf.setYidaAppType("app");
+        conf.setYidaSystemToken("token");
+        conf.setFormUuidRequiredHours("required-hours");
+
+        DDR_New<Object> firstPage = new DDR_New<>();
+        firstPage.setTotalCount(1);
+        firstPage.setData(Collections.singletonList(requiredHoursRecord(
+                "instance-1", "employee-1", LocalDate.of(2026, 7, 16))));
+        DDR_New<Object> emptyPage = new DDR_New<>();
+        emptyPage.setTotalCount(0);
+        emptyPage.setData(Collections.emptyList());
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(firstPage, emptyPage);
+
+        WorkHoursCalcService service = new WorkHoursCalcService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", conf);
+
+        Map<String, Object> stats = service.cleanupFutureHours(LocalDate.of(2026, 7, 15), true);
+
+        assertEquals(1, stats.get("toDelete"));
+        assertEquals(0, stats.get("deleted"));
+        assertEquals(0, stats.get("fail"));
+        verify(ydClient, never()).operateData(any(YDParam.class), eq(YDConf.FORM_OPERATION.delete_batch));
+    }
+
+    @Test
+    public void computeDailyPmsShouldMergeDistinctManagersAndIncludeOfflineDate() throws Exception {
+        WorkHoursCalcService service = new WorkHoursCalcService();
+        LocalDate offlineDate = LocalDate.of(2026, 7, 15);
+        List<Object> assignments = Arrays.asList(
+                assignment("project-1", offlineDate, "manager-a"),
+                assignment("project-2", null, "manager-b"),
+                assignment("project-3", null, "manager-a"));
+
+        List<String> onOfflineDate = ReflectionTestUtils.invokeMethod(
+                service, "computeDailyPms", assignments, offlineDate);
+        List<String> afterOfflineDate = ReflectionTestUtils.invokeMethod(
+                service, "computeDailyPms", assignments, offlineDate.plusDays(1));
+
+        assertEquals(Arrays.asList("manager-a", "manager-b"), onOfflineDate);
+        assertEquals(Arrays.asList("manager-b", "manager-a"), afterOfflineDate);
+    }
+
+    @Test
+    public void computeDailyPmsShouldReturnEmptyWhenAllManagersAreMissing() throws Exception {
+        WorkHoursCalcService service = new WorkHoursCalcService();
+        List<Object> assignments = new ArrayList<>();
+        assignments.add(assignment("project-1", null, null));
+        assignments.add(assignment("project-2", LocalDate.of(2026, 7, 14), "manager-a"));
+
+        List<String> managers = ReflectionTestUtils.invokeMethod(
+                service, "computeDailyPms", assignments, LocalDate.of(2026, 7, 15));
+
+        assertEquals(Collections.emptyList(), managers);
+    }
+
+    private static Object assignment(String projectInstanceId,
+                                     LocalDate offlineDate,
+                                     String managerId) throws Exception {
+        Class<?> assignmentClass = Class.forName(
+                "com.malk.service.workhours.WorkHoursCalcService$Assignment");
+        Constructor<?> constructor = assignmentClass.getDeclaredConstructor(
+                String.class, LocalDate.class, String.class);
+        constructor.setAccessible(true);
+        return constructor.newInstance(projectInstanceId, offlineDate, managerId);
+    }
+
+    private static Map<String, Object> requiredHoursRecord(String instanceId,
+                                                            String employeeId,
+                                                            LocalDate workDay) {
+        Map<String, Object> formData = new LinkedHashMap<>();
+        formData.put("employeeField_mmd8onl4_id", Arrays.asList(employeeId));
+        formData.put("dateField_mmd8onl5", workDay.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli());
+        Map<String, Object> record = new LinkedHashMap<>();
+        record.put("formInstanceId", instanceId);
+        record.put("formData", formData);
+        return record;
+    }
+}

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

@@ -0,0 +1,61 @@
+package com.malk.timer;
+
+import org.junit.Test;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.scheduling.annotation.Scheduled;
+
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+import static org.junit.Assert.assertEquals;
+
+public class WorkHoursTimerScheduleTest {
+
+    @Test
+    public void workHoursIncrementalTimerShouldRunAtThreeThirtyAndTwelveFortyFive() throws Exception {
+        Method method = WorkHoursCalcTimer.class.getMethod("calcDailyIncrementalSync");
+
+        assertEquals(setOf("0 30 3 * * ?", "0 45 12 * * ?"), scheduledCrons(method));
+    }
+
+    @Test
+    public void workHoursTimerShouldRespectGlobalSchedulingSwitch() {
+        ConditionalOnProperty condition = WorkHoursCalcTimer.class.getAnnotation(ConditionalOnProperty.class);
+
+        assertEquals("enable", condition.prefix());
+        assertEquals(Arrays.asList("scheduling"), Arrays.asList(condition.name()));
+        assertEquals("true", condition.havingValue());
+        assertEquals(false, condition.matchIfMissing());
+    }
+
+    @Test
+    public void projectChangeTimerShouldRunFifteenMinutesBeforeWorkHoursTimer() throws Exception {
+        Method method = ProjectChangeSyncTimer.class.getMethod("syncProjectChanges");
+
+        assertEquals(setOf("0 15 3 * * ?", "0 30 12 * * ?"), scheduledCrons(method));
+    }
+
+    @Test
+    public void projectChangeTimerShouldRequireExplicitEnablement() {
+        ConditionalOnProperty condition = ProjectChangeSyncTimer.class.getAnnotation(ConditionalOnProperty.class);
+
+        assertEquals("workhours", condition.prefix());
+        assertEquals(Arrays.asList("projectChangeSyncEnabled"), Arrays.asList(condition.name()));
+        assertEquals("true", condition.havingValue());
+        assertEquals(false, condition.matchIfMissing());
+    }
+
+    private static Set<String> scheduledCrons(Method method) {
+        Set<String> crons = new HashSet<>();
+        for (Scheduled scheduled : method.getAnnotationsByType(Scheduled.class)) {
+            crons.add(scheduled.cron());
+        }
+        return crons;
+    }
+
+    private static Set<String> setOf(String... values) {
+        return new HashSet<>(Arrays.asList(values));
+    }
+}