🔀 合并 websoft/dev,解决所属组织与数据权限冲突
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+9
@@ -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;
|
||||
|
||||
|
||||
+8
@@ -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;
|
||||
|
||||
|
||||
+12
@@ -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<>();
|
||||
|
||||
|
||||
+4
-1
@@ -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;
|
||||
|
||||
|
||||
+4
-1
@@ -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;
|
||||
|
||||
|
||||
+13
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
+21
-6
@@ -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 -> {
|
||||
|
||||
+411
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+8
@@ -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() {
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -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())) {
|
||||
|
||||
+4
-4
@@ -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));
|
||||
|
||||
+12
-3
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+6
-1
@@ -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())) {
|
||||
|
||||
+12
-2
@@ -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}
|
||||
|
||||
+12
-2
@@ -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 + '%'"/>
|
||||
|
||||
+17
@@ -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>
|
||||
|
||||
+11
@@ -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);
|
||||
|
||||
|
||||
+9
-22
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+10
-7
@@ -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);
|
||||
|
||||
+28
-2
@@ -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();
|
||||
|
||||
+23
-3
@@ -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);
|
||||
|
||||
+73
-1
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
-3
@@ -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("车牌号不能为空");
|
||||
}
|
||||
|
||||
+47
-6
@@ -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) {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+8
-5
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+74
-8
@@ -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;
|
||||
|
||||
+30
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+10
-7
@@ -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;
|
||||
|
||||
+2
-1
@@ -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());
|
||||
|
||||
+2
-1
@@ -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());
|
||||
|
||||
+2
-1
@@ -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);
|
||||
|
||||
+65
-1
@@ -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");
|
||||
|
||||
+2
-1
@@ -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);
|
||||
|
||||
+2
-1
@@ -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);
|
||||
|
||||
+2
-1
@@ -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));
|
||||
|
||||
+2
-1
@@ -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());
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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;
|
||||
|
||||
+2
-1
@@ -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));
|
||||
|
||||
+148
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+15
-6
@@ -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) {
|
||||
|
||||
+7
-4
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
+43
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user