diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminWaybillCardVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminWaybillCardVO.java index 9e63df31..b0dca1a4 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminWaybillCardVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminWaybillCardVO.java @@ -92,4 +92,30 @@ public class AdminWaybillCardVO implements Serializable { @Schema(description = "采购方是否已付款(预留)") private Boolean buyerPaid; + /* ===== 运次(一趟车)字段:列表按运次下发,卡片的展示单号与路线取这些 ===== */ + + @Schema(description = "运次主键:配载单主键或运单主键,按字符串下发(19 位雪花号按数字下发会被前端取整)") + private String tripId; + + @Schema(description = "配载单号,未配载为空") + private String loadingNo; + + @Schema(description = "展示单号:有配载取配载单号,否则取运单号") + private String tripNo; + + @Schema(description = "途经站点:整趟车中间各站按顺序以「 → 」连接,无中间站为空") + private String viaName; + + @Schema(description = "站点数(整趟车,含起终点)") + private Integer stationCount; + + @Schema(description = "货物汇总(整趟车,同名货物合并且单位一致时取数量合计)") + private String cargoSummary; + + @Schema(description = "合计重量(整趟车同单位时才有值,单位不一致为空)") + private String totalWeight; + + @Schema(description = "子运单数(可见口径;配载运次即整趟车的子单数)") + private Integer waybillCount; + } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminWaybillDetailVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminWaybillDetailVO.java index ebbd9cce..b799765c 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminWaybillDetailVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminWaybillDetailVO.java @@ -135,6 +135,29 @@ public class AdminWaybillDetailVO implements Serializable { @Schema(description = "途打卡记录") private List enrouteRecords; + /* ===== 运次(一趟车)字段:详情按运次下发,与司机端接单页同一口径 ===== */ + + @Schema(description = "运次主键:配载单主键或运单主键,按字符串下发(19 位雪花号按数字下发会被前端取整)") + private String tripId; + + @Schema(description = "配载单号,未配载为空") + private String loadingNo; + + @Schema(description = "展示单号:有配载取配载单号,否则取运单号") + private String tripNo; + + @Schema(description = "途经站点:整趟车中间各站按顺序以「 → 」连接,无中间站为空") + private String viaName; + + @Schema(description = "站点数(整趟车,含起终点)") + private Integer stationCount; + + @Schema(description = "货物汇总(整趟车,同名货物合并且单位一致时取数量合计)") + private String cargoSummary; + + @Schema(description = "站点任务安排(整趟车按实际途经顺序,第 1 站即 ①;配载运次含每站装卸与货物条目)") + private List stations; + @Data @Schema(description = "装卸点") public static class AdminRoutePointVO implements Serializable { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java index 0b7dda41..291a5582 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java @@ -99,9 +99,9 @@ public class ManageWaybillController extends BladeController { @GetMapping("/detail") @ApiOperationSupport(order = 4) - @Operation(summary = "运单详情", description = "调度端查看运单详情(含 punchNodes / enrouteRecords),不校验司机归属与组织;字段对齐小程序 pages/waybill/detail") + @Operation(summary = "运次详情", description = "调度端查看整趟车详情(含 stations / punchNodes / enrouteRecords),不校验司机归属与组织;id 传运次主键字符串,已拒单的运单主键回落成单票视图") public R detail( - @Parameter(description = "运单ID", required = true) @RequestParam Long id) { + @Parameter(description = "运次主键(配载单主键或运单主键,字符串)", required = true) @RequestParam String id) { return R.data(manageWaybillService.detail(id)); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IManageWaybillService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IManageWaybillService.java index 08bd411c..64a3f9ee 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IManageWaybillService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IManageWaybillService.java @@ -63,9 +63,12 @@ public interface IManageWaybillService { String exception, String transportType, String startDate, String endDate); /** - * 调度端运单详情(不校验司机归属) + * 调度端运次详情(不校验司机归属) + *

+ * 传运次主键:配载单主键或未配载运单主键,都按字符串。已拒单的子运单按运次取不到(运次口径 + * 不含已拒子单),此时按运单主键回落成单票视图,供重新派单等从运单进入的场景使用。 */ - AdminWaybillDetailVO detail(Long id); + AdminWaybillDetailVO detail(String tripId); /** * 待处理运单(待接单 / 运输中;可筛需重新派单) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java index 71327729..273390ab 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java @@ -23,16 +23,19 @@ package org.springblade.transport.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import lombok.RequiredArgsConstructor; +import org.springblade.core.log.exception.ServiceException; import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.utils.DateUtil; import org.springblade.core.tool.utils.Func; import org.springblade.system.cache.UserDisplayCache; import org.springblade.transport.pojo.entity.Driver; import org.springblade.transport.pojo.entity.ExceptionDisposal; +import org.springblade.transport.pojo.entity.LoadingManage; import org.springblade.transport.pojo.entity.RiskDisposal; import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.pojo.vo.AdminDriverOptionVO; @@ -44,12 +47,19 @@ import org.springblade.transport.pojo.vo.AdminVehicleOptionVO; import org.springblade.transport.pojo.vo.AdminWaybillCardVO; import org.springblade.transport.pojo.vo.AdminWaybillDetailVO; import org.springblade.transport.pojo.vo.DriverWaybillCardVO; +import org.springblade.transport.pojo.vo.TripQueryVO; +import org.springblade.transport.pojo.vo.TripStationVO; +import org.springblade.transport.pojo.vo.TripTabCountsVO; +import org.springblade.transport.pojo.vo.TripVO; import org.springblade.transport.service.IDriverService; import org.springblade.transport.service.IDriverWaybillService; import org.springblade.transport.service.IExceptionDisposalService; +import org.springblade.transport.service.ILoadingManageService; import org.springblade.transport.service.IManageWaybillService; import org.springblade.transport.service.IRiskDisposalService; +import org.springblade.transport.service.ITripQueryService; import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.WaybillProcessSupport; import org.springframework.stereotype.Service; import java.math.BigDecimal; @@ -58,16 +68,24 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.Date; import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.function.Function; import java.util.stream.Collectors; /** - * 调度端:运单状态统计 + 异常/风险角标 + 待处理事项 + 运单列表 + * 调度端:运次状态统计 + 异常/风险角标 + 待处理事项 + 运次列表 + *

+ * 列表与详情与司机端同粒度:取数、状态聚合、接单需求合并都调共享运次读服务, + * 本类只做字段裁剪与调度端独有的筛选(组织类型 / 异常 / 创建时间 / 待重新派单)。 *

* 小程序调度账号(如「小程序管理」)组织常与运单业务组织不一致,故不做 dept 过滤, * 仅依赖租户隔离,口径接近后台 {@code /waybill-manage/list?allDept=1}。 @@ -102,6 +120,25 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { private static final int DEFAULT_PAGE_SIZE = 10; private static final int MAX_PAGE_SIZE = 50; + /** + * 库层分组键:有配载按配载单号成组,未配载每张运单自成一组(W 前缀避免与配载单号撞键)。 + * 与运次读服务同一个表达式,两处的「运次」才是同一个东西。 + */ + private static final String SQL_TRIP_KEY = "IFNULL(NULLIF(loading_no, ''), CONCAT('W', id))"; + /** 待重新派单的排序键:运次内子单被拒的时刻取最大值 */ + private static final String SQL_MAX_REJECT_TIME = "MAX(driver_reject_time)"; + /** 待重新派单的排序键:运次内子单创建时刻取最大值(与运次列表同一排序口径) */ + private static final String SQL_MAX_CREATE_TIME = "MAX(create_time)"; + /** 关键字在子单上匹配的字段:与运次读服务同一份口径 */ + private static final String[] KEYWORD_COLUMNS = { + "k.waybill_no", "k.loading_no", "k.departure_name", "k.arrival_name", + "k.departure_address", "k.arrival_address", "k.vehicle_no" + }; + /** 已拒子单命中的运次键:待重新派单的判据(已拒单 + 未取消未完成),与首页待办同源 */ + private static final String SQL_REJECTED_TRIP_KEYS = "SELECT IFNULL(NULLIF(r.loading_no, ''), CONCAT('W', r.id))" + + " FROM blade_waybill r WHERE r.is_deleted = 0 AND r.driver_accept_status = '" + ACCEPT_REJECTED + "'" + + " AND r.business_status IN ('" + STATUS_PENDING + "', '" + STATUS_RUNNING + "')"; + private static final DateTimeFormatter DATE_MD = DateTimeFormatter.ofPattern("MM-dd"); private static final DateTimeFormatter DATE_YMD = DateTimeFormatter.ofPattern("yyyy-MM-dd"); @@ -110,13 +147,17 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { private final IExceptionDisposalService exceptionDisposalService; private final IRiskDisposalService riskDisposalService; private final IDriverWaybillService driverWaybillService; + private final ITripQueryService tripQueryService; + private final ILoadingManageService loadingManageService; @Override public AdminHomeStatsVO stats() { AdminHomeStatsVO vo = new AdminHomeStatsVO(); - vo.setPendingAccept(countWaybillByStatus(STATUS_PENDING)); - vo.setTransporting(countWaybillByStatus(STATUS_RUNNING)); - vo.setCompleted(countWaybillByStatus(STATUS_COMPLETED)); + // 三档状态按运次计:有配载单的一趟车算一个,与列表和司机端同一份聚合 + TripTabCountsVO tripCounts = tripQueryService.tabCounts(TripQueryVO.forAdmin()); + vo.setPendingAccept(tripCounts.getPending()); + vo.setTransporting(tripCounts.getDoing()); + vo.setCompleted(tripCounts.getDone()); vo.setException(countIncompleteExceptions()); return vo; } @@ -144,57 +185,45 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { Set exceptionWaybillIds = loadIncompleteExceptionWaybillIds(); if (EXCEPTION_YES.equals(exception) && exceptionWaybillIds.isEmpty()) { - Page emptyVo = new Page<>(pageNo, pageSize, 0); - emptyVo.setRecords(List.of()); - return emptyVo; + return emptyCardPage(pageNo, pageSize); } - LambdaQueryWrapper wrapper = scopedWaybillQuery(); - applyStatusFilter(wrapper, status); - applyExceptionFilter(wrapper, exception, exceptionWaybillIds); - applyTransportTypeFilter(wrapper, transportType); - applyKeywordFilter(wrapper, keyword); - applyCreateTimeFilter(wrapper, startDate, endDate); - wrapper.orderByDesc(Waybill::getCreateTime); + TripQueryVO query = TripQueryVO.forAdmin(); + query.setStatus(toTripStatus(status)); + query.setKeyword(keyword); - IPage entityPage = waybillService.page(new Page<>(pageNo, pageSize), wrapper); - List pageIds = entityPage.getRecords().stream() - .map(Waybill::getId) - .filter(Objects::nonNull) - .toList(); - Set pageExceptionIds = pageIds.isEmpty() - ? Collections.emptySet() - : exceptionWaybillIds.stream().filter(pageIds::contains).collect(Collectors.toSet()); - - Page voPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal()); - voPage.setRecords(entityPage.getRecords().stream() - .map(w -> toCard(w, pageExceptionIds.contains(w.getId()))) - .toList()); - return voPage; + // 运次读服务只认状态、关键字与分页;组织类型 / 异常 / 创建时间这些筛选它表达不了, + // 走库层分组取这一页的运次主键与总数,再逐条交给它取整趟车(状态与货物汇总仍出自同一份实现) + boolean filtered = Func.isNotBlank(transportType) || Func.isNotBlank(exception) + || Func.isNotBlank(startDate) || Func.isNotBlank(endDate); + if (!filtered) { + IPage trips = tripQueryService.page(tripPageQuery(query, pageNo, pageSize)); + return tripCardPage(trips.getRecords(), pageNo, pageSize, trips.getTotal(), exceptionWaybillIds); + } + return groupedTripPage(filteredTripKeyQuery(query, transportType, exception, exceptionWaybillIds, + parseDayStart(startDate), parseDayEnd(endDate)), pageNo, pageSize, exceptionWaybillIds); } @Override - public AdminWaybillDetailVO detail(Long id) { + public AdminWaybillDetailVO detail(String tripId) { + Long id = parseId(tripId); if (id == null) { - throw new org.springblade.core.log.exception.ServiceException("运单ID不能为空"); + throw new ServiceException("运次不存在或无权操作"); } + TripVO trip = visibleTrip(tripId); + if (trip != null) { + return tripDetail(trip); + } + // 已拒单的子运单按运次取不到(运次口径不含已拒子单),此时按运单主键回落,供重新派单等从运单进入的场景使用 Waybill waybill = waybillService.getById(id); - if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) { - throw new org.springblade.core.log.exception.ServiceException("运单不存在"); + if (waybill != null && !Objects.equals(waybill.getIsDeleted(), 1)) { + return waybillDetail(waybill); } - boolean hasException = false; - Long exceptionId = null; - ExceptionDisposal latest = exceptionDisposalService.getOne(Wrappers.lambdaQuery() - .eq(ExceptionDisposal::getWaybillId, id) - .in(ExceptionDisposal::getDisposalStatus, DISPOSAL_PENDING, DISPOSAL_PROCESSING) - .orderByDesc(ExceptionDisposal::getReportTime) - .orderByDesc(ExceptionDisposal::getCreateTime) - .last("LIMIT 1")); - if (latest != null) { - hasException = true; - exceptionId = latest.getId(); + LoadingManage loading = loadingManageService.getById(id); + if (loading == null) { + throw new ServiceException("运次不存在或无权操作"); } - return toDetail(waybill, hasException, exceptionId); + return loadingDetail(loading, visibleChildren(loading.getLoadingNo())); } @Override @@ -202,32 +231,681 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { int pageNo = current == null || current < 1 ? 1 : current; int pageSize = size == null || size < 1 ? DEFAULT_PAGE_SIZE : Math.min(size, MAX_PAGE_SIZE); - LambdaQueryWrapper wrapper = scopedWaybillQuery() - .in(Waybill::getBusinessStatus, STATUS_PENDING, STATUS_RUNNING); if (Boolean.TRUE.equals(needReassign)) { - wrapper.eq(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED); - } else if (Boolean.FALSE.equals(needReassign)) { - wrapper.and(w -> w.isNull(Waybill::getDriverAcceptStatus) - .or().ne(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED)); + // 待重新派单必须按「子单被拒」找:运次口径把已拒子单排除在外,整单拒绝后这趟车在运次里已经不存在 + return reassignPendingPage(pageNo, pageSize, keyword); } - applyKeywordFilter(wrapper, keyword); - wrapper.orderByDesc(Waybill::getUpdateTime).orderByDesc(Waybill::getCreateTime); - IPage entityPage = waybillService.page(new Page<>(pageNo, pageSize), wrapper); Set exceptionWaybillIds = loadIncompleteExceptionWaybillIds(); - List pageIds = entityPage.getRecords().stream() - .map(Waybill::getId) - .filter(Objects::nonNull) - .toList(); - Set pageExceptionIds = pageIds.isEmpty() - ? Collections.emptySet() - : exceptionWaybillIds.stream().filter(pageIds::contains).collect(Collectors.toSet()); + TripQueryVO query = TripQueryVO.forAdmin(); + query.setKeyword(keyword); + return groupedTripPage(pendingTripKeyQuery(query, needReassign), pageNo, pageSize, exceptionWaybillIds); + } - Page voPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal()); - voPage.setRecords(entityPage.getRecords().stream() - .map(w -> toCard(w, pageExceptionIds.contains(w.getId()))) - .toList()); - return voPage; + /* =========================================================================== + * 运次取数:列表 / 待处理都走共享运次读服务,本类只做字段裁剪与调度端独有的筛选 + * =========================================================================*/ + + private TripQueryVO tripPageQuery(TripQueryVO base, int pageNo, int pageSize) { + TripQueryVO query = new TripQueryVO(); + query.setAllDrivers(base.isAllDrivers()); + query.setDriverPhone(base.getDriverPhone()); + query.setStatus(base.getStatus()); + query.setKeyword(base.getKeyword()); + query.setCurrent(pageNo); + query.setSize(pageSize); + return query; + } + + /** + * 库层按运次分组取这一页的运次主键与总数,再逐条向运次读服务取整趟车。 + *

+ * 为什么不是直接把筛选交给运次读服务:它只认状态、关键字与分页,没有筛选钩子; + * 而按运次在服务端过滤必须先扫完命中的运次才能给出列表口径的总数,几千个运次的租户下要十几秒。 + * 这里只把「它表达不了的筛选」落到库层,分组键与 Tab 判据都复用它的那份声明, + * 卡片上的状态、接单需求、站点与货物汇总仍由运次读服务下发。 + */ + private Page groupedTripPage(QueryWrapper keyQuery, int pageNo, int pageSize, + Set exceptionWaybillIds) { + Page rows = new Page<>(pageNo, pageSize); + waybillService.page(rows, keyQuery); + // 分组行只带运次键:配载运次要换成配载单主键,运次读服务才认这个主键 + Set loadingNos = rows.getRecords().stream().map(Waybill::getLoadingNo).filter(Func::isNotEmpty) + .collect(Collectors.toCollection(LinkedHashSet::new)); + Map loadings = loadingsByNo(loadingNos); + List trips = new ArrayList<>(rows.getRecords().size()); + for (Waybill row : rows.getRecords()) { + Long tripId = Func.isEmpty(row.getLoadingNo()) + ? row.getId() + : loadingId(loadings.get(row.getLoadingNo()), row.getId()); + TripVO trip = tripId == null ? null : visibleTrip(String.valueOf(tripId)); + if (trip != null) { + // 运次在两次查询之间被拒单/释放:这一页少一张卡,不拿没有子单的运次凑数 + trips.add(trip); + } + } + return tripCardPage(trips, pageNo, pageSize, rows.getTotal(), exceptionWaybillIds); + } + + /** + * 筛选用的库层运次查询:分组键、可见口径与 Tab 判据都与运次读服务同源 + */ + private QueryWrapper filteredTripKeyQuery(TripQueryVO base, String transportType, String exception, + Set exceptionWaybillIds, Date createdFrom, Date createdTo) { + QueryWrapper wrapper = visibleTripRows(); + applyTransportOrgTypeFilter(wrapper, transportType); + applyExceptionFilter(wrapper, exception, exceptionWaybillIds); + applyCreatedRangeFilter(wrapper, createdFrom, createdTo); + applyTripKeywordFilter(wrapper, base.getKeyword()); + wrapper.groupBy(SQL_TRIP_KEY); + wrapper.having(TripAggregation.tabPredicate(base.getStatus())); + wrapper.orderByDesc(SQL_MAX_CREATE_TIME); + return wrapper; + } + + /** + * 待处理列表的库层运次查询:待接单 + 进行中两档,可选「未拒单」。 + * 已拒子单的运次键子查询与首页待办同源,整单拒绝后这趟车不再算待处理。 + */ + private QueryWrapper pendingTripKeyQuery(TripQueryVO base, Boolean needReassign) { + QueryWrapper wrapper = visibleTripRows(); + applyTripKeywordFilter(wrapper, base.getKeyword()); + if (Boolean.FALSE.equals(needReassign)) { + wrapper.apply(SQL_TRIP_KEY + " NOT IN (" + SQL_REJECTED_TRIP_KEYS + ")"); + } + wrapper.groupBy(SQL_TRIP_KEY); + wrapper.having("(" + TripAggregation.tabPredicate(0) + " OR " + TripAggregation.tabPredicate(1) + ")"); + wrapper.orderByDesc(SQL_MAX_CREATE_TIME); + return wrapper; + } + + /** 可见运次行:与运次读服务的可见口径同一条(已拒子单不进运次,草稿不成运次) */ + private QueryWrapper visibleTripRows() { + QueryWrapper wrapper = Wrappers.query(); + wrapper.select("MIN(id) AS id", "MIN(NULLIF(loading_no, '')) AS loading_no", SQL_MAX_CREATE_TIME + " AS create_time"); + wrapper.apply("(driver_accept_status IS NULL OR driver_accept_status <> {0})", ACCEPT_REJECTED); + wrapper.in("business_status", TripAggregation.CHILD_STATUSES); + return wrapper; + } + + private void applyTransportOrgTypeFilter(QueryWrapper wrapper, String transportType) { + if (TRANSPORT_LOAD.equals(transportType)) { + wrapper.apply("IFNULL(loading_no, '') <> ''"); + } else if (TRANSPORT_COMMON.equals(transportType)) { + wrapper.apply("IFNULL(loading_no, '') = ''"); + } + } + + /** 异常筛选按运单命中:整趟车任一子单有未完成异常即命中(与旧运单级筛选同判据) */ + private void applyExceptionFilter(QueryWrapper wrapper, String exception, Set exceptionWaybillIds) { + if (exceptionWaybillIds.isEmpty()) { + return; + } + if (EXCEPTION_YES.equals(exception)) { + wrapper.in("id", exceptionWaybillIds); + } else if (EXCEPTION_NO.equals(exception)) { + wrapper.notIn("id", exceptionWaybillIds); + } + } + + private void applyCreatedRangeFilter(QueryWrapper wrapper, Date createdFrom, Date createdTo) { + if (createdFrom != null) { + wrapper.ge("create_time", createdFrom); + } + if (createdTo != null) { + wrapper.le("create_time", createdTo); + } + } + + /** 关键字与运次读服务同一口径:任一子单或配载单号命中即整趟车命中 */ + private void applyTripKeywordFilter(QueryWrapper wrapper, String keyword) { + if (Func.isBlank(keyword)) { + return; + } + Object[] params = new Object[KEYWORD_COLUMNS.length]; + StringBuilder matched = new StringBuilder(); + for (int i = 0; i < KEYWORD_COLUMNS.length; i++) { + if (i > 0) { + matched.append(" OR "); + } + matched.append(KEYWORD_COLUMNS[i]).append(" LIKE {").append(i).append('}'); + params[i] = "%" + keyword.trim() + "%"; + } + wrapper.apply(SQL_TRIP_KEY + " IN (SELECT IFNULL(NULLIF(k.loading_no, ''), CONCAT('W', k.id))" + + " FROM blade_waybill k WHERE k.is_deleted = 0 AND (" + matched + "))", params); + } + + private Page emptyCardPage(int pageNo, int pageSize) { + Page page = new Page<>(pageNo, pageSize, 0); + page.setRecords(List.of()); + return page; + } + + /** + * 运次卡片页:一次算清这一页的重派标记、异常与未配载运次的计划时间,再逐条拼卡片 + */ + private Page tripCardPage(List trips, int pageNo, int pageSize, long total, + Set exceptionWaybillIds) { + List cards = new ArrayList<>(trips.size()); + if (!trips.isEmpty()) { + Set reassignKeys = loadReassignTripKeys( + trips.stream().map(this::tripKeyOf).collect(Collectors.toCollection(LinkedHashSet::new))); + Map loneWaybills = loadLoneWaybills(trips); + for (TripVO trip : trips) { + cards.add(toTripCard(trip, exceptionWaybillIds, reassignKeys, loneWaybills)); + } + } + Page page = new Page<>(pageNo, pageSize, total); + page.setRecords(cards); + return page; + } + + /** + * 运次卡片:展示单号、整趟车起/经/终与站点数、货物汇总都取运次字段。 + * 旧字段 id / waybillNo 保留运单语义(未配载就是这张运单,配载取第一张可见子运单), + * 打卡订正、重新派单等按运单进入的入口仍靠它们。 + */ + private AdminWaybillCardVO toTripCard(TripVO trip, Set exceptionWaybillIds, + Set reassignTripIds, Map loneWaybills) { + AdminWaybillCardVO card = new AdminWaybillCardVO(); + List children = trip.getWaybills() == null ? List.of() : trip.getWaybills(); + Long firstChildId = children.isEmpty() ? null : parseId(children.get(0).getId()); + boolean loaded = Func.isNotEmpty(trip.getLoadingNo()); + + card.setTripId(trip.getId()); + card.setTripNo(Func.toStr(trip.getTripNo(), "")); + card.setLoadingNo(Func.toStr(trip.getLoadingNo(), "")); + card.setId(firstChildId == null ? parseId(trip.getId()) : firstChildId); + card.setWaybillNo(children.isEmpty() ? card.getTripNo() : Func.toStr(children.get(0).getWaybillNo(), "")); + card.setWaybillCount(trip.getWaybillCount()); + card.setTransportMode(Func.toStr(trip.getTransportType(), "")); + card.setTransportType(loaded ? TRANSPORT_LOAD : TRANSPORT_COMMON); + card.setFromName(Func.toStrWithEmpty(trip.getDepartureName(), Func.toStr(trip.getDepartureAddress(), ""))); + card.setToName(Func.toStrWithEmpty(trip.getArrivalName(), Func.toStr(trip.getArrivalAddress(), ""))); + card.setViaName(Func.toStr(trip.getTransitName(), "")); + card.setStationCount(trip.getStationCount()); + card.setCargo(Func.toStr(trip.getCargoSummary(), "")); + card.setCargoSummary(Func.toStr(trip.getCargoSummary(), "")); + String weight = formatWeight(trip.getTotalQuantity(), trip.getQuantityUnit()); + card.setWeight(weight); + card.setTotalWeight(weight); + if (loaded) { + card.setPlanTime(formatLocalDate(trip.getEstimatedStartDate())); + card.setPlanTimeEnd(formatLocalDate(trip.getEstimatedEndDate())); + } else { + // 未配载运次不带计划时间,按运单主键补一次;缺失时留空,不与其他运次串值 + Waybill waybill = loneWaybills.get(parseId(trip.getId())); + card.setPlanTime(formatLocalDate(waybill == null ? null : waybill.getEstimatedStartTime())); + card.setPlanTimeEnd(formatLocalDate(waybill == null ? null : waybill.getEstimatedEndTime())); + } + card.setStatus(trip.getStatus()); + card.setDriverName(Func.toStr(trip.getDriverName(), "")); + card.setVehicleNo(Func.toStr(trip.getVehicleNo(), "")); + card.setCreateTime(formatDateTime(trip.getSortTime())); + card.setNeedReassign(reassignTripIds.contains(tripKeyOf(trip))); + card.setHasException(hasChildException(children.stream().map(TripVO.TripWaybillVO::getId).toList(), exceptionWaybillIds)); + card.setBuyerPaid(false); + return card; + } + + /** + * 未配载运次(每张运单自成运次)的计划时间:运次读服务不下发它,一页一次批量补查 + */ + private Map loadLoneWaybills(List trips) { + Set loneIds = trips.stream() + .filter(trip -> Func.isEmpty(trip.getLoadingNo())) + .map(trip -> parseId(trip.getId())) + .filter(Objects::nonNull) + .collect(Collectors.toCollection(LinkedHashSet::new)); + if (loneIds.isEmpty()) { + return Map.of(); + } + return waybillService.list(Wrappers.lambdaQuery() + .select(Waybill::getId, Waybill::getEstimatedStartTime, Waybill::getEstimatedEndTime) + .in(Waybill::getId, loneIds)) + .stream() + .collect(Collectors.toMap(Waybill::getId, Function.identity(), (first, ignored) -> first)); + } + + /** 这一页里带「子单被拒」标记的运次主键:判据与重派待办一致(已拒单 + 未取消未完成) */ + private Set loadReassignTripKeys(Set tripIds) { + if (Func.isEmpty(tripIds)) { + return Set.of(); + } + // 占位符逐个列出:分组键是 SQL 表达式,用不了 in(column, values),而 {0} 传集合会被当成单个参数序列化 + List keys = new ArrayList<>(tripIds); + Object[] params = new Object[keys.size()]; + StringBuilder placeholders = new StringBuilder(); + for (int i = 0; i < keys.size(); i++) { + if (i > 0) { + placeholders.append(", "); + } + placeholders.append("{").append(i).append("}"); + params[i] = keys.get(i); + } + List rejected = waybillService.list(pendingReassignQuery() + .select(Waybill::getId, Waybill::getLoadingNo) + .apply(SQL_TRIP_KEY + " IN (" + placeholders + ")", params)); + return rejected.stream().map(this::tripKeyOf).collect(Collectors.toSet()); + } + + private boolean hasRejectedChild(String tripId) { + return Func.isNotEmpty(tripId) && !loadReassignTripKeys(Set.of(tripId)).isEmpty(); + } + + /** 运次里有没有未完成的异常:异常仍是运单级的事实,落到运次上按「任一子单有」算 */ + private boolean hasChildException(Collection childIds, Set exceptionWaybillIds) { + if (exceptionWaybillIds.isEmpty() || childIds.isEmpty()) { + return false; + } + return childIds.stream().map(this::parseId).filter(Objects::nonNull).anyMatch(exceptionWaybillIds::contains); + } + + /** 小程序 status → 运次状态:0待接单 / 1进行中 / 2已完成 */ + private Integer toTripStatus(String status) { + if (Func.isBlank(status)) { + return null; + } + return switch (status.trim()) { + case "0", STATUS_PENDING -> 0; + case "1", STATUS_RUNNING, "transporting", "doing" -> 1; + case "2", STATUS_COMPLETED, "done" -> 2; + case "3", STATUS_CANCELLED -> 3; + default -> null; + }; + } + + private Date parseDayStart(String date) { + if (Func.isBlank(date)) { + return null; + } + return DateUtil.parse(date.trim() + " 00:00:00", DateUtil.PATTERN_DATETIME); + } + + private Date parseDayEnd(String date) { + if (Func.isBlank(date)) { + return null; + } + return DateUtil.parse(date.trim() + " 23:59:59", DateUtil.PATTERN_DATETIME); + } + + private Long parseId(String value) { + if (Func.isEmpty(value)) { + return null; + } + try { + return Long.valueOf(value.trim()); + } catch (NumberFormatException ignored) { + return null; + } + } + + /** 库层分组用的运次键,与 SQL_TRIP_KEY 同一个表达式 */ + private String tripKeyOf(Waybill waybill) { + if (waybill == null) { + return null; + } + return Func.isEmpty(waybill.getLoadingNo()) ? "W" + waybill.getId() : waybill.getLoadingNo(); + } + + /** 运次的键:配载取配载单号,未配载取 W + 运单主键,与 SQL_TRIP_KEY 同一个表达式 */ + private String tripKeyOf(TripVO trip) { + if (trip == null) { + return null; + } + return Func.isEmpty(trip.getLoadingNo()) ? "W" + trip.getId() : trip.getLoadingNo(); + } + + /* =========================================================================== + * 详情:按运次展示;已拒单等取不到运次的场景回落成运单视图 + * =========================================================================*/ + + /** 按运次主键取可见运次;取不到(已拒子单、不是运次主键)返回空,交给运单级回落 */ + private TripVO visibleTrip(String tripId) { + try { + TripQueryVO query = TripQueryVO.forAdmin(); + query.setTripId(tripId); + return tripQueryService.detail(query); + } catch (ServiceException ignored) { + return null; + } + } + + /** + * 运次详情:与司机端接单页同一口径(单号取配载单号,站点 ①②③ 与每站货物条目都带运单号)。 + * 打卡节点取第一张可见子运单:管理端打卡订正仍按运单走,逐子单的打卡视图不在运次这一层。 + */ + private AdminWaybillDetailVO tripDetail(TripVO trip) { + List children = trip.getWaybills() == null ? List.of() : trip.getWaybills(); + Long firstChildId = children.isEmpty() ? null : parseId(children.get(0).getId()); + List stations = trip.getStations() == null ? List.of() : trip.getStations(); + List scopeIds = new ArrayList<>(); + children.forEach(child -> { + Long childId = parseId(child.getId()); + if (childId != null) { + scopeIds.add(childId); + } + }); + if (scopeIds.isEmpty() && parseId(trip.getId()) != null) { + scopeIds.add(parseId(trip.getId())); + } + + AdminWaybillDetailVO detail = new AdminWaybillDetailVO(); + detail.setId(firstChildId == null ? parseId(trip.getId()) : firstChildId); + detail.setWaybillNo(children.isEmpty() ? Func.toStr(trip.getTripNo(), "") : Func.toStr(children.get(0).getWaybillNo(), "")); + detail.setStatus(trip.getStatus()); + detail.setTransportMode(Func.toStr(trip.getTransportType(), "")); + detail.setTransportType(toTransportTypeLabel(Func.toStr(trip.getTransportType(), ""))); + detail.setTransportOrgType(Func.isEmpty(trip.getLoadingNo()) ? TRANSPORT_COMMON : TRANSPORT_LOAD); + detail.setTripId(trip.getId()); + detail.setLoadingNo(Func.toStr(trip.getLoadingNo(), "")); + detail.setTripNo(Func.toStr(trip.getTripNo(), "")); + detail.setViaName(Func.toStr(trip.getTransitName(), "")); + detail.setStationCount(trip.getStationCount()); + detail.setCargoSummary(Func.toStr(trip.getCargoSummary(), "")); + String weight = formatWeight(trip.getTotalQuantity(), trip.getQuantityUnit()); + detail.setCargoName(Func.toStr(trip.getCargoName(), "")); + detail.setCargoQuantity(weight); + detail.setWeight(weight); + detail.setTotalWeight(weight); + String fromAddr = Func.toStr(trip.getDepartureAddress(), ""); + String toAddr = Func.toStr(trip.getArrivalAddress(), ""); + detail.setFromName(Func.toStrWithEmpty(trip.getDepartureName(), fromAddr)); + detail.setToName(Func.toStrWithEmpty(trip.getArrivalName(), toAddr)); + detail.setFromAddress(Func.toStrWithEmpty(fromAddr, detail.getFromName())); + detail.setToAddress(Func.toStrWithEmpty(toAddr, detail.getToName())); + detail.setPickupAddress(detail.getFromAddress()); + detail.setUnloadAddress(detail.getToAddress()); + detail.setPlanShipTime(formatLocalDate(trip.getEstimatedStartDate())); + detail.setPlanFinishTime(formatLocalDate(trip.getEstimatedEndDate())); + detail.setCarrierName(""); + detail.setDriverName(Func.toStr(trip.getDriverName(), "")); + detail.setDriverPhone(Func.toStr(trip.getDriverPhone(), "")); + detail.setVehicleNo(Func.toStr(trip.getVehicleNo(), "")); + detail.setRemark(Func.toStr(trip.getTaskRemark(), "")); + detail.setNeedReassign(hasRejectedChild(tripKeyOf(trip))); + detail.setAcceptStatus(Func.toStr(trip.getAcceptStatus(), "")); + detail.setRejectReason(""); + detail.setStations(stations); + detail.setRoutePoints(toRoutePoints(stations)); + fillException(detail, scopeIds); + fillPunchSnapshot(detail, firstChildId); + return detail; + } + + /** + * 运单主键回落:重新派单、异常处置等按运单进入的场景仍按运单展示; + * 它属于配载单时补上整趟车的展示字段(配载单号、站点 ①②③、计划时间),与列表和司机端同一口径。 + */ + private AdminWaybillDetailVO waybillDetail(Waybill waybill) { + Set scopeIds = new LinkedHashSet<>(); + scopeIds.add(waybill.getId()); + AdminWaybillDetailVO detail = toDetail(waybill, false, null); + if (Func.isEmpty(waybill.getLoadingNo())) { + fillException(detail, scopeIds); + return detail; + } + List children = visibleChildren(waybill.getLoadingNo()); + children.forEach(child -> scopeIds.add(child.getId())); + LoadingManage loading = findLoading(waybill.getLoadingNo()); + if (loading != null) { + // 整趟车的子单都被拒时组里为空,站点退回配载单自己的发货/途经/到货 + List stations = TripStationAssembler.assemble(loading, children); + detail.setTripId(String.valueOf(loading.getId())); + detail.setLoadingNo(loading.getLoadingNo()); + detail.setTripNo(loading.getLoadingNo()); + detail.setTransportOrgType(TRANSPORT_LOAD); + detail.setViaName(TripStationAssembler.transitName(stations)); + detail.setStationCount(stations.size()); + detail.setStations(stations); + detail.setPlanShipTime(formatLocalDate(loading.getEstimatedStartDate())); + detail.setPlanFinishTime(formatLocalDate(loading.getEstimatedEndDate())); + detail.setFromName(Func.toStrWithEmpty(TripStationAssembler.departureName(stations), detail.getFromName())); + detail.setToName(Func.toStrWithEmpty(TripStationAssembler.arrivalName(stations), detail.getToName())); + if (Func.isEmpty(detail.getDriverName())) { + detail.setDriverName(Func.toStr(loading.getDriverName(), "")); + } + if (Func.isEmpty(detail.getVehicleNo())) { + detail.setVehicleNo(Func.toStr(loading.getVehicleNo(), "")); + } + } + fillException(detail, scopeIds); + return detail; + } + + /** + * 按配载单主键取整趟车:整单被拒时子单为空,站点与计划时间仍按配载单展示 + */ + private AdminWaybillDetailVO loadingDetail(LoadingManage loading, List children) { + List stations = TripStationAssembler.assemble(loading, children); + List scopeIds = children.stream().map(Waybill::getId).filter(Objects::nonNull).toList(); + Waybill head = children.isEmpty() ? null : children.get(0); + + AdminWaybillDetailVO detail = new AdminWaybillDetailVO(); + detail.setId(head == null ? loading.getId() : head.getId()); + detail.setWaybillNo(head == null ? loading.getLoadingNo() : Func.toStr(head.getWaybillNo(), loading.getLoadingNo())); + detail.setStatus(TripAggregation.appStatus(tripBusinessStatus(children))); + detail.setTransportMode(Func.toStr(loading.getTransportType(), "")); + detail.setTransportType(toTransportTypeLabel(Func.toStr(loading.getTransportType(), ""))); + detail.setTransportOrgType(TRANSPORT_LOAD); + detail.setTripId(String.valueOf(loading.getId())); + detail.setLoadingNo(loading.getLoadingNo()); + detail.setTripNo(loading.getLoadingNo()); + detail.setViaName(TripStationAssembler.transitName(stations)); + detail.setStationCount(stations.size()); + detail.setCargoName(Func.toStrWithEmpty(loading.getCargoName(), head == null ? "" : Func.toStr(head.getCargoName(), ""))); + String weight = head == null ? "" : formatWeight(head.getQuantity(), head.getQuantityUnit()); + detail.setCargoQuantity(weight); + detail.setWeight(weight); + detail.setTotalWeight(weight); + detail.setFromName(TripStationAssembler.departureName(stations)); + detail.setToName(TripStationAssembler.arrivalName(stations)); + detail.setFromAddress(Func.toStr(loading.getDepartureAddress(), "")); + detail.setToAddress(Func.toStr(loading.getArrivalAddress(), "")); + detail.setPickupAddress(detail.getFromAddress()); + detail.setUnloadAddress(detail.getToAddress()); + detail.setPlanShipTime(formatLocalDate(loading.getEstimatedStartDate())); + detail.setPlanFinishTime(formatLocalDate(loading.getEstimatedEndDate())); + detail.setCarrierName(""); + detail.setDriverName(Func.toStr(loading.getDriverName(), "")); + detail.setDriverPhone(Func.toStr(loading.getDriverPhone(), "")); + detail.setVehicleNo(Func.toStr(loading.getVehicleNo(), "")); + detail.setRemark(Func.toStr(loading.getTaskRemark(), "")); + detail.setNeedReassign(true); + detail.setAcceptStatus(Func.toStr(TripAggregation.acceptStatus(children), "")); + detail.setRejectReason(""); + detail.setStations(stations); + detail.setRoutePoints(toRoutePoints(stations)); + fillException(detail, scopeIds); + fillPunchSnapshot(detail, head == null ? null : head.getId()); + return detail; + } + + /** 一组子运单的业务状态:全部终态取已完成/已取消,否则有待执行取待执行、其余进行中 */ + private String tripBusinessStatus(List children) { + if (children.isEmpty()) { + return null; + } + boolean requireAccept = TripAggregation.requiresAccept(children); + boolean accepted = WaybillProcessSupport.ACCEPT_ACCEPTED.equals(TripAggregation.acceptStatus(children)); + return TripAggregation.statusOf(children, requireAccept, accepted); + } + + private List visibleChildren(String loadingNo) { + if (Func.isEmpty(loadingNo)) { + return List.of(); + } + return waybillService.list(Wrappers.lambdaQuery() + .eq(Waybill::getLoadingNo, loadingNo) + .in(Waybill::getBusinessStatus, TripAggregation.CHILD_STATUSES) + .and(visible -> visible.isNull(Waybill::getDriverAcceptStatus) + .or().ne(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED)) + .orderByAsc(Waybill::getCreateTime)); + } + + private LoadingManage findLoading(String loadingNo) { + if (Func.isEmpty(loadingNo)) { + return null; + } + return loadingManageService.getOne(Wrappers.lambdaQuery() + .eq(LoadingManage::getLoadingNo, loadingNo) + .last("LIMIT 1")); + } + + /** 未完成的异常处置:整趟车取最新一条,与旧单票详情同口径 */ + private void fillException(AdminWaybillDetailVO detail, Collection waybillIds) { + ExceptionDisposal latest = latestException(waybillIds); + detail.setHasException(latest != null); + detail.setExceptionId(latest == null ? null : latest.getId()); + } + + private ExceptionDisposal latestException(Collection waybillIds) { + if (Func.isEmpty(waybillIds)) { + return null; + } + return exceptionDisposalService.getOne(Wrappers.lambdaQuery() + .in(ExceptionDisposal::getWaybillId, waybillIds) + .in(ExceptionDisposal::getDisposalStatus, DISPOSAL_PENDING, DISPOSAL_PROCESSING) + .orderByDesc(ExceptionDisposal::getReportTime) + .orderByDesc(ExceptionDisposal::getCreateTime) + .last("LIMIT 1")); + } + + /** 打卡节点与途打卡记录复用司机端组装;管理端打卡订正按运单走,故传第一张可见子运单 */ + private void fillPunchSnapshot(AdminWaybillDetailVO detail, Long waybillId) { + if (waybillId == null) { + return; + } + DriverWaybillCardVO punch = driverWaybillService.detailPunchSnapshot(waybillId); + if (punch == null) { + return; + } + detail.setPunchNodes(punch.getPunchNodes()); + detail.setEnrouteRecords(punch.getEnrouteRecords()); + if (punch.getRoutePoints() != null && !punch.getRoutePoints().isEmpty()) { + detail.setRoutePoints(punch.getRoutePoints().stream().map(p -> { + AdminWaybillDetailVO.AdminRoutePointVO rp = new AdminWaybillDetailVO.AdminRoutePointVO(); + rp.setName(p.getName()); + rp.setAddress(p.getAddress()); + rp.setStatus(p.getStatus()); + return rp; + }).toList()); + } + } + + /** 点位列:整趟车的全部站点,站序即途经顺序 */ + private List toRoutePoints(List stations) { + if (Func.isEmpty(stations)) { + return List.of(); + } + return stations.stream().map(station -> { + AdminWaybillDetailVO.AdminRoutePointVO point = new AdminWaybillDetailVO.AdminRoutePointVO(); + point.setName(Func.toStr(station.getName(), "")); + point.setAddress(Func.toStrWithEmpty(station.getAddress(), point.getName())); + point.setStatus("pending"); + return point; + }).toList(); + } + + /** + * 待重新派单列表:按库层分组把「同一配载单被拒的多张子单」收成一条,未配载运单各自一条。 + * 分页与总数都在库层按运次完成,避免整单拒绝后同一趟车重复出现。 + */ + private IPage reassignPendingPage(int pageNo, int pageSize, String keyword) { + QueryWrapper wrapper = reassignGroupQuery(); + applyGroupKeyword(wrapper, keyword); + wrapper.orderByDesc(SQL_MAX_REJECT_TIME); + + Page rows = new Page<>(pageNo, pageSize); + waybillService.page(rows, wrapper); + + Set exceptionWaybillIds = loadIncompleteExceptionWaybillIds(); + Page page = new Page<>(pageNo, pageSize, rows.getTotal()); + page.setRecords(toReassignCards(rows.getRecords(), exceptionWaybillIds)); + return page; + } + + /** + * 库层按运次分组的「待重新派单」查询:一条即一个运次,段二不用再回查子单。 + * 展示用字段一律包聚合函数取组内代表值,既满足 ONLY_FULL_GROUP_BY,也让一条待办自带它要显示的信息。 + */ + private QueryWrapper reassignGroupQuery() { + QueryWrapper wrapper = Wrappers.query(); + wrapper.select("MIN(id) AS id", "MIN(NULLIF(loading_no, '')) AS loading_no", "MIN(waybill_no) AS waybill_no", + "MIN(transport_type) AS transport_type", "MIN(business_status) AS business_status", + "MIN(departure_name) AS departure_name", "MIN(arrival_name) AS arrival_name", + "MIN(cargo_name) AS cargo_name", "MIN(quantity) AS quantity", "MIN(quantity_unit) AS quantity_unit", + "MIN(estimated_start_time) AS estimated_start_time", "MIN(estimated_end_time) AS estimated_end_time", + "MIN(driver_name) AS driver_name", "MIN(vehicle_no) AS vehicle_no", + "MIN(driver_reject_reason) AS driver_reject_reason", + SQL_MAX_REJECT_TIME + " AS driver_reject_time", SQL_MAX_CREATE_TIME + " AS create_time"); + wrapper.eq("driver_accept_status", ACCEPT_REJECTED); + wrapper.in("business_status", STATUS_PENDING, STATUS_RUNNING); + wrapper.groupBy(SQL_TRIP_KEY); + return wrapper; + } + + /** 一条待办 = 一个运次:整趟车取配载单的单号与站点,未配载就是这张运单本身 */ + private List toReassignCards(List rows, Set exceptionWaybillIds) { + if (Func.isEmpty(rows)) { + return List.of(); + } + Set loadingNos = rows.stream().map(Waybill::getLoadingNo).filter(Func::isNotEmpty) + .collect(Collectors.toCollection(LinkedHashSet::new)); + Map loadings = loadingsByNo(loadingNos); + + List cards = new ArrayList<>(rows.size()); + for (Waybill row : rows) { + String loadingNo = Func.toStr(row.getLoadingNo(), ""); + AdminWaybillCardVO card = toCard(row, hasExceptionIds(row, exceptionWaybillIds)); + card.setTripId(String.valueOf(Func.isEmpty(loadingNo) ? row.getId() : loadingId(loadings.get(loadingNo), row.getId()))); + card.setTripNo(Func.isEmpty(loadingNo) ? Func.toStr(row.getWaybillNo(), "") : loadingNo); + card.setLoadingNo(loadingNo); + card.setWaybillCount(1); + card.setCargoSummary(Func.toStr(row.getCargoName(), "")); + card.setTotalWeight(formatWeight(row.getQuantity(), row.getQuantityUnit())); + card.setNeedReassign(true); + if (Func.isNotEmpty(loadingNo)) { + List stations = TripStationAssembler.assemble(loadings.get(loadingNo), List.of()); + card.setTransportType(TRANSPORT_LOAD); + card.setFromName(TripStationAssembler.departureName(stations)); + card.setToName(TripStationAssembler.arrivalName(stations)); + card.setViaName(TripStationAssembler.transitName(stations)); + card.setStationCount(stations.size()); + } else { + card.setTransportType(TRANSPORT_COMMON); + card.setStationCount(2); + } + card.setCreateTime(formatDateTime(row.getCreateTime())); + cards.add(card); + } + return cards; + } + + private Map loadingsByNo(Set loadingNos) { + if (loadingNos.isEmpty()) { + return Map.of(); + } + return loadingManageService.list(Wrappers.lambdaQuery().in(LoadingManage::getLoadingNo, loadingNos)) + .stream() + .collect(Collectors.toMap(LoadingManage::getLoadingNo, Function.identity(), (first, ignored) -> first)); + } + + private Long loadingId(LoadingManage loading, Long fallback) { + return loading == null || loading.getId() == null ? fallback : loading.getId(); + } + + /** 分组查询的关键字:运单号 / 司机 / 车牌,与列表关键字口径一致 */ + private void applyGroupKeyword(QueryWrapper wrapper, String keyword) { + if (Func.isBlank(keyword)) { + return; + } + String key = keyword.trim(); + wrapper.and(w -> w.like("waybill_no", key).or().like("driver_name", key).or().like("vehicle_no", key)); + } + + private boolean hasExceptionIds(Waybill waybill, Set exceptionWaybillIds) { + return waybill != null && waybill.getId() != null && exceptionWaybillIds.contains(waybill.getId()); } private AdminWaybillDetailVO toDetail(Waybill waybill, boolean hasException, Long exceptionId) { @@ -371,11 +1049,6 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { }; } - private long countWaybillByStatus(String status) { - return waybillService.count(Wrappers.lambdaQuery() - .eq(Waybill::getBusinessStatus, status)); - } - /** * 小程序调度端不做组织过滤。 * 「小程序管理」等账号 JWT/档案 dept 常与运单业务组织不一致,按 dept 过滤会导致统计全 0; @@ -385,68 +1058,6 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { return Wrappers.lambdaQuery(); } - private void applyStatusFilter(LambdaQueryWrapper wrapper, String status) { - String businessStatus = toBusinessStatus(status); - if (Func.isNotBlank(businessStatus)) { - wrapper.eq(Waybill::getBusinessStatus, businessStatus); - } - } - - private void applyExceptionFilter(LambdaQueryWrapper wrapper, String exception, Set exceptionWaybillIds) { - if (EXCEPTION_YES.equals(exception)) { - wrapper.in(Waybill::getId, exceptionWaybillIds); - } else if (EXCEPTION_NO.equals(exception) && !exceptionWaybillIds.isEmpty()) { - wrapper.notIn(Waybill::getId, exceptionWaybillIds); - } - } - - private void applyTransportTypeFilter(LambdaQueryWrapper wrapper, String transportType) { - if (TRANSPORT_LOAD.equals(transportType)) { - wrapper.isNotNull(Waybill::getLoadingNo).ne(Waybill::getLoadingNo, ""); - } else if (TRANSPORT_COMMON.equals(transportType)) { - wrapper.and(w -> w.isNull(Waybill::getLoadingNo).or().eq(Waybill::getLoadingNo, "")); - } - } - - private void applyKeywordFilter(LambdaQueryWrapper wrapper, String keyword) { - if (Func.isBlank(keyword)) { - return; - } - String key = keyword.trim(); - wrapper.and(w -> w.like(Waybill::getWaybillNo, key) - .or().like(Waybill::getDriverName, key) - .or().like(Waybill::getVehicleNo, key)); - } - - private void applyCreateTimeFilter(LambdaQueryWrapper wrapper, String startDate, String endDate) { - if (Func.isNotBlank(startDate)) { - Date start = DateUtil.parse(startDate.trim() + " 00:00:00", DateUtil.PATTERN_DATETIME); - if (start != null) { - wrapper.ge(Waybill::getCreateTime, start); - } - } - if (Func.isNotBlank(endDate)) { - Date end = DateUtil.parse(endDate.trim() + " 23:59:59", DateUtil.PATTERN_DATETIME); - if (end != null) { - wrapper.le(Waybill::getCreateTime, end); - } - } - } - - /** 小程序 status → 后端 businessStatus */ - private String toBusinessStatus(String status) { - if (Func.isBlank(status)) { - return null; - } - return switch (status.trim()) { - case "0", STATUS_PENDING -> STATUS_PENDING; - case "1", STATUS_RUNNING, "transporting", "doing" -> STATUS_RUNNING; - case "2", STATUS_COMPLETED, "done" -> STATUS_COMPLETED; - case "3", STATUS_CANCELLED -> STATUS_CANCELLED; - default -> null; - }; - } - private Set loadIncompleteExceptionWaybillIds() { List list = exceptionDisposalService.list(Wrappers.lambdaQuery() .select(ExceptionDisposal::getWaybillId) @@ -570,13 +1181,20 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { .eq(RiskDisposal::getDisposalStatus, RISK_PENDING)); } - /** 待重新派单:司机已拒单,且运单尚未取消/完成 */ + /** + * 待重新派单的运次数:整单拒绝会把一张配载单的多张子运单一起写成已拒绝,按运次键去重后只算一个。 + *

+ * 这里不走运次读服务:它的可见口径已排除被拒子单,整单拒绝的配载单在运次里根本不存在; + * 分组键用与运次读服务同一个表达式,两处的运次才是同一个东西。 + */ private long countPendingReassignments() { - return waybillService.count(pendingReassignQuery()); + Page rows = new Page<>(1, 1); + waybillService.page(rows, reassignGroupQuery()); + return rows.getTotal(); } private LambdaQueryWrapper pendingReassignQuery() { - return Wrappers.lambdaQuery() + return scopedWaybillQuery() .eq(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED) .in(Waybill::getBusinessStatus, STATUS_PENDING, STATUS_RUNNING); } @@ -611,11 +1229,17 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { .collect(Collectors.toList()); } + /** + * 待重新派单的待办:一条 = 一个运次。 + *

+ * 整单拒绝会把一张配载单的多张子运单一起写成已拒绝,按运次键分组后同一趟车只出 1 条; + * 未配载运单自成运次,各自一条。排序与取值都在库层按运次完成,与待处理列表、角标同一口径。 + */ private List buildReassignFeed() { - List list = waybillService.list(pendingReassignQuery() - .orderByDesc(Waybill::getDriverRejectTime) + List rows = waybillService.list(reassignGroupQuery() + .orderByDesc(SQL_MAX_REJECT_TIME) .last("LIMIT " + FEED_LIMIT)); - return list.stream() + return rows.stream() .map(waybill -> new TimedTodo(toReassignTodoItem(waybill), rejectTimeOf(waybill))) .collect(Collectors.toList()); } @@ -649,7 +1273,8 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { item.setTitle("重新派单待处理"); item.setTimeAgo(formatTimeAgo(rejectTimeOf(waybill))); item.setDesc(buildReassignDesc(waybill)); - item.setWaybillNo(Func.toStr(waybill.getWaybillNo(), "")); + // 一条待办即一个运次:有配载单的展示配载单号;跳转参数仍是运单主键,该页据此回显并直接重派 + item.setWaybillNo(Func.toStrWithEmpty(waybill.getLoadingNo(), Func.toStr(waybill.getWaybillNo(), ""))); item.setActionLabel("重新派单"); item.setTargetUrl(String.format(REASSIGN_TARGET_URL, waybill.getId())); return item; diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageAdminHomeFeedTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageAdminHomeFeedTest.java index 1d27f177..fe15f9ee 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageAdminHomeFeedTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageAdminHomeFeedTest.java @@ -2,7 +2,7 @@ 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.query.QueryWrapper; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import org.apache.ibatis.builder.MapperBuilderAssistant; import org.junit.jupiter.api.BeforeAll; @@ -17,7 +17,9 @@ 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.ILoadingManageService; import org.springblade.transport.service.IRiskDisposalService; +import org.springblade.transport.service.ITripQueryService; import org.springblade.transport.service.IWaybillService; import java.time.LocalDateTime; @@ -49,6 +51,7 @@ class ManageAdminHomeFeedTest { private static final Long EXCEPTION_ID = 8801L; private static final String REJECTED_WAYBILL_NO = "YD-20261010-0001"; + private static final String REJECTED_LOADING_NO = "PZ-20261017-001"; private ManageWaybillServiceImpl service; private IWaybillService waybillService; @@ -72,7 +75,9 @@ class ManageAdminHomeFeedTest { mock(IDriverService.class), exceptionService, mock(IRiskDisposalService.class), - mock(IDriverWaybillService.class)); + mock(IDriverWaybillService.class), + mock(ITripQueryService.class), + mock(ILoadingManageService.class)); when(exceptionService.list(ArgumentMatchers.>any())) .thenReturn(new ArrayList<>()); when(waybillService.list(ArgumentMatchers.>any())) @@ -141,6 +146,43 @@ class ManageAdminHomeFeedTest { "拒单发生在昨日 22:00,异常上报于 26 小时前(更早),故重派条目在前"); } + @Test + @DisplayName("整单拒绝的多张子单按运次收成一条:同一配载单只出一条重新派单待办") + void rejectedLoadingChildrenCollapseToOneTodo() { + // 库层按运次分组后,一张配载单无论几张子单被拒都只回一行(分组键见下面的 SQL 断言) + Waybill grouped = rejectedWaybill(7210L, "黄大富", "车辆不符"); + grouped.setLoadingNo(REJECTED_LOADING_NO); + when(waybillService.list(ArgumentMatchers.>any())).thenReturn(List.of(grouped)); + + List feed = service.buildAdminFeed(); + + assertEquals(1, feed.size(), "整单拒绝只应出一条待办"); + assertEquals(REJECTED_LOADING_NO, feed.get(0).getWaybillNo(), "配载运次的待办展示配载单号"); + assertEquals(Long.valueOf(7210L), feed.get(0).getId(), "跳转参数仍是运单主键:重新派单按运单写入"); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(waybillService).list(captor.capture()); + QueryWrapper wrapper = (QueryWrapper) captor.getValue(); + String sql = wrapper.getTargetSql(); + assertTrue(sql.contains("GROUP BY"), "去重要落在库层分组上,否则多张被拒子单会各出一条:" + sql); + assertTrue(sql.contains("CONCAT('W', id)"), + "分组键与运次读服务同源:有配载按配载单号、未配载每张运单自成一组:" + sql); + } + + @Test + @DisplayName("两张未配载运单被拒仍是两条待办:未配载运单各自就是一趟运次") + void twoLoneRejectedWaybillsStayTwoTodos() { + when(waybillService.list(ArgumentMatchers.>any())).thenReturn(List.of( + rejectedWaybill(7221L, "黄大富", null), + rejectedWaybill(7222L, "黄宇富", null))); + + List feed = service.buildAdminFeed(); + + assertEquals(2, feed.size(), "未配载运单不合并"); + assertEquals(List.of(7221L, 7222L), + feed.stream().map(AdminTodoItemVO::getId).sorted().collect(Collectors.toList())); + } + @Test @DisplayName("判据只认「已拒单 + 未取消未完成」,已取消已完成即便被拒过也不参与") void reassignQueryExcludesTerminalStatus() { @@ -148,7 +190,7 @@ class ManageAdminHomeFeedTest { ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); verify(waybillService).list(captor.capture()); - LambdaQueryWrapper wrapper = (LambdaQueryWrapper) captor.getValue(); + QueryWrapper wrapper = (QueryWrapper) captor.getValue(); String sql = wrapper.getTargetSql(); assertTrue(sql.contains("driver_accept_status"), "重派判据要落在接单状态上:" + sql);