Merge remote-tracking branch 'websoft/dev'
# Conflicts: # blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java
This commit is contained in:
+3
@@ -44,4 +44,7 @@ public class AdminHomeBadgesVO implements Serializable {
|
||||
@Schema(description = "风险记录待办数(disposalStatus=pending)")
|
||||
private long risk;
|
||||
|
||||
@Schema(description = "重新派单待办数(司机已拒单且运单未取消未完成)")
|
||||
private long pendingReassign;
|
||||
|
||||
}
|
||||
|
||||
+1
-1
@@ -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<>();
|
||||
|
||||
}
|
||||
|
||||
+6
@@ -70,6 +70,12 @@ public class CertAuditDetailVO implements Serializable {
|
||||
private Long submitUser;
|
||||
@Schema(description = "本次提交人姓名")
|
||||
private String submitUserName;
|
||||
@Schema(description = "本次审核人;未审核时为 null")
|
||||
private Long auditUser;
|
||||
@Schema(description = "本次审核人姓名;未审核时为空")
|
||||
private String auditUserName;
|
||||
@Schema(description = "审核时间(通过或驳回的时间);未审核时为 null")
|
||||
private LocalDateTime auditTime;
|
||||
|
||||
@Schema(description = "待审快照:本次提交的全量内容")
|
||||
private String pendingSnapshotJson;
|
||||
|
||||
+3
@@ -49,4 +49,7 @@ public class DriverNodePunchVO implements Serializable {
|
||||
@Schema(description = "数量")
|
||||
private String quantity;
|
||||
|
||||
@Schema(description = "本次打卡是否使运单自动完成")
|
||||
private Boolean waybillCompleted;
|
||||
|
||||
}
|
||||
|
||||
+1
-1
@@ -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());
|
||||
}
|
||||
|
||||
+47
-5
@@ -126,6 +126,11 @@ public class CertAuditServiceImpl implements ICertAuditService {
|
||||
detail.setSubmitUser(record.getSubmitUser());
|
||||
// 面向前端的展示字段必须由服务端填好,否则审核页「提交人」一栏恒为空白
|
||||
detail.setSubmitUserName(UserCache.getUserRealName(record.getSubmitUser()));
|
||||
// 审核留痕同理:审核人姓名由服务端查好,前端不做用户ID → 姓名的二次请求。
|
||||
// 记录还在「审核中」时审核人与审核时间都为空,由前端显示占位符
|
||||
detail.setAuditUser(record.getAuditUser());
|
||||
detail.setAuditTime(record.getAuditTime());
|
||||
detail.setAuditUserName(UserCache.getUserRealName(record.getAuditUser()));
|
||||
detail.setPendingSnapshotJson(record.getSnapshotJson());
|
||||
|
||||
// 生效值一侧:从未认证通过时为 null,前端按「无对比基准」处理
|
||||
@@ -191,7 +196,6 @@ public class CertAuditServiceImpl implements ICertAuditService {
|
||||
private void updateCertTarget(TransportCertRecord record, boolean pass, String rejectReason) {
|
||||
boolean vehicleTarget = TransportCertRecord.BIZ_TYPE_VEHICLE.equals(record.getBizType());
|
||||
Integer status = pass ? TransportCertRecord.STATUS_APPROVED : TransportCertRecord.STATUS_REJECTED;
|
||||
String reason = pass ? null : CertificationRuleSupport.reasonForReject(rejectReason);
|
||||
// 只有通过才切指针;驳回时保持 null,框架的 not_null 更新策略会跳过该列
|
||||
Long newEffectiveId = pass ? record.getId() : null;
|
||||
|
||||
@@ -199,22 +203,25 @@ public class CertAuditServiceImpl implements ICertAuditService {
|
||||
TransportVehicle vehicle = new TransportVehicle();
|
||||
vehicle.setId(record.getBizId());
|
||||
vehicle.setCertificationStatus(status);
|
||||
vehicle.setCertificationRejectReason(reason);
|
||||
if (pass) {
|
||||
vehicle.setEffectiveCertRecordId(newEffectiveId);
|
||||
// 通过即生效:把这次快照的证件内容一并写回车辆档案,让「车辆档案 = 生效值」成立。
|
||||
// 不写回的话,审核通过之后管理端与司机端看到的仍是提交之前的旧值——
|
||||
// 状态显示「已通过」、内容却还是旧的,是最容易让人误判的一种不一致。
|
||||
// 与指针合并成同一次 UPDATE,不额外多写一次同一行。
|
||||
// 与指针合并成同一次 UPDATE。
|
||||
applySnapshotToVehicle(vehicle, record.getSnapshotJson());
|
||||
} else {
|
||||
vehicle.setCertificationRejectReason(CertificationRuleSupport.reasonForReject(rejectReason));
|
||||
}
|
||||
requireTargetUpdated(transportVehicleService.updateById(vehicle), "车辆", record.getBizId());
|
||||
if (pass) {
|
||||
clearVehicleRejectReason(record.getBizId());
|
||||
}
|
||||
return;
|
||||
}
|
||||
Driver driver = new Driver();
|
||||
driver.setId(record.getBizId());
|
||||
driver.setCertificationStatus(status);
|
||||
driver.setCertificationRejectReason(reason);
|
||||
// 必须带上 driving_vehicle 原值:该列是 FieldStrategy.ALWAYS,
|
||||
// 更新时忽略 null 直接写,patch 里不带它就会把司机的车辆绑定清成 NULL,
|
||||
// 而车辆列表正是按这一列匹配的(清空即「暂无绑定车辆」)。
|
||||
@@ -224,10 +231,45 @@ public class CertAuditServiceImpl implements ICertAuditService {
|
||||
// 通过即生效:与车辆侧同理,把这次快照的证件内容一并写回司机档案。
|
||||
// 不写回的话,司机档案里始终是管理端当初录入的旧值(甚至为空),
|
||||
// 而认证状态已是「已通过」——状态与内容不一致最容易让人误判。
|
||||
// 与指针合并成同一次 UPDATE,不额外多写一次同一行。
|
||||
// 与指针合并成同一次 UPDATE。
|
||||
applySnapshotToDriver(driver, record.getSnapshotJson());
|
||||
} else {
|
||||
driver.setCertificationRejectReason(CertificationRuleSupport.reasonForReject(rejectReason));
|
||||
}
|
||||
requireTargetUpdated(driverService.updateById(driver), "司机", record.getBizId());
|
||||
if (pass) {
|
||||
clearDriverRejectReason(record.getBizId());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空车辆档案上的驳回原因展示缓存
|
||||
* <p>
|
||||
* 该列没有驳回原因时必须是 NULL(建表即 DEFAULT NULL)。但框架的 not_null 更新策略
|
||||
* 会跳过 patch 里的 null 字段,交给 updateById 的 null 到不了 SQL,
|
||||
* 「已通过」的车辆会一直挂着上一次的驳回文案,故单独发一条只改该列的语句。
|
||||
* 它不碰生效快照指针。
|
||||
*
|
||||
* @param vehicleId 车辆主键
|
||||
*/
|
||||
private void clearVehicleRejectReason(Long vehicleId) {
|
||||
transportVehicleService.update(Wrappers.<TransportVehicle>lambdaUpdate()
|
||||
.eq(TransportVehicle::getId, vehicleId)
|
||||
.set(TransportVehicle::getCertificationRejectReason, null));
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空司机档案上的驳回原因展示缓存
|
||||
* <p>
|
||||
* 与车辆侧同理:null 交给 updateById 到不了 SQL,只能单独发一条只改该列的语句。
|
||||
* 它不碰生效快照指针,也不碰车辆绑定。
|
||||
*
|
||||
* @param driverId 司机主键
|
||||
*/
|
||||
private void clearDriverRejectReason(Long driverId) {
|
||||
driverService.update(Wrappers.<Driver>lambdaUpdate()
|
||||
.eq(Driver::getId, driverId)
|
||||
.set(Driver::getCertificationRejectReason, null));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+26
-11
@@ -186,7 +186,13 @@ public class DriverAppServiceImpl implements IDriverAppService {
|
||||
String selfPhone = resolveSelfPhone(userId);
|
||||
String hint = Func.isEmpty(mobileHint) ? null : mobileHint.trim();
|
||||
if (Func.isNotEmpty(hint)) {
|
||||
if (Func.isNotEmpty(selfPhone) && !selfPhone.equals(hint)) {
|
||||
if (Func.isEmpty(selfPhone)) {
|
||||
// 解析不出本人号码时不能拿前端传的 mobile 去查:
|
||||
// currentDriver 会直接按 mobile 等值查询,传入他人号码即可读到他人档案。
|
||||
// 这里返回空让它退回按 user_id 查,宁可查不到也不越权。
|
||||
return null;
|
||||
}
|
||||
if (!selfPhone.equals(hint)) {
|
||||
throw new ServiceException("只能查询本人司机档案");
|
||||
}
|
||||
return hint;
|
||||
@@ -196,21 +202,30 @@ public class DriverAppServiceImpl implements IDriverAppService {
|
||||
|
||||
private String resolveSelfPhone(Long userId) {
|
||||
String account = AuthUtil.getUserAccount();
|
||||
if (Func.isNotEmpty(account) && account.matches("^1\\d{10}$")) {
|
||||
if (isMobileLike(account)) {
|
||||
return account.trim();
|
||||
}
|
||||
// 用户中心的 phone 带 @Sensitive(MOBILE),经 Feign 回来是「175****12082」这类脱敏串,
|
||||
// 拿它去 eq(mobile) 永远查不到、拿它比对本人的真实号码又必然不等。
|
||||
// 因此这里只取未脱敏的 account;脱敏的 phone 一律不用。
|
||||
R<User> result = userClient.userInfoById(userId);
|
||||
if (result == null || !R.isSuccess(result) || result.getData() == null) {
|
||||
return Func.isEmpty(account) ? null : account.trim();
|
||||
}
|
||||
User user = result.getData();
|
||||
if (Func.isNotEmpty(user.getPhone())) {
|
||||
return user.getPhone().trim();
|
||||
}
|
||||
if (Func.isNotEmpty(user.getAccount()) && user.getAccount().matches("^1\\d{10}$")) {
|
||||
return user.getAccount().trim();
|
||||
if (result != null && R.isSuccess(result) && result.getData() != null) {
|
||||
String userAccount = result.getData().getAccount();
|
||||
if (isMobileLike(userAccount)) {
|
||||
return userAccount.trim();
|
||||
}
|
||||
}
|
||||
return Func.isEmpty(account) ? null : account.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否是「可作为司机档案手机号使用」的号码串。
|
||||
* <p>
|
||||
* 只判 1 开头的 11~15 位纯数字:库里既有 11 位真实手机号,也有 12 位账号
|
||||
* (如 175874212082),写死 11 位会让这类账号的司机解析不出本人号码。
|
||||
*/
|
||||
private boolean isMobileLike(String value) {
|
||||
return Func.isNotEmpty(value) && value.trim().matches("^1\\d{10,14}$");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+13
@@ -376,6 +376,13 @@ public class DriverCertServiceImpl implements IDriverCertService {
|
||||
patch.setDrivingVehicle(current.getDrivingVehicle());
|
||||
}
|
||||
driverService.updateById(patch);
|
||||
if (Func.isBlank(rejectReason)) {
|
||||
// 重新提交时驳回原因应为空,但框架的 not_null 更新策略会跳过 patch 里的 null,
|
||||
// 只写 null 到不了 SQL,旧驳回文案会一直挂到审核通过,故单独清一次
|
||||
driverService.update(Wrappers.<Driver>lambdaUpdate()
|
||||
.eq(Driver::getId, driverId)
|
||||
.set(Driver::getCertificationRejectReason, null));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -389,6 +396,12 @@ public class DriverCertServiceImpl implements IDriverCertService {
|
||||
patch.setCertificationStatus(status);
|
||||
patch.setCertificationRejectReason(rejectReason);
|
||||
transportVehicleService.updateById(patch);
|
||||
if (Func.isBlank(rejectReason)) {
|
||||
// 与司机侧同理:null 交给 updateById 到不了 SQL,旧驳回文案会一直挂着
|
||||
transportVehicleService.update(Wrappers.<TransportVehicle>lambdaUpdate()
|
||||
.eq(TransportVehicle::getId, vehicleId)
|
||||
.set(TransportVehicle::getCertificationRejectReason, null));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+50
-110
@@ -56,7 +56,6 @@ import org.springblade.transport.pojo.vo.DriverPunchPhotoVO;
|
||||
import org.springblade.transport.pojo.vo.DriverWaybillCardVO;
|
||||
import org.springblade.transport.pojo.vo.DriverWaybillPreviewVO;
|
||||
import org.springblade.transport.pojo.vo.DriverWaybillTabCountsVO;
|
||||
import org.springblade.transport.pojo.vo.WaybillTrackVO;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.IDriverWaybillService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
@@ -130,12 +129,12 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
|
||||
@Override
|
||||
public DriverWaybillCardVO currentTask() {
|
||||
String phone = currentUserPhone();
|
||||
if (Func.isEmpty(phone)) {
|
||||
List<String> plates = currentBoundPlates();
|
||||
if (plates.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
// 进行中:running,或无需确认接单但仍为 pending 的历史数据
|
||||
List<Waybill> candidates = waybillService.list(scopedQuery(phone)
|
||||
List<Waybill> candidates = waybillService.list(scopedQuery(plates)
|
||||
.in(Waybill::getBusinessStatus, List.of(STATUS_RUNNING, STATUS_PENDING))
|
||||
.orderByDesc(Waybill::getUpdateTime)
|
||||
.last("LIMIT 20"));
|
||||
@@ -151,12 +150,12 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
@Override
|
||||
public DriverWaybillPreviewVO pendingPreview(Integer size) {
|
||||
DriverWaybillPreviewVO preview = new DriverWaybillPreviewVO();
|
||||
String phone = currentUserPhone();
|
||||
if (Func.isEmpty(phone)) {
|
||||
List<String> plates = currentBoundPlates();
|
||||
if (plates.isEmpty()) {
|
||||
return preview;
|
||||
}
|
||||
int limit = normalizePreviewSize(size);
|
||||
List<Waybill> pendingList = waybillService.list(scopedQuery(phone)
|
||||
List<Waybill> pendingList = waybillService.list(scopedQuery(plates)
|
||||
.in(Waybill::getBusinessStatus, PENDING_STATUSES)
|
||||
.orderByDesc(Waybill::getCreateTime));
|
||||
List<Waybill> needAccept = pendingList.stream()
|
||||
@@ -171,11 +170,11 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
@Override
|
||||
public DriverWaybillTabCountsVO tabCounts() {
|
||||
DriverWaybillTabCountsVO vo = new DriverWaybillTabCountsVO();
|
||||
String phone = currentUserPhone();
|
||||
if (Func.isEmpty(phone)) {
|
||||
List<String> plates = currentBoundPlates();
|
||||
if (plates.isEmpty()) {
|
||||
return vo;
|
||||
}
|
||||
List<Waybill> waybills = waybillService.list(scopedQuery(phone)
|
||||
List<Waybill> waybills = waybillService.list(scopedQuery(plates)
|
||||
.in(Waybill::getBusinessStatus, TAB_ALL_STATUSES));
|
||||
long pending = 0;
|
||||
long doing = 0;
|
||||
@@ -207,12 +206,12 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
int pageSize = size == null || size < 1 ? DEFAULT_PAGE_SIZE : Math.min(size, MAX_PAGE_SIZE);
|
||||
Page<DriverWaybillCardVO> empty = new Page<>(pageNo, pageSize);
|
||||
|
||||
String phone = currentUserPhone();
|
||||
if (Func.isEmpty(phone)) {
|
||||
List<String> plates = currentBoundPlates();
|
||||
if (plates.isEmpty()) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
LambdaQueryWrapper<Waybill> wrapper = scopedQuery(phone);
|
||||
LambdaQueryWrapper<Waybill> wrapper = scopedQuery(plates);
|
||||
// 先按 Tab 口径拉候选,再按过程配置校正 pending→running 后内存分页
|
||||
applyStatusFilterForQuery(wrapper, status);
|
||||
applyKeyword(wrapper, keyword);
|
||||
@@ -235,74 +234,11 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
|
||||
@Override
|
||||
public DriverWaybillCardVO detail(Long id) {
|
||||
requireCurrentDriver();
|
||||
Waybill waybill = loadDriverWaybill(id);
|
||||
Driver driver = requireCurrentDriver();
|
||||
Waybill waybill = loadDriverWaybill(id, currentBoundPlates(driver));
|
||||
return toCard(normalizeAcceptStatus(waybill), true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public WaybillTrackVO track(Long id) {
|
||||
requireCurrentDriver();
|
||||
Waybill waybill = loadDriverWaybill(id);
|
||||
LocalDate[] range = resolveTrackDateRange(waybill);
|
||||
return waybillService.trackVehicle(waybill.getId(), range[0].toString(), range[1].toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* 小程序行驶轨迹查询区间:
|
||||
* 1/2 有打卡 → 首条打卡日为开始;多于 1 条结束取末条日,仅 1 条结束取今天
|
||||
* 3 无打卡有计划开始日 → 计划开始日到今天
|
||||
* 4 无打卡无计划 → 3 天前到今天
|
||||
*/
|
||||
private LocalDate[] resolveTrackDateRange(Waybill waybill) {
|
||||
LocalDate today = LocalDate.now();
|
||||
List<Date> punchTimes = listAllPunchTimes(waybill.getId());
|
||||
if (!punchTimes.isEmpty()) {
|
||||
LocalDate start = toLocalDate(punchTimes.get(0));
|
||||
LocalDate end = punchTimes.size() > 1
|
||||
? toLocalDate(punchTimes.get(punchTimes.size() - 1))
|
||||
: today;
|
||||
return clampTrackRange(start, end, today);
|
||||
}
|
||||
LocalDate planStart = waybill.getEstimatedStartTime();
|
||||
if (planStart != null) {
|
||||
return clampTrackRange(planStart, today, today);
|
||||
}
|
||||
return clampTrackRange(today.minusDays(3), today, today);
|
||||
}
|
||||
|
||||
private LocalDate[] clampTrackRange(LocalDate start, LocalDate end, LocalDate today) {
|
||||
LocalDate safeStart = start == null ? today.minusDays(3) : start;
|
||||
LocalDate safeEnd = end == null ? today : end;
|
||||
if (safeEnd.isBefore(safeStart)) {
|
||||
safeEnd = safeStart;
|
||||
}
|
||||
return new LocalDate[]{safeStart, safeEnd};
|
||||
}
|
||||
|
||||
private List<Date> listAllPunchTimes(Long waybillId) {
|
||||
if (waybillId == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<Date> times = new ArrayList<>();
|
||||
nodePunchMapper.selectList(Wrappers.<WaybillNodePunch>lambdaQuery()
|
||||
.eq(WaybillNodePunch::getWaybillId, waybillId)
|
||||
.isNotNull(WaybillNodePunch::getPunchTime)
|
||||
.orderByAsc(WaybillNodePunch::getPunchTime))
|
||||
.forEach(punch -> times.add(punch.getPunchTime()));
|
||||
enroutePunchMapper.selectList(Wrappers.<WaybillEnroutePunch>lambdaQuery()
|
||||
.eq(WaybillEnroutePunch::getWaybillId, waybillId)
|
||||
.isNotNull(WaybillEnroutePunch::getPunchTime)
|
||||
.orderByAsc(WaybillEnroutePunch::getPunchTime))
|
||||
.forEach(punch -> times.add(punch.getPunchTime()));
|
||||
times.sort(Date::compareTo);
|
||||
return times;
|
||||
}
|
||||
|
||||
private LocalDate toLocalDate(Date date) {
|
||||
return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
|
||||
}
|
||||
|
||||
@Override
|
||||
public DriverWaybillCardVO detailPunchSnapshot(Long id) {
|
||||
if (id == null) {
|
||||
@@ -322,7 +258,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
throw new ServiceException("运单ID不能为空");
|
||||
}
|
||||
Driver driver = requireCurrentDriver();
|
||||
Waybill waybill = loadDriverWaybill(dto.getWaybillId());
|
||||
Waybill waybill = loadDriverWaybill(dto.getWaybillId(), currentBoundPlates(driver));
|
||||
waybill = normalizeAcceptStatus(waybill);
|
||||
|
||||
String processJson = resolveProcessJson(waybill);
|
||||
@@ -371,7 +307,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
throw new ServiceException("节点编码不能为空");
|
||||
}
|
||||
Driver driver = requireCurrentDriver();
|
||||
Waybill waybill = loadDriverWaybill(dto.getWaybillId());
|
||||
Waybill waybill = loadDriverWaybill(dto.getWaybillId(), currentBoundPlates(driver));
|
||||
waybill = normalizeAcceptStatus(waybill);
|
||||
|
||||
String processJson = resolveProcessJson(waybill);
|
||||
@@ -434,7 +370,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
// 认证门槛:司机本人须已认证,且承运车辆须已认证。
|
||||
// 判据是各自持有的生效快照指针,故「已认证的车重新提交认证」不会拦下接单。
|
||||
assertCertified(driver);
|
||||
Waybill waybill = loadDriverWaybill(id);
|
||||
Waybill waybill = loadDriverWaybill(id, currentBoundPlates(driver));
|
||||
waybillService.syncDriverAcceptState(waybill);
|
||||
assertAcceptable(waybill);
|
||||
Date now = new Date();
|
||||
@@ -484,8 +420,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean reject(Long id, String reason) {
|
||||
requireCurrentDriver();
|
||||
Waybill waybill = loadDriverWaybill(id);
|
||||
Driver driver = requireCurrentDriver();
|
||||
Waybill waybill = loadDriverWaybill(id, currentBoundPlates(driver));
|
||||
waybillService.syncDriverAcceptState(waybill);
|
||||
assertRejectable(waybill);
|
||||
String rejectReason = Func.isEmpty(reason) ? null : reason.trim();
|
||||
@@ -508,8 +444,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
if (id == null) {
|
||||
throw new ServiceException("运单ID不能为空");
|
||||
}
|
||||
requireCurrentDriver();
|
||||
Waybill waybill = loadDriverWaybill(id);
|
||||
Driver driver = requireCurrentDriver();
|
||||
Waybill waybill = loadDriverWaybill(id, currentBoundPlates(driver));
|
||||
waybill = normalizeAcceptStatus(waybill);
|
||||
if (!STATUS_RUNNING.equals(waybill.getBusinessStatus())
|
||||
&& !STATUS_PENDING.equals(waybill.getBusinessStatus())
|
||||
@@ -547,15 +483,14 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
}
|
||||
}
|
||||
|
||||
private Waybill loadDriverWaybill(Long id) {
|
||||
private Waybill loadDriverWaybill(Long id, List<String> plates) {
|
||||
if (id == null) {
|
||||
throw new ServiceException("运单ID不能为空");
|
||||
}
|
||||
String phone = currentUserPhone();
|
||||
if (Func.isEmpty(phone)) {
|
||||
throw new ServiceException("当前用户手机号为空,无法查找运单");
|
||||
if (plates.isEmpty()) {
|
||||
throw new ServiceException("当前司机未绑定车辆");
|
||||
}
|
||||
Waybill waybill = waybillService.getOne(scopedQuery(phone).eq(Waybill::getId, id).last("LIMIT 1"));
|
||||
Waybill waybill = waybillService.getOne(scopedQuery(plates).eq(Waybill::getId, id).last("LIMIT 1"));
|
||||
if (waybill == null) {
|
||||
throw new ServiceException("运单不存在或无权操作");
|
||||
}
|
||||
@@ -612,13 +547,24 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 司机可见运单范围:运单司机手机号(driver_phone)等于当前用户手机号。
|
||||
* 司机可见运单范围:运单车牌(主车/挂车)落在当前司机绑定车牌内。
|
||||
* 绑定来源:blade_transport_driver.driving_vehicle
|
||||
*/
|
||||
private LambdaQueryWrapper<Waybill> scopedQuery(String phone) {
|
||||
String normalizedPhone = Func.toStr(phone, "").trim();
|
||||
return Wrappers.<Waybill>lambdaQuery().and(w -> w
|
||||
.eq(Waybill::getDriverPhone, normalizedPhone)
|
||||
.or().apply("REPLACE(IFNULL(driver_phone,''),' ','') = {0}", normalizedPhone));
|
||||
private LambdaQueryWrapper<Waybill> scopedQuery(List<String> plates) {
|
||||
return Wrappers.<Waybill>lambdaQuery().and(w -> {
|
||||
w.in(Waybill::getVehicleNo, plates)
|
||||
.or().in(Waybill::getTrailerVehicleNo, plates);
|
||||
for (String plate : plates) {
|
||||
w.or().apply(
|
||||
"REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(UPPER(IFNULL(vehicle_no,'')),'·',''),'•',''),'.',''),'-',''),' ','') = {0}",
|
||||
plate
|
||||
);
|
||||
w.or().apply(
|
||||
"REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(UPPER(IFNULL(trailer_vehicle_no,'')),'·',''),'•',''),'.',''),'-',''),' ','') = {0}",
|
||||
plate
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void applyKeyword(LambdaQueryWrapper<Waybill> wrapper, String keyword) {
|
||||
@@ -634,19 +580,13 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
.or().like(Waybill::getVehicleNo, kw));
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前用户手机号:优先司机档案 mobile,其次 JWT 账号(11 位手机号)。
|
||||
*/
|
||||
private String currentUserPhone() {
|
||||
/** 当前登录司机绑定的规范化车牌列表;无司机或无绑定车牌则空 */
|
||||
private List<String> currentBoundPlates() {
|
||||
Driver driver = currentDriver();
|
||||
if (driver != null && Func.isNotEmpty(driver.getMobile())) {
|
||||
return driver.getMobile().trim();
|
||||
if (driver == null || Func.isEmpty(driver.getDrivingVehicle())) {
|
||||
return List.of();
|
||||
}
|
||||
String account = AuthUtil.getUserAccount();
|
||||
if (Func.isNotEmpty(account) && account.matches("^1\\d{10}$")) {
|
||||
return account.trim();
|
||||
}
|
||||
return null;
|
||||
return splitPlates(driver.getDrivingVehicle());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -869,9 +809,9 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
|
||||
@Override
|
||||
public void assertCurrentDriverOwnsWaybill(Long waybillId) {
|
||||
// 归属口径与司机打卡、司机接单一致:按当前用户手机号匹配运单 driverPhone
|
||||
requireCurrentDriver();
|
||||
loadDriverWaybill(waybillId);
|
||||
// 归属口径与司机打卡、司机接单一致:都按司机档案自述绑定的车牌判定
|
||||
Driver driver = requireCurrentDriver();
|
||||
loadDriverWaybill(waybillId, currentBoundPlates(driver));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+94
-4
@@ -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 "";
|
||||
|
||||
+46
@@ -53,6 +53,7 @@ public final class WaybillProcessSupport {
|
||||
public static final String ACCEPT_REJECTED = "rejected";
|
||||
private static final String CONFIRM_YES = "yes";
|
||||
private static final String CONFIRM_NO_ACCEPT = "no_confirm_accept";
|
||||
private static final String CONFIRM_NO_COMPLETE = "no_confirm_complete";
|
||||
private static final String NODE_TRANSIT = "transit";
|
||||
private static final String NODE_TRANSIT_NAME = "在途";
|
||||
/** 异常处置终态:completed=已完成,revoked=已撤销。只有终态解除打卡门禁 */
|
||||
@@ -244,6 +245,21 @@ public final class WaybillProcessSupport {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 本次打卡的节点是否是司机可打卡节点的末位。
|
||||
* <p>
|
||||
* 末位取「司机可打卡节点」序列:接单与回单不打卡,不参与排位——
|
||||
* 回单只要启用就必然排在配置最末,按全部启用节点取末位会永远取到回单。
|
||||
*/
|
||||
public static boolean isLastDriverPunchNode(String processJson, String nodeCode) {
|
||||
List<Map<String, Object>> nodes = listDriverPunchNodes(processJson);
|
||||
if (nodes.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String code = stringVal(nodeCode);
|
||||
return !code.isEmpty() && code.equals(nodeKey(nodes.get(nodes.size() - 1)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用中的过程节点(按配置顺序,含接单/回单)。
|
||||
*/
|
||||
@@ -591,6 +607,36 @@ public final class WaybillProcessSupport {
|
||||
return isAcceptNode(stringVal(node.get("key")), stringVal(node.get("name")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否到货 / 卸货 / 签收节点:key 或中文名任一命中即算。
|
||||
*/
|
||||
public static boolean isDeliveryNode(String nodeCode, String nodeName) {
|
||||
String key = stringVal(nodeCode);
|
||||
String name = stringVal(nodeName);
|
||||
return "arrival".equals(key) || "到货".equals(name)
|
||||
|| "unload".equals(key) || "卸货".equals(name)
|
||||
|| "sign".equals(key) || "签收".equals(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 回单是否无需确认完成:配置里没有回单节点、回单未启用,或启用且配置为「无需确认完成」。
|
||||
* <p>
|
||||
* 未启用的回单节点不看 confirmMode——没有回单环节就不会有人来确认;
|
||||
* 启用但 confirmMode 缺失按「需确认完成」算。
|
||||
*/
|
||||
public static boolean returnNeedsNoConfirmation(String processJson) {
|
||||
for (Map<String, Object> node : parseProcessNodes(processJson)) {
|
||||
if (!isReturnNode(node)) {
|
||||
continue;
|
||||
}
|
||||
if (!isEnabled(node)) {
|
||||
return true;
|
||||
}
|
||||
return CONFIRM_NO_COMPLETE.equals(stringVal(node.get("confirmMode")));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean isTransitNode(Map<String, Object> node) {
|
||||
String key = stringVal(node.get("key"));
|
||||
String name = stringVal(node.get("name"));
|
||||
|
||||
+86
@@ -1,5 +1,11 @@
|
||||
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.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -14,7 +20,10 @@ import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.ITransportCertRecordService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
|
||||
import java.util.Collection;
|
||||
|
||||
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.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
@@ -43,6 +52,16 @@ class CertAuditServiceImplTest {
|
||||
private IDriverService driverService;
|
||||
private CertAuditServiceImpl service;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
// 构造 LambdaUpdateWrapper 需要 MyBatis-Plus 的实体元数据缓存
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, Driver.class);
|
||||
TableInfoHelper.initTableInfo(assistant, TransportVehicle.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
certRecordService = mock(ITransportCertRecordService.class);
|
||||
@@ -330,6 +349,73 @@ class CertAuditServiceImplTest {
|
||||
assertNull(detail.getEffectiveRecordId());
|
||||
}
|
||||
|
||||
// -------------------------------------------- 驳回原因的展示缓存
|
||||
|
||||
@Test
|
||||
@DisplayName("通过车辆认证:显式清空档案上的驳回原因(not_null 策略会让 null 到不了 SQL)")
|
||||
@SuppressWarnings("unchecked")
|
||||
void approveClearsStaleVehicleRejectReason() {
|
||||
TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING);
|
||||
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null));
|
||||
|
||||
service.audit(RECORD_ID, true, null);
|
||||
|
||||
ArgumentCaptor<Wrapper<TransportVehicle>> captor = ArgumentCaptor.forClass(Wrapper.class);
|
||||
verify(transportVehicleService).update(captor.capture());
|
||||
LambdaUpdateWrapper<TransportVehicle> wrapper =
|
||||
(LambdaUpdateWrapper<TransportVehicle>) captor.getValue();
|
||||
assertTrue(wrapper.getSqlSet().contains("certification_reject_reason"),
|
||||
"通过时必须显式清空驳回原因,实际: " + wrapper.getSqlSet());
|
||||
Collection<Object> values = wrapper.getParamNameValuePairs().values();
|
||||
assertTrue(values.contains(null), "清空必须显式写 null(该列无原因时的取值)");
|
||||
assertTrue(wrapper.getSqlSegment().contains("id =")
|
||||
&& values.stream().anyMatch(v -> v instanceof Number
|
||||
&& ((Number) v).longValue() == VEHICLE_ID),
|
||||
"必须带主键条件且落在被审核的那一行,否则会更新整张表,实际: " + wrapper.getSqlSegment());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("通过司机认证:同样显式清空驳回原因,且不顺带动车辆绑定")
|
||||
@SuppressWarnings("unchecked")
|
||||
void approveClearsStaleDriverRejectReason() {
|
||||
TransportCertRecord record = driverRecord(TransportCertRecord.STATUS_PENDING);
|
||||
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
|
||||
when(driverService.getById(DRIVER_ID)).thenReturn(driver(null));
|
||||
|
||||
service.audit(RECORD_ID, true, null);
|
||||
|
||||
ArgumentCaptor<Wrapper<Driver>> captor = ArgumentCaptor.forClass(Wrapper.class);
|
||||
verify(driverService).update(captor.capture());
|
||||
LambdaUpdateWrapper<Driver> wrapper = (LambdaUpdateWrapper<Driver>) captor.getValue();
|
||||
assertTrue(wrapper.getSqlSet().contains("certification_reject_reason"),
|
||||
"通过时必须显式清空驳回原因,实际: " + wrapper.getSqlSet());
|
||||
Collection<Object> values = wrapper.getParamNameValuePairs().values();
|
||||
assertTrue(values.contains(null));
|
||||
assertTrue(wrapper.getSqlSegment().contains("id =")
|
||||
&& values.stream().anyMatch(v -> v instanceof Number
|
||||
&& ((Number) v).longValue() == DRIVER_ID),
|
||||
"必须带主键条件且落在被审核的那一行,否则会更新整张表,实际: " + wrapper.getSqlSegment());
|
||||
assertFalse(wrapper.getSqlSet().contains("driving_vehicle"),
|
||||
"清空驳回原因不得顺带动车辆绑定");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("驳回:原因去空白后写入档案,且不发清空语句")
|
||||
void rejectWritesReasonAndDoesNotClear() {
|
||||
TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING);
|
||||
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(777L));
|
||||
|
||||
service.audit(RECORD_ID, false, " 行驶证模糊 ");
|
||||
|
||||
ArgumentCaptor<TransportVehicle> captor = ArgumentCaptor.forClass(TransportVehicle.class);
|
||||
verify(transportVehicleService).updateById(captor.capture());
|
||||
assertEquals("行驶证模糊", captor.getValue().getCertificationRejectReason(),
|
||||
"驳回原因去首尾空白后落库");
|
||||
verify(transportVehicleService, never()).update(any());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 辅助
|
||||
|
||||
private TransportCertRecord vehicleRecord(Integer status) {
|
||||
|
||||
+57
@@ -5,7 +5,10 @@ import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.core.tool.api.R;
|
||||
import org.springblade.system.pojo.entity.User;
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
import org.springblade.transport.pojo.entity.TransportVehicle;
|
||||
import org.springblade.transport.pojo.vo.DriverVehicleCardVO;
|
||||
@@ -17,6 +20,7 @@ import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
@@ -81,6 +85,31 @@ class DriverAppServiceImplTest {
|
||||
assertEquals("2028-12-04", card.getRoadTransportCertStartDate());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 手机号解析
|
||||
|
||||
@Test
|
||||
@DisplayName("12 位手机号也能解析出本人号码——库里存在 12 位司机账号,写死 11 位会让他们查不到自己的档案")
|
||||
void twelveDigitAccountIsUsableAsPhone() {
|
||||
assertEquals("黄大富", currentByPhone("175874212082", "175874212082", null).getDriverName());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("用户中心 phone 是脱敏串时改用其 account——脱敏 phone 既查不到档案,也会把本人真实号码误判成越权")
|
||||
void maskedUserPhoneFallsBackToAccount() {
|
||||
User user = new User();
|
||||
user.setPhone("175****12082");
|
||||
user.setAccount("175874212082");
|
||||
|
||||
assertEquals("黄大富", currentByPhone(null, null, user).getDriverName());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("传他人手机号一律拒绝——不能因为解析不到本人号码就放行,那等于允许按手机号查任意档案")
|
||||
void otherPeoplesMobileIsRejected() {
|
||||
assertEquals("只能查询本人司机档案", assertThrows(ServiceException.class,
|
||||
() -> currentByPhone("13900000001", "175874212082", null)).getMessage());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 辅助
|
||||
|
||||
/** 造一辆只关心有效期三个字段的车 */
|
||||
@@ -122,4 +151,32 @@ class DriverAppServiceImplTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按给定的登录态跑一次 currentByPhone。
|
||||
*
|
||||
* @param mobileHint 前端传入的 mobile,可为 null
|
||||
* @param account JWT 里的账号,可为 null
|
||||
* @param user 用户中心返回的用户,可为 null(表示走不到用户中心)
|
||||
*/
|
||||
private Driver currentByPhone(String mobileHint, String account, User user) {
|
||||
IUserClient userClient = mock(IUserClient.class);
|
||||
if (user != null) {
|
||||
when(userClient.userInfoById(USER_ID)).thenReturn(R.data(user));
|
||||
}
|
||||
DriverAppServiceImpl scoped = new DriverAppServiceImpl(driverService, transportVehicleService, userClient);
|
||||
|
||||
Driver driver = new Driver();
|
||||
driver.setId(9002L);
|
||||
driver.setUserId(USER_ID);
|
||||
driver.setDriverName("黄大富");
|
||||
driver.setMobile("175874212082");
|
||||
when(driverService.getOne(any(LambdaQueryWrapper.class))).thenReturn(driver);
|
||||
|
||||
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class)) {
|
||||
authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID);
|
||||
authUtil.when(AuthUtil::getUserAccount).thenReturn(account);
|
||||
return scoped.currentByPhone(mobileHint);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+43
@@ -1,7 +1,13 @@
|
||||
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.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
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;
|
||||
@@ -18,6 +24,7 @@ import org.springblade.transport.service.ITransportCertRecordService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
@@ -53,6 +60,16 @@ class DriverCertServiceImplTest {
|
||||
private ITransportCertRecordService certRecordService;
|
||||
private DriverCertServiceImpl service;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
// 构造 LambdaUpdateWrapper 需要 MyBatis-Plus 的实体元数据缓存
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, Driver.class);
|
||||
TableInfoHelper.initTableInfo(assistant, TransportVehicle.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
driverAppService = mock(IDriverAppService.class);
|
||||
@@ -279,6 +296,32 @@ class DriverCertServiceImplTest {
|
||||
"提交链路不得写生效快照指针");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("个人提交:清空上一次的驳回原因(null 交给 updateById 到不了 SQL)")
|
||||
@SuppressWarnings("unchecked")
|
||||
void submitPersonCertClearsStaleRejectReason() {
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
|
||||
when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null);
|
||||
|
||||
PersonCertSubmitRequest request = new PersonCertSubmitRequest();
|
||||
request.setRealName("张三");
|
||||
request.setIdCardNo("110101199001011234");
|
||||
|
||||
service.submitPersonCert(request);
|
||||
|
||||
ArgumentCaptor<Wrapper<Driver>> captor = ArgumentCaptor.forClass(Wrapper.class);
|
||||
verify(driverService).update(captor.capture());
|
||||
LambdaUpdateWrapper<Driver> wrapper = (LambdaUpdateWrapper<Driver>) captor.getValue();
|
||||
assertTrue(wrapper.getSqlSet().contains("certification_reject_reason"),
|
||||
"重新提交时旧驳回原因必须清掉,实际: " + wrapper.getSqlSet());
|
||||
Collection<Object> values = wrapper.getParamNameValuePairs().values();
|
||||
assertTrue(values.contains(null), "清空必须显式写 null");
|
||||
assertTrue(wrapper.getSqlSegment().contains("id =")
|
||||
&& values.stream().anyMatch(v -> v instanceof Number
|
||||
&& ((Number) v).longValue() == DRIVER_ID),
|
||||
"必须带主键条件且落在被审核的那一行,否则会更新整张表,实际: " + wrapper.getSqlSegment());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("个人提交:内容为空必须拒绝")
|
||||
void submitPersonCertRejectsEmpty() {
|
||||
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
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.MockedStatic;
|
||||
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.NodeSubmitDTO;
|
||||
import org.springblade.transport.pojo.dto.PunchPhotoDTO;
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.entity.WaybillNodePunch;
|
||||
import org.springblade.transport.pojo.vo.DriverNodePunchVO;
|
||||
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 static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 司机打完末位打卡节点后运单自动完成。
|
||||
* <p>
|
||||
* 命中要四个条件同时成立:本次打卡的是「司机可打卡节点」的末位、该节点是到货/卸货/签收、
|
||||
* 回单无需确认完成、运单本身允许完成。任一不成立都只落打卡流水,
|
||||
* 响应里的 waybillCompleted 始终取完成接口的返回值,前端据此决定是否提示「运单已完成」。
|
||||
*/
|
||||
class DriverWaybillAutoCompleteTest {
|
||||
|
||||
private static final Long WAYBILL_ID = 5801L;
|
||||
private static final Long USER_ID = 7001L;
|
||||
private static final String PLATE = "桂AAW3255";
|
||||
private static final String CLOCK_PHOTO = "https://oss/clock.jpg";
|
||||
|
||||
/** 末位打卡节点是「签收」,没有回单节点 */
|
||||
private static final String SIGN_LAST_JSON = """
|
||||
[{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true}]
|
||||
""";
|
||||
|
||||
/** 末位打卡节点是「签收」,回单启用且需确认完成 */
|
||||
private static final String SIGN_LAST_RETURN_CONFIRM_JSON = """
|
||||
[{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true},
|
||||
{"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"yes"}]
|
||||
""";
|
||||
|
||||
/** 末位打卡节点是「发货」,不是到货/卸货/签收 */
|
||||
private static final String DISPATCH_LAST_JSON = """
|
||||
[{"key":"dispatch","name":"发货","type":"cargo","enabled":true,"punch":true}]
|
||||
""";
|
||||
|
||||
private IWaybillService waybillService;
|
||||
private IDriverService driverService;
|
||||
private WaybillNodePunchMapper nodePunchMapper;
|
||||
private ExceptionDisposalMapper exceptionDisposalMapper;
|
||||
private DriverWaybillServiceImpl service;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
// 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, Waybill.class);
|
||||
TableInfoHelper.initTableInfo(assistant, Driver.class);
|
||||
TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
waybillService = mock(IWaybillService.class);
|
||||
driverService = mock(IDriverService.class);
|
||||
nodePunchMapper = mock(WaybillNodePunchMapper.class);
|
||||
exceptionDisposalMapper = mock(ExceptionDisposalMapper.class);
|
||||
service = new DriverWaybillServiceImpl(
|
||||
waybillService,
|
||||
driverService,
|
||||
mock(IProcessConfigService.class),
|
||||
mock(ITransportVehicleService.class),
|
||||
mock(CertificationGate.class),
|
||||
mock(WaybillEnroutePunchMapper.class),
|
||||
nodePunchMapper,
|
||||
exceptionDisposalMapper);
|
||||
|
||||
when(driverService.getOne(any())).thenReturn(driver());
|
||||
when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(waybillService.updateById(any())).thenReturn(true);
|
||||
when(nodePunchMapper.selectList(any())).thenReturn(List.of());
|
||||
when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of());
|
||||
when(nodePunchMapper.insert(any(WaybillNodePunch.class))).thenReturn(1);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 命中
|
||||
|
||||
@Test
|
||||
@DisplayName("末位签收打卡且回单无需确认时调完成接口,响应标记 waybillCompleted")
|
||||
void lastDeliveryPunchCompletesWaybill() {
|
||||
givenWaybill(SIGN_LAST_JSON, "running");
|
||||
when(waybillService.completeWithoutDeptCheck(WAYBILL_ID)).thenReturn(true);
|
||||
|
||||
DriverNodePunchVO vo = submit("sign");
|
||||
|
||||
verify(waybillService).completeWithoutDeptCheck(WAYBILL_ID);
|
||||
assertTrue(vo.getWaybillCompleted(), "完成接口返回成功,响应要告诉前端运单已完成");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("waybillCompleted 取完成接口的返回值,不按判定命中臆断")
|
||||
void completionFlagFollowsServiceReturn() {
|
||||
givenWaybill(SIGN_LAST_JSON, "running");
|
||||
when(waybillService.completeWithoutDeptCheck(WAYBILL_ID)).thenReturn(false);
|
||||
|
||||
DriverNodePunchVO vo = submit("sign");
|
||||
|
||||
verify(waybillService).completeWithoutDeptCheck(WAYBILL_ID);
|
||||
assertFalse(vo.getWaybillCompleted(), "完成接口自己说不算完成时,前端不能提示运单已完成");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 不命中
|
||||
|
||||
@Test
|
||||
@DisplayName("末位是发货时只打卡不完成")
|
||||
void punchOnNonDeliveryLastNodeDoesNotComplete() {
|
||||
givenWaybill(DISPATCH_LAST_JSON, "running");
|
||||
|
||||
DriverNodePunchVO vo = submit("dispatch");
|
||||
|
||||
verify(waybillService, never()).completeWithoutDeptCheck(any());
|
||||
verify(nodePunchMapper).insert(any(WaybillNodePunch.class));
|
||||
assertFalse(vo.getWaybillCompleted());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("回单启用且需确认完成时只打卡不完成")
|
||||
void returnNeedingConfirmationDoesNotComplete() {
|
||||
givenWaybill(SIGN_LAST_RETURN_CONFIRM_JSON, "running");
|
||||
|
||||
DriverNodePunchVO vo = submit("sign");
|
||||
|
||||
verify(waybillService, never()).completeWithoutDeptCheck(any());
|
||||
verify(nodePunchMapper).insert(any(WaybillNodePunch.class));
|
||||
assertFalse(vo.getWaybillCompleted());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 前置:运单本身能否完成
|
||||
|
||||
@Test
|
||||
@DisplayName("业务状态为 draft 时只打卡不完成,打卡照常成功")
|
||||
void draftWaybillIsPunchedButNotCompleted() {
|
||||
givenWaybill(SIGN_LAST_JSON, "draft");
|
||||
|
||||
DriverNodePunchVO vo = submit("sign");
|
||||
|
||||
verify(waybillService, never()).completeWithoutDeptCheck(any());
|
||||
verify(nodePunchMapper).insert(any(WaybillNodePunch.class));
|
||||
assertFalse(vo.getWaybillCompleted());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("待执行与调度中间态同样允许自动完成")
|
||||
void dispatchableStatusesStillComplete() {
|
||||
for (String status : List.of("pending", "waiting_dispatch", "dispatching")) {
|
||||
givenWaybill(SIGN_LAST_JSON, status);
|
||||
when(waybillService.completeWithoutDeptCheck(WAYBILL_ID)).thenReturn(true);
|
||||
|
||||
DriverNodePunchVO vo = submit("sign");
|
||||
|
||||
assertTrue(vo.getWaybillCompleted(), status + " 属于可完成状态");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("有未了结异常时不自动完成——与司机端「完成运单」同一口径,异常不落在打卡节点上也拦")
|
||||
void unresolvedExceptionBlocksAutoComplete() {
|
||||
givenWaybill(SIGN_LAST_JSON, "running");
|
||||
when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of(unresolvedException("unload")));
|
||||
|
||||
DriverNodePunchVO vo = submit("sign");
|
||||
|
||||
verify(waybillService, never()).completeWithoutDeptCheck(any());
|
||||
verify(nodePunchMapper).insert(any(WaybillNodePunch.class));
|
||||
assertFalse(vo.getWaybillCompleted());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
private DriverNodePunchVO submit(String nodeCode) {
|
||||
NodeSubmitDTO dto = new NodeSubmitDTO();
|
||||
dto.setWaybillId(WAYBILL_ID);
|
||||
dto.setNodeCode(nodeCode);
|
||||
dto.setPhotoItems(List.of(photo("打卡照片", CLOCK_PHOTO)));
|
||||
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class)) {
|
||||
authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID);
|
||||
return service.submitNode(dto);
|
||||
}
|
||||
}
|
||||
|
||||
private void givenWaybill(String processJson, String businessStatus) {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setId(WAYBILL_ID);
|
||||
waybill.setWaybillNo("YD202610080001");
|
||||
waybill.setVehicleNo(PLATE);
|
||||
waybill.setProcessJson(processJson);
|
||||
waybill.setBusinessStatus(businessStatus);
|
||||
waybill.setIsDeleted(0);
|
||||
when(waybillService.getOne(any())).thenReturn(waybill);
|
||||
}
|
||||
|
||||
private PunchPhotoDTO photo(String type, String url) {
|
||||
PunchPhotoDTO photo = new PunchPhotoDTO();
|
||||
photo.setType(type);
|
||||
photo.setUrl(url);
|
||||
return photo;
|
||||
}
|
||||
|
||||
private Driver driver() {
|
||||
Driver driver = new Driver();
|
||||
driver.setId(9001L);
|
||||
driver.setUserId(USER_ID);
|
||||
driver.setDrivingVehicle(PLATE);
|
||||
return driver;
|
||||
}
|
||||
|
||||
private ExceptionDisposal unresolvedException(String nodeCode) {
|
||||
ExceptionDisposal exception = new ExceptionDisposal();
|
||||
exception.setNodeCode(nodeCode);
|
||||
exception.setDisposalStatus("processing");
|
||||
return exception;
|
||||
}
|
||||
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
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.springblade.transport.mapper.ExceptionDisposalMapper;
|
||||
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
|
||||
import org.springblade.transport.mapper.WaybillNodePunchMapper;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.entity.WaybillEnroutePunch;
|
||||
import org.springblade.transport.pojo.entity.WaybillNodePunch;
|
||||
import org.springblade.transport.pojo.vo.DriverWaybillCardVO;
|
||||
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 static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 司机端卡片上的发货地 / 到货地。
|
||||
* <p>
|
||||
* 地点名称与详细地址是两列:批量导入的运单只填了地址(导入模板里没有「发货地」「收货地」列),
|
||||
* 名称为空时卡片要回退到地址,否则司机端的地点位置是空的。
|
||||
*/
|
||||
class DriverWaybillCardLocationTest {
|
||||
|
||||
private static final Long WAYBILL_ID = 5601L;
|
||||
|
||||
private static final String PROCESS_JSON = """
|
||||
[{"key":"accept","name":"接单","type":"confirm","enabled":true,"punch":true},
|
||||
{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true},
|
||||
{"key":"transit","name":"在途","type":"transit","enabled":true,"punch":true},
|
||||
{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true}]
|
||||
""";
|
||||
|
||||
private static final String DEPARTURE_ADDRESS = "广西壮族自治区南宁市西乡塘区安吉大道";
|
||||
private static final String ARRIVAL_ADDRESS = "广西壮族自治区河池市金城江区金城中路1号";
|
||||
|
||||
private IWaybillService waybillService;
|
||||
private DriverWaybillServiceImpl service;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
// 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class);
|
||||
TableInfoHelper.initTableInfo(assistant, WaybillEnroutePunch.class);
|
||||
TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
waybillService = mock(IWaybillService.class);
|
||||
WaybillNodePunchMapper nodePunchMapper = mock(WaybillNodePunchMapper.class);
|
||||
WaybillEnroutePunchMapper enroutePunchMapper = mock(WaybillEnroutePunchMapper.class);
|
||||
ExceptionDisposalMapper exceptionDisposalMapper = mock(ExceptionDisposalMapper.class);
|
||||
service = new DriverWaybillServiceImpl(
|
||||
waybillService,
|
||||
mock(IDriverService.class),
|
||||
mock(IProcessConfigService.class),
|
||||
mock(ITransportVehicleService.class),
|
||||
mock(CertificationGate.class),
|
||||
enroutePunchMapper,
|
||||
nodePunchMapper,
|
||||
exceptionDisposalMapper);
|
||||
|
||||
when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(nodePunchMapper.selectList(any())).thenReturn(List.of());
|
||||
when(enroutePunchMapper.selectOne(any())).thenReturn(null);
|
||||
when(enroutePunchMapper.selectList(any())).thenReturn(List.of());
|
||||
when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("地点名称为空(导入的运单)时,名称回退到详细地址")
|
||||
void nameFallsBackToAddressWhenBlank() {
|
||||
Waybill waybill = waybill();
|
||||
waybill.setDepartureName(null);
|
||||
waybill.setDepartureAddress(DEPARTURE_ADDRESS);
|
||||
waybill.setArrivalName("");
|
||||
waybill.setArrivalAddress(ARRIVAL_ADDRESS);
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill);
|
||||
|
||||
DriverWaybillCardVO card = service.detailPunchSnapshot(WAYBILL_ID);
|
||||
|
||||
assertEquals(DEPARTURE_ADDRESS, card.getFromName());
|
||||
assertEquals(ARRIVAL_ADDRESS, card.getToName());
|
||||
assertEquals(DEPARTURE_ADDRESS, card.getFromAddress());
|
||||
assertEquals(ARRIVAL_ADDRESS, card.getToAddress());
|
||||
assertEquals(DEPARTURE_ADDRESS, card.getPickupAddress());
|
||||
assertEquals(ARRIVAL_ADDRESS, card.getUnloadAddress());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("地点名称有值时用它,不被地址覆盖")
|
||||
void nameWinsWhenPresent() {
|
||||
Waybill waybill = waybill();
|
||||
waybill.setDepartureName("南宁市西乡塘区");
|
||||
waybill.setDepartureAddress(DEPARTURE_ADDRESS);
|
||||
waybill.setArrivalName("河池市金城江区");
|
||||
waybill.setArrivalAddress(ARRIVAL_ADDRESS);
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill);
|
||||
|
||||
DriverWaybillCardVO card = service.detailPunchSnapshot(WAYBILL_ID);
|
||||
|
||||
assertEquals("南宁市西乡塘区", card.getFromName());
|
||||
assertEquals("河池市金城江区", card.getToName());
|
||||
assertEquals(DEPARTURE_ADDRESS, card.getFromAddress());
|
||||
assertEquals(ARRIVAL_ADDRESS, card.getToAddress());
|
||||
}
|
||||
|
||||
private Waybill waybill() {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setId(WAYBILL_ID);
|
||||
waybill.setWaybillNo("YD-20261007-0006");
|
||||
waybill.setProcessJson(PROCESS_JSON);
|
||||
waybill.setBusinessStatus("running");
|
||||
waybill.setIsDeleted(0);
|
||||
return waybill;
|
||||
}
|
||||
|
||||
}
|
||||
+230
@@ -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;
|
||||
}
|
||||
}
|
||||
+198
@@ -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();
|
||||
}
|
||||
}
|
||||
+13
@@ -1,6 +1,10 @@
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
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;
|
||||
@@ -53,6 +57,15 @@ class PersonCertRoundTripTest {
|
||||
private Driver driver;
|
||||
private long idSequence = 100L;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
// 构造 LambdaUpdateWrapper 需要 MyBatis-Plus 的实体元数据缓存
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, Driver.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
recordStore.clear();
|
||||
|
||||
+140
@@ -172,4 +172,144 @@ class WaybillProcessSupportTest {
|
||||
assertFalse(WaybillProcessSupport.sameNodeCode(null, null));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 到货/卸货/签收
|
||||
|
||||
@Test
|
||||
@DisplayName("到货/卸货/签收:key 或中文名任一命中即可,两者都为空不算")
|
||||
void deliveryNodeMatchesKeyOrName() {
|
||||
assertTrue(WaybillProcessSupport.isDeliveryNode("arrival", "到货"));
|
||||
assertTrue(WaybillProcessSupport.isDeliveryNode("unload", "卸货"));
|
||||
assertTrue(WaybillProcessSupport.isDeliveryNode("sign", "签收"));
|
||||
assertTrue(WaybillProcessSupport.isDeliveryNode("arrival", ""), "只有 key 也要认,别把中文名当必要条件");
|
||||
assertTrue(WaybillProcessSupport.isDeliveryNode("", "签收"), "只有中文名也要认——key 可能被改成自定义编码");
|
||||
assertFalse(WaybillProcessSupport.isDeliveryNode("dispatch", "发货"));
|
||||
assertFalse(WaybillProcessSupport.isDeliveryNode("transit", "在途"));
|
||||
assertFalse(WaybillProcessSupport.isDeliveryNode("", ""));
|
||||
assertFalse(WaybillProcessSupport.isDeliveryNode(null, null));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 末位司机打卡节点
|
||||
|
||||
/** 末位是「到货」,其后没有打卡节点 */
|
||||
private static final String ARRIVAL_LAST_JSON = """
|
||||
[{"key":"load","name":"装货","enabled":true,"punch":true},
|
||||
{"key":"arrival","name":"到货","enabled":true,"punch":true}]
|
||||
""";
|
||||
|
||||
/** 末位是「卸货」 */
|
||||
private static final String UNLOAD_LAST_JSON = """
|
||||
[{"key":"load","name":"装货","enabled":true,"punch":true},
|
||||
{"key":"unload","name":"卸货","enabled":true,"punch":true}]
|
||||
""";
|
||||
|
||||
/** 末位是「在途」 */
|
||||
private static final String TRANSIT_LAST_JSON = """
|
||||
[{"key":"load","name":"装货","enabled":true,"punch":true},
|
||||
{"key":"transit","name":"在途","type":"transit","enabled":true,"punch":true}]
|
||||
""";
|
||||
|
||||
/** 末位是「发货」 */
|
||||
private static final String DISPATCH_LAST_JSON = """
|
||||
[{"key":"load","name":"装货","enabled":true,"punch":true},
|
||||
{"key":"dispatch","name":"发货","enabled":true,"punch":true}]
|
||||
""";
|
||||
|
||||
@Test
|
||||
@DisplayName("末位打卡节点取「司机可打卡节点」的末位:接单与回单就算启用也不算")
|
||||
void lastDriverPunchNodeSkipsAcceptAndReturn() {
|
||||
assertTrue(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, "sign"),
|
||||
"回单启用在配置里排最末,但回单不打卡,末位打卡节点仍是签收");
|
||||
assertFalse(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, "unload"));
|
||||
assertFalse(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, "arrival"));
|
||||
assertFalse(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, "return"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("末位是签收 / 到货 / 卸货才同时成立「末位」与「到货卸货签收」;末位是在途 / 发货只满足前者")
|
||||
void lastNodeIsDeliveryOnlyForDeliveryTypes() {
|
||||
assertTrue(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, "sign")
|
||||
&& WaybillProcessSupport.isDeliveryNode("sign", "签收"));
|
||||
assertTrue(WaybillProcessSupport.isLastDriverPunchNode(ARRIVAL_LAST_JSON, "arrival")
|
||||
&& WaybillProcessSupport.isDeliveryNode("arrival", "到货"));
|
||||
assertTrue(WaybillProcessSupport.isLastDriverPunchNode(UNLOAD_LAST_JSON, "unload")
|
||||
&& WaybillProcessSupport.isDeliveryNode("unload", "卸货"));
|
||||
assertTrue(WaybillProcessSupport.isLastDriverPunchNode(TRANSIT_LAST_JSON, "transit"),
|
||||
"在途排在末位时它确实是末位打卡节点");
|
||||
assertFalse(WaybillProcessSupport.isDeliveryNode("transit", "在途"), "在途不是可完成节点");
|
||||
assertTrue(WaybillProcessSupport.isLastDriverPunchNode(DISPATCH_LAST_JSON, "dispatch"));
|
||||
assertFalse(WaybillProcessSupport.isDeliveryNode("dispatch", "发货"), "发货不是可完成节点");
|
||||
assertFalse(WaybillProcessSupport.isLastDriverPunchNode(DISPATCH_LAST_JSON, "unload"),
|
||||
"该配置里没有卸货节点,不能因为编码眼熟就认下");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("没有可打卡节点(或没有过程配置)时,谁都不是末位")
|
||||
void lastDriverPunchNodeWithoutPunchNodes() {
|
||||
String onlyAcceptAndReturn = """
|
||||
[{"key":"accept","name":"接单","enabled":true,"punch":true},
|
||||
{"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true}]
|
||||
""";
|
||||
|
||||
assertFalse(WaybillProcessSupport.isLastDriverPunchNode(onlyAcceptAndReturn, "accept"));
|
||||
assertFalse(WaybillProcessSupport.isLastDriverPunchNode(onlyAcceptAndReturn, "return"));
|
||||
assertFalse(WaybillProcessSupport.isLastDriverPunchNode("", "sign"));
|
||||
assertFalse(WaybillProcessSupport.isLastDriverPunchNode(null, "sign"));
|
||||
assertFalse(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, null));
|
||||
assertFalse(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, ""));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 回单是否无需确认完成
|
||||
|
||||
/** 回单启用且配置为「无需确认完成」 */
|
||||
private static final String RETURN_NO_CONFIRM_JSON = """
|
||||
[{"key":"load","name":"装货","enabled":true,"punch":true},
|
||||
{"key":"sign","name":"签收","enabled":true,"punch":true},
|
||||
{"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"no_confirm_complete"}]
|
||||
""";
|
||||
|
||||
/** 回单启用且配置为「需确认完成」 */
|
||||
private static final String RETURN_NEED_CONFIRM_JSON = """
|
||||
[{"key":"load","name":"装货","enabled":true,"punch":true},
|
||||
{"key":"sign","name":"签收","enabled":true,"punch":true},
|
||||
{"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"yes"}]
|
||||
""";
|
||||
|
||||
/** 回单未启用,但 confirmMode 写着「需确认完成」 */
|
||||
private static final String RETURN_DISABLED_JSON = """
|
||||
[{"key":"load","name":"装货","enabled":true,"punch":true},
|
||||
{"key":"sign","name":"签收","enabled":true,"punch":true},
|
||||
{"key":"return","name":"回单","type":"confirm","enabled":false,"punch":true,"confirmMode":"yes"}]
|
||||
""";
|
||||
|
||||
/** 配置里根本没有回单节点 */
|
||||
private static final String NO_RETURN_JSON = """
|
||||
[{"key":"load","name":"装货","enabled":true,"punch":true},
|
||||
{"key":"sign","name":"签收","enabled":true,"punch":true}]
|
||||
""";
|
||||
|
||||
@Test
|
||||
@DisplayName("回单启用时要看 confirmMode:只有「无需确认完成」才成立")
|
||||
void enabledReturnNeedsConfirmMode() {
|
||||
assertTrue(WaybillProcessSupport.returnNeedsNoConfirmation(RETURN_NO_CONFIRM_JSON));
|
||||
assertFalse(WaybillProcessSupport.returnNeedsNoConfirmation(RETURN_NEED_CONFIRM_JSON),
|
||||
"回单需要确认完成时,打完签收不能算整单完成");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("回单未启用、或配置里没有回单节点,一律视为无需确认完成")
|
||||
void absentOrDisabledReturnNeedsNoConfirmation() {
|
||||
assertTrue(WaybillProcessSupport.returnNeedsNoConfirmation(RETURN_DISABLED_JSON),
|
||||
"回单未启用时 confirmMode 写着需确认也不算数:没启用的环节不会有人来确认");
|
||||
assertTrue(WaybillProcessSupport.returnNeedsNoConfirmation(NO_RETURN_JSON));
|
||||
assertTrue(WaybillProcessSupport.returnNeedsNoConfirmation(""));
|
||||
assertTrue(WaybillProcessSupport.returnNeedsNoConfirmation(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("回单启用但 confirmMode 缺失时按「需确认完成」算")
|
||||
void enabledReturnWithoutConfirmModeNeedsConfirmation() {
|
||||
assertFalse(WaybillProcessSupport.returnNeedsNoConfirmation(TRANSPORT_JSON),
|
||||
"TRANSPORT_JSON 的回单启用且没有 confirmMode:缺字段当没配「无需确认完成」,否则会跳过本该人工确认的回单");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user