|
|
@@ -5,16 +5,16 @@ import com.alibaba.fastjson.JSONObject;
|
|
|
import com.google.common.util.concurrent.RateLimiter;
|
|
|
import com.malk.server.aliwork.YDConf;
|
|
|
import com.malk.server.aliwork.YDParam;
|
|
|
+import com.malk.server.common.McException;
|
|
|
import com.malk.server.dingtalk.DDR_New;
|
|
|
import com.malk.server.workhours.WHConf;
|
|
|
import com.malk.service.aliwork.YDClient;
|
|
|
import com.malk.service.aliwork.YDService;
|
|
|
-import com.malk.service.dingtalk.DDClient;
|
|
|
-import com.malk.service.dingtalk.DDClient_Contacts;
|
|
|
import lombok.extern.slf4j.Slf4j;
|
|
|
import org.springframework.beans.factory.annotation.Autowired;
|
|
|
import org.springframework.stereotype.Service;
|
|
|
|
|
|
+import java.io.IOException;
|
|
|
import java.time.DayOfWeek;
|
|
|
import java.time.Instant;
|
|
|
import java.time.LocalDate;
|
|
|
@@ -37,36 +37,34 @@ public class WorkHoursCalcService {
|
|
|
@Autowired
|
|
|
private WHConf whConf;
|
|
|
|
|
|
- @Autowired
|
|
|
- private DDClient ddClient;
|
|
|
-
|
|
|
- @Autowired
|
|
|
- private DDClient_Contacts ddClient_contacts;
|
|
|
-
|
|
|
private static final int DAILY_HOURS = 8;
|
|
|
private static final int THREAD_POOL_SIZE = 10;
|
|
|
private static final int MAX_RETRY = 2;
|
|
|
+ private static final int EXISTING_QUERY_MAX_ATTEMPTS = 3;
|
|
|
+ private static final long[] EXISTING_QUERY_RETRY_DELAYS_MS = {2_000L, 5_000L};
|
|
|
+ private RetrySleeper existingQueryRetrySleeper = Thread::sleep;
|
|
|
|
|
|
// prd 增量同步只覆盖最近 N 天工作日窗口 (含 today), 避免服务器停机/单次失败导致数据永久丢失
|
|
|
// fixme 窗口内已存在的记录一律 skip (补漏语义, 不刷新已写入字段); 字段刷新走独立接口 (backfill / cleanup)
|
|
|
- private static final int INCREMENTAL_WINDOW_DAYS = 3;
|
|
|
+ private static final int INCREMENTAL_WINDOW_DAYS = 7;
|
|
|
|
|
|
// prd 离职时间字段(人员档案侧, 用于过滤离职后不再生成/清理历史应报工时)
|
|
|
private static final String PERSONNEL_OFFLINE_DATE = "dateField_mh8xhqc7";
|
|
|
// prd 入职时间字段(以人员档案创建日期为准,入职日前不生成应报工时,入职当天保留)
|
|
|
private static final String PERSONNEL_HIRED_DATE = "dateField_mh8xhqc6";
|
|
|
+ private static final String PERSONNEL_MANAGER = "employeeField_mh8xhqc3";
|
|
|
// prd 在职状态字段 + 离职取值 (防御开关: status=离职 且 offlineDate=空 → 数据不一致, 该员工不写工时)
|
|
|
private static final String PERSONNEL_STATUS = "radioField_mp1sngq1";
|
|
|
private static final String STATUS_INACTIVE = "离职";
|
|
|
// prd 应填报人显示名称文本,用于报表按姓名筛选;姓名取自人员档案成员组件显示值
|
|
|
private static final String REQUIRED_HOURS_EMPLOYEE_NAME = "textField_ms4bzjkm";
|
|
|
|
|
|
- // prd 全员项目参与关系 + 外部员工项目经理:项目档案字段
|
|
|
+ // prd 外部员工项目参与关系 + 项目经理:项目档案字段
|
|
|
private static final String PROJECT_SUB_TABLE = "tableField_mkowyn6d"; // 成员子表(人员明细)
|
|
|
private static final String PROJECT_SUB_MEMBER = "employeeField_mmbfe0ij"; // 子表-员工(成员)
|
|
|
private static final String PROJECT_SUB_PM = "employeeField_mkoxpswf"; // 子表-项目经理
|
|
|
private static final String PROJECT_SUB_OFFLINE = "dateField_mo6s11tc"; // 子表-下线时间
|
|
|
- // prd 仅启用项目参与人员需要统计应填报工时
|
|
|
+ // prd 外部员工仅在参与启用项目且存在有效 PM 时统计应填报工时
|
|
|
private static final String PROJECT_STATUS = "textField_mpwc5r0q"; // 主表-项目状态
|
|
|
private static final String PROJECT_STATUS_ACTIVE = "启用";
|
|
|
|
|
|
@@ -96,11 +94,10 @@ public class WorkHoursCalcService {
|
|
|
return stats;
|
|
|
}
|
|
|
|
|
|
- // 2. 预取 Manager + 启用项目参与关系;所有员工均须命中当天参与项目
|
|
|
+ // 2. 预取 Manager:内部员工取直属主管,外部员工取启用项目 assignments
|
|
|
ManagerData managerData = queryManagerData(personnelMap);
|
|
|
stats.put("internalMgrCount", managerData.internal.size());
|
|
|
stats.put("externalWithProjects", managerData.external.size());
|
|
|
- stats.put("projectParticipantCount", managerData.projects.size());
|
|
|
|
|
|
// 3. 查询节假日规则
|
|
|
Map<LocalDate, String> holidayRules = queryHolidayRules(String.valueOf(year));
|
|
|
@@ -131,7 +128,7 @@ public class WorkHoursCalcService {
|
|
|
* 增量同步:全员 × 近 N 天工作日窗口(含 today), 已存在的记录一律 skip (补漏语义)
|
|
|
* ppExt: 用途 = 服务器停机/单次失败 补齐; 姓名/部门等字段刷新走 backfill 接口(不在增量做)
|
|
|
*
|
|
|
- * @param daysBack 窗口天数(含 today), 默认 3
|
|
|
+ * @param daysBack 自然日窗口天数(含 today), 默认 7
|
|
|
*/
|
|
|
public void incrementalSync(int daysBack) {
|
|
|
if (daysBack <= 0) daysBack = INCREMENTAL_WINDOW_DAYS;
|
|
|
@@ -148,7 +145,7 @@ public class WorkHoursCalcService {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
- // 2. 预取 Manager + 全员启用项目 assignments
|
|
|
+ // 2. 预取 Manager:内部员工取直属主管,外部员工取启用项目 assignments
|
|
|
ManagerData managerData = queryManagerData(personnelMap);
|
|
|
|
|
|
// 3~4. 节假日 + 收窄到窗口内工作日 (跨月边界: 若窗口跨 6-30/7-1, 分别按各自月份的节假日规则)
|
|
|
@@ -239,33 +236,23 @@ public class WorkHoursCalcService {
|
|
|
return result;
|
|
|
}
|
|
|
|
|
|
- // 2. 所有员工都必须参与启用项目;内部取直属主管,外部取当天参与项目的 PM 合并
|
|
|
- Map<String, List<Assignment>> projectMap = queryProjectAssignments(Collections.singleton(userId));
|
|
|
- List<Assignment> assignments = projectMap.get(userId);
|
|
|
- if (!hasActiveProject(assignments, workDay)) {
|
|
|
- result.put("success", false);
|
|
|
- result.put("error", "员工当日未参与启用项目, 按业务规则跳过写入");
|
|
|
- return result;
|
|
|
- }
|
|
|
-
|
|
|
- boolean isInternal = "内部".equals(String.valueOf(info.get("radioField_mkow4ydo")));
|
|
|
+ // 2. 内部员工不校验项目,外部员工取当天参与启用项目的 PM 合并
|
|
|
+ boolean isInternal = isInternalEmployee(info);
|
|
|
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 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);
|
|
|
+ String mgrId = personnelManagerId(info);
|
|
|
+ // prd 内部员工直接使用人员档案已同步 Manager,不再重复查询通讯录;Manager 为空仍写入
|
|
|
+ managerIds = mgrId == null
|
|
|
+ ? Collections.emptyList()
|
|
|
+ : Collections.singletonList(mgrId);
|
|
|
} else {
|
|
|
+ Map<String, List<Assignment>> projectMap = queryProjectAssignments(Collections.singleton(userId));
|
|
|
+ List<Assignment> assignments = projectMap.get(userId);
|
|
|
+ if (!hasActiveProject(assignments, workDay)) {
|
|
|
+ result.put("success", false);
|
|
|
+ result.put("error", "外部员工当日未参与启用项目, 按业务规则跳过写入");
|
|
|
+ return result;
|
|
|
+ }
|
|
|
// 外部员工: 按 workDay 算当天活跃项目 PM; 全空则按业务规则不写入
|
|
|
managerIds = computeDailyPms(assignments, workDay);
|
|
|
if (managerIds.isEmpty()) {
|
|
|
@@ -318,7 +305,7 @@ public class WorkHoursCalcService {
|
|
|
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;
|
|
|
+ if (!isInternalEmployee(info)) continue;
|
|
|
subset.put(entry.getKey(), info);
|
|
|
Map<String, Object> s = new LinkedHashMap<>();
|
|
|
s.put("userId", entry.getKey());
|
|
|
@@ -339,7 +326,6 @@ public class WorkHoursCalcService {
|
|
|
ManagerData managerData = queryManagerData(subset);
|
|
|
stats.put("internalMgrCount", managerData.internal.size());
|
|
|
stats.put("externalWithProjects", managerData.external.size());
|
|
|
- stats.put("projectParticipantCount", managerData.projects.size());
|
|
|
|
|
|
// 4. 节假日 + 工作日
|
|
|
Map<LocalDate, String> rules = queryHolidayRules(String.valueOf(year));
|
|
|
@@ -857,10 +843,10 @@ public class WorkHoursCalcService {
|
|
|
for (Map.Entry<String, Map<String, Object>> entry : personnelMap.entrySet()) {
|
|
|
String empId = entry.getKey();
|
|
|
Map<String, Object> info = entry.getValue();
|
|
|
- boolean isInternal = "内部".equals(String.valueOf(info.get("radioField_mkow4ydo")));
|
|
|
- // 内部: 单值直属主管; 外部: assignments 交给内层按 workDay 算 PM
|
|
|
+ boolean isInternal = isInternalEmployee(info);
|
|
|
+ // 内部: 单值直属主管且不校验项目; 外部: assignments 交给内层按 workDay 算 PM
|
|
|
String internalMgrId = isInternal ? managerData.internal.get(empId) : null;
|
|
|
- List<Assignment> projectAssignments = managerData.projects.get(empId);
|
|
|
+ List<Assignment> externalAssignments = isInternal ? null : managerData.external.get(empId);
|
|
|
// prd 离职时间: 若有离职时间, 该日之后的应报工时不再生成
|
|
|
LocalDate offlineDate = parseToLocalDate(info.get(PERSONNEL_OFFLINE_DATE));
|
|
|
// prd 入职时间: 入职日前不生成, 入职当天保留
|
|
|
@@ -894,19 +880,19 @@ public class WorkHoursCalcService {
|
|
|
skippedBeforeHired.incrementAndGet();
|
|
|
continue;
|
|
|
}
|
|
|
- // prd 所有员工均须在启用项目中且当日未下线;无项目不统计应填报工时
|
|
|
- if (!hasActiveProject(projectAssignments, workDay)) {
|
|
|
+ // prd 外部员工须在启用项目中且当日未下线;内部员工不校验项目
|
|
|
+ if (!isInternal && !hasActiveProject(externalAssignments, workDay)) {
|
|
|
skippedNoProject.incrementAndGet();
|
|
|
continue;
|
|
|
}
|
|
|
- // prd 外部员工: 当天参与项目的 PM 集合为空(无项目/PM 全空/项目全下线) → 不生成记录
|
|
|
+ // prd 内部员工允许空 Manager;外部员工仍必须存在启用项目及有效项目 PM
|
|
|
List<String> managerIds;
|
|
|
if (isInternal) {
|
|
|
managerIds = (internalMgrId == null || internalMgrId.isEmpty())
|
|
|
? Collections.emptyList()
|
|
|
: Collections.singletonList(internalMgrId);
|
|
|
} else {
|
|
|
- managerIds = computeDailyPms(projectAssignments, workDay);
|
|
|
+ managerIds = computeDailyPms(externalAssignments, workDay);
|
|
|
if (managerIds.isEmpty()) {
|
|
|
skippedNoPm.incrementAndGet();
|
|
|
continue;
|
|
|
@@ -962,7 +948,7 @@ public class WorkHoursCalcService {
|
|
|
log.info("入职日期过滤: 跳过{}条 workDay < hiredDate 的记录", skippedBeforeHired.get());
|
|
|
}
|
|
|
if (skippedNoProject.get() > 0) {
|
|
|
- log.info("启用项目参与过滤: 跳过{}条 当天未参与启用项目的记录", skippedNoProject.get());
|
|
|
+ log.info("外部员工启用项目参与过滤: 跳过{}条 当天未参与启用项目的记录", skippedNoProject.get());
|
|
|
}
|
|
|
if (skippedNoPm.get() > 0) {
|
|
|
log.info("外部员工无 PM 过滤: 跳过{}条 当天无活跃项目 PM 的记录", skippedNoPm.get());
|
|
|
@@ -1540,14 +1526,8 @@ public class WorkHoursCalcService {
|
|
|
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);
|
|
|
+ DDR_New result = queryExistingHoursPage(
|
|
|
+ appType, systemToken, searchFieldJson, fromDate, toDate, currentPage, pageSize);
|
|
|
|
|
|
totalCount = result.getTotalCount();
|
|
|
List<Map> dataList = (List<Map>) result.getData();
|
|
|
@@ -1568,6 +1548,104 @@ public class WorkHoursCalcService {
|
|
|
return keys;
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 查询一页已存在工时;宜搭临时服务异常时仅重试当前页,避免前面已完成的分页重复执行。
|
|
|
+ */
|
|
|
+ private DDR_New queryExistingHoursPage(String appType,
|
|
|
+ String systemToken,
|
|
|
+ String searchFieldJson,
|
|
|
+ LocalDate fromDate,
|
|
|
+ LocalDate toDate,
|
|
|
+ int currentPage,
|
|
|
+ int pageSize) {
|
|
|
+ for (int attempt = 1; attempt <= EXISTING_QUERY_MAX_ATTEMPTS; attempt++) {
|
|
|
+ try {
|
|
|
+ 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);
|
|
|
+ if (attempt > 1) {
|
|
|
+ log.info("查询已有工时重试成功: range=[{} ~ {}], page={}, attempt={}/{}",
|
|
|
+ fromDate, toDate, currentPage, attempt, EXISTING_QUERY_MAX_ATTEMPTS);
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ } catch (Exception e) {
|
|
|
+ boolean retryable = isRetryableExistingQueryError(e);
|
|
|
+ log.warn("查询已有工时失败: range=[{} ~ {}], page={}, pageSize={}, attempt={}/{}, retryable={}, code={}, source={}, message={}",
|
|
|
+ fromDate, toDate, currentPage, pageSize, attempt, EXISTING_QUERY_MAX_ATTEMPTS,
|
|
|
+ retryable, queryErrorCode(e), queryErrorSource(e), e.getMessage());
|
|
|
+ if (!retryable || attempt >= EXISTING_QUERY_MAX_ATTEMPTS) throw e;
|
|
|
+ sleepBeforeExistingQueryRetry(EXISTING_QUERY_RETRY_DELAYS_MS[attempt - 1]);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ throw new IllegalStateException("查询已有工时重试流程异常结束");
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String queryErrorCode(Exception e) {
|
|
|
+ return e instanceof McException ? ((McException) e).getCode() : e.getClass().getSimpleName();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String queryErrorSource(Exception e) {
|
|
|
+ return e instanceof McException ? ((McException) e).getSource() : "runtime";
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 仅重试网络异常、服务端临时错误和明确限流;权限、参数、配置及程序错误立即失败。
|
|
|
+ */
|
|
|
+ private static boolean isRetryableExistingQueryError(Exception e) {
|
|
|
+ for (Throwable cause = e; cause != null; cause = cause.getCause()) {
|
|
|
+ if (cause instanceof IOException) return true;
|
|
|
+ }
|
|
|
+ if (!(e instanceof McException)) return false;
|
|
|
+
|
|
|
+ McException mcException = (McException) e;
|
|
|
+ return containsRetryableQueryMarker(mcException.getCode())
|
|
|
+ || containsRetryableQueryMarker(mcException.getMessage());
|
|
|
+ }
|
|
|
+
|
|
|
+ private static boolean containsRetryableQueryMarker(String value) {
|
|
|
+ if (value == null || value.trim().isEmpty()) return false;
|
|
|
+ String normalized = value.trim().toLowerCase(Locale.ROOT);
|
|
|
+ return "429".equals(normalized)
|
|
|
+ || "500".equals(normalized)
|
|
|
+ || "502".equals(normalized)
|
|
|
+ || "503".equals(normalized)
|
|
|
+ || "504".equals(normalized)
|
|
|
+ || "90002".equals(normalized)
|
|
|
+ || normalized.contains("temporary")
|
|
|
+ || normalized.contains("temporarily")
|
|
|
+ || normalized.contains("timeout")
|
|
|
+ || normalized.contains("timed out")
|
|
|
+ || normalized.contains("service unavailable")
|
|
|
+ || normalized.contains("system busy")
|
|
|
+ || normalized.contains("too many request")
|
|
|
+ || normalized.contains("rate limit")
|
|
|
+ || normalized.contains("throttl")
|
|
|
+ || normalized.contains("请求过于频繁")
|
|
|
+ || normalized.contains("系统繁忙")
|
|
|
+ || normalized.contains("服务暂时")
|
|
|
+ || normalized.contains("服务端临时")
|
|
|
+ || normalized.contains("限流");
|
|
|
+ }
|
|
|
+
|
|
|
+ private void sleepBeforeExistingQueryRetry(long delayMs) {
|
|
|
+ try {
|
|
|
+ existingQueryRetrySleeper.sleep(delayMs);
|
|
|
+ } catch (InterruptedException e) {
|
|
|
+ Thread.currentThread().interrupt();
|
|
|
+ throw new IllegalStateException("查询已有工时重试等待被中断", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @FunctionalInterface
|
|
|
+ interface RetrySleeper {
|
|
|
+ void sleep(long delayMs) throws InterruptedException;
|
|
|
+ }
|
|
|
+
|
|
|
// ==================== 数据查询 ====================
|
|
|
|
|
|
/**
|
|
|
@@ -1685,6 +1763,11 @@ public class WorkHoursCalcService {
|
|
|
info.put(PERSONNEL_HIRED_DATE, formData.get(PERSONNEL_HIRED_DATE));
|
|
|
// prd 在职状态: 携带供 concurrentUpsert 防御 (status=离职 且 offlineDate=空 时跳过写入)
|
|
|
info.put(PERSONNEL_STATUS, formData.get(PERSONNEL_STATUS));
|
|
|
+ // prd 内部员工主管单一来源:直接使用人员档案最近一次同步的 Manager
|
|
|
+ List<String> personnelManagers = extractIdList(formData, PERSONNEL_MANAGER);
|
|
|
+ if (!personnelManagers.isEmpty()) {
|
|
|
+ info.put(PERSONNEL_MANAGER, personnelManagers.get(0));
|
|
|
+ }
|
|
|
personnelMap.put(empId, info);
|
|
|
}
|
|
|
}
|
|
|
@@ -1696,55 +1779,40 @@ public class WorkHoursCalcService {
|
|
|
|
|
|
/**
|
|
|
* 预取 Manager 数据:
|
|
|
- * - 内部员工: 钉钉 API 取 manager_userid (直属主管, 单值)
|
|
|
- * - 全体员工: 匹配启用项目 assignments,作为应填报工时准入条件
|
|
|
- * - 外部员工: 再按天从 assignments 计算 PM,多项目合并去重
|
|
|
+ * - 内部员工: 直接使用人员档案已同步 Manager (单值,允许为空)
|
|
|
+ * - 外部员工: 匹配启用项目 assignments,再按天计算 PM,多项目合并去重
|
|
|
+ * - 内部员工: 不以项目参与关系作为应填报工时准入条件
|
|
|
* ppExt: 与前端 TimeCard 刻意不同 - 不做 PM 离职探活、不兜底 Raymond (后端只记数据, 非审批找活人)
|
|
|
*
|
|
|
* @param personnelMap 全量人员档案
|
|
|
- * @return ManagerData: internal=内部主管, projects=全员启用项目参与关系, external=外部员工项目参与关系
|
|
|
+ * @return ManagerData: internal=内部主管, external=外部员工启用项目参与关系
|
|
|
*/
|
|
|
- @SuppressWarnings("unchecked")
|
|
|
private ManagerData queryManagerData(Map<String, Map<String, Object>> personnelMap) {
|
|
|
ManagerData md = new ManagerData();
|
|
|
+ Set<String> internalIds = new HashSet<>();
|
|
|
Set<String> externalIds = new HashSet<>();
|
|
|
- String accessToken = ddClient.getAccessToken();
|
|
|
- int internalCount = 0;
|
|
|
|
|
|
- // 内部员工取钉钉直属主管;同时记录外部员工集合,供项目匹配统计与 PM 计算
|
|
|
+ // 先分类;内部员工直接使用人员档案 Manager,外部员工再匹配项目
|
|
|
for (Map.Entry<String, Map<String, Object>> entry : personnelMap.entrySet()) {
|
|
|
String empId = entry.getKey();
|
|
|
- Object attr = entry.getValue().get("radioField_mkow4ydo");
|
|
|
-
|
|
|
- if ("内部".equals(String.valueOf(attr))) {
|
|
|
- internalCount++;
|
|
|
- try {
|
|
|
- Map userInfo = ddClient_contacts.getUserInfoById(accessToken, empId);
|
|
|
- if (userInfo != null && userInfo.get("manager_userid") != null) {
|
|
|
- String mgrId = String.valueOf(userInfo.get("manager_userid"));
|
|
|
- if (!mgrId.isEmpty() && !"null".equals(mgrId)) {
|
|
|
- md.internal.put(empId, mgrId);
|
|
|
- }
|
|
|
- }
|
|
|
- } catch (Exception e) {
|
|
|
- log.warn("获取员工{}直属主管失败: {}", empId, e.getMessage());
|
|
|
+ if (isInternalEmployee(entry.getValue())) {
|
|
|
+ internalIds.add(empId);
|
|
|
+ String fallbackManagerId = personnelManagerId(entry.getValue());
|
|
|
+ if (fallbackManagerId != null) {
|
|
|
+ md.internal.put(empId, fallbackManagerId);
|
|
|
}
|
|
|
} else {
|
|
|
externalIds.add(empId);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // prd 全体员工都必须匹配启用项目;外部员工另保留子集用于兼容现有统计字段
|
|
|
- md.projects = queryProjectAssignments(personnelMap.keySet());
|
|
|
- for (String externalId : externalIds) {
|
|
|
- List<Assignment> assignments = md.projects.get(externalId);
|
|
|
- if (assignments != null && !assignments.isEmpty()) {
|
|
|
- md.external.put(externalId, assignments);
|
|
|
- }
|
|
|
+ // prd 仅外部员工匹配启用项目;内部员工只按人员在职及日期边界生成应填报工时
|
|
|
+ if (!externalIds.isEmpty()) {
|
|
|
+ md.external = queryProjectAssignments(externalIds);
|
|
|
}
|
|
|
|
|
|
- log.info("Manager 预取完成: 内部{}人取直属主管落地{}, 全员启用项目参与者{}, 外部{}人中{}人有启用项目",
|
|
|
- internalCount, md.internal.size(), md.projects.size(), externalIds.size(), md.external.size());
|
|
|
+ log.info("Manager 预取完成: 内部{}人使用人员档案主管{}, 外部{}人中{}人有启用项目",
|
|
|
+ internalIds.size(), md.internal.size(), externalIds.size(), md.external.size());
|
|
|
return md;
|
|
|
}
|
|
|
|
|
|
@@ -1842,10 +1910,9 @@ public class WorkHoursCalcService {
|
|
|
return new ArrayList<>(pms);
|
|
|
}
|
|
|
|
|
|
- /** 内部员工 → 直属主管;projects → 全员启用项目;external → 外部员工启用项目子集。 */
|
|
|
+ /** 内部员工 → 直属主管;外部员工 → 启用项目 assignments。 */
|
|
|
private static class ManagerData {
|
|
|
Map<String, String> internal = new HashMap<>();
|
|
|
- Map<String, List<Assignment>> projects = new HashMap<>();
|
|
|
Map<String, List<Assignment>> external = new HashMap<>();
|
|
|
}
|
|
|
|
|
|
@@ -1926,7 +1993,23 @@ public class WorkHoursCalcService {
|
|
|
return stats;
|
|
|
}
|
|
|
|
|
|
- // 2. 拉这些员工的项目 assignments (基于最新项目档案)
|
|
|
+ // prd 项目变更只处理外部员工;内部员工 Manager 始终来自钉钉直属主管,不读取项目 PM
|
|
|
+ Map<String, Map<String, Object>> personnelMap = queryAllPersonnelDetails();
|
|
|
+ int affectedBeforeInternalFilter = memberEffectiveDate.size();
|
|
|
+ memberEffectiveDate.entrySet().removeIf(entry -> isInternalEmployee(personnelMap.get(entry.getKey())));
|
|
|
+ int skippedInternalMembers = affectedBeforeInternalFilter - memberEffectiveDate.size();
|
|
|
+ stats.put("skippedInternalMembers", skippedInternalMembers);
|
|
|
+ stats.put("externalAffectedMembers", memberEffectiveDate.size());
|
|
|
+ if (memberEffectiveDate.isEmpty()) {
|
|
|
+ stats.put("updated", 0);
|
|
|
+ stats.put("skippedSame", 0);
|
|
|
+ stats.put("skippedNoPm", 0);
|
|
|
+ stats.put("fail", 0);
|
|
|
+ log.info("[项目变更同步] 涉及成员均为内部员工,跳过项目 PM 回写: {}人", skippedInternalMembers);
|
|
|
+ return stats;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. 仅拉外部员工的项目 assignments (基于最新项目档案)
|
|
|
Map<String, List<Assignment>> assignmentsMap = queryProjectAssignments(memberEffectiveDate.keySet());
|
|
|
|
|
|
// 3. 对每员工扫其应报工时 (workDay >= effectiveDate), 逐条重算 Manager 并对比
|
|
|
@@ -1934,6 +2017,7 @@ public class WorkHoursCalcService {
|
|
|
AtomicInteger updated = new AtomicInteger(0);
|
|
|
AtomicInteger skippedSame = new AtomicInteger(0);
|
|
|
AtomicInteger skippedNoPm = new AtomicInteger(0);
|
|
|
+ AtomicInteger skippedInternalRecords = new AtomicInteger(0);
|
|
|
AtomicInteger fail = new AtomicInteger(0);
|
|
|
ZoneId zone = ZoneId.systemDefault();
|
|
|
RateLimiter yidaLimiter = RateLimiter.create(20.0);
|
|
|
@@ -1967,6 +2051,11 @@ public class WorkHoursCalcService {
|
|
|
Object instId = rec.get("formInstanceId");
|
|
|
Map<String, Object> fd = (Map<String, Object>) rec.get("formData");
|
|
|
if (instId == null || fd == null) continue;
|
|
|
+ // fixme 人员档案缺失或属性变化时再做一次记录级防御,内部工时绝不按项目 PM 更新
|
|
|
+ if (isInternalEmployee(fd)) {
|
|
|
+ skippedInternalRecords.incrementAndGet();
|
|
|
+ continue;
|
|
|
+ }
|
|
|
LocalDate workDay = parseToLocalDate(fd.get("dateField_mmd8onl5"));
|
|
|
if (workDay == null || workDay.isBefore(effectiveDate)) continue;
|
|
|
|
|
|
@@ -2018,12 +2107,26 @@ public class WorkHoursCalcService {
|
|
|
stats.put("updated", updated.get());
|
|
|
stats.put("skippedSame", skippedSame.get());
|
|
|
stats.put("skippedNoPm", skippedNoPm.get());
|
|
|
+ stats.put("skippedInternalRecords", skippedInternalRecords.get());
|
|
|
stats.put("fail", fail.get());
|
|
|
- log.info("[项目变更同步] 完成: 扫描应报工时{}条, 更新{}, 值相同跳过{}, 无PM跳过{}, 失败{}",
|
|
|
- totalScanned.get(), updated.get(), skippedSame.get(), skippedNoPm.get(), fail.get());
|
|
|
+ log.info("[项目变更同步] 完成: 扫描应报工时{}条, 更新{}, 值相同跳过{}, 无PM跳过{}, 内部员工记录跳过{}, 失败{}",
|
|
|
+ totalScanned.get(), updated.get(), skippedSame.get(), skippedNoPm.get(),
|
|
|
+ skippedInternalRecords.get(), fail.get());
|
|
|
return stats;
|
|
|
}
|
|
|
|
|
|
+ private static boolean isInternalEmployee(Map<String, Object> info) {
|
|
|
+ return info != null && "内部".equals(String.valueOf(info.get("radioField_mkow4ydo")));
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String personnelManagerId(Map<String, Object> info) {
|
|
|
+ if (info == null) return null;
|
|
|
+ Object value = info.get(PERSONNEL_MANAGER);
|
|
|
+ if (value == null) return null;
|
|
|
+ String managerId = String.valueOf(value).trim();
|
|
|
+ return managerId.isEmpty() || "null".equalsIgnoreCase(managerId) ? null : managerId;
|
|
|
+ }
|
|
|
+
|
|
|
/** 从 formData 里提取员工/成员字段 id 列表 (优先 _id 后缀; 字符串形态返回单元素列表) */
|
|
|
@SuppressWarnings("unchecked")
|
|
|
private List<String> extractIdList(Map<String, Object> data, String fieldId) {
|