🔀 合并 websoft/dev,解决所属组织与数据权限冲突

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-25 20:37:21 +08:00
49 changed files with 1277 additions and 121 deletions
@@ -54,6 +54,15 @@ public class NodeSubmitDTO implements Serializable {
@Schema(description = "数量(件)")
private String quantity;
/**
* 里程(公里):只由末位打卡节点采集,运单计费规则含里程时必填。
* <p>
* 用字符串接收——旧版小程序不传、新版按输入框传字符串,两种都要能进;
* 非末位节点收到该值时忽略,不报错。
*/
@Schema(description = "里程(公里),末位打卡节点采集")
private String mileage;
@Schema(description = "备注")
private String remark;
@@ -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;
@@ -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;
/**
* 本节点是否需要采集里程:末位可打卡节点、且运单计费规则含里程(应收或应付任一侧)。
* <p>
* 判据与 finishOnPunch、自动完成同源的「末位」定义;客户端只认这个标记,不自己推末位。
*/
@Schema(description = "本节点是否需要采集里程(末位可打卡节点且计费规则含里程)")
private Boolean needMileage;
@Schema(description = "运单里程(公里),needMileage 为真时用于回显")
private BigDecimal mileage;
@Schema(description = "已上传凭证图(已打卡回显)")
private List<DriverPunchPhotoVO> photos = new ArrayList<>();
@@ -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;
@@ -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;
@@ -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;
+7
View File
@@ -94,6 +94,13 @@
</exclusion>
</exclusions>
</dependency>
<!-- JUnit 5 / Mockito 由该 starter 带入,版本由父 pom 管理 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
@@ -299,7 +299,7 @@ public class MpUserServiceImpl extends BaseServiceImpl<MpUserMapper, MpUser> 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<MpUserMapper, MpUser> 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<MpUserMapper, MpUser> 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<MpUserMapper, MpUser> 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<MpUserMapper, MpUser> 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.<MpUserDataScope>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 -> {
@@ -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;
/**
* 小程序用户身份与数据权限测试。
* <p>
* 注入方式:被测类直接 new(唯一构造参数是 MpUserDataScopeMapper),
* {@code BaseServiceImpl.baseMapper} 用反射写入 MpUserMapper 的 mock,
* 与 blade-transport 的 DriverServiceImplTest 同一套路,不起 Spring 容器。
* {@code MpUserCache.clear()} 内部走 SpringUtil.getBean(CacheManager),无容器即抛空指针,
* 故每次调用前把该静态方法 mock 掉。
* <p>
* 非法入参用例反射调用私有的 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<Arguments> 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<MpUser> 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<MpUser> 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<MpUser> 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<MpUser> 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<MpUserCache> 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<MpUserCache> ignored = mockStatic(MpUserCache.class)) {
assertTrue(service.submit(user), label + ":所属组织不是注册的准入门槛");
}
ArgumentCaptor<MpUser> captor = ArgumentCaptor.forClass(MpUser.class);
verify(mpUserMapper).insert(captor.capture());
assertNull(captor.getValue().getDeptId(), "未选组织时组织列保持 null,不写空串");
assertEquals(1, captor.getValue().getDataScopeRange(), "未选组织的账号按默认 1-仅所属组织落库");
}
static Stream<Arguments> 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<Arguments> 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<MpUserCache> 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<MpUserDataScope> 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<MpUser> 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);
}
}
+3 -9
View File
@@ -55,6 +55,9 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<!-- 测试:本模块此前的 src/test 下只有 main() 手跑脚本,没有可自动执行的测试。
认证改造的提交/审核链路带状态机语义(同对象至多一条待审、提交不得改生效快照
等),必须用可重复执行的单元测试守住,故引入 JUnit 5 与 Mockito。 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
@@ -64,15 +67,6 @@
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
</dependency>
<!-- 测试:本模块此前的 src/test 下只有 main() 手跑脚本,没有可自动执行的测试。
认证改造的提交/审核链路带状态机语义(同对象至多一条待审、提交不得改生效快照
等),必须用可重复执行的单元测试守住,故引入 JUnit 5 与 Mockito。 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
@@ -61,6 +61,14 @@ public final class TransportBusiConstant {
/** 审批状态:已作废(终态)。 */
public static final String VOIDED = "voided";
/**
* 「未分配组织」筛选项的哨兵值,不是真实组织名。
* <p>
* 前端 organization-search 混入、DriverMapper.xml 与 TransportVehicleMapper.xml 的
* organizationName 分支、以及两个导出查询都按这个字面量判定,四处必须同步修改。
*/
public static final String UNASSIGNED_ORG_FILTER = "__UNASSIGNED_ORG__";
private TransportBusiConstant() {
}
}
@@ -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())) {
@@ -107,7 +107,7 @@ public class DriverWaybillController extends BladeController {
@GetMapping("/task/current")
@ApiOperationSupport(order = 21)
@Operation(summary = "首页:当前运输中运次", description = "当前用户手机号可见的运输中运次(有配载单的一趟车算一个)最新一条")
@Operation(summary = "首页:当前运输中运次", description = "当前用户手机号可见的运输中或待确认(待回单确认)运次(有配载单的一趟车算一个)最新一条")
public R<DriverWaybillCardVO> 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<DriverWaybillTabCountsVO> 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<IPage<DriverWaybillCardVO>> 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));
@@ -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<IPage<AdminWaybillCardVO>> 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<IPage<AdminWaybillCardVO>> 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<WaybillLocateVO> locate(
@Parameter(description = "运单ID", required = true) @RequestParam Long id) {
return R.data(waybillService.locateVehicle(id));
}
}
@@ -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())) {
@@ -170,8 +170,18 @@
AND driver_type = #{driver.driverType}
</if>
<if test="driver.organizationName != null and driver.organizationName != ''">
<bind name="organizationNameLike" value="'%' + driver.organizationName + '%'"/>
AND organization_name LIKE #{organizationNameLike}
<!-- 「未分配组织」哨兵值定义在 TransportBusiConstant.UNASSIGNED_ORG_FILTER 与前端
tms-erp-web/src/mixins/organization-search.js,改这里必须同步常量与 TransportVehicleMapper.xml 的同名分支。
组织列 NOT NULL,无组织存空串。 -->
<choose>
<when test="'__UNASSIGNED_ORG__'.equals(driver.organizationName)">
AND organization_name = ''
</when>
<otherwise>
<bind name="organizationNameLike" value="'%' + driver.organizationName + '%'"/>
AND organization_name LIKE #{organizationNameLike}
</otherwise>
</choose>
</if>
<if test="driver.status != null">
AND status = #{driver.status}
@@ -117,8 +117,18 @@
<sql id="QueryCondition">
is_deleted = 0
<if test="vehicle.organizationName != null and vehicle.organizationName != ''">
<bind name="organizationNameLike" value="'%' + vehicle.organizationName + '%'"/>
AND organization_name LIKE #{organizationNameLike}
<!-- 「未分配组织」哨兵值定义在 TransportBusiConstant.UNASSIGNED_ORG_FILTER 与前端
tms-erp-web/src/mixins/organization-search.js,改这里必须同步常量与 DriverMapper.xml 的同名分支。
组织列 NOT NULL,无组织存空串。 -->
<choose>
<when test="'__UNASSIGNED_ORG__'.equals(vehicle.organizationName)">
AND organization_name = ''
</when>
<otherwise>
<bind name="organizationNameLike" value="'%' + vehicle.organizationName + '%'"/>
AND organization_name LIKE #{organizationNameLike}
</otherwise>
</choose>
</if>
<if test="vehicle.useDepartment != null and vehicle.useDepartment != ''">
<bind name="useDepartmentLike" value="'%' + vehicle.useDepartment + '%'"/>
@@ -84,12 +84,29 @@ public interface IReceivablePayableDetailService extends BaseService<ReceivableP
/** 完成运单后按合同系统计费模式自动生成应收、应付明细。 */
void generateForCompletedWaybills(List<Long> waybillIds);
/**
* 同上,但按 {@code mileageOverrides} 覆盖运单里程后再算。
* <p>
* 生成走独立事务(REQUIRES_NEW),读不到调用方事务里尚未提交的里程更新,
* 由调用方把本次刚写入的里程显式传进来,否则按里程计费会读到旧值或 null(算成 0 元)。
*/
void generateForCompletedWaybills(List<Long> waybillIds, Map<Long, BigDecimal> mileageOverrides);
/**
* 运单维护里程后,同步未挂结算单的应收应付明细里程;
* 对按里程、按吨·公里计费的费用行按新里程重新计算。
*/
void syncMileageFromWaybill(Long waybillId, BigDecimal mileage);
/**
* 运单的计费规则是否包含里程:客户合同(应收)与承运商合同(应付)任一侧命中里程类计费要素即为真。
* <p>
* 判据与费用生成同源(同一份方案解析与规则命中),只读、不产生任何写操作;
* 无合同、方案不匹配、规则不命中一律返回 false。
* </p>
*/
boolean requiresMileage(Waybill waybill);
/**
* 批量导入完成运单后按合同系统计费模式生成应收、应付明细。
* <p>与导入事务共用同一事务,运单尚未提交,因此直接传入实体而非主键。</p>
@@ -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<Waybill> {
WaybillVO copy(Long id);
boolean changeRoute(Waybill waybill);
boolean maintainMileage(WaybillMileageRequest request);
/**
* 只写运单里程(定向更新),用于打卡链路采集里程。
* <p>
* 不做 maintainMileage 的「仅已完成 / 未生成结算单」前置——末位打卡发生在完成之前;
* 结算状态由调用方按各自入口的规则判定,也不触发费用重算,由调用方决定是否同步。
* </p>
*/
void savePunchMileage(Long waybillId, BigDecimal mileage);
boolean cancel(Long id);
boolean reassign(Waybill waybill);
@@ -123,21 +123,6 @@ public class DriverCertServiceImpl implements IDriverCertService {
}
}
/**
* 建档前置条件:车辆行的 {@code organization_name} 是 NOT NULL 且无默认值,缺了这条 INSERT
* 直接报「Field 'organization_name' doesn't have a default value」,用户看到的是一串英文异常。
* <p>
* 只在**新建车辆**时要求:重新认证不会写这一列(它由管理端维护,属于内部管理归属),
* 拿它拦住重新认证只会让司机无从下手。
*
* @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();
}
}
@@ -383,9 +383,6 @@ public class DriverServiceImpl extends BaseServiceImpl<DriverMapper, Driver> 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<DriverMapper, Driver> imp
}
}
// 部门可选:匹配到则写入小程序用户,匹配不到不阻断同步
// 按「组织名称」反查组织树 id:组织改名后这里必然落空——不报错、不掉数据,
// 只是该司机账号的权限不再随组织变化(新建账号落空时数据权限为空集,管理端看不到)。
// 组织为空时不做反查:DeptServiceImpl 会把空名称拆成空集合,MyBatis-Plus 的 inExpression
// 对空集合拼出 dept_name IN () 非法子句。此时 deptId 保持 null 而不是空串:updateUser 的
// updateById 忽略 null,账号原有的组织与数据权限得以保留;写成空串会把它们覆盖掉。
String deptId = null;
R<String> 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<String> 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);
@@ -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<TripVO> 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<String, WaybillNodePunch> latestNodePunchByCode = latestNodePunchMap(waybill.getId());
// 未开始运输的运单在途卡仍下发,但不可提交:司机要看得到这张卡,才知道为什么不能打
String notStartedReason = WaybillProcessSupport.punchNotStartedReason(waybill.getBusinessStatus());
// 计费规则是否含里程与节点无关,整条运单算一次;放在循环外避免逐节点回查合同。
boolean lastNodeNeedsMileage = receivablePayableDetailService.requiresMileage(waybill);
for (Map<String, Object> 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();
@@ -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;
}
/**
* 卡片展示用的原始业务状态:整趟车子单同一业务状态时取它,混着多种状态时为空,卡片按状态码展示。
* <p>
* 待回单确认的趟车状态码仍是 1(运输中),只有这个字段能把它区分开。
*/
private String uniqueChildBusinessStatus(List<TripVO.TripWaybillVO> children) {
Set<String> 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待确认(待回单确认)。
* <p>
* 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);
@@ -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);
}
/**
* 这套合同命中的计费规则里,是否有里程类要素且规则本身命中该运单。
* <p>
* 方案解析与规则命中都复用费用生成那两条私有方法,保证「打卡时要里程」与「结算时按里程算」同一口径。
*/
private boolean hasMileageBillingRule(ContractManage contract, Waybill waybill) {
if (contract == null || waybill == null) {
return false;
}
Map<String, Object> 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<String, Object> 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<Long> 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<Long> waybillIds, Map<Long, BigDecimal> mileageOverrides) {
List<Waybill> 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());
}
@@ -264,9 +264,6 @@ public class TransportVehicleServiceImpl extends BaseServiceImpl<TransportVehicl
}
private void validate(TransportVehicle vehicle) {
if (Func.isEmpty(vehicle.getOrganizationName())) {
throw new ServiceException("所属组织不能为空");
}
if (Func.isEmpty(vehicle.getPlateNo())) {
throw new ServiceException("车牌号不能为空");
}
@@ -82,21 +82,41 @@ final class TripAggregation {
return switch (Func.toStr(status, "")) {
case PENDING -> 0;
case RUNNING -> 1;
case PENDING_RETURN_CONFIRM -> 1;
case COMPLETED -> 2;
case CANCELLED -> 3;
default -> null;
};
}
/**
* 运次的列表档位:0 待接单 / 1 进行中 / 2 已完成 / 3 已取消 / 4 待确认(待回单确认)。
* <p>
* 与 {@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;
};
}
/**
* 运次状态:终态优先——子单全为终态时有已完成取已完成、否则已取消;
* 否则需接单且未接单取待接单,其余取运输中。
* 其次待接单档优先;再次有在跑的子单取运输中;只剩待回单确认的取待回单确认。
* <p>
* 待回单确认不受接单需求影响:需要接单的趟车走到回单确认时司机已接过单,不再回退成待接单。
* <p>
* 子单集合为空时无从判定,返回空。
*/
static String statusOf(List<Waybill> 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<Waybill> 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) {
}
}
@@ -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<Integer> 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);
}
/**
@@ -421,6 +421,16 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
if (processNodes.isEmpty()) {
processNodes = WaybillProcessSupport.listDriverPunchNodes(processJson);
}
// 里程只在末位打卡节点采集:判据与自动完成、司机端 needMileage 同源(司机可打卡节点的末位)。
List<Map<String, Object>> 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<String, Object> node : processNodes) {
String nodeCode = WaybillProcessSupport.nodeKey(node);
@@ -430,6 +440,11 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
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<WaybillMapper, Waybill>
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);
}
/**
* 管理端订正打卡时写里程:只在末位打卡节点、且本次是打卡(非取消)时生效。
* <p>
* 里程留空(null)表示不改动;已生成结算单时拒绝改值,但其余字段的保存不受影响。
* 写入后同步未结算明细的里程并按新里程重算,与「维护里程」同一条链路。
*/
private void applyPunchRecordMileage(Waybill waybill, WaybillPunchRecordSaveRequest request, boolean punched) {
if (!punched || request.getMileage() == null) {
return;
}
String processJson = resolveLiveProcessJson(waybill);
Map<String, Object> 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<WaybillMapper, Waybill>
.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<WaybillMapper, Waybill>
return updated;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void savePunchMileage(Long waybillId, BigDecimal mileage) {
if (waybillId == null || mileage == null) {
return;
}
// 只 set 里程列:整行 updateById 会把并发改过的其它字段一并覆盖回去。
update(Wrappers.<Waybill>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<WaybillMapper, Waybill>
waybill.setEndDate(LocalDate.now());
boolean updated = updateById(waybill);
if (updated) {
receivablePayableDetailService.generateForCompletedWaybills(List.of(waybill.getId()));
// 计费生成是独立事务,读不到本事务里刚写的里程:把当前里程显式带过去,
// 否则司机末位打卡写入的里程会被当成 null,按里程的费用算成 0 元。
Map<Long, BigDecimal> 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;
@@ -252,4 +252,34 @@ public final class TransportBusinessSupport {
}
}
/**
* 里程(公里):正整数、有效位不超过 10 位。
* <p>
* 打卡采集与「维护里程」共用这一条判据,两处文案必须一致。空值按「没有填」拒回,
* 非数字按格式错误拒回——两者给司机/业务员的下一步动作不同。
*/
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;
}
}
@@ -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<TransportVehicle> 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;
@@ -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());
@@ -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());
@@ -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);
@@ -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<MpUser> 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<MpUser> 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> 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");
@@ -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);
@@ -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);
@@ -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));
@@ -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());
@@ -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
@@ -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
@@ -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;
@@ -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));
@@ -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})的组织口径。
* <p>
* 所属组织是车辆的档案属性,不是保存的前置条件:组织为空串(司机端建车、管理端未选组织)
* 也能保存,落库后即「未分配组织」。
*/
class TransportVehicleServiceImplTest {
private static final String PLATE = "桂A32523";
private TransportVehicleServiceImpl service;
private TransportVehicleMapper vehicleMapper;
private MockedStatic<AuthUtil> 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<TransportVehicle> 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<TransportVehicle> 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);
}
}
@@ -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<Waybill> 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) {
@@ -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.<Waybill>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.<Waybill>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.<Waybill>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());
}
@@ -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<WaybillPunchRecordItemVO> 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) {
+5 -1
View File
@@ -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 = '客商档案';
-- ----------------------------
@@ -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` (
@@ -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;