Kaynağa Gözat

feat(workhours+personnel): 离职时间追溯 + 外部员工按天算 PM + 项目变更审批增量同步

Step 1 - 人员档案离职时间:
- PersonnelSyncConf 加 fieldOfflineDate (dateField_mh8xhqc7)
- PersonnelSyncServiceImpl.diff() MARK_OFF 分支: 首次判定离职时写入当天日期
  作为 offlineDate; 已有值不覆盖, 保护首次判定日不被后续 sync 覆盖

Step 3 - 外部员工 Manager 按天算多 PM 合并:
- queryManagerMap 拆为 queryManagerData: 内部 Map<uid, mgrId> + 外部 Map<uid,
  List<Assignment>>; 内部路径不变 (钉钉 manager_userid)
- 新增 queryProjectAssignments + computeDailyPms + Assignment/ManagerData 内部类:
  按 workDay 判定当天在项目 (只用下线日期: null 或 workDay <= offlineDate);
  多项目 PM 去重合并; 全空则该员工该天不生成工时
- upsertDailyHours 签名 String → List<String>; concurrentUpsert 内层按天算 PM
- syncOneEmployeeOneDay 同步改造, 外部无 PM 直接 error 返回
- 离职员工过滤: workDay > offlineDate 不生成
- 新增 cleanupAfterOffline(dryRun): 清理离职日之后历史记录, 按月分区扫描 +
  分批 delete_batch (每批 100)

Step 4 - 项目变更审批增量同步:
- WHConf 加 formUuidProjectChange (FORM-B86D...); dev/prod yml 同步
- syncProjectChanges(daysBack): 扫最近 N 天 gmtModified 的变更审批实例, 收集
  涉及员工 + 变更生效日(取最早值), 对该员工 workDay >= effectiveDate 的应报
  工时用最新 assignments 重算 Manager; 只更新变更时间未来的记录, 变更前一律不动
- setEquals (忽略顺序 List 比较) 幂等跳过; 20 QPS 限流 + 重试退避
- 新增 ProjectChangeSyncTimer: 06:45/12:45/18:45 三次错峰 workhours 增量任务

Controller:
- +/workhours/cleanup-after-offline
- +/workhours/sync-project-changes

配套 config:
- dev/prod yml 加 formUuidProjectChange
- dev yml 本地 mysql 环境切换 (mjava/ide123)

本地数据修复实测:
- personnel-sync/full: 74 人补写 offlineDate, fail=0
- workhours/sync?month=2026-07: success=13278, fail=0
  (离职过滤 1188, 外部无 PM 3221, 769×23=17687 数字对齐)
- sync-project-changes?daysBack=30: 3 实例扫描, 涉及 0 人 (功能通)
- cleanup-after-offline: 按用户要求跳过, 待手工补精确离职日后单独跑

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
malk 4 hafta önce
ebeveyn
işleme
bcecf963c0

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

@@ -123,6 +123,54 @@ public class WorkHoursController {
         return result;
     }
 
