From 7d951d5b044b4e45d890610e2f0cdd045a117316 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Sun, 18 Oct 2026 11:22:04 +0800 Subject: [PATCH] =?UTF-8?q?:sparkles:=20=E5=B0=8F=E7=A8=8B=E5=BA=8F?= =?UTF-8?q?=E8=B0=83=E5=BA=A6=E7=AB=AF=E8=BF=90=E5=8D=95=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E3=80=81=E5=BE=85=E5=A4=84=E7=90=86=E4=B8=8E=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E6=8C=89=E7=BB=84=E7=BB=87=E6=94=B6=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原先三个取数入口都不带组织条件,租户内两个组织的运单混在同一张表里。「小程序管理」 账号与运单业务组织不一致确实是事实,但那是账号挂在一个已软删部门上的脏数据症状, 不是「不该按组织过滤」的理由。 现在与后台运单列表同一条规则:普通用户只出自己所属组织,超级管理员不过滤, 小程序端不提供「全部组织」开关;详情与重新派单照旧不按组织收口。 组织条件由调用方算好、经 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 项用例通过。 --- .../transport/pojo/vo/TripQueryVO.java | 3 + .../controller/ManageWaybillController.java | 2 +- .../impl/ManageWaybillServiceImpl.java | 33 ++- .../service/impl/TripQueryServiceImpl.java | 15 ++ .../service/impl/ManageAdminHomeFeedTest.java | 16 ++ .../impl/ManageTripListDetailTest.java | 16 ++ .../impl/ManageWaybillOrgScopeTest.java | 226 ++++++++++++++++++ .../impl/TripQueryServiceImplTest.java | 38 +++ .../blade_waybill_dept_backfill_20261018.sql | 61 +++++ 9 files changed, 403 insertions(+), 7 deletions(-) create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageWaybillOrgScopeTest.java create mode 100644 doc/sql/transport/blade_waybill_dept_backfill_20261018.sql diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripQueryVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripQueryVO.java index 1b87fe58..5027ada9 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripQueryVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripQueryVO.java @@ -53,6 +53,9 @@ public class TripQueryVO implements Serializable { @Schema(description = "是否不按司机收口(管理端)") private boolean allDrivers; + @Schema(description = "可见组织 ID 集合:非空时只出这些组织的运单行,空集合表示不按组织过滤") + private List deptIds; + @Schema(description = "状态:0待接单/1进行中/2已完成/3已取消,不传为全部(不含已取消)") private Integer status; 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 291a5582..ee10e0b6 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 @@ -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 stats() { return R.data(manageWaybillService.stats()); } 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 e47b6357..41ab9cc2 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 @@ -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 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 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)。 + * 调度端可见组织:普通用户(含各级角色)只出自己所属组织,超级管理员不过滤。 + *

