✨ 小程序调度端运单列表、待处理与统计按组织收口

原先三个取数入口都不带组织条件,租户内两个组织的运单混在同一张表里。「小程序管理」
账号与运单业务组织不一致确实是事实,但那是账号挂在一个已软删部门上的脏数据症状,
不是「不该按组织过滤」的理由。

现在与后台运单列表同一条规则:普通用户只出自己所属组织,超级管理员不过滤,
小程序端不提供「全部组织」开关;详情与重新派单照旧不按组织收口。
组织条件由调用方算好、经 TripQueryVO.deptIds 落到运次读服务的运单行上,
列表、待处理与首页三档统计因此共用同一条可见范围;「待重新派单」另走库层分组查询,
单独带上,首页重派角标与待办 feed 随之一起收口。

运次读服务的分档统计走一份「去掉 Tab 条件」的副本,该副本必须照搬 deptIds,
否则统计会退回全租户、与列表的数对不上(已由用例钉住)。

存量 42 张 dept_id 为 -1 或 NULL 的运单不属于任何组织,收紧后普通用户再也看不到,
另订正到各自的创建组织,见 doc/sql/transport/blade_waybill_dept_backfill_20261018.sql。

新增 ManageWaybillOrgScopeTest(8 项),TripQueryServiceImplTest 补 3 项;
blade-transport 全量 376 项用例通过。
This commit is contained in:
2026-10-18 11:22:04 +08:00
parent 1720f9192f
commit 7d951d5b04
9 changed files with 403 additions and 7 deletions
@@ -53,6 +53,9 @@ public class TripQueryVO implements Serializable {
@Schema(description = "是否不按司机收口(管理端)")
private boolean allDrivers;
@Schema(description = "可见组织 ID 集合:非空时只出这些组织的运单行,空集合表示不按组织过滤")
private List<Long> deptIds;
@Schema(description = "状态:0待接单/1进行中/2已完成/3已取消,不传为全部(不含已取消)")
private Integer status;
@@ -69,7 +69,7 @@ public class ManageWaybillController extends BladeController {
@GetMapping("/stats")
@ApiOperationSupport(order = 1)
@Operation(summary = "运单状态统计", description = "待接单=pending,运输中=running,已完成=completed;租户内不过滤组织(小程序调度账号组织常与运单不一致);在途异常=异常处置状态≠已完成")
@Operation(summary = "运单状态统计", description = "待接单=pending,运输中=running,已完成=completed;只统计本组织的运单(超级管理员不过滤组织);在途异常=异常处置状态≠已完成")
public R<AdminHomeStatsVO> stats() {
return R.data(manageWaybillService.stats());
}
@@ -59,6 +59,7 @@ 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.TransportBusinessSupport;
import org.springblade.transport.support.WaybillProcessSupport;
import org.springframework.stereotype.Service;
@@ -140,7 +141,9 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
public AdminHomeStatsVO stats() {
AdminHomeStatsVO vo = new AdminHomeStatsVO();
// 三档状态按运次计:有配载单的一趟车算一个,与列表和司机端同一份聚合
TripTabCountsVO tripCounts = tripQueryService.tabCounts(TripQueryVO.forAdmin());
TripQueryVO query = TripQueryVO.forAdmin();
query.setDeptIds(manageableDeptIds());
TripTabCountsVO tripCounts = tripQueryService.tabCounts(query);
vo.setPendingAccept(tripCounts.getPending());
vo.setTransporting(tripCounts.getDoing());
vo.setCompleted(tripCounts.getDone());
@@ -177,6 +180,7 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
// 组织类型 / 异常 / 创建时间都是子单行级条件,与状态、关键字、分页一起交给运次读服务,
// 一次取数取完整趟车:分组、分档、筛选、排序与分页都在它那边算完
TripQueryVO query = TripQueryVO.forAdmin();
query.setDeptIds(manageableDeptIds());
query.setStatus(toTripStatus(status));
query.setKeyword(keyword);
query.setTransportOrgType(transportType);
@@ -224,6 +228,7 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
Set<Long> exceptionWaybillIds = loadIncompleteExceptionWaybillIds();
TripQueryVO query = TripQueryVO.forAdmin();
query.setDeptIds(manageableDeptIds());
query.setKeyword(keyword);
// 待处理只要待接单与进行中两档;「未拒单」也是交给运次读服务按子单行筛
query.setStatuses(List.of(0, 1));
@@ -686,6 +691,11 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
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);
// 这条查询不经运次读服务,组织收口得自己带上,否则待重派列表与首页重派角标会出别的组织的单
List<Long> deptIds = manageableDeptIds();
if (Func.isNotEmpty(deptIds)) {
wrapper.in("dept_id", deptIds);
}
wrapper.groupBy(TripAggregation.SQL_TRIP_KEY);
return wrapper;
}
@@ -895,11 +905,22 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
}
/**
* 小程序调度端不做组织过滤。
* 「小程序管理」等账号 JWT/档案 dept 常与运单业务组织不一致,按 dept 过滤会导致统计全 0;
* 与后台 allDept=1 一致,仅依赖租户隔离(MyBatis-Plus TenantLine)。
* 调度端可见组织:普通用户(含各级角色)只出自己所属组织,超级管理员不过滤。
* <p>
* 与后台运单列表同一条规则({@link TransportBusinessSupport#resolveDeptFilterIds}),
* 小程序端不提供「全部组织」开关,故两个入参固定传空;详情与重新派单不按组织收口。
*/
private LambdaQueryWrapper<Waybill> scopedWaybillQuery() {
private List<Long> manageableDeptIds() {
return TransportBusinessSupport.resolveDeptFilterIds(null, null, "运单管理");
}
/**
* 已拒子单的查询底:只有租户与逻辑删除(MyBatis-Plus 自动补),不带组织。
* <p>
* 它服务的是「这个运次里有没有被拒的子单」,可见范围由调用方先定好——列表传的运次已经收过组织,
* 详情按主键进入不收组织。这里再按组织收一次,详情里别的组织的运次会丢掉重派标记。
*/
private LambdaQueryWrapper<Waybill> rejectedChildQuery() {
return Wrappers.<Waybill>lambdaQuery();
}
@@ -1039,7 +1060,7 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
}
private LambdaQueryWrapper<Waybill> pendingReassignQuery() {
return scopedWaybillQuery()
return rejectedChildQuery()
.eq(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED)
.in(Waybill::getBusinessStatus, STATUS_PENDING, STATUS_RUNNING);
}
@@ -236,6 +236,7 @@ public class TripQueryServiceImpl implements ITripQueryService {
.and(visible -> visible.isNull(Waybill::getDriverAcceptStatus)
.or().ne(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED));
applyScope(wrapper, condition);
applyDeptScope(wrapper, condition);
applyOrgTypeFilter(wrapper, condition);
applyKeyword(wrapper, condition.getKeyword());
List<Waybill> rows = waybillService.list(wrapper);
@@ -243,6 +244,19 @@ public class TripQueryServiceImpl implements ITripQueryService {
return rows;
}
/**
* 组织收口:条件里给了组织 ID 集合时只出这些组织的运单行,空集合表示不过滤。
* <p>
* 与司机手机号收口一样落在库层的运单行上:分档、分页、总数都由它缩过的行集合算出来。
* 读服务不自己解析当前用户的组织——它同时服务司机端与管理端,可见范围由调用方算好再传。
*/
private void applyDeptScope(LambdaQueryWrapper<Waybill> wrapper, TripQueryVO condition) {
if (Func.isEmpty(condition.getDeptIds())) {
return;
}
wrapper.in(Waybill::getDeptId, condition.getDeptIds());
}
/**
* 运输组织类型按子单行筛:它在同一趟车里必然是同一个值(配载的每张子单带同一个配载单号,
* 未配载的每张自成一趟车),所以这一层不会把一趟车拆开。
@@ -709,6 +723,7 @@ public class TripQueryServiceImpl implements ITripQueryService {
TripQueryVO copy = new TripQueryVO();
copy.setDriverPhone(source.getDriverPhone());
copy.setAllDrivers(source.isAllDrivers());
copy.setDeptIds(source.getDeptIds());
copy.setKeyword(source.getKeyword());
copy.setTransportOrgType(source.getTransportOrgType());
copy.setException(source.getException());
@@ -5,12 +5,15 @@ import com.baomidou.mybatisplus.core.conditions.Wrapper;
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.AfterEach;
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.secure.utils.AuthUtil;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.AdminTodoItemVO;
@@ -34,6 +37,7 @@ 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.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -52,10 +56,13 @@ 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 static final String ROLE_ORDINARY = "yewu";
private static final String DEPT_ID = "1123598813738675201";
private ManageWaybillServiceImpl service;
private IWaybillService waybillService;
private IExceptionDisposalService exceptionService;
private MockedStatic<AuthUtil> authUtil;
@BeforeAll
static void initTableInfo() {
@@ -82,6 +89,15 @@ class ManageAdminHomeFeedTest {
.thenReturn(new ArrayList<>());
when(waybillService.list(ArgumentMatchers.<Wrapper<Waybill>>any()))
.thenReturn(new ArrayList<>());
// 重派待办的库层查询要带可见组织;没有请求上下文时按普通用户 + 本组织给出
authUtil = mockStatic(AuthUtil.class);
authUtil.when(AuthUtil::getUserRole).thenReturn(ROLE_ORDINARY);
authUtil.when(AuthUtil::getDeptId).thenReturn(DEPT_ID);
}
@AfterEach
void tearDown() {
authUtil.close();
}
@Test
@@ -6,13 +6,16 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.AfterEach;
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.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.entity.LoadingManage;
import org.springblade.transport.pojo.entity.Waybill;
@@ -41,6 +44,7 @@ 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.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@@ -58,12 +62,15 @@ class ManageTripListDetailTest {
private static final Long LOADING_ID = 2111350784070275074L;
private static final Long CHILD_A = 2111350784070275123L;
private static final Long CHILD_B = 2102780956493299713L;
private static final String ROLE_ORDINARY = "yewu";
private static final String DEPT_ID = "1123598813738675201";
private ManageWaybillServiceImpl service;
private IWaybillService waybillService;
private IExceptionDisposalService exceptionDisposalService;
private ITripQueryService tripQueryService;
private ILoadingManageService loadingManageService;
private MockedStatic<AuthUtil> authUtil;
private IDriverWaybillService driverWaybillService;
private static java.time.LocalDate toLocalDate(Date date) {
@@ -98,6 +105,15 @@ class ManageTripListDetailTest {
when(exceptionDisposalService.list(ArgumentMatchers.<Wrapper<ExceptionDisposal>>any()))
.thenReturn(new ArrayList<>());
when(waybillService.list(ArgumentMatchers.<Wrapper<Waybill>>any())).thenReturn(new ArrayList<>());
// 列表与待处理都要先算出可见组织;没有请求上下文时按普通用户 + 本组织给出
authUtil = mockStatic(AuthUtil.class);
authUtil.when(AuthUtil::getUserRole).thenReturn(ROLE_ORDINARY);
authUtil.when(AuthUtil::getDeptId).thenReturn(DEPT_ID);
}
@AfterEach
void tearDown() {
authUtil.close();
}
@Test
@@ -0,0 +1,226 @@
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.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.AfterEach;
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.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.TripQueryVO;
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.IRiskDisposalService;
import org.springblade.transport.service.ITripQueryService;
import org.springblade.transport.service.IWaybillService;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
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.atLeastOnce;
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>
* 三个取数入口必须同一条规则——列表、待处理、首页三档统计各走一次查询,漏一处就会出现
* 「统计说 46 张、列表只出 18 张」这种对不上的数;待重新派单另走库层分组查询,也要单独带上。
* <p>
* 组织取不到时报错而不是放行:放行等于把全租户的运单发给一个没有组织的账号。
*/
class ManageWaybillOrgScopeTest {
private static final String DEPT_ID = "1123598813738675202";
private static final Long DEPT = 1123598813738675202L;
private static final String ROLE_ORDINARY = "yewu";
private static final String ROLE_ADMINISTRATOR = "administrator";
private ManageWaybillServiceImpl service;
private IWaybillService waybillService;
private ITripQueryService tripQueryService;
private MockedStatic<AuthUtil> authUtil;
@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);
tripQueryService = mock(ITripQueryService.class);
IExceptionDisposalService exceptionDisposalService = mock(IExceptionDisposalService.class);
service = new ManageWaybillServiceImpl(
waybillService,
mock(IDriverService.class),
exceptionDisposalService,
mock(IRiskDisposalService.class),
mock(IDriverWaybillService.class),
tripQueryService,
mock(ILoadingManageService.class));
when(exceptionDisposalService.list(ArgumentMatchers.<Wrapper<ExceptionDisposal>>any()))
.thenReturn(new ArrayList<>());
when(waybillService.list(ArgumentMatchers.<Wrapper<Waybill>>any())).thenReturn(new ArrayList<>());
// 待重派那次分页查询回它收到的 Page(总数从入参上读,回 null 会空指针)
when(waybillService.page(any(), any())).thenAnswer(invocation -> invocation.getArgument(0));
// 运次读服务的两次取数:本类只看传给它的条件,行集合给空页即可
when(tripQueryService.page(any())).thenReturn(emptyTripPage());
when(tripQueryService.tabCounts(any())).thenReturn(new TripTabCountsVO());
authUtil = mockStatic(AuthUtil.class);
asOrdinaryUser();
}
@AfterEach
void tearDown() {
authUtil.close();
}
@Test
@DisplayName("普通用户:列表把当前组织交给运次读服务,行级收口落在库层")
void pageListScopesCurrentDept() {
service.pageList(1, 10, null, null, null, null, null, null);
TripQueryVO query = capturedPageQuery();
assertEquals(List.of(DEPT), query.getDeptIds(), "列表只出本组织");
assertTrue(query.isAllDrivers(), "组织收口不影响司机收口口径");
}
@Test
@DisplayName("普通用户:待处理列表与列表同一条组织规则")
void pendingListScopesCurrentDept() {
service.pendingList(1, 10, null, false);
assertEquals(List.of(DEPT), capturedPageQuery().getDeptIds());
}
@Test
@DisplayName("普通用户:首页三档统计与列表同一口径,否则统计与列表的数对不上")
void statsScopesCurrentDept() {
service.stats();
ArgumentCaptor<TripQueryVO> captor = ArgumentCaptor.forClass(TripQueryVO.class);
verify(tripQueryService, atLeastOnce()).tabCounts(captor.capture());
assertEquals(List.of(DEPT), captor.getValue().getDeptIds());
}
@Test
@DisplayName("超级管理员:三处都不带组织,看全租户")
void administratorSeesEveryDept() {
asAdministrator();
service.pageList(1, 10, null, null, null, null, null, null);
ArgumentCaptor<TripQueryVO> pageCaptor = ArgumentCaptor.forClass(TripQueryVO.class);
verify(tripQueryService).page(pageCaptor.capture());
assertTrue(pageCaptor.getValue().getDeptIds().isEmpty(), "超管不过滤组织");
service.stats();
ArgumentCaptor<TripQueryVO> statsCaptor = ArgumentCaptor.forClass(TripQueryVO.class);
verify(tripQueryService, atLeastOnce()).tabCounts(statsCaptor.capture());
assertTrue(statsCaptor.getValue().getDeptIds().isEmpty(), "超管的统计也不过过滤组织");
}
@Test
@DisplayName("待重新派单:库层分组查询也带组织,否则重派列表与角标会出别的组织的单")
void reassignQueryScopesCurrentDept() {
service.pendingList(1, 10, null, true);
ArgumentCaptor<Wrapper<Waybill>> captor = ArgumentCaptor.forClass(Wrapper.class);
verify(waybillService).page(any(), captor.capture());
QueryWrapper<Waybill> wrapper = (QueryWrapper<Waybill>) captor.getValue();
String sql = String.valueOf(wrapper.getTargetSql());
assertTrue(sql.contains("dept_id"), "重派查询要带上组织:" + sql);
Set<Object> values = new HashSet<>(wrapper.getParamNameValuePairs().values());
assertTrue(values.contains(DEPT), "组织值要作为参数下发:" + values);
}
@Test
@DisplayName("待重新派单:超管的库层分组查询不带组织")
void reassignQuerySkipsScopeForAdministrator() {
asAdministrator();
service.pendingList(1, 10, null, true);
ArgumentCaptor<Wrapper<Waybill>> captor = ArgumentCaptor.forClass(Wrapper.class);
verify(waybillService).page(any(), captor.capture());
String sql = String.valueOf(captor.getValue().getTargetSql());
assertFalse(sql.contains("dept_id"), "超管不按组织收口:" + sql);
}
@Test
@DisplayName("详情不按组织收口:按主键进入,不带组织条件")
void detailIsNotScoped() {
when(tripQueryService.detail(any())).thenThrow(new ServiceException("运次不存在或无权操作"));
when(waybillService.getById(any())).thenReturn(null);
assertThrows(ServiceException.class, () -> service.detail("7201"));
ArgumentCaptor<TripQueryVO> captor = ArgumentCaptor.forClass(TripQueryVO.class);
verify(tripQueryService).detail(captor.capture());
assertTrue(captor.getValue().getDeptIds() == null || captor.getValue().getDeptIds().isEmpty(),
"详情与后台同口径:不按组织收口");
}
@Test
@DisplayName("组织取不到时报错,不退回不带组织条件")
void blankDeptFailsClosed() {
authUtil.when(AuthUtil::getDeptId).thenReturn("");
assertThrows(ServiceException.class,
() -> service.pageList(1, 10, null, null, null, null, null, null),
"没有组织的账号不能拿到全租户运单");
}
// ------------------------------------------------------------ 夹具
private IPage<TripVO> emptyTripPage() {
Page<TripVO> page = new Page<>(1, 10, 0);
page.setRecords(List.of());
return page;
}
private TripQueryVO capturedPageQuery() {
ArgumentCaptor<TripQueryVO> captor = ArgumentCaptor.forClass(TripQueryVO.class);
verify(tripQueryService).page(captor.capture());
return captor.getValue();
}
private void asOrdinaryUser() {
authUtil.when(AuthUtil::getUserRole).thenReturn(ROLE_ORDINARY);
authUtil.when(AuthUtil::getDeptId).thenReturn(DEPT_ID);
}
private void asAdministrator() {
authUtil.when(AuthUtil::getUserRole).thenReturn(ROLE_ADMINISTRATOR);
authUtil.when(AuthUtil::getDeptId).thenReturn(DEPT_ID);
}
}
@@ -24,7 +24,9 @@ import org.springblade.transport.service.IProcessConfigService;
import org.springblade.transport.service.IWaybillService;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -116,6 +118,42 @@ class TripQueryServiceImplTest {
assertFalse(select.contains(String.valueOf((Object) null)) && select.contains("*"), "不得整行拖回:" + select);
}
@Test
@DisplayName("组织收口给了就落在库层运单行上,分档与分页都由缩过的行集合算")
void deptScopeIsAppliedOnRows() {
TripQueryVO query = TripQueryVO.forDriver(PHONE);
query.setDeptIds(List.of(1123598813738675202L, 1123598813738675201L));
service.page(query);
String sql = capturedRowSql();
assertTrue(sql.contains("dept_id IN"), "组织收口要下发到库层:" + sql);
Set<Object> values = new HashSet<>(capturedRowQuery().getParamNameValuePairs().values());
assertTrue(values.contains(1123598813738675202L) && values.contains(1123598813738675201L),
"组织值按参数下发:" + values);
assertTrue(capturedRowSql().contains("driver_phone"), "组织收口不顶替司机收口:两种收口同时生效");
}
@Test
@DisplayName("统计四档时组织收口不能丢:去掉的只有 Tab 条件,其余条件照搬")
void tabCountsKeepsDeptScope() {
TripQueryVO query = TripQueryVO.forAdmin();
query.setDeptIds(List.of(1123598813738675202L));
service.tabCounts(query);
assertTrue(capturedRowSql().contains("dept_id IN"),
"统计与列表必须是同一份可见范围,否则首页三张卡与列表的数对不上:" + capturedRowSql());
}
@Test
@DisplayName("组织收口缺省时不下发组织条件:超管与司机端都按不传处理")
void deptScopeIsOptional() {
service.page(TripQueryVO.forDriver(PHONE));
assertFalse(capturedRowSql().contains("dept_id"), "不给组织集合即不过滤:" + capturedRowSql());
}
@Test
@DisplayName("Tab 在服务层按推导分档过滤,每张卡的 status 与该档一致")
void tabFilterHappensOnDerivedTier() {
@@ -0,0 +1,61 @@
-- ----------------------------
-- 调度端运单列表按组织过滤:dept_id 缺失的存量运单订正回它所属的组织
-- 写业务数据。执行前必须先建备份表(第 0 节),执行后必须跑第 2 节校验。可重复执行。
-- ----------------------------
--
-- 为什么要订正:
-- 调度端(小程序管理端)运单列表的可见范围是「本组织」,超管看全租户。
-- dept_id 为 NULL 或 -1 的运单不属于任何组织,普通用户再也看不到它们。
-- 本机库实测 42 张:34 张的 dept_id = -1、8 张为 NULL,全部是 2026-10-15 / 10-16
-- 由 admin 在 1123598813738675201 下建的(-1 是客户端把「未选组织」当 -1 传上来的哨兵值,
-- 服务端写入前的兜底只判空、拦不住负数)。
--
-- 处置口径:取该运单的 create_dept(创建人所属组织)作为它的组织。
-- 42 张的 create_dept 都是 1123598813738675201,与它们的业务归属一致;
-- 按 create_dept 取而不是写死这个 id,其它环境执行时同样成立。
-- dept_name 取组织表当前的名字:新建运单写的就是这个名字,不跟随存量行里的历史短名
-- (「桂物物流集团」这类短名是历史写法,本次只补缺失值,不复位已存在的值)。
-- 只动 dept_id 为空或非正数的行,且要求 create_dept 为正数:重复执行不再改动任何行。
-- update_time 不在 SET 里,保留原值。
-- ----------------------------
-- 0. 备份(必须在订正之前执行,仅首次需要)
-- ----------------------------
-- CREATE TABLE blade_waybill_dept_bak AS
-- SELECT id, waybill_no, dept_id, dept_name, create_dept, update_time
-- FROM blade_waybill
-- WHERE is_deleted = 0 AND (dept_id IS NULL OR dept_id <= 0);
START TRANSACTION;
-- ----------------------------
-- 1. 订正组织
-- ----------------------------
UPDATE `blade_waybill`
SET `dept_id` = `create_dept`,
`dept_name` = (SELECT `dept_name` FROM `blade_dept` WHERE `id` = `blade_waybill`.`create_dept` LIMIT 1)
WHERE `is_deleted` = 0
AND (`dept_id` IS NULL OR `dept_id` <= 0)
AND `create_dept` IS NOT NULL
AND `create_dept` > 0;
COMMIT;
-- ----------------------------
-- 2. 校验(执行后必看,两个都必须返回 0)
-- ----------------------------
-- -- 仍不属于任何组织的可见运单
-- SELECT COUNT(*) FROM blade_waybill
-- WHERE is_deleted = 0 AND (dept_id IS NULL OR dept_id <= 0) AND create_dept > 0;
--
-- -- 本次订正过的行,组织 ID 与组织名都必须跟组织表一致
-- SELECT COUNT(*) FROM blade_waybill w
-- JOIN blade_waybill_dept_bak b ON b.id = w.id
-- JOIN blade_dept d ON d.id = w.dept_id
-- WHERE w.dept_id <> w.create_dept OR w.dept_name <> d.dept_name;
-- ----------------------------
-- 3. 回退(订正后发现问题时执行)
-- ----------------------------
-- UPDATE blade_waybill w
-- JOIN blade_waybill_dept_bak b ON b.id = w.id
-- SET w.dept_id = b.dept_id, w.dept_name = b.dept_name;