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.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.mockito.ArgumentCaptor; import org.springframework.test.util.ReflectionTestUtils; import java.time.YearMonth; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; 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.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class TimeCardChangeServiceTest { private static final String USER = "user-1"; private static final String DAY = "20260818"; private static final String NEXT_DAY = "20260819"; @Test void shouldSaveZeroTargetAsChangeDraftWithoutTouchingRepairType() { Fixture fixture = fixture(); when(fixture.client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form))) .thenReturn(records(record("formal-1")), records()); when(fixture.client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_id))) .thenReturn(detail(formalSummary(8d))); TimeCardRepairSummaryUpsertRequest request = new TimeCardRepairSummaryUpsertRequest(); request.setSubmitterUserId(USER); request.setDayText(DAY); request.setFormData(summaryWithBillableRows(billable("P-001", 0d, 0d, 0d, 0d))); assertTrue(fixture.service.upsert(request)); ArgumentCaptor captor = ArgumentCaptor.forClass(YDParam.class); verify(fixture.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); List rows = (List) written.get("tableField_mmczo634"); assertEquals("变更", condition.get("fill-type")); assertEquals("变更", written.get("fill-type")); assertEquals("暂存", written.get("sync-status")); assertEquals(0d, ((Number) rows.get(0).get("numberField_mmczo635")).doubleValue()); assertEquals(8d, ((Number) rows.get(0).get("numberField_mmczo637")).doubleValue()); assertEquals(0d, ((Number) rows.get(0).get("numberField_mmd80zae")).doubleValue()); } @Test void shouldRejectChangeWhenFormalDailyApprovedHoursAreNotPositive() { Fixture fixture = fixture(); when(fixture.client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form))) .thenReturn(records(record("formal-1"))); when(fixture.client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_id))) .thenReturn(detail(formalSummary(0d))); TimeCardRepairSummaryUpsertRequest request = new TimeCardRepairSummaryUpsertRequest(); request.setSubmitterUserId(USER); request.setDayText(DAY); request.setFormData(summaryWithBillableRows(billable("P-001", 6d, 0d, 0d, 0d))); McException error = assertThrows(McException.class, () -> fixture.service.upsert(request)); assertEquals("目标日期已审批工时(日)必须大于0,不能发起工时变更", error.getMessage()); verify(fixture.client, never()).operateData(any(YDParam.class), any(YDConf.FORM_OPERATION.class)); } @Test void shouldUseLatestApprovedChangeAsBaselineForRepeatedChange() { Fixture fixture = fixture(); Map previousChange = summaryWithBillableRows( billable("P-001", 6d, 6d, 6d, -2d)); previousChange.put("sync-status", "全部成功"); when(fixture.client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form))) .thenReturn(records(record("formal-1")), records(record("change-1"))); when(fixture.client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_id))) .thenReturn(detail(formalSummary(8d)), detail(previousChange)); TimeCardRepairSummaryUpsertRequest request = new TimeCardRepairSummaryUpsertRequest(); request.setSubmitterUserId(USER); request.setDayText(DAY); request.setFormData(summaryWithBillableRows(billable("P-001", 5d, 0d, 0d, 0d))); assertTrue(fixture.service.upsert(request)); ArgumentCaptor captor = ArgumentCaptor.forClass(YDParam.class); verify(fixture.client).operateData(captor.capture(), eq(YDConf.FORM_OPERATION.upsert)); Map written = JSON.parseObject(captor.getValue().getFormDataJson(), Map.class); List rows = (List) written.get("tableField_mmczo634"); assertEquals(5d, ((Number) rows.get(0).get("numberField_mmczo635")).doubleValue()); assertEquals(6d, ((Number) rows.get(0).get("numberField_mmczo637")).doubleValue()); assertEquals(0d, ((Number) rows.get(0).get("numberField_mmd80zae")).doubleValue()); } @Test void shouldSubmitOnlyChangedRowsAndKeepZeroTarget() { Fixture fixture = fixture(); Map baseline = summaryWithBillableRows( billable("P-001", 8d, 8d, 8d, 0d), billable("P-002", 4d, 4d, 4d, 0d)); baseline.put("sync-status", "暂存"); Map submitted = summaryWithBillableRows( billable("P-001", 0d, 0d, 8d, -8d), billable("P-002", 4d, 4d, 4d, 0d)); submitted.put("sync-status", "暂存"); when(fixture.client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form))) .thenReturn(records(record("formal-1")), records(record("change-1")), records(record("formal-1")), records(record("change-1"))); when(fixture.client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_id))) .thenReturn(detail(formalSummary(8d)), detail(baseline), detail(submitted), detail(formalSummary(8d)), detail(submitted)); Map group = new LinkedHashMap<>(); group.put("managerId", "manager-1"); group.put("billableRows", Arrays.asList( changeRow("P-001", 0d), changeRow("P-002", 4d))); group.put("nonBillableRows", Collections.emptyList()); Map groups = new LinkedHashMap<>(); groups.put("manager-1", group); TimeCardRepairSubmitRequest request = new TimeCardRepairSubmitRequest(); request.setSubmitterUserId(USER); request.setSubmitterName("Test User"); request.setSourceMonthText("202608"); request.setProjectGroups(groups); request.setOtherGroup(Collections.emptyMap()); Map result = fixture.service.submit(request); assertEquals("审批中", result.get("status")); ArgumentCaptor approvalCaptor = ArgumentCaptor.forClass(TimeCardApprovalStartRequest.class); verify(fixture.approvalService).startApproval(approvalCaptor.capture()); Map approval = JSON.parseObject( approvalCaptor.getValue().getFormDataJson(), Map.class); List approvalRows = (List) approval.get("tableField_mmae8t99"); assertEquals("变更", approval.get("approval-fill-type")); assertEquals(1, approvalRows.size()); assertEquals("P-001", approvalRows.get(0).get("textField_mmacxew3")); assertEquals(0d, ((Number) approvalRows.get(0).get("numberField_mmacxew9")).doubleValue()); } @Test void shouldSubmitOppositeDayChangesWhenNetDeltaIsZero() { Fixture fixture = fixture(); Map firstBaseline = summaryWithBillableRows( billable("P-001", 0d, 8d, 8d, 0d)); firstBaseline.put("sync-status", "暂存"); Map firstSubmitted = summaryWithBillableRows( billable("P-001", 0d, 0d, 8d, -8d)); firstSubmitted.put("sync-status", "审批中"); Map secondBaseline = summaryWithBillableRows( billable("P-001", 8d, 8d, 8d, 0d), billable("P-002", 0d, 0d, 0d, 0d)); secondBaseline.put("sync-status", "暂存"); Map secondSubmitted = summaryWithBillableRows( billable("P-001", 8d, 8d, 8d, 0d), billable("P-002", 8d, 8d, 0d, 8d)); secondSubmitted.put("sync-status", "审批中"); when(fixture.client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form))) .thenReturn(records(record("formal-1")), records(record("change-1")), records(record("formal-2")), records(record("change-2"))); when(fixture.client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_id))) .thenReturn(detail(formalSummary(8d)), detail(firstBaseline), detail(firstSubmitted), detail(formalSummary(8d)), detail(secondBaseline), detail(secondSubmitted)); Map group = new LinkedHashMap<>(); group.put("managerId", "manager-1"); group.put("billableRows", Arrays.asList( changeRow(DAY, "P-001", 0d), changeRow(NEXT_DAY, "P-002", 8d))); group.put("nonBillableRows", Collections.emptyList()); Map groups = new LinkedHashMap<>(); groups.put("manager-1", group); TimeCardRepairSubmitRequest request = new TimeCardRepairSubmitRequest(); request.setSubmitterUserId(USER); request.setSubmitterName("Test User"); request.setSourceMonthText("202608"); request.setProjectGroups(groups); request.setOtherGroup(Collections.emptyMap()); Map result = fixture.service.submit(request); assertEquals("审批中", result.get("status")); ArgumentCaptor approvalCaptor = ArgumentCaptor.forClass(TimeCardApprovalStartRequest.class); verify(fixture.approvalService).startApproval(approvalCaptor.capture()); Map approval = JSON.parseObject( approvalCaptor.getValue().getFormDataJson(), Map.class); List approvalRows = (List) approval.get("tableField_mmae8t99"); assertEquals(2, approvalRows.size()); assertEquals(0d, ((Number) approvalRows.get(0).get("numberField_mmacxew9")).doubleValue()); assertEquals(8d, ((Number) approvalRows.get(1).get("numberField_mmacxew9")).doubleValue()); } @Test void shouldIgnoreRepairRowsDuringChangeResubmitScan() { Fixture fixture = fixture(); Map repair = new LinkedHashMap<>(); repair.put("fill-type", "补填"); repair.put("sync-status", "审批中"); when(fixture.client.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_form))) .thenReturn(records(Collections.singletonMap("formData", repair))); TimeCardChangeService.ChangeResubmitResult result = fixture.service.resubmitRecentlyModifiedPending(7); assertEquals(0, result.getStarted()); assertEquals(0, result.getFailed()); verify(fixture.approvalService, never()).startApproval(any(TimeCardApprovalStartRequest.class)); } private static Fixture fixture() { YDClient client = mock(YDClient.class); MonthClosingService closingService = mock(MonthClosingService.class); ReSubmitApprovalService approvalService = mock(ReSubmitApprovalService.class); when(closingService.status(any(MonthClosingCheckRequest.class))) .thenReturn(MonthClosingStatusResult.builder() .monthText(currentMonth()).closed(false).runningClosingCount(0).build()); WHConf conf = new WHConf(); conf.setYidaAppType("APP"); conf.setYidaSystemToken("TOKEN"); conf.setFormUuidWorkHoursSummary("formal-summary"); conf.setFormUuidRepairWorkHoursSummary("shared-summary"); conf.setRepairSummaryFillTypeField("fill-type"); conf.setRepairSummarySyncStatusField("sync-status"); conf.setRepairSummarySubmitDateField("submit-date"); conf.setRepairSummaryBatchField("batch"); conf.setRepairSummarySourceMonthField("source-month"); conf.setApprovalFillTypeField("approval-fill-type"); conf.setFormUuidApproval("project-approval"); conf.setApprovalProcessCode("project-process"); conf.setApprovalRepairSubmitDateField("approval-submit-date"); conf.setApprovalRepairBatchField("approval-batch"); conf.setApprovalRepairSourceMonthField("approval-source-month"); conf.setFormUuidOtherApproval("other-approval"); conf.setOtherApprovalProcessCode("other-process"); conf.setOtherApprovalRepairSubmitDateField("other-submit-date"); conf.setOtherApprovalRepairBatchField("other-batch"); conf.setOtherApprovalRepairSourceMonthField("other-source-month"); TimeCardChangeService service = new TimeCardChangeService(); ReflectionTestUtils.setField(service, "ydClient", client); ReflectionTestUtils.setField(service, "whConf", conf); ReflectionTestUtils.setField(service, "monthClosingService", closingService); ReflectionTestUtils.setField(service, "reSubmitApprovalService", approvalService); return new Fixture(service, client, approvalService); } private static String currentMonth() { return YearMonth.now(ZoneId.of("Asia/Shanghai")) .format(DateTimeFormatter.ofPattern("yyyyMM")); } private static Map formalSummary(double dailyApproved) { Map result = summaryWithBillableRows( billable("P-001", 0d, 8d, 8d, 0d), billable("P-002", 0d, 4d, 4d, 0d)); result.put("numberField_mmeakgi4", dailyApproved); return result; } private static Map summaryWithBillableRows(Map... rows) { Map result = new LinkedHashMap<>(); result.put("tableField_mmczo634", Arrays.asList(rows)); result.put("tableField_mmczo63h", Collections.emptyList()); result.put("tableField_mmeakgid", Collections.emptyList()); return result; } private static Map billable(String projectCode, double draft, double submitted, double approved, double change) { Map row = new LinkedHashMap<>(); row.put("textField_mmacxew3", projectCode); row.put("textField_mmad6jcj", "Project " + projectCode); row.put("numberField_mmczo635", draft); row.put("numberField_mmczo636", submitted); row.put("numberField_mmczo637", approved); row.put("numberField_mmd80zae", change); return row; } private static Map changeRow(String projectCode, double hours) { return changeRow(DAY, projectCode, hours); } private static Map changeRow(String day, String projectCode, double hours) { Map row = new LinkedHashMap<>(); row.put("dateStr", day); row.put("projectCode", projectCode); row.put("projectName", "Project " + projectCode); row.put("hours", hours); return row; } private static Map record(String id) { return Collections.singletonMap("formInstId", id); } private static DDR_New records(Map... items) { DDR_New result = new DDR_New<>(); result.setData(Arrays.asList(items)); return result; } private static DDR_New detail(Map data) { DDR_New result = new DDR_New<>(); result.setFormData(data); return result; } private static final class Fixture { private final TimeCardChangeService service; private final YDClient client; private final ReSubmitApprovalService approvalService; private Fixture(TimeCardChangeService service, YDClient client, ReSubmitApprovalService approvalService) { this.service = service; this.client = client; this.approvalService = approvalService; } } }