+ * 与后台运单列表同一条规则({@link TransportBusinessSupport#resolveDeptFilterIds}), + * 小程序端不提供「全部组织」开关,故两个入参固定传空;详情与重新派单不按组织收口。 */ - private LambdaQueryWrapper scopedWaybillQuery() { + private List manageableDeptIds() { + return TransportBusinessSupport.resolveDeptFilterIds(null, null, "运单管理"); + } + + /** + * 已拒子单的查询底:只有租户与逻辑删除(MyBatis-Plus 自动补),不带组织。 + *

+ * 它服务的是「这个运次里有没有被拒的子单」,可见范围由调用方先定好——列表传的运次已经收过组织, + * 详情按主键进入不收组织。这里再按组织收一次,详情里别的组织的运次会丢掉重派标记。 + */ + private LambdaQueryWrapper rejectedChildQuery() { return Wrappers.lambdaQuery(); } @@ -1039,7 +1060,7 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { } private LambdaQueryWrapper pendingReassignQuery() { - return scopedWaybillQuery() + return rejectedChildQuery() .eq(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED) .in(Waybill::getBusinessStatus, STATUS_PENDING, STATUS_RUNNING); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripQueryServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripQueryServiceImpl.java index bfefd237..7af9b79f 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripQueryServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripQueryServiceImpl.java @@ -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 rows = waybillService.list(wrapper); @@ -243,6 +244,19 @@ public class TripQueryServiceImpl implements ITripQueryService { return rows; } + /** + * 组织收口:条件里给了组织 ID 集合时只出这些组织的运单行,空集合表示不过滤。 + *

+ * 与司机手机号收口一样落在库层的运单行上:分档、分页、总数都由它缩过的行集合算出来。 + * 读服务不自己解析当前用户的组织——它同时服务司机端与管理端,可见范围由调用方算好再传。 + */ + private void applyDeptScope(LambdaQueryWrapper 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()); 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 fe15f9ee..3b37513b 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 @@ -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; @BeforeAll static void initTableInfo() { @@ -82,6 +89,15 @@ class ManageAdminHomeFeedTest { .thenReturn(new ArrayList<>()); when(waybillService.list(ArgumentMatchers.>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 diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageTripListDetailTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageTripListDetailTest.java index fbfde184..b5771621 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageTripListDetailTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageTripListDetailTest.java @@ -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; private IDriverWaybillService driverWaybillService; private static java.time.LocalDate toLocalDate(Date date) { @@ -98,6 +105,15 @@ class ManageTripListDetailTest { when(exceptionDisposalService.list(ArgumentMatchers.>any())) .thenReturn(new ArrayList<>()); when(waybillService.list(ArgumentMatchers.>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 diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageWaybillOrgScopeTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageWaybillOrgScopeTest.java new file mode 100644 index 00000000..15a31a27 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageWaybillOrgScopeTest.java @@ -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; + +/** + * 调度端的组织可见范围:普通用户只出自己所属组织,超级管理员不过滤。 + *

+ * 三个取数入口必须同一条规则——列表、待处理、首页三档统计各走一次查询,漏一处就会出现 + * 「统计说 46 张、列表只出 18 张」这种对不上的数;待重新派单另走库层分组查询,也要单独带上。 + *

+ * 组织取不到时报错而不是放行:放行等于把全租户的运单发给一个没有组织的账号。 + */ +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; + + @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.>any())) + .thenReturn(new ArrayList<>()); + when(waybillService.list(ArgumentMatchers.>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 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 pageCaptor = ArgumentCaptor.forClass(TripQueryVO.class); + verify(tripQueryService).page(pageCaptor.capture()); + assertTrue(pageCaptor.getValue().getDeptIds().isEmpty(), "超管不过滤组织"); + + service.stats(); + ArgumentCaptor 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> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(waybillService).page(any(), captor.capture()); + QueryWrapper wrapper = (QueryWrapper) captor.getValue(); + String sql = String.valueOf(wrapper.getTargetSql()); + assertTrue(sql.contains("dept_id"), "重派查询要带上组织:" + sql); + Set values = new HashSet<>(wrapper.getParamNameValuePairs().values()); + assertTrue(values.contains(DEPT), "组织值要作为参数下发:" + values); + } + + @Test + @DisplayName("待重新派单:超管的库层分组查询不带组织") + void reassignQuerySkipsScopeForAdministrator() { + asAdministrator(); + + service.pendingList(1, 10, null, true); + + ArgumentCaptor> 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 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 emptyTripPage() { + Page page = new Page<>(1, 10, 0); + page.setRecords(List.of()); + return page; + } + + private TripQueryVO capturedPageQuery() { + ArgumentCaptor 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); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripQueryServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripQueryServiceImplTest.java index 639b5ab6..e981e71e 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripQueryServiceImplTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripQueryServiceImplTest.java @@ -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 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() { diff --git a/doc/sql/transport/blade_waybill_dept_backfill_20261018.sql b/doc/sql/transport/blade_waybill_dept_backfill_20261018.sql new file mode 100644 index 00000000..f1a9eaf4 --- /dev/null +++ b/doc/sql/transport/blade_waybill_dept_backfill_20261018.sql @@ -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;