瀏覽代碼

fix(timecard): enforce personnel policy and repair month defaults

malk 2 周之前
父節點
當前提交
a3d16893f9

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

@@ -98,7 +98,7 @@ public class ApprovalController {
      */
     @PostMapping("/start")
     public McR<Boolean> start(@RequestBody TimeCardApprovalStartRequest request) {
-        return McR.success(reSubmitApprovalService.startApproval(request));
+        return McR.success(reSubmitApprovalService.startNormalApproval(request));
     }
 
     /** 根据工时变更单实时查询变更项目的审批经理 userId。 */

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

@@ -61,6 +61,9 @@ public class ReSubmitApprovalService {
     @Autowired
     private WHConf whConf;
 
+    @Autowired
+    private TimeCardPersonnelPolicy personnelPolicy;
+
     @Autowired
     private MonthClosingService monthClosingService;
 
@@ -450,6 +453,18 @@ public class ReSubmitApprovalService {
      * @return true 表示宜搭发起接口调用成功
      */
     public boolean startApproval(TimeCardApprovalStartRequest request) {
+        return startApproval(request, false);
+    }
+
+    /** 正常TimeCard公开入口单独校验外部人员,既有补填和变更调用保持原样。
+     * @param request 正常工时审批请求
+     * @return 发起结果
+     */
+    public boolean startNormalApproval(TimeCardApprovalStartRequest request) {
+        return startApproval(request, true);
+    }
+
+    private boolean startApproval(TimeCardApprovalStartRequest request, boolean normalEntry) {
         McException.assertAccessException(request == null, "请求不能为空");
         McException.assertAccessException(
                 StringUtils.isAnyBlank(
@@ -474,6 +489,10 @@ public class ReSubmitApprovalService {
         String fillTypeField = whConf.getApprovalFillTypeField();
         String fillType = StringUtils.isBlank(fillTypeField) ? "" : str(formData, fillTypeField);
         boolean adjustment = "补填".equals(fillType) || "变更".equals(fillType);
+        // prd 只约束正常TimeCard公开入口,不能用伪造类型绕过;既有变更调用不受本次校验影响。
+        McException.assertAccessException(normalEntry && adjustment,
+                "补填和变更请通过各自提交入口发起");
+        if (normalEntry) personnelPolicy.assertNormalApproval(formData, billable);
         String guardKey = adjustment ? null : approvalStartGuardKey(request, formData);
         ApprovalStartGuard guard = adjustment ? null : acquireApprovalStartGuard(guardKey);
         if (guard != null && guard.completed) {

+ 111 - 0
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/TimeCardPersonnelPolicy.java

@@ -0,0 +1,111 @@
+package com.malk.service.workhours;
+
+import com.alibaba.fastjson.JSON;
+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 org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/** 普通 TimeCard 的人员准入;补填和变更由各自服务校验。 */
+@Service
+public class TimeCardPersonnelPolicy {
+    private static final String EMPLOYEE = "employeeField_mkow4ydp";
+    private static final String ATTRIBUTE = "radioField_mkow4ydo";
+    @Autowired private YDClient ydClient;
+    @Autowired private WHConf whConf;
+
+    /** 仅档案中明确为内部的人员可以正常填报,查询异常不得视为内部。 */
+    public void assertNormalUser(String userId) {
+        McException.assertAccessException(StringUtils.isBlank(userId), "审批明细填报人ID不能为空");
+        McException.assertAccessException(StringUtils.isBlank(whConf.getFormUuidPersonnel()), "人员档案表未配置");
+        DDR_New response = ydClient.queryData(YDParam.builder()
+                .appType(whConf.getYidaAppType()).systemToken(whConf.getYidaSystemToken())
+                .formUuid(whConf.getFormUuidPersonnel())
+                .searchFieldJson(JSON.toJSONString(Collections.singletonMap(EMPLOYEE,
+                        Collections.singletonList(userId))))
+                .currentPage(1).pageSize(2).build(), YDConf.FORM_QUERY.retrieve_search_form);
+        McException.assertAccessException(response == null || !(response.getData() instanceof List),
+                "人员档案查询失败,请稍后重试");
+        response.assertSuccess();
+        McException.assertAccessException(response.getAccessdenieddetail() != null,
+                "人员档案查询无权限,不能正常填报");
+        List records = (List) response.getData();
+        McException.assertAccessException(records.size() != 1 || response.getTotalCount() != 1,
+                "人员档案缺失或重复,不能正常填报");
+        McException.assertAccessException(!(records.get(0) instanceof Map), "人员档案格式异常");
+        Object raw = ((Map) records.get(0)).get("formData");
+        McException.assertAccessException(!(raw instanceof Map), "人员档案数据缺失");
+        Map data = (Map) raw;
+        McException.assertAccessException(!userId.equals(memberId(data, EMPLOYEE)), "人员档案身份不一致");
+        String attribute = String.valueOf(data.get(ATTRIBUTE));
+        // prd 外部人员按客户确认工时通过补填上报,不再使用普通 TimeCard。
+        McException.assertAccessException("外部".equals(attribute), "外部人员请通过TimeCard补填上报工时");
+        McException.assertAccessException(!"内部".equals(attribute), "人员属性缺失或异常,不能正常填报");
+    }
+
+    /** 先检查全部明细填报人,再发起任何分片,防止混合审批部分成功。 */
+    public void assertNormalApproval(Map<String, Object> data, boolean projectApproval) {
+        Set<String> users = new LinkedHashSet<>();
+        if (projectApproval) collectUsers(data, "tableField_mmae8t99", "textField_mmd1ozk4",
+                "employeeField_mmacxew4", users);
+        collectUsers(data, "tableField_mmd1wu9h", "textField_mmd1wu9f", "employeeField_mmd1wu9b", users);
+        McException.assertAccessException(users.isEmpty(), "审批明细填报人ID不能为空");
+        users.forEach(this::assertNormalUser);
+    }
+
+    /** 成员实时值优先,空值回退同行 _id;对象统一按标准顺序取人员ID。 */
+    static String memberId(Map data, String field) {
+        Object raw = data.get(field);
+        Object first = raw instanceof List && !((List) raw).isEmpty() ? ((List) raw).get(0) : raw;
+        String id = first instanceof Map ? valueId(raw) : "";
+        if (StringUtils.isBlank(id)) id = valueId(data.get(field + "_id"));
+        // ppExt 查询结果原字段可能是显示姓名;存在 _id 时以真实ID作回退。
+        return StringUtils.isNotBlank(id) ? id : valueId(raw);
+    }
+
+    private static String valueId(Object raw) {
+        if (raw instanceof List) {
+            List values = (List) raw;
+            McException.assertAccessException(values.size() > 1, "填报人成员字段必须为单人");
+            return values.isEmpty() ? "" : valueId(values.get(0));
+        }
+        if (raw instanceof Map) {
+            for (String key : new String[]{"value", "userId", "key", "workNo", "employeeId"}) {
+                String id = valueId(((Map) raw).get(key));
+                if (StringUtils.isNotBlank(id)) return id;
+            }
+            return "";
+        }
+        return raw == null ? "" : String.valueOf(raw).trim();
+    }
+
+    private static void collectUsers(Map data, String table, String userField, String memberField,
+                                     Set<String> users) {
+        Object raw = data.get(table);
+        if (raw == null) return;
+        McException.assertAccessException(!(raw instanceof List), "审批明细格式异常");
+        for (Object item : (List) raw) {
+            McException.assertAccessException(!(item instanceof Map), "审批明细行格式异常");
+            Map row = (Map) item;
+            if (row.isEmpty()) continue;
+            String user = valueId(row.get(userField));
+            String member = memberId(row, memberField);
+            McException.assertAccessException(StringUtils.isNotBlank(user) && StringUtils.isNotBlank(member)
+                    && !user.equals(member), "审批明细填报人ID不一致");
+            if (StringUtils.isBlank(user)) user = member;
+            McException.assertAccessException(StringUtils.isBlank(user), "审批明细填报人ID不能为空");
+            users.add(user);
+        }
+    }
+}

+ 4 - 0
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/TimeCardSummaryService.java

@@ -52,6 +52,9 @@ public class TimeCardSummaryService {
     @Autowired
     private WHConf whConf;
 
+    @Autowired
+    private TimeCardPersonnelPolicy personnelPolicy;
+
     /**
      * 以填报人 ID + 日期为唯一条件执行宜搭原子 upsert。
      *
@@ -73,6 +76,7 @@ public class TimeCardSummaryService {
 
         String userId = request.getSubmitterUserId();
         String dayText = request.getDayText();
+        personnelPolicy.assertNormalUser(userId);
         List<Map> existing = queryDailyRecords(whConf.getFormUuidWorkHoursSummary(), userId, dayText);
         McException.assertAccessException(existing.size() > 1, "正式工时汇总记录重复,已停止保存");
         boolean approved = existing

+ 94 - 23
mjava-akdsbeisen/src/test/java/com/malk/service/workhours/ReSubmitApprovalServiceTest.java

@@ -36,6 +36,77 @@ import static org.mockito.Mockito.verify;
 
 class ReSubmitApprovalServiceTest {
 
+    @Test void shouldRejectForgedAdjustmentTypesThroughNormalEndpoint() {
+        for (String type : new String[]{"补填", "变更"}) {
+            YDClient client = mock(YDClient.class);
+            ReSubmitApprovalService service = normalService();
+            WHConf conf = new WHConf();
+            conf.setFormUuidApproval("form"); conf.setApprovalProcessCode("process");
+            conf.setApprovalOriginatorUserId("system"); conf.setApprovalFillTypeField("type");
+            conf.setApprovalRepairSubmitDateField("submitDate");
+            ReflectionTestUtils.setField(service, "whConf", conf);
+            ReflectionTestUtils.setField(service, "ydClient", client);
+            attachOpenMonth(service);
+            TimeCardApprovalStartRequest request = new TimeCardApprovalStartRequest();
+            request.setFormUuid("form"); request.setProcessCode("process");
+            request.setFormDataJson("{\"type\":\"" + type + "\",\"submitDate\":\"2026-10-20\"}");
+            assertTrue(assertThrows(McException.class, () -> service.startNormalApproval(request))
+                    .getMessage().contains("各自提交入口"));
+            verifyNoInteractions(client);
+        }
+    }
+
+    @Test void shouldRejectExternalNormalApprovalBeforeStartingAnyShard() {
+        ReSubmitApprovalService service = normalService();
+        YDClient client = mock(YDClient.class);
+        TimeCardPersonnelPolicy policy = mock(TimeCardPersonnelPolicy.class);
+        org.mockito.Mockito.doThrow(new McException("external", "外部人员请通过TimeCard补填上报工时"))
+                .when(policy).assertNormalApproval(any(), eq(true));
+        WHConf conf = new WHConf();
+        conf.setFormUuidApproval("form"); conf.setApprovalProcessCode("process");
+        conf.setApprovalOriginatorUserId("system");
+        ReflectionTestUtils.setField(service, "whConf", conf);
+        ReflectionTestUtils.setField(service, "ydClient", client);
+        ReflectionTestUtils.setField(service, "personnelPolicy", policy);
+        attachOpenMonth(service);
+        TimeCardApprovalStartRequest request = new TimeCardApprovalStartRequest();
+        request.setFormUuid("form"); request.setProcessCode("process");
+        request.setFormDataJson("{\"textField_mmd2lv0y\":\"202610\"}");
+        assertThrows(McException.class, () -> service.startNormalApproval(request));
+        verifyNoInteractions(client);
+        verify(policy).assertNormalApproval(any(), eq(true));
+    }
+
+    @Test void shouldKeepInternalRepairAndChangeIndependentOfNormalPersonnelPolicy() {
+        for (String type : new String[]{"补填", "变更"}) {
+            ReSubmitApprovalService service = normalService();
+            YDClient client = mock(YDClient.class);
+            TimeCardPersonnelPolicy policy = mock(TimeCardPersonnelPolicy.class);
+            WHConf conf = new WHConf();
+            conf.setFormUuidApproval("form"); conf.setApprovalProcessCode("process");
+            conf.setApprovalOriginatorUserId("system"); conf.setApprovalFillTypeField("type");
+            conf.setApprovalRepairSubmitDateField("submitDate");
+            ReflectionTestUtils.setField(service, "whConf", conf);
+            ReflectionTestUtils.setField(service, "ydClient", client);
+            ReflectionTestUtils.setField(service, "personnelPolicy", policy);
+            attachOpenMonth(service);
+            TimeCardApprovalStartRequest request = new TimeCardApprovalStartRequest();
+            request.setFormUuid("form"); request.setProcessCode("process");
+            request.setFormDataJson("{\"type\":\"" + type + "\",\"submitDate\":\"2026-10-20\"}");
+            assertTrue(service.startApproval(request));
+            verify(client).operateData(any(), eq(YDConf.FORM_OPERATION.start));
+            verifyNoInteractions(policy);
+            assertThrows(McException.class, () -> service.startNormalApproval(request));
+        }
+    }
+
+    private static ReSubmitApprovalService normalService() {
+        ReSubmitApprovalService service = new ReSubmitApprovalService();
+        ReflectionTestUtils.setField(service, "personnelPolicy", mock(TimeCardPersonnelPolicy.class));
+        return service;
+    }
+
+
     private static final String DEFAULT_MANAGER_ID = "634735129";
 
     @Test
@@ -89,7 +160,7 @@ class ReSubmitApprovalServiceTest {
                 ReSubmitApprovalService.approvalDetailKey("P1", "")), completedHours);
         Map<String, String> managers = new HashMap<>();
         managers.put("", "pm-active");
-        Object result = ReflectionTestUtils.invokeMethod(new ReSubmitApprovalService(),
+        Object result = ReflectionTestUtils.invokeMethod(normalService(),
                 "collectApprovalData", "user-1", "202609",
                 Collections.singletonList(Collections.singletonMap("formData", data)), coverage, managers);
         List<?> rows;
@@ -149,7 +220,7 @@ class ReSubmitApprovalServiceTest {
         whConf.setApprovalOriginatorDeptId("dept-default");
         whConf.setApprovalFillTypeField("radioField_fill_type");
 
-        ReSubmitApprovalService service = new ReSubmitApprovalService();
+        ReSubmitApprovalService service = normalService();
         ReflectionTestUtils.setField(service, "ydClient", ydClient);
         ReflectionTestUtils.setField(service, "whConf", whConf);
         attachOpenMonth(service);
@@ -160,7 +231,7 @@ class ReSubmitApprovalServiceTest {
         request.setDeptId("dept-request");
         request.setFormDataJson("{\"textField_mmd2lv0y\":\"202609\",\"textField_test\":\"value\"}");
 
-        assertTrue(service.startApproval(request));
+        assertTrue(service.startNormalApproval(request));
 
         ArgumentCaptor<YDParam> captor = ArgumentCaptor.forClass(YDParam.class);
         verify(ydClient).operateData(captor.capture(), eq(YDConf.FORM_OPERATION.start));
@@ -182,7 +253,7 @@ class ReSubmitApprovalServiceTest {
         whConf.setApprovalOriginatorUserId("system-user");
         whConf.setApprovalFillTypeField("radioField_fill_type");
 
-        ReSubmitApprovalService service = new ReSubmitApprovalService();
+        ReSubmitApprovalService service = normalService();
         ReflectionTestUtils.setField(service, "ydClient", ydClient);
         ReflectionTestUtils.setField(service, "whConf", whConf);
         attachOpenMonth(service);
@@ -191,12 +262,12 @@ class ReSubmitApprovalServiceTest {
         request.setProcessCode("PROCESS-APPROVAL");
         request.setFormDataJson("{\"textField_mmd2lv0y\":\"202609\",\"textField_test\":\"same-hours\"}");
 
-        assertTrue(service.startApproval(request));
-        assertTrue(service.startApproval(request));
+        assertTrue(service.startNormalApproval(request));
+        assertTrue(service.startNormalApproval(request));
         verify(ydClient, times(1)).operateData(any(YDParam.class), eq(YDConf.FORM_OPERATION.start));
 
         request.setFormDataJson("{\"textField_mmd2lv0y\":\"202609\",\"textField_test\":\"changed-hours\"}");
-        assertTrue(service.startApproval(request));
+        assertTrue(service.startNormalApproval(request));
         verify(ydClient, times(2)).operateData(any(YDParam.class), eq(YDConf.FORM_OPERATION.start));
     }
 
@@ -217,7 +288,7 @@ class ReSubmitApprovalServiceTest {
                     release.await(5, TimeUnit.SECONDS);
                     return null;
                 });
-        ReSubmitApprovalService service = new ReSubmitApprovalService();
+        ReSubmitApprovalService service = normalService();
         ReflectionTestUtils.setField(service, "ydClient", ydClient);
         ReflectionTestUtils.setField(service, "whConf", whConf);
         attachOpenMonth(service);
@@ -226,10 +297,10 @@ class ReSubmitApprovalServiceTest {
         request.setProcessCode("PROCESS-APPROVAL");
         request.setFormDataJson("{\"textField_mmd2lv0y\":\"202609\",\"textField_test\":\"same-hours\"}");
 
-        CompletableFuture<Boolean> first = CompletableFuture.supplyAsync(() -> service.startApproval(request));
+        CompletableFuture<Boolean> first = CompletableFuture.supplyAsync(() -> service.startNormalApproval(request));
         try {
             assertTrue(entered.await(5, TimeUnit.SECONDS));
-            assertThrows(RuntimeException.class, () -> service.startApproval(request));
+            assertThrows(RuntimeException.class, () -> service.startNormalApproval(request));
         } finally {
             release.countDown();
         }
@@ -246,7 +317,7 @@ class ReSubmitApprovalServiceTest {
         whConf.setFormUuidApproval("FORM-APPROVAL");
         whConf.setApprovalProcessCode("PROCESS-APPROVAL");
         whConf.setApprovalOriginatorUserId("system-user");
-        ReSubmitApprovalService service = new ReSubmitApprovalService();
+        ReSubmitApprovalService service = normalService();
         ReflectionTestUtils.setField(service, "ydClient", ydClient);
         ReflectionTestUtils.setField(service, "whConf", whConf);
         ReflectionTestUtils.setField(service, "monthClosingService", monthClosingService);
@@ -256,7 +327,7 @@ class ReSubmitApprovalServiceTest {
         request.setProcessCode("PROCESS-APPROVAL");
         request.setFormDataJson("{\"textField_mmd2lv0y\":\"202609\"}");
 
-        assertThrows(McException.class, () -> service.startApproval(request));
+        assertThrows(McException.class, () -> service.startNormalApproval(request));
         assertThrows(McException.class, () -> service.resubmit("202609", "user-1", false));
         verifyNoInteractions(ydClient);
     }
@@ -272,7 +343,7 @@ class ReSubmitApprovalServiceTest {
         whConf.setApprovalOriginatorUserId("system-user");
         whConf.setApprovalFillTypeField("radioField_fill_type");
         whConf.setApprovalRepairSubmitDateField("dateField_repair_submit");
-        ReSubmitApprovalService service = new ReSubmitApprovalService();
+        ReSubmitApprovalService service = normalService();
         ReflectionTestUtils.setField(service, "ydClient", ydClient);
         ReflectionTestUtils.setField(service, "whConf", whConf);
         ReflectionTestUtils.setField(service, "monthClosingService", monthClosingService);
@@ -299,7 +370,7 @@ class ReSubmitApprovalServiceTest {
         whConf.setApprovalOriginatorUserId("system-user");
         whConf.setApprovalFillTypeField("radioField_fill_type");
         whConf.setApprovalRepairSubmitDateField("dateField_repair_submit");
-        ReSubmitApprovalService service = new ReSubmitApprovalService();
+        ReSubmitApprovalService service = normalService();
         ReflectionTestUtils.setField(service, "ydClient", ydClient);
         ReflectionTestUtils.setField(service, "whConf", whConf);
         ReflectionTestUtils.setField(service, "monthClosingService", monthClosingService);
@@ -329,7 +400,7 @@ class ReSubmitApprovalServiceTest {
         whConf.setApprovalFillTypeField("radioField_fill_type");
         whConf.setApprovalRepairSubmitDateField("dateField_repair_submit");
 
-        ReSubmitApprovalService service = new ReSubmitApprovalService();
+        ReSubmitApprovalService service = normalService();
         ReflectionTestUtils.setField(service, "ydClient", ydClient);
         ReflectionTestUtils.setField(service, "whConf", whConf);
         MonthClosingService monthClosingService = attachOpenMonth(service);
@@ -357,7 +428,7 @@ class ReSubmitApprovalServiceTest {
         whConf.setYidaAppType("APP");
         whConf.setYidaSystemToken("TOKEN");
 
-        ReSubmitApprovalService service = new ReSubmitApprovalService();
+        ReSubmitApprovalService service = normalService();
         ReflectionTestUtils.setField(service, "ydClient", ydClient);
         ReflectionTestUtils.setField(service, "whConf", whConf);
 
@@ -468,7 +539,7 @@ class ReSubmitApprovalServiceTest {
         whConf.setApprovalProcessCode("PROCESS-APPROVAL");
         whConf.setApprovalOriginatorUserId("system-user");
 
-        ReSubmitApprovalService service = new ReSubmitApprovalService();
+        ReSubmitApprovalService service = normalService();
         ReflectionTestUtils.setField(service, "ydClient", ydClient);
         ReflectionTestUtils.setField(service, "whConf", whConf);
         attachOpenMonth(service);
@@ -486,7 +557,7 @@ class ReSubmitApprovalServiceTest {
         request.setProcessCode("PROCESS-APPROVAL");
         request.setFormDataJson(com.alibaba.fastjson.JSON.toJSONString(formData));
 
-        assertTrue(service.startApproval(request));
+        assertTrue(service.startNormalApproval(request));
 
         ArgumentCaptor<YDParam> captor = ArgumentCaptor.forClass(YDParam.class);
         verify(ydClient).operateData(captor.capture(), eq(YDConf.FORM_OPERATION.start));
@@ -505,7 +576,7 @@ class ReSubmitApprovalServiceTest {
         whConf.setApprovalProcessCode("PROCESS-APPROVAL");
         whConf.setApprovalOriginatorUserId("system-user");
 
-        ReSubmitApprovalService service = new ReSubmitApprovalService();
+        ReSubmitApprovalService service = normalService();
         ReflectionTestUtils.setField(service, "ydClient", ydClient);
         ReflectionTestUtils.setField(service, "whConf", whConf);
         attachOpenMonth(service);
@@ -525,7 +596,7 @@ class ReSubmitApprovalServiceTest {
         request.setProcessCode("PROCESS-APPROVAL");
         request.setFormDataJson(com.alibaba.fastjson.JSON.toJSONString(formData));
 
-        assertTrue(service.startApproval(request));
+        assertTrue(service.startNormalApproval(request));
 
         ArgumentCaptor<YDParam> captor = ArgumentCaptor.forClass(YDParam.class);
         verify(ydClient, org.mockito.Mockito.times(2))
@@ -550,7 +621,7 @@ class ReSubmitApprovalServiceTest {
         whConf.setOtherApprovalProcessCode("PROCESS-OTHER");
         whConf.setApprovalOriginatorUserId("system-user");
 
-        ReSubmitApprovalService service = new ReSubmitApprovalService();
+        ReSubmitApprovalService service = normalService();
         ReflectionTestUtils.setField(service, "ydClient", ydClient);
         ReflectionTestUtils.setField(service, "whConf", whConf);
         attachOpenMonth(service);
@@ -568,7 +639,7 @@ class ReSubmitApprovalServiceTest {
         request.setProcessCode("PROCESS-OTHER");
         request.setFormDataJson(com.alibaba.fastjson.JSON.toJSONString(formData));
 
-        assertTrue(service.startApproval(request));
+        assertTrue(service.startNormalApproval(request));
 
         ArgumentCaptor<YDParam> captor = ArgumentCaptor.forClass(YDParam.class);
         verify(ydClient, org.mockito.Mockito.times(2))
@@ -680,7 +751,7 @@ class ReSubmitApprovalServiceTest {
         whConf.setYidaSystemToken("TOKEN");
         whConf.setFormUuidPersonnel("FORM-PERSONNEL");
 
-        ReSubmitApprovalService service = new ReSubmitApprovalService();
+        ReSubmitApprovalService service = normalService();
         ReflectionTestUtils.setField(service, "ddClient", ddClient);
         ReflectionTestUtils.setField(service, "ddClient_contacts", contacts);
         ReflectionTestUtils.setField(service, "ydClient", ydClient);

+ 134 - 0
mjava-akdsbeisen/src/test/java/com/malk/service/workhours/TimeCardPersonnelPolicyTest.java

@@ -0,0 +1,134 @@
+package com.malk.service.workhours;
+
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.server.common.McException;
+import com.malk.server.workhours.WHConf;
+import com.malk.service.aliwork.YDClient;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+class TimeCardPersonnelPolicyTest {
+    private static TimeCardPersonnelPolicy policy(YDClient client) {
+        TimeCardPersonnelPolicy policy = new TimeCardPersonnelPolicy();
+        WHConf conf = new WHConf();
+        conf.setFormUuidPersonnel("personnel");
+        ReflectionTestUtils.setField(policy, "ydClient", client);
+        ReflectionTestUtils.setField(policy, "whConf", conf);
+        return policy;
+    }
+
+    private static Map<String, Object> person(String user, String attribute) {
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("employeeField_mkow4ydp", Collections.singletonList("显示姓名"));
+        data.put("employeeField_mkow4ydp_id", Collections.singletonList(user));
+        data.put("radioField_mkow4ydo", attribute);
+        return Collections.singletonMap("formData", data);
+    }
+
+    private static DDR_New<Object> response(Object... records) {
+        DDR_New<Object> result = new DDR_New<>();
+        result.setData(Arrays.asList(records));
+        result.setTotalCount(records.length);
+        return result;
+    }
+
+    @Test void shouldAllowOnlyConfirmedInternalPersonnel() {
+        YDClient client = mock(YDClient.class);
+        when(client.queryData(any(), any())).thenReturn(response(person("u", "内部")),
+                response(person("u", "外部")), response(person("u", "")),
+                response(person("wrong", "内部")), response(),
+                response(person("u", "内部"), person("u", "内部")));
+        TimeCardPersonnelPolicy policy = policy(client);
+        assertDoesNotThrow(() -> policy.assertNormalUser("u"));
+        assertTrue(assertThrows(McException.class, () -> policy.assertNormalUser("u")).getMessage().contains("外部人员"));
+        for (int index = 0; index < 4; index++) {
+            assertThrows(McException.class, () -> policy.assertNormalUser("u"));
+        }
+    }
+
+    @Test void shouldStopWhenPersonnelLookupFails() {
+        YDClient client = mock(YDClient.class);
+        assertThrows(McException.class, () -> policy(client).assertNormalUser("u"));
+        when(client.queryData(any(), any())).thenThrow(new IllegalStateException("offline"));
+        assertThrows(IllegalStateException.class, () -> policy(client).assertNormalUser("u"));
+    }
+
+    @Test void shouldRejectMissingZeroOrInconsistentPersonnelTotal() {
+        YDClient client = mock(YDClient.class);
+        TimeCardPersonnelPolicy policy = policy(client);
+        for (long total : new long[]{0, 2, -1}) {
+            DDR_New<Object> result = response(person("u", "内部"));
+            result.setTotalCount(total);
+            when(client.queryData(any(), any())).thenReturn(result);
+            assertThrows(McException.class, () -> policy.assertNormalUser("u"));
+        }
+        DDR_New<Object> missingTotal = new DDR_New<>();
+        missingTotal.setData(Collections.singletonList(person("u", "内部")));
+        when(client.queryData(any(), any())).thenReturn(missingTotal);
+        assertThrows(McException.class, () -> policy.assertNormalUser("u"));
+    }
+
+    @Test void shouldRejectFailedLookupEvenWhenInternalPersonnelDataRemains() {
+        YDClient client = mock(YDClient.class);
+        TimeCardPersonnelPolicy policy = policy(client);
+        DDR_New<Object> error = response(person("u", "内部"));
+        error.setCode("Forbidden");
+        when(client.queryData(any(), any())).thenReturn(error);
+        assertThrows(McException.class, () -> policy.assertNormalUser("u"));
+        DDR_New<Object> denied = response(person("u", "内部"));
+        denied.setAccessdenieddetail(Collections.singletonMap("denied", true));
+        when(client.queryData(any(), any())).thenReturn(denied);
+        assertThrows(McException.class, () -> policy.assertNormalUser("u"));
+    }
+
+    @Test void shouldCollectActualUsersFromAllThreeCategoriesBeforeStarting() {
+        TimeCardPersonnelPolicy policy = spy(new TimeCardPersonnelPolicy());
+        doNothing().when(policy).assertNormalUser(any());
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("tableField_mmae8t99", Collections.singletonList(Collections.singletonMap("textField_mmd1ozk4", "bil")));
+        data.put("tableField_mmd1wu9h", Collections.singletonList(Collections.singletonMap("employeeField_mmd1wu9b",
+                Collections.singletonList(Collections.singletonMap("key", "non")))));
+        policy.assertNormalApproval(data, true);
+        verify(policy).assertNormalUser("bil");
+        verify(policy).assertNormalUser("non");
+        data.remove("tableField_mmae8t99");
+        data.put("tableField_mmd1wu9h", Collections.singletonList(Collections.singletonMap("textField_mmd1wu9f", "other")));
+        policy.assertNormalApproval(data, false);
+        verify(policy).assertNormalUser("other");
+    }
+
+    @Test void shouldBlockMissingOrInconsistentRowIdentity() {
+        TimeCardPersonnelPolicy policy = new TimeCardPersonnelPolicy();
+        assertThrows(McException.class, () -> policy.assertNormalApproval(Collections.emptyMap(), true));
+        Map<String, Object> row = new LinkedHashMap<>();
+        row.put("numberField_mmacxew9", 8d);
+        Map<String, Object> data = Collections.singletonMap("tableField_mmae8t99", Collections.singletonList(row));
+        assertThrows(McException.class, () -> policy.assertNormalApproval(data, true));
+        row.put("textField_mmd1ozk4", "u");
+        row.put("employeeField_mmacxew4", Collections.singletonList("another"));
+        assertThrows(McException.class, () -> policy.assertNormalApproval(data, true));
+    }
+
+    @Test void shouldUseStandardMemberObjectPriorityAndIdFallback() {
+        Map<String, Object> member = new LinkedHashMap<>();
+        member.put("value", "value-id"); member.put("userId", "user-id"); member.put("key", "key-id");
+        member.put("workNo", "work-id"); member.put("employeeId", "employee-id"); member.put("label", "姓名");
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("member", Collections.singletonList(member));
+        for (String key : new String[]{"value", "userId", "key", "workNo", "employeeId"}) {
+            assertEquals(member.get(key), TimeCardPersonnelPolicy.memberId(data, "member"));
+            member.remove(key);
+        }
+        data.put("member_id", Collections.singletonList("fallback"));
+        assertEquals("fallback", TimeCardPersonnelPolicy.memberId(data, "member"));
+        data.put("member", Collections.singletonList("姓名"));
+        assertEquals("fallback", TimeCardPersonnelPolicy.memberId(data, "member"));
+    }
+}

+ 24 - 7
mjava-akdsbeisen/src/test/java/com/malk/service/workhours/TimeCardSummaryServiceTest.java

@@ -32,9 +32,26 @@ import static org.mockito.Mockito.when;
 
 class TimeCardSummaryServiceTest {
 
+    @Test void shouldRejectExternalSaveBeforeAnySummaryWrite() {
+        SaveFixture fixture = new SaveFixture();
+        TimeCardPersonnelPolicy policy = mock(TimeCardPersonnelPolicy.class);
+        org.mockito.Mockito.doThrow(new McException("external", "外部人员请通过TimeCard补填上报工时"))
+                .when(policy).assertNormalUser("user-1");
+        ReflectionTestUtils.setField(fixture.service, "personnelPolicy", policy);
+        assertThrows(McException.class, () -> fixture.service.upsert(fixture.request));
+        org.mockito.Mockito.verifyNoInteractions(fixture.client);
+    }
+
+    private static TimeCardSummaryService normalService() {
+        TimeCardSummaryService service = new TimeCardSummaryService();
+        ReflectionTestUtils.setField(service, "personnelPolicy", mock(TimeCardPersonnelPolicy.class));
+        return service;
+    }
+
+
     private static final class SaveFixture {
         final YDClient client = mock(YDClient.class);
-        final TimeCardSummaryService service = new TimeCardSummaryService();
+        final TimeCardSummaryService service = normalService();
         final TimeCardSummaryUpsertRequest request = new TimeCardSummaryUpsertRequest();
         final Map<String, Object> database = new LinkedHashMap<>();
         Object response = true;
@@ -280,7 +297,7 @@ class TimeCardSummaryServiceTest {
         whConf.setYidaSystemToken("TOKEN");
         whConf.setFormUuidWorkHoursSummary("FORM-SUMMARY");
 
-        TimeCardSummaryService service = new TimeCardSummaryService();
+        TimeCardSummaryService service = normalService();
         ReflectionTestUtils.setField(service, "ydClient", ydClient);
         ReflectionTestUtils.setField(service, "whConf", whConf);
 
@@ -324,7 +341,7 @@ class TimeCardSummaryServiceTest {
         YDClient ydClient = mock(YDClient.class);
         WHConf whConf = new WHConf();
         whConf.setFormUuidWorkHoursSummary("FORM-SUMMARY");
-        TimeCardSummaryService service = new TimeCardSummaryService();
+        TimeCardSummaryService service = normalService();
         ReflectionTestUtils.setField(service, "ydClient", ydClient);
         ReflectionTestUtils.setField(service, "whConf", whConf);
 
@@ -354,7 +371,7 @@ class TimeCardSummaryServiceTest {
         whConf.setFormUuidWorkHoursSummary("FORM-SUMMARY");
         whConf.setFormUuidRepairWorkHoursSummary("FORM-REPAIR");
         whConf.setRepairSummarySyncStatusField("repair-status");
-        TimeCardSummaryService service = new TimeCardSummaryService();
+        TimeCardSummaryService service = normalService();
         ReflectionTestUtils.setField(service, "ydClient", ydClient);
         ReflectionTestUtils.setField(service, "whConf", whConf);
         DDR_New<Object> empty = new DDR_New<>();
@@ -382,7 +399,7 @@ class TimeCardSummaryServiceTest {
         whConf.setFormUuidRepairWorkHoursSummary("FORM-ADJUSTMENT");
         whConf.setRepairSummaryFillTypeField("fill-type");
         whConf.setRepairSummarySyncStatusField("repair-status");
-        TimeCardSummaryService service = new TimeCardSummaryService();
+        TimeCardSummaryService service = normalService();
         ReflectionTestUtils.setField(service, "ydClient", ydClient);
         ReflectionTestUtils.setField(service, "whConf", whConf);
         DDR_New<Object> empty = new DDR_New<>();
@@ -431,7 +448,7 @@ class TimeCardSummaryServiceTest {
         whConf.setYidaSystemToken("TOKEN");
         whConf.setFormUuidWorkHoursSummary("FORM-SUMMARY");
 
-        TimeCardSummaryService service = new TimeCardSummaryService();
+        TimeCardSummaryService service = normalService();
         ReflectionTestUtils.setField(service, "ydClient", ydClient);
         ReflectionTestUtils.setField(service, "whConf", whConf);
 
@@ -491,7 +508,7 @@ class TimeCardSummaryServiceTest {
         whConf.setYidaSystemToken("TOKEN");
         whConf.setFormUuidWorkHoursSummary("FORM-SUMMARY");
 
-        TimeCardSummaryService service = new TimeCardSummaryService();
+        TimeCardSummaryService service = normalService();
         ReflectionTestUtils.setField(service, "ydClient", ydClient);
         ReflectionTestUtils.setField(service, "whConf", whConf);
 

File diff suppressed because it is too large
+ 591 - 208
pages/src/timecard-repair.oyd.jsx


+ 50 - 0
pages/tests/verify-repair-month.cjs

@@ -0,0 +1,50 @@
+'use strict';
+
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const vm = require('node:vm');
+
+/**
+ * @param {string} source - 页面源码
+ * @param {string} name - 函数名
+ * @returns {string} 可独立执行的函数源码
+ */
+function extractFunction(source, name) {
+  const pattern = new RegExp('function ' + name + '\\(');
+  const match = pattern.exec(source);
+  assert.ok(match, '缺少方法 ' + name);
+  const end = source.indexOf('\n/**', match.index + match[0].length);
+  return source.slice(match.index, end < 0 ? source.length : end);
+}
+
+const sourcePath = path.resolve(__dirname, '../src/timecard-repair.oyd.jsx');
+const source = fs.readFileSync(sourcePath, 'utf8');
+const methods = vm.runInNewContext(
+  ['toTwoDigitText', 'resolveRepairMonth']
+    .map((name) => extractFunction(source, name)).join('\n')
+    + '\n({resolveRepairMonth})',
+);
+
+assert.deepEqual(
+  { ...methods.resolveRepairMonth('', new Date(2026, 9, 21)) },
+  { year: 2026, month: 9 },
+  '未传月份时应默认上一个月',
+);
+assert.deepEqual(
+  { ...methods.resolveRepairMonth('202610', new Date(2026, 9, 21)) },
+  { year: 2026, month: 9 },
+  '传入当前月时应回退到上一个月',
+);
+assert.deepEqual(
+  { ...methods.resolveRepairMonth('202608', new Date(2026, 9, 21)) },
+  { year: 2026, month: 8 },
+  '显式历史月份应保持不变',
+);
+assert.deepEqual(
+  { ...methods.resolveRepairMonth('202701', new Date(2027, 0, 10)) },
+  { year: 2026, month: 12 },
+  '一月份回退时应正确跨年',
+);
+
+console.log('PASS 补填页面默认月份解析');