Procházet zdrojové kódy

fix(workhours): 归属公司字段ID纠错 + delete-all bug 修复 + 新增分级验证入口

- 纠正人员档案"归属公司"字段 ID:textField_mmekrcji → selectField_mh8xhqc4(下拉选择器),目标表单写入字段仍为 textField_mmekrcji(两侧 fieldId 不同)
- 修复 deleteAllRequiredHours 提前退出 bug:原 `if (totalDeleted >= totalCount) break` 用累计已删数对比当次剩余总数,两者相遇时提前 break 导致残留(实测 3288→3218 / 1518→1700 未清);去除该条件,仅靠循环顶部 dataList.isEmpty() 判空,加 20 万条安全阈值防死循环
- 新增 syncOneEmployeeOneDay / syncBatchEmployees 两个 Service 方法与 /sync-one、/sync-batch HTTP 端点,填补"单条 ↔ 全量"之间的分级验证缺口,返回体回显 sampleEmployees 便于核对字段

2026-04-23 全量验证:687 人 × 21 工作日 = 14427 条 / 0 失败 / 17.9 分钟

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
malk před 1 týdnem
rodič
revize
0b55cb7aef

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

@@ -45,6 +45,60 @@ public class WorkHoursController {
         return result;
     }
 
+    /**
+     * 单员工单日验证写入(清空后做一条验证,确认归属公司等字段正确带入)
+     * GET /workhours/sync-one?userId=xxx&date=2026-04-22
+     */
+    @GetMapping("/sync-one")
+    public Map<String, Object> syncOne(@RequestParam String userId,
+                                       @RequestParam String date) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        try {
+            long start = System.currentTimeMillis();
+            LocalDate workDay = LocalDate.parse(date);
+            Map<String, Object> data = workHoursCalcService.syncOneEmployeeOneDay(userId, workDay);
+            long cost = System.currentTimeMillis() - start;
+            Object ok = data.get("success");
+            result.put("success", Boolean.TRUE.equals(ok));
+            result.put("message", Boolean.TRUE.equals(ok) ? "单条验证写入完成" : "单条验证写入失败");
+            result.put("data", data);
+            result.put("costMs", cost);
+        } catch (Exception e) {
+            log.error("单条验证失败", e);
+            result.put("success", false);
+            result.put("message", e.getMessage());
+        }
+        return result;
+    }
+
+    /**
+     * 小批量同步:前 N 个内部员工 × 当月工作日(默认 count=5)
+     * GET /workhours/sync-batch?count=5&month=2026-04
+     */
+    @GetMapping("/sync-batch")
+    public Map<String, Object> syncBatch(@RequestParam(defaultValue = "5") int count,
+                                         @RequestParam(required = false) String month) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        try {
+            LocalDate targetMonth = null;
+            if (month != null && !month.isEmpty()) {
+                targetMonth = LocalDate.parse(month + "-01");
+            }
+            long start = System.currentTimeMillis();
+            Map<String, Object> stats = workHoursCalcService.syncBatchEmployees(count, targetMonth);
+            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

+ 134 - 4
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/WorkHoursCalcService.java

@@ -129,6 +129,130 @@ public class WorkHoursCalcService {
                 counts[0], counts[1], personnelMap.size(), workingDays.size());
     }
 
