Kaynağa Gözat

fix(timecard): isolate repair approval writeback

malk 6 gün önce
ebeveyn
işleme
2e2d74a81e

+ 0 - 1
mjava-akdsbeisen/src/main/java/com/malk/controller/AdminEndpointGuardConfig.java

@@ -30,7 +30,6 @@ public class AdminEndpointGuardConfig implements WebMvcConfigurer {
             "/personnel-sync/**",
             "/approval/writeback/sync",
             "/approval/resubmit",
-            "/approval/start",
             "/timecard/summary/cleanup-duplicates",
             "/timecard/summary/delete-instances"
     ));

+ 5 - 0
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/ReSubmitApprovalService.java

@@ -1681,6 +1681,11 @@ public class ReSubmitApprovalService {
                                       String processCode,
                                       Map<String, Object> formData,
                                       String deptId) {
+        String fillTypeField = whConf.getApprovalFillTypeField();
+        if (StringUtils.isNotBlank(fillTypeField)
+                && StringUtils.isBlank(str(formData, fillTypeField))) {
+            formData.put(fillTypeField, "正常");
+        }
         // fixme 必须用真人 System 账号发起,yida_pub_account 不触发钉钉待办通知。
         ydClient.operateData(YDParam.builder()
                 .appType(whConf.getYidaAppType())

+ 29 - 3
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/RepairApprovalWriteBackService.java

@@ -22,6 +22,7 @@ import java.util.*;
 @Service
 public class RepairApprovalWriteBackService {
     private static final String SYNC_SUCCESS = "全部成功";
+    private static final String SUMMARY_STATUS_DRAFT = "暂存";
     private static final String APPROVAL_SYNC_PARTIAL = "部分失败";
     private static final String REPAIR_SUMMARY_PARTIAL = "部分成功";
     private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
@@ -46,6 +47,7 @@ public class RepairApprovalWriteBackService {
 
     @Autowired private YDClient ydClient;
     @Autowired private WHConf whConf;
+    @Autowired private WorkHoursCalcService workHoursCalcService;
 
     public ApprovalWriteBackResult writeBack(String instanceId, int result) {
         Map form = queryProcessData(instanceId);
@@ -119,11 +121,11 @@ public class RepairApprovalWriteBackService {
             final String sourceDay = entry.getKey();
             final double sourceHours = entry.getValue();
             completedSteps = runStep(other, instanceId, totalSteps, completedSteps, stepIndex++,
-                    () -> adjustRequired(user, sourceDay, -sourceHours));
+                    () -> adjustRequired(user, sourceDay, -sourceHours, true));
         }
         final double submittedHours = dayTotals.values().stream().mapToDouble(Double::doubleValue).sum();
         runStep(other, instanceId, totalSteps, completedSteps, stepIndex,
-                () -> adjustRequired(user, submitDay, submittedHours));
+                () -> adjustRequired(user, submitDay, submittedHours, false));
     }
 
     private int runStep(boolean other, String instanceId, int totalSteps, int completedSteps,
@@ -327,6 +329,12 @@ public class RepairApprovalWriteBackService {
         return "暂存";
     }
 
+    static void putDraftSummaryStatus(Map<String, Object> update, String statusField) {
+        McException.assertAccessException(StringUtils.isBlank(statusField),
+                "工时汇总审批状态字段未配置");
+        update.put(statusField, SUMMARY_STATUS_DRAFT);
+    }
+
     private void updateOriginalSubmissionDay(String submitDay, String user, List<Row> rows) {
         List<Map> found = query(whConf.getFormUuidWorkHoursSummary(), map(ORIG_USER,user,ORIG_DAY,submitDay));
         Map<String,Object> update = new LinkedHashMap<>();
@@ -334,6 +342,7 @@ public class RepairApprovalWriteBackService {
             update.put(ORIG_USER,user); update.put(ORIG_DAY,submitDay);
             update.put("textField_mmbffvda",submitDay.substring(0,6)); update.put("textField_mmbffvd9",user);
             update.put("employeeField_mmacxew4",Collections.singletonList(user)); update.put("dateField_mmbffvd8",parseDay(submitDay)); update.put("dateField_mmacxewf",parseDay(submitDay.substring(0,6)+"01"));
+            putDraftSummaryStatus(update, whConf.getSummaryApprovalStatusField());
             update.put("tableField_mmczo634", childChanges(rows, true));
             update.put("tableField_mmczo63h", childChanges(rows, false));
             update.put("tableField_mmeakgid", childChanges(rows, null));
@@ -355,11 +364,24 @@ public class RepairApprovalWriteBackService {
                 () -> verifyOriginalSubmissionDay(user, submitDay, update));
     }
 
-    private void adjustRequired(String user,String day,double delta){
+    private void adjustRequired(String user, String day, double delta, boolean sourceDay) {
         McException.assertAccessException(StringUtils.isBlank(whConf.getFormUuidRequiredHours()), "应报工时表未配置");
         McException.assertAccessException(StringUtils.isBlank(user), "应报工时员工不能为空");
         long ts=parseDay(day); Map<String,Object> search=map(REQUIRED_USER,Collections.singletonList(user),REQUIRED_DAY,Arrays.asList(ts,ts+86400000L-1));
         List<Map> found=query(whConf.getFormUuidRequiredHours(),search);
+        if (shouldSkipMissingRequiredRecord(sourceDay, found.size())) {
+            // fixme 应报工时只生成工作日;周末/休息日补填没有来源记录时无需调减。
+            log.info("[补填审批回写] 来源日无应报工时记录,跳过调减 userId={} day={}", user, day);
+            return;
+        }
+        if (!sourceDay && found.isEmpty()) {
+            Map<String, Object> created = workHoursCalcService.syncOneEmployeeOneDayForRepair(
+                    user, LocalDate.parse(day, DAY));
+            McException.assertAccessException(!Boolean.TRUE.equals(created.get("success")),
+                    "补填提交日应报工时创建失败 userId=" + user + ", day=" + day
+                            + ", error=" + text(created.get("error")));
+            found = query(whConf.getFormUuidRequiredHours(), search);
+        }
         McException.assertAccessException(found.size() != 1,
                 "应报工时记录不存在或重复 userId=" + user + ", day=" + day);
         Map item=found.get(0); Map fd=(Map)item.get("formData"); if(fd==null) fd=item;
@@ -374,6 +396,10 @@ public class RepairApprovalWriteBackService {
                 () -> verifyRequiredHours(targetId, user, day, expectedHours));
     }
 
+    static boolean shouldSkipMissingRequiredRecord(boolean sourceDay, int matchedRecords) {
+        return sourceDay && matchedRecords == 0;
+    }
+
     private void verifyOriginalSubmissionDay(String user, String day, Map<String, Object> expected) {
         List<Map> found = query(whConf.getFormUuidWorkHoursSummary(), map(ORIG_USER, user, ORIG_DAY, day));
         McException.assertAccessException(found.size() != 1,

+ 34 - 12
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/WorkHoursCalcService.java

@@ -193,6 +193,17 @@ 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);
+    }
+
+    Map<String, Object> syncOneEmployeeOneDayForRepair(String userId, LocalDate workDay) {
+        Map<LocalDate, String> holidayRules = queryHolidayRules(String.valueOf(workDay.getYear()));
+        return syncOneEmployeeOneDay(userId, workDay,
+                resolveRequiredHours(workDay, holidayRules));
+    }
+
+    private Map<String, Object> syncOneEmployeeOneDay(String userId, LocalDate workDay,
+                                                       double requiredHours) {
         Map<String, Object> result = new LinkedHashMap<>();
         result.put("userId", userId);
         result.put("workDay", workDay == null ? null : workDay.toString());
@@ -269,7 +280,7 @@ public class WorkHoursCalcService {
 
         // 3. upsert 写一条
         try {
-            upsertDailyHours(userId, managerIds, workDay, info);
+            upsertDailyHours(userId, managerIds, workDay, info, requiredHours);
             result.put("success", true);
             log.info("单条验证写入成功: userId={}, workDay={}, 归属公司={}",
                     userId, workDay, info.get("textField_mmekrcji"));
@@ -1808,21 +1819,26 @@ public class WorkHoursCalcService {
 
         for (int day = 1; day <= daysInMonth; day++) {
             LocalDate current = LocalDate.of(year, month, day);
-            DayOfWeek dow = current.getDayOfWeek();
-            boolean isWeekend = (dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY);
-            String holidayType = holidayRules.get(current);
-
-            if ("调休".equals(holidayType)) {
-                continue;
-            } else if ("加班".equals(holidayType)) {
-                workingDays.add(current);
-            } else if (!isWeekend) {
+            if (resolveRequiredHours(current, holidayRules) > 0) {
                 workingDays.add(current);
             }
         }
         return workingDays;
     }
 
+    static int resolveRequiredHours(LocalDate workDay, Map<LocalDate, String> holidayRules) {
+        String holidayType = holidayRules.get(workDay);
+        if ("调休".equals(holidayType)) {
+            return 0;
+        }
+        if ("加班".equals(holidayType)) {
+            return DAILY_HOURS;
+        }
+        DayOfWeek dayOfWeek = workDay.getDayOfWeek();
+        return dayOfWeek == DayOfWeek.SATURDAY || dayOfWeek == DayOfWeek.SUNDAY
+                ? 0 : DAILY_HOURS;
+    }
+
     /**
      * 查询节假日规则(按年份)
      *
@@ -2366,6 +2382,11 @@ public class WorkHoursCalcService {
      */
     private void upsertDailyHours(String employeeId, List<String> managerIds, LocalDate workDay,
                                   Map<String, Object> personnelInfo) {
+        upsertDailyHours(employeeId, managerIds, workDay, personnelInfo, DAILY_HOURS);
+    }
+
+    private void upsertDailyHours(String employeeId, List<String> managerIds, LocalDate workDay,
+                                  Map<String, Object> personnelInfo, double requiredHours) {
         String appType = whConf.getYidaAppType();
         String systemToken = whConf.getYidaSystemToken();
         long dayTimestamp = workDay.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli();
@@ -2375,7 +2396,7 @@ public class WorkHoursCalcService {
         formData.put("employeeField_mmd8onl4", Arrays.asList(employeeId));
         formData.put("dateField_mmd8onl5", dayTimestamp);
         formData.put(REQUIRED_HOURS_DATE_TEXT, workDay.format(DateTimeFormatter.BASIC_ISO_DATE));
-        formData.put("numberField_mmd8onl6", DAILY_HOURS);
+        formData.put("numberField_mmd8onl6", requiredHours);
 
         // Manager: 内部=[钉钉直属主管], 外部=当天参与项目的 PM 去重合并
         if (managerIds != null && !managerIds.isEmpty()) {
@@ -2420,7 +2441,8 @@ public class WorkHoursCalcService {
                 .build();
 
         Object result = ydClient.operateData(param, YDConf.FORM_OPERATION.upsert);
-        log.debug("员工{} {} 写入8h,结果: {}", employeeId, workDay, JSON.toJSONString(result));
+        log.debug("员工{} {} 写入{}h,结果: {}", employeeId, workDay,
+                requiredHours, JSON.toJSONString(result));
     }
 
     // ==================== 工具方法 ====================

+ 5 - 0
mjava-akdsbeisen/src/test/java/com/malk/controller/AdminEndpointGuardConfigTest.java

@@ -46,6 +46,11 @@ public class AdminEndpointGuardConfigTest {
         assertFalse(AdminEndpointGuardConfig.tokenMatches("", "supplied"));
     }
 
+    @Test
+    public void businessApprovalStartShouldNotBeProtectedAsAdminApi() {
+        assertFalse(AdminEndpointGuardConfig.PROTECTED_PATHS.contains("/approval/start"));
+    }
+
     private static MockHttpServletRequest request(String token) {
         MockHttpServletRequest request = new MockHttpServletRequest("POST", "/workhours/sync");
         if (token != null) {

+ 16 - 0
mjava-akdsbeisen/src/test/java/com/malk/service/workhours/ApprovalWriteBackServiceTest.java

@@ -268,6 +268,22 @@ class ApprovalWriteBackServiceTest {
         assertEquals("审批中", RepairApprovalWriteBackService.resolveRepairSummaryStatus(totals));
     }
 
+    @Test
+    void shouldInitializeCreatedOriginalSummaryAsDraft() {
+        Map<String, Object> update = new HashMap<>();
+
+        RepairApprovalWriteBackService.putDraftSummaryStatus(update, "summaryStatus");
+
+        assertEquals("暂存", update.get("summaryStatus"));
+    }
+
+    @Test
+    void shouldOnlySkipMissingRequiredHoursForRepairSourceDay() {
+        assertTrue(RepairApprovalWriteBackService.shouldSkipMissingRequiredRecord(true, 0));
+        assertFalse(RepairApprovalWriteBackService.shouldSkipMissingRequiredRecord(false, 0));
+        assertFalse(RepairApprovalWriteBackService.shouldSkipMissingRequiredRecord(true, 2));
+    }
+
     @Test
     void shouldNeverTreatEmptyOrMalformedApprovalDetailsAsSuccess() {
         assertEquals("全部失败", ApprovalWriteBackService.determineSyncStatus(0, 0));

+ 33 - 0
mjava-akdsbeisen/src/test/java/com/malk/service/workhours/ReSubmitApprovalServiceTest.java

@@ -74,6 +74,7 @@ class ReSubmitApprovalServiceTest {
         whConf.setApprovalProcessCode("PROCESS-APPROVAL");
         whConf.setApprovalOriginatorUserId("system-user");
         whConf.setApprovalOriginatorDeptId("dept-default");
+        whConf.setApprovalFillTypeField("radioField_fill_type");
 
         ReSubmitApprovalService service = new ReSubmitApprovalService();
         ReflectionTestUtils.setField(service, "ydClient", ydClient);
@@ -91,6 +92,38 @@ class ReSubmitApprovalServiceTest {
         verify(ydClient).operateData(captor.capture(), eq(YDConf.FORM_OPERATION.start));
         assertEquals("system-user", captor.getValue().getUserId());
         assertEquals("dept-request", captor.getValue().getDeptId());
+        Map started = com.alibaba.fastjson.JSON.parseObject(
+                captor.getValue().getFormDataJson(), Map.class);
+        assertEquals("正常", started.get("radioField_fill_type"));
+    }
+
+    @Test
+    void shouldKeepExplicitRepairFillTypeWhenStartingApproval() {
+        YDClient ydClient = mock(YDClient.class);
+        WHConf whConf = new WHConf();
+        whConf.setYidaAppType("APP");
+        whConf.setYidaSystemToken("TOKEN");
+        whConf.setFormUuidApproval("FORM-APPROVAL");
+        whConf.setApprovalProcessCode("PROCESS-APPROVAL");
+        whConf.setApprovalOriginatorUserId("system-user");
+        whConf.setApprovalFillTypeField("radioField_fill_type");
+
+        ReSubmitApprovalService service = new ReSubmitApprovalService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", whConf);
+
+        TimeCardApprovalStartRequest request = new TimeCardApprovalStartRequest();
+        request.setFormUuid("FORM-APPROVAL");
+        request.setProcessCode("PROCESS-APPROVAL");
+        request.setFormDataJson("{\"radioField_fill_type\":\"补填\"}");
+
+        assertTrue(service.startApproval(request));
+
+        ArgumentCaptor<YDParam> captor = ArgumentCaptor.forClass(YDParam.class);
+        verify(ydClient).operateData(captor.capture(), eq(YDConf.FORM_OPERATION.start));
+        Map started = com.alibaba.fastjson.JSON.parseObject(
+                captor.getValue().getFormDataJson(), Map.class);
+        assertEquals("补填", started.get("radioField_fill_type"));
     }
 
     @Test

+ 15 - 0
mjava-akdsbeisen/src/test/java/com/malk/service/workhours/WorkHoursCalcServiceTest.java

@@ -37,6 +37,21 @@ import static org.mockito.Mockito.when;
 
 public class WorkHoursCalcServiceTest {
 
+    @Test
+    public void repairRequiredHoursShouldFollowWorkdayAndHolidayRules() {
+        LocalDate saturday = LocalDate.of(2026, 9, 12);
+        LocalDate monday = LocalDate.of(2026, 9, 14);
+        Map<LocalDate, String> holidayRules = new HashMap<>();
+
+        assertEquals(0, WorkHoursCalcService.resolveRequiredHours(saturday, holidayRules));
+        assertEquals(8, WorkHoursCalcService.resolveRequiredHours(monday, holidayRules));
+
+        holidayRules.put(saturday, "加班");
+        holidayRules.put(monday, "调休");
+        assertEquals(8, WorkHoursCalcService.resolveRequiredHours(saturday, holidayRules));
+        assertEquals(0, WorkHoursCalcService.resolveRequiredHours(monday, holidayRules));
+    }
+
     @Test
     public void upsertDailyHoursShouldWriteCompactDateText() {
         YDClient ydClient = mock(YDClient.class);