|
|
@@ -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 目标月份(用于判定项目下线时间:下线时间 < 目标月 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: 子表 <50 行用内联、==50 走 ydService.queryDetails 取全(与 ApprovalWriteBackService.resolveDetailRows 同款)
|
|
|
+ * 扫项目档案全量, 收集外部员工的项目 assignments (含下线日期 + PM), 按天算 PM 见 computeDailyPms
|
|
|
+ * ppExt: 子表 <50 行用内联、==50 走 ydService.queryDetails 取全 (与 ApprovalWriteBackService.resolveDetailRows 同款)
|
|
|
*
|
|
|
* @param externalIds 外部员工 userId 集合
|
|
|
- * @param targetMonth 目标月份(下线时间 < 目标月 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 <= 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 >= 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);
|
|
|
}
|
|
|
|
|
|
// 人员档案补充字段
|