+    /**
+     * 单员工单日 upsert 验证入口(清空后做一条验证用,非日常流程)
+     * ppExt: 复用 queryAllPersonnelDetails + 钉钉主管查询 + upsertDailyHours;
+     *        返回体回显 personnelInfo(含 textField_mmekrcji 归属公司)供核对
+     *
+     * @param userId  员工钉钉 userId
+     * @param workDay 目标日期
+     * @return Map{userId, workDay, personnelInfo, managerId, success, error?}
+     */
+    public Map<String, Object> syncOneEmployeeOneDay(String userId, LocalDate workDay) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("userId", userId);
+        result.put("workDay", workDay == null ? null : workDay.toString());
+
+        if (userId == null || userId.isEmpty() || workDay == null) {
+            result.put("success", false);
+            result.put("error", "userId 和 workDay 不能为空");
+            return result;
+        }
+
+        // 1. 全量查人员档案,取目标员工信息
+        Map<String, Map<String, Object>> personnelMap = queryAllPersonnelDetails();
+        Map<String, Object> info = personnelMap.get(userId);
+        if (info == null) {
+            result.put("success", false);
+            result.put("error", "人员档案未找到该 userId: " + userId);
+            return result;
+        }
+        result.put("personnelInfo", info);
+
+        // 2. 内部员工查主管(外部员工跳过)
+        String managerId = null;
+        if ("内部".equals(String.valueOf(info.get("radioField_mkow4ydo")))) {
+            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;
+                    }
+                }
+            } catch (Exception e) {
+                log.warn("获取员工{}直属主管失败: {}", userId, e.getMessage());
+            }
+        }
+        result.put("managerId", managerId);
+
+        // 3. upsert 写一条
+        try {
+            upsertDailyHours(userId, managerId, workDay, info);
+            result.put("success", true);
+            log.info("单条验证写入成功: userId={}, workDay={}, 归属公司={}",
+                    userId, workDay, info.get("textField_mmekrcji"));
+        } catch (Exception e) {
+            log.error("单条验证写入失败: userId={}, workDay={}", userId, workDay, e);
+            result.put("success", false);
+            result.put("error", e.getMessage());
+        }
+        return result;
+    }
+
+    /**
+     * 小批量同步验证入口(清空后取前 N 个内部员工写入当月数据,便于核对归属公司与 Manager)
+     * ppExt: 走完整流程(人员档案→主管→节假日→工作日→并发写入),仅人员集合缩成前 N 个内部员工
+     *
+     * @param limit       前 N 个内部员工
+     * @param targetMonth 目标月份,null 则默认当前月
+     * @return Map{employeeCount, workingDays, success, fail, sampleEmployees}
+     */
+    public Map<String, Object> syncBatchEmployees(int limit, LocalDate targetMonth) {
+        Map<String, Object> stats = new LinkedHashMap<>();
+        if (targetMonth == null) {
+            targetMonth = LocalDate.now();
+        }
+        if (limit <= 0) {
+            limit = 5;
+        }
+        int year = targetMonth.getYear();
+        int month = targetMonth.getMonthValue();
+        log.info("开始小批量同步: 前{}个内部员工, {}年{}月", limit, year, month);
+
+        // 1. 全量查人员档案
+        Map<String, Map<String, Object>> all = queryAllPersonnelDetails();
+
+        // 2. 截取前 limit 个内部员工(保持档案原始顺序)
+        Map<String, Map<String, Object>> subset = new LinkedHashMap<>();
+        List<Map<String, Object>> sample = new ArrayList<>();
+        for (Map.Entry<String, Map<String, Object>> entry : all.entrySet()) {
+            Map<String, Object> info = entry.getValue();
+            if (!"内部".equals(String.valueOf(info.get("radioField_mkow4ydo")))) continue;
+            subset.put(entry.getKey(), info);
+            Map<String, Object> s = new LinkedHashMap<>();
+            s.put("userId", entry.getKey());
+            s.put("textField_mh8xhqc1", info.get("textField_mh8xhqc1"));
+            s.put("textField_mmekrcji", info.get("textField_mmekrcji"));
+            sample.add(s);
+            if (subset.size() >= limit) break;
+        }
+        stats.put("employeeCount", subset.size());
+        stats.put("sampleEmployees", sample);
+        if (subset.isEmpty()) {
+            log.warn("未取到任何内部员工,跳过");
+            return stats;
+        }
+
+        // 3. 主管
+        Map<String, String> managerMap = queryManagerMap(subset);
+        stats.put("managerCount", managerMap.size());
+
+        // 4. 节假日 + 工作日
+        Map<LocalDate, String> rules = queryHolidayRules(String.valueOf(year));
+        List<LocalDate> workingDays = getWorkingDays(year, month, rules);
+        stats.put("workingDays", workingDays.size());
+
+        // 5. 并发写入
+        int[] counts = concurrentUpsert(subset, managerMap, workingDays);
+        stats.put("success", counts[0]);
+        stats.put("fail", counts[1]);
+        log.info("小批量同步完成: 成功{}条, 失败{}条({}名员工 × {}个工作日)",
+                counts[0], counts[1], subset.size(), workingDays.size());
+        return stats;
+    }
+
     /**
      * 批量删除应填报工时全部数据(验证时使用,非日常流程)
      */
@@ -185,8 +309,12 @@ public class WorkHoursCalcService {
                 }
             }
 
-            // 删除后总数变化,始终查第1页
-            if (totalDeleted >= totalCount) break;
+            // fixme: 原判断 `totalDeleted >= totalCount` 会提前 break(累计已删 vs 当次剩余总数语义不同)
+            //        改为仅靠下一轮 dataList.isEmpty() 判空;加安全阈值防死循环
+            if (totalDeleted > 200000) {
+                log.warn("已删除{}条超过安全阈值,终止批量删除", totalDeleted);
+                break;
+            }
         }
         log.info("应填报工时数据清空完成, 共删除{}条", totalDeleted);
     }
@@ -355,7 +483,8 @@ public class WorkHoursCalcService {
                     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"));
-                    info.put("textField_mmekrcji", formData.get("textField_mmekrcji"));
+                    // 归属公司:人员档案是 SelectField 下拉,直接取字符串;目标表单写入仍用 textField_mmekrcji
+                    info.put("textField_mmekrcji", formData.get("selectField_mh8xhqc4"));
                     personnelMap.put(empId, info);
                 }
             }
@@ -409,7 +538,8 @@ public class WorkHoursCalcService {
                     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"));
-                    info.put("textField_mmekrcji", formData.get("textField_mmekrcji"));
+                    // 归属公司:人员档案是 SelectField 下拉,直接取字符串;目标表单写入仍用 textField_mmekrcji
+                    info.put("textField_mmekrcji", formData.get("selectField_mh8xhqc4"));
                     personnelMap.put(empId, info);
                 }
             }