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.MonthClosingConf; import com.malk.server.workhours.MonthClosingStatusResult; import com.malk.server.workhours.MonthClosingValidationResult; import com.malk.server.workhours.WHConf; import com.malk.service.aliwork.YDClient; import org.junit.jupiter.api.Test; import org.mockito.invocation.InvocationOnMock; import org.springframework.test.util.ReflectionTestUtils; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.FutureTask; import java.util.concurrent.atomic.AtomicLong; import java.util.function.LongSupplier; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; 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.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; class MonthClosingServiceTest { private static final String CLOSING_FORM = "FORM-CLOSING"; private static final String PROJECT_FORM = "FORM-PROJECT-APPROVAL"; private static final String OTHER_FORM = "FORM-OTHER-APPROVAL"; private static final String SUMMARY_FORM = "FORM-SUMMARY"; private static final String REPAIR_SUMMARY_FORM = "FORM-REPAIR-SUMMARY"; private static final String CLOSING_MONTH_FIELD = "dateField_closing_month"; private static final String CLOSING_STATUS_FIELD = "radioField_closing_status"; private static final String SYNC_STATUS_FIELD = "selectField_sync_status"; private static final String SUMMARY_STATUS_FIELD = "selectField_summary_status"; private static final String APPROVAL_MONTH_FIELD = "textField_mmd2lv0y"; private static final String FILL_TYPE_FIELD = "radioField_fill_type"; private static final String PROJECT_REPAIR_SUBMIT_DATE_FIELD = "dateField_project_repair_submit"; private static final String OTHER_REPAIR_SUBMIT_DATE_FIELD = "dateField_other_repair_submit"; private static final String REPAIR_SUMMARY_SUBMIT_DATE_FIELD = "dateField_repair_summary_submit"; private static final String REPAIR_SUMMARY_STATUS_FIELD = "selectField_repair_summary_status"; @Test void shouldRejectInvalidMonthBeforeQueryingYida() { YDClient ydClient = mock(YDClient.class); MonthClosingService service = service(ydClient); assertThrows( McException.class, () -> service.status(request("202613"))); verifyNoInteractions(ydClient); } @Test void shouldAllowClosingWhenNoApprovalIsPending() { YDClient ydClient = mock(YDClient.class); when(ydClient.queryData(any(YDParam.class), any(YDConf.FORM_QUERY.class))) .thenReturn(response(Collections.emptyList())); MonthClosingService service = service(ydClient); MonthClosingValidationResult result = service.validate(request("202608")); assertTrue(result.isAllowed()); assertFalse(result.isAlreadyClosed()); assertEquals(0, result.getProjectRunningCount()); assertEquals(0, result.getOtherRunningCount()); assertEquals(0, result.getWriteBackPendingCount()); assertEquals(0, result.getSummaryPendingCount()); } @Test void shouldBlockClosingWhenProjectApprovalIsRunning() { YDClient ydClient = mock(YDClient.class); when(ydClient.queryData(any(YDParam.class), any(YDConf.FORM_QUERY.class))) .thenAnswer(this::queryResponse); MonthClosingService service = service(ydClient); MonthClosingValidationResult result = service.validate(request("202608")); assertFalse(result.isAllowed()); assertEquals(1, result.getProjectRunningCount()); assertTrue(result.getMessage().contains("项目工时 1 张")); } @Test void shouldLockMonthWhileClosingFlowIsRunning() { YDClient ydClient = mock(YDClient.class); when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_process))) .thenAnswer(invocation -> { YDParam param = invocation.getArgument(0); if (CLOSING_FORM.equals(param.getFormUuid())) { Map data = new HashMap<>(); data.put(CLOSING_MONTH_FIELD, "2026-09-01"); Map item = new HashMap<>(); item.put("instanceStatus", "RUNNING"); item.put("formData", data); return response(Collections.singletonList(item)); } return response(Collections.emptyList()); }); MonthClosingService service = service(ydClient); assertTrue(service.isLocked("202609")); assertFalse(service.isLocked("202608")); } @Test void shouldTreatApprovedClosingProcessAsClosed() { YDClient ydClient = mock(YDClient.class); when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_process))) .thenAnswer(invocation -> { YDParam param = invocation.getArgument(0); if (CLOSING_FORM.equals(param.getFormUuid())) { Map formData = new HashMap<>(); formData.put(CLOSING_MONTH_FIELD, "2026-08-01"); formData.put(CLOSING_STATUS_FIELD, "封账"); Map item = new HashMap<>(); item.put("instanceStatus", "COMPLETED"); item.put("approvedResult", "agree"); item.put("modifiedTimeGMT", 1_777_590_000_000L); item.put("formData", formData); return response(Collections.singletonList(item)); } return response(Collections.emptyList()); }); MonthClosingService service = service(ydClient); MonthClosingStatusResult result = service.status(request("202608")); assertTrue(result.isClosed()); assertEquals(1, result.getApprovedClosingCount()); assertEquals("封账", result.getCurrentStatus()); } @Test void shouldTreatLatestApprovedReopeningAsOpen() { YDClient ydClient = mock(YDClient.class); when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_process))) .thenAnswer(invocation -> { YDParam param = invocation.getArgument(0); if (CLOSING_FORM.equals(param.getFormUuid())) { return response(Arrays.asList( closingAction("启封", 1_777_676_400_000L), closingAction("封账", 1_777_590_000_000L))); } return response(Collections.emptyList()); }); MonthClosingService service = service(ydClient); MonthClosingStatusResult result = service.status(request("202608")); assertFalse(result.isClosed()); assertEquals("启封", result.getCurrentStatus()); assertEquals(1, result.getApprovedClosingCount()); assertEquals(1, result.getApprovedReopeningCount()); } @Test void shouldAllowReopeningOnlyWhenMonthIsClosed() { YDClient ydClient = mock(YDClient.class); when(ydClient.queryData(any(YDParam.class), any(YDConf.FORM_QUERY.class))) .thenAnswer(invocation -> { YDParam param = invocation.getArgument(0); if (CLOSING_FORM.equals(param.getFormUuid())) { return response(Collections.singletonList( closingAction("封账", 1_777_590_000_000L))); } return response(Collections.emptyList()); }); MonthClosingService service = service(ydClient); MonthClosingValidationResult result = service.validate(request("202608", "启封")); assertTrue(result.isAllowed()); assertEquals("启封", result.getRequestedStatus()); assertEquals("封账", result.getCurrentStatus()); } @Test void shouldBlockReopeningWhenMonthIsAlreadyOpen() { YDClient ydClient = mock(YDClient.class); when(ydClient.queryData(any(YDParam.class), any(YDConf.FORM_QUERY.class))) .thenReturn(response(Collections.emptyList())); MonthClosingService service = service(ydClient); MonthClosingValidationResult result = service.validate(request("202608", "启封")); assertFalse(result.isAllowed()); assertTrue(result.getMessage().contains("当前未封账")); } @Test void shouldNormalizeDateFieldValuesToMonth() { assertEquals("202608", MonthClosingService.monthFromDateValue("2026-08-01")); assertEquals( "202608", MonthClosingService.monthFromDateValue( java.time.Instant.parse("2026-08-01T00:00:00Z").toEpochMilli())); } @Test void shouldQueryClosingProcessesWithTargetMonthDateRange() { YDClient ydClient = mock(YDClient.class); List closingQueries = new ArrayList<>(); when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_process))) .thenAnswer(invocation -> { YDParam param = invocation.getArgument(0); if (CLOSING_FORM.equals(param.getFormUuid())) { closingQueries.add(param); } return response(Collections.emptyList()); }); MonthClosingService service = service(ydClient); service.status(request("202608")); List expectedRange = MonthClosingService.closingMonthRange("202608"); assertEquals(1, closingQueries.size()); for (YDParam query : closingQueries) { assertNull(query.getInstanceStatus()); Map search = JSON.parseObject(query.getSearchFieldJson(), Map.class); List actualRange = (List) search.get(CLOSING_MONTH_FIELD); assertEquals(expectedRange.get(0).longValue(), ((Number) actualRange.get(0)).longValue()); assertEquals(expectedRange.get(1).longValue(), ((Number) actualRange.get(1)).longValue()); } } @Test @SuppressWarnings("unchecked") void shouldReuseInFlightClosingStatusRequestForSameMonth() { YDClient ydClient = mock(YDClient.class); MonthClosingService service = service(ydClient); MonthClosingStatusResult sharedResult = MonthClosingStatusResult.builder() .monthText("202608") .closed(false) .runningClosingCount(0) .build(); FutureTask activeRequest = new FutureTask<>(() -> sharedResult); activeRequest.run(); ConcurrentMap> requests = (ConcurrentMap>) ReflectionTestUtils.getField(service, "closingStatusRequests"); requests.put("202608", activeRequest); MonthClosingStatusResult result = service.status(request("202608")); assertEquals("202608", result.getMonthText()); assertFalse(result.isClosed()); verifyNoInteractions(ydClient); } @Test void shouldReuseSuccessfulClosingStatusResultForFiveMinutes() { YDClient ydClient = mock(YDClient.class); when(ydClient.queryData(any(YDParam.class), any(YDConf.FORM_QUERY.class))) .thenReturn(response(Collections.emptyList())); MonthClosingService service = service(ydClient); service.status(request("202608")); service.isLocked("202608"); verify(ydClient, times(1)) .queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_process)); } @Test void shouldWarmExistingClosingMonthsWithSingleQuery() { YDClient ydClient = mock(YDClient.class); Map augustData = new HashMap<>(); augustData.put(CLOSING_MONTH_FIELD, "2026-08-01"); augustData.put(CLOSING_STATUS_FIELD, "封账"); Map august = new HashMap<>(); august.put("instanceStatus", "COMPLETED"); august.put("approvedResult", "agree"); august.put("modifiedTimeGMT", 1_777_590_000_000L); august.put("formData", augustData); Map septemberData = new HashMap<>(); septemberData.put(CLOSING_MONTH_FIELD, "2026-09-01"); Map september = new HashMap<>(); september.put("instanceStatus", "RUNNING"); september.put("formData", septemberData); when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_process))) .thenReturn(response(Arrays.asList(august, september))); MonthClosingService service = service(ydClient); int warmedMonths = service.warmUpClosingStatusCache(); MonthClosingStatusResult augustStatus = service.status(request("202608")); MonthClosingStatusResult septemberStatus = service.status(request("202609")); assertTrue(warmedMonths >= 2); assertTrue(augustStatus.isClosed()); assertEquals(0, augustStatus.getRunningClosingCount()); assertFalse(septemberStatus.isClosed()); assertEquals(1, septemberStatus.getRunningClosingCount()); verify(ydClient, times(1)) .queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_process)); } @Test void shouldRefreshClosingStatusAfterFiveMinutes() { YDClient ydClient = mock(YDClient.class); when(ydClient.queryData(any(YDParam.class), any(YDConf.FORM_QUERY.class))) .thenReturn(response(Collections.emptyList())); MonthClosingService service = service(ydClient); AtomicLong now = new AtomicLong(1_000L); ReflectionTestUtils.setField( service, "currentTimeMillis", (LongSupplier) now::get); service.status(request("202608")); now.addAndGet(5 * 60 * 1000L + 1L); service.status(request("202608")); verify(ydClient, times(2)) .queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_process)); } @Test void shouldNotCacheFailedClosingStatusQuery() { YDClient ydClient = mock(YDClient.class); when(ydClient.queryData(any(YDParam.class), any(YDConf.FORM_QUERY.class))) .thenThrow(new IllegalStateException("query failed")) .thenReturn(response(Collections.emptyList())); MonthClosingService service = service(ydClient); assertThrows(IllegalStateException.class, () -> service.status(request("202608"))); MonthClosingStatusResult result = service.status(request("202608")); assertFalse(result.isClosed()); verify(ydClient, times(2)) .queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_process)); } @Test void shouldBlockCompletedApprovalWhoseWriteBackIsNotSuccessful() { YDClient ydClient = mock(YDClient.class); when(ydClient.queryData(any(YDParam.class), any(YDConf.FORM_QUERY.class))) .thenAnswer(invocation -> { YDParam param = invocation.getArgument(0); YDConf.FORM_QUERY queryType = invocation.getArgument(1); if (queryType == YDConf.FORM_QUERY.retrieve_search_process && PROJECT_FORM.equals(param.getFormUuid()) && "COMPLETED".equals(param.getInstanceStatus())) { Map formData = new HashMap<>(); formData.put(SYNC_STATUS_FIELD, "部分失败"); Map item = new HashMap<>(); item.put("approvedResult", "agree"); item.put("formData", formData); return response(Collections.singletonList(item)); } return response(Collections.emptyList()); }); MonthClosingService service = service(ydClient); MonthClosingValidationResult result = service.validate(request("202608")); assertFalse(result.isAllowed()); assertEquals(1, result.getWriteBackPendingCount()); } @Test void shouldAssignRunningRepairApprovalToSubmitMonthInsteadOfSourceMonth() { YDClient ydClient = mock(YDClient.class); when(ydClient.queryData(any(YDParam.class), any(YDConf.FORM_QUERY.class))) .thenAnswer(invocation -> { YDParam param = invocation.getArgument(0); if (PROJECT_FORM.equals(param.getFormUuid()) && "RUNNING".equals(param.getInstanceStatus())) { return response(Collections.singletonList( repairApproval(PROJECT_REPAIR_SUBMIT_DATE_FIELD, "2026-09-15", "", ""))); } return response(Collections.emptyList()); }); MonthClosingService service = service(ydClient); MonthClosingValidationResult september = service.validate(request("202609")); MonthClosingValidationResult august = service.validate(request("202608")); assertFalse(september.isAllowed()); assertEquals(1, september.getProjectRunningCount()); assertTrue(august.isAllowed()); } @Test void shouldAssignRunningChangeApprovalToSubmitMonthInsteadOfTargetMonth() { YDClient ydClient = mock(YDClient.class); when(ydClient.queryData(any(YDParam.class), any(YDConf.FORM_QUERY.class))) .thenAnswer(invocation -> { YDParam param = invocation.getArgument(0); if (PROJECT_FORM.equals(param.getFormUuid()) && "RUNNING".equals(param.getInstanceStatus())) { return response(Collections.singletonList( adjustmentApproval(PROJECT_REPAIR_SUBMIT_DATE_FIELD, "2026-10-07", "", "", "变更"))); } return response(Collections.emptyList()); }); MonthClosingService service = service(ydClient); MonthClosingValidationResult october = service.validate(request("202610")); MonthClosingValidationResult december = service.validate(request("202612")); assertFalse(october.isAllowed()); assertEquals(1, october.getProjectRunningCount()); assertTrue(december.isAllowed()); } @Test void shouldBlockClosingWhenOtherRepairApprovalAwaitsWriteBack() { YDClient ydClient = mock(YDClient.class); when(ydClient.queryData(any(YDParam.class), any(YDConf.FORM_QUERY.class))) .thenAnswer(invocation -> { YDParam param = invocation.getArgument(0); if (OTHER_FORM.equals(param.getFormUuid()) && "COMPLETED".equals(param.getInstanceStatus())) { return response(Collections.singletonList( repairApproval(OTHER_REPAIR_SUBMIT_DATE_FIELD, "2026-09-15", "agree", "部分失败"))); } return response(Collections.emptyList()); }); MonthClosingService service = service(ydClient); MonthClosingValidationResult result = service.validate(request("202609")); assertFalse(result.isAllowed()); assertEquals(1, result.getWriteBackPendingCount()); } @Test void shouldAllowClosingAfterRepairApprovalAndSummaryWriteBackFinish() { YDClient ydClient = mock(YDClient.class); when(ydClient.queryData(any(YDParam.class), any(YDConf.FORM_QUERY.class))) .thenAnswer(invocation -> { YDParam param = invocation.getArgument(0); if (OTHER_FORM.equals(param.getFormUuid()) && "COMPLETED".equals(param.getInstanceStatus())) { return response(Collections.singletonList( repairApproval(OTHER_REPAIR_SUBMIT_DATE_FIELD, "2026-09-15", "agree", "全部成功"))); } if (REPAIR_SUMMARY_FORM.equals(param.getFormUuid())) { Map data = new HashMap<>(); data.put(REPAIR_SUMMARY_SUBMIT_DATE_FIELD, "2026-09-15"); data.put(REPAIR_SUMMARY_STATUS_FIELD, "全部成功"); Map item = new HashMap<>(); item.put("formData", data); return response(Collections.singletonList(item)); } return response(Collections.emptyList()); }); MonthClosingService service = service(ydClient); MonthClosingValidationResult result = service.validate(request("202609")); assertTrue(result.isAllowed()); assertEquals(0, result.getWriteBackPendingCount()); assertEquals(0, result.getSummaryPendingCount()); } @Test void shouldBlockClosingWhileRepairSummaryWriteBackIsPartial() { YDClient ydClient = mock(YDClient.class); when(ydClient.queryData(any(YDParam.class), any(YDConf.FORM_QUERY.class))) .thenAnswer(invocation -> { YDParam param = invocation.getArgument(0); if (REPAIR_SUMMARY_FORM.equals(param.getFormUuid())) { Map data = new HashMap<>(); data.put(REPAIR_SUMMARY_SUBMIT_DATE_FIELD, "2026-09-15"); data.put(REPAIR_SUMMARY_STATUS_FIELD, "部分成功"); Map item = new HashMap<>(); item.put("formData", data); return response(Collections.singletonList(item)); } return response(Collections.emptyList()); }); MonthClosingService service = service(ydClient); MonthClosingValidationResult result = service.validate(request("202609")); MonthClosingValidationResult previousMonth = service.validate(request("202608")); assertFalse(result.isAllowed()); assertEquals(1, result.getSummaryPendingCount()); assertTrue(previousMonth.isAllowed()); } @Test void shouldKeepBlockingClosingForNormalSummaryInApproval() { YDClient ydClient = mock(YDClient.class); when(ydClient.queryData(any(YDParam.class), any(YDConf.FORM_QUERY.class))) .thenAnswer(invocation -> { YDParam param = invocation.getArgument(0); if (SUMMARY_FORM.equals(param.getFormUuid())) { Map data = new HashMap<>(); data.put("textField_mmbffvda", "202609"); data.put(SUMMARY_STATUS_FIELD, "审批中"); Map item = new HashMap<>(); item.put("formData", data); return response(Collections.singletonList(item)); } return response(Collections.emptyList()); }); MonthClosingService service = service(ydClient); MonthClosingValidationResult result = service.validate(request("202609")); assertFalse(result.isAllowed()); assertEquals(1, result.getSummaryPendingCount()); } private static Map repairApproval(String submitDateField, String submitDate, String result, String syncStatus) { return adjustmentApproval(submitDateField, submitDate, result, syncStatus, "补填"); } private static Map adjustmentApproval(String submitDateField, String submitDate, String result, String syncStatus, String fillType) { Map formData = new HashMap<>(); formData.put(APPROVAL_MONTH_FIELD, "202608"); formData.put(FILL_TYPE_FIELD, fillType); formData.put(submitDateField, submitDate); formData.put(SYNC_STATUS_FIELD, syncStatus); Map item = new HashMap<>(); item.put("approvedResult", result); item.put("formData", formData); return item; } private DDR_New queryResponse(InvocationOnMock invocation) { YDParam param = invocation.getArgument(0); YDConf.FORM_QUERY queryType = invocation.getArgument(1); if (queryType == YDConf.FORM_QUERY.retrieve_search_process && PROJECT_FORM.equals(param.getFormUuid()) && "RUNNING".equals(param.getInstanceStatus())) { Map item = new HashMap<>(); item.put("processInstanceId", "PROC-RUNNING"); return response(Collections.singletonList(item)); } return response(Collections.emptyList()); } private MonthClosingService service(YDClient ydClient) { WHConf whConf = new WHConf(); whConf.setYidaAppType("APP-TEST"); whConf.setYidaSystemToken("TOKEN-TEST"); whConf.setFormUuidApproval(PROJECT_FORM); whConf.setFormUuidOtherApproval(OTHER_FORM); whConf.setFormUuidWorkHoursSummary(SUMMARY_FORM); whConf.setFormUuidRepairWorkHoursSummary(REPAIR_SUMMARY_FORM); whConf.setApprovalSyncStatusField(SYNC_STATUS_FIELD); whConf.setOtherApprovalSyncStatusField(SYNC_STATUS_FIELD); whConf.setSummaryApprovalStatusField(SUMMARY_STATUS_FIELD); whConf.setApprovalFillTypeField(FILL_TYPE_FIELD); whConf.setApprovalRepairSubmitDateField(PROJECT_REPAIR_SUBMIT_DATE_FIELD); whConf.setOtherApprovalRepairSubmitDateField(OTHER_REPAIR_SUBMIT_DATE_FIELD); whConf.setRepairSummarySubmitDateField(REPAIR_SUMMARY_SUBMIT_DATE_FIELD); whConf.setRepairSummarySyncStatusField(REPAIR_SUMMARY_STATUS_FIELD); MonthClosingConf closingConf = new MonthClosingConf(); closingConf.setFormUuid(CLOSING_FORM); closingConf.setMonthDateField(CLOSING_MONTH_FIELD); closingConf.setStatusField(CLOSING_STATUS_FIELD); MonthClosingService service = new MonthClosingService(); ReflectionTestUtils.setField(service, "ydClient", ydClient); ReflectionTestUtils.setField(service, "whConf", whConf); ReflectionTestUtils.setField(service, "closingConf", closingConf); return service; } private static MonthClosingCheckRequest request(String monthText) { return request(monthText, "封账"); } private static MonthClosingCheckRequest request(String monthText, String closingStatus) { return MonthClosingCheckRequest.builder() .monthText(monthText) .closingStatus(closingStatus) .build(); } private static Map closingAction(String status, long modifiedTime) { Map formData = new HashMap<>(); formData.put(CLOSING_MONTH_FIELD, "2026-08-01"); formData.put(CLOSING_STATUS_FIELD, status); Map item = new HashMap<>(); item.put("instanceStatus", "COMPLETED"); item.put("approvedResult", "agree"); item.put("modifiedTimeGMT", modifiedTime); item.put("formData", formData); return item; } private static DDR_New response(List data) { DDR_New response = new DDR_New(); response.setData(new ArrayList<>(data)); response.setTotalCount(data.size()); return response; } }