diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java index 7393aa55..3870cef5 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java @@ -28,7 +28,9 @@ import org.springblade.transport.pojo.vo.LoadingCarrierContractVO; import org.springblade.transport.pojo.vo.LoadingManageVO; import org.springblade.transport.service.IContractManageService; import org.springblade.transport.service.ILoadingManageService; +import org.springblade.transport.service.IProcessConfigService; import org.springblade.transport.service.IReceivablePayableDetailService; +import org.springblade.transport.support.LiveProcessConfigSupport; import org.springblade.transport.support.TransportBusinessSupport; import org.springblade.transport.support.WaybillProcessSupport; import org.springblade.transport.wrapper.LoadingManageWrapper; @@ -43,8 +45,10 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -76,6 +80,9 @@ public class LoadingManageServiceImpl extends BaseServiceImpl selectLoadingManagePage(IPage page, LoadingManageVO loadingManage) { IPage entityPage = page(page, buildQuery(loadingManage)); @@ -163,7 +170,7 @@ public class LoadingManageServiceImpl extends BaseServiceImpl + * 推导按「接单事实已清、尚未接单」的口径取值,故清接单事实放在状态写入之后,两者口径一致。 + */ + private void writeWaybillsForDriver(LoadingManage loadingManage, List oldWaybillIds) { + syncAssociatedWaybills(loadingManage, + deriveWaybillBusinessStatuses(waybillIds(loadingManage.getWaybillIdsJson())), oldWaybillIds); + // 接单事实不清,被拒的票对新司机不可见——司机端一切入口都排除已拒绝的运单 + clearAssociatedWaybillAcceptRecords(loadingManage); + } + + /** + * 子运单业务状态逐张按各自项目的实时过程配置推导:需要接单 → 待执行,不需要 → 进行中。 + *

+ * 配置取法与运单读路径同一处:项目生效配置优先,取不到时才回退该运单的过程配置快照; + * 快照只作兜底、永不优先,否则项目改配置后写入值与读时的归一化结果会分叉。 + * 项目为空且快照也为空 → 视为不需要接单 → 进行中。 + */ + private Map deriveWaybillBusinessStatuses(List waybillIds) { + Map businessStatusByWaybillId = new LinkedHashMap<>(); + if (Func.isEmpty(waybillIds)) { + return businessStatusByWaybillId; + } + List waybills = waybillMapper.selectList(Wrappers.lambdaQuery() + .select(Waybill::getId, Waybill::getProjectId, Waybill::getProcessJson) + .eq(Waybill::getIsDeleted, 0) + .in(Waybill::getId, waybillIds)); + // 一次查全所有项目的生效配置,不逐张回查 + Map liveConfigByProject = LiveProcessConfigSupport.byProjects(processConfigService, + waybills.stream().map(Waybill::getProjectId).toList()); + for (Waybill waybill : waybills) { + String processJson = LiveProcessConfigSupport.resolve( + liveConfigByProject.get(waybill.getProjectId()), waybill.getProcessJson()); + // 以「待执行 + 待接单」为输入走读路径同一个归一化:需要接单留下待执行,不需要接单提升为进行中 + businessStatusByWaybillId.put(waybill.getId(), WaybillProcessSupport.normalizeBusinessStatus( + STATUS_PENDING, processJson, WaybillProcessSupport.ACCEPT_PENDING)); + } + return businessStatusByWaybillId; + } + private void syncAssociatedWaybills(LoadingManage loadingManage, String status, List oldWaybillIds) { + Map statusByWaybillId = new LinkedHashMap<>(); + for (Long waybillId : waybillIds(loadingManage.getWaybillIdsJson())) { + statusByWaybillId.put(waybillId, status); + } + syncAssociatedWaybills(loadingManage, statusByWaybillId, oldWaybillIds); + } + + /** + * 逐张写状态:同状态的票合成一条批量更新,配载字段对每张票都写全。 + */ + private void syncAssociatedWaybills(LoadingManage loadingManage, Map statusByWaybillId, List oldWaybillIds) { List newWaybillIds = waybillIds(loadingManage.getWaybillIdsJson()); if (Func.isNotEmpty(oldWaybillIds)) { List removedIds = new ArrayList<>(oldWaybillIds); @@ -829,8 +887,18 @@ public class LoadingManageServiceImpl extends BaseServiceImpl> waybillIdsByStatus = new LinkedHashMap<>(); + for (Long waybillId : newWaybillIds) { + waybillIdsByStatus + .computeIfAbsent(statusByWaybillId.getOrDefault(waybillId, STATUS_PENDING), key -> new ArrayList<>()) + .add(waybillId); + } + waybillIdsByStatus.forEach((status, ids) -> updateAssociatedWaybillFields(loadingManage, ids, status)); + } + + private void updateAssociatedWaybillFields(LoadingManage loadingManage, List waybillIds, String status) { waybillMapper.update(null, Wrappers.lambdaUpdate() - .in(Waybill::getId, newWaybillIds) + .in(Waybill::getId, waybillIds) .set(Waybill::getLoadingNo, loadingManage.getLoadingNo()) .set(Waybill::getBusinessStatus, status) .set(Waybill::getCarrierType, loadingManage.getCarrierType()) diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageChildStatusWriteTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageChildStatusWriteTest.java new file mode 100644 index 00000000..5b90e689 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageChildStatusWriteTest.java @@ -0,0 +1,466 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; +import org.mockito.MockedStatic; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.core.tool.jackson.JsonUtil; +import org.springblade.transport.mapper.WaybillMapper; +import org.springblade.transport.pojo.entity.LoadingManage; +import org.springblade.transport.pojo.entity.ProcessConfig; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.service.IContractManageService; +import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.IReceivablePayableDetailService; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 配载提交 / 重新派单写入子运单状态:状态逐张按各票自己项目的实时过程配置推导, + * 并把这批票的接单事实清成「待接单」,使新司机能看到它们。 + *

+ * 推导必须与运单读路径的状态归一化同口径(同一份实时配置、同一个归一化函数), + * 否则写完再读会漂移。 + */ +class LoadingManageChildStatusWriteTest { + + /** 当前登录组织,与夹具运单的 deptId 一致 */ + private static final String DEPT_ID = "1123598813738675201"; + private static final Long DEPT_ID_LONG = 1123598813738675201L; + private static final String LOADING_NO = "PZ-20261018-001"; + private static final Long LOADING_ID = 1800000000000000001L; + private static final String DRIVER_PHONE = "13800138000"; + + private static final Long NEED_ACCEPT_WAYBILL_ID = 3101L; + private static final Long NO_ACCEPT_WAYBILL_ID = 3102L; + private static final Long NEED_ACCEPT_PROJECT_ID = 2101L; + private static final Long NO_ACCEPT_PROJECT_ID = 2102L; + + /** 接单节点「是否确认=是」→ 需要司机接单确认 */ + private static final String NEED_ACCEPT_JSON = + "[{\"key\":\"accept\",\"name\":\"接单\",\"enabled\":true,\"confirmMode\":\"yes\"}]"; + /** 接单节点「无需确认接单」→ 不需要接单 */ + private static final String NO_ACCEPT_JSON = + "[{\"key\":\"accept\",\"name\":\"接单\",\"enabled\":true,\"confirmMode\":\"no_confirm_accept\"}]"; + + /** 配载写入给子运单落的全部配载字段,逐条都不能因为状态要分批写而漏掉 */ + private static final List SYNC_COLUMNS = List.of( + "loading_no", + "business_status", + "carrier_type", + "carrier_contract_id", + "carrier_name", + "driver_name", + "driver_phone", + "vehicle_no", + "trailer_vehicle_no", + "escort_name", + "escort_phone", + "mileage", + "estimated_start_time", + "estimated_end_time", + "task_remark", + "task_info_json", + "current_process_node"); + + private static final List ACCEPT_CLEAR_COLUMNS = List.of( + "driver_accept_status", + "driver_accept_time", + "driver_accept_driver_id", + "driver_reject_time", + "driver_reject_reason"); + + private WaybillMapper waybillMapper; + private IProcessConfigService processConfigService; + private IContractManageService contractManageService; + private IReceivablePayableDetailService receivablePayableDetailService; + private LoadingManageServiceImpl service; + private MockedStatic authUtil; + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaQueryWrapper / LambdaUpdateWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Waybill.class); + TableInfoHelper.initTableInfo(assistant, ProcessConfig.class); + } + + @BeforeEach + void setUp() { + waybillMapper = mock(WaybillMapper.class); + processConfigService = mock(IProcessConfigService.class); + contractManageService = mock(IContractManageService.class); + receivablePayableDetailService = mock(IReceivablePayableDetailService.class); + service = spy(new LoadingManageServiceImpl()); + inject(service, "waybillMapper", waybillMapper); + inject(service, "processConfigService", processConfigService); + inject(service, "contractManageService", contractManageService); + inject(service, "receivablePayableDetailService", receivablePayableDetailService); + doReturnTrue(service); + authUtil = mockStatic(AuthUtil.class); + authUtil.when(AuthUtil::getDeptId).thenReturn(DEPT_ID); + } + + @AfterEach + void tearDown() { + authUtil.close(); + } + + @Test + @DisplayName("不需接单的项目:提交时子单直接落进行中,不留待执行等读路径去提升") + void submitWritesRunningForProjectWithoutAcceptNode() { + stubWaybills(waybill(NO_ACCEPT_WAYBILL_ID, NO_ACCEPT_PROJECT_ID)); + stubConfigs(config(NO_ACCEPT_PROJECT_ID, NO_ACCEPT_JSON)); + + assertTrue(service.submit(loadingForm(NO_ACCEPT_WAYBILL_ID))); + + CapturedUpdate statusUpdate = statusSyncUpdates().get(0); + assertEquals("running", statusUpdate.setValue("business_status")); + assertEquals(List.of(NO_ACCEPT_WAYBILL_ID), statusUpdate.whereIds()); + assertEquals(SYNC_COLUMNS, statusUpdate.columns(), "配载字段一张不少地随状态一起写下去"); + } + + @Test + @DisplayName("需接单的项目:提交时子单落待执行,等新司机接单") + void submitWritesPendingForProjectWithAcceptNode() { + stubWaybills(waybill(NEED_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID)); + stubConfigs(config(NEED_ACCEPT_PROJECT_ID, NEED_ACCEPT_JSON)); + + assertTrue(service.submit(loadingForm(NEED_ACCEPT_WAYBILL_ID))); + + assertEquals("pending", statusSyncUpdates().get(0).setValue("business_status")); + } + + @Test + @DisplayName("跨项目运次:两张子单各自按自己项目推导,互不牵连,配置只查一次") + void submitDerivesEachWaybillFromItsOwnProject() { + stubWaybills( + waybill(NEED_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID), + waybill(NO_ACCEPT_WAYBILL_ID, NO_ACCEPT_PROJECT_ID)); + stubConfigs( + config(NEED_ACCEPT_PROJECT_ID, NEED_ACCEPT_JSON), + config(NO_ACCEPT_PROJECT_ID, NO_ACCEPT_JSON)); + + assertTrue(service.submit(loadingForm(NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID))); + + assertEquals(Map.of( + NEED_ACCEPT_WAYBILL_ID, "pending", + NO_ACCEPT_WAYBILL_ID, "running"), statusByWaybillId()); + verify(processConfigService, times(1)).list(ArgumentMatchers.>any()); + } + + @Test + @DisplayName("项目生效配置优先:项目不需要接单时,运单自己的历史快照说需要接单也不采信") + void liveConfigWinsOverWaybillSnapshot() { + stubWaybills(waybill(NEED_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID, NEED_ACCEPT_JSON)); + stubConfigs(config(NEED_ACCEPT_PROJECT_ID, NO_ACCEPT_JSON)); + + assertTrue(service.submit(loadingForm(NEED_ACCEPT_WAYBILL_ID))); + + assertEquals("running", statusSyncUpdates().get(0).setValue("business_status")); + } + + @Test + @DisplayName("没有项目也没有快照:视为不需要接单,写进行中") + void waybillWithoutProjectAndSnapshotWritesRunning() { + stubWaybills(waybill(NO_ACCEPT_WAYBILL_ID, null)); + stubConfigs(); + + assertTrue(service.submit(loadingForm(NO_ACCEPT_WAYBILL_ID))); + + assertEquals("running", statusSyncUpdates().get(0).setValue("business_status")); + } + + @Test + @DisplayName("项目为空的运单:配置取不到时按运单快照兜底,与读路径的归一化保持一致") + void waybillWithoutProjectFallsBackToSnapshot() { + stubWaybills(waybill(NEED_ACCEPT_WAYBILL_ID, null, NEED_ACCEPT_JSON)); + stubConfigs(); + + assertTrue(service.submit(loadingForm(NEED_ACCEPT_WAYBILL_ID))); + + assertEquals("pending", statusSyncUpdates().get(0).setValue("business_status")); + } + + @Test + @DisplayName("提交清除本次合进来子单的接单事实:置为待接单,接单/拒单痕迹一并清空") + void submitClearsAcceptRecordOfWaybillsTakenIn() { + stubWaybills( + waybill(NEED_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID), + waybill(NO_ACCEPT_WAYBILL_ID, NO_ACCEPT_PROJECT_ID)); + stubConfigs( + config(NEED_ACCEPT_PROJECT_ID, NEED_ACCEPT_JSON), + config(NO_ACCEPT_PROJECT_ID, NO_ACCEPT_JSON)); + + assertTrue(service.submit(loadingForm(NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID))); + + CapturedUpdate clearUpdate = acceptClearUpdates().get(0); + assertEquals(ACCEPT_CLEAR_COLUMNS, clearUpdate.columns()); + assertEquals("pending", clearUpdate.setValue("driver_accept_status")); + assertNull(clearUpdate.setValue("driver_accept_time")); + assertNull(clearUpdate.setValue("driver_accept_driver_id")); + assertNull(clearUpdate.setValue("driver_reject_time")); + assertNull(clearUpdate.setValue("driver_reject_reason")); + assertEquals( + Set.of(NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID), + Set.copyOf(clearUpdate.whereIds())); + } + + @Test + @DisplayName("重新派单:与提交同一套逐张推导,并同样清除接单事实;先写状态后清事实") + void reassignDerivesStatusAndClearsAcceptRecord() { + stubLoadingRecord(loadingRecord("pending", NEED_ACCEPT_WAYBILL_ID)); + stubWaybills(waybill(NEED_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID)); + stubConfigs(config(NEED_ACCEPT_PROJECT_ID, NEED_ACCEPT_JSON)); + stubRejectedWaybillExists(); + + LoadingManage form = editForm(LOADING_ID, NEED_ACCEPT_WAYBILL_ID); + assertTrue(service.reassign(form)); + + List updates = waybillUpdates(); + assertEquals("pending", updates.get(0).setValue("business_status")); + assertEquals(List.of(NEED_ACCEPT_WAYBILL_ID), updates.get(1).whereIds()); + assertEquals(ACCEPT_CLEAR_COLUMNS, updates.get(1).columns(), "清接单事实紧跟状态写入之后"); + } + + @Test + @DisplayName("编辑配载单移除运单:被移除的票照旧退回待执行,不参与本次的状态推导与清接单事实") + void removedWaybillIsReleasedWithoutDerivation() { + stubLoadingRecord(loadingRecord("pending", NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID)); + stubWaybills(waybill(NO_ACCEPT_WAYBILL_ID, NO_ACCEPT_PROJECT_ID)); + stubConfigs(config(NO_ACCEPT_PROJECT_ID, NO_ACCEPT_JSON)); + + assertTrue(service.submit(editForm(LOADING_ID, NO_ACCEPT_WAYBILL_ID))); + + assertEquals( + Map.of(NO_ACCEPT_WAYBILL_ID, "running"), + statusByWaybillId(), + "留下的票按自己项目推导,被移除的票不出现在状态写入里"); + CapturedUpdate releaseUpdate = waybillUpdates().stream() + .filter(update -> !update.columns().contains("driver_name")) + .findFirst() + .orElseThrow(); + assertEquals(List.of(NEED_ACCEPT_WAYBILL_ID), releaseUpdate.whereIds(), "被移除的票退回未配载"); + assertEquals("pending", releaseUpdate.setValue("business_status")); + assertEquals(List.of(NO_ACCEPT_WAYBILL_ID), acceptClearUpdates().get(0).whereIds()); + } + + @Test + @DisplayName("回归:开始配载仍整批置进行中,不按项目逐张推导") + void startStillWritesRunningForWholeBatch() { + stubLoadingRecord(loadingRecord("pending", NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID)); + + assertTrue(service.start(LOADING_ID)); + + CapturedUpdate statusUpdate = statusSyncUpdates().get(0); + assertEquals("running", statusUpdate.setValue("business_status")); + assertEquals( + Set.of(NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID), + Set.copyOf(statusUpdate.whereIds())); + verify(processConfigService, never()).list(ArgumentMatchers.>any()); + } + + @Test + @DisplayName("回归:完成配载仍整批置已完成,结算触发照旧") + void completeStillWritesCompletedForWholeBatch() { + stubLoadingRecord(loadingRecord("running", NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID)); + + assertTrue(service.complete(LOADING_ID)); + + CapturedUpdate statusUpdate = statusSyncUpdates().get(0); + assertEquals("completed", statusUpdate.setValue("business_status")); + assertEquals( + Set.of(NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID), + Set.copyOf(statusUpdate.whereIds())); + verify(receivablePayableDetailService).generateForCompletedLoading( + List.of(NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID), null, LOADING_NO); + verify(processConfigService, never()).list(ArgumentMatchers.>any()); + } + + // ------------------------------------------------------------ 夹具 + + private LoadingManage loadingForm(Long... waybillIds) { + return fillForm(new LoadingManage(), List.of(waybillIds)); + } + + private LoadingManage editForm(Long loadingId, Long... waybillIds) { + LoadingManage form = new LoadingManage(); + form.setId(loadingId); + return fillForm(form, List.of(waybillIds)); + } + + private LoadingManage fillForm(LoadingManage form, List ids) { + form.setLoadingNo(LOADING_NO); + form.setDeptId(DEPT_ID_LONG); + form.setDeptName("测试组织"); + form.setWaybillIdsJson(JsonUtil.toJson(ids)); + form.setLoadingSubNos(ids.stream().map(id -> "YD-" + id).reduce((left, right) -> left + "," + right).orElse("")); + form.setTransportType("公路运输"); + form.setCarrierType("自运"); + form.setVehicleNo("桂A12345"); + form.setDriverName("张三"); + form.setDriverPhone(DRIVER_PHONE); + form.setDepartureAddress("南宁"); + form.setArrivalAddress("广州"); + return form; + } + + private static LoadingManage loadingRecord(String businessStatus, Long... waybillIds) { + LoadingManage record = new LoadingManage(); + record.setId(LOADING_ID); + record.setLoadingNo(LOADING_NO); + record.setDeptId(DEPT_ID_LONG); + record.setDeptName("测试组织"); + record.setCarrierType("自运"); + record.setBusinessStatus(businessStatus); + record.setWaybillIdsJson(JsonUtil.toJson(List.of(waybillIds))); + return record; + } + + private void stubLoadingRecord(LoadingManage record) { + doReturn(record).when(service).getById(LOADING_ID); + } + + /** 重新派单只对「有被拒子单」的配载单开放 */ + private void stubRejectedWaybillExists() { + when(waybillMapper.selectCount(ArgumentMatchers.>any())).thenReturn(1L); + } + + private static Waybill waybill(Long id, Long projectId) { + return waybill(id, projectId, null); + } + + private static Waybill waybill(Long id, Long projectId, String processJson) { + Waybill waybill = new Waybill(); + waybill.setId(id); + waybill.setWaybillNo("YD-" + id); + waybill.setProjectId(projectId); + waybill.setProcessJson(processJson); + waybill.setDeptId(DEPT_ID_LONG); + waybill.setBusinessStatus("pending"); + return waybill; + } + + private void stubWaybills(Waybill... waybills) { + when(waybillMapper.selectList(ArgumentMatchers.>any())).thenReturn(List.of(waybills)); + } + + private void stubConfigs(ProcessConfig... configs) { + when(processConfigService.list(ArgumentMatchers.>any())).thenReturn(List.of(configs)); + } + + private static ProcessConfig config(Long projectId, String nodeConfigJson) { + ProcessConfig config = new ProcessConfig(); + config.setProjectIds(String.valueOf(projectId)); + config.setNodeConfigJson(nodeConfigJson); + return config; + } + + /** 提交路径上 mock 掉框架自带的落库与回查,只留业务写入可断言 */ + private static void doReturnTrue(LoadingManageServiceImpl target) { + doReturn(true).when(target).saveOrUpdate(any(LoadingManage.class)); + doReturn(true).when(target).updateById(any(LoadingManage.class)); + } + + /** 配载字段 + 状态那条批量更新;退回未配载的那条只写 loading_no/合同/状态,靠 driver_name 区分 */ + private List statusSyncUpdates() { + return waybillUpdates().stream().filter(update -> update.columns().contains("driver_name")).toList(); + } + + /** 状态写入的 where 只有运单 id,故一条更新对应一张票 */ + private Map statusByWaybillId() { + Map statusByWaybillId = new LinkedHashMap<>(); + statusSyncUpdates().forEach(update -> statusByWaybillId.put( + update.whereIds().get(0), String.valueOf(update.setValue("business_status")))); + return statusByWaybillId; + } + + private List acceptClearUpdates() { + return waybillUpdates().stream() + .filter(update -> update.columns().contains("driver_accept_status")) + .toList(); + } + + private List waybillUpdates() { + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(waybillMapper, atLeastOnce()).update(ArgumentMatchers.isNull(), captor.capture()); + return captor.getAllValues().stream().map(CapturedUpdate::of).toList(); + } + + private static void inject(LoadingManageServiceImpl target, String fieldName, Object value) { + try { + Field field = LoadingManageServiceImpl.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (ReflectiveOperationException exception) { + throw new IllegalStateException("注入测试替身失败:" + fieldName, exception); + } + } + + /** 一条被捕获的批量更新:SET 的列 → 值、WHERE 的运单 id */ + private record CapturedUpdate(String sqlSet, String whereSql, Map params, List columns) { + + @SuppressWarnings("unchecked") + static CapturedUpdate of(Wrapper wrapper) { + LambdaUpdateWrapper update = (LambdaUpdateWrapper) wrapper; + String sqlSet = update.getSqlSet(); + // SET 与 WHERE 的参数都是渲染时才进参数表,取值前先把两段都渲染一遍 + String whereSql = update.getTargetSql(); + List columns = Arrays.stream(sqlSet.split(",")) + .map(part -> part.substring(0, part.indexOf('=')).trim()) + .toList(); + return new CapturedUpdate(sqlSet, whereSql, update.getParamNameValuePairs(), columns); + } + + Object setValue(String column) { + for (String part : sqlSet.split(",")) { + int equalsIndex = part.indexOf('='); + if (!column.equals(part.substring(0, equalsIndex).trim())) { + continue; + } + String reference = part.substring(equalsIndex + 1).trim(); + if (!reference.startsWith("#{")) { + return reference; + } + String key = reference.substring(reference.lastIndexOf('.') + 1, reference.length() - 1); + return params.get(key); + } + return null; + } + + List whereIds() { + return params.values().stream().filter(Long.class::isInstance).map(Long.class::cast).toList(); + } + } +}