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:
2026-10-12 10:39:38 +08:00
20 changed files with 1441 additions and 132 deletions
@@ -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());
}
@@ -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));
}
/**
@@ -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}$");
}
}
@@ -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));
}
}
/**
@@ -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));
}
/**
@@ -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 "";
@@ -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"));