+    /**
+     * 清理已离职员工「离职日之后」的历史应报工时(一次性接口)
+     * GET /workhours/cleanup-after-offline            (实际删除)
+     * GET /workhours/cleanup-after-offline?dryRun=true(仅统计不删除)
+     */
+    @GetMapping("/cleanup-after-offline")
+    public Map<String, Object> cleanupAfterOffline(@RequestParam(defaultValue = "false") boolean dryRun) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        try {
+            long start = System.currentTimeMillis();
+            Map<String, Object> stats = workHoursCalcService.cleanupAfterOffline(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
+     * 只更新变更时间未来的应报工时 Manager, 变更前记录一律不动
+     */
+    @GetMapping("/sync-project-changes")
+    public Map<String, Object> syncProjectChanges(@RequestParam(defaultValue = "7") int daysBack) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        try {
+            long start = System.currentTimeMillis();
+            Map<String, Object> stats = workHoursCalcService.syncProjectChanges(daysBack);
+            long cost = System.currentTimeMillis() - start;
+            result.put("success", true);
+            result.put("message", "项目变更增量同步完成");
+            result.put("stats", stats);
+            result.put("costMs", cost);
+        } catch (Exception e) {
+            log.error("项目变更同步失败", e);
+            result.put("success", false);
+            result.put("message", e.getMessage());
+        }
+        return result;
+    }
+
     /**
      * 全量同步当月应填报工时
      * GET /workhours/sync?month=2026-04

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

@@ -28,6 +28,7 @@ public class PersonnelSyncConf {
     private String fieldIsCf        = "textField_mow9w7d8";
     private String fieldCostCenter  = "textField_mow9w7d9";
     private String fieldStatus      = "radioField_mp1sngq1";
+    private String fieldOfflineDate = "dateField_mh8xhqc7";   // 离职时间 (首次判定离职时写入当天, 已有值不覆盖)
 
     // 内外部判定: extAttrKeyUserType 留空 -> 走部门白名单; 命中 externalDeptIds -> 外部
     private String extAttrKeyUserType   = "";

+ 26 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/workhours/WHConf.java

@@ -20,6 +20,10 @@ public class WHConf {
     // prd 外部员工关联项目经理:项目档案表(含成员子表),用于按 userId 匹配 PM 写入 Manager 字段
     private String formUuidProject;
 
+    // prd 项目变更审批表: 复用项目档案子表结构 (tableField_mkowyn6d) + 新增"原项目经理" 字段;
+    // 定时任务扫描该表单最近 gmtModified 的实例, 对审批通过日之后的应报工时重算 Manager
+    private String formUuidProjectChange;
+
     // prd 审批回写:工时汇总表 + 两类审批单
     private String formUuidWorkHoursSummary;
 
@@ -44,4 +48,26 @@ public class WHConf {
     private String otherApprovalSyncTotalField;    // 其他工时审批 - 同步总数(数字)
 
     private String otherApprovalSyncFailField;     // 其他工时审批 - 失败数量(数字)
+
+    // prd 补发起(bug1)配置:汇总表 + 两类审批单
+    // fixme 若原匹配到审批单已 COMPLETED,前端逻辑是 queryProcess(RUNNING) 命中失败 → 走 create 新建单,
+    //       后端 ReSubmitApprovalService 完全对齐该逻辑
+
+    // 工时审批流程 processCode(startInstance 用)
+    private String approvalProcessCode;
+
+    // 其他工时审批流程 processCode
+    private String otherApprovalProcessCode;
+
+    // 补发起 startInstance 的发起人 userId
+    // fixme 前端 v2.35 已确认: yida_pub_account 虚拟账号不触发钉钉待办通知(审批人收不到红点),
+    //       必须改用真人钉钉账号 userId(建议命名 System); 查询/更新类操作仍可用 pub_account
+    private String approvalOriginatorUserId;
+
+    // 补发起 deptId(可空)
+    private String approvalOriginatorDeptId;
+
+    // 汇总表: 审批状态字段(SelectField, 3 档 "暂存/审批中/已审批")
+    // prd 前端在保存/提交时写入此字段, 后端补发起 Scheduler 按 "审批中" + 近 3 天 gmtModified 扫描兜底
+    private String summaryApprovalStatusField;
 }

+ 32 - 2
mjava-akdsbeisen/src/main/java/com/malk/service/personnel/impl/PersonnelSyncServiceImpl.java

@@ -17,6 +17,8 @@ import org.springframework.stereotype.Service;
 
 import javax.annotation.PostConstruct;
 
+import java.time.LocalDate;
+import java.time.ZoneId;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -68,6 +70,13 @@ public class PersonnelSyncServiceImpl implements PersonnelSyncService {
     private static final double DD_API_QPS = 50.0;
     private static final double YIDA_API_QPS = 30.0;
 
+    private static final ZoneId CST = ZoneId.of("Asia/Shanghai");
+
+    /** 当天 CST 00:00 epoch 毫秒 (离职时间按天存储, 与应报工时日期格式对齐) */
+    private static long todayCstStartMillis() {
+        return LocalDate.now(CST).atStartOfDay(CST).toInstant().toEpochMilli();
+    }
+
     @Override
     public Map<String, Object> fullSync(Integer limitOverride) {
         long start = System.currentTimeMillis();
@@ -595,20 +604,41 @@ public class PersonnelSyncServiceImpl implements PersonnelSyncService {
         }
 
         // 宜搭里有、钉钉里没有 -> 标记离职 (limit 模式下拉取非全量, 跳过此步)
+        // ppExt 离职时间: 首次判定离职 status+offlineDate 一起写; 已"离职"但 offlineDate 空 -> 补写(当天);
+        //                已有 offlineDate 且状态一致 -> 完全跳过, 保护首次判定日不被覆盖
         if (!skipMarkOff) {
+            long todayMs = todayCstStartMillis();
+            boolean writeOfflineDate = notBlank(conf.getFieldOfflineDate());
             for (Map.Entry<String, YidaRecord> e : yidaMap.entrySet()) {
                 if (dingUserMap.containsKey(e.getKey())) continue;
                 YidaRecord yida = e.getValue();
                 Object currentStatus = yida.formData.get(conf.getFieldStatus());
-                if (conf.getStatusValueInactive().equals(String.valueOf(currentStatus))) continue;   // 已离职跳过
+                boolean alreadyInactive = conf.getStatusValueInactive().equals(String.valueOf(currentStatus));
+                boolean offlineDateBlank = writeOfflineDate && isBlankValue(yida.formData.get(conf.getFieldOfflineDate()));
+
+                if (alreadyInactive && (!writeOfflineDate || !offlineDateBlank)) continue;   // 全部就绪, 跳过
+
                 Map<String, Object> formData = new LinkedHashMap<>();
-                formData.put(conf.getFieldStatus(), conf.getStatusValueInactive());
+                if (!alreadyInactive) {
+                    formData.put(conf.getFieldStatus(), conf.getStatusValueInactive());
+                }
+                if (writeOfflineDate && offlineDateBlank) {
+                    formData.put(conf.getFieldOfflineDate(), todayMs);
+                }
+                if (formData.isEmpty()) continue;   // 防御: 无字段变化不写入
                 actions.add(new Action(ACTION_MARK_OFF, e.getKey(), yida.instanceId, formData));
             }
         }
         return actions;
     }
 
+    /** dateField 在 formData 里可能是 Long 时间戳或 "yyyy-MM-dd HH:mm:ss" 字符串, 空/空串/字面 "null" 一律视为空 */
+    private boolean isBlankValue(Object v) {
+        if (v == null) return true;
+        String s = String.valueOf(v).trim();
+        return s.isEmpty() || "null".equalsIgnoreCase(s);
+    }
+
     // ==================== 内部: 字段映射 ====================
 
     @SuppressWarnings("unchecked")

+ 470 - 90
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/WorkHoursCalcService.java

@@ -47,6 +47,9 @@ public class WorkHoursCalcService {
     private static final int THREAD_POOL_SIZE = 10;
     private static final int MAX_RETRY = 2;
 
+    // prd 离职时间字段(人员档案侧, 用于过滤离职后不再生成/清理历史应报工时)
+    private static final String PERSONNEL_OFFLINE_DATE = "dateField_mh8xhqc7";
+
     // prd 外部员工关联项目经理:项目档案成员子表字段
     private static final String PROJECT_SUB_TABLE = "tableField_mkowyn6d";      // 成员子表(人员明细)
     private static final String PROJECT_SUB_MEMBER = "employeeField_mmbfe0ij";  // 子表-员工(成员)
@@ -79,10 +82,10 @@ public class WorkHoursCalcService {
             return stats;
         }
 
-        // 2. 查询直属主管(仅内部员工调用钉钉 API)
-        Map<String, String> managerMap = queryManagerMap(personnelMap, targetMonth);
-        log.info("获取到{}名员工的直属主管", managerMap.size());
-        stats.put("managerCount", managerMap.size());
+        // 2. 预取 Manager 数据: 内部员工=钉钉直属主管, 外部员工=项目 assignments (按天算 PM 在 concurrentUpsert 内层)
+        ManagerData managerData = queryManagerData(personnelMap);
+        stats.put("internalMgrCount", managerData.internal.size());
+        stats.put("externalWithProjects", managerData.external.size());
 
         // 3. 查询节假日规则
         Map<LocalDate, String> holidayRules = queryHolidayRules(String.valueOf(year));
@@ -94,7 +97,7 @@ public class WorkHoursCalcService {
         stats.put("workingDays", workingDays.size());
 
         // 5. 多线程并发写入:按员工维度分任务
-        int[] counts = concurrentUpsert(personnelMap, managerMap, workingDays);
+        int[] counts = concurrentUpsert(personnelMap, managerData, workingDays);
         stats.put("success", counts[0]);
         stats.put("fail", counts[1]);
         log.info("应填报工时写入完成: 成功{}条, 失败{}条({}名员工 × {}个工作日)",
@@ -126,8 +129,8 @@ public class WorkHoursCalcService {
             return;
         }
 
-        // 2. 查询直属主管(仅内部员工)
-        Map<String, String> managerMap = queryManagerMap(personnelMap, today);
+        // 2. 预取 Manager (内部=钉钉主管, 外部=项目 assignments)
+        ManagerData managerData = queryManagerData(personnelMap);
 
         // 3~4 查询节假日和工作日
         Map<LocalDate, String> holidayRules = queryHolidayRules(String.valueOf(year));
@@ -135,7 +138,7 @@ public class WorkHoursCalcService {
         log.info("{}年{}月工作日{}天", year, month, workingDays.size());
 
         // 5. 多线程并发写入
-        int[] counts = concurrentUpsert(personnelMap, managerMap, workingDays);
+        int[] counts = concurrentUpsert(personnelMap, managerData, workingDays);
         log.info("增量同步完成: 成功{}条, 失败{}条({}名员工 × {}个工作日)",
                 counts[0], counts[1], personnelMap.size(), workingDays.size());
     }
@@ -170,31 +173,47 @@ public class WorkHoursCalcService {
         }
         result.put("personnelInfo", info);
 
-        // 2. Manager 取值:内部=钉钉直属主管, 外部=项目档案唯一在线项目 PM
-        String managerId = null;
-        if ("内部".equals(String.valueOf(info.get("radioField_mkow4ydo")))) {
+        // 离职时间: workDay > offlineDate 直接跳过验证
+        LocalDate offlineDate = parseToLocalDate(info.get(PERSONNEL_OFFLINE_DATE));
+        if (offlineDate != null && workDay.isAfter(offlineDate)) {
+            result.put("success", false);
+            result.put("error", "员工已离职(offlineDate=" + offlineDate + "), 按业务规则跳过写入");
+            return result;
+        }
+
+        // 2. Manager 取值: 内部=[钉钉直属主管], 外部=当天参与项目的 PM 合并
+        boolean isInternal = "内部".equals(String.valueOf(info.get("radioField_mkow4ydo")));
+        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 mgrId = String.valueOf(userInfo.get("manager_userid"));
-                    if (!mgrId.isEmpty() && !"null".equals(mgrId)) {
-                        managerId = mgrId;
+                    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);
         } else {
-            // prd 外部员工:仅排除已下线后唯一在线项目+PM非空才写,否则留空
-            Map<String, String> pmMap = queryProjectPmMap(Collections.singleton(userId), workDay);
-            managerId = pmMap.get(userId);
+            // 外部员工: 按 workDay 算当天活跃项目 PM; 全空则按业务规则不写入
+            Map<String, List<Assignment>> extMap = queryProjectAssignments(Collections.singleton(userId));
+            managerIds = computeDailyPms(extMap.get(userId), workDay);
+            if (managerIds.isEmpty()) {
+                result.put("success", false);
+                result.put("error", "外部员工当日无可用项目 PM (无项目 / 项目已下线 / PM 为空), 按业务规则跳过写入");
+                return result;
+            }
         }
-        result.put("managerId", managerId);
+        result.put("managerIds", managerIds);
 
         // 3. upsert 写一条
         try {
-            upsertDailyHours(userId, managerId, workDay, info);
+            upsertDailyHours(userId, managerIds, workDay, info);
             result.put("success", true);
             log.info("单条验证写入成功: userId={}, workDay={}, 归属公司={}",
                     userId, workDay, info.get("textField_mmekrcji"));
@@ -252,8 +271,9 @@ public class WorkHoursCalcService {
         }
 
         // 3. 主管
-        Map<String, String> managerMap = queryManagerMap(subset, targetMonth);
-        stats.put("managerCount", managerMap.size());
+        ManagerData managerData = queryManagerData(subset);
+        stats.put("internalMgrCount", managerData.internal.size());
+        stats.put("externalWithProjects", managerData.external.size());
 
         // 4. 节假日 + 工作日
         Map<LocalDate, String> rules = queryHolidayRules(String.valueOf(year));
@@ -261,7 +281,7 @@ public class WorkHoursCalcService {
         stats.put("workingDays", workingDays.size());
 
         // 5. 并发写入
-        int[] counts = concurrentUpsert(subset, managerMap, workingDays);
+        int[] counts = concurrentUpsert(subset, managerData, workingDays);
         stats.put("success", counts[0]);
         stats.put("fail", counts[1]);
         log.info("小批量同步完成: 成功{}条, 失败{}条({}名员工 × {}个工作日)",
@@ -511,7 +531,7 @@ public class WorkHoursCalcService {
      * @return int[]{successCount, failCount}
      */
     private int[] concurrentUpsert(Map<String, Map<String, Object>> personnelMap,
-                                   Map<String, String> managerMap,
+                                   ManagerData managerData,
                                    List<LocalDate> workingDays) {
         AtomicInteger successCount = new AtomicInteger(0);
         AtomicInteger failCount = new AtomicInteger(0);
@@ -522,18 +542,42 @@ public class WorkHoursCalcService {
         try {
             List<Future<?>> futures = new ArrayList<>();
 
+            AtomicInteger skippedOffline = new AtomicInteger(0);
+            AtomicInteger skippedNoPm = new AtomicInteger(0);
             for (Map.Entry<String, Map<String, Object>> entry : personnelMap.entrySet()) {
                 String empId = entry.getKey();
                 Map<String, Object> info = entry.getValue();
-                String mgrId = managerMap.get(empId);
+                boolean isInternal = "内部".equals(String.valueOf(info.get("radioField_mkow4ydo")));
+                // 内部: 单值直属主管; 外部: assignments 交给内层按 workDay 算 PM
+                String internalMgrId = isInternal ? managerData.internal.get(empId) : null;
+                List<Assignment> externalAssignments = isInternal ? null : managerData.external.get(empId);
+                // prd 离职时间: 若有离职时间, 该日之后的应报工时不再生成
+                LocalDate offlineDate = parseToLocalDate(info.get(PERSONNEL_OFFLINE_DATE));
 
                 futures.add(executor.submit(() -> {
                     for (LocalDate workDay : workingDays) {
+                        if (offlineDate != null && workDay.isAfter(offlineDate)) {
+                            skippedOffline.incrementAndGet();
+                            continue;
+                        }
+                        // prd 外部员工: 当天参与项目的 PM 集合为空(无项目/PM 全空/项目全下线) → 不生成记录
+                        List<String> managerIds;
+                        if (isInternal) {
+                            managerIds = (internalMgrId == null || internalMgrId.isEmpty())
+                                    ? Collections.emptyList()
+                                    : Collections.singletonList(internalMgrId);
+                        } else {
+                            managerIds = computeDailyPms(externalAssignments, workDay);
+                            if (managerIds.isEmpty()) {
+                                skippedNoPm.incrementAndGet();
+                                continue;
+                            }
+                        }
                         boolean written = false;
                         for (int retry = 0; retry <= MAX_RETRY; retry++) {
                             try {
                                 yidaLimiter.acquire();
-                                upsertDailyHours(empId, mgrId, workDay, info);
+                                upsertDailyHours(empId, managerIds, workDay, info);
                                 written = true;
                                 break;
                             } catch (Exception e) {
@@ -566,6 +610,12 @@ public class WorkHoursCalcService {
                     log.error("线程执行异常", e);
                 }
             }
+            if (skippedOffline.get() > 0) {
+                log.info("离职员工过滤: 跳过{}条 workDay > offlineDate 的记录", skippedOffline.get());
+            }
+            if (skippedNoPm.get() > 0) {
+                log.info("外部员工无 PM 过滤: 跳过{}条 当天无活跃项目 PM 的记录", skippedNoPm.get());
+            }
         } finally {
             executor.shutdown();
         }
@@ -573,6 +623,127 @@ public class WorkHoursCalcService {
         return new int[]{successCount.get(), failCount.get()};
     }
 
+    /**
+     * 清理应报工时中已离职员工「离职日之后」的历史记录(一次性接口)
+     * ppExt: 与 concurrentUpsert 的 offline 过滤逻辑对齐 (离职后不新增 + 已存在的历史 workDay > offlineDate 清掉);
+     *        与 backfillCfEmployee 同款分区扫描策略, 按【应填报日期】逐月分区绕过宜搭 search 30000 条上限;
+     *        collect instanceIds → 分批 delete_batch (每批 100)
+     *
+     * @param dryRun true 仅统计不删除
+     * @return Map{offlineEmployees, total, toDelete, deleted, fail}
+     */
+    @SuppressWarnings("unchecked")
+    public Map<String, Object> cleanupAfterOffline(boolean dryRun) {
+        Map<String, Object> stats = new LinkedHashMap<>();
+        String appType = whConf.getYidaAppType();
+        String systemToken = whConf.getYidaSystemToken();
+
+        // 1. 预加载 userId -> 离职日期
+        Map<String, Map<String, Object>> personnelMap = queryAllPersonnelDetails();
+        Map<String, LocalDate> offlineMap = new HashMap<>();
+        for (Map.Entry<String, Map<String, Object>> e : personnelMap.entrySet()) {
+            LocalDate d = parseToLocalDate(e.getValue().get(PERSONNEL_OFFLINE_DATE));
+            if (d != null) offlineMap.put(e.getKey(), d);
+        }
+        log.info("清理离职后工时: 人员档案{}条, 其中带离职时间{}人", personnelMap.size(), offlineMap.size());
+        stats.put("offlineEmployees", offlineMap.size());
+        if (offlineMap.isEmpty()) {
+            stats.put("total", 0);
+            stats.put("toDelete", 0);
+            stats.put("deleted", 0);
+            return stats;
+        }
+
+        // 2. 按月分区扫应报工时, 收集离职日之后的 instanceIds
+        List<String> toDelete = new ArrayList<>();
+        int total = 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) continue;
+
+                    String empId = extractEmployeeId(formData, "employeeField_mmd8onl4");
+                    LocalDate offlineDate = empId == null ? null : offlineMap.get(empId);
+                    if (offlineDate == null) continue;
+
+                    LocalDate workDay = parseToLocalDate(formData.get("dateField_mmd8onl5"));
+                    // 离职日之后的历史工时全删 (与 concurrentUpsert 的 offline 过滤逻辑对齐);
+                    // 离职日当天及之前保留 (员工在职期间工时有效, 不动)
+                    if (workDay != null && workDay.isAfter(offlineDate)) {
+                        toDelete.add(String.valueOf(instId));
+                    }
+                }
+                currentPage++;
+            } while ((long) (currentPage - 1) * pageSize < totalCount);
+            log.info("清理离职后工时扫描[{}]: 累计扫描{}, 待删除{}", monthCursor, total, toDelete.size());
+            monthCursor = nextMonth;
+        }
+
+        stats.put("total", total);
+        stats.put("toDelete", toDelete.size());
+        if (dryRun) {
+            stats.put("deleted", 0);
+            stats.put("fail", 0);
+            stats.put("dryRun", true);
+            log.info("清理离职后工时: dryRun 模式, 仅预览; 扫描{}, 待删除{}", total, toDelete.size());
+            return stats;
+        }
+
+        // 3. 分批 delete_batch (宜搭建议每批 <=100)
+        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("清理离职后工时完成: 扫描{}, 删除{}, 失败{}", total, deleted, fail);
+        return stats;
+    }
+
     // ==================== 数据查询 ====================
 
     /**
@@ -681,6 +852,8 @@ public class WorkHoursCalcService {
                     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));
                     personnelMap.put(empId, info);
                 }
             }
@@ -738,6 +911,8 @@ public class WorkHoursCalcService {
                     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));
                     personnelMap.put(empId, info);
                 }
             }
@@ -748,18 +923,17 @@ public class WorkHoursCalcService {
     }
 
     /**
-     * 批量查询应填报工时的 Manager 字段值:
-     * - 内部员工:钉钉 API 取 manager_userid(直属主管)
-     * - 外部员工:项目档案成员子表匹配 PM,仅「排除已下线后唯一在线项目 + PM 非空」才写,否则留空
-     * ppExt: 与前端 TimeCard 刻意不同 —— 不做 PM 离职探活、不兜底 Raymond(后端只记数据,非审批找活人)
+     * 预取 Manager 数据:
+     * - 内部员工: 钉钉 API 取 manager_userid (直属主管, 单值)
+     * - 外部员工: 项目档案 assignments (按天算 PM 在 concurrentUpsert 内层, 多项目合并去重, 全空则不写入)
+     * ppExt: 与前端 TimeCard 刻意不同 - 不做 PM 离职探活、不兜底 Raymond (后端只记数据, 非审批找活人)
      *
      * @param personnelMap 全量人员档案
-     * @param targetMonth  目标月份(用于判定项目下线时间:下线时间 &lt; 目标月 1 号视为本月已下线)
-     * @return Map<employeeId, managerUserId>
+     * @return ManagerData: internal=Map<userId, mgrUserId>, external=Map<userId, List<Assignment>>
      */
     @SuppressWarnings("unchecked")
-    private Map<String, String> queryManagerMap(Map<String, Map<String, Object>> personnelMap, LocalDate targetMonth) {
-        Map<String, String> managerMap = new HashMap<>();
+    private ManagerData queryManagerData(Map<String, Map<String, Object>> personnelMap) {
+        ManagerData md = new ManagerData();
         Set<String> externalIds = new HashSet<>();
         String accessToken = ddClient.getAccessToken();
         int internalCount = 0;
@@ -776,7 +950,7 @@ public class WorkHoursCalcService {
                     if (userInfo != null && userInfo.get("manager_userid") != null) {
                         String mgrId = String.valueOf(userInfo.get("manager_userid"));
                         if (!mgrId.isEmpty() && !"null".equals(mgrId)) {
-                            managerMap.put(empId, mgrId);
+                            md.internal.put(empId, mgrId);
                         }
                     }
                 } catch (Exception e) {
@@ -787,37 +961,30 @@ public class WorkHoursCalcService {
             }
         }
 
-        // 外部员工批量 PM 匹配(targetMonth 为 null 则跳过 PM 匹配,Manager 留空)
-        if (!externalIds.isEmpty() && targetMonth != null) {
-            Map<String, String> pmMap = queryProjectPmMap(externalIds, targetMonth);
-            managerMap.putAll(pmMap);
+        // 外部员工: 预取项目 assignments (供 concurrentUpsert 按天算 PM)
+        if (!externalIds.isEmpty()) {
+            md.external = queryProjectAssignments(externalIds);
         }
 
-        log.info("Manager 匹配完成: 内部{}人取直属主管, 外部{}人匹配PM, 总落地{}条",
-                internalCount, externalIds.size(), managerMap.size());
-        return managerMap;
+        log.info("Manager 预取完成: 内部{}人取直属主管落地{}, 外部{}人预取项目档案({}人有项目)",
+                internalCount, md.internal.size(), externalIds.size(), md.external.size());
+        return md;
     }
 
     /**
-     * 外部员工项目经理批量匹配:扫项目档案全量 + 遍历成员子表,仅命中「排除已下线后唯一在线项目 + PM 非空」的写入
-     * ppExt: 子表 &lt;50 行用内联、==50 走 ydService.queryDetails 取全(与 ApprovalWriteBackService.resolveDetailRows 同款)
+     * 扫项目档案全量, 收集外部员工的项目 assignments (含下线日期 + PM), 按天算 PM 见 computeDailyPms
+     * ppExt: 子表 &lt;50 行用内联、==50 走 ydService.queryDetails 取全 (与 ApprovalWriteBackService.resolveDetailRows 同款)
      *
      * @param externalIds 外部员工 userId 集合
-     * @param targetMonth 目标月份(下线时间 &lt; 目标月 1 号 → 该项目本月已下线,排除)
-     * @return Map<外部员工 userId, 项目 PM userId>,仅含命中条件的外部员工
+     * @return Map<外部员工 userId, List<Assignment>>, 仅含在项目子表出现过的外部员工
      */
     @SuppressWarnings("unchecked")
-    private Map<String, String> queryProjectPmMap(Set<String> externalIds, LocalDate targetMonth) {
-        Map<String, String> result = new HashMap<>();
+    private Map<String, List<Assignment>> queryProjectAssignments(Set<String> externalIds) {
+        Map<String, List<Assignment>> result = new HashMap<>();
         if (externalIds == null || externalIds.isEmpty()) {
             return result;
         }
 
-        long monthStartMs = targetMonth.withDayOfMonth(1).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli();
-
-        // 每个外部员工 → 在线项目集合(projectInstId → pmUserId,pmUserId 可能 null)
-        Map<String, Map<String, String>> userProjects = new HashMap<>();
-
         String appType = whConf.getYidaAppType();
         String systemToken = whConf.getYidaSystemToken();
         String formUuidProject = whConf.getFormUuidProject();
@@ -851,51 +1018,263 @@ public class WorkHoursCalcService {
                     String memberId = extractEmployeeId(row, PROJECT_SUB_MEMBER);
                     if (memberId == null || !externalIds.contains(memberId)) continue;
 
-                    // 下线时间判定:解析得到时间戳且 < 目标月首日 → 已下线,排除;解析不到/空 → 不过滤
-                    Object offlineObj = row.get(PROJECT_SUB_OFFLINE);
-                    if (offlineObj != null) {
-                        String s = String.valueOf(offlineObj).trim();
-                        if (!s.isEmpty()) {
-                            try {
-                                long offlineMs = Long.parseLong(s);
-                                if (offlineMs < monthStartMs) continue;
-                            } catch (NumberFormatException ignore) {
-                                // 解析失败视为无下线时间,不过滤
-                            }
-                        }
-                    }
-
+                    LocalDate offlineDate = parseToLocalDate(row.get(PROJECT_SUB_OFFLINE));
                     String pmId = extractEmployeeId(row, PROJECT_SUB_PM);
-                    userProjects.computeIfAbsent(memberId, k -> new HashMap<>())
-                            .put(projectInstId, pmId);
+                    result.computeIfAbsent(memberId, k -> new ArrayList<>())
+                            .add(new Assignment(projectInstId, offlineDate, pmId));
                 }
             }
             currentPage++;
         } while ((long) (currentPage - 1) * pageSize < totalCount);
 
-        // 命中条件:去重项目数 == 1 且 PM 非空
-        int multiCount = 0, noPmCount = 0, missCount = 0;
-        for (String uid : externalIds) {
-            Map<String, String> projects = userProjects.get(uid);
-            if (projects == null || projects.isEmpty()) {
-                missCount++;
-                continue;
+        return result;
+    }
+
+    /**
+     * 计算某员工在 workDay 当天参与的活跃项目 PM 集合 (去重, 排除下线日期已过 或 PM 为空的项目)
+     * ppExt: 只用下线日期判定 — 下线日期为 null 或 workDay &lt;= offlineDate 视为当天在项目
+     *
+     * @return 当天参与项目的 PM userId 去重列表, 全空返回 emptyList
+     */
+    private List<String> computeDailyPms(List<Assignment> assignments, LocalDate workDay) {
+        if (assignments == null || assignments.isEmpty()) return Collections.emptyList();
+        Set<String> pms = new LinkedHashSet<>();
+        for (Assignment a : assignments) {
+            if (a.offlineDate != null && workDay.isAfter(a.offlineDate)) continue;
+            if (a.pmId != null && !a.pmId.isEmpty()) pms.add(a.pmId);
+        }
+        return new ArrayList<>(pms);
+    }
+
+    /** 内部员工 → 直属主管 userId; 外部员工 → 项目 assignments 明细. 供 concurrentUpsert 按天算 Manager */
+    private static class ManagerData {
+        Map<String, String> internal = new HashMap<>();
+        Map<String, List<Assignment>> external = new HashMap<>();
+    }
+
+    /**
+     * 项目变更审批增量同步: 扫最近 daysBack 天 gmtModified 的变更审批实例, 收集涉及员工 + 变更生效日,
+     * 对每员工 workDay &gt;= effectiveDate 的应报工时重算 Manager (基于最新项目档案 assignments),
+     * 有差异才更新. 需求约束: 只更新变更时间未来的记录, 变更前的一律不动.
+     * ppExt: 变更生效日 = 表单实例 gmtModified (审批通过后 modify); 同一员工出现在多条变更单时取最早日期
+     *        (保证之后所有记录都会被扫过). upsert 语义靠"值相等则跳过"实现幂等.
+     *
+     * @param daysBack 回溯天数 (定时通常 7, 首次可传 30)
+     */
+    @SuppressWarnings("unchecked")
+    public Map<String, Object> syncProjectChanges(int daysBack) {
+        Map<String, Object> stats = new LinkedHashMap<>();
+        if (daysBack <= 0) daysBack = 7;
+        String appType = whConf.getYidaAppType();
+        String systemToken = whConf.getYidaSystemToken();
+        String formUuidProjectChange = whConf.getFormUuidProjectChange();
+        if (formUuidProjectChange == null || formUuidProjectChange.isEmpty()) {
+            log.warn("[项目变更同步] formUuidProjectChange 未配置, 跳过");
+            stats.put("skipped", true);
+            return stats;
+        }
+        LocalDate fromDate = LocalDate.now().minusDays(daysBack);
+        LocalDate toDate = LocalDate.now().plusDays(1);
+        log.info("[项目变更同步] 扫描 {} ~ {} 内变更审批实例", fromDate, toDate);
+
+        // 1. 拉最近 gmtModified 的变更审批实例
+        DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+        Map<String, LocalDate> memberEffectiveDate = new HashMap<>();
+        int scannedInstances = 0;
+        int currentPage = 1;
+        int pageSize = YDConf.PAGE_SIZE_LIMIT;
+        long totalCount;
+        do {
+            DDR_New result = ydClient.queryData(YDParam.builder()
+                    .appType(appType).systemToken(systemToken)
+                    .formUuid(formUuidProjectChange)
+                    .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;
+
+            for (Map inst : dataList) {
+                scannedInstances++;
+                LocalDate effectiveDate = parseToLocalDate(inst.get("gmtModified"));
+                if (effectiveDate == null) continue;
+                Map<String, Object> formData = (Map<String, Object>) inst.get("formData");
+                if (formData == null) continue;
+                Object instIdObj = inst.get("formInstanceId");
+                if (instIdObj == null) continue;
+                String projectInstId = String.valueOf(instIdObj);
+
+                List<Map> subRows = resolveProjectMembers(projectInstId, (List<Map>) formData.get(PROJECT_SUB_TABLE));
+                for (Map row : subRows) {
+                    String memberId = extractEmployeeId(row, PROJECT_SUB_MEMBER);
+                    if (memberId == null || memberId.isEmpty()) continue;
+                    LocalDate cur = memberEffectiveDate.get(memberId);
+                    if (cur == null || effectiveDate.isBefore(cur)) {
+                        memberEffectiveDate.put(memberId, effectiveDate);
+                    }
+                }
             }
-            if (projects.size() > 1) {
-                multiCount++;
-                continue;
+            currentPage++;
+        } while ((long) (currentPage - 1) * pageSize < totalCount);
+
+        stats.put("scannedInstances", scannedInstances);
+        stats.put("affectedMembers", memberEffectiveDate.size());
+        log.info("[项目变更同步] 扫到{}条变更实例, 涉及{}人", scannedInstances, memberEffectiveDate.size());
+        if (memberEffectiveDate.isEmpty()) {
+            stats.put("updated", 0);
+            stats.put("skippedSame", 0);
+            stats.put("fail", 0);
+            return stats;
+        }
+
+        // 2. 拉这些员工的项目 assignments (基于最新项目档案)
+        Map<String, List<Assignment>> assignmentsMap = queryProjectAssignments(memberEffectiveDate.keySet());
+
+        // 3. 对每员工扫其应报工时 (workDay >= effectiveDate), 逐条重算 Manager 并对比
+        AtomicInteger totalScanned = new AtomicInteger(0);
+        AtomicInteger updated = new AtomicInteger(0);
+        AtomicInteger skippedSame = new AtomicInteger(0);
+        AtomicInteger skippedNoPm = new AtomicInteger(0);
+        AtomicInteger fail = new AtomicInteger(0);
+        ZoneId zone = ZoneId.systemDefault();
+        RateLimiter yidaLimiter = RateLimiter.create(20.0);
+
+        for (Map.Entry<String, LocalDate> e : memberEffectiveDate.entrySet()) {
+            String memberId = e.getKey();
+            LocalDate effectiveDate = e.getValue();
+            List<Assignment> assignments = assignmentsMap.get(memberId);
+            long fromMs = effectiveDate.atStartOfDay(zone).toInstant().toEpochMilli();
+            long toMs = Long.MAX_VALUE / 2;   // 上界给个很大值即可
+            Map<String, Object> searchField = new LinkedHashMap<>();
+            searchField.put("employeeField_mmd8onl4", memberId);
+            searchField.put("dateField_mmd8onl5", Arrays.asList(fromMs, toMs));
+            String searchFieldJson = JSON.toJSONString(searchField);
+
+            int subPage = 1;
+            long subTotal;
+            do {
+                DDR_New res = ydClient.queryData(YDParam.builder()
+                        .appType(appType).systemToken(systemToken)
+                        .formUuid(whConf.getFormUuidRequiredHours())
+                        .searchFieldJson(searchFieldJson)
+                        .currentPage(subPage).pageSize(pageSize)
+                        .build(), YDConf.FORM_QUERY.retrieve_search_form);
+                subTotal = res.getTotalCount();
+                List<Map> recs = (List<Map>) res.getData();
+                if (recs == null || recs.isEmpty()) break;
+
+                for (Map rec : recs) {
+                    totalScanned.incrementAndGet();
+                    Object instId = rec.get("formInstanceId");
+                    Map<String, Object> fd = (Map<String, Object>) rec.get("formData");
+                    if (instId == null || fd == null) continue;
+                    LocalDate workDay = parseToLocalDate(fd.get("dateField_mmd8onl5"));
+                    if (workDay == null || workDay.isBefore(effectiveDate)) continue;
+
+                    List<String> newPmIds = computeDailyPms(assignments, workDay);
+                    if (newPmIds.isEmpty()) {
+                        // 变更后当日无活跃项目 PM: 需求约束下不新增/不删除, 交由 workhours 全量同步收敛
+                        skippedNoPm.incrementAndGet();
+                        continue;
+                    }
+                    List<String> curMgrs = extractIdList(fd, "employeeField_mh8xhqc3");
+                    if (setEquals(curMgrs, newPmIds)) {
+                        skippedSame.incrementAndGet();
+                        continue;
+                    }
+
+                    JSONObject upd = new JSONObject();
+                    upd.put("employeeField_mh8xhqc3", newPmIds);
+                    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(String.valueOf(instId))
+                                    .updateFormDataJson(upd.toJSONString())
+                                    .ignoreEmpty(false)
+                                    .useLatestVersion(true)
+                                    .build(), YDConf.FORM_OPERATION.update);
+                            ok = true;
+                        } catch (Exception ex) {
+                            if (retry < MAX_RETRY) {
+                                try { Thread.sleep(1000L * (retry + 1)); }
+                                catch (InterruptedException ie) { Thread.currentThread().interrupt(); }
+                            } else {
+                                fail.incrementAndGet();
+                                log.error("[项目变更同步] 更新 Manager 失败 instId={} memberId={} workDay={}",
+                                        instId, memberId, workDay, ex);
+                            }
+                        }
+                    }
+                    if (ok) updated.incrementAndGet();
+                }
+                subPage++;
+            } while ((long) (subPage - 1) * pageSize < subTotal);
+        }
+
+        stats.put("totalScanned", totalScanned.get());
+        stats.put("updated", updated.get());
+        stats.put("skippedSame", skippedSame.get());
+        stats.put("skippedNoPm", skippedNoPm.get());
+        stats.put("fail", fail.get());
+        log.info("[项目变更同步] 完成: 扫描应报工时{}条, 更新{}, 值相同跳过{}, 无PM跳过{}, 失败{}",
+                totalScanned.get(), updated.get(), skippedSame.get(), skippedNoPm.get(), fail.get());
+        return stats;
+    }
+
+    /** 从 formData 里提取员工/成员字段 id 列表 (优先 _id 后缀; 字符串形态返回单元素列表) */
+    @SuppressWarnings("unchecked")
+    private List<String> extractIdList(Map<String, Object> data, String fieldId) {
+        Object raw = data.get(fieldId + "_id");
+        if (raw == null) raw = data.get(fieldId);
+        if (raw == null) return Collections.emptyList();
+        List<String> out = new ArrayList<>();
+        if (raw instanceof List) {
+            for (Object o : (List) raw) {
+                if (o == null) continue;
+                String s = String.valueOf(o);
+                if (!s.isEmpty()) out.add(s);
             }
-            String pmId = projects.values().iterator().next();
-            if (pmId == null || pmId.isEmpty()) {
-                noPmCount++;
-                continue;
+            return out;
+        }
+        String str = String.valueOf(raw).trim();
+        if (str.isEmpty()) return Collections.emptyList();
+        if (str.startsWith("[")) {
+            try {
+                List<String> parsed = JSON.parseArray(str, String.class);
+                for (String s : parsed) if (s != null && !s.isEmpty()) out.add(s);
+                return out;
+            } catch (Exception e) {
+                return Collections.emptyList();
             }
-            result.put(uid, pmId);
         }
+        out.add(str);
+        return out;
+    }
 
-        log.info("外部员工PM匹配: 待匹配{}人, 命中{}人, 多项目跳过{}人, PM为空跳过{}人, 未匹配项目{}人",
-                externalIds.size(), result.size(), multiCount, noPmCount, missCount);
-        return result;
+    /** 忽略顺序的 List 集合相等判定 (Manager 字段: 多 PM 数组顺序不稳定, 用集合语义比较) */
+    private boolean setEquals(List<String> a, List<String> b) {
+        if (a == null) a = Collections.emptyList();
+        if (b == null) b = Collections.emptyList();
+        if (a.size() != b.size()) return false;
+        return new HashSet<>(a).equals(new HashSet<>(b));
+    }
+
+    /** 外部员工在某项目中的一条 assignment: 项目实例 + 下线日期(可空) + PM(可空) */
+    private static class Assignment {
+        final String projectInstId;
+        final LocalDate offlineDate;
+        final String pmId;
+        Assignment(String projectInstId, LocalDate offlineDate, String pmId) {
+            this.projectInstId = projectInstId;
+            this.offlineDate = offlineDate;
+            this.pmId = pmId;
+        }
     }
 
     /**
@@ -931,8 +1310,9 @@ public class WorkHoursCalcService {
 
     /**
      * 按员工+日期 upsert 写入单日应填报工时(8h)
+     * ppExt: managerIds 支持多值 (内部员工 = [直属主管]; 外部员工 = 当天参与项目的 PM 去重合并)
      */
-    private void upsertDailyHours(String employeeId, String managerId, LocalDate workDay,
+    private void upsertDailyHours(String employeeId, List<String> managerIds, LocalDate workDay,
                                   Map<String, Object> personnelInfo) {
         String appType = whConf.getYidaAppType();
         String systemToken = whConf.getYidaSystemToken();
@@ -944,9 +1324,9 @@ public class WorkHoursCalcService {
         formData.put("dateField_mmd8onl5", dayTimestamp);
         formData.put("numberField_mmd8onl6", DAILY_HOURS);
 
-        // 直属主管(来自钉钉用户详情 manager_userid)
-        if (managerId != null && !managerId.isEmpty()) {
-            formData.put("employeeField_mh8xhqc3", Arrays.asList(managerId));
+        // Manager: 内部=[钉钉直属主管], 外部=当天参与项目的 PM 去重合并
+        if (managerIds != null && !managerIds.isEmpty()) {
+            formData.put("employeeField_mh8xhqc3", managerIds);
         }
 
         // 人员档案补充字段

+ 39 - 0
mjava-akdsbeisen/src/main/java/com/malk/timer/ProjectChangeSyncTimer.java

@@ -0,0 +1,39 @@
+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.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.scheduling.annotation.Scheduled;
+
+/**
+ * 项目变更审批增量同步定时器
+ * <p>
+ * 每天 3 次错峰扫描项目变更审批表单最近 7 天 gmtModified 的实例,
+ * 对涉及员工的应报工时按「变更生效日之后」重算 Manager (仅外部员工受影响; 内部员工 Manager 走钉钉主管不受本任务影响)。
+ * <p>
+ * cron 时点故意错开 WorkHoursCalcTimer 的 07:00/12:15/18:30, 避免宜搭 QPS 突发叠加。
+ */
+@Slf4j
+@Configuration
+@EnableScheduling
+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 * * ?")
+    public void syncProjectChanges() {
+        log.info("开始执行项目变更审批增量同步任务");
+        try {
+            workHoursCalcService.syncProjectChanges(7);
+            log.info("项目变更审批增量同步任务执行完成");
+        } catch (Exception e) {
+            log.error("项目变更审批增量同步任务执行失败", e);
+        }
+    }
+}

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

@@ -24,8 +24,8 @@ spring:
       connection-init-sql: SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci           # SqlServer, Oracle 无需设置类型
     driver-class-name: com.mysql.cj.jdbc.Driver
     username: root
-    password: 123456
-    url: jdbc:mysql://localhost:3306/paineng?serverTimezone=Asia/Shanghai&useUnicode=yes&characterEncoding=UTF-8&useSSL=true
+    password: ide123
+    url: jdbc:mysql://127.0.0.1:3306/mjava?serverTimezone=Asia/Shanghai&useUnicode=yes&characterEncoding=UTF-8&useSSL=true
   jpa:
     hibernate:
       ddl-auto: none      # JPA对表没有任何操作
@@ -72,6 +72,8 @@ workhours:
   formUuidRequiredHours: "FORM-D9144092C2C24C93A1B02A8EEED0663FFD8G"
   # 外部员工关联项目经理:项目档案表(含成员子表 tableField_mkowyn6d)
   formUuidProject: "FORM-A4A04FCDE2024B23B386504954760F55HIB9"
+  # 项目变更审批:子表复用项目档案 tableField_mkowyn6d 结构 + 原项目经理 employeeField_mrd9y8i9
+  formUuidProjectChange: "FORM-B86D895C90AE460B9749CC15E9079457PC79"
   # 审批回写:工时汇总表 + 两类审批单(当前指向测试环境,正式见注释)
   formUuidWorkHoursSummary: "FORM-87E51B67EBC1461C8247ED7D5D3E3DD829KC"
   formUuidApproval: "FORM-230BB68C42304492A242CA278EF4A853LYIY"        # 正式 FORM-DDA1683645954101890AF6BA723705F9M0XV
@@ -95,6 +97,7 @@ tianyancha:
   timeoutMs: 8000
 
 # 客户档案·22 字段 fieldId 映射 (云璞租户)
+# appType/formUuid 仅用于日志与联调排错; 后端不写宜搭 (由调用方自行 update)
 customer:
   yidaAppType: "APP_W73TG2OPB9M2J21FUSFA"
   yidaFormUuid: "FORM-743EC9F3012146B3831594126EE6117F12U8"

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

@@ -56,6 +56,8 @@ workhours:
   formUuidRequiredHours: "FORM-D9144092C2C24C93A1B02A8EEED0663FFD8G"
   # 外部员工关联项目经理:项目档案表(含成员子表 tableField_mkowyn6d)
   formUuidProject: "FORM-A4A04FCDE2024B23B386504954760F55HIB9"
+  # 项目变更审批:子表复用项目档案 tableField_mkowyn6d 结构 + 原项目经理 employeeField_mrd9y8i9
+  formUuidProjectChange: "FORM-B86D895C90AE460B9749CC15E9079457PC79"
   # 审批回写:工时汇总表 + 两类审批单(当前指向测试环境,正式见注释)
   formUuidWorkHoursSummary: "FORM-87E51B67EBC1461C8247ED7D5D3E3DD829KC"
   formUuidApproval: "FORM-230BB68C42304492A242CA278EF4A853LYIY"        # 正式 FORM-DDA1683645954101890AF6BA723705F9M0XV