Forráskód Böngészése

feat(workhours): sync 加防御开关: status=离职 且 offlineDate=空 时跳过, 避免误写

背景: 首轮 offlineDate 补齐后手工回填精确离职日 SOP 建立, 但仍可能出现人员档案
      status=离职 但 offlineDate 未同步的短暂不一致态; 此时应报工时逻辑无 offlineDate
      作过滤依据会照常生成, 后续 cleanup 也不会命中 → 误写沉积

改动:
- WorkHoursCalcService: +PERSONNEL_STATUS/STATUS_INACTIVE 常量
- queryAllPersonnelDetails + queryRecentPersonnelDetails 携带 status 字段
- concurrentUpsert: 员工层加防御, status=离职 且 offlineDate=null 时整月 skip
  + skippedInconsistent 计数 + WARN 日志便于排查
- syncOneEmployeeOneDay: 同款防御, 返回 success=false + error 提示
- cleanupAfterOffline: dryRun 返回前 5 条抽样 (instanceId/empId/workDay/offlineDate)
  便于人工核对

数据修复:
- workhours/cleanup-after-offline: 抽样核对通过, deleted=3351 fail=0
  (75 人精确离职日就位后, 剔除 5/6/7 月离职日之后残留工时 3351 条)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
malk 4 hete%!(EXTRA string=óta)
szülő
commit
0fbb0c160b

+ 35 - 0
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/WorkHoursCalcService.java

@@ -49,6 +49,9 @@ public class WorkHoursCalcService {
 
     // prd 离职时间字段(人员档案侧, 用于过滤离职后不再生成/清理历史应报工时)
     private static final String PERSONNEL_OFFLINE_DATE = "dateField_mh8xhqc7";
+    // prd 在职状态字段 + 离职取值 (防御开关: status=离职 且 offlineDate=空 → 数据不一致, 该员工不写工时)
+    private static final String PERSONNEL_STATUS = "radioField_mp1sngq1";
+    private static final String STATUS_INACTIVE = "离职";
 
     // prd 外部员工关联项目经理:项目档案成员子表字段
     private static final String PROJECT_SUB_TABLE = "tableField_mkowyn6d";      // 成员子表(人员明细)
@@ -180,6 +183,12 @@ public class WorkHoursCalcService {
             result.put("error", "员工已离职(offlineDate=" + offlineDate + "), 按业务规则跳过写入");
             return result;
         }
+        // 防御: status=离职 但 offlineDate=空 → 不一致态, 拒绝写入
+        if (STATUS_INACTIVE.equals(String.valueOf(info.get(PERSONNEL_STATUS))) && offlineDate == null) {
+            result.put("success", false);
+            result.put("error", "员工 status=离职 但 offlineDate=空, 数据不一致, 拒绝写入");
+            return result;
+        }
 
         // 2. Manager 取值: 内部=[钉钉直属主管], 外部=当天参与项目的 PM 合并
         boolean isInternal = "内部".equals(String.valueOf(info.get("radioField_mkow4ydo")));
@@ -544,6 +553,7 @@ public class WorkHoursCalcService {
 
             AtomicInteger skippedOffline = new AtomicInteger(0);
             AtomicInteger skippedNoPm = new AtomicInteger(0);
+            AtomicInteger skippedInconsistent = new AtomicInteger(0);
             for (Map.Entry<String, Map<String, Object>> entry : personnelMap.entrySet()) {
                 String empId = entry.getKey();
                 Map<String, Object> info = entry.getValue();
@@ -553,6 +563,14 @@ public class WorkHoursCalcService {
                 List<Assignment> externalAssignments = isInternal ? null : managerData.external.get(empId);
                 // prd 离职时间: 若有离职时间, 该日之后的应报工时不再生成
                 LocalDate offlineDate = parseToLocalDate(info.get(PERSONNEL_OFFLINE_DATE));
+                // prd 防御开关: 人员档案 status=离职 但 offlineDate 空 → 数据不一致态, 该员工整月跳过, 避免误写
+                boolean inconsistentOffline = STATUS_INACTIVE.equals(String.valueOf(info.get(PERSONNEL_STATUS)))
+                        && offlineDate == null;
+                if (inconsistentOffline) {
+                    log.warn("[防御跳过] 员工{} status=离职 但 offlineDate=空, 整月跳过写入(工作日{}天)", empId, workingDays.size());
+                    skippedInconsistent.addAndGet(workingDays.size());
+                    continue;
+                }
 
                 futures.add(executor.submit(() -> {
                     for (LocalDate workDay : workingDays) {
@@ -616,6 +634,9 @@ public class WorkHoursCalcService {
             if (skippedNoPm.get() > 0) {
                 log.info("外部员工无 PM 过滤: 跳过{}条 当天无活跃项目 PM 的记录", skippedNoPm.get());
             }
+            if (skippedInconsistent.get() > 0) {
+                log.warn("[防御跳过统计] 累计跳过{}条 status=离职 但 offlineDate=空 的记录", skippedInconsistent.get());
+            }
         } finally {
             executor.shutdown();
         }
@@ -656,6 +677,7 @@ public class WorkHoursCalcService {
 
         // 2. 按月分区扫应报工时, 收集离职日之后的 instanceIds
         List<String> toDelete = new ArrayList<>();
+        List<Map<String, Object>> samples = new ArrayList<>();   // dryRun 抽样前 5 条便于人工核对
         int total = 0;
         int pageSize = YDConf.PAGE_SIZE_LIMIT;
         ZoneId zone = ZoneId.systemDefault();
@@ -700,6 +722,14 @@ public class WorkHoursCalcService {
                     // 离职日当天及之前保留 (员工在职期间工时有效, 不动)
                     if (workDay != null && workDay.isAfter(offlineDate)) {
                         toDelete.add(String.valueOf(instId));
+                        if (samples.size() < 5) {
+                            Map<String, Object> s = new LinkedHashMap<>();
+                            s.put("instanceId", String.valueOf(instId));
+                            s.put("empId", empId);
+                            s.put("workDay", workDay.toString());
+                            s.put("offlineDate", offlineDate.toString());
+                            samples.add(s);
+                        }
                     }
                 }
                 currentPage++;
@@ -710,6 +740,7 @@ public class WorkHoursCalcService {
 
         stats.put("total", total);
         stats.put("toDelete", toDelete.size());
+        stats.put("samples", samples);
         if (dryRun) {
             stats.put("deleted", 0);
             stats.put("fail", 0);
@@ -854,6 +885,8 @@ public class WorkHoursCalcService {
                     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);
                 }
             }
@@ -913,6 +946,8 @@ public class WorkHoursCalcService {
                     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);
                 }
             }