✨ 调度首页把司机已拒单的运单列为重新派单待处理,并在司机端让该运单脱离

This commit is contained in:
2026-10-10 01:48:49 +08:00
parent b66984d3a1
commit 4fe0c8a968
7 changed files with 560 additions and 11 deletions
@@ -44,4 +44,7 @@ public class AdminHomeBadgesVO implements Serializable {
@Schema(description = "风险记录待办数(disposalStatus=pending)")
private long risk;
@Schema(description = "重新派单待办数(司机已拒单且运单未取消未完成)")
private long pendingReassign;
}
@@ -49,7 +49,7 @@ public class AdminHomeVO implements Serializable {
@Schema(description = "快捷入口角标")
private AdminHomeBadgesVO badges = new AdminHomeBadgesVO();
@Schema(description = "待处理事项(异常处置状态≠已完成)")
@Schema(description = "待处理事项(异常处置状态≠已完成,或司机已拒单待重新派单)")
private List<AdminTodoItemVO> feed = new ArrayList<>();
}
@@ -72,7 +72,7 @@ public class ManageWaybillController extends BladeController {
@GetMapping("/home")
@ApiOperationSupport(order = 2)
@Operation(summary = "首页聚合", description = "统计 + 异常/风险角标 + 待处理事项(异常处置≠已完成)+ 当前用户名")
@Operation(summary = "首页聚合", description = "统计 + 异常/风险/重新派单角标 + 待处理事项(异常处置≠已完成,或司机已拒单待重新派单)+ 当前用户名")
public R<AdminHomeVO> home() {
return R.data(manageWaybillService.home());
}
@@ -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<String> plates) {
return loadDriverWaybill(id, plates, false);
}
/** @param includeRejected 仅 submitEnroute 传 true,见 scopedQuery 的重载说明 */
private Waybill loadDriverWaybill(Long id, List<String> 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
* <p>
* 拒单后运单对司机彻底脱离——列表、计数、详情、打卡、接单一律不可达,等管理员重新派单。
* 运单上的司机姓名/手机/车牌不清空,调度重新派单时要知道原来派给谁。
*/
private LambdaQueryWrapper<Waybill> scopedQuery(List<String> plates) {
return Wrappers.<Waybill>lambdaQuery().and(w -> {
return scopedQuery(plates, false);
}
/**
* @param includeRejected true 时不排除已拒单的运单,仅限在途打卡使用:
* 拒单的运单不可能处于运输中,收口拦不到合法操作,
* 而保留原判据可避免「已拒单 + 进行中」的历史脏数据被误伤
*/
private LambdaQueryWrapper<Waybill> scopedQuery(List<String> plates, boolean includeRejected) {
LambdaQueryWrapper<Waybill> wrapper = Wrappers.<Waybill>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<Waybill> wrapper, String keyword) {
@@ -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<AdminTodoItemVO> buildExceptionFeed() {
/** 待重新派单:司机已拒单,且运单尚未取消/完成 */
private long countPendingReassignments() {
return waybillService.count(pendingReassignQuery());
}
private LambdaQueryWrapper<Waybill> pendingReassignQuery() {
return Wrappers.<Waybill>lambdaQuery()
.eq(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED)
.in(Waybill::getBusinessStatus, STATUS_PENDING, STATUS_RUNNING);
}
/**
* 首页待处理事项:异常待处置 + 重新派单待处理。
* 两类各按自己的业务发生时间(上报时刻 / 拒单时刻)倒序,合并后再整体倒序取前 N 条。
* 排序时间随条目一起携带,不塞进 id 字段——id 在两型下各自是处置主键与运单主键。
*/
List<AdminTodoItemVO> buildAdminFeed() {
List<TimedTodo> 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<TimedTodo> buildExceptionFeed() {
List<ExceptionDisposal> list = exceptionDisposalService.list(Wrappers.<ExceptionDisposal>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<TimedTodo> buildReassignFeed() {
List<Waybill> 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 "";
@@ -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;
/**
* 司机拒单后运单对本人脱离。
* <p>
* 拒单不再只是一个标记:该运单从列表、计数、详情到接单全部不可达,等管理员重新派单。
* <p>
* 拦截落在司机端共用的取单条件上,而单元测试里 {@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.<com.baomidou.mybatisplus.core.conditions.Wrapper<Waybill>>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.<com.baomidou.mybatisplus.core.conditions.Wrapper<Waybill>>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.<com.baomidou.mybatisplus.core.conditions.Wrapper<Waybill>>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.<com.baomidou.mybatisplus.core.conditions.Wrapper<Waybill>>any())).thenReturn(List.of(
waybill(7202L, "YD-20261001-0002", "pending", "pending"),
waybill(7203L, "YD-20261001-0003", "running", "accepted")));
IPage<DriverWaybillCardVO> 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> T runAsDriver(Supplier<T> action) {
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class)) {
authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID);
return action.get();
}
}
private LambdaQueryWrapper<Waybill> capturedListQuery() {
ArgumentCaptor<LambdaQueryWrapper<Waybill>> 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;
}
}
@@ -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;
/**
* 调度端首页待处理事项:异常待处置 + 重新派单待处理。
* <p>
* 拒单条目的判据是「司机已拒单且运单未取消未完成」,必须与待处理运单列表的 needReassign
* 口径一致,否则两个页面的条数对不上;卡片上「多久之前」取拒单时刻,不取最后修改时间
* (后者会被任何无关改动刷新)。
* <p>
* 这里直接验归并方法本身,不走 {@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.<Wrapper<ExceptionDisposal>>any()))
.thenReturn(new ArrayList<>());
when(waybillService.list(ArgumentMatchers.<Wrapper<Waybill>>any()))
.thenReturn(new ArrayList<>());
}
@Test
@DisplayName("拒单运单产出一条重新派单待处理,字段与跳转参数对齐小程序约定")
void rejectedWaybillAppearsAsReassignTodo() {
when(waybillService.list(ArgumentMatchers.<Wrapper<Waybill>>any()))
.thenReturn(List.of(rejectedWaybill(7201L, "黄大富", "时间冲突")));
List<AdminTodoItemVO> 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.<Wrapper<Waybill>>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.<Wrapper<ExceptionDisposal>>any()))
.thenReturn(List.of(exceptionDisposal()));
List<AdminTodoItemVO> 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.<Wrapper<ExceptionDisposal>>any()))
.thenReturn(List.of(exceptionDisposal()));
when(waybillService.list(ArgumentMatchers.<Wrapper<Waybill>>any()))
.thenReturn(List.of(rejectedWaybill(7203L, "黄大富", "时间冲突")));
List<AdminTodoItemVO> 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<Wrapper<Waybill>> captor = ArgumentCaptor.forClass(Wrapper.class);
verify(waybillService).list(captor.capture());
LambdaQueryWrapper<Waybill> wrapper = (LambdaQueryWrapper<Waybill>) captor.getValue();
String sql = wrapper.getTargetSql();
assertTrue(sql.contains("driver_accept_status"), "重派判据要落在接单状态上:" + sql);
assertTrue(sql.contains("business_status"),
"已取消/已完成的运单不该再催重新派单:" + sql);
Set<Object> 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();
}
}