🔀 合并 websoft/dev,解决待回单状态与汇票可选冲突
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -83,6 +83,17 @@
|
||||
<groupId>org.springblade</groupId>
|
||||
<artifactId>blade-resource-api</artifactId>
|
||||
</dependency>
|
||||
<!-- 登录编排调用司机档案的 ensureProfile -->
|
||||
<dependency>
|
||||
<groupId>org.springblade</groupId>
|
||||
<artifactId>blade-transport-api</artifactId>
|
||||
</dependency>
|
||||
<!-- 登录编排测试:mock feign 返回值 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!--安全模块-->
|
||||
<dependency>
|
||||
<groupId>org.springblade</groupId>
|
||||
|
||||
@@ -29,6 +29,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.SneakyThrows;
|
||||
import org.springblade.auth.constant.BladeAuthConstant;
|
||||
import org.springblade.auth.support.MpLoginRequestSupport;
|
||||
import org.springblade.core.oauth2.props.OAuth2Properties;
|
||||
import org.springblade.core.oauth2.provider.OAuth2Request;
|
||||
import org.springblade.core.oauth2.service.OAuth2User;
|
||||
@@ -87,13 +88,16 @@ public class Oauth2SmsEndpoint {
|
||||
if (StringUtil.isBlank(decryptedPhone)) {
|
||||
return R.fail(USER_PHONE_NOT_FOUND);
|
||||
}
|
||||
// 校验手机是否已注册,防止恶意发送验证码
|
||||
// buildArgs:带上 User-Type 等请求头,小程序传 User-Type=mp 时查 blade_mp_user
|
||||
OAuth2Request request = OAuth2Request.create().buildArgs();
|
||||
request.setTenantId(tenantId);
|
||||
OAuth2User oAuth2User = userService.loadByPhone(decryptedPhone, request);
|
||||
if (oAuth2User == null) {
|
||||
return R.fail(USER_PHONE_NOT_FOUND);
|
||||
// 「手机号是否已注册」只对 Web 校验;小程序端未注册手机号也发码,账号在登录时创建。
|
||||
// 发码路径只做查询,不建号。
|
||||
if (!MpLoginRequestSupport.isMp(request)) {
|
||||
OAuth2User oAuth2User = userService.loadByPhone(decryptedPhone, request);
|
||||
if (oAuth2User == null) {
|
||||
return R.fail(USER_PHONE_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
// 使用指定短信资源(默认 ali_reg)
|
||||
String smsResourceCode = Func.toStr(code, BladeAuthConstant.LOGIN_SMS_CODE);
|
||||
|
||||
@@ -27,6 +27,8 @@ package org.springblade.auth.granter;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springblade.auth.constant.BladeAuthConstant;
|
||||
import org.springblade.auth.support.MpLoginRequestSupport;
|
||||
import org.springblade.auth.support.MpLoginSupport;
|
||||
import org.springblade.core.oauth2.constant.OAuth2TokenConstant;
|
||||
import org.springblade.core.oauth2.exception.UserInvalidException;
|
||||
import org.springblade.core.oauth2.granter.AbstractTokenGranter;
|
||||
@@ -47,6 +49,9 @@ import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* SmsTokenGranter
|
||||
* <p>
|
||||
* 按请求来源分流:小程序(User-Type: mp)登录时账号不存在要自动建,Web 短信登录不建号。
|
||||
* {@code BladeUserDetailService.loadByPhone} 同时被「发送短信验证码」复用,其中不得建号。
|
||||
*
|
||||
* @author BladeX
|
||||
*/
|
||||
@@ -56,12 +61,14 @@ public class SmsTokenGranter extends AbstractTokenGranter {
|
||||
private final OAuth2UserService userService;
|
||||
private final ISmsClient smsClient;
|
||||
private final OAuth2Properties properties;
|
||||
private final MpLoginSupport mpLoginSupport;
|
||||
|
||||
public SmsTokenGranter(OAuth2ClientService clientService, OAuth2UserService userService, PasswordHandler passwordHandler, ISmsClient smsClient, OAuth2Properties properties) {
|
||||
public SmsTokenGranter(OAuth2ClientService clientService, OAuth2UserService userService, PasswordHandler passwordHandler, ISmsClient smsClient, OAuth2Properties properties, MpLoginSupport mpLoginSupport) {
|
||||
super(clientService, userService, passwordHandler);
|
||||
this.userService = userService;
|
||||
this.smsClient = smsClient;
|
||||
this.properties = properties;
|
||||
this.mpLoginSupport = mpLoginSupport;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -86,8 +93,10 @@ public class SmsTokenGranter extends AbstractTokenGranter {
|
||||
if (!result.isSuccess()) {
|
||||
throw new UserInvalidException(OAuth2TokenConstant.CAPTCHA_NOT_CORRECT);
|
||||
}
|
||||
// 获取用户信息
|
||||
OAuth2User user = userService.loadByPhone(decryptedPhone, request);
|
||||
// 获取用户信息:小程序登录走自动建号编排,Web 短信登录只做查询
|
||||
OAuth2User user = MpLoginRequestSupport.isMp(request)
|
||||
? mpLoginSupport.ensureLoginAccount(tenantId, decryptedPhone, null)
|
||||
: userService.loadByPhone(decryptedPhone, request);
|
||||
// 校验用户信息
|
||||
if (!userService.validateUser(user)) {
|
||||
throw new UserInvalidException(OAuth2TokenConstant.TOKEN_NOT_CORRECT);
|
||||
|
||||
@@ -2,7 +2,7 @@ package org.springblade.auth.granter;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.auth.utils.TokenUtil;
|
||||
import org.springblade.auth.support.MpLoginSupport;
|
||||
import org.springblade.core.oauth2.exception.UserInvalidException;
|
||||
import org.springblade.core.oauth2.granter.AbstractTokenGranter;
|
||||
import org.springblade.core.oauth2.handler.PasswordHandler;
|
||||
@@ -11,12 +11,9 @@ import org.springblade.core.oauth2.service.OAuth2Client;
|
||||
import org.springblade.core.oauth2.service.OAuth2ClientService;
|
||||
import org.springblade.core.oauth2.service.OAuth2User;
|
||||
import org.springblade.core.oauth2.service.OAuth2UserService;
|
||||
import org.springblade.core.tool.api.R;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.core.tool.utils.StringUtil;
|
||||
import org.springblade.core.tool.utils.WebUtil;
|
||||
import org.springblade.system.feign.IMpUserClient;
|
||||
import org.springblade.system.pojo.entity.MpUserInfo;
|
||||
import org.springblade.thirdparty.wechat.constant.WechatMiniConstant;
|
||||
import org.springblade.thirdparty.wechat.exception.WechatMiniException;
|
||||
import org.springblade.thirdparty.wechat.pojo.vo.WechatPhoneVO;
|
||||
@@ -28,8 +25,8 @@ import org.springframework.stereotype.Component;
|
||||
* 微信小程序手机号一键登录。
|
||||
* <p>
|
||||
* grant_type=wechat_applet,参数:loginCode(wx.login)、phoneCode(getPhoneNumber)。
|
||||
* 流程:换 openid + 手机号 → 按手机号查小程序用户(blade_mp_user,不存在则拒绝)
|
||||
* → 记录 openid → 按身份发令牌(不依赖系统角色)。
|
||||
* 流程:换 openid + 手机号 → 由 {@link MpLoginSupport} 找到或创建账号并补齐档案
|
||||
* → 按身份发令牌(不依赖系统角色)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@@ -37,17 +34,17 @@ public class WechatMiniTokenGranter extends AbstractTokenGranter {
|
||||
|
||||
private final OAuth2UserService userService;
|
||||
private final IWechatMiniService wechatMiniService;
|
||||
private final IMpUserClient mpUserClient;
|
||||
private final MpLoginSupport mpLoginSupport;
|
||||
|
||||
public WechatMiniTokenGranter(OAuth2ClientService clientService,
|
||||
OAuth2UserService userService,
|
||||
PasswordHandler passwordHandler,
|
||||
IWechatMiniService wechatMiniService,
|
||||
IMpUserClient mpUserClient) {
|
||||
MpLoginSupport mpLoginSupport) {
|
||||
super(clientService, userService, passwordHandler);
|
||||
this.userService = userService;
|
||||
this.wechatMiniService = wechatMiniService;
|
||||
this.mpUserClient = mpUserClient;
|
||||
this.mpLoginSupport = mpLoginSupport;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -82,28 +79,12 @@ public class WechatMiniTokenGranter extends AbstractTokenGranter {
|
||||
throw new UserInvalidException("未获取到微信手机号");
|
||||
}
|
||||
|
||||
R<MpUserInfo> mpUserResult = mpUserClient.userInfoByPhone(request.getTenantId(), phone);
|
||||
if (mpUserResult == null || !mpUserResult.isSuccess() || mpUserResult.getData() == null
|
||||
|| mpUserResult.getData().getUser() == null) {
|
||||
throw new UserInvalidException("用户不存在,无法登录");
|
||||
}
|
||||
|
||||
OAuth2User user = TokenUtil.convertMpUser(mpUserResult.getData());
|
||||
// 账号不存在时自动建号;建档与绑 openid 失败不阻断登录
|
||||
OAuth2User user = mpLoginSupport.ensureLoginAccount(request.getTenantId(), phone, session.getOpenid());
|
||||
if (!userService.validateUser(user)) {
|
||||
throw new UserInvalidException("用户身份无效,无法登录");
|
||||
}
|
||||
|
||||
R<Boolean> bindResult = mpUserClient.bindOpenId(
|
||||
request.getTenantId(),
|
||||
Func.toLong(user.getUserId()),
|
||||
session.getOpenid()
|
||||
);
|
||||
if (bindResult == null || !bindResult.isSuccess()) {
|
||||
String msg = bindResult != null ? bindResult.getMsg() : "绑定 openid 失败";
|
||||
log.warn("绑定微信 openid 失败 userId={} openid={} msg={}", user.getUserId(), session.getOpenid(), msg);
|
||||
throw new UserInvalidException(StringUtil.isBlank(msg) ? "绑定 openid 失败" : msg);
|
||||
}
|
||||
|
||||
user.setClient(oauthClient);
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
package org.springblade.auth.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springblade.auth.support.MpLoginRequestSupport;
|
||||
import org.springblade.auth.utils.TokenUtil;
|
||||
import org.springblade.core.oauth2.exception.OAuth2ErrorCode;
|
||||
import org.springblade.core.oauth2.provider.OAuth2Request;
|
||||
@@ -36,7 +37,6 @@ import org.springblade.system.feign.IMpUserClient;
|
||||
import org.springblade.system.feign.IUserClient;
|
||||
import org.springblade.system.pojo.entity.MpUserInfo;
|
||||
import org.springblade.system.pojo.entity.UserInfo;
|
||||
import org.springblade.system.pojo.enums.MpUserIdentity;
|
||||
import org.springblade.system.pojo.enums.UserType;
|
||||
|
||||
import java.util.Optional;
|
||||
@@ -54,7 +54,7 @@ public class BladeUserDetailService implements OAuth2UserService {
|
||||
@Override
|
||||
public OAuth2User loadByUserId(String userId, OAuth2Request request) {
|
||||
// 小程序刷新/鉴权:User-Type=mp 时只查 blade_mp_user
|
||||
if (isMpLoginRequest(request)) {
|
||||
if (MpLoginRequestSupport.isMp(request)) {
|
||||
OAuth2User mpUser = loadMpUserById(userId);
|
||||
if (mpUser == null) {
|
||||
OAuth2ExceptionUtil.throwFromCode(OAuth2ErrorCode.INVALID_USER);
|
||||
@@ -83,7 +83,7 @@ public class BladeUserDetailService implements OAuth2UserService {
|
||||
String tenantId = request.getTenantId();
|
||||
|
||||
// 小程序账号密码 / 图形验证码登录:仅查 blade_mp_user
|
||||
if (isMpLoginRequest(request)) {
|
||||
if (MpLoginRequestSupport.isMp(request)) {
|
||||
R<MpUserInfo> mpResult = mpUserClient.userInfoByAccount(tenantId, username);
|
||||
if (mpResult == null || !mpResult.isSuccess() || mpResult.getData() == null) {
|
||||
return null;
|
||||
@@ -104,7 +104,7 @@ public class BladeUserDetailService implements OAuth2UserService {
|
||||
String tenantId = request.getTenantId();
|
||||
|
||||
// 小程序短信登录:仅查 blade_mp_user
|
||||
if (isMpLoginRequest(request)) {
|
||||
if (MpLoginRequestSupport.isMp(request)) {
|
||||
return loadMpUserByPhone(tenantId, phone);
|
||||
}
|
||||
|
||||
@@ -125,17 +125,6 @@ public class BladeUserDetailService implements OAuth2UserService {
|
||||
.isPresent();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否小程序登录:请求头 User-Type=mp(Web 与小程序共用 saber3 客户端,不能按 clientId 分流)
|
||||
*/
|
||||
private boolean isMpLoginRequest(OAuth2Request request) {
|
||||
return request != null && isMpUserType(request.getUserType());
|
||||
}
|
||||
|
||||
private boolean isMpUserType(String userType) {
|
||||
return MpUserIdentity.DETAIL_TYPE.equalsIgnoreCase(Func.toStr(userType));
|
||||
}
|
||||
|
||||
private String resolveUserType(OAuth2Request request) {
|
||||
return Optional.ofNullable(request.getUserType())
|
||||
.filter(s -> !StringUtil.isBlank(s))
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.auth.support;
|
||||
|
||||
import org.springblade.core.oauth2.provider.OAuth2Request;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.system.pojo.enums.MpUserIdentity;
|
||||
|
||||
/**
|
||||
* 登录请求来源判定
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public final class MpLoginRequestSupport {
|
||||
|
||||
private MpLoginRequestSupport() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否小程序请求:请求头 User-Type 为 mp
|
||||
* <p>
|
||||
* Web 与小程序共用同一个客户端,来源只认请求头 User-Type。
|
||||
*
|
||||
* @param request 登录请求,可为 null
|
||||
* @return 请求头 User-Type 为 mp 时返回 true
|
||||
*/
|
||||
public static boolean isMp(OAuth2Request request) {
|
||||
return request != null && MpUserIdentity.DETAIL_TYPE.equalsIgnoreCase(Func.toStr(request.getUserType()));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.auth.support;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.auth.utils.TokenUtil;
|
||||
import org.springblade.core.oauth2.exception.UserInvalidException;
|
||||
import org.springblade.core.oauth2.service.OAuth2User;
|
||||
import org.springblade.core.tool.api.R;
|
||||
import org.springblade.core.tool.support.Kv;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.core.tool.utils.StringUtil;
|
||||
import org.springblade.system.feign.IMpUserClient;
|
||||
import org.springblade.system.pojo.dto.MpUserEnsureDTO;
|
||||
import org.springblade.system.pojo.entity.MpUser;
|
||||
import org.springblade.system.pojo.entity.MpUserInfo;
|
||||
import org.springblade.system.pojo.enums.MpUserIdentity;
|
||||
import org.springblade.transport.feign.IDriverClient;
|
||||
import org.springblade.transport.pojo.dto.DriverEnsureProfileDTO;
|
||||
import org.springblade.transport.pojo.vo.DriverEnsureProfileVO;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 小程序登录编排:按 openid / 手机号找到或创建账号,并补齐司机档案与微信绑定。
|
||||
* <p>
|
||||
* 只有「建号失败」会让登录失败;建档与绑 openid 失败只记 WARN,不影响本次登录。
|
||||
* 本类只供登录链路调用。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class MpLoginSupport {
|
||||
|
||||
/**
|
||||
* detail 中承载登录提示的键,前端从令牌的 detail 里读取
|
||||
*/
|
||||
public static final String DETAIL_LOGIN_NOTICE = "loginNotice";
|
||||
|
||||
/**
|
||||
* 换号撞键提示:openid 属于旧账号、新手机号已属于另一账号
|
||||
*/
|
||||
private static final String NOTICE_PHONE_OCCUPIED = "该微信已绑定其他账号,请联系管理员";
|
||||
|
||||
private final IMpUserClient mpUserClient;
|
||||
private final IDriverClient driverClient;
|
||||
|
||||
/**
|
||||
* 找到或创建本次登录使用的账号,并补齐档案与 openid 绑定。
|
||||
* <p>
|
||||
* 返回 {@link OAuth2User}:两条登录链路都消费 OAuth2User,而 MpUserInfo
|
||||
* (identities / permissions / detail)只在 system 侧组装。
|
||||
*
|
||||
* @param tenantId 请求租户,为空时由 system 侧兜到默认租户
|
||||
* @param phone 本次登录的手机号
|
||||
* @param openid 微信小程序 openid,短信登录传 null
|
||||
* @return 通过用户校验之前的登录用户(身份校验与 setClient 由调用方完成)
|
||||
*/
|
||||
public OAuth2User ensureLoginAccount(String tenantId, String phone, String openid) {
|
||||
if (StringUtil.isBlank(phone)) {
|
||||
throw new UserInvalidException("未获取到手机号,无法登录");
|
||||
}
|
||||
MpUser account;
|
||||
String loginNotice = null;
|
||||
// openid 已属于另一个账号:本次不迁移、也不再尝试绑定(与建档冲突提示区分开)
|
||||
boolean openidOccupied = false;
|
||||
// ① openid 命中即复用:openid 是稳定标识,手机号会被运营商二次放号
|
||||
MpUser openidAccount = findByOpenid(tenantId, openid);
|
||||
if (openidAccount == null) {
|
||||
// ② 未按 openid 命中:按手机号查找或创建(登录路径专用,已存在不抛异常)
|
||||
account = ensureUser(tenantId, phone, openid);
|
||||
} else {
|
||||
account = openidAccount;
|
||||
// 账号 phone 经 feign 下发时是脱敏值(MpUser.phone 标了 @Sensitive),不能拿来比较。
|
||||
// 改按「该手机号是否已有账号」判断:命中自己是同一个人、命中别人是撞键、没命中就换号。
|
||||
MpUser phoneAccount = findByPhone(tenantId, phone);
|
||||
if (phoneAccount != null && !Objects.equals(phoneAccount.getId(), openidAccount.getId())) {
|
||||
// ①-撞键:落到手机号账号,不迁移 openid
|
||||
account = phoneAccount;
|
||||
loginNotice = NOTICE_PHONE_OCCUPIED;
|
||||
openidOccupied = true;
|
||||
log.warn("微信换号撞键,登录落到手机号账号:租户={} openid账号={} 手机号账号={}",
|
||||
tenantId, openidAccount.getId(), phoneAccount.getId());
|
||||
} else if (phoneAccount == null) {
|
||||
account = movePhone(openidAccount, phone);
|
||||
if (account == null) {
|
||||
// 更新手机号失败:并发时可能是手机号刚被别的账号占用,重查一次再决定落点
|
||||
MpUser concurrent = findByPhone(tenantId, phone);
|
||||
if (concurrent != null && !Objects.equals(concurrent.getId(), openidAccount.getId())) {
|
||||
account = concurrent;
|
||||
loginNotice = NOTICE_PHONE_OCCUPIED;
|
||||
openidOccupied = true;
|
||||
log.warn("更新手机号失败后命中手机号账号:租户={} openid账号={} 手机号账号={}",
|
||||
tenantId, openidAccount.getId(), concurrent.getId());
|
||||
} else {
|
||||
// 仍然用 openid 账号登录:手机号未能更新不影响身份识别,下次登录再试
|
||||
account = openidAccount;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// ③ 身份含 driver 才建档;纯 admin 不建
|
||||
String profileNotice = ensureProfile(account, phone, tenantId);
|
||||
if (StringUtil.isBlank(loginNotice)) {
|
||||
loginNotice = profileNotice;
|
||||
}
|
||||
// ④ 微信登录补绑 openid;失败只 WARN
|
||||
bindOpenid(account, openid, openidOccupied);
|
||||
MpUserInfo userInfo = loadUserInfo(account);
|
||||
attachNotice(userInfo, loginNotice);
|
||||
return TokenUtil.convertMpUser(userInfo);
|
||||
}
|
||||
|
||||
private MpUser findByOpenid(String tenantId, String openid) {
|
||||
if (StringUtil.isBlank(openid)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
R<MpUser> result = mpUserClient.userByOpenid(tenantId, openid);
|
||||
return isSuccess(result) ? result.getData() : null;
|
||||
} catch (Exception exception) {
|
||||
// 查询失败按未命中处理,随后由 ensureUser 触达系统服务并兜住建号失败
|
||||
log.warn("按 openid 查询小程序账号失败:租户={} openid={}", tenantId, openid, exception);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private MpUser findByPhone(String tenantId, String phone) {
|
||||
try {
|
||||
R<MpUser> result = mpUserClient.userByPhone(tenantId, phone);
|
||||
return isSuccess(result) ? result.getData() : null;
|
||||
} catch (Exception exception) {
|
||||
log.warn("按手机号查询小程序账号失败:租户={} 手机号={}", tenantId, phone, exception);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ② 查找或创建账号:登录路径上唯一允许让登录失败的动作
|
||||
*/
|
||||
private MpUser ensureUser(String tenantId, String phone, String openid) {
|
||||
MpUserEnsureDTO param = new MpUserEnsureDTO();
|
||||
param.setTenantId(tenantId);
|
||||
param.setPhone(phone);
|
||||
param.setOpenid(openid);
|
||||
try {
|
||||
R<MpUser> result = mpUserClient.ensureUser(param);
|
||||
if (isSuccess(result) && result.getData() != null && Func.isNotEmpty(result.getData().getId())) {
|
||||
return result.getData();
|
||||
}
|
||||
// R.data(null) 也是 success,此时 msg 是「暂无数据」之类的占位文案,不能当业务错误下发
|
||||
String message = result != null && !result.isSuccess() ? result.getMsg() : null;
|
||||
log.error("小程序账号查找或创建失败:租户={} 手机号={} 返回={}", tenantId, phone, message);
|
||||
throw new UserInvalidException(StringUtil.isBlank(message) ? "账号创建失败,请稍后重试" : message);
|
||||
} catch (UserInvalidException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
log.error("小程序账号查找或创建失败:租户={} 手机号={}", tenantId, phone, exception);
|
||||
throw new UserInvalidException("账号创建失败,请稍后重试");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 openid 账号的手机号换成新号
|
||||
* <p>
|
||||
* 只 patch phone:account 是登录名,不随手机号变;status 必须带原值,
|
||||
* system 侧 updateUser 的 prepare 会把空 status 重置成启用。
|
||||
*
|
||||
* @return 更新成功后的账号;失败返回 null,不抛异常
|
||||
*/
|
||||
private MpUser movePhone(MpUser account, String phone) {
|
||||
MpUser patch = new MpUser();
|
||||
patch.setId(account.getId());
|
||||
patch.setTenantId(account.getTenantId());
|
||||
patch.setPhone(phone);
|
||||
patch.setAccount(account.getAccount());
|
||||
patch.setIdentities(account.getIdentities());
|
||||
patch.setStatus(account.getStatus());
|
||||
try {
|
||||
R<Boolean> result = mpUserClient.updateUser(patch);
|
||||
if (isSuccess(result) && Boolean.TRUE.equals(result.getData())) {
|
||||
account.setPhone(phone);
|
||||
return account;
|
||||
}
|
||||
log.warn("更新小程序账号手机号失败:租户={} 账号={} 新手机号={} 返回={}",
|
||||
account.getTenantId(), account.getId(), phone, result == null ? null : result.getMsg());
|
||||
} catch (Exception exception) {
|
||||
log.warn("更新小程序账号手机号失败:租户={} 账号={} 新手机号={}",
|
||||
account.getTenantId(), account.getId(), phone, exception);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* ③ 为司机身份账号补齐档案;失败或冲突只记 WARN,不阻断登录
|
||||
*
|
||||
* @return 冲突时的可读原因,供 detail 提示使用;成功或无需建档返回 null
|
||||
*/
|
||||
private String ensureProfile(MpUser account, String requestedPhone, String requestedTenantId) {
|
||||
if (account == null || !MpUserIdentity.has(account.getIdentities(), MpUserIdentity.DRIVER)) {
|
||||
return null;
|
||||
}
|
||||
// 租户取账号上的值(档案必须与账号同租户,唯一键对 NULL 不去重);
|
||||
// 手机号只能取登录入参:账号 phone 经 feign 下发时是脱敏值,写进档案会让按手机号搜不到司机
|
||||
String tenantId = Func.toStr(account.getTenantId(), requestedTenantId);
|
||||
String phone = trimToNull(requestedPhone);
|
||||
if (phone == null) {
|
||||
log.warn("登录自动建档跳过:登录手机号为空,租户={} 账号={}", tenantId, account.getId());
|
||||
return null;
|
||||
}
|
||||
DriverEnsureProfileDTO param = new DriverEnsureProfileDTO();
|
||||
param.setTenantId(tenantId);
|
||||
param.setMpUserId(account.getId());
|
||||
param.setPhone(phone);
|
||||
try {
|
||||
R<DriverEnsureProfileVO> result = driverClient.ensureProfile(param);
|
||||
if (!isSuccess(result) || result.getData() == null) {
|
||||
log.warn("登录自动建档未完成(不阻断登录):租户={} 账号={} 手机号={} 返回={}",
|
||||
tenantId, account.getId(), phone, result == null ? null : result.getMsg());
|
||||
return null;
|
||||
}
|
||||
DriverEnsureProfileVO profile = result.getData();
|
||||
if (StringUtil.isNotBlank(profile.getMessage())) {
|
||||
log.warn("登录自动建档冲突(不阻断登录):租户={} 账号={} 手机号={} 原因={}",
|
||||
tenantId, account.getId(), phone, profile.getMessage());
|
||||
return profile.getMessage();
|
||||
}
|
||||
return null;
|
||||
} catch (Exception exception) {
|
||||
log.warn("登录自动建档失败(不阻断登录):租户={} 账号={} 手机号={}",
|
||||
tenantId, account.getId(), phone, exception);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ④ 微信登录补绑 openid;失败只 WARN,不阻断登录
|
||||
*/
|
||||
private void bindOpenid(MpUser account, String openid, boolean openidOccupied) {
|
||||
if (account == null || StringUtil.isBlank(openid)) {
|
||||
return;
|
||||
}
|
||||
// 撞键场景 openid 已属于另一个账号,明确不迁移,也不再尝试绑定
|
||||
if (openidOccupied) {
|
||||
return;
|
||||
}
|
||||
if (StringUtil.isNotBlank(account.getOpenid())) {
|
||||
if (!openid.equals(account.getOpenid())) {
|
||||
log.warn("小程序账号已有其他 openid,本次不覆盖:租户={} 账号={} 已有={} 本次={}",
|
||||
account.getTenantId(), account.getId(), account.getOpenid(), openid);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
R<Boolean> result = mpUserClient.bindOpenId(account.getTenantId(), account.getId(), openid);
|
||||
if (!isSuccess(result) || !Boolean.TRUE.equals(result.getData())) {
|
||||
log.warn("绑定微信 openid 失败(不阻断登录):租户={} 账号={} openid={} 返回={}",
|
||||
account.getTenantId(), account.getId(), openid, result == null ? null : result.getMsg());
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
log.warn("绑定微信 openid 失败(不阻断登录):租户={} 账号={} openid={}",
|
||||
account.getTenantId(), account.getId(), openid, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MpUserInfo 由 system 侧 buildUserInfo 统一组装,auth 不复制一份 detail 口径
|
||||
*/
|
||||
private MpUserInfo loadUserInfo(MpUser account) {
|
||||
try {
|
||||
R<MpUserInfo> result = mpUserClient.userInfoById(account.getId());
|
||||
if (isSuccess(result) && result.getData() != null && result.getData().getUser() != null) {
|
||||
return result.getData();
|
||||
}
|
||||
String message = result != null && !result.isSuccess() ? result.getMsg() : null;
|
||||
log.error("小程序账号登录信息查询失败:租户={} 账号={} 返回={}", account.getTenantId(), account.getId(), message);
|
||||
throw new UserInvalidException(StringUtil.isBlank(message) ? "账号信息查询失败,请稍后重试" : message);
|
||||
} catch (UserInvalidException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
log.error("小程序账号登录信息查询失败:租户={} 账号={}", account.getTenantId(), account.getId(), exception);
|
||||
throw new UserInvalidException("账号信息查询失败,请稍后重试");
|
||||
}
|
||||
}
|
||||
|
||||
private void attachNotice(MpUserInfo userInfo, String loginNotice) {
|
||||
if (userInfo == null || StringUtil.isBlank(loginNotice)) {
|
||||
return;
|
||||
}
|
||||
// detail 是否下发到前端取决于令牌序列化,冲突点同时另记 WARN
|
||||
Kv detail = userInfo.getDetail() == null ? Kv.create() : userInfo.getDetail();
|
||||
detail.set(DETAIL_LOGIN_NOTICE, loginNotice);
|
||||
userInfo.setDetail(detail);
|
||||
}
|
||||
|
||||
private String trimToNull(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String trimValue = value.trim();
|
||||
return trimValue.isEmpty() ? null : trimValue;
|
||||
}
|
||||
|
||||
private boolean isSuccess(R<?> result) {
|
||||
return result != null && result.isSuccess();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package org.springblade.auth.endpoint;
|
||||
|
||||
import org.bouncycastle.crypto.AsymmetricCipherKeyPair;
|
||||
import org.bouncycastle.util.encoders.Hex;
|
||||
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.springblade.core.oauth2.props.OAuth2Properties;
|
||||
import org.springblade.core.oauth2.service.OAuth2User;
|
||||
import org.springblade.core.oauth2.service.OAuth2UserService;
|
||||
import org.springblade.core.tool.api.R;
|
||||
import org.springblade.core.tool.utils.SM2Util;
|
||||
import org.springblade.resource.feign.ISmsClient;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springblade.core.oauth2.constant.OAuth2TokenConstant.USER_PHONE_NOT_FOUND;
|
||||
|
||||
/**
|
||||
* 发码端点的端分流测试。
|
||||
* <p>
|
||||
* 保住发码端点的分流口径:小程序端(User-Type: mp)对未注册手机号也发码,
|
||||
* Web 端仍拒绝。
|
||||
* 同时保住「发码不建号」:mp 分支根本不查账号。
|
||||
*/
|
||||
class Oauth2SmsEndpointTest {
|
||||
|
||||
private static final String TENANT_ID = "000000";
|
||||
private static final String PHONE = "13800000001";
|
||||
private static final String SMS_CODE = "ali_reg";
|
||||
|
||||
private static String publicKey;
|
||||
private static String privateKey;
|
||||
|
||||
private ISmsClient smsClient;
|
||||
private OAuth2UserService userService;
|
||||
private Oauth2SmsEndpoint endpoint;
|
||||
|
||||
@BeforeAll
|
||||
static void initSm2Keys() {
|
||||
AsymmetricCipherKeyPair keyPair = SM2Util.generateKeyPair();
|
||||
publicKey = SM2Util.getPublicKeyString(keyPair);
|
||||
privateKey = SM2Util.getPrivateKeyString(keyPair);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
OAuth2Properties properties = new OAuth2Properties();
|
||||
properties.setPublicKey(publicKey);
|
||||
properties.setPrivateKey(privateKey);
|
||||
smsClient = mock(ISmsClient.class);
|
||||
userService = mock(OAuth2UserService.class);
|
||||
endpoint = new Oauth2SmsEndpoint(smsClient, userService, properties);
|
||||
when(smsClient.sendValidate(anyString(), anyString(), anyString())).thenReturn(R.data("sms-id-1"));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clearRequestContext() {
|
||||
RequestContextHolder.resetRequestAttributes();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("小程序 + 未注册手机号:发码成功,且完全不查账号(发码不建号)")
|
||||
void mpUnregisteredPhoneSendsCode() {
|
||||
bindRequest("mp");
|
||||
when(userService.loadByPhone(anyString(), any())).thenReturn(null);
|
||||
|
||||
R result = endpoint.sendValidate(TENANT_ID, encrypt(PHONE), SMS_CODE);
|
||||
|
||||
assertTrue(result.isSuccess());
|
||||
verify(smsClient).sendValidate(TENANT_ID, SMS_CODE, PHONE);
|
||||
verify(userService, never()).loadByPhone(anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Web + 未注册手机号:仍返回手机号不存在,不发码")
|
||||
void webUnregisteredPhoneIsRejected() {
|
||||
bindRequest(null);
|
||||
when(userService.loadByPhone(anyString(), any())).thenReturn(null);
|
||||
|
||||
R result = endpoint.sendValidate(TENANT_ID, encrypt(PHONE), SMS_CODE);
|
||||
|
||||
assertFalse(result.isSuccess());
|
||||
assertEquals(USER_PHONE_NOT_FOUND, result.getMsg());
|
||||
verify(smsClient, never()).sendValidate(anyString(), anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Web + 已注册手机号:行为与改造前一致,正常发码")
|
||||
void webRegisteredPhoneSendsCode() {
|
||||
bindRequest(null);
|
||||
when(userService.loadByPhone(anyString(), any())).thenReturn(mock(OAuth2User.class));
|
||||
|
||||
R result = endpoint.sendValidate(TENANT_ID, encrypt(PHONE), SMS_CODE);
|
||||
|
||||
assertTrue(result.isSuccess());
|
||||
verify(userService).loadByPhone(anyString(), any());
|
||||
verify(smsClient).sendValidate(TENANT_ID, SMS_CODE, PHONE);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("小程序 + 已注册手机号:跳过注册查询,依旧发码")
|
||||
void mpRegisteredPhoneSendsCode() {
|
||||
bindRequest("mp");
|
||||
|
||||
R result = endpoint.sendValidate(TENANT_ID, encrypt(PHONE), SMS_CODE);
|
||||
|
||||
assertTrue(result.isSuccess());
|
||||
verify(userService, never()).loadByPhone(anyString(), any());
|
||||
verify(smsClient).sendValidate(TENANT_ID, SMS_CODE, PHONE);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("密文无法解密:无论哪端都不发码")
|
||||
void invalidCipherIsRejected() {
|
||||
bindRequest("mp");
|
||||
|
||||
R result = endpoint.sendValidate(TENANT_ID, "deadbeef", SMS_CODE);
|
||||
|
||||
assertFalse(result.isSuccess());
|
||||
assertEquals(USER_PHONE_NOT_FOUND, result.getMsg());
|
||||
verify(smsClient, never()).sendValidate(anyString(), anyString(), anyString());
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求上下文决定 buildArgs() 读到什么头;Authorization 是 buildClientArgs 的前提
|
||||
*/
|
||||
private void bindRequest(String userType) {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
String credential = Base64.getEncoder()
|
||||
.encodeToString("saber:saber_secret".getBytes(StandardCharsets.UTF_8));
|
||||
request.addHeader("Authorization", "Basic " + credential);
|
||||
if (userType != null) {
|
||||
request.addHeader("User-Type", userType);
|
||||
}
|
||||
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
|
||||
}
|
||||
|
||||
private String encrypt(String plainPhone) {
|
||||
return Hex.toHexString(SM2Util.encrypt(plainPhone, publicKey));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package org.springblade.auth.support;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springblade.core.oauth2.exception.UserInvalidException;
|
||||
import org.springblade.core.oauth2.service.OAuth2User;
|
||||
import org.springblade.core.tool.api.R;
|
||||
import org.springblade.core.tool.support.Kv;
|
||||
import org.springblade.system.feign.IMpUserClient;
|
||||
import org.springblade.system.pojo.dto.MpUserEnsureDTO;
|
||||
import org.springblade.system.pojo.entity.MpUser;
|
||||
import org.springblade.system.pojo.entity.MpUserInfo;
|
||||
import org.springblade.transport.feign.IDriverClient;
|
||||
import org.springblade.transport.pojo.dto.DriverEnsureProfileDTO;
|
||||
import org.springblade.transport.pojo.vo.DriverEnsureProfileVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
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.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 登录编排测试。
|
||||
* <p>
|
||||
* 保住三条不变式:建号失败才阻断登录;建档失败放行;
|
||||
* openid 换号撞键时落到手机号账号且不迁移 openid。
|
||||
*/
|
||||
class MpLoginSupportTest {
|
||||
|
||||
private static final String TENANT_ID = "000000";
|
||||
private static final String PHONE = "13800000001";
|
||||
private static final String NEW_PHONE = "13800000002";
|
||||
private static final String OPENID = "wx-openid-1";
|
||||
|
||||
private IMpUserClient mpUserClient;
|
||||
private IDriverClient driverClient;
|
||||
private MpLoginSupport mpLoginSupport;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mpUserClient = mock(IMpUserClient.class);
|
||||
driverClient = mock(IDriverClient.class);
|
||||
mpLoginSupport = new MpLoginSupport(mpUserClient, driverClient);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("新手机号微信登录:建号 + 建档,登录成功且无提示")
|
||||
void newPhoneCreatesAccountAndProfile() {
|
||||
// feign 下发时 phone 是脱敏值(MpUser.phone 标了 @Sensitive),建档必须仍用登录入参的真实号
|
||||
MpUser created = mpUser(11L, "138****0001", OPENID, "driver");
|
||||
when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(null));
|
||||
when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(created));
|
||||
when(mpUserClient.userInfoById(11L)).thenReturn(R.data(userInfo(created, "driver")));
|
||||
when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult()));
|
||||
|
||||
OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID);
|
||||
|
||||
assertNotNull(user);
|
||||
assertEquals("11", user.getUserId());
|
||||
assertNull(user.getDetail().get(MpLoginSupport.DETAIL_LOGIN_NOTICE));
|
||||
ArgumentCaptor<MpUserEnsureDTO> ensureCaptor = ArgumentCaptor.forClass(MpUserEnsureDTO.class);
|
||||
verify(mpUserClient).ensureUser(ensureCaptor.capture());
|
||||
assertEquals(TENANT_ID, ensureCaptor.getValue().getTenantId());
|
||||
assertEquals(PHONE, ensureCaptor.getValue().getPhone());
|
||||
assertEquals(OPENID, ensureCaptor.getValue().getOpenid());
|
||||
ArgumentCaptor<DriverEnsureProfileDTO> profileCaptor = ArgumentCaptor.forClass(DriverEnsureProfileDTO.class);
|
||||
verify(driverClient).ensureProfile(profileCaptor.capture());
|
||||
assertEquals(TENANT_ID, profileCaptor.getValue().getTenantId());
|
||||
assertEquals(11L, profileCaptor.getValue().getMpUserId());
|
||||
assertEquals(PHONE, profileCaptor.getValue().getPhone());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("建档抛异常:登录仍然成功")
|
||||
void profileFailureDoesNotBlockLogin() {
|
||||
MpUser created = mpUser(12L, PHONE, OPENID, "driver");
|
||||
when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(null));
|
||||
when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(created));
|
||||
when(mpUserClient.userInfoById(12L)).thenReturn(R.data(userInfo(created, "driver")));
|
||||
when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class)))
|
||||
.thenThrow(new RuntimeException("transport 不可用"));
|
||||
|
||||
OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID);
|
||||
|
||||
assertNotNull(user);
|
||||
assertEquals("12", user.getUserId());
|
||||
verify(driverClient).ensureProfile(any(DriverEnsureProfileDTO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("openid 绑定失败:登录仍然成功,不抛异常")
|
||||
void bindOpenidFailureDoesNotBlockLogin() {
|
||||
MpUser existing = mpUser(13L, PHONE, null, "driver");
|
||||
when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(null));
|
||||
when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(existing));
|
||||
when(mpUserClient.userInfoById(13L)).thenReturn(R.data(userInfo(existing, "driver")));
|
||||
when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult()));
|
||||
when(mpUserClient.bindOpenId(TENANT_ID, 13L, OPENID)).thenReturn(R.fail("该微信已绑定其他小程序用户"));
|
||||
|
||||
OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID);
|
||||
|
||||
assertNotNull(user);
|
||||
assertEquals("13", user.getUserId());
|
||||
verify(mpUserClient).bindOpenId(TENANT_ID, 13L, OPENID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("打开号码来源:微信登录时给空 openid 账号补绑 openid")
|
||||
void bindsOpenidForPhoneAccount() {
|
||||
MpUser existing = mpUser(14L, PHONE, null, "driver");
|
||||
when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(null));
|
||||
when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(existing));
|
||||
when(mpUserClient.userInfoById(14L)).thenReturn(R.data(userInfo(existing, "driver")));
|
||||
when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult()));
|
||||
when(mpUserClient.bindOpenId(TENANT_ID, 14L, OPENID)).thenReturn(R.data(Boolean.TRUE));
|
||||
|
||||
mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID);
|
||||
|
||||
verify(mpUserClient).bindOpenId(TENANT_ID, 14L, OPENID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("openid 命中旧账号但新手机号已被别的账号占用:落到手机号账号、不迁移 openid、有提示")
|
||||
void phoneOccupiedKeepsPhoneAccount() {
|
||||
MpUser openidAccount = mpUser(21L, "13900000000", OPENID, "driver");
|
||||
MpUser phoneAccount = mpUser(22L, NEW_PHONE, null, "driver");
|
||||
when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(openidAccount));
|
||||
when(mpUserClient.userByPhone(TENANT_ID, NEW_PHONE)).thenReturn(R.data(phoneAccount));
|
||||
when(mpUserClient.userInfoById(22L)).thenReturn(R.data(userInfo(phoneAccount, "driver")));
|
||||
when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult()));
|
||||
|
||||
OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, NEW_PHONE, OPENID);
|
||||
|
||||
assertNotNull(user);
|
||||
assertEquals("22", user.getUserId(), "应落在手机号账号上");
|
||||
verify(mpUserClient, never()).updateUser(any(MpUser.class));
|
||||
verify(mpUserClient, never()).bindOpenId(anyString(), anyLong(), anyString());
|
||||
assertNotNull(user.getDetail().get(MpLoginSupport.DETAIL_LOGIN_NOTICE), "应带上可读提示");
|
||||
assertTrue(String.valueOf(user.getDetail().get(MpLoginSupport.DETAIL_LOGIN_NOTICE)).contains("该微信已绑定其他账号"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("openid 命中且手机号不同但新号空闲:更新账号手机号")
|
||||
void openidHitWithFreePhoneUpdatesPhone() {
|
||||
MpUser openidAccount = mpUser(31L, "139****0000", OPENID, "driver");
|
||||
when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(openidAccount));
|
||||
when(mpUserClient.userByPhone(TENANT_ID, NEW_PHONE)).thenReturn(R.data(null));
|
||||
when(mpUserClient.updateUser(any(MpUser.class))).thenReturn(R.data(Boolean.TRUE));
|
||||
when(mpUserClient.userInfoById(31L)).thenReturn(R.data(userInfo(openidAccount, "driver")));
|
||||
when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult()));
|
||||
|
||||
OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, NEW_PHONE, OPENID);
|
||||
|
||||
assertEquals("31", user.getUserId());
|
||||
ArgumentCaptor<MpUser> updateCaptor = ArgumentCaptor.forClass(MpUser.class);
|
||||
verify(mpUserClient).updateUser(updateCaptor.capture());
|
||||
assertEquals(31L, updateCaptor.getValue().getId());
|
||||
assertEquals(NEW_PHONE, updateCaptor.getValue().getPhone());
|
||||
verify(mpUserClient, never()).ensureUser(any(MpUserEnsureDTO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("openid 命中且该手机号就是本账号:不重复更新手机号")
|
||||
void openidHitSameAccountDoesNotUpdatePhone() {
|
||||
MpUser openidAccount = mpUser(32L, "138****0001", OPENID, "driver");
|
||||
when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(openidAccount));
|
||||
when(mpUserClient.userByPhone(TENANT_ID, PHONE)).thenReturn(R.data(openidAccount));
|
||||
when(mpUserClient.userInfoById(32L)).thenReturn(R.data(userInfo(openidAccount, "driver")));
|
||||
when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult()));
|
||||
|
||||
OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID);
|
||||
|
||||
assertEquals("32", user.getUserId());
|
||||
verify(mpUserClient, never()).updateUser(any(MpUser.class));
|
||||
verify(mpUserClient, never()).ensureUser(any(MpUserEnsureDTO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("纯 admin 身份:不建司机档案")
|
||||
void adminOnlyDoesNotCreateProfile() {
|
||||
MpUser admin = mpUser(41L, PHONE, OPENID, "admin");
|
||||
when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(null));
|
||||
when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(admin));
|
||||
when(mpUserClient.userInfoById(41L)).thenReturn(R.data(userInfo(admin, "admin")));
|
||||
|
||||
OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID);
|
||||
|
||||
assertEquals("41", user.getUserId());
|
||||
verify(driverClient, never()).ensureProfile(any(DriverEnsureProfileDTO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("建号失败是唯一阻断登录的条件")
|
||||
void accountCreationFailureBlocksLogin() {
|
||||
when(mpUserClient.userByOpenid(TENANT_ID, OPENID)).thenReturn(R.data(null));
|
||||
when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.fail("创建小程序用户失败"));
|
||||
|
||||
UserInvalidException exception = assertThrows(UserInvalidException.class,
|
||||
() -> mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, OPENID));
|
||||
assertEquals("创建小程序用户失败", exception.getMessage());
|
||||
verify(driverClient, never()).ensureProfile(any(DriverEnsureProfileDTO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("短信登录(openid 为空)不做 openid 相关调用")
|
||||
void smsLoginSkipsOpenid() {
|
||||
MpUser created = mpUser(51L, PHONE, null, "driver");
|
||||
when(mpUserClient.ensureUser(any(MpUserEnsureDTO.class))).thenReturn(R.data(created));
|
||||
when(mpUserClient.userInfoById(51L)).thenReturn(R.data(userInfo(created, "driver")));
|
||||
when(driverClient.ensureProfile(any(DriverEnsureProfileDTO.class))).thenReturn(R.data(profileResult()));
|
||||
|
||||
OAuth2User user = mpLoginSupport.ensureLoginAccount(TENANT_ID, PHONE, null);
|
||||
|
||||
assertEquals("51", user.getUserId());
|
||||
verify(mpUserClient, never()).userByOpenid(anyString(), anyString());
|
||||
verify(mpUserClient, never()).bindOpenId(anyString(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
private MpUser mpUser(Long id, String phone, String openid, String identities) {
|
||||
MpUser user = new MpUser();
|
||||
user.setId(id);
|
||||
user.setTenantId(TENANT_ID);
|
||||
user.setPhone(phone);
|
||||
user.setAccount(phone);
|
||||
user.setOpenid(openid);
|
||||
user.setIdentities(identities);
|
||||
user.setStatus(1);
|
||||
return user;
|
||||
}
|
||||
|
||||
private MpUserInfo userInfo(MpUser user, String... identities) {
|
||||
MpUserInfo userInfo = new MpUserInfo();
|
||||
userInfo.setUser(user);
|
||||
userInfo.setIdentities(List.of(identities));
|
||||
userInfo.setDetail(Kv.create()
|
||||
.set("type", "mp")
|
||||
.set("identities", String.join(",", identities))
|
||||
.set("openid", user.getOpenid() == null ? "" : user.getOpenid()));
|
||||
return userInfo;
|
||||
}
|
||||
|
||||
private DriverEnsureProfileVO profileResult() {
|
||||
DriverEnsureProfileVO result = new DriverEnsureProfileVO();
|
||||
result.setDriverId(9001L);
|
||||
result.setCreated(true);
|
||||
result.setBoundExisting(false);
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
+3
@@ -61,6 +61,9 @@ public class OaPersonSyncPageVO implements Serializable {
|
||||
@Schema(description = "本页跳过条数")
|
||||
private Integer skippedCount;
|
||||
|
||||
@Schema(description = "全量同步结束时删除的、不在本次 OA 数据中的 is_oa=2 人员数")
|
||||
private Integer removedCount;
|
||||
|
||||
@Schema(description = "是否已到最后一页")
|
||||
private Boolean finished;
|
||||
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.feign;
|
||||
|
||||
import org.springblade.core.tool.api.R;
|
||||
import org.springblade.transport.pojo.dto.DriverEnsureProfileDTO;
|
||||
import org.springblade.transport.pojo.vo.DriverEnsureProfileVO;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
|
||||
/**
|
||||
* 司机档案 Feign 接口
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@FeignClient(value = "blade-transport")
|
||||
public interface IDriverClient {
|
||||
|
||||
String API_PREFIX = "/feign/client/driver";
|
||||
String ENSURE_PROFILE = API_PREFIX + "/ensure-profile";
|
||||
|
||||
/**
|
||||
* 查找或创建司机档案,并把档案绑定到指定小程序账号
|
||||
* <p>
|
||||
* 幂等入口:按 user_id 命中时原样返回;冲突(同号多档案 / 已被其他账号占用)
|
||||
* 不新建、不覆盖,通过返回值的 message 说明原因,不抛异常给登录方。
|
||||
*
|
||||
* @param param 租户、小程序账号与手机号
|
||||
* @return 档案ID与本次处置标志
|
||||
*/
|
||||
@PostMapping(ENSURE_PROFILE)
|
||||
R<DriverEnsureProfileVO> ensureProfile(@RequestBody DriverEnsureProfileDTO param);
|
||||
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.pojo.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 司机档案「查找或创建并绑定账号」参数
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "司机档案查找或创建参数")
|
||||
public class DriverEnsureProfileDTO implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "租户ID,为空时取当前请求租户;仍为空则拒绝写库")
|
||||
private String tenantId;
|
||||
|
||||
@Schema(description = "关联小程序用户ID(blade_mp_user.id)")
|
||||
private Long mpUserId;
|
||||
|
||||
@Schema(description = "手机号,仅在按账号未命中时用于查找已有档案")
|
||||
private String phone;
|
||||
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.pojo.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 异常处置拒绝请求
|
||||
* <p>
|
||||
* 只有拒绝原因一个业务字段:拒绝复用了结终态,不留独立列,
|
||||
* 原因随跟进记录落进 follow_content。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "异常处置拒绝请求")
|
||||
public class ExceptionDisposalRejectRequest implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "异常处置ID")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "拒绝原因,必填,最多200字")
|
||||
private String rejectReason;
|
||||
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.pojo.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 异常处置「同意并调整路线」请求
|
||||
* <p>
|
||||
* 站点按数组顺序即途经顺序回传,kind 是站序的声明:首个 start、末个 end、其余 transit。
|
||||
* 每个站点上的 tags 指向配载单里第 N 张子运单(形如「装1」「卸2」),后端据此落成站点行;
|
||||
* 没有配载单的运单没有子运单归属,tags 不参与落库。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "异常处置调整路线请求")
|
||||
public class ExceptionDisposalRouteAdjustRequest implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "异常处置ID")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "调整后的站点,按途经顺序排列")
|
||||
private List<RouteNode> routeNodes;
|
||||
|
||||
/**
|
||||
* 一个站点:地址 + 该站装卸的子运单标记
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "路线站点")
|
||||
public static class RouteNode implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "站点类型:start 首站、transit 中间站、end 末站")
|
||||
private String kind;
|
||||
|
||||
@Schema(description = "站点地址")
|
||||
private String address;
|
||||
|
||||
@Schema(description = "该站的装卸标记,形如「装1」「卸2」,数字是配载单子运单序号")
|
||||
private List<String> tags;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+12
@@ -40,6 +40,7 @@ import org.springblade.transport.jackson.FlexibleLocalDateDeserializer;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 司机管理实体类
|
||||
@@ -223,6 +224,17 @@ public class Driver extends TenantEntity {
|
||||
@Schema(description = "关联小程序用户ID")
|
||||
@JsonSerialize(using = ToStringSerializer.class)
|
||||
private Long userId;
|
||||
/**
|
||||
* 档案来源:login_auto(登录自动建)/ login_bound(绑到已有档案)/
|
||||
* admin(后台建)/ cert_backfill(存量订正);NULL 表示来源未知
|
||||
*/
|
||||
@Schema(description = "档案来源")
|
||||
private String profileSource;
|
||||
/**
|
||||
* 档案与账号建立关联的时点
|
||||
*/
|
||||
@Schema(description = "账号绑定时间")
|
||||
private LocalDateTime accountBoundTime;
|
||||
/**
|
||||
* 与联系人关系
|
||||
*/
|
||||
|
||||
+3
@@ -65,6 +65,9 @@ public class AdminWaybillCardVO implements Serializable {
|
||||
@Schema(description = "状态:0待接单/1运输中/2已完成/3已取消")
|
||||
private Integer status;
|
||||
|
||||
@Schema(description = "原始业务状态:pending / running / pending_return_confirm / completed / cancelled")
|
||||
private String businessStatus;
|
||||
|
||||
@Schema(description = "承运方")
|
||||
private String carrierName;
|
||||
|
||||
|
||||
+3
@@ -48,6 +48,9 @@ public class AdminWaybillDetailVO implements Serializable {
|
||||
@Schema(description = "状态:0待接单/1运输中/2已完成/3已取消")
|
||||
private Integer status;
|
||||
|
||||
@Schema(description = "原始业务状态:pending / running / pending_return_confirm / completed / cancelled")
|
||||
private String businessStatus;
|
||||
|
||||
@Schema(description = "起点名称")
|
||||
private String fromName;
|
||||
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.pojo.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 司机档案「查找或创建并绑定账号」结果
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "司机档案查找或创建结果")
|
||||
public class DriverEnsureProfileVO implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "命中的司机档案ID,冲突时为null")
|
||||
private Long driverId;
|
||||
|
||||
@Schema(description = "本次新建了档案")
|
||||
private Boolean created;
|
||||
|
||||
@Schema(description = "本次把已有档案绑定到了账号")
|
||||
private Boolean boundExisting;
|
||||
|
||||
@Schema(description = "冲突原因;成功时为null")
|
||||
private String message;
|
||||
|
||||
}
|
||||
+3
@@ -52,4 +52,7 @@ public class DriverNodePunchVO implements Serializable {
|
||||
@Schema(description = "本次打卡是否使运单自动完成")
|
||||
private Boolean waybillCompleted;
|
||||
|
||||
@Schema(description = "本次打卡后的运单业务状态:running / pending_return_confirm / completed")
|
||||
private String waybillBusinessStatus;
|
||||
|
||||
}
|
||||
|
||||
+3
@@ -76,6 +76,9 @@ public class DriverWaybillCardVO implements Serializable {
|
||||
@Schema(description = "状态:0待接单/1运输中/2已完成/3已取消")
|
||||
private Integer status;
|
||||
|
||||
@Schema(description = "原始业务状态:pending / running / pending_return_confirm / completed / cancelled")
|
||||
private String businessStatus;
|
||||
|
||||
@Schema(description = "创建时间")
|
||||
private String createTime;
|
||||
|
||||
|
||||
+37
@@ -30,6 +30,7 @@ import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -93,4 +94,40 @@ public class ExceptionDisposalVO extends ExceptionDisposal {
|
||||
@Schema(description = "货物信息:{name, weight}")
|
||||
private Map<String, String> cargo;
|
||||
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "关联运单的配载单号,没有配载单时为空")
|
||||
private String loadingNo;
|
||||
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "配载单下的子运单号,按配载单运单集合的顺序排列")
|
||||
private List<String> childWaybillNos;
|
||||
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "可调整的路线站点,少于两个站时为空")
|
||||
private List<RouteNode> routeNodes;
|
||||
|
||||
/**
|
||||
* 一个可调整的站点:站序声明、地址、该站的装卸标记与项目名
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "路线站点")
|
||||
public static class RouteNode implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "站点类型:start 首站、transit 中间站、end 末站")
|
||||
private String kind;
|
||||
|
||||
@Schema(description = "站点地址")
|
||||
private String address;
|
||||
|
||||
@Schema(description = "该站的装卸标记,形如「装1」「卸2」,数字是配载单子运单序号")
|
||||
private List<String> tags;
|
||||
|
||||
@Schema(description = "该站装卸的子运单项目名,按标记顺序去重")
|
||||
private List<String> projects;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ public class TripQueryVO implements Serializable {
|
||||
@Schema(description = "多档状态:同上取值,非空时优先于 status(待处理列表要待接单与进行中两档)")
|
||||
private List<Integer> statuses;
|
||||
|
||||
@Schema(description = "关键字:运单号 / 配载单号 / 起终点 / 车牌")
|
||||
@Schema(description = "关键字:运单号 / 配载单号 / 项目 / 司机 / 车牌 / 起终点 / 地址(任一子单命中即整趟车命中)")
|
||||
private String keyword;
|
||||
|
||||
@Schema(description = "运输组织类型:load 公路配载 / common 普通运输,不传为全部(按子单行筛选)")
|
||||
|
||||
+15
@@ -24,6 +24,7 @@ package org.springblade.system.feign;
|
||||
|
||||
import org.springblade.core.launch.constant.AppConstant;
|
||||
import org.springblade.core.tool.api.R;
|
||||
import org.springblade.system.pojo.dto.MpUserEnsureDTO;
|
||||
import org.springblade.system.pojo.entity.MpUser;
|
||||
import org.springblade.system.pojo.entity.MpUserInfo;
|
||||
import org.springblade.system.pojo.vo.MpUserDataScopeVO;
|
||||
@@ -47,6 +48,7 @@ public interface IMpUserClient {
|
||||
String USER_BY_ID = API_PREFIX + "/user-by-id";
|
||||
String USER_BY_PHONE = API_PREFIX + "/user-by-phone";
|
||||
String USER_BY_ACCOUNT = API_PREFIX + "/user-by-account";
|
||||
String USER_BY_OPENID = API_PREFIX + "/user-by-openid";
|
||||
String USER_INFO_BY_ID = API_PREFIX + "/info-by-id";
|
||||
String USER_INFO_BY_PHONE = API_PREFIX + "/info-by-phone";
|
||||
String USER_INFO_BY_ACCOUNT = API_PREFIX + "/info-by-account";
|
||||
@@ -55,6 +57,7 @@ public interface IMpUserClient {
|
||||
String BIND_OPENID = API_PREFIX + "/bind-openid";
|
||||
String SET_PASSWORD = API_PREFIX + "/set-password";
|
||||
String RESOLVE_DATA_SCOPE = API_PREFIX + "/resolve-data-scope";
|
||||
String ENSURE_USER = API_PREFIX + "/ensure-user";
|
||||
|
||||
/**
|
||||
* 按主键查询
|
||||
@@ -80,6 +83,18 @@ public interface IMpUserClient {
|
||||
@GetMapping(USER_BY_ACCOUNT)
|
||||
R<MpUser> userByAccount(@RequestParam("tenantId") String tenantId, @RequestParam("account") String account);
|
||||
|
||||
/**
|
||||
* 按租户 + openid 查询
|
||||
*/
|
||||
@GetMapping(USER_BY_OPENID)
|
||||
R<MpUser> userByOpenid(@RequestParam("tenantId") String tenantId, @RequestParam("openid") String openid);
|
||||
|
||||
/**
|
||||
* 查找或创建小程序用户(登录路径专用,已存在时不抛异常、不改身份)
|
||||
*/
|
||||
@PostMapping(ENSURE_USER)
|
||||
R<MpUser> ensureUser(@RequestBody MpUserEnsureDTO param);
|
||||
|
||||
/**
|
||||
* 按主键查询登录信息
|
||||
*/
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.system.pojo.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 小程序用户「查找或创建」参数
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "小程序用户查找或创建参数")
|
||||
public class MpUserEnsureDTO implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "租户ID,为空时依次取 Tenant-Id 请求头与默认租户")
|
||||
private String tenantId;
|
||||
|
||||
@Schema(description = "手机号")
|
||||
private String phone;
|
||||
|
||||
@Schema(description = "微信小程序openid,非空时随首次建号一并写入")
|
||||
private String openid;
|
||||
|
||||
}
|
||||
+4
@@ -43,6 +43,7 @@ import org.springblade.core.excel.util.ExcelUtil;
|
||||
import org.springblade.core.mp.support.Condition;
|
||||
import org.springblade.core.mp.support.Query;
|
||||
import org.springblade.core.secure.annotation.PreAuth;
|
||||
import org.springblade.core.secure.constant.AuthConstant;
|
||||
import org.springblade.core.tenant.annotation.NonDS;
|
||||
import org.springblade.core.tool.api.R;
|
||||
import org.springblade.core.tool.utils.DateUtil;
|
||||
@@ -117,6 +118,9 @@ public class RegionController extends BladeController {
|
||||
/**
|
||||
* 懒加载列表
|
||||
*/
|
||||
// 引用数据下拉:小程序司机认证表单的住址选择也调用本接口。小程序用户不绑定系统角色,
|
||||
// 落到类上的 menu=region 校验会抛 401,前端随即清登录态把用户踢回登录页。
|
||||
@PreAuth(AuthConstant.PERMIT_ALL)
|
||||
@GetMapping("/lazy-tree")
|
||||
@Parameters({
|
||||
@Parameter(name = "code", description = "区划编号", in = ParameterIn.QUERY, schema = @Schema(type = "string")),
|
||||
|
||||
+13
@@ -26,6 +26,7 @@ import io.swagger.v3.oas.annotations.Hidden;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springblade.core.tenant.annotation.NonDS;
|
||||
import org.springblade.core.tool.api.R;
|
||||
import org.springblade.system.pojo.dto.MpUserEnsureDTO;
|
||||
import org.springblade.system.pojo.entity.MpUser;
|
||||
import org.springblade.system.pojo.entity.MpUserInfo;
|
||||
import org.springblade.system.pojo.vo.MpUserDataScopeVO;
|
||||
@@ -73,6 +74,18 @@ public class MpUserClient implements IMpUserClient {
|
||||
return R.data(mpUserService.getByAccount(tenantId, account));
|
||||
}
|
||||
|
||||
@Override
|
||||
@GetMapping(USER_BY_OPENID)
|
||||
public R<MpUser> userByOpenid(String tenantId, String openid) {
|
||||
return R.data(mpUserService.getByOpenid(tenantId, openid));
|
||||
}
|
||||
|
||||
@Override
|
||||
@PostMapping(ENSURE_USER)
|
||||
public R<MpUser> ensureUser(@RequestBody MpUserEnsureDTO param) {
|
||||
return R.data(mpUserService.ensureUser(param));
|
||||
}
|
||||
|
||||
@Override
|
||||
@GetMapping(USER_INFO_BY_ID)
|
||||
public R<MpUserInfo> userInfoById(Long userId) {
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
bud.user_id
|
||||
from
|
||||
blade_user_dept bud
|
||||
left join blade_dept bd on bd.dept_code = bud.dept_code
|
||||
left join blade_dept bd on bd.id = bud.dept_id and bd.is_deleted = 0
|
||||
where
|
||||
bd.id is not null
|
||||
and bud.is_deleted = 0
|
||||
|
||||
+13
@@ -24,6 +24,7 @@ package org.springblade.system.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import org.springblade.core.mp.base.BaseService;
|
||||
import org.springblade.system.pojo.dto.MpUserEnsureDTO;
|
||||
import org.springblade.system.pojo.entity.MpUser;
|
||||
import org.springblade.system.pojo.entity.MpUserInfo;
|
||||
import org.springblade.system.pojo.vo.MpUserDataScopeVO;
|
||||
@@ -48,6 +49,18 @@ public interface IMpUserService extends BaseService<MpUser> {
|
||||
*/
|
||||
MpUser getByAccount(String tenantId, String account);
|
||||
|
||||
/**
|
||||
* 按租户 + 微信小程序 openid 查询
|
||||
*/
|
||||
MpUser getByOpenid(String tenantId, String openid);
|
||||
|
||||
/**
|
||||
* 查找或创建小程序用户(登录路径专用)
|
||||
* <p>
|
||||
* 与 {@link #submit} 的提交式语义相反:手机号已存在时直接返回已有账号,且不改其身份。
|
||||
*/
|
||||
MpUser ensureUser(MpUserEnsureDTO param);
|
||||
|
||||
/**
|
||||
* 按主键组装登录信息
|
||||
*/
|
||||
|
||||
+108
@@ -24,7 +24,9 @@ package org.springblade.system.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.common.utils.PasswordRuleUtil;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.mp.base.BaseServiceImpl;
|
||||
@@ -34,10 +36,12 @@ import org.springblade.core.tool.utils.DateUtil;
|
||||
import org.springblade.core.tool.utils.DigestUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.core.tool.utils.StringUtil;
|
||||
import org.springblade.core.tool.utils.WebUtil;
|
||||
import org.springblade.system.cache.MpUserCache;
|
||||
import org.springblade.system.cache.SysCache;
|
||||
import org.springblade.system.mapper.MpUserDataScopeMapper;
|
||||
import org.springblade.system.mapper.MpUserMapper;
|
||||
import org.springblade.system.pojo.dto.MpUserEnsureDTO;
|
||||
import org.springblade.system.pojo.entity.MpUser;
|
||||
import org.springblade.system.pojo.entity.MpUserDataScope;
|
||||
import org.springblade.system.pojo.entity.MpUserInfo;
|
||||
@@ -58,6 +62,7 @@ import java.util.Objects;
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MpUserServiceImpl extends BaseServiceImpl<MpUserMapper, MpUser> implements IMpUserService {
|
||||
@@ -73,6 +78,16 @@ public class MpUserServiceImpl extends BaseServiceImpl<MpUserMapper, MpUser> imp
|
||||
/** 数据层级范围:仅本级 */
|
||||
private static final int DATA_LEVEL_CURRENT_ONLY = 2;
|
||||
|
||||
/**
|
||||
* 兜底租户:与 blade_mp_user.tenant_id 的建表默认值一致
|
||||
*/
|
||||
private static final String DEFAULT_TENANT_ID = "000000";
|
||||
|
||||
/**
|
||||
* 登录请求携带租户的请求头名
|
||||
*/
|
||||
private static final String TENANT_HEADER = "Tenant-Id";
|
||||
|
||||
private final MpUserDataScopeMapper mpUserDataScopeMapper;
|
||||
|
||||
@Override
|
||||
@@ -99,6 +114,92 @@ public class MpUserServiceImpl extends BaseServiceImpl<MpUserMapper, MpUser> imp
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public MpUser getByOpenid(String tenantId, String openid) {
|
||||
if (Func.isBlank(tenantId) || Func.isBlank(openid)) {
|
||||
return null;
|
||||
}
|
||||
return getOne(Wrappers.<MpUser>lambdaQuery()
|
||||
.eq(MpUser::getTenantId, tenantId)
|
||||
.eq(MpUser::getOpenid, openid)
|
||||
.orderByDesc(MpUser::getCreateTime)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public MpUser ensureUser(MpUserEnsureDTO param) {
|
||||
if (param == null) {
|
||||
throw new ServiceException("小程序用户参数不能为空");
|
||||
}
|
||||
String tenantId = resolveEnsureTenantId(param.getTenantId());
|
||||
String phone = trimToNull(param.getPhone());
|
||||
if (Func.isBlank(phone)) {
|
||||
throw new ServiceException("手机号不能为空");
|
||||
}
|
||||
// 先查后插:命中即返回,不新建也不改身份。租户为空时唯一键对 NULL 不去重,
|
||||
// 故 resolveEnsureTenantId 已在写库前把租户兜到非空。
|
||||
MpUser existed = getByPhone(tenantId, phone);
|
||||
if (existed != null && Func.isNotEmpty(existed.getId())) {
|
||||
return existed;
|
||||
}
|
||||
MpUser user = new MpUser();
|
||||
user.setTenantId(tenantId);
|
||||
user.setPhone(phone);
|
||||
user.setAccount(phone);
|
||||
user.setIdentities(MpUserIdentity.DRIVER.getCode());
|
||||
user.setOpenid(trimToNull(param.getOpenid()));
|
||||
user.setStatus(1);
|
||||
prepare(user);
|
||||
validate(user);
|
||||
// 登录路径不设密码,password 保持为空
|
||||
user.setPassword(null);
|
||||
try {
|
||||
save(user);
|
||||
} catch (Exception exception) {
|
||||
// 并发建号撞 (tenant_id, phone) 唯一键:重读一次并返回已有账号
|
||||
MpUser concurrent = getByPhone(tenantId, phone);
|
||||
if (concurrent != null && Func.isNotEmpty(concurrent.getId())) {
|
||||
return concurrent;
|
||||
}
|
||||
log.warn("小程序用户建号失败,租户={} 手机号={}", tenantId, phone, exception);
|
||||
throw new ServiceException("创建小程序用户失败");
|
||||
}
|
||||
if (Func.isEmpty(user.getId())) {
|
||||
throw new ServiceException("创建小程序用户失败");
|
||||
}
|
||||
MpUserCache.clear();
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录路径的租户解析:Tenant-Id 头 → 入参 tenantId → 默认租户
|
||||
* <p>
|
||||
* 请求头与入参不一致时以请求头为准。写库前必须得到非空值:唯一键 (tenant_id, phone)
|
||||
* 对 NULL 不去重,租户为 NULL 会为同一手机号造出重复账号。
|
||||
*/
|
||||
private String resolveEnsureTenantId(String tenantId) {
|
||||
String resolved = requestHeaderTenantId();
|
||||
if (resolved == null) {
|
||||
resolved = trimToNull(tenantId);
|
||||
}
|
||||
if (resolved == null) {
|
||||
resolved = DEFAULT_TENANT_ID;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前请求头里的租户;无请求上下文时返回 null
|
||||
*/
|
||||
private String requestHeaderTenantId() {
|
||||
HttpServletRequest request = WebUtil.getRequest();
|
||||
if (request == null) {
|
||||
return null;
|
||||
}
|
||||
return trimToNull(request.getHeader(TENANT_HEADER));
|
||||
}
|
||||
|
||||
@Override
|
||||
public MpUserInfo userInfo(Long userId) {
|
||||
if (Func.isEmpty(userId)) {
|
||||
@@ -324,6 +425,13 @@ public class MpUserServiceImpl extends BaseServiceImpl<MpUserMapper, MpUser> imp
|
||||
if (!tenantId.equals(user.getTenantId())) {
|
||||
throw new ServiceException("租户不匹配,无法绑定 openid");
|
||||
}
|
||||
// 目标账号已有另一个非空 openid 时不覆盖:一个微信换绑由管理员处理,
|
||||
// 登录路径直接放弃本次绑定
|
||||
if (Func.isNotBlank(user.getOpenid()) && !user.getOpenid().equals(openid)) {
|
||||
log.warn("小程序用户已绑定其他 openid,跳过绑定:租户={} 账号={} 已有openid={} 本次openid={}",
|
||||
tenantId, userId, user.getOpenid(), openid);
|
||||
return false;
|
||||
}
|
||||
MpUser byOpenId = getOne(Wrappers.<MpUser>lambdaQuery()
|
||||
.eq(MpUser::getTenantId, tenantId)
|
||||
.eq(MpUser::getOpenid, openid)
|
||||
|
||||
+18
-6
@@ -277,6 +277,11 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
}, 10000, ComposeLogUtil.getLastLog()::info).accept(oaPersons::addAll);
|
||||
OaUserListSyncHelper.OaOrgIndex orgIndex = oaUserListSyncHelper.syncOrgsFromPersons(oaPersons);
|
||||
OaUserListSyncHelper.PersonSyncCount personSyncCount = oaUserListSyncHelper.handlePerson(oaPersons, orgIndex);
|
||||
if (startTime == null) {
|
||||
oaUserListSyncHelper.removeOaUsersAbsentFrom(
|
||||
oaUserListSyncHelper.extractOaLoginIds(oaPersons),
|
||||
oaUserListSyncHelper.extractWorkcodes(oaPersons));
|
||||
}
|
||||
CacheUtil.clear(USER_CACHE);
|
||||
CacheUtil.clear(SYS_CACHE);
|
||||
CacheUtil.clear(SYS_CACHE, Boolean.FALSE);
|
||||
@@ -295,9 +300,10 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
private OaPersonSyncPageVO syncPersonFromOaPage(int current, int size, boolean incremental, String modifiedSince) {
|
||||
int pageNo = current < 1 ? 1 : current;
|
||||
int pageSize = size < 1 ? 20 : Math.min(size, 200);
|
||||
// 全量同步首页开启原始数据收集,结束后按父级 id 写入 JSON
|
||||
// 全量同步首页开启原始数据收集,并开始累积登录名,最后一页再删除本地多余人员
|
||||
if (!incremental && pageNo == 1) {
|
||||
oaSyncRawDumpHelper.beginFullDump();
|
||||
oaUserListSyncHelper.beginFullSyncReconcile();
|
||||
}
|
||||
IncrementalStart incrementalStart = resolvePersonIncrementalStart(incremental, modifiedSince);
|
||||
OAPersonSearch personSearch = buildPersonSearch(incrementalStart.getStartTime());
|
||||
@@ -314,9 +320,17 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
long totalSize = responseData.getTotalSize() == null ? 0L : responseData.getTotalSize();
|
||||
if (!incremental) {
|
||||
oaSyncRawDumpHelper.collectPersons(oaPersons);
|
||||
oaUserListSyncHelper.collectFullSyncPersons(oaPersons);
|
||||
}
|
||||
OaUserListSyncHelper.OaOrgIndex orgIndex = oaUserListSyncHelper.syncOrgsFromPersons(oaPersons);
|
||||
OaUserListSyncHelper.PersonSyncCount personSyncCount = oaUserListSyncHelper.handlePerson(oaPersons, orgIndex);
|
||||
boolean finished = oaPersons.isEmpty()
|
||||
|| oaPersons.size() < pageSize
|
||||
|| (long) pageNo * pageSize >= totalSize;
|
||||
int removedCount = personSyncCount.getRemovedCount();
|
||||
if (!incremental && finished) {
|
||||
removedCount += oaUserListSyncHelper.finishFullSyncReconcile();
|
||||
}
|
||||
CacheUtil.clear(USER_CACHE);
|
||||
CacheUtil.clear(SYS_CACHE);
|
||||
CacheUtil.clear(SYS_CACHE, Boolean.FALSE);
|
||||
@@ -327,17 +341,15 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
pageVO.setFetchedCount(oaPersons.size());
|
||||
pageVO.setSyncedCount(personSyncCount.getSyncedCount());
|
||||
pageVO.setSkippedCount(personSyncCount.getSkippedCount());
|
||||
pageVO.setRemovedCount(removedCount);
|
||||
pageVO.setModifiedSince(incrementalStart.getModifiedSince());
|
||||
boolean finished = oaPersons.isEmpty()
|
||||
|| oaPersons.size() < pageSize
|
||||
|| (long) pageNo * pageSize >= totalSize;
|
||||
pageVO.setFinished(finished);
|
||||
if (!incremental && finished) {
|
||||
pageVO.setDumpFilePath(oaSyncRawDumpHelper.finishFullDump());
|
||||
}
|
||||
ComposeLogUtil.getLastLog().info("OA人员分页同步完成 mode={} {}/{},成功{},跳过{}",
|
||||
ComposeLogUtil.getLastLog().info("OA人员分页同步完成 mode={} {}/{},成功{},跳过{},删除{}",
|
||||
incremental ? "incremental" : "full", pageNo, totalSize,
|
||||
personSyncCount.getSyncedCount(), personSyncCount.getSkippedCount());
|
||||
personSyncCount.getSyncedCount(), personSyncCount.getSkippedCount(), removedCount);
|
||||
return pageVO;
|
||||
}
|
||||
|
||||
|
||||
+434
-46
@@ -30,6 +30,7 @@ import com.baomidou.mybatisplus.core.incrementer.DefaultIdentifierGenerator;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springblade.common.constant.CommonConstant;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.core.tool.constant.BladeConstant;
|
||||
import org.springblade.core.tool.utils.DigestUtil;
|
||||
@@ -55,10 +56,12 @@ import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.springblade.common.constant.CommonConstant.DEFAULT_PARAM_PASSWORD;
|
||||
@@ -76,6 +79,22 @@ public class OaUserListSyncHelper {
|
||||
|
||||
private static final int MAX_DEPT_ID_LENGTH = 2000;
|
||||
|
||||
private static final int OA_LOGIN_ID_BATCH_SIZE = 500;
|
||||
|
||||
/**
|
||||
* OA 人员状态:5 离职。离职人员不入库,已有 is_oa=2 账号直接删除。
|
||||
*/
|
||||
private static final String OA_PERSON_STATUS_RESIGNED = "5";
|
||||
|
||||
/**
|
||||
* 全量同步跨页累积的 OA 登录名。分页接口每页独立事务,删除必须等最后一页再执行。
|
||||
*/
|
||||
private final Set<String> fullSyncLoginIds = ConcurrentHashMap.newKeySet();
|
||||
|
||||
private final Set<String> fullSyncWorkcodes = ConcurrentHashMap.newKeySet();
|
||||
|
||||
private volatile boolean fullSyncCollecting;
|
||||
|
||||
private final IDeptService deptService;
|
||||
private final IUserService userService;
|
||||
private final UserConvert userConvert;
|
||||
@@ -390,48 +409,325 @@ public class OaUserListSyncHelper {
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步人员并绑定到三级部门
|
||||
* 全量同步从第一页开始收集 OA 登录名,最后一页再删除本地多余的 is_oa=2 人员。
|
||||
*/
|
||||
public synchronized void beginFullSyncReconcile() {
|
||||
this.fullSyncLoginIds.clear();
|
||||
this.fullSyncWorkcodes.clear();
|
||||
this.fullSyncCollecting = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 累积本页 OA 人员登录名,供全量同步结束后比对删除。
|
||||
*
|
||||
* @param oaPersons 本页 OA 人员
|
||||
*/
|
||||
public synchronized void collectFullSyncPersons(List<OAPersonResponse> oaPersons) {
|
||||
if (!this.fullSyncCollecting || CollectionUtil.isEmpty(oaPersons)) {
|
||||
return;
|
||||
}
|
||||
this.fullSyncLoginIds.addAll(this.extractOaLoginIds(oaPersons));
|
||||
this.fullSyncWorkcodes.addAll(this.extractWorkcodes(oaPersons));
|
||||
}
|
||||
|
||||
/**
|
||||
* 全量同步结束后,删除登录名不在本次 OA 数据中的 is_oa=2 人员。
|
||||
*
|
||||
* @return 删除人数;未开始收集或登录名集合为空时返回 0,避免误删全部 OA 人员
|
||||
*/
|
||||
public synchronized int finishFullSyncReconcile() {
|
||||
if (!this.fullSyncCollecting) {
|
||||
return 0;
|
||||
}
|
||||
this.fullSyncCollecting = false;
|
||||
Set<String> oaLoginIds = new LinkedHashSet<>(this.fullSyncLoginIds);
|
||||
Set<String> workcodes = new LinkedHashSet<>(this.fullSyncWorkcodes);
|
||||
this.fullSyncLoginIds.clear();
|
||||
this.fullSyncWorkcodes.clear();
|
||||
return this.removeOaUsersAbsentFrom(oaLoginIds, workcodes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取仍在职的 OA 人员登录名。离职人员不计入,全量同步结束时会删掉对应本地账号。
|
||||
*
|
||||
* @param oaPersons OA人员
|
||||
* @return 去重后的登录名
|
||||
*/
|
||||
public Set<String> extractOaLoginIds(List<OAPersonResponse> oaPersons) {
|
||||
Set<String> oaLoginIds = new LinkedHashSet<>();
|
||||
if (CollectionUtil.isEmpty(oaPersons)) {
|
||||
return oaLoginIds;
|
||||
}
|
||||
for (OAPersonResponse oaPerson : oaPersons) {
|
||||
String oaLoginId = this.resolveOaLoginId(oaPerson);
|
||||
if (oaLoginId != null && !this.isOaResigned(oaPerson)) {
|
||||
oaLoginIds.add(oaLoginId);
|
||||
}
|
||||
}
|
||||
return oaLoginIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取仍在职人员的工号。没有登录名时靠工号保留本地账号。
|
||||
*
|
||||
* @param oaPersons OA人员
|
||||
* @return 去重后的工号
|
||||
*/
|
||||
public Set<String> extractWorkcodes(List<OAPersonResponse> oaPersons) {
|
||||
Set<String> workcodes = new LinkedHashSet<>();
|
||||
if (CollectionUtil.isEmpty(oaPersons)) {
|
||||
return workcodes;
|
||||
}
|
||||
for (OAPersonResponse oaPerson : oaPersons) {
|
||||
String workcode = this.resolveWorkcode(oaPerson);
|
||||
if (workcode != null && !this.isOaResigned(oaPerson)) {
|
||||
workcodes.add(workcode);
|
||||
}
|
||||
}
|
||||
return workcodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除当前租户中 is_oa=2、且既不在本次登录名里、也不是无登录名工号人员的账号。
|
||||
* 登录名和工号都没收集到时不删除。当前登录账号即使不在同步数据中也保留。
|
||||
*
|
||||
* @param oaLoginIds 本次全量同步出现过的 OA 登录名
|
||||
* @param workcodes 本次全量同步出现过的工号
|
||||
* @return 删除人数
|
||||
*/
|
||||
public int removeOaUsersAbsentFrom(Set<String> oaLoginIds, Set<String> workcodes) {
|
||||
boolean noLoginIds = CollectionUtil.isEmpty(oaLoginIds);
|
||||
boolean noWorkcodes = CollectionUtil.isEmpty(workcodes);
|
||||
if (noLoginIds && noWorkcodes) {
|
||||
ComposeLogUtil.getLastLog().warn("全量同步未收集到OA登录名和工号,跳过删除本地 is_oa=2 人员");
|
||||
return 0;
|
||||
}
|
||||
Set<String> loginIdSet = noLoginIds ? Set.of() : oaLoginIds;
|
||||
Set<String> workcodeSet = noWorkcodes ? Set.of() : workcodes;
|
||||
String tenantId = this.resolveTenantId();
|
||||
List<User> oaUsers = this.userService.list(Wrappers.<User>lambdaQuery()
|
||||
.select(User::getId, User::getOaLoginId, User::getCode)
|
||||
.eq(User::getTenantId, tenantId)
|
||||
.eq(User::getIsOa, CommonConstant.YES));
|
||||
Long currentUserId = AuthUtil.getUserId();
|
||||
List<Long> removeIds = new ArrayList<>();
|
||||
for (User oaUser : oaUsers) {
|
||||
if (oaUser.getId() == null) {
|
||||
continue;
|
||||
}
|
||||
if (currentUserId != null && currentUserId > 0 && currentUserId.equals(oaUser.getId())) {
|
||||
ComposeLogUtil.getLastLog().warn("全量同步跳过删除当前登录账号 userId={}", oaUser.getId());
|
||||
continue;
|
||||
}
|
||||
String oaLoginId = StringUtils.trimToNull(oaUser.getOaLoginId());
|
||||
String workcode = StringUtils.trimToNull(oaUser.getCode());
|
||||
boolean keptByLoginId = oaLoginId != null && loginIdSet.contains(oaLoginId);
|
||||
boolean keptByWorkcode = oaLoginId == null && workcode != null && workcodeSet.contains(workcode);
|
||||
if (!keptByLoginId && !keptByWorkcode) {
|
||||
removeIds.add(oaUser.getId());
|
||||
}
|
||||
}
|
||||
if (removeIds.isEmpty()) {
|
||||
ComposeLogUtil.getLastLog().info("全量同步无需删除 is_oa=2 人员");
|
||||
return 0;
|
||||
}
|
||||
return this.deleteOaUsers(removeIds, "全量同步删除不在OA数据中的 is_oa=2 人员");
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 OA 登录名删除已有 is_oa=2 人员。用于离职人员,全量和增量同步都会执行。
|
||||
*
|
||||
* @param tenantId 租户
|
||||
* @param oaLoginIds 离职人员登录名
|
||||
* @return 删除人数
|
||||
*/
|
||||
private int removeOaUsersByLoginIds(String tenantId, Collection<String> oaLoginIds) {
|
||||
if (CollectionUtil.isEmpty(oaLoginIds)) {
|
||||
return 0;
|
||||
}
|
||||
List<String> oaLoginIdList = oaLoginIds.stream()
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (oaLoginIdList.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
Long currentUserId = AuthUtil.getUserId();
|
||||
List<Long> removeIds = new ArrayList<>();
|
||||
for (int fromIndex = 0; fromIndex < oaLoginIdList.size(); fromIndex += OA_LOGIN_ID_BATCH_SIZE) {
|
||||
List<String> oaLoginIdBatch = oaLoginIdList.subList(fromIndex, Math.min(fromIndex + OA_LOGIN_ID_BATCH_SIZE, oaLoginIdList.size()));
|
||||
this.userService.list(Wrappers.<User>lambdaQuery()
|
||||
.select(User::getId, User::getOaLoginId)
|
||||
.eq(User::getTenantId, tenantId)
|
||||
.eq(User::getIsOa, CommonConstant.YES)
|
||||
.in(User::getOaLoginId, oaLoginIdBatch))
|
||||
.forEach(oaUser -> {
|
||||
if (oaUser.getId() == null) {
|
||||
return;
|
||||
}
|
||||
if (currentUserId != null && currentUserId > 0 && currentUserId.equals(oaUser.getId())) {
|
||||
ComposeLogUtil.getLastLog().warn("同步跳过删除当前登录账号 userId={}", oaUser.getId());
|
||||
return;
|
||||
}
|
||||
removeIds.add(oaUser.getId());
|
||||
});
|
||||
}
|
||||
return this.deleteOaUsers(removeIds, "同步删除OA离职人员");
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除没有登录名、且工号属于离职人员的 is_oa=2 账号。
|
||||
*
|
||||
* @param tenantId 租户
|
||||
* @param workcodes 离职人员工号
|
||||
* @return 删除人数
|
||||
*/
|
||||
private int removeOaUsersByBlankLoginWorkcodes(String tenantId, Collection<String> workcodes) {
|
||||
if (CollectionUtil.isEmpty(workcodes)) {
|
||||
return 0;
|
||||
}
|
||||
List<String> workcodeList = workcodes.stream()
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (workcodeList.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
Long currentUserId = AuthUtil.getUserId();
|
||||
List<Long> removeIds = new ArrayList<>();
|
||||
for (int fromIndex = 0; fromIndex < workcodeList.size(); fromIndex += OA_LOGIN_ID_BATCH_SIZE) {
|
||||
List<String> workcodeBatch = workcodeList.subList(fromIndex, Math.min(fromIndex + OA_LOGIN_ID_BATCH_SIZE, workcodeList.size()));
|
||||
this.userService.list(Wrappers.<User>lambdaQuery()
|
||||
.select(User::getId, User::getOaLoginId, User::getCode)
|
||||
.eq(User::getTenantId, tenantId)
|
||||
.eq(User::getIsOa, CommonConstant.YES)
|
||||
.in(User::getCode, workcodeBatch)
|
||||
.and(wrapper -> wrapper.isNull(User::getOaLoginId).or().eq(User::getOaLoginId, "")))
|
||||
.forEach(oaUser -> {
|
||||
if (oaUser.getId() == null) {
|
||||
return;
|
||||
}
|
||||
if (currentUserId != null && currentUserId > 0 && currentUserId.equals(oaUser.getId())) {
|
||||
ComposeLogUtil.getLastLog().warn("同步跳过删除当前登录账号 userId={}", oaUser.getId());
|
||||
return;
|
||||
}
|
||||
removeIds.add(oaUser.getId());
|
||||
});
|
||||
}
|
||||
return this.deleteOaUsers(removeIds, "同步删除无登录名的OA离职人员");
|
||||
}
|
||||
|
||||
/**
|
||||
* 逻辑删除用户及其部门关系。
|
||||
*
|
||||
* @param removeIds 用户 id
|
||||
* @param logMessage 日志说明
|
||||
* @return 删除人数
|
||||
*/
|
||||
private int deleteOaUsers(List<Long> removeIds, String logMessage) {
|
||||
if (CollectionUtil.isEmpty(removeIds)) {
|
||||
return 0;
|
||||
}
|
||||
for (int fromIndex = 0; fromIndex < removeIds.size(); fromIndex += OA_LOGIN_ID_BATCH_SIZE) {
|
||||
List<Long> removeIdBatch = removeIds.subList(fromIndex, Math.min(fromIndex + OA_LOGIN_ID_BATCH_SIZE, removeIds.size()));
|
||||
this.userService.deleteLogic(removeIdBatch);
|
||||
this.userDeptService.remove(Wrappers.<UserDept>lambdaQuery().in(UserDept::getUserId, removeIdBatch));
|
||||
}
|
||||
ComposeLogUtil.getLastLog().info("{}:{}", logMessage, removeIds.size());
|
||||
return removeIds.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* OA 状态 5 表示离职。
|
||||
*/
|
||||
private boolean isOaResigned(OAPersonResponse oaPerson) {
|
||||
return oaPerson != null && OA_PERSON_STATUS_RESIGNED.equals(StringUtils.trimToEmpty(oaPerson.getStatus()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步人员并绑定到三级部门。有登录名按登录名更新,没有登录名但有工号则按工号更新且不回填登录名。离职人员直接删除。
|
||||
*
|
||||
* @param oaPersons OA人员
|
||||
* @param orgIndex 组织索引
|
||||
* @return 本批同步成功与跳过数量
|
||||
* @return 本批同步成功、跳过与删除数量
|
||||
*/
|
||||
public PersonSyncCount handlePerson(List<OAPersonResponse> oaPersons, OaOrgIndex orgIndex) {
|
||||
if (CollectionUtil.isEmpty(oaPersons)) {
|
||||
return new PersonSyncCount(0, 0);
|
||||
return new PersonSyncCount(0, 0, 0);
|
||||
}
|
||||
Map<String, OAPersonResponse> uniquePersonMap = new LinkedHashMap<>();
|
||||
Set<String> resignedLoginIds = new LinkedHashSet<>();
|
||||
Set<String> resignedWorkcodes = new LinkedHashSet<>();
|
||||
int skippedCount = 0;
|
||||
for (OAPersonResponse oaPerson : oaPersons) {
|
||||
String oaLoginId = resolveOaLoginId(oaPerson);
|
||||
if (StringUtils.isBlank(oaLoginId)) {
|
||||
String syncKey = this.resolveSyncKey(oaPerson);
|
||||
if (syncKey == null) {
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
uniquePersonMap.put(oaLoginId, oaPerson);
|
||||
}
|
||||
if (uniquePersonMap.isEmpty()) {
|
||||
ComposeLogUtil.getLastLog().warn("OA人员均缺少登录名,跳过人员同步");
|
||||
return new PersonSyncCount(0, skippedCount);
|
||||
String oaLoginId = this.resolveOaLoginId(oaPerson);
|
||||
String workcode = this.resolveWorkcode(oaPerson);
|
||||
if (this.isOaResigned(oaPerson)) {
|
||||
uniquePersonMap.remove(syncKey);
|
||||
if (oaLoginId != null) {
|
||||
resignedLoginIds.add(oaLoginId);
|
||||
} else if (workcode != null) {
|
||||
resignedWorkcodes.add(workcode);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (oaLoginId != null) {
|
||||
resignedLoginIds.remove(oaLoginId);
|
||||
} else if (workcode != null) {
|
||||
resignedWorkcodes.remove(workcode);
|
||||
}
|
||||
uniquePersonMap.put(syncKey, oaPerson);
|
||||
}
|
||||
String tenantId = resolveTenantId();
|
||||
int removedCount = this.removeOaUsersByLoginIds(tenantId, resignedLoginIds)
|
||||
+ this.removeOaUsersByBlankLoginWorkcodes(tenantId, resignedWorkcodes);
|
||||
if (uniquePersonMap.isEmpty()) {
|
||||
if (resignedLoginIds.isEmpty() && resignedWorkcodes.isEmpty()) {
|
||||
ComposeLogUtil.getLastLog().warn("OA人员均缺少登录名和工号,跳过人员同步");
|
||||
}
|
||||
return new PersonSyncCount(0, skippedCount, removedCount);
|
||||
}
|
||||
String defaultPassword = DigestUtil.encrypt(ParamCache.getValue(DEFAULT_PARAM_PASSWORD));
|
||||
List<User> users = uniquePersonMap.entrySet().stream()
|
||||
.map(entry -> {
|
||||
User user = userConvert.person2user(entry.getValue(), defaultPassword);
|
||||
user.setTenantId(tenantId);
|
||||
user.setOaLoginId(entry.getKey());
|
||||
user.setAccount(entry.getKey());
|
||||
return user;
|
||||
})
|
||||
.toList();
|
||||
Map<String, User> existingByOaLoginId = this.loadUsersByOaLoginId(tenantId, uniquePersonMap.keySet());
|
||||
Set<String> oaLoginIds = new LinkedHashSet<>();
|
||||
Set<String> workcodes = new LinkedHashSet<>();
|
||||
for (OAPersonResponse oaPerson : uniquePersonMap.values()) {
|
||||
String oaLoginId = this.resolveOaLoginId(oaPerson);
|
||||
if (oaLoginId != null) {
|
||||
oaLoginIds.add(oaLoginId);
|
||||
continue;
|
||||
}
|
||||
String workcode = this.resolveWorkcode(oaPerson);
|
||||
if (workcode != null) {
|
||||
workcodes.add(workcode);
|
||||
}
|
||||
}
|
||||
Map<String, User> existingByOaLoginId = this.loadUsersByOaLoginId(tenantId, oaLoginIds);
|
||||
Map<String, User> existingByWorkcode = this.loadUsersByBlankLoginWorkcode(tenantId, workcodes);
|
||||
List<User> users = new ArrayList<>();
|
||||
Map<String, Long> userMap = new HashMap<>();
|
||||
Set<Long> existsUserIds = new HashSet<>();
|
||||
users.forEach(user -> {
|
||||
User existingUser = existingByOaLoginId.get(user.getOaLoginId());
|
||||
for (Map.Entry<String, OAPersonResponse> entry : uniquePersonMap.entrySet()) {
|
||||
OAPersonResponse oaPerson = entry.getValue();
|
||||
String oaLoginId = this.resolveOaLoginId(oaPerson);
|
||||
String workcode = this.resolveWorkcode(oaPerson);
|
||||
User user = this.userConvert.person2user(oaPerson, defaultPassword);
|
||||
user.setTenantId(tenantId);
|
||||
User existingUser = oaLoginId != null ? existingByOaLoginId.get(oaLoginId) : existingByWorkcode.get(workcode);
|
||||
if (oaLoginId != null) {
|
||||
user.setOaLoginId(oaLoginId);
|
||||
user.setAccount(oaLoginId);
|
||||
} else {
|
||||
user.setOaLoginId(null);
|
||||
user.setAccount(existingUser == null ? workcode : null);
|
||||
}
|
||||
if (existingUser != null) {
|
||||
user.setId(existingUser.getId());
|
||||
// 全局更新策略为 not_null,置空后不会回写密码和登录名
|
||||
user.setPassword(null);
|
||||
existsUserIds.add(existingUser.getId());
|
||||
} else {
|
||||
@@ -442,9 +738,10 @@ public class OaUserListSyncHelper {
|
||||
user.setDataLevelRange(1);
|
||||
user.setIncludeNewCustomer(0);
|
||||
}
|
||||
userService.encryptPhone(user);
|
||||
userMap.put(user.getOaLoginId(), user.getId());
|
||||
});
|
||||
this.userService.encryptPhone(user);
|
||||
users.add(user);
|
||||
userMap.put(entry.getKey(), user.getId());
|
||||
}
|
||||
|
||||
List<User> addUsers = users.stream()
|
||||
.filter(user -> !existsUserIds.contains(user.getId()))
|
||||
@@ -460,33 +757,58 @@ public class OaUserListSyncHelper {
|
||||
ComposeLogUtil.getLastLog().info("批量修改用户:{}", updateUsers.size());
|
||||
userService.updateBatchById(updateUsers);
|
||||
}
|
||||
ComposeLogUtil.getLastLog().info("缺少OA登录名已跳过的人员:{}", skippedCount);
|
||||
ComposeLogUtil.getLastLog().info("缺少登录名和工号已跳过的人员:{}", skippedCount);
|
||||
int syncedCount = addUsers.size() + updateUsers.size();
|
||||
if (userMap.isEmpty()) {
|
||||
return new PersonSyncCount(0, skippedCount);
|
||||
return new PersonSyncCount(0, skippedCount, removedCount);
|
||||
}
|
||||
|
||||
Map<String, Long> userDeptMap = userDeptService.list(Wrappers.<UserDept>lambdaQuery()
|
||||
.in(UserDept::getUserId, userMap.values())
|
||||
).stream()
|
||||
List<UserDept> existingUserDeptList = userDeptService.list(Wrappers.<UserDept>lambdaQuery()
|
||||
.in(UserDept::getUserId, userMap.values()));
|
||||
Map<String, Long> userDeptMap = existingUserDeptList.stream()
|
||||
.collect(Collectors.toMap(this::getUserDeptKey, UserDept::getId, (first, second) -> second));
|
||||
Set<Long> existsUserDeptIds = new HashSet<>(userDeptMap.values());
|
||||
List<UserDept> userDeptList = oaPersons.stream()
|
||||
Map<String, UserDept> currentUserDeptMap = new LinkedHashMap<>();
|
||||
oaPersons.stream()
|
||||
.filter(oaPerson -> {
|
||||
String oaLoginId = resolveOaLoginId(oaPerson);
|
||||
return StringUtils.isNotBlank(oaLoginId) && userMap.containsKey(oaLoginId);
|
||||
String syncKey = this.resolveSyncKey(oaPerson);
|
||||
return syncKey != null && userMap.containsKey(syncKey) && !this.isOaResigned(oaPerson);
|
||||
})
|
||||
.map(oaPerson -> this.buildUserDept(oaPerson, userMap, orgIndex))
|
||||
.filter(Objects::nonNull)
|
||||
.forEach(userDept -> {
|
||||
String userDeptKey = getUserDeptKey(userDept);
|
||||
if (userDeptMap.containsKey(userDeptKey)) {
|
||||
userDept.setId(userDeptMap.get(userDeptKey));
|
||||
} else {
|
||||
UserDept duplicated = currentUserDeptMap.get(userDeptKey);
|
||||
userDept.setId(duplicated == null
|
||||
? DefaultIdentifierGenerator.getInstance().nextId(null)
|
||||
: duplicated.getId());
|
||||
}
|
||||
currentUserDeptMap.put(userDeptKey, userDept);
|
||||
});
|
||||
List<UserDept> userDeptList = new ArrayList<>(currentUserDeptMap.values());
|
||||
Set<Long> currentUserDeptIds = userDeptList.stream()
|
||||
.map(UserDept::getId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
Set<Long> reboundUserIds = userDeptList.stream()
|
||||
.map(UserDept::getUserId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
List<Long> staleUserDeptIds = existingUserDeptList.stream()
|
||||
.filter(userDept -> userDept.getId() != null && reboundUserIds.contains(userDept.getUserId()))
|
||||
.map(UserDept::getId)
|
||||
.filter(userDeptId -> !currentUserDeptIds.contains(userDeptId))
|
||||
.toList();
|
||||
userDeptList.forEach(userDept -> {
|
||||
String userDeptKey = getUserDeptKey(userDept);
|
||||
if (userDeptMap.containsKey(userDeptKey)) {
|
||||
userDept.setId(userDeptMap.get(userDeptKey));
|
||||
} else {
|
||||
userDept.setId(DefaultIdentifierGenerator.getInstance().nextId(null));
|
||||
if (CollectionUtil.isNotEmpty(staleUserDeptIds)) {
|
||||
ComposeLogUtil.getLastLog().info("移除不再属于当前OA部门的人员关系:{}", staleUserDeptIds.size());
|
||||
for (int fromIndex = 0; fromIndex < staleUserDeptIds.size(); fromIndex += OA_LOGIN_ID_BATCH_SIZE) {
|
||||
List<Long> staleIdBatch = staleUserDeptIds.subList(fromIndex, Math.min(fromIndex + OA_LOGIN_ID_BATCH_SIZE, staleUserDeptIds.size()));
|
||||
userDeptService.removeByIds(staleIdBatch);
|
||||
}
|
||||
});
|
||||
}
|
||||
List<UserDept> addList = userDeptList.stream()
|
||||
.filter(userDept -> !existsUserDeptIds.contains(userDept.getId()))
|
||||
.toList();
|
||||
@@ -540,11 +862,11 @@ public class OaUserListSyncHelper {
|
||||
if (CollectionUtil.isNotEmpty(updateUserParams)) {
|
||||
userService.updateBatchById(updateUserParams);
|
||||
}
|
||||
return new PersonSyncCount(syncedCount, skippedCount);
|
||||
return new PersonSyncCount(syncedCount, skippedCount, removedCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 OA 登录名加载已有用户。同一登录名有多条时保留较早的一条,后续同步都更新这一条。
|
||||
* 按 OA 登录名加载已有 is_oa=2 用户。同一登录名有多条时保留较早的一条,后续同步都更新这一条。
|
||||
*/
|
||||
private Map<String, User> loadUsersByOaLoginId(String tenantId, Collection<String> oaLoginIds) {
|
||||
Map<String, User> existingByOaLoginId = new HashMap<>();
|
||||
@@ -555,11 +877,11 @@ public class OaUserListSyncHelper {
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.distinct()
|
||||
.toList();
|
||||
int batchSize = 500;
|
||||
for (int fromIndex = 0; fromIndex < oaLoginIdList.size(); fromIndex += batchSize) {
|
||||
List<String> oaLoginIdBatch = oaLoginIdList.subList(fromIndex, Math.min(fromIndex + batchSize, oaLoginIdList.size()));
|
||||
for (int fromIndex = 0; fromIndex < oaLoginIdList.size(); fromIndex += OA_LOGIN_ID_BATCH_SIZE) {
|
||||
List<String> oaLoginIdBatch = oaLoginIdList.subList(fromIndex, Math.min(fromIndex + OA_LOGIN_ID_BATCH_SIZE, oaLoginIdList.size()));
|
||||
userService.list(Wrappers.<User>lambdaQuery()
|
||||
.eq(User::getTenantId, tenantId)
|
||||
.eq(User::getIsOa, CommonConstant.YES)
|
||||
.in(User::getOaLoginId, oaLoginIdBatch))
|
||||
.forEach(user -> {
|
||||
String oaLoginId = StringUtils.trimToNull(user.getOaLoginId());
|
||||
@@ -584,6 +906,39 @@ public class OaUserListSyncHelper {
|
||||
return existingByOaLoginId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按工号加载没有登录名的 is_oa=2 用户。同一工号有多条时保留较早的一条。
|
||||
*/
|
||||
private Map<String, User> loadUsersByBlankLoginWorkcode(String tenantId, Collection<String> workcodes) {
|
||||
Map<String, User> existingByWorkcode = new HashMap<>();
|
||||
if (CollectionUtil.isEmpty(workcodes)) {
|
||||
return existingByWorkcode;
|
||||
}
|
||||
List<String> workcodeList = workcodes.stream()
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.distinct()
|
||||
.toList();
|
||||
for (int fromIndex = 0; fromIndex < workcodeList.size(); fromIndex += OA_LOGIN_ID_BATCH_SIZE) {
|
||||
List<String> workcodeBatch = workcodeList.subList(fromIndex, Math.min(fromIndex + OA_LOGIN_ID_BATCH_SIZE, workcodeList.size()));
|
||||
this.userService.list(Wrappers.<User>lambdaQuery()
|
||||
.eq(User::getTenantId, tenantId)
|
||||
.eq(User::getIsOa, CommonConstant.YES)
|
||||
.in(User::getCode, workcodeBatch)
|
||||
.and(wrapper -> wrapper.isNull(User::getOaLoginId).or().eq(User::getOaLoginId, "")))
|
||||
.forEach(user -> {
|
||||
String workcode = StringUtils.trimToNull(user.getCode());
|
||||
if (workcode == null) {
|
||||
return;
|
||||
}
|
||||
User previous = existingByWorkcode.get(workcode);
|
||||
if (previous == null || (user.getId() != null && previous.getId() != null && user.getId() < previous.getId())) {
|
||||
existingByWorkcode.put(workcode, user);
|
||||
}
|
||||
});
|
||||
}
|
||||
return existingByWorkcode;
|
||||
}
|
||||
|
||||
private String resolveOaLoginId(OAPersonResponse oaPerson) {
|
||||
if (oaPerson == null) {
|
||||
return null;
|
||||
@@ -591,6 +946,28 @@ public class OaUserListSyncHelper {
|
||||
return StringUtils.trimToNull(oaPerson.getLoginid());
|
||||
}
|
||||
|
||||
private String resolveWorkcode(OAPersonResponse oaPerson) {
|
||||
if (oaPerson == null) {
|
||||
return null;
|
||||
}
|
||||
return StringUtils.trimToNull(oaPerson.getWorkcode());
|
||||
}
|
||||
|
||||
/**
|
||||
* 有登录名用登录名,没有登录名但有工号用工号。两者都没有则不同步。
|
||||
*/
|
||||
private String resolveSyncKey(OAPersonResponse oaPerson) {
|
||||
String oaLoginId = this.resolveOaLoginId(oaPerson);
|
||||
if (oaLoginId != null) {
|
||||
return "L:" + oaLoginId;
|
||||
}
|
||||
String workcode = this.resolveWorkcode(oaPerson);
|
||||
if (workcode != null) {
|
||||
return "W:" + workcode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean isPersonBypassCode(String deptCode, DeptCategory deptCategory) {
|
||||
if (StringUtils.isBlank(deptCode)) {
|
||||
return true;
|
||||
@@ -635,6 +1012,11 @@ public class OaUserListSyncHelper {
|
||||
return null;
|
||||
}
|
||||
UserDept userDept = userConvert.person2userDept(oaPerson, userMap);
|
||||
String syncKey = this.resolveSyncKey(oaPerson);
|
||||
userDept.setUserId(syncKey == null ? null : userMap.get(syncKey));
|
||||
if (StringUtils.isBlank(this.resolveOaLoginId(oaPerson))) {
|
||||
userDept.setOaLoginId(null);
|
||||
}
|
||||
if (userDept.getUserId() == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -684,10 +1066,12 @@ public class OaUserListSyncHelper {
|
||||
public static class PersonSyncCount {
|
||||
private final int syncedCount;
|
||||
private final int skippedCount;
|
||||
private final int removedCount;
|
||||
|
||||
public PersonSyncCount(int syncedCount, int skippedCount) {
|
||||
public PersonSyncCount(int syncedCount, int skippedCount, int removedCount) {
|
||||
this.syncedCount = syncedCount;
|
||||
this.skippedCount = skippedCount;
|
||||
this.removedCount = removedCount;
|
||||
}
|
||||
|
||||
public int getSyncedCount() {
|
||||
@@ -697,6 +1081,10 @@ public class OaUserListSyncHelper {
|
||||
public int getSkippedCount() {
|
||||
return skippedCount;
|
||||
}
|
||||
|
||||
public int getRemovedCount() {
|
||||
return removedCount;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+3
-10
@@ -92,7 +92,7 @@ public class DriverWaybillController extends BladeController {
|
||||
@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 = "关键字:运单号/起终点") @RequestParam(required = false) String keyword) {
|
||||
@Parameter(description = "关键字:运单号/配载单号/项目/司机/车牌/起终点/地址") @RequestParam(required = false) String keyword) {
|
||||
Integer statusCode = parseStatus(status);
|
||||
return R.data(driverWaybillService.page(current, size, statusCode, keyword));
|
||||
}
|
||||
@@ -129,12 +129,12 @@ public class DriverWaybillController extends BladeController {
|
||||
|
||||
@GetMapping("/task/page")
|
||||
@ApiOperationSupport(order = 24)
|
||||
@Operation(summary = "运次分页列表", description = "一页即若干个运次;status:空=全部,0待接单,1运输中,2已完成;keyword 支持运单号/配载单号/起终点/车牌")
|
||||
@Operation(summary = "运次分页列表", description = "一页即若干个运次;status:空=全部,0待接单,1运输中,2已完成;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 = "关键字:运单号/配载单号/起终点/车牌") @RequestParam(required = false) String keyword) {
|
||||
@Parameter(description = "关键字:运单号/配载单号/项目/司机/车牌/起终点/地址") @RequestParam(required = false) String keyword) {
|
||||
Integer statusCode = parseStatus(status);
|
||||
return R.data(driverWaybillService.tripPage(current, size, statusCode, keyword));
|
||||
}
|
||||
@@ -201,13 +201,6 @@ public class DriverWaybillController extends BladeController {
|
||||
return R.data(driverWaybillService.submitNode(dto));
|
||||
}
|
||||
|
||||
@PostMapping("/complete")
|
||||
@ApiOperationSupport(order = 10)
|
||||
@Operation(summary = "司机完成运单", description = "校验司机归属后改状态为已完成,并检查生成应收应付明细(与管理端一致)")
|
||||
public R complete(@Parameter(description = "运单ID", required = true) @RequestParam Long id) {
|
||||
return R.status(driverWaybillService.complete(id));
|
||||
}
|
||||
|
||||
/** 前端可能传空字符串表示「全部」 */
|
||||
private Integer parseStatus(String status) {
|
||||
if (Func.isEmpty(status)) {
|
||||
|
||||
+27
-1
@@ -33,6 +33,8 @@ import org.springblade.core.mp.support.Query;
|
||||
import org.springblade.core.secure.annotation.PreAuth;
|
||||
import org.springblade.core.tool.api.R;
|
||||
import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest;
|
||||
import org.springblade.transport.pojo.dto.ExceptionDisposalRejectRequest;
|
||||
import org.springblade.transport.pojo.dto.ExceptionDisposalRouteAdjustRequest;
|
||||
import org.springblade.transport.pojo.dto.ExceptionDisposalUpdateRequest;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.vo.ExceptionDisposalVO;
|
||||
@@ -50,7 +52,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
* <p>
|
||||
* 对外路径:{@code /api/blade-transport/exception-disposal/**}
|
||||
* 同时兼容未去前缀直连 {@code /blade-transport/exception-disposal/**}。
|
||||
* 列表 / 详情 / 上报 / 跟进 / 完成均仅需登录态(小程序调度端与司机端共用)。
|
||||
* 列表 / 详情 / 上报 / 跟进 / 完成 / 拒绝 / 撤销均仅需登录态(小程序调度端与司机端共用)。
|
||||
*/
|
||||
@RestController
|
||||
@AllArgsConstructor
|
||||
@@ -114,4 +116,28 @@ public class ExceptionDisposalController extends BladeController {
|
||||
return R.success("编辑成功");
|
||||
}
|
||||
|
||||
@PostMapping("/reject")
|
||||
@ApiOperationSupport(order = 8)
|
||||
@Operation(summary = "拒绝异常", description = "调度端拒绝上报:先写一条拒绝原因的跟进记录,再了结;仅需登录态")
|
||||
public R reject(@RequestBody ExceptionDisposalRejectRequest request) {
|
||||
exceptionDisposalService.reject(request);
|
||||
return R.success("已拒绝");
|
||||
}
|
||||
|
||||
@PostMapping("/revoke")
|
||||
@ApiOperationSupport(order = 9)
|
||||
@Operation(summary = "撤销异常上报", description = "上报人撤销自己未了结的上报,不写跟进记录;仅需登录态")
|
||||
public R revoke(@RequestBody ExceptionDisposalFollowRequest request) {
|
||||
exceptionDisposalService.revoke(request.getId());
|
||||
return R.success("已撤销");
|
||||
}
|
||||
|
||||
@PostMapping("/route-adjust")
|
||||
@ApiOperationSupport(order = 10)
|
||||
@Operation(summary = "同意并调整路线", description = "调度端同意上报的路线修改:先写跟进记录,再改路线,最后了结;仅需登录态")
|
||||
public R adjustRoute(@RequestBody ExceptionDisposalRouteAdjustRequest request) {
|
||||
exceptionDisposalService.adjustRoute(request);
|
||||
return R.success("已调整路线");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+2
-2
@@ -88,7 +88,7 @@ public class ManageWaybillController extends BladeController {
|
||||
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 = "关键字:运单号/配载单号/项目/司机/车牌/起终点/地址") @RequestParam(required = false) String keyword,
|
||||
@Parameter(description = "状态:0待接单/1运输中/2已完成,不传为全部") @RequestParam(required = false) String status,
|
||||
@Parameter(description = "异常:exception有异常/normal无异常") @RequestParam(required = false) String exception,
|
||||
@Parameter(description = "运输组织:common普通/load配载") @RequestParam(required = false) String transportType,
|
||||
@@ -112,7 +112,7 @@ public class ManageWaybillController extends BladeController {
|
||||
public R<IPage<AdminWaybillCardVO>> pending(
|
||||
@Parameter(description = "当前页") @RequestParam(required = false) Integer current,
|
||||
@Parameter(description = "每页条数") @RequestParam(required = false) Integer size,
|
||||
@Parameter(description = "关键字:运单号/司机/车牌") @RequestParam(required = false) String keyword,
|
||||
@Parameter(description = "关键字:运单号/配载单号/项目/司机/车牌/起终点/地址") @RequestParam(required = false) String keyword,
|
||||
@Parameter(description = "是否需重新派单") @RequestParam(required = false) Boolean needReassign) {
|
||||
return R.data(manageWaybillService.pendingList(current, size, keyword, needReassign));
|
||||
}
|
||||
|
||||
+1
-1
@@ -145,7 +145,7 @@ public final class SettlementImportTemplateExporter {
|
||||
List<String> lines = new ArrayList<>();
|
||||
lines.add("导入结算单填写说明");
|
||||
lines.add("1. 请不要修改「导入结算单模板」工作表的固定表头;带 * 号为必填项。");
|
||||
lines.add("2. 运输方式请从下拉项中选择。");
|
||||
lines.add("2. 运输方式请从下拉项中选择,须与业务字典 transport_type 的字典名称一致。");
|
||||
lines.add("3. 日期请填写 yyyy-MM-dd(如 2026-01-01);Excel 日期单元格也可直接选择。");
|
||||
lines.add("4. 里程(km) 可为空;填写时须为正数。");
|
||||
lines.add("5. 「*运输费」之后、「*实际发货时间」之前可按需自行插入费用项列,列名须与系统已启用费用项名称完全一致;金额不为 0 时若不存在或已停用则无法导入。");
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.feign;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springblade.core.tenant.annotation.NonDS;
|
||||
import org.springblade.core.tool.api.R;
|
||||
import org.springblade.transport.pojo.dto.DriverEnsureProfileDTO;
|
||||
import org.springblade.transport.pojo.vo.DriverEnsureProfileVO;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 司机档案 Feign 实现类
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@NonDS
|
||||
@Hidden
|
||||
@RestController
|
||||
@AllArgsConstructor
|
||||
public class DriverClient implements IDriverClient {
|
||||
|
||||
private final IDriverService driverService;
|
||||
|
||||
@Override
|
||||
@PostMapping(ENSURE_PROFILE)
|
||||
public R<DriverEnsureProfileVO> ensureProfile(@RequestBody DriverEnsureProfileDTO param) {
|
||||
if (param == null) {
|
||||
return R.fail("司机档案参数不能为空");
|
||||
}
|
||||
return R.data(driverService.ensureProfile(param.getTenantId(), param.getMpUserId(), param.getPhone()));
|
||||
}
|
||||
|
||||
}
|
||||
+11
@@ -30,6 +30,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import org.springblade.core.mp.base.BaseService;
|
||||
import org.springblade.transport.excel.DriverExcel;
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
import org.springblade.transport.pojo.vo.DriverEnsureProfileVO;
|
||||
import org.springblade.transport.pojo.vo.DriverExpiryStatVO;
|
||||
import org.springblade.transport.pojo.vo.DriverVO;
|
||||
|
||||
@@ -59,6 +60,16 @@ public interface IDriverService extends BaseService<Driver> {
|
||||
*/
|
||||
boolean submit(Driver driver);
|
||||
|
||||
/**
|
||||
* 查找或创建司机档案并绑定小程序账号(登录路径专用)
|
||||
*
|
||||
* @param tenantId 租户ID,必须非空
|
||||
* @param mpUserId 小程序用户ID
|
||||
* @param phone 手机号
|
||||
* @return 档案ID与本次处置标志;冲突时 message 携带可读原因
|
||||
*/
|
||||
DriverEnsureProfileVO ensureProfile(String tenantId, Long mpUserId, String phone);
|
||||
|
||||
/**
|
||||
* 修改状态
|
||||
*
|
||||
|
||||
-5
@@ -157,11 +157,6 @@ public interface IDriverWaybillService {
|
||||
*/
|
||||
DriverNodePunchVO submitNode(NodeSubmitDTO dto);
|
||||
|
||||
/**
|
||||
* 司机完成运单:校验归属后改状态为已完成,并走与管理端相同的应收应付明细生成逻辑。
|
||||
*/
|
||||
boolean complete(Long id);
|
||||
|
||||
/**
|
||||
* 按运单与节点 key 取该运单实际生效的过程节点配置;节点不存在时返回 null。
|
||||
* <p>
|
||||
|
||||
+19
@@ -25,6 +25,8 @@ package org.springblade.transport.service;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import org.springblade.core.mp.base.BaseService;
|
||||
import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest;
|
||||
import org.springblade.transport.pojo.dto.ExceptionDisposalRejectRequest;
|
||||
import org.springblade.transport.pojo.dto.ExceptionDisposalRouteAdjustRequest;
|
||||
import org.springblade.transport.pojo.dto.ExceptionDisposalUpdateRequest;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.vo.ExceptionDisposalVO;
|
||||
@@ -56,4 +58,21 @@ public interface IExceptionDisposalService extends BaseService<ExceptionDisposal
|
||||
|
||||
void batchComplete(String ids);
|
||||
|
||||
/**
|
||||
* 拒绝异常:先写一条「拒绝:原因」的跟进记录,再了结。
|
||||
* 拒绝了结复用已完成终态,不新增状态值。
|
||||
*/
|
||||
void reject(ExceptionDisposalRejectRequest request);
|
||||
|
||||
/**
|
||||
* 撤销异常上报:仅限上报人本人、仅限未了结,置为已撤销,不留跟进记录
|
||||
*/
|
||||
void revoke(Long id);
|
||||
|
||||
/**
|
||||
* 同意并调整路线:先写一条调整说明的跟进记录,再改载体路线,最后了结。
|
||||
* 有配载单时改配载单的站点行路线,没有配载单时只按路线段改运单的地址。
|
||||
*/
|
||||
void adjustRoute(ExceptionDisposalRouteAdjustRequest request);
|
||||
|
||||
}
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@ public interface IWaybillService extends BaseService<Waybill> {
|
||||
boolean complete(Long id);
|
||||
|
||||
/**
|
||||
* 司机端完成运单:跳过管理端部门校验,其余逻辑与 {@link #complete(Long)} 一致
|
||||
* 末位打卡自动完成:跳过管理端部门校验,其余逻辑与 {@link #complete(Long)} 一致
|
||||
* (改状态 + 生成应收应付明细 + 尝试完成配载单)。
|
||||
*/
|
||||
boolean completeWithoutDeptCheck(Long id);
|
||||
|
||||
+6
-6
@@ -349,8 +349,8 @@ public class CertAuditServiceImpl implements ICertAuditService {
|
||||
if (Func.isEmpty(snapshot.getIdCardNo()) && Func.isEmpty(snapshot.getDriverFileNo())) {
|
||||
return;
|
||||
}
|
||||
// 身份证号要落到档案上,先确认它没有落在别人名下:该列没有唯一索引,
|
||||
// 直接写会静默产生两条同证件号的档案,管理端的证件号查重从此失效
|
||||
// 身份证号要落到档案上,先确认它没有落在别人名下:重复证件号一旦落库,
|
||||
// 建档查重与按证件号识别同一人都会失效
|
||||
requireIdCardNotTaken(snapshot.getIdCardNo(), driver.getId());
|
||||
driver.setDriverName(snapshot.getRealName());
|
||||
driver.setIdCardNo(snapshot.getIdCardNo());
|
||||
@@ -382,10 +382,10 @@ public class CertAuditServiceImpl implements ICertAuditService {
|
||||
/**
|
||||
* 身份证号不得落在另一名司机名下
|
||||
* <p>
|
||||
* 司机申报的实名内容会写回档案,其中身份证号是管理端建档时的查重判据,
|
||||
* 而 {@code blade_transport_driver.id_card_no} 上没有唯一索引:若申报的证件号
|
||||
* 已属于另一条档案,直接写会静默产生两条同证件号的档案,后续建档查重、
|
||||
* 按证件号识别同一人都将失效。此处阻断审核并整体回滚,交由管理端先处理档案归属。
|
||||
* 司机申报的实名内容会写回档案,其中身份证号是管理端建档时的查重判据。
|
||||
* {@code blade_transport_driver.id_card_no} 上有唯一索引
|
||||
* {@code uk_transport_driver_id_card};证件号已属于另一条档案时,
|
||||
* 此处阻断审核并整体回滚,交由管理端先处理档案归属。
|
||||
* <p>
|
||||
* 证件号与当前档案相同(绝大多数情况)、或库里尚无该号时不阻断;
|
||||
* 快照没带证件号时无从判断,同样不阻断(历史快照就是这种)。
|
||||
|
||||
+150
@@ -29,6 +29,7 @@ import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.mp.base.BaseServiceImpl;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
@@ -45,6 +46,7 @@ import org.springblade.transport.excel.DriverExcel;
|
||||
import org.springblade.transport.mapper.DriverMapper;
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
import org.springblade.transport.pojo.entity.TransportVehicle;
|
||||
import org.springblade.transport.pojo.vo.DriverEnsureProfileVO;
|
||||
import org.springblade.transport.pojo.vo.DriverExpiryStatVO;
|
||||
import org.springblade.transport.pojo.vo.DriverVO;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
@@ -53,6 +55,7 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
@@ -61,6 +64,7 @@ import java.util.Objects;
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DriverServiceImpl extends BaseServiceImpl<DriverMapper, Driver> implements IDriverService {
|
||||
@@ -74,6 +78,21 @@ public class DriverServiceImpl extends BaseServiceImpl<DriverMapper, Driver> imp
|
||||
private static final int REMARK_MAX_LENGTH = 200;
|
||||
private static final int DEFAULT_ENABLED_STATUS = 1;
|
||||
private static final int DEFAULT_FALSE = 0;
|
||||
/**
|
||||
* 档案来源:登录时自动新建
|
||||
*/
|
||||
private static final String PROFILE_SOURCE_LOGIN_AUTO = "login_auto";
|
||||
/**
|
||||
* 档案来源:绑定到已有档案
|
||||
*/
|
||||
private static final String PROFILE_SOURCE_LOGIN_BOUND = "login_bound";
|
||||
/**
|
||||
* 自动建档的默认岗位:从小程序司机端登录即视为司机。
|
||||
* 岗位为空会让档案被派单候选筛掉。
|
||||
*/
|
||||
private static final String PROFILE_DEFAULT_POST = "司机";
|
||||
private static final String CONFLICT_MOBILE_OCCUPIED = "该手机号已被其他账号占用,请联系管理员";
|
||||
private static final String CONFLICT_MOBILE_MULTIPLE = "该手机号存在多条司机档案,请联系管理员核对";
|
||||
|
||||
private final IMpUserClient mpUserClient;
|
||||
private final ISysClient sysClient;
|
||||
@@ -101,6 +120,137 @@ public class DriverServiceImpl extends BaseServiceImpl<DriverMapper, Driver> imp
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public DriverEnsureProfileVO ensureProfile(String tenantId, Long mpUserId, String phone) {
|
||||
String resolvedTenantId = trimToNull(tenantId);
|
||||
if (resolvedTenantId == null) {
|
||||
resolvedTenantId = trimToNull(AuthUtil.getTenantId());
|
||||
}
|
||||
// 租户是档案归属与手机号查重的范围,tenant_id 为空的档案不能被后续同号查询命中,
|
||||
// 故此处拒绝写库(登录层只记 WARN)
|
||||
if (resolvedTenantId == null) {
|
||||
throw new ServiceException("租户信息为空,无法创建司机档案");
|
||||
}
|
||||
if (Func.isEmpty(mpUserId)) {
|
||||
throw new ServiceException("小程序用户ID不能为空");
|
||||
}
|
||||
String mobile = trimToNull(phone);
|
||||
if (mobile == null) {
|
||||
throw new ServiceException("手机号不能为空");
|
||||
}
|
||||
// 脱敏手机号(如 155****2748)不是可用的业务数据:写进档案后按手机号查不到司机
|
||||
if (mobile.contains("*")) {
|
||||
throw new ServiceException("手机号无效,不能写入司机档案");
|
||||
}
|
||||
// 查找顺序固定为 user_id → phone:user_id 是账号的稳定标识,
|
||||
// phone 会被运营商二次放号
|
||||
Driver bound = findDriverByUserId(mpUserId);
|
||||
if (bound != null) {
|
||||
return ensureResult(bound.getId(), false, false, null);
|
||||
}
|
||||
List<Driver> sameMobile = list(Wrappers.<Driver>lambdaQuery()
|
||||
.eq(Driver::getTenantId, resolvedTenantId)
|
||||
.eq(Driver::getMobile, mobile));
|
||||
if (sameMobile.isEmpty()) {
|
||||
return createPlaceholderProfile(resolvedTenantId, mpUserId, mobile);
|
||||
}
|
||||
if (sameMobile.size() > 1) {
|
||||
return ensureResult(null, false, false, CONFLICT_MOBILE_MULTIPLE);
|
||||
}
|
||||
Driver existing = sameMobile.get(0);
|
||||
if (Func.isNotEmpty(existing.getUserId())) {
|
||||
if (Objects.equals(existing.getUserId(), mpUserId)) {
|
||||
return ensureResult(existing.getId(), false, false, null);
|
||||
}
|
||||
return ensureResult(null, false, false, CONFLICT_MOBILE_OCCUPIED);
|
||||
}
|
||||
return bindExistingProfile(existing.getId(), mpUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按小程序账号反查档案;user_id 有全局唯一索引,故不叠加租户条件
|
||||
*/
|
||||
private Driver findDriverByUserId(Long mpUserId) {
|
||||
return getOne(Wrappers.<Driver>lambdaQuery()
|
||||
.eq(Driver::getUserId, mpUserId)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 新建占位档案
|
||||
* <p>
|
||||
* 不走 {@link #submit}:那条路径要求 15 项必填。这里只满足表约束——
|
||||
* id_card_no 留 NULL(唯一索引对 NULL 不去重,NULL 同时是「未采集」的判据),
|
||||
* 其余 NOT NULL 列写空串或可读占位。
|
||||
*/
|
||||
private DriverEnsureProfileVO createPlaceholderProfile(String tenantId, Long mpUserId, String mobile) {
|
||||
Driver driver = new Driver();
|
||||
driver.setTenantId(tenantId);
|
||||
driver.setUserId(mpUserId);
|
||||
driver.setDriverName("司机" + mobileTail(mobile));
|
||||
driver.setMobile(mobile);
|
||||
driver.setDrivingType("");
|
||||
driver.setDriverType("");
|
||||
driver.setPosts(PROFILE_DEFAULT_POST);
|
||||
driver.setOrganizationName("");
|
||||
driver.setEmergencyContactName("");
|
||||
driver.setEmergencyContactMobile("");
|
||||
driver.setStatus(DEFAULT_ENABLED_STATUS);
|
||||
driver.setProfileSource(PROFILE_SOURCE_LOGIN_AUTO);
|
||||
driver.setAccountBoundTime(LocalDateTime.now());
|
||||
try {
|
||||
save(driver);
|
||||
} catch (Exception exception) {
|
||||
// 并发登录撞 uk_transport_driver_user:重查一次,说明另一个请求已经建好档案
|
||||
Driver concurrent = findDriverByUserId(mpUserId);
|
||||
if (concurrent != null) {
|
||||
return ensureResult(concurrent.getId(), false, false, null);
|
||||
}
|
||||
log.error("自动创建司机档案失败,租户={} 账号={}", tenantId, mpUserId, exception);
|
||||
throw new ServiceException("创建司机档案失败");
|
||||
}
|
||||
return ensureResult(driver.getId(), true, false, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把已有档案绑定到账号:只 patch 关联字段与留痕,不覆盖档案内容
|
||||
*/
|
||||
private DriverEnsureProfileVO bindExistingProfile(Long driverId, Long mpUserId) {
|
||||
Driver patch = new Driver();
|
||||
patch.setId(driverId);
|
||||
patch.setUserId(mpUserId);
|
||||
patch.setProfileSource(PROFILE_SOURCE_LOGIN_BOUND);
|
||||
patch.setAccountBoundTime(LocalDateTime.now());
|
||||
// 只改 user_id 也必须带 driving_vehicle 原值,理由见 carryDrivingVehicle
|
||||
carryDrivingVehicle(driverId, patch);
|
||||
try {
|
||||
updateById(patch);
|
||||
} catch (Exception exception) {
|
||||
// 并发绑定撞 uk_transport_driver_user:另一个请求已经把这个账号绑到档案上
|
||||
Driver concurrent = findDriverByUserId(mpUserId);
|
||||
if (concurrent != null) {
|
||||
return ensureResult(concurrent.getId(), false, false, null);
|
||||
}
|
||||
log.error("绑定司机档案失败,档案={} 账号={}", driverId, mpUserId, exception);
|
||||
throw new ServiceException("绑定司机档案失败");
|
||||
}
|
||||
return ensureResult(driverId, false, true, null);
|
||||
}
|
||||
|
||||
private DriverEnsureProfileVO ensureResult(Long driverId, boolean created, boolean boundExisting, String message) {
|
||||
DriverEnsureProfileVO result = new DriverEnsureProfileVO();
|
||||
result.setDriverId(driverId);
|
||||
result.setCreated(created);
|
||||
result.setBoundExisting(boundExisting);
|
||||
result.setMessage(message);
|
||||
return result;
|
||||
}
|
||||
|
||||
private String mobileTail(String mobile) {
|
||||
return mobile.length() <= 4 ? mobile : mobile.substring(mobile.length() - 4);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean changeStatus(Long id, Integer status) {
|
||||
|
||||
+37
-57
@@ -107,7 +107,6 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
private static final String STATUS_WAITING_DISPATCH = "waiting_dispatch";
|
||||
private static final String STATUS_DISPATCHING = "dispatching";
|
||||
private static final String STATUS_RUNNING = "running";
|
||||
private static final String STATUS_PENDING_RETURN = WaybillProcessSupport.STATUS_PENDING_RETURN;
|
||||
private static final String STATUS_COMPLETED = "completed";
|
||||
private static final String STATUS_CANCELLED = "cancelled";
|
||||
|
||||
@@ -119,14 +118,18 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
private static final List<String> PENDING_STATUSES = Arrays.asList(
|
||||
STATUS_PENDING, STATUS_WAITING_DISPATCH, STATUS_DISPATCHING
|
||||
);
|
||||
/** Tab「全部」统计口径:待接 + 运输中 + 待确认回单 + 已完成(不含已取消) */
|
||||
/** Tab「全部」统计口径:待接 + 运输中 + 待回单确认 + 已完成(不含已取消) */
|
||||
private static final List<String> TAB_ALL_STATUSES = Arrays.asList(
|
||||
STATUS_PENDING, STATUS_WAITING_DISPATCH, STATUS_DISPATCHING, STATUS_RUNNING,
|
||||
STATUS_PENDING_RETURN, STATUS_COMPLETED
|
||||
WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM, STATUS_COMPLETED
|
||||
);
|
||||
/** 允许完成运单的业务状态 */
|
||||
private static final List<String> COMPLETABLE_STATUSES = Arrays.asList(
|
||||
STATUS_RUNNING, STATUS_PENDING, STATUS_WAITING_DISPATCH, STATUS_DISPATCHING
|
||||
/**
|
||||
* 进行中口径:司机手上还没走完的单,含待回单确认。
|
||||
* 待回单确认指司机已打完末位卡、等业务员确认回单,此时业务状态还不是 completed。
|
||||
*/
|
||||
private static final List<String> ACTIVE_STATUSES = Arrays.asList(
|
||||
STATUS_RUNNING, STATUS_PENDING, STATUS_WAITING_DISPATCH, STATUS_DISPATCHING,
|
||||
WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM
|
||||
);
|
||||
|
||||
private static final int DEFAULT_PREVIEW_SIZE = 2;
|
||||
@@ -522,35 +525,35 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
nodePunchMapper.insert(punch);
|
||||
|
||||
advanceCurrentProcessNode(waybill, nodeCfg);
|
||||
// 末位打卡节点是到货/卸货/签收时:
|
||||
// - 回单无需确认 → 直接完成;
|
||||
// - 回单需确认 → 进入「待确认回单」,等运管员批量/单条完成后才变已完成。
|
||||
// isLastDriverPunchNode 与 returnNeedsNoConfirmation 传的就是上面那份 processJson,未再调 resolveProcessJson 取过程配置。
|
||||
// 末位打卡节点为到货/卸货/签收、且运单允许完成时:回单无需确认则本次打卡一并完成运单,
|
||||
// 回单需业务员确认则停在待回单确认。
|
||||
// isLastDriverPunchNode 与 returnNeedsNoConfirmation 传的是上面那份 processJson,未再调 resolveProcessJson 取过程配置。
|
||||
String nodeKey = WaybillProcessSupport.nodeKey(nodeCfg);
|
||||
String nodeName = WaybillProcessSupport.nodeName(nodeCfg);
|
||||
String waybillBusinessStatus = waybill.getBusinessStatus();
|
||||
boolean completed = false;
|
||||
if (WaybillProcessSupport.isLastDriverPunchNode(processJson, nodeKey)
|
||||
&& WaybillProcessSupport.isDeliveryNode(nodeKey, nodeName)
|
||||
&& canAutoCompleteWaybill(waybill)) {
|
||||
if (WaybillProcessSupport.returnNeedsNoConfirmation(processJson)) {
|
||||
completed = waybillService.completeWithoutDeptCheck(waybill.getId());
|
||||
} else {
|
||||
markPendingReturn(waybill);
|
||||
if (completed) {
|
||||
waybillBusinessStatus = STATUS_COMPLETED;
|
||||
}
|
||||
} else if (WaybillProcessSupport.STATUS_RUNNING.equalsIgnoreCase(waybill.getBusinessStatus())) {
|
||||
// 当前业务状态为 running 才置为待回单确认;canAutoCompleteWaybill 的状态口径还含待执行/待调度。
|
||||
// 待回单确认在 isPunchEnded 里与已完成、已取消同属结束态。
|
||||
waybill.setBusinessStatus(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM);
|
||||
waybillService.updateById(waybill);
|
||||
waybillBusinessStatus = WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM;
|
||||
}
|
||||
}
|
||||
DriverNodePunchVO vo = toNodePunchVO(punch);
|
||||
vo.setWaybillCompleted(completed);
|
||||
vo.setWaybillBusinessStatus(waybillBusinessStatus);
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 司机末位打卡完成且回单需内部确认时,将运单置为待确认回单。
|
||||
*/
|
||||
private void markPendingReturn(Waybill waybill) {
|
||||
waybill.setBusinessStatus(STATUS_PENDING_RETURN);
|
||||
waybillService.updateById(waybill);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean accept(Long id) {
|
||||
@@ -626,24 +629,6 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
return waybillService.updateById(waybill);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean complete(Long id) {
|
||||
if (id == null) {
|
||||
throw new ServiceException("运单ID不能为空");
|
||||
}
|
||||
requireCurrentDriver();
|
||||
Waybill waybill = loadDriverWaybill(id);
|
||||
waybill = normalizeAcceptStatus(waybill);
|
||||
if (!COMPLETABLE_STATUSES.contains(waybill.getBusinessStatus())) {
|
||||
throw new ServiceException("当前运单状态不允许完成");
|
||||
}
|
||||
// 有未了结异常时不允许完成——完成接口不要求节点全部打卡,不挡住就能绕过打卡门禁
|
||||
assertNoUnresolvedException(waybill.getId());
|
||||
// 与管理端完成逻辑一致:改状态 + 生成应收应付明细 + 尝试完成配载单
|
||||
return waybillService.completeWithoutDeptCheck(waybill.getId());
|
||||
}
|
||||
|
||||
private void assertAcceptable(Waybill waybill) {
|
||||
if (WaybillProcessSupport.isTerminalBusinessStatus(waybill.getBusinessStatus())) {
|
||||
throw new ServiceException("当前运单状态不允许接单");
|
||||
@@ -858,8 +843,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
}
|
||||
switch (status) {
|
||||
case 0 -> wrapper.in(Waybill::getBusinessStatus, PENDING_STATUSES);
|
||||
case 1 -> wrapper.in(Waybill::getBusinessStatus, COMPLETABLE_STATUSES);
|
||||
case 2 -> wrapper.in(Waybill::getBusinessStatus, STATUS_COMPLETED, STATUS_PENDING_RETURN);
|
||||
case 1 -> wrapper.in(Waybill::getBusinessStatus, ACTIVE_STATUSES);
|
||||
case 2 -> wrapper.eq(Waybill::getBusinessStatus, STATUS_COMPLETED);
|
||||
case 3 -> wrapper.eq(Waybill::getBusinessStatus, STATUS_CANCELLED);
|
||||
default -> wrapper.in(Waybill::getBusinessStatus, TAB_ALL_STATUSES);
|
||||
}
|
||||
@@ -1026,6 +1011,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
WaybillCargoSupport.Quantity weighing = WaybillCargoSupport.weighing(cargos);
|
||||
card.setWeight(weighing == null ? "" : formatWeight(weighing.value(), weighing.unit()));
|
||||
card.setStatus(toAppStatus(waybill.getBusinessStatus()));
|
||||
card.setBusinessStatus(waybill.getBusinessStatus());
|
||||
String createTime = formatDateTime(waybill.getCreateTime());
|
||||
card.setCreateTime(createTime);
|
||||
card.setPublishTime(createTime);
|
||||
@@ -1127,6 +1113,13 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
card.setId(parseId(children.isEmpty() ? trip.getId() : children.get(0).getId()));
|
||||
card.setWaybillNo(children.isEmpty() ? card.getTripNo() : Func.toStr(children.get(0).getWaybillNo(), ""));
|
||||
card.setStatus(trip.getStatus());
|
||||
// 原始业务状态只在整趟车子单状态唯一时下发:配载车混状态时一趟车没有唯一原始状态,给 null 由前端按数字态展示
|
||||
List<String> childBusinessStatuses = children.stream()
|
||||
.map(TripVO.TripWaybillVO::getBusinessStatus)
|
||||
.filter(Func::isNotBlank)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
card.setBusinessStatus(childBusinessStatuses.size() == 1 ? childBusinessStatuses.get(0) : null);
|
||||
card.setRequireAccept(trip.getRequireAccept());
|
||||
card.setAcceptStatus(trip.getAcceptStatus());
|
||||
card.setTransportOrgType(trip.getTransportOrgType());
|
||||
@@ -1534,27 +1527,15 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动完成的前置:与司机端「完成运单」同一套——业务状态允许完成,且没有未了结异常。
|
||||
* 自动完成的前置:业务状态在进行中口径内,且没有未了结异常。
|
||||
* <p>
|
||||
* 不满足返回 false,由调用方静默跳过,打卡不受影响。
|
||||
*/
|
||||
private boolean canAutoCompleteWaybill(Waybill waybill) {
|
||||
return COMPLETABLE_STATUSES.contains(waybill.getBusinessStatus())
|
||||
return ACTIVE_STATUSES.contains(waybill.getBusinessStatus())
|
||||
&& unresolvedExceptionNodeCodeList(waybill.getId()).isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言该运单没有未了结的异常。
|
||||
* <p>
|
||||
* 完成运单前必须校验:完成接口本身不要求节点全部打卡,不挡住它就能从
|
||||
* 「完成运单」绕过打卡门禁。
|
||||
*/
|
||||
private void assertNoUnresolvedException(Long waybillId) {
|
||||
if (!unresolvedExceptionNodeCodeList(waybillId).isEmpty()) {
|
||||
throw new ServiceException("该运单存在未处理异常,处理完成后才能完成运单");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装过程配置 punch=是 的打卡节点列表。
|
||||
* <p>
|
||||
@@ -2018,9 +1999,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
}
|
||||
return switch (businessStatus) {
|
||||
case STATUS_PENDING, STATUS_WAITING_DISPATCH, STATUS_DISPATCHING -> 0;
|
||||
case STATUS_RUNNING -> 1;
|
||||
// 待确认回单:司机侧节点已打完,对司机按已完成展示
|
||||
case STATUS_PENDING_RETURN, STATUS_COMPLETED -> 2;
|
||||
case STATUS_RUNNING, WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM -> 1;
|
||||
case STATUS_COMPLETED -> 2;
|
||||
case STATUS_CANCELLED -> 3;
|
||||
default -> null;
|
||||
};
|
||||
|
||||
+744
-10
@@ -29,15 +29,21 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.mp.base.BaseServiceImpl;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.core.tool.jackson.JsonUtil;
|
||||
import org.springblade.core.tool.utils.BeanUtil;
|
||||
import org.springblade.core.tool.utils.DateUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.system.cache.UserDisplayCache;
|
||||
import org.springblade.transport.mapper.ExceptionDisposalFollowRecordMapper;
|
||||
import org.springblade.transport.mapper.ExceptionDisposalMapper;
|
||||
import org.springblade.transport.mapper.LoadingManageMapper;
|
||||
import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest;
|
||||
import org.springblade.transport.pojo.dto.ExceptionDisposalRejectRequest;
|
||||
import org.springblade.transport.pojo.dto.ExceptionDisposalRouteAdjustRequest;
|
||||
import org.springblade.transport.pojo.dto.ExceptionDisposalUpdateRequest;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposalFollowRecord;
|
||||
import org.springblade.transport.pojo.entity.LoadingManage;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.vo.ExceptionDisposalFollowRecordVO;
|
||||
import org.springblade.transport.pojo.vo.ExceptionDisposalVO;
|
||||
@@ -45,6 +51,7 @@ import org.springblade.transport.service.IDriverWaybillService;
|
||||
import org.springblade.transport.service.IExceptionDisposalService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.support.MpUserDataScopeSupport;
|
||||
import org.springblade.transport.support.TransportBusinessSupport;
|
||||
import org.springblade.transport.support.WaybillProcessSupport;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -54,10 +61,13 @@ import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@@ -73,23 +83,53 @@ public class ExceptionDisposalServiceImpl
|
||||
private static final String STATUS_PENDING = "pending";
|
||||
private static final String STATUS_PROCESSING = "processing";
|
||||
private static final String STATUS_COMPLETED = "completed";
|
||||
private static final String STATUS_REVOKED = "revoked";
|
||||
private static final String STATUS_CANCELLED = "cancelled";
|
||||
|
||||
/** latest_follow_content 与 follow_content 两列均为 varchar(500) */
|
||||
private static final int FOLLOW_CONTENT_MAX_LENGTH = 500;
|
||||
|
||||
/** 拒绝原因长度上限,与上报说明、异常原因同量级,且远小于跟进记录列宽 */
|
||||
private static final int REJECT_REASON_MAX_LENGTH = 200;
|
||||
|
||||
/** 路线与任务信息的列宽上限,与 PC 改路线同一口径 */
|
||||
private static final int ROUTE_JSON_MAX_LENGTH = 8000;
|
||||
private static final int TASK_INFO_JSON_MAX_LENGTH = 8000;
|
||||
private static final int ADDRESS_MAX_LENGTH = 255;
|
||||
|
||||
private static final String NODE_KIND_START = "start";
|
||||
private static final String NODE_KIND_TRANSIT = "transit";
|
||||
private static final String NODE_KIND_END = "end";
|
||||
|
||||
/** 途经站地址在配载单上的连接符,与 PC 同步地址列时一致 */
|
||||
private static final String TRANSIT_ADDRESS_JOINER = ",";
|
||||
|
||||
/** 跟进记录与变更记录共用的同一句结论 */
|
||||
private static final String ROUTE_CHANGE_UNCHANGED = "同意调整路线,未修改路线";
|
||||
private static final String ROUTE_CHANGE_PREFIX = "同意调整路线:";
|
||||
|
||||
/** 结论里站点之间的连接符,如「A → B → C」 */
|
||||
private static final String ROUTE_STATION_SEPARATOR = " → ";
|
||||
|
||||
/** task_info_json 里存路线变更记录数组的键,与 PC 同一份结构 */
|
||||
private static final String TASK_INFO_ROUTE_CHANGE_RECORDS = "routeChangeRecords";
|
||||
|
||||
private final ExceptionDisposalFollowRecordMapper followRecordMapper;
|
||||
private final IWaybillService waybillService;
|
||||
private final IDriverWaybillService driverWaybillService;
|
||||
private final MpUserDataScopeSupport mpUserDataScopeSupport;
|
||||
private final LoadingManageMapper loadingManageMapper;
|
||||
|
||||
public ExceptionDisposalServiceImpl(ExceptionDisposalFollowRecordMapper followRecordMapper,
|
||||
IWaybillService waybillService,
|
||||
IDriverWaybillService driverWaybillService,
|
||||
MpUserDataScopeSupport mpUserDataScopeSupport) {
|
||||
MpUserDataScopeSupport mpUserDataScopeSupport,
|
||||
LoadingManageMapper loadingManageMapper) {
|
||||
this.followRecordMapper = followRecordMapper;
|
||||
this.waybillService = waybillService;
|
||||
this.driverWaybillService = driverWaybillService;
|
||||
this.mpUserDataScopeSupport = mpUserDataScopeSupport;
|
||||
this.loadingManageMapper = loadingManageMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -102,7 +142,9 @@ public class ExceptionDisposalServiceImpl
|
||||
|
||||
@Override
|
||||
public ExceptionDisposalVO detail(Long id) {
|
||||
ExceptionDisposalVO vo = toVO(getExisting(id));
|
||||
ExceptionDisposal entity = getExisting(id);
|
||||
ExceptionDisposalVO vo = convertVO(entity);
|
||||
fillRouteNodes(vo, fillRouteAndCargo(vo, entity.getWaybillId()));
|
||||
vo.setFollowRecords(followRecords(id));
|
||||
return vo;
|
||||
}
|
||||
@@ -342,7 +384,7 @@ public class ExceptionDisposalServiceImpl
|
||||
throw new ServiceException("跟进说明不能超过500字");
|
||||
}
|
||||
ExceptionDisposal disposal = getExisting(request.getId());
|
||||
if (STATUS_COMPLETED.equals(disposal.getDisposalStatus())) {
|
||||
if (WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES.contains(disposal.getDisposalStatus())) {
|
||||
throw new ServiceException("已完成的异常不允许继续跟进");
|
||||
}
|
||||
// 上传了附件但没写说明时,跟进说明留空,列表按附件回退展示
|
||||
@@ -368,7 +410,7 @@ public class ExceptionDisposalServiceImpl
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void complete(Long id) {
|
||||
ExceptionDisposal disposal = getExisting(id);
|
||||
if (STATUS_COMPLETED.equals(disposal.getDisposalStatus())) {
|
||||
if (WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES.contains(disposal.getDisposalStatus())) {
|
||||
throw new ServiceException("异常已完成,请勿重复操作");
|
||||
}
|
||||
disposal.setDisposalStatus(STATUS_COMPLETED);
|
||||
@@ -390,7 +432,7 @@ public class ExceptionDisposalServiceImpl
|
||||
throw new ServiceException("存在无效的异常记录");
|
||||
}
|
||||
for (ExceptionDisposal disposal : disposals) {
|
||||
if (STATUS_COMPLETED.equals(disposal.getDisposalStatus())) {
|
||||
if (WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES.contains(disposal.getDisposalStatus())) {
|
||||
throw new ServiceException("已完成的异常不允许重复完成");
|
||||
}
|
||||
disposal.setDisposalStatus(STATUS_COMPLETED);
|
||||
@@ -398,6 +440,484 @@ public class ExceptionDisposalServiceImpl
|
||||
updateBatchById(disposals);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void reject(ExceptionDisposalRejectRequest request) {
|
||||
if (request == null || request.getId() == null) {
|
||||
throw new ServiceException("请选择需要拒绝的异常");
|
||||
}
|
||||
ExceptionDisposal disposal = getExisting(request.getId());
|
||||
assertNotSettled(disposal);
|
||||
String rejectReason = Func.toStr(request.getRejectReason(), "").trim();
|
||||
if (Func.isBlank(rejectReason)) {
|
||||
throw new ServiceException("请填写拒绝原因");
|
||||
}
|
||||
if (rejectReason.length() > REJECT_REASON_MAX_LENGTH) {
|
||||
throw new ServiceException("拒绝原因不能超过200字");
|
||||
}
|
||||
|
||||
String rejectContent = "拒绝:" + rejectReason;
|
||||
// 拒绝弹窗只有原因、没有附件,attachments_json 不写
|
||||
insertFollowRecord(disposal.getId(), rejectContent);
|
||||
|
||||
// 记录先于状态落库,且同在一个事务里:结案了却没有拒绝记录,事后无从知道是谁以什么理由拒绝的
|
||||
disposal.setDisposalStatus(STATUS_COMPLETED);
|
||||
disposal.setLatestFollowContent(rejectContent);
|
||||
updateById(disposal);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void revoke(Long id) {
|
||||
ExceptionDisposal disposal = getExisting(id);
|
||||
Long currentUserId = AuthUtil.getUserId();
|
||||
// 撤销是上报人收回自己的上报,不是一次处置
|
||||
if (currentUserId == null || !Objects.equals(disposal.getReporterId(), currentUserId)) {
|
||||
throw new ServiceException("只能撤销本人上报的异常");
|
||||
}
|
||||
assertNotSettled(disposal);
|
||||
// 不写跟进记录,也不动 latest_follow_content
|
||||
disposal.setDisposalStatus(STATUS_REVOKED);
|
||||
updateById(disposal);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void adjustRoute(ExceptionDisposalRouteAdjustRequest request) {
|
||||
if (request == null || request.getId() == null) {
|
||||
throw new ServiceException("请选择需要调整路线的异常");
|
||||
}
|
||||
ExceptionDisposal disposal = getExisting(request.getId());
|
||||
assertNotSettled(disposal);
|
||||
Waybill waybill = disposal.getWaybillId() == null ? null : waybillService.getById(disposal.getWaybillId());
|
||||
if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) {
|
||||
throw new ServiceException("关联运单不存在");
|
||||
}
|
||||
// 路线改在谁身上跟着配载单走:配载单是这趟车的执行主体
|
||||
LoadingManage loading = findLoading(waybill.getLoadingNo());
|
||||
if (loading == null && Func.isNotBlank(waybill.getLoadingNo())) {
|
||||
throw new ServiceException("运单已关联配载单,请在配载单中修改");
|
||||
}
|
||||
// 校验与拼装全部在写入之前完成
|
||||
List<Station> stations = validateRouteNodes(request.getRouteNodes(), loading);
|
||||
List<String> sourceAddresses = loading != null
|
||||
? loadingStationAddresses(loading)
|
||||
: waybillStationAddresses(waybill);
|
||||
List<String> targetAddresses = stations.stream().map(Station::address).toList();
|
||||
// 首站地址不可修改;原数据首站地址为空时跳过比对
|
||||
if (!sourceAddresses.isEmpty() && Func.isNotBlank(sourceAddresses.get(0))
|
||||
&& !Objects.equals(sourceAddresses.get(0), targetAddresses.get(0))) {
|
||||
throw new ServiceException("起始地址不允许修改");
|
||||
}
|
||||
String businessStatus = loading != null ? loading.getBusinessStatus() : waybill.getBusinessStatus();
|
||||
if (STATUS_COMPLETED.equals(businessStatus) || STATUS_CANCELLED.equals(businessStatus)) {
|
||||
throw new ServiceException("当前状态不允许变更运输路线");
|
||||
}
|
||||
RouteChangePlan plan = planRouteChange(loading, waybill, stations, sourceAddresses, targetAddresses);
|
||||
|
||||
// 事务内顺序固定:跟进记录 → 载体路线 → 异常状态
|
||||
insertFollowRecord(disposal.getId(), plan.followContent());
|
||||
if (loading != null) {
|
||||
applyLoadingRoute(loading, plan);
|
||||
} else {
|
||||
applyWaybillRoute(waybill, plan);
|
||||
}
|
||||
disposal.setDisposalStatus(STATUS_COMPLETED);
|
||||
disposal.setLatestFollowContent(plan.followContent());
|
||||
updateById(disposal);
|
||||
}
|
||||
|
||||
/** 已了结(已完成 / 已撤销)的异常不允许再次处置 */
|
||||
private void assertNotSettled(ExceptionDisposal disposal) {
|
||||
if (WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES.contains(disposal.getDisposalStatus())) {
|
||||
throw new ServiceException("该异常已处理");
|
||||
}
|
||||
}
|
||||
|
||||
/** 一条跟进记录:处置记录里「先写记录再改状态」的顺序由调用方的事务保证 */
|
||||
private void insertFollowRecord(Long disposalId, String content) {
|
||||
ExceptionDisposalFollowRecord record = new ExceptionDisposalFollowRecord();
|
||||
record.setDisposalId(disposalId);
|
||||
record.setFollowContent(content);
|
||||
record.setFollowUser(AuthUtil.getUserId());
|
||||
record.setFollowUserName(UserDisplayCache.getUserRealName(AuthUtil.getUserId()));
|
||||
record.setFollowTime(LocalDateTime.now());
|
||||
followRecordMapper.insert(record);
|
||||
}
|
||||
|
||||
/** 配载单按配载单号取,逻辑删除的视为没有 */
|
||||
private LoadingManage findLoading(String loadingNo) {
|
||||
if (Func.isBlank(loadingNo)) {
|
||||
return null;
|
||||
}
|
||||
return loadingManageMapper.selectOne(Wrappers.<LoadingManage>lambdaQuery()
|
||||
.eq(LoadingManage::getLoadingNo, loadingNo.trim())
|
||||
.eq(LoadingManage::getIsDeleted, 0)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并规范化站点:站数至少两个、首站 start、末站 end、其余 transit,地址都不能为空。
|
||||
* <p>
|
||||
* kind 由位置决定,必须与站序一致。
|
||||
*/
|
||||
private List<Station> validateRouteNodes(List<ExceptionDisposalRouteAdjustRequest.RouteNode> routeNodes,
|
||||
LoadingManage loading) {
|
||||
if (Func.isEmpty(routeNodes) || routeNodes.size() < 2) {
|
||||
throw new ServiceException("路线节点至少需要两个");
|
||||
}
|
||||
List<Station> stations = new ArrayList<>(routeNodes.size());
|
||||
for (int index = 0; index < routeNodes.size(); index++) {
|
||||
ExceptionDisposalRouteAdjustRequest.RouteNode node = routeNodes.get(index);
|
||||
if (node == null) {
|
||||
throw new ServiceException("请完善路线节点");
|
||||
}
|
||||
String address = Func.toStr(node.getAddress(), "").trim();
|
||||
if (Func.isBlank(address)) {
|
||||
throw new ServiceException("站点地址不能为空");
|
||||
}
|
||||
String expectedKind = index == 0
|
||||
? NODE_KIND_START
|
||||
: (index == routeNodes.size() - 1 ? NODE_KIND_END : NODE_KIND_TRANSIT);
|
||||
if (!expectedKind.equals(Func.toStr(node.getKind(), "").trim())) {
|
||||
throw new ServiceException("路线节点类型不正确");
|
||||
}
|
||||
stations.add(new Station(address, normalizedTags(node.getTags())));
|
||||
}
|
||||
assertTagsMatchChildWaybills(stations, loading);
|
||||
return stations;
|
||||
}
|
||||
|
||||
private List<String> normalizedTags(List<String> tags) {
|
||||
List<String> normalized = new ArrayList<>();
|
||||
if (tags == null) {
|
||||
return normalized;
|
||||
}
|
||||
for (String tag : tags) {
|
||||
String text = Func.toStr(tag, "").trim();
|
||||
if (Func.isNotBlank(text)) {
|
||||
normalized.add(text);
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 站点标记里的数字是配载单运单集合里的位次(1 起),越界时拒绝。
|
||||
* <p>
|
||||
* 没有配载单时没有子运单序号,站点标记不参与落库。
|
||||
*/
|
||||
private void assertTagsMatchChildWaybills(List<Station> stations, LoadingManage loading) {
|
||||
if (loading == null) {
|
||||
return;
|
||||
}
|
||||
int childCount = childWaybillCount(loading.getWaybillIdsJson());
|
||||
for (Station station : stations) {
|
||||
for (String tag : station.tags()) {
|
||||
int order = tagOrder(tag);
|
||||
if (order < 1 || order > childCount) {
|
||||
throw new ServiceException("路线节点未关联子运单");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 配载单运单集合的元素个数,即站点标记可用的位次上限 */
|
||||
private int childWaybillCount(String waybillIdsJson) {
|
||||
if (Func.isBlank(waybillIdsJson)) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
List<?> waybillIds = JsonUtil.parse(waybillIdsJson, List.class);
|
||||
return waybillIds == null ? 0 : waybillIds.size();
|
||||
} catch (Exception ignored) {
|
||||
// 运单集合不是 JSON 数组时按没有子运单处理
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** 配载单当前路线的站点地址:优先站点行,没有站点行时回退到发货 / 途经 / 到货地址三列 */
|
||||
private List<String> loadingStationAddresses(LoadingManage loading) {
|
||||
List<String> addresses = new ArrayList<>();
|
||||
for (Map<String, Object> row : loadingStationRows(loading)) {
|
||||
addAddress(addresses, text(row.get("address")));
|
||||
}
|
||||
if (!addresses.isEmpty()) {
|
||||
return addresses;
|
||||
}
|
||||
return addressColumns(loading.getDepartureAddress(), loading.getTransitAddress(), loading.getArrivalAddress());
|
||||
}
|
||||
|
||||
/**
|
||||
* 运单当前路线的站点地址:首段出发地是首站,其后每段的到达地各成一站。
|
||||
* 段里缺地址时用站名替代。
|
||||
*/
|
||||
private List<String> waybillStationAddresses(Waybill waybill) {
|
||||
List<Map<String, Object>> segments = routeSegments(waybill.getRouteJson());
|
||||
if (segments.isEmpty()) {
|
||||
return addressColumns(waybill.getDepartureAddress(), null, waybill.getArrivalAddress());
|
||||
}
|
||||
List<String> addresses = new ArrayList<>(segments.size() + 1);
|
||||
Map<String, Object> firstSegment = segments.get(0);
|
||||
addresses.add(firstNonBlank(text(firstSegment.get("departureAddress")),
|
||||
text(firstSegment.get("departureName")), Func.toStr(waybill.getDepartureAddress(), "")));
|
||||
for (Map<String, Object> segment : segments) {
|
||||
addresses.add(firstNonBlank(text(segment.get("arrivalAddress")), text(segment.get("arrivalName")), ""));
|
||||
}
|
||||
return addresses;
|
||||
}
|
||||
|
||||
/** 发货 / 途经 / 到货三列拼成站点地址,途经列按中英文逗号拆开 */
|
||||
private List<String> addressColumns(String departureAddress, String transitAddress, String arrivalAddress) {
|
||||
List<String> addresses = new ArrayList<>(3);
|
||||
addAddress(addresses, departureAddress);
|
||||
if (Func.isNotBlank(transitAddress)) {
|
||||
for (String transit : transitAddress.split("[,,]")) {
|
||||
addAddress(addresses, transit.trim());
|
||||
}
|
||||
}
|
||||
addAddress(addresses, arrivalAddress);
|
||||
return addresses;
|
||||
}
|
||||
|
||||
private void addAddress(List<String> addresses, String address) {
|
||||
if (Func.isNotBlank(address)) {
|
||||
addresses.add(address.trim());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把校验过的站点拼成一次写入所需的全部内容:跟进记录文案、载体要写的路线与地址、合并后的任务信息。
|
||||
* 长度口径与 PC 改路线一致。
|
||||
*/
|
||||
private RouteChangePlan planRouteChange(LoadingManage loading, Waybill waybill, List<Station> stations,
|
||||
List<String> sourceAddresses, List<String> targetAddresses) {
|
||||
String followContent = truncate(routeChangeContent(sourceAddresses, targetAddresses));
|
||||
String departureAddress = targetAddresses.get(0);
|
||||
String arrivalAddress = targetAddresses.get(targetAddresses.size() - 1);
|
||||
String routeJson;
|
||||
String transitAddress = null;
|
||||
String sourceTaskInfoJson;
|
||||
if (loading != null) {
|
||||
routeJson = stationRowsJson(stations);
|
||||
transitAddress = targetAddresses.subList(1, targetAddresses.size() - 1).stream()
|
||||
.collect(Collectors.joining(TRANSIT_ADDRESS_JOINER));
|
||||
sourceTaskInfoJson = loading.getTaskInfoJson();
|
||||
} else {
|
||||
// 运单的 route_json 是路线段形状,司机端按段解析:只按段改地址,不整份换成站点行
|
||||
List<Map<String, Object>> segments = routeSegments(waybill.getRouteJson());
|
||||
boolean addressChanged = !sourceAddresses.equals(targetAddresses);
|
||||
// 段形状可识别时站数必须等于段数 + 1;认不出段形状时不改 route_json
|
||||
if (addressChanged && !segments.isEmpty() && segments.size() + 1 != targetAddresses.size()) {
|
||||
throw new ServiceException("无配载单的运单不支持增减途经站");
|
||||
}
|
||||
routeJson = addressChanged ? rewriteWaybillRouteSegments(segments, targetAddresses) : null;
|
||||
sourceTaskInfoJson = waybill.getTaskInfoJson();
|
||||
}
|
||||
String taskInfoJson = appendRouteChangeRecord(sourceTaskInfoJson, followContent);
|
||||
|
||||
TransportBusinessSupport.validateLength(routeJson, ROUTE_JSON_MAX_LENGTH, "路线信息不能超过8000字");
|
||||
TransportBusinessSupport.validateLength(departureAddress, ADDRESS_MAX_LENGTH, "发货地址不能超过255字");
|
||||
TransportBusinessSupport.validateLength(arrivalAddress, ADDRESS_MAX_LENGTH, "收货地址不能超过255字");
|
||||
TransportBusinessSupport.validateLength(taskInfoJson, TASK_INFO_JSON_MAX_LENGTH, "任务信息不能超过8000字");
|
||||
return new RouteChangePlan(followContent, routeJson, departureAddress, transitAddress,
|
||||
arrivalAddress, taskInfoJson);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一份结论既进跟进记录,也进 task_info_json 的变更记录:
|
||||
* 写调整后的站点顺序(地址 → 地址 → …),不写「哪一站从什么改成什么」;
|
||||
* 一个地址都没改时给固定的一句。
|
||||
*/
|
||||
private String routeChangeContent(List<String> sourceAddresses, List<String> targetAddresses) {
|
||||
if (sourceAddresses.equals(targetAddresses)) {
|
||||
return ROUTE_CHANGE_UNCHANGED;
|
||||
}
|
||||
List<String> parts = new ArrayList<>(targetAddresses.size());
|
||||
for (String address : targetAddresses) {
|
||||
parts.add(displayAddress(address));
|
||||
}
|
||||
return ROUTE_CHANGE_PREFIX + String.join(ROUTE_STATION_SEPARATOR, parts);
|
||||
}
|
||||
|
||||
private String displayAddress(String address) {
|
||||
return Func.isBlank(address) ? "-" : address;
|
||||
}
|
||||
|
||||
/** 配载单的路线是站点行:地址、展示地址、该站装卸的子运单标记 */
|
||||
private String stationRowsJson(List<Station> stations) {
|
||||
List<Map<String, Object>> rows = new ArrayList<>(stations.size());
|
||||
for (Station station : stations) {
|
||||
Map<String, Object> row = new LinkedHashMap<>(4);
|
||||
row.put("address", station.address());
|
||||
// 站点行没有独立的站名,展示地址与地址取同一份文本
|
||||
row.put("displayAddress", station.address());
|
||||
row.put("tags", station.tags());
|
||||
// locked 只影响 PC 编辑弹窗能否拖动,司机端不读
|
||||
row.put("locked", false);
|
||||
rows.add(row);
|
||||
}
|
||||
return JsonUtil.toJson(rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按路线段改写运单路线:每段的出发地对应第 i 站、到达地对应第 i+1 站,其它键(segmentNo、
|
||||
* transportType、未知键)原样保留。
|
||||
* <p>
|
||||
* 现有 route_json 认不出段形状(空、坏 JSON、PC 节点行)时返回 null,调用方跳过路线改写;
|
||||
* 站数与段数不匹配由调用方拒绝。
|
||||
*/
|
||||
private String rewriteWaybillRouteSegments(List<Map<String, Object>> segments, List<String> targetAddresses) {
|
||||
if (segments.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
List<Map<String, Object>> rewritten = new ArrayList<>(segments.size());
|
||||
for (int index = 0; index < segments.size(); index++) {
|
||||
Map<String, Object> segment = new LinkedHashMap<>(segments.get(index));
|
||||
rewriteSegmentPoint(segment, "departure", targetAddresses.get(index));
|
||||
rewriteSegmentPoint(segment, "arrival", targetAddresses.get(index + 1));
|
||||
rewritten.add(segment);
|
||||
}
|
||||
return JsonUtil.toJson(rewritten);
|
||||
}
|
||||
|
||||
/** 站点地址与站名写同一份文本 */
|
||||
private void rewriteSegmentPoint(Map<String, Object> segment, String pointKey, String targetAddress) {
|
||||
String currentAddress = firstNonBlank(text(segment.get(pointKey + "Address")),
|
||||
text(segment.get(pointKey + "Name")), "");
|
||||
if (Objects.equals(currentAddress, targetAddress)) {
|
||||
return;
|
||||
}
|
||||
segment.put(pointKey + "Address", targetAddress);
|
||||
segment.put(pointKey + "Name", targetAddress);
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别路线段形状的 route_json:元素带段字段,且不带 address / tags。
|
||||
* 另一形状是 PC 写下的节点行(address + tags + 运单号),司机端按段解析读不出来。
|
||||
*/
|
||||
private List<Map<String, Object>> routeSegments(String routeJson) {
|
||||
List<Map<String, Object>> elements = jsonObjectList(routeJson);
|
||||
for (Map<String, Object> element : elements) {
|
||||
if (element.containsKey("address") || element.containsKey("tags")) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
if (!element.containsKey("departureAddress") && !element.containsKey("departureName")
|
||||
&& !element.containsKey("arrivalAddress") && !element.containsKey("arrivalName")) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务信息是整列覆盖:读出原对象,只把本次变更记录插到最前面,其余键原样写回。
|
||||
* 原来的内容不是 JSON 对象(坏数据或历史数组)时按空对象重建。
|
||||
*/
|
||||
private String appendRouteChangeRecord(String taskInfoJson, String content) {
|
||||
Map<String, Object> taskInfo = parseTaskInfo(taskInfoJson);
|
||||
List<Object> records = new ArrayList<>();
|
||||
Map<String, Object> record = new LinkedHashMap<>(4);
|
||||
record.put("changeTime", DateUtil.formatDateTime(LocalDateTime.now()));
|
||||
record.put("changeUser", UserDisplayCache.getUserRealName(AuthUtil.getUserId()));
|
||||
record.put("content", content);
|
||||
record.put("remark", "");
|
||||
records.add(record);
|
||||
Object existing = taskInfo.get(TASK_INFO_ROUTE_CHANGE_RECORDS);
|
||||
if (existing instanceof List<?> existingRecords) {
|
||||
records.addAll(existingRecords);
|
||||
}
|
||||
taskInfo.put(TASK_INFO_ROUTE_CHANGE_RECORDS, records);
|
||||
return JsonUtil.toJson(taskInfo);
|
||||
}
|
||||
|
||||
/** 任务信息只认 JSON 对象:数组与坏数据都按空对象重建 */
|
||||
private Map<String, Object> parseTaskInfo(String taskInfoJson) {
|
||||
Map<String, Object> taskInfo = new LinkedHashMap<>();
|
||||
if (Func.isBlank(taskInfoJson)) {
|
||||
return taskInfo;
|
||||
}
|
||||
try {
|
||||
Object parsed = JsonUtil.parse(taskInfoJson, Object.class);
|
||||
if (parsed instanceof Map<?, ?> map) {
|
||||
map.forEach((key, value) -> taskInfo.put(String.valueOf(key), value));
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
taskInfo.clear();
|
||||
}
|
||||
return taskInfo;
|
||||
}
|
||||
|
||||
private void applyLoadingRoute(LoadingManage loading, RouteChangePlan plan) {
|
||||
loading.setRouteJson(plan.routeJson());
|
||||
loading.setDepartureAddress(plan.departureAddress());
|
||||
loading.setTransitAddress(TransportBusinessSupport.trimToNull(plan.transitAddress()));
|
||||
loading.setArrivalAddress(plan.arrivalAddress());
|
||||
loading.setTaskInfoJson(plan.taskInfoJson());
|
||||
if (loadingManageMapper.updateById(loading) <= 0) {
|
||||
throw new ServiceException("变更运输路线失败");
|
||||
}
|
||||
}
|
||||
|
||||
private void applyWaybillRoute(Waybill waybill, RouteChangePlan plan) {
|
||||
if (Func.isNotBlank(plan.routeJson())) {
|
||||
waybill.setRouteJson(plan.routeJson());
|
||||
}
|
||||
waybill.setDepartureAddress(plan.departureAddress());
|
||||
waybill.setArrivalAddress(plan.arrivalAddress());
|
||||
waybill.setTaskInfoJson(plan.taskInfoJson());
|
||||
if (!waybillService.updateById(waybill)) {
|
||||
throw new ServiceException("变更运输路线失败");
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析成对象数组;坏 JSON 与空值都按空数组处理 */
|
||||
private List<Map<String, Object>> jsonObjectList(String json) {
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
if (Func.isBlank(json)) {
|
||||
return result;
|
||||
}
|
||||
try {
|
||||
Object parsed = JsonUtil.parse(json, List.class);
|
||||
if (parsed instanceof List<?> list) {
|
||||
for (Object item : list) {
|
||||
if (item instanceof Map<?, ?> map) {
|
||||
Map<String, Object> element = new LinkedHashMap<>();
|
||||
map.forEach((key, value) -> element.put(String.valueOf(key), value));
|
||||
result.add(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String text(Object value) {
|
||||
return value == null ? "" : String.valueOf(value).trim();
|
||||
}
|
||||
|
||||
private String firstNonBlank(String addressValue, String nameValue, String fallbackValue) {
|
||||
if (Func.isNotBlank(addressValue)) {
|
||||
return addressValue;
|
||||
}
|
||||
return Func.isNotBlank(nameValue) ? nameValue : fallbackValue;
|
||||
}
|
||||
|
||||
/** 规范化后的一个站点:地址 + 该站的装卸标记 */
|
||||
private record Station(String address, List<String> tags) {
|
||||
}
|
||||
|
||||
/** 一次路线调整要落库的全部内容 */
|
||||
private record RouteChangePlan(String followContent,
|
||||
String routeJson,
|
||||
String departureAddress,
|
||||
String transitAddress,
|
||||
String arrivalAddress,
|
||||
String taskInfoJson) {
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<ExceptionDisposal> buildQuery(ExceptionDisposalVO query) {
|
||||
LambdaQueryWrapper<ExceptionDisposal> wrapper = Wrappers.<ExceptionDisposal>lambdaQuery()
|
||||
.eq(ExceptionDisposal::getIsDeleted, 0)
|
||||
@@ -429,7 +949,14 @@ public class ExceptionDisposalServiceImpl
|
||||
return disposal;
|
||||
}
|
||||
|
||||
/** 列表与上报结果共用的转换:不含详情页的配载单号、子运单号与可调整站点 */
|
||||
private ExceptionDisposalVO toVO(ExceptionDisposal entity) {
|
||||
ExceptionDisposalVO vo = convertVO(entity);
|
||||
fillRouteAndCargo(vo, entity.getWaybillId());
|
||||
return vo;
|
||||
}
|
||||
|
||||
private ExceptionDisposalVO convertVO(ExceptionDisposal entity) {
|
||||
ExceptionDisposalVO vo = BeanUtil.copyProperties(entity, ExceptionDisposalVO.class);
|
||||
if (vo == null) {
|
||||
return null;
|
||||
@@ -438,18 +965,17 @@ public class ExceptionDisposalServiceImpl
|
||||
vo.setUpdateUserName(UserDisplayCache.getUserRealName(entity.getUpdateUser()));
|
||||
vo.setDisposalStatusName(statusName(entity.getDisposalStatus()));
|
||||
vo.setScenePhotoList(splitPhotos(entity.getScenePhotos()));
|
||||
fillRouteAndCargo(vo, entity.getWaybillId());
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 按关联运单补齐详情页路线 / 货物展示字段 */
|
||||
private void fillRouteAndCargo(ExceptionDisposalVO vo, Long waybillId) {
|
||||
/** 按关联运单补齐路线与货物展示字段,返回本次取到的运单;运单不存在时返回 null */
|
||||
private Waybill fillRouteAndCargo(ExceptionDisposalVO vo, Long waybillId) {
|
||||
if (vo == null || waybillId == null) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
Waybill waybill = waybillService.getById(waybillId);
|
||||
if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
Map<String, String> route = new HashMap<>(2);
|
||||
route.put("start", Func.toStr(waybill.getDepartureName(), ""));
|
||||
@@ -460,6 +986,213 @@ public class ExceptionDisposalServiceImpl
|
||||
cargo.put("name", Func.toStr(waybill.getCargoName(), ""));
|
||||
cargo.put("weight", formatWeight(waybill.getQuantity(), waybill.getQuantityUnit()));
|
||||
vo.setCargo(cargo);
|
||||
return waybill;
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情页的可编辑站点与配载单信息。
|
||||
* <p>
|
||||
* 站点取配载单的站点行;运单没有配载单号,或配载单已删除时退回运单的路线段。
|
||||
* 运单不存在时三项各给空值。
|
||||
*/
|
||||
private void fillRouteNodes(ExceptionDisposalVO vo, Waybill waybill) {
|
||||
if (vo == null) {
|
||||
return;
|
||||
}
|
||||
if (waybill == null) {
|
||||
vo.setChildWaybillNos(List.of());
|
||||
vo.setRouteNodes(List.of());
|
||||
return;
|
||||
}
|
||||
LoadingManage loading = findLoading(waybill.getLoadingNo());
|
||||
if (loading == null) {
|
||||
vo.setChildWaybillNos(new ArrayList<>());
|
||||
vo.setRouteNodes(waybillRouteNodes(waybill));
|
||||
return;
|
||||
}
|
||||
Map<Integer, Waybill> childWaybills = childWaybillsByOrder(loading);
|
||||
vo.setLoadingNo(loading.getLoadingNo());
|
||||
vo.setChildWaybillNos(childWaybillNos(childWaybills));
|
||||
List<Map<String, Object>> rows = loadingStationRows(loading);
|
||||
vo.setRouteNodes(loadingRouteNodes(rows, childWaybills));
|
||||
vo.setRoute(loadingRoute(rows, loading, waybill));
|
||||
}
|
||||
|
||||
/** 配载单的站点行:只有带地址的行才算一个站,缺地址的历史行不参与 */
|
||||
private List<Map<String, Object>> loadingStationRows(LoadingManage loading) {
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for (Map<String, Object> row : jsonObjectList(loading.getRouteJson())) {
|
||||
if (Func.isNotBlank(text(row.get("address")))) {
|
||||
rows.add(row);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private List<ExceptionDisposalVO.RouteNode> loadingRouteNodes(List<Map<String, Object>> rows,
|
||||
Map<Integer, Waybill> childWaybills) {
|
||||
List<ExceptionDisposalVO.RouteNode> nodes = new ArrayList<>(rows.size());
|
||||
for (Map<String, Object> row : rows) {
|
||||
List<String> tags = stringList(row.get("tags"));
|
||||
ExceptionDisposalVO.RouteNode node = new ExceptionDisposalVO.RouteNode();
|
||||
node.setAddress(text(row.get("address")));
|
||||
node.setTags(tags);
|
||||
node.setProjects(stationProjects(tags, childWaybills));
|
||||
nodes.add(node);
|
||||
}
|
||||
return stationKinds(nodes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 没有配载单时从路线段推导站点:首段出发地是首站,其后每段的到达地各成一站。
|
||||
* 地址口径与改路线时的比对一致(地址优先、站名兜底)。
|
||||
*/
|
||||
private List<ExceptionDisposalVO.RouteNode> waybillRouteNodes(Waybill waybill) {
|
||||
List<ExceptionDisposalVO.RouteNode> nodes = new ArrayList<>();
|
||||
for (String address : waybillStationAddresses(waybill)) {
|
||||
if (Func.isBlank(address)) {
|
||||
continue;
|
||||
}
|
||||
ExceptionDisposalVO.RouteNode node = new ExceptionDisposalVO.RouteNode();
|
||||
node.setAddress(address);
|
||||
node.setTags(new ArrayList<>());
|
||||
node.setProjects(new ArrayList<>());
|
||||
nodes.add(node);
|
||||
}
|
||||
return stationKinds(nodes);
|
||||
}
|
||||
|
||||
/** 站序声明:首站 start、末站 end、其余 transit;不足两个站时返回空列表 */
|
||||
private List<ExceptionDisposalVO.RouteNode> stationKinds(List<ExceptionDisposalVO.RouteNode> nodes) {
|
||||
if (nodes.size() < 2) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
for (int index = 0; index < nodes.size(); index++) {
|
||||
nodes.get(index).setKind(index == 0 ? NODE_KIND_START
|
||||
: (index == nodes.size() - 1 ? NODE_KIND_END : NODE_KIND_TRANSIT));
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 站点标记指向配载单运单集合里的第 N 张子运单,取其项目名去重。
|
||||
* 标记对不上子运单(越界、子运单已不存在)时跳过。
|
||||
*/
|
||||
private List<String> stationProjects(List<String> tags, Map<Integer, Waybill> childWaybills) {
|
||||
Set<String> projects = new LinkedHashSet<>();
|
||||
for (String tag : tags) {
|
||||
Waybill child = childWaybills.get(tagOrder(tag) - 1);
|
||||
if (child == null) {
|
||||
continue;
|
||||
}
|
||||
String projectName = Func.toStr(child.getProjectName(), "").trim();
|
||||
if (Func.isNotBlank(projectName)) {
|
||||
projects.add(projectName);
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(projects);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按配载单运单集合的位次取子运单,位次从 0 起(站点标记里的 N 减一)。
|
||||
* 位次不因缺子运单而前移。
|
||||
*/
|
||||
private Map<Integer, Waybill> childWaybillsByOrder(LoadingManage loading) {
|
||||
Map<Integer, Waybill> childWaybills = new LinkedHashMap<>();
|
||||
List<Long> waybillIds = waybillIds(loading.getWaybillIdsJson());
|
||||
List<Long> queryIds = waybillIds.stream().filter(Objects::nonNull).distinct().toList();
|
||||
if (queryIds.isEmpty()) {
|
||||
return childWaybills;
|
||||
}
|
||||
Map<Long, Waybill> waybillsById = new HashMap<>();
|
||||
List<Waybill> found = waybillService.listByIds(queryIds);
|
||||
if (found != null) {
|
||||
for (Waybill child : found) {
|
||||
if (child != null && !Objects.equals(child.getIsDeleted(), 1)) {
|
||||
waybillsById.put(child.getId(), child);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int order = 0; order < waybillIds.size(); order++) {
|
||||
Waybill child = waybillsById.get(waybillIds.get(order));
|
||||
if (child != null) {
|
||||
childWaybills.put(order, child);
|
||||
}
|
||||
}
|
||||
return childWaybills;
|
||||
}
|
||||
|
||||
/** 配载单运单集合的元素是运单主键;元素不是主键时留空占位,后面的位次不能因此前移 */
|
||||
private List<Long> waybillIds(String waybillIdsJson) {
|
||||
List<Long> waybillIds = new ArrayList<>();
|
||||
if (Func.isBlank(waybillIdsJson)) {
|
||||
return waybillIds;
|
||||
}
|
||||
try {
|
||||
Object parsed = JsonUtil.parse(waybillIdsJson, List.class);
|
||||
if (parsed instanceof List<?> list) {
|
||||
for (Object item : list) {
|
||||
long waybillId = Func.toLong(text(item), 0L);
|
||||
waybillIds.add(waybillId > 0 ? waybillId : null);
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 运单集合不是 JSON 数组时按没有子运单处理
|
||||
return new ArrayList<>();
|
||||
}
|
||||
return waybillIds;
|
||||
}
|
||||
|
||||
/** 子运单号按配载单运单集合的顺序给出;查不到的子运单跳过,不占位 */
|
||||
private List<String> childWaybillNos(Map<Integer, Waybill> childWaybills) {
|
||||
List<String> childWaybillNos = new ArrayList<>(childWaybills.size());
|
||||
for (Waybill child : childWaybills.values()) {
|
||||
String waybillNo = Func.toStr(child.getWaybillNo(), "").trim();
|
||||
if (Func.isNotBlank(waybillNo)) {
|
||||
childWaybillNos.add(waybillNo);
|
||||
}
|
||||
}
|
||||
return childWaybillNos;
|
||||
}
|
||||
|
||||
/** 有配载单时路线摘要取这趟车自己的首末站:站点行的展示地址优先,没有站点行回落到地址列 */
|
||||
private Map<String, String> loadingRoute(List<Map<String, Object>> rows, LoadingManage loading, Waybill waybill) {
|
||||
String start;
|
||||
String end;
|
||||
if (rows.isEmpty()) {
|
||||
start = firstNonBlank(loading.getDepartureAddress(), waybill.getDepartureName(), "");
|
||||
end = firstNonBlank(loading.getArrivalAddress(), waybill.getArrivalName(), "");
|
||||
} else {
|
||||
start = stationDisplayAddress(rows.get(0));
|
||||
end = stationDisplayAddress(rows.get(rows.size() - 1));
|
||||
}
|
||||
Map<String, String> route = new HashMap<>(2);
|
||||
route.put("start", start);
|
||||
route.put("end", end);
|
||||
return route;
|
||||
}
|
||||
|
||||
private String stationDisplayAddress(Map<String, Object> row) {
|
||||
return firstNonBlank(text(row.get("displayAddress")), text(row.get("address")), "");
|
||||
}
|
||||
|
||||
/** 站点标记里的数字是配载单运单集合里的位次(1 起),取不到数字时返回 0 */
|
||||
private int tagOrder(String tag) {
|
||||
String digits = Func.toStr(tag, "").replaceAll("\\D+", "");
|
||||
return Func.isEmpty(digits) ? 0 : Func.toInt(digits, 0);
|
||||
}
|
||||
|
||||
private List<String> stringList(Object value) {
|
||||
List<String> texts = new ArrayList<>();
|
||||
if (value instanceof List<?> list) {
|
||||
for (Object item : list) {
|
||||
String text = text(item);
|
||||
if (Func.isNotBlank(text)) {
|
||||
texts.add(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
return texts;
|
||||
}
|
||||
|
||||
private String formatWeight(BigDecimal quantity, String unit) {
|
||||
@@ -494,6 +1227,7 @@ public class ExceptionDisposalServiceImpl
|
||||
case STATUS_PENDING -> "待处理";
|
||||
case STATUS_PROCESSING -> "处理中";
|
||||
case STATUS_COMPLETED -> "已完成";
|
||||
case STATUS_REVOKED -> "已撤销";
|
||||
default -> "";
|
||||
};
|
||||
}
|
||||
|
||||
+21
-7
@@ -114,6 +114,8 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
private static final String TODO_REASSIGN = "reassign";
|
||||
/** 重派条目跳转页码:参数 id 是运单主键(不是运单号),该页据此回显运单;buyerPaid 恒为 0,与列表卡片一致 */
|
||||
private static final String REASSIGN_TARGET_URL = "/pages/waybill/reassign?id=%s&buyerPaid=0";
|
||||
/** 异常条目跳转页码:参数 id 是异常处置记录主键,该页直接按 id 拉详情 */
|
||||
private static final String EXCEPTION_TARGET_URL = "/subpackages/admin/disposal-detail?id=%s";
|
||||
|
||||
private static final String EXCEPTION_YES = "exception";
|
||||
private static final String EXCEPTION_NO = "normal";
|
||||
@@ -726,13 +728,18 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
return loading == null || loading.getId() == null ? fallback : loading.getId();
|
||||
}
|
||||
|
||||
/** 分组查询的关键字:运单号 / 司机 / 车牌,与列表关键字口径一致 */
|
||||
/**
|
||||
* 分组查询的关键字:逐行模糊匹配运单号 / 司机 / 车牌 / 项目。
|
||||
* <p>
|
||||
* 这里是逐行 LIKE,不是运次级匹配:命中哪一行只决定这趟车要不要出现,不缩小它的子单集合。
|
||||
*/
|
||||
private void applyGroupKeyword(QueryWrapper<Waybill> wrapper, String keyword) {
|
||||
if (Func.isBlank(keyword)) {
|
||||
return;
|
||||
}
|
||||
String key = keyword.trim();
|
||||
wrapper.and(w -> w.like("waybill_no", key).or().like("driver_name", key).or().like("vehicle_no", key));
|
||||
wrapper.and(w -> w.like("waybill_no", key).or().like("driver_name", key)
|
||||
.or().like("vehicle_no", key).or().like("project_name", key));
|
||||
}
|
||||
|
||||
private boolean hasExceptionIds(Waybill waybill, Set<Long> exceptionWaybillIds) {
|
||||
@@ -744,6 +751,7 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
detail.setId(waybill.getId());
|
||||
detail.setWaybillNo(waybill.getWaybillNo());
|
||||
detail.setStatus(toAppStatus(waybill.getBusinessStatus()));
|
||||
detail.setBusinessStatus(waybill.getBusinessStatus());
|
||||
|
||||
String mode = Func.toStr(waybill.getTransportType(), "");
|
||||
detail.setTransportMode(mode);
|
||||
@@ -927,7 +935,10 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统用户可见组织:普通账号只出所属组织,超管不过滤;组织为空不拦截。
|
||||
* 调度端可见组织:普通用户(含各级角色)只出自己所属组织,超级管理员不过滤。
|
||||
* <p>
|
||||
* 小程序不校验「所属组织不能为空」:账号没有组织时不按组织收口,避免首页、待处理等接口直接报错。
|
||||
* 详情与重新派单本身不走这里。
|
||||
*/
|
||||
private List<Long> manageableDeptIds() {
|
||||
if (TransportBusinessSupport.isAdministrator()) {
|
||||
@@ -1011,6 +1022,7 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
card.setPlanTime(formatLocalDate(waybill.getEstimatedStartTime()));
|
||||
card.setPlanTimeEnd(formatLocalDate(waybill.getEstimatedEndTime()));
|
||||
card.setStatus(toAppStatus(waybill.getBusinessStatus()));
|
||||
card.setBusinessStatus(waybill.getBusinessStatus());
|
||||
card.setCarrierName(Func.toStr(waybill.getCarrierName(), ""));
|
||||
card.setDriverName(Func.toStr(waybill.getDriverName(), ""));
|
||||
card.setVehicleNo(Func.toStr(waybill.getVehicleNo(), ""));
|
||||
@@ -1068,8 +1080,10 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
}
|
||||
return switch (businessStatus) {
|
||||
case STATUS_PENDING, "waiting_dispatch", "dispatching" -> 0;
|
||||
case STATUS_RUNNING -> 1;
|
||||
case WaybillProcessSupport.STATUS_PENDING_RETURN, STATUS_COMPLETED -> 2;
|
||||
// 数字枚举与司机端 toAppStatus 同源:0 待接单 / 1 运输中 / 2 已完成 / 3 已取消;
|
||||
// 待回单确认属进行中,落 1。给 null 时前端显示「未知」。
|
||||
case STATUS_RUNNING, WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM -> 1;
|
||||
case STATUS_COMPLETED -> 2;
|
||||
case STATUS_CANCELLED -> 3;
|
||||
default -> null;
|
||||
};
|
||||
@@ -1220,8 +1234,8 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
item.setDesc(buildExceptionDesc(disposal));
|
||||
item.setWaybillNo(Func.toStr(disposal.getWaybillNo(), ""));
|
||||
item.setActionLabel("立即处置");
|
||||
String status = Func.toStr(disposal.getDisposalStatus(), DISPOSAL_PENDING);
|
||||
item.setTargetUrl("/subpackages/admin/exception?status=" + status);
|
||||
// 待处理事项的「立即处置」直接落到该条异常的处置详情页,不再先到异常列表
|
||||
item.setTargetUrl(String.format(EXCEPTION_TARGET_URL, disposal.getId()));
|
||||
return item;
|
||||
}
|
||||
|
||||
|
||||
+12
-2
@@ -362,7 +362,7 @@ public class MasterOrderServiceImpl extends BaseServiceImpl<MasterOrderMapper, M
|
||||
Map<String, BigDecimal> dispatchedGoods = dispatchedGoods(routeWaybills, routePlans);
|
||||
route.put("dispatchedQuantity", dispatchedGoods.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add));
|
||||
route.put("dispatchedGoods", dispatchedGoods);
|
||||
route.put("arrivedQuantity", routeWaybills.stream().filter(item -> "completed".equals(item.getBusinessStatus())).flatMap(item -> waybillGoods(item).stream()).map(item -> decimal(item, "quantity")).reduce(BigDecimal.ZERO, BigDecimal::add));
|
||||
route.put("arrivedQuantity", routeWaybills.stream().filter(item -> arrived(item.getBusinessStatus())).flatMap(item -> waybillGoods(item).stream()).map(item -> decimal(item, "quantity")).reduce(BigDecimal.ZERO, BigDecimal::add));
|
||||
route.put("waybills", routeWaybills);
|
||||
route.put("transportPlans", routePlans);
|
||||
}
|
||||
@@ -541,12 +541,22 @@ public class MasterOrderServiceImpl extends BaseServiceImpl<MasterOrderMapper, M
|
||||
}
|
||||
private BigDecimal completedQuantity(String masterNo, Map<String, Object> route) {
|
||||
return waybillsByMasterNo(masterNo).stream()
|
||||
.filter(item -> "completed".equals(item.getBusinessStatus()))
|
||||
.filter(item -> arrived(item.getBusinessStatus()))
|
||||
.filter(item -> belongsToRoute(item, route))
|
||||
.flatMap(item -> waybillGoods(item).stream())
|
||||
.map(item -> decimal(item, "quantity"))
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
}
|
||||
|
||||
/**
|
||||
* 已到达口径:待回单确认也算到达——货物确实到了,回单只是单据。
|
||||
* 不计入会按「调度总量不能大于上一段已到达数量」把多联总单的下一段挡在门外。
|
||||
*/
|
||||
static boolean arrived(String businessStatus) {
|
||||
return "completed".equals(businessStatus)
|
||||
|| WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM.equals(businessStatus);
|
||||
}
|
||||
|
||||
private Map<String, BigDecimal> availableGoods(MasterOrder masterOrder) {
|
||||
Map<String, BigDecimal> available = new LinkedHashMap<>();
|
||||
for (Map<String, Object> goods : parseArray(masterOrder.getGoodsJson())) available.merge(goodsKey(goods), decimal(goods, "quantity"), BigDecimal::add);
|
||||
|
||||
+2
-1
@@ -61,6 +61,7 @@ import org.springblade.transport.service.IFormalSettlementService;
|
||||
import org.springblade.transport.service.IPreSettlementService;
|
||||
import org.springblade.transport.service.IReceivablePayableDetailService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.support.WaybillProcessSupport;
|
||||
import org.springblade.transport.wrapper.ReceivablePayableDetailWrapper;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -3295,9 +3296,9 @@ public class ReceivablePayableDetailServiceImpl
|
||||
private String waybillStatusName(String status) {
|
||||
return switch (status == null ? "" : status) {
|
||||
case "completed" -> "已完成";
|
||||
case "pending_return" -> "待确认回单";
|
||||
case "processing", "running", "in_progress", "inProgress" -> "进行中";
|
||||
case "pending", "created" -> "待执行";
|
||||
case WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM -> "待回单确认";
|
||||
case "cancelled", "canceled" -> "已取消";
|
||||
default -> status;
|
||||
};
|
||||
|
||||
+3
-1
@@ -45,6 +45,7 @@ final class TripAggregation {
|
||||
static final String RUNNING = "running";
|
||||
static final String COMPLETED = "completed";
|
||||
static final String CANCELLED = "cancelled";
|
||||
static final String PENDING_RETURN_CONFIRM = WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM;
|
||||
|
||||
/** 待接单档:与运单侧 Tab「待接单」取同一组状态 */
|
||||
static final List<String> PENDING_STATUSES = List.of(PENDING, WAITING_DISPATCH, DISPATCHING);
|
||||
@@ -53,9 +54,10 @@ final class TripAggregation {
|
||||
/**
|
||||
* 进运次的子单状态:草稿尚未派单、不成运次;已取消要进,
|
||||
* 否则「子单全已取消」会被算成运输中而不是已取消。
|
||||
* 待回单确认要进:子单漏在运次外,剩下那几张会被判成整趟车已走完。
|
||||
*/
|
||||
static final List<String> CHILD_STATUSES = List.of(
|
||||
PENDING, WAITING_DISPATCH, DISPATCHING, RUNNING, COMPLETED, CANCELLED
|
||||
PENDING, WAITING_DISPATCH, DISPATCHING, RUNNING, PENDING_RETURN_CONFIRM, COMPLETED, CANCELLED
|
||||
);
|
||||
|
||||
/** 待重新派单的口径:子单被拒且这趟车还没结束 */
|
||||
|
||||
+6
-2
@@ -90,10 +90,14 @@ public class TripQueryServiceImpl implements ITripQueryService {
|
||||
private static final String EXCEPTION_YES = "exception";
|
||||
private static final String EXCEPTION_NO = "normal";
|
||||
|
||||
/** 关键字在子单上匹配的字段 */
|
||||
/**
|
||||
* 关键字在子单上匹配的字段。
|
||||
* <p>
|
||||
* project_name 是运单创建时写入的快照列:项目改名后旧运单仍是旧名,按新名搜不到这些单。
|
||||
*/
|
||||
private static final String[] KEYWORD_COLUMNS = {
|
||||
"waybill_no", "loading_no", "departure_name", "arrival_name",
|
||||
"departure_address", "arrival_address", "vehicle_no"
|
||||
"departure_address", "arrival_address", "vehicle_no", "driver_name", "project_name"
|
||||
};
|
||||
|
||||
private final IWaybillService waybillService;
|
||||
|
||||
+50
-20
@@ -531,10 +531,33 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
}
|
||||
// 只有显式传 false 才是取消打卡,未传按已打卡处理
|
||||
boolean punched = !Boolean.FALSE.equals(request.getPunched());
|
||||
if (ENROUTE_PUNCH_TYPE.equalsIgnoreCase(TransportBusinessSupport.trimToEmpty(request.getType()))) {
|
||||
return saveEnroutePunchRecord(waybill, request, punched);
|
||||
// 待回单确认的运单司机已无待打卡节点,再补一条打卡流水与状态含义矛盾;
|
||||
// 漏打的出路是先取消该节点的打卡退回进行中(punched=false),再重新补录。
|
||||
if (punched && WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM
|
||||
.equalsIgnoreCase(waybill.getBusinessStatus())) {
|
||||
throw new ServiceException("运单待业务员确认回单,不能补录打卡");
|
||||
}
|
||||
return saveNodePunchRecord(waybill, request, punched);
|
||||
if (ENROUTE_PUNCH_TYPE.equalsIgnoreCase(TransportBusinessSupport.trimToEmpty(request.getType()))) {
|
||||
return revertIfReturnConfirmPreconditionLost(waybill, punched,
|
||||
saveEnroutePunchRecord(waybill, request, punched));
|
||||
}
|
||||
return revertIfReturnConfirmPreconditionLost(waybill, punched,
|
||||
saveNodePunchRecord(waybill, request, punched));
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消打卡撤掉了「司机已无待打卡节点」这个前提,停在待回单确认的运单退回进行中。
|
||||
* <p>
|
||||
* 只认取消打卡后的待回单确认,其它状态一律不动。
|
||||
*/
|
||||
private boolean revertIfReturnConfirmPreconditionLost(Waybill waybill, boolean punched, boolean saved) {
|
||||
if (!saved || punched
|
||||
|| !WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM
|
||||
.equalsIgnoreCase(waybill.getBusinessStatus())) {
|
||||
return saved;
|
||||
}
|
||||
waybill.setBusinessStatus(WaybillProcessSupport.STATUS_RUNNING);
|
||||
return updateById(waybill);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -962,14 +985,16 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
/**
|
||||
* 运单是否已越过接单阶段。
|
||||
* <p>
|
||||
* 正向列举进行中/已完成/已取消,不能用「不是终态」取反:终态里还包含草稿、待调度、
|
||||
* 正向列举进行中/已完成/已取消/待回单确认,不能用「不是终态」取反:终态里还包含草稿、待调度、
|
||||
* 调度中这些并未越过接单的阶段,取反会把它们判成已接单。
|
||||
* <p>
|
||||
* 待回单确认下司机已打完末位卡,无需接单配置时接单节点不该再显示未打卡。
|
||||
*/
|
||||
private static boolean passedAcceptStage(String businessStatus) {
|
||||
return "running".equals(businessStatus)
|
||||
|| WaybillProcessSupport.STATUS_PENDING_RETURN.equals(businessStatus)
|
||||
|| "completed".equals(businessStatus)
|
||||
|| "cancelled".equals(businessStatus);
|
||||
|| "cancelled".equals(businessStatus)
|
||||
|| WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM.equals(businessStatus);
|
||||
}
|
||||
|
||||
private String formatPunchTime(Date time) {
|
||||
@@ -1064,10 +1089,11 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
waybill.setLoadingNo(null);
|
||||
waybill.setMileageRemark(null);
|
||||
}
|
||||
// 新建若已带业务状态(如复制提交已完成)则在过程校正后回写,保证与原运单一致
|
||||
// 新建若已带业务状态(如复制提交已完成)则在过程校正后回写,保证与原运单一致;
|
||||
// 编辑保存只改资料,business_status 以库里的值为准,请求体里的值不回写
|
||||
String preservedBusinessStatus = created
|
||||
? TransportBusinessSupport.trimToNull(waybill.getBusinessStatus())
|
||||
: null;
|
||||
: editPreservedBusinessStatus(oldRecord);
|
||||
if (isCompletedBusinessStatus(preservedBusinessStatus)) {
|
||||
preservedBusinessStatus = "completed";
|
||||
}
|
||||
@@ -1089,6 +1115,17 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑保存要回写的业务状态:取库里的值。
|
||||
* 草稿返回 null —— submit 即正式提交,草稿须由过程校正提升为待执行 / 进行中。
|
||||
*/
|
||||
private String editPreservedBusinessStatus(Waybill oldRecord) {
|
||||
String currentStatus = oldRecord == null
|
||||
? null
|
||||
: TransportBusinessSupport.trimToNull(oldRecord.getBusinessStatus());
|
||||
return STATUS_DRAFT.equals(currentStatus) ? null : currentStatus;
|
||||
}
|
||||
|
||||
private void fillCustomerName(Waybill waybill) {
|
||||
if (Func.isNotEmpty(waybill.getContractId())) {
|
||||
ContractManage contract = contractManageService.getById(waybill.getContractId());
|
||||
@@ -1509,21 +1546,14 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean confirmReturnReceipt(Long id) {
|
||||
Waybill waybill = loadEditable(id, true);
|
||||
String status = waybill.getBusinessStatus();
|
||||
// pending_return 为正规待确认态;running/processing 兼容历史「打完卡仍停在进行中」的数据
|
||||
if (!WaybillProcessSupport.STATUS_PENDING_RETURN.equals(status)
|
||||
&& !"running".equals(status)
|
||||
&& !"processing".equals(status)) {
|
||||
// 判据只看运单当前状态,不看回单配置当前值:配置改回「无需确认」后,
|
||||
// 已停在待回单确认的运单仍要能确认回单
|
||||
if (!WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM.equalsIgnoreCase(waybill.getBusinessStatus())) {
|
||||
throw new ServiceException("当前状态不允许确认回单");
|
||||
}
|
||||
String processJson = LiveProcessConfigSupport.resolve(
|
||||
LiveProcessConfigSupport.byProject(processConfigService, waybill.getProjectId()),
|
||||
waybill.getProcessJson());
|
||||
if (WaybillProcessSupport.returnNeedsNoConfirmation(processJson)) {
|
||||
// 不要求确认时司机打完末位卡就已经完成了,运单不会停在进行中等确认;配置对不上要说清,
|
||||
// 否则用户会以为是确认没生效
|
||||
throw new ServiceException("该运单的回单不需要业务员确认");
|
||||
}
|
||||
assertPunchNodesFinished(waybill.getId(), processJson);
|
||||
assertNoUnresolvedException(waybill.getId());
|
||||
return doComplete(waybill);
|
||||
@@ -1594,8 +1624,8 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
BusinessRemoveResultVO result = new BusinessRemoveResultVO();
|
||||
for (Waybill waybill : listByIds(idList)) {
|
||||
try {
|
||||
// 待确认回单走确认回单校验(打卡节点 + 未了结异常);其它状态仍走普通完成
|
||||
if (WaybillProcessSupport.STATUS_PENDING_RETURN.equals(waybill.getBusinessStatus())) {
|
||||
// 待回单确认走确认回单校验(打卡节点 + 未了结异常);其它状态仍走普通完成
|
||||
if (WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM.equals(waybill.getBusinessStatus())) {
|
||||
confirmReturnReceipt(waybill.getId());
|
||||
} else {
|
||||
complete(waybill.getId());
|
||||
|
||||
+7
-7
@@ -39,12 +39,12 @@ import org.springframework.stereotype.Component;
|
||||
* 判据是生效快照指针,不是「最新提交状态」展示缓存——重新提交认证会把展示缓存
|
||||
* 变成「审核中」,照它判定会让司机一提交就接不了单。生效快照只在审核通过时切换。
|
||||
* <p>
|
||||
* 两个开关默认开启,可关掉以回退到不校验认证的行为:
|
||||
* 两个开关默认关闭,接单不校验认证;显式置 true 恢复拦截:
|
||||
* <pre>
|
||||
* transport:
|
||||
* certification:
|
||||
* vehicle-gate-enabled: false
|
||||
* driver-gate-enabled: false
|
||||
* vehicle-gate-enabled: true
|
||||
* driver-gate-enabled: true
|
||||
* </pre>
|
||||
*
|
||||
* @author Chill
|
||||
@@ -57,14 +57,14 @@ public class CertificationGate {
|
||||
/**
|
||||
* 车辆认证门槛开关
|
||||
*/
|
||||
@Value("${transport.certification.vehicle-gate-enabled:true}")
|
||||
private boolean vehicleGateEnabled = true;
|
||||
@Value("${transport.certification.vehicle-gate-enabled:false}")
|
||||
private boolean vehicleGateEnabled = false;
|
||||
|
||||
/**
|
||||
* 司机认证门槛开关
|
||||
*/
|
||||
@Value("${transport.certification.driver-gate-enabled:true}")
|
||||
private boolean driverGateEnabled = true;
|
||||
@Value("${transport.certification.driver-gate-enabled:false}")
|
||||
private boolean driverGateEnabled = false;
|
||||
|
||||
/**
|
||||
* 判定车辆是否可用
|
||||
|
||||
+10
-11
@@ -49,8 +49,8 @@ public final class WaybillProcessSupport {
|
||||
|
||||
public static final String STATUS_PENDING = "pending";
|
||||
public static final String STATUS_RUNNING = "running";
|
||||
/** 司机打卡已完成、待运管员确认回单 */
|
||||
public static final String STATUS_PENDING_RETURN = "pending_return";
|
||||
/** 司机已打完末位卡、回单需业务员确认,尚未生成应收应付 */
|
||||
public static final String STATUS_PENDING_RETURN_CONFIRM = "pending_return_confirm";
|
||||
public static final String ACCEPT_PENDING = "pending";
|
||||
public static final String ACCEPT_ACCEPTED = "accepted";
|
||||
public static final String ACCEPT_REJECTED = "rejected";
|
||||
@@ -136,17 +136,16 @@ public final class WaybillProcessSupport {
|
||||
}
|
||||
|
||||
/**
|
||||
* 运单已结束:已完成、已取消,或待确认回单。此后不再接受司机打卡。
|
||||
* 运单已结束:已完成、已取消,或已打完末位卡停在待回单确认。此后不再接受司机打卡。
|
||||
* <p>
|
||||
* 待确认回单表示司机侧节点已打完,等待运管确认,不能再打卡。
|
||||
* 草稿、待执行、待调度、调度中的运单司机仍要能打卡,
|
||||
* 只认这三个状态,大小写不敏感。草稿、待执行、待调度、调度中的运单司机仍要能打卡,
|
||||
* 所以不能按 {@link #isTerminalBusinessStatus} 的「终态或调度中间态」口径判定。
|
||||
*/
|
||||
public static boolean isPunchEnded(String businessStatus) {
|
||||
String status = stringVal(businessStatus);
|
||||
return "completed".equalsIgnoreCase(status)
|
||||
|| "cancelled".equalsIgnoreCase(status)
|
||||
|| STATUS_PENDING_RETURN.equalsIgnoreCase(status);
|
||||
|| STATUS_PENDING_RETURN_CONFIRM.equalsIgnoreCase(status);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -160,8 +159,8 @@ public final class WaybillProcessSupport {
|
||||
if ("cancelled".equalsIgnoreCase(status)) {
|
||||
return "运单已取消";
|
||||
}
|
||||
if (STATUS_PENDING_RETURN.equalsIgnoreCase(status)) {
|
||||
return "运单待确认回单";
|
||||
if (STATUS_PENDING_RETURN_CONFIRM.equalsIgnoreCase(status)) {
|
||||
return "运单待业务员确认回单";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -203,7 +202,7 @@ public final class WaybillProcessSupport {
|
||||
* 上报异常前的运单状态门禁原因;可上报时返回 null。
|
||||
* <p>
|
||||
* 与打卡共用「已开始运输」判据,但文案说的是能不能报,不是能不能打。
|
||||
* 结束优先于未开始:已完成/已取消的运单先说结束,不再提「等你上路」。
|
||||
* 结束优先于未开始:已结束的运单先说结束,不再提「等你上路」。
|
||||
*/
|
||||
public static String reportBlockedReason(String businessStatus) {
|
||||
String endedReason = punchEndedReason(businessStatus);
|
||||
@@ -222,7 +221,7 @@ public final class WaybillProcessSupport {
|
||||
|| "cancelled".equals(businessStatus)
|
||||
|| "waiting_dispatch".equals(businessStatus)
|
||||
|| "dispatching".equals(businessStatus)
|
||||
|| STATUS_PENDING_RETURN.equals(businessStatus);
|
||||
|| STATUS_PENDING_RETURN_CONFIRM.equals(businessStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -230,7 +229,7 @@ public final class WaybillProcessSupport {
|
||||
* <p>
|
||||
* 需要接单(接单节点 confirmMode=yes)且尚未接单(未响应 / 已拒绝)→ pending(待执行);
|
||||
* 已接单 → running(进行中);不需要接单 → running(仅当当前为空或 pending 时提升)。
|
||||
* draft / completed / cancelled / pending_return 等终态或调度中间态不改动。
|
||||
* draft / completed / cancelled / pending_return_confirm 等终态或调度中间态不改动。
|
||||
*/
|
||||
public static String normalizeBusinessStatus(String businessStatus, String processJson) {
|
||||
return normalizeBusinessStatus(businessStatus, processJson, null);
|
||||
|
||||
+1
-1
@@ -122,7 +122,7 @@ public class WaybillWrapper extends BaseEntityWrapper<Waybill, WaybillVO> {
|
||||
case "waiting_dispatch" -> "待调度";
|
||||
case "dispatching" -> "调度中";
|
||||
case "running", "processing", "in_progress", "inProgress" -> "进行中";
|
||||
case "pending_return" -> "待确认回单";
|
||||
case WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM -> "待回单确认";
|
||||
case "completed" -> "已完成";
|
||||
case "cancelled" -> "已取消";
|
||||
case "closed" -> "已关闭";
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package org.springblade.transport.controller;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springblade.transport.service.IContractManageService;
|
||||
import org.springblade.transport.service.ICustomerArchiveService;
|
||||
import org.springblade.transport.service.IDriverWaybillService;
|
||||
import org.springblade.transport.service.IProjectApplyService;
|
||||
import org.springblade.transport.service.ITransportPlanService;
|
||||
import org.springblade.transport.service.IWaybillImportBatchService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* 运单「完成运单」接口的边界:司机端不再有完成入口,管理端强制完成仍可达。
|
||||
* <p>
|
||||
* 走真实 HTTP 路由:司机端那条链删没删干净由 404 说话,管理端保持可用由 200 + 落到
|
||||
* {@code waybillService.complete} 说话。
|
||||
*/
|
||||
class WaybillCompleteEndpointTest {
|
||||
|
||||
private IWaybillService waybillService;
|
||||
private MockMvc driverMvc;
|
||||
private MockMvc manageMvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
waybillService = mock(IWaybillService.class);
|
||||
DriverWaybillController driverController = new DriverWaybillController(mock(IDriverWaybillService.class));
|
||||
driverMvc = MockMvcBuilders.standaloneSetup(driverController).build();
|
||||
WaybillController manageController = new WaybillController(
|
||||
waybillService,
|
||||
mock(IProjectApplyService.class),
|
||||
mock(IContractManageService.class),
|
||||
mock(ICustomerArchiveService.class),
|
||||
mock(ITransportPlanService.class),
|
||||
mock(IWaybillImportBatchService.class));
|
||||
manageMvc = MockMvcBuilders.standaloneSetup(manageController).build();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("POST /blade-transport/waybill/complete:司机端完成运单路由已下线")
|
||||
void driverCompleteRouteIsGone() throws Exception {
|
||||
driverMvc.perform(post("/blade-transport/waybill/complete").param("id", "6001"))
|
||||
.andExpect(status().isNotFound());
|
||||
driverMvc.perform(post("/waybill/complete").param("id", "6001"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("POST /waybill-manage/complete:管理端强制完成仍可达")
|
||||
void manageCompleteRouteIsKept() throws Exception {
|
||||
when(waybillService.complete(6001L)).thenReturn(true);
|
||||
|
||||
manageMvc.perform(post("/waybill-manage/complete").param("id", "6001"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200));
|
||||
|
||||
verify(waybillService).complete(6001L);
|
||||
}
|
||||
|
||||
}
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
package org.springblade.transport.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.mockito.ArgumentCaptor;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.system.feign.IMpUserClient;
|
||||
import org.springblade.system.feign.ISysClient;
|
||||
import org.springblade.transport.mapper.DriverMapper;
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
import org.springblade.transport.pojo.vo.DriverEnsureProfileVO;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
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.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 登录自动建档与绑定已有档案的测试。
|
||||
* <p>
|
||||
* 守住三条不变式:按 user_id 幂等重入不重复建档、同号冲突一律不绑定也不新建、
|
||||
* 绑定那次 patch 必须带上 driving_vehicle 原值(该列 {@code FieldStrategy.ALWAYS}
|
||||
* 会把缺失的列写成 NULL,司机端的车辆绑定会整块消失)。
|
||||
*/
|
||||
class DriverEnsureProfileTest {
|
||||
|
||||
private static final Long MP_USER_ID = 7001L;
|
||||
private static final Long OTHER_USER_ID = 7002L;
|
||||
private static final Long DRIVER_ID = 9001L;
|
||||
private static final Long NEW_DRIVER_ID = 9002L;
|
||||
private static final String TENANT_ID = "000000";
|
||||
private static final String MOBILE = "15975348524";
|
||||
private static final String PLATE = "桂A32523";
|
||||
|
||||
private DriverMapper driverMapper;
|
||||
private DriverServiceImpl service;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
// save 走 MyBatis-Plus 的实体元数据缓存
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, Driver.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
driverMapper = mock(DriverMapper.class);
|
||||
IMpUserClient mpUserClient = mock(IMpUserClient.class);
|
||||
ISysClient sysClient = mock(ISysClient.class);
|
||||
service = new DriverServiceImpl(mpUserClient, sysClient);
|
||||
inject("baseMapper", driverMapper);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 新建占位档案
|
||||
|
||||
@Test
|
||||
@DisplayName("无账号档案、无同号档案:按占位值新建,created=true")
|
||||
void createsPlaceholderProfile() {
|
||||
// 按 user_id 查(getOne → selectOne)与按手机号查(selectList)都没有
|
||||
stubUserLookup();
|
||||
when(driverMapper.selectList(any())).thenReturn(Collections.emptyList());
|
||||
when(driverMapper.insert(any(Driver.class))).thenAnswer(invocation -> {
|
||||
Driver inserted = invocation.getArgument(0);
|
||||
inserted.setId(NEW_DRIVER_ID);
|
||||
return 1;
|
||||
});
|
||||
|
||||
DriverEnsureProfileVO result = service.ensureProfile(TENANT_ID, MP_USER_ID, MOBILE);
|
||||
|
||||
assertTrue(result.getCreated());
|
||||
assertFalse(result.getBoundExisting());
|
||||
assertNull(result.getMessage());
|
||||
assertEquals(NEW_DRIVER_ID, result.getDriverId());
|
||||
|
||||
ArgumentCaptor<Driver> captor = ArgumentCaptor.forClass(Driver.class);
|
||||
verify(driverMapper).insert(captor.capture());
|
||||
Driver saved = captor.getValue();
|
||||
assertEquals(TENANT_ID, saved.getTenantId(), "tenant_id 必须显式写入且非空");
|
||||
assertEquals(MP_USER_ID, saved.getUserId());
|
||||
assertEquals(MOBILE, saved.getMobile());
|
||||
assertEquals("司机8524", saved.getDriverName());
|
||||
assertNull(saved.getIdCardNo(), "身份证号留 NULL 才是「未采集」,也是补全判据");
|
||||
assertEquals("", saved.getDrivingType());
|
||||
assertEquals("", saved.getDriverType());
|
||||
assertEquals("", saved.getOrganizationName());
|
||||
assertEquals("司机", saved.getPosts(), "岗位为空会被派单候选的 posts 过滤筛掉,新注册司机就选不到");
|
||||
assertEquals("", saved.getEmergencyContactName());
|
||||
assertEquals("", saved.getEmergencyContactMobile());
|
||||
assertEquals(1, saved.getStatus());
|
||||
assertEquals("login_auto", saved.getProfileSource());
|
||||
assertNotNull(saved.getAccountBoundTime());
|
||||
verify(driverMapper, never()).updateById(any(Driver.class));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 绑定已有档案
|
||||
|
||||
@Test
|
||||
@DisplayName("命中同号未绑定的档案:绑定并留痕,patch 必须带上 driving_vehicle 原值")
|
||||
void bindsExistingProfileAndKeepsDrivingVehicle() {
|
||||
Driver existing = driver(DRIVER_ID, null, PLATE);
|
||||
stubUserLookup();
|
||||
when(driverMapper.selectList(any())).thenReturn(List.of(existing));
|
||||
when(driverMapper.selectById(DRIVER_ID)).thenReturn(existing);
|
||||
when(driverMapper.updateById(any(Driver.class))).thenReturn(1);
|
||||
|
||||
DriverEnsureProfileVO result = service.ensureProfile(TENANT_ID, MP_USER_ID, MOBILE);
|
||||
|
||||
assertFalse(result.getCreated());
|
||||
assertTrue(result.getBoundExisting());
|
||||
assertNull(result.getMessage());
|
||||
assertEquals(DRIVER_ID, result.getDriverId());
|
||||
|
||||
ArgumentCaptor<Driver> captor = ArgumentCaptor.forClass(Driver.class);
|
||||
verify(driverMapper).updateById(captor.capture());
|
||||
Driver patch = captor.getValue();
|
||||
assertEquals(MP_USER_ID, patch.getUserId());
|
||||
assertEquals("login_bound", patch.getProfileSource());
|
||||
assertNotNull(patch.getAccountBoundTime());
|
||||
assertEquals(PLATE, patch.getDrivingVehicle(),
|
||||
"绑定 patch 不带 driving_vehicle 会把司机的车辆绑定写掉");
|
||||
assertNull(patch.getMobile(), "绑定不得覆盖档案上的手机号");
|
||||
verify(driverMapper, never()).insert(any(Driver.class));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 幂等重入
|
||||
|
||||
@Test
|
||||
@DisplayName("按 user_id 命中:直接返回该档案,不新建、不改手机号")
|
||||
void returnsExistingProfileForRepeatLogin() {
|
||||
stubUserLookup(driver(DRIVER_ID, MP_USER_ID, PLATE));
|
||||
|
||||
DriverEnsureProfileVO result = service.ensureProfile(TENANT_ID, MP_USER_ID, MOBILE);
|
||||
|
||||
assertFalse(result.getCreated());
|
||||
assertFalse(result.getBoundExisting());
|
||||
assertNull(result.getMessage());
|
||||
assertEquals(DRIVER_ID, result.getDriverId());
|
||||
verify(driverMapper, never()).insert(any(Driver.class));
|
||||
verify(driverMapper, never()).updateById(any(Driver.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("并发重复建档撞唯一键:重查后按已存在的档案返回,不把异常抛给登录方")
|
||||
void toleratesConcurrentInsert() {
|
||||
Driver concurrent = driver(DRIVER_ID, MP_USER_ID, PLATE);
|
||||
stubUserLookup(null, concurrent);
|
||||
when(driverMapper.selectList(any())).thenReturn(Collections.emptyList());
|
||||
when(driverMapper.insert(any(Driver.class))).thenThrow(new DuplicateKeyException("uk_transport_driver_user"));
|
||||
|
||||
DriverEnsureProfileVO result = service.ensureProfile(TENANT_ID, MP_USER_ID, MOBILE);
|
||||
|
||||
assertFalse(result.getCreated());
|
||||
assertFalse(result.getBoundExisting());
|
||||
assertEquals(DRIVER_ID, result.getDriverId());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 冲突
|
||||
|
||||
@Test
|
||||
@DisplayName("同手机号多条档案:拒绝绑定也不新建,返回可读原因")
|
||||
void rejectsMultipleProfilesWithSameMobile() {
|
||||
stubUserLookup();
|
||||
when(driverMapper.selectList(any())).thenReturn(
|
||||
List.of(driver(DRIVER_ID, null, PLATE), driver(9003L, null, "")));
|
||||
|
||||
DriverEnsureProfileVO result = service.ensureProfile(TENANT_ID, MP_USER_ID, MOBILE);
|
||||
|
||||
assertFalse(result.getCreated());
|
||||
assertFalse(result.getBoundExisting());
|
||||
assertNull(result.getDriverId());
|
||||
assertEquals("该手机号存在多条司机档案,请联系管理员核对", result.getMessage());
|
||||
verify(driverMapper, never()).insert(any(Driver.class));
|
||||
verify(driverMapper, never()).updateById(any(Driver.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("同手机号档案已属于其他账号:不覆盖,返回可读原因")
|
||||
void rejectsMobileOwnedByAnotherAccount() {
|
||||
stubUserLookup();
|
||||
when(driverMapper.selectList(any())).thenReturn(
|
||||
List.of(driver(DRIVER_ID, OTHER_USER_ID, PLATE)));
|
||||
|
||||
DriverEnsureProfileVO result = service.ensureProfile(TENANT_ID, MP_USER_ID, MOBILE);
|
||||
|
||||
assertFalse(result.getCreated());
|
||||
assertFalse(result.getBoundExisting());
|
||||
assertNull(result.getDriverId());
|
||||
assertEquals("该手机号已被其他账号占用,请联系管理员", result.getMessage());
|
||||
verify(driverMapper, never()).insert(any(Driver.class));
|
||||
verify(driverMapper, never()).updateById(any(Driver.class));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 入参兜底
|
||||
|
||||
@Test
|
||||
@DisplayName("脱敏手机号:拒绝写库,不新建档案")
|
||||
void rejectsMaskedMobile() {
|
||||
assertThrows(ServiceException.class, () -> service.ensureProfile(TENANT_ID, MP_USER_ID, "155****2748"));
|
||||
verify(driverMapper, never()).insert(any(Driver.class));
|
||||
verify(driverMapper, never()).updateById(any(Driver.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("租户为空且当前上下文也没有租户:拒绝写库")
|
||||
void rejectsBlankTenant() {
|
||||
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class)) {
|
||||
authUtil.when(AuthUtil::getTenantId).thenReturn(null);
|
||||
|
||||
assertThrows(ServiceException.class, () -> service.ensureProfile(null, MP_USER_ID, MOBILE));
|
||||
}
|
||||
verify(driverMapper, never()).insert(any(Driver.class));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
/**
|
||||
* 按 user_id 反查走 {@code getOne} → {@code baseMapper.selectOne};MyBatis-Plus 同时提供
|
||||
* 单参与带 throwEx 两个重载,桩必须两个都打,否则 mock 返回默认值、反查永远落空。
|
||||
*/
|
||||
private void stubUserLookup() {
|
||||
stubUserLookup((Driver) null);
|
||||
}
|
||||
|
||||
private void stubUserLookup(Driver first, Driver... rest) {
|
||||
when(driverMapper.selectOne(any())).thenReturn(first, rest);
|
||||
when(driverMapper.selectOne(any(), anyBoolean())).thenReturn(first, rest);
|
||||
}
|
||||
|
||||
private Driver driver(Long id, Long userId, String drivingVehicle) {
|
||||
Driver driver = new Driver();
|
||||
driver.setId(id);
|
||||
driver.setIsDeleted(0);
|
||||
driver.setTenantId(TENANT_ID);
|
||||
driver.setUserId(userId);
|
||||
driver.setMobile(MOBILE);
|
||||
driver.setDrivingVehicle(drivingVehicle);
|
||||
return driver;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
+29
@@ -30,6 +30,7 @@ import org.springblade.transport.service.ITransportVehicleService;
|
||||
import org.springblade.transport.service.ITripQueryService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.support.CertificationGate;
|
||||
import org.springblade.transport.support.WaybillProcessSupport;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
@@ -147,6 +148,34 @@ class DriverTripCardTest {
|
||||
assertEquals("桂A32523", card.getVehicleNo());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("运次卡片带唯一原始业务状态:子单都在待回单确认时下发 pending_return_confirm,数字态仍是 1")
|
||||
void tripCardCarriesUniqueChildBusinessStatus() {
|
||||
TripVO trip = loadingTrip();
|
||||
trip.getWaybills().forEach(child -> child.setBusinessStatus(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM));
|
||||
stubTripPage(1, trip);
|
||||
|
||||
IPage<DriverWaybillCardVO> page = runAsDriver(() -> service.tripPage(1, 10, null, null));
|
||||
|
||||
DriverWaybillCardVO card = page.getRecords().get(0);
|
||||
assertEquals(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM, card.getBusinessStatus());
|
||||
assertEquals(trip.getStatus(), card.getStatus(), "运次数字态口径不变,仍取运次状态");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("运次卡片原始业务状态:配载车混状态时给 null,由前端回退数字态")
|
||||
void tripCardWithoutUniqueChildBusinessStatusGivesNull() {
|
||||
TripVO trip = loadingTrip();
|
||||
trip.getWaybills().get(0).setBusinessStatus(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM);
|
||||
stubTripPage(1, trip);
|
||||
|
||||
IPage<DriverWaybillCardVO> page = runAsDriver(() -> service.tripPage(1, 10, null, null));
|
||||
|
||||
DriverWaybillCardVO card = page.getRecords().get(0);
|
||||
assertNull(card.getBusinessStatus(), "一趟车里两种原始状态,没有唯一值可下发");
|
||||
assertEquals(1, card.getStatus(), "混状态不影响运次数字态");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("运次列表不因配载卡去回查运单,也不写库")
|
||||
void tripPageDoesNotLookUpWaybillsOrWrite() {
|
||||
|
||||
+104
-11
@@ -7,6 +7,7 @@ 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;
|
||||
@@ -28,6 +29,7 @@ import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.service.ITripQueryService;
|
||||
import org.springblade.transport.support.CertificationGate;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
@@ -35,6 +37,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.atLeastOnce;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
@@ -42,11 +45,13 @@ import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 司机打完末位打卡节点后运单自动完成。
|
||||
* 司机打完末位打卡节点后运单的去向。
|
||||
* <p>
|
||||
* 命中要四个条件同时成立:本次打卡的是「司机可打卡节点」的末位、该节点是到货/卸货/签收、
|
||||
* 回单无需确认完成、运单本身允许完成。任一不成立都只落打卡流水,
|
||||
* 响应里的 waybillCompleted 始终取完成接口的返回值,前端据此决定是否提示「运单已完成」。
|
||||
* 末位打卡是到货/卸货/签收、运单本身允许完成时,回单是否需业务员确认决定去向:
|
||||
* 无需确认则本次一并完成运单,需确认则把运单置为待回单确认等业务员动手。
|
||||
* 任一条件不成立都只落打卡流水。
|
||||
* 响应里的 waybillCompleted 始终取完成接口的返回值,前端据此决定是否提示「运单已完成」;
|
||||
* 待回单确认不是已完成,只能靠 waybillBusinessStatus 分辨。
|
||||
*/
|
||||
class DriverWaybillAutoCompleteTest {
|
||||
|
||||
@@ -67,11 +72,24 @@ class DriverWaybillAutoCompleteTest {
|
||||
{"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"yes"}]
|
||||
""";
|
||||
|
||||
/** 装货 → 签收两个司机打卡节点,末位是签收,回单需确认完成 */
|
||||
private static final String LOAD_SIGN_RETURN_CONFIRM_JSON = """
|
||||
[{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true},
|
||||
{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true},
|
||||
{"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"yes"}]
|
||||
""";
|
||||
|
||||
/** 末位打卡节点是「发货」,不是到货/卸货/签收 */
|
||||
private static final String DISPATCH_LAST_JSON = """
|
||||
[{"key":"dispatch","name":"发货","type":"cargo","enabled":true,"punch":true}]
|
||||
""";
|
||||
|
||||
/** 末位司机打卡节点是「发货」,回单启用且需确认完成 */
|
||||
private static final String DISPATCH_LAST_RETURN_CONFIRM_JSON = """
|
||||
[{"key":"dispatch","name":"发货","type":"cargo","enabled":true,"punch":true},
|
||||
{"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"yes"}]
|
||||
""";
|
||||
|
||||
private IWaybillService waybillService;
|
||||
private IDriverService driverService;
|
||||
private WaybillNodePunchMapper nodePunchMapper;
|
||||
@@ -118,7 +136,7 @@ class DriverWaybillAutoCompleteTest {
|
||||
// ------------------------------------------------------------ 命中
|
||||
|
||||
@Test
|
||||
@DisplayName("末位签收打卡且回单无需确认时调完成接口,响应标记 waybillCompleted")
|
||||
@DisplayName("末位签收打卡且回单无需确认时调完成接口,响应标记 waybillCompleted 与 completed")
|
||||
void lastDeliveryPunchCompletesWaybill() {
|
||||
givenWaybill(SIGN_LAST_JSON, "running");
|
||||
when(waybillService.completeWithoutDeptCheck(WAYBILL_ID)).thenReturn(true);
|
||||
@@ -127,6 +145,7 @@ class DriverWaybillAutoCompleteTest {
|
||||
|
||||
verify(waybillService).completeWithoutDeptCheck(WAYBILL_ID);
|
||||
assertTrue(vo.getWaybillCompleted(), "完成接口返回成功,响应要告诉前端运单已完成");
|
||||
assertEquals("completed", vo.getWaybillBusinessStatus(), "完成后运单业务状态要如实回报");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -139,6 +158,25 @@ class DriverWaybillAutoCompleteTest {
|
||||
|
||||
verify(waybillService).completeWithoutDeptCheck(WAYBILL_ID);
|
||||
assertFalse(vo.getWaybillCompleted(), "完成接口自己说不算完成时,前端不能提示运单已完成");
|
||||
assertEquals("running", vo.getWaybillBusinessStatus(), "完成没落库,运单仍在进行中");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 待回单确认
|
||||
|
||||
@Test
|
||||
@DisplayName("回单需业务员确认时末位打卡进入待回单确认,落库并回报,且不调完成接口")
|
||||
void returnNeedingConfirmationEntersPendingReturnConfirm() {
|
||||
givenWaybill(SIGN_LAST_RETURN_CONFIRM_JSON, "running");
|
||||
|
||||
DriverNodePunchVO vo = submit("sign");
|
||||
|
||||
verify(waybillService, never()).completeWithoutDeptCheck(any());
|
||||
verify(nodePunchMapper).insert(any(WaybillNodePunch.class));
|
||||
assertFalse(vo.getWaybillCompleted(), "待回单确认不是已完成");
|
||||
assertEquals("pending_return_confirm", vo.getWaybillBusinessStatus(),
|
||||
"客户端要知道打卡已提交、等业务员确认回单");
|
||||
assertEquals("pending_return_confirm", writtenBusinessStatus(),
|
||||
"状态要落库,否则下次查询又回到进行中");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 不命中
|
||||
@@ -153,18 +191,31 @@ class DriverWaybillAutoCompleteTest {
|
||||
verify(waybillService, never()).completeWithoutDeptCheck(any());
|
||||
verify(nodePunchMapper).insert(any(WaybillNodePunch.class));
|
||||
assertFalse(vo.getWaybillCompleted());
|
||||
assertEquals("running", vo.getWaybillBusinessStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("回单启用且需确认完成时只打卡不完成")
|
||||
void returnNeedingConfirmationDoesNotComplete() {
|
||||
givenWaybill(SIGN_LAST_RETURN_CONFIRM_JSON, "running");
|
||||
@DisplayName("回单需确认但不是末位打卡:运单留在进行中")
|
||||
void nonLastPunchDoesNotEnterReturnConfirmation() {
|
||||
givenWaybill(LOAD_SIGN_RETURN_CONFIRM_JSON, "running");
|
||||
|
||||
DriverNodePunchVO vo = submit("sign");
|
||||
DriverNodePunchVO vo = submit("load");
|
||||
|
||||
verify(nodePunchMapper).insert(any(WaybillNodePunch.class));
|
||||
assertEquals("running", vo.getWaybillBusinessStatus(), "后面还有签收要打,不能提前停在待回单确认");
|
||||
assertEquals("running", writtenBusinessStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("回单需确认但末位不是到货/卸货/签收:运单留在进行中")
|
||||
void nonDeliveryLastNodeDoesNotEnterReturnConfirmation() {
|
||||
givenWaybill(DISPATCH_LAST_RETURN_CONFIRM_JSON, "running");
|
||||
|
||||
DriverNodePunchVO vo = submit("dispatch");
|
||||
|
||||
verify(waybillService, never()).completeWithoutDeptCheck(any());
|
||||
verify(nodePunchMapper).insert(any(WaybillNodePunch.class));
|
||||
assertFalse(vo.getWaybillCompleted());
|
||||
assertEquals("running", vo.getWaybillBusinessStatus(), "发货不是到货类节点,打完不该停在等待回单");
|
||||
assertEquals("running", writtenBusinessStatus());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 前置:运单本身能否完成
|
||||
@@ -208,8 +259,50 @@ class DriverWaybillAutoCompleteTest {
|
||||
assertFalse(vo.getWaybillCompleted());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("有未了结异常时回单需确认也不进待回单确认——四条进入条件共同成立才进")
|
||||
void unresolvedExceptionBlocksEnteringReturnConfirmation() {
|
||||
givenWaybill(SIGN_LAST_RETURN_CONFIRM_JSON, "running");
|
||||
when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of(unresolvedException("unload")));
|
||||
|
||||
DriverNodePunchVO vo = submit("sign");
|
||||
|
||||
assertEquals("running", vo.getWaybillBusinessStatus(), "异常未了结,不能把运单停在等待回单");
|
||||
assertEquals("running", writtenBusinessStatus());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 并发:只从进行中进入
|
||||
|
||||
@Test
|
||||
@DisplayName("打卡期间运单已被推到终态时不再写待回单确认——只从进行中进入")
|
||||
void concurrentTerminalStatusPreventsEnteringReturnConfirmation() {
|
||||
givenWaybill(SIGN_LAST_RETURN_CONFIRM_JSON, "running");
|
||||
List<String> statusesWritten = new ArrayList<>();
|
||||
// 模拟另一事务在这次打卡推进过程节点时把运单置为已完成(管理端强制完成)
|
||||
when(waybillService.updateById(any())).thenAnswer(invocation -> {
|
||||
Waybill entity = invocation.getArgument(0);
|
||||
statusesWritten.add(entity.getBusinessStatus());
|
||||
entity.setBusinessStatus("completed");
|
||||
return true;
|
||||
});
|
||||
|
||||
DriverNodePunchVO vo = submit("sign");
|
||||
|
||||
assertFalse(statusesWritten.contains("pending_return_confirm"),
|
||||
"运单已被完成,不能再改写回待回单确认");
|
||||
assertEquals("completed", vo.getWaybillBusinessStatus());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
/** 打卡流程最后一次落库时运单的业务状态;状态写库走 updateById,各次传的是同一个实体引用 */
|
||||
private String writtenBusinessStatus() {
|
||||
ArgumentCaptor<Waybill> captor = ArgumentCaptor.forClass(Waybill.class);
|
||||
verify(waybillService, atLeastOnce()).updateById(captor.capture());
|
||||
List<Waybill> written = captor.getAllValues();
|
||||
return written.get(written.size() - 1).getBusinessStatus();
|
||||
}
|
||||
|
||||
private DriverNodePunchVO submit(String nodeCode) {
|
||||
NodeSubmitDTO dto = new NodeSubmitDTO();
|
||||
dto.setWaybillId(WAYBILL_ID);
|
||||
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
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.mockito.MockedStatic;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.system.feign.IMpUserClient;
|
||||
import org.springblade.transport.mapper.ExceptionDisposalMapper;
|
||||
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
|
||||
import org.springblade.transport.mapper.WaybillNodePunchMapper;
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.entity.WaybillNodePunch;
|
||||
import org.springblade.transport.pojo.vo.DriverWaybillCardVO;
|
||||
import org.springblade.transport.pojo.vo.DriverWaybillTabCountsVO;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
import org.springblade.transport.service.ITripQueryService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.support.CertificationGate;
|
||||
import org.springblade.transport.support.WaybillProcessSupport;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 司机端四个 Tab 的状态分档。
|
||||
* <p>
|
||||
* 待回单确认是「等业务员动手」的进行中:数字态沿用 1(进行中),查询口径也必须认它。
|
||||
* 漏掉的话司机打完末位卡,这张单从他的列表里彻底消失——不是展示问题,是单子不见了。
|
||||
*/
|
||||
class DriverWaybillTabStatusTest {
|
||||
|
||||
private static final Long WAYBILL_ID = 5901L;
|
||||
private static final Long USER_ID = 7001L;
|
||||
private static final String PLATE = "桂AAW3256";
|
||||
private static final String PHONE = "13800138002";
|
||||
|
||||
private IWaybillService waybillService;
|
||||
private IDriverService driverService;
|
||||
private WaybillNodePunchMapper nodePunchMapper;
|
||||
private ExceptionDisposalMapper exceptionDisposalMapper;
|
||||
private final List<Wrapper<Waybill>> queried = new ArrayList<>();
|
||||
private DriverWaybillServiceImpl service;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
// 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, Waybill.class);
|
||||
TableInfoHelper.initTableInfo(assistant, Driver.class);
|
||||
TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
waybillService = mock(IWaybillService.class);
|
||||
driverService = mock(IDriverService.class);
|
||||
nodePunchMapper = mock(WaybillNodePunchMapper.class);
|
||||
exceptionDisposalMapper = mock(ExceptionDisposalMapper.class);
|
||||
service = new DriverWaybillServiceImpl(
|
||||
waybillService,
|
||||
driverService,
|
||||
mock(IMpUserClient.class),
|
||||
mock(IProcessConfigService.class),
|
||||
mock(ITransportVehicleService.class),
|
||||
mock(CertificationGate.class),
|
||||
mock(WaybillEnroutePunchMapper.class),
|
||||
nodePunchMapper,
|
||||
exceptionDisposalMapper,
|
||||
mock(ITripQueryService.class));
|
||||
|
||||
when(driverService.getOne(any())).thenReturn(driver());
|
||||
when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(waybillService.list(anyWrapper())).thenAnswer(invocation -> {
|
||||
queried.add(invocation.getArgument(0));
|
||||
return List.of(pendingReturnConfirmWaybill());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("待回单确认落在「进行中」档:列表查得到,数字态是 1")
|
||||
void pendingReturnConfirmIsInProgressTier() {
|
||||
IPage<DriverWaybillCardVO> page = runAsDriver(() -> service.page(1, 10, 1, null));
|
||||
|
||||
assertEquals(1, page.getRecords().size(), "进行中档必须包含待回单确认,否则司机看不到这张单");
|
||||
assertEquals(Integer.valueOf(1), page.getRecords().get(0).getStatus(), "数字态沿用 1(进行中),不新增档位");
|
||||
assertEquals(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM,
|
||||
page.getRecords().get(0).getBusinessStatus(),
|
||||
"原始业务状态随卡片下发:数字态恒为 1,前端靠它区分运输中与待回单确认");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("「进行中」查询口径在库层就带上待回单确认")
|
||||
void inProgressQueryCarriesPendingReturnConfirm() {
|
||||
runAsDriver(() -> service.page(1, 10, 1, null));
|
||||
|
||||
assertTrue(queriedStatuses().contains(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("「全部」tab 不漏掉待回单确认的运单")
|
||||
void allTabKeepsPendingReturnConfirm() {
|
||||
IPage<DriverWaybillCardVO> page = runAsDriver(() -> service.page(1, 10, null, null));
|
||||
|
||||
assertEquals(1, page.getRecords().size(), "打完末位卡的单不能从全部 tab 消失");
|
||||
assertEquals(Integer.valueOf(1), page.getRecords().get(0).getStatus());
|
||||
assertEquals(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM,
|
||||
page.getRecords().get(0).getBusinessStatus());
|
||||
assertTrue(queriedStatuses().contains(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Tab 计数把它算进进行中,不算已完成")
|
||||
void tabCountsCountsItAsDoing() {
|
||||
DriverWaybillTabCountsVO counts = runAsDriver(service::tabCounts);
|
||||
|
||||
assertEquals(1, counts.getDoing(), "待回单确认仍是进行中,不是已完成");
|
||||
assertEquals(0, counts.getDone());
|
||||
assertEquals(1, counts.getAll());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
private <T> T runAsDriver(Supplier<T> action) {
|
||||
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class)) {
|
||||
authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID);
|
||||
return action.get();
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Collection<Object> queriedStatuses() {
|
||||
assertEquals(1, queried.size(), "一次列表查询");
|
||||
LambdaQueryWrapper<Waybill> wrapper = (LambdaQueryWrapper<Waybill>) queried.get(0);
|
||||
// 参数槽由 SQL 片段构建时填充:不先取一次片段,参数表是空的
|
||||
wrapper.getSqlSegment();
|
||||
Collection<Object> statuses = new ArrayList<>();
|
||||
wrapper.getParamNameValuePairs().values().forEach(value -> {
|
||||
if (value instanceof Collection<?> items) {
|
||||
statuses.addAll(items);
|
||||
} else {
|
||||
statuses.add(value);
|
||||
}
|
||||
});
|
||||
return statuses;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Wrapper<Waybill> anyWrapper() {
|
||||
return org.mockito.ArgumentMatchers.<Wrapper<Waybill>>any();
|
||||
}
|
||||
|
||||
private Waybill pendingReturnConfirmWaybill() {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setId(WAYBILL_ID);
|
||||
waybill.setWaybillNo("YD202610080002");
|
||||
waybill.setVehicleNo(PLATE);
|
||||
waybill.setDriverPhone(PHONE);
|
||||
waybill.setBusinessStatus(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM);
|
||||
waybill.setIsDeleted(0);
|
||||
return waybill;
|
||||
}
|
||||
|
||||
private Driver driver() {
|
||||
Driver driver = new Driver();
|
||||
driver.setId(9001L);
|
||||
driver.setUserId(USER_ID);
|
||||
driver.setDrivingVehicle(PLATE);
|
||||
driver.setMobile(PHONE);
|
||||
return driver;
|
||||
}
|
||||
|
||||
}
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
package org.springblade.transport.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.mockito.MockedStatic;
|
||||
import org.springblade.core.tool.jackson.JsonUtil;
|
||||
import org.springblade.system.cache.UserDisplayCache;
|
||||
import org.springblade.transport.mapper.ExceptionDisposalFollowRecordMapper;
|
||||
import org.springblade.transport.mapper.ExceptionDisposalMapper;
|
||||
import org.springblade.transport.mapper.LoadingManageMapper;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposalFollowRecord;
|
||||
import org.springblade.transport.pojo.entity.LoadingManage;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.vo.ExceptionDisposalVO;
|
||||
import org.springblade.transport.service.IDriverWaybillService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 异常详情下发的配载单、子运单与可编辑站点。
|
||||
* <p>
|
||||
* 有配载单时站点取配载单的站点行;没有配载单才退回运单的路线段。
|
||||
* 站点地址与改路线时的比对口径一致(地址优先、站名兜底)。
|
||||
*/
|
||||
class ExceptionDisposalDetailPayloadTest {
|
||||
|
||||
private static final Long DISPOSAL_ID = 9101L;
|
||||
private static final Long WAYBILL_ID = 9201L;
|
||||
private static final String LOADING_NO = "PZ20261023009";
|
||||
private static final String WAYBILL_NO = "YD202610230009";
|
||||
private static final Long CHILD_ID_FIRST = 9301L;
|
||||
private static final Long CHILD_ID_SECOND = 9302L;
|
||||
private static final String CHILD_NO_FIRST = "YD202610230011";
|
||||
private static final String CHILD_NO_SECOND = "YD202610230012";
|
||||
private static final String PROJECT_FIRST = "冷链项目";
|
||||
private static final String PROJECT_SECOND = "普货项目";
|
||||
private static final String START = "南宁市/邕宁区/那赖二路神网数码物流园";
|
||||
private static final String TRANSIT = "钦州市/沙坪坝区/泰安大道物流园";
|
||||
private static final String END = "成都市/双流区/口岸路成藏宝物流园";
|
||||
private static final String WAYBILL_START_NAME = "南宁";
|
||||
private static final String WAYBILL_END_NAME = "成都";
|
||||
|
||||
private ExceptionDisposalMapper disposalMapper;
|
||||
private ExceptionDisposalFollowRecordMapper followRecordMapper;
|
||||
private IWaybillService waybillService;
|
||||
private LoadingManageMapper loadingManageMapper;
|
||||
private ExceptionDisposalServiceImpl service;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
// 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class);
|
||||
TableInfoHelper.initTableInfo(assistant, ExceptionDisposalFollowRecord.class);
|
||||
TableInfoHelper.initTableInfo(assistant, LoadingManage.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
disposalMapper = mock(ExceptionDisposalMapper.class);
|
||||
followRecordMapper = mock(ExceptionDisposalFollowRecordMapper.class);
|
||||
waybillService = mock(IWaybillService.class);
|
||||
loadingManageMapper = mock(LoadingManageMapper.class);
|
||||
service = new ExceptionDisposalServiceImpl(
|
||||
followRecordMapper, waybillService, mock(IDriverWaybillService.class),
|
||||
mock(org.springblade.transport.support.MpUserDataScopeSupport.class), loadingManageMapper);
|
||||
inject("baseMapper", disposalMapper);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 有配载单
|
||||
|
||||
@Test
|
||||
@DisplayName("有配载单:下发配载单号、按运单集合顺序的子运单号,站点与项目取自站点行")
|
||||
void loadingDetailPublishesChildWaybillsAndStationNodes() {
|
||||
stubDisposal();
|
||||
stubWaybill(LOADING_NO, null);
|
||||
stubLoading(List.of(
|
||||
stationRow(START, null, List.of("装1", "卸1")),
|
||||
stationRow(TRANSIT, null, List.of("卸1", "装2")),
|
||||
stationRow(END, null, List.of("卸2"))));
|
||||
// 数据库不保证按主键集合的顺序返回:倒序回来,下发仍要按配载单运单集合的顺序
|
||||
when(waybillService.listByIds(any())).thenReturn(List.of(
|
||||
child(CHILD_ID_SECOND, CHILD_NO_SECOND, PROJECT_SECOND),
|
||||
child(CHILD_ID_FIRST, CHILD_NO_FIRST, PROJECT_FIRST)));
|
||||
|
||||
ExceptionDisposalVO vo = detailResponse();
|
||||
|
||||
assertEquals(LOADING_NO, vo.getLoadingNo());
|
||||
assertEquals(List.of(CHILD_NO_FIRST, CHILD_NO_SECOND), vo.getChildWaybillNos(),
|
||||
"子运单号顺序就是站点标记里数字的顺序");
|
||||
|
||||
List<ExceptionDisposalVO.RouteNode> nodes = vo.getRouteNodes();
|
||||
assertEquals(3, nodes.size(), "站点数与配载单路线一致");
|
||||
assertEquals(List.of("start", "transit", "end"), kinds(nodes));
|
||||
assertEquals(START, nodes.get(0).getAddress());
|
||||
assertEquals(TRANSIT, nodes.get(1).getAddress());
|
||||
assertEquals(END, nodes.get(2).getAddress());
|
||||
assertEquals(List.of("装1", "卸1"), nodes.get(0).getTags());
|
||||
assertEquals(List.of(PROJECT_FIRST), nodes.get(0).getProjects(), "同一站的同一项目只出现一次");
|
||||
assertEquals(List.of(PROJECT_FIRST, PROJECT_SECOND), nodes.get(1).getProjects(),
|
||||
"标记按顺序映射到子运单");
|
||||
assertEquals(List.of(PROJECT_SECOND), nodes.get(2).getProjects());
|
||||
assertEquals(START, vo.getRoute().get("start"));
|
||||
assertEquals(END, vo.getRoute().get("end"));
|
||||
|
||||
verify(disposalMapper, never()).updateById(any(ExceptionDisposal.class));
|
||||
verify(loadingManageMapper, never()).updateById(any(LoadingManage.class));
|
||||
verify(waybillService, never()).updateById(any(Waybill.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("路线摘要用站点行的展示地址,站点地址仍取地址列")
|
||||
void routeSummaryPrefersDisplayAddress() {
|
||||
stubDisposal();
|
||||
stubWaybill(LOADING_NO, null);
|
||||
stubLoading(List.of(
|
||||
stationRow(START, START + "(展示)", List.of("装1")),
|
||||
stationRow(END, null, List.of("卸2"))));
|
||||
when(waybillService.listByIds(any())).thenReturn(List.of(
|
||||
child(CHILD_ID_FIRST, CHILD_NO_FIRST, PROJECT_FIRST),
|
||||
child(CHILD_ID_SECOND, CHILD_NO_SECOND, PROJECT_SECOND)));
|
||||
|
||||
ExceptionDisposalVO vo = detailResponse();
|
||||
|
||||
assertEquals(START + "(展示)", vo.getRoute().get("start"), "摘要展示用站点行的展示地址");
|
||||
assertEquals(END, vo.getRoute().get("end"));
|
||||
assertEquals(START, vo.getRouteNodes().get(0).getAddress(),
|
||||
"站点地址是改路线时比对用的地址列,不能换成展示地址");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("只有一个站时不给出可编辑站点")
|
||||
void singleStationGetsNoRouteNodes() {
|
||||
stubDisposal();
|
||||
stubWaybill(LOADING_NO, null);
|
||||
stubLoading(List.of(stationRow(START, null, List.of("装1"))));
|
||||
when(waybillService.listByIds(any())).thenReturn(List.of(
|
||||
child(CHILD_ID_FIRST, CHILD_NO_FIRST, PROJECT_FIRST)));
|
||||
|
||||
ExceptionDisposalVO vo = detailResponse();
|
||||
|
||||
assertTrue(vo.getRouteNodes().isEmpty(), "没有首末站就没有可调整的站序");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("子运单查不到时跳过,不打乱其余子运单的顺序")
|
||||
void missingChildWaybillIsSkipped() {
|
||||
stubDisposal();
|
||||
stubWaybill(LOADING_NO, null);
|
||||
stubLoading(List.of(
|
||||
stationRow(START, null, List.of("装1")),
|
||||
stationRow(TRANSIT, null, List.of("卸1", "装2")),
|
||||
stationRow(END, null, List.of("卸2"))));
|
||||
// 第一张子运单已不在(被删或运单集合与运单实际不一致),只剩第二张
|
||||
when(waybillService.listByIds(any())).thenReturn(List.of(
|
||||
child(CHILD_ID_SECOND, CHILD_NO_SECOND, PROJECT_SECOND)));
|
||||
|
||||
ExceptionDisposalVO vo = detailResponse();
|
||||
|
||||
assertEquals(List.of(CHILD_NO_SECOND), vo.getChildWaybillNos());
|
||||
assertEquals(3, vo.getRouteNodes().size(), "站点不因子运单缺失而消失");
|
||||
assertTrue(vo.getRouteNodes().get(0).getProjects().isEmpty(), "找不到子运单的标记给空项目");
|
||||
assertEquals(List.of(PROJECT_SECOND), vo.getRouteNodes().get(1).getProjects());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 无配载单
|
||||
|
||||
@Test
|
||||
@DisplayName("配载单已不存在时退回运单路线段,历史异常照常打开")
|
||||
void missingLoadingFallsBackToWaybillRouteSegments() {
|
||||
stubDisposal();
|
||||
// 运单还挂着配载单号,配载单已被删除或解绑:按没有配载单处理
|
||||
stubWaybill("PZ202610230404", segmentRouteJson());
|
||||
|
||||
ExceptionDisposalVO vo = assertDoesNotThrow(this::detailResponse);
|
||||
|
||||
assertNull(vo.getLoadingNo());
|
||||
assertTrue(vo.getChildWaybillNos().isEmpty());
|
||||
List<ExceptionDisposalVO.RouteNode> nodes = vo.getRouteNodes();
|
||||
assertEquals(3, nodes.size(), "首段出发地是首站,其后每段到达地各一站");
|
||||
assertEquals(List.of("start", "transit", "end"), kinds(nodes));
|
||||
assertEquals(START, nodes.get(0).getAddress());
|
||||
assertEquals(List.of(), nodes.get(0).getTags(), "运单路线段没有装卸归属");
|
||||
assertEquals(List.of(), nodes.get(0).getProjects());
|
||||
assertEquals(WAYBILL_START_NAME, vo.getRoute().get("start"), "无配载单时摘要维持运单起讫名");
|
||||
assertEquals(WAYBILL_END_NAME, vo.getRoute().get("end"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("无配载单的站点地址地址优先、站名兜底,与改路线时的比对口径一致")
|
||||
void waybillNodesPreferAddressOverName() {
|
||||
stubDisposal();
|
||||
stubWaybill(null, JsonUtil.toJson(List.of(
|
||||
segment(WAYBILL_START_NAME, START, "钦州", TRANSIT),
|
||||
segment("钦州", TRANSIT, "贵阳", null))));
|
||||
|
||||
ExceptionDisposalVO vo = detailResponse();
|
||||
|
||||
List<ExceptionDisposalVO.RouteNode> nodes = vo.getRouteNodes();
|
||||
assertEquals(List.of(START, TRANSIT, "贵阳"),
|
||||
nodes.stream().map(ExceptionDisposalVO.RouteNode::getAddress).toList(),
|
||||
"原样提交时站点地址要与运单段地址一致,缺地址才用站名兜底");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("关联运单已删除时详情照常打开,配载单号 / 子运单号 / 站点各给空值")
|
||||
void deletedWaybillGivesEmptyDetailFields() {
|
||||
stubDisposal();
|
||||
Waybill deleted = new Waybill();
|
||||
deleted.setId(WAYBILL_ID);
|
||||
deleted.setIsDeleted(1);
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(deleted);
|
||||
|
||||
ExceptionDisposalVO vo = assertDoesNotThrow(this::detailResponse);
|
||||
|
||||
assertNull(vo.getLoadingNo());
|
||||
assertEquals(List.of(), vo.getChildWaybillNos());
|
||||
assertEquals(List.of(), vo.getRouteNodes());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
private void stubDisposal() {
|
||||
ExceptionDisposal disposal = new ExceptionDisposal();
|
||||
disposal.setId(DISPOSAL_ID);
|
||||
disposal.setDisposalStatus("pending");
|
||||
disposal.setWaybillId(WAYBILL_ID);
|
||||
disposal.setIsDeleted(0);
|
||||
when(disposalMapper.selectById(DISPOSAL_ID)).thenReturn(disposal);
|
||||
}
|
||||
|
||||
private void stubWaybill(String loadingNo, String routeJson) {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setId(WAYBILL_ID);
|
||||
waybill.setWaybillNo(WAYBILL_NO);
|
||||
waybill.setLoadingNo(loadingNo);
|
||||
waybill.setRouteJson(routeJson);
|
||||
waybill.setDepartureName(WAYBILL_START_NAME);
|
||||
waybill.setArrivalName(WAYBILL_END_NAME);
|
||||
waybill.setIsDeleted(0);
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill);
|
||||
}
|
||||
|
||||
private void stubLoading(List<Map<String, Object>> rows) {
|
||||
LoadingManage loading = new LoadingManage();
|
||||
loading.setId(9401L);
|
||||
loading.setLoadingNo(LOADING_NO);
|
||||
loading.setWaybillIdsJson("[" + CHILD_ID_FIRST + "," + CHILD_ID_SECOND + "]");
|
||||
loading.setRouteJson(JsonUtil.toJson(rows));
|
||||
loading.setIsDeleted(0);
|
||||
when(loadingManageMapper.selectOne(any())).thenReturn(loading);
|
||||
}
|
||||
|
||||
private Waybill child(Long id, String waybillNo, String projectName) {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setId(id);
|
||||
waybill.setWaybillNo(waybillNo);
|
||||
waybill.setProjectName(projectName);
|
||||
waybill.setIsDeleted(0);
|
||||
return waybill;
|
||||
}
|
||||
|
||||
private Map<String, Object> stationRow(String address, String displayAddress, List<String> tags) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("address", address);
|
||||
if (displayAddress != null) {
|
||||
row.put("displayAddress", displayAddress);
|
||||
}
|
||||
row.put("tags", tags);
|
||||
row.put("locked", false);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** 运单的路线段:两段三站,带地址的段 */
|
||||
private String segmentRouteJson() {
|
||||
return JsonUtil.toJson(List.of(
|
||||
segment(WAYBILL_START_NAME, START, TRANSIT, TRANSIT),
|
||||
segment(TRANSIT, TRANSIT, WAYBILL_END_NAME, END)));
|
||||
}
|
||||
|
||||
private Map<String, Object> segment(String departureName, String departureAddress,
|
||||
String arrivalName, String arrivalAddress) {
|
||||
Map<String, Object> segment = new LinkedHashMap<>();
|
||||
segment.put("departureName", departureName);
|
||||
segment.put("departureAddress", departureAddress);
|
||||
segment.put("arrivalName", arrivalName);
|
||||
if (arrivalAddress != null) {
|
||||
segment.put("arrivalAddress", arrivalAddress);
|
||||
}
|
||||
return segment;
|
||||
}
|
||||
|
||||
private List<String> kinds(List<ExceptionDisposalVO.RouteNode> nodes) {
|
||||
return nodes.stream().map(ExceptionDisposalVO.RouteNode::getKind).toList();
|
||||
}
|
||||
|
||||
private ExceptionDisposalVO detailResponse() {
|
||||
// 详情里的创建人 / 更新人姓名走缓存,单测不装配 Spring 上下文
|
||||
try (MockedStatic<UserDisplayCache> ignored = mockStatic(UserDisplayCache.class)) {
|
||||
return service.detail(DISPOSAL_ID);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
+47
-2
@@ -11,20 +11,29 @@ 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.system.cache.UserDisplayCache;
|
||||
import org.springblade.transport.mapper.ExceptionDisposalFollowRecordMapper;
|
||||
import org.springblade.transport.mapper.ExceptionDisposalMapper;
|
||||
import org.springblade.transport.mapper.LoadingManageMapper;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.vo.ExceptionDisposalVO;
|
||||
import org.springblade.transport.service.IDriverWaybillService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
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.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -39,6 +48,8 @@ class ExceptionDisposalListQueryTest {
|
||||
private static final Long WAYBILL_ID = 5401L;
|
||||
|
||||
private ExceptionDisposalMapper disposalMapper;
|
||||
private IWaybillService waybillService;
|
||||
private LoadingManageMapper loadingManageMapper;
|
||||
private ExceptionDisposalServiceImpl service;
|
||||
|
||||
@BeforeAll
|
||||
@@ -47,18 +58,22 @@ class ExceptionDisposalListQueryTest {
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class);
|
||||
TableInfoHelper.initTableInfo(assistant, Waybill.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
disposalMapper = mock(ExceptionDisposalMapper.class);
|
||||
waybillService = mock(IWaybillService.class);
|
||||
loadingManageMapper = mock(LoadingManageMapper.class);
|
||||
org.springblade.transport.support.MpUserDataScopeSupport scopeSupport =
|
||||
mock(org.springblade.transport.support.MpUserDataScopeSupport.class);
|
||||
service = new ExceptionDisposalServiceImpl(
|
||||
mock(ExceptionDisposalFollowRecordMapper.class),
|
||||
mock(IWaybillService.class),
|
||||
waybillService,
|
||||
mock(IDriverWaybillService.class),
|
||||
scopeSupport);
|
||||
scopeSupport,
|
||||
loadingManageMapper);
|
||||
inject("baseMapper", disposalMapper);
|
||||
when(disposalMapper.selectPage(any(), any())).thenReturn(new Page<ExceptionDisposal>(1, 10));
|
||||
}
|
||||
@@ -89,6 +104,36 @@ class ExceptionDisposalListQueryTest {
|
||||
assertFalse(wrapper.getTargetSql().contains("waybill_id"), wrapper.getTargetSql());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("列表不组装详情字段:每行不查配载单、不查子运单")
|
||||
void listPageDoesNotLoadDetailFields() {
|
||||
ExceptionDisposal disposal = new ExceptionDisposal();
|
||||
disposal.setId(6501L);
|
||||
disposal.setWaybillId(WAYBILL_ID);
|
||||
disposal.setDisposalStatus("pending");
|
||||
disposal.setIsDeleted(0);
|
||||
Page<ExceptionDisposal> entityPage = new Page<>(1, 10);
|
||||
entityPage.setRecords(List.of(disposal));
|
||||
when(disposalMapper.selectPage(any(), any())).thenReturn(entityPage);
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setId(WAYBILL_ID);
|
||||
waybill.setLoadingNo("PZ20261023001");
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill);
|
||||
|
||||
IPage<ExceptionDisposalVO> page;
|
||||
try (MockedStatic<UserDisplayCache> ignored = mockStatic(UserDisplayCache.class)) {
|
||||
page = service.selectPage(new Page<>(1, 10), new ExceptionDisposalVO());
|
||||
}
|
||||
|
||||
assertEquals(1, page.getRecords().size());
|
||||
ExceptionDisposalVO vo = page.getRecords().get(0);
|
||||
assertNull(vo.getLoadingNo(), "配载单号只在详情下发");
|
||||
assertNull(vo.getChildWaybillNos(), "子运单号只在详情下发");
|
||||
assertNull(vo.getRouteNodes(), "可调整站点只在详情下发");
|
||||
verify(loadingManageMapper, never()).selectOne(any());
|
||||
verify(waybillService, never()).listByIds(any());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
private LambdaQueryWrapper<ExceptionDisposal> capturedQuery(ExceptionDisposalVO query) {
|
||||
|
||||
+355
@@ -0,0 +1,355 @@
|
||||
package org.springblade.transport.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.mockito.ArgumentCaptor;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.system.cache.UserDisplayCache;
|
||||
import org.springblade.transport.mapper.ExceptionDisposalFollowRecordMapper;
|
||||
import org.springblade.transport.mapper.ExceptionDisposalMapper;
|
||||
import org.springblade.transport.mapper.LoadingManageMapper;
|
||||
import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest;
|
||||
import org.springblade.transport.pojo.dto.ExceptionDisposalRejectRequest;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposalFollowRecord;
|
||||
import org.springblade.transport.pojo.vo.ExceptionDisposalVO;
|
||||
import org.springblade.transport.service.IDriverWaybillService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.List;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 拒绝与撤销:两个动作都了结异常,只有拒绝留痕。
|
||||
* <p>
|
||||
* 拒绝复用已完成这个终态(台账仍显示「已处理」),并写一条「拒绝:…」的跟进记录;
|
||||
* 撤销置为已撤销,不写跟进记录。终态(已完成 / 已撤销)不允许再跟进、再完成。
|
||||
*/
|
||||
class ExceptionDisposalRejectRevokeTest {
|
||||
|
||||
private static final Long DISPOSAL_ID = 6101L;
|
||||
private static final Long REPORTER_ID = 7001L;
|
||||
|
||||
private ExceptionDisposalMapper disposalMapper;
|
||||
private ExceptionDisposalFollowRecordMapper followRecordMapper;
|
||||
private ExceptionDisposalServiceImpl service;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
// 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
disposalMapper = mock(ExceptionDisposalMapper.class);
|
||||
followRecordMapper = mock(ExceptionDisposalFollowRecordMapper.class);
|
||||
service = new ExceptionDisposalServiceImpl(
|
||||
followRecordMapper, mock(IWaybillService.class), mock(IDriverWaybillService.class),
|
||||
mock(org.springblade.transport.support.MpUserDataScopeSupport.class),
|
||||
mock(LoadingManageMapper.class));
|
||||
inject("baseMapper", disposalMapper);
|
||||
when(disposalMapper.updateById(any(ExceptionDisposal.class))).thenReturn(1);
|
||||
when(followRecordMapper.insert(any(ExceptionDisposalFollowRecord.class))).thenReturn(1);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 拒绝
|
||||
|
||||
@Test
|
||||
@DisplayName("待处理异常被拒绝后了结,并留下一条带「拒绝:」前缀的跟进记录")
|
||||
void rejectSettlesPendingDisposalWithFollowRecord() {
|
||||
stubDisposal("pending", REPORTER_ID);
|
||||
|
||||
runAsReporter(() -> service.reject(rejectRequest("地址核实无误,按原计划执行")));
|
||||
|
||||
ExceptionDisposalFollowRecord record = insertedRecord();
|
||||
assertEquals("拒绝:地址核实无误,按原计划执行", record.getFollowContent());
|
||||
assertEquals(DISPOSAL_ID, record.getDisposalId());
|
||||
assertEquals(REPORTER_ID, record.getFollowUser());
|
||||
assertEquals("韦调度", record.getFollowUserName());
|
||||
assertNotNull(record.getFollowTime());
|
||||
assertNull(record.getAttachmentsJson(), "拒绝弹窗没有附件,跟进记录不得凭空写一份");
|
||||
|
||||
ExceptionDisposal settled = updatedDisposal();
|
||||
assertEquals("completed", settled.getDisposalStatus(), "拒绝复用已完成终态,不新增状态值");
|
||||
assertEquals("拒绝:地址核实无误,按原计划执行", settled.getLatestFollowContent(),
|
||||
"列表上的最新跟进说明要与跟进记录同一条文案");
|
||||
verify(followRecordMapper).insert(any(ExceptionDisposalFollowRecord.class));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 拒绝门禁与校验
|
||||
|
||||
@Test
|
||||
@DisplayName("处理中的异常同样能被拒绝并了结")
|
||||
void rejectSettlesProcessingDisposal() {
|
||||
stubDisposal("processing", REPORTER_ID);
|
||||
|
||||
runAsReporter(() -> service.reject(rejectRequest("按原计划执行")));
|
||||
|
||||
assertEquals("completed", updatedDisposal().getDisposalStatus());
|
||||
assertEquals("拒绝:按原计划执行", insertedRecord().getFollowContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已完成的异常再拒绝报「该异常已处理」,且不多写记录、不改状态")
|
||||
void rejectSettledDisposalRejected() {
|
||||
stubDisposal("completed", REPORTER_ID);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> runAsReporter(() -> service.reject(rejectRequest("地址核实无误"))));
|
||||
|
||||
assertEquals("该异常已处理", exception.getMessage());
|
||||
verify(followRecordMapper, never()).insert(any(ExceptionDisposalFollowRecord.class));
|
||||
verify(disposalMapper, never()).updateById(any(ExceptionDisposal.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已撤销的异常同属了结态,再拒绝也是「该异常已处理」")
|
||||
void rejectRevokedDisposalRejected() {
|
||||
stubDisposal("revoked", REPORTER_ID);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> runAsReporter(() -> service.reject(rejectRequest("地址核实无误"))));
|
||||
|
||||
assertEquals("该异常已处理", exception.getMessage());
|
||||
verify(followRecordMapper, never()).insert(any(ExceptionDisposalFollowRecord.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("拒绝原因只有空白时不肯了结,要求填写原因")
|
||||
void blankRejectReasonRejected() {
|
||||
stubDisposal("pending", REPORTER_ID);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> runAsReporter(() -> service.reject(rejectRequest(" "))));
|
||||
|
||||
assertEquals("请填写拒绝原因", exception.getMessage());
|
||||
verify(followRecordMapper, never()).insert(any(ExceptionDisposalFollowRecord.class));
|
||||
verify(disposalMapper, never()).updateById(any(ExceptionDisposal.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("拒绝原因超过200字被拒,避免撑爆跟进记录列")
|
||||
void tooLongRejectReasonRejected() {
|
||||
stubDisposal("pending", REPORTER_ID);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> runAsReporter(() -> service.reject(rejectRequest("理".repeat(201)))));
|
||||
|
||||
assertEquals("拒绝原因不能超过200字", exception.getMessage());
|
||||
verify(followRecordMapper, never()).insert(any(ExceptionDisposalFollowRecord.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("拒绝原因去掉首尾空白后落库")
|
||||
void rejectReasonIsTrimmed() {
|
||||
stubDisposal("pending", REPORTER_ID);
|
||||
|
||||
runAsReporter(() -> service.reject(rejectRequest(" 地址核实无误 ")));
|
||||
|
||||
assertEquals("拒绝:地址核实无误", insertedRecord().getFollowContent());
|
||||
assertEquals("拒绝:地址核实无误", updatedDisposal().getLatestFollowContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已撤销的异常状态名为「已撤销」,不能是空串")
|
||||
void revokedStatusHasDisplayName() {
|
||||
stubDisposal("revoked", REPORTER_ID);
|
||||
when(followRecordMapper.selectList(any())).thenReturn(List.of());
|
||||
|
||||
ExceptionDisposalVO vo = asReporter(() -> service.detail(DISPOSAL_ID));
|
||||
|
||||
assertEquals("已撤销", vo.getDisposalStatusName());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 撤销
|
||||
|
||||
@Test
|
||||
@DisplayName("上报人撤销自己的待处理异常:置为已撤销,不留跟进记录")
|
||||
void revokeOwnPendingDisposal() {
|
||||
stubDisposal("pending", REPORTER_ID);
|
||||
|
||||
runAsReporter(() -> service.revoke(DISPOSAL_ID));
|
||||
|
||||
assertEquals("revoked", updatedDisposal().getDisposalStatus());
|
||||
verify(followRecordMapper, never()).insert(any(ExceptionDisposalFollowRecord.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已经有人跟进(处理中)的异常,上报人仍能撤销:撤销取回的是上报本身")
|
||||
void revokeOwnProcessingDisposal() {
|
||||
stubDisposal("processing", REPORTER_ID);
|
||||
|
||||
runAsReporter(() -> service.revoke(DISPOSAL_ID));
|
||||
|
||||
assertEquals("revoked", updatedDisposal().getDisposalStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("撤销别人的上报被拒:撤销只能由上报人发起")
|
||||
void revokeOthersDisposalRejected() {
|
||||
stubDisposal("pending", 7002L);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> runAsReporter(() -> service.revoke(DISPOSAL_ID)));
|
||||
|
||||
assertEquals("只能撤销本人上报的异常", exception.getMessage());
|
||||
verify(disposalMapper, never()).updateById(any(ExceptionDisposal.class));
|
||||
verify(followRecordMapper, never()).insert(any(ExceptionDisposalFollowRecord.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("没有上报人(历史数据)时不得撤销:份属不明按非本人处理")
|
||||
void revokeDisposalWithoutReporterRejected() {
|
||||
stubDisposal("pending", null);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> runAsReporter(() -> service.revoke(DISPOSAL_ID)));
|
||||
|
||||
assertEquals("只能撤销本人上报的异常", exception.getMessage());
|
||||
verify(disposalMapper, never()).updateById(any(ExceptionDisposal.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已完成的异常不能再撤销")
|
||||
void revokeSettledDisposalRejected() {
|
||||
stubDisposal("completed", REPORTER_ID);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> runAsReporter(() -> service.revoke(DISPOSAL_ID)));
|
||||
|
||||
assertEquals("该异常已处理", exception.getMessage());
|
||||
verify(disposalMapper, never()).updateById(any(ExceptionDisposal.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已撤销的异常再撤销同样是「该异常已处理」")
|
||||
void revokeAlreadyRevokedDisposalRejected() {
|
||||
stubDisposal("revoked", REPORTER_ID);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> runAsReporter(() -> service.revoke(DISPOSAL_ID)));
|
||||
|
||||
assertEquals("该异常已处理", exception.getMessage());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 终态门禁
|
||||
|
||||
@Test
|
||||
@DisplayName("已撤销的异常不能再跟进:终态不允许回到处理中")
|
||||
void followRevokedDisposalRejected() {
|
||||
stubDisposal("revoked", REPORTER_ID);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> runAsReporter(() -> service.follow(followRequest("已核实,等待补件"))));
|
||||
|
||||
assertEquals("已完成的异常不允许继续跟进", exception.getMessage());
|
||||
verify(disposalMapper, never()).updateById(any(ExceptionDisposal.class));
|
||||
verify(followRecordMapper, never()).insert(any(ExceptionDisposalFollowRecord.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已撤销的异常不能再完成:撤销后不能改成已完成")
|
||||
void completeRevokedDisposalRejected() {
|
||||
stubDisposal("revoked", REPORTER_ID);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> runAsReporter(() -> service.complete(DISPOSAL_ID)));
|
||||
|
||||
assertEquals("异常已完成,请勿重复操作", exception.getMessage());
|
||||
verify(disposalMapper, never()).updateById(any(ExceptionDisposal.class));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
private void stubDisposal(String status, Long reporterId) {
|
||||
ExceptionDisposal disposal = new ExceptionDisposal();
|
||||
disposal.setId(DISPOSAL_ID);
|
||||
disposal.setDisposalStatus(status);
|
||||
disposal.setReporterId(reporterId);
|
||||
disposal.setIsDeleted(0);
|
||||
when(disposalMapper.selectById(DISPOSAL_ID)).thenReturn(disposal);
|
||||
}
|
||||
|
||||
private ExceptionDisposalRejectRequest rejectRequest(String reason) {
|
||||
ExceptionDisposalRejectRequest request = new ExceptionDisposalRejectRequest();
|
||||
request.setId(DISPOSAL_ID);
|
||||
request.setRejectReason(reason);
|
||||
return request;
|
||||
}
|
||||
|
||||
private ExceptionDisposalFollowRequest followRequest(String content) {
|
||||
ExceptionDisposalFollowRequest request = new ExceptionDisposalFollowRequest();
|
||||
request.setId(DISPOSAL_ID);
|
||||
request.setFollowContent(content);
|
||||
return request;
|
||||
}
|
||||
|
||||
private ExceptionDisposalFollowRecord insertedRecord() {
|
||||
ArgumentCaptor<ExceptionDisposalFollowRecord> captor =
|
||||
ArgumentCaptor.forClass(ExceptionDisposalFollowRecord.class);
|
||||
verify(followRecordMapper).insert(captor.capture());
|
||||
return captor.getValue();
|
||||
}
|
||||
|
||||
private ExceptionDisposal updatedDisposal() {
|
||||
ArgumentCaptor<ExceptionDisposal> captor = ArgumentCaptor.forClass(ExceptionDisposal.class);
|
||||
verify(disposalMapper).updateById(captor.capture());
|
||||
return captor.getValue();
|
||||
}
|
||||
|
||||
/** 以调度员身份执行,登录态与真实姓名都走缓存 */
|
||||
private void runAsReporter(Runnable action) {
|
||||
asReporter(() -> {
|
||||
action.run();
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
private <T> T asReporter(Supplier<T> action) {
|
||||
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class);
|
||||
MockedStatic<UserDisplayCache> userCache = mockStatic(UserDisplayCache.class)) {
|
||||
authUtil.when(AuthUtil::getUserId).thenReturn(REPORTER_ID);
|
||||
userCache.when(() -> UserDisplayCache.getUserRealName(REPORTER_ID)).thenReturn("韦调度");
|
||||
return action.get();
|
||||
}
|
||||
}
|
||||
|
||||
private void inject(String field, Object value) throws Exception {
|
||||
Class<?> type = service.getClass();
|
||||
while (type != null) {
|
||||
try {
|
||||
Field declared = type.getDeclaredField(field);
|
||||
declared.setAccessible(true);
|
||||
declared.set(service, value);
|
||||
return;
|
||||
} catch (NoSuchFieldException ignored) {
|
||||
type = type.getSuperclass();
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("未找到字段:" + field);
|
||||
}
|
||||
|
||||
}
|
||||
+3
-1
@@ -15,6 +15,7 @@ import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.system.cache.UserDisplayCache;
|
||||
import org.springblade.transport.mapper.ExceptionDisposalFollowRecordMapper;
|
||||
import org.springblade.transport.mapper.ExceptionDisposalMapper;
|
||||
import org.springblade.transport.mapper.LoadingManageMapper;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.service.IDriverWaybillService;
|
||||
@@ -72,7 +73,8 @@ class ExceptionDisposalReportGateTest {
|
||||
driverWaybillService = mock(IDriverWaybillService.class);
|
||||
service = new ExceptionDisposalServiceImpl(
|
||||
mock(ExceptionDisposalFollowRecordMapper.class), waybillService, driverWaybillService,
|
||||
mock(org.springblade.transport.support.MpUserDataScopeSupport.class));
|
||||
mock(org.springblade.transport.support.MpUserDataScopeSupport.class),
|
||||
mock(LoadingManageMapper.class));
|
||||
inject("baseMapper", disposalMapper);
|
||||
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill());
|
||||
|
||||
+580
@@ -0,0 +1,580 @@
|
||||
package org.springblade.transport.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.mockito.ArgumentCaptor;
|
||||
import org.mockito.InOrder;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.core.tool.jackson.JsonUtil;
|
||||
import org.springblade.system.cache.UserDisplayCache;
|
||||
import org.springblade.transport.mapper.ExceptionDisposalFollowRecordMapper;
|
||||
import org.springblade.transport.mapper.ExceptionDisposalMapper;
|
||||
import org.springblade.transport.mapper.LoadingManageMapper;
|
||||
import org.springblade.transport.pojo.dto.ExceptionDisposalRouteAdjustRequest;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposalFollowRecord;
|
||||
import org.springblade.transport.pojo.entity.LoadingManage;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.service.IDriverWaybillService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
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.Mockito.inOrder;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 同意并调整路线:先写跟进记录、再改载体路线、最后了结。
|
||||
* <p>
|
||||
* 载体跟配载单走:配载单是这趟车的执行主体,司机端打卡读它的站点行;没有配载单才改运单,
|
||||
* 运单的 route_json 是路线段形状。
|
||||
*/
|
||||
class ExceptionDisposalRouteAdjustTest {
|
||||
|
||||
private static final Long DISPOSAL_ID = 8201L;
|
||||
private static final Long WAYBILL_ID = 8301L;
|
||||
private static final Long USER_ID = 8401L;
|
||||
private static final String LOADING_NO = "PZ20261023001";
|
||||
private static final String START = "南宁市/邕宁区/那赖二路神网数码物流园";
|
||||
private static final String TRANSIT = "钦州市/沙坪坝区/泰安大道物流园";
|
||||
private static final String TRANSIT_SECOND = "贵阳市/白云区/都拉营物流园";
|
||||
private static final String END = "成都市/双流区/口岸路成藏宝物流园";
|
||||
private static final String NEW_END = "成都市/新都区/物流大道";
|
||||
|
||||
private ExceptionDisposalMapper disposalMapper;
|
||||
private ExceptionDisposalFollowRecordMapper followRecordMapper;
|
||||
private IWaybillService waybillService;
|
||||
private LoadingManageMapper loadingManageMapper;
|
||||
private ExceptionDisposalServiceImpl service;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
// 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class);
|
||||
TableInfoHelper.initTableInfo(assistant, LoadingManage.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
disposalMapper = mock(ExceptionDisposalMapper.class);
|
||||
followRecordMapper = mock(ExceptionDisposalFollowRecordMapper.class);
|
||||
waybillService = mock(IWaybillService.class);
|
||||
loadingManageMapper = mock(LoadingManageMapper.class);
|
||||
service = new ExceptionDisposalServiceImpl(
|
||||
followRecordMapper, waybillService, mock(IDriverWaybillService.class),
|
||||
mock(org.springblade.transport.support.MpUserDataScopeSupport.class), loadingManageMapper);
|
||||
inject("baseMapper", disposalMapper);
|
||||
when(disposalMapper.updateById(any(ExceptionDisposal.class))).thenReturn(1);
|
||||
when(followRecordMapper.insert(any(ExceptionDisposalFollowRecord.class))).thenReturn(1);
|
||||
when(loadingManageMapper.updateById(any(LoadingManage.class))).thenReturn(1);
|
||||
when(waybillService.updateById(any(Waybill.class))).thenReturn(true);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 有配载单
|
||||
|
||||
@Test
|
||||
@DisplayName("有配载单:路线整份写成站点行、地址列同步、变更记录前插、异常了结")
|
||||
void adjustRouteOnLoadingWritesStationRows() {
|
||||
stubPendingDisposal();
|
||||
LoadingManage loading = loading("in_progress",
|
||||
stationRowsJson(List.of(
|
||||
station(START, List.of("装1")),
|
||||
station(TRANSIT, List.of("卸1")),
|
||||
station(END, List.of("卸2")))));
|
||||
loading.setTaskInfoJson("{\"planName\":\"干线一\",\"routeChangeRecords\":[{\"content\":\"旧记录\"}]}");
|
||||
stubLoading(loading);
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill(LOADING_NO, "in_progress", null));
|
||||
|
||||
asScheduler(() -> service.adjustRoute(request(
|
||||
node("start", START, List.of("装1")),
|
||||
node("transit", TRANSIT_SECOND, List.of("卸1")),
|
||||
node("transit", TRANSIT, List.of("装2")),
|
||||
node("end", END, List.of("卸2")))));
|
||||
|
||||
LoadingManage updated = updatedLoading();
|
||||
List<Map<String, Object>> rows = JsonUtil.readListMap(updated.getRouteJson());
|
||||
assertEquals(4, rows.size(), "路线整份替换成调整后的站点");
|
||||
assertEquals(START, rows.get(0).get("address"));
|
||||
assertEquals(START, rows.get(0).get("displayAddress"), "站点行没有独立站名,展示地址就是地址");
|
||||
assertEquals(List.of("装1"), rows.get(0).get("tags"));
|
||||
assertEquals(List.of("卸1"), rows.get(1).get("tags"), "站点上的子运单标记必须原样保留");
|
||||
assertEquals(List.of("装2"), rows.get(2).get("tags"));
|
||||
assertEquals(Boolean.FALSE, rows.get(0).get("locked"));
|
||||
|
||||
assertEquals(START, updated.getDepartureAddress());
|
||||
assertEquals(END, updated.getArrivalAddress());
|
||||
assertEquals(TRANSIT_SECOND + "," + TRANSIT, updated.getTransitAddress(),
|
||||
"中间站地址用中文逗号连接");
|
||||
|
||||
Map<String, Object> taskInfo = JsonUtil.toMap(updated.getTaskInfoJson());
|
||||
assertEquals("干线一", taskInfo.get("planName"), "任务信息里其它键不能丢");
|
||||
List<?> records = (List<?>) taskInfo.get("routeChangeRecords");
|
||||
assertEquals(2, records.size());
|
||||
Map<?, ?> latest = (Map<?, ?>) records.get(0);
|
||||
assertEquals("韦调度", latest.get("changeUser"), "变更记录的操作人是真实姓名");
|
||||
assertEquals("", latest.get("remark"));
|
||||
String changeTime = String.valueOf(latest.get("changeTime"));
|
||||
assertTrue(changeTime.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}"), changeTime);
|
||||
assertEquals(routeContent(START, TRANSIT_SECOND, TRANSIT, END), latest.get("content"));
|
||||
assertEquals("旧记录", ((Map<?, ?>) records.get(1)).get("content"), "已有记录排在新的后面");
|
||||
|
||||
ExceptionDisposalFollowRecord record = insertedRecord();
|
||||
assertEquals(routeContent(START, TRANSIT_SECOND, TRANSIT, END), record.getFollowContent());
|
||||
assertEquals(USER_ID, record.getFollowUser());
|
||||
assertEquals("韦调度", record.getFollowUserName());
|
||||
assertNotNull(record.getFollowTime());
|
||||
|
||||
ExceptionDisposal settled = updatedDisposal();
|
||||
assertEquals("completed", settled.getDisposalStatus());
|
||||
assertEquals(record.getFollowContent(), settled.getLatestFollowContent());
|
||||
|
||||
InOrder order = inOrder(followRecordMapper, loadingManageMapper, disposalMapper);
|
||||
order.verify(followRecordMapper).insert(any(ExceptionDisposalFollowRecord.class));
|
||||
order.verify(loadingManageMapper).updateById(any(LoadingManage.class));
|
||||
order.verify(disposalMapper).updateById(any(ExceptionDisposal.class));
|
||||
verify(waybillService, never()).updateById(any(Waybill.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("地址一个都没改也允许了结,记录写「同意调整路线,未修改路线」")
|
||||
void adjustRouteWithoutAddressChangeStillSettles() {
|
||||
stubPendingDisposal();
|
||||
stubLoading(loading("in_progress",
|
||||
stationRowsJson(List.of(station(START, List.of("装1")), station(END, List.of("卸1"))))));
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill(LOADING_NO, "in_progress", null));
|
||||
|
||||
asScheduler(() -> service.adjustRoute(request(
|
||||
node("start", START, List.of("装1")),
|
||||
node("end", END, List.of("卸1")))));
|
||||
|
||||
assertEquals("同意调整路线,未修改路线", insertedRecord().getFollowContent());
|
||||
LoadingManage updated = updatedLoading();
|
||||
Map<String, Object> taskInfo = JsonUtil.toMap(updated.getTaskInfoJson());
|
||||
Map<?, ?> latest = (Map<?, ?>) ((List<?>) taskInfo.get("routeChangeRecords")).get(0);
|
||||
assertEquals("同意调整路线,未修改路线", latest.get("content"), "变更记录与跟进记录同一句摘要");
|
||||
assertEquals("completed", updatedDisposal().getDisposalStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("首站地址被改动时拒绝,且不写任何记录、不改状态")
|
||||
void adjustRouteRejectsStartAddressChange() {
|
||||
stubPendingDisposal();
|
||||
stubLoading(loading("in_progress",
|
||||
stationRowsJson(List.of(station(START, List.of("装1")), station(END, List.of("卸1"))))));
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill(LOADING_NO, "in_progress", null));
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class, () -> asScheduler(
|
||||
() -> service.adjustRoute(request(
|
||||
node("start", START + "(改)", List.of("装1")),
|
||||
node("end", END, List.of("卸1"))))));
|
||||
|
||||
assertEquals("起始地址不允许修改", exception.getMessage());
|
||||
verifyNoWrite();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("站点标记的数字超出子运单序号时拒绝,不写一份司机端读不出装卸的路线")
|
||||
void adjustRouteRejectsTagOutOfChildRange() {
|
||||
stubPendingDisposal();
|
||||
LoadingManage loading = loading("in_progress",
|
||||
stationRowsJson(List.of(station(START, List.of("装1")), station(END, List.of("卸1")))));
|
||||
loading.setWaybillIdsJson("[9001,9002]");
|
||||
stubLoading(loading);
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill(LOADING_NO, "in_progress", null));
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class, () -> asScheduler(
|
||||
() -> service.adjustRoute(request(
|
||||
node("start", START, List.of("装1")),
|
||||
node("end", END, List.of("卸3"))))));
|
||||
|
||||
assertEquals("路线节点未关联子运单", exception.getMessage());
|
||||
verifyNoWrite();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已完成的配载单不允许再改路线")
|
||||
void adjustRouteRejectsCompletedLoading() {
|
||||
stubPendingDisposal();
|
||||
stubLoading(loading("completed",
|
||||
stationRowsJson(List.of(station(START, List.of("装1")), station(END, List.of("卸1"))))));
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill(LOADING_NO, "in_progress", null));
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class, () -> asScheduler(
|
||||
() -> service.adjustRoute(request(
|
||||
node("start", START, List.of("装1")),
|
||||
node("end", NEW_END, List.of("卸1"))))));
|
||||
|
||||
assertEquals("当前状态不允许变更运输路线", exception.getMessage());
|
||||
verifyNoWrite();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 节点校验
|
||||
|
||||
@Test
|
||||
@DisplayName("少于两个站点的路线不是一条路线")
|
||||
void adjustRouteRejectsSingleStation() {
|
||||
stubPendingDisposal();
|
||||
stubLoading(loading("in_progress", null));
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill(LOADING_NO, "in_progress", null));
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class, () -> asScheduler(
|
||||
() -> service.adjustRoute(request(node("start", START, List.of("装1"))))));
|
||||
|
||||
assertEquals("路线节点至少需要两个", exception.getMessage());
|
||||
verifyNoWrite();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("站点类型与站序不一致时拒绝:首站必须 start、末站必须 end")
|
||||
void adjustRouteRejectsWrongNodeKind() {
|
||||
stubPendingDisposal();
|
||||
stubLoading(loading("in_progress", null));
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill(LOADING_NO, "in_progress", null));
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class, () -> asScheduler(
|
||||
() -> service.adjustRoute(request(
|
||||
node("transit", START, List.of("装1")),
|
||||
node("end", END, List.of("卸1"))))));
|
||||
|
||||
assertEquals("路线节点类型不正确", exception.getMessage());
|
||||
verifyNoWrite();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已了结的异常再调整路线报「该异常已处理」")
|
||||
void adjustRouteRejectsSettledDisposal() {
|
||||
ExceptionDisposal disposal = new ExceptionDisposal();
|
||||
disposal.setId(DISPOSAL_ID);
|
||||
disposal.setDisposalStatus("completed");
|
||||
disposal.setWaybillId(WAYBILL_ID);
|
||||
disposal.setIsDeleted(0);
|
||||
when(disposalMapper.selectById(DISPOSAL_ID)).thenReturn(disposal);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class, () -> asScheduler(
|
||||
() -> service.adjustRoute(request(
|
||||
node("start", START, List.of("装1")),
|
||||
node("end", NEW_END, List.of("卸1"))))));
|
||||
|
||||
assertEquals("该异常已处理", exception.getMessage());
|
||||
verifyNoWrite();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 未配载运单
|
||||
|
||||
@Test
|
||||
@DisplayName("未配载的运单只按段改到达地,route_json 仍是司机端能解析的段数组")
|
||||
void adjustRouteOnWaybillKeepsSegmentShape() {
|
||||
stubPendingDisposal();
|
||||
Waybill waybill = waybill(null, "in_progress", segmentRouteJson());
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill);
|
||||
|
||||
asScheduler(() -> service.adjustRoute(request(
|
||||
node("start", START, null),
|
||||
node("transit", TRANSIT, null),
|
||||
node("end", NEW_END, null))));
|
||||
|
||||
Waybill updated = updatedWaybill();
|
||||
List<Map<String, Object>> segments = JsonUtil.readListMap(updated.getRouteJson());
|
||||
assertEquals(2, segments.size(), "段数不变,不能改成站点行");
|
||||
assertFalse(segments.get(0).containsKey("address"), "运单路线不得出现站点行的 address 键");
|
||||
assertFalse(segments.get(0).containsKey("tags"), "运单路线不得出现站点行的 tags 键");
|
||||
assertEquals(TRANSIT, segments.get(0).get("arrivalAddress"), "没改的段原样保留");
|
||||
assertEquals(1, ((Number) segments.get(0).get("segmentNo")).intValue());
|
||||
assertEquals("road", segments.get(0).get("transportType"), "未知键与业务键都要原样保留");
|
||||
assertEquals(NEW_END, segments.get(1).get("arrivalAddress"));
|
||||
assertEquals(NEW_END, segments.get(1).get("arrivalName"), "站名跟着地址一起改");
|
||||
assertEquals(START, segments.get(0).get("departureAddress"), "首站地址不可改");
|
||||
|
||||
assertEquals(START, updated.getDepartureAddress());
|
||||
assertEquals(NEW_END, updated.getArrivalAddress());
|
||||
Map<String, Object> taskInfo = JsonUtil.toMap(updated.getTaskInfoJson());
|
||||
Map<?, ?> latest = (Map<?, ?>) ((List<?>) taskInfo.get("routeChangeRecords")).get(0);
|
||||
assertEquals(routeContent(START, TRANSIT, NEW_END), latest.get("content"));
|
||||
assertEquals("completed", updatedDisposal().getDisposalStatus());
|
||||
verify(loadingManageMapper, never()).selectOne(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("运单已关联配载单但配载单查不到时拒绝,不越权改运单")
|
||||
void adjustRouteRejectsWaybillBranchWhenLoadingNoPresent() {
|
||||
stubPendingDisposal();
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill(LOADING_NO, "in_progress", segmentRouteJson()));
|
||||
when(loadingManageMapper.selectOne(any())).thenReturn(null);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class, () -> asScheduler(
|
||||
() -> service.adjustRoute(request(
|
||||
node("start", START, null),
|
||||
node("end", NEW_END, null)))));
|
||||
|
||||
assertEquals("运单已关联配载单,请在配载单中修改", exception.getMessage());
|
||||
verifyNoWrite();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("运单路线是 PC 写下的节点行形状时跳过路线改写,仍同步地址列并照常留痕")
|
||||
void adjustRouteKeepsLegacyNodeRowWaybillRoute() {
|
||||
stubPendingDisposal();
|
||||
String legacyRouteJson = JsonUtil.toJson(List.of(
|
||||
Map.of("address", START, "tags", List.of("发", "YD001")),
|
||||
Map.of("address", END, "tags", List.of("收", "YD001"))));
|
||||
Waybill waybill = waybill(null, "in_progress", legacyRouteJson);
|
||||
waybill.setDepartureAddress(START);
|
||||
waybill.setArrivalAddress(END);
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill);
|
||||
|
||||
asScheduler(() -> service.adjustRoute(request(
|
||||
node("start", START, null),
|
||||
node("end", NEW_END, null))));
|
||||
|
||||
Waybill updated = updatedWaybill();
|
||||
assertEquals(legacyRouteJson, updated.getRouteJson(), "认不出的路线形状不能瞎写");
|
||||
assertEquals(START, updated.getDepartureAddress());
|
||||
assertEquals(NEW_END, updated.getArrivalAddress());
|
||||
assertEquals(routeContent(START, NEW_END), insertedRecord().getFollowContent());
|
||||
assertEquals("completed", updatedDisposal().getDisposalStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("改中间站地址时段首尾都同步:上一段到达地与下一段出发地写成同一个新址")
|
||||
void adjustRouteSyncsMiddleStationOnBothSegments() {
|
||||
stubPendingDisposal();
|
||||
Waybill waybill = waybill(null, "in_progress", segmentRouteJson());
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill);
|
||||
|
||||
asScheduler(() -> service.adjustRoute(request(
|
||||
node("start", START, null),
|
||||
node("transit", TRANSIT_SECOND, null),
|
||||
node("end", END, null))));
|
||||
|
||||
List<Map<String, Object>> segments = JsonUtil.readListMap(updatedWaybill().getRouteJson());
|
||||
assertEquals(2, segments.size(), "站数不变,段数也不变");
|
||||
assertEquals(TRANSIT_SECOND, segments.get(0).get("arrivalAddress"));
|
||||
assertEquals(TRANSIT_SECOND, segments.get(0).get("arrivalName"), "站名跟着地址一起改");
|
||||
assertEquals(TRANSIT_SECOND, segments.get(1).get("departureAddress"),
|
||||
"下一段的出发地必须与上一段的到达地一致");
|
||||
assertEquals(TRANSIT_SECOND, segments.get(1).get("departureName"));
|
||||
assertEquals(START, segments.get(0).get("departureAddress"), "首站地址不可改");
|
||||
assertEquals(END, segments.get(1).get("arrivalAddress"), "没改的到达地原样保留");
|
||||
assertEquals("completed", updatedDisposal().getDisposalStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("运单站数变化时拒绝:无配载单的运单只支持改地址,不支持增减途经站")
|
||||
void adjustRouteRejectsWaybillStationCountChange() {
|
||||
stubPendingDisposal();
|
||||
Waybill waybill = waybill(null, "in_progress", segmentRouteJson());
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class, () -> asScheduler(
|
||||
() -> service.adjustRoute(request(
|
||||
node("start", START, null),
|
||||
node("end", NEW_END, null)))));
|
||||
|
||||
assertEquals("无配载单的运单不支持增减途经站", exception.getMessage());
|
||||
verifyNoWrite();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("目的地未变而只见中间站变化时,结论仍写调整后的整条路线")
|
||||
void adjustRouteWritesResultingRouteWhenOnlyTransitChanges() {
|
||||
stubPendingDisposal();
|
||||
stubLoading(loading("in_progress",
|
||||
stationRowsJson(List.of(
|
||||
station(START, List.of("装1")),
|
||||
station(TRANSIT, List.of("卸1")),
|
||||
station(END, List.of("卸2"))))));
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill(LOADING_NO, "in_progress", null));
|
||||
|
||||
asScheduler(() -> service.adjustRoute(request(
|
||||
node("start", START, List.of("装1")),
|
||||
node("transit", TRANSIT_SECOND, List.of("卸1")),
|
||||
node("transit", TRANSIT, List.of("装2")),
|
||||
node("end", END, List.of("卸2")))));
|
||||
|
||||
assertEquals(routeContent(START, TRANSIT_SECOND, TRANSIT, END), insertedRecord().getFollowContent());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
/** 结论写调整后的站点顺序,如「同意调整路线:A → B → C」 */
|
||||
private static String routeContent(String... addresses) {
|
||||
return "同意调整路线:" + String.join(" → ", addresses);
|
||||
}
|
||||
|
||||
private void stubPendingDisposal() {
|
||||
ExceptionDisposal disposal = new ExceptionDisposal();
|
||||
disposal.setId(DISPOSAL_ID);
|
||||
disposal.setDisposalStatus("pending");
|
||||
disposal.setWaybillId(WAYBILL_ID);
|
||||
disposal.setIsDeleted(0);
|
||||
when(disposalMapper.selectById(DISPOSAL_ID)).thenReturn(disposal);
|
||||
}
|
||||
|
||||
private void stubLoading(LoadingManage loading) {
|
||||
when(loadingManageMapper.selectOne(any())).thenReturn(loading);
|
||||
}
|
||||
|
||||
private Waybill waybill(String loadingNo, String businessStatus, String routeJson) {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setId(WAYBILL_ID);
|
||||
waybill.setWaybillNo("YD20261023001");
|
||||
waybill.setLoadingNo(loadingNo);
|
||||
waybill.setBusinessStatus(businessStatus);
|
||||
waybill.setRouteJson(routeJson);
|
||||
waybill.setIsDeleted(0);
|
||||
return waybill;
|
||||
}
|
||||
|
||||
private LoadingManage loading(String businessStatus, String routeJson) {
|
||||
LoadingManage loading = new LoadingManage();
|
||||
loading.setId(8501L);
|
||||
loading.setLoadingNo(LOADING_NO);
|
||||
loading.setWaybillIdsJson("[9001,9002]");
|
||||
loading.setBusinessStatus(businessStatus);
|
||||
loading.setRouteJson(routeJson);
|
||||
loading.setIsDeleted(0);
|
||||
return loading;
|
||||
}
|
||||
|
||||
/** 配载单的路线段夹具:两段三站,首段出发地是首站 */
|
||||
private String segmentRouteJson() {
|
||||
List<Map<String, Object>> segments = new ArrayList<>();
|
||||
Map<String, Object> first = new LinkedHashMap<>();
|
||||
first.put("departureName", START);
|
||||
first.put("departureAddress", START);
|
||||
first.put("arrivalName", TRANSIT);
|
||||
first.put("arrivalAddress", TRANSIT);
|
||||
first.put("segmentNo", 1);
|
||||
first.put("transportType", "road");
|
||||
segments.add(first);
|
||||
Map<String, Object> second = new LinkedHashMap<>();
|
||||
second.put("departureName", TRANSIT);
|
||||
second.put("departureAddress", TRANSIT);
|
||||
second.put("arrivalName", END);
|
||||
second.put("arrivalAddress", END);
|
||||
second.put("segmentNo", 2);
|
||||
segments.add(second);
|
||||
return JsonUtil.toJson(segments);
|
||||
}
|
||||
|
||||
private Map<String, Object> station(String address, List<String> tags) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("address", address);
|
||||
row.put("displayAddress", address);
|
||||
row.put("tags", tags);
|
||||
row.put("locked", false);
|
||||
return row;
|
||||
}
|
||||
|
||||
private String stationRowsJson(List<Map<String, Object>> rows) {
|
||||
return JsonUtil.toJson(rows);
|
||||
}
|
||||
|
||||
private ExceptionDisposalRouteAdjustRequest.RouteNode node(String kind, String address, List<String> tags) {
|
||||
ExceptionDisposalRouteAdjustRequest.RouteNode node = new ExceptionDisposalRouteAdjustRequest.RouteNode();
|
||||
node.setKind(kind);
|
||||
node.setAddress(address);
|
||||
node.setTags(tags);
|
||||
return node;
|
||||
}
|
||||
|
||||
private ExceptionDisposalRouteAdjustRequest request(
|
||||
ExceptionDisposalRouteAdjustRequest.RouteNode... nodes) {
|
||||
ExceptionDisposalRouteAdjustRequest request = new ExceptionDisposalRouteAdjustRequest();
|
||||
request.setId(DISPOSAL_ID);
|
||||
request.setRouteNodes(List.of(nodes));
|
||||
return request;
|
||||
}
|
||||
|
||||
private ExceptionDisposalFollowRecord insertedRecord() {
|
||||
ArgumentCaptor<ExceptionDisposalFollowRecord> captor =
|
||||
ArgumentCaptor.forClass(ExceptionDisposalFollowRecord.class);
|
||||
verify(followRecordMapper).insert(captor.capture());
|
||||
return captor.getValue();
|
||||
}
|
||||
|
||||
private LoadingManage updatedLoading() {
|
||||
ArgumentCaptor<LoadingManage> captor = ArgumentCaptor.forClass(LoadingManage.class);
|
||||
verify(loadingManageMapper).updateById(captor.capture());
|
||||
return captor.getValue();
|
||||
}
|
||||
|
||||
private Waybill updatedWaybill() {
|
||||
ArgumentCaptor<Waybill> captor = ArgumentCaptor.forClass(Waybill.class);
|
||||
verify(waybillService).updateById(captor.capture());
|
||||
return captor.getValue();
|
||||
}
|
||||
|
||||
private ExceptionDisposal updatedDisposal() {
|
||||
ArgumentCaptor<ExceptionDisposal> captor = ArgumentCaptor.forClass(ExceptionDisposal.class);
|
||||
verify(disposalMapper).updateById(captor.capture());
|
||||
return captor.getValue();
|
||||
}
|
||||
|
||||
/** 校验不通过时不该留下任何写入 */
|
||||
private void verifyNoWrite() {
|
||||
verify(followRecordMapper, never()).insert(any(ExceptionDisposalFollowRecord.class));
|
||||
verify(loadingManageMapper, never()).updateById(any(LoadingManage.class));
|
||||
verify(waybillService, never()).updateById(any(Waybill.class));
|
||||
verify(disposalMapper, never()).updateById(any(ExceptionDisposal.class));
|
||||
}
|
||||
|
||||
/** 以调度员身份执行,登录态与真实姓名都走缓存 */
|
||||
private void asScheduler(Runnable action) {
|
||||
asScheduler(() -> {
|
||||
action.run();
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
private <T> T asScheduler(Supplier<T> action) {
|
||||
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class);
|
||||
MockedStatic<UserDisplayCache> userCache = mockStatic(UserDisplayCache.class)) {
|
||||
authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID);
|
||||
userCache.when(() -> UserDisplayCache.getUserRealName(USER_ID)).thenReturn("韦调度");
|
||||
return action.get();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
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.IPage;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.vo.AdminWaybillCardVO;
|
||||
import org.springblade.transport.pojo.vo.AdminWaybillDetailVO;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.IDriverWaybillService;
|
||||
import org.springblade.transport.service.IExceptionDisposalService;
|
||||
import org.springblade.transport.service.ILoadingManageService;
|
||||
import org.springblade.transport.service.IRiskDisposalService;
|
||||
import org.springblade.transport.service.ITripQueryService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.support.WaybillProcessSupport;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 调度端运单详情的原始业务状态。
|
||||
* <p>
|
||||
* 数字态只有 0/1/2/3:待回单确认落进行中(1),与运输中同档。
|
||||
* 调度端要区分这两者只能看原始状态码,所以详情与卡片都得带上它。
|
||||
*/
|
||||
class ManageWaybillBusinessStatusTest {
|
||||
|
||||
private static final Long WAYBILL_ID = 6201L;
|
||||
private static final String PENDING_RETURN_CONFIRM = WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM;
|
||||
|
||||
private ManageWaybillServiceImpl service;
|
||||
private IWaybillService waybillService;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, Waybill.class);
|
||||
TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
waybillService = mock(IWaybillService.class);
|
||||
IExceptionDisposalService exceptionDisposalService = mock(IExceptionDisposalService.class);
|
||||
service = new ManageWaybillServiceImpl(
|
||||
waybillService,
|
||||
mock(IDriverService.class),
|
||||
exceptionDisposalService,
|
||||
mock(IRiskDisposalService.class),
|
||||
mock(IDriverWaybillService.class),
|
||||
mock(ITripQueryService.class),
|
||||
mock(ILoadingManageService.class));
|
||||
when(exceptionDisposalService.list(anyWrapper())).thenReturn(new ArrayList<>());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("运单详情:数字态 1,原始业务状态是待回单确认")
|
||||
void detailCarriesRawBusinessStatus() {
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill());
|
||||
|
||||
AdminWaybillDetailVO detail = service.detail(String.valueOf(WAYBILL_ID));
|
||||
|
||||
assertEquals(Integer.valueOf(1), detail.getStatus(), "数字态仍落进行中,未新增档位");
|
||||
assertEquals(PENDING_RETURN_CONFIRM, detail.getBusinessStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("待重新派单卡片:原始业务状态随卡片下发")
|
||||
void reassignCardCarriesRawBusinessStatus() {
|
||||
when(waybillService.page(any(), any())).thenAnswer(invocation -> {
|
||||
Page<Waybill> rows = invocation.getArgument(0);
|
||||
rows.setRecords(List.of(waybill()));
|
||||
rows.setTotal(1);
|
||||
return rows;
|
||||
});
|
||||
|
||||
IPage<AdminWaybillCardVO> page = service.pendingList(1, 10, null, true);
|
||||
|
||||
assertEquals(1, page.getRecords().size());
|
||||
assertEquals(Integer.valueOf(1), page.getRecords().get(0).getStatus(),
|
||||
"数字态落进行中:null 到前端会掉进「非 0/1/2 即已取消」的展示分支");
|
||||
assertEquals(PENDING_RETURN_CONFIRM, page.getRecords().get(0).getBusinessStatus());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
private Waybill waybill() {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setId(WAYBILL_ID);
|
||||
waybill.setWaybillNo("YD202610080003");
|
||||
waybill.setBusinessStatus(PENDING_RETURN_CONFIRM);
|
||||
waybill.setDriverAcceptStatus("rejected");
|
||||
waybill.setIsDeleted(0);
|
||||
return waybill;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static <T> Wrapper<T> anyWrapper() {
|
||||
return org.mockito.ArgumentMatchers.<Wrapper<T>>any();
|
||||
}
|
||||
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
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.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springblade.transport.mapper.MasterOrderMapper;
|
||||
import org.springblade.transport.pojo.entity.MasterOrder;
|
||||
import org.springblade.transport.pojo.entity.TransportPlan;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.vo.MasterOrderVO;
|
||||
import org.springblade.transport.service.IContractManageService;
|
||||
import org.springblade.transport.service.IProjectApplyService;
|
||||
import org.springblade.transport.service.ITransportPlanService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.support.WaybillProcessSupport;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 多联总单分段进度的「已到达数量」口径。
|
||||
* <p>
|
||||
* 到货但回单待确认的运单算到达:货物确实到了,回单只是单据。
|
||||
* 不算会让下一段被「调度总量不能大于上一段已到达数量」挡住,整条线路卡在第二段。
|
||||
*/
|
||||
class MasterOrderProgressTest {
|
||||
|
||||
private static final Long MASTER_ID = 6101L;
|
||||
private static final String MASTER_NO = "ZD20261008001";
|
||||
private static final String ROUTE_JSON = """
|
||||
[{"segmentNo":"1","transportType":"公路运输","departureName":"南宁","arrivalName":"北海"},
|
||||
{"segmentNo":"2","transportType":"铁路运输","departureName":"北海","arrivalName":"昆明"}]
|
||||
""";
|
||||
|
||||
private MasterOrderMapper masterOrderMapper;
|
||||
private IWaybillService waybillService;
|
||||
private ITransportPlanService transportPlanService;
|
||||
private MasterOrderServiceImpl service;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
// 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, Waybill.class);
|
||||
TableInfoHelper.initTableInfo(assistant, TransportPlan.class);
|
||||
TableInfoHelper.initTableInfo(assistant, MasterOrder.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
masterOrderMapper = mock(MasterOrderMapper.class);
|
||||
waybillService = mock(IWaybillService.class);
|
||||
transportPlanService = mock(ITransportPlanService.class);
|
||||
service = new MasterOrderServiceImpl(
|
||||
waybillService,
|
||||
transportPlanService,
|
||||
mock(IProjectApplyService.class),
|
||||
mock(IContractManageService.class));
|
||||
inject("baseMapper", masterOrderMapper);
|
||||
|
||||
when(transportPlanService.list(anyWrapper())).thenReturn(List.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("到货但回单待确认的运单计入「已到达数量」")
|
||||
void arrivedQuantityCountsPendingReturnConfirm() {
|
||||
when(masterOrderMapper.selectById(MASTER_ID)).thenReturn(masterOrder());
|
||||
when(waybillService.list(anyWrapper())).thenReturn(List.of(
|
||||
child(1L, "1", WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM, "8")));
|
||||
|
||||
MasterOrderVO vo = service.detail(MASTER_ID);
|
||||
|
||||
assertEquals(0, decimalOf(vo.getRouteProgress().get(0).get("arrivedQuantity"))
|
||||
.compareTo(new BigDecimal("8")), "回单待确认不等于没到货");
|
||||
assertEquals(0, decimalOf(vo.getRouteProgress().get(1).get("arrivedQuantity")).signum(),
|
||||
"第二段还没有到货的运单");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已到达口径:已完成 + 待回单确认,进行中不算")
|
||||
void arrivedQuantityIgnoresRunning() {
|
||||
when(masterOrderMapper.selectById(MASTER_ID)).thenReturn(masterOrder());
|
||||
when(waybillService.list(anyWrapper())).thenReturn(List.of(
|
||||
child(1L, "1", "running", "8"),
|
||||
child(2L, "1", "completed", "5"),
|
||||
child(3L, "1", WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM, "3")));
|
||||
|
||||
MasterOrderVO vo = service.detail(MASTER_ID);
|
||||
|
||||
assertEquals(0, decimalOf(vo.getRouteProgress().get(0).get("arrivedQuantity"))
|
||||
.compareTo(new BigDecimal("8")), "5 + 3:进行中的 8 吨还没到,不能算到达");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
private MasterOrder masterOrder() {
|
||||
MasterOrder masterOrder = new MasterOrder();
|
||||
masterOrder.setId(MASTER_ID);
|
||||
masterOrder.setMasterNo(MASTER_NO);
|
||||
masterOrder.setRouteJson(ROUTE_JSON);
|
||||
return masterOrder;
|
||||
}
|
||||
|
||||
private Waybill child(Long id, String segmentNo, String businessStatus, String quantity) {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setId(id);
|
||||
waybill.setMasterNo(MASTER_NO);
|
||||
waybill.setRelationNo(segmentNo);
|
||||
waybill.setBusinessStatus(businessStatus);
|
||||
waybill.setGoodsJson("[{\"cargoName\":\"钢材\",\"quantity\":" + quantity + "}]");
|
||||
return waybill;
|
||||
}
|
||||
|
||||
private BigDecimal decimalOf(Object value) {
|
||||
return value == null ? BigDecimal.ZERO : (BigDecimal) value;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static <T> Wrapper<T> anyWrapper() {
|
||||
return org.mockito.ArgumentMatchers.<Wrapper<T>>any();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
+17
@@ -193,6 +193,23 @@ class TripAggregationTest {
|
||||
assertTrue(TripAggregation.CHILD_STATUSES.containsAll(TripAggregation.TERMINAL_STATUSES));
|
||||
}
|
||||
|
||||
@Test
|
||||
@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), "运次不进已完成档");
|
||||
}
|
||||
|
||||
private Waybill waybill(String businessStatus, String acceptStatus, String processJson) {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setBusinessStatus(businessStatus);
|
||||
|
||||
+2
@@ -221,6 +221,8 @@ class TripQueryServiceImplTest {
|
||||
String keywordSql = capturedKeywordSql();
|
||||
assertTrue(keywordSql.contains("waybill_no LIKE ?"), "关键字在子单上找:" + keywordSql);
|
||||
assertTrue(keywordSql.contains("loading_no LIKE ?"), keywordSql);
|
||||
assertTrue(keywordSql.contains("driver_name LIKE ?"), "司机名也参与匹配:" + keywordSql);
|
||||
assertTrue(keywordSql.contains("project_name LIKE ?"), "项目名(运单上的快照列)也参与匹配:" + keywordSql);
|
||||
assertEquals(2, trip.getWaybillCount(), "整趟车的子单都留着,不按命中的那一张缩小");
|
||||
assertTrue(capturedRowSql().contains("loading_no IN ("), "命中的运次键换成配载单号下发:" + capturedRowSql());
|
||||
}
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springblade.transport.mapper.MasterOrderMapper;
|
||||
import org.springblade.transport.mapper.ReceivablePayableCargoFeeMapper;
|
||||
import org.springblade.transport.mapper.ReceivablePayableChangeRecordMapper;
|
||||
import org.springblade.transport.service.IContractManageService;
|
||||
import org.springblade.transport.service.ICommonAddressService;
|
||||
import org.springblade.transport.service.IFormalSettlementService;
|
||||
import org.springblade.transport.service.IPreSettlementService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.support.WaybillProcessSupport;
|
||||
import org.springblade.transport.wrapper.WaybillWrapper;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/**
|
||||
* 运单业务状态的中文名,两套表并存(历史结构,不合并):运单管理一套、应收应付一套。
|
||||
* <p>
|
||||
* 两套都要认待回单确认:漏一套就在页面上露出原始英文码。
|
||||
*/
|
||||
class WaybillBusinessStatusNameTest {
|
||||
|
||||
private static final String PENDING_RETURN_CONFIRM = WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM;
|
||||
|
||||
@Test
|
||||
@DisplayName("运单管理名称表给出「待回单确认」——导出与详情共用这张表")
|
||||
void waybillWrapperNamesPendingReturnConfirm() {
|
||||
assertEquals("待回单确认", WaybillWrapper.businessStatusName(PENDING_RETURN_CONFIRM));
|
||||
// 相邻档位不受影响
|
||||
assertEquals("进行中", WaybillWrapper.businessStatusName("running"));
|
||||
assertEquals("已完成", WaybillWrapper.businessStatusName("completed"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("应收应付的第二套名称表同步给出「待回单确认」")
|
||||
void receivablePayableNamesPendingReturnConfirm() throws Exception {
|
||||
assertEquals("待回单确认", receivableStatusName(PENDING_RETURN_CONFIRM));
|
||||
}
|
||||
|
||||
/**
|
||||
* 应收应付那张表只被 waybillMap 用,而 waybillMap 当前没有调用方,
|
||||
* 没有公共接缝;它是纯函数,按既有做法直接取私有方法(同 LoadingManageChildStatusWriteTest)。
|
||||
*/
|
||||
private String receivableStatusName(String status) throws Exception {
|
||||
ReceivablePayableDetailServiceImpl service = new ReceivablePayableDetailServiceImpl(
|
||||
mock(ReceivablePayableCargoFeeMapper.class),
|
||||
mock(ReceivablePayableChangeRecordMapper.class),
|
||||
mock(MasterOrderMapper.class),
|
||||
mock(IWaybillService.class),
|
||||
mock(IContractManageService.class),
|
||||
mock(ICommonAddressService.class),
|
||||
mock(IPreSettlementService.class),
|
||||
mock(IFormalSettlementService.class));
|
||||
Method method = ReceivablePayableDetailServiceImpl.class
|
||||
.getDeclaredMethod("waybillStatusName", String.class);
|
||||
method.setAccessible(true);
|
||||
return (String) method.invoke(service, status);
|
||||
}
|
||||
|
||||
}
|
||||
+86
-14
@@ -23,6 +23,7 @@ import org.springblade.transport.pojo.entity.WaybillNodePunch;
|
||||
import org.springblade.transport.service.ILoadingManageService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
import org.springblade.transport.service.IReceivablePayableDetailService;
|
||||
import org.springblade.transport.support.WaybillProcessSupport;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.List;
|
||||
@@ -37,15 +38,17 @@ import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 确认回单:回单节点要求业务员确认时,内部人员确认后把运单置为已完成。
|
||||
* 确认回单:停在待回单确认的运单,内部人员确认后把运单置为已完成。
|
||||
* <p>
|
||||
* 完整入口不校验节点打卡,故这里必须自己挡住两件事——该运单的司机打卡节点(在途除外)全部已打卡、
|
||||
* 无未了结异常;不挡住就成了绕过打卡门禁与异常门禁的通道。回单不要求确认时不能走这个入口。
|
||||
* 完整入口不校验节点打卡,故这里必须自己挡住三件事——运单确实停在待回单确认、该运单的司机打卡节点
|
||||
* (在途除外)全部已打卡、无未了结异常;不挡住就成了绕过打卡门禁与异常门禁的通道。
|
||||
* 判据只看运单状态,不看回单配置当前值。
|
||||
*/
|
||||
class WaybillConfirmReturnTest {
|
||||
|
||||
private static final Long WAYBILL_ID = 5701L;
|
||||
private static final String DEPT_ID = "1123598813738675201";
|
||||
private static final String PENDING_RETURN_CONFIRM = WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM;
|
||||
|
||||
private static final String RETURN_NEED_CONFIRM_JSON = """
|
||||
[{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true},
|
||||
@@ -118,9 +121,9 @@ class WaybillConfirmReturnTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("回单要求确认且节点都打完:置为已完成,并照常生成应收应付、尝试完成配载单")
|
||||
@DisplayName("待回单确认且节点都打完:置为已完成,并照常生成应收应付、尝试完成配载单")
|
||||
void confirmsReturnAndCompletesWaybill() {
|
||||
stubWaybill(RETURN_NEED_CONFIRM_JSON);
|
||||
stubWaybill(RETURN_NEED_CONFIRM_JSON, PENDING_RETURN_CONFIRM);
|
||||
stubPunches("load", "sign");
|
||||
|
||||
boolean result = service.confirmReturnReceipt(WAYBILL_ID);
|
||||
@@ -135,16 +138,57 @@ class WaybillConfirmReturnTest {
|
||||
@Test
|
||||
@DisplayName("在途节点没打卡也放行:它不是必经一步")
|
||||
void transitNodeDoesNotBlockConfirmation() {
|
||||
stubWaybill(WITH_TRANSIT_JSON);
|
||||
stubWaybill(WITH_TRANSIT_JSON, PENDING_RETURN_CONFIRM);
|
||||
stubPunches("load", "sign");
|
||||
|
||||
assertEquals(true, service.confirmReturnReceipt(WAYBILL_ID));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("进行中的运单不能确认回单:状态校验先于打卡校验")
|
||||
void runningWaybillCannotConfirmReturn() {
|
||||
stubWaybill(RETURN_NEED_CONFIRM_JSON, "running");
|
||||
// 故意一条打卡都不补:若状态校验排在打卡校验之后,这里报的会是缺哪个节点
|
||||
stubPunches();
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> service.confirmReturnReceipt(WAYBILL_ID));
|
||||
|
||||
assertEquals("当前状态不允许确认回单", exception.getMessage());
|
||||
verify(waybillMapper, never()).updateById(any(Waybill.class));
|
||||
verify(receivablePayableDetailService, never()).generateForCompletedWaybills(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已完成的运单不能确认回单")
|
||||
void completedWaybillCannotConfirmReturn() {
|
||||
stubWaybill(RETURN_NEED_CONFIRM_JSON, "completed");
|
||||
stubPunches("load", "sign");
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> service.confirmReturnReceipt(WAYBILL_ID));
|
||||
|
||||
assertEquals("当前状态不允许确认回单", exception.getMessage());
|
||||
verify(waybillMapper, never()).updateById(any(Waybill.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("待执行的运单不能确认回单")
|
||||
void pendingWaybillCannotConfirmReturn() {
|
||||
stubWaybill(RETURN_NEED_CONFIRM_JSON, "pending");
|
||||
stubPunches("load", "sign");
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> service.confirmReturnReceipt(WAYBILL_ID));
|
||||
|
||||
assertEquals("当前状态不允许确认回单", exception.getMessage());
|
||||
verify(waybillMapper, never()).updateById(any(Waybill.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("还有节点没打卡:拒绝并点名缺哪个节点,不改状态")
|
||||
void unfinishedPunchNodeBlocksConfirmation() {
|
||||
stubWaybill(RETURN_NEED_CONFIRM_JSON);
|
||||
stubWaybill(RETURN_NEED_CONFIRM_JSON, PENDING_RETURN_CONFIRM);
|
||||
stubPunches("load");
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
@@ -157,7 +201,7 @@ class WaybillConfirmReturnTest {
|
||||
@Test
|
||||
@DisplayName("有未了结异常:拒绝,不改状态")
|
||||
void unresolvedExceptionBlocksConfirmation() {
|
||||
stubWaybill(RETURN_NEED_CONFIRM_JSON);
|
||||
stubWaybill(RETURN_NEED_CONFIRM_JSON, PENDING_RETURN_CONFIRM);
|
||||
stubPunches("load", "sign");
|
||||
when(exceptionDisposalMapper.selectCount(any())).thenReturn(1L);
|
||||
|
||||
@@ -169,31 +213,59 @@ class WaybillConfirmReturnTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("回单不要求业务员确认:这个入口不适用,说清而不是静默完成")
|
||||
void returnWithoutConfirmationIsRejected() {
|
||||
stubWaybill(RETURN_NO_CONFIRM_JSON);
|
||||
@DisplayName("回单配置已改成无需确认、运单停在待回单确认:仍能确认回单")
|
||||
void confirmStillWorksAfterConfigSwitchedToNoConfirm() {
|
||||
stubWaybill(RETURN_NO_CONFIRM_JSON, PENDING_RETURN_CONFIRM);
|
||||
stubPunches("load", "sign");
|
||||
|
||||
assertEquals(true, service.confirmReturnReceipt(WAYBILL_ID));
|
||||
|
||||
verify(waybillMapper).updateById(org.mockito.ArgumentMatchers.<Waybill>argThat(waybill ->
|
||||
"completed".equals(waybill.getBusinessStatus())));
|
||||
verify(receivablePayableDetailService).generateForCompletedWaybills(List.of(WAYBILL_ID));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("回单配置无需确认且运单还在进行中:这个入口不适用,报状态不符")
|
||||
void noConfirmConfigRunningWaybillCannotConfirmReturn() {
|
||||
stubWaybill(RETURN_NO_CONFIRM_JSON, "running");
|
||||
stubPunches("load", "sign");
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> service.confirmReturnReceipt(WAYBILL_ID));
|
||||
|
||||
assertEquals("该运单的回单不需要业务员确认", exception.getMessage());
|
||||
assertEquals("当前状态不允许确认回单", exception.getMessage());
|
||||
verify(waybillMapper, never()).updateById(any(Waybill.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("待回单确认可以取消:置为已取消,不生成应收应付")
|
||||
void cancelPendingReturnConfirmWaybill() {
|
||||
// 取消接口不允许已配载的运单
|
||||
stubWaybill(RETURN_NEED_CONFIRM_JSON, PENDING_RETURN_CONFIRM).setLoadingNo(null);
|
||||
|
||||
assertEquals(true, service.cancel(WAYBILL_ID));
|
||||
|
||||
verify(waybillMapper).updateById(org.mockito.ArgumentMatchers.<Waybill>argThat(waybill ->
|
||||
"cancelled".equals(waybill.getBusinessStatus()) && WAYBILL_ID.equals(waybill.getId())));
|
||||
verify(receivablePayableDetailService, never()).generateForCompletedWaybills(any());
|
||||
verify(loadingManageService, never()).completeIfAllWaybillsCompleted(any());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
private void stubWaybill(String processJson) {
|
||||
private Waybill stubWaybill(String processJson, String businessStatus) {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setId(WAYBILL_ID);
|
||||
waybill.setWaybillNo("YD202610180002");
|
||||
waybill.setDeptId(Long.valueOf(DEPT_ID));
|
||||
waybill.setProjectId(7001L);
|
||||
waybill.setProcessJson(processJson);
|
||||
waybill.setBusinessStatus("running");
|
||||
waybill.setBusinessStatus(businessStatus);
|
||||
waybill.setLoadingNo("PZ-20261018-001");
|
||||
waybill.setIsDeleted(0);
|
||||
when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill);
|
||||
return waybill;
|
||||
}
|
||||
|
||||
private void stubPunches(String... nodeCodes) {
|
||||
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
package org.springblade.transport.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.mockito.MockedStatic;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.transport.mapper.WaybillMapper;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.support.WaybillProcessSupport;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 运单编辑保存的业务状态来源测试。
|
||||
* <p>
|
||||
* 编辑保存只改运单资料,business_status 一律以库里的值为准:请求体不回传(null / 空串)
|
||||
* 不得把库里的待执行、进行中、已完成改写成别的状态,回传陈旧值也不得覆盖。
|
||||
* 唯一例外是草稿——submit 即正式提交,草稿须按过程配置提升为待执行 / 进行中。
|
||||
* <p>
|
||||
* 过程配置用「无需确认接单」的快照({@code []}):无过程校正时待执行会被提升为进行中,
|
||||
* 正是编辑保存最容易把库里状态写坏的场景。
|
||||
*/
|
||||
class WaybillEditSaveBusinessStatusTest {
|
||||
|
||||
private static final Long WAYBILL_ID = 7001L;
|
||||
private static final Long DEPT_ID = 100L;
|
||||
/** 无接单节点的过程配置:normalizeBusinessStatus 会把空 / pending 提升为 running */
|
||||
private static final String PROCESS_JSON_NO_ACCEPT = "[]";
|
||||
|
||||
private WaybillMapper waybillMapper;
|
||||
private WaybillServiceImpl service;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
// saveOrUpdate 判主键需要 MyBatis-Plus 的实体元数据缓存
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, Waybill.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
waybillMapper = mock(WaybillMapper.class);
|
||||
service = new WaybillServiceImpl();
|
||||
inject("baseMapper", waybillMapper);
|
||||
when(waybillMapper.updateById(any(Waybill.class))).thenReturn(1);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 编辑保存:以库里的状态为准
|
||||
|
||||
@Test
|
||||
@DisplayName("编辑保存:请求体不回传业务状态,库里的待执行不得被提升为进行中")
|
||||
void editSaveKeepsPendingWhenRequestBodyOmitsStatus() {
|
||||
Waybill request = editableRequest();
|
||||
|
||||
submit(request, "pending");
|
||||
|
||||
assertEquals("pending", request.getBusinessStatus(),
|
||||
"编辑保存时请求体没有 business_status,不得按过程配置把库里的待执行改写成进行中");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("编辑保存:请求体不回传业务状态,库里的进行中不得被改写")
|
||||
void editSaveKeepsRunningWhenRequestBodyOmitsStatus() {
|
||||
Waybill request = editableRequest();
|
||||
|
||||
submit(request, "running");
|
||||
|
||||
assertEquals("running", request.getBusinessStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("编辑保存:请求体不回传业务状态,库里的已完成不得被改写")
|
||||
void editSaveKeepsCompletedWhenRequestBodyOmitsStatus() {
|
||||
Waybill request = editableRequest();
|
||||
|
||||
submit(request, "completed");
|
||||
|
||||
assertEquals("completed", request.getBusinessStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("编辑保存:请求体回传陈旧状态,仍以库里的值为准")
|
||||
void editSaveKeepsDatabaseStatusWhenRequestBodyCarriesStaleValue() {
|
||||
Waybill request = editableRequest();
|
||||
request.setBusinessStatus("running");
|
||||
|
||||
submit(request, "completed");
|
||||
|
||||
assertEquals("completed", request.getBusinessStatus(),
|
||||
"库里已完成,请求体带的是打开表单时的旧值,不得据此回退状态");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("编辑保存:请求体回传空串,库里的状态不得被清空")
|
||||
void editSaveKeepsDatabaseStatusWhenRequestBodyIsBlank() {
|
||||
Waybill request = editableRequest();
|
||||
request.setBusinessStatus("");
|
||||
|
||||
submit(request, "running");
|
||||
|
||||
assertEquals("running", request.getBusinessStatus());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 新增状态
|
||||
|
||||
@Test
|
||||
@DisplayName("编辑保存:待回单确认的运单不得被编辑保存改成进行中")
|
||||
void editSaveKeepsPendingReturnConfirm() {
|
||||
Waybill request = editableRequest();
|
||||
|
||||
submit(request, WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM);
|
||||
|
||||
assertEquals(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM, request.getBusinessStatus(),
|
||||
"司机已打完末位卡,编辑一次运单资料不得把它退回进行中");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 草稿例外
|
||||
|
||||
@Test
|
||||
@DisplayName("提交草稿:草稿仍按过程配置提升为进行中")
|
||||
void submitDraftStillPromotesToRunning() {
|
||||
Waybill request = editableRequest();
|
||||
|
||||
submit(request, "draft");
|
||||
|
||||
assertEquals("running", request.getBusinessStatus(),
|
||||
"submit 即正式提交,草稿必须脱离 draft,不能被「以库里的值为准」锁死");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
private Waybill editableRequest() {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setId(WAYBILL_ID);
|
||||
waybill.setProjectName("项目A");
|
||||
waybill.setContractName("客户合同A");
|
||||
waybill.setCustomerName("客户A");
|
||||
waybill.setTransportType("公路运输");
|
||||
waybill.setCargoName("钢材");
|
||||
waybill.setCargoType("普货");
|
||||
waybill.setQuantityUnit("吨");
|
||||
waybill.setCarrierType("自运");
|
||||
waybill.setVehicleNo("桂A12345");
|
||||
waybill.setDriverName("张三");
|
||||
waybill.setCarrierJson("{}");
|
||||
waybill.setQuantity(BigDecimal.TEN);
|
||||
waybill.setProcessJson(PROCESS_JSON_NO_ACCEPT);
|
||||
return waybill;
|
||||
}
|
||||
|
||||
/**
|
||||
* 让库里的同一条运单处于 {@code databaseStatus},再走一次编辑保存(submit)。
|
||||
* 司机与车牌保持一致,避免接单记录被判定为「换了司机」而清空。
|
||||
*/
|
||||
private void submit(Waybill request, String databaseStatus) {
|
||||
Waybill oldRecord = editableRequest();
|
||||
oldRecord.setBusinessStatus(databaseStatus);
|
||||
oldRecord.setDeptId(DEPT_ID);
|
||||
oldRecord.setDeptName("测试组织");
|
||||
when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(oldRecord);
|
||||
|
||||
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class)) {
|
||||
authUtil.when(AuthUtil::getDeptId).thenReturn(String.valueOf(DEPT_ID));
|
||||
assertTrue(service.submit(request));
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
+14
@@ -14,6 +14,7 @@ import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.entity.WaybillEnroutePunch;
|
||||
import org.springblade.transport.pojo.entity.WaybillNodePunch;
|
||||
import org.springblade.transport.pojo.vo.WaybillPunchRecordItemVO;
|
||||
import org.springblade.transport.support.WaybillProcessSupport;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.text.ParseException;
|
||||
@@ -126,6 +127,19 @@ class WaybillPunchRecordListTest {
|
||||
assertEquals("", accept.getPunchTime(), "没有接单动作就没有完成时刻,不得拿别的字段编一个");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("待回单确认的无需确认接单运单:接单节点已打卡,但没有完成时刻")
|
||||
void acceptNodePunchedWhenPendingReturnConfirm() {
|
||||
stubWaybill(waybill(NO_CONFIRM_ACCEPT_JSON,
|
||||
WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM, null));
|
||||
|
||||
WaybillPunchRecordItemVO accept = acceptRecord();
|
||||
|
||||
assertTrue(accept.getPunched());
|
||||
assertEquals("已打卡", accept.getStatusName());
|
||||
assertEquals("", accept.getPunchTime());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("无需确认接单但仍是草稿:接单节点未打卡")
|
||||
void acceptNodeUnpunchedWhenDraft() {
|
||||
|
||||
+111
@@ -18,6 +18,7 @@ import org.springblade.transport.pojo.dto.WaybillPunchRecordSaveRequest;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.entity.WaybillEnroutePunch;
|
||||
import org.springblade.transport.pojo.entity.WaybillNodePunch;
|
||||
import org.springblade.transport.support.WaybillProcessSupport;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
@@ -67,6 +68,7 @@ class WaybillPunchRecordSaveTest {
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class);
|
||||
TableInfoHelper.initTableInfo(assistant, WaybillEnroutePunch.class);
|
||||
TableInfoHelper.initTableInfo(assistant, Waybill.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
@@ -78,6 +80,7 @@ class WaybillPunchRecordSaveTest {
|
||||
inject("baseMapper", waybillMapper);
|
||||
inject("waybillNodePunchMapper", nodePunchMapper);
|
||||
inject("waybillEnroutePunchMapper", enroutePunchMapper);
|
||||
when(waybillMapper.updateById(any(Waybill.class))).thenReturn(1);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 过程节点打卡
|
||||
@@ -165,6 +168,114 @@ class WaybillPunchRecordSaveTest {
|
||||
verify(nodePunchMapper, never()).updateById(any(WaybillNodePunch.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("待回单确认:补录打卡被拒——司机已无待打卡,漏打应先取消打卡退回进行中")
|
||||
void rejectBackfillOnPendingReturnConfirm() {
|
||||
Waybill waybill = waybill();
|
||||
waybill.setBusinessStatus(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM);
|
||||
when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill);
|
||||
when(nodePunchMapper.selectList(any())).thenReturn(List.of());
|
||||
|
||||
WaybillPunchRecordSaveRequest request = request("node", "load", "装货");
|
||||
request.setPunched(true);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> service.savePunchRecord(request));
|
||||
|
||||
assertEquals("运单待业务员确认回单,不能补录打卡", exception.getMessage());
|
||||
verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("待回单确认:取消打卡仍放行——那是退回进行中的唯一路径")
|
||||
void keepCancelPunchOnPendingReturnConfirm() {
|
||||
Waybill waybill = waybill();
|
||||
waybill.setBusinessStatus(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM);
|
||||
when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill);
|
||||
when(nodePunchMapper.selectList(any())).thenReturn(List.of(nodePunch(7001L, "load", "装货")));
|
||||
|
||||
WaybillPunchRecordSaveRequest request = request("node", "load", "装货");
|
||||
request.setPunched(false);
|
||||
|
||||
assertTrue(service.savePunchRecord(request));
|
||||
|
||||
ArgumentCaptor<Collection<Long>> captor = ArgumentCaptor.forClass(Collection.class);
|
||||
verify(nodePunchMapper).deleteBatchIds(captor.capture());
|
||||
assertEquals(Set.of(7001L), new HashSet<>(captor.getValue()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("待回单确认:取消打卡后退回进行中——进入该状态的前提已被撤掉")
|
||||
void revertToRunningWhenCancelPunchOnPendingReturnConfirm() {
|
||||
Waybill waybill = waybill();
|
||||
waybill.setBusinessStatus(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM);
|
||||
when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill);
|
||||
when(nodePunchMapper.selectList(any())).thenReturn(List.of(nodePunch(7001L, "load", "装货")));
|
||||
|
||||
WaybillPunchRecordSaveRequest request = request("node", "load", "装货");
|
||||
request.setPunched(false);
|
||||
|
||||
assertTrue(service.savePunchRecord(request));
|
||||
|
||||
ArgumentCaptor<Waybill> captor = ArgumentCaptor.forClass(Waybill.class);
|
||||
verify(waybillMapper).updateById(captor.capture());
|
||||
assertEquals(WaybillProcessSupport.STATUS_RUNNING, captor.getValue().getBusinessStatus());
|
||||
assertEquals(WAYBILL_ID, captor.getValue().getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("进行中:取消打卡不动运单状态")
|
||||
void keepStatusWhenCancelPunchOnRunningWaybill() {
|
||||
Waybill waybill = waybill();
|
||||
waybill.setBusinessStatus(WaybillProcessSupport.STATUS_RUNNING);
|
||||
when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill);
|
||||
when(nodePunchMapper.selectList(any())).thenReturn(List.of(nodePunch(7001L, "load", "装货")));
|
||||
|
||||
WaybillPunchRecordSaveRequest request = request("node", "load", "装货");
|
||||
request.setPunched(false);
|
||||
|
||||
assertTrue(service.savePunchRecord(request));
|
||||
|
||||
verify(waybillMapper, never()).updateById(any(Waybill.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("待回单确认:取消在途打卡同样退回进行中")
|
||||
void revertToRunningWhenCancelEnroutePunchOnPendingReturnConfirm() {
|
||||
Waybill waybill = waybill();
|
||||
waybill.setBusinessStatus(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM);
|
||||
when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill);
|
||||
WaybillEnroutePunch existed = new WaybillEnroutePunch();
|
||||
existed.setId(8001L);
|
||||
when(enroutePunchMapper.selectList(any())).thenReturn(List.of(existed));
|
||||
|
||||
WaybillPunchRecordSaveRequest request = request("enroute", "transit", "在途");
|
||||
request.setPunched(false);
|
||||
|
||||
assertTrue(service.savePunchRecord(request));
|
||||
|
||||
ArgumentCaptor<Waybill> captor = ArgumentCaptor.forClass(Waybill.class);
|
||||
verify(waybillMapper).updateById(captor.capture());
|
||||
assertEquals(WaybillProcessSupport.STATUS_RUNNING, captor.getValue().getBusinessStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已完成运单仍可补录——门禁只对待回单确认生效")
|
||||
void keepBackfillOnCompletedWaybill() {
|
||||
Waybill waybill = waybill();
|
||||
waybill.setBusinessStatus("completed");
|
||||
when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill);
|
||||
when(nodePunchMapper.selectList(any())).thenReturn(List.of());
|
||||
|
||||
WaybillPunchRecordSaveRequest request = request("node", "load", "装货");
|
||||
request.setPunched(true);
|
||||
request.setPunchTime("2026-10-06 09:30:00");
|
||||
|
||||
assertTrue(service.savePunchRecord(request));
|
||||
|
||||
verify(nodePunchMapper).insert(any(WaybillNodePunch.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("接单节点:补录与取消打卡都拒绝——它的完成由运单的接单事实决定")
|
||||
void rejectAcceptNodePunchEdit() {
|
||||
|
||||
+39
-9
@@ -11,6 +11,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
/**
|
||||
* 认证可用性门槛测试。
|
||||
* <p>
|
||||
* 开关默认关闭:未认证的司机与车辆都可接单;只有显式置 true 才按生效快照校验。
|
||||
* <p>
|
||||
* 关键场景:已认证的车被重新提交认证后仍可接单——判定必须走生效快照,
|
||||
* 而重新提交会把「最新提交状态」变成「审核中」。
|
||||
*/
|
||||
@@ -26,14 +28,33 @@ class CertificationGateTest {
|
||||
// ------------------------------------------------------------ 开关
|
||||
|
||||
@Test
|
||||
@DisplayName("门槛默认开启")
|
||||
void enabledByDefault() {
|
||||
assertTrue(gate.isVehicleGateEnabled());
|
||||
assertTrue(gate.isDriverGateEnabled());
|
||||
@DisplayName("门槛默认关闭")
|
||||
void disabledByDefault() {
|
||||
assertFalse(gate.isVehicleGateEnabled());
|
||||
assertFalse(gate.isDriverGateEnabled());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("关闭后不因未认证而拦截(便于快速回退)")
|
||||
@DisplayName("默认关闭:未认证的司机与车辆都不拦")
|
||||
void disabledByDefaultLetsUncertifiedThrough() {
|
||||
assertTrue(gate.isVehicleAvailable(Boolean.TRUE, null));
|
||||
assertTrue(gate.isDriverAvailable(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("显式置 true:未认证的司机与车辆重新被拒")
|
||||
void explicitlyEnabledRejectsUncertified() {
|
||||
gate.setVehicleGateEnabled(true);
|
||||
gate.setDriverGateEnabled(true);
|
||||
|
||||
assertFalse(gate.isDriverAvailable(null));
|
||||
assertFalse(gate.isVehicleAvailable(Boolean.TRUE, null));
|
||||
assertTrue(gate.driverUnavailableReason().contains("认证"));
|
||||
assertTrue(gate.vehicleUnavailableReason(Boolean.TRUE, null).contains("认证"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("显式关闭后不因未认证而拦截(配置回退路径)")
|
||||
void disabledLetsEveryoneThrough() {
|
||||
gate.setVehicleGateEnabled(false);
|
||||
gate.setDriverGateEnabled(false);
|
||||
@@ -45,28 +66,32 @@ class CertificationGateTest {
|
||||
// ------------------------------------------------------------ 车辆
|
||||
|
||||
@Test
|
||||
@DisplayName("车辆:有生效快照即可用(核心:重新提交不影响可用性)")
|
||||
@DisplayName("车辆:开启门槛时有生效快照即可用(核心:重新提交不影响可用性)")
|
||||
void vehicleWithEffectiveSnapshotIsAvailable() {
|
||||
gate.setVehicleGateEnabled(true);
|
||||
assertTrue(gate.isVehicleAvailable(Boolean.TRUE, 7001L));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("车辆:重新提交后【状态变成审核中】但仍有生效快照 → 仍可用")
|
||||
void resubmittedVehicleStaysAvailable() {
|
||||
gate.setVehicleGateEnabled(true);
|
||||
// 展示缓存变成「审核中」,但 effective_cert_record_id 仍指着上一次通过的记录。
|
||||
assertTrue(gate.isVehicleAvailable(Boolean.TRUE, 7001L),
|
||||
"已认证的车重新提交后必须仍可接单,否则司机一提交就停工");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("车辆:从未认证(无生效快照)被拒绝")
|
||||
@DisplayName("车辆:开启门槛时从未认证(无生效快照)被拒绝")
|
||||
void vehicleWithoutEffectiveSnapshotIsRejected() {
|
||||
gate.setVehicleGateEnabled(true);
|
||||
assertFalse(gate.isVehicleAvailable(Boolean.TRUE, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("车辆:停用状态优先于认证——停用的车即便有生效快照也不可用")
|
||||
void disabledVehicleIsRejectedEvenIfCertified() {
|
||||
gate.setVehicleGateEnabled(true);
|
||||
assertFalse(gate.isVehicleAvailable(Boolean.FALSE, 7001L),
|
||||
"启用/停用是运营状态,认证门槛只叠加不覆盖");
|
||||
assertFalse(gate.isVehicleAvailable(null, 7001L));
|
||||
@@ -75,20 +100,23 @@ class CertificationGateTest {
|
||||
// ------------------------------------------------------------ 司机
|
||||
|
||||
@Test
|
||||
@DisplayName("司机:有生效快照即可用")
|
||||
@DisplayName("司机:开启门槛时有生效快照即可用")
|
||||
void driverWithEffectiveSnapshotIsAvailable() {
|
||||
gate.setDriverGateEnabled(true);
|
||||
assertTrue(gate.isDriverAvailable(9001L));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("司机:从未认证被拒绝")
|
||||
@DisplayName("司机:开启门槛时从未认证被拒绝")
|
||||
void driverWithoutEffectiveSnapshotIsRejected() {
|
||||
gate.setDriverGateEnabled(true);
|
||||
assertFalse(gate.isDriverAvailable(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("司机门槛可单独关闭")
|
||||
void driverGateCanBeDisabledIndependently() {
|
||||
gate.setVehicleGateEnabled(true);
|
||||
gate.setDriverGateEnabled(false);
|
||||
assertTrue(gate.isDriverAvailable(null), "司机门槛关闭时不应拦截");
|
||||
assertFalse(gate.isVehicleAvailable(Boolean.TRUE, null), "车辆门槛不受影响");
|
||||
@@ -99,6 +127,7 @@ class CertificationGateTest {
|
||||
@Test
|
||||
@DisplayName("停用与未认证分开报:前者让人去启用车辆,后者才让人去认证")
|
||||
void unavailableReasonsDistinguishDisabledFromUncertified() {
|
||||
gate.setVehicleGateEnabled(true);
|
||||
String disabled = gate.vehicleUnavailableReason(Boolean.FALSE, 7001L);
|
||||
assertTrue(disabled.contains("停用"), disabled);
|
||||
assertFalse(disabled.contains("认证审核"), "已认证的车被停用,不该报成认证没通过");
|
||||
@@ -111,6 +140,7 @@ class CertificationGateTest {
|
||||
@Test
|
||||
@DisplayName("可用时没有原因;两种不可用文案都不泄露内部字段名")
|
||||
void availableHasNoReasonAndTextIsReadable() {
|
||||
gate.setVehicleGateEnabled(true);
|
||||
assertNull(gate.vehicleUnavailableReason(Boolean.TRUE, 7001L));
|
||||
assertFalse(gate.vehicleUnavailableReason(Boolean.TRUE, null).contains("effective_cert_record_id"));
|
||||
assertFalse(gate.vehicleUnavailableReason(Boolean.FALSE, null).contains("effective_cert_record_id"));
|
||||
|
||||
+39
-7
@@ -334,17 +334,31 @@ class WaybillProcessSupportTest {
|
||||
"TRANSPORT_JSON 的回单启用且没有 confirmMode:缺字段当没配「无需确认完成」,否则会跳过本该人工确认的回单");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 待回单确认
|
||||
|
||||
@Test
|
||||
@DisplayName("待回单确认落库字面量为 pending_return_confirm,且接单归一不改写它")
|
||||
void pendingReturnConfirmLiteralSurvivesAcceptanceNormalization() {
|
||||
assertEquals("pending_return_confirm", WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM,
|
||||
"库里存的是这个字面量,web 与小程序也按它判断,不能改名");
|
||||
assertEquals(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM,
|
||||
WaybillProcessSupport.normalizeBusinessStatus(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM, TRANSPORT_JSON),
|
||||
"TRANSPORT_JSON 要求确认接单,未接单时归一结果本是 pending;运单已跑完全程,不能被改回待执行");
|
||||
assertEquals(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM,
|
||||
WaybillProcessSupport.normalizeBusinessStatus(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM, TRANSPORT_JSON, "accepted"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 结束门禁
|
||||
|
||||
@Test
|
||||
@DisplayName("结束门禁:已完成、已取消、待确认回单,大小写不敏感")
|
||||
void punchEndedOnlyForCompletedAndCancelled() {
|
||||
@DisplayName("结束门禁:已完成、已取消与待回单确认都算结束,大小写不敏感")
|
||||
void punchEndedForCompletedCancelledAndPendingReturnConfirm() {
|
||||
assertTrue(WaybillProcessSupport.isPunchEnded("completed"));
|
||||
assertTrue(WaybillProcessSupport.isPunchEnded("COMPLETED"));
|
||||
assertTrue(WaybillProcessSupport.isPunchEnded("cancelled"));
|
||||
assertTrue(WaybillProcessSupport.isPunchEnded("Cancelled"));
|
||||
assertTrue(WaybillProcessSupport.isPunchEnded("pending_return"));
|
||||
assertTrue(WaybillProcessSupport.isPunchEnded("PENDING_RETURN"));
|
||||
assertTrue(WaybillProcessSupport.isPunchEnded("pending_return_confirm"),
|
||||
"司机已打完末位卡,不能再打卡");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -360,12 +374,12 @@ class WaybillProcessSupportTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("结束门禁文案:完成、取消、待确认回单各一句,非结束态没有文案")
|
||||
@DisplayName("结束门禁文案:完成、取消与待回单确认各一句,非结束态没有文案")
|
||||
void punchEndedReasonOnlyForEndedStatuses() {
|
||||
assertEquals("运单已完成", WaybillProcessSupport.punchEndedReason("completed"));
|
||||
assertEquals("运单已完成", WaybillProcessSupport.punchEndedReason(" COMPLETED "));
|
||||
assertEquals("运单已取消", WaybillProcessSupport.punchEndedReason("cancelled"));
|
||||
assertEquals("运单待确认回单", WaybillProcessSupport.punchEndedReason("pending_return"));
|
||||
assertEquals("运单待业务员确认回单", WaybillProcessSupport.punchEndedReason("pending_return_confirm"));
|
||||
assertNull(WaybillProcessSupport.punchEndedReason(null));
|
||||
assertNull(WaybillProcessSupport.punchEndedReason(""));
|
||||
assertNull(WaybillProcessSupport.punchEndedReason(" "));
|
||||
@@ -424,6 +438,22 @@ class WaybillProcessSupportTest {
|
||||
// 结束态由结束文案负责,两句话不能抢
|
||||
assertNull(WaybillProcessSupport.punchNotStartedReason("completed"));
|
||||
assertNull(WaybillProcessSupport.punchNotStartedReason("cancelled"));
|
||||
assertNull(WaybillProcessSupport.punchNotStartedReason("pending_return_confirm"),
|
||||
"司机已打完末位卡,不能再被说成「尚未开始运输」");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("在途卡:待回单确认的运单与已完成同样不可打卡")
|
||||
void transitCardOnPendingReturnConfirmWaybillIsVisibleButNotPunchable() {
|
||||
LocalDateTime now = LocalDateTime.of(2026, 10, 20, 12, 0);
|
||||
|
||||
WaybillProcessSupport.TransitCheckinDecision decision =
|
||||
WaybillProcessSupport.evaluateTransitCheckin(TRANSPORT_JSON, "pending_return_confirm", null, now);
|
||||
|
||||
assertTrue(decision.visible(), "卡片仍要下发,司机能回看");
|
||||
assertFalse(decision.punchEnabled(), "末位卡已打完,在途卡不能再提交");
|
||||
assertFalse(decision.doneToday());
|
||||
assertFalse(decision.dueToday());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -458,9 +488,11 @@ class WaybillProcessSupportTest {
|
||||
assertNull(WaybillProcessSupport.reportBlockedReason("running"));
|
||||
assertEquals("运单尚未开始运输,暂不可上报异常", WaybillProcessSupport.reportBlockedReason("pending"));
|
||||
assertEquals("运单尚未开始运输,暂不可上报异常", WaybillProcessSupport.reportBlockedReason("dispatching"));
|
||||
// 结束优先:已完成/已取消先说结束,不提「等你上路」
|
||||
// 结束优先:已结束态先说结束,不提「等你上路」
|
||||
assertEquals("运单已完成,不能上报异常", WaybillProcessSupport.reportBlockedReason("completed"));
|
||||
assertEquals("运单已取消,不能上报异常", WaybillProcessSupport.reportBlockedReason("cancelled"));
|
||||
assertEquals("运单待业务员确认回单,不能上报异常",
|
||||
WaybillProcessSupport.reportBlockedReason("pending_return_confirm"));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ CREATE TABLE `blade_transport_driver` (
|
||||
`id` bigint(20) NOT NULL COMMENT '主键',
|
||||
`tenant_id` varchar(12) DEFAULT '000000' COMMENT '租户ID',
|
||||
`driver_name` varchar(20) NOT NULL COMMENT '司机姓名',
|
||||
`id_card_no` varchar(18) NOT NULL COMMENT '身份证号',
|
||||
`id_card_no` varchar(18) DEFAULT NULL COMMENT '身份证号,NULL 表示尚未采集',
|
||||
`birthday` date DEFAULT NULL COMMENT '出生年月',
|
||||
`gender` varchar(10) DEFAULT NULL COMMENT '性别',
|
||||
`nation` varchar(20) DEFAULT NULL COMMENT '民族',
|
||||
@@ -37,6 +37,8 @@ CREATE TABLE `blade_transport_driver` (
|
||||
`driver_type` varchar(20) NOT NULL COMMENT '司机类型:自有/外协',
|
||||
`mobile` varchar(20) NOT NULL COMMENT '手机号',
|
||||
`user_id` bigint(20) DEFAULT NULL COMMENT '关联系统用户ID',
|
||||
`profile_source` varchar(20) DEFAULT NULL COMMENT 'login_auto/login_bound/admin/cert_backfill,NULL 为改造前存量',
|
||||
`account_bound_time` datetime DEFAULT NULL COMMENT '档案与账号建立关联的时点',
|
||||
`contact_relation` varchar(20) DEFAULT NULL COMMENT '与联系人关系',
|
||||
`organization_name` varchar(50) NOT NULL COMMENT '所属组织',
|
||||
`emergency_contact_name` varchar(20) NOT NULL COMMENT '紧急联系人姓名',
|
||||
@@ -54,6 +56,7 @@ CREATE TABLE `blade_transport_driver` (
|
||||
`is_deleted` int(11) DEFAULT '0' COMMENT '是否已删除',
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
UNIQUE KEY `uk_transport_driver_id_card` (`id_card_no`) USING BTREE,
|
||||
UNIQUE KEY `uk_transport_driver_user` (`user_id`) USING BTREE,
|
||||
KEY `idx_transport_driver_mobile` (`mobile`) USING BTREE,
|
||||
KEY `idx_transport_driver_type` (`driver_type`) USING BTREE,
|
||||
KEY `idx_transport_driver_org` (`organization_name`) USING BTREE,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
-- ----------------------------
|
||||
-- 登录自动建档的司机档案补岗位
|
||||
-- 写业务数据。执行前先做第 0 节备份,执行后跑第 3 节校验。可重复执行。
|
||||
-- ----------------------------
|
||||
--
|
||||
-- 为什么要订正:
|
||||
-- 派单候选司机按岗位过滤(posts LIKE '%司机%'),岗位为空的档案选不到。
|
||||
-- 小程序登录自动建档原先只写了手机号、租户、user_id,posts 留空,
|
||||
-- 于是新注册的司机在运单派单里搜不到。建档逻辑已同步改为写岗位(PROFILE_DEFAULT_POST)。
|
||||
--
|
||||
-- 处置口径:只补「登录自动建档」的档案,条件是 profile_source = 'login_auto' 且岗位为空。
|
||||
-- 手工建档的档案岗位由管理端必填,为空说明被人主动清空过,不在本次订正范围。
|
||||
-- update_time 不在 SET 里,保留原值。
|
||||
-- ----------------------------
|
||||
-- 0. 备份(必须在订正之前执行,仅首次需要)
|
||||
-- ----------------------------
|
||||
-- CREATE TABLE blade_transport_driver_posts_bak AS
|
||||
-- SELECT id, tenant_id, driver_name, mobile, posts, profile_source, update_time
|
||||
-- FROM blade_transport_driver
|
||||
-- WHERE is_deleted = 0
|
||||
-- AND profile_source = 'login_auto'
|
||||
-- AND (posts IS NULL OR posts = '');
|
||||
|
||||
START TRANSACTION;
|
||||
|
||||
-- ----------------------------
|
||||
-- 1. 订正岗位
|
||||
-- ----------------------------
|
||||
UPDATE `blade_transport_driver`
|
||||
SET `posts` = '司机'
|
||||
WHERE `is_deleted` = 0
|
||||
AND `profile_source` = 'login_auto'
|
||||
AND (`posts` IS NULL OR `posts` = '');
|
||||
|
||||
-- ----------------------------
|
||||
-- 2. 提交
|
||||
-- ----------------------------
|
||||
COMMIT;
|
||||
|
||||
-- ----------------------------
|
||||
-- 3. 校验(两行都应为 0)
|
||||
-- ----------------------------
|
||||
-- SELECT COUNT(1) AS remaining_empty
|
||||
-- FROM blade_transport_driver
|
||||
-- WHERE is_deleted = 0 AND profile_source = 'login_auto' AND (posts IS NULL OR posts = '');
|
||||
--
|
||||
-- SELECT COUNT(1) AS touched_overlap
|
||||
-- FROM blade_transport_driver
|
||||
-- WHERE is_deleted = 0 AND profile_source = 'login_auto' AND posts IS NOT NULL AND posts <> '司机';
|
||||
|
||||
-- ----------------------------
|
||||
-- 4. 回退(仅在需要撤销本次订正时手工执行;执行前确认这些行确实是本次改的)
|
||||
-- ----------------------------
|
||||
-- UPDATE `blade_transport_driver`
|
||||
-- SET `posts` = NULL
|
||||
-- WHERE `is_deleted` = 0
|
||||
-- AND `profile_source` = 'login_auto'
|
||||
-- AND `posts` = '司机';
|
||||
@@ -0,0 +1,148 @@
|
||||
-- ----------------------------
|
||||
-- 司机档案与登录账号打通(2026-10-23)
|
||||
-- ----------------------------
|
||||
-- 三处结构变更:
|
||||
-- ① user_id 加唯一索引 uk_transport_driver_user —— 一个账号只能对应一条司机档案。
|
||||
-- ② id_card_no 放宽为可空 —— NULL 表示「身份证号尚未采集」,是判断档案是否完善的口径。
|
||||
-- uk_transport_driver_id_card 保留:MySQL 唯一索引对多行 NULL 不去重,
|
||||
-- 并发创建多个占位档案不会被唯一键挡住。
|
||||
-- ③ 新增 profile_source / account_bound_time 两列留痕:
|
||||
-- profile_source 取值 login_auto / login_bound / admin / cert_backfill,
|
||||
-- NULL 表示改造前存量(来源未知,不臆造);account_bound_time 是档案与账号建立关联的时点。
|
||||
--
|
||||
-- 只做结构变更,不回填数据:存量档案的 profile_source / account_bound_time 一律保持 NULL。
|
||||
-- 判定「档案是否采集」用 id_card_no IS NULL,不另加标记列。
|
||||
--
|
||||
-- 幂等:每段先查 information_schema,条件已满足即跳过,重复执行不报错;
|
||||
-- 全部用 PREPARE 动态语句而非存储过程,因此不需要 DELIMITER——按分号逐句切分的客户端
|
||||
-- (含数据库驱动)也能直接执行。
|
||||
--
|
||||
-- 前置检查(user_id 有重复时,第 1 节会以
|
||||
-- Duplicate entry '<值>' for key 'blade_transport_driver.uk_transport_driver_user'
|
||||
-- 失败并终止;此时本脚本对表零修改,先修数据再重跑):
|
||||
-- 唯一索引统计**全表**行,含 is_deleted = 1 的软删除档案:只按 is_deleted = 0
|
||||
-- 只能发现活跃重复,软删除行之间的重复同样会让第 1 节失败:
|
||||
-- SELECT user_id, COUNT(*) AS dup_count FROM `blade_transport_driver`
|
||||
-- WHERE user_id IS NOT NULL
|
||||
-- GROUP BY user_id HAVING COUNT(*) > 1;
|
||||
-- 修复口径:一个 user_id 只保留一条档案,其余把 user_id 置 NULL(不要改 id / is_deleted),
|
||||
-- 确认无误后重跑本脚本。
|
||||
--
|
||||
-- 注意:uk_transport_driver_user 覆盖软删除行 —— is_deleted = 1 的档案仍占用 user_id,
|
||||
-- 后台软删除某账号的档案后,该账号不能再建第二条档案(会撞唯一键);
|
||||
-- 需要释放时,软删除前先把该行的 user_id 置 NULL。
|
||||
|
||||
SET @transport_driver_schema = DATABASE();
|
||||
|
||||
-- ----------------------------
|
||||
-- 0. 表存在性守卫
|
||||
-- ----------------------------
|
||||
-- 表不存在时故意执行一条只读查询,让客户端报 1146「Table ... doesn't exist」;
|
||||
-- 否则下面每段都因查不到列/索引而静默跳过,脚本会假绿。
|
||||
SET @transport_driver_guard_sql = (
|
||||
SELECT IF(COUNT(*) > 0,
|
||||
'SELECT 1',
|
||||
'SELECT 1 FROM `blade_transport_driver` LIMIT 0')
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = @transport_driver_schema
|
||||
AND table_name = 'blade_transport_driver'
|
||||
);
|
||||
PREPARE transport_driver_guard_stmt FROM @transport_driver_guard_sql;
|
||||
EXECUTE transport_driver_guard_stmt;
|
||||
DEALLOCATE PREPARE transport_driver_guard_stmt;
|
||||
|
||||
-- ----------------------------
|
||||
-- 1. 账号唯一索引(先行执行:有重复时立刻失败,脚本对表零修改)
|
||||
-- ----------------------------
|
||||
-- 只读诊断:有输出即为重复清单,先按上面口径修数据再往下跑。
|
||||
SELECT `user_id`, COUNT(*) AS `dup_count`
|
||||
FROM `blade_transport_driver`
|
||||
WHERE `user_id` IS NOT NULL
|
||||
GROUP BY `user_id`
|
||||
HAVING COUNT(*) > 1;
|
||||
|
||||
-- 同名索引若已存在但是普通索引(non_unique = 1),本段会以
|
||||
-- Duplicate key name 'uk_transport_driver_user' 失败:先 DROP INDEX 再重跑本脚本。
|
||||
SET @transport_driver_user_index_sql = (
|
||||
SELECT IF(COUNT(*) = 0,
|
||||
'ALTER TABLE `blade_transport_driver` ADD UNIQUE KEY `uk_transport_driver_user` (`user_id`) USING BTREE',
|
||||
'SELECT 1')
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = @transport_driver_schema
|
||||
AND table_name = 'blade_transport_driver'
|
||||
AND index_name = 'uk_transport_driver_user'
|
||||
AND non_unique = 0
|
||||
);
|
||||
PREPARE transport_driver_user_index_stmt FROM @transport_driver_user_index_sql;
|
||||
EXECUTE transport_driver_user_index_stmt;
|
||||
DEALLOCATE PREPARE transport_driver_user_index_stmt;
|
||||
|
||||
-- ----------------------------
|
||||
-- 2. 身份证号放宽为可空(唯一索引 uk_transport_driver_id_card 保留)
|
||||
-- ----------------------------
|
||||
-- 条件写成「非空 或 长度不是 18」而不是只看 is_nullable:
|
||||
-- 该列历史上曾被截短/加长过,只判可空性会漏掉类型漂移。
|
||||
SET @transport_driver_id_card_sql = (
|
||||
SELECT IF(COUNT(*) > 0,
|
||||
'ALTER TABLE `blade_transport_driver` MODIFY COLUMN `id_card_no` varchar(18) NULL COMMENT ''身份证号,NULL 表示尚未采集''',
|
||||
'SELECT 1')
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = @transport_driver_schema
|
||||
AND table_name = 'blade_transport_driver'
|
||||
AND column_name = 'id_card_no'
|
||||
AND (is_nullable = 'NO' OR character_maximum_length <> 18)
|
||||
);
|
||||
PREPARE transport_driver_id_card_stmt FROM @transport_driver_id_card_sql;
|
||||
EXECUTE transport_driver_id_card_stmt;
|
||||
DEALLOCATE PREPARE transport_driver_id_card_stmt;
|
||||
|
||||
-- ----------------------------
|
||||
-- 3. 来源留痕列:profile_source
|
||||
-- ----------------------------
|
||||
SET @transport_driver_profile_source_sql = (
|
||||
SELECT IF(COUNT(*) = 0,
|
||||
'ALTER TABLE `blade_transport_driver` ADD COLUMN `profile_source` varchar(20) DEFAULT NULL COMMENT ''login_auto/login_bound/admin/cert_backfill,NULL 为改造前存量'' AFTER `user_id`',
|
||||
'SELECT 1')
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = @transport_driver_schema
|
||||
AND table_name = 'blade_transport_driver'
|
||||
AND column_name = 'profile_source'
|
||||
);
|
||||
PREPARE transport_driver_profile_source_stmt FROM @transport_driver_profile_source_sql;
|
||||
EXECUTE transport_driver_profile_source_stmt;
|
||||
DEALLOCATE PREPARE transport_driver_profile_source_stmt;
|
||||
|
||||
-- ----------------------------
|
||||
-- 4. 绑定留痕列:account_bound_time
|
||||
-- ----------------------------
|
||||
SET @transport_driver_account_bound_time_sql = (
|
||||
SELECT IF(COUNT(*) = 0,
|
||||
'ALTER TABLE `blade_transport_driver` ADD COLUMN `account_bound_time` datetime DEFAULT NULL COMMENT ''档案与账号建立关联的时点'' AFTER `profile_source`',
|
||||
'SELECT 1')
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = @transport_driver_schema
|
||||
AND table_name = 'blade_transport_driver'
|
||||
AND column_name = 'account_bound_time'
|
||||
);
|
||||
PREPARE transport_driver_account_bound_time_stmt FROM @transport_driver_account_bound_time_sql;
|
||||
EXECUTE transport_driver_account_bound_time_stmt;
|
||||
DEALLOCATE PREPARE transport_driver_account_bound_time_stmt;
|
||||
|
||||
-- ----------------------------
|
||||
-- 5. 回滚
|
||||
-- ----------------------------
|
||||
-- 顺序与正向相反:先删索引与两列,最后把 id_card_no 收回 NOT NULL。
|
||||
-- 收窄之前必须先清掉 NULL,否则 ALTER 会以
|
||||
-- Invalid use of NULL value / Data truncated for column 'id_card_no' 失败。
|
||||
-- 占位档案的身份证号没有真实值可还原,回滚前须业务先决定「删档案」还是「补占位串」:
|
||||
-- -- ① 处理 NULL(示例为删除登录自动建的占位档案;改为 UPDATE 成占位串亦可,注意会占唯一索引)
|
||||
-- DELETE FROM `blade_transport_driver`
|
||||
-- WHERE `id_card_no` IS NULL AND `profile_source` IN ('login_auto', 'login_bound');
|
||||
-- -- ② 收回身份证号非空与旧注释
|
||||
-- ALTER TABLE `blade_transport_driver`
|
||||
-- MODIFY COLUMN `id_card_no` varchar(18) NOT NULL COMMENT '身份证号';
|
||||
-- -- ③ 删唯一索引、删两列
|
||||
-- ALTER TABLE `blade_transport_driver` DROP INDEX `uk_transport_driver_user`;
|
||||
-- ALTER TABLE `blade_transport_driver` DROP COLUMN `profile_source`, DROP COLUMN `account_bound_time`;
|
||||
--
|
||||
-- uk_transport_driver_id_card 全程保留,回滚不需要恢复它。
|
||||
Reference in New Issue
Block a user