| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866 |
- 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<Object> 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<String, Object> row = new HashMap<>();
- row.put("dateStr", "20260916");
- row.put("projectCode", "P");
- row.put("hours", 8d);
- Map<String, Object> 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<Object> 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<Object> 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<String, Object> 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<Object> 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<String, Object> fd = new HashMap<>();
- fd.put("fill-type", "补填");
- fd.put("sync", "审批中");
- fd.put("numberField_mmd1smem", 8d);
- DDR_New<Object> 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<Object> 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<String, Object> fd = new HashMap<>();
- fd.put("numberField_mmd1smem", 8d);
- DDR_New<Object> 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<Object> 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<String, Object> row = new HashMap<>();
- row.put("dateStr", "20260930");
- row.put("projectCode", "P");
- row.put("hours", 8d);
- Map<String, Object> 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<String, Object> reversal = reversalRecord();
- when(client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form)))
- .thenAnswer(invocation -> {
- YDParam param = invocation.getArgument(0);
- Map<String, Object> data = "original-summary".equals(param.getFormUuid())
- ? Collections.singletonMap("numberField_mmd1smem", 8d) : reversal;
- DDR_New<Object> 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<String, Object> normal = awaitReadyContext(service, request);
- assertEquals(1, ((List<?>) normal.get("originalRecords")).size());
- request.setRepairMode("REENTRY");
- Map<String, Object> 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<String, Object> 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<Object> 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<Object> 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<String, Object> row = new HashMap<>();
- row.put("dateStr", "20260930"); row.put("projectCode", "P"); row.put("hours", 8d);
- Map<String, Object> 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<String, Object> reversalRecord() {
- Map<String, Object> 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<String, Object> formData = new HashMap<>();
- if ("repair-summary".equals(param.getFormUuid())) formData.put("fill-type", "补填");
- Map<String, Object> record = new HashMap<>();
- record.put("formInstId", param.getFormUuid() + "-instance");
- record.put("formData", formData);
- record.put("instValue", "redundant payload");
- DDR_New<Object> response = new DDR_New<>();
- response.setData(Collections.singletonList(record));
- return response;
- });
- TimeCardRepairService service = repairContextService(client, closingService, conf);
- TimeCardRepairContextRequest request = repairContextRequest();
- Map<String, Object> first = service.context(request);
- assertEquals(false, first.get("ready"));
- assertEquals(2_000L, first.get("retryAfterMillis"));
- assertTrue(queriesStarted.await(2, TimeUnit.SECONDS));
- Map<String, Object> second = service.context(request);
- assertEquals(false, second.get("ready"));
- releaseQueries.countDown();
- Map<String, Object> 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<Object> 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<String, Object> first = awaitReadyContext(service, request);
- Map<String, Object> 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<Object> 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<String, Object> awaitReadyContext(TimeCardRepairService service,
- TimeCardRepairContextRequest request) throws InterruptedException {
- for (int attempt = 0; attempt < 200; attempt++) {
- Map<String, Object> 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<String, Object> formData = new HashMap<>();
- formData.put("numberField_mmd1smel", 8d);
- Map<String, Object> record = new HashMap<>();
- record.put("formData", formData);
- assertFalse(TimeCardRepairService.hasApprovedOrChangedHours(record));
- }
- @Test
- void shouldBlockRepairWhenApprovedAndChangedHoursRemain() {
- Map<String, Object> formData = new HashMap<>();
- formData.put("numberField_mmeakgi4", 8d);
- formData.put("numberField_mmeakgi5", -2d);
- assertTrue(TimeCardRepairService.hasApprovedOrChangedHours(formData));
- }
- @Test
- void shouldBlockRepairWhenChangeFullyOffsetsApprovedHours() {
- Map<String, Object> formData = new HashMap<>();
- formData.put("numberField_mmeakgi4", 8d);
- formData.put("numberField_mmeakgi5", -8d);
- assertTrue(TimeCardRepairService.hasApprovedOrChangedHours(formData));
- }
- @Test
- void shouldBlockRepairWhenOriginalApprovalStatusIsApproved() {
- Map<String, Object> 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<Object> noOriginal = new DDR_New<>();
- noOriginal.setData(Collections.emptyList());
- Map<String, Object> child = new HashMap<>();
- child.put("numberField_mmeakgii", 8d);
- Map<String, Object> 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<String, Object> record = new HashMap<>();
- record.put("formData", formData);
- DDR_New<Object> 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<Object> empty = new DDR_New<>();
- empty.setData(Collections.emptyList());
- Map<String, Object> stored = new HashMap<>();
- Map<String, Object> storedRecord = new HashMap<>();
- storedRecord.put("formInstId", "saved-repair");
- storedRecord.put("formData", stored);
- DDR_New<Object> 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<Object> 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<String, Object> child = new HashMap<>();
- child.put("numberField_mmczo635", 5d);
- Map<String, Object> 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<YDParam> captor = ArgumentCaptor.forClass(YDParam.class);
- verify(client).operateData(captor.capture(), eq(YDConf.FORM_OPERATION.upsert));
- Map<String, Object> condition = JSON.parseObject(captor.getValue().getSearchCondition(), Map.class);
- Map<String, Object> 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<String, Object> change = new HashMap<>();
- change.put("fill-type", "变更");
- change.put("sync-status", "审批中");
- Map<String, Object> record = new HashMap<>();
- record.put("formData", change);
- DDR_New<Object> 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<String, Object> otherRow = new HashMap<>();
- otherRow.put("numberField_mmeakgij", 8d);
- otherRow.put("numberField_mmeakgii", 0d);
- otherRow.put("selectField_mmeakgie", "培训");
- Map<String, Object> 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<String, Object> record = new HashMap<>();
- record.put("formData", formData);
- DDR_New<Object> summaries = new DDR_New<>();
- summaries.setData(Collections.singletonList(record));
- DDR_New<Object> 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));
- }
- }
|