|
@@ -47,6 +47,10 @@ public class WorkHoursCalcService {
|
|
|
private static final int THREAD_POOL_SIZE = 10;
|
|
private static final int THREAD_POOL_SIZE = 10;
|
|
|
private static final int MAX_RETRY = 2;
|
|
private static final int MAX_RETRY = 2;
|
|
|
|
|
|
|
|
|
|
+ // prd 增量同步只覆盖最近 N 天工作日窗口 (含 today), 避免服务器停机/单次失败导致数据永久丢失
|
|
|
|
|
+ // fixme 窗口内已存在的记录一律 skip (补漏语义, 不刷新已写入字段); 字段刷新走独立接口 (backfill / cleanup)
|
|
|
|
|
+ private static final int INCREMENTAL_WINDOW_DAYS = 3;
|
|
|
|
|
+
|
|
|
// prd 离职时间字段(人员档案侧, 用于过滤离职后不再生成/清理历史应报工时)
|
|
// prd 离职时间字段(人员档案侧, 用于过滤离职后不再生成/清理历史应报工时)
|
|
|
private static final String PERSONNEL_OFFLINE_DATE = "dateField_mh8xhqc7";
|
|
private static final String PERSONNEL_OFFLINE_DATE = "dateField_mh8xhqc7";
|
|
|
// prd 在职状态字段 + 离职取值 (防御开关: status=离职 且 offlineDate=空 → 数据不一致, 该员工不写工时)
|
|
// prd 在职状态字段 + 离职取值 (防御开关: status=离职 且 offlineDate=空 → 数据不一致, 该员工不写工时)
|
|
@@ -99,51 +103,75 @@ public class WorkHoursCalcService {
|
|
|
log.info("{}年{}月工作日{}天", year, month, workingDays.size());
|
|
log.info("{}年{}月工作日{}天", year, month, workingDays.size());
|
|
|
stats.put("workingDays", 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("success", counts[0]);
|
|
|
stats.put("fail", counts[1]);
|
|
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;
|
|
return stats;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * 增量同步:查询最近 N 天内修改过的人员档案,仅同步变动员工的当月数据
|
|
|
|
|
|
|
+ * 增量同步:全员 × 近 N 天工作日窗口(含 today), 已存在的记录一律 skip (补漏语义)
|
|
|
|
|
+ * ppExt: 用途 = 服务器停机/单次失败 补齐; 姓名/部门等字段刷新走 backfill 接口(不在增量做)
|
|
|
*
|
|
*
|
|
|
- * @param daysBack 回溯天数(默认2天)
|
|
|
|
|
|
|
+ * @param daysBack 窗口天数(含 today), 默认 3
|
|
|
*/
|
|
*/
|
|
|
public void incrementalSync(int daysBack) {
|
|
public void incrementalSync(int daysBack) {
|
|
|
|
|
+ if (daysBack <= 0) daysBack = INCREMENTAL_WINDOW_DAYS;
|
|
|
LocalDate today = LocalDate.now();
|
|
LocalDate today = LocalDate.now();
|
|
|
|
|
+ LocalDate fromDate = today.minusDays(daysBack - 1);
|
|
|
int year = today.getYear();
|
|
int year = today.getYear();
|
|
|
int month = today.getMonthValue();
|
|
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()) {
|
|
if (personnelMap.isEmpty()) {
|
|
|
- log.info("无人员档案变动,增量同步跳过");
|
|
|
|
|
|
|
+ log.warn("增量: 人员档案为空, 跳过");
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// 2. 预取 Manager (内部=钉钉主管, 外部=项目 assignments)
|
|
// 2. 预取 Manager (内部=钉钉主管, 外部=项目 assignments)
|
|
|
ManagerData managerData = queryManagerData(personnelMap);
|
|
ManagerData managerData = queryManagerData(personnelMap);
|
|
|
|
|
|
|
|
- // 3~4 查询节假日和工作日
|
|
|
|
|
|
|
+ // 3~4. 节假日 + 收窄到窗口内工作日 (跨月边界: 若窗口跨 6-30/7-1, 分别按各自月份的节假日规则)
|
|
|
Map<LocalDate, String> holidayRules = queryHolidayRules(String.valueOf(year));
|
|
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 不能为空");
|
|
result.put("error", "userId 和 workDay 不能为空");
|
|
|
return result;
|
|
return result;
|
|
|
}
|
|
}
|
|
|
|
|
+ // prd 只写到 today: 单条验证入口也拦截, 保持与主流程语义一致
|
|
|
|
|
+ if (workDay.isAfter(LocalDate.now())) {
|
|
|
|
|
+ result.put("success", false);
|
|
|
|
|
+ result.put("error", "workDay 在 today 之后, 按业务规则不写入未来数据");
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
// 1. 全量查人员档案,取目标员工信息
|
|
// 1. 全量查人员档案,取目标员工信息
|
|
|
Map<String, Map<String, Object>> personnelMap = queryAllPersonnelDetails();
|
|
Map<String, Map<String, Object>> personnelMap = queryAllPersonnelDetails();
|
|
@@ -289,8 +323,8 @@ public class WorkHoursCalcService {
|
|
|
List<LocalDate> workingDays = getWorkingDays(year, month, rules);
|
|
List<LocalDate> workingDays = getWorkingDays(year, month, rules);
|
|
|
stats.put("workingDays", workingDays.size());
|
|
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("success", counts[0]);
|
|
|
stats.put("fail", counts[1]);
|
|
stats.put("fail", counts[1]);
|
|
|
log.info("小批量同步完成: 成功{}条, 失败{}条({}名员工 × {}个工作日)",
|
|
log.info("小批量同步完成: 成功{}条, 失败{}条({}名员工 × {}个工作日)",
|
|
@@ -532,25 +566,256 @@ public class WorkHoursCalcService {
|
|
|
return stats;
|
|
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,每个线程处理一个员工的所有工作日
|
|
* 按员工维度多线程并发 upsert,每个线程处理一个员工的所有工作日
|
|
|
|
|
+ * ppExt: existKeys 传入调用方预查的 "empId|yyyy-MM-dd" 集合, 命中的直接跳过 (补漏语义, 不刷新已写入字段);
|
|
|
|
|
+ * workDay > today 也跳过 (业务规则: 只写到当天为止)
|
|
|
*
|
|
*
|
|
|
|
|
+ * @param existKeys 已存在的 empId|yyyy-MM-dd 键集合 (nullable, null 视为空集)
|
|
|
* @return int[]{successCount, failCount}
|
|
* @return int[]{successCount, failCount}
|
|
|
*/
|
|
*/
|
|
|
private int[] concurrentUpsert(Map<String, Map<String, Object>> personnelMap,
|
|
private int[] concurrentUpsert(Map<String, Map<String, Object>> personnelMap,
|
|
|
ManagerData managerData,
|
|
ManagerData managerData,
|
|
|
- List<LocalDate> workingDays) {
|
|
|
|
|
|
|
+ List<LocalDate> workingDays,
|
|
|
|
|
+ Set<String> existKeys) {
|
|
|
AtomicInteger successCount = new AtomicInteger(0);
|
|
AtomicInteger successCount = new AtomicInteger(0);
|
|
|
AtomicInteger failCount = new AtomicInteger(0);
|
|
AtomicInteger failCount = new AtomicInteger(0);
|
|
|
// fixme: 宜搭写接口有突发 QPS 上限,10 线程裸跑会零星触发「请求过于频繁」导致单条记录被丢(按人散落缺日,
|
|
// fixme: 宜搭写接口有突发 QPS 上限,10 线程裸跑会零星触发「请求过于频繁」导致单条记录被丢(按人散落缺日,
|
|
|
// 如吕加冕缺 6-16/6-25)。与 backfillCfEmployee 同款处方:20 QPS 全局限流 + 重试退避,补齐丢失记录。
|
|
// 如吕加冕缺 6-16/6-25)。与 backfillCfEmployee 同款处方:20 QPS 全局限流 + 重试退避,补齐丢失记录。
|
|
|
RateLimiter yidaLimiter = RateLimiter.create(20.0);
|
|
RateLimiter yidaLimiter = RateLimiter.create(20.0);
|
|
|
ExecutorService executor = Executors.newFixedThreadPool(THREAD_POOL_SIZE);
|
|
ExecutorService executor = Executors.newFixedThreadPool(THREAD_POOL_SIZE);
|
|
|
|
|
+ final Set<String> existSet = existKeys == null ? Collections.emptySet() : existKeys;
|
|
|
|
|
+ final LocalDate today = LocalDate.now();
|
|
|
try {
|
|
try {
|
|
|
List<Future<?>> futures = new ArrayList<>();
|
|
List<Future<?>> futures = new ArrayList<>();
|
|
|
|
|
|
|
|
|
|
+ AtomicInteger skippedFuture = new AtomicInteger(0);
|
|
|
|
|
+ AtomicInteger skippedExisting = new AtomicInteger(0);
|
|
|
AtomicInteger skippedOffline = new AtomicInteger(0);
|
|
AtomicInteger skippedOffline = new AtomicInteger(0);
|
|
|
AtomicInteger skippedNoPm = new AtomicInteger(0);
|
|
AtomicInteger skippedNoPm = new AtomicInteger(0);
|
|
|
AtomicInteger skippedInconsistent = new AtomicInteger(0);
|
|
AtomicInteger skippedInconsistent = new AtomicInteger(0);
|
|
@@ -574,6 +839,16 @@ public class WorkHoursCalcService {
|
|
|
|
|
|
|
|
futures.add(executor.submit(() -> {
|
|
futures.add(executor.submit(() -> {
|
|
|
for (LocalDate workDay : workingDays) {
|
|
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)) {
|
|
if (offlineDate != null && workDay.isAfter(offlineDate)) {
|
|
|
skippedOffline.incrementAndGet();
|
|
skippedOffline.incrementAndGet();
|
|
|
continue;
|
|
continue;
|
|
@@ -628,6 +903,12 @@ public class WorkHoursCalcService {
|
|
|
log.error("线程执行异常", e);
|
|
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) {
|
|
if (skippedOffline.get() > 0) {
|
|
|
log.info("离职员工过滤: 跳过{}条 workDay > offlineDate 的记录", skippedOffline.get());
|
|
log.info("离职员工过滤: 跳过{}条 workDay > offlineDate 的记录", skippedOffline.get());
|
|
|
}
|
|
}
|
|
@@ -775,6 +1056,166 @@ public class WorkHoursCalcService {
|
|
|
return stats;
|
|
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;
|
|
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 数据:
|
|
* 预取 Manager 数据:
|
|
|
* - 内部员工: 钉钉 API 取 manager_userid (直属主管, 单值)
|
|
* - 内部员工: 钉钉 API 取 manager_userid (直属主管, 单值)
|