Просмотр исходного кода

fix(workhours): 防止离职同步误伤应报工时

malk месяцев назад: 2
Родитель
Сommit
4ab0f62cb1

+ 6 - 0
docs/superpowers/plans/2026-07-15-workhours-data-cleanup.md

@@ -384,3 +384,9 @@ ssh root@120.55.113.155 'cd /home/server/akds && ./server.sh status'
 ```
 
 Expected: code worktree clean and remote service RUNNING.
+
+### 2026-07-16 部署暂缓说明
+
+- 离职链路安全补强完成后先提交代码,不在本轮部署。
+- 等后续工时异常处理全部完成,再统一构建候选 JAR、执行只读审计并申请生产部署确认。
+- 部署前必须再次验证人员同步不会误标离职且会清除恢复在职人员的旧离职日期,并确认三类工时清理 dry-run 均符合预期。

+ 12 - 1
docs/superpowers/specs/2026-07-15-workhours-data-cleanup-design.md

@@ -1,7 +1,7 @@
 # 应报工时重复与离职后数据清理设计
 
 > 日期:2026-07-15
-> 状态:数据清理与删除后复查已完成,等待最终代码部署
+> 状态:数据清理与删除后复查已完成;2026-07-16 离职链路安全补强完成,等待工时异常处理结束后部署
 
 ## 目标
 
@@ -108,9 +108,20 @@ GET /workhours/cleanup-duplicates
 3. 员工或日期缺失的记录不进入删除集合。
 4. dry-run 只返回待删除数量,不调用 `delete_batch`。
 5. 离职边界保持 `workDay > offlineDate`:离职当天保留,次日删除或跳过写入。
+6. 外部部门人员源抓取失败时中止整轮同步,不生成误标离职动作。
+7. 员工恢复在职时由人员同步清除旧离职日期,应报工时只读取人员档案离职日期。
+8. `/cleanup-after-offline` 省略 `dryRun` 时默认仅预演。
 
 最终执行模块测试、完整打包、差异检查,再提交和部署。
 
+## 2026-07-16 离职链路安全补强
+
+- 人员全量同步依赖的外部部门抓取失败时直接终止,不再用不完整人员集合计算离职差异。
+- 钉钉仍可查询到的人员统一恢复为在职;若宜搭残留旧离职日期,同步时显式清空。
+- 应报工时与人员同步解耦:不调用人员同步服务、不触发人员数据修改,只读取人员档案离职日期作为过滤边界。
+- 离职后工时清理继续保持独立操作,不与人员同步自动耦合,正式删除仍需显式 `dryRun=false`。
+- 本轮只完成代码、测试和文档归档,不执行生产部署。
+
 ## 不在本次范围
 
 - 不删除无法匹配人员档案的记录。

+ 4 - 4
mjava-akdsbeisen/src/main/java/com/malk/controller/WorkHoursController.java

@@ -173,12 +173,12 @@ public class WorkHoursController {
     }
 
     /**
-     * 清理已离职员工「离职日之后」的历史应报工时(一次性接口)
-     * GET /workhours/cleanup-after-offline            (实际删除)
-     * GET /workhours/cleanup-after-offline?dryRun=true(仅统计不删除)
+     * 清理已离职员工「离职日之后」的历史应报工时(一次性接口,默认仅预览
+     * GET /workhours/cleanup-after-offline             (仅统计不删除)
+     * GET /workhours/cleanup-after-offline?dryRun=false(实际删除)
      */
     @GetMapping("/cleanup-after-offline")
-    public Map<String, Object> cleanupAfterOffline(@RequestParam(defaultValue = "false") boolean dryRun) {
+    public Map<String, Object> cleanupAfterOffline(@RequestParam(defaultValue = "true") boolean dryRun) {
         Map<String, Object> result = new LinkedHashMap<>();
         try {
             long start = System.currentTimeMillis();

+ 21 - 1
mjava-akdsbeisen/src/main/java/com/malk/service/personnel/impl/PersonnelSyncServiceImpl.java

@@ -197,7 +197,9 @@ public class PersonnelSyncServiceImpl implements PersonnelSyncService {
                     }
                     log.info("[PersonnelSync] 补抓外部部门 dept={} 新增 {} 人 (该部门共 {})", dept, added, ext.size());
                 } catch (Exception ex) {
-                    log.warn("[PersonnelSync] 抓外部部门失败 dept={} err={}", dept, ex.getMessage());
+                    // fixme 外部部门是全量人员源的一部分,失败后继续会把该部门人员误判为离职,进而影响应报工时
+                    throw new IllegalStateException(
+                            "抓取外部部门失败 dept=" + dept + ",中止本轮人员同步以避免误标离职", ex);
                 }
             }
         }
@@ -229,6 +231,7 @@ public class PersonnelSyncServiceImpl implements PersonnelSyncService {
         r.put("yidaFormData", yidaFormData);
         // 新 formData (在职判定改为存在性, probe 统一用 UPDATE)
         Map<String, Object> newData = toYidaFormData(userid, ding, ACTION_UPDATE);
+        clearOfflineDateForActiveUser(newData, yidaFormData);
         r.put("newFormData", newData);
         // 字段对比
         Map<String, Object> diff = new LinkedHashMap<>();
@@ -491,6 +494,9 @@ public class PersonnelSyncServiceImpl implements PersonnelSyncService {
         // 3. 构造 formData 并选 action
         String action = (instanceId == null) ? ACTION_CREATE : ACTION_UPDATE;
         Map<String, Object> formData = toYidaFormData(userid, ding, action);
+        if (ACTION_UPDATE.equals(action)) {
+            clearOfflineDateForActiveUser(formData, existFormData);
+        }
         if (ACTION_UPDATE.equals(action) && isSameAsYida(formData, existFormData)) {
             result.put("action", "SKIP");
             result.put("instanceId", instanceId);
@@ -598,6 +604,7 @@ public class PersonnelSyncServiceImpl implements PersonnelSyncService {
                 actions.add(new Action(ACTION_CREATE, userid, null, toYidaFormData(userid, ding, ACTION_CREATE)));
             } else {
                 Map<String, Object> formData = toYidaFormData(userid, ding, ACTION_UPDATE);
+                clearOfflineDateForActiveUser(formData, yida.formData);
                 if (isSameAsYida(formData, yida.formData)) continue;   // 幂等跳过
                 actions.add(new Action(ACTION_UPDATE, userid, yida.instanceId, formData));
             }
@@ -639,6 +646,19 @@ public class PersonnelSyncServiceImpl implements PersonnelSyncService {
         return s.isEmpty() || "null".equalsIgnoreCase(s);
     }
 
+    /**
+     * 钉钉仍能查到的人员按在职处理;若宜搭残留旧离职日期,则与在职状态一起清空,
+     * 避免应报工时继续按旧日期过滤。
+     */
+    private void clearOfflineDateForActiveUser(Map<String, Object> formData,
+                                               Map<String, Object> existingFormData) {
+        String fieldOfflineDate = conf.getFieldOfflineDate();
+        if (!notBlank(fieldOfflineDate) || existingFormData == null) return;
+        if (!isBlankValue(existingFormData.get(fieldOfflineDate))) {
+            formData.put(fieldOfflineDate, null);
+        }
+    }
+
     // ==================== 内部: 字段映射 ====================
 
     @SuppressWarnings("unchecked")

+ 19 - 0
mjava-akdsbeisen/src/test/java/com/malk/controller/WorkHoursControllerTest.java

@@ -0,0 +1,19 @@
+package com.malk.controller;
+
+import org.junit.Test;
+import org.springframework.web.bind.annotation.RequestParam;
+
+import java.lang.reflect.Method;
+
+import static org.junit.Assert.assertEquals;
+
+public class WorkHoursControllerTest {
+
+    @Test
+    public void cleanupAfterOfflineShouldDefaultToDryRun() throws Exception {
+        Method method = WorkHoursController.class.getMethod("cleanupAfterOffline", boolean.class);
+        RequestParam requestParam = method.getParameters()[0].getAnnotation(RequestParam.class);
+
+        assertEquals("true", requestParam.defaultValue());
+    }
+}

+ 125 - 0
mjava-akdsbeisen/src/test/java/com/malk/service/personnel/impl/PersonnelSyncServiceImplTest.java

@@ -0,0 +1,125 @@
+package com.malk.service.personnel.impl;
+
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.server.personnel.PersonnelSyncConf;
+import com.malk.service.aliwork.YDClient;
+import com.malk.service.dingtalk.DDClient;
+import com.malk.service.dingtalk.DDClient_Contacts;
+import org.junit.Test;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class PersonnelSyncServiceImplTest {
+
+    @Test
+    public void fetchAllDingUsersShouldAbortWhenExternalDepartmentCannotBeLoaded() throws Exception {
+        DDClient ddClient = mock(DDClient.class);
+        DDClient_Contacts contacts = mock(DDClient_Contacts.class);
+        YDClient ydClient = mock(YDClient.class);
+        PersonnelSyncConf conf = testConf();
+        conf.setExternalDeptIds(Collections.singletonList(123L));
+        when(ddClient.getAccessToken()).thenReturn("test-access-token");
+        when(contacts.getAllUserDetails("test-access-token", true)).thenReturn(Collections.emptyList());
+        when(contacts.listDepartmentUserDetail_all("test-access-token", 123L))
+                .thenThrow(new RuntimeException("source unavailable"));
+
+        PersonnelSyncServiceImpl service = new PersonnelSyncServiceImpl();
+        setField(service, "ddClient", ddClient);
+        setField(service, "ddClient_contacts", contacts);
+        setField(service, "ydClient", ydClient);
+        setField(service, "conf", conf);
+
+        try {
+            service.fullSync(null);
+            fail("人员源不完整时必须中止同步");
+        } catch (IllegalStateException expected) {
+            assertTrue(expected.getMessage().contains("避免误标离职"));
+        }
+        verify(ydClient, never()).queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form));
+    }
+
+    @Test
+    public void syncSingleShouldClearOfflineDateWhenEmployeeReturnsActive() throws Exception {
+        DDClient ddClient = mock(DDClient.class);
+        DDClient_Contacts contacts = mock(DDClient_Contacts.class);
+        YDClient ydClient = mock(YDClient.class);
+        PersonnelSyncConf conf = testConf();
+        String userId = "employee-1";
+
+        Map<String, Object> dingUser = new LinkedHashMap<>();
+        dingUser.put("userid", userId);
+        dingUser.put("name", "Employee One");
+        when(ddClient.getAccessToken()).thenReturn("test-access-token");
+        when(contacts.getUserInfoById("test-access-token", userId)).thenReturn(dingUser);
+
+        Map<String, Object> existingFormData = new LinkedHashMap<>();
+        existingFormData.put(conf.getFieldEmployee(), Collections.singletonList(userId));
+        existingFormData.put(conf.getFieldStatus(), conf.getStatusValueInactive());
+        existingFormData.put(conf.getFieldOfflineDate(),
+                LocalDate.of(2026, 7, 15).atStartOfDay(ZoneId.of("Asia/Shanghai")).toInstant().toEpochMilli());
+        Map<String, Object> existingRecord = new LinkedHashMap<>();
+        existingRecord.put("formInstanceId", "instance-1");
+        existingRecord.put("formData", existingFormData);
+        DDR_New<Object> queryResult = new DDR_New<>();
+        queryResult.setTotalCount(1);
+        queryResult.setData(Collections.singletonList(existingRecord));
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenReturn(queryResult);
+
+        PersonnelSyncServiceImpl service = new PersonnelSyncServiceImpl();
+        setField(service, "ddClient", ddClient);
+        setField(service, "ddClient_contacts", contacts);
+        setField(service, "ydClient", ydClient);
+        setField(service, "conf", conf);
+        invokeNoArg(service, "initRateLimiters");
+
+        Map<String, Object> result = service.syncSingle(userId);
+
+        assertEquals("UPDATE", result.get("action"));
+        assertEquals(1, result.get("updated"));
+        Map<String, Object> formData = (Map<String, Object>) result.get("formData");
+        assertEquals(conf.getStatusValueActive(), formData.get(conf.getFieldStatus()));
+        assertTrue(formData.containsKey(conf.getFieldOfflineDate()));
+        assertNull(formData.get(conf.getFieldOfflineDate()));
+    }
+
+    private static PersonnelSyncConf testConf() {
+        PersonnelSyncConf conf = new PersonnelSyncConf();
+        conf.setYidaAppType("test-app");
+        conf.setYidaSystemToken("test-token");
+        conf.setFormUuidPersonnel("test-personnel");
+        return conf;
+    }
+
+    private static void setField(Object target, String name, Object value) throws Exception {
+        Field field = target.getClass().getDeclaredField(name);
+        field.setAccessible(true);
+        field.set(target, value);
+    }
+
+    private static void invokeNoArg(Object target, String name) throws Exception {
+        Method method = target.getClass().getDeclaredMethod(name);
+        method.setAccessible(true);
+        method.invoke(target);
+    }
+}

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

@@ -157,6 +157,40 @@ public class WorkHoursCalcServiceTest {
         assertEquals(0, stats.get("toDelete"));
     }
 
+    @Test
+    public void cleanupAfterOfflineDryRunShouldUsePersonnelOfflineDateAndKeepOfflineDay() {
+        YDClient ydClient = mock(YDClient.class);
+        WHConf conf = requiredHoursConf();
+        DDR_New<Object> personnelPage = pageOf(Collections.singletonList(
+                personnelRecord("employee-1", "在职", LocalDate.of(2026, 7, 14))));
+        DDR_New<Object> requiredHoursPage = pageOf(Arrays.asList(
+                requiredHoursRecord("on-offline-date", "employee-1", LocalDate.of(2026, 7, 14)),
+                requiredHoursRecord("after-offline-date", "employee-1", LocalDate.of(2026, 7, 15))));
+        DDR_New<Object> emptyPage = pageOf(Collections.emptyList());
+        AtomicBoolean requiredHoursReturned = new AtomicBoolean(false);
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
+                .thenAnswer(invocation -> {
+                    YDParam param = (YDParam) invocation.getArguments()[0];
+                    if (conf.getFormUuidPersonnel().equals(param.getFormUuid())) return personnelPage;
+                    if (requiredHoursReturned.compareAndSet(false, true)) return requiredHoursPage;
+                    return emptyPage;
+                });
+
+        WorkHoursCalcService service = new WorkHoursCalcService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", conf);
+
+        Map<String, Object> stats = service.cleanupAfterOffline(true);
+
+        assertEquals(1, stats.get("offlineEmployees"));
+        assertEquals(1, stats.get("toDelete"));
+        assertEquals(0, stats.get("deleted"));
+        assertEquals(true, stats.get("dryRun"));
+        List<Map<String, Object>> samples = (List<Map<String, Object>>) stats.get("samples");
+        assertEquals("after-offline-date", samples.get(0).get("instanceId"));
+        verify(ydClient, never()).operateData(any(YDParam.class), eq(YDConf.FORM_OPERATION.delete_batch));
+    }
+
     @Test
     public void computeDailyPmsShouldMergeDistinctManagersAndIncludeOfflineDate() throws Exception {
         WorkHoursCalcService service = new WorkHoursCalcService();
@@ -231,8 +265,17 @@ public class WorkHoursCalcServiceTest {
     }
 
     private static Map<String, Object> personnelRecord(String employeeId, LocalDate offlineDate) {
+        return personnelRecord(employeeId, null, offlineDate);
+    }
+
+    private static Map<String, Object> personnelRecord(String employeeId,
+                                                        String status,
+                                                        LocalDate offlineDate) {
         Map<String, Object> formData = new LinkedHashMap<>();
         formData.put("employeeField_mkow4ydp_id", Collections.singletonList(employeeId));
+        if (status != null) {
+            formData.put("radioField_mp1sngq1", status);
+        }
         if (offlineDate != null) {
             formData.put("dateField_mh8xhqc7",
                     offlineDate.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli());