diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeBadgesVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeBadgesVO.java index d37bd5bd..bb6081fb 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeBadgesVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeBadgesVO.java @@ -44,4 +44,7 @@ public class AdminHomeBadgesVO implements Serializable { @Schema(description = "风险记录待办数(disposalStatus=pending)") private long risk; + @Schema(description = "重新派单待办数(司机已拒单且运单未取消未完成)") + private long pendingReassign; + } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeVO.java index 2302825d..25c34e4a 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeVO.java @@ -49,7 +49,7 @@ public class AdminHomeVO implements Serializable { @Schema(description = "快捷入口角标") private AdminHomeBadgesVO badges = new AdminHomeBadgesVO(); - @Schema(description = "待处理事项(异常处置状态≠已完成)") + @Schema(description = "待处理事项(异常处置状态≠已完成,或司机已拒单待重新派单)") private List feed = new ArrayList<>(); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java index 119cf5b9..6d381639 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java @@ -72,7 +72,7 @@ public class ManageWaybillController extends BladeController { @GetMapping("/home") @ApiOperationSupport(order = 2) - @Operation(summary = "首页聚合", description = "统计 + 异常/风险角标 + 待处理事项(异常处置≠已完成)+ 当前用户名") + @Operation(summary = "首页聚合", description = "统计 + 异常/风险/重新派单角标 + 待处理事项(异常处置≠已完成,或司机已拒单待重新派单)+ 当前用户名") public R home() { return R.data(manageWaybillService.home()); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java index 33a84710..742cb529 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java @@ -262,7 +262,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { throw new ServiceException("运单ID不能为空"); } Driver driver = requireCurrentDriver(); - Waybill waybill = loadDriverWaybill(dto.getWaybillId(), currentBoundPlates(driver)); + // 在途打卡单独保留原取单判据,不排除已拒单(见 scopedQuery 的重载说明) + Waybill waybill = loadDriverWaybill(dto.getWaybillId(), currentBoundPlates(driver), true); waybill = normalizeAcceptStatus(waybill); String processJson = resolveProcessJson(waybill); @@ -483,6 +484,10 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { if (WaybillProcessSupport.isAccepted(waybill.getDriverAcceptStatus())) { throw new ServiceException("该运单已接单"); } + // 拒单后运单已对本人脱离,不给自己接回来的入口;要改主意只能由调度重新派单 + if (WaybillProcessSupport.isRejected(waybill.getDriverAcceptStatus())) { + throw new ServiceException("该运单已拒单,请联系调度重新派单"); + } } private void assertRejectable(Waybill waybill) { @@ -498,13 +503,19 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } private Waybill loadDriverWaybill(Long id, List plates) { + return loadDriverWaybill(id, plates, false); + } + + /** @param includeRejected 仅 submitEnroute 传 true,见 scopedQuery 的重载说明 */ + private Waybill loadDriverWaybill(Long id, List plates, boolean includeRejected) { if (id == null) { throw new ServiceException("运单ID不能为空"); } if (plates.isEmpty()) { throw new ServiceException("当前司机未绑定车辆"); } - Waybill waybill = waybillService.getOne(scopedQuery(plates).eq(Waybill::getId, id).last("LIMIT 1")); + Waybill waybill = waybillService.getOne(scopedQuery(plates, includeRejected) + .eq(Waybill::getId, id).last("LIMIT 1")); if (waybill == null) { throw new ServiceException("运单不存在或无权操作"); } @@ -560,11 +571,23 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } /** - * 司机可见运单范围:运单车牌(主车/挂车)落在当前司机绑定车牌内。 - * 绑定来源:blade_transport_driver.driving_vehicle + * 司机可见运单范围:运单车牌(主车/挂车)落在当前司机绑定车牌内, + * 且该运单未被这个司机拒单。绑定来源:blade_transport_driver.driving_vehicle + *

+ * 拒单后运单对司机彻底脱离——列表、计数、详情、打卡、接单一律不可达,等管理员重新派单。 + * 运单上的司机姓名/手机/车牌不清空,调度重新派单时要知道原来派给谁。 */ private LambdaQueryWrapper scopedQuery(List plates) { - return Wrappers.lambdaQuery().and(w -> { + return scopedQuery(plates, false); + } + + /** + * @param includeRejected true 时不排除已拒单的运单,仅限在途打卡使用: + * 拒单的运单不可能处于运输中,收口拦不到合法操作, + * 而保留原判据可避免「已拒单 + 进行中」的历史脏数据被误伤 + */ + private LambdaQueryWrapper scopedQuery(List plates, boolean includeRejected) { + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery().and(w -> { w.in(Waybill::getVehicleNo, plates) .or().in(Waybill::getTrailerVehicleNo, plates); for (String plate : plates) { @@ -578,6 +601,11 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { ); } }); + if (!includeRejected) { + wrapper.and(w -> w.isNull(Waybill::getDriverAcceptStatus) + .or().ne(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED)); + } + return wrapper; } private void applyKeyword(LambdaQueryWrapper wrapper, String keyword) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java index 5854689f..1f4868f2 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java @@ -57,6 +57,7 @@ import java.time.Duration; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.HashSet; @@ -85,6 +86,13 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { private static final String DISPOSAL_PROCESSING = "processing"; private static final String RISK_PENDING = "pending"; + /** 小程序首页待处理事项的类型:异常待处置 */ + private static final String TODO_EXCEPTION = "exception"; + /** 小程序首页待处理事项的类型:重新派单待处理 */ + private static final String TODO_REASSIGN = "reassign"; + /** 重派条目跳转页码:参数 id 是运单主键(不是运单号),该页据此回显运单;buyerPaid 恒为 0,与列表卡片一致 */ + private static final String REASSIGN_TARGET_URL = "/pages/waybill/reassign?id=%s&buyerPaid=0"; + private static final String EXCEPTION_YES = "exception"; private static final String EXCEPTION_NO = "normal"; private static final String TRANSPORT_COMMON = "common"; @@ -122,8 +130,9 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { AdminHomeBadgesVO badges = new AdminHomeBadgesVO(); badges.setException(home.getStats().getException()); badges.setRisk(countPendingRisks()); + badges.setPendingReassign(countPendingReassignments()); home.setBadges(badges); - home.setFeed(buildExceptionFeed()); + home.setFeed(buildAdminFeed()); return home; } @@ -561,18 +570,66 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { .eq(RiskDisposal::getDisposalStatus, RISK_PENDING)); } - private List buildExceptionFeed() { + /** 待重新派单:司机已拒单,且运单尚未取消/完成 */ + private long countPendingReassignments() { + return waybillService.count(pendingReassignQuery()); + } + + private LambdaQueryWrapper pendingReassignQuery() { + return Wrappers.lambdaQuery() + .eq(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED) + .in(Waybill::getBusinessStatus, STATUS_PENDING, STATUS_RUNNING); + } + + /** + * 首页待处理事项:异常待处置 + 重新派单待处理。 + * 两类各按自己的业务发生时间(上报时刻 / 拒单时刻)倒序,合并后再整体倒序取前 N 条。 + * 排序时间随条目一起携带,不塞进 id 字段——id 在两型下各自是处置主键与运单主键。 + */ + List buildAdminFeed() { + List merged = new ArrayList<>(); + merged.addAll(buildExceptionFeed()); + merged.addAll(buildReassignFeed()); + merged.sort(java.util.Comparator.comparing( + (TimedTodo entry) -> entry.time, java.util.Comparator.nullsFirst(java.util.Comparator.naturalOrder()))); + java.util.Collections.reverse(merged); + int to = Math.min(merged.size(), FEED_LIMIT); + return merged.subList(0, to).stream().map(TimedTodo::item).collect(Collectors.toList()); + } + + /** 待处理事项 + 它的业务发生时间(仅用于排序) */ + private record TimedTodo(AdminTodoItemVO item, LocalDateTime time) { + } + + private List buildExceptionFeed() { List list = exceptionDisposalService.list(Wrappers.lambdaQuery() .in(ExceptionDisposal::getDisposalStatus, DISPOSAL_PENDING, DISPOSAL_PROCESSING) .orderByDesc(ExceptionDisposal::getReportTime) .last("LIMIT " + FEED_LIMIT)); - return list.stream().map(this::toTodoItem).collect(Collectors.toList()); + return list.stream() + .map(disposal -> new TimedTodo(toTodoItem(disposal), exceptionTimeOf(disposal))) + .collect(Collectors.toList()); + } + + private List buildReassignFeed() { + List list = waybillService.list(pendingReassignQuery() + .orderByDesc(Waybill::getDriverRejectTime) + .last("LIMIT " + FEED_LIMIT)); + return list.stream() + .map(waybill -> new TimedTodo(toReassignTodoItem(waybill), rejectTimeOf(waybill))) + .collect(Collectors.toList()); + } + + private LocalDateTime exceptionTimeOf(ExceptionDisposal disposal) { + return disposal.getReportTime() != null + ? disposal.getReportTime() + : toLocalDateTime(disposal.getCreateTime()); } private AdminTodoItemVO toTodoItem(ExceptionDisposal disposal) { AdminTodoItemVO item = new AdminTodoItemVO(); item.setId(disposal.getId()); - item.setType("exception"); + item.setType(TODO_EXCEPTION); item.setTitle("异常待处置"); item.setTimeAgo(formatTimeAgo(disposal.getReportTime() != null ? disposal.getReportTime() @@ -585,6 +642,38 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { return item; } + private AdminTodoItemVO toReassignTodoItem(Waybill waybill) { + AdminTodoItemVO item = new AdminTodoItemVO(); + item.setId(waybill.getId()); + item.setType(TODO_REASSIGN); + item.setTitle("重新派单待处理"); + item.setTimeAgo(formatTimeAgo(rejectTimeOf(waybill))); + item.setDesc(buildReassignDesc(waybill)); + item.setWaybillNo(Func.toStr(waybill.getWaybillNo(), "")); + item.setActionLabel("重新派单"); + item.setTargetUrl(String.format(REASSIGN_TARGET_URL, waybill.getId())); + return item; + } + + /** 拒单时刻是「被拒了多久」的唯一依据;为空(历史数据)才回退到最后修改时间 */ + private LocalDateTime rejectTimeOf(Waybill waybill) { + if (waybill.getDriverRejectTime() != null) { + return toLocalDateTime(waybill.getDriverRejectTime()); + } + return toLocalDateTime(waybill.getUpdateTime()); + } + + /** 拒单原因的输入是可选的,没填时正文不能留下空的「:」 */ + private String buildReassignDesc(Waybill waybill) { + String driver = Func.toStr(waybill.getDriverName(), "原司机"); + String text = "运单已指派" + driver + ",被拒绝,请重新派单"; + String reason = Func.toStr(waybill.getDriverRejectReason(), "").trim(); + if (Func.isNotBlank(reason)) { + text = text + ":" + reason; + } + return text.length() > 80 ? text.substring(0, 80) + "…" : text; + } + private String buildExceptionDesc(ExceptionDisposal disposal) { String reporter = Func.toStr(disposal.getReporterName(), "司机"); String type = Func.toStr(disposal.getExceptionType(), "异常"); @@ -606,6 +695,7 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { return Func.toStr(AuthUtil.getUserName(), ""); } + /** 相对时间文案;时间取不到时留空,避免把「现在」当成发生时刻显示成「刚刚」 */ private String formatTimeAgo(LocalDateTime time) { if (time == null) { return ""; diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java new file mode 100644 index 00000000..096a837c --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java @@ -0,0 +1,230 @@ +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.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +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.log.exception.ServiceException; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.mapper.WaybillEnroutePunchMapper; +import org.springblade.transport.mapper.WaybillNodePunchMapper; +import org.springblade.transport.pojo.dto.EnrouteSubmitDTO; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.vo.DriverWaybillCardVO; +import org.springblade.transport.pojo.vo.DriverWaybillTabCountsVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.CertificationGate; + +import java.util.List; +import java.util.function.Supplier; + +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.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 司机拒单后运单对本人脱离。 + *

+ * 拒单不再只是一个标记:该运单从列表、计数、详情到接单全部不可达,等管理员重新派单。 + *

+ * 拦截落在司机端共用的取单条件上,而单元测试里 {@code waybillService} 是 mock、不会真的执行 + * 这个条件,所以「已拒单被排除」只能断言条件本身;过滤之后的行为则用「已过滤的输入」来验, + * 不假装 mock 会替我们过滤。 + */ +class DriverWaybillRejectDetachTest { + + private static final Long USER_ID = 7401L; + private static final Long DRIVER_ID = 9001L; + private static final String PLATE = "沪AE31312"; + private static final Long REJECTED_ID = 7201L; + + private IDriverService driverService; + private CertificationGate certificationGate; + private IWaybillService waybillService; + private DriverWaybillServiceImpl service; + + @BeforeAll + static void initTableInfo() { + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Waybill.class); + } + + @BeforeEach + void setUp() { + driverService = mock(IDriverService.class); + waybillService = mock(IWaybillService.class); + certificationGate = mock(CertificationGate.class); + lenient().when(certificationGate.isDriverAvailable(any())).thenReturn(true); + service = new DriverWaybillServiceImpl( + waybillService, + driverService, + mock(IProcessConfigService.class), + mock(ITransportVehicleService.class), + certificationGate, + mock(WaybillEnroutePunchMapper.class), + mock(WaybillNodePunchMapper.class), + mock(ExceptionDisposalMapper.class)); + lenient().when(driverService.getOne(any())).thenReturn(driver()); + lenient().when(waybillService.updateById(any())).thenReturn(true); + // syncDriverAcceptState 是 IWaybillService 的方法,mock 默认返 null 会让调用方 NPE; + // 真实实现是把入参校正后返回同一个对象 + lenient().when(waybillService.syncDriverAcceptState(any())) + .thenAnswer(invocation -> invocation.getArgument(0)); + } + + @Test + @DisplayName("首页待接预览的取单条件排除已拒单,且排除子句与车牌条件并列") + void pendingPreviewQueryExcludesRejected() { + when(waybillService.list(ArgumentMatchers.>any())).thenReturn(List.of()); + + runAsDriver(() -> service.pendingPreview(5)); + + String sql = capturedListQuery().getTargetSql(); + assertTrue(sql.contains("driver_accept_status"), + "取单条件必须排除已拒单:" + sql); + assertTrue(sql.toUpperCase().contains("AND"), + "排除子句不能与车牌条件「或」在一起,否则等于没排除:" + sql); + } + + @Test + @DisplayName("运单列表的取单条件同样排除已拒单") + void pageQueryExcludesRejected() { + when(waybillService.list(ArgumentMatchers.>any())).thenReturn(List.of()); + + runAsDriver(() -> service.page(1, 10, 0, null)); + + String sql = capturedListQuery().getTargetSql(); + assertTrue(sql.contains("driver_accept_status"), "列表也要排除已拒单:" + sql); + } + + @Test + @DisplayName("Tab 计数不再把已拒单算进待接单") + void tabCountsOnFilteredInputCountsOnlyUnrejected() { + // 查询层已排除拒单后落回来的输入:一张待接、一张运输中、一张已完成 + when(waybillService.list(ArgumentMatchers.>any())).thenReturn(List.of( + waybill(7202L, "YD-20261001-0002", "pending", "pending"), + waybill(7203L, "YD-20261001-0003", "running", "accepted"), + waybill(7204L, "YD-20261001-0004", "completed", "accepted"))); + + DriverWaybillTabCountsVO counts = runAsDriver(() -> service.tabCounts()); + + assertEquals(1, counts.getPending(), "待接单只算那张未被拒的"); + assertEquals(1, counts.getDoing()); + assertEquals(1, counts.getDone()); + assertEquals(3, counts.getAll(), "全部 = 待接 + 进行中 + 已完成"); + } + + @Test + @DisplayName("未拒单的运单不被误伤:待接与运输中照常出现") + void unrejectedWaybillsUnaffected() { + when(waybillService.list(ArgumentMatchers.>any())).thenReturn(List.of( + waybill(7202L, "YD-20261001-0002", "pending", "pending"), + waybill(7203L, "YD-20261001-0003", "running", "accepted"))); + + IPage page = runAsDriver(() -> service.page(1, 10, null, null)); + + assertEquals(2, page.getTotal(), "未被拒的运单条数不变"); + assertEquals(2, page.getRecords().size()); + } + + @Test + @DisplayName("已拒单运单的详情不可达:取单条件已把它挡住") + void detailOfRejectedIsUnreachable() { + // 被取单条件排除后 getOne 落回 null,与「不属于这个司机」同一条报错 + when(waybillService.getOne(any())).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runAsDriver(() -> service.detail(REJECTED_ID))); + + assertEquals("运单不存在或无权操作", exception.getMessage()); + } + + @Test + @DisplayName("已拒单运单不能自行接回来") + void acceptOfRejectedIsRejected() { + when(waybillService.getOne(any())).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runAsDriver(() -> service.accept(REJECTED_ID))); + + assertEquals("运单不存在或无权操作", exception.getMessage()); + } + + @Test + @DisplayName("在途打卡保留原取单判据,不排除已拒单的历史脏数据") + void enrouteSubmitKeepsUnfilteredScope() { + EnrouteSubmitDTO dto = new EnrouteSubmitDTO(); + dto.setWaybillId(REJECTED_ID); + when(waybillService.getOne(any())).thenReturn(rejectedWaybill()); + + // 断言的是「取单这一步没有被拒单条件拦住」:若被拦住会抛「运单不存在或无权操作」, + // 而后面因过程配置为空会走「该运单未启用在途打卡」,两者可区分 + ServiceException exception = assertThrows(ServiceException.class, + () -> runAsDriver(() -> service.submitEnroute(dto))); + + assertEquals("该运单未启用在途打卡", exception.getMessage(), + "在途打卡不应因运单已拒单而在取单阶段被挡下"); + verify(waybillService, org.mockito.Mockito.atLeastOnce()).getOne(any()); + } + + // ------------------------------------------------------------ 夹具 + + private T runAsDriver(Supplier action) { + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + return action.get(); + } + } + + private LambdaQueryWrapper capturedListQuery() { + ArgumentCaptor> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(waybillService).list(captor.capture()); + return captor.getValue(); + } + + private Driver driver() { + Driver driver = new Driver(); + driver.setId(DRIVER_ID); + driver.setUserId(USER_ID); + driver.setDriverName("黄大富"); + driver.setDrivingVehicle(PLATE); + return driver; + } + + private Waybill rejectedWaybill() { + return waybill(REJECTED_ID, "YD-20261010-0001", "pending", "rejected"); + } + + private Waybill waybill(Long id, String no, String businessStatus, String acceptStatus) { + Waybill waybill = new Waybill(); + waybill.setId(id); + waybill.setWaybillNo(no); + waybill.setVehicleNo(PLATE); + waybill.setBusinessStatus(businessStatus); + waybill.setDriverAcceptStatus(acceptStatus); + waybill.setIsDeleted(0); + return waybill; + } +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageAdminHomeFeedTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageAdminHomeFeedTest.java new file mode 100644 index 00000000..1d27f177 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageAdminHomeFeedTest.java @@ -0,0 +1,198 @@ +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.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +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.springblade.transport.pojo.entity.ExceptionDisposal; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.vo.AdminTodoItemVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IDriverWaybillService; +import org.springblade.transport.service.IExceptionDisposalService; +import org.springblade.transport.service.IRiskDisposalService; +import org.springblade.transport.service.IWaybillService; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 调度端首页待处理事项:异常待处置 + 重新派单待处理。 + *

+ * 拒单条目的判据是「司机已拒单且运单未取消未完成」,必须与待处理运单列表的 needReassign + * 口径一致,否则两个页面的条数对不上;卡片上「多久之前」取拒单时刻,不取最后修改时间 + * (后者会被任何无关改动刷新)。 + *

+ * 这里直接验归并方法本身,不走 {@code home()}:那个入口会解析当前用户名、依赖 Spring 缓存, + * 在单测里没有上下文。 + */ +class ManageAdminHomeFeedTest { + + private static final Long EXCEPTION_ID = 8801L; + private static final String REJECTED_WAYBILL_NO = "YD-20261010-0001"; + + private ManageWaybillServiceImpl service; + private IWaybillService waybillService; + private IExceptionDisposalService exceptionService; + + @BeforeAll + static void initTableInfo() { + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Waybill.class); + TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class); + } + + @BeforeEach + void setUp() { + waybillService = mock(IWaybillService.class); + exceptionService = mock(IExceptionDisposalService.class); + service = new ManageWaybillServiceImpl( + waybillService, + mock(IDriverService.class), + exceptionService, + mock(IRiskDisposalService.class), + mock(IDriverWaybillService.class)); + when(exceptionService.list(ArgumentMatchers.>any())) + .thenReturn(new ArrayList<>()); + when(waybillService.list(ArgumentMatchers.>any())) + .thenReturn(new ArrayList<>()); + } + + @Test + @DisplayName("拒单运单产出一条重新派单待处理,字段与跳转参数对齐小程序约定") + void rejectedWaybillAppearsAsReassignTodo() { + when(waybillService.list(ArgumentMatchers.>any())) + .thenReturn(List.of(rejectedWaybill(7201L, "黄大富", "时间冲突"))); + + List feed = service.buildAdminFeed(); + + assertEquals(1, feed.size()); + AdminTodoItemVO item = feed.get(0); + assertEquals("reassign", item.getType()); + assertEquals("重新派单待处理", item.getTitle()); + assertEquals("重新派单", item.getActionLabel()); + assertEquals(REJECTED_WAYBILL_NO, item.getWaybillNo()); + assertEquals(Long.valueOf(7201L), item.getId(), "重派条目应当带运单主键"); + assertEquals("运单已指派黄大富,被拒绝,请重新派单:时间冲突", item.getDesc()); + assertEquals("/pages/waybill/reassign?id=7201&buyerPaid=0", item.getTargetUrl(), + "跳转参数必须是运单主键:该页按主键回显运单,填运单号读不出数据"); + assertFalse(item.getTimeAgo().isEmpty(), "拒单时刻应换算成相对时间"); + } + + @Test + @DisplayName("拒单原因为空时正文不带空冒号") + void withoutRejectReasonKeepsDescClean() { + when(waybillService.list(ArgumentMatchers.>any())) + .thenReturn(List.of(rejectedWaybill(7202L, "黄宇富", null))); + + AdminTodoItemVO item = service.buildAdminFeed().get(0); + + assertEquals("运单已指派黄宇富,被拒绝,请重新派单", item.getDesc()); + assertFalse(item.getDesc().contains(":"), "原因缺失时不得留下空的「:」"); + } + + @Test + @DisplayName("没有拒单时只出异常条目,不凭空造重派条目") + void withoutRejectedWaybillNoReassignTodo() { + when(exceptionService.list(ArgumentMatchers.>any())) + .thenReturn(List.of(exceptionDisposal())); + + List feed = service.buildAdminFeed(); + + assertEquals(1, feed.size()); + assertEquals("exception", feed.get(0).getType()); + assertEquals("异常待处置", feed.get(0).getTitle()); + } + + @Test + @DisplayName("两类条目按各自业务发生时间倒序混排") + void feedIsMergedByBusinessTime() { + when(exceptionService.list(ArgumentMatchers.>any())) + .thenReturn(List.of(exceptionDisposal())); + when(waybillService.list(ArgumentMatchers.>any())) + .thenReturn(List.of(rejectedWaybill(7203L, "黄大富", "时间冲突"))); + + List feed = service.buildAdminFeed(); + + assertEquals(2, feed.size()); + assertEquals(List.of("reassign", "exception"), + feed.stream().map(AdminTodoItemVO::getType).collect(Collectors.toList()), + "拒单发生在昨日 22:00,异常上报于 26 小时前(更早),故重派条目在前"); + } + + @Test + @DisplayName("判据只认「已拒单 + 未取消未完成」,已取消已完成即便被拒过也不参与") + void reassignQueryExcludesTerminalStatus() { + service.buildAdminFeed(); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(waybillService).list(captor.capture()); + LambdaQueryWrapper wrapper = (LambdaQueryWrapper) captor.getValue(); + String sql = wrapper.getTargetSql(); + + assertTrue(sql.contains("driver_accept_status"), "重派判据要落在接单状态上:" + sql); + assertTrue(sql.contains("business_status"), + "已取消/已完成的运单不该再催重新派单:" + sql); + + Set values = new HashSet<>(wrapper.getParamNameValuePairs().values()); + assertTrue(values.contains("rejected"), "要带上已拒绝:" + values); + assertTrue(values.contains("pending") && values.contains("running"), + "业务状态只应放待执行与进行中:" + values); + } + + // ------------------------------------------------------------ 夹具 + + private Waybill rejectedWaybill(Long id, String driverName, String reason) { + Waybill waybill = new Waybill(); + waybill.setId(id); + waybill.setWaybillNo(REJECTED_WAYBILL_NO); + waybill.setDriverName(driverName); + waybill.setDriverAcceptStatus("rejected"); + waybill.setBusinessStatus("pending"); + waybill.setDriverRejectReason(reason); + waybill.setDriverRejectTime(yesterdayAt22()); + return waybill; + } + + private ExceptionDisposal exceptionDisposal() { + ExceptionDisposal disposal = new ExceptionDisposal(); + disposal.setId(EXCEPTION_ID); + disposal.setWaybillNo("YD-20261006-0001"); + disposal.setReporterName("黄宇富"); + disposal.setExceptionType("交通异常"); + disposal.setDisposalStatus("pending"); + disposal.setReportTime(LocalDateTime.now().minusHours(26)); + return disposal; + } + + private Date yesterdayAt22() { + java.util.Calendar calendar = java.util.Calendar.getInstance(); + calendar.add(java.util.Calendar.DAY_OF_MONTH, -1); + calendar.set(java.util.Calendar.HOUR_OF_DAY, 22); + calendar.set(java.util.Calendar.MINUTE, 0); + calendar.set(java.util.Calendar.SECOND, 0); + calendar.set(java.util.Calendar.MILLISECOND, 0); + return calendar.getTime(); + } +}