Bläddra i källkod

fix(workhours): include summary changes in required hours

malk 1 månad sedan
förälder
incheckning
5d4795afc5

+ 129 - 11
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/WorkHoursCalcService.java

@@ -62,6 +62,11 @@ public class WorkHoursCalcService {
     private static final String REQUIRED_HOURS_EMPLOYEE_NAME = "textField_ms4bzjkm";
     // prd 应填报日期文本,用于跨表按 yyyyMMdd 精确关联,避免日期组件时区偏移
     private static final String REQUIRED_HOURS_DATE_TEXT = "textField_mtseezhf";
+    // prd 正式工时汇总按填报人+日期匹配;创建应报工时时累计当天日变更工时。
+    private static final String SUMMARY_SUBMITTER_ID = "textField_mmbffvd9";
+    private static final String SUMMARY_DAY_DATE = "dateField_mmbffvd8";
+    private static final String SUMMARY_DAY_TEXT = "textField_mmbffvdb";
+    private static final String SUMMARY_DAILY_CHANGE = "numberField_mmeakgi5";
 
     // prd 外部员工项目参与关系 + 项目经理:项目档案字段
     private static final String PROJECT_SUB_TABLE = "tableField_mkowyn6d";      // 成员子表(人员明细)
@@ -116,11 +121,14 @@ public class WorkHoursCalcService {
         LocalDate monthStart = LocalDate.of(year, month, 1);
         LocalDate monthEnd = monthStart.plusMonths(1).minusDays(1);
         Set<String> existKeys = queryExistingHoursKeys(monthStart, monthEnd);
+        Map<String, Double> summaryChangeHours = querySummaryChangeHours(monthStart, monthEnd);
         log.info("全量: 当月已存在记录{}条", existKeys.size());
         stats.put("existingCount", existKeys.size());
+        stats.put("summaryChangeCount", summaryChangeHours.size());
 
         // 6. 多线程并发写入:按员工维度分任务
-        int[] counts = concurrentUpsert(personnelMap, managerData, workingDays, existKeys);
+        int[] counts = concurrentUpsert(
+                personnelMap, managerData, workingDays, existKeys, summaryChangeHours);
         stats.put("success", counts[0]);
         stats.put("fail", counts[1]);
         log.info("应填报工时写入完成: 成功{}条, 失败{}条({}名员工 × {}个工作日, 已存在{}条 skip)",
@@ -175,10 +183,12 @@ public class WorkHoursCalcService {
 
         // 5. 预查窗口内已存在记录 (empId|yyyy-MM-dd 集合), 传入 concurrentUpsert 命中即 skip
         Set<String> existKeys = queryExistingHoursKeys(fromDate, today);
+        Map<String, Double> summaryChangeHours = querySummaryChangeHours(fromDate, today);
         log.info("增量: 窗口内已存在记录{}条", existKeys.size());
 
         // 6. 并发写入
-        int[] counts = concurrentUpsert(personnelMap, managerData, windowDays, existKeys);
+        int[] counts = concurrentUpsert(
+                personnelMap, managerData, windowDays, existKeys, summaryChangeHours);
         log.info("增量同步完成: 成功{}条, 失败{}条 ({}名员工 × {}天工作日, 已存在{}条 skip)",
                 counts[0], counts[1], personnelMap.size(), windowDays.size(), existKeys.size());
     }
@@ -193,17 +203,18 @@ public class WorkHoursCalcService {
      * @return Map{userId, workDay, personnelInfo, managerId, success, error?}
      */
     public Map<String, Object> syncOneEmployeeOneDay(String userId, LocalDate workDay) {
-        return syncOneEmployeeOneDay(userId, workDay, DAILY_HOURS);
+        return syncOneEmployeeOneDay(userId, workDay, DAILY_HOURS, true);
     }
 
     Map<String, Object> syncOneEmployeeOneDayForRepair(String userId, LocalDate workDay) {
         Map<LocalDate, String> holidayRules = queryHolidayRules(String.valueOf(workDay.getYear()));
         return syncOneEmployeeOneDay(userId, workDay,
-                resolveRequiredHours(workDay, holidayRules));
+                resolveRequiredHours(workDay, holidayRules), false);
     }
 
     private Map<String, Object> syncOneEmployeeOneDay(String userId, LocalDate workDay,
-                                                       double requiredHours) {
+                                                       double baseRequiredHours,
+                                                       boolean includeSummaryChange) {
         Map<String, Object> result = new LinkedHashMap<>();
         result.put("userId", userId);
         result.put("workDay", workDay == null ? null : workDay.toString());
@@ -280,6 +291,12 @@ public class WorkHoursCalcService {
 
         // 3. upsert 写一条
         try {
+            double requiredHours = baseRequiredHours;
+            if (includeSummaryChange) {
+                double changeHours = querySummaryChangeHours(workDay, workDay)
+                        .getOrDefault(requiredHoursKey(userId, workDay), 0d);
+                requiredHours = resolveInitialRequiredHours(baseRequiredHours, changeHours);
+            }
             upsertDailyHours(userId, managerIds, workDay, info, requiredHours);
             result.put("success", true);
             log.info("单条验证写入成功: userId={}, workDay={}, 归属公司={}",
@@ -348,7 +365,11 @@ public class WorkHoursCalcService {
         stats.put("workingDays", workingDays.size());
 
         // 5. 并发写入 (小批量验证入口: existKeys 传空集, 保持"必写"语义便于回归验证)
-        int[] counts = concurrentUpsert(subset, managerData, workingDays, Collections.emptySet());
+        LocalDate monthStart = LocalDate.of(year, month, 1);
+        LocalDate monthEnd = monthStart.plusMonths(1).minusDays(1);
+        Map<String, Double> summaryChangeHours = querySummaryChangeHours(monthStart, monthEnd);
+        int[] counts = concurrentUpsert(
+                subset, managerData, workingDays, Collections.emptySet(), summaryChangeHours);
         stats.put("success", counts[0]);
         stats.put("fail", counts[1]);
         log.info("小批量同步完成: 成功{}条, 失败{}条({}名员工 × {}个工作日)",
@@ -836,7 +857,8 @@ public class WorkHoursCalcService {
     private int[] concurrentUpsert(Map<String, Map<String, Object>> personnelMap,
                                    ManagerData managerData,
                                    List<LocalDate> workingDays,
-                                   Set<String> existKeys) {
+                                   Set<String> existKeys,
+                                   Map<String, Double> summaryChangeHours) {
         AtomicInteger successCount = new AtomicInteger(0);
         AtomicInteger failCount = new AtomicInteger(0);
         // fixme 宜搭写接口失败时不能原地重试:首个请求可能已成功落库但响应丢失,连续 upsert 会生成重复记录。
@@ -898,6 +920,10 @@ public class WorkHoursCalcService {
                         }
                         // prd 内部员工允许空 Manager;外部员工仍必须存在启用项目及有效项目 PM
                         List<String> managerIds = null;
+                        double requiredHours = resolveInitialRequiredHours(
+                                DAILY_HOURS,
+                                summaryChangeHours.getOrDefault(
+                                        requiredHoursKey(empId, workDay), 0d));
                         try {
                             // prd 外部员工须在启用项目中且当日未下线;内部员工不校验项目
                             if (!isInternal && !hasActiveProject(externalAssignments, workDay)) {
@@ -916,11 +942,13 @@ public class WorkHoursCalcService {
                                 }
                             }
                             yidaLimiter.acquire();
-                            upsertDailyHours(empId, managerIds, workDay, info);
+                            upsertDailyHours(empId, managerIds, workDay, info, requiredHours);
                             successCount.incrementAndGet();
                         } catch (Exception e) {
                             boolean rateLimited = isRateLimitError(e);
-                            deferredWrites.add(new DeferredWrite(empId, managerIds, workDay, info, externalAssignments));
+                            deferredWrites.add(new DeferredWrite(
+                                    empId, managerIds, workDay, info,
+                                    externalAssignments, requiredHours));
                             log.warn("员工{} {} 首轮处理失败,已记录并跳过,待首轮全部完成后查询再补写: rateLimited={}, code={}, message={}",
                                     empId, workDay, rateLimited, queryErrorCode(e), e.getMessage());
                             if (rateLimited) {
@@ -1021,7 +1049,9 @@ public class WorkHoursCalcService {
                     continue;
                 }
                 yidaLimiter.acquire();
-                upsertDailyHours(item.employeeId, managerIds, item.workDay, item.personnelInfo);
+                upsertDailyHours(
+                        item.employeeId, managerIds, item.workDay,
+                        item.personnelInfo, item.requiredHours);
                 successCount.incrementAndGet();
                 log.info("员工{} {} 延后补写成功", item.employeeId, item.workDay);
             } catch (Exception e) {
@@ -1588,6 +1618,91 @@ public class WorkHoursCalcService {
         return stats;
     }
 
+    /**
+     * 查询日期范围内正式工时汇总的日变更工时,按员工+日期累计。
+     * ppExt: 只供常规应报工时新增使用;补填专用创建仍由调用方按原有基础值处理。
+     */
+    @SuppressWarnings("unchecked")
+    private Map<String, Double> querySummaryChangeHours(LocalDate fromDate, LocalDate toDate) {
+        Map<String, Double> changeHours = new HashMap<>();
+        if (fromDate == null || toDate == null || fromDate.isAfter(toDate)) {
+            return changeHours;
+        }
+        if (whConf.getFormUuidWorkHoursSummary() == null
+                || whConf.getFormUuidWorkHoursSummary().trim().isEmpty()) {
+            throw new IllegalStateException("工时汇总表未配置");
+        }
+
+        ZoneId zone = ZoneId.systemDefault();
+        long startMs = fromDate.atStartOfDay(zone).toInstant().toEpochMilli();
+        long endMs = toDate.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli() - 1;
+        Map<String, Object> search = new HashMap<>();
+        search.put(SUMMARY_DAY_DATE, Arrays.asList(startMs, endMs));
+
+        int currentPage = 1;
+        int pageSize = YDConf.PAGE_SIZE_LIMIT;
+        long totalCount;
+        Map<String, Integer> matchedCounts = new HashMap<>();
+        do {
+            DDR_New result = ydClient.queryData(YDParam.builder()
+                    .appType(whConf.getYidaAppType())
+                    .systemToken(whConf.getYidaSystemToken())
+                    .formUuid(whConf.getFormUuidWorkHoursSummary())
+                    .searchFieldJson(JSON.toJSONString(search))
+                    .currentPage(currentPage)
+                    .pageSize(pageSize)
+                    .build(), YDConf.FORM_QUERY.retrieve_search_form);
+            if (result == null) {
+                throw new IllegalStateException("查询工时汇总变更工时返回空响应");
+            }
+
+            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 employeeId = textValue(formData.get(SUMMARY_SUBMITTER_ID));
+                String dayText = textValue(formData.get(SUMMARY_DAY_TEXT));
+                if (employeeId.isEmpty() || !dayText.matches("\\d{8}")) continue;
+
+                String key = employeeId + "|" + LocalDate.parse(
+                        dayText, DateTimeFormatter.BASIC_ISO_DATE);
+                double dailyChange = hoursValue(formData.get(SUMMARY_DAILY_CHANGE));
+                changeHours.merge(key, dailyChange, Double::sum);
+                matchedCounts.merge(key, 1, Integer::sum);
+            }
+            currentPage++;
+        } while ((long) (currentPage - 1) * pageSize < totalCount);
+
+        matchedCounts.forEach((key, count) -> {
+            if (count > 1) {
+                log.warn("工时汇总同员工同日期存在{}条记录,日变更工时按合计读取: key={}", count, key);
+            }
+        });
+        return changeHours;
+    }
+
+    static double resolveInitialRequiredHours(double baseHours, double changeHours) {
+        return baseHours + changeHours;
+    }
+
+    private static String requiredHoursKey(String employeeId, LocalDate workDay) {
+        return employeeId + "|" + workDay;
+    }
+
+    private static String textValue(Object value) {
+        return value == null ? "" : String.valueOf(value).trim();
+    }
+
+    private static double hoursValue(Object value) {
+        if (value == null || String.valueOf(value).trim().isEmpty()) return 0d;
+        if (value instanceof Number) return ((Number) value).doubleValue();
+        return Double.parseDouble(String.valueOf(value).trim());
+    }
+
     /**
      * 预查指定日期范围内已存在的应报工时记录, 返回 "empId|yyyy-MM-dd" 键集合
      * ppExt: 供 concurrentUpsert 补漏语义使用 (命中即 skip); 范围内不含重复员工+日期 (upsert 唯一键)
@@ -1807,12 +1922,14 @@ public class WorkHoursCalcService {
         private final List<Assignment> externalAssignments;
         private final LocalDate workDay;
         private final Map<String, Object> personnelInfo;
+        private final double requiredHours;
 
         private DeferredWrite(String employeeId,
                               List<String> managerIds,
                               LocalDate workDay,
                               Map<String, Object> personnelInfo,
-                              List<Assignment> externalAssignments) {
+                              List<Assignment> externalAssignments,
+                              double requiredHours) {
             this.employeeId = employeeId;
             this.managerIds = managerIds == null
                     ? null
@@ -1820,6 +1937,7 @@ public class WorkHoursCalcService {
             this.externalAssignments = externalAssignments;
             this.workDay = workDay;
             this.personnelInfo = personnelInfo;
+            this.requiredHours = requiredHours;
         }
     }
 

+ 96 - 6
mjava-akdsbeisen/src/test/java/com/malk/service/workhours/WorkHoursCalcServiceTest.java

@@ -55,6 +55,60 @@ public class WorkHoursCalcServiceTest {
         assertEquals(0, WorkHoursCalcService.resolveRequiredHours(monday, holidayRules));
     }
 
+    @Test
+    public void initialRequiredHoursShouldIncludePositiveAndNegativeChanges() {
+        assertEquals(12d, WorkHoursCalcService.resolveInitialRequiredHours(8d, 4d), 0.000001d);
+        assertEquals(0d, WorkHoursCalcService.resolveInitialRequiredHours(8d, -8d), 0.000001d);
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void summaryChangeHoursShouldAccumulateByEmployeeAndDay() {
+        YDClient ydClient = mock(YDClient.class);
+        WorkHoursCalcService service = workHoursService(ydClient);
+        LocalDate workDay = LocalDate.of(2026, 9, 30);
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(pageOf(Arrays.asList(
+                        summaryRecord("employee-1", workDay, 4d),
+                        summaryRecord("employee-1", workDay, -1.5d),
+                        summaryRecord("employee-2", workDay, 2d))));
+
+        Map<String, Double> result = ReflectionTestUtils.invokeMethod(
+                service, "querySummaryChangeHours", workDay, workDay);
+
+        assertEquals(2.5d, result.get("employee-1|2026-09-30"), 0.000001d);
+        assertEquals(2d, result.get("employee-2|2026-09-30"), 0.000001d);
+    }
+
+    @Test
+    public void concurrentUpsertShouldWriteBasePlusSummaryChange() {
+        YDClient ydClient = mock(YDClient.class);
+        WorkHoursCalcService service = workHoursService(ydClient);
+        LocalDate workDay = LocalDate.now().minusDays(1);
+        Map<String, Map<String, Object>> personnelMap = internalPersonnelMap("employee-1");
+        Object managerData = ReflectionTestUtils.invokeMethod(
+                service, "queryManagerData", personnelMap);
+        Map<String, Double> summaryChangeHours = Collections.singletonMap(
+                "employee-1|" + workDay, 4d);
+
+        int[] result = ReflectionTestUtils.invokeMethod(
+                service,
+                "concurrentUpsert",
+                personnelMap,
+                managerData,
+                Collections.singletonList(workDay),
+                Collections.emptySet(),
+                summaryChangeHours);
+
+        ArgumentCaptor<YDParam> paramCaptor = ArgumentCaptor.forClass(YDParam.class);
+        verify(ydClient).operateData(paramCaptor.capture(), eq(YDConf.FORM_OPERATION.upsert));
+        Map<String, Object> formData = JSON.parseObject(
+                paramCaptor.getValue().getFormDataJson(), Map.class);
+        assertEquals(12d, ((Number) formData.get("numberField_mmd8onl6")).doubleValue(), 0.000001d);
+        assertEquals(1, result[0]);
+        assertEquals(0, result[1]);
+    }
+
     @Test
     public void upsertDailyHoursShouldWriteCompactDateText() {
         YDClient ydClient = mock(YDClient.class);
@@ -193,7 +247,8 @@ public class WorkHoursCalcServiceTest {
                 personnelMap,
                 managerData,
                 Collections.singletonList(LocalDate.now().minusDays(1)),
-                Collections.emptySet());
+                Collections.emptySet(),
+                Collections.emptyMap());
 
         assertEquals(1, result[0]);
         assertEquals(0, result[1]);
@@ -223,7 +278,8 @@ public class WorkHoursCalcServiceTest {
                 personnelMap,
                 managerData,
                 Collections.singletonList(LocalDate.now().minusDays(1)),
-                Collections.emptySet());
+                Collections.emptySet(),
+                Collections.emptyMap());
 
         assertEquals(1, result[0]);
         assertEquals(0, result[1]);
@@ -271,7 +327,8 @@ public class WorkHoursCalcServiceTest {
                 .thenReturn(pageOf(Collections.emptyList()));
 
         int[] result = ReflectionTestUtils.invokeMethod(service, "concurrentUpsert",
-                personnelMap, managerData, Collections.singletonList(workDay), Collections.emptySet());
+                personnelMap, managerData, Collections.singletonList(workDay),
+                Collections.emptySet(), Collections.emptyMap());
 
         assertEquals(1, result[0]);
         assertEquals(0, result[1]);
@@ -311,12 +368,31 @@ public class WorkHoursCalcServiceTest {
                 .thenReturn(pageOf(Collections.emptyList()));
 
         WorkHoursCalcService service = workHoursService(ydClient);
-        int[] result = invokeConcurrentUpsert(service, internalPersonnelMap("employee-1"), workDay);
+        Map<String, Map<String, Object>> personnelMap = internalPersonnelMap("employee-1");
+        Object managerData = ReflectionTestUtils.invokeMethod(
+                service, "queryManagerData", personnelMap);
+        Map<String, Double> summaryChangeHours = Collections.singletonMap(
+                "employee-1|" + workDay, 4d);
+        int[] result = ReflectionTestUtils.invokeMethod(
+                service,
+                "concurrentUpsert",
+                personnelMap,
+                managerData,
+                Collections.singletonList(workDay),
+                Collections.emptySet(),
+                summaryChangeHours);
 
         assertEquals(1, result[0]);
         assertEquals(0, result[1]);
+        ArgumentCaptor<YDParam> paramCaptor = ArgumentCaptor.forClass(YDParam.class);
         verify(ydClient, times(2)).operateData(
-                any(YDParam.class), eq(YDConf.FORM_OPERATION.upsert));
+                paramCaptor.capture(), eq(YDConf.FORM_OPERATION.upsert));
+        for (YDParam param : paramCaptor.getAllValues()) {
+            Map<String, Object> formData = JSON.parseObject(param.getFormDataJson(), Map.class);
+            assertEquals(12d,
+                    ((Number) formData.get("numberField_mmd8onl6")).doubleValue(),
+                    0.000001d);
+        }
         verify(ydClient, times(1)).queryData(
                 any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form));
     }
@@ -891,6 +967,18 @@ public class WorkHoursCalcServiceTest {
         return record;
     }
 
+    private static Map<String, Object> summaryRecord(String employeeId,
+                                                     LocalDate workDay,
+                                                     double changeHours) {
+        Map<String, Object> formData = new LinkedHashMap<>();
+        formData.put("textField_mmbffvd9", employeeId);
+        formData.put("textField_mmbffvdb", workDay.format(java.time.format.DateTimeFormatter.BASIC_ISO_DATE));
+        formData.put("numberField_mmeakgi5", changeHours);
+        Map<String, Object> record = new LinkedHashMap<>();
+        record.put("formData", formData);
+        return record;
+    }
+
     private static Map<String, Object> completeRequiredHoursRecord(String instanceId,
                                                                     String employeeId,
                                                                     LocalDate workDay) {
@@ -953,6 +1041,7 @@ public class WorkHoursCalcServiceTest {
         conf.setYidaSystemToken("token");
         conf.setFormUuidPersonnel("personnel");
         conf.setFormUuidRequiredHours("required-hours");
+        conf.setFormUuidWorkHoursSummary("work-hours-summary");
         return conf;
     }
 
@@ -982,6 +1071,7 @@ public class WorkHoursCalcServiceTest {
                 personnelMap,
                 managerData,
                 Collections.singletonList(workDay),
-                Collections.emptySet());
+                Collections.emptySet(),
+                Collections.emptyMap());
     }
 }