diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/NodeSubmitDTO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/NodeSubmitDTO.java index 0ba2fba4..46720941 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/NodeSubmitDTO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/NodeSubmitDTO.java @@ -54,6 +54,15 @@ public class NodeSubmitDTO implements Serializable { @Schema(description = "数量(件)") private String quantity; + /** + * 里程(公里):只由末位打卡节点采集,运单计费规则含里程时必填。 + *

+ * 用字符串接收——旧版小程序不传、新版按输入框传字符串,两种都要能进; + * 非末位节点收到该值时忽略,不报错。 + */ + @Schema(description = "里程(公里),末位打卡节点采集") + private String mileage; + @Schema(description = "备注") private String remark; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/WaybillPunchRecordSaveRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/WaybillPunchRecordSaveRequest.java index 763f8f31..af45b2df 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/WaybillPunchRecordSaveRequest.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/WaybillPunchRecordSaveRequest.java @@ -27,6 +27,7 @@ import lombok.Data; import java.io.Serial; import java.io.Serializable; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; @@ -84,6 +85,13 @@ public class WaybillPunchRecordSaveRequest implements Serializable { @Schema(description = "数量(件)") private String quantity; + /** + * 里程(公里):null 表示不改动运单里程;只在末位打卡节点上生效。 + * 管理端为选填,留空即保持原值。 + */ + @Schema(description = "里程(公里),为空表示不改") + private BigDecimal mileage; + @Schema(description = "备注") private String remark; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java index eaeec543..7533ce2e 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java @@ -11,6 +11,7 @@ import lombok.Data; import java.io.Serial; import java.io.Serializable; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; @@ -94,6 +95,17 @@ public class DriverPunchNodeVO implements Serializable { @Schema(description = "数量(件)") private String quantity; + /** + * 本节点是否需要采集里程:末位可打卡节点、且运单计费规则含里程(应收或应付任一侧)。 + *

+ * 判据与 finishOnPunch、自动完成同源的「末位」定义;客户端只认这个标记,不自己推末位。 + */ + @Schema(description = "本节点是否需要采集里程(末位可打卡节点且计费规则含里程)") + private Boolean needMileage; + + @Schema(description = "运单里程(公里),needMileage 为真时用于回显") + private BigDecimal mileage; + @Schema(description = "已上传凭证图(已打卡回显)") private List photos = new ArrayList<>(); diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverWaybillTabCountsVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverWaybillTabCountsVO.java index 3aa95f5e..79787cb7 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverWaybillTabCountsVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverWaybillTabCountsVO.java @@ -40,7 +40,7 @@ public class DriverWaybillTabCountsVO implements Serializable { @Serial private static final long serialVersionUID = 1L; - @Schema(description = "全部(待接单+运输中+已完成)") + @Schema(description = "全部(待接单+运输中+待确认+已完成)") private long all; @Schema(description = "待接单") @@ -49,6 +49,9 @@ public class DriverWaybillTabCountsVO implements Serializable { @Schema(description = "进行中(运输中)") private long doing; + @Schema(description = "待确认(待回单确认)") + private long confirm; + @Schema(description = "已完成") private long done; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripTabCountsVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripTabCountsVO.java index 944c4ded..01eb22f7 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripTabCountsVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripTabCountsVO.java @@ -40,7 +40,7 @@ public class TripTabCountsVO implements Serializable { @Serial private static final long serialVersionUID = 1L; - @Schema(description = "全部(待接单+进行中+已完成,不含已取消)") + @Schema(description = "全部(待接单+进行中+待确认+已完成,不含已取消)") private long all; @Schema(description = "待接单") @@ -49,6 +49,9 @@ public class TripTabCountsVO implements Serializable { @Schema(description = "进行中") private long doing; + @Schema(description = "待确认(待回单确认)") + private long confirm; + @Schema(description = "已完成") private long done; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillPunchRecordItemVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillPunchRecordItemVO.java index 140d6bc8..62f089ba 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillPunchRecordItemVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillPunchRecordItemVO.java @@ -9,6 +9,7 @@ import lombok.Data; import java.io.Serial; import java.io.Serializable; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; @@ -55,6 +56,18 @@ public class WaybillPunchRecordItemVO implements Serializable { @Schema(description = "数量") private String quantity; + /** + * 本条记录是否采集里程:末位打卡节点、且运单计费规则含里程。只有该条为真时管理端才展示里程项。 + */ + @Schema(description = "本条是否需要采集里程(末位打卡节点且计费规则含里程)") + private Boolean needMileage; + + @Schema(description = "运单里程(公里),needMileage 为真时用于回显") + private BigDecimal mileage; + + @Schema(description = "里程是否已冻结(运单已生成结算单,只能看不能改)") + private Boolean mileageLocked; + @Schema(description = "备注") private String remark; diff --git a/blade-service/blade-system/pom.xml b/blade-service/blade-system/pom.xml index 81ac5a93..27c09ffa 100644 --- a/blade-service/blade-system/pom.xml +++ b/blade-service/blade-system/pom.xml @@ -94,6 +94,13 @@ + + + + org.springframework.boot + spring-boot-starter-test + test + diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java index c93c8097..55e6556f 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java @@ -299,7 +299,7 @@ public class MpUserServiceImpl extends BaseServiceImpl imp throw new ServiceException("该手机号已存在小程序用户"); } encryptPasswordIfPresent(user); - boolean saved = save(user) && submitScopes(user); + boolean saved = save(user) && submitScopes(user, true); if (saved) { MpUserCache.clear(); } @@ -316,6 +316,20 @@ public class MpUserServiceImpl extends BaseServiceImpl imp if (dbUser == null || Func.isEmpty(dbUser.getId())) { throw new ServiceException("小程序用户不存在"); } + // 数据权限三字段以 null 表示调用方未提供(与 updateById 忽略 null 同口径),未提供的用库值回填。 + // 判定与回填都必须在 prepare 之前:prepare 会把 range != 3 的 dataScopeDeptIds 置空, + // 之后就分不清「未提供」和「提供了空列表」。 + // 不变量:只改人口字段的更新不得改变数据权限范围、层级与自定义组织。 + boolean rebuildScopes = user.getDataScopeRange() != null || user.getDataScopeDeptIds() != null; + if (user.getDataScopeRange() == null) { + user.setDataScopeRange(dbUser.getDataScopeRange()); + } + if (user.getDataLevelRange() == null) { + user.setDataLevelRange(dbUser.getDataLevelRange()); + } + if (user.getDataScopeDeptIds() == null && Objects.equals(user.getDataScopeRange(), 3)) { + user.setDataScopeDeptIds(fillScopes(dbUser).getDataScopeDeptIds()); + } prepare(user); // 未改手机号(前端脱敏未提交 / 仍为脱敏值)时沿用库中原值,避免把 138****8000 写回 if (Func.isBlank(user.getPhone()) || isMaskedPhone(user.getPhone())) { @@ -331,7 +345,7 @@ public class MpUserServiceImpl extends BaseServiceImpl imp } // 后台编辑不改密码,避免空串覆盖 user.setPassword(null); - boolean updated = updateById(user) && submitScopes(user); + boolean updated = updateById(user) && submitScopes(user, rebuildScopes); if (updated) { MpUserCache.clear(); } @@ -552,9 +566,7 @@ public class MpUserServiceImpl extends BaseServiceImpl imp if (Func.isBlank(user.getPhone())) { throw new ServiceException("手机号不能为空"); } - if (Func.isBlank(user.getDeptId())) { - throw new ServiceException("请选择所属组织"); - } + // 账号组织只服务调度端数据权限:为空表示空集(不是全部组织),司机身份不需要它,故不作必填 if (Func.isBlank(user.getIdentities())) { throw new ServiceException("小程序身份不能为空"); } @@ -580,10 +592,13 @@ public class MpUserServiceImpl extends BaseServiceImpl imp } } - private boolean submitScopes(MpUser user) { + private boolean submitScopes(MpUser user, boolean rebuild) { if (user == null || Func.isEmpty(user.getId())) { return false; } + if (!rebuild) { + return true; + } mpUserDataScopeMapper.delete(Wrappers.lambdaQuery().eq(MpUserDataScope::getUserId, user.getId())); if (Objects.equals(user.getDataScopeRange(), 3) && Func.isNotEmpty(user.getDataScopeDeptIds())) { user.getDataScopeDeptIds().stream().filter(Objects::nonNull).distinct().forEach(deptId -> { diff --git a/blade-service/blade-system/src/test/java/org/springblade/system/service/impl/MpUserServiceImplTest.java b/blade-service/blade-system/src/test/java/org/springblade/system/service/impl/MpUserServiceImplTest.java new file mode 100644 index 00000000..8ff82829 --- /dev/null +++ b/blade-service/blade-system/src/test/java/org/springblade/system/service/impl/MpUserServiceImplTest.java @@ -0,0 +1,411 @@ +package org.springblade.system.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.system.cache.MpUserCache; +import org.springblade.system.mapper.MpUserDataScopeMapper; +import org.springblade.system.mapper.MpUserMapper; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.system.pojo.entity.MpUserDataScope; +import org.springblade.system.pojo.vo.MpUserDataScopeVO; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +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.ArgumentMatchers.anyBoolean; +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; +import static org.mockito.Mockito.when; + +/** + * 小程序用户身份与数据权限测试。 + *

+ * 注入方式:被测类直接 new(唯一构造参数是 MpUserDataScopeMapper), + * {@code BaseServiceImpl.baseMapper} 用反射写入 MpUserMapper 的 mock, + * 与 blade-transport 的 DriverServiceImplTest 同一套路,不起 Spring 容器。 + * {@code MpUserCache.clear()} 内部走 SpringUtil.getBean(CacheManager),无容器即抛空指针, + * 故每次调用前把该静态方法 mock 掉。 + *

+ * 非法入参用例反射调用私有的 validate:updateUser 会先把手机号用库值回填、再补数据权限默认值, + * 空值走不到校验分支。 + */ +class MpUserServiceImplTest { + + private static final Long USER_ID = 7001L; + private static final Long SCOPE_DEPT_A = 1001L; + private static final Long SCOPE_DEPT_B = 1002L; + private static final String TENANT_ID = "000000"; + private static final String PHONE = "15975348524"; + private static final String DEPT_ID = "1123598817739906"; + + private MpUserMapper mpUserMapper; + private MpUserDataScopeMapper mpUserDataScopeMapper; + private MpUserServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // 构造 Lambda 条件需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, MpUser.class); + TableInfoHelper.initTableInfo(assistant, MpUserDataScope.class); + } + + @BeforeEach + void setUp() throws Exception { + mpUserMapper = mock(MpUserMapper.class); + mpUserDataScopeMapper = mock(MpUserDataScopeMapper.class); + service = new MpUserServiceImpl(mpUserDataScopeMapper); + inject("baseMapper", mpUserMapper); + } + + // ------------------------------------------------------------ 账号组织不再必填 + + @ParameterizedTest(name = "{2}") + @MethodSource("identityCases") + @DisplayName("编辑小程序用户:司机 / 管理员 / 司机+管理员 × 有组织 / 无组织,六种组合全部放行") + void updateUserAcceptsEmptyDeptForEveryIdentity(String identities, String deptId, String label) { + when(mpUserMapper.selectById(USER_ID)).thenReturn(dbUser()); + when(mpUserMapper.updateById(any(MpUser.class))).thenReturn(1); + + MpUser user = new MpUser(); + user.setId(USER_ID); + user.setTenantId(TENANT_ID); + user.setPhone(PHONE); + user.setIdentities(identities); + user.setDeptId(deptId); + + assertTrue(updateUser(user), label + ":账号组织不是准入门槛"); + } + + static Stream identityCases() { + return Stream.of( + Arguments.of("driver", DEPT_ID, "司机 + 有组织"), + Arguments.of("driver", null, "司机 + 无组织"), + Arguments.of("admin", DEPT_ID, "管理员 + 有组织"), + Arguments.of("admin", null, "管理员 + 无组织"), + Arguments.of("driver,admin", DEPT_ID, "司机+管理员 + 有组织"), + Arguments.of("driver,admin", null, "司机+管理员 + 无组织")); + } + + @Test + @DisplayName("validate 仍拦截:租户空、手机号空、身份空、数据权限范围与层级非法") + void validateStillRejectsMissingOrIllegalFields() { + assertValidateRejects(user -> user.setTenantId(null), "租户信息不能为空"); + assertValidateRejects(user -> user.setPhone(null), "手机号不能为空"); + assertValidateRejects(user -> user.setIdentities(null), "小程序身份不能为空"); + assertValidateRejects(user -> user.setDataScopeRange(0), "数据权限范围不正确"); + assertValidateRejects(user -> user.setDataScopeRange(4), "数据权限范围不正确"); + assertValidateRejects(user -> user.setDataLevelRange(0), "数据层级范围不正确"); + assertValidateRejects(user -> user.setDataLevelRange(3), "数据层级范围不正确"); + } + + // ------------------------------------------------------------ 只改人口字段不重置数据权限 + + @Test + @DisplayName("只改姓名:数据权限范围、层级与自定义组织全部不变,关联表一行都不动") + void updatePersonFieldsOnlyKeepsDataScope() { + when(mpUserMapper.selectById(USER_ID)).thenReturn(customScopeDbUser()); + when(mpUserMapper.updateById(any(MpUser.class))).thenReturn(1); + when(mpUserDataScopeMapper.selectList(any())).thenReturn(scopes(SCOPE_DEPT_A, SCOPE_DEPT_B)); + + MpUser user = new MpUser(); + user.setId(USER_ID); + user.setTenantId(TENANT_ID); + user.setName("张三"); + user.setIdentities("admin"); + + assertTrue(updateUser(user)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MpUser.class); + verify(mpUserMapper).updateById(captor.capture()); + assertEquals(3, captor.getValue().getDataScopeRange(), "未提供范围时沿用库里的 3-自定义"); + assertEquals(2, captor.getValue().getDataLevelRange(), "未提供层级时沿用库里的 2-仅本级"); + assertEquals(List.of(SCOPE_DEPT_A, SCOPE_DEPT_B), captor.getValue().getDataScopeDeptIds()); + assertNull(captor.getValue().getDeptId(), "调用方不传组织时 updateById 忽略该列,不得写成空串"); + verify(mpUserDataScopeMapper, never()).delete(any()); + verify(mpUserDataScopeMapper, never()).insert(any(MpUserDataScope.class)); + } + + @Test + @DisplayName("显式传 range=2:范围改写为全部组织,自定义组织关联被清空") + void updateWithExplicitRangeRewritesScopes() { + when(mpUserMapper.selectById(USER_ID)).thenReturn(customScopeDbUser()); + when(mpUserMapper.updateById(any(MpUser.class))).thenReturn(1); + + MpUser user = new MpUser(); + user.setId(USER_ID); + user.setTenantId(TENANT_ID); + user.setIdentities("admin"); + user.setDataScopeRange(2); + + assertTrue(updateUser(user)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MpUser.class); + verify(mpUserMapper).updateById(captor.capture()); + assertEquals(2, captor.getValue().getDataScopeRange()); + assertEquals(2, captor.getValue().getDataLevelRange(), "未提供层级时沿用库值"); + assertTrue(captor.getValue().getDataScopeDeptIds().isEmpty(), "range != 3 时关联列表被清空"); + verify(mpUserDataScopeMapper).delete(any()); + verify(mpUserDataScopeMapper, never()).insert(any(MpUserDataScope.class)); + } + + @Test + @DisplayName("显式传 level=1:层级改写为仅本级,范围与自定义组织保持库值,关联表一行都不动") + void updateWithExplicitLevelRewritesLevelOnly() { + when(mpUserMapper.selectById(USER_ID)).thenReturn(customScopeDbUser()); + when(mpUserMapper.updateById(any(MpUser.class))).thenReturn(1); + when(mpUserDataScopeMapper.selectList(any())).thenReturn(scopes(SCOPE_DEPT_A, SCOPE_DEPT_B)); + + MpUser user = new MpUser(); + user.setId(USER_ID); + user.setTenantId(TENANT_ID); + user.setIdentities("admin"); + user.setDataLevelRange(1); + + assertTrue(updateUser(user)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MpUser.class); + verify(mpUserMapper).updateById(captor.capture()); + assertEquals(1, captor.getValue().getDataLevelRange(), "显式传的层级要落库"); + assertEquals(3, captor.getValue().getDataScopeRange(), "未传范围时沿用库里的 3-自定义"); + assertEquals(List.of(SCOPE_DEPT_A, SCOPE_DEPT_B), captor.getValue().getDataScopeDeptIds()); + verify(mpUserDataScopeMapper, never()).delete(any()); + verify(mpUserDataScopeMapper, never()).insert(any(MpUserDataScope.class)); + } + + @Test + @DisplayName("只显式传自定义组织列表:关联按新列表重建,范围沿用库里的 3-自定义") + void updateWithExplicitDeptIdsRebuildsScopes() { + when(mpUserMapper.selectById(USER_ID)).thenReturn(customScopeDbUser()); + when(mpUserMapper.updateById(any(MpUser.class))).thenReturn(1); + + MpUser user = new MpUser(); + user.setId(USER_ID); + user.setTenantId(TENANT_ID); + user.setIdentities("admin"); + user.setDataScopeDeptIds(List.of(SCOPE_DEPT_A)); + + assertTrue(updateUser(user)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MpUser.class); + verify(mpUserMapper).updateById(captor.capture()); + assertEquals(3, captor.getValue().getDataScopeRange(), "未提供范围时沿用库值"); + assertEquals(List.of(SCOPE_DEPT_A), captor.getValue().getDataScopeDeptIds()); + verify(mpUserDataScopeMapper).delete(any()); + verify(mpUserDataScopeMapper).insert(any(MpUserDataScope.class)); + } + + @Test + @DisplayName("新建小程序用户:仍按提交的 range=3 重建自定义组织关联") + void submitStillRebuildsScopes() { + when(mpUserMapper.selectOne(any(), anyBoolean())).thenReturn(null); + when(mpUserMapper.insert(any(MpUser.class))).thenAnswer(invocation -> { + invocation.getArgument(0, MpUser.class).setId(USER_ID); + return 1; + }); + + MpUser user = new MpUser(); + user.setTenantId(TENANT_ID); + user.setPhone(PHONE); + user.setIdentities("admin"); + user.setDataScopeRange(3); + user.setDataScopeDeptIds(List.of(SCOPE_DEPT_A, SCOPE_DEPT_B)); + + try (MockedStatic ignored = mockStatic(MpUserCache.class)) { + assertTrue(service.submit(user)); + } + verify(mpUserDataScopeMapper).delete(any()); + verify(mpUserDataScopeMapper, times(2)).insert(any(MpUserDataScope.class)); + } + + // ------------------------------------------------------------ 注册路径不要求所属组织 + + @ParameterizedTest(name = "{1}") + @MethodSource("emptyDeptRegistrationCases") + @DisplayName("注册:司机 / 管理员 / 司机+管理员不选所属组织也能建号") + void submitAcceptsEmptyDeptForEveryIdentity(String identities, String label) { + when(mpUserMapper.selectOne(any(), anyBoolean())).thenReturn(null); + when(mpUserMapper.insert(any(MpUser.class))).thenAnswer(invocation -> { + invocation.getArgument(0, MpUser.class).setId(USER_ID); + return 1; + }); + + MpUser user = new MpUser(); + user.setTenantId(TENANT_ID); + user.setPhone(PHONE); + user.setIdentities(identities); + + try (MockedStatic ignored = mockStatic(MpUserCache.class)) { + assertTrue(service.submit(user), label + ":所属组织不是注册的准入门槛"); + } + + ArgumentCaptor captor = ArgumentCaptor.forClass(MpUser.class); + verify(mpUserMapper).insert(captor.capture()); + assertNull(captor.getValue().getDeptId(), "未选组织时组织列保持 null,不写空串"); + assertEquals(1, captor.getValue().getDataScopeRange(), "未选组织的账号按默认 1-仅所属组织落库"); + } + + static Stream emptyDeptRegistrationCases() { + return Stream.of( + Arguments.of("driver", "司机"), + Arguments.of("admin", "管理员"), + Arguments.of("driver,admin", "司机+管理员")); + } + + // ------------------------------------------------------------ 空组织的解析结果是空集 + + @ParameterizedTest(name = "{2}") + @MethodSource("emptyScopeCases") + @DisplayName("数据权限解析:基础组织为空时得到空集,不退化成全部组织") + void resolveDataScopeIsEmptyWhenBaseDeptsEmpty(Integer dataScopeRange, Integer dataLevelRange, String label) { + MpUser user = dbUser(); + user.setDeptId(null); + user.setDataScopeRange(dataScopeRange); + user.setDataLevelRange(dataLevelRange); + when(mpUserMapper.selectById(USER_ID)).thenReturn(user); + when(mpUserDataScopeMapper.selectList(any())).thenReturn(List.of()); + + MpUserDataScopeVO scope = service.resolveDataScope(USER_ID); + + assertFalse(scope.isAllDept(), label + ":空集不是全部组织,allDept=true 表示不按组织过滤"); + assertTrue(scope.getDeptIds().isEmpty()); + } + + static Stream emptyScopeCases() { + return Stream.of( + Arguments.of(null, null, "范围与层级都未设置"), + Arguments.of(1, 1, "1-仅所属组织 + 1-含下级,但没有组织"), + Arguments.of(3, 2, "3-自定义,但一条组织关联都没有")); + } + + @Test + @DisplayName("数据权限解析:只有范围是 2-全部组织时才返回全部") + void resolveDataScopeIsAllOnlyForAllScope() { + MpUser user = dbUser(); + user.setDeptId(null); + user.setDataScopeRange(2); + when(mpUserMapper.selectById(USER_ID)).thenReturn(user); + + MpUserDataScopeVO scope = service.resolveDataScope(USER_ID); + + assertTrue(scope.isAllDept()); + } + + // ------------------------------------------------------------ 夹具 + + /** + * updateUser 成功后会清缓存;缓存实现依赖 Spring 容器,无容器时静态 mock 掉 + */ + private boolean updateUser(MpUser user) { + try (MockedStatic ignored = mockStatic(MpUserCache.class)) { + return service.updateUser(user); + } + } + + private MpUser dbUser() { + MpUser user = new MpUser(); + user.setId(USER_ID); + user.setTenantId(TENANT_ID); + user.setPhone(PHONE); + user.setAccount(PHONE); + user.setName("张三"); + user.setIdentities("driver"); + user.setDeptId(DEPT_ID); + user.setDataScopeRange(1); + user.setDataLevelRange(1); + return user; + } + + /** + * range=3(自定义)+ 仅本级,自定义组织由 mpUserDataScopeMapper.selectList 提供 + */ + private MpUser customScopeDbUser() { + MpUser user = dbUser(); + user.setIdentities("admin"); + user.setDataScopeRange(3); + user.setDataLevelRange(2); + return user; + } + + private List scopes(Long... deptIds) { + return Stream.of(deptIds).map(deptId -> { + MpUserDataScope scope = new MpUserDataScope(); + scope.setUserId(USER_ID); + scope.setDeptId(deptId); + return scope; + }).toList(); + } + + private MpUser validUser() { + MpUser user = new MpUser(); + user.setId(USER_ID); + user.setTenantId(TENANT_ID); + user.setPhone(PHONE); + user.setIdentities("driver"); + user.setDataScopeRange(1); + user.setDataLevelRange(1); + return user; + } + + private void assertValidateRejects(Consumer mutation, String expectedMessage) { + MpUser user = validUser(); + mutation.accept(user); + ServiceException exception = assertThrows(ServiceException.class, () -> invokeValidate(user)); + assertEquals(expectedMessage, exception.getMessage()); + } + + private void invokeValidate(MpUser user) throws Exception { + Method validate = MpUserServiceImpl.class.getDeclaredMethod("validate", MpUser.class); + validate.setAccessible(true); + try { + validate.invoke(service, user); + } catch (InvocationTargetException exception) { + if (exception.getCause() instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw exception; + } + } + + private void inject(String field, Object value) throws Exception { + Class type = service.getClass(); + while (type != null) { + try { + Field declared = type.getDeclaredField(field); + declared.setAccessible(true); + declared.set(service, value); + return; + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } + } + throw new IllegalStateException("未找到字段:" + field); + } + +} diff --git a/blade-service/blade-transport/pom.xml b/blade-service/blade-transport/pom.xml index 7f7e843b..02e9bab8 100644 --- a/blade-service/blade-transport/pom.xml +++ b/blade-service/blade-transport/pom.xml @@ -55,6 +55,9 @@ org.springframework.boot spring-boot-starter-amqp + org.springframework.boot spring-boot-starter-test @@ -64,15 +67,6 @@ io.minio minio - - - - org.springframework.boot - spring-boot-starter-test - test - diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/TransportBusiConstant.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/TransportBusiConstant.java index f27ce70d..93f278b8 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/TransportBusiConstant.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/TransportBusiConstant.java @@ -61,6 +61,14 @@ public final class TransportBusiConstant { /** 审批状态:已作废(终态)。 */ public static final String VOIDED = "voided"; + /** + * 「未分配组织」筛选项的哨兵值,不是真实组织名。 + *

+ * 前端 organization-search 混入、DriverMapper.xml 与 TransportVehicleMapper.xml 的 + * organizationName 分支、以及两个导出查询都按这个字面量判定,四处必须同步修改。 + */ + public static final String UNASSIGNED_ORG_FILTER = "__UNASSIGNED_ORG__"; + private TransportBusiConstant() { } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverController.java index 89f336bc..e409446d 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverController.java @@ -43,6 +43,7 @@ import org.springblade.core.secure.annotation.PreAuth; import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.DateUtil; import org.springblade.core.tool.utils.Func; +import org.springblade.transport.constant.TransportBusiConstant; import org.springblade.transport.excel.DriverExcel; import org.springblade.transport.pojo.entity.Driver; import org.springblade.transport.pojo.vo.DriverExpiryStatVO; @@ -233,7 +234,11 @@ public class DriverController extends BladeController { if (Func.isNotEmpty(driver.getDriverType())) { queryWrapper.eq(Driver::getDriverType, driver.getDriverType()); } - if (Func.isNotEmpty(driver.getOrganizationName())) { + // 「未分配组织」哨兵改判组织为空串:组织列 NOT NULL,无组织存空串。 + // 哨兵字面量由 TransportBusiConstant.UNASSIGNED_ORG_FILTER 定义,前端与两个 Mapper XML 同步。 + if (TransportBusiConstant.UNASSIGNED_ORG_FILTER.equals(driver.getOrganizationName())) { + queryWrapper.eq(Driver::getOrganizationName, ""); + } else if (Func.isNotEmpty(driver.getOrganizationName())) { queryWrapper.like(Driver::getOrganizationName, driver.getOrganizationName()); } if (Func.isNotEmpty(driver.getStatus())) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java index 313db1da..06eaf200 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java @@ -107,7 +107,7 @@ public class DriverWaybillController extends BladeController { @GetMapping("/task/current") @ApiOperationSupport(order = 21) - @Operation(summary = "首页:当前运输中运次", description = "当前用户手机号可见的运输中运次(有配载单的一趟车算一个)最新一条") + @Operation(summary = "首页:当前运输中运次", description = "当前用户手机号可见的运输中或待确认(待回单确认)运次(有配载单的一趟车算一个)最新一条") public R tripCurrentTask() { return R.data(driverWaybillService.tripCurrentTask()); } @@ -122,18 +122,18 @@ public class DriverWaybillController extends BladeController { @GetMapping("/task/counts") @ApiOperationSupport(order = 23) - @Operation(summary = "运次 Tab 统计", description = "按运次统计:全部 = 待接单 + 进行中 + 已完成(不含已取消)") + @Operation(summary = "运次 Tab 统计", description = "按运次统计:全部 = 待接单 + 进行中 + 待确认 + 已完成(不含已取消);confirm 为待确认(待回单确认)") public R tripCounts() { return R.data(driverWaybillService.tripTabCounts()); } @GetMapping("/task/page") @ApiOperationSupport(order = 24) - @Operation(summary = "运次分页列表", description = "一页即若干个运次;status:空=全部,0待接单,1运输中,2已完成;keyword 支持运单号/配载单号/项目/司机/车牌/起终点/地址") + @Operation(summary = "运次分页列表", description = "一页即若干个运次;status:空=全部,0待接单,1运输中(不含待回单确认),2已完成,4待确认(待回单确认);keyword 支持运单号/配载单号/项目/司机/车牌/起终点/地址") public R> tripPage( @Parameter(description = "当前页") @RequestParam(required = false) Integer current, @Parameter(description = "每页运次数") @RequestParam(required = false) Integer size, - @Parameter(description = "状态:0待接单/1运输中/2已完成,不传为全部") @RequestParam(required = false) String status, + @Parameter(description = "状态:0待接单/1运输中/2已完成/4待确认,不传为全部") @RequestParam(required = false) String status, @Parameter(description = "关键字:运单号/配载单号/项目/司机/车牌/起终点/地址") @RequestParam(required = false) String keyword) { Integer statusCode = parseStatus(status); return R.data(driverWaybillService.tripPage(current, size, statusCode, keyword)); 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 81265fc4..ccab5d51 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 @@ -38,6 +38,7 @@ import org.springblade.transport.pojo.vo.AdminHomeVO; 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.WaybillLocateVO; import org.springblade.transport.pojo.vo.WaybillPunchRecordsVO; import org.springblade.transport.pojo.vo.WaybillTrackVO; import org.springblade.transport.service.IManageWaybillService; @@ -84,12 +85,12 @@ public class ManageWaybillController extends BladeController { @GetMapping("/list") @ApiOperationSupport(order = 3) - @Operation(summary = "运单分页列表", description = "当前组织运单;status:0待接单/1运输中/2已完成;exception:exception/normal;transportType:common/load") + @Operation(summary = "运单分页列表", description = "当前组织运单;status:0待接单/1运输中(不含待回单确认)/2已完成/4待确认(待回单确认);exception:exception/normal;transportType:common/load") public R> list( @Parameter(description = "当前页") @RequestParam(required = false) Integer current, @Parameter(description = "每页条数") @RequestParam(required = false) Integer size, @Parameter(description = "关键字:运单号/配载单号/项目/司机/车牌/起终点/地址") @RequestParam(required = false) String keyword, - @Parameter(description = "状态:0待接单/1运输中/2已完成,不传为全部") @RequestParam(required = false) String status, + @Parameter(description = "状态:0待接单/1运输中/2已完成/4待确认,不传为全部") @RequestParam(required = false) String status, @Parameter(description = "异常:exception有异常/normal无异常") @RequestParam(required = false) String exception, @Parameter(description = "运输组织:common普通/load配载") @RequestParam(required = false) String transportType, @Parameter(description = "创建日起 YYYY-MM-DD") @RequestParam(required = false) String startDate, @@ -108,7 +109,7 @@ public class ManageWaybillController extends BladeController { @GetMapping("/pending") @ApiOperationSupport(order = 5) - @Operation(summary = "待处理运单", description = "待接单/运输中;needReassign=true 仅司机已拒单") + @Operation(summary = "待处理运单", description = "待接单/运输中/待确认;needReassign=true 仅司机已拒单") public R> pending( @Parameter(description = "当前页") @RequestParam(required = false) Integer current, @Parameter(description = "每页条数") @RequestParam(required = false) Integer size, @@ -170,4 +171,12 @@ public class ManageWaybillController extends BladeController { return R.data(waybillService.trackVehicle(id, startDate, endDate)); } + @PostMapping("/locate") + @ApiOperationSupport(order = 12) + @Operation(summary = "车辆实时定位", description = "小程序调度端:仅需登录态,按运单绑定车牌查询 LBS 最新定位") + public R locate( + @Parameter(description = "运单ID", required = true) @RequestParam Long id) { + return R.data(waybillService.locateVehicle(id)); + } + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/TransportVehicleController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/TransportVehicleController.java index 37596eb4..a321bdc3 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/TransportVehicleController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/TransportVehicleController.java @@ -44,6 +44,7 @@ import org.springblade.core.secure.annotation.PreAuth; import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.DateUtil; import org.springblade.core.tool.utils.Func; +import org.springblade.transport.constant.TransportBusiConstant; import org.springblade.transport.excel.TransportVehicleExcel; import org.springblade.transport.pojo.entity.TransportVehicle; import org.springblade.transport.pojo.vo.TransportVehicleExpiryStatVO; @@ -226,7 +227,11 @@ public class TransportVehicleController extends BladeController { if (Func.isNotEmpty(ids)) { queryWrapper.in(TransportVehicle::getId, Func.toLongList(ids)); } - if (Func.isNotEmpty(vehicle.getOrganizationName())) { + // 「未分配组织」哨兵改判组织为空串:组织列 NOT NULL,无组织存空串。 + // 哨兵字面量由 TransportBusiConstant.UNASSIGNED_ORG_FILTER 定义,前端与两个 Mapper XML 同步。 + if (TransportBusiConstant.UNASSIGNED_ORG_FILTER.equals(vehicle.getOrganizationName())) { + queryWrapper.eq(TransportVehicle::getOrganizationName, ""); + } else if (Func.isNotEmpty(vehicle.getOrganizationName())) { queryWrapper.like(TransportVehicle::getOrganizationName, vehicle.getOrganizationName()); } if (Func.isNotEmpty(vehicle.getPlateNo())) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/DriverMapper.xml b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/DriverMapper.xml index 89e87067..2f1b5e2a 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/DriverMapper.xml +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/DriverMapper.xml @@ -170,8 +170,18 @@ AND driver_type = #{driver.driverType} - - AND organization_name LIKE #{organizationNameLike} + + + + AND organization_name = '' + + + + AND organization_name LIKE #{organizationNameLike} + + AND status = #{driver.status} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/TransportVehicleMapper.xml b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/TransportVehicleMapper.xml index 0faafb78..0d5857fd 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/TransportVehicleMapper.xml +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/TransportVehicleMapper.xml @@ -117,8 +117,18 @@ is_deleted = 0 - - AND organization_name LIKE #{organizationNameLike} + + + + AND organization_name = '' + + + + AND organization_name LIKE #{organizationNameLike} + + diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceivablePayableDetailService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceivablePayableDetailService.java index 82592347..c472b14e 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceivablePayableDetailService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceivablePayableDetailService.java @@ -84,12 +84,29 @@ public interface IReceivablePayableDetailService extends BaseService waybillIds); + /** + * 同上,但按 {@code mileageOverrides} 覆盖运单里程后再算。 + *

+ * 生成走独立事务(REQUIRES_NEW),读不到调用方事务里尚未提交的里程更新, + * 由调用方把本次刚写入的里程显式传进来,否则按里程计费会读到旧值或 null(算成 0 元)。 + */ + void generateForCompletedWaybills(List waybillIds, Map mileageOverrides); + /** * 运单维护里程后,同步未挂结算单的应收应付明细里程; * 对按里程、按吨·公里计费的费用行按新里程重新计算。 */ void syncMileageFromWaybill(Long waybillId, BigDecimal mileage); + /** + * 运单的计费规则是否包含里程:客户合同(应收)与承运商合同(应付)任一侧命中里程类计费要素即为真。 + *

+ * 判据与费用生成同源(同一份方案解析与规则命中),只读、不产生任何写操作; + * 无合同、方案不匹配、规则不命中一律返回 false。 + *

+ */ + boolean requiresMileage(Waybill waybill); + /** * 批量导入完成运单后按合同系统计费模式生成应收、应付明细。 *

与导入事务共用同一事务,运单尚未提交,因此直接传入实体而非主键。

diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillService.java index e87bc79a..53e50ad5 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillService.java @@ -35,6 +35,7 @@ import org.springblade.transport.pojo.vo.WaybillVO; import org.springblade.transport.pojo.dto.WaybillMileageRequest; import org.springblade.transport.pojo.dto.WaybillPunchRecordSaveRequest; +import java.math.BigDecimal; import java.util.List; /** @@ -97,6 +98,16 @@ public interface IWaybillService extends BaseService { WaybillVO copy(Long id); boolean changeRoute(Waybill waybill); boolean maintainMileage(WaybillMileageRequest request); + + /** + * 只写运单里程(定向更新),用于打卡链路采集里程。 + *

+ * 不做 maintainMileage 的「仅已完成 / 未生成结算单」前置——末位打卡发生在完成之前; + * 结算状态由调用方按各自入口的规则判定,也不触发费用重算,由调用方决定是否同步。 + *

+ */ + void savePunchMileage(Long waybillId, BigDecimal mileage); + boolean cancel(Long id); boolean reassign(Waybill waybill); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java index ca7ef54e..703119df 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java @@ -123,21 +123,6 @@ public class DriverCertServiceImpl implements IDriverCertService { } } - /** - * 建档前置条件:车辆行的 {@code organization_name} 是 NOT NULL 且无默认值,缺了这条 INSERT - * 直接报「Field 'organization_name' doesn't have a default value」,用户看到的是一串英文异常。 - *

- * 只在**新建车辆**时要求:重新认证不会写这一列(它由管理端维护,属于内部管理归属), - * 拿它拦住重新认证只会让司机无从下手。 - * - * @param driver 当前司机 - */ - private void requireOrganizationForNewVehicle(Driver driver) { - if (Func.isEmpty(driver.getOrganizationName())) { - throw new ServiceException("司机档案缺少所属组织,请联系管理员补充后再提交"); - } - } - /** * 文本必填 + 长度校验 * @@ -474,15 +459,13 @@ public class DriverCertServiceImpl implements IDriverCertService { */ private TransportVehicle createVehicleForDriver(Driver driver, VehicleCertSubmitRequest request, String normalizedPlate) { - // 内容校验已在 submitVehicleCert 里统一做过;这里只剩「这条 INSERT 需要什么」的前置条件 - requireOrganizationForNewVehicle(driver); - + // 内容校验已在 submitVehicleCert 里统一做过 TransportVehicle vehicle = new TransportVehicle(); vehicle.setPlateNo(normalizedPlate); - // 所属组织取司机档案:车辆表的该列 NOT NULL 且无默认值,不填这条 INSERT 直接报 - // 「Field 'organization_name' doesn't have a default value」(严格模式下必错)。 - // 车辆归到司机所属组织名下,管理端按组织查看时才看得到这台车。 - vehicle.setOrganizationName(driver.getOrganizationName()); + // 所属组织是建档时对司机档案取的一次**快照**,不是活关联:司机之后补上组织也不回填已建的车辆。 + // 司机档案没有组织时写空串——该列 NOT NULL 且无默认值,落 null 这条 INSERT 直接报 + // 「Field 'organization_name' doesn't have a default value」。 + vehicle.setOrganizationName(trimToEmpty(driver.getOrganizationName())); vehicle.setOwnerName(request.getOwnerName()); vehicle.setPlateColor(request.getPlateColor()); vehicle.setVehicleType(request.getVehicleType()); @@ -571,4 +554,8 @@ public class DriverCertServiceImpl implements IDriverCertService { return driver; } + private String trimToEmpty(String value) { + return value == null ? "" : value.trim(); + } + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java index 41964813..423bafec 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java @@ -383,9 +383,6 @@ public class DriverServiceImpl extends BaseServiceImpl imp if (Func.isEmpty(driver.getMobile())) { throw new ServiceException("手机号不能为空"); } - if (Func.isEmpty(driver.getOrganizationName())) { - throw new ServiceException("所属组织不能为空"); - } if (Func.isEmpty(driver.getEmergencyContactName())) { throw new ServiceException("紧急联系人姓名不能为空"); } @@ -475,11 +472,17 @@ public class DriverServiceImpl extends BaseServiceImpl imp } } - // 部门可选:匹配到则写入小程序用户,匹配不到不阻断同步 + // 按「组织名称」反查组织树 id:组织改名后这里必然落空——不报错、不掉数据, + // 只是该司机账号的权限不再随组织变化(新建账号落空时数据权限为空集,管理端看不到)。 + // 组织为空时不做反查:DeptServiceImpl 会把空名称拆成空集合,MyBatis-Plus 的 inExpression + // 对空集合拼出 dept_name IN () 非法子句。此时 deptId 保持 null 而不是空串:updateUser 的 + // updateById 忽略 null,账号原有的组织与数据权限得以保留;写成空串会把它们覆盖掉。 String deptId = null; - R deptResult = sysClient.getDeptIds(tenantId, driver.getOrganizationName()); - if (deptResult != null && R.isSuccess(deptResult) && Func.isNotEmpty(deptResult.getData())) { - deptId = deptResult.getData(); + if (Func.isNotEmpty(driver.getOrganizationName())) { + R deptResult = sysClient.getDeptIds(tenantId, driver.getOrganizationName()); + if (deptResult != null && R.isSuccess(deptResult) && Func.isNotEmpty(deptResult.getData())) { + deptId = deptResult.getData(); + } } MpUser existing = findExistingMpUser(driver, tenantId, mobile); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java index 428c4398..25b6bd4a 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java @@ -52,6 +52,7 @@ import org.springblade.transport.pojo.entity.WaybillNodePunch; import org.springblade.transport.service.ITransportVehicleService; import org.springblade.transport.support.CertificationGate; import org.springblade.transport.support.LiveProcessConfigSupport; +import org.springblade.transport.support.TransportBusinessSupport; import org.springblade.transport.support.VehiclePlateSupport; import org.springblade.transport.support.WaybillCargoSupport; import org.springblade.transport.pojo.vo.DriverEnrouteRecordVO; @@ -70,6 +71,7 @@ import org.springblade.transport.pojo.vo.WaybillTrackVO; import org.springblade.transport.service.IDriverService; import org.springblade.transport.service.IDriverWaybillService; import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.IReceivablePayableDetailService; import org.springblade.transport.service.ITripQueryService; import org.springblade.transport.service.IWaybillService; import org.springblade.transport.support.WaybillProcessSupport; @@ -147,6 +149,9 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { private static final int TRIP_STATUS_PENDING = 0; private static final int TRIP_STATUS_RUNNING = 1; + /** 运次列表档位:4 待确认(待回单确认)。卡片状态码仍按 1 展示,只有列表 Tab 用它分档 */ + private static final int TRIP_STATUS_PENDING_RETURN_CONFIRM = 4; + private final IWaybillService waybillService; private final IDriverService driverService; private final IMpUserClient mpUserClient; @@ -157,6 +162,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { private final WaybillNodePunchMapper nodePunchMapper; private final ExceptionDisposalMapper exceptionDisposalMapper; private final ITripQueryService tripQueryService; + private final IReceivablePayableDetailService receivablePayableDetailService; @Override public DriverWaybillCardVO currentTask() { @@ -277,7 +283,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { return null; } TripQueryVO query = TripQueryVO.forDriver(phone); - query.setStatus(TRIP_STATUS_RUNNING); + // 待确认(待回单确认)的趟车司机仍需回到卡片上继续操作,与进行中并列作为当前任务 + query.setStatuses(List.of(TRIP_STATUS_RUNNING, TRIP_STATUS_PENDING_RETURN_CONFIRM)); query.setCurrent(1); query.setSize(1); IPage page = tripQueryService.page(query); @@ -313,6 +320,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { TripTabCountsVO tripCounts = tripQueryService.tabCounts(TripQueryVO.forDriver(phone)); counts.setPending(tripCounts.getPending()); counts.setDoing(tripCounts.getDoing()); + counts.setConfirm(tripCounts.getConfirm()); counts.setDone(tripCounts.getDone()); counts.setAll(tripCounts.getAll()); return counts; @@ -497,6 +505,16 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { : legacyNodePunchPhotos(dto.getPhotos(), WaybillProcessSupport.nodeStringList(nodeCfg, "voucherTypes")); assertNodePunchPhotos(photoItems, nodeCfg, typedPhotoItems); + // 里程:末位可打卡节点 + 运单计费规则含里程(应收或应付任一侧)时必填。 + // 必须在完成判定之前落库——应收应付在本次打卡触发的完成里生成,读到的得是新里程。 + String submitNodeKey = WaybillProcessSupport.nodeKey(nodeCfg); + if (WaybillProcessSupport.isLastDriverPunchNode(processJson, submitNodeKey) + && receivablePayableDetailService.requiresMileage(waybill)) { + BigDecimal mileage = TransportBusinessSupport.requireMileage(dto.getMileage()); + waybillService.savePunchMileage(waybill.getId(), mileage); + waybill.setMileage(mileage); + } + Date now = new Date(); WaybillNodePunch punch = new WaybillNodePunch(); punch.setWaybillId(waybill.getId()); @@ -1564,6 +1582,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { Map latestNodePunchByCode = latestNodePunchMap(waybill.getId()); // 未开始运输的运单在途卡仍下发,但不可提交:司机要看得到这张卡,才知道为什么不能打 String notStartedReason = WaybillProcessSupport.punchNotStartedReason(waybill.getBusinessStatus()); + // 计费规则是否含里程与节点无关,整条运单算一次;放在循环外避免逐节点回查合同。 + boolean lastNodeNeedsMileage = receivablePayableDetailService.requiresMileage(waybill); for (Map cfg : punchConfigs) { boolean isTransit = WaybillProcessSupport.isTransitNodePublic(cfg); @@ -1622,9 +1642,15 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { // 末位卡上打一下就完成这张运单:判据与下方 submitNode 的自动完成同一份 // (末位可打卡节点 + 到货/卸货/签收 + 回单不要求业务员确认),客户端只按它把按钮文案改成「完成运单」。 // 两处判据必须同源,否则会出现「按钮写着完成运单、打下去这张运单没完成」。 - vo.setFinishOnPunch(WaybillProcessSupport.isLastDriverPunchNode(processJson, vo.getKey()) + boolean lastPunchNode = WaybillProcessSupport.isLastDriverPunchNode(processJson, vo.getKey()); + vo.setFinishOnPunch(lastPunchNode && WaybillProcessSupport.isDeliveryNode(vo.getKey(), vo.getName()) && WaybillProcessSupport.returnNeedsNoConfirmation(processJson)); + // 里程同样只在末位卡采集:末位判定与上面同一个变量,避免两处各算一次走偏。 + if (lastPunchNode && lastNodeNeedsMileage) { + vo.setNeedMileage(true); + vo.setMileage(waybill.getMileage()); + } if (done) { if (punched.getPunchTime() != null) { LocalDateTime ldt = punched.getPunchTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime(); 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 73fbe0f4..26684dad 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 @@ -236,8 +236,8 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { TripQueryVO query = TripQueryVO.forAdmin(); applyAdminDataScope(query); query.setKeyword(keyword); - // 待处理只要待接单与进行中两档;「未拒单」也是交给运次读服务按子单行筛 - query.setStatuses(List.of(0, 1)); + // 待处理含待接单、进行中与待确认(待回单确认原本就算进行中,拆档后不能从待处理里消失) + query.setStatuses(List.of(0, 1, 4)); query.setExcludeRejectedTrips(Boolean.FALSE.equals(needReassign)); query.setCurrent(pageNo); query.setSize(pageSize); @@ -269,6 +269,19 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { return page; } + /** + * 卡片展示用的原始业务状态:整趟车子单同一业务状态时取它,混着多种状态时为空,卡片按状态码展示。 + *

+ * 待回单确认的趟车状态码仍是 1(运输中),只有这个字段能把它区分开。 + */ + private String uniqueChildBusinessStatus(List children) { + Set statuses = children.stream() + .map(TripVO.TripWaybillVO::getBusinessStatus) + .filter(Func::isNotBlank) + .collect(Collectors.toCollection(LinkedHashSet::new)); + return statuses.size() == 1 ? statuses.iterator().next() : null; + } + /** * 运次卡片:展示单号、整趟车起/经/终与站点数、货物汇总、计划时间都取运次字段。 * 旧字段 id / waybillNo 保留运单语义(未配载就是这张运单,配载取第一张可见子运单), @@ -302,6 +315,7 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { card.setPlanTime(formatLocalDate(trip.getEstimatedStartDate())); card.setPlanTimeEnd(formatLocalDate(trip.getEstimatedEndDate())); card.setStatus(trip.getStatus()); + card.setBusinessStatus(uniqueChildBusinessStatus(children)); card.setDriverName(Func.toStr(trip.getDriverName(), "")); card.setVehicleNo(Func.toStr(trip.getVehicleNo(), "")); card.setCreateTime(formatDateTime(trip.getSortTime())); @@ -336,7 +350,11 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { return childIds.stream().map(this::parseId).filter(Objects::nonNull).anyMatch(exceptionWaybillIds::contains); } - /** 小程序 status → 运次状态:0待接单 / 1进行中 / 2已完成 */ + /** + * 小程序 status → 运次可筛选档位:0待接单 / 1进行中 / 2已完成 / 3已取消 / 4待确认(待回单确认)。 + *

+ * 1 不含待回单确认——待回单确认单独成档,卡片状态码仍是 1。 + */ private Integer toTripStatus(String status) { if (Func.isBlank(status)) { return null; @@ -346,6 +364,7 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { case "1", STATUS_RUNNING, "transporting", "doing" -> 1; case "2", STATUS_COMPLETED, "done" -> 2; case "3", STATUS_CANCELLED -> 3; + case "4", WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM, "confirm" -> 4; default -> null; }; } @@ -515,6 +534,7 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { 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.setBusinessStatus(tripBusinessStatus(children)); detail.setTransportMode(Func.toStr(loading.getTransportType(), "")); detail.setTransportType(toTransportTypeLabel(Func.toStr(loading.getTransportType(), ""))); detail.setTransportOrgType(TRANSPORT_LOAD); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java index c9865f90..6bdbdcdd 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java @@ -200,6 +200,60 @@ public class ReceivablePayableDetailServiceImpl return result; } + @Override + public boolean requiresMileage(Waybill waybill) { + if (waybill == null) { + return false; + } + ContractManage receivableContract = Func.isEmpty(waybill.getContractId()) + ? null : contractManageService.getById(waybill.getContractId()); + if (hasMileageBillingRule(receivableContract, waybill)) { + return true; + } + ContractManage payableContract; + try { + payableContract = resolveCarrierContract(waybill); + } catch (ServiceException exception) { + // 承运商合同数据有问题时不在打卡页抛错:费用生成会带着明确文案拒绝, + // 打卡页只回答"这一趟要不要填里程",不该被合同脏数据卡住。 + log.warn("判定运单是否含里程计费时承运商合同解析失败,按不含里程处理,waybillId:{},原因:{}", + waybill.getId(), exception.getMessage()); + return false; + } + return hasMileageBillingRule(payableContract, waybill); + } + + /** + * 这套合同命中的计费规则里,是否有里程类要素且规则本身命中该运单。 + *

+ * 方案解析与规则命中都复用费用生成那两条私有方法,保证「打卡时要里程」与「结算时按里程算」同一口径。 + */ + private boolean hasMileageBillingRule(ContractManage contract, Waybill waybill) { + if (contract == null || waybill == null) { + return false; + } + Map plan = resolveDefaultBillingPlan( + parseList(contract.getBillingPlanJson()), waybill.getTransportType()); + if (plan == null || !(plan.get("rules") instanceof List rules)) { + return false; + } + for (Object value : rules) { + if (!(value instanceof Map raw)) { + continue; + } + Map rule = toRuleMap(raw); + if (!isMileageBasedElement(stringValue(rule, "billingElement", ""))) { + continue; + } + boolean matched = feeWaybills(rule, waybill).stream() + .anyMatch(feeWaybill -> matchesBillingRule(raw, rule, feeWaybill)); + if (matched) { + return true; + } + } + return false; + } + @Override @Transactional(rollbackFor = Exception.class) public void syncMileageFromWaybill(Long waybillId, BigDecimal mileage) { @@ -1052,7 +1106,25 @@ public class ReceivablePayableDetailServiceImpl @Transactional(propagation = org.springframework.transaction.annotation.Propagation.REQUIRES_NEW, rollbackFor = Exception.class) public void generateForCompletedWaybills(List waybillIds) { - generateAutomaticWaybillDetails(loadWaybills(waybillIds), true, + generateForCompletedWaybills(waybillIds, Map.of()); + } + + @Override + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.REQUIRES_NEW, + rollbackFor = Exception.class) + public void generateForCompletedWaybills(List waybillIds, Map mileageOverrides) { + List waybills = loadWaybills(waybillIds); + // 独立事务读的是调用方提交前的数据:司机打卡刚写的里程在这里不可见, + // 不覆盖就会按 null 里程算出 0 元运费。 + if (Func.isNotEmpty(mileageOverrides)) { + waybills.forEach(waybill -> { + BigDecimal mileage = mileageOverrides.get(waybill.getId()); + if (mileage != null) { + waybill.setMileage(mileage); + } + }); + } + generateAutomaticWaybillDetails(waybills, true, this::resolveWaybillCarrierContract, AuthUtil.getUserId(), new Date()); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportVehicleServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportVehicleServiceImpl.java index d9e1c734..4e6e2c00 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportVehicleServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportVehicleServiceImpl.java @@ -264,9 +264,6 @@ public class TransportVehicleServiceImpl extends BaseServiceImpl 0; case RUNNING -> 1; + case PENDING_RETURN_CONFIRM -> 1; case COMPLETED -> 2; case CANCELLED -> 3; default -> null; }; } + /** + * 运次的列表档位:0 待接单 / 1 进行中 / 2 已完成 / 3 已取消 / 4 待确认(待回单确认)。 + *

+ * 与 {@link #appStatus(String)} 的区别只在待回单确认:卡片状态仍按 1(运输中)展示, + * 列表档位单独成档,待回单确认不再落进行中。 + */ + static Integer tabOf(String status) { + return switch (Func.toStr(status, "")) { + case PENDING -> 0; + case RUNNING -> 1; + case COMPLETED -> 2; + case CANCELLED -> 3; + case PENDING_RETURN_CONFIRM -> 4; + default -> null; + }; + } + /** * 运次状态:终态优先——子单全为终态时有已完成取已完成、否则已取消; - * 否则需接单且未接单取待接单,其余取运输中。 + * 其次待接单档优先;再次有在跑的子单取运输中;只剩待回单确认的取待回单确认。 + *

+ * 待回单确认不受接单需求影响:需要接单的趟车走到回单确认时司机已接过单,不再回退成待接单。 *

* 子单集合为空时无从判定,返回空。 */ static String statusOf(List children, boolean requireAccept, boolean accepted) { String status = classify(count(children)); - if (status == null || TERMINAL_STATUSES.contains(status)) { + if (status == null || TERMINAL_STATUSES.contains(status) || PENDING_RETURN_CONFIRM.equals(status)) { return status; } if (!requireAccept) { @@ -106,13 +126,15 @@ final class TripAggregation { } /** - * 子单聚合结果:子单总数、终态数、已完成数、待接单档数 + * 子单聚合结果:子单总数、终态数、已完成数、待接单档数、运输中数、待回单确认数 */ static Counters count(List children) { int total = 0; int terminal = 0; int completed = 0; int pending = 0; + int running = 0; + int returnConfirm = 0; for (Waybill child : children) { String status = child == null ? null : child.getBusinessStatus(); total++; @@ -125,10 +147,20 @@ final class TripAggregation { if (status != null && PENDING_STATUSES.contains(status)) { pending++; } + if (RUNNING.equals(status)) { + running++; + } + if (PENDING_RETURN_CONFIRM.equals(status)) { + returnConfirm++; + } } - return new Counters(total, terminal, completed, pending); + return new Counters(total, terminal, completed, pending, running, returnConfirm); } + /** + * 待接单档优先,其次运输中,最后只剩待回单确认——整趟车都打完卡等回单确认才单独成档, + * 混着在跑的子单时仍算运输中。 + */ private static String classify(Counters counters) { if (counters.total() == 0) { return null; @@ -136,7 +168,16 @@ final class TripAggregation { if (counters.terminal() == counters.total()) { return counters.completed() > 0 ? COMPLETED : CANCELLED; } - return counters.pending() > 0 ? PENDING : RUNNING; + if (counters.pending() > 0) { + return PENDING; + } + if (counters.running() > 0) { + return RUNNING; + } + if (counters.returnConfirm() > 0) { + return PENDING_RETURN_CONFIRM; + } + return RUNNING; } /** @@ -169,7 +210,7 @@ final class TripAggregation { } /** 子单聚合计数 */ - record Counters(int total, int terminal, int completed, int pending) { + record Counters(int total, int terminal, int completed, int pending, int running, int returnConfirm) { } } 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 abf0fc92..56a2a5d3 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 @@ -128,9 +128,10 @@ public class TripQueryServiceImpl implements ITripQueryService { if (!scoped(condition)) { return counts; } - // 一次取数算清四档:分档口径与列表、卡片同一份推导,不再按库里的业务状态分批计数 + // 一次取数算清各档:分档口径与列表、卡片同一份推导,不再按库里的业务状态分批计数 long pending = 0; long doing = 0; + long confirm = 0; long done = 0; for (TripRows trip : trips(withoutTab(condition))) { Integer tier = trip.tier(); @@ -141,14 +142,16 @@ public class TripQueryServiceImpl implements ITripQueryService { case 0 -> pending++; case 1 -> doing++; case 2 -> done++; + case 4 -> confirm++; default -> { } } } counts.setPending(pending); counts.setDoing(doing); + counts.setConfirm(confirm); counts.setDone(done); - counts.setAll(pending + doing + done); + counts.setAll(pending + doing + confirm + done); return counts; } @@ -205,7 +208,7 @@ public class TripQueryServiceImpl implements ITripQueryService { boolean requireAccept = TripAggregation.requiresAccept(children); boolean accepted = WaybillProcessSupport.ACCEPT_ACCEPTED.equals(TripAggregation.acceptStatus(children)); String status = TripAggregation.statusOf(children, requireAccept, accepted); - Integer tier = TripAggregation.appStatus(status); + Integer tier = TripAggregation.tabOf(status); if (tier == null || !tiers.contains(tier)) { return; } @@ -361,7 +364,7 @@ public class TripQueryServiceImpl implements ITripQueryService { } /** - * 要哪几档:多档优先,其次单档,都不传按「全部」=待接单/进行中/已完成(不含已取消) + * 要哪几档:多档优先,其次单档,都不传按「全部」=待接单/进行中/待确认/已完成(不含已取消) */ private Set tiers(TripQueryVO condition) { if (Func.isNotEmpty(condition.getStatuses())) { @@ -370,7 +373,7 @@ public class TripQueryServiceImpl implements ITripQueryService { if (condition.getStatus() != null) { return Set.of(condition.getStatus()); } - return Set.of(0, 1, 2); + return Set.of(0, 1, 2, 4); } /** diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java index be049adc..45a9ffa8 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java @@ -421,6 +421,16 @@ public class WaybillServiceImpl extends BaseServiceImpl if (processNodes.isEmpty()) { processNodes = WaybillProcessSupport.listDriverPunchNodes(processJson); } + // 里程只在末位打卡节点采集:判据与自动完成、司机端 needMileage 同源(司机可打卡节点的末位)。 + List> driverPunchNodes = WaybillProcessSupport.listDriverPunchNodes(processJson); + String lastPunchKey = driverPunchNodes.isEmpty() ? null + : WaybillProcessSupport.nodeKey(driverPunchNodes.get(driverPunchNodes.size() - 1)); + boolean lastNodeNeedsMileage = lastPunchKey != null + && receivablePayableDetailService.requiresMileage(waybill); + // 已生成结算单的运单,里程只能看不能改(与「维护里程」同一条判据) + boolean punchMileageLocked = lastNodeNeedsMileage + && receivablePayableDetailService.settlementLinkedWaybillIds(List.of(waybill.getId())) + .contains(waybill.getId()); for (Map node : processNodes) { String nodeCode = WaybillProcessSupport.nodeKey(node); @@ -430,6 +440,11 @@ public class WaybillServiceImpl extends BaseServiceImpl item.setNodeCode(nodeCode); item.setNodeName(nodeName); item.setType(isTransit ? "enroute" : "node"); + if (lastNodeNeedsMileage && !isTransit && lastPunchKey.equals(nodeCode)) { + item.setNeedMileage(true); + item.setMileage(waybill.getMileage()); + item.setMileageLocked(punchMileageLocked); + } item.setExceptionFlag(false); item.setPhotos(new ArrayList<>()); @@ -541,8 +556,46 @@ public class WaybillServiceImpl extends BaseServiceImpl return revertIfReturnConfirmPreconditionLost(waybill, punched, saveEnroutePunchRecord(waybill, request, punched)); } - return revertIfReturnConfirmPreconditionLost(waybill, punched, - saveNodePunchRecord(waybill, request, punched)); + boolean saved = saveNodePunchRecord(waybill, request, punched); + if (saved) { + applyPunchRecordMileage(waybill, request, punched); + } + return revertIfReturnConfirmPreconditionLost(waybill, punched, saved); + } + + /** + * 管理端订正打卡时写里程:只在末位打卡节点、且本次是打卡(非取消)时生效。 + *

+ * 里程留空(null)表示不改动;已生成结算单时拒绝改值,但其余字段的保存不受影响。 + * 写入后同步未结算明细的里程并按新里程重算,与「维护里程」同一条链路。 + */ + private void applyPunchRecordMileage(Waybill waybill, WaybillPunchRecordSaveRequest request, boolean punched) { + if (!punched || request.getMileage() == null) { + return; + } + String processJson = resolveLiveProcessJson(waybill); + Map nodeConfig = findEnabledNodeConfig( + processJson, request.getNodeCode(), request.getNodeName()); + String nodeCode = nodeConfig == null + ? TransportBusinessSupport.trimToEmpty(request.getNodeCode()) + : WaybillProcessSupport.nodeKey(nodeConfig); + // 非末位节点收到里程就忽略:客户端不该传,后端也不该因此拒掉整次订正。 + if (!WaybillProcessSupport.isLastDriverPunchNode(processJson, nodeCode)) { + return; + } + BigDecimal mileage = TransportBusinessSupport.requireMileage(request.getMileage()); + BigDecimal current = waybill.getMileage(); + // 同值放行:订正其它字段时前端会带回落库现值,不该被当成"改里程"。 + if (current != null && current.compareTo(mileage) == 0) { + return; + } + if (receivablePayableDetailService.settlementLinkedWaybillIds(List.of(waybill.getId())) + .contains(waybill.getId())) { + throw new ServiceException("该运单已生成结算单,无法修改里程"); + } + savePunchMileage(waybill.getId(), mileage); + waybill.setMileage(mileage); + receivablePayableDetailService.syncMileageFromWaybill(waybill.getId(), mileage); } /** @@ -1450,11 +1503,8 @@ public class WaybillServiceImpl extends BaseServiceImpl .contains(waybill.getId())) { throw new ServiceException("该运单已生成结算单,无法维护里程"); } - BigDecimal mileage = request.getMileage(); - if (mileage == null || mileage.compareTo(BigDecimal.ZERO) <= 0 - || mileage.stripTrailingZeros().scale() > 0 || mileage.stripTrailingZeros().precision() > 10) { - throw new ServiceException("里程必须为不超过10位的正整数"); - } + // 与打卡采集共用同一套校验(正整数、有效位 ≤10),两处文案不许分叉 + BigDecimal mileage = TransportBusinessSupport.requireMileage(request.getMileage()); String mileageRemark = TransportBusinessSupport.trimToNull(request.getMileageRemark()); TransportBusinessSupport.validateLength(mileageRemark, 200, "里程维护备注不能超过200字"); waybill.setMileage(mileage); @@ -1466,6 +1516,18 @@ public class WaybillServiceImpl extends BaseServiceImpl return updated; } + @Override + @Transactional(rollbackFor = Exception.class) + public void savePunchMileage(Long waybillId, BigDecimal mileage) { + if (waybillId == null || mileage == null) { + return; + } + // 只 set 里程列:整行 updateById 会把并发改过的其它字段一并覆盖回去。 + update(Wrappers.lambdaUpdate() + .set(Waybill::getMileage, mileage) + .eq(Waybill::getId, waybillId)); + } + @Override @Transactional(rollbackFor = Exception.class) public boolean cancel(Long id) { @@ -1608,7 +1670,11 @@ public class WaybillServiceImpl extends BaseServiceImpl waybill.setEndDate(LocalDate.now()); boolean updated = updateById(waybill); if (updated) { - receivablePayableDetailService.generateForCompletedWaybills(List.of(waybill.getId())); + // 计费生成是独立事务,读不到本事务里刚写的里程:把当前里程显式带过去, + // 否则司机末位打卡写入的里程会被当成 null,按里程的费用算成 0 元。 + Map mileageOverrides = waybill.getMileage() == null + ? Map.of() : Map.of(waybill.getId(), waybill.getMileage()); + receivablePayableDetailService.generateForCompletedWaybills(List.of(waybill.getId()), mileageOverrides); loadingManageService.completeIfAllWaybillsCompleted(waybill.getLoadingNo()); } return updated; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/TransportBusinessSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/TransportBusinessSupport.java index ad497230..5f1e8efd 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/TransportBusinessSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/TransportBusinessSupport.java @@ -252,4 +252,34 @@ public final class TransportBusinessSupport { } } + /** + * 里程(公里):正整数、有效位不超过 10 位。 + *

+ * 打卡采集与「维护里程」共用这一条判据,两处文案必须一致。空值按「没有填」拒回, + * 非数字按格式错误拒回——两者给司机/业务员的下一步动作不同。 + */ + public static BigDecimal requireMileage(String value) { + String text = trimToNull(value); + if (text == null) { + throw new ServiceException("请输入里程"); + } + try { + return requireMileage(new BigDecimal(text)); + } catch (NumberFormatException exception) { + throw new ServiceException("里程必须为不超过10位的正整数"); + } + } + + public static BigDecimal requireMileage(BigDecimal mileage) { + if (mileage == null) { + throw new ServiceException("请输入里程"); + } + if (mileage.compareTo(BigDecimal.ZERO) <= 0 + || mileage.stripTrailingZeros().scale() > 0 + || mileage.stripTrailingZeros().precision() > 10) { + throw new ServiceException("里程必须为不超过10位的正整数"); + } + return mileage; + } + } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java index 44d58724..7e99a3e5 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java @@ -581,17 +581,21 @@ class DriverCertServiceImplTest { } @Test - @DisplayName("司机建车:没有所属组织时明确拒绝,不落一辆查不到归属的车") - void submitVehicleRejectsDriverWithoutOrganization() { + @DisplayName("司机建车:司机档案没有组织也允许建车,车辆组织落空串而不是 null") + void submitVehicleCreatesVehicleForDriverWithoutOrganization() { Driver driver = driver(null); driver.setOrganizationName(null); when(driverAppService.currentDriverEntity()).thenReturn(driver); when(transportVehicleService.list(any(LambdaQueryWrapper.class))).thenReturn(List.of()); when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); - ServiceException exception = assertThrows(ServiceException.class, - () -> service.submitVehicleCert(completeVehicleRequest("桂A32523"))); - assertTrue(exception.getMessage().contains("所属组织")); + service.submitVehicleCert(completeVehicleRequest("桂A32523")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportVehicle.class); + verify(transportVehicleService).save(captor.capture()); + assertEquals("", captor.getValue().getOrganizationName(), + "该列 NOT NULL 且无默认值:缺组织要落空串,落 null 这条 INSERT 就直接报错"); + verify(certRecordService).save(any(TransportCertRecord.class)); } @Test @@ -689,8 +693,7 @@ class DriverCertServiceImplTest { // 司机档案ID与用户ID是两个不同的ID:提交人字段要的是后者 driver.setUserId(USER_ID); driver.setDriverName("张三"); - // 车辆表的 organization_name 是 NOT NULL 且无默认值:司机缺组织时建车必然失败, - // 故建车链路要求它非空,fixture 也必须给 + // 车辆行的 organization_name 是建档时从司机档案抄下来的快照 driver.setOrganizationName("广西现代物流集团有限公司"); driver.setDrivingVehicle(drivingVehicle); return driver; diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java index 703070cb..bc544d0a 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java @@ -102,7 +102,8 @@ class DriverNodePunchPhotoTest { mock(WaybillEnroutePunchMapper.class), nodePunchMapper, exceptionDisposalMapper, - mock(ITripQueryService.class)); + mock(ITripQueryService.class), + mock(org.springblade.transport.service.IReceivablePayableDetailService.class)); when(driverService.getOne(any())).thenReturn(driver()); when(waybillService.getOne(any())).thenReturn(waybill()); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchFinishFlagTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchFinishFlagTest.java index daecf3cf..06667c50 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchFinishFlagTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchFinishFlagTest.java @@ -99,7 +99,8 @@ class DriverPunchFinishFlagTest { enroutePunchMapper, nodePunchMapper, exceptionDisposalMapper, - mock(ITripQueryService.class)); + mock(ITripQueryService.class), + mock(org.springblade.transport.service.IReceivablePayableDetailService.class)); when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0)); when(nodePunchMapper.selectList(any())).thenReturn(List.of()); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java index 2b35117f..0009525f 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java @@ -94,7 +94,8 @@ class DriverPunchNodeCardTest { enroutePunchMapper, nodePunchMapper, exceptionDisposalMapper, - mock(ITripQueryService.class)); + mock(ITripQueryService.class), + mock(org.springblade.transport.service.IReceivablePayableDetailService.class)); Waybill waybill = waybill(); when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverServiceImplTest.java index 878fa815..0ecd4f9e 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverServiceImplTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverServiceImplTest.java @@ -23,6 +23,7 @@ import java.time.LocalDate; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -141,9 +142,72 @@ class DriverServiceImplTest { verify(driverMapper, never()).updateById(any(Driver.class)); } + // ------------------------------------------------------------ 无组织司机 + + @Test + @DisplayName("保存司机:组织为空照常保存成功,档案里的组织是空串") + void submitAllowsEmptyOrganization() { + Driver driver = driver(PLATE, ""); + submitWithExistingMpUser(driver, mpUser(USER_ID, "1123598821738675201")); + verify(mpUserClient).updateUser(any(MpUser.class)); + assertEquals("", driver.getOrganizationName(), "无组织按空串落库,不是 NULL"); + } + + @Test + @DisplayName("保存司机:组织为空时不发起组织反查,deptId 留 null 交给更新忽略") + void submitWithEmptyOrganizationKeepsMpUserDeptId() { + Driver driver = driver(PLATE, ""); + submitWithExistingMpUser(driver, mpUser(USER_ID, "1123598821738675201")); + + verify(sysClient, never()).getDeptIds(any(), any()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MpUser.class); + verify(mpUserClient).updateUser(captor.capture()); + assertNull(captor.getValue().getDeptId(), + "组织为空时 deptId 只能留 null;写成空串会把账号原有组织与数据权限覆盖掉"); + } + + @Test + @DisplayName("保存司机:组织非空且名称反查命中时,落库的 deptId 是反查到的组织 id") + void submitWithFilledOrganizationUsesResolvedDeptId() { + Driver driver = driver(PLATE, "测试组织"); + when(sysClient.getDeptIds(TENANT_ID, "测试组织")).thenReturn(R.data("1123598821738675201")); + submitWithExistingMpUser(driver, mpUser(USER_ID, "1120666100345487362")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MpUser.class); + verify(mpUserClient).updateUser(captor.capture()); + assertEquals("1123598821738675201", captor.getValue().getDeptId(), + "组织非空时 deptId 由名称反查结果覆盖账号原组织"); + } + // ------------------------------------------------------------ 夹具 + // 库里已有该司机与对应的小程序账号:走 submit 的「更新并关联」分支 + private void submitWithExistingMpUser(Driver driver, MpUser existing) { + when(driverMapper.selectByIdIncludingDeleted(DRIVER_ID)).thenReturn(driver); + when(driverMapper.selectByIdCardNoIncludingDeleted(driver.getIdCardNo())).thenReturn(null); + when(driverMapper.selectById(DRIVER_ID)).thenReturn(driver); + when(driverMapper.updateById(any(Driver.class))).thenReturn(1); + when(mpUserClient.userByPhone(TENANT_ID, MOBILE)).thenReturn(R.data(existing)); + when(mpUserClient.updateUser(any(MpUser.class))).thenReturn(R.data(true)); + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getTenantId).thenReturn(TENANT_ID); + service.submit(driver); + } + } + + private MpUser mpUser(Long id, String deptId) { + MpUser user = new MpUser(); + user.setId(id); + user.setDeptId(deptId); + return user; + } + private Driver driver(String drivingVehicle) { + return driver(drivingVehicle, "测试组织"); + } + + private Driver driver(String drivingVehicle, String organizationName) { Driver driver = new Driver(); driver.setId(DRIVER_ID); driver.setIsDeleted(0); @@ -160,7 +224,7 @@ class DriverServiceImplTest { driver.setQualificationLongTerm(1); driver.setDriverType("自有"); driver.setMobile(MOBILE); - driver.setOrganizationName("测试组织"); + driver.setOrganizationName(organizationName); driver.setEmergencyContactName("李四"); driver.setEmergencyContactMobile("13800000000"); driver.setIdCardFront("https://oss/id-front.jpg"); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripAcceptRejectTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripAcceptRejectTest.java index 24d1d635..05c3af55 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripAcceptRejectTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripAcceptRejectTest.java @@ -92,7 +92,8 @@ class DriverTripAcceptRejectTest { mock(WaybillEnroutePunchMapper.class), mock(WaybillNodePunchMapper.class), mock(ExceptionDisposalMapper.class), - tripQueryService); + tripQueryService, + mock(org.springblade.transport.service.IReceivablePayableDetailService.class)); lenient().when(driverService.getOne(any())).thenReturn(driver()); lenient().when(certificationGate.isDriverAvailable(any())).thenReturn(true); lenient().when(waybillService.update(any())).thenReturn(true); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripCardTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripCardTest.java index 41189b41..c34e0441 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripCardTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripCardTest.java @@ -108,7 +108,8 @@ class DriverTripCardTest { enroutePunchMapper, nodePunchMapper, mock(ExceptionDisposalMapper.class), - tripQueryService); + tripQueryService, + mock(org.springblade.transport.service.IReceivablePayableDetailService.class)); lenient().when(driverService.getOne(any())).thenReturn(driver()); lenient().when(enroutePunchMapper.selectList(any())).thenReturn(List.of()); lenient().when(enroutePunchMapper.selectOne(any())).thenReturn(null); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java index 6a23c1ac..42b742fe 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java @@ -123,7 +123,8 @@ class DriverWaybillAutoCompleteTest { mock(WaybillEnroutePunchMapper.class), nodePunchMapper, exceptionDisposalMapper, - mock(ITripQueryService.class)); + mock(ITripQueryService.class), + mock(org.springblade.transport.service.IReceivablePayableDetailService.class)); when(driverService.getOne(any())).thenReturn(driver()); when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0)); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java index 35e8dc58..13ace47b 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java @@ -80,7 +80,8 @@ class DriverWaybillCardLocationTest { enroutePunchMapper, nodePunchMapper, exceptionDisposalMapper, - mock(ITripQueryService.class)); + mock(ITripQueryService.class), + mock(org.springblade.transport.service.IReceivablePayableDetailService.class)); when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0)); when(nodePunchMapper.selectList(any())).thenReturn(List.of()); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java index 83e8475e..f1223d3c 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java @@ -62,7 +62,8 @@ class DriverWaybillNodePunchTest { mock(WaybillEnroutePunchMapper.class), nodePunchMapper, mock(ExceptionDisposalMapper.class), - mock(ITripQueryService.class)); + mock(ITripQueryService.class), + mock(org.springblade.transport.service.IReceivablePayableDetailService.class)); } @Test diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java index a96f086f..7f3f6cae 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java @@ -74,7 +74,8 @@ class DriverWaybillOwnershipTest { mock(WaybillEnroutePunchMapper.class), mock(WaybillNodePunchMapper.class), mock(ExceptionDisposalMapper.class), - mock(ITripQueryService.class)); + mock(ITripQueryService.class), + mock(org.springblade.transport.service.IReceivablePayableDetailService.class)); } @Test diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java index b11451aa..49502666 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java @@ -90,7 +90,8 @@ class DriverWaybillRejectDetachTest { mock(WaybillEnroutePunchMapper.class), mock(WaybillNodePunchMapper.class), mock(ExceptionDisposalMapper.class), - mock(ITripQueryService.class)); + mock(ITripQueryService.class), + mock(org.springblade.transport.service.IReceivablePayableDetailService.class)); lenient().when(driverService.getOne(any())).thenReturn(driver()); lenient().when(waybillService.updateById(any())).thenReturn(true); // syncDriverAcceptState 是 IWaybillService 的方法,mock 默认返 null 会让调用方 NPE; diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillTabStatusTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillTabStatusTest.java index 668dc965..1789c892 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillTabStatusTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillTabStatusTest.java @@ -88,7 +88,8 @@ class DriverWaybillTabStatusTest { mock(WaybillEnroutePunchMapper.class), nodePunchMapper, exceptionDisposalMapper, - mock(ITripQueryService.class)); + mock(ITripQueryService.class), + mock(org.springblade.transport.service.IReceivablePayableDetailService.class)); when(driverService.getOne(any())).thenReturn(driver()); when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0)); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TransportVehicleServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TransportVehicleServiceImplTest.java new file mode 100644 index 00000000..933ee1ea --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TransportVehicleServiceImplTest.java @@ -0,0 +1,148 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +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.MockedStatic; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.transport.mapper.DriverMapper; +import org.springblade.transport.mapper.TransportVehicleMapper; +import org.springblade.transport.pojo.entity.TransportVehicle; + +import java.lang.reflect.Field; +import java.time.LocalDate; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 车辆管理保存({@code submit} → {@code prepare} + {@code validate})的组织口径。 + *

+ * 所属组织是车辆的档案属性,不是保存的前置条件:组织为空串(司机端建车、管理端未选组织) + * 也能保存,落库后即「未分配组织」。 + */ +class TransportVehicleServiceImplTest { + + private static final String PLATE = "桂A32523"; + + private TransportVehicleServiceImpl service; + private TransportVehicleMapper vehicleMapper; + private MockedStatic authUtil; + + @BeforeAll + static void initTableInfo() { + // saveOrUpdate / count 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, TransportVehicle.class); + } + + @BeforeEach + void setUp() throws Exception { + vehicleMapper = mock(TransportVehicleMapper.class); + service = new TransportVehicleServiceImpl(mock(DriverMapper.class)); + inject("baseMapper", vehicleMapper); + authUtil = mockStatic(AuthUtil.class); + } + + @AfterEach + void tearDown() { + authUtil.close(); + } + + @Test + @DisplayName("保存车辆:所属组织为空也允许保存,落库是空串而不是 null(该列 NOT NULL)") + void submitAcceptsVehicleWithoutOrganization() { + when(vehicleMapper.selectCount(any(Wrapper.class))).thenReturn(0L); + + TransportVehicle vehicle = completeVehicle(); + vehicle.setOrganizationName(null); + + assertDoesNotThrow(() -> service.submit(vehicle)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportVehicle.class); + verify(vehicleMapper).insert(captor.capture()); + assertEquals("", captor.getValue().getOrganizationName(), + "organization_name 是 NOT NULL 且无默认值,null 会让这条 INSERT 直接报错"); + } + + @Test + @DisplayName("保存车辆:所属组织是空白串时归一成空串,不落一片空格") + void submitTrimsBlankOrganizationToEmpty() { + when(vehicleMapper.selectCount(any(Wrapper.class))).thenReturn(0L); + + TransportVehicle vehicle = completeVehicle(); + vehicle.setOrganizationName(" "); + + assertDoesNotThrow(() -> service.submit(vehicle)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportVehicle.class); + verify(vehicleMapper).insert(captor.capture()); + assertEquals("", captor.getValue().getOrganizationName()); + } + + @Test + @DisplayName("保存车辆:组织已不再必填,但其余必填项照旧拦住(证明校验本身还在跑)") + void submitStillValidatesOtherRequiredFields() { + TransportVehicle vehicle = completeVehicle(); + vehicle.setPlateNo(""); + + ServiceException exception = assertThrows(ServiceException.class, () -> service.submit(vehicle)); + assertEquals("车牌号不能为空", exception.getMessage()); + verify(vehicleMapper, never()).insert(any(TransportVehicle.class)); + } + + // ------------------------------------------------------------ 夹具 + + /** + * 一份能通过全部校验的车辆内容;只有组织一项按用例需要改动。 + */ + private TransportVehicle completeVehicle() { + TransportVehicle vehicle = new TransportVehicle(); + vehicle.setOrganizationName("测试组织"); + vehicle.setPlateNo(PLATE); + vehicle.setVehicleType("重型货车"); + vehicle.setBusinessRelation("管理挂靠"); + vehicle.setEnergyType("柴油"); + vehicle.setDrivingLicenseNo("450100123456"); + vehicle.setDrivingLicenseEndDate(LocalDate.of(2028, 3, 9)); + vehicle.setRoadTransportCertNo("桂交运管0001"); + vehicle.setRoadTransportCertEndDate(LocalDate.of(2030, 1, 1)); + vehicle.setAnnualReviewEndDate(LocalDate.of(2030, 1, 1)); + vehicle.setRegistrationNo("450100123456"); + vehicle.setApprovedLoadKg(18000); + return vehicle; + } + + private void inject(String field, Object value) throws Exception { + Class type = service.getClass(); + while (type != null) { + try { + Field declared = type.getDeclaredField(field); + declared.setAccessible(true); + declared.set(service, value); + return; + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } + } + throw new IllegalStateException("未找到字段:" + field); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripAggregationTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripAggregationTest.java index 1bee8fd5..3fafa0fe 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripAggregationTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripAggregationTest.java @@ -140,7 +140,7 @@ class TripAggregationTest { } @Test - @DisplayName("四档都由同一份推导给出:待接单 / 进行中 / 已完成 / 已取消") + @DisplayName("五档都由同一份推导给出:待接单 / 进行中 / 待确认 / 已完成 / 已取消") void tiersComeFromTheSameDerivation() { // 需接单且未接单 → 待接单档 assertEquals(0, tier(List.of(waybill("pending", "pending", ACCEPT_YES)))); @@ -150,6 +150,9 @@ class TripAggregationTest { assertEquals(2, tier(List.of(waybill("completed", "accepted", ACCEPT_YES)))); // 子单全取消 → 已取消档 assertEquals(3, tier(List.of(waybill("cancelled", null, ACCEPT_YES)))); + // 待回单确认独立成档,即便项目要接单也不回落到待接单 + assertEquals(4, tier(List.of(waybill( + org.springblade.transport.support.WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM, "pending", ACCEPT_YES)))); // 子单无从判定(空集合)时不落任何档,不会混进「全部」 assertNull(tier(List.of())); } @@ -182,7 +185,7 @@ class TripAggregationTest { boolean requireAccept = TripAggregation.requiresAccept(children); boolean accepted = org.springblade.transport.support.WaybillProcessSupport.ACCEPT_ACCEPTED .equals(TripAggregation.acceptStatus(children)); - return TripAggregation.appStatus(TripAggregation.statusOf(children, requireAccept, accepted)); + return TripAggregation.tabOf(TripAggregation.statusOf(children, requireAccept, accepted)); } @Test @@ -194,20 +197,26 @@ class TripAggregationTest { } @Test - @DisplayName("待回单确认的子单仍进运次,且不会把运次判成已完成") + @DisplayName("待回单确认的子单仍进运次,独占一档且不会被判成已完成") void pendingReturnConfirmChildKeepsTripRunning() { String pendingReturnConfirm = org.springblade.transport.support.WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM; assertTrue(TripAggregation.CHILD_STATUSES.contains(pendingReturnConfirm), "漏掉这个状态,子单会从运次读服务消失,配载单被当成全部完成"); - // 与一张已完成的子单拼载:还有子单没走完,运次只能是运输中 + // 与一张已完成的子单拼载:还有子单没走完,运次只能是待回单确认 List children = List.of( waybill("completed", null, null), waybill(pendingReturnConfirm, null, null)); String status = TripAggregation.statusOf(children, false, false); - assertEquals("running", status); - assertEquals(1, TripAggregation.appStatus(status), "运次不进已完成档"); + assertEquals(pendingReturnConfirm, status); + assertEquals(4, TripAggregation.tabOf(status), "待回单确认单独一档"); + assertEquals(1, TripAggregation.appStatus(status), "卡片状态码仍是运输中,不进已完成档"); + + // 还挂着在途子单时仍算进行中:待确认只在整趟车没有司机待办时成立 + assertEquals("running", TripAggregation.statusOf(List.of( + waybill("running", null, null), + waybill(pendingReturnConfirm, null, null)), false, false)); } private Waybill waybill(String businessStatus, String acceptStatus, String processJson) { diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillConfirmReturnTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillConfirmReturnTest.java index b753a03b..c8bcc5fd 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillConfirmReturnTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillConfirmReturnTest.java @@ -27,10 +27,13 @@ import org.springblade.transport.support.WaybillProcessSupport; import java.lang.reflect.Field; import java.util.List; +import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; @@ -131,7 +134,7 @@ class WaybillConfirmReturnTest { assertEquals(true, result); verify(waybillMapper).updateById(org.mockito.ArgumentMatchers.argThat(waybill -> "completed".equals(waybill.getBusinessStatus()) && WAYBILL_ID.equals(waybill.getId()))); - verify(receivablePayableDetailService).generateForCompletedWaybills(List.of(WAYBILL_ID)); + verify(receivablePayableDetailService).generateForCompletedWaybills(List.of(WAYBILL_ID), Map.of()); verify(loadingManageService).completeIfAllWaybillsCompleted(any()); } @@ -156,7 +159,7 @@ class WaybillConfirmReturnTest { assertEquals("当前状态不允许确认回单", exception.getMessage()); verify(waybillMapper, never()).updateById(any(Waybill.class)); - verify(receivablePayableDetailService, never()).generateForCompletedWaybills(any()); + verify(receivablePayableDetailService, never()).generateForCompletedWaybills(anyList(), anyMap()); } @Test @@ -222,7 +225,7 @@ class WaybillConfirmReturnTest { verify(waybillMapper).updateById(org.mockito.ArgumentMatchers.argThat(waybill -> "completed".equals(waybill.getBusinessStatus()))); - verify(receivablePayableDetailService).generateForCompletedWaybills(List.of(WAYBILL_ID)); + verify(receivablePayableDetailService).generateForCompletedWaybills(List.of(WAYBILL_ID), Map.of()); } @Test @@ -248,7 +251,7 @@ class WaybillConfirmReturnTest { verify(waybillMapper).updateById(org.mockito.ArgumentMatchers.argThat(waybill -> "cancelled".equals(waybill.getBusinessStatus()) && WAYBILL_ID.equals(waybill.getId()))); - verify(receivablePayableDetailService, never()).generateForCompletedWaybills(any()); + verify(receivablePayableDetailService, never()).generateForCompletedWaybills(anyList(), anyMap()); verify(loadingManageService, never()).completeIfAllWaybillsCompleted(any()); } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordListTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordListTest.java index 6f055f13..d9e7e76b 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordListTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordListTest.java @@ -10,6 +10,7 @@ import org.junit.jupiter.api.Test; import org.springblade.transport.mapper.WaybillEnroutePunchMapper; import org.springblade.transport.mapper.WaybillMapper; import org.springblade.transport.mapper.WaybillNodePunchMapper; +import org.springblade.transport.service.IReceivablePayableDetailService; import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.pojo.entity.WaybillEnroutePunch; import org.springblade.transport.pojo.entity.WaybillNodePunch; @@ -20,6 +21,7 @@ import java.lang.reflect.Field; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Date; +import java.math.BigDecimal; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -57,6 +59,7 @@ class WaybillPunchRecordListTest { private WaybillMapper waybillMapper; private WaybillNodePunchMapper nodePunchMapper; private WaybillEnroutePunchMapper enroutePunchMapper; + private IReceivablePayableDetailService receivablePayableDetailService; private WaybillServiceImpl service; @BeforeAll @@ -74,10 +77,12 @@ class WaybillPunchRecordListTest { waybillMapper = mock(WaybillMapper.class); nodePunchMapper = mock(WaybillNodePunchMapper.class); enroutePunchMapper = mock(WaybillEnroutePunchMapper.class); + receivablePayableDetailService = mock(IReceivablePayableDetailService.class); service = new WaybillServiceImpl(); inject("baseMapper", waybillMapper); inject("waybillNodePunchMapper", nodePunchMapper); inject("waybillEnroutePunchMapper", enroutePunchMapper); + inject("receivablePayableDetailService", receivablePayableDetailService); } @Test @@ -187,6 +192,44 @@ class WaybillPunchRecordListTest { assertEquals(7001L, load.getId()); } + @Test + @DisplayName("计费含里程:里程只挂在末位可打卡节点上,其它节点不带") + void mileageOnlyOnLastDriverPunchNode() { + Waybill waybill = waybill(CONFIRM_ACCEPT_JSON, "running", "accepted"); + waybill.setMileage(new BigDecimal("128")); + stubWaybill(waybill); + when(receivablePayableDetailService.requiresMileage(waybill)).thenReturn(true); + + List records = service.listPunchRecords(WAYBILL_ID).getRecords(); + WaybillPunchRecordItemVO accept = records.stream() + .filter(record -> "accept".equals(record.getNodeCode())).findFirst() + .orElseThrow(() -> new AssertionError("时间轴里没有接单节点")); + WaybillPunchRecordItemVO load = records.stream() + .filter(record -> "load".equals(record.getNodeCode())).findFirst() + .orElseThrow(() -> new AssertionError("时间轴里没有装货节点")); + + // 接单不参与司机打卡序列,末位是装货 + assertFalse(Boolean.TRUE.equals(accept.getNeedMileage())); + assertTrue(Boolean.TRUE.equals(load.getNeedMileage())); + assertEquals(0, new BigDecimal("128").compareTo(load.getMileage())); + assertFalse(Boolean.TRUE.equals(load.getMileageLocked())); + } + + @Test + @DisplayName("计费不含里程:末位节点也不带里程标记") + void noMileageFlagWhenBillingHasNoMileage() { + Waybill waybill = waybill(CONFIRM_ACCEPT_JSON, "running", "accepted"); + stubWaybill(waybill); + when(receivablePayableDetailService.requiresMileage(waybill)).thenReturn(false); + + WaybillPunchRecordItemVO load = service.listPunchRecords(WAYBILL_ID).getRecords().stream() + .filter(record -> "load".equals(record.getNodeCode())).findFirst() + .orElseThrow(() -> new AssertionError("时间轴里没有装货节点")); + + assertFalse(Boolean.TRUE.equals(load.getNeedMileage())); + assertNull(load.getMileage()); + } + // ------------------------------------------------------------ 夹具 private void stubWaybill(Waybill waybill) { diff --git a/doc/sql/transport/blade_customer_archive.sql b/doc/sql/transport/blade_customer_archive.sql index 58bf476a..0cec14d9 100644 --- a/doc/sql/transport/blade_customer_archive.sql +++ b/doc/sql/transport/blade_customer_archive.sql @@ -43,6 +43,9 @@ CREATE TABLE `blade_customer_archive` ( `current_node` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '当前节点', `current_processor` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '当前处理人', `approved_time` datetime NULL DEFAULT NULL COMMENT '审核通过时间', + `kingdee_status` varchar(20) NOT NULL DEFAULT 'none' COMMENT '金蝶协同状态:none未推送/exist金蝶已有/synced已推送/failed推送失败', + `kingdee_fail_reason` varchar(500) NULL DEFAULT NULL COMMENT '金蝶推送失败原因', + `kingdee_sync_time` datetime NULL DEFAULT NULL COMMENT '金蝶推送时间', `create_user` bigint NULL DEFAULT NULL COMMENT '创建人', `create_dept` bigint NULL DEFAULT NULL COMMENT '创建部门', `create_time` datetime NULL DEFAULT NULL COMMENT '创建时间', @@ -54,7 +57,8 @@ CREATE TABLE `blade_customer_archive` ( UNIQUE KEY `uk_customer_archive_code` (`tenant_id`, `customer_code`, `is_deleted`) USING BTREE, UNIQUE KEY `uk_customer_archive_credit_code` (`tenant_id`, `unified_credit_code`, `is_deleted`) USING BTREE, KEY `idx_customer_archive_access` (`tenant_id`, `access_type`, `approval_status`) USING BTREE, - KEY `idx_customer_archive_name` (`tenant_id`, `full_name`, `short_name`) USING BTREE + KEY `idx_customer_archive_name` (`tenant_id`, `full_name`, `short_name`) USING BTREE, + KEY `idx_kingdee_status` (`kingdee_status`) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '客商档案'; -- ---------------------------- diff --git a/doc/sql/transport/blade_invoice_receipt_20260821.sql b/doc/sql/transport/blade_invoice_receipt_20260821.sql index d00db219..2c925ea9 100644 --- a/doc/sql/transport/blade_invoice_receipt_20260821.sql +++ b/doc/sql/transport/blade_invoice_receipt_20260821.sql @@ -67,8 +67,6 @@ CREATE TABLE IF NOT EXISTS `blade_invoice_receipt` ( `approval_status` varchar(30) COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'draft', `current_node` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL, `current_processor` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL, - `kingdee_bill_no` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL, - `kingdee_status` varchar(30) COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'unsynced', `attachments_json` longtext COLLATE utf8mb4_general_ci, `remark` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL, `void_reason` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL, @@ -77,8 +75,7 @@ CREATE TABLE IF NOT EXISTS `blade_invoice_receipt` ( KEY `idx_invoice_receipt_no` (`tenant_id`,`invoice_no`), KEY `idx_invoice_receipt_project` (`project_id`), KEY `idx_invoice_receipt_date` (`invoice_date`), - KEY `idx_invoice_receipt_status` (`approval_status`), - KEY `idx_invoice_receipt_kingdee` (`kingdee_status`) + KEY `idx_invoice_receipt_status` (`approval_status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='收票登记'; CREATE TABLE IF NOT EXISTS `blade_invoice_receipt_settlement` ( diff --git a/doc/sql/transport/blade_waybill_pending_return_confirm_20261024.sql b/doc/sql/transport/blade_waybill_pending_return_confirm_20261024.sql new file mode 100644 index 00000000..8b80aa07 --- /dev/null +++ b/doc/sql/transport/blade_waybill_pending_return_confirm_20261024.sql @@ -0,0 +1,79 @@ +-- ---------------------------- +-- 回单待确认状态改名:business_status 还停在 pending_return 的运单订正为 pending_return_confirm +-- 写业务数据。执行前必须先存档(第 0 节),执行后必须跑第 2 节校验。可重复执行。 +-- ---------------------------- +-- +-- 为什么要订正: +-- 2026-10-22 起 dev 把「司机打完末位卡、等业务员确认回单」的状态值从 pending_return +-- 改成 pending_return_confirm(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM,911393fb), +-- 并把旧值从代码里彻底删掉——dev 全仓 grep '"pending_return"' 已 0 命中。 +-- 而列表口径只认新值: +-- · 运次列表(司机端小程序 /waybill/task/page)的状态集合直接下到 SQL: +-- TripQueryServiceImpl.visibleRows 的 .in(business_status, TripAggregation.CHILD_STATUSES), +-- CHILD_STATUSES 里没有 pending_return ⇒ 这行连分组的机会都没有,整趟车在库层被滤掉; +-- · 运单列表的 TAB_ALL_STATUSES / ACTIVE_STATUSES 同样没有旧值。 +-- 于是库里还停在 pending_return 的运单,部署 dev 之后从运单列表和运次列表一起消失, +-- 司机端小程序四个 tab 全都看不到它——这正是 2026-10-24 测试反馈「待回单确认的运单在小程序 +-- 列表里看不到」的机制(若该环境部署的仍是 master,则 master 的 TripAggregation.CHILD_STATUSES +-- 本来就不含 pending_return,症状相同)。 +-- +-- 旧值是谁写的: +-- master 的 DriverWaybillServiceImpl(末位打卡节点为到货/卸货/签收、且过程配置要求回单内部确认时) +-- 调 markPendingReturn(),写入 business_status = 'pending_return'。语义与新值一一对应, +-- 都是「司机已打完末位卡、等确认回单」。 +-- +-- 处置口径: +-- 只改可见运单(is_deleted = 0)里 business_status = 'pending_return' 的行,改成 pending_return_confirm。 +-- update_time 不在 SET 里,保留原值——它是事后追溯改动时间的唯一线索。 +-- blade_loading_manage / blade_master_order / blade_transport_plan 也有 business_status 列, +-- 但那是各自单据的过程状态,不参与运次可见口径,本脚本不动它们(第 2 节给出现值为旧值的行数, +-- 预计为 0;若不为 0 先人工确认再决定是否订正)。 +-- 可重复执行:第二次执行时 WHERE 命中 0 行。 +-- ---------------------------- +-- 0. 存档(必须在订正之前执行,仅首次需要) +-- ---------------------------- +-- CREATE TABLE blade_waybill_pending_return_bak AS +-- SELECT id, waybill_no, loading_no, business_status, driver_phone, update_time +-- FROM blade_waybill +-- WHERE is_deleted = 0 AND business_status = 'pending_return'; + +-- ---------------------------- +-- 1. 订正状态 +-- ---------------------------- +START TRANSACTION; + +UPDATE `blade_waybill` +SET `business_status` = 'pending_return_confirm' +WHERE `is_deleted` = 0 + AND `business_status` = 'pending_return'; + +COMMIT; + +-- ---------------------------- +-- 2. 校验(执行后必看,前两条都必须返回 0) +-- ---------------------------- +-- -- 仍停在旧值的可见运单 +-- SELECT COUNT(*) FROM blade_waybill +-- WHERE is_deleted = 0 AND business_status = 'pending_return'; +-- +-- -- 订正过的行必须都在备份表里且已是新值(应为 0) +-- SELECT COUNT(*) FROM blade_waybill_pending_return_bak b +-- LEFT JOIN blade_waybill w ON w.id = b.id +-- WHERE w.id IS NULL OR w.business_status <> 'pending_return_confirm'; +-- +-- -- 其它单据的 business_status 里是否也有旧值(只报不改,预计 0) +-- SELECT 'blade_loading_manage' AS tbl, COUNT(*) FROM blade_loading_manage +-- WHERE is_deleted = 0 AND business_status = 'pending_return' +-- UNION ALL +-- SELECT 'blade_master_order', COUNT(*) FROM blade_master_order +-- WHERE is_deleted = 0 AND business_status = 'pending_return' +-- UNION ALL +-- SELECT 'blade_transport_plan', COUNT(*) FROM blade_transport_plan +-- WHERE is_deleted = 0 AND business_status = 'pending_return'; + +-- ---------------------------- +-- 3. 回退(订正后发现问题时执行) +-- ---------------------------- +-- UPDATE blade_waybill w +-- JOIN blade_waybill_pending_return_bak b ON b.id = w.id +-- SET w.business_status = b.business_status;