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.MonthClosingCheckRequest; import com.malk.server.workhours.MonthClosingStatusResult; import com.malk.server.workhours.TimeCardApprovalStartRequest; import com.malk.server.workhours.TimeCardRepairContextRequest; import com.malk.server.workhours.TimeCardRepairSubmitRequest; import com.malk.server.workhours.TimeCardRepairSummaryUpsertRequest; import com.malk.server.workhours.WHConf; import com.malk.service.aliwork.YDClient; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.mockito.ArgumentCaptor; import org.springframework.test.util.ReflectionTestUtils; import java.time.YearMonth; import java.time.LocalDate; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.doCallRealMethod; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; class TimeCardRepairServiceTest { @Test void shouldCheckSourceEmploymentDatesForAllRepairCategoriesAndAllowLaterSubmission() { for (String category : new String[]{"billableRows", "nonBillableRows", "otherRows"}) { YDClient client = mock(YDClient.class); DDR_New response = new DDR_New<>(); response.setData(Collections.emptyList()); when(client.queryData(any(YDParam.class), any())).thenReturn(response); TimeCardRepairService service = repairContextService(client, mock(MonthClosingService.class), repairContextConf()); TimeCardPersonnelPolicy policy = mock(TimeCardPersonnelPolicy.class); when(policy.getStaffType("user-1")).thenReturn("外部"); when(policy.getEmploymentDates("user-1")).thenReturn(Collections.singletonMap( "end", java.time.LocalDate.of(2026, 9, 15))); ReflectionTestUtils.setField(service, "personnelPolicy", policy); TimeCardRepairSubmitRequest request = new TimeCardRepairSubmitRequest(); request.setSubmitterUserId("user-1"); request.setSourceMonthText("202609"); Map row = new HashMap<>(); row.put("dateStr", "20260916"); row.put("projectCode", "P"); row.put("hours", 8d); Map group = Collections.singletonMap(category, Collections.singletonList(row)); Map projects = "otherRows".equals(category) ? Collections.emptyMap() : Collections.singletonMap("manager", group); Map other = "otherRows".equals(category) ? group : Collections.emptyMap(); assertThrows(McException.class, () -> ReflectionTestUtils.invokeMethod(service, "validateSubmitRows", request, projects, other)); verifyNoInteractions(client); row.remove("dateStr"); row.put("dayText", "20260915"); assertEquals(1, (int) ReflectionTestUtils.invokeMethod(service, "validateSubmitRows", request, projects, other)); } } private static TimeCardPersonnelPolicy internalPolicy() { TimeCardPersonnelPolicy policy = mock(TimeCardPersonnelPolicy.class); when(policy.getStaffType(any())).thenReturn("内部"); return policy; } @Test void shouldReturnPendingWhilePersonnelLookupIsStillRunning() throws Exception { YDClient client = mock(YDClient.class); DDR_New response = new DDR_New<>(); response.setData(Collections.emptyList()); when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form))).thenReturn(response); TimeCardRepairService service = repairContextService(client, mock(MonthClosingService.class), repairContextConf()); CountDownLatch started = new CountDownLatch(1); CountDownLatch release = new CountDownLatch(1); TimeCardPersonnelPolicy policy = mock(TimeCardPersonnelPolicy.class); when(policy.getStaffType("user-1")).thenAnswer(invocation -> { started.countDown(); assertTrue(release.await(2, TimeUnit.SECONDS)); return "外部"; }); ReflectionTestUtils.setField(service, "personnelPolicy", policy); try { assertEquals(false, service.context(repairContextRequest()).get("ready")); assertTrue(started.await(1, TimeUnit.SECONDS)); verifyNoInteractions(client); } finally { release.countDown(); } assertEquals("original-and-repair", awaitReadyContext(service, repairContextRequest()).get("summaryReadMode")); } @Test void shouldRejectForgedExternalAttributeBeforeAnySummaryQuery() throws Exception { YDClient client = mock(YDClient.class); TimeCardRepairService service = repairContextService(client, mock(MonthClosingService.class), repairContextConf()); TimeCardRepairContextRequest request = repairContextRequest(); request.setStaffType("外部"); assertTrue(awaitContextFailure(service, request).getMessage().contains("人员属性已变化")); verifyNoInteractions(client); } @Test void shouldQueryBothSummariesForConfirmedExternalUser() throws Exception { YDClient client = mock(YDClient.class); DDR_New response = new DDR_New<>(); response.setData(Collections.emptyList()); when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form))) .thenReturn(response); TimeCardRepairService service = repairContextService(client, mock(MonthClosingService.class), repairContextConf()); TimeCardPersonnelPolicy policy = mock(TimeCardPersonnelPolicy.class); when(policy.getStaffType("user-1")).thenReturn("外部"); ReflectionTestUtils.setField(service, "personnelPolicy", policy); Map context = awaitReadyContext(service, repairContextRequest()); assertEquals("original-and-repair", context.get("summaryReadMode")); assertEquals("外部", context.get("staffType")); assertTrue(((List) context.get("originalRecords")).isEmpty()); verify(client, times(1)).queryData(org.mockito.ArgumentMatchers.argThat( p -> "original-summary".equals(p.getFormUuid())), eq(YDConf.FORM_QUERY.retrieve_search_form)); verify(client, times(3)).queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)); } @Test void shouldSeparateContextCacheWhenPersonnelAttributeChanges() throws Exception { YDClient client = mock(YDClient.class); DDR_New response = new DDR_New<>(); response.setData(Collections.emptyList()); when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form))).thenReturn(response); TimeCardRepairService service = repairContextService(client, mock(MonthClosingService.class), repairContextConf()); TimeCardPersonnelPolicy policy = mock(TimeCardPersonnelPolicy.class); when(policy.getStaffType("user-1")).thenReturn("内部"); ReflectionTestUtils.setField(service, "personnelPolicy", policy); TimeCardRepairContextRequest request = repairContextRequest(); request.setStaffType("内部"); assertEquals("original-and-repair", awaitReadyContext(service, request).get("summaryReadMode")); when(policy.getStaffType("user-1")).thenReturn("外部"); request.setStaffType("外部"); assertEquals("original-and-repair", awaitReadyContext(service, request).get("summaryReadMode")); verify(client, times(2)).queryData(org.mockito.ArgumentMatchers.argThat( p -> "original-summary".equals(p.getFormUuid())), eq(YDConf.FORM_QUERY.retrieve_search_form)); } @Test void shouldKeepRepairDayLockedForExternalSaveAfterCheckingOriginal() { YDClient client = mock(YDClient.class); MonthClosingService closing = mock(MonthClosingService.class); when(closing.isLocked("202609")).thenReturn(true); when(closing.status(any(MonthClosingCheckRequest.class))) .thenReturn(MonthClosingStatusResult.builder().build()); WHConf conf = repairContextConf(); conf.setRepairSummarySyncStatusField("sync"); TimeCardRepairService service = repairContextService(client, closing, conf); TimeCardPersonnelPolicy policy = mock(TimeCardPersonnelPolicy.class); when(policy.getStaffType("user-1")).thenReturn("外部"); ReflectionTestUtils.setField(service, "personnelPolicy", policy); Map fd = new HashMap<>(); fd.put("fill-type", "补填"); fd.put("sync", "审批中"); fd.put("numberField_mmd1smem", 8d); DDR_New response = new DDR_New<>(); response.setData(Collections.singletonList(Collections.singletonMap("formData", fd))); when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form))).thenAnswer(invocation -> { YDParam param = invocation.getArgument(0); if ("repair-summary".equals(param.getFormUuid())) return response; DDR_New original = new DDR_New<>(); original.setData(Collections.emptyList()); return original; }); TimeCardRepairSummaryUpsertRequest request = new TimeCardRepairSummaryUpsertRequest(); request.setSubmitterUserId("user-1"); request.setDayText("20260930"); request.setFormData(new HashMap<>()); assertTrue(assertThrows(McException.class, () -> service.upsert(request)).getMessage().contains("已提交或已审批")); verify(client, times(1)).queryData(org.mockito.ArgumentMatchers.argThat( p -> "original-summary".equals(p.getFormUuid())), eq(YDConf.FORM_QUERY.retrieve_search_form)); } @ParameterizedTest @ValueSource(booleans = {false, true}) void shouldCheckOriginalSourceGuardForEveryStaffType(boolean external) { YDClient client = mock(YDClient.class); TimeCardRepairService service = repairContextService(client, mock(MonthClosingService.class), repairContextConf()); Map fd = new HashMap<>(); fd.put("numberField_mmd1smem", 8d); DDR_New response = new DDR_New<>(); response.setData(Collections.singletonList(Collections.singletonMap("formData", fd))); when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form))).thenReturn(response); assertThrows(McException.class, () -> ReflectionTestUtils.invokeMethod( service, "assertSourceDateAvailable", "user-1", "20260930", external)); verify(client, times(1)).queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)); } @Test void shouldCheckBothTablesDuringExternalSubmitValidation() { YDClient client = mock(YDClient.class); DDR_New response = new DDR_New<>(); response.setData(Collections.emptyList()); when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form))).thenReturn(response); TimeCardRepairService service = repairContextService(client, mock(MonthClosingService.class), repairContextConf()); TimeCardPersonnelPolicy policy = mock(TimeCardPersonnelPolicy.class); when(policy.getStaffType("user-1")).thenReturn("外部"); ReflectionTestUtils.setField(service, "personnelPolicy", policy); TimeCardRepairSubmitRequest request = new TimeCardRepairSubmitRequest(); request.setSubmitterUserId("user-1"); request.setSourceMonthText("202609"); Map row = new HashMap<>(); row.put("dateStr", "20260930"); row.put("projectCode", "P"); row.put("hours", 8d); Map group = Collections.singletonMap("billableRows", Collections.singletonList(row)); Object count = ReflectionTestUtils.invokeMethod(service, "validateSubmitRows", request, Collections.singletonMap("manager", group), Collections.emptyMap()); assertEquals(1, count); verify(policy, times(1)).getStaffType("user-1"); verify(client, times(1)).queryData(org.mockito.ArgumentMatchers.argThat( p -> "original-summary".equals(p.getFormUuid())), eq(YDConf.FORM_QUERY.retrieve_search_form)); verify(client, times(1)).queryData(org.mockito.ArgumentMatchers.argThat( p -> "repair-summary".equals(p.getFormUuid())), eq(YDConf.FORM_QUERY.retrieve_search_form)); } @Test void shouldSeparateNormalAndConfirmedReentryContextsAndKeepOriginalProtection() throws Exception { YDClient client = mock(YDClient.class); Map reversal = reversalRecord(); when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form))) .thenAnswer(invocation -> { YDParam param = invocation.getArgument(0); Map data = "original-summary".equals(param.getFormUuid()) ? Collections.singletonMap("numberField_mmd1smem", 8d) : reversal; DDR_New response = new DDR_New<>(); response.setData(Collections.singletonList(Collections.singletonMap("formData", data))); return response; }); TimeCardRepairService service = repairContextService(client, mock(MonthClosingService.class), repairContextConf()); TimeCardPersonnelPolicy policy = mock(TimeCardPersonnelPolicy.class); when(policy.getStaffType("user-1")).thenReturn("外部"); ReflectionTestUtils.setField(service, "personnelPolicy", policy); TimeCardRepairContextRequest request = repairContextRequest(); Map normal = awaitReadyContext(service, request); assertEquals(1, ((List) normal.get("originalRecords")).size()); request.setRepairMode("REENTRY"); Map reentry = awaitReadyContext(service, request); assertEquals("repair-only", reentry.get("summaryReadMode")); assertTrue(((List) reentry.get("originalRecords")).isEmpty()); assertEquals(Collections.singletonList("20260930"), reentry.get("reentryAllowedDays")); assertTrue(((List) reentry.get("repairRecords")).isEmpty(), "冲销变更不能成为补填审批锁"); verify(client, times(1)).queryData(org.mockito.ArgumentMatchers.argThat( p -> "original-summary".equals(p.getFormUuid())), eq(YDConf.FORM_QUERY.retrieve_search_form)); assertThrows(McException.class, () -> ReflectionTestUtils.invokeMethod(service, "assertRepairSourceDateAvailable", "user-1", "20260930", "NORMAL", true)); ReflectionTestUtils.invokeMethod(service, "assertRepairSourceDateAvailable", "user-1", "20260930", "REENTRY", true); } @Test void shouldRejectRolledBackUnapprovedOrForgedReentryBeforeWrites() { for (String invalid : new String[]{"missing", "batch", "sync", "user", "day", "positive", "internal", "mode"}) { YDClient client = mock(YDClient.class); Map reversal = reversalRecord(); if ("batch".equals(invalid)) reversal.put("batch", "unconfirmed"); if ("sync".equals(invalid)) reversal.put("sync", "部分成功"); if ("user".equals(invalid)) reversal.put("textField_mmbffvd9", "other-user"); if ("day".equals(invalid)) reversal.put("textField_mmbffvdb", "20260929"); if ("positive".equals(invalid)) reversal.put("numberField_mmeakgi4", 8d); DDR_New response = new DDR_New<>(); response.setData("missing".equals(invalid) ? Collections.emptyList() : Collections.singletonList(Collections.singletonMap("formData", reversal))); when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form))).thenReturn(response); TimeCardRepairService service = repairContextService(client, mock(MonthClosingService.class), repairContextConf()); assertThrows(McException.class, () -> ReflectionTestUtils.invokeMethod(service, "assertRepairSourceDateAvailable", "user-1", "20260930", "mode".equals(invalid) ? "FAKE" : "REENTRY", !"internal".equals(invalid)), invalid); verify(client, never()).operateData(any(YDParam.class), any()); } } @Test void shouldBlockNormalExternalSaveAndAllSubmitCategoriesOnOriginalApprovedDay() { for (String category : new String[]{"billableRows", "nonBillableRows", "otherRows"}) { YDClient client = mock(YDClient.class); DDR_New response = new DDR_New<>(); response.setData(Collections.singletonList(Collections.singletonMap("formData", Collections.singletonMap("numberField_mmeakgi4", 8d)))); when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form))).thenReturn(response); TimeCardRepairService service = repairContextService(client, mock(MonthClosingService.class), repairContextConf()); TimeCardPersonnelPolicy policy = mock(TimeCardPersonnelPolicy.class); when(policy.getStaffType("user-1")).thenReturn("外部"); ReflectionTestUtils.setField(service, "personnelPolicy", policy); TimeCardRepairSubmitRequest request = repairSubmitRequest(); request.setSourceMonthText("202609"); Map row = new HashMap<>(); row.put("dateStr", "20260930"); row.put("projectCode", "P"); row.put("hours", 8d); Map group = Collections.singletonMap(category, Collections.singletonList(row)); assertThrows(McException.class, () -> ReflectionTestUtils.invokeMethod(service, "validateSubmitRows", request, "otherRows".equals(category) ? Collections.emptyMap() : Collections.singletonMap("manager", group), "otherRows".equals(category) ? group : Collections.emptyMap())); verify(client, never()).operateData(any(YDParam.class), any()); } } private static Map reversalRecord() { Map data = new HashMap<>(); data.put("fill-type", "变更"); data.put("batch", "10月调账-EXT-20261020"); data.put("sync", "全部成功"); data.put("textField_mmbffvd9", "user-1"); data.put("textField_mmbffvda", "202609"); data.put("textField_mmbffvdb", "20260930"); data.put("numberField_mmeakgi4", -8d); return data; } @Test void shouldReturnPendingImmediatelyThenExposeCompletedContext() throws Exception { YDClient client = mock(YDClient.class); MonthClosingService closingService = mock(MonthClosingService.class); WHConf conf = repairContextConf(); CountDownLatch queriesStarted = new CountDownLatch(2); CountDownLatch releaseQueries = new CountDownLatch(1); when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form))) .thenAnswer(invocation -> { YDParam param = invocation.getArgument(0); queriesStarted.countDown(); if (!releaseQueries.await(2, TimeUnit.SECONDS)) { throw new IllegalStateException("parallel context queries did not start"); } Map formData = new HashMap<>(); if ("repair-summary".equals(param.getFormUuid())) formData.put("fill-type", "补填"); Map record = new HashMap<>(); record.put("formInstId", param.getFormUuid() + "-instance"); record.put("formData", formData); record.put("instValue", "redundant payload"); DDR_New response = new DDR_New<>(); response.setData(Collections.singletonList(record)); return response; }); TimeCardRepairService service = repairContextService(client, closingService, conf); TimeCardRepairContextRequest request = repairContextRequest(); Map first = service.context(request); assertEquals(false, first.get("ready")); assertEquals(2_000L, first.get("retryAfterMillis")); assertTrue(queriesStarted.await(2, TimeUnit.SECONDS)); Map second = service.context(request); assertEquals(false, second.get("ready")); releaseQueries.countDown(); Map completed = awaitReadyContext(service, request); assertEquals(true, completed.get("ready")); assertEquals(1, ((List) completed.get("originalRecords")).size()); assertEquals(1, ((List) completed.get("repairRecords")).size()); Map compactRecord = (Map) ((List) completed.get("originalRecords")).get(0); assertEquals("original-summary-instance", compactRecord.get("formInstId")); assertFalse(compactRecord.containsKey("instValue")); assertFalse(completed.containsKey("closed")); verify(client, times(2)).queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)); verifyNoInteractions(closingService); } @Test void shouldReuseCompletedRepairContextWithinTtl() throws Exception { YDClient client = mock(YDClient.class); MonthClosingService closingService = mock(MonthClosingService.class); DDR_New response = new DDR_New<>(); response.setData(Collections.emptyList()); when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form))) .thenReturn(response); TimeCardRepairService service = repairContextService(client, closingService, repairContextConf()); TimeCardRepairContextRequest request = repairContextRequest(); service.context(request); Map first = awaitReadyContext(service, request); Map second = service.context(request); assertEquals(first, second); verify(client, times(2)).queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)); verifyNoInteractions(closingService); } @Test void shouldStartDifferentUserContextsWithoutWaitingForEachOther() throws Exception { YDClient client = mock(YDClient.class); MonthClosingService closingService = mock(MonthClosingService.class); CountDownLatch queriesStarted = new CountDownLatch(4); CountDownLatch releaseQueries = new CountDownLatch(1); when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form))) .thenAnswer(invocation -> { queriesStarted.countDown(); if (!releaseQueries.await(2, TimeUnit.SECONDS)) { throw new IllegalStateException("different user context queries did not start"); } DDR_New response = new DDR_New<>(); response.setData(Collections.emptyList()); return response; }); TimeCardRepairService service = repairContextService(client, closingService, repairContextConf()); TimeCardRepairContextRequest firstRequest = repairContextRequest(); TimeCardRepairContextRequest secondRequest = repairContextRequest(); secondRequest.setSubmitterUserId("user-2"); assertEquals(false, service.context(firstRequest).get("ready")); assertEquals(false, service.context(secondRequest).get("ready")); assertTrue(queriesStarted.await(2, TimeUnit.SECONDS)); releaseQueries.countDown(); assertEquals(true, awaitReadyContext(service, firstRequest).get("ready")); assertEquals(true, awaitReadyContext(service, secondRequest).get("ready")); verify(client, times(4)).queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)); verifyNoInteractions(closingService); } private static Map awaitReadyContext(TimeCardRepairService service, TimeCardRepairContextRequest request) throws InterruptedException { for (int attempt = 0; attempt < 200; attempt++) { Map result = service.context(request); if (Boolean.TRUE.equals(result.get("ready"))) return result; Thread.sleep(10L); } throw new AssertionError("context did not become ready"); } @Test void shouldPropagateRepairContextQueryFailureWithoutPartialResult() throws Exception { YDClient client = mock(YDClient.class); MonthClosingService closingService = mock(MonthClosingService.class); CountDownLatch queryStarted = new CountDownLatch(1); CountDownLatch releaseQuery = new CountDownLatch(1); when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form))) .thenAnswer(invocation -> { queryStarted.countDown(); if (!releaseQuery.await(2, TimeUnit.SECONDS)) { throw new IllegalStateException("failed context query was not released"); } throw new McException("TEMPORARY_FAILURE", "宜搭查询超时"); }); TimeCardRepairService service = repairContextService(client, closingService, repairContextConf()); TimeCardRepairContextRequest request = repairContextRequest(); assertEquals(false, service.context(request).get("ready")); assertTrue(queryStarted.await(2, TimeUnit.SECONDS)); releaseQuery.countDown(); McException error = awaitContextFailure(service, request); assertEquals("宜搭查询超时", error.getMessage()); verifyNoInteractions(closingService); } private static McException awaitContextFailure(TimeCardRepairService service, TimeCardRepairContextRequest request) throws InterruptedException { for (int attempt = 0; attempt < 200; attempt++) { try { service.context(request); } catch (McException error) { return error; } Thread.sleep(10L); } throw new AssertionError("context failure was not propagated"); } private static TimeCardRepairService repairContextService(YDClient client, MonthClosingService closingService, WHConf conf) { TimeCardRepairService service = new TimeCardRepairService(); ReflectionTestUtils.setField(service, "personnelPolicy", internalPolicy()); ReflectionTestUtils.setField(service, "ydClient", client); ReflectionTestUtils.setField(service, "monthClosingService", closingService); ReflectionTestUtils.setField(service, "whConf", conf); return service; } private static WHConf repairContextConf() { WHConf conf = new WHConf(); conf.setYidaAppType("app"); conf.setYidaSystemToken("token"); conf.setFormUuidWorkHoursSummary("original-summary"); conf.setFormUuidRepairWorkHoursSummary("repair-summary"); conf.setRepairSummaryFillTypeField("fill-type"); conf.setRepairSummaryBatchField("batch"); conf.setRepairSummarySyncStatusField("sync"); return conf; } private static TimeCardRepairContextRequest repairContextRequest() { TimeCardRepairContextRequest request = new TimeCardRepairContextRequest(); request.setSubmitterUserId("user-1"); request.setSourceMonthText("202609"); return request; } @ParameterizedTest @ValueSource(booleans = {false, true}) void shouldBlockSavingWhenCurrentMonthClosingRunsOrCompleted(boolean closed) { MonthClosingService closingService = mock(MonthClosingService.class); when(closingService.isLocked("202608")).thenReturn(true); when(closingService.status(any(MonthClosingCheckRequest.class))) .thenReturn(MonthClosingStatusResult.builder().closed(closed) .runningClosingCount(closed ? 0 : 1).build()); TimeCardRepairService service = new TimeCardRepairService(); ReflectionTestUtils.setField(service, "personnelPolicy", internalPolicy()); ReflectionTestUtils.setField(service, "monthClosingService", closingService); TimeCardRepairSummaryUpsertRequest request = new TimeCardRepairSummaryUpsertRequest(); request.setSubmitterUserId("user-1"); request.setDayText("20260818"); assertEquals("当前月份已封账或封账流程审批中,不可保存补填", assertThrows(McException.class, () -> service.upsert(request)).getMessage()); } @Test void shouldAllowSubmissionPastSourceClosingCheckWhileClosingIsRunning() { MonthClosingService closingService = mock(MonthClosingService.class); YDClient client = mock(YDClient.class); TimeCardRepairSubmitRequest request = repairSubmitRequest(); doCallRealMethod().when(closingService).isLocked(request.getSourceMonthText()); when(closingService.status(any(MonthClosingCheckRequest.class))).thenAnswer(invocation -> { MonthClosingCheckRequest query = invocation.getArgument(0); return MonthClosingStatusResult.builder().monthText(query.getMonthText()) .runningClosingCount(request.getSourceMonthText().equals(query.getMonthText()) ? 1 : 0) .build(); }); TimeCardRepairService service = new TimeCardRepairService(); ReflectionTestUtils.setField(service, "personnelPolicy", internalPolicy()); ReflectionTestUtils.setField(service, "monthClosingService", closingService); ReflectionTestUtils.setField(service, "ydClient", client); McException error = assertThrows(McException.class, () -> service.submit(request)); assertEquals("没有需要提交的补填工时", error.getMessage()); verifyNoInteractions(client); } @Test void shouldRejectSaveAndSubmitWhenSourceHasNoClosingProcess() { MonthClosingService closingService = mock(MonthClosingService.class); YDClient client = mock(YDClient.class); TimeCardRepairService service = new TimeCardRepairService(); ReflectionTestUtils.setField(service, "personnelPolicy", internalPolicy()); ReflectionTestUtils.setField(service, "monthClosingService", closingService); ReflectionTestUtils.setField(service, "ydClient", client); TimeCardRepairSummaryUpsertRequest draft = new TimeCardRepairSummaryUpsertRequest(); draft.setSubmitterUserId("user-1"); draft.setDayText("20260920"); assertEquals("补填只能保存已发起封账或已完成封账的月份", assertThrows(McException.class, () -> service.upsert(draft)).getMessage()); assertEquals("补填只能提交已发起封账或已完成封账的月份", assertThrows(McException.class, () -> service.submit(repairSubmitRequest())).getMessage()); verifyNoInteractions(client); } @Test void shouldBlockRepairSubmissionWhenCurrentMonthClosingIsRunning() { assertCurrentMonthBlocksSubmit(false, 1); } @Test void shouldBlockRepairSubmissionWhenCurrentMonthIsClosed() { assertCurrentMonthBlocksSubmit(true, 0); } @Test void shouldAllowRepairSubmissionPastClosingCheckWhenCurrentMonthIsOpen() { MonthClosingService closingService = mock(MonthClosingService.class); YDClient client = mock(YDClient.class); TimeCardRepairSubmitRequest request = repairSubmitRequest(); String currentMonth = YearMonth.now(ZoneId.of("Asia/Shanghai")) .format(DateTimeFormatter.ofPattern("yyyyMM")); when(closingService.isLocked(request.getSourceMonthText())).thenReturn(true); when(closingService.status(any(MonthClosingCheckRequest.class))) .thenReturn(MonthClosingStatusResult.builder().monthText(currentMonth).build()); TimeCardRepairService service = new TimeCardRepairService(); ReflectionTestUtils.setField(service, "personnelPolicy", internalPolicy()); ReflectionTestUtils.setField(service, "monthClosingService", closingService); ReflectionTestUtils.setField(service, "ydClient", client); McException error = assertThrows(McException.class, () -> service.submit(request)); assertEquals("没有需要提交的补填工时", error.getMessage()); verify(closingService).status(org.mockito.ArgumentMatchers.argThat( value -> currentMonth.equals(value.getMonthText()))); verifyNoInteractions(client); } private static void assertCurrentMonthBlocksSubmit(boolean closed, int runningCount) { MonthClosingService closingService = mock(MonthClosingService.class); YDClient client = mock(YDClient.class); TimeCardRepairSubmitRequest request = repairSubmitRequest(); when(closingService.isLocked(request.getSourceMonthText())).thenReturn(true); when(closingService.status(any(MonthClosingCheckRequest.class))) .thenReturn(MonthClosingStatusResult.builder() .closed(closed).runningClosingCount(runningCount).build()); TimeCardRepairService service = new TimeCardRepairService(); ReflectionTestUtils.setField(service, "personnelPolicy", internalPolicy()); ReflectionTestUtils.setField(service, "monthClosingService", closingService); ReflectionTestUtils.setField(service, "ydClient", client); McException error = assertThrows(McException.class, () -> service.submit(request)); assertEquals("当前月份已封账或封账流程审批中,不可提交补填", error.getMessage()); verifyNoInteractions(client); } private static TimeCardRepairSubmitRequest repairSubmitRequest() { TimeCardRepairSubmitRequest request = new TimeCardRepairSubmitRequest(); request.setSubmitterUserId("user-1"); request.setSourceMonthText(YearMonth.now(ZoneId.of("Asia/Shanghai")) .minusMonths(1).format(DateTimeFormatter.ofPattern("yyyyMM"))); return request; } @Test void shouldAllowRepairWhenOriginalSummaryOnlyHasDraftHours() { Map formData = new HashMap<>(); formData.put("numberField_mmd1smel", 8d); Map record = new HashMap<>(); record.put("formData", formData); assertFalse(TimeCardRepairService.hasApprovedOrChangedHours(record)); } @Test void shouldBlockRepairWhenApprovedAndChangedHoursRemain() { Map formData = new HashMap<>(); formData.put("numberField_mmeakgi4", 8d); formData.put("numberField_mmeakgi5", -2d); assertTrue(TimeCardRepairService.hasApprovedOrChangedHours(formData)); } @Test void shouldBlockRepairWhenChangeFullyOffsetsApprovedHours() { Map formData = new HashMap<>(); formData.put("numberField_mmeakgi4", 8d); formData.put("numberField_mmeakgi5", -8d); assertTrue(TimeCardRepairService.hasApprovedOrChangedHours(formData)); } @Test void shouldBlockRepairWhenOriginalApprovalStatusIsApproved() { Map formData = new HashMap<>(); formData.put("selectField_mre1xz9g", "已审批"); assertTrue(TimeCardRepairService.hasApprovedOrChangedHours(formData)); } @Test void shouldNotSaveDraftOverApprovedRepairWithCorruptedDraftStatus() { YDClient client = mock(YDClient.class); MonthClosingService closingService = mock(MonthClosingService.class); WHConf conf = new WHConf(); conf.setFormUuidWorkHoursSummary("original-summary"); conf.setFormUuidRepairWorkHoursSummary("repair-summary"); conf.setRepairSummaryFillTypeField("fill-type"); conf.setRepairSummarySyncStatusField("repair-status"); when(closingService.isLocked("202608")).thenReturn(true); when(closingService.status(any(MonthClosingCheckRequest.class))) .thenReturn(MonthClosingStatusResult.builder().build()); DDR_New noOriginal = new DDR_New<>(); noOriginal.setData(Collections.emptyList()); Map child = new HashMap<>(); child.put("numberField_mmeakgii", 8d); Map formData = new HashMap<>(); formData.put("repair-status", "暂存"); formData.put("textField_mmbffvd9", "user-1"); formData.put("textField_mmbffvdb", "20260818"); formData.put("tableField_mmeakgid", Collections.singletonList(child)); Map record = new HashMap<>(); record.put("formData", formData); DDR_New approvedRepair = new DDR_New<>(); approvedRepair.setData(Collections.singletonList(record)); when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form))) .thenReturn(noOriginal, approvedRepair); TimeCardRepairService service = new TimeCardRepairService(); ReflectionTestUtils.setField(service, "personnelPolicy", internalPolicy()); ReflectionTestUtils.setField(service, "ydClient", client); ReflectionTestUtils.setField(service, "whConf", conf); ReflectionTestUtils.setField(service, "monthClosingService", closingService); TimeCardRepairSummaryUpsertRequest request = new TimeCardRepairSummaryUpsertRequest(); request.setSubmitterUserId("user-1"); request.setDayText("20260818"); request.setFormData(Collections.singletonMap("numberField_mmd1smel", 8d)); assertThrows(McException.class, () -> service.upsert(request)); verify(client, never()).operateData(any(YDParam.class), eq(YDConf.FORM_OPERATION.upsert)); } @ParameterizedTest @ValueSource(booleans = {false, true}) void shouldCalculateRepairDraftTotalsWhenSourceClosingIsRunningOrCompleted(boolean closed) { YDClient client = mock(YDClient.class); MonthClosingService closingService = mock(MonthClosingService.class); WHConf conf = new WHConf(); conf.setFormUuidWorkHoursSummary("original-summary"); conf.setFormUuidRepairWorkHoursSummary("repair-summary"); conf.setRepairSummaryFillTypeField("fill-type"); conf.setRepairSummarySyncStatusField("repair-status"); doCallRealMethod().when(closingService).isLocked("202608"); when(closingService.status(any(MonthClosingCheckRequest.class))).thenAnswer(invocation -> { String month = ((MonthClosingCheckRequest) invocation.getArgument(0)).getMonthText(); boolean source = "202608".equals(month); return MonthClosingStatusResult.builder().monthText(month) .closed(source && closed).runningClosingCount(source && !closed ? 1 : 0).build(); }); DDR_New empty = new DDR_New<>(); empty.setData(Collections.emptyList()); Map stored = new HashMap<>(); Map storedRecord = new HashMap<>(); storedRecord.put("formInstId", "saved-repair"); storedRecord.put("formData", stored); DDR_New saved = new DDR_New<>(); saved.setData(Collections.singletonList(storedRecord)); when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form))) .thenReturn(empty, empty, saved); when(client.operateData(any(YDParam.class), eq(YDConf.FORM_OPERATION.upsert))).thenAnswer(invocation -> { stored.putAll(JSON.parseObject(((YDParam) invocation.getArgument(0)).getFormDataJson(), Map.class)); return true; }); DDR_New storedDetail = new DDR_New<>(); storedDetail.setFormData(stored); when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_id))).thenReturn(storedDetail); TimeCardRepairService service = new TimeCardRepairService(); ReflectionTestUtils.setField(service, "personnelPolicy", internalPolicy()); ReflectionTestUtils.setField(service, "ydClient", client); ReflectionTestUtils.setField(service, "whConf", conf); ReflectionTestUtils.setField(service, "monthClosingService", closingService); Map child = new HashMap<>(); child.put("numberField_mmczo635", 5d); Map payload = new HashMap<>(); conf.setRepairSummarySourceMonthField("source-month"); conf.setRepairSummarySubmitDateField("submit-date"); payload.put("tableField_mmczo634", Collections.singletonList(child)); payload.put("numberField_mmeakgi4", 9d); payload.put("numberField_mmd1smel", 99d); TimeCardRepairSummaryUpsertRequest request = new TimeCardRepairSummaryUpsertRequest(); request.setSubmitterUserId("user-1"); request.setDayText("20260818"); request.setFormData(payload); assertTrue(service.upsert(request)); ArgumentCaptor captor = ArgumentCaptor.forClass(YDParam.class); verify(client).operateData(captor.capture(), eq(YDConf.FORM_OPERATION.upsert)); Map condition = JSON.parseObject(captor.getValue().getSearchCondition(), Map.class); Map written = JSON.parseObject(captor.getValue().getFormDataJson(), Map.class); assertEquals("补填", condition.get("fill-type")); assertEquals(5d, ((Number) written.get("numberField_mmd1smel")).doubleValue()); assertEquals(0d, ((Number) written.get("numberField_mmeakgi4")).doubleValue()); assertEquals("补填", written.get("fill-type")); assertEquals("暂存", written.get("repair-status")); assertEquals("202608", written.get("source-month")); assertEquals(LocalDate.now(ZoneId.of("Asia/Shanghai")).atStartOfDay(ZoneId.of("Asia/Shanghai")) .toInstant().toEpochMilli(), ((Number) written.get("submit-date")).longValue()); } @Test void shouldIgnoreChangeRowsDuringRepairResubmitScan() { YDClient client = mock(YDClient.class); ReSubmitApprovalService approvalService = mock(ReSubmitApprovalService.class); WHConf conf = new WHConf(); conf.setFormUuidRepairWorkHoursSummary("shared-summary"); conf.setRepairSummaryFillTypeField("fill-type"); conf.setRepairSummarySyncStatusField("sync-status"); Map change = new HashMap<>(); change.put("fill-type", "变更"); change.put("sync-status", "审批中"); Map record = new HashMap<>(); record.put("formData", change); DDR_New summaries = new DDR_New<>(); summaries.setData(Collections.singletonList(record)); when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form))) .thenReturn(summaries); TimeCardRepairService service = new TimeCardRepairService(); ReflectionTestUtils.setField(service, "personnelPolicy", internalPolicy()); ReflectionTestUtils.setField(service, "ydClient", client); ReflectionTestUtils.setField(service, "whConf", conf); ReflectionTestUtils.setField(service, "reSubmitApprovalService", approvalService); TimeCardRepairService.RepairResubmitResult result = service.resubmitRecentlyModifiedPending(7); assertEquals(0, result.getStarted()); assertEquals(0, result.getFailed()); verifyNoInteractions(approvalService); } @Test void shouldTreatBlankLegacyTypeAndRepairTypeAsRepairOnly() { assertTrue(TimeCardRepairService.isRepairSummaryRecord( Collections.emptyMap(), "fill-type")); assertTrue(TimeCardRepairService.isRepairSummaryRecord( Collections.singletonMap("fill-type", "补填"), "fill-type")); assertFalse(TimeCardRepairService.isRepairSummaryRecord( Collections.singletonMap("fill-type", "变更"), "fill-type")); } @Test void shouldReportRepairBatchStillFailedAfterDeferredRetry() { YDClient client = mock(YDClient.class); ReSubmitApprovalService approvalService = mock(ReSubmitApprovalService.class); WHConf conf = new WHConf(); conf.setFormUuidRepairWorkHoursSummary("repair-summary"); conf.setFormUuidApproval("bill-approval"); conf.setFormUuidOtherApproval("other-approval"); conf.setRepairSummaryFillTypeField("fill-type"); conf.setRepairSummarySyncStatusField("repair-status"); conf.setRepairSummaryBatchField("repair-batch"); conf.setRepairSummarySubmitDateField("repair-submit-date"); conf.setApprovalFillTypeField("fill-type"); conf.setOtherApprovalRepairSubmitDateField("other-submit-date"); conf.setOtherApprovalRepairBatchField("other-batch"); conf.setOtherApprovalRepairSourceMonthField("other-source-month"); Map otherRow = new HashMap<>(); otherRow.put("numberField_mmeakgij", 8d); otherRow.put("numberField_mmeakgii", 0d); otherRow.put("selectField_mmeakgie", "培训"); Map formData = new HashMap<>(); formData.put("fill-type", "补填"); formData.put("repair-status", "审批中"); formData.put("repair-batch", "batch-1"); formData.put("repair-submit-date", 1787000000000L); formData.put("textField_mmbffvd9", "user-1"); formData.put("textField_mmbffvda", "202608"); formData.put("textField_mmbffvdb", "20260818"); formData.put("tableField_mmeakgid", Collections.singletonList(otherRow)); Map record = new HashMap<>(); record.put("formData", formData); DDR_New summaries = new DDR_New<>(); summaries.setData(Collections.singletonList(record)); DDR_New noApprovals = new DDR_New<>(); noApprovals.setData(Collections.emptyList()); when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form))) .thenReturn(summaries); when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_process))) .thenReturn(noApprovals); when(approvalService.startApproval(any(TimeCardApprovalStartRequest.class))) .thenThrow(new IllegalStateException("temporary failure")); TimeCardRepairService service = new TimeCardRepairService(); ReflectionTestUtils.setField(service, "personnelPolicy", internalPolicy()); ReflectionTestUtils.setField(service, "ydClient", client); ReflectionTestUtils.setField(service, "whConf", conf); ReflectionTestUtils.setField(service, "reSubmitApprovalService", approvalService); TimeCardRepairService.RepairResubmitResult result = service.resubmitRecentlyModifiedPending(7); assertEquals(0, result.getStarted()); assertEquals(1, result.getFailed()); verify(approvalService, times(2)).startApproval(any(TimeCardApprovalStartRequest.class)